@geekity/cms 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +177 -6
  2. package/admin/pages/account/login.njk +3 -0
  3. package/admin/pages/documents/editor.njk +7 -0
  4. package/admin/pages/indieauth/consent.njk +51 -0
  5. package/admin/pages/indieauth/refused.njk +11 -0
  6. package/admin/pages/settings/general.njk +9 -0
  7. package/admin/pages/settings/reading.njk +93 -0
  8. package/admin/pages/users/apps.njk +74 -0
  9. package/admin/pages/users/edit.njk +12 -0
  10. package/admin/static/admin.css +27 -0
  11. package/dist/admin/accounts.d.ts +3 -0
  12. package/dist/admin/accounts.d.ts.map +1 -1
  13. package/dist/admin/accounts.js +5 -0
  14. package/dist/admin/accounts.js.map +1 -1
  15. package/dist/admin/documents.d.ts +2 -0
  16. package/dist/admin/documents.d.ts.map +1 -1
  17. package/dist/admin/documents.js +17 -1
  18. package/dist/admin/documents.js.map +1 -1
  19. package/dist/admin/headers.d.ts +5 -1
  20. package/dist/admin/headers.d.ts.map +1 -1
  21. package/dist/admin/headers.js +7 -3
  22. package/dist/admin/headers.js.map +1 -1
  23. package/dist/admin/menu.d.ts.map +1 -1
  24. package/dist/admin/menu.js +2 -1
  25. package/dist/admin/menu.js.map +1 -1
  26. package/dist/admin/routes.d.ts.map +1 -1
  27. package/dist/admin/routes.js +48 -5
  28. package/dist/admin/routes.js.map +1 -1
  29. package/dist/admin/settings-general.js +1 -1
  30. package/dist/admin/settings-general.js.map +1 -1
  31. package/dist/admin/settings-reading.d.ts.map +1 -1
  32. package/dist/admin/settings-reading.js +15 -1
  33. package/dist/admin/settings-reading.js.map +1 -1
  34. package/dist/admin/settings.d.ts +79 -2
  35. package/dist/admin/settings.d.ts.map +1 -1
  36. package/dist/admin/settings.js +168 -2
  37. package/dist/admin/settings.js.map +1 -1
  38. package/dist/admin/templates.d.ts +3 -0
  39. package/dist/admin/templates.d.ts.map +1 -1
  40. package/dist/admin/templates.js +3 -0
  41. package/dist/admin/templates.js.map +1 -1
  42. package/dist/admin/users.d.ts +8 -0
  43. package/dist/admin/users.d.ts.map +1 -1
  44. package/dist/admin/users.js +9 -0
  45. package/dist/admin/users.js.map +1 -1
  46. package/dist/config.d.ts +30 -0
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js +1 -0
  49. package/dist/config.js.map +1 -1
  50. package/dist/env.d.ts +14 -0
  51. package/dist/env.d.ts.map +1 -1
  52. package/dist/federation/article.d.ts.map +1 -1
  53. package/dist/federation/article.js +18 -5
  54. package/dist/federation/article.js.map +1 -1
  55. package/dist/images/icons.d.ts +44 -25
  56. package/dist/images/icons.d.ts.map +1 -1
  57. package/dist/images/icons.js +205 -60
  58. package/dist/images/icons.js.map +1 -1
  59. package/dist/images/index.d.ts +2 -2
  60. package/dist/images/index.d.ts.map +1 -1
  61. package/dist/images/index.js +1 -1
  62. package/dist/images/index.js.map +1 -1
  63. package/dist/images/markup.d.ts +14 -0
  64. package/dist/images/markup.d.ts.map +1 -1
  65. package/dist/images/markup.js +16 -11
  66. package/dist/images/markup.js.map +1 -1
  67. package/dist/images/variants.d.ts.map +1 -1
  68. package/dist/images/variants.js +3 -4
  69. package/dist/images/variants.js.map +1 -1
  70. package/dist/index.d.ts +14 -4
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +28 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/indexnow.d.ts +34 -0
  75. package/dist/indexnow.d.ts.map +1 -0
  76. package/dist/indexnow.js +141 -0
  77. package/dist/indexnow.js.map +1 -0
  78. package/dist/indieauth/bearer.d.ts +39 -0
  79. package/dist/indieauth/bearer.d.ts.map +1 -0
  80. package/dist/indieauth/bearer.js +63 -0
  81. package/dist/indieauth/bearer.js.map +1 -0
  82. package/dist/indieauth/client.d.ts +61 -0
  83. package/dist/indieauth/client.d.ts.map +1 -0
  84. package/dist/indieauth/client.js +139 -0
  85. package/dist/indieauth/client.js.map +1 -0
  86. package/dist/indieauth/connected-apps.d.ts +23 -0
  87. package/dist/indieauth/connected-apps.d.ts.map +1 -0
  88. package/dist/indieauth/connected-apps.js +73 -0
  89. package/dist/indieauth/connected-apps.js.map +1 -0
  90. package/dist/indieauth/consent.d.ts +28 -0
  91. package/dist/indieauth/consent.d.ts.map +1 -0
  92. package/dist/indieauth/consent.js +185 -0
  93. package/dist/indieauth/consent.js.map +1 -0
  94. package/dist/indieauth/discovery.d.ts +87 -0
  95. package/dist/indieauth/discovery.d.ts.map +1 -0
  96. package/dist/indieauth/discovery.js +127 -0
  97. package/dist/indieauth/discovery.js.map +1 -0
  98. package/dist/indieauth/grants.d.ts +57 -0
  99. package/dist/indieauth/grants.d.ts.map +1 -0
  100. package/dist/indieauth/grants.js +35 -0
  101. package/dist/indieauth/grants.js.map +1 -0
  102. package/dist/indieauth/identity.d.ts +36 -0
  103. package/dist/indieauth/identity.d.ts.map +1 -0
  104. package/dist/indieauth/identity.js +78 -0
  105. package/dist/indieauth/identity.js.map +1 -0
  106. package/dist/indieauth/redeem.d.ts +47 -0
  107. package/dist/indieauth/redeem.d.ts.map +1 -0
  108. package/dist/indieauth/redeem.js +69 -0
  109. package/dist/indieauth/redeem.js.map +1 -0
  110. package/dist/indieauth/request.d.ts +47 -0
  111. package/dist/indieauth/request.d.ts.map +1 -0
  112. package/dist/indieauth/request.js +104 -0
  113. package/dist/indieauth/request.js.map +1 -0
  114. package/dist/indieauth/token.d.ts +19 -0
  115. package/dist/indieauth/token.d.ts.map +1 -0
  116. package/dist/indieauth/token.js +110 -0
  117. package/dist/indieauth/token.js.map +1 -0
  118. package/dist/indieauth/tokens.d.ts +131 -0
  119. package/dist/indieauth/tokens.d.ts.map +1 -0
  120. package/dist/indieauth/tokens.js +205 -0
  121. package/dist/indieauth/tokens.js.map +1 -0
  122. package/dist/web/archive.d.ts +1 -1
  123. package/dist/web/archive.d.ts.map +1 -1
  124. package/dist/web/archive.js +3 -2
  125. package/dist/web/archive.js.map +1 -1
  126. package/dist/web/context.d.ts +25 -0
  127. package/dist/web/context.d.ts.map +1 -1
  128. package/dist/web/context.js +18 -0
  129. package/dist/web/context.js.map +1 -1
  130. package/dist/web/feed-atom.d.ts.map +1 -1
  131. package/dist/web/feed-atom.js +3 -1
  132. package/dist/web/feed-atom.js.map +1 -1
  133. package/dist/web/feed-item.d.ts +11 -2
  134. package/dist/web/feed-item.d.ts.map +1 -1
  135. package/dist/web/feed-item.js +9 -3
  136. package/dist/web/feed-item.js.map +1 -1
  137. package/dist/web/feed-json.d.ts +4 -0
  138. package/dist/web/feed-json.d.ts.map +1 -1
  139. package/dist/web/feed-json.js +4 -1
  140. package/dist/web/feed-json.js.map +1 -1
  141. package/dist/web/feed-rss.d.ts.map +1 -1
  142. package/dist/web/feed-rss.js +16 -3
  143. package/dist/web/feed-rss.js.map +1 -1
  144. package/dist/web/feed-source.d.ts +28 -0
  145. package/dist/web/feed-source.d.ts.map +1 -1
  146. package/dist/web/feed-source.js +31 -0
  147. package/dist/web/feed-source.js.map +1 -1
  148. package/dist/web/feed-xml.d.ts +2 -0
  149. package/dist/web/feed-xml.d.ts.map +1 -1
  150. package/dist/web/feed-xml.js +2 -0
  151. package/dist/web/feed-xml.js.map +1 -1
  152. package/dist/web/feeds.d.ts.map +1 -1
  153. package/dist/web/feeds.js +8 -1
  154. package/dist/web/feeds.js.map +1 -1
  155. package/dist/web/index.d.ts +3 -1
  156. package/dist/web/index.d.ts.map +1 -1
  157. package/dist/web/index.js +2 -1
  158. package/dist/web/index.js.map +1 -1
  159. package/dist/web/indexnow.d.ts +9 -0
  160. package/dist/web/indexnow.d.ts.map +1 -0
  161. package/dist/web/indexnow.js +27 -0
  162. package/dist/web/indexnow.js.map +1 -0
  163. package/dist/web/llms.d.ts +58 -0
  164. package/dist/web/llms.d.ts.map +1 -0
  165. package/dist/web/llms.js +91 -0
  166. package/dist/web/llms.js.map +1 -0
  167. package/dist/web/locale.d.ts +41 -0
  168. package/dist/web/locale.d.ts.map +1 -0
  169. package/dist/web/locale.js +56 -0
  170. package/dist/web/locale.js.map +1 -0
  171. package/dist/web/manifest.d.ts +50 -0
  172. package/dist/web/manifest.d.ts.map +1 -0
  173. package/dist/web/manifest.js +56 -0
  174. package/dist/web/manifest.js.map +1 -0
  175. package/dist/web/negotiate.d.ts.map +1 -1
  176. package/dist/web/negotiate.js +6 -0
  177. package/dist/web/negotiate.js.map +1 -1
  178. package/dist/web/render.d.ts +6 -1
  179. package/dist/web/render.d.ts.map +1 -1
  180. package/dist/web/render.js +68 -22
  181. package/dist/web/render.js.map +1 -1
  182. package/dist/web/robots.d.ts +89 -0
  183. package/dist/web/robots.d.ts.map +1 -0
  184. package/dist/web/robots.js +259 -0
  185. package/dist/web/robots.js.map +1 -0
  186. package/dist/web/routes.d.ts.map +1 -1
  187. package/dist/web/routes.js +105 -6
  188. package/dist/web/routes.js.map +1 -1
  189. package/dist/web/share-image.d.ts +51 -0
  190. package/dist/web/share-image.d.ts.map +1 -0
  191. package/dist/web/share-image.js +43 -0
  192. package/dist/web/share-image.js.map +1 -0
  193. package/dist/web/sitemap.d.ts +0 -26
  194. package/dist/web/sitemap.d.ts.map +1 -1
  195. package/dist/web/sitemap.js +0 -44
  196. package/dist/web/sitemap.js.map +1 -1
  197. package/dist/web/templates.d.ts +16 -9
  198. package/dist/web/templates.d.ts.map +1 -1
  199. package/dist/web/templates.js +83 -55
  200. package/dist/web/templates.js.map +1 -1
  201. package/dist/web/themes.d.ts +33 -0
  202. package/dist/web/themes.d.ts.map +1 -1
  203. package/dist/web/themes.js +57 -0
  204. package/dist/web/themes.js.map +1 -1
  205. package/dist/webmention/discovery.d.ts +4 -2
  206. package/dist/webmention/discovery.d.ts.map +1 -1
  207. package/dist/webmention/discovery.js +14 -8
  208. package/dist/webmention/discovery.js.map +1 -1
  209. package/dist/webmention/fetch-public.d.ts +2 -0
  210. package/dist/webmention/fetch-public.d.ts.map +1 -1
  211. package/dist/webmention/fetch-public.js +1 -1
  212. package/dist/webmention/fetch-public.js.map +1 -1
  213. package/package.json +7 -2
  214. package/themes/default/README.md +320 -139
  215. package/themes/default/layouts/404.njk +2 -0
  216. package/themes/default/layouts/500.njk +2 -0
  217. package/themes/default/layouts/503.njk +2 -0
  218. package/themes/default/layouts/author.njk +3 -1
  219. package/themes/default/layouts/base.njk +96 -17
  220. package/themes/default/layouts/category.njk +4 -2
  221. package/themes/default/layouts/front-page.njk +7 -2
  222. package/themes/default/layouts/home.njk +10 -0
  223. package/themes/default/layouts/page.njk +7 -4
  224. package/themes/default/layouts/post.njk +35 -25
  225. package/themes/default/layouts/search.njk +5 -9
  226. package/themes/default/layouts/tag.njk +7 -1
  227. package/themes/default/partials/archive.njk +3 -1
  228. package/themes/default/partials/bio.njk +9 -2
  229. package/themes/default/partials/breadcrumbs.njk +20 -0
  230. package/themes/default/partials/comment-form.njk +8 -0
  231. package/themes/default/partials/contact-form.njk +3 -0
  232. package/themes/default/partials/conversation.njk +14 -4
  233. package/themes/default/partials/jsonld.njk +19 -4
  234. package/themes/default/partials/kicker.njk +36 -0
  235. package/themes/default/partials/lang.njk +7 -0
  236. package/themes/default/partials/post-list.njk +43 -47
  237. package/themes/default/partials/reply-context.njk +14 -5
  238. package/themes/default/src/style.css +1109 -0
  239. package/themes/default/static/style.css +2124 -1160
  240. package/themes/default/theme.json +3 -1
@@ -23,6 +23,8 @@ themes/default/
23
23
  post-list.njk the h-feed a listing is made of
24
24
  pagination.njk previous/next pager
25
25
  tags.njk macros for tag and category links
26
+ kicker.njk the line above an entry naming its kind and date
27
+ reply-context.njk the citation of what a reply answers
26
28
  bio.njk who an entry is by, as an h-card
27
29
  menu.njk one named menu, as a nav of links
28
30
  feeds.njk macros for the feed links in <head>
@@ -39,12 +41,39 @@ themes/default/
39
41
  comment-digest.*.njk the hourly or daily digest of what is waiting
40
42
  comment-reply.*.njk the notice a commenter gets about a reply
41
43
  contact-message.*.njk a message from a page's contact form
44
+ src/
45
+ style.css the stylesheet's source, compiled with Tailwind
42
46
  static/
43
- style.css served at /theme/style.css
47
+ style.css the compiled stylesheet, served at /theme/style.css
48
+ highlight.js the code highlighter
44
49
  ```
45
50
 
46
- Plain Nunjucks and plain CSS. There is no build step and no dependency: a site
47
- that wants Tailwind or anything else brings its own.
51
+ Plain Nunjucks templates and one stylesheet. The stylesheet is written in
52
+ Tailwind v4 (`src/style.css`) and compiled when the package is built, so
53
+ `static/style.css` is a build product: gitignored in this repository, written
54
+ by `pnpm build` (which the root `prepare` runs after an install and before a
55
+ publish), and shipped in the package like every other file here. The compile
56
+ happens once, on the machine that builds the package. A reader gets one plain
57
+ CSS file and no script, web font or CDN request because of it.
58
+
59
+ The design is Paper (doc-9, decision-22): one warm column, serif throughout,
60
+ sans for small labels, and a kicker above every entry naming its kind.
61
+
62
+ **Rebuilding the stylesheet.** After editing `src/style.css`:
63
+
64
+ ```
65
+ pnpm --filter @geekity/cms build:theme
66
+ ```
67
+
68
+ Tailwind scans only this theme's `layouts/` and `partials/`, so a utility class
69
+ written in a template here is compiled in and nothing outside the theme is
70
+ read. The templates themselves use the semantic class names below and the look
71
+ lives in `src/style.css`, through `@apply`.
72
+
73
+ **A site theme owes Tailwind nothing.** Its own `static/style.css` replaces this
74
+ one wholesale, and any CSS that knows the class names in this README will do.
75
+ The demo theme's is plain CSS. A site that only wants different colours
76
+ overrides the `--color-*` custom properties instead; see [Colours](#colours).
48
77
 
49
78
  ## The manifest
50
79
 
@@ -54,7 +83,9 @@ that wants Tailwind or anything else brings its own.
54
83
  {
55
84
  "name": "Default",
56
85
  "kind": "site",
57
- "description": "One line about the theme."
86
+ "description": "One line about the theme.",
87
+ "colorScheme": "light dark",
88
+ "themeColor": { "light": "#faf7f2", "dark": "#171412" }
58
89
  }
59
90
  ```
60
91
 
@@ -64,6 +95,14 @@ and `description` is optional. The directory name is the theme's id. A
64
95
  directory without a readable manifest, or one naming a kind this CMS does not
65
96
  have, is not a theme.
66
97
 
98
+ `colorScheme` and `themeColor` are the colours the head declares before the
99
+ stylesheet loads (see [The head](#the-head)). `colorScheme` is one of `normal`,
100
+ `light`, `dark`, `light dark`, `dark light` or `only light`, preferred scheme
101
+ first. `themeColor` is the colour of the browser around the page, one per
102
+ scheme, as a three- or six-digit hex colour with no alpha. Make it the page's
103
+ own background. A value outside those is ignored, and so is one a theme leaves
104
+ out: each falls back to this theme's, just as a template does.
105
+
67
106
  ## Overriding a template
68
107
 
69
108
  A site keeps its themes under one directory, `themes/` by default and
@@ -122,8 +161,8 @@ packaged ones by name:
122
161
  ```
123
162
 
124
163
  `layouts/base.njk` defines the blocks `title`, `speculationRules`,
125
- `viewTransitions`, `head`, `alternates`, `header`, `content`, `footer` and
126
- `scripts`, so most sites never have to copy it.
164
+ `viewTransitions`, `head`, `alternates`, `header`, `breadcrumbs`, `content`,
165
+ `footer` and `scripts`, so most sites never have to copy it.
127
166
 
128
167
  Two layouts are named for the pages the Reading settings pick:
129
168
 
@@ -145,9 +184,10 @@ words above the posts; `layouts/home.njk` already does.
145
184
 
146
185
  ## The page shell
147
186
 
148
- `layouts/base.njk` is the andrewshell.org design's shell (decision-16): the
149
- skip link, one `.global-wrapper` at the 42rem measure, `.global-header`,
150
- `<main id="main">` and the footer, in that order.
187
+ `layouts/base.njk` is the page shell: the skip link, one `.global-wrapper` at the
188
+ 42rem measure, `.global-header`, `<main id="main">` and the footer, in that
189
+ order. The shape is the andrewshell.org design's (decision-16) and the look is
190
+ Paper's (doc-9).
151
191
 
152
192
  **One home for each kind of link.** The header carries the site menu, on every
153
193
  page. [The bio](#the-bio) carries the person whose page or post it is. The
@@ -156,7 +196,7 @@ nowhere else.
156
196
 
157
197
  **The header has one rule, and the menu under it.** On the front page it is the
158
198
  site title as `h1.main-heading`, linked home, with `site.tagline` in a
159
- paragraph under it; on every other page it is `a.header-link-home`, the site
199
+ `p.site-tagline` under it; on every other page it is `a.header-link-home`, the site
160
200
  title small and linked home, and no tagline. The wrapper carries
161
201
  `data-is-root-path="true"` at `/` and nothing anywhere else, which is how the
162
202
  stylesheet tells the two apart. `menus.primary` is printed inside the
@@ -176,6 +216,18 @@ and the copyright line stands on its own. The year is
176
216
  rather than parses — so it is the year at the moment the page is rendered, in
177
217
  the site's own timezone.
178
218
 
219
+ **The breadcrumb** opens `<main>` on a page with a place in the site's
220
+ hierarchy (TASK-150): a post filed under a category, and a category, tag or
221
+ author archive. It is `nav.breadcrumbs` labelled `Breadcrumb`, holding an `ol`
222
+ whose items run Home, then the post's first category and the post, or the
223
+ archive's term or person, then `Page N` past page one. Every crumb but the last
224
+ is a link; the last is the page itself, a `span` with `aria-current="page"`.
225
+ The trail is `breadcrumbs`, a list of `{ name, url }` that `layouts/base.njk`
226
+ sets before anything is printed, and the JSON-LD prints the same list, so the
227
+ two cannot disagree. Every other page has an empty trail and prints nothing. A
228
+ site that wants no visible breadcrumb empties the `breadcrumbs` block, and the
229
+ `BreadcrumbList` stays in the graph.
230
+
179
231
  Webrings, badges, a licence notice and anything else that is markup rather than
180
232
  a link are deliberately not in the package. They go in a site theme's `footer`
181
233
  block:
@@ -260,13 +312,19 @@ html.geekity-admin-bar {
260
312
 
261
313
  ### An entry
262
314
 
263
- `layouts/post.njk` and `layouts/page.njk` draw the same thing, the source
264
- design's entry, and it is a microformats2 `h-entry`:
315
+ `layouts/post.njk` and `layouts/page.njk` draw the same thing, the entry, and it
316
+ is a microformats2 `h-entry`. An article, and a page:
265
317
 
266
318
  ```html
267
319
  <article class="blog-post h-entry">
268
- <header>
320
+ <header class="post-header">
321
+ <p class="kicker">
322
+ <span class="kicker-kind">Article</span>
323
+ <span class="kicker-dot" aria-hidden="true">·</span>
324
+ <a href="/category/notes/" class="p-category" rel="category">notes</a>
325
+ </p>
269
326
  <h1 class="p-name">Hello</h1>
327
+ <p class="post-deck p-summary">The description the author wrote.</p>
270
328
  </header>
271
329
  <section class="e-content">
272
330
  <p>The rendered body.</p>
@@ -279,38 +337,47 @@ design's entry, and it is a microformats2 `h-entry`:
279
337
  >Published 2 September 2026</time
280
338
  ></a
281
339
  >
282
- <br /><time class="small dt-updated" datetime="2026-09-05T09:00:00.000Z"
340
+ <span aria-hidden="true">·</span>
341
+ <time class="small dt-updated" datetime="2026-09-05T09:00:00.000Z"
283
342
  >Updated 5 September 2026</time
284
343
  >
285
344
  </p>
286
345
  </section>
287
- <p class="post-categories">…categories…</p>
288
346
  <p class="post-categories">…tags…</p>
289
347
  <hr />
290
348
  <footer>…the bio…</footer>
291
349
  </article>
292
- <nav class="blog-post-nav">…the posts either side…</nav>
350
+ <nav class="blog-post-nav" aria-label="Neighbouring posts">…</nav>
293
351
  ```
294
352
 
353
+ **The kicker names the kind.** `p.kicker`, from `partials/kicker.njk`, opens
354
+ every entry: the kind word in `span.kicker-kind` (Article, Note or Reply for a
355
+ post, from its `postType`; Page for a page), then the categories it is filed
356
+ under as `p-category` links. It is the only place the kind is said, small and
357
+ once, and an article is additionally the only kind with a visible headline.
358
+ The deck is the front matter's `description` as a `p.post-deck.p-summary`,
359
+ printed only when the author wrote one.
360
+
295
361
  **The Published line is inside the `e-content`**, which is the one thing about
296
362
  this shape worth knowing. It is what the source theme does: the date and the
297
- permalink are part of the words, so a reader — or a fediverse peer reading the
298
- `e-content` — takes them with the post. The permalink wraps the `dt-published`
299
- time as a `u-url`, and a `dt-updated` line follows it only when the update
300
- happened on a different day in the site's own timezone; a typo fixed an hour
301
- later is not news. A post tagged `indienews` opens the line with a
363
+ permalink are part of the words, so a reader, or a fediverse peer reading the
364
+ `e-content`, takes them with the post. The permalink wraps the `dt-published`
365
+ time as a `u-url`, and a `dt-updated` follows it after a middle dot only when
366
+ the update happened on a different day in the site's own timezone; a typo fixed
367
+ an hour later is not news. A post tagged `indienews` opens the line with a
302
368
  `u-category` link to <https://news.indieweb.org/en>, which is how IndieNews is
303
369
  told the post is for it; a page never prints one, and its line is a
304
370
  `p.page-meta` rather than a `p.entry-meta`.
305
371
 
306
372
  **Every post's page has one `h1`.** A post with a name of its own is headed by
307
373
  it, as above. A note, or a reply without a title, opens on its words instead,
308
- so it has no `header`; in its place is an `h1` a screen reader and a crawler
309
- navigate by and a sighted reader never sees, saying what the post is, who wrote
310
- it and when:
374
+ set larger, so it has no `header`; in its place is an `h1` a screen reader and
375
+ a crawler navigate by and a sighted reader never sees, saying what the post is,
376
+ who wrote it and when, and then the kicker:
311
377
 
312
378
  ```html
313
379
  <h1 class="screen-reader-text">Note by Ada Lovelace, 11 September 2026</h1>
380
+ <p class="kicker"><span class="kicker-kind">Note</span></p>
314
381
  ```
315
382
 
316
383
  It says `Reply` for a reply and leaves out the name or the date the post does
@@ -319,16 +386,24 @@ for an article. The headings under it step down one level at a time: the
319
386
  conversation and the comment form are `h2`, and a refused form's error summary
320
387
  is an `h3` inside the form's section.
321
388
 
322
- What it is filed under is printed under the words and inside the article, so
323
- that each link is a `p-category` of this entry: the categories first, then the
324
- tags, each as one `p.post-categories` from `partials/tags.njk`.
325
-
326
- After the entry a post prints `nav.blog-post-nav`, `rel="prev"` and
327
- `rel="next"` links to `previous` and `next` with an arrow either side, and
328
- nothing at all at the ends of the archive; then the conversation and the
329
- comment form. A page prints the contact form when its front matter asked for
330
- one. A page has no neighbours, no taxonomy and no IndieNews link, because none
331
- of those are things a page has.
389
+ **A reply cites what it answers** with `partials/reply-context.njk`, a
390
+ `div.reply-context.cite.u-in-reply-to.h-cite`: a rule on the start side, a
391
+ `p.cite-line` saying "In reply to" and the target's name, author and date as
392
+ far as they are known, and its excerpt as a `blockquote.cite-quote.p-content`.
393
+ An untitled reply prints it under the kicker; a titled reply prints it above
394
+ its header. The same partial cites a reply in a feed.
395
+
396
+ The tags are printed under the words and inside the article, so that each link
397
+ is a `p-category` of this entry, as one `p.post-categories` from
398
+ `partials/tags.njk`. The stylesheet draws the `#` in front of each, so the
399
+ category a parser reads is the tag and not the hash.
400
+
401
+ After the entry a post prints `nav.blog-post-nav`: the `previous` and `next`
402
+ posts as two cards, `rel="prev"` and `rel="next"`, each opening on a
403
+ `span.blog-post-nav-label`, and nothing at all at the ends of the archive; then
404
+ the conversation and the comment form. A page prints the contact form when its
405
+ front matter asked for one. A page has no neighbours, no tags and no IndieNews
406
+ link, because none of those are things a page has.
332
407
 
333
408
  ### The bio
334
409
 
@@ -352,7 +427,16 @@ What the bio prints: a round `u-photo` at 50px when they have an avatar,
352
427
  their archive, then `p-job-title` and `p-locality` when the profile says, then
353
428
  their `p-note` and their `rel="me"` links as a `ul.hlist.bio-links` — each
354
429
  link's `rel` is on it ready to print, `me` first and then whatever else the
355
- Links box typed. Each of
430
+ Links box typed. Set `bioRelMe` to `false` to keep the card and drop its
431
+ claims: the name is linked `rel="author"` alone and `me` comes off every
432
+ link.
433
+
434
+ **The homepage** speaks for the site's author only on a solo author blog,
435
+ which `soloAuthor` on the context says. Then both `layouts/home.njk` at `/` and
436
+ `layouts/front-page.njk` end with that person's card, with its `rel="me"`
437
+ claims, and their author archive's link home carries `rel="me"` back, so the
438
+ homepage and the archive name each other. On any other site a listing homepage
439
+ prints no card, and the front page prints its card with `bioRelMe` false. Each of
356
440
  those is printed only when the profile says it, so a profile holding a name
357
441
  alone prints a name alone. A name this site has no account for is printed
358
442
  unlinked, because the file still said somebody wrote this.
@@ -387,49 +471,66 @@ byline under the title rather than a bio in the footer, so it writes one.
387
471
 
388
472
  ### A listing
389
473
 
390
- `partials/post-list.njk` is the feed every listing is made of — the home page,
391
- the posts page, a tag, category or author archive — and it is a microformats2
392
- `h-feed`:
474
+ `partials/post-list.njk` is the feed every listing is made of (the home page,
475
+ the posts page, a tag, category or author archive, and the front page's recent
476
+ posts) and it is a microformats2 `h-feed` of `h-entry` items, each opening on
477
+ its kicker:
393
478
 
394
479
  ```html
395
480
  <div class="feed h-feed">
396
481
  <article class="feed-item h-entry">
397
- <div class="feed-content">
398
- <h2 class="feed-title p-name">
399
- <a href="/2026/09/hello/" class="u-url">Hello</a>
400
- </h2>
401
- <div class="feed-excerpt p-summary">
402
- <p>What the post is about, in about 280 characters...</p>
403
- </div>
404
- <p class="feed-more">
405
- <a href="/hello/" aria-label="Continue reading: Hello">
406
- Continue reading<span aria-hidden="true"> &rarr;</span>
407
- </a>
408
- </p>
409
- <div class="feed-meta">
410
- <p>
411
- <time class="feed-date dt-published" datetime="…"
412
- >2 September 2026</time
413
- >
414
- </p>
415
- <p class="post-categories">
416
- <a href="/category/notes/" class="p-category" rel="category">notes</a>
417
- </p>
418
- </div>
482
+ <p class="kicker">
483
+ <span class="kicker-kind">Article</span>
484
+ <span class="kicker-dot" aria-hidden="true">·</span>
485
+ <time class="feed-date dt-published" datetime="…">2 September 2026</time>
486
+ <span class="kicker-dot" aria-hidden="true">·</span>
487
+ <a href="/category/notes/" class="p-category" rel="category">notes</a>
488
+ </p>
489
+ <h2 class="feed-title p-name">
490
+ <a href="/2026/09/hello/" class="u-url">Hello</a>
491
+ </h2>
492
+ <div class="feed-excerpt p-summary">
493
+ <p>What the post is about, in about 280 characters...</p>
419
494
  </div>
495
+ <p class="feed-more">
496
+ <a href="/2026/09/hello/" aria-label="Continue reading: Hello">
497
+ Continue reading<span aria-hidden="true"> &rarr;</span>
498
+ </a>
499
+ </p>
420
500
  </article>
421
501
  <hr class="feed-separator" />
422
- <article class="feed-item h-entry">…</article>
502
+ <article class="feed-item h-entry">
503
+ <p class="kicker">
504
+ <span class="kicker-kind">Note</span>
505
+ <span class="kicker-dot" aria-hidden="true">·</span>
506
+ <a href="/2026/09/coffee/" class="u-url"
507
+ ><time class="feed-date dt-published" datetime="…"
508
+ >3 September 2026</time
509
+ ></a
510
+ >
511
+ </p>
512
+ <div class="feed-excerpt e-content"><p>The whole note.</p></div>
513
+ </article>
423
514
  </div>
424
515
  ```
425
516
 
426
- The excerpt is the entry's `summary` — the very line the feeds publish, so a
427
- reader and a feed cannot be told two different things — through Nunjucks'
517
+ **Only an article has a headline.** A note, which has no title, is printed
518
+ whole as its `e-content`, and the date in its kicker is its `u-url`. A reply is
519
+ a note that first cites what it answers with `partials/reply-context.njk`, and
520
+ a reply with a title is an article that does. An article's categories are in
521
+ its kicker; a note's are on its own page.
522
+
523
+ The excerpt is the entry's `summary`, the very line the feeds publish, so a
524
+ reader and a feed cannot be told two different things, through Nunjucks'
428
525
  `truncate(280)`, which cuts at the last space before 280 characters and adds an
429
526
  ellipsis. A `description` shorter than that is printed whole. It is plain text
430
527
  rather than markup, deliberately: an excerpt with half a code block in it is
431
528
  not an excerpt.
432
529
 
530
+ Each entry of a listing carries `replyContext` beside `inReplyTo` when it is a
531
+ reply whose target has been fetched, the same object a reply's own page gets,
532
+ so the feed can cite it in full.
533
+
433
534
  An entry's tags are not in a feed item. They belong under the entry, where
434
535
  there is one post's worth of them rather than twenty.
435
536
 
@@ -445,21 +546,24 @@ before including it, so the outline never skips a level:
445
546
 
446
547
  A listing with nothing on it says `No posts found.` in `p.empty`.
447
548
 
448
- `partials/pagination.njk` prints two arrows in `nav.pagination` — `← Previous`
549
+ `partials/pagination.njk` prints two links in `nav.pagination`, `← Previous`
449
550
  to the page before this one and `Next →` to the page after it, with `rel="prev"`
450
- and `rel="next"` — and nothing at all on a listing of one page. The arrows
551
+ and `rel="next"`, and nothing at all on a listing of one page. The arrows
451
552
  themselves are `aria-hidden`, because a screen reader that announced them would
452
553
  read the decoration and then the word.
453
554
 
454
555
  Each listing layout heads its own page: `layouts/home.njk` with the listing's
455
- title, `layouts/tag.njk` and `layouts/category.njk` with the term, and
456
- `layouts/author.njk` with the person as an `h-card`. The category archive puts
457
- its heading in a `header.category-header` and prints a `div.category-description`
458
- under it when the context carries a `categoryDescription`; the CMS has no store
459
- of term descriptions, so nothing writes one today and the header is the heading
460
- alone.
461
-
462
- `layouts/404.njk` says `Content not found.` and links home and to the search.
556
+ title, and the three archives with a `header.archive-header` whose kicker says
557
+ what kind of archive it is (Tag, Category, Author) over an `h1.page-title`
558
+ naming whose: the term, or the person, followed by their `h-card`. The
559
+ category archive's header is also a `header.category-header` and prints a
560
+ `div.category-description` under the name when the context carries a
561
+ `categoryDescription`; the CMS has no store of term descriptions, so nothing
562
+ writes one today and the header is the heading alone.
563
+
564
+ `layouts/404.njk` says `Content not found.` under a `Not found` kicker and
565
+ links home and to the search. The 500 and 503 pages below open on a kicker the
566
+ same way.
463
567
 
464
568
  `layouts/500.njk` is the page a request gets when the server fails while
465
569
  answering it (TASK-129). It says `Something went wrong.` in an
@@ -583,25 +687,52 @@ the entry's `summary` — the line the feeds publish — else `site.tagline`. It
583
687
  printed once, as `<meta name="description">`, and the Open Graph and Twitter
584
688
  descriptions say the same thing.
585
689
 
690
+ **The colours** come from the theme's `theme.json` (see
691
+ [The manifest](#the-manifest)), on the context as `theme`.
692
+ `<meta name="color-scheme">` comes before the stylesheet, so a reader in dark
693
+ mode gets a dark canvas rather than a white flash while the stylesheet loads.
694
+ `<meta name="theme-color">` is printed twice, once with
695
+ `media="(prefers-color-scheme: light)"` and once for dark.
696
+
586
697
  **The card.** `og:title` is the page's title, or the site's on the front page;
587
698
  `og:site_name` is always the site's. `og:type` is `article` on a rendered post
588
699
  or page and `website` everywhere else, a listing carrying a page's front matter
589
- included. `og:url` is the canonical URL. `twitter:card` is `summary`, the small
590
- square picture beside the words, because the picture is usually a face rather
591
- than a wide photograph.
592
-
593
- **The picture** is the `image` in the entry's front matter, else `site.avatar`.
594
- A site with neither prints no `og:image` and no `twitter:image` rather than an
595
- empty one.
596
-
597
- **The icons** come from the site's avatar through the derived images
598
- (decision-10): `icon` at 32 and 16 pixels and `apple-touch-icon` at 180, each a
599
- square PNG cropped from the middle of the avatar and encoded the first time a
600
- browser asks for it. They are on the context as `icons`, a list of
601
- `{ rel, sizes, href }`, which is empty — and the links are not printed at all —
602
- when the site has no avatar, when its avatar is a file no icon can be made of,
603
- or when image optimization is off. There is no web manifest; a site that wants
604
- one adds it in its own `head` block.
700
+ included. `og:url` is the canonical URL. An article also carries
701
+ `article:published_time`, `article:modified_time` (the `updated` date, else the
702
+ publish date), `article:author` (the author's profile URL here, else their
703
+ name) and one `article:tag` per tag.
704
+
705
+ **The picture** is the `image` in the entry's front matter, else `site.avatar`,
706
+ on the context as `shareImage`. A site with neither prints no `og:image` and no
707
+ `twitter:image` rather than an empty one. The picture always carries
708
+ `og:image:alt` and `twitter:image:alt`. For the entry's own picture, the alt
709
+ text is its `imageAlt` front matter, else what the media library says about the
710
+ upload, else the entry's title. For the avatar, it is the media library's text,
711
+ else the site's author. `og:image:width` and `og:image:height` are printed when
712
+ the image's variants have been derived, because the size is read from their
713
+ sidecar rather than from the file. `twitter:card` is `summary_large_image` for a
714
+ picture at least 1200 pixels wide and wider than it is tall. Everything else,
715
+ the avatar included, is `summary`, the small picture beside the words.
716
+
717
+ **The icons** come from the site's `icon` setting in `site.json`, else
718
+ `site.avatar`, through the derived images (decision-10). `icon` exists for a
719
+ site whose avatar is a face and whose mark is a logo. The head links `icon` at
720
+ 32 and 16 pixels and `apple-touch-icon` at 180, each a square PNG cropped from
721
+ the middle of the picture and encoded the first time a browser asks for it. An
722
+ SVG icon is linked first as itself, with `type="image/svg+xml"` and no
723
+ `sizes`, and the PNGs are rasterised from it. They are on the context as
724
+ `icons`, a list of `{ rel, sizes, type, href }`, which is empty, and the links
725
+ are not printed at all, when the site has neither setting, when it names a
726
+ file no icon can be made of, or when image optimization is off.
727
+
728
+ **The favicon and the manifest** are routes, not templates. `/favicon.ico`
729
+ holds 16, 32 and 48 pixel frames drawn from the same picture, because browsers
730
+ and crawlers ask the root for it whatever the head links; it answers 404 on a
731
+ site with no icon. `/manifest.webmanifest` gives the site's title as `name` and
732
+ `short_name`, `start_url` `/`, `display` `minimal-ui`, the theme's light
733
+ `themeColor` as `theme_color` and `background_color`, and icons at 192 and 512
734
+ pixels plus a maskable 512 whose picture sits inside the middle 80%, padded
735
+ with its own dominant colour. The head links it on every page.
605
736
 
606
737
  **The structured data** is `partials/jsonld.njk`, one `<script
607
738
  type="application/ld+json">` holding one `@graph` per page, and it is the only
@@ -611,7 +742,8 @@ IndieWeb. The graph holds:
611
742
 
612
743
  - `WebSite`, always, with the site's title, tagline and URL, and a `publisher`
613
744
  pointing at the Person, and a `potentialAction` that is a `SearchAction` on
614
- `/search/?q={search_term_string}`.
745
+ `/search/?q={search_term_string}`. On the homepage of a solo author blog it
746
+ also has an `about` pointing at the site author's Person.
615
747
  - `Person`, from `siteAuthor`: their name, archive URL, avatar, bio, job title
616
748
  and location, and a `sameAs` of their profile links and their actor id, which
617
749
  is what asserts that the schema.org Person and the fediverse actor are one
@@ -622,6 +754,9 @@ IndieWeb. The graph holds:
622
754
  - `BlogPosting` on a post and `Article` on a page, with the headline, URL,
623
755
  `mainEntityOfPage`, `datePublished`, `dateModified`, description, image,
624
756
  `author` and `publisher`.
757
+ - `BreadcrumbList` wherever the page prints a breadcrumb (see
758
+ [The page shell](#the-page-shell)): one `ListItem` per crumb with its
759
+ `position`, `name` and absolute URL as `item`.
625
760
 
626
761
  A site that wants a different graph — more types, an `Organization` publisher,
627
762
  nothing at all — writes its own `partials/jsonld.njk` and that file replaces
@@ -682,38 +817,48 @@ to its own stylesheet.
682
817
 
683
818
  ### Colours
684
819
 
685
- `static/style.css` is the source design: a serif body and sans headings at an
686
- 18px root on a 1.2 minor-third scale, warm paper with near-black text, a rust
687
- primary and a blue secondary, one column, links that invert to the primary
688
- colour on hover, and a rule in the primary colour. Everything is a custom
689
- property on `:root`, so a site that only wants different colours overrides the
690
- half-dozen `--color-*` tokens rather than the stylesheet.
691
-
692
- The source is light only. The theme adds a second scheme under
693
- `@media (prefers-color-scheme: dark)` that redefines the same colour tokens on
694
- dark paper, with the rust and the blue lifted until they read on it, and
695
- `color-scheme: light dark` so a browser paints its own form controls and
696
- scrollbars to match. There is no toggle: the reader's system setting is the
697
- setting.
698
-
699
- Both schemes meet WCAG 2.2 AA — 4.5:1 for body text, 3:1 for large text, rules
700
- and focus outlines — and that is a test rather than a claim.
701
- `src/web/theme-colors.test.ts` reads the custom properties out of this
702
- stylesheet and computes the ratios for every pair the design puts on screen, so
703
- a colour changed here that breaks one fails the build.
704
-
705
- | Token | What it colours |
706
- | ------------------------- | -------------------------------------------------- |
707
- | `--color-body` | The paper, and the text of an inverted link. |
708
- | `--color-text` | The ink. |
709
- | `--color-primary` | Links, the rule, the focus outline, table headers. |
710
- | `--color-secondary` | Blockquote text and its border. |
711
- | `--color-base` | A warm sunk surface: rules between entries. |
712
- | `--color-base-2` | A cooler sunk surface. |
713
- | `--color-base-3` | The raised surface: the focused skip link, inputs. |
714
- | `--color-code-background` | Behind `code` and a fenced block. |
715
- | `--color-code-text` | Code with no highlighting on it. |
716
- | `--color-error` | A form field that will not do. |
820
+ Every colour is a custom property on `:root`, and these tokens are the
821
+ override contract: a site that only wants different colours redefines them in
822
+ its own stylesheet rather than replacing this one. Tailwind's own palette is
823
+ cleared in `src/style.css`, and its utilities (`text-ink`, `bg-paper`,
824
+ `border-edge` and the rest) are mapped onto these properties with
825
+ `@theme inline`, so redefining a token recolours every rule that uses it.
826
+
827
+ A second scheme under `@media (prefers-color-scheme: dark)` redefines the same
828
+ tokens on dark paper, and `color-scheme: light dark` lets a browser paint its
829
+ own form controls and scrollbars to match. There is no toggle: the reader's
830
+ system setting is the setting.
831
+
832
+ Both schemes meet the Colour contrast rule of the
833
+ [specification.website checklist](https://specification.website/checklist.md):
834
+ every run of text, the small kickers, dates and meta lines included, at 7:1
835
+ (WCAG 1.4.6, AAA) on whatever it sits on, and every border a reader relies on
836
+ (a field, a card, a code block, the focus outline, the citation and blockquote
837
+ rules) at 3:1 (1.4.11). Nothing a reader needs is drawn at reduced opacity.
838
+ That is a test rather than a claim: `src/web/theme-colors.test.ts` reads the
839
+ tokens out of the compiled stylesheet and computes the ratio of every pair the
840
+ design puts on screen, so a colour changed in `src/style.css` that breaks one
841
+ fails the build.
842
+
843
+ | Token | Utility | What it colours |
844
+ | ------------------------- | ---------- | ---------------------------------------------------------------------- |
845
+ | `--color-body` | `paper` | The paper, and the words on a submit button. |
846
+ | `--color-text` | `ink` | Body text and headlines. |
847
+ | `--color-muted` | `muted` | Kickers, dates, meta lines, decks, quotes, the bio note, the footer. |
848
+ | `--color-primary` | `accent` | Links, a kicker's kind word, the citation rule, the submit button. |
849
+ | `--color-secondary` | `accent-2` | Tag links and the focus outline. |
850
+ | `--color-rule` | `rule` | Decorative hairlines only: between entries, above the footer. |
851
+ | `--color-edge` | `edge` | Borders a reader relies on: fields, the previous and next cards, code. |
852
+ | `--color-base` | `surface` | A sunk surface: fields, notices, a face with no avatar. |
853
+ | `--color-base-2` | — | A cooler sunk surface. |
854
+ | `--color-base-3` | — | The raised surface: the focused skip link. |
855
+ | `--color-code-background` | `code` | Behind `code` and a fenced block. |
856
+ | `--color-code-text` | — | Code with no highlighting on it. |
857
+ | `--color-error` | `error` | A field that will not do, and what the form says about it. |
858
+
859
+ `--color-rule` is the one token in no contrast pair, because it is decoration:
860
+ a site that recolours it may make it as faint as it likes. Anything drawn with
861
+ `--color-edge` has to stay at 3:1 against the paper and the surface.
717
862
 
718
863
  ### Code highlighting
719
864
 
@@ -750,11 +895,12 @@ a different version of highlight.js than the one installed.
750
895
  **The palette is Tomorrow**, the one both of the sites this design comes from
751
896
  used: Chris Kempson's Tomorrow on light paper and Tomorrow Night on dark, from
752
897
  highlight.js's own base16 themes, which is also what decides which `hljs-`
753
- scope takes which slot. Five of the light colours are not the stock ones — a
754
- syntax theme is drawn for an editor, where a faint comment is a feature, and on
755
- this paper the stock greys, orange, yellow, green and teal read between 1.7:1
756
- and 3.5:1. Each was darkened along its own hue until it made 4.5:1 and no
757
- further; on the dark scheme only the `base0F` brown needed lifting. Every one
898
+ scope takes which slot. None of the light colours is the stock one: a syntax
899
+ theme is drawn for an editor, where a faint comment is a feature, and here code
900
+ is text like any other and meets 7:1 on its block, the bar every run of text
901
+ on the page meets. Each light colour was darkened along its own hue until it
902
+ did and no further, and four of the dark ones (the comment grey, the red, the
903
+ blue and the `base0F` brown) were lightened toward white the same way. Every one
758
904
  of them is in the contrast test beside the rest of the palette.
759
905
 
760
906
  | Token | base16 | What it colours |
@@ -871,7 +1017,11 @@ Every template gets:
871
1017
  | `site` | `content/_data/site.json`, if the site has one, over the defaults `title` and `url`. Any key in the file is readable, so `site.tagline`, `site.author` and anything else a site adds are all available. |
872
1018
  | `menus` | Every menu the site stores, by name, marked for this page: `menus.primary`, `menus.footer`, and any other name. See [Navigation](#navigation). |
873
1019
  | `siteAuthor` | Who the page is by, as a profile. **Absent** when nobody matches. See [Bylines and author archives](#bylines-and-author-archives). |
874
- | `icons` | The site's icons, as `{ rel, sizes, href }`. Empty until the site has an avatar to derive them from. See [The head](#the-head). |
1020
+ | `soloAuthor` | The site author's profile, on a site whose Solo author blog setting is on. **Absent** when it is off or the author setting names nobody. See [The bio](#the-bio). |
1021
+ | `icons` | The site's icons, as `{ rel, sizes, type, href }`. Empty until the site has an icon or an avatar to derive them from. See [The head](#the-head). |
1022
+ | `theme` | The colours the theme declares, as `{ colorScheme, themeColor: { light, dark } }`, each absent when no theme declares it. See [The manifest](#the-manifest). |
1023
+ | `shareImage` | The picture a shared link shows, as `{ url, alt, size: { width, height }, card }`. `size` is absent when it is not known. **Absent** when there is no picture. See [The head](#the-head). |
1024
+ | `llmsTxt` | The path of `/llms.txt`, while the site serves it. **Absent** when the site has turned it off. The default theme links it from the front page only. |
875
1025
 
876
1026
  A document — one post, one page, or one entry of a listing — adds:
877
1027
 
@@ -891,6 +1041,7 @@ A document — one post, one page, or one entry of a listing — adds:
891
1041
  | `page.inputPath` | The source file, relative to the content directory. |
892
1042
  | `type` | `post` or `page`. |
893
1043
  | `permalink`, `slug`, `draft`, `description` | Straight from the front matter. |
1044
+ | `lang` | The `lang` front matter as a canonical tag, such as `fr-CA`. Absent when it names none or no tag. |
894
1045
  | `author` | Who wrote it, as a profile rather than a string. See [Bylines and author archives](#bylines-and-author-archives). |
895
1046
  | `activityStreams` | The post's ActivityPub object id, absolute. Only on a rendered published post. |
896
1047
  | `previous` | The published post before this one by date, as `{ title, url }`. Absent on the oldest post. |
@@ -921,7 +1072,10 @@ archive — adds:
921
1072
  How many posts a listing page holds comes from `postsPerPage` in
922
1073
  `content/_data/site.json`, and defaults to 10. How many entries a feed holds
923
1074
  comes from `feedSize` in the same file, and defaults to 20. The `<html lang>`
924
- comes from `language`, and defaults to `en`. The rssCloud and WebSub server the
1075
+ comes from `language`, and defaults to `en`. A document whose `lang` names
1076
+ another language carries it on its own `<article>`, in an entry, a listing and
1077
+ search results alike, through `partials/lang.njk`, and the dates inside that
1078
+ article are written in it (see the `date` filter). The rssCloud and WebSub server the
925
1079
  feeds advertise comes from `notifyServer`, and is empty for none; the theme
926
1080
  writes nothing for it, because a cloud is advertised in the feed rather than on
927
1081
  the page.
@@ -1169,6 +1323,15 @@ site that has turned them off advertises nothing. The CMS also sends the same
1169
1323
  endpoint as a `Link` header on every representation of a document, so a sender
1170
1324
  that does not parse HTML still finds it.
1171
1325
 
1326
+ On the front page it also writes the site's index for language models:
1327
+
1328
+ ```html
1329
+ <link rel="describedby" type="text/markdown" href="/llms.txt" />
1330
+ ```
1331
+
1332
+ It comes from `llmsTxt` on the context, which is absent once the site turns the
1333
+ file off. The CMS sends the same link as a `Link` header on the front page.
1334
+
1172
1335
  `layouts/tag.njk` and `layouts/category.njk` override that block, call `super()`
1173
1336
  and add the archive's own three feeds:
1174
1337
 
@@ -1275,8 +1438,11 @@ The packaged partial draws each entry as the source design does: an
1275
1438
  `li.comment.h-entry` carrying `id="comment-{{ reply.id }}"` and a
1276
1439
  `comment-{{ reply.source }}` class, holding an `article.comment-body` whose
1277
1440
  `footer.comment-meta` is the author as a `.comment-author.vcard.p-author.h-card`
1278
- and the permalink as a `.comment-metadata` link around a `time.dt-published`,
1279
- then the words in `div.comment-content.e-content`. Answers nest in an
1441
+ (their avatar, or the first letter of their name in a `span.comment-avatar-empty`
1442
+ when there is none) and the permalink as a `.comment-metadata` link around a
1443
+ `time.dt-published`, with a `span.comment-source` saying "via webmention" or
1444
+ "via the fediverse" when it came that way, then the words in
1445
+ `div.comment-content.e-content`. Answers nest in an
1280
1446
  `ol.children` inside what they answer. A Reply link in a `div.reply` goes on the
1281
1447
  ones written here — `source == "comment"` — when the post is still open; it
1282
1448
  carries the comment's id to the form as `?reply_to=`, so threading needs no
@@ -1285,7 +1451,8 @@ webmention on the page that sent it, so neither gets one.
1285
1451
 
1286
1452
  Above the thread, `group()` draws one `div.reaction-group` per kind that has
1287
1453
  anything — `p-like`, `p-repost` and `p-mention`, the source theme's classes — as
1288
- an `h2.reaction-title` of the label and the count beside a `div.facepile` of
1454
+ an `h2.reaction-title` of the label and the count, in a `span.reaction-count`,
1455
+ beside a `div.facepile` of
1289
1456
  `a.u-url` faces. A face is a round `u-photo` avatar; a reaction whose author the
1290
1457
  site knows no picture of is the emoji badge of its kind and a `.reaction-name`
1291
1458
  instead. Likes and boosts link to the person, and a mention to the page it came
@@ -1352,7 +1519,10 @@ The packaged partial is a `section#respond.comment-respond` with an
1352
1519
  `h2.comment-reply-title`, one paragraph per field and the button in a
1353
1520
  `p.form-submit` — the source design's names, so the stylesheet styles every
1354
1521
  label, field and button of both forms through `.comment-respond` and a site
1355
- that has CSS for the WordPress theme can bring it.
1522
+ that has CSS for the WordPress theme can bring it. The name and email fields sit
1523
+ in a `div.field-row`, side by side on a wide screen, in both forms. On a site
1524
+ that takes webmentions a `p.comment-help` under the heading says a reader can
1525
+ reply from their own site instead.
1356
1526
 
1357
1527
  | Key | What it holds |
1358
1528
  | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1508,18 +1678,29 @@ the import above carries `with context`: a macro imported without it cannot see
1508
1678
 
1509
1679
  ## Filters
1510
1680
 
1511
- | Filter | What it does |
1512
- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1513
- | `date(format, zone)` | Formats a `Date` or a date string. `readable` (the default) gives `2 September 2026`, `html` gives `2026-09-02` for a `<time datetime>`, `year` gives `2026`, `month` gives `September 2026`, `iso` gives the full ISO 8601 instant. A date in a file is a UTC instant; every format but `iso` is rendered in the site's `timezone` setting, and `iso` stays the instant. Pass `zone` — an IANA name — to override the setting for one call. A value that is not a date renders as the empty string. |
1514
- | `url` | Prefixes a root-relative path with the base URL's path, so a site served from a subdirectory links correctly. Eleventy's filter of the same name. |
1515
- | `absoluteUrl` | The same path as a fully qualified URL against the site's `baseUrl`. |
1516
- | `asset` | The URL of a file in the theme's `static/` directory, with the base path in front and a hash of the file's bytes on the end: `{{ "style.css" \| asset }}` gives `/theme/style.css?v=3f2a9c01b7d4`. The file is found the way `/theme/` finds it, the chosen theme first. A hashed URL is served with `Cache-Control: public, max-age=31536000, immutable`, and editing the file changes the hash on the next render. A file no theme has gets its plain `/theme/` URL. |
1681
+ | Filter | What it does |
1682
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1683
+ | `date(format, zone, locale)` | Formats a `Date` or a date string. `long` gives `2 September 2026` on an `en` site and `2 septembre 2026` on an `fr` one; `full`, `medium` and `short` are Intl's other date styles, and `readable` (the default) is `long`. `month` gives `September 2026`, `year` gives `2026`, `html` gives `2026-09-02` for a `<time datetime>`, and `iso` gives the full ISO 8601 instant. Every format but `html` and `iso` is written in the site's locale: the `locale` setting, or the `language` when that is empty. Bare `en` writes the day first, as `en-GB` does; `en-US` gives `September 2, 2026`. A date in a file is a UTC instant; every format but `iso` is rendered in the site's `timezone` setting, and `iso` stays the instant. Pass `zone` — an IANA name — to override the setting for one call. Pass `locale` — a BCP 47 tag — to write the words in another language for one call; it comes after `zone`, so pass `none` for the zone to keep the site's: `{{ date \| date("readable", none, lang) }}`. An empty locale, or one that is no tag, writes in the site's. A value that is not a date renders as the empty string. |
1684
+ | `plural(forms, locale)` | Picks the form for a count by its plural category in the site's locale, through `Intl.PluralRules`: `{{ n \| plural({ one: "# reply", other: "# replies" }) }}`. The keys are the CLDR categories (`zero`, `one`, `two`, `few`, `many`, `other`), a category the forms leave out falls back to `other`, and `#` is the count in the locale's digits. Pass `locale` when the strings are in a language of their own: the default theme's words are English, so it passes `"en"`. |
1685
+ | `url` | Prefixes a root-relative path with the base URL's path, so a site served from a subdirectory links correctly. Eleventy's filter of the same name. |
1686
+ | `absoluteUrl` | The same path as a fully qualified URL against the site's `baseUrl`. |
1687
+ | `asset` | The URL of a file in the theme's `static/` directory, with the base path in front and a hash of the file's bytes on the end: `{{ "style.css" \| asset }}` gives `/theme/style.css?v=3f2a9c01b7d4`. The file is found the way `/theme/` finds it, the chosen theme first. A hashed URL is served with `Cache-Control: public, max-age=31536000, immutable`, and editing the file changes the hash on the next render. A file no theme has gets its plain `/theme/` URL. |
1517
1688
 
1518
1689
  `date` reads one word rather than parsing it: `{{ "now" | date("year") }}` is
1519
1690
  the year at the moment the page is rendered, in the site's own timezone. It is
1520
1691
  what the footer's copyright line is written with, and it is the one date a page
1521
1692
  has that no file carries.
1522
1693
 
1694
+ The default theme passes the document's `lang` as that locale for every date
1695
+ inside its `<article>`: the Published and Updated lines, the hidden heading of
1696
+ a note, the kicker in a listing or a search result (`kicker.line(…,
1697
+ lang=post.lang)`), and the date of the post a reply answers. So a post that
1698
+ says `lang: fr` on an `en` site reads `Published 2 septembre 2026`, while the
1699
+ navigation, the conversation under it, the archive's month headings and the
1700
+ footer stay in the site's locale. A theme that wants the same passes `lang`
1701
+ on an entry and `post.lang` in a listing; one that passes nothing writes every
1702
+ date in the site's locale, as before.
1703
+
1523
1704
  Nunjucks' own filters — `default`, `join`, `urlencode`, `safe` and the rest —
1524
1705
  are all available. Autoescaping is on, so rendered Markdown is the one thing
1525
1706
  that has to be passed through `safe`.