Перейти до змісту

templ

HTML-шаблони, які компілюються, а не парсяться. Ви пишете файли .templ, генератор видає .go, і одруківка в назві поля стає помилкою компіляції замість порожньої сторінки.

Модуль: github.com/a-h/templ, плюс CLI templ.

Порівняйте з шаблонами, що виконує ту саму роботу через html/template.

templ itemRow(i Item) {
    <li class="row">
        <span>{ i.Name }</span>
    </li>
}

Ідея

html/template розв'язує {{.Name}} під час виконання. Перейменуйте поле — і дізнаєтесь про це, коли сторінка рендериться — або коли рендериться саме та гілка, а це може статись у продакшені.

templ генерує Go. i.Name — справжній доступ до поля, тож компілятор перевіряє його, редактор доповнює, а перейменування через інструмент рефакторингу оновлює шаблон.

Синтаксис

Блок templ — це функція, що повертає компонент:

package main

import "fmt"

templ itemRow(i Item) {
    <li class="row">
        <span>{ i.Name }</span>
        <span>{ fmt.Sprintf("$%.2f", i.Price) }</span>
        if len(i.Tags) > 0 {
            <em>{ fmt.Sprint(len(i.Tags)) } tags</em>
        } else {
            <em>untagged</em>
        }
    </li>
}

Фігурні дужки інтерполюють вираз Go, що повертає рядок. Не мова шаблонів — справжній Go, зі справжніми імпортами. Ось чому форматування ціни — це fmt.Sprintf, а не власна функція шаблону.

if, for і switch — ключові слова Go зі синтаксисом Go, тож немає другого діалекту, який треба вивчати, і немає {{end}}, який можна забути.

for _, i := range items {
    @itemRow(i)
}

@name(args) рендерить інший компонент.

Композиція через children...

Макет бере вміст сторінки як дочірні елементи:

templ layout(title string) {
    <html>
        <head><title>{ title }</title></head>
        <body>
            { children... }
        </body>
    </html>
}

templ ItemList(title string, items []Item) {
    @layout(title) {
        <ul>
            for _, i := range items {
                @itemRow(i)
            }
        </ul>
    }
}

Блок після @layout(title) стає його дочірніми елементами. Це заміняє define/block з html/template, і компонується як звичайні виклики функцій.

Генерація

templ generate

Створює page_templ.go поруч із кожним .templ, з заголовком:

// Code generated by templ - DO NOT EDIT.

Комітьте згенеровані файли. Так репозиторій збирається звичайним go build, і CI не потребує CLI — та сама аргументація, що й у статті про збірку та кодогенерацію. Підключіть templ generate у ваш Makefile, і хай CI регенерує й провалюється на diff, щоб ніхто не забув.

templ generate --watch регенерує при збереженні під час розробки.

Рендеринг

Компонент рендериться в будь-який io.Writer:

err := ItemList("Shop & Co", items).Render(context.Background(), w)

Що в обробнику net/http виглядає так:

func (h Handler) list(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    if err := ItemList("Shop", items).Render(r.Context(), w); err != nil {
        http.Error(w, "render failed", http.StatusInternalServerError)
    }
}

Є нюанс, про який варто знати: Render стрімить, тож частина сторінки могла бути записана вже до провалу — і статус уже 200. Якщо це має значення, рендеріть спершу в bytes.Buffer і копіюйте назовні лише у разі успіху.

Екранування

Контекстне, як і в html/template:

<title>Shop &amp; Co</title>
<span>Widget &lt;b&gt;</span>
<a href="/x?q=1&amp;b=2">link</a>

Текст екранується, атрибути екрануються, а templ.URL санітизує URL в href. Небезпечну схему заміняють повністю:

templ LinkTo(u string) {
    <a href={ templ.URL(u) }>link</a>
}
"/x?q=1&b=2"                    <a href="/x?q=1&amp;b=2">link</a>
"https://example.com/a"         <a href="https://example.com/a">link</a>
"mailto:a@b.c"                  <a href="mailto:a@b.c">link</a>
"javascript:alert(1)"           <a href="about:invalid#TemplFailedSanitizationURL">link</a>
"data:text/html,<script>1"      <a href="about:invalid#TemplFailedSanitizationURL">link</a>

about:invalid#TemplFailedSanitizationURL — це еквівалент маркера ZgotmplZ з html/template, але для templ: якщо ви бачите його на відрендереній сторінці, це означає, що небезпечний вміст потрапив у позицію URL, і виправляти треба дані, а не шаблон.

templ.Raw відмовляється від екранування й повинен бачити лише той вміст, який ви самі створили.

Оскільки екранування відбувається на етапі генерації у відомих позиціях, обійти його випадково важче, ніж у шаблоні на основі рядків.

В'юмоделі

Не передавайте свої моделі бази даних напряму в шаблон. Окремий тип в'юмоделі тримає логіку відображення подалі від обох:

type ItemVM struct {
    Name      string
    PriceText string
    Badge     string
}

Обробник мапить домен на в'юмодель, шаблон рендерить в'юмодель. Чисті функції-мапери легко тестувати, а шаблон залишається вільним від умов на кшталт if user.Role == "admin" && !user.Suspended.

Тестування

Компонент — це функція, що повертає щось із методом Render, тож тести — звичайний Go:

var sb strings.Builder
err := ItemList("t", items).Render(context.Background(), &sb)
// then assert on sb.String()

Перевіряйте структуру, а не точні байти — golden-файл зі статті про хелпери й golden-файли добре тут працює, оскільки ціла сторінка задовга, щоб вбудовувати її в код.

Ціна

  • Крок збірки. Редагування HTML тепер означає регенерацію, а застарілий згенерований файл — заплутана помилка.
  • Підтримка редактора окрема. Є LSP і плагіни, але це не той готовий досвід, що з .html.
  • Згенерований код шумний у diff. Деякі команди додають його в gitignore й генерують у CI — за ціну того, що go build більше не працює самостійно.
  • Це інша мова в тому самому файлі. Дизайнерам, що редагують шаблони, доведеться її вивчити.

Проти цього: перейменоване поле не може мовчки зламати сторінку, і взагалі немає парсингу шаблонів під час виконання.

З досвіду Python: це Jinja, заміщена чимось ближчим до компілятора JSX — шаблони стають типізованими функціями, перевіреними на етапі збірки. Компроміс той самий: типобезпека й підтримка редактора проти кроку компіляції й формату, менш доступного для тих, хто не програміст.

Швидка довідка

Задача Форма
компонент templ Name(args) { ... } у файлі .templ
інтерполяція { goExpression } — справжній Go, що повертає рядок
керування потоком Go if, for, switch — без {{end}}
викликати інший @other(arg)
макети { children... } плюс @layout(x) { ... }
генерувати templ generate, --watch під час розробки
комітити так — згенеровані файли _templ.go
рендерити .Render(ctx, w) у будь-який io.Writer
безпечні URL templ.URL(...); templ.Raw лише для власного HTML
тест рендерити в strings.Builder, перевіряти або використати golden-файл

Джерела