Shanraq.org Shanraq.org
Макет: одна рамка на все страницы
IT

Go: с нуля до своего блога Урок 35 из 50

Макет: одна рамка на все страницы

Тридцать пятый урок курса по Go. Семь шаблонов блога держат семь копий одного и того же начала страницы. Сегодня остаётся одна рамка: base с блоками, папки pages и partials, семантическая разметка — и измеренная ловушка, из-за которой у людей все страницы вдруг показывают одну и ту же.

Зачем это нужно

Посчитаем, что накопилось в блоге к этому уроку. Семь шаблонов, и в каждом из них — своё <!doctype html>, свой <meta charset>, своя ссылка на стили и свой логотип. Семь копий одного и того же.

Пока страниц было две, это выглядело как мелочь. Дальше в курсе появятся вход, регистрация, профиль и настройки — и копий станет одиннадцать. Захотите поменять шапку — поменяете в одиннадцати местах, в десяти правильно.

Есть и вторая причина, не про удобство. Страницы блога до сих пор собраны из <h1> и <p>, а браузер, поисковик и программа чтения с экрана ждут разметки, которая говорит, где шапка, где основное, где подвал. Сегодня появится и она.

Сразу целиком

Новая папка, go mod init sabaq32. Файлов теперь несколько, и разложены они не как раньше:

templates/
    base.html            рамка: одна на всё
    partials/
        header.html      куски, общие для страниц
        footer.html
    pages/
        list.html        то, что вставляется в рамку
        about.html
main.go

templates/base.html

{{ define "base" -}}
<!doctype html>
<html lang="ru">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/static/style.css">
    <title>{{ block "title" . }}Мой блог{{ end }}</title>
  </head>
  <body>
    {{ template "header" . }}
    <main>
{{- block "main" . }}{{ end }}
    </main>
    {{ template "footer" . }}
  </body>
</html>
{{- end }}

templates/partials/header.html

    {{ define "header" -}}
    <header>
      <a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
      <nav>
        <a href="/">Статьи</a>
        <a href="/search">Поиск</a>
        <a href="/about">О блоге</a>
      </nav>
    </header>
    {{- end }}

templates/partials/footer.html

    {{ define "footer" -}}
    <footer>
      <p>&copy; {{ .Year }} Мой блог</p>
    </footer>
    {{- end }}

templates/pages/list.html

{{ define "title" }}Статьи — Мой блог{{ end }}
{{ define "main" }}
      <ul>
        {{- range .Articles }}
        <li><a href="/read/{{ .Slug }}">{{ .Title }}</a> — {{ .Updated }}</li>
        {{- end }}
      </ul>
{{- end }}

templates/pages/about.html

{{ define "title" }}О блоге — Мой блог{{ end }}
{{ define "main" }}
      <article>
        <h1>О блоге</h1>
        <p>Здесь я пишу о том, что узнал.</p>
      </article>
{{- end }}

main.go

package main

import (
	"embed"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"os"
	"path"
	"time"
)

//go:embed templates
var files embed.FS

// Article — статья такая, какой её видит страница.
type Article struct {
	Slug      string
	Title     string
	UpdatedAt time.Time
}

// months — потому что названий месяцев по-русски в Go нет: пакет time знает
// только английские.
var months = [...]string{"января", "февраля", "марта", "апреля", "мая", "июня",
	"июля", "августа", "сентября", "октября", "ноября", "декабря"}

// Updated возвращает дату так, как её читает человек. Форматирование живёт
// здесь, а не в шаблоне: шаблон показывает, а не считает.
func (a Article) Updated() string {
	if a.UpdatedAt.IsZero() {
		return "не правилась"
	}
	return fmt.Sprintf("%d %s %d", a.UpdatedAt.Day(),
		months[a.UpdatedAt.Month()-1], a.UpdatedAt.Year())
}

// pages собирает по одному набору шаблонов на страницу: макет, все куски и
// ровно одна страница. Иначе блоки с одинаковыми именами затрут друг друга.
func pages() map[string]*template.Template {
	found, err := fs.Glob(files, "templates/pages/*.html")
	if err != nil || len(found) == 0 {
		log.Fatal("страницы не найдены: ", err)
	}

	out := make(map[string]*template.Template, len(found))
	for _, page := range found {
		name := path.Base(page)
		t, err := template.New(name).ParseFS(files,
			"templates/base.html", "templates/partials/*.html", page)
		if err != nil {
			log.Fatal(name, ": ", err)
		}
		out[name] = t
	}
	return out
}

func main() {
	tpl := pages()

	data := map[string]any{
		"Year": 2026,
		"Articles": []Article{
			{Slug: "dala", Title: "О степи", UpdatedAt: time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)},
			{Slug: "privet", Title: "Привет, мир"},
		},
	}

	for _, name := range []string{"list.html", "about.html"} {
		fmt.Printf("== %s\n", name)
		if err := tpl[name].ExecuteTemplate(os.Stdout, "base", data); err != nil {
			log.Fatal(err)
		}
		fmt.Println()
	}
}

Вывод — две готовые страницы:

== list.html
<!doctype html>
<html lang="ru">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/static/style.css">
    <title>Статьи — Мой блог</title>
  </head>
  <body>
    <header>
      <a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
      <nav>
        <a href="/">Статьи</a>
        <a href="/search">Поиск</a>
        <a href="/about">О блоге</a>
      </nav>
    </header>
    <main>
      <ul>
        <li><a href="/read/dala">О степи</a> — 6 сентября 2026</li>
        <li><a href="/read/privet">Привет, мир</a> — не правилась</li>
      </ul>
    </main>
    <footer>
      <p>&copy; 2026 Мой блог</p>
    </footer>
  </body>
</html>
== about.html
<!doctype html>
<html lang="ru">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/static/style.css">
    <title>О блоге — Мой блог</title>
  </head>
  <body>
    <header>
      <a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
      <nav>
        <a href="/">Статьи</a>
        <a href="/search">Поиск</a>
        <a href="/about">О блоге</a>
      </nav>
    </header>
    <main>
      <article>
        <h1>О блоге</h1>
        <p>Здесь я пишу о том, что узнал.</p>
      </article>
    </main>
    <footer>
      <p>&copy; 2026 Мой блог</p>
    </footer>
  </body>
</html>

Разбор

define и block: рамка и то, что в неё вставляют

{{ define "header" }} даёт куску имя. {{ template "header" . }} вставляет кусок по имени, а точка передаёт ему те же данные, что и всей странице, — забудете её, и подвал не узнает про год.

{{ block "main" . }} — это define и template в одной записи: «здесь будет блок с таким именем, а если его никто не задал, то вот содержимое по умолчанию». В base.html таких блоков два: заголовок окна с запасным значением Мой блог и пустой main.

Страница не рисует ничего целиком. Она только говорит: «мой title вот такой, мой main вот такой». Рамку рисует base.

Папки pages и partials

Разделение не для красоты. В partials лежит то, что вставляют в рамку всегда и по имени: шапка, подвал, дальше — карточка статьи, форма поиска. В pages — то, чем рамку наполняют, по одному файлу на страницу.

Разница видна в следующем разделе: наборы шаблонов собираются по одному на страницу, и partials попадают в каждый набор, а из pages в набор берётся ровно один файл.

Ловушка: все шаблоны в одном пространстве имён

Вот из-за чего макеты ломаются у половины тех, кто делает их впервые.

ParseFS складывает все найденные шаблоны в один набор, а имя внутри набора одно на всех. Две страницы, обе объявляют {{ define "main" }} — и вторая молча затирает первую. Проверим:

== один набор на все страницы
-- просим list:
<!doctype html>
<html lang="ru">
  <head>
    <title>Список</title>
  </head>
  <body>
    <main><p>это список</p></main>
  </body>
</html>
-- просим about:
<!doctype html>
<html lang="ru">
  <head>
    <title>Список</title>
  </head>
  <body>
    <main><p>это список</p></main>
  </body>
</html>

Мы просили разные страницы, а получили одну и ту же — дважды. Ошибки нет, потому что с точки зрения Go ничего плохого не случилось: в наборе просто оказался один шаблон main, последний из прочитанных.

Образ. Ящик с папками, где на всех папках одна подпись. Положить можно сколько угодно, достать — только ту, что сверху.

Свой набор шаблонов на каждую страницу

Лечится это тем, что наборов делают столько же, сколько страниц:

t, err := template.New(name).ParseFS(files,
	"templates/base.html", "templates/partials/*.html", page)

В каждый набор входят рамка, все общие куски и одна страница. Имена внутри набора больше не сталкиваются: main в наборе ровно один.

Собирается это один раз при старте, в карту «имя страницы → набор». Дальше обработчик берёт нужный набор и выполняет в нём base. Заметьте: выполняется всегда base, а не имя страницы, — рисует-то рамка.

Ошибка в любом шаблоне обнаружится при старте, а не у читателя, — то же правило, что и в уроке про шаблоны: разобрать всё на старте лучше, чем получить сюрприз в середине страницы.

Семантика: header, main, footer, nav, article

В выводе видно, что страница теперь собрана не из «дивов вообще», а из тегов, у которых есть смысл: <header> — шапка, <nav> — навигация, <main> — единственное на странице основное содержимое, <article> — самостоятельный текст, <footer> — подвал.

Это не украшение. Программа чтения с экрана позволяет перейти сразу к <main>, минуя меню. Поисковик отличает содержимое от обвязки. Браузер в режиме чтения выбрасывает всё, кроме <article>.

Правило простое: <main> на странице один, <h1> тоже один, а <div> берут тогда, когда подходящего по смыслу тега нет.

Дата для человека, а не для базы

До сих пор блог показывал время правки так, как его отдала SQLite: 2026-09-06 12:00:00. Читателю это ни о чём.

Форматирование живёт в Go, а не в шаблоне: шаблон показывает, а не считает. Метод у типа — самое подходящее место:

func (a Article) Updated() string {
	if a.UpdatedAt.IsZero() {
		return "не правилась"
	}
	return fmt.Sprintf("%d %s %d", a.UpdatedAt.Day(),
		months[a.UpdatedAt.Month()-1], a.UpdatedAt.Year())
}

Шаблон зовёт его как поле — {{ .Updated }}, без скобок.

Про месяцы стоит сказать отдельно. В Go нет названий месяцев на русском или казахском: пакет time знает только английские. Список приходится писать самому — как в этой программе. И само форматирование в Go делается не буквами вроде YYYY-MM-DD, а образцом даты: 02.01.2006. Это не случайные числа, а «второе января две тысячи шестого» — способ записать, что где стоит.

Пробелы: {{- и -}}

Шаблон печатает всё, что стоит между действиями, включая переводы строк. Из-за этого range оставляет пустую строку на каждый оборот, а страница выглядит рваной.

Минус внутри скобок съедает пробелы с этой стороны: {{- range .Articles }} уберёт перевод строки перед собой. В выводе выше список идёт ровно, без дыр, — именно из-за минусов в list.html.

Карта урока

Карта урока: одна рамка, куски и страницы

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему две страницы с блоком main в одном наборе шаблонов показывают одно и то же?
  2. Что выполняет обработчик — шаблон страницы или base, и почему?
  3. Почему дату форматируют в Go, а не в шаблоне?

Задание

Обязательное. Переведите блог на макет. Все семь шаблонов разъезжаются по pages и partials, общее начало остаётся в base.html, шапка с навигацией — в partials/header.html. Наборы собираются один раз при старте в карту. Разметка становится семантической, а дата правки выводится через метод, а не сырой строкой.

Всё это сделано в step-20 — сверьтесь после того, как сделаете сами.

По желанию.

  • Подсветите в навигации текущую страницу. Понадобится передать в шаблон её имя.
  • Добавьте partials/article-card.html и используйте его и в списке, и на странице тега.
  • Уберите минусы из {{- range }} и посмотрите на исходный код страницы в браузере.
  • Год в подвале здесь приходит с данными. Сделайте его функцией шаблона через Funcs: тогда его не придётся передавать на каждой странице, а подвал не покажет 2026 в две тысячи двадцать седьмом. В step-20 сделано именно так.

Куда это встанет в блоге

Это первый урок, после которого блог перестаёт выглядеть учебным примером. Одна рамка, настоящая разметка, человеческая дата.

Долги. Стилей по-прежнему почти нет, и это сознательно: оформлению курс не учит. Навигация не знает, на какой странице читатель. И у страниц нет ни описания для поисковика, ни заголовков для соцсетей — это будет в уроке про чек-лист перед запуском.

Ответы

Показать ответы
  1. Потому что ParseFS кладёт все шаблоны в один набор, а имена в нём общие: второй main затирает первый. Никакой ошибки при этом нет — в наборе просто остаётся один шаблон с этим именем.
  2. base, всегда. Страница задаёт только блоки title и main, а рисует рамка; выполнять имя страницы бессмысленно — в ней нет ни <html>, ни <body>.
  3. Потому что шаблон показывает, а не считает. Метод у типа держит правило в одном месте, его можно проверить тестом, и он одинаково работает на всех страницах.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.