Go: с нуля до своего блога Урок 45 из 50
Свой логотип, своё имя, своя иконка: блог становится вашим
Сорок пятый урок курса по Go. Блог носит чужое имя, чужой знак и чужие цвета — до этого урока. Имя переезжает из одиннадцати шаблонов в одну настройку, логотип становится вашим файлом, палитра — девятью строками, а иконка — вашей. Заодно посчитан контраст, и две наши ошибки исправлены.
Зачем это нужно
Блог работает, читает, ищет, пускает людей — и всё ещё выглядит как чужая учебная заготовка. Перед тем как показывать его кому-то, стоит сделать его своим, и это дело одного вечера.
Здесь четыре отдельные вещи, и путать их не надо. Имя — текст, который стоит в шапке, в заголовке вкладки и в подвале. Логотип — картинка рядом с именем. Цвета — фон страницы, текст на нём, ссылки и кнопки. Иконка — то маленькое, что браузер рисует на вкладке и в закладках.
Каждая живёт в своём месте, и каждую меняют по-своему.
Сразу целиком
Новая папка, go mod init sabaq42. Рядом — папка static/brand с логотипом:
package main
import (
"bytes"
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"strings"
)
//go:embed static
var files embed.FS
// page — то, что видит шаблон. Имя блога приходит сюда из настройки, а не
// вписано в разметку: вписанное в разметку живёт в каждом шаблоне сразу.
type page struct {
SiteName string
Title string
}
const layout = `<!doctype html>
<html lang="ru">
<head>
<title>{{ .Title }} — {{ .SiteName }}</title>
<link rel="icon" href="/static/brand/logo.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/brand/apple-touch-icon.png">
</head>
<body>
<header>
<a href="/"><img src="/static/brand/logo.svg" width="40" height="40" alt=""></a>
<span>{{ .SiteName }}</span>
</header>
</body>
</html>`
func main() {
name := flag.String("name", "Мой блог", "название блога")
flag.Parse()
tpl := template.Must(template.New("layout").Parse(layout))
fmt.Println("== одно имя на всю страницу")
tpl.Execute(os.Stdout, page{SiteName: *name, Title: "Статьи"})
fmt.Println()
// Тот же шаблон с другим именем: печатаем только строки, которые
// изменились, чтобы не повторять страницу целиком.
fmt.Println("== другое имя — тот же шаблон")
var buf bytes.Buffer
tpl.Execute(&buf, page{SiteName: "Блог о степи", Title: "Статьи"})
for _, line := range strings.Split(buf.String(), "\n") {
if strings.Contains(line, "<title>") || strings.Contains(line, "<span>") {
fmt.Println(strings.TrimSpace(line))
}
}
fmt.Println()
fmt.Println("== чего просит браузер сам")
bare := httptest.NewServer(routes(false))
defer bare.Close()
fmt.Printf("%-30s %s\n", "/favicon.ico без ответа:", get(bare.URL+"/favicon.ico"))
srv := httptest.NewServer(routes(true))
defer srv.Close()
fmt.Printf("%-30s %s\n", "/favicon.ico с ответом:", get(srv.URL+"/favicon.ico"))
fmt.Printf("%-30s %s\n", "/static/brand/logo.svg:", get(srv.URL+"/static/brand/logo.svg"))
fmt.Printf("%-30s %s\n", "apple-touch-icon.png:", get(srv.URL+"/static/brand/apple-touch-icon.png"))
fmt.Println()
fmt.Println("== размер картинки")
svg, _ := fs.ReadFile(files, "static/brand/logo.svg")
fmt.Printf("logo.svg: %d байт, и это текст: %s…\n", len(svg),
strings.SplitN(string(svg), ">", 2)[0][:38])
}
func routes(withIcon bool) http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(files))
// Браузер просит /favicon.ico сам, даже если в разметке про него ни слова.
// Без ответа он получит 404 — и запишет это в журнал каждому читателю.
if withIcon {
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/brand/logo.svg", http.StatusMovedPermanently)
})
}
return mux
}
func get(url string) string {
resp, err := http.Get(url)
if err != nil {
return "ошибка: " + err.Error()
}
defer resp.Body.Close()
return fmt.Sprintf("%d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
Вывод:
== одно имя на всю страницу
<!doctype html>
<html lang="ru">
<head>
<title>Статьи — Мой блог</title>
<link rel="icon" href="/static/brand/logo.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/brand/apple-touch-icon.png">
</head>
<body>
<header>
<a href="/"><img src="/static/brand/logo.svg" width="40" height="40" alt=""></a>
<span>Мой блог</span>
</header>
</body>
</html>
== другое имя — тот же шаблон
<title>Статьи — Блог о степи</title>
<span>Блог о степи</span>
== чего просит браузер сам
/favicon.ico без ответа: 404 text/plain; charset=utf-8
/favicon.ico с ответом: 200 image/svg+xml
/static/brand/logo.svg: 200 image/svg+xml
apple-touch-icon.png: 200 image/png
== размер картинки
logo.svg: 193 байт, и это текст: <svg xmlns="http://www.w3.org/2000/svg…
Разбор
Имя блога — настройка, а не текст в разметке
В шаблоне нет слова «Мой блог». Есть {{ .SiteName }}, а само имя приходит флагом:
name := flag.String("name", "Мой блог", "название блога")
Разница видна во втором блоке вывода: тот же шаблон с другим именем — и поменялись обе строки, где имя встречается. В примере их две, в блоге — одиннадцать файлов, и правка всё равно одна.
Пока имя вписано в разметку, оно живёт в каждом шаблоне: шапка, подвал, <title> каждой страницы. В нашем блоге таких файлов одиннадцать — менять придётся все, и в десятом однажды забудут. Настройка убирает этот класс ошибок целиком — как в уроке про конфигурацию, где так же переехал адрес базы.
В блоге имя попадёт в шаблоны там же, где вошедший человек: render кладёт его в данные страницы один раз, и ни один обработчик про это не думает.
Образ. Табличка на двери, а не надпись краской на стене. Табличку меняют за минуту, краску перекрашивают всю стену.
Логотип: SVG — это текст
Последняя строка вывода: наш логотип весит 193 байта и начинается с <svg. Это не картинка в обычном смысле, а разметка — те же угловые скобки, что и в HTML, только описывают они круг и треугольник.
Отсюда три следствия, ради которых для знака берут именно SVG:
- он не мутнеет: одна и та же строка рисуется и в 16 пикселей на вкладке, и в 512 на экране телефона;
- он крошечный: две сотни байт против десятков килобайт у PNG того же качества;
- его можно править текстом: открыть в редакторе и поменять цвет, не заводя графическую программу.
Чтобы поставить свой знак, положите свой файл на место static/brand/logo.svg — имя в шаблонах менять не придётся. Если знак у вас в PNG, тоже нормально: положите рядом и поправьте src, но обязательно оставьте width и height, иначе страница дёрнется, когда картинка догрузится.
Цвета: девять строк на весь блог
Знак поменяли, а страница всё ещё чужая: та же красная ссылка, тот же кремовый фон. Цвет блога живёт в самом начале стилевого файла:
:root {
--ink: #1a1a1a; /* текст */
--paper: #fffdf8; /* фон страницы */
--accent: #d32f2f; /* ссылки и кнопки */
--muted: #6b625a; /* подписи */
--faint: #7a7169; /* подвал и подсказки */
--line: #e6e0d6; /* линии под шапкой и над подвалом */
--edge: #9d8f7b; /* рамка поля ввода */
--field: #ffffff; /* чем залито поле */
--mark: #ffe08a; /* найденные слова в поиске */
}
Это переменные CSS: --ink — имя, #1a1a1a — значение, а любое правило ниже берёт значение через var(--ink). Браузер умеет это сам, никаких сборщиков и препроцессоров не нужно.
Главных цветов три, и с них начинают: --paper — фон страницы, --ink — текст на нём, --accent — ссылки и кнопки. Остальные шесть — оттенки того же: подписи, линии, рамка поля.
В шаге 31 я это посчитал: к палитре обращаются 17 раз, объявлений — девять, а цветов, написанных мимо палитры, — ноль. Значит, --accent меняется в одном месте, и вместе с ним меняются ссылки, кнопка, рамка кнопки и текст ошибки. Без переменных пришлось бы искать #d32f2f по всему файлу и один раз не найти.
Цвет нельзя брать на глаз
Красивый цвет и читаемый цвет — разные вещи, и разница считается. Правило называется контраст: отношение яркости текста к яркости фона. Обычному тексту нужно не меньше 4.5:1, крупному и границам, которые должны быть видны, — 3:1.
Это вторая маленькая программа этого урока — go mod init sabaq42b. Она считает контраст по правилу WCAG и говорит, годится цвет или нет:
package main
import (
"fmt"
"math"
"strconv"
"strings"
)
// Палитра блога: девять строк, из которых берётся каждый цвет на странице.
var palette = map[string]string{
"--ink": "#1a1a1a", // текст
"--paper": "#fffdf8", // фон страницы
"--accent": "#d32f2f", // ссылки и кнопки
"--muted": "#6b625a", // подписи
"--faint": "#7a7169", // подвал и подсказки
"--edge": "#9d8f7b", // рамка поля
}
// pair — что на чём написано. Норма WCAG: 4.5:1 для обычного текста,
// 3:1 для крупного и для границ, которые должны быть видны.
type pair struct {
what string
fg string
bg string
need float64
}
func main() {
fmt.Println("== наша палитра")
check([]pair{
{"текст на странице", palette["--ink"], palette["--paper"], 4.5},
{"ссылка", palette["--accent"], palette["--paper"], 4.5},
{"подпись под полем", palette["--muted"], palette["--paper"], 4.5},
{"подвал и подсказки", palette["--faint"], palette["--paper"], 4.5},
{"текст на кнопке", palette["--paper"], palette["--accent"], 4.5},
{"рамка поля ввода", palette["--edge"], palette["--paper"], 3},
})
fmt.Println()
fmt.Println("== что было в шагах до этого урока")
check([]pair{
{"подвал", "#857c72", palette["--paper"], 4.5},
{"рамка поля ввода", "#ddd5c9", palette["--paper"], 3},
})
fmt.Println()
fmt.Println("== красивое, но нечитаемое")
check([]pair{
{"светло-серая ссылка", "#9aa0a6", "#ffffff", 4.5},
{"жёлтый на белом", "#ffd400", "#ffffff", 4.5},
{"свой синий на бумаге", "#1a56db", palette["--paper"], 4.5},
})
}
func check(pairs []pair) {
for _, p := range pairs {
r := contrast(p.fg, p.bg)
verdict := "мало, нужно " + strconv.FormatFloat(p.need, 'g', -1, 64) + ":1"
if r >= p.need {
verdict = "годится"
}
fmt.Printf("%-22s %s на %s %5.2f:1 %s\n", p.what, p.fg, p.bg, r, verdict)
}
}
// contrast — отношение яркостей по правилу WCAG: светлее плюс 0,05 делить на
// темнее плюс 0,05. Единица — цвета совпали, двадцать один — чёрное на белом.
func contrast(a, b string) float64 {
la, lb := luminance(a), luminance(b)
if la < lb {
la, lb = lb, la
}
return (la + 0.05) / (lb + 0.05)
}
// luminance — сколько света в цвете. Зелёный человеческий глаз видит ярче
// красного, а синий — тусклее всех, поэтому веса разные.
func luminance(hex string) float64 {
h := strings.TrimPrefix(hex, "#")
r := channel(h[0:2])
g := channel(h[2:4])
b := channel(h[4:6])
return 0.2126*r + 0.7152*g + 0.0722*b
}
// channel переводит одну составляющую в линейный вид: экран показывает цвет
// не пропорционально числу, и без этой поправки счёт получится неверным.
func channel(s string) float64 {
v, err := strconv.ParseUint(s, 16, 8)
if err != nil {
return 0
}
c := float64(v) / 255
if c <= 0.03928 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
Вывод:
== наша палитра
текст на странице #1a1a1a на #fffdf8 17.12:1 годится
ссылка #d32f2f на #fffdf8 4.90:1 годится
подпись под полем #6b625a на #fffdf8 5.87:1 годится
подвал и подсказки #7a7169 на #fffdf8 4.70:1 годится
текст на кнопке #fffdf8 на #d32f2f 4.90:1 годится
рамка поля ввода #9d8f7b на #fffdf8 3.11:1 годится
== что было в шагах до этого урока
подвал #857c72 на #fffdf8 4.03:1 мало, нужно 4.5:1
рамка поля ввода #ddd5c9 на #fffdf8 1.43:1 мало, нужно 3:1
== красивое, но нечитаемое
светло-серая ссылка #9aa0a6 на #ffffff 2.64:1 мало, нужно 4.5:1
жёлтый на белом #ffd400 на #ffffff 1.43:1 мало, нужно 4.5:1
свой синий на бумаге #1a56db на #fffdf8 6.08:1 годится
Три вещи в этом выводе стоит увидеть.
Наша палитра проходит — но не с запасом: ссылка 4.90:1 при норме 4.5:1. Чуть светлее красный — и ссылку станет трудно читать при солнце на экране телефона.
Второй блок — наша собственная ошибка. До этого урока подвал в шагах был #857c72 — 4.03:1, ниже нормы для мелкого текста. А рамка поля ввода #ddd5c9 давала 1.43:1: поле было почти не обведено, и на плохом экране его край терялся. Оба цвета в шагах исправлены — теперь 4.70:1 и 3.11:1. Я не заметил этого, пока не посчитал; на глаз всё выглядело прилично.
Третий блок — про вкус. Светло-серая ссылка #9aa0a6 даёт 2.64:1, а жёлтый на белом — 1.43:1. Это те самые сайты, которые «красиво выглядят на макете» и не читаются на улице. А вот синий #1a56db на нашей бумаге — 6.08:1, и его можно брать смело.
Чтобы сделать блог своим по цвету, меняйте три строки и проверяйте программой:
--paper: #f7f7f5; /* холодная бумага вместо кремовой */
--ink: #14181f;
--accent: #1a56db; /* синий вместо красного */
Образ. Не красить каждую доску забора отдельно, а поменять банку краски. Все доски перекрасятся сами — и ни одна не останется старого цвета.
Тёмная тема — то же самое ещё раз. Браузер сообщает, что человек выбрал тёмное оформление системы, и достаточно переопределить те же переменные:
@media (prefers-color-scheme: dark) {
:root {
--paper: #14120f;
--ink: #efe9df;
--accent: #ff6b6b;
}
}
Ни одно правило ниже трогать не надо: они и так берут цвет из палитры. Контраст в тёмной теме считается той же программой — и, как правило, красный приходится делать светлее, иначе на чёрном он «садится».
Иконка вкладки: браузер просит её сам
Третий блок вывода — измеренный факт, который многих удивляет:
/favicon.ico без ответа: 404
/favicon.ico с ответом: 200 image/svg+xml
Браузер запрашивает /favicon.ico сам, даже если в разметке про него нет ни слова. Не ответите — получите 404 в журнале на каждого читателя и пустой квадратик на вкладке.
Ответить можно двумя строками:
<link rel="icon" href="/static/brand/logo.svg" type="image/svg+xml">
и перенаправлением с /favicon.ico на тот же файл — как в примере. Первое говорит браузеру, где икона; второе ловит тех, кто спросил по старой привычке.
Одного SVG мало: apple-touch-icon
SVG в качестве иконки понимают все нынешние браузеры, кроме одного случая: когда страницу добавляют на домашний экран айфона. Там нужен квадратный PNG:
<link rel="apple-touch-icon" href="/static/brand/apple-touch-icon.png">
Размер — 180×180, без прозрачности (iOS всё равно подставит свой фон), с небольшим полем по краям: система обрежет углы. В примере он лежит рядом со знаком и отвечает 200 image/png — один файл, и блог перестаёт выглядеть безымянным на чужом телефоне.
Иконку браузер запоминает надолго
Отдельно стоит знать: иконку кешируют жёстче, чем всё остальное. Поменяли файл, обновили страницу — на вкладке всё ещё старая, и это нормально.
Лечится не бубном, а именем: сохраните файл под новым именем (logo-2.svg) и поправьте ссылку. Тот же приём, что и со стилями в уроке про статику, и он работает всегда, в отличие от «очистите кеш».
Что ещё носит ваше имя
Пока вы в шаблонах, поправьте заодно то, что видит не читатель, а машина:
<html lang="ru">— язык страницы. Стоит правильный, иначе браузер предложит перевести страницу не с того языка;<title>— то, что видно во вкладке и в результатах поиска. Формат «Страница — Имя блога» из примера читается лучше, чем одно имя на всех страницах;<meta name="description">— строка под ссылкой в поиске. Её у нас ещё нет, и это долг урока про чек-лист.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему имя блога держат в настройке, а не в шаблонах?
- Почему цвет пишут в палитре, а не в каждом правиле, где он нужен?
- Какой контраст нужен обычному тексту и почему на глаз его не определить?
- Что произойдёт, если не отвечать на
/favicon.ico? - Почему для знака берут SVG, а не PNG?
Задание
Обязательное. Сделайте блог своим: имя приходит из флага -name или переменной BLOG_NAME и попадает во все шаблоны разом, ваш логотип лежит в static/brand/logo.svg, ваши три цвета стоят в :root, а /favicon.ico отвечает, а не молчит. Контраст текста на фоне и ссылки на фоне проверьте программой из урока: ниже 4,5:1 — возвращайтесь к палитре.
Всё это сделано в step-30 — сверьтесь после того, как сделаете сами.
По желанию.
- Нарисуйте свой знак прямо в редакторе: круг, буква, две линии. SVG — это текст, начать можно с копии нашего файла.
- Добавьте тёмную тему:
@media (prefers-color-scheme: dark)и те же переменные с другими значениями. Проверьте контраст и там — на чёрном фоне цвета ведут себя иначе. - Добавьте
apple-touch-icon.png180×180 и проверьте, как блог выглядит на домашнем экране телефона. - Сделайте так, чтобы имя блога попадало и в
<title>, и в подвал, и в заголовок RSS — из одного места.
Куда это встанет в блоге
В нашем блоге имя стояло в одиннадцати файлах — в заголовке каждой страницы и в подвале. Теперь оно приходит настройкой: запуск с -name "Блог о степи" меняет и заголовок вкладки, и строку в подвале, а render кладёт имя в данные страницы один раз — ни один обработчик про это не помнит.
Цвета в шагах теперь тоже в одном месте: к палитре обращаются 17 раз, объявлений девять, а цветов мимо палитры не осталось ни одного. Заодно исправлены две находки счётчика контраста — подвал 4,03 → 4,70 и рамка поля 1,43 → 3,11.
Иконки в шаге 30 отвечают все три:
/favicon.ico 301 -> /static/brand/logo.svg
/static/brand/logo.svg 200 image/svg+xml 1143 байт
/static/brand/apple-touch-icon.png 200 image/png 2092 байт
Блог перестаёт быть чужой заготовкой. Дальше его можно показывать людям — и следующие уроки как раз про то, как это сделать.
Долги. Описания для поисковика у страниц пока нет. Картинки для соцсетей — той, что показывается при отправке ссылки, — тоже. И имя автора нигде не выведено, хотя в базе оно есть.
Ответы
Показать ответы
- Потому что в разметке оно живёт в каждом шаблоне — шапка, подвал, заголовок каждой вкладки — и однажды его поменяют не везде. Настройка держит его в одном месте, и шаблоны берут готовое.
- Потому что один и тот же цвет нужен в нескольких правилах: ссылка, кнопка, её рамка, текст ошибки. В палитре он объявлен один раз, и меняется тоже один раз; иначе его пришлось бы искать по всему файлу и один раз не найти.
- Не меньше 4,5:1. На глаз не определить, потому что глаз сравнивает цвета, а не яркости: наш подвал выглядел прилично и давал 4,03:1 — ниже нормы, и мы это увидели только посчитав.
- Браузер всё равно его запросит и получит 404: в журнале появится ошибка на каждого читателя, а на вкладке — пустой квадрат вместо знака.
- Потому что SVG — это текст, который рисуется под любой размер без мути, весит сотни байт вместо десятков килобайт и правится в обычном редакторе.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.