---
title: "Шаблоны HTML"
description: "Шаблоны HTML в gin: LoadHTMLGlob и LoadHTMLFiles, данные через gin.H и структуры, функции через SetFuncMap, Delims и multitemplate для раскладок."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/12-shablony/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Шаблоны HTML

*Курс «Gin: самый популярный веб-фреймворк Go», урок 12 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/12-shablony/*

Stylesheet из прошлого урока просится на настоящую страницу — значит, пора рендерить HTML на сервере. В gin шаблоны подключаются двумя вызовами: LoadHTMLGlob или LoadHTMLFiles собирают набор html/template, а c.HTML отдаёт страницу с вашими данными. Разберём, как gin парсит шаблоны и чем debug-режим отличается от release, зачем SetFuncMap звать до загрузки файлов, как менять разделители, если {{ занял Vue, и как gin-contrib/multitemplate держит две раскладки в одном приложении. JSON-конверты из [урока про ответы](/courses/gin/lessons/05-json-otvety/) остаются для /api — теперь у трекера появится и человеческое лицо.

### gin шаблоны: HTML вместо JSON

Пока наш трекер отвечал только JSON: Envelope для API, NOT_FOUND для промахов, статика из прошлого урока обслуживала файлы, но не страницы. Пришло время gin шаблонов — серверного HTML, который собирается на лету из данных. Сценарий знакомый: страница со списком задач, где каждая строка приходит из хендлера, а разметка живёт в отдельном файле. Gin не пишет свой шаблонизатор — он оборачивает html/template из стандартной библиотеки, тот самый, с auto-escaping: любая строка из данных вставляется в разметку безопасно, теги и скрипты экранируются автоматически. Если вы уже работали с html/template — всё знакомо; если нет — разберём по ходу, для gin-специфики достаточно трёх действий. Первое: загрузить шаблоны один раз при старте — LoadHTMLGlob или LoadHTMLFiles. Второе: отдавать страницы через c.HTML(код, имя, данные) — собрат c.JSON, который вместо конверта рендерит файл. Третье: при необходимости зарегистрировать свои функции шаблонов через SetFuncMap — даты, плюрализация, любые преобразования прямо в разметке. Архитектурно это выглядит так: хендлеры /api/v1/todos продолжают отдавать JSON из прошлого урока, а новые маршруты — /, /tasks, /tasks/:id — рендерят страницы. Один движок, два формата: JSON для машин, HTML для людей — и вся связка html gin держится на трёх действиях из начала урока. В этом уроке мы соберём страницу трекера на gin.H и структуре, разберём экранирование на живом примере, подключим кастомные функции и раскладки, а на выходе получим приложение, у которого есть и API, и интерфейс.

### Первая страница: LoadHTMLGlob и c.HTML

```go
r := gin.Default()
r.LoadHTMLGlob("templates/*")

r.GET("/", func(c *gin.Context) {
        tasks := []Todo{
                {Title: "Купить хлеб", Done: false},
                {Title: "Полить цветы", Done: true},
        }
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "Задачи на сегодня",
                "tasks": tasks,
        })
})

if err := r.Run(":8080"); err != nil {
        panic(err)
}
```

LoadHTMLGlob("templates/*") разбирает все файлы каталога templates один раз при старте. c.HTML берёт три аргумента: статус, имя шаблона и данные. Имя — это базовое имя файла: index.html. Точка в разметке — это gin.H, переданный вторым: {{ .title }} читает ключ title. Папка templates — относительно рабочей директории процесса, как и статика из прошлого урока.

### LoadHTMLGlob и LoadHTMLFiles: как gin парсит шаблоны

Заглянем в gin.go — там два метода, и они ближе к стандартной библиотеке, чем кажется. LoadHTMLGlob(pattern) делает ровно одно: template.Must(template.New("").Delims(...).Funcs(engine.FuncMap).ParseGlob(pattern)) — это чистый html/template: ParseGlob находит файлы по маске через filepath.Glob и парсит каждый, а template.Must паникует, если файл не читается или разметка битая. То есть опечатка в шаблоне роняет приложение на старте, до первого запроса — и это хорошо: битая разметка должна ловиться при запуске, а не в проде у пользователя. Имена шаблонов — базовые имена файлов: templates/index.html становится index.html, и c.HTML ищет именно по этому имени. LoadHTMLFiles делает то же самое по явному списку: r.LoadHTMLFiles("templates/index.html", "templates/form.html") — удобно, когда каталог замусорен частями, которые не должны попадать в набор. Раскладки и подпапки требуют блока define: маска templates/* не заходит в подкаталоги, а маска templates/**/* (две звёздочки в filepath.Glob — это просто один уровень) собирает файлы из подпапок, но тогда каждое имя объявляют вручную: {{ define "posts/index.html" }} в начале файла и соответствующее имя в c.HTML — так делают в официальных примерах gin. Теперь главное, что почти все упускают: результат парсинга зависит от gin.Mode(). В release-режиме отрендеренный набор кладётся в HTMLProduction — один раз спарсенный, навсегда закэшированный. А в debug-режиме — режиме по умолчанию — gin ставит HTMLDebug, который перечитывает и перепарсивает шаблоны на каждый запрос: правка файла видна по F5, без перезапуска. Это удобно в разработке и незаметно дорого в бою: перепарс на каждый запрос плюс паники template.Must уже внутри обслуживания. Отсюда правило: gin.SetMode(gin.ReleaseMode) перед LoadHTMLGlob в проде — и шаблоны спарсятся ровно один раз.

### Проверка: правка шаблона после старта

**Вопрос:** Приложение собрано с gin.SetMode(gin.ReleaseMode), LoadHTMLGlob отработал, сервер крутится. Вы правили templates/index.html, не перезапуская процесс. Что увидит следующий запрос?

- Новую версию: gin перечитывает шаблоны на каждый запрос
- ✅ Старую версию: в release набор спарсен один раз и закэширован в HTMLProduction
- Ошибку 500: релизный рендерер заметит изменение mtime и откажется работать
- Пустую страницу: кэш шаблонов очищается после старта

HTMLProduction хранит уже распарсенный *template.Template — файлы на диске его больше не волнуют. Перечитывание — прерогатива HTMLDebug, который gin ставит в debug-режиме: там правка видна по F5, но каждый запрос платит перепарсом. В release — только рестарт. Это цена за стабильность и скорость продакшена.

### Передача данных: gin.H, структуры и экранирование

Данные в шаблон попадают любым значением — c.HTML принимает any. Для быстрого прототипа используют gin.H — это псевдоним map[string]any, тот же тип, что и в c.JSON: gin.H{"title": "Задачи", "tasks": tasks} — и точка в разметке ходит по ключам карты. Для боевого кода лучше структуры: поля типизированы, поля подсказывают в IDE, а json-теги, которые мы расставляли в уроке про привязку, работают и здесь — только в шаблонах тегом по умолчанию считается form, а имена полей html/template берёт по-другому. Ключевое правило: шаблон видит только экспортированные поля. Структура Todo с полем title (со строчной) отдаст пустоту — интерполяция {{ .title }} или {{ .Title }} вернёт нулевое значение; с полем Title — всё отрисуется. Методы тоже работают, если они экспортированы: {{ .DueFmt }} вызовет метод с таким именем — это законный способ класть вычисления рядом с данными вместо размножения функций. А вот экранирование — главная причина, почему html/template, а не text/template. Контекстный анализатор понимает, где именно вставляется значение: в HTML-тексте экранирует &lt;b&gt;, в атрибуте — кавычки, в JavaScript-блоке — спецсимволы строк, в URL — пробелы и кавычки. Практический тест: передайте в title строку с <script>alert(1)</script> — на странице отобразится текстом ровно то, что ввёл пользователь, а в HTML-исходнике будут экранированные сущности. XSS-векторы через данные гибнут на ровном месте. Побег есть всегда — кроме одного: значение типа template.HTML html/template вставит как есть, без экранирования, потому что вы явно заявили «это уже HTML». Такой тип — осознанный костыль для заранее sanitized-контента; если он добирается до шаблона из пользовательского ввода, вы только что собрали XSS своими руками, поэтому держите template.HTML подальше от форм.

### Что окажется в HTML при вводе тега

```go
r := gin.Default()
r.LoadHTMLGlob("templates/*")

r.GET("/echo", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "<b>Срочно</b> и <script>alert(1)</script>",
        })
})

// <h1>{{ .title }}</h1>
// curl localhost:8080/echo
```

**Что выведет программа?**

- Заголовок отрисуется жирным, скрипт исполнится в браузере
- ✅ В HTML-исходнике придёт &lt;b&gt;Срочно&lt;/b&gt; и экранированный script — на странице текст, XSS не сработает
- Паника: html/template запрещает теги в данных
- Пустой h1: шаблонизатор отбросит опасные значения

Контекстное экранирование — суперсила html/template: значение интерполируется как текст, теги превращаются в сущности, скрипт не исполнится. Паники нет, отбрасывания нет — данные на месте, просто безопасно закодированные. Разметить «Срочно» жирным — задача данных (поле-флаг и условие {{ if }}) или заранее подготовленного template.HTML, но не сырой строки из формы.

### Кастомные функции шаблонов: SetFuncMap

Рано или поздно разметке нужны вычисления: дата в человеческом формате, правильная форма слова «задача/задачи/задач», цена с копейками, урезанный текст. В html/template это решается функциями: FuncMap — карта имя-функция, и шаблон вызывает их как {{ fmtDate .Due }}. В gin за это отвечает r.SetFuncMap(template.FuncMap{...}) — метод движка, который кладёт карту в engine.FuncMap, и дальше её подхватывают оба загрузчика. Обратите внимание на порядок — это самая частая ошибка темы: SetFuncMap должен быть вызван до LoadHTMLGlob и LoadHTMLFiles. Причина в механике парсинга: при разборе шаблона имена функций фиксируются, и если функции ещё не зарегистрированы, ParseGlob упадёт с «function "fmtDate" not defined» — template.Must превратит это в панику на старте. Зарегистрируете после — та же паника, только в запутанной формулировке; держите SetFuncMap первой строкой после gin.Default() и не отделяйте его от загрузки шаблонов. Что класть в карту: функции чистые, быстрые и про представление — форматирование дат через t.Format, плюрализация по правилам русского языка (n%10, группы исключений), обрезка строк с многоточием, экранирование уже не нужно — оно контекстное. Что класть не стоит: обращения к базе и любые I/O — шаблон должен собирать строку из готовых данных, а не ходить за ними; готовьте значения в хендлере или сервисном слое. Функции в карте типизированы по-обычному: fmtDate func(time.Time) string, plural func(int, string) string — компилятор проверит сигнатуры, а шаблон просто вызовет по имени.

### FuncMap: дата и плюрализация

```go
func fmtDate(t time.Time) string {
        return t.Format("02.01.2006")
}

func plural(n int, forms string) string {
        // forms: "задача,задачи,задач" — формы через запятую.
        parts := strings.Split(forms, ",")
        idx := 2
        if n%10 == 1 && n%100 != 11 {
                idx = 0
        } else if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
                idx = 1
        }
        return fmt.Sprintf("%d %s", n, parts[idx])
}

func main() {
        gin.SetMode(gin.ReleaseMode)
        r := gin.Default()

        r.SetFuncMap(template.FuncMap{
                "fmtDate": fmtDate,
                "plural":  plural,
        })
        r.LoadHTMLGlob("templates/*") // функции уже зарегистрированы
        // ...
}
```

SetFuncMap перед LoadHTMLGlob — иначе ParseGlob не найдёт fmtDate и plural и упадёт в template.Must. В разметке: {{ fmtDate .Due }} и {{ plural .OpenCount "задача,задачи,задач" }}. Строковый аргумент в кавычках — литерал шаблона. Функции чистые: никаких обращений к базе внутри разметки.

### Проверка: порядок инициализации

**Вопрос:** В какой момент нужно вызвать r.SetFuncMap, чтобы шаблоны видели ваши функции?

- В любой момент до первого запроса — gin свяжет функции лениво при рендере
- ✅ До LoadHTMLGlob и LoadHTMLFiles: имена функций фиксируются при парсинге шаблонов
- После r.Run(): регистрация функций идёт в фоне
- Порядок не важен: функции ищутся заново на каждый вызов c.HTML

ParseGlob и ParseFiles резолвят имена функций в момент разбора — не найдя fmtDate, template.Must паникует прямо на старте. SetFuncMap кладёт карту в engine.FuncMap, и оба загрузчика подхватывают её при парсинге. Поэтому каноничный порядок: gin.SetMode, gin.Default, SetFuncMap, LoadHTMLGlob/Files, маршруты, Run.

### Delims: когда двойные фигурные скобки заняты

Штатные разделители {{ }} — источник конфликтов с фронтенд-шаблонизаторами. Vue, Alpine.js и целый зоопарк мини-фреймворков используют те же двойные фигурные: страница собирается на сервере gin-ом, а затем «оживает» на клиенте, и обе машины претендуют на одни скобки. Реакция предсказуема: Vue выдернет из {{ .title }} значение, получит пустую строку и швырнёт warning в консоль, а ваша серверная интерполяция окажется съеденной. Решение в gin — r.Delims("[[", "]]"): метод движка меняет разделители для всех загружаемых после него шаблонов, и в разметке пишете [[ .title ]]. Обратите внимание на два требования. Первое: Delims тоже до загрузки — парсер расставляет границы по разделителям, менять их после ParseGlob поздно. Второе: возвращаемое значение — *Engine, метод цепочный: r.Delims("[[", "]]").LoadHTMLGlob("templates/*") — хотя читается такая цепочка тяжеловато, лучше отдельной строкой. Бэкенд и фронтенд расходятся по зонам: серверные интерполяции в [[ ]], клиентские {{ }} остаются Vue — конфликт снят без escaping-танцев. Из той же серии — точечная гигиена: если скобки нужны в тексте как литералы (фрагменты кода в статье, документация по шаблонам), вынесите их в данные и вставляйте значением — или используйте пользовательские функции, возвращающие нужный текст. И помните про predict ниже: смена Delims делает старый синтаксис просто текстом, и никакой ошибки парсинга не случится — файл с {{ .title }} при Delims("[[", "]]") спарсится тихо, а на странице отобразится фигурная запись дословно. Проверяйте визуально после миграции, а не по логам.

### Старые скобки после смены разделителей

```go
r := gin.Default()
r.Delims("[[", "]]")
r.LoadHTMLGlob("templates/*")

r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{"title": "План"})
})

// index.html: <h1>{{ .title }}</h1>
// curl localhost:8080/
```

**Что выведет программа?**

- В ответе будет <h1>План</h1> — gin распознаёт и старый, и новый синтаксис
- ✅ В ответе будет литерал <h1>{{ .title }}</h1> — со сменой Delims старые скобки стали обычным текстом
- Паника на старте: файл с {{ }} не парсится при других разделителях
- Пустой h1: значение не найдено по ключу title

Разделители задают границы интерполяции: при Delims("[[", "]]") пара {{ }} для парсера — обычные символы текста. Шаблон спарсится без ошибок и выведет скобки дословно. Поэтому при миграции разделителей файлы переписывают сразу и целиком, а половинчатые состояния тишиной проскакивают в прод — ошибок в логах не будет.

### Раскладки и частичные шаблоны: define и template

Одна страница — редкость; хочется общего каркаса: head с stylesheet, шапка с навигацией, подвал. В html/template это делается блоками define и template, и gin здесь не добавляет ничего — это чистая стандартная библиотека. Блок {{ define "head" }}...{{ end }} объявляет именованный фрагмент внутри файла, а {{ template "head" . }} вставляет его в точке вызова, передавая те же данные — точка в конце не опечатка, это передача контекста. Классическая раскладка: layout.html держит каркас с двумя template-вставками — head и footer, — а page-файлы объявляют эти блоки своим содержимым. Порядок загрузки в gin: LoadHTMLGlob("templates/*") соберёт все файлы в один набор, и имена блоков станут общими для набора — template ищет блок по имени во всём наборе, а не только в своём файле. Отсюда следствие, которое стоит проговорить: имена define уникальны в рамках набора — два файла с {{ define "head" }} перезапишут друг друга без предупреждений; добавьте зону в имя: {{ define "site/head" }}, {{ define "admin/head" }}. Для вложенных страниц из подпапок действует правило из прошлого блока: {{ define "posts/index.html" }} и c.HTML с тем же именем — маска с двумя звёздочками собирает подпапки, define даёт им предсказуемые имена. И тонкость передачи данных: {{ template "head" . }} передаёт весь контекст, а {{ template "head" .Title }} — только значение; внутри блока точка уже будет строкой, а не картой. Это удобно для узких фрагментов, но ловит на рефакторинге: поменяли контекст — блоки молча отрисуют пустоту. Держите передачу контекста явной и одинаковой во всех раскладках проекта.

### Multitemplate: две раскладки в одном приложении

Загрузчики gin дают один набор шаблонов на приложение — и этого хватает, пока все страницы живут в общем каркасе. Как только появляются принципиально разные раскладки — админка с боковой панелью и публичные страницы с меню, письмо-шаблон, лендинг, — наборный подход трещит: имена define расползаются, общий head переопределяется, layout-логика превращается в условия. Официальное решение — HTMLRender интерфейс: у движка есть поле HTMLRender с методом Instance(name string, data any) Render, и c.HTML просто делегирует ему. Подмените HTMLRender — и c.HTML начнёт отдавать то, что вы решите. Пакет gin-contrib/multitemplate делает подмену за вас: multitemplate.NewRenderer() создаёт рендерер, методы AddFromFiles и AddFromGlob регистрируют именованные наборы — каждый со своим каркасом и своими файлами, — а присвоение r.HTMLRender = renderer включает его. Дальше c.HTML(http.StatusOK, "user", data) рендерит набор user с раскладкой пользователя, c.HTML(..., "admin", data) — набор admin с панелью администратора; JSON-API это не трогает. Собирайте рендерер фабричной функцией — createRender() — и передавайте движку до старта: HTMLRender назначается один раз, как и FuncMap. Плата очевидна и мала: каждый набор парсится отдельно, дубли общих фрагментов можно разрулить общими define-блоками с зональными именами. Альтернатива без внешнего пакета — свой тип с тем же методом Instance поверх html/template: интерфейс крошечный, а понимание, что за ним стоит, у вас теперь есть. В трекере multitemplate закрывает ровно одну развилку — публичная страница и будущая админка — но решает её чисто, без условий в разметке.

### Две раскладки через gin-contrib/multitemplate

```go
import (
        "github.com/gin-contrib/multitemplate"
        "github.com/gin-gonic/gin"
)

func createRender() multitemplate.Renderer {
        r := multitemplate.NewRenderer()
        // Каркас + страница для каждого набора.
        r.AddFromFiles("user", "templates/layout.html", "templates/user.html")
        r.AddFromFiles("admin", "templates/layout.html", "templates/admin.html")
        r.AddFromGlob("letter", "templates/letters/*.html")
        return r
}

func main() {
        r := gin.Default()
        r.HTMLRender = createRender()

        r.GET("/", func(c *gin.Context) {
                c.HTML(http.StatusOK, "user", gin.H{"title": "Мои задачи"})
        })

        r.GET("/admin", func(c *gin.Context) {
                c.HTML(http.StatusOK, "admin", gin.H{"title": "Админка"})
        })
}
```

Имена наборов заменяют имена файлов: c.HTML ищет не index.html, а user или admin. AddFromFiles парсит файлы пачкой — каркас и страница; AddFromGlob берёт набор из маски. Присвоение r.HTMLRender до первого запроса: подмена на лету с живым трафиком — гонка. Пакет ставится классически: go get github.com/gin-contrib/multitemplate.

### Проверка: Content-Type страницы

**Вопрос:** Хендлер отдал c.HTML(http.StatusOK, "index.html", data). Какой Content-Type придёт в ответе?

- text/plain; charset=utf-8 — HTML не зарегистрирован как MIME-тип по умолчанию
- ✅ text/html; charset=utf-8 — константу пишет render.HTML перед ExecuteTemplate
- application/xhtml+xml — StrictHTML-режим gin
- Зависит от расширения файла: .html → text/html, .htm → application/octet-stream

Тип жёстко зашит в рендерере: render.HTML.WriteContentType пишет text/html; charset=utf-8 до исполнения шаблона — расширение файла ни при чём. Это отличает c.HTML от c.Data, куда тип передаёте вы сами, и роднит с c.JSON, где константа application/json; charset=utf-8.

### Что выбрать для ответа

| Инструмент | Content-Type | Данные | Когда брать |
| --- | --- | --- | --- |
| c.HTML | text/html; charset=utf-8 | шаблон + gin.H/структура | страницы, отчёты, HTML-письма |
| c.JSON | application/json; charset=utf-8 | конверт Envelope | API для фронтенда и клиентов |
| c.Data(code, type, bytes) | тот, что передали | готовые байты | SVG-иконки, CSV, свои форматы |
| Static из урока 11 | по расширению файла | файлы на диске/embed | неизменяемые ассеты: CSS, JS, картинки |
| NoRoute + c.File | по расширению | файл | SPA-фолбэк на промахи |

### HTML или JSON: когда что рендерить

Теперь у трекера два лица, и стоит зафиксировать критерий выбора. JSON — когда потребителя нельзя предсказать: мобильное приложение, чужой скрипт, публичный API; контракт из урока про ответы — Envelope с полем Error — остаётся сетевым стандартом проекта. HTML — когда потребитель человек и SEO важнее интерактивности: публичные страницы, документация, панель со списком задач — серверный рендер даёт готовую разметку без ожидания и запросов данных с клиента, а поисковик индексирует её без выкручиваний. Смешивать — нормально и типично: страницы рендерятся на /, /tasks и /tasks/:id через c.HTML, а под группой /api/v1 живут JSON-эндпоинты того же трекера — [группы маршрутов из прошлого урока](/courses/gin/lessons/10-gruppy/) как раз разводят эти зоны, и middleware на группе API не мешает страницам. Опасная зона одна: не начинайте выдавать HTML за JSON-клиента. У API нет шаблонов: если фронтенд — SPA на webpack/vite, ваши c.HTML-страницы никому не нужны, а полноценный HTML-слой удвоит работу. Признак, что серверный рендер вам показан: ссылки должны открываться с нуля, поиск должен видеть контент, интерактив минимален. Признак против: страница — это приложение с состоянием, список фильтруется без перезагрузки, офлайн-режим. В учебном трекере скучно ни то ни другое, поэтому берём обе стороны: страница списка — HTML, операции — JSON. Так проект честно упражняет оба режима ответа, и на собеседовании вы не будете делать вид, что «HTML для веба не нужен».

### Шесть правил шаблонов gin

- Порядок инициализации: gin.SetMode, SetFuncMap, Delims, LoadHTMLGlob/Files, маршруты, Run — всё, что влияет на парсинг, до загрузки шаблонов.
- Имя шаблона — базовое имя файла; для подпапок — явный {{ define "posts/index.html" }} и то же имя в c.HTML.
- Шаблон видит только экспортированные поля структуры; методы тоже доступны, если они экспортированы.
- Экранирование контекстное и обязательное; template.HTML — сознательный обход для проверенного контента, не для строк из форм.
- В debug-режиме шаблоны перечитываются на каждый запрос (HTMLDebug), в release парсятся один раз (HTMLProduction) — для прода ставьте gin.SetMode(gin.ReleaseMode).
- Разные раскладки — gin-contrib/multitemplate или свой рендерер поверх HTMLRender: именованные наборы вместо условий в разметке.

### Соберите инициализацию страниц

Расставьте вызовы по порядку: функции шаблонов, разбор каталога и отдача страницы.

```go
func buildPages(r *gin.Engine) {
        // 1. Функции шаблонов — ДО загрузки файлов.
        r.___(template.___{
                "fmtDate": fmtDate,
                "plural":  plural,
        })

        // 2. Разбор всех шаблонов из каталога templates.
        r.___("templates/*")

        // 3. Страница списка задач.
        r.GET("/", func(c *gin.Context) {
                c.___(http.StatusOK, "index.html", gin.H{
                        "title": "Задачи на сегодня",
                        "tasks": tasks,
                })
        })
}
```

Подсказка: SetFuncMap принимает template.FuncMap; каталог парсит LoadHTMLGlob; страница отдаётся c.HTML со статусом, именем и данными.

Правильные ответы в пропусках: `SetFuncMap`, `FuncMap`, `LoadHTMLGlob`, `HTML`

### Соберите путь запроса страницы

GET / при release-режиме и LoadHTMLGlob: разложите события от curl до строки лога.

Правильный порядок строк:

1. `На старте SetFuncMap положил fmtDate и plural в engine.FuncMap`
2. `LoadHTMLGlob спарсил templates/* один раз и закэшировал набор в HTMLProduction`
3. `curl отправляет GET /`
4. `handleHTTPRequest находит в GET-дереве маршрут /`
5. `Хендлер зовёт c.HTML(200, "index.html", gin.H{"title": ...})`
6. `c.HTML берёт Instance у HTMLRender по имени index.html`
7. `WriteContentType ставит Content-Type: text/html; charset=utf-8`
8. `ExecuteTemplate исполняет index.html с картой данных`
9. `{{ .title }} интерполируется с контекстным экранированием`
10. `{{ template "head" . }} вставляет общий блок каркаса`
11. `Тело пишется в ResponseWriter — статус 200 уходит вместе с разметкой`
12. `Logger печатает [GIN] GET / 200 с временем и размером`

### Карточки: шаблоны HTML

- **LoadHTMLGlob и LoadHTMLFiles** — Оба собирают набор html/template: первый — по маске filepath.Glob, второй — по явному списку файлов. Имя шаблона — базовое имя файла. Паника template.Must при битой разметке — на старте, до первого запроса. Пути — от рабочей директории процесса.
- **HTMLDebug и HTMLProduction** — Две реализации интерфейса HTMLRender с методом Instance(name, data). В debug-режиме gin ставит HTMLDebug — шаблоны перечитываются и перепарсиваются на каждый запрос (hot reload). В release — HTMLProduction с одним парсингом на старте. Правка файла после старта в release не видна до рестарта.
- **c.HTML и gin.H** — c.HTML(code, name, data) рендерит шаблон по имени с Content-Type text/html; charset=utf-8. gin.H — псевдоним map[string]any, быстрый способ передать данные; для боевого кода лучше структуры: типы, подсказки IDE, видны только экспортированные поля.
- **SetFuncMap и FuncMap** — r.SetFuncMap(template.FuncMap{"fmtDate": fmtDate}) регистрирует функции шаблонов. Звать ДО LoadHTMLGlob/Files: имена функций фиксируются при парсинге, незнакомое имя — паника ParseGlob. Функции — про представление: форматирование, плюрализация; I/O из шаблонов выносить в хендлеры.
- **Delims** — r.Delims("[[", "]]") меняет разделители интерполяции до загрузки шаблонов — способ разойтись с Vue/Alpine, которые живут на {{ }}. Старый синтаксис после смены становится обычным текстом без ошибок парсинга: миграцию проверять глазами.
- **define, template и multitemplate** — {{ define "head" }} объявляет именованный блок, {{ template "head" . }} вставляет его с контекстом; имена блоков уникальны в наборе. Разные каркасы — gin-contrib/multitemplate: NewRenderer, AddFromFiles/AddFromGlob, r.HTMLRender = renderer, и c.HTML ищет наборы по имени.

> **Проверьте страницу curl-ом.** Соберите buildPages и снимите страницу curl-ом: curl -i localhost:8080/ — заголовок Content-Type: text/html; charset=utf-8, в теле интерполированный title и список задач; подставьте в данные <script>alert(1)</script> и убедитесь, что в исходнике приходят сущности, а браузер ничего не исполняет. Сломайте разметку шаблона и перезапустите сервер — паника на старте в release поймает опечатку раньше пользователя. Сравните два режима: закомментируйте gin.SetMode(gin.ReleaseMode) и понаблюдайте, как правка файла видна по F5 без рестарта. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 13-fajly: загрузка файлов, FormFile и SaveUploadedFile, ограничения размера — форма трекера обзаведётся вложениями.

## Коротко: что запомнить

- LoadHTMLGlob/Files собирают набор html/template один раз при старте; имена — базовые имена файлов, битая разметка паникует на старте через template.Must.
- Режим решает: в debug шаблоны перечитываются на каждый запрос (HTMLDebug), в release закэшированы в HTMLProduction — прод стартует с gin.SetMode(gin.ReleaseMode).
- Данные — gin.H или структура: шаблон видит только экспортированные поля и методы; экранирование контекстное, template.HTML — сознательный обход.
- SetFuncMap до загрузки шаблонов: имена функций фиксируются при парсинге; функции — про представление, без I/O.
- Delims меняет скобки до парсинга — способ разойтись с Vue и Alpine; старый синтаксис после смены — просто текст.
- Раскладки — define/template с уникальными именами; разные каркасы — gin-contrib/multitemplate с r.HTMLRender = createRender() и именованными наборами.

## Домашнее задание

Соберите HTML-этаж трекера: LoadHTMLGlob("templates/*"), SetFuncMap с fmtDate и plural до загрузки, страницу / со списком задач через c.HTML и gin.H — карточка задачи показывает Done флагом {{ if }}, дату через fmtDate, счётчик через plural. Рядом оставьте /api/v1/todos в JSON — группы из прошлого урока разводят зоны. Подставьте в данные строку с тегами и убедитесь в экранировании. Затем добавьте multitemplate: каркас layout.html, наборы user и admin, r.HTMLRender = createRender() и /admin с другой шапкой. Прогоните оба режима gin и зафиксируйте разницу в правках шаблона без рестарта.

## Вопросы и ответы по уроку

### Почему шаблоны не видят поля структуры со строчной буквы?

html/template работает через рефлексию и видит только экспортированные поля — верхний регистр первой буквы. Поле title для шаблона пусто, поле Title отрисуется. Методы — то же правило: интерполяция {{ .DueFmt }} вызовет экспортированный метод без аргументов.

### Как грузить шаблоны из подпапок?

Маска LoadHTMLGlob("templates/**/*") в filepath.Glob означает один уровень вложенности и соберёт файлы подпапок; имена объявляйте явно через {{ define "posts/index.html" }} и указывайте то же имя в c.HTML. Вложеннее двух уровней — несколько вызовов LoadHTMLGlob с разными масками или multitemplate.

### Можно ли перезагружать шаблоны без рестарта в проде?

Штатно — только оставаясь в debug-режиме, где HTMLDebug перепарсивает на каждый запрос; в проде это не рекомендуется: лишняя работа и паники в момент обслуживания. Правильный путь — релиз с новым бинарником; фанатам hot-reload — свой рендерер поверх HTMLRender с проверкой mtime и мьютексом.
