@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.
- package/README.md +86 -24
- package/admin/components/admin-bar.njk +26 -0
- package/admin/components/fields.njk +55 -12
- package/admin/components/public-admin-bar.njk +41 -0
- package/admin/layouts/base.njk +6 -1
- package/admin/layouts/settings-page.njk +7 -2
- package/admin/layouts/shell.njk +9 -20
- package/admin/pages/account/forgot.njk +1 -1
- package/admin/pages/account/login.njk +1 -1
- package/admin/pages/account/reset.njk +4 -2
- package/admin/pages/account/setup.njk +1 -1
- package/admin/pages/dashboard/home.njk +1 -0
- package/admin/pages/documents/list.njk +1 -0
- package/admin/pages/documents/taxonomy.njk +1 -0
- package/admin/pages/federation/followers.njk +3 -0
- package/admin/pages/federation/settings.njk +5 -0
- package/admin/pages/media/library.njk +39 -0
- package/admin/pages/navigation/menus.njk +6 -1
- package/admin/pages/settings/discussion.njk +7 -0
- package/admin/pages/settings/email.njk +10 -0
- package/admin/pages/settings/general.njk +7 -0
- package/admin/pages/settings/permalinks.njk +6 -0
- package/admin/pages/settings/reading.njk +7 -0
- package/admin/pages/tools/content-index.njk +1 -0
- package/admin/pages/tools/personal-data.njk +5 -0
- package/admin/pages/users/edit.njk +7 -5
- package/admin/pages/users/list.njk +1 -0
- package/admin/pages/users/new.njk +5 -1
- package/admin/static/admin-bar.css +153 -0
- package/admin/static/admin.css +95 -96
- package/dist/admin/comments.js +2 -1
- package/dist/admin/comments.js.map +1 -1
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +18 -0
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js.map +1 -1
- package/dist/admin/media.d.ts +13 -2
- package/dist/admin/media.d.ts.map +1 -1
- package/dist/admin/media.js +58 -3
- package/dist/admin/media.js.map +1 -1
- package/dist/admin/personal-data.d.ts.map +1 -1
- package/dist/admin/personal-data.js +5 -1
- package/dist/admin/personal-data.js.map +1 -1
- package/dist/admin/preview.d.ts.map +1 -1
- package/dist/admin/preview.js +1 -1
- package/dist/admin/preview.js.map +1 -1
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +2 -1
- package/dist/admin/routes.js.map +1 -1
- package/dist/admin/store.d.ts +36 -1
- package/dist/admin/store.d.ts.map +1 -1
- package/dist/admin/store.js +63 -1
- package/dist/admin/store.js.map +1 -1
- package/dist/admin/templates.d.ts +1 -0
- package/dist/admin/templates.d.ts.map +1 -1
- package/dist/admin/templates.js +1 -0
- package/dist/admin/templates.js.map +1 -1
- package/dist/admin/tools.d.ts +2 -0
- package/dist/admin/tools.d.ts.map +1 -1
- package/dist/admin/tools.js +3 -2
- package/dist/admin/tools.js.map +1 -1
- package/dist/admin/uploads.d.ts +5 -2
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +6 -2
- package/dist/admin/uploads.js.map +1 -1
- package/dist/comments/index.d.ts +2 -2
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +1 -1
- package/dist/comments/index.js.map +1 -1
- package/dist/comments/records.d.ts +43 -2
- package/dist/comments/records.d.ts.map +1 -1
- package/dist/comments/records.js +201 -54
- package/dist/comments/records.js.map +1 -1
- package/dist/comments/routes.js +1 -1
- package/dist/comments/routes.js.map +1 -1
- package/dist/comments/viewer.d.ts +13 -0
- package/dist/comments/viewer.d.ts.map +1 -1
- package/dist/comments/viewer.js +13 -1
- package/dist/comments/viewer.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/env.d.ts +19 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +37 -1
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/delivery.d.ts +3 -1
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +6 -1
- package/dist/federation/delivery.js.map +1 -1
- package/dist/federation/federation.d.ts +3 -0
- package/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/inbox.d.ts +7 -5
- package/dist/federation/inbox.d.ts.map +1 -1
- package/dist/federation/inbox.js +15 -32
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/index.d.ts +2 -0
- package/dist/federation/index.d.ts.map +1 -1
- package/dist/federation/index.js +1 -0
- package/dist/federation/index.js.map +1 -1
- package/dist/federation/mount.js +6 -1
- package/dist/federation/mount.js.map +1 -1
- package/dist/federation/profiles.d.ts +95 -0
- package/dist/federation/profiles.d.ts.map +1 -0
- package/dist/federation/profiles.js +171 -0
- package/dist/federation/profiles.js.map +1 -0
- package/dist/federation/relays.d.ts +3 -1
- package/dist/federation/relays.d.ts.map +1 -1
- package/dist/federation/relays.js +6 -1
- package/dist/federation/relays.js.map +1 -1
- package/dist/federation/replies.d.ts +13 -4
- package/dist/federation/replies.d.ts.map +1 -1
- package/dist/federation/replies.js +25 -6
- package/dist/federation/replies.js.map +1 -1
- package/dist/images/alt-text.d.ts +51 -0
- package/dist/images/alt-text.d.ts.map +1 -0
- package/dist/images/alt-text.js +113 -0
- package/dist/images/alt-text.js.map +1 -0
- package/dist/images/icons.d.ts.map +1 -1
- package/dist/images/icons.js +3 -2
- package/dist/images/icons.js.map +1 -1
- package/dist/images/index.d.ts +2 -2
- package/dist/images/index.d.ts.map +1 -1
- package/dist/images/markup.d.ts +26 -4
- package/dist/images/markup.d.ts.map +1 -1
- package/dist/images/markup.js +36 -15
- package/dist/images/markup.js.map +1 -1
- package/dist/images/paths.d.ts +11 -0
- package/dist/images/paths.d.ts.map +1 -1
- package/dist/images/paths.js +17 -1
- package/dist/images/paths.js.map +1 -1
- package/dist/images/variants.d.ts +13 -3
- package/dist/images/variants.d.ts.map +1 -1
- package/dist/images/variants.js +13 -11
- package/dist/images/variants.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -11
- package/dist/index.js.map +1 -1
- package/dist/notifications/routes.js +1 -1
- package/dist/notifications/routes.js.map +1 -1
- package/dist/privacy/erase.d.ts.map +1 -1
- package/dist/privacy/erase.js +2 -3
- package/dist/privacy/erase.js.map +1 -1
- package/dist/privacy/retention.js +1 -1
- package/dist/privacy/retention.js.map +1 -1
- package/dist/web/admin-bar.d.ts +21 -0
- package/dist/web/admin-bar.d.ts.map +1 -0
- package/dist/web/admin-bar.js +145 -0
- package/dist/web/admin-bar.js.map +1 -0
- package/dist/web/assets.d.ts +29 -1
- package/dist/web/assets.d.ts.map +1 -1
- package/dist/web/assets.js +48 -3
- package/dist/web/assets.js.map +1 -1
- package/dist/web/compression.d.ts +19 -0
- package/dist/web/compression.d.ts.map +1 -0
- package/dist/web/compression.js +160 -0
- package/dist/web/compression.js.map +1 -0
- package/dist/web/context.d.ts +5 -1
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +5 -2
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts.map +1 -1
- package/dist/web/conversation.js +13 -12
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/render.d.ts +27 -0
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +77 -38
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +63 -28
- package/dist/web/routes.js.map +1 -1
- package/dist/web/templates.d.ts.map +1 -1
- package/dist/web/templates.js +9 -2
- package/dist/web/templates.js.map +1 -1
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +5 -1
- package/dist/webmention/service.js.map +1 -1
- package/package.json +8 -8
- package/themes/default/README.md +176 -19
- package/themes/default/layouts/base.njk +51 -10
- package/themes/default/layouts/post.njk +10 -1
- package/themes/default/partials/comment-form.njk +33 -14
- package/themes/default/partials/contact-form.njk +32 -14
- 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
|
|
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
|
|
1400
|
-
| -------------------------------------------------------------------------------------------------------------------- |
|
|
1401
|
-
| A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git
|
|
1402
|
-
| A commenter's email, and whether they asked to be told about replies | `
|
|
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
|
|
1404
|
-
| A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json`
|
|
1405
|
-
| A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600`
|
|
1406
|
-
| The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600`
|
|
1407
|
-
| Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git
|
|
1408
|
-
| Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git
|
|
1409
|
-
| What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git
|
|
1410
|
-
|
|
|
1411
|
-
|
|
|
1412
|
-
|
|
|
1413
|
-
|
|
|
1414
|
-
| Client addresses in the
|
|
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.
|
|
1479
|
-
|
|
1480
|
-
|
|
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 — ``
|
|
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>
|
package/admin/layouts/base.njk
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 }}">
|
package/admin/layouts/shell.njk
CHANGED
|
@@ -3,25 +3,14 @@
|
|
|
3
3
|
{% extends "layouts/base.njk" %}
|
|
4
4
|
|
|
5
5
|
{% block chrome %}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
33
|
+
{{ field.summary(error) }}
|
|
34
34
|
{% endif %}
|
|
35
35
|
|
|
36
36
|
<form method="post" action="{{ forgotUrl }}">
|
|
@@ -17,14 +17,16 @@
|
|
|
17
17
|
<p><a href="{{ forgotUrl }}">Ask for another one</a></p>
|
|
18
18
|
{% else %}
|
|
19
19
|
{% if problem %}
|
|
20
|
-
|
|
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,
|
|
@@ -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>
|