@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.
- package/README.md +153 -18
- package/admin/layouts/shell.njk +13 -6
- package/admin/pages/error.njk +11 -0
- package/admin/static/admin.css +69 -15
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +58 -31
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +5 -2
- package/dist/admin/routes.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/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +128 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/content/document.d.ts +10 -2
- package/dist/content/document.d.ts.map +1 -1
- package/dist/content/document.js +1 -0
- package/dist/content/document.js.map +1 -1
- package/dist/content/parser.d.ts.map +1 -1
- package/dist/content/parser.js +13 -0
- package/dist/content/parser.js.map +1 -1
- package/dist/content/store.d.ts +13 -2
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +45 -1
- package/dist/content/store.js.map +1 -1
- package/dist/content/writer.d.ts.map +1 -1
- package/dist/content/writer.js +4 -0
- package/dist/content/writer.js.map +1 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/federation/article.d.ts +9 -0
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +24 -2
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js +24 -6
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/inbox.d.ts +17 -3
- package/dist/federation/inbox.d.ts.map +1 -1
- package/dist/federation/inbox.js +72 -6
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/mount.d.ts.map +1 -1
- package/dist/federation/mount.js +8 -3
- package/dist/federation/mount.js.map +1 -1
- package/dist/federation/paths.d.ts +5 -0
- package/dist/federation/paths.d.ts.map +1 -1
- package/dist/federation/paths.js +5 -0
- package/dist/federation/paths.js.map +1 -1
- package/dist/federation/quotes.d.ts +56 -0
- package/dist/federation/quotes.d.ts.map +1 -0
- package/dist/federation/quotes.js +85 -0
- package/dist/federation/quotes.js.map +1 -0
- package/dist/federation/wordpress.d.ts.map +1 -1
- package/dist/federation/wordpress.js +5 -4
- package/dist/federation/wordpress.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -1
- package/dist/index.js.map +1 -1
- package/dist/maintenance.d.ts +47 -0
- package/dist/maintenance.d.ts.map +1 -0
- package/dist/maintenance.js +78 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/web/conversation.js +1 -32
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/documents.d.ts +9 -0
- package/dist/web/documents.d.ts.map +1 -1
- package/dist/web/documents.js +31 -0
- package/dist/web/documents.js.map +1 -1
- package/dist/web/errors.d.ts +19 -0
- package/dist/web/errors.d.ts.map +1 -0
- package/dist/web/errors.js +93 -0
- package/dist/web/errors.js.map +1 -0
- package/dist/web/health.d.ts +6 -0
- package/dist/web/health.d.ts.map +1 -1
- package/dist/web/health.js +5 -1
- package/dist/web/health.js.map +1 -1
- package/dist/web/index.d.ts +4 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -0
- package/dist/web/index.js.map +1 -1
- package/dist/web/maintenance.d.ts +18 -0
- package/dist/web/maintenance.d.ts.map +1 -0
- package/dist/web/maintenance.js +108 -0
- package/dist/web/maintenance.js.map +1 -0
- package/dist/web/redirects.d.ts +75 -0
- package/dist/web/redirects.d.ts.map +1 -0
- package/dist/web/redirects.js +200 -0
- package/dist/web/redirects.js.map +1 -0
- package/dist/web/render.d.ts +9 -0
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +19 -0
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +53 -0
- package/dist/web/routes.js.map +1 -1
- package/package.json +1 -1
- package/themes/default/README.md +20 -0
- package/themes/default/layouts/500.njk +8 -0
- 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
|
|
83
|
-
|
|
|
84
|
-
| `geekity serve`
|
|
85
|
-
| `geekity init <dir>`
|
|
86
|
-
| `geekity sync`
|
|
87
|
-
| `geekity rebuild`
|
|
88
|
-
| `geekity
|
|
89
|
-
| `geekity
|
|
90
|
-
| `geekity
|
|
91
|
-
| `geekity
|
|
92
|
-
|
|
93
|
-
|
|
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`.
|
|
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
|
package/admin/layouts/shell.njk
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</
|
|
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 %}
|
package/admin/static/admin.css
CHANGED
|
@@ -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-
|
|
78
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE1C,OAAO,KAAK,EAAE,QAAQ,EAAmB,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAStF,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AASxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/admin/documents.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE1C,OAAO,KAAK,EAAE,QAAQ,EAAmB,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAStF,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AASxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAgB5C;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,0CAA0C;IAC1C,IAAI,EAAE,YAAY,CAAC;IACnB,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,OAAO,CAAC;IACf,oDAAoD;IACpD,MAAM,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,WAAW,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB;;;;;;;;OAQG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,mCAAmC,CAAC;AAE5D;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB;aAAK,IAAI,EAAE,EAAE;aAAE,IAAI,EAAE,MAAM;aAAE,MAAM,EAAE,QAAQ;CAAW,CAAC;AAiBtF,2EAA2E;AAC3E,eAAO,MAAM,SAAS,EAAE,YAWvB,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,SAAS,EAAE,YAWvB,CAAC;AAEF,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CAExD;AAED;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,GAAG,OAAO,CAAC;AAEnF,gDAAgD;AAChD,eAAO,MAAM,gBAAgB,EAAE,SAAS,cAAc,EAMrD,CAAC;AAEF,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC,0EAA0E;AAC1E,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,cAAc,CAExE;AAED,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,GAAG,cAAc,CAQzF;AAED,mEAAmE;AACnE,MAAM,MAAM,WAAW,GAAG,CACxB,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,EACtB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC9B,QAAQ,CAAC;AAEd,wEAAwE;AACxE,MAAM,WAAW,2BAA2B;IAC1C,2CAA2C;IAC3C,IAAI,EAAE,YAAY,CAAC;IACnB,oFAAoF;IACpF,MAAM,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,EACrB,OAAO,EAAE,2BAA2B,GACnC,IAAI,CAuEN;AA4bD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAOjD;AAkCD,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,MAAM,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,YAAY,EAAE,CAkBrF;AA6LD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK5D;AAwBD,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,mEAAmE;IACnE,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,YAAY,EAClB,QAAQ,GAAE,MAAyB,EACnC,GAAG,GAAE,IAAiB,GACrB,UAAU,CAqBZ;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,GAAE,MAAyB,GAAG,UAAU,CAkB3F;AAiFD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,MAAM,GACX,QAAQ,GAAG,SAAS,CAWtB;AAED,wDAAwD;AACxD,MAAM,WAAW,WAAW;IAC1B,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAC;IACnB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B;;;OAGG;IACH,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;aAAK,QAAQ,EAAE,YAAY;aAAE,SAAS,EAAE,YAAY;CAAW,CAAC;AAoD7F,2EAA2E;AAC3E,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,IAAI,SAAI,GAAG,MAAM,CAMvF"}
|
package/dist/admin/documents.js
CHANGED
|
@@ -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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
|
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
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* and one whose date has not arrived have been shown to nobody, and
|
|
383
|
-
*
|
|
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(
|
|
386
|
-
if (document === undefined
|
|
377
|
+
function promisedDocument(document, now) {
|
|
378
|
+
if (document === undefined)
|
|
387
379
|
return undefined;
|
|
388
|
-
|
|
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.
|