@geekity/cms 0.7.0 → 0.8.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 (107) hide show
  1. package/README.md +153 -18
  2. package/admin/layouts/shell.njk +13 -6
  3. package/admin/pages/error.njk +11 -0
  4. package/admin/static/admin.css +69 -15
  5. package/dist/admin/documents.d.ts.map +1 -1
  6. package/dist/admin/documents.js +58 -31
  7. package/dist/admin/documents.js.map +1 -1
  8. package/dist/admin/routes.d.ts.map +1 -1
  9. package/dist/admin/routes.js +5 -2
  10. package/dist/admin/routes.js.map +1 -1
  11. package/dist/admin/templates.d.ts +1 -0
  12. package/dist/admin/templates.d.ts.map +1 -1
  13. package/dist/admin/templates.js +1 -0
  14. package/dist/admin/templates.js.map +1 -1
  15. package/dist/cli.d.ts +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +128 -3
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config.d.ts +13 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +1 -0
  22. package/dist/config.js.map +1 -1
  23. package/dist/content/document.d.ts +10 -2
  24. package/dist/content/document.d.ts.map +1 -1
  25. package/dist/content/document.js +1 -0
  26. package/dist/content/document.js.map +1 -1
  27. package/dist/content/parser.d.ts.map +1 -1
  28. package/dist/content/parser.js +13 -0
  29. package/dist/content/parser.js.map +1 -1
  30. package/dist/content/store.d.ts +13 -2
  31. package/dist/content/store.d.ts.map +1 -1
  32. package/dist/content/store.js +45 -1
  33. package/dist/content/store.js.map +1 -1
  34. package/dist/content/writer.d.ts.map +1 -1
  35. package/dist/content/writer.js +4 -0
  36. package/dist/content/writer.js.map +1 -1
  37. package/dist/env.d.ts +12 -0
  38. package/dist/env.d.ts.map +1 -1
  39. package/dist/federation/article.d.ts +9 -0
  40. package/dist/federation/article.d.ts.map +1 -1
  41. package/dist/federation/article.js +24 -2
  42. package/dist/federation/article.js.map +1 -1
  43. package/dist/federation/federation.d.ts.map +1 -1
  44. package/dist/federation/federation.js +24 -6
  45. package/dist/federation/federation.js.map +1 -1
  46. package/dist/federation/inbox.d.ts +17 -3
  47. package/dist/federation/inbox.d.ts.map +1 -1
  48. package/dist/federation/inbox.js +72 -6
  49. package/dist/federation/inbox.js.map +1 -1
  50. package/dist/federation/mount.d.ts.map +1 -1
  51. package/dist/federation/mount.js +8 -3
  52. package/dist/federation/mount.js.map +1 -1
  53. package/dist/federation/paths.d.ts +5 -0
  54. package/dist/federation/paths.d.ts.map +1 -1
  55. package/dist/federation/paths.js +5 -0
  56. package/dist/federation/paths.js.map +1 -1
  57. package/dist/federation/quotes.d.ts +56 -0
  58. package/dist/federation/quotes.d.ts.map +1 -0
  59. package/dist/federation/quotes.js +85 -0
  60. package/dist/federation/quotes.js.map +1 -0
  61. package/dist/federation/wordpress.d.ts.map +1 -1
  62. package/dist/federation/wordpress.js +5 -4
  63. package/dist/federation/wordpress.js.map +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +24 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/maintenance.d.ts +47 -0
  68. package/dist/maintenance.d.ts.map +1 -0
  69. package/dist/maintenance.js +78 -0
  70. package/dist/maintenance.js.map +1 -0
  71. package/dist/web/conversation.js +1 -32
  72. package/dist/web/conversation.js.map +1 -1
  73. package/dist/web/documents.d.ts +9 -0
  74. package/dist/web/documents.d.ts.map +1 -1
  75. package/dist/web/documents.js +31 -0
  76. package/dist/web/documents.js.map +1 -1
  77. package/dist/web/errors.d.ts +19 -0
  78. package/dist/web/errors.d.ts.map +1 -0
  79. package/dist/web/errors.js +93 -0
  80. package/dist/web/errors.js.map +1 -0
  81. package/dist/web/health.d.ts +6 -0
  82. package/dist/web/health.d.ts.map +1 -1
  83. package/dist/web/health.js +5 -1
  84. package/dist/web/health.js.map +1 -1
  85. package/dist/web/index.d.ts +4 -0
  86. package/dist/web/index.d.ts.map +1 -1
  87. package/dist/web/index.js +3 -0
  88. package/dist/web/index.js.map +1 -1
  89. package/dist/web/maintenance.d.ts +18 -0
  90. package/dist/web/maintenance.d.ts.map +1 -0
  91. package/dist/web/maintenance.js +108 -0
  92. package/dist/web/maintenance.js.map +1 -0
  93. package/dist/web/redirects.d.ts +75 -0
  94. package/dist/web/redirects.d.ts.map +1 -0
  95. package/dist/web/redirects.js +200 -0
  96. package/dist/web/redirects.js.map +1 -0
  97. package/dist/web/render.d.ts +9 -0
  98. package/dist/web/render.d.ts.map +1 -1
  99. package/dist/web/render.js +19 -0
  100. package/dist/web/render.js.map +1 -1
  101. package/dist/web/routes.d.ts.map +1 -1
  102. package/dist/web/routes.js +53 -0
  103. package/dist/web/routes.js.map +1 -1
  104. package/package.json +1 -1
  105. package/themes/default/README.md +20 -0
  106. package/themes/default/layouts/500.njk +8 -0
  107. package/themes/default/layouts/503.njk +8 -0
package/README.md CHANGED
@@ -79,18 +79,20 @@ changelog.
79
79
  site (or `npx geekity`, or a `package.json` script, which is how the generated
80
80
  `sync` script calls it).
81
81
 
82
- | Command | What it does |
83
- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
85
- | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
86
- | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
87
- | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
88
- | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
89
- | `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
90
- | `geekity --help`, `-h` | The same table, on the terminal. |
91
- | `geekity --version` | The installed version. |
92
-
93
- `serve`, `sync`, `rebuild`, `user add` and `import wordpress-actor` take
82
+ | Command | What it does |
83
+ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
85
+ | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
86
+ | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
87
+ | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
88
+ | `geekity resend --all`, `<slug>...` | Send announced posts to every follower and relay again, as they now read. See [Quote posts](#quote-posts). |
89
+ | `geekity maintenance on`, `off`, `status` | Take the public site down on purpose with a 503 and `Retry-After`, or bring it back, without a restart. `on --until <time>` names when it should be back. |
90
+ | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
91
+ | `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
92
+ | `geekity --help`, `-h` | The same table, on the terminal. |
93
+ | `geekity --version` | The installed version. |
94
+
95
+ `serve`, `sync`, `rebuild`, `resend`, `maintenance`, `user add` and `import wordpress-actor` take
94
96
  `--config <file>`; without it they look for `geekity.config.ts`, then
95
97
  `geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
96
98
  on defaults if there is none.
@@ -273,6 +275,7 @@ directory; absolute ones are used as given.
273
275
  | `accessLog` | `false`; `true` under `geekity serve` | `GEEKITY_ACCESS_LOG` | One line per request on stdout: method, path with query, status, duration. See [The access log](#the-access-log). |
274
276
  | `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
275
277
  | `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
278
+ | `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
276
279
  | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
277
280
  | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
278
281
  | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
@@ -538,17 +541,20 @@ its front matter: the record that the post has been announced and when, which
538
541
  is what decides `Create` against `Update` and what a resend reads. Restoring a
539
542
  trashed post reuses it too.
540
543
 
541
- Because that URL is a promise, the editor keeps it: **a published post's slug
542
- and permalink cannot be changed**. Both fields still move freely on a draft,
543
- and a site that really means to move a published post can edit the file,
544
- knowing that its followers will be handed a second object.
545
-
546
544
  A post whose file already names an `activitypub.id` keeps it as its object id
547
545
  for the life of the post — that is how a post migrated from WordPress keeps the
548
546
  `https://example.com/?p=813` its followers, its replies and its RSS subscribers
549
547
  already hold. The CMS serves the post's object at that URL on an ActivityStreams
550
548
  request, redirects a browser from it to the permalink, and names it in every
551
- `Update` and `Delete`. The CMS never writes one itself.
549
+ `Update` and `Delete`.
550
+
551
+ The CMS writes one itself in one case: when a published post's slug or
552
+ permalink is changed in the editor. A fediverse server cannot rename an object
553
+ it holds, so the URL the post is leaving becomes its stored `activitypub.id`.
554
+ Followers get an `Update` of the object they already have, a peer at the old
555
+ URL still gets the object, and a browser there is redirected to the new
556
+ permalink. The object's `url` is the new permalink. See
557
+ [Moved URLs](#moved-urls).
552
558
 
553
559
  One POST serves a whole instance — the shared inbox is preferred — but the
554
560
  outcome is recorded per recipient, so an admin can see which one did not get it
@@ -580,6 +586,56 @@ queue, which retries out of band) or `failed`.
580
586
  The one capability given up is tombstoning a post whose file is gone entirely
581
587
  rather than in the trash: there is no file left to build the `Tombstone` from.
582
588
 
589
+ ### Quote posts
590
+
591
+ Mastodon 4.5 lets people quote a post only when the post says who may, and
592
+ shows a quote only once the quoted author's server has approved it
593
+ ([FEP-044f]). Every post this site federates is public, so every `Note` and
594
+ `Article` carries `interactionPolicy.canQuote` with `automaticApproval` set to
595
+ the public collection: anybody may quote it, and nobody has to approve it by
596
+ hand. Pages, drafts, scheduled and trashed posts do not federate, so nobody
597
+ can quote them.
598
+
599
+ A quoting server sends a `QuoteRequest` to the post's author. The inbox, and
600
+ the WordPress-compatible inbox when that switch is on, answers it:
601
+
602
+ - For a post the site federates, whose quote lives on the requester's own
603
+ server, it stores an approval and replies `Accept` with a
604
+ `QuoteAuthorization` as its `result`. The authorization names the quote
605
+ (`interactingObject`), the post (`interactionTarget`) and the author
606
+ (`attributedTo`).
607
+ - For anything else it replies `Reject` and stores nothing.
608
+
609
+ Approvals are files, like followers:
610
+ `content/_data/federation/{username}/quotes.json` holds one object per quote of
611
+ that user's posts. Each is served unsigned, as ActivityStreams JSON, at
612
+ `{baseUrl}/author/{username}/quotes/{id}/`, for as long as the post is still
613
+ published. An `Undo` of the `QuoteRequest`, or a `Delete` of the quoting post,
614
+ removes the approval, and its URL answers 404 after that. A quote does not
615
+ notify the author.
616
+
617
+ Mastodon keeps the copy of a post it fetched first, so a post federated before
618
+ the quote policy existed is not quotable until its followers receive an
619
+ `Update`. Editing the post sends one. To update every announced post at once,
620
+ run `geekity resend --all` in the site directory:
621
+
622
+ ```sh
623
+ $ geekity resend --all
624
+ hello-world: Update to 3 of 3 inboxes
625
+ another-post: Update to 3 of 3 inboxes
626
+ ```
627
+
628
+ `geekity resend <slug>...` does the same for named posts. Each is what the
629
+ Resend button on the federation screen does: an `Update` for a post still
630
+ published, a `Delete` for one that has been withdrawn, and a `Create` for a
631
+ published post that was never announced. `--all` only reaches posts that were
632
+ announced. The command delivers with no queue, so every activity has been
633
+ posted before it exits, and it exits non-zero when a named slug has nothing to
634
+ resend. It works while the server runs, because both read the same files and
635
+ database.
636
+
637
+ [FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
638
+
583
639
  ### Relays
584
640
 
585
641
  A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
@@ -2146,6 +2202,85 @@ hand-edited `site.json` that is not an object of lists of items yields no menu
2146
2202
  rather than an error, exactly as a bad archive base falls back rather than
2147
2203
  taking the site down.
2148
2204
 
2205
+ ### Moved URLs
2206
+
2207
+ Changing the slug or the permalink of a published post or page in the editor
2208
+ moves it, and the URL it leaves is written into its file:
2209
+
2210
+ ```yaml
2211
+ permalink: /2026/01/new-name/
2212
+ redirect_from:
2213
+ - /2026/01/first-name/
2214
+ - /2026/01/second-name/
2215
+ ```
2216
+
2217
+ Every URL in `redirect_from` answers `301 Moved Permanently` with the current
2218
+ permalink, and so do its `.md` and `.json` spellings. Each entry points at the
2219
+ document itself, not at the next rename, so no chain of redirects builds up.
2220
+ The list is in the file, so deleting `data/geekity.db` keeps it working.
2221
+
2222
+ A URL in the list only redirects while nothing else lives there: a new document
2223
+ given that URL takes it over. Moving a document back to a URL in its list takes
2224
+ that URL off the list. A draft that was never published moves without leaving
2225
+ anything behind. Correcting a published post's date files it under the new
2226
+ day but keeps its URL.
2227
+
2228
+ ### Declared redirects
2229
+
2230
+ A site that moves to this CMS, or reorganises, lists the old URLs that no
2231
+ document owns in `content/_data/redirects.json`. The file is a JSON list:
2232
+
2233
+ ```json
2234
+ [
2235
+ { "from": "/?p=123", "to": "/2026/01/hello/" },
2236
+ { "from": "/old-section/", "to": "/new-section/", "status": 301 },
2237
+ { "from": "/sale/", "to": "/shop/", "status": 302 },
2238
+ { "from": "/forum/", "to": "https://forum.example.com/", "status": 308 }
2239
+ ]
2240
+ ```
2241
+
2242
+ - `from` is a path on this site. It starts with `/` and can carry a query
2243
+ string.
2244
+ - `to` is a path on this site, or an absolute `http` or `https` URL.
2245
+ - `status` is `301` or `308` for a permanent redirect, and `302` or `307` for a
2246
+ temporary one. It defaults to `301`.
2247
+
2248
+ A `from` without a query string matches that path with any query, or none. The
2249
+ request's query goes on to a `to` that has no query of its own, so
2250
+ `/old-section/?page=2` lands on `/new-section/?page=2`. The path must match
2251
+ exactly, except that a `from` ending in `/` also answers the same path without
2252
+ the slash, in one hop.
2253
+
2254
+ A `from` with a query string matches only that query. The order of the
2255
+ parameters does not matter, so `/archives/?cat=4&paged=2` also matches
2256
+ `/archives/?paged=2&cat=4`. Nothing else in the request's query may differ:
2257
+ `/?p=123` does not match `/?p=123&replytocom=9`. This is how old WordPress
2258
+ `?p=` links reach their posts.
2259
+
2260
+ A declared redirect never hides something real. A path-only `from` is answered
2261
+ only after every document, archive, feed and moved URL has had its turn, and
2262
+ just before the 404 page, so a document given that URL later takes it over. A
2263
+ `from` with a query string is answered first, because no document is addressed
2264
+ by its query. The admin, the federation endpoints and the health check answer
2265
+ before any declared redirect.
2266
+
2267
+ The file is read on the next request after it changes, with no restart. An
2268
+ entry that cannot be served is logged as a warning at boot, and again on the
2269
+ first request after the file changes, and it is skipped. The following entries are skipped:
2270
+
2271
+ - An entry with a missing or malformed `from`, `to` or `status`.
2272
+ - A second entry with the same `from`. The first entry wins.
2273
+ - Every entry that leads into a loop, such as `/a/` to `/b/` and `/b/` back to
2274
+ `/a/`.
2275
+
2276
+ A file that is not a JSON list serves no redirects and is reported the same
2277
+ way.
2278
+
2279
+ Every redirect the CMS sends carries `X-Redirect-By: Geekity CMS`. This
2280
+ includes the declared redirects, moved URLs, trailing slashes, feed spellings
2281
+ and the admin's redirects. Someone who traces a chain of redirects through a
2282
+ proxy can see which layer sent each one.
2283
+
2149
2284
  ## Content negotiation
2150
2285
 
2151
2286
  Every public URL is one resource with more than one body. A post or a page has
@@ -7,12 +7,19 @@
7
7
  <a class="admin-bar-site" href="{{ adminUrl }}">{{ site.title }}</a>
8
8
  <a href="{{ siteUrl }}">View site</a>
9
9
  <span class="admin-bar-spacer"></span>
10
- {% if user %}
11
- <span class="admin-bar-user">Signed in as {{ user.username }}</span>
12
- <form method="post" action="{{ logoutUrl }}">
13
- <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
14
- <button type="submit">Log out</button>
15
- </form>
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>
16
23
  {% endif %}
17
24
  </header>
18
25
  {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "layouts/base.njk" %}
2
+
3
+ {% block title %}Something went wrong{% endblock %}
4
+
5
+ {% block content %}
6
+ {# The page an admin request that threw answers with. It extends the bare
7
+ layout rather than the shell because the shell reads the session, the
8
+ menu and the site, and any of those may be what failed. #}
9
+ <h1>Something went wrong</h1>
10
+ <p>This screen could not be shown because of a problem on the server. The error has been logged. Try again in a moment, or go back to the <a href="{{ adminUrl }}">dashboard</a>.</p>
11
+ {% endblock %}
@@ -54,11 +54,13 @@ a {
54
54
  min-height: 2.5rem;
55
55
  background: var(--admin-bar);
56
56
  color: var(--admin-nav-text);
57
+ anchor-name: --admin-bar;
57
58
  }
58
59
 
59
60
  .admin-bar a {
60
61
  color: var(--admin-nav-text);
61
62
  text-decoration: none;
63
+ white-space: nowrap;
62
64
  }
63
65
 
64
66
  .admin-bar a:hover,
@@ -74,22 +76,71 @@ a {
74
76
  flex: 1;
75
77
  }
76
78
 
77
- .admin-bar-user {
78
- color: #c3c4c7;
79
+ .admin-bar-account {
80
+ padding: 0;
81
+ border: 0;
82
+ background: none;
83
+ color: var(--admin-nav-text);
84
+ font: inherit;
85
+ text-align: right;
86
+ cursor: pointer;
87
+ anchor-name: --admin-account;
79
88
  }
80
89
 
81
- .admin-bar form {
82
- display: inline;
90
+ .admin-bar-account:hover,
91
+ .admin-bar-account:focus-visible,
92
+ .admin-bar-account:popover-open {
93
+ color: #72aee6;
83
94
  }
84
95
 
85
- .admin-bar button {
86
- padding: 0;
96
+ .admin-bar-account::after {
97
+ content: ' \25BE' / '';
98
+ }
99
+
100
+ /* The menu is in the top layer, so it is placed rather than laid out: under
101
+ the greeting where anchor positioning exists, at the bar's right edge
102
+ where it does not. */
103
+
104
+ .admin-account-menu {
105
+ inset: 2.5rem 0 auto auto;
106
+ margin: 0;
107
+ min-width: 10rem;
108
+ padding: 0.25rem 0;
109
+ border: 0;
110
+ background: var(--admin-nav);
111
+ color: var(--admin-nav-text);
112
+ box-shadow: 0 3px 5px rgb(0 0 0 / 20%);
113
+ }
114
+
115
+ @supports (top: anchor(--admin-bar bottom)) {
116
+ .admin-account-menu {
117
+ inset: anchor(--admin-bar bottom) anchor(--admin-account right) auto auto;
118
+ }
119
+ }
120
+
121
+ .admin-account-menu a,
122
+ .admin-account-menu button[type='submit'] {
123
+ display: block;
124
+ box-sizing: border-box;
125
+ width: 100%;
126
+ margin: 0;
127
+ padding: 0.5rem 1rem;
87
128
  border: 0;
129
+ border-radius: 0;
88
130
  background: none;
89
131
  color: var(--admin-nav-text);
90
132
  font: inherit;
133
+ text-align: left;
134
+ text-decoration: none;
91
135
  cursor: pointer;
92
- text-decoration: underline;
136
+ }
137
+
138
+ .admin-account-menu a:hover,
139
+ .admin-account-menu a:focus-visible,
140
+ .admin-account-menu button[type='submit']:hover,
141
+ .admin-account-menu button[type='submit']:focus-visible {
142
+ background: var(--admin-bar);
143
+ color: #72aee6;
93
144
  }
94
145
 
95
146
  /* The shell: navigation column beside the screen itself. */
@@ -331,6 +382,8 @@ label {
331
382
  input[type='text'],
332
383
  input[type='password'],
333
384
  input[type='email'],
385
+ input[type='url'],
386
+ select,
334
387
  textarea {
335
388
  width: 100%;
336
389
  padding: 0.4rem 0.5rem;
@@ -346,6 +399,13 @@ textarea {
346
399
  resize: vertical;
347
400
  }
348
401
 
402
+ /* A select lays its text out at line-height: normal whatever it inherits, so
403
+ it is given the height a text box reaches: the body's 1.6 line, the padding
404
+ and the border. */
405
+ select {
406
+ min-height: calc(1.6em + 0.8rem + 2px);
407
+ }
408
+
349
409
  button[type='submit'],
350
410
  .admin-button {
351
411
  margin-top: 1rem;
@@ -477,6 +537,7 @@ button[type='submit']:hover,
477
537
 
478
538
  .admin-check input {
479
539
  width: auto;
540
+ margin: 0;
480
541
  }
481
542
 
482
543
  .admin-editor-buttons {
@@ -659,14 +720,6 @@ button[type='submit']:hover,
659
720
  border-top: 0;
660
721
  }
661
722
 
662
- .admin-settings select {
663
- padding: 0.4rem 0.5rem;
664
- border: 1px solid #8c8f94;
665
- background: var(--admin-surface);
666
- color: inherit;
667
- font: inherit;
668
- }
669
-
670
723
  .admin-settings input:disabled {
671
724
  background: var(--admin-field);
672
725
  color: var(--admin-muted);
@@ -877,6 +930,7 @@ button[type='submit']:hover,
877
930
  switches and windows still reads as one column rather than as a form. */
878
931
 
879
932
  .admin-inline select {
933
+ width: auto;
880
934
  margin: 0;
881
935
  }
882
936
 
@@ -1 +1 @@
1
- {"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/admin/documents.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/admin/documents.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ import { DuplicatePermalinkError, isTrashedPath, TRASH_DIRECTORY } from '../cont
11
11
  import { calendarDayIn, DEFAULT_TIMEZONE, toUtcInstant, wallClockIn, zoneLabel, } from '../content/time.js';
12
12
  import { normalizeBody, serializeDocument } from '../content/writer.js';
13
13
  import { isPublicDocument } from '../web/documents.js';
14
+ import { absoluteUrl } from '../web/negotiate.js';
14
15
  import { COMMENTS_FRONT_MATTER_KEY } from '../comments/policy.js';
15
16
  import { CONTACT_FRONT_MATTER_KEY } from '../contact/form.js';
16
17
  import { userForAuthor } from '../web/authors.js';
@@ -258,27 +259,17 @@ async function saveFromForm(c, options) {
258
259
  const finalSlug = document === undefined
259
260
  ? await freeSlug({ contentDir, store, type: kind.type, slug, date: filed })
260
261
  : slug;
261
- // decision-13: a published post's permalink is its name in the fediverse as
262
- // well as on the web, and the two are one promise. Moving it hands every
263
- // follower a second object and breaks every link somebody already shared, so
264
- // the editor refuses. A draft has promised nothing yet and may still move;
265
- // a site that really means to move a published post can still do it in the
266
- // file, knowing what it costs.
267
- const promised = promisedDocument(kind, document, store.now());
268
- if (promised !== undefined) {
269
- if (finalSlug !== promised.slug) {
270
- return refuse(`The permalink of a published post is permanent, and the slug names the file it is filed under: this ${kind.singular} keeps the slug ${promised.slug}.`);
271
- }
272
- // An empty field is not a request to move: it is a form that carried no
273
- // permalink, and the promised one is what it keeps. Only a field naming
274
- // some other URL is somebody asking for the one thing this refuses.
275
- const submitted = normalizePermalink(form.permalink);
276
- if (submitted !== undefined && submitted !== promised.permalink) {
277
- return refuse(`The permalink of a published post is permanent: this ${kind.singular} stays at ${promised.permalink}.`);
278
- }
279
- }
280
- const permalink = promised?.permalink ??
281
- resolvePermalink({
262
+ // A URL somebody has been shown only moves when the author asks it to, by
263
+ // editing the slug or the permalink: correcting a date refiles the file and
264
+ // leaves the URL where it was. An empty permalink field is a form that
265
+ // carried none, not a request to move.
266
+ const promised = promisedDocument(document, store.now());
267
+ const submitted = normalizePermalink(form.permalink);
268
+ const asked = promised !== undefined &&
269
+ (finalSlug !== promised.slug || (submitted !== undefined && submitted !== promised.permalink));
270
+ const permalink = promised !== undefined && !asked
271
+ ? promised.permalink
272
+ : resolvePermalink({
282
273
  kind,
283
274
  form,
284
275
  slug: finalSlug,
@@ -301,13 +292,14 @@ async function saveFromForm(c, options) {
301
292
  ...(date === undefined ? {} : { date }),
302
293
  updated: store.now().toISOString(),
303
294
  permalink,
295
+ ...optional('redirectFrom', formerPermalinks(document, promised, permalink)),
304
296
  tags: kind.tagged ? splitTags(form.tags) : [],
305
297
  categories: kind.categorised ? splitTags(form.categories) : [],
306
298
  draft,
307
299
  ...(form.description === '' ? {} : { description: form.description }),
308
300
  ...optional('author', chosenAuthor(c, form.author, document)),
309
301
  ...optional('inReplyTo', replyTo(kind, form, document)),
310
- ...optional('activitypub', document?.activitypub),
302
+ ...optional('activitypub', keptIdentity(document, promised, permalink, c.var.config.baseUrl)),
311
303
  extra: resolveExtra(kind, document, form),
312
304
  body: form.body,
313
305
  };
@@ -373,19 +365,54 @@ function documentPath(input) {
373
365
  return input.trashed ? `${TRASH_DIRECTORY}/${relative}` : relative;
374
366
  }
375
367
  /**
376
- * The document whose permalink has already been promised, or `undefined` when
368
+ * The document whose URL has already been promised, or `undefined` when
377
369
  * nothing has been promised yet.
378
370
  *
379
- * A published post, and only a published post. decision-13 makes its permalink
380
- * its ActivityStreams object id, so the URL is a name its followers, its
381
- * replies and its feed subscribers are all holding; a draft, a trashed post
382
- * and one whose date has not arrived have been shown to nobody, and a page
383
- * federates nothing at all.
371
+ * A published post or page is at a URL readers, search engines and other
372
+ * sites may hold, and a post that was ever announced is at one its followers
373
+ * hold even while it is a draft. A draft nobody was told about, a trashed
374
+ * document and one whose date has not arrived have been shown to nobody, and
375
+ * may move without leaving anything behind.
384
376
  */
385
- function promisedDocument(kind, document, now) {
386
- if (document === undefined || kind.type !== 'post')
377
+ function promisedDocument(document, now) {
378
+ if (document === undefined)
387
379
  return undefined;
388
- return isPublicDocument(document, now) ? document : undefined;
380
+ const announced = document.activitypub?.published !== undefined;
381
+ return isPublicDocument(document, now) || announced ? document : undefined;
382
+ }
383
+ /**
384
+ * The URLs a document is saved as having lived at (TASK-127).
385
+ *
386
+ * Every entry names the document itself rather than the next hop, so a chain
387
+ * of renames collapses by construction: the URL it is leaving joins the ones
388
+ * it already left, and all of them redirect straight to where it is now. The
389
+ * URL it is arriving at leaves the list, because a document moved back to an
390
+ * old URL lives there again.
391
+ */
392
+ function formerPermalinks(document, promised, permalink) {
393
+ const left = promised !== undefined && promised.permalink !== permalink ? [promised.permalink] : [];
394
+ const former = [...new Set([...(document?.redirectFrom ?? []), ...left])].filter((url) => url !== permalink);
395
+ return former.length === 0 ? undefined : former;
396
+ }
397
+ /**
398
+ * The `activitypub` block to write, which pins the object id when a promised
399
+ * post moves (decision-20).
400
+ *
401
+ * decision-13 makes a post's object id its permalink, and a fediverse server
402
+ * cannot rename an object it holds. So the URL the post is leaving is written
403
+ * as its stored id, and the stored-id path does the rest: a peer at the old
404
+ * URL still gets the `Article`, a browser there is sent on, and every `Update`
405
+ * names the id the followers already have. An id the file already stores
406
+ * stays, since it is already what they hold.
407
+ */
408
+ function keptIdentity(document, promised, permalink, baseUrl) {
409
+ const block = document?.activitypub;
410
+ if (promised === undefined || promised.type !== 'post' || promised.permalink === permalink) {
411
+ return block;
412
+ }
413
+ if (block?.id !== undefined)
414
+ return block;
415
+ return { ...block, id: absoluteUrl(promised.permalink, baseUrl) };
389
416
  }
390
417
  /**
391
418
  * The permalink to write.