diff --git a/.env.example b/.env.example index 39b765f..c2d2ee9 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ # Site SITE_NAME=Example Blog +# Author in JSON-LD and RSS. Default: SITE_NAME. +AUTHOR_NAME= SITE_URL=https://example.com DEFAULT_LANG=en GITHUB_URL=https://github.com/example @@ -30,7 +32,9 @@ POPULAR_DAYS=30 POPULAR_LIMIT=5 PAGE_SIZE=10 RSS_LIMIT=20 +# Link preview image for posts without a cover, a file inside assets/ (e.g. assets/og.png). DEFAULT_OG_IMAGE= +# Optional, enables IndexNow pings: 8-128 characters a-z, A-Z, 0-9 or -. INDEXNOW_KEY= # Logs: debug | info | warn | error; json | text diff --git a/SPEC.md b/SPEC.md index 8e991c5..a45e5da 100644 --- a/SPEC.md +++ b/SPEC.md @@ -82,6 +82,7 @@ Proposed variables (final names set during implementation): | Variable | Purpose | |---|---| | `SITE_NAME`, `SITE_URL` | Site name and public URL. | +| `AUTHOR_NAME` | Optional. Author in JSON-LD and RSS. Default: `SITE_NAME`. | | `DEFAULT_LANG` | UI language and default post language: `en` or `ru`. Default `en`. | | `GITHUB_URL` | Link in the top bar. | | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Bucket access. `S3_ENDPOINT` must be `https://`. `S3_REGION` default: `garage`. | @@ -96,8 +97,8 @@ Proposed variables (final names set during implementation): | `POPULAR_DAYS`, `POPULAR_LIMIT` | "Popular" window and size. Defaults: `30`, `5`. | | `PAGE_SIZE` | Posts per page in "Newest" and tag pages. Default: `10`. | | `RSS_LIMIT` | Posts in the RSS feed. Default: `20`. | -| `DEFAULT_OG_IMAGE` | Preview image for posts without `cover`. | -| `INDEXNOW_KEY` | Optional. Enables IndexNow pings. | +| `DEFAULT_OG_IMAGE` | Optional. Preview image for pages and posts without `cover`: a file inside `assets/`, same rules as `cover`. | +| `INDEXNOW_KEY` | Optional. Enables IndexNow pings. 8–128 characters `a-z A-Z 0-9 -`. Served at `/.txt`. | | `RATE_LIMIT_SEARCH` | Search limit per client. Default: `30/1m`. | | `VISITOR_SECRET` | HMAC key for the daily visitor hash, ≥ 32 bytes. | | `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Challenge lifetime and difficulty. Defaults: `5m`, `100000`. | @@ -189,6 +190,7 @@ Rules: - No `title` or `date`, or invalid YAML → post skipped, WARN log. - Invalid `date`, `updated`, `slug`, `lang` (not two lower-case letters), `cover` (not an allowed file in `assets/`) or `draft` → post skipped, WARN log. - Slug from the file name: lower case, Russian letters transliterated (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` and `ь` dropped), any other character run → `-`. Heading anchors use the same rule. +- `tags`: lower-cased (`Go` → `go`), duplicates merged. Letters of any script, digits, `-`, `_`, `/`; 1–50 characters each; at most 20 per post. Anything else → post skipped, WARN log. - Two posts with one `slug` → the first by object key wins, the second is skipped, ERROR log. - Unknown fields are ignored. Obsidian can add its own. - README describes every field in this table. @@ -288,6 +290,7 @@ Goal: the best search possible without an extra service. - Result snippets with highlighted matches. - Works without JS: `GET /search?q=…` renders results. With JS: results update while typing. - Filter by tag. +- The results page has `noindex`. Rejected: Meilisearch and similar. Better typo handling, but a separate service. @@ -417,7 +420,8 @@ Trust boundaries: internet → Caddy → service; bucket → service (semi-trust | `/metrics` | GET | private network | Caddy block, `METRICS_ALLOW` | | `/healthz` | GET | public | constant body, no data | | `/prefs` | POST | public | form body ≤ 4 KB, enum check for `theme` / `lang`, redirect only to a local path | -| `/static/*` | GET | public | embedded CSS and fonts only, no directory listing | +| `/static/*` | GET | public | embedded CSS, JS and fonts only, no directory listing | +| `/.txt` | GET | public | only if `INDEXNOW_KEY` is set; body is the key | Other methods → `405`. Unknown paths → `404`. @@ -610,3 +614,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-03 — Config defaults: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Log component `main`. The service listens on fixed port 8080. - 2026-11-03 — Sync: S3 client `minio-go/v7`, frontmatter via `go.yaml.in/yaml/v3`, GFM. Mirror state in `VAULT_DIR/.hyperhack.json`. S3 down at startup: serve from the mirror; exit 1 only with no mirror. No Remotely Save prefix. Keys with a `.` or `_` segment are ignored (was proposed). `MAX_OBJECTS` counts all keys. An invalid frontmatter field skips the post. 3 S3 attempts per poll. `/reindex` waits for the sync and returns `204`. - 2026-11-04 — Pages: theme and language switch via a no-JS form `POST /prefs`; cookies `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 year`, `Secure` on https). Logo from `assets/logo.svg`, built-in mark otherwise. Fonts Inter + JetBrains Mono, self-hosted. Pagination `/?page=N`: page 1 is `/`, other out-of-range values → `404`. `` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`. +- 2026-11-04 — Search, tags, RSS, SEO: Bleve `v2.6.1`, in-memory index, one document mapping per language (`en`, `ru`). Fuzziness by word length: ≤ 3 letters 0, ≤ 6 letters 1, longer 2; prefix on the last word. Snippets keep only ``. Search works without JS; `search.js` swaps the results while typing (debounced). Results page `noindex`. Tags lower case, Obsidian characters, `/tags/` paginated like the main page. 24 tag hue buckets (FNV-1a hash), contrast checked in tests. RSS: `dc:creator` from `AUTHOR_NAME`, `atom:updated` from `updated`. Image `width`/`height` read from the mirror (`golang.org/x/image` for WebP); an asset change re-renders all posts. IndexNow: one ping per sync with new, changed and removed post URLs, not on the first index load. New `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` is a path inside `assets/`. diff --git a/SPEC.ru.md b/SPEC.ru.md index ee5e937..bf571b0 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -82,6 +82,7 @@ | Переменная | Назначение | |---|---| | `SITE_NAME`, `SITE_URL` | Название и публичный URL сайта. | +| `AUTHOR_NAME` | Необязательно. Автор в JSON-LD и RSS. По умолчанию: `SITE_NAME`. | | `DEFAULT_LANG` | Язык интерфейса и язык поста по умолчанию: `en` или `ru`. По умолчанию `en`. | | `GITHUB_URL` | Ссылка в верхнем баре. | | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Доступ к бакету. `S3_ENDPOINT` только `https://`. `S3_REGION` по умолчанию: `garage`. | @@ -96,8 +97,8 @@ | `POPULAR_DAYS`, `POPULAR_LIMIT` | Окно и размер «Популярных». По умолчанию: `30`, `5`. | | `PAGE_SIZE` | Постов на страницу в «Новых» и на страницах тегов. По умолчанию: `10`. | | `RSS_LIMIT` | Постов в RSS. По умолчанию: `20`. | -| `DEFAULT_OG_IMAGE` | Картинка превью для постов без `cover`. | -| `INDEXNOW_KEY` | Необязательно. Включает пинги IndexNow. | +| `DEFAULT_OG_IMAGE` | Необязательно. Картинка превью для страниц и постов без `cover`: файл внутри `assets/`, правила как у `cover`. | +| `INDEXNOW_KEY` | Необязательно. Включает пинги IndexNow. 8–128 символов `a-z A-Z 0-9 -`. Отдаётся на `/.txt`. | | `RATE_LIMIT_SEARCH` | Лимит поиска на клиента. По умолчанию: `30/1m`. | | `VISITOR_SECRET` | HMAC-ключ для суточного хеша посетителя, ≥ 32 байт. | | `ALTCHA_EXPIRES`, `ALTCHA_MAX_NUMBER` | Срок жизни и сложность задачи. По умолчанию: `5m`, `100000`. | @@ -189,6 +190,7 @@ draft: false - Нет `title` или `date`, или YAML не парсится → пост пропускается, лог WARN. - Неверные `date`, `updated`, `slug`, `lang` (не две строчные буквы), `cover` (не разрешённый файл в `assets/`) или `draft` → пост пропускается, лог WARN. - Slug из имени файла: строчные буквы, русские буквы транслитерируются (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` и `ь` убираются), любая другая последовательность символов → `-`. Якоря заголовков строятся так же. +- `tags`: приводятся к нижнему регистру (`Go` → `go`), дубли сливаются. Буквы любого алфавита, цифры, `-`, `_`, `/`; 1–50 символов каждый; не больше 20 на пост. Иное → пост пропускается, лог WARN. - Два поста с одним `slug` → публикуется первый по ключу объекта, второй пропускается, лог ERROR. - Неизвестные поля игнорируются. Obsidian может добавлять свои. - README описывает все поля этой таблицы. @@ -288,6 +290,7 @@ Garage не шлёт уведомления об изменениях в бак - Сниппеты с подсветкой совпадений. - Работает без JS: `GET /search?q=…` рендерит результаты. С JS: результаты обновляются при наборе. - Фильтр по тегу. +- У страницы результатов `noindex`. Отклонено: Meilisearch и подобные. Опечатки ловит лучше, но это отдельный сервис. @@ -417,7 +420,8 @@ Garage не шлёт уведомления об изменениях в бак | `/metrics` | GET | приватная сеть | блок в Caddy, `METRICS_ALLOW` | | `/healthz` | GET | публичный | постоянный ответ, без данных | | `/prefs` | POST | публичный | тело формы ≤ 4 КБ, проверка перечислений `theme` / `lang`, редирект только на локальный путь | -| `/static/*` | GET | публичный | только встроенные CSS и шрифты, без листинга папок | +| `/static/*` | GET | публичный | только встроенные CSS, JS и шрифты, без листинга папок | +| `/.txt` | GET | публичный | только если задан `INDEXNOW_KEY`; в теле — ключ | Другие методы → `405`. Неизвестные пути → `404`. @@ -610,3 +614,4 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-03 — Значения по умолчанию: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Компонент логов `main`. Сервис слушает фиксированный порт 8080. - 2026-11-03 — Синхронизация: клиент S3 `minio-go/v7`, frontmatter через `go.yaml.in/yaml/v3`, GFM. Состояние зеркала в `VAULT_DIR/.hyperhack.json`. S3 недоступен при старте: работаем из зеркала; выход с кодом 1 только без зеркала. Без префикса Remotely Save. Ключи с сегментом на `.` или `_` игнорируются (было предложено). `MAX_OBJECTS` считает все ключи. Неверное поле frontmatter — пост пропускается. 3 попытки S3 за опрос. `/reindex` ждёт синхронизацию и возвращает `204`. - 2026-11-04 — Страницы: тема и язык переключаются формой без JS `POST /prefs`; cookie `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 год`, `Secure` на https). Логотип из `assets/logo.svg`, иначе встроенный знак. Шрифты Inter + JetBrains Mono, свои. Пагинация `/?page=N`: страница 1 — это `/`, другие значения вне диапазона → `404`. `` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`. +- 2026-11-04 — Поиск, теги, RSS, SEO: Bleve `v2.6.1`, индекс в памяти, своя схема документа на каждый язык (`en`, `ru`). Опечатки по длине слова: ≤ 3 букв 0, ≤ 6 букв 1, длиннее 2; префикс по последнему слову. В сниппетах остаётся только ``. Поиск работает без JS; `search.js` подменяет результаты при наборе (с задержкой). У страницы результатов `noindex`. Теги в нижнем регистре, символы как в Obsidian, `/tags/` с пагинацией как на главной. 24 оттенка для тегов (хеш FNV-1a), контраст проверяется в тестах. RSS: `dc:creator` из `AUTHOR_NAME`, `atom:updated` из `updated`. `width`/`height` картинок читаются из зеркала (`golang.org/x/image` для WebP); изменение ассета перерисовывает все посты. IndexNow: один пинг за синхронизацию с URL новых, изменённых и удалённых постов, не при первой загрузке индекса. Новая `AUTHOR_NAME`; `DEFAULT_OG_IMAGE` — путь внутри `assets/`. diff --git a/go.mod b/go.mod index a9aac63..f579542 100644 --- a/go.mod +++ b/go.mod @@ -6,32 +6,59 @@ toolchain go1.27.3 require ( github.com/alecthomas/chroma/v2 v2.27.0 + github.com/blevesearch/bleve/v2 v2.6.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.4.1 github.com/yuin/goldmark v1.8.7 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.yaml.in/yaml/v3 v3.0.6 + golang.org/x/image v0.47.0 golang.org/x/net v0.62.0 ) require ( + github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/blevesearch/bleve_index_api v1.4.1 // indirect + github.com/blevesearch/geo v0.2.6 // indirect + github.com/blevesearch/go-faiss v1.1.5 // indirect + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect + github.com/blevesearch/gtreap v0.1.1 // indirect + github.com/blevesearch/mmap-go v1.2.0 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.4.10 // indirect + github.com/blevesearch/segment v0.9.1 // indirect + github.com/blevesearch/snowballstem v0.9.0 // indirect + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect + github.com/blevesearch/vellum v1.2.0 // indirect + github.com/blevesearch/zapx/v11 v11.4.3 // indirect + github.com/blevesearch/zapx/v12 v12.4.3 // indirect + github.com/blevesearch/zapx/v13 v13.4.3 // indirect + github.com/blevesearch/zapx/v14 v14.4.3 // indirect + github.com/blevesearch/zapx/v15 v15.4.3 // indirect + github.com/blevesearch/zapx/v16 v16.3.4 // indirect + github.com/blevesearch/zapx/v17 v17.2.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect + go.etcd.io/bbolt v1.4.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.3 // indirect ) diff --git a/go.sum b/go.sum index f651301..7983681 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw= +github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= @@ -8,6 +10,46 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= +github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blevesearch/bleve/v2 v2.6.1 h1:47vLskRTqxvQEtxVPYHjf5KpOgzD2msslXFjvUQCgWQ= +github.com/blevesearch/bleve/v2 v2.6.1/go.mod h1:Dvvx6ZoEBTOj6RSzfk0lEz0wce/qhe2yOUubXeuzd2c= +github.com/blevesearch/bleve_index_api v1.4.1 h1:CYIyecFlI+/RYjzUm+NmDjYbSvk870Bb7f+Vl4b12q8= +github.com/blevesearch/bleve_index_api v1.4.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.6 h1:7K1oyQKYlauC+mJuo2AfNPyjN/4mihEoJMfyClVH1Mo= +github.com/blevesearch/geo v0.2.6/go.mod h1:6qzVUiB4BK47QkSZcRqiXEP2W3EeXuzM5XFTF8AdZ8A= +github.com/blevesearch/go-faiss v1.1.5 h1:/IU5lkOahH9Ghfk9n3F6N0XD7PYVXZJWmNDc9TtXuco= +github.com/blevesearch/go-faiss v1.1.5/go.mod h1:w3W9AiWsFRGVaMG+/cmJi7iHEAuGyC6blsgO1EzCK/M= +github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= +github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= +github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= +github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= +github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI= +github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0= +github.com/blevesearch/scorch_segment_api/v2 v2.4.10 h1:C3873+iWZ0YJM2ijaSHhJJzSvD4x1k+5UaQdGygZVhM= +github.com/blevesearch/scorch_segment_api/v2 v2.4.10/go.mod h1:WUUkAocbkDlNK/kgAE13NvS9oxe+u618mYZ8sOvcCc4= +github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= +github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= +github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= +github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= +github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0= +github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k= +github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY= +github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo= +github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE= +github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM= +github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU= +github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0= +github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg= +github.com/blevesearch/zapx/v17 v17.2.3 h1:UYYJPAt5b2tVxldx5h0jmv23RMsg8/UZKFVya7v92po= +github.com/blevesearch/zapx/v17 v17.2.3/go.mod h1:r7mb4QWbDQSkbAnOjCb9iCfkcrzajB4yBdJpuBIo/fE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -18,12 +60,20 @@ github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede h1:YrgBGwxMRK0Vq0WSCWFaZUnTsrA/PZE/xs1QZh+/edg= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -39,6 +89,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w= github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -66,16 +118,24 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM= go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= diff --git a/internal/config/config.go b/internal/config/config.go index a36ee95..71ece99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,9 +7,12 @@ import ( "log/slog" "net/netip" "net/url" + "regexp" "strconv" "strings" "time" + + "github.com/Mimic890/hyperhack/internal/mirror" ) // Rate is a request limit: N requests per Per. @@ -21,6 +24,7 @@ type Rate struct { // Config holds all settings. Secrets are never logged; use Summary for logs. type Config struct { SiteName string + AuthorName string SiteURL *url.URL DefaultLang string GitHubURL string @@ -72,6 +76,8 @@ type Config struct { const minSecretLen = 32 +var indexNowKeyRe = regexp.MustCompile(`^[a-zA-Z0-9-]{8,128}$`) + // privateRanges is the default for METRICS_ALLOW and REINDEX_ALLOW: loopback and private ranges. const privateRanges = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7" @@ -81,6 +87,7 @@ func Load(getenv func(string) string) (*Config, error) { l := loader{getenv: getenv} c := &Config{ SiteName: l.required("SITE_NAME"), + AuthorName: l.str("AUTHOR_NAME", strings.TrimSpace(getenv("SITE_NAME"))), SiteURL: l.url("SITE_URL", true, "http", "https"), DefaultLang: l.enum("DEFAULT_LANG", "en", "en", "ru"), GitHubURL: l.optionalURL("GITHUB_URL"), @@ -118,8 +125,8 @@ func Load(getenv func(string) string) (*Config, error) { PageSize: l.int("PAGE_SIZE", 10, 1, 100), RSSLimit: l.int("RSS_LIMIT", 20, 1, 1000), - DefaultOGImage: l.str("DEFAULT_OG_IMAGE", ""), - IndexNowKey: l.str("INDEXNOW_KEY", ""), + DefaultOGImage: l.asset("DEFAULT_OG_IMAGE"), + IndexNowKey: l.match("INDEXNOW_KEY", indexNowKeyRe, "8–128 characters a-z, A-Z, 0-9 or -"), MetricsAllow: l.prefixes("METRICS_ALLOW", privateRanges), ReindexAllow: l.prefixes("REINDEX_ALLOW", privateRanges), @@ -248,6 +255,28 @@ func (l *loader) url(name string, required bool, schemes ...string) *url.URL { return nil } +// asset reads an optional key inside assets/ (same rule as a post cover). +func (l *loader) asset(name string) string { + v := strings.TrimPrefix(l.str(name, ""), "/") + if v == "" { + return "" + } + if k, err := mirror.Classify(v); err != nil || k != mirror.Asset { + l.fail(name, "must be a file inside assets/ with an allowed extension") + return "" + } + return v +} + +func (l *loader) match(name string, re *regexp.Regexp, rule string) string { + v := l.str(name, "") + if v != "" && !re.MatchString(v) { + l.fail(name, "must be %s", rule) + return "" + } + return v +} + func (l *loader) optionalURL(name string) string { if u := l.url(name, false, "https"); u != nil { return u.String() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 929d65b..95b38fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,6 +30,14 @@ func TestLoadDefaults(t *testing.T) { if err != nil { t.Fatal(err) } + if c.AuthorName != "Example" || c.DefaultOGImage != "" || c.IndexNowKey != "" { + t.Errorf("author %q, og %q, indexnow %q", c.AuthorName, c.DefaultOGImage, c.IndexNowKey) + } + env := validEnv() + env["AUTHOR_NAME"], env["DEFAULT_OG_IMAGE"], env["INDEXNOW_KEY"] = "Jane", "/assets/og.png", "abcdef0123456789" + if c2, err := load(env); err != nil || c2.AuthorName != "Jane" || c2.DefaultOGImage != "assets/og.png" || c2.IndexNowKey != "abcdef0123456789" { + t.Errorf("custom = %+v, %v", c2, err) + } if c.SiteURL.String() != "https://example.com" { t.Errorf("SiteURL = %s", c.SiteURL) } @@ -52,19 +60,21 @@ func TestLoadDefaults(t *testing.T) { func TestLoadErrors(t *testing.T) { cases := map[string]string{ - "SITE_NAME": "", - "SITE_URL": "ftp://example.com", - "S3_ENDPOINT": "http://s3.example.com", - "REINDEX_TOKEN": "short", - "ALTCHA_HMAC_KEY": "", - "DEFAULT_LANG": "de", - "LOG_LEVEL": "loud", - "LOG_FORMAT": "xml", - "POLL_INTERVAL": "1s", - "PAGE_SIZE": "0", - "RATE_LIMIT_VOTE": "10", - "METRICS_ALLOW": "10.0.0.0/33", - "GITHUB_URL": "javascript:alert(1)", + "SITE_NAME": "", + "SITE_URL": "ftp://example.com", + "S3_ENDPOINT": "http://s3.example.com", + "REINDEX_TOKEN": "short", + "ALTCHA_HMAC_KEY": "", + "DEFAULT_LANG": "de", + "LOG_LEVEL": "loud", + "LOG_FORMAT": "xml", + "POLL_INTERVAL": "1s", + "PAGE_SIZE": "0", + "RATE_LIMIT_VOTE": "10", + "METRICS_ALLOW": "10.0.0.0/33", + "GITHUB_URL": "javascript:alert(1)", + "DEFAULT_OG_IMAGE": "../etc/og.png", + "INDEXNOW_KEY": "short", } for name, val := range cases { t.Run(name, func(t *testing.T) { diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 2e3ac66..3c741f8 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -40,11 +40,11 @@ func TestParse(t *testing.T) { } func TestParseDefaults(t *testing.T) { - p, err := NewRenderer("ru").Parse("Мой Первый Пост!.md", []byte("---\ntitle: T\ndate: 2026-11-03\ntags: go\ndraft: true\n---\n")) + p, err := NewRenderer("ru").Parse("Мой Первый Пост!.md", []byte("---\ntitle: T\ndate: 2026-11-03\ntags: [Go, go, Заметки, dev/go-1_2]\ndraft: true\n---\n")) if err != nil { t.Fatal(err) } - if p.Slug != "moy-pervyy-post" || p.Lang != "ru" || !p.Draft || len(p.Tags) != 1 || p.Tags[0] != "go" { + if p.Slug != "moy-pervyy-post" || p.Lang != "ru" || !p.Draft || strings.Join(p.Tags, ",") != "go,заметки,dev/go-1_2" || len(p.Source) == 0 { t.Errorf("got %+v", p) } } @@ -65,6 +65,10 @@ func TestParseErrors(t *testing.T) { "cover wikilink": "---\ntitle: T\ndate: 2026-11-03\ncover: \"[[x.png]]\"\n---\n", "cover ext": "---\ntitle: T\ndate: 2026-11-03\ncover: assets/x.html\n---\n", "bad draft": "---\ntitle: T\ndate: 2026-11-03\ndraft: maybe\n---\n", + "tag space": "---\ntitle: T\ndate: 2026-11-03\ntags: [a b]\n---\n", + "tag html": "---\ntitle: T\ndate: 2026-11-03\ntags: ['']\n---\n", + "tag long": "---\ntitle: T\ndate: 2026-11-03\ntags: [" + strings.Repeat("x", 51) + "]\n---\n", + "too many tags": "---\ntitle: T\ndate: 2026-11-03\ntags: [a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u]\n---\n", "huge": "---\ntitle: T\n" + strings.Repeat("x: y\n", maxFrontmatter/5+1) + "---\n", } r := NewRenderer("en") @@ -96,6 +100,12 @@ func TestSlugify(t *testing.T) { func TestRender(t *testing.T) { r := NewRenderer("en") + r.ImageSize = func(key string) (int, int) { + if key == "assets/a b.png" { + return 640, 360 + } + return 0, 0 + } src := "# Привет мир\n\n## Привет мир\n\n```go\nfunc main() {}\n```\n\n" + "[ext](https://example.com) [int](/posts/x) [file](assets/doc.png) \n\n" + "![alt](assets/a%20b.png)\n" @@ -112,7 +122,7 @@ func TestRender(t *testing.T) { `int`, `file`, ``, - `alt`, + `alt`, } { if !strings.Contains(string(got), want) { t.Errorf("missing %s in\n%s", want, got) diff --git a/internal/content/index.go b/internal/content/index.go index 2564854..766fb2b 100644 --- a/internal/content/index.go +++ b/internal/content/index.go @@ -2,14 +2,20 @@ package content import ( "log/slog" + "maps" "slices" "strings" + + "github.com/blevesearch/bleve/v2" ) // Index is an immutable set of published posts. type Index struct { posts []*Post // newest first bySlug map[string]*Post + byTag map[string][]*Post // newest first + tags []string // sorted + search bleve.Index Logo bool // assets/logo.svg is in the mirror } @@ -31,9 +37,26 @@ func NewIndex(posts []*Post, log *slog.Logger) *Index { ix.posts = append(ix.posts, p) } slices.SortStableFunc(ix.posts, func(a, b *Post) int { return b.Date.Compare(a.Date) }) + ix.byTag = map[string][]*Post{} + for _, p := range ix.posts { + for _, t := range p.Tags { + ix.byTag[t] = append(ix.byTag[t], p) + } + } + ix.tags = slices.Sorted(maps.Keys(ix.byTag)) + var err error + if ix.search, err = newSearchIndex(ix.posts); err != nil { + panic(err) // the mapping is static, so this is a bug, not bad input + } return ix } +// Tag returns published posts with this tag, newest first. +func (ix *Index) Tag(tag string) []*Post { return ix.byTag[tag] } + +// Tags returns all tags, sorted. +func (ix *Index) Tags() []string { return ix.tags } + // Get returns the post with this slug, or nil. func (ix *Index) Get(slug string) *Post { return ix.bySlug[slug] } diff --git a/internal/content/post.go b/internal/content/post.go index 177b4f9..74b359c 100644 --- a/internal/content/post.go +++ b/internal/content/post.go @@ -8,10 +8,12 @@ import ( "html/template" "path" "regexp" + "slices" "strings" "time" "go.yaml.in/yaml/v3" + "golang.org/x/net/html" "github.com/Mimic890/hyperhack/internal/mirror" ) @@ -24,12 +26,16 @@ type Post struct { Description string Date time.Time Updated time.Time // zero if not set - Tags []string - Cover string // key inside assets/, empty if not set + Tags []string // lower case, unique + Cover string // key inside assets/, empty if not set + CoverW int // cover size in pixels, 0 if unknown + CoverH int Lang string Draft bool Words int // body word count, for reading time HTML template.HTML // sanitized + Text string // plain text of HTML, for search + Source []byte // the raw .md file, served at /posts/.md } const maxFrontmatter = 64 << 10 @@ -37,6 +43,8 @@ const maxFrontmatter = 64 << 10 var ( slugRe = regexp.MustCompile(`^[a-z0-9-]{1,100}$`) langRe = regexp.MustCompile(`^[a-z]{2}$`) + // Obsidian tag characters: letters, digits, - _ /. + tagRe = regexp.MustCompile(`^[\p{L}\p{N}_/-]{1,50}$`) ) type frontmatter struct { @@ -98,10 +106,8 @@ func (r *Renderer) Parse(key string, src []byte) (*Post, error) { return nil, fmt.Errorf("updated: %w", err) } } - for _, t := range f.Tags { - if t = strings.TrimSpace(t); t != "" { - p.Tags = append(p.Tags, t) - } + if p.Tags, err = cleanTags(f.Tags); err != nil { + return nil, err } if f.Cover != "" { c := strings.TrimPrefix(f.Cover, "/") @@ -109,6 +115,9 @@ func (r *Renderer) Parse(key string, src []byte) (*Post, error) { return nil, errors.New("cover must be a file inside assets/") } p.Cover = c + if r.ImageSize != nil { + p.CoverW, p.CoverH = r.ImageSize(c) + } } if f.Lang != "" { if !langRe.MatchString(f.Lang) { @@ -132,9 +141,53 @@ func (r *Renderer) Parse(key string, src []byte) (*Post, error) { if p.HTML, err = r.render(body); err != nil { return nil, fmt.Errorf("render: %w", err) } + p.Text = plainText(string(p.HTML)) + p.Source = src return p, nil } +const maxTags = 20 + +// cleanTags lowercases tags and drops duplicates. A tag outside the Obsidian rules is an error. +func cleanTags(in []string) ([]string, error) { + var out []string + for _, t := range in { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "" || slices.Contains(out, t) { + continue + } + if !tagRe.MatchString(t) { + return nil, fmt.Errorf("tag %q: use 1-50 letters, digits, - _ or /", t) + } + out = append(out, t) + } + if len(out) > maxTags { + return nil, fmt.Errorf("more than %d tags", maxTags) + } + return out, nil +} + +// plainText returns the text nodes of s, separated by spaces. +func plainText(s string) string { + var b strings.Builder + z := html.NewTokenizer(strings.NewReader(s)) + for { + switch z.Next() { + case html.ErrorToken: + return strings.Join(strings.Fields(b.String()), " ") + case html.TextToken: + b.Write(z.Text()) + case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: + if name, _ := z.TagName(); !inlineTags[string(name)] { + b.WriteByte(' ') // block boundary: keep words apart + } + } + } +} + +// inlineTags do not split words: "x." stays "x.". +var inlineTags = map[string]bool{"a": true, "b": true, "code": true, "del": true, "em": true, "i": true, "mark": true, "s": true, "span": true, "strong": true, "sub": true, "sup": true} + // splitFrontmatter cuts the YAML block between the leading "---" lines. func splitFrontmatter(src []byte) (fm, body []byte, err error) { src = bytes.TrimPrefix(src, []byte("\ufeff")) diff --git a/internal/content/render.go b/internal/content/render.go index 2ec9262..da0dcca 100644 --- a/internal/content/render.go +++ b/internal/content/render.go @@ -23,11 +23,14 @@ type Renderer struct { md goldmark.Markdown policy *bluemonday.Policy defaultLang string + // ImageSize returns the pixel size of an assets/ key, or 0, 0. Nil means unknown. + ImageSize func(key string) (w, h int) } // NewRenderer builds the Markdown pipeline. defaultLang is used for posts without lang. func NewRenderer(defaultLang string) *Renderer { - md := goldmark.New( + r := &Renderer{defaultLang: defaultLang} + r.md = goldmark.New( // No goldmark.WithUnsafe: raw HTML and dangerous URLs are dropped. goldmark.WithExtensions( extension.GFM, @@ -35,14 +38,18 @@ func NewRenderer(defaultLang string) *Renderer { ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), - parser.WithASTTransformers(util.Prioritized(linkTransformer{}, 100)), + parser.WithASTTransformers(util.Prioritized(linkTransformer{r}, 100)), ), ) p := bluemonday.UGCPolicy() p.RequireNoFollowOnLinks(false) p.AllowAttrs("rel").Matching(regexp.MustCompile(`^noopener noreferrer$`)).OnElements("a") p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z0-9 -]+$`)).OnElements("pre", "code", "span") - return &Renderer{md: md, policy: p, defaultLang: defaultLang} + p.AllowAttrs("width", "height").Matching(regexp.MustCompile(`^[0-9]{1,6}$`)).OnElements("img") + p.AllowAttrs("loading").Matching(regexp.MustCompile(`^lazy$`)).OnElements("img") + p.AllowAttrs("decoding").Matching(regexp.MustCompile(`^async$`)).OnElements("img") + r.policy = p + return r } func (r *Renderer) render(src []byte) (template.HTML, error) { @@ -54,16 +61,26 @@ func (r *Renderer) render(src []byte) (template.HTML, error) { return template.HTML(r.policy.SanitizeBytes(buf.Bytes())), nil // safe: sanitized above } -// linkTransformer maps "assets/…" to "/assets/…" and marks external links. -type linkTransformer struct{} +// linkTransformer maps "assets/…" to "/assets/…", marks external links and sizes images. +type linkTransformer struct{ r *Renderer } -func (linkTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { +func (t linkTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } switch n := n.(type) { case *ast.Image: + if t.r.ImageSize != nil && bytes.HasPrefix(n.Destination, []byte("assets/")) { + if key, err := url.PathUnescape(string(n.Destination)); err == nil { + if w, h := t.r.ImageSize(key); w > 0 { + n.SetAttributeString("width", []byte(strconv.Itoa(w))) + n.SetAttributeString("height", []byte(strconv.Itoa(h))) + } + } + } + n.SetAttributeString("loading", []byte("lazy")) + n.SetAttributeString("decoding", []byte("async")) n.Destination = assetPath(n.Destination) case *ast.Link: n.Destination = assetPath(n.Destination) diff --git a/internal/content/search.go b/internal/content/search.go new file mode 100644 index 0000000..8f81408 --- /dev/null +++ b/internal/content/search.go @@ -0,0 +1,170 @@ +package content + +import ( + "html/template" + "strings" + "unicode" + "unicode/utf8" + + "github.com/blevesearch/bleve/v2" + _ "github.com/blevesearch/bleve/v2/analysis/lang/en" // register the "en" analyzer + _ "github.com/blevesearch/bleve/v2/analysis/lang/ru" // register the "ru" analyzer + "github.com/blevesearch/bleve/v2/search/query" + "github.com/microcosm-cc/bluemonday" +) + +// Search limits (SPEC → Input validation). +const ( + MaxQueryRunes = 200 + maxTerms = 10 + minPrefix = 2 + MaxSearchPage = 50 +) + +// analyzers are the post languages with their own analysis; other languages use "en". +var analyzers = []string{"en", "ru"} + +// fields and their boosts: title > tags > description > body. +var fields = []struct { + name string + boost float64 +}{{"title", 4}, {"tags", 3}, {"description", 2}, {"body", 1}} + +// searchDoc is what bleve indexes for a post. Type picks the language mapping. +type searchDoc struct { + Title string `json:"title"` + Tags []string `json:"tags"` + Description string `json:"description"` + Body string `json:"body"` + Tag []string `json:"tag"` // exact tags for the filter + lang string +} + +func (d searchDoc) Type() string { return d.lang } + +func newSearchIndex(posts []*Post) (bleve.Index, error) { + im := bleve.NewIndexMapping() + for _, lang := range analyzers { + dm := bleve.NewDocumentMapping() + for _, f := range fields { + fm := bleve.NewTextFieldMapping() + fm.Analyzer = lang + fm.Store = f.name != "tags" + fm.IncludeTermVectors = fm.Store // for highlighting + dm.AddFieldMappingsAt(f.name, fm) + } + kw := bleve.NewKeywordFieldMapping() + kw.Store = false + dm.AddFieldMappingsAt("tag", kw) + im.AddDocumentMapping(lang, dm) + } + im.DefaultType = "en" + im.DefaultMapping = im.TypeMapping["en"] + ix, err := bleve.NewMemOnly(im) + if err != nil { + return nil, err + } + b := ix.NewBatch() + for _, p := range posts { + lang := p.Lang + if lang != "ru" { + lang = "en" + } + doc := searchDoc{Title: p.Title, Tags: p.Tags, Description: p.Description, Body: p.Text, Tag: p.Tags, lang: lang} + if err := b.Index(p.Slug, doc); err != nil { + return nil, err + } + } + return ix, ix.Batch(b) +} + +// Hit is one search result. +type Hit struct { + Post *Post + Snippet template.HTML // escaped text with around matches +} + +var snippetPolicy = bluemonday.NewPolicy().AllowElements("mark") + +// Terms splits a query into lower-case words: at most MaxQueryRunes runes and maxTerms terms. +func Terms(q string) []string { + if utf8.RuneCountInString(q) > MaxQueryRunes { + q = string([]rune(q)[:MaxQueryRunes]) + } + terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) + return terms[:min(len(terms), maxTerms)] +} + +// fuzziness allows more typos in longer words, never more than 2. +func fuzziness(term string) int { + switch n := utf8.RuneCountInString(term); { + case n <= 3: + return 0 + case n <= 6: + return 1 + default: + return 2 + } +} + +// buildQuery makes match + fuzzy (+ prefix for the last term) queries in code. +// User input never reaches Bleve's query-string syntax, so no regex or wildcard queries. +func buildQuery(terms []string, tag string) query.Query { + var all []query.Query + for i, t := range terms { + var anyOf []query.Query + for _, f := range fields { + for _, a := range analyzers { + m := bleve.NewMatchQuery(t) + m.SetField(f.name) + m.Analyzer = a + m.SetFuzziness(fuzziness(t)) + m.SetBoost(f.boost) + anyOf = append(anyOf, m) + } + if i == len(terms)-1 && utf8.RuneCountInString(t) >= minPrefix { + p := bleve.NewPrefixQuery(t) + p.SetField(f.name) + p.SetBoost(f.boost / 2) + anyOf = append(anyOf, p) + } + } + all = append(all, bleve.NewDisjunctionQuery(anyOf...)) + } + if tag != "" { + tq := bleve.NewTermQuery(tag) + tq.SetField("tag") + all = append(all, tq) + } + return bleve.NewConjunctionQuery(all...) +} + +// Search finds posts for q, optionally only with tag. page starts at 1. +func (ix *Index) Search(q, tag string, page, size int) (hits []Hit, total int, err error) { + terms := Terms(q) + if len(terms) == 0 || page < 1 || page > MaxSearchPage { + return nil, 0, nil + } + req := bleve.NewSearchRequestOptions(buildQuery(terms, tag), size, (page-1)*size, false) + req.Highlight = bleve.NewHighlightWithStyle("html") + req.Highlight.Fields = []string{"body", "description"} + res, err := ix.search.Search(req) + if err != nil { + return nil, 0, err + } + for _, h := range res.Hits { + p := ix.bySlug[h.ID] + if p == nil { + continue + } + hit := Hit{Post: p} + for _, f := range req.Highlight.Fields { + if fr := h.Fragments[f]; len(fr) > 0 { + hit.Snippet = template.HTML(snippetPolicy.Sanitize(fr[0])) // safe: only survives + break + } + } + hits = append(hits, hit) + } + return hits, int(res.Total), nil +} diff --git a/internal/content/search_test.go b/internal/content/search_test.go new file mode 100644 index 0000000..8aa4d96 --- /dev/null +++ b/internal/content/search_test.go @@ -0,0 +1,140 @@ +package content + +import ( + "log/slog" + "strings" + "testing" + "time" +) + +func searchIndex(t testing.TB) *Index { + t.Helper() + r := NewRenderer("en") + src := map[string]string{ + "ghost.md": "---\ntitle: Leaving Ghost\ndescription: A lighter blog engine\ndate: 2026-11-03\ntags: [go, selfhost]\n---\nI replaced Ghost with a small Go binary that syncs from a bucket.\n", + "ru.md": "---\ntitle: Заметка о постах\ndate: 2026-11-02\nlang: ru\ntags: [заметки]\n---\nЗдесь много постов про синхронизацию и разметку.\n", + "xss.md": "---\ntitle: Escaping\ndate: 2026-11-01\n---\nThe word kubernetes next to `` in code.\n", + "go.md": "---\ntitle: Tags only\ndate: 2026-10-01\ntags: [go]\n---\nNothing about the topic in the body.\n", + } + var posts []*Post + for k, s := range src { + p, err := r.Parse(k, []byte(s)) + if err != nil { + t.Fatal(k, err) + } + posts = append(posts, p) + } + return NewIndex(posts, slog.New(slog.DiscardHandler)) +} + +func slugsOf(hits []Hit) string { + var s []string + for _, h := range hits { + s = append(s, h.Post.Slug) + } + return strings.Join(s, ",") +} + +func TestSearch(t *testing.T) { + ix := searchIndex(t) + tests := []struct{ q, tag, want string }{ + {"ghost", "", "ghost"}, + {"GHOST binary", "", "ghost"}, + {"ghist", "", "ghost"}, // one-letter typo + {"replcaed", "", "ghost"}, // two edits in a long word + {"bin", "", "ghost"}, // prefix while typing + {"пост", "", "ru"}, // Russian stemming: "постах", "постов" + {"постов", "", "ru"}, // and the other way round + {"синхронизация", "", "ru"}, // stem of "синхронизацию" + {"kubernetes", "", "xss"}, + {"go", "", "go,ghost"}, // tags outrank body; both have the tag + {"go", "selfhost", "ghost"}, // tag filter + {"ghost", "заметки", ""}, + {"nothingmatches", "", ""}, + {"", "", ""}, + {"!!! ???", "", ""}, + } + for _, tt := range tests { + hits, _, err := ix.Search(tt.q, tt.tag, 1, 10) + if err != nil { + t.Fatal(err) + } + got := slugsOf(hits) + if tt.q == "go" && tt.tag == "" { + if !strings.Contains(got, "go") || !strings.Contains(got, "ghost") { + t.Errorf("Search(%q) = %q, want both go posts", tt.q, got) + } + continue + } + if got != tt.want { + t.Errorf("Search(%q, %q) = %q, want %q", tt.q, tt.tag, got, tt.want) + } + } +} + +func TestSearchSnippetEscaped(t *testing.T) { + ix := searchIndex(t) + hits, _, _ := ix.Search("kubernetes", "", 1, 10) + if len(hits) != 1 { + t.Fatalf("hits = %d", len(hits)) + } + s := string(hits[0].Snippet) + if !strings.Contains(s, "kubernetes") || strings.Contains(s, " tips\ndescription: Short \"tips\" x\ndate: 2026-11-01\nupdated: 2026-11-03\ntags: [go, dev/tools]\ncover: assets/c.png\n---\nSee [home](/) and ![i](assets/i.png). Bleve search works.\n", + "zametka.md": "---\ntitle: Заметка\ndate: 2026-11-02\nlang: ru\ntags: [заметки, go]\n---\nМного постов.\n", + "d.md": "---\ntitle: Draft\ndate: 2026-11-04\ndraft: true\n---\nSecret.\n", + } { + p, err := r.Parse(key, []byte(src)) + if err != nil { + t.Fatal(key, err) + } + posts = append(posts, p) + } + cfg := testConfig() + cfg.AuthorName, cfg.GitHubURL, cfg.RSSLimit, cfg.PageSize = "Ann", "https://github.com/example", 10, 1 + cfg.DefaultOGImage, cfg.IndexNowKey = "assets/og.png", "key12345" + return newSite(t, cfg, content.NewIndex(posts, slog.New(slog.DiscardHandler))) +} + +func TestPostHead(t *testing.T) { + s := seoSite(t) + rec := s.get("/posts/go-script-tips") + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + body := rec.Body.String() + mustContain(t, body, + ``, + ``, + ``, + ``, + ``, + `href="/tags/dev/tools">#dev/tools`, + ) + m := regexp.MustCompile(`(?s)`).FindStringSubmatch(body) + if m == nil { + t.Fatal("no JSON-LD") + } + if strings.Contains(m[1], "<") { + t.Errorf("JSON-LD not escaped: %s", m[1]) + } + var ld map[string]any + if err := json.Unmarshal([]byte(m[1]), &ld); err != nil { + t.Fatalf("JSON-LD: %v", err) + } + if ld["@type"] != "BlogPosting" || ld["headline"] != "Go & tips" || ld["dateModified"] != "2026-11-03" || + ld["keywords"] != "go, dev/tools" || ld["author"].(map[string]any)["name"] != "Ann" || ld["inLanguage"] != "en" { + t.Errorf("JSON-LD = %v", ld) + } + + // No cover: DEFAULT_OG_IMAGE. + mustContain(t, s.get("/posts/zametka").Body.String(), ``, `", `href="/posts/zametka"`, `href="/tags/go?page=2"`, ``) + mustContain(t, s.get("/tags/go?page=2").Body.String(), `href="/posts/go-script-tips"`) + mustContain(t, s.get("/tags/%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8").Body.String(), "#заметки") + mustContain(t, s.get("/tags/dev/tools").Body.String(), "#dev/tools") + for _, path := range []string{"/tags/nope", "/tags/", "/tags/go?page=3", "/tags/go?page=1", "/tags/go/"} { + if rec := s.get(path); rec.Code != 404 { + t.Errorf("%s = %d, want 404", path, rec.Code) + } + } +} + +func TestSearchPage(t *testing.T) { + s := seoSite(t) + rec := s.get("/search?q=bleve") + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + body := rec.Body.String() + mustContain(t, body, ``, `Bleve`, `href="/posts/go-script-tips"`, + ``).Body.String() + if strings.Contains(xss, " tips](https://example.com/posts/go-script-tips.md): Short "tips" x`) + if rec := s.get("/key12345.txt"); rec.Code != 200 || rec.Body.String() != "key12345" { + t.Errorf("indexnow key = %d %q", rec.Code, rec.Body.String()) + } + if rec := s.get("/other.txt"); rec.Code != 404 { + t.Errorf("other.txt = %d", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a1c730..8b83d17 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,9 +21,21 @@ func New(log *slog.Logger, cfg *config.Config, ready *atomic.Bool, sync func(con p := newPages(log, cfg, index) mux := http.NewServeMux() mux.HandleFunc("GET /{$}", p.home) - mux.HandleFunc("GET /posts/{slug}", p.post) + mux.HandleFunc("GET /posts/{slug}", p.post) // also /posts/.md + mux.HandleFunc("GET /tags/{tag...}", p.tag) + mux.HandleFunc("GET /search", p.search) + mux.HandleFunc("GET /rss.xml", p.rss) + mux.HandleFunc("GET /sitemap.xml", p.sitemap) + mux.HandleFunc("GET /robots.txt", p.robots) + mux.HandleFunc("GET /llms.txt", p.llms) + if key := cfg.IndexNowKey; key != "" { // the config allows only [a-zA-Z0-9-] + mux.HandleFunc("GET /"+key+".txt", func(w http.ResponseWriter, _ *http.Request) { + writeText(w, "text/plain; charset=utf-8", key) + }) + } mux.HandleFunc("POST /prefs", p.prefs) mux.HandleFunc("GET /static/style.css", p.style) + mux.HandleFunc("GET /static/search.js", p.script) mux.HandleFunc("GET /static/fonts/{file}", p.font) mux.HandleFunc("GET /", p.notFound) mux.Handle("POST /reindex", reindex(log, cfg.ReindexToken, cfg.ReindexAllow, cfg.TrustedProxies, sync)) diff --git a/internal/server/tags.go b/internal/server/tags.go new file mode 100644 index 0000000..eaac1be --- /dev/null +++ b/internal/server/tags.go @@ -0,0 +1,81 @@ +package server + +import ( + "fmt" + "hash/fnv" + "math" + "net/url" + "strings" +) + +// tagHues is the number of tag color buckets, 15° of hue apart. +const tagHues = 24 + +// tagColor is one theme's chip palette: background and text lightness/saturation. +type tagColor struct{ bgS, bgL, fgS, fgL float64 } + +var ( + tagLight = tagColor{0.75, 0.93, 0.75, 0.26} + tagDark = tagColor{0.40, 0.20, 0.75, 0.82} +) + +// tagClass picks a stable color bucket from a hash of the tag name. +func tagClass(tag string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(tag)) + return fmt.Sprintf("t%d", h.Sum32()%tagHues) +} + +// tagPath is the escaped URL path of a tag page. Tags may contain "/". +func tagPath(tag string) string { return (&url.URL{Path: "/tags/" + tag}).EscapedPath() } + +// tagCSS writes the chip colors for every bucket, scoped per theme like style.css. +func tagCSS() []byte { + var b strings.Builder + rules := func(scope string, c tagColor) { + for i := range tagHues { + bg, fg := c.colors(float64(i * 360 / tagHues)) + fmt.Fprintf(&b, "%s.t%d { --tag-bg: %s; --tag-fg: %s; }\n", scope, i, cssHex(bg), cssHex(fg)) + } + } + rules("", tagLight) + b.WriteString("@media (prefers-color-scheme: dark) {\n") + rules(`:root:not([data-theme="light"]) `, tagDark) + b.WriteString("}\n") + rules(`:root[data-theme="dark"] `, tagDark) + return []byte(b.String()) +} + +func (c tagColor) colors(hue float64) (bg, fg [3]float64) { + return hsl(hue, c.bgS, c.bgL), hsl(hue, c.fgS, c.fgL) +} + +func hsl(h, s, l float64) [3]float64 { + k := func(n float64) float64 { + k := math.Mod(n+h/30, 12) + return l - s*min(l, 1-l)*max(-1, min(k-3, 9-k, 1)) + } + return [3]float64{k(0), k(8), k(4)} +} + +func cssHex(c [3]float64) string { + return fmt.Sprintf("#%02x%02x%02x", byte(math.Round(c[0]*255)), byte(math.Round(c[1]*255)), byte(math.Round(c[2]*255))) +} + +// contrast is the WCAG 2 contrast ratio, on the 8-bit colors that reach the CSS. +func contrast(a, b [3]float64) float64 { + lum := func(c [3]float64) float64 { + var v [3]float64 + for i, x := range c { + x = math.Round(x*255) / 255 + if x <= 0.04045 { + v[i] = x / 12.92 + } else { + v[i] = math.Pow((x+0.055)/1.055, 2.4) + } + } + return 0.2126*v[0] + 0.7152*v[1] + 0.0722*v[2] + } + la, lb := lum(a), lum(b) + return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) +} diff --git a/internal/server/tags_test.go b/internal/server/tags_test.go new file mode 100644 index 0000000..7266923 --- /dev/null +++ b/internal/server/tags_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "strings" + "testing" +) + +func TestTagContrast(t *testing.T) { + for name, c := range map[string]tagColor{"light": tagLight, "dark": tagDark} { + for i := range tagHues { + bg, fg := c.colors(float64(i * 360 / tagHues)) + if r := contrast(bg, fg); r < 4.5 { + t.Errorf("%s bucket %d: contrast %.2f < 4.5 (%s on %s)", name, i, r, cssHex(fg), cssHex(bg)) + } + } + } + if r := contrast([3]float64{0, 0, 0}, [3]float64{1, 1, 1}); r < 20.9 || r > 21.1 { + t.Errorf("black on white = %.2f, want 21", r) + } +} + +func TestTagClassAndPath(t *testing.T) { + if a, b := tagClass("go"), tagClass(strings.Clone("go")); a != b || !strings.HasPrefix(tagClass("заметки"), "t") { + t.Error("tagClass not stable") + } + if got := tagPath("dev/заметки"); got != "/tags/dev/%D0%B7%D0%B0%D0%BC%D0%B5%D1%82%D0%BA%D0%B8" { + t.Errorf("tagPath = %s", got) + } + css := string(tagCSS()) + if !strings.Contains(css, ".t23 {") || !strings.Contains(css, `:root[data-theme="dark"] .t0 {`) { + t.Error("tagCSS missing buckets") + } +} diff --git a/internal/server/web/i18n/en.json b/internal/server/web/i18n/en.json index 335271d..b33e3ec 100644 --- a/internal/server/web/i18n/en.json +++ b/internal/server/web/i18n/en.json @@ -32,5 +32,14 @@ "month_9": "September", "month_10": "October", "month_11": "November", - "month_12": "December" + "month_12": "December", + "search": "Search", + "search_placeholder": "Search posts", + "tag_filter": "Filter by tag", + "all_tags": "All tags", + "found": "Found: {n}", + "no_results": "Nothing found. Try other words.", + "results": "Search results", + "prev": "Previous", + "next": "Next" } diff --git a/internal/server/web/i18n/ru.json b/internal/server/web/i18n/ru.json index 38ad33f..dd5cf35 100644 --- a/internal/server/web/i18n/ru.json +++ b/internal/server/web/i18n/ru.json @@ -32,5 +32,14 @@ "month_9": "сентября", "month_10": "октября", "month_11": "ноября", - "month_12": "декабря" + "month_12": "декабря", + "search": "Поиск", + "search_placeholder": "Искать в постах", + "tag_filter": "Фильтр по тегу", + "all_tags": "Все теги", + "found": "Найдено: {n}", + "no_results": "Ничего не найдено. Попробуйте другие слова.", + "results": "Результаты поиска", + "prev": "Назад", + "next": "Дальше" } diff --git a/internal/server/web/static/search.js b/internal/server/web/static/search.js new file mode 100644 index 0000000..cda922c --- /dev/null +++ b/internal/server/web/static/search.js @@ -0,0 +1,29 @@ +// Updates search results while typing. The form works the same without this script. +(() => { + const form = document.getElementById("search"); + const results = document.getElementById("results"); + const status = document.getElementById("status"); + if (!form || !results || !status) return; + let timer, ctrl; + const run = () => { + const url = "/search?" + new URLSearchParams(new FormData(form)); + if (ctrl) ctrl.abort(); + ctrl = new AbortController(); + fetch(url, { signal: ctrl.signal }) + .then((r) => (r.ok ? r.text() : Promise.reject(r.status))) + .then((html) => { + const doc = new DOMParser().parseFromString(html, "text/html"); + const next = doc.getElementById("results"); + if (!next) return; + results.replaceChildren(...next.childNodes); + status.textContent = doc.getElementById("status").textContent; + document.title = doc.title; + history.replaceState(null, "", url); + }) + .catch(() => {}); // aborted or failed: keep the current results + }; + form.addEventListener("input", () => { + clearTimeout(timer); + timer = setTimeout(run, 250); + }); +})(); diff --git a/internal/server/web/static/style.css b/internal/server/web/static/style.css index 1cc5325..e5d361c 100644 --- a/internal/server/web/static/style.css +++ b/internal/server/web/static/style.css @@ -133,6 +133,27 @@ main { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; } .pager a { text-decoration: none; font-weight: 600; } .pager [rel="next"] { justify-self: end; } +/* Tags: colors per hash bucket (.t0–.t23) are appended at startup. */ +.tags { display: flex; flex-wrap: wrap; gap: 0.375rem; margin: 0.625rem 0 0; padding: 0; list-style: none; } +.tag { + display: inline-block; padding: 0.125rem 0.55rem; border-radius: 999px; + background: var(--tag-bg, var(--surface)); color: var(--tag-fg, var(--text)); + font-size: 0.8125rem; font-weight: 550; line-height: 1.6; text-decoration: none; +} +.tag:hover { text-decoration: underline; text-decoration-thickness: 1px; } + +/* Search */ +.search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 2rem; } +.search input, .search select, .search button { + height: 2.5rem; padding: 0 0.875rem; border: 1px solid var(--border); border-radius: 10px; + background: var(--bg); color: var(--text); font: inherit; font-size: 0.9375rem; +} +.search input { flex: 1 1 14rem; min-width: 0; } +.search select { flex: 0 1 auto; max-width: 12rem; } +.search button { background: var(--accent-soft); color: var(--accent); font-weight: 600; cursor: pointer; } +#status { margin: -1rem 0 1.25rem; } +.snippet mark { background: var(--accent-soft); color: inherit; border-radius: 3px; padding: 0 0.1em; } + /* Post page */ .post > header h1 { margin: 0; font-size: clamp(1.875rem, 4vw + 1rem, 2.625rem); line-height: 1.15; font-weight: 700; letter-spacing: -0.025em; } .post > header .meta { margin-top: 0.75rem; } @@ -163,6 +184,8 @@ code, pre { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, .topbar { padding: 0.75rem 1rem; } main { padding: 1.75rem 1rem 3rem; } .brand span { font-size: 1rem; } + .search input { flex-basis: 100%; } + .search select { flex: 1; max-width: none; } } @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } diff --git a/internal/server/web/templates/home.html b/internal/server/web/templates/home.html index cd28038..f4dd834 100644 --- a/internal/server/web/templates/home.html +++ b/internal/server/web/templates/home.html @@ -1,22 +1,13 @@ {{define "title"}}{{.Site.Name}}{{if gt .Page 1}} — {{t "page_of" "n" .Page "m" .Pages}}{{end}}{{end}} {{define "main"}} +{{template "searchform" .}}

{{t "newest"}}

{{- range .Posts}} -
-

{{.Title}}

-

{{template "date" .Date}} · {{t "min_read" "n" (minutes .Words)}}

- {{- with .Description}}

{{.}}

{{end}} -
+ {{template "card" .}} {{- else}}

{{t "no_posts"}}

{{- end}}
-{{- if gt .Pages 1}} - -{{- end}} +{{- template "pager" .}} {{end}} diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html index 2be7447..cdd1121 100644 --- a/internal/server/web/templates/layout.html +++ b/internal/server/web/templates/layout.html @@ -5,7 +5,38 @@ {{block "title" .}}{{.Site.Name}}{{end}} +{{- with .Desc}} + +{{- end}} +{{- if .NoIndex}} + +{{- end}} +{{- with .Canonical}} + +{{- end}} + + + + +{{- with .Desc}} + +{{- end}} +{{- with .Canonical}} + +{{- end}} +{{- with .Image}} + + +{{- else}} + +{{- end}} +{{- with .JSONLD}} + +{{- end}} +{{- with .JS}} + +{{- end}} @@ -37,3 +68,24 @@ {{define "date"}}{{end}} +{{define "tags"}}{{with .}}{{end}}{{end}} +{{define "card"}}
+

{{.Title}}

+

{{template "date" .Date}} · {{t "min_read" "n" (minutes .Words)}}

+ {{- with .Description}}

{{.}}

{{end}} + {{- template "tags" .Tags}} +
{{end}} +{{define "pager"}}{{if gt .Pages 1}} + +{{- end}}{{end}} +{{define "searchform"}}{{end}} diff --git a/internal/server/web/templates/post.html b/internal/server/web/templates/post.html index 9bdf61f..2e81de1 100644 --- a/internal/server/web/templates/post.html +++ b/internal/server/web/templates/post.html @@ -1,17 +1,20 @@ {{define "title"}}{{.Post.Title}} — {{.Site.Name}}{{end}} {{define "main"}} +{{- with .Post}}
-

{{.Post.Title}}

-

{{template "date" .Post.Date}} - {{- if not .Post.Updated.IsZero}} · {{t "updated" "date" (date .Post.Updated)}}{{end}} - · {{t "min_read" "n" (minutes .Post.Words)}}

+

{{.Title}}

+

{{template "date" .Date}} + {{- if not .Updated.IsZero}} · {{t "updated" "date" (date .Updated)}}{{end}} + · {{t "min_read" "n" (minutes .Words)}}

+ {{- template "tags" .Tags}}
- {{- with .Post.Cover}} - + {{- if .Cover}} + {{- end}}
-{{.Post.HTML}} +{{.HTML}}
+{{- end}} {{end}} diff --git a/internal/server/web/templates/search.html b/internal/server/web/templates/search.html new file mode 100644 index 0000000..5e2ffea --- /dev/null +++ b/internal/server/web/templates/search.html @@ -0,0 +1,19 @@ +{{define "title"}}{{with .Query}}{{.}} — {{end}}{{t "search"}} — {{.Site.Name}}{{end}} +{{define "main"}} +

{{t "search"}}

+{{template "searchform" .}} +

{{if .Query}}{{t "found" "n" .Total}}{{end}}

+
+ {{- range .Hits}} +
+

{{.Post.Title}}

+

{{template "date" .Post.Date}}

+ {{- if .Snippet}}

{{.Snippet}}

{{else}}{{with .Post.Description}}

{{.}}

{{end}}{{end}} + {{- template "tags" .Post.Tags}} +
+ {{- else}}{{if .Query}} +

{{t "no_results"}}

+ {{- end}}{{end}} + {{- template "pager" .}} +
+{{end}} diff --git a/internal/server/web/templates/tag.html b/internal/server/web/templates/tag.html new file mode 100644 index 0000000..efeb993 --- /dev/null +++ b/internal/server/web/templates/tag.html @@ -0,0 +1,10 @@ +{{define "title"}}#{{.Tag}} — {{.Site.Name}}{{if gt .Page 1}} — {{t "page_of" "n" .Page "m" .Pages}}{{end}}{{end}} +{{define "main"}} +
+

#{{.Tag}}

+ {{- range .Posts}} + {{template "card" .}} + {{- end}} +
+{{- template "pager" .}} +{{end}} diff --git a/internal/syncer/indexnow.go b/internal/syncer/indexnow.go new file mode 100644 index 0000000..cc88265 --- /dev/null +++ b/internal/syncer/indexnow.go @@ -0,0 +1,66 @@ +package syncer + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "time" + + "github.com/Mimic890/hyperhack/internal/content" +) + +const ( + indexNowURL = "https://api.indexnow.org/indexnow" + maxIndexNow = 10000 // IndexNow limit per request +) + +// changedURLs lists posts that are new, changed or removed between two indexes. +func (s *Syncer) changedURLs(old, cur *content.Index) []string { + base := s.opts.SiteURL.String() + "/posts/" + var urls []string + for _, p := range cur.Posts() { + if o := old.Get(p.Slug); o == nil || !bytes.Equal(o.Source, p.Source) { + urls = append(urls, base+p.Slug) + } + } + for _, o := range old.Posts() { + if cur.Get(o.Slug) == nil { + urls = append(urls, base+o.Slug) + } + } + return urls[:min(len(urls), maxIndexNow)] // shortcut: drops URLs past 10000 in one sync, batch if a vault ever gets that big +} + +// indexNow tells search engines about changed URLs. Failures are logged and not retried. +func (s *Syncer) indexNow(urls []string) { + body, err := json.Marshal(map[string]any{ + "host": s.opts.SiteURL.Host, + "key": s.opts.IndexNowKey, + "keyLocation": s.opts.SiteURL.String() + "/" + s.opts.IndexNowKey + ".txt", + "urlList": urls, + }) + if err != nil { + s.log.Warn("indexnow", "err", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.indexNowURL, bytes.NewReader(body)) + if err != nil { + s.log.Warn("indexnow", "err", err) + return + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.log.Warn("indexnow ping failed", "err", err) + return + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + s.log.Warn("indexnow ping rejected", "status", resp.StatusCode, "urls", len(urls)) + return + } + s.log.Info("indexnow ping sent", "urls", len(urls)) +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 4905e0d..b01b438 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "net/url" "sync" "sync/atomic" "time" @@ -24,6 +25,8 @@ type Options struct { MaxPostBytes int64 MaxAssetBytes int64 MaxObjects int + SiteURL *url.URL + IndexNowKey string // empty: no IndexNow pings } // Syncer runs one sync at a time; Sync calls wait for the running one. @@ -36,6 +39,8 @@ type Syncer struct { rlog *slog.Logger // component render retry []time.Duration + indexNowURL string + mu sync.Mutex cache map[string]cached // post key → last parse result warned map[string]string // key → ETag already warned about, so polls do not repeat WARNs @@ -52,11 +57,13 @@ type cached struct { func New(b Bucket, m *mirror.Mirror, r *content.Renderer, opts Options, log *slog.Logger) *Syncer { return &Syncer{ bucket: b, mirror: m, render: r, opts: opts, - log: log.With("component", "sync"), - rlog: log.With("component", "render"), - retry: []time.Duration{5 * time.Second, 30 * time.Second}, - cache: map[string]cached{}, - warned: map[string]string{}, + log: log.With("component", "sync"), + rlog: log.With("component", "render"), + retry: []time.Duration{5 * time.Second, 30 * time.Second}, + + indexNowURL: indexNowURL, + cache: map[string]cached{}, + warned: map[string]string{}, } } @@ -135,6 +142,7 @@ func (s *Syncer) Sync(ctx context.Context) error { } keep := map[string]bool{} var added, updated, removed, failed int + assets := false // an image changed: posts must re-render for new width/height for _, o := range objs { if ctx.Err() != nil { break @@ -166,6 +174,7 @@ func (s *Syncer) Sync(ctx context.Context) error { continue } s.log.Debug("fetched", "key", o.Key, "etag", o.ETag) + assets = assets || kind == mirror.Asset if existed { updated++ } else { @@ -184,6 +193,9 @@ func (s *Syncer) Sync(ctx context.Context) error { continue } s.log.Debug("removed", "key", key) + if k, _ := mirror.Classify(key); k == mirror.Asset { + assets = true + } removed++ } } @@ -194,9 +206,19 @@ func (s *Syncer) Sync(ctx context.Context) error { return fmt.Errorf("save mirror state: %w", err) } } - if changed || s.index.Load() == nil { + old := s.index.Load() + if assets { + clear(s.cache) + } + if changed || old == nil { s.rebuild() } + // No ping without an old index: the first load is not a change. + if changed && old != nil && s.opts.IndexNowKey != "" { + if urls := s.changedURLs(old, s.Index()); len(urls) > 0 { + go s.indexNow(urls) + } + } level := slog.LevelDebug if changed || failed > 0 { level = slog.LevelInfo diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 06c14f5..c3cb2fc 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -1,10 +1,17 @@ package syncer import ( + "bytes" "context" + "encoding/json" "errors" + "image" + "image/png" "io" "log/slog" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "slices" @@ -255,3 +262,84 @@ func TestSyncConcurrent(t *testing.T) { t.Errorf("gets = %d, want 1", b.gets) } } + +func TestIndexNow(t *testing.T) { + got := make(chan map[string]any, 4) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json; charset=utf-8" || + json.NewDecoder(r.Body).Decode(&body) != nil { + t.Errorf("bad request: %s %s", r.Method, r.Header.Get("Content-Type")) + } + got <- body + w.WriteHeader(http.StatusAccepted) + })) + defer ts.Close() + + b := &fakeBucket{objs: map[string]fakeObj{"a.md": {"e1", post("A", "a")}, "b.md": {"e1", post("B", "b")}}} + s := newSyncer(t, t.TempDir(), b) + s.opts.SiteURL, _ = url.Parse("https://example.com") + s.opts.IndexNowKey = "key12345" + s.indexNowURL = ts.URL + ctx := context.Background() + if err := s.Sync(ctx); err != nil { // first load: no ping + t.Fatal(err) + } + b.set("a.md", "e2", post("A v2", "a")) + b.set("c.md", "e1", post("C", "c")) + delete(b.objs, "b.md") + b.set("assets/x.png", "e1", "png") // asset only: re-render, no ping for unchanged posts + if err := s.Sync(ctx); err != nil { + t.Fatal(err) + } + select { + case body := <-got: + var urls []string + for _, u := range body["urlList"].([]any) { + urls = append(urls, u.(string)) + } + slices.Sort(urls) + want := []string{"https://example.com/posts/a", "https://example.com/posts/b", "https://example.com/posts/c"} + if !slices.Equal(urls, want) || body["host"] != "example.com" || body["key"] != "key12345" || + body["keyLocation"] != "https://example.com/key12345.txt" { + t.Errorf("body = %v", body) + } + case <-time.After(5 * time.Second): + t.Fatal("no ping") + } + b.set("assets/x.png", "e2", "png2") + if err := s.Sync(ctx); err != nil { + t.Fatal(err) + } + select { + case body := <-got: + t.Errorf("ping for an asset-only change: %v", body) + case <-time.After(200 * time.Millisecond): + } +} + +func TestAssetChangeRerenders(t *testing.T) { + b := &fakeBucket{objs: map[string]fakeObj{"a.md": {"e1", "---\ntitle: A\ndate: 2026-11-03\n---\n![x](assets/x.png)\n"}}} + s := newSyncer(t, t.TempDir(), b) + s.render.ImageSize = s.mirror.ImageSize + ctx := context.Background() + if err := s.Sync(ctx); err != nil { + t.Fatal(err) + } + if strings.Contains(string(s.Index().Get("a").HTML), "width=") { + t.Fatal("width before the image exists") + } + var buf bytes.Buffer + _ = pngEncode(&buf, 3, 2) + b.set("assets/x.png", "e1", buf.String()) + if err := s.Sync(ctx); err != nil { + t.Fatal(err) + } + if h := string(s.Index().Get("a").HTML); !strings.Contains(h, `width="3" height="2"`) { + t.Errorf("not re-rendered after the asset appeared: %s", h) + } +} + +func pngEncode(w io.Writer, width, height int) error { + return png.Encode(w, image.NewGray(image.Rect(0, 0, width, height))) +} diff --git a/main.go b/main.go index 5c4dfe5..b353114 100644 --- a/main.go +++ b/main.go @@ -49,11 +49,15 @@ func run() int { mainLog.Error("S3 client", "err", err) return 1 } - syn := syncer.New(bucket, mir, content.NewRenderer(cfg.DefaultLang), syncer.Options{ + render := content.NewRenderer(cfg.DefaultLang) + render.ImageSize = mir.ImageSize + syn := syncer.New(bucket, mir, render, syncer.Options{ PollInterval: cfg.PollInterval, MaxPostBytes: cfg.MaxPostBytes, MaxAssetBytes: cfg.MaxAssetBytes, MaxObjects: cfg.MaxObjects, + SiteURL: cfg.SiteURL, + IndexNowKey: cfg.IndexNowKey, }, log) var ready atomic.Bool