@geekity/cms 0.9.0 → 0.11.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 (190) hide show
  1. package/README.md +86 -24
  2. package/admin/components/admin-bar.njk +26 -0
  3. package/admin/components/fields.njk +55 -12
  4. package/admin/components/public-admin-bar.njk +41 -0
  5. package/admin/layouts/base.njk +6 -1
  6. package/admin/layouts/settings-page.njk +7 -2
  7. package/admin/layouts/shell.njk +9 -20
  8. package/admin/pages/account/forgot.njk +1 -1
  9. package/admin/pages/account/login.njk +1 -1
  10. package/admin/pages/account/reset.njk +4 -2
  11. package/admin/pages/account/setup.njk +1 -1
  12. package/admin/pages/dashboard/home.njk +1 -0
  13. package/admin/pages/documents/list.njk +1 -0
  14. package/admin/pages/documents/taxonomy.njk +1 -0
  15. package/admin/pages/federation/followers.njk +3 -0
  16. package/admin/pages/federation/settings.njk +5 -0
  17. package/admin/pages/media/library.njk +39 -0
  18. package/admin/pages/navigation/menus.njk +6 -1
  19. package/admin/pages/settings/discussion.njk +7 -0
  20. package/admin/pages/settings/email.njk +10 -0
  21. package/admin/pages/settings/general.njk +7 -0
  22. package/admin/pages/settings/permalinks.njk +6 -0
  23. package/admin/pages/settings/reading.njk +7 -0
  24. package/admin/pages/tools/content-index.njk +1 -0
  25. package/admin/pages/tools/personal-data.njk +5 -0
  26. package/admin/pages/users/edit.njk +7 -5
  27. package/admin/pages/users/list.njk +1 -0
  28. package/admin/pages/users/new.njk +5 -1
  29. package/admin/static/admin-bar.css +153 -0
  30. package/admin/static/admin.css +95 -96
  31. package/dist/admin/comments.js +2 -1
  32. package/dist/admin/comments.js.map +1 -1
  33. package/dist/admin/documents.d.ts.map +1 -1
  34. package/dist/admin/documents.js +18 -0
  35. package/dist/admin/documents.js.map +1 -1
  36. package/dist/admin/index.d.ts +1 -1
  37. package/dist/admin/index.d.ts.map +1 -1
  38. package/dist/admin/index.js.map +1 -1
  39. package/dist/admin/media.d.ts +13 -2
  40. package/dist/admin/media.d.ts.map +1 -1
  41. package/dist/admin/media.js +58 -3
  42. package/dist/admin/media.js.map +1 -1
  43. package/dist/admin/personal-data.d.ts.map +1 -1
  44. package/dist/admin/personal-data.js +5 -1
  45. package/dist/admin/personal-data.js.map +1 -1
  46. package/dist/admin/preview.d.ts.map +1 -1
  47. package/dist/admin/preview.js +1 -1
  48. package/dist/admin/preview.js.map +1 -1
  49. package/dist/admin/routes.d.ts.map +1 -1
  50. package/dist/admin/routes.js +2 -1
  51. package/dist/admin/routes.js.map +1 -1
  52. package/dist/admin/store.d.ts +36 -1
  53. package/dist/admin/store.d.ts.map +1 -1
  54. package/dist/admin/store.js +63 -1
  55. package/dist/admin/store.js.map +1 -1
  56. package/dist/admin/templates.d.ts +1 -0
  57. package/dist/admin/templates.d.ts.map +1 -1
  58. package/dist/admin/templates.js +1 -0
  59. package/dist/admin/templates.js.map +1 -1
  60. package/dist/admin/tools.d.ts +2 -0
  61. package/dist/admin/tools.d.ts.map +1 -1
  62. package/dist/admin/tools.js +3 -2
  63. package/dist/admin/tools.js.map +1 -1
  64. package/dist/admin/uploads.d.ts +5 -2
  65. package/dist/admin/uploads.d.ts.map +1 -1
  66. package/dist/admin/uploads.js +6 -2
  67. package/dist/admin/uploads.js.map +1 -1
  68. package/dist/comments/index.d.ts +2 -2
  69. package/dist/comments/index.d.ts.map +1 -1
  70. package/dist/comments/index.js +1 -1
  71. package/dist/comments/index.js.map +1 -1
  72. package/dist/comments/records.d.ts +43 -2
  73. package/dist/comments/records.d.ts.map +1 -1
  74. package/dist/comments/records.js +201 -54
  75. package/dist/comments/records.js.map +1 -1
  76. package/dist/comments/routes.js +1 -1
  77. package/dist/comments/routes.js.map +1 -1
  78. package/dist/comments/viewer.d.ts +13 -0
  79. package/dist/comments/viewer.d.ts.map +1 -1
  80. package/dist/comments/viewer.js +13 -1
  81. package/dist/comments/viewer.js.map +1 -1
  82. package/dist/config.d.ts +21 -0
  83. package/dist/config.d.ts.map +1 -1
  84. package/dist/config.js +2 -0
  85. package/dist/config.js.map +1 -1
  86. package/dist/env.d.ts +19 -0
  87. package/dist/env.d.ts.map +1 -1
  88. package/dist/federation/article.d.ts.map +1 -1
  89. package/dist/federation/article.js +37 -1
  90. package/dist/federation/article.js.map +1 -1
  91. package/dist/federation/delivery.d.ts +3 -1
  92. package/dist/federation/delivery.d.ts.map +1 -1
  93. package/dist/federation/delivery.js +6 -1
  94. package/dist/federation/delivery.js.map +1 -1
  95. package/dist/federation/federation.d.ts +3 -0
  96. package/dist/federation/federation.d.ts.map +1 -1
  97. package/dist/federation/federation.js.map +1 -1
  98. package/dist/federation/inbox.d.ts +7 -5
  99. package/dist/federation/inbox.d.ts.map +1 -1
  100. package/dist/federation/inbox.js +15 -32
  101. package/dist/federation/inbox.js.map +1 -1
  102. package/dist/federation/index.d.ts +2 -0
  103. package/dist/federation/index.d.ts.map +1 -1
  104. package/dist/federation/index.js +1 -0
  105. package/dist/federation/index.js.map +1 -1
  106. package/dist/federation/mount.js +6 -1
  107. package/dist/federation/mount.js.map +1 -1
  108. package/dist/federation/profiles.d.ts +95 -0
  109. package/dist/federation/profiles.d.ts.map +1 -0
  110. package/dist/federation/profiles.js +171 -0
  111. package/dist/federation/profiles.js.map +1 -0
  112. package/dist/federation/relays.d.ts +3 -1
  113. package/dist/federation/relays.d.ts.map +1 -1
  114. package/dist/federation/relays.js +6 -1
  115. package/dist/federation/relays.js.map +1 -1
  116. package/dist/federation/replies.d.ts +13 -4
  117. package/dist/federation/replies.d.ts.map +1 -1
  118. package/dist/federation/replies.js +25 -6
  119. package/dist/federation/replies.js.map +1 -1
  120. package/dist/images/alt-text.d.ts +51 -0
  121. package/dist/images/alt-text.d.ts.map +1 -0
  122. package/dist/images/alt-text.js +113 -0
  123. package/dist/images/alt-text.js.map +1 -0
  124. package/dist/images/icons.d.ts.map +1 -1
  125. package/dist/images/icons.js +3 -2
  126. package/dist/images/icons.js.map +1 -1
  127. package/dist/images/index.d.ts +2 -2
  128. package/dist/images/index.d.ts.map +1 -1
  129. package/dist/images/markup.d.ts +26 -4
  130. package/dist/images/markup.d.ts.map +1 -1
  131. package/dist/images/markup.js +36 -15
  132. package/dist/images/markup.js.map +1 -1
  133. package/dist/images/paths.d.ts +11 -0
  134. package/dist/images/paths.d.ts.map +1 -1
  135. package/dist/images/paths.js +17 -1
  136. package/dist/images/paths.js.map +1 -1
  137. package/dist/images/variants.d.ts +13 -3
  138. package/dist/images/variants.d.ts.map +1 -1
  139. package/dist/images/variants.js +13 -11
  140. package/dist/images/variants.js.map +1 -1
  141. package/dist/index.d.ts +11 -5
  142. package/dist/index.d.ts.map +1 -1
  143. package/dist/index.js +50 -11
  144. package/dist/index.js.map +1 -1
  145. package/dist/notifications/routes.js +1 -1
  146. package/dist/notifications/routes.js.map +1 -1
  147. package/dist/privacy/erase.d.ts.map +1 -1
  148. package/dist/privacy/erase.js +2 -3
  149. package/dist/privacy/erase.js.map +1 -1
  150. package/dist/privacy/retention.js +1 -1
  151. package/dist/privacy/retention.js.map +1 -1
  152. package/dist/web/admin-bar.d.ts +21 -0
  153. package/dist/web/admin-bar.d.ts.map +1 -0
  154. package/dist/web/admin-bar.js +145 -0
  155. package/dist/web/admin-bar.js.map +1 -0
  156. package/dist/web/assets.d.ts +29 -1
  157. package/dist/web/assets.d.ts.map +1 -1
  158. package/dist/web/assets.js +48 -3
  159. package/dist/web/assets.js.map +1 -1
  160. package/dist/web/compression.d.ts +19 -0
  161. package/dist/web/compression.d.ts.map +1 -0
  162. package/dist/web/compression.js +160 -0
  163. package/dist/web/compression.js.map +1 -0
  164. package/dist/web/context.d.ts +5 -1
  165. package/dist/web/context.d.ts.map +1 -1
  166. package/dist/web/context.js +5 -2
  167. package/dist/web/context.js.map +1 -1
  168. package/dist/web/conversation.d.ts.map +1 -1
  169. package/dist/web/conversation.js +13 -12
  170. package/dist/web/conversation.js.map +1 -1
  171. package/dist/web/render.d.ts +27 -0
  172. package/dist/web/render.d.ts.map +1 -1
  173. package/dist/web/render.js +77 -38
  174. package/dist/web/render.js.map +1 -1
  175. package/dist/web/routes.d.ts.map +1 -1
  176. package/dist/web/routes.js +63 -28
  177. package/dist/web/routes.js.map +1 -1
  178. package/dist/web/templates.d.ts.map +1 -1
  179. package/dist/web/templates.js +9 -2
  180. package/dist/web/templates.js.map +1 -1
  181. package/dist/webmention/service.d.ts.map +1 -1
  182. package/dist/webmention/service.js +5 -1
  183. package/dist/webmention/service.js.map +1 -1
  184. package/package.json +8 -8
  185. package/themes/default/README.md +176 -19
  186. package/themes/default/layouts/base.njk +51 -10
  187. package/themes/default/layouts/post.njk +10 -1
  188. package/themes/default/partials/comment-form.njk +33 -14
  189. package/themes/default/partials/contact-form.njk +32 -14
  190. package/themes/default/static/style.css +109 -24
package/README.md CHANGED
@@ -283,6 +283,7 @@ directory; absolute ones are used as given.
283
283
  | `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
284
284
  | `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
285
285
  | `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
286
+ | `compression` | `true` | `GEEKITY_COMPRESSION` | Compress text responses with brotli or gzip. Turn it off behind a proxy that compresses. See [Compression](#compression). |
286
287
  | `securityHeaders` | [see below](#security-headers) | — | Headers every response carries. A string replaces a default or adds a header, `false` removes one. |
287
288
  | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
288
289
  | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
@@ -364,6 +365,7 @@ the same directory reads all of it, and everything in it is meant to be public:
364
365
  | `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
365
366
  | `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
366
367
  | `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
368
+ | `data/comments/{slug}.json` | The emails of that post's commenters, and whether each asked to hear about replies, keyed by comment id. Mode `0600`. |
367
369
  | `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
368
370
  | `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
369
371
  | `data/notification-secret` | What signs the one-click links in a notification. Mode `0600`. Losing it kills every link already in an inbox and nothing else. |
@@ -979,7 +981,12 @@ comments — id, source, kind, status, author, the Markdown and the HTML it
979
981
  rendered to, when it was submitted, a salted hash of the address, and what it
980
982
  answers — so they are in git beside the posts, an Eleventy build of the same
981
983
  directory shows them, and the `comments` table is an index emptied and read
982
- back on every boot.
984
+ back on every boot. The commenter's email is not in that file: it is in
985
+ `data/comments/{slug}.json` (mode `0600`), keyed by comment id, because
986
+ everything under `content/` is published and goes into git. A site upgrading
987
+ from a version that kept the email in the comment file has it moved there on
988
+ the next boot, and so does an email somebody types into a comment file by
989
+ hand.
983
990
 
984
991
  Three settings and one front-matter key decide whether a post is still taking
985
992
  them, and they are read in one place so the form and the endpoint can never
@@ -1040,7 +1047,7 @@ on one comment, lasts a week, and is spent the first time it is used.
1040
1047
 
1041
1048
  Commenters get the other half. The form offers "email me when somebody replies
1042
1049
  to this", but only on a site that can actually send mail; ticking it stores
1043
- `notify: true` beside the address that is already in the comment file, and
1050
+ `notify: true` beside the commenter's address in `data/comments/{slug}.json`, and
1044
1051
  nothing about either is ever rendered — not on the page, not in the JSON or
1045
1052
  Markdown representations, and not in the comments feeds. When a reply to that
1046
1053
  comment is **approved**, one message goes out with the reply in it and an
@@ -1396,22 +1403,23 @@ This is every piece of personal data the CMS stores, where it is, and how long
1396
1403
  it stays. "Kept" means until somebody deletes it, unless a retention period
1397
1404
  says otherwise.
1398
1405
 
1399
- | What | Where | How long |
1400
- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
1401
- | A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git | Kept. Erasing on request replaces the name and drops the website. |
1402
- | A commenter's email, and whether they asked to be told about replies | `content/_data/comments/{slug}.json`, **never shown**, but in git | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
1403
- | A salted hash of the address a comment, webmention or contact message came from (the address itself is never stored) | The comment file, or the contact message file | `addressHashRetentionDays`: forever unless set, 30 on a new site. |
1404
- | A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json` | Kept, as a copy of a page that is public already. |
1405
- | A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600` | `contactMessageRetentionDays`: forever unless set, 365 on a new site. |
1406
- | The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600` | Kept, so the site goes on not writing to them. Erasing on request removes one. |
1407
- | Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
1408
- | Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
1409
- | What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
1410
- | Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
1411
- | Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
1412
- | An index of all of the above, and sessions, reset tokens and spent link tokens | `data/geekity.db` | A cache of the files. Sessions and tokens are pruned when they expire. |
1413
- | Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
1414
- | Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
1406
+ | What | Where | How long |
1407
+ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------ |
1408
+ | A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git | Kept. Erasing on request replaces the name and drops the website. |
1409
+ | A commenter's email, and whether they asked to be told about replies | `data/comments/{slug}.json`, mode `0600`, keyed by comment id | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
1410
+ | A salted hash of the address a comment, webmention or contact message came from (the address itself is never stored) | The comment file, or the contact message file | `addressHashRetentionDays`: forever unless set, 30 on a new site. |
1411
+ | A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json` | Kept, as a copy of a page that is public already. |
1412
+ | A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600` | `contactMessageRetentionDays`: forever unless set, 365 on a new site. |
1413
+ | The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600` | Kept, so the site goes on not writing to them. Erasing on request removes one. |
1414
+ | Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
1415
+ | Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
1416
+ | What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
1417
+ | Other actors who liked, boosted, answered or quoted: handle, display name, avatar URL, profile URL | `data/geekity.db`, fetched from their actor document | A cache. Refetched weekly while the inbox log names them. |
1418
+ | Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
1419
+ | Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
1420
+ | An index of all of the above, and sessions, reset tokens and spent link tokens | `data/geekity.db` | A cache of the files. Sessions and tokens are pruned when they expire. |
1421
+ | Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
1422
+ | Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
1415
1423
 
1416
1424
  Two services outside the site see personal data when a site turns them on.
1417
1425
  Akismet is sent a commenter's or sender's address, user agent, referrer, name,
@@ -1475,9 +1483,11 @@ skip the queue. Its author is no longer told about replies, and their next
1475
1483
  comment waits for a moderator. A moderation link in an inbox names the comment
1476
1484
  by id, so it goes on working.
1477
1485
 
1478
- The sweep changes the files as they are now. A comment file in git keeps its
1479
- history, so an email committed before it was removed is still in the
1480
- repository's history until that history is rewritten.
1486
+ The sweep changes the files as they are now. The emails are in `data/`, which
1487
+ is not in git, so removing one removes it. Emails from before they moved out of
1488
+ the comment files (TASK-182) were committed with those files, and they stay in
1489
+ the repository's history until that history is rewritten, whatever the sweep or
1490
+ an erasure does to the files today.
1481
1491
 
1482
1492
  ### Erasing one person's data
1483
1493
 
@@ -1666,6 +1676,7 @@ shadow the login form.
1666
1676
  | `/admin/media` | Everything under `content/uploads`, with the URL, the Markdown and what uses it. |
1667
1677
  | `/admin/media/upload` | `POST` only. Stores one file by the rules the editor's upload enforces. |
1668
1678
  | `/admin/media/delete` | `POST` only. Deletes one upload, asking first when a document points at it. |
1679
+ | `/admin/media/alt` | `POST` only. Sets a picture's alt text, or marks it decorative, in `media.json`. |
1669
1680
  | `/admin/comments` | Pending, approved and spam, with approve, spam, delete and reply. |
1670
1681
  | `/admin/comments/moderate` | `POST` only. Approves one comment, files it as spam, or deletes it. |
1671
1682
  | `/admin/comments/reply` | `POST` only. Posts an approved reply under the comment it answers. |
@@ -1920,8 +1931,9 @@ written by an Eleventy build is on the screen without a restart, and one
1920
1931
  removed the same way is off it. The only question the index is asked is which
1921
1932
  documents mention a URL, which is the one thing a directory cannot answer.
1922
1933
 
1923
- Each row carries the public URL and the ready-made Markdown — `![name](url)`
1924
- for a picture and `[name](url)` for anything else, from the same function the
1934
+ Each row carries the public URL and the ready-made Markdown — `![alt](url)`
1935
+ for a picture, with its alt text from the library or empty when it has none or
1936
+ is decorative, and `[name](url)` for anything else, from the same function the
1925
1937
  editor's upload control pastes from — as readonly text fields, which select and
1926
1938
  copy on their own. `admin/static/copy.js` reveals a Copy button beside each and
1927
1939
  does nothing else, so the screen is complete before it loads and a browser
@@ -1946,7 +1958,17 @@ name a file elsewhere in the content directory.
1946
1958
  content-relative path once the original is gone. The original under
1947
1959
  `content/uploads` is the only source of truth (decision-10) and anything
1948
1960
  generated from it is derived state that must not outlive it; the hook defaults
1949
- to `removeImageVariants`, which takes the file's derived images with it.
1961
+ to `removeImageVariants`, which takes the file's derived images with it. The
1962
+ file's entry in `content/_data/media.json` goes too.
1963
+
1964
+ A picture's row also carries its alt text: a text field, a Decorative checkbox
1965
+ that wins over the text, and a line saying which of the two the library holds
1966
+ or that it holds neither. The form posts to `/admin/media/alt`, which writes
1967
+ `content/_data/media.json` atomically, keeping every other entry. The file is
1968
+ the truth (decision-9) and is read on each request, so an entry written by hand
1969
+ shows up without a restart. `readAltTexts(contentDir)` and
1970
+ `undescribedImages(html, library)` in `src/images/alt-text.ts` are what the
1971
+ editor's publish check and the federated `Image` attachments read.
1950
1972
 
1951
1973
  ### Image variants
1952
1974
 
@@ -2240,6 +2262,46 @@ uploads. Four directives say more than that:
2240
2262
  sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
2241
2263
  the admin page itself.
2242
2264
 
2265
+ ### Compression
2266
+
2267
+ Text responses are compressed on the way out: HTML, CSS, JavaScript, feeds,
2268
+ JSON, Markdown, SVG, sitemaps and anything else whose media type is `text/*`,
2269
+ `+json` or `+xml`. A client that accepts brotli (`br`) gets brotli, at quality
2270
+ 5 because every page is built per request; one that accepts only gzip gets
2271
+ gzip; one that accepts neither gets the plain bytes.
2272
+
2273
+ Some responses go out as they are:
2274
+
2275
+ - Images, video, WOFF and WOFF2 fonts and other formats that carry their own
2276
+ compression. Encoding them again costs CPU and can make them bigger.
2277
+ - Bodies under 1024 bytes, where the saving is smaller than the cost.
2278
+ - A response that already has a `Content-Encoding`, or whose `Cache-Control`
2279
+ says `no-transform`.
2280
+ - A response that may hold a secret: anything whose `Cache-Control` says
2281
+ `private` or `no-store`, which every page drawn for somebody signed in does,
2282
+ and every admin screen under `/admin`, the setup and login forms included.
2283
+ These carry CSRF tokens next to text a visitor can influence, and compressing
2284
+ them would let an attacker who watches response sizes recover the token a
2285
+ byte at a time (the BREACH attack). Their `Vary` is left as it was. The
2286
+ admin's static files under `/admin/_static/` hold no secret and are
2287
+ compressed. Error pages say `no-store`, so they go out plain too.
2288
+
2289
+ Every other text response carries `Vary: Accept-Encoding`, whether it was
2290
+ compressed or not, and so does every `304`. A cache in front of the site
2291
+ therefore keeps a copy per encoding and never hands brotli to a client that
2292
+ cannot decode it.
2293
+
2294
+ A compressed response's `ETag` is the plain response's with `W/` in front: the
2295
+ bytes differ, but the representation is the same. `If-None-Match` compares
2296
+ weakly, so a browser holding the brotli copy and a feed reader holding the gzip
2297
+ copy both revalidate to `304`, and each gets back the validator it sent.
2298
+
2299
+ `compression: false`, or `GEEKITY_COMPRESSION=false`, turns all of this off.
2300
+ Turn it off when a reverse proxy or CDN in front of the site compresses
2301
+ responses itself (Caddy's `encode`, nginx's `gzip on`, Cloudflare), so the work
2302
+ is done once and the proxy's settings apply. Leave it on when nothing in front
2303
+ compresses, as in the Docker deployment the repository documents.
2304
+
2243
2305
  ### security.txt and change-password
2244
2306
 
2245
2307
  `/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))
@@ -0,0 +1,26 @@
1
+ {# The admin bar, in the admin and across the top of the public site alike
2
+ (TASK-126, TASK-183). The site title leads to the dashboard; `barLinks` are
3
+ the shortcuts beside it, which each place chooses for itself; the account
4
+ menu ends it.
5
+
6
+ WordPress's "Howdy," menu, greeting the way this site's owner does. A
7
+ popover rather than a script: the browser opens and closes it, shuts it on
8
+ Escape or a click elsewhere, and tells a screen reader whether it is
9
+ expanded, all under the admin CSP's script-src 'self'. #}
10
+ <nav class="admin-bar" aria-label="Admin bar">
11
+ <a class="admin-bar-site" href="{{ adminUrl }}">{{ site.title }}</a>
12
+ {% for link in barLinks %}
13
+ <a href="{{ link.url }}">{{ link.label }}</a>
14
+ {% endfor %}
15
+ <span class="admin-bar-spacer"></span>
16
+ {% if me %}
17
+ <button type="button" class="admin-bar-account" popovertarget="admin-account-menu">Hoopla! {{ me.name }}</button>
18
+ <div id="admin-account-menu" class="admin-account-menu" popover>
19
+ <a href="{{ me.url }}">Edit profile</a>
20
+ <form method="post" action="{{ logoutUrl }}">
21
+ <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
22
+ <button type="submit">Log out</button>
23
+ </form>
24
+ </div>
25
+ {% endif %}
26
+ </nav>
@@ -34,9 +34,9 @@
34
34
  with. `errorHtml` is the rare second thing a field has to say — the Reading
35
35
  page's "that page is no longer published" — already written as its own
36
36
  paragraph. #}
37
- {% macro _below(hint, error, errorHtml) %}
37
+ {% macro _below(id, hint, error, errorHtml) %}
38
38
  {% if error %}
39
- <p class="admin-field-error">{{ error }}</p>
39
+ <p class="admin-field-error" id="{{ id }}-error">{{ error }}</p>
40
40
  {% endif %}
41
41
  {% if errorHtml %}
42
42
  {{ errorHtml | safe }}
@@ -46,6 +46,14 @@
46
46
  {% endif %}
47
47
  {% endmacro %}
48
48
 
49
+ {# What a control says about itself when the form refused it: that it is
50
+ invalid, and which paragraph says why, so a screen reader reads the message
51
+ with the field rather than leaving it to be found. `errorHtml` is not named
52
+ here: it is a standing note about the stored value, not a refusal. #}
53
+ {% macro _invalid(id, error) -%}
54
+ {% if error %} aria-invalid="true" aria-describedby="{{ id }}-error"{% endif %}
55
+ {%- endmacro %}
56
+
49
57
  {# A box somebody types into: text, email, number, or whatever `type` says.
50
58
 
51
59
  `value` is always written, even empty, so the control's state is on the page
@@ -71,8 +79,8 @@
71
79
  disabled=false
72
80
  ) %}
73
81
  <label for="{{ id }}">{{ label | safe }}</label>
74
- <input id="{{ id }}" name="{{ name }}" type="{{ type }}"{% if min %} min="{{ min }}"{% endif %}{% if step %} step="{{ step }}"{% endif %}{% if inputmode %} inputmode="{{ inputmode }}"{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} value="{{ value }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if readonly %} readonly{% endif %}{% if disabled %} disabled{% endif %} />
75
- {{ _below(hint, error, errorHtml) }}
82
+ <input id="{{ id }}" name="{{ name }}" type="{{ type }}"{% if min %} min="{{ min }}"{% endif %}{% if step %} step="{{ step }}"{% endif %}{% if inputmode %} inputmode="{{ inputmode }}"{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} value="{{ value }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if readonly %} readonly{% endif %}{% if disabled %} disabled{% endif %}{{ _invalid(id, error) }} />
83
+ {{ _below(id, hint, error, errorHtml) }}
76
84
  {%- endmacro %}
77
85
 
78
86
  {# A password box. It carries `value=""` and no way to set one: nothing typed
@@ -93,8 +101,8 @@
93
101
  autofocus=false
94
102
  ) %}
95
103
  <label for="{{ id }}">{{ label | safe }}</label>
96
- <input id="{{ id }}" name="{{ name }}" type="password" value=""{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %} />
97
- {{ _below(hint, error, errorHtml) }}
104
+ <input id="{{ id }}" name="{{ name }}" type="password" value=""{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{{ _invalid(id, error) }} />
105
+ {{ _below(id, hint, error, errorHtml) }}
98
106
  {%- endmacro %}
99
107
 
100
108
  {# A box of several lines. #}
@@ -114,8 +122,8 @@
114
122
  disabled=false
115
123
  ) %}
116
124
  <label for="{{ id }}">{{ label | safe }}</label>
117
- <textarea id="{{ id }}" name="{{ name }}"{% if rows %} rows="{{ rows }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if readonly %} readonly{% endif %}{% if disabled %} disabled{% endif %}>{{ value }}</textarea>
118
- {{ _below(hint, error, errorHtml) }}
125
+ <textarea id="{{ id }}" name="{{ name }}"{% if rows %} rows="{{ rows }}"{% endif %}{% if spellcheck %} spellcheck="{{ spellcheck }}"{% endif %}{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if readonly %} readonly{% endif %}{% if disabled %} disabled{% endif %}{{ _invalid(id, error) }}>{{ value }}</textarea>
126
+ {{ _below(id, hint, error, errorHtml) }}
119
127
  {%- endmacro %}
120
128
 
121
129
  {# A list to pick from. The options are the call's body, because which of them
@@ -137,10 +145,10 @@
137
145
  disabled=false
138
146
  ) %}
139
147
  <label for="{{ id }}">{{ label | safe }}</label>
140
- <select id="{{ id }}" name="{{ name }}"{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if disabled %} disabled{% endif %}>
148
+ <select id="{{ id }}" name="{{ name }}"{% if required %} required{% endif %}{% if autofocus %} autofocus{% endif %}{% if disabled %} disabled{% endif %}{{ _invalid(id, error) }}>
141
149
  {{ caller() }}
142
150
  </select>
143
- {{ _below(hint, error, errorHtml) }}
151
+ {{ _below(id, hint, error, errorHtml) }}
144
152
  {%- endmacro %}
145
153
 
146
154
  {# A switch. The label follows the box rather than heading it, which is what a
@@ -158,8 +166,43 @@
158
166
  disabled=false
159
167
  ) %}
160
168
  <p class="admin-check">
161
- <input id="{{ id }}" name="{{ name }}" type="checkbox" value="{{ value }}"{% if checked %} checked{% endif %}{% if disabled %} disabled{% endif %} />
169
+ <input id="{{ id }}" name="{{ name }}" type="checkbox" value="{{ value }}"{% if checked %} checked{% endif %}{% if disabled %} disabled{% endif %}{{ _invalid(id, error) }} />
162
170
  <label for="{{ id }}">{{ label | safe }}</label>
163
171
  </p>
164
- {{ _below(hint, error, errorHtml) }}
172
+ {{ _below(id, hint, error, errorHtml) }}
173
+ {%- endmacro %}
174
+
175
+ {# The top of a refused form: what went wrong, and a link to every field that
176
+ needs putting right. It is an alert and takes focus when the page loads, so
177
+ a screen reader hears it at once and a keyboard starts from it, with no
178
+ script. It comes before the form, so its `autofocus` is the first on the
179
+ page and wins over any a field carries.
180
+
181
+ A form with one thing to say calls it bare, with the message as the heading.
182
+ A form with fields to fix calls it with a `problem` per field; one given no
183
+ message writes nothing, so a page lists every field it could fault:
184
+
185
+ {% call field.summary('Nothing was saved. Fix the fields marked below.') %}
186
+ {{ field.problem('settings-title', problems.title) }}
187
+ {% endcall %}
188
+
189
+ Only one form on a page is ever refused at a time, which is why the ids are
190
+ fixed. #}
191
+ {% macro summary(heading) %}
192
+ {% set problems = (caller() if caller else '') | trim %}
193
+ <div class="admin-error admin-error-summary" role="alert" tabindex="-1" autofocus aria-labelledby="admin-error-summary-heading">
194
+ <h2 id="admin-error-summary-heading">{{ heading }}</h2>
195
+ {% if problems %}
196
+ <ul>
197
+ {{ problems | safe }}
198
+ </ul>
199
+ {% endif %}
200
+ </div>
201
+ {%- endmacro %}
202
+
203
+ {# One field's line in the summary: its message, linking to the field. #}
204
+ {% macro problem(id, message) %}
205
+ {% if message %}
206
+ <li><a href="#{{ id }}">{{ message }}</a></li>
207
+ {% endif %}
165
208
  {%- endmacro %}
@@ -0,0 +1,41 @@
1
+ {# The admin bar as the public site draws it for a signed-in user (TASK-183),
2
+ put straight after the theme's <body> tag.
3
+
4
+ Whatever theme the site wears has never heard of it, so it keeps to a shadow
5
+ root: no selector of the theme's reaches the markup inside, and the
6
+ stylesheet inside reaches nothing outside. The stylesheet is inlined rather
7
+ than linked so the bar is never drawn unstyled; the public site has no
8
+ content CSP to refuse it.
9
+
10
+ The host is fixed to the top of the window, as WordPress's is. In normal
11
+ flow it sat inside whatever padding or margin the theme gave <body>, so it
12
+ stopped short of the window's edges. Out of flow, nothing the theme does to
13
+ <body> moves it, and the inline reset keeps the theme's rules for the host
14
+ element itself, a `body > *` or a grid on <body>, from restyling it; only an
15
+ `!important` rule can, which is also how a theme that insists hides it. The
16
+ page is pushed down from outside, by a stylesheet the CMS puts in <head>
17
+ (OFFSET_STYLE in src/web/admin-bar.ts) reading --geekity-admin-bar-height.
18
+
19
+ The script does two things. It measures the bar and sets that height on
20
+ <html>, so a bar that wraps to two lines on a phone, or one a theme has
21
+ hidden, pushes the page down by what it really is. And it gives focus back
22
+ to the greeting when the menu closes with focus inside it: Chrome does that
23
+ for a popover in the light DOM, not from inside a shadow root, where the
24
+ focus is simply lost. A click elsewhere has already moved focus by then,
25
+ and keeps it. #}
26
+ <geekity-admin-bar style="all: initial; display: block; position: fixed; top: 0; left: 0; right: 0; z-index: 99999"><template shadowrootmode="open"><style>{% include "static/admin-bar.css" %}</style>{% include "components/admin-bar.njk" %}<script>
27
+ (() => {
28
+ const host = document.querySelector('geekity-admin-bar');
29
+ const root = host.shadowRoot;
30
+ new ResizeObserver(() => {
31
+ const height = host.getBoundingClientRect().height;
32
+ document.documentElement.style.setProperty('--geekity-admin-bar-height', `${height}px`);
33
+ }).observe(host);
34
+ const menu = root.getElementById('admin-account-menu');
35
+ menu?.addEventListener('beforetoggle', (event) => {
36
+ if (event.newState === 'closed' && menu.contains(root.activeElement)) {
37
+ setTimeout(() => root.querySelector('.admin-bar-account').focus());
38
+ }
39
+ });
40
+ })();
41
+ </script></template></geekity-admin-bar>
@@ -13,12 +13,17 @@
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1" />
14
14
  <title>{% block title %}Admin{% endblock %}{% if site.title %} · {{ site.title }}{% endif %}</title>
15
15
  <meta name="robots" content="noindex, nofollow" />
16
+ <link rel="stylesheet" href="{{ assetPrefix }}admin-bar.css" />
16
17
  <link rel="stylesheet" href="{{ assetPrefix }}admin.css" />
17
18
  </head>
18
19
  <body>
20
+ {# First on every screen, so a keyboard gets past the bar and the menu
21
+ in one key. It moves no focus of its own: a refused form's summary
22
+ still takes focus on load (TASK-142). #}
23
+ <a class="admin-skip-link" href="#main">Skip to main content</a>
19
24
  {% block chrome %}{% endblock %}
20
25
  {% block main %}
21
- <main class="admin-plain">
26
+ <main id="main" class="admin-plain">
22
27
  {% include "components/flash.njk" %}
23
28
  {% block content %}{% endblock %}
24
29
  </main>
@@ -1,7 +1,10 @@
1
1
  {# What every settings page is: a heading, one form that saves the fields that
2
2
  page carries, and whatever panels sit under it. A page fills in the two
3
- blocks; nothing about the chrome is written six times. #}
3
+ blocks, and names in `settingsProblems` each field a save can refuse, so
4
+ the summary at the top links to it; nothing about the chrome is written six
5
+ times. #}
4
6
  {% extends "layouts/shell.njk" %}
7
+ {% import "components/fields.njk" as field %}
5
8
 
6
9
  {% block title %}{{ settingsLabel }} settings{% endblock %}
7
10
 
@@ -9,7 +12,9 @@
9
12
  <h1>{{ settingsLabel }}</h1>
10
13
 
11
14
  {% if hasProblems %}
12
- <p class="admin-error">Nothing was saved. Fix the fields marked below and try again.</p>
15
+ {% call field.summary('Nothing was saved. Fix the fields marked below and try again.') %}
16
+ {% block settingsProblems %}{% endblock %}
17
+ {% endcall %}
13
18
  {% endif %}
14
19
 
15
20
  <form class="admin-settings" method="post" action="{{ settingsUrl }}">
@@ -3,25 +3,14 @@
3
3
  {% extends "layouts/base.njk" %}
4
4
 
5
5
  {% block chrome %}
6
- <header class="admin-bar">
7
- <a class="admin-bar-site" href="{{ adminUrl }}">{{ site.title }}</a>
8
- <a href="{{ siteUrl }}">View site</a>
9
- <span class="admin-bar-spacer"></span>
10
- {% if me %}
11
- {# WordPress's "Howdy," menu, greeting the way this site's owner does.
12
- A popover rather than a script: the browser opens and closes it,
13
- shuts it on Escape or a click elsewhere, and tells a screen reader
14
- whether it is expanded, all under the CSP's script-src 'self'. #}
15
- <button type="button" class="admin-bar-account" popovertarget="admin-account-menu">Hoopla! {{ me.name }}</button>
16
- <div id="admin-account-menu" class="admin-account-menu" popover>
17
- <a href="{{ me.url }}">Edit profile</a>
18
- <form method="post" action="{{ logoutUrl }}">
19
- <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
20
- <button type="submit">Log out</button>
21
- </form>
22
- </div>
23
- {% endif %}
24
- </header>
6
+ {# View site, because in here you are not looking at it; + New, as on the
7
+ public site; and, on the editor of something readers can see, the way to
8
+ go and look at it. #}
9
+ {% set barLinks = [{ label: "View site", url: siteUrl }, { label: "+ New", url: newPostUrl }] %}
10
+ {% if viewUrl and kind %}
11
+ {% set barLinks = barLinks.concat([{ label: "View " + (kind.singular | capitalize), url: viewUrl }]) %}
12
+ {% endif %}
13
+ {% include "components/admin-bar.njk" %}
25
14
  {% endblock %}
26
15
 
27
16
  {% block main %}
@@ -50,7 +39,7 @@
50
39
  </ul>
51
40
  </nav>
52
41
 
53
- <main class="admin-main">
42
+ <main id="main" class="admin-main">
54
43
  {% include "components/flash.njk" %}
55
44
  {% block content %}{% endblock %}
56
45
  </main>
@@ -30,7 +30,7 @@
30
30
  <p>Type your username or your email address, and a link to set a new password is sent to the address on your account.</p>
31
31
 
32
32
  {% if error %}
33
- <p class="error">{{ error }}</p>
33
+ {{ field.summary(error) }}
34
34
  {% endif %}
35
35
 
36
36
  <form method="post" action="{{ forgotUrl }}">
@@ -7,7 +7,7 @@
7
7
  <h1>Log in</h1>
8
8
 
9
9
  {% if error %}
10
- <p class="error">{{ error }}</p>
10
+ {{ field.summary(error) }}
11
11
  {% endif %}
12
12
 
13
13
  <form method="post" action="{{ loginUrl }}">
@@ -17,14 +17,16 @@
17
17
  <p><a href="{{ forgotUrl }}">Ask for another one</a></p>
18
18
  {% else %}
19
19
  {% if problem %}
20
- <p class="error">{{ problem }}</p>
20
+ {% call field.summary('The password was not set.') %}
21
+ {{ field.problem('password', problem) }}
22
+ {% endcall %}
21
23
  {% endif %}
22
24
 
23
25
  <form method="post" action="{{ resetUrl }}">
24
26
  <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
25
27
  <input type="hidden" name="{{ fields.token }}" value="{{ token }}" />
26
28
 
27
- {{ field.password('password', fields.password, 'New password',
29
+ {{ field.password('password', fields.password, 'New password', error=problem,
28
30
  autocomplete='new-password', required=true, autofocus=true) }}
29
31
 
30
32
  {{ field.password('password_confirmation', fields.passwordConfirmation,
@@ -8,7 +8,7 @@
8
8
  <p>Nobody can sign in to this site yet. Pick a username and a password.</p>
9
9
 
10
10
  {% if error %}
11
- <p class="error">{{ error }}</p>
11
+ {{ field.summary(error) }}
12
12
  {% endif %}
13
13
 
14
14
  <form method="post" action="{{ setupUrl }}">
@@ -40,6 +40,7 @@
40
40
  <h2>Recent posts</h2>
41
41
  {% if recent.length %}
42
42
  <table class="admin-list">
43
+ <caption class="admin-visually-hidden">Recent posts</caption>
43
44
  <thead>
44
45
  <tr>
45
46
  <th scope="col">Title</th>
@@ -26,6 +26,7 @@
26
26
 
27
27
  {% if documents.length %}
28
28
  <table class="admin-list">
29
+ <caption class="admin-visually-hidden">{{ kind.plural }}</caption>
29
30
  <thead>
30
31
  <tr>
31
32
  <th scope="col">Title</th>
@@ -42,6 +42,7 @@
42
42
  <p>Nothing carries a {{ label }} yet.</p>
43
43
  {% else %}
44
44
  <table class="admin-list">
45
+ <caption class="admin-visually-hidden">{{ heading }}</caption>
45
46
  <thead>
46
47
  <tr>
47
48
  <th scope="col">{{ kind.singular | capitalize }}</th>
@@ -40,6 +40,7 @@
40
40
 
41
41
  {% if actor.followers.length %}
42
42
  <table class="admin-list">
43
+ <caption>Followers of {{ actor.handle }}</caption>
43
44
  <thead>
44
45
  <tr>
45
46
  <th scope="col">Follower</th>
@@ -80,6 +81,7 @@
80
81
  <h2>Relays</h2>
81
82
  {% if relays.length %}
82
83
  <table class="admin-list">
84
+ <caption class="admin-visually-hidden">Relays</caption>
83
85
  <thead>
84
86
  <tr>
85
87
  <th scope="col">Relay</th>
@@ -174,6 +176,7 @@
174
176
  <h2>Delivery</h2>
175
177
  {% if posts.length %}
176
178
  <table class="admin-list">
179
+ <caption class="admin-visually-hidden">Delivery</caption>
177
180
  <thead>
178
181
  <tr>
179
182
  <th scope="col">Post</th>
@@ -7,6 +7,10 @@
7
7
  {% extends "layouts/settings-page.njk" %}
8
8
  {% import "components/fields.njk" as field %}
9
9
 
10
+ {% block settingsProblems %}
11
+ {{ field.problem('settings-relays', problems.relays) }}
12
+ {% endblock %}
13
+
10
14
  {% block settingsFields %}
11
15
  {% set relaysHint %}
12
16
  One relay inbox per line, such as
@@ -42,6 +46,7 @@
42
46
 
43
47
  {% if wordpressRoutes.length %}
44
48
  <table class="admin-table">
49
+ <caption class="admin-visually-hidden">WordPress paths</caption>
45
50
  <thead>
46
51
  <tr><th scope="col">Path</th><th scope="col">For</th><th scope="col">Last asked for</th></tr>
47
52
  </thead>