Shanraq.org Shanraq.org
Свой логотип, своё имя, своя иконка: блог становится вашим
IT

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. Чуть светлее красный — и ссылку станет трудно читать при солнце на экране телефона.

Второй блок — наша собственная ошибка. До этого урока подвал в шагах был #857c724.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"> — строка под ссылкой в поиске. Её у нас ещё нет, и это долг урока про чек-лист.

Карта урока

Карта урока: имя, знак и иконка

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

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

  1. Почему имя блога держат в настройке, а не в шаблонах?
  2. Почему цвет пишут в палитре, а не в каждом правиле, где он нужен?
  3. Какой контраст нужен обычному тексту и почему на глаз его не определить?
  4. Что произойдёт, если не отвечать на /favicon.ico?
  5. Почему для знака берут 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.png 180×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 байт

Блог перестаёт быть чужой заготовкой. Дальше его можно показывать людям — и следующие уроки как раз про то, как это сделать.

Долги. Описания для поисковика у страниц пока нет. Картинки для соцсетей — той, что показывается при отправке ссылки, — тоже. И имя автора нигде не выведено, хотя в базе оно есть.

Ответы

Показать ответы
  1. Потому что в разметке оно живёт в каждом шаблоне — шапка, подвал, заголовок каждой вкладки — и однажды его поменяют не везде. Настройка держит его в одном месте, и шаблоны берут готовое.
  2. Потому что один и тот же цвет нужен в нескольких правилах: ссылка, кнопка, её рамка, текст ошибки. В палитре он объявлен один раз, и меняется тоже один раз; иначе его пришлось бы искать по всему файлу и один раз не найти.
  3. Не меньше 4,5:1. На глаз не определить, потому что глаз сравнивает цвета, а не яркости: наш подвал выглядел прилично и давал 4,03:1 — ниже нормы, и мы это увидели только посчитав.
  4. Браузер всё равно его запросит и получит 404: в журнале появится ошибка на каждого читателя, а на вкладке — пустой квадрат вместо знака.
  5. Потому что SVG — это текст, который рисуется под любой размер без мути, весит сотни байт вместо десятков килобайт и правится в обычном редакторе.

Источники

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

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

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

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

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

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