@geekity/cms 0.14.0 → 0.16.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 +270 -52
- package/admin/pages/documents/editor.njk +120 -0
- package/admin/pages/documents/syndication.njk +110 -0
- package/admin/pages/federation/followers.njk +7 -0
- package/admin/pages/media/library.njk +4 -3
- package/admin/pages/settings/general.njk +44 -0
- package/admin/static/admin.css +44 -0
- package/dist/admin/documents.d.ts +104 -0
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +220 -55
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/enclosure-field.d.ts +91 -0
- package/dist/admin/enclosure-field.d.ts.map +1 -0
- package/dist/admin/enclosure-field.js +282 -0
- package/dist/admin/enclosure-field.js.map +1 -0
- package/dist/admin/federation.d.ts +15 -0
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +19 -0
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/media.d.ts +7 -2
- package/dist/admin/media.d.ts.map +1 -1
- package/dist/admin/media.js +4 -2
- package/dist/admin/media.js.map +1 -1
- package/dist/admin/menu.d.ts.map +1 -1
- package/dist/admin/menu.js +3 -0
- package/dist/admin/menu.js.map +1 -1
- package/dist/admin/photo-field.d.ts +39 -0
- package/dist/admin/photo-field.d.ts.map +1 -0
- package/dist/admin/photo-field.js +91 -0
- package/dist/admin/photo-field.js.map +1 -0
- package/dist/admin/preview.d.ts.map +1 -1
- package/dist/admin/preview.js +6 -1
- package/dist/admin/preview.js.map +1 -1
- package/dist/admin/routes.d.ts.map +1 -1
- package/dist/admin/routes.js +4 -0
- package/dist/admin/routes.js.map +1 -1
- package/dist/admin/settings-general.d.ts.map +1 -1
- package/dist/admin/settings-general.js +36 -2
- package/dist/admin/settings-general.js.map +1 -1
- package/dist/admin/settings-page.d.ts.map +1 -1
- package/dist/admin/settings-page.js +1 -2
- package/dist/admin/settings-page.js.map +1 -1
- package/dist/admin/settings.d.ts +26 -0
- package/dist/admin/settings.d.ts.map +1 -1
- package/dist/admin/settings.js +88 -2
- package/dist/admin/settings.js.map +1 -1
- package/dist/admin/syndication-targets.d.ts +57 -0
- package/dist/admin/syndication-targets.d.ts.map +1 -0
- package/dist/admin/syndication-targets.js +266 -0
- package/dist/admin/syndication-targets.js.map +1 -0
- 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/uploads.d.ts +13 -2
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +23 -5
- package/dist/admin/uploads.js.map +1 -1
- package/dist/config.d.ts +17 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -7
- package/dist/config.js.map +1 -1
- package/dist/content/citation.d.ts +36 -0
- package/dist/content/citation.d.ts.map +1 -0
- package/dist/content/citation.js +31 -0
- package/dist/content/citation.js.map +1 -0
- package/dist/content/enclosure.d.ts +69 -0
- package/dist/content/enclosure.d.ts.map +1 -0
- package/dist/content/enclosure.js +165 -0
- package/dist/content/enclosure.js.map +1 -0
- package/dist/content/index.d.ts +5 -1
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +2 -0
- package/dist/content/index.js.map +1 -1
- package/dist/content/media.d.ts +15 -2
- package/dist/content/media.d.ts.map +1 -1
- package/dist/content/media.js +92 -9
- package/dist/content/media.js.map +1 -1
- package/dist/content/photo.d.ts +45 -0
- package/dist/content/photo.d.ts.map +1 -0
- package/dist/content/photo.js +55 -0
- package/dist/content/photo.js.map +1 -0
- package/dist/content/post-type.d.ts +20 -8
- package/dist/content/post-type.d.ts.map +1 -1
- package/dist/content/post-type.js +17 -0
- package/dist/content/post-type.js.map +1 -1
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +93 -10
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/citations.d.ts +38 -0
- package/dist/federation/citations.d.ts.map +1 -0
- package/dist/federation/citations.js +98 -0
- package/dist/federation/citations.js.map +1 -0
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +79 -31
- package/dist/federation/delivery.js.map +1 -1
- package/dist/images/alt-text.d.ts +6 -0
- package/dist/images/alt-text.d.ts.map +1 -1
- package/dist/images/alt-text.js +10 -0
- package/dist/images/alt-text.js.map +1 -1
- package/dist/images/icons.d.ts +5 -0
- package/dist/images/icons.d.ts.map +1 -1
- package/dist/images/icons.js +13 -8
- package/dist/images/icons.js.map +1 -1
- package/dist/images/paths.d.ts +1 -1
- package/dist/images/paths.d.ts.map +1 -1
- package/dist/images/paths.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -4
- package/dist/index.js.map +1 -1
- package/dist/indieauth/bearer.d.ts +6 -1
- package/dist/indieauth/bearer.d.ts.map +1 -1
- package/dist/indieauth/bearer.js +39 -12
- package/dist/indieauth/bearer.js.map +1 -1
- package/dist/indieauth/discovery.d.ts +9 -4
- package/dist/indieauth/discovery.d.ts.map +1 -1
- package/dist/indieauth/discovery.js +18 -7
- package/dist/indieauth/discovery.js.map +1 -1
- package/dist/micropub/create.d.ts +56 -0
- package/dist/micropub/create.d.ts.map +1 -0
- package/dist/micropub/create.js +200 -0
- package/dist/micropub/create.js.map +1 -0
- package/dist/micropub/endpoint.d.ts +21 -0
- package/dist/micropub/endpoint.d.ts.map +1 -0
- package/dist/micropub/endpoint.js +353 -0
- package/dist/micropub/endpoint.js.map +1 -0
- package/dist/micropub/media.d.ts +14 -0
- package/dist/micropub/media.d.ts.map +1 -0
- package/dist/micropub/media.js +93 -0
- package/dist/micropub/media.js.map +1 -0
- package/dist/micropub/update.d.ts +44 -0
- package/dist/micropub/update.d.ts.map +1 -0
- package/dist/micropub/update.js +133 -0
- package/dist/micropub/update.js.map +1 -0
- package/dist/web/assets.d.ts +14 -0
- package/dist/web/assets.d.ts.map +1 -1
- package/dist/web/assets.js +56 -1
- package/dist/web/assets.js.map +1 -1
- package/dist/web/context.d.ts +68 -3
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +56 -1
- package/dist/web/context.js.map +1 -1
- package/dist/web/feed-atom.d.ts.map +1 -1
- package/dist/web/feed-atom.js +20 -0
- package/dist/web/feed-atom.js.map +1 -1
- package/dist/web/feed-item.d.ts +19 -0
- package/dist/web/feed-item.d.ts.map +1 -1
- package/dist/web/feed-item.js +22 -0
- package/dist/web/feed-item.js.map +1 -1
- package/dist/web/feed-json.d.ts +13 -0
- package/dist/web/feed-json.d.ts.map +1 -1
- package/dist/web/feed-json.js +15 -0
- package/dist/web/feed-json.js.map +1 -1
- package/dist/web/feed-rss.d.ts.map +1 -1
- package/dist/web/feed-rss.js +59 -2
- package/dist/web/feed-rss.js.map +1 -1
- package/dist/web/feed-xml.d.ts +7 -1
- package/dist/web/feed-xml.d.ts.map +1 -1
- package/dist/web/feed-xml.js +8 -2
- package/dist/web/feed-xml.js.map +1 -1
- package/dist/web/feeds.d.ts +1 -1
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +6 -0
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/index.d.ts +4 -1
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +2 -0
- package/dist/web/index.js.map +1 -1
- package/dist/web/license.d.ts +94 -0
- package/dist/web/license.d.ts.map +1 -0
- package/dist/web/license.js +116 -0
- package/dist/web/license.js.map +1 -0
- package/dist/web/oembed.d.ts.map +1 -1
- package/dist/web/oembed.js +1 -2
- package/dist/web/oembed.js.map +1 -1
- package/dist/web/render.d.ts +15 -0
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +27 -2
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +10 -6
- package/dist/web/routes.js.map +1 -1
- package/dist/web/share-image.d.ts +7 -7
- package/dist/web/share-image.d.ts.map +1 -1
- package/dist/web/share-image.js +8 -7
- package/dist/web/share-image.js.map +1 -1
- package/dist/webmention/index.d.ts +2 -0
- package/dist/webmention/index.d.ts.map +1 -1
- package/dist/webmention/index.js +1 -0
- package/dist/webmention/index.js.map +1 -1
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +96 -18
- package/dist/webmention/service.js.map +1 -1
- package/dist/webmention/syndication.d.ts +135 -0
- package/dist/webmention/syndication.d.ts.map +1 -0
- package/dist/webmention/syndication.js +337 -0
- package/dist/webmention/syndication.js.map +1 -0
- package/package.json +1 -1
- package/themes/default/README.md +121 -37
- package/themes/default/layouts/base.njk +3 -4
- package/themes/default/layouts/post.njk +23 -6
- package/themes/default/partials/citations.njk +17 -0
- package/themes/default/partials/jsonld.njk +15 -6
- package/themes/default/partials/photos.njk +10 -0
- package/themes/default/partials/post-list.njk +7 -0
- package/themes/default/partials/recording.njk +41 -0
- package/themes/default/src/style.css +25 -0
- package/themes/default/static/style.css +41 -0
package/dist/admin/documents.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { CITATION_PROPERTIES, citationText } from '../content/citation.js';
|
|
3
4
|
import { renderMarkdown } from '../content/markdown.js';
|
|
4
5
|
import { parseDocument } from '../content/parser.js';
|
|
5
6
|
import { PINNED_FRONT_MATTER_KEY, PINNED_POST_LIMIT, pinnedAt } from '../content/pinned.js';
|
|
6
7
|
import { postLabel, replyTarget } from '../content/post-type.js';
|
|
8
|
+
import { ENCLOSURE_FRONT_MATTER_KEY, enclosureOf, isWebUrl, TRANSCRIPT_TYPES, } from '../content/enclosure.js';
|
|
9
|
+
import { PHOTO_FRONT_MATTER_KEY, photoFrontMatter } from '../content/photo.js';
|
|
7
10
|
import { contentFilePath, freeSlug, saveDocument } from '../content/save.js';
|
|
8
11
|
import { scheduledFor } from '../content/schedule.js';
|
|
9
12
|
import { htmlToText } from '../content/search.js';
|
|
@@ -11,7 +14,7 @@ import { defaultPermalink, slugify } from '../content/slug.js';
|
|
|
11
14
|
import { DuplicatePermalinkError, isTrashedPath, TRASH_DIRECTORY } from '../content/store.js';
|
|
12
15
|
import { calendarDayIn, DEFAULT_TIMEZONE, toUtcInstant, wallClockIn, zoneLabel, } from '../content/time.js';
|
|
13
16
|
import { normalizeBody, serializeDocument } from '../content/writer.js';
|
|
14
|
-
import { readAltTexts, undescribedImages } from '../images/alt-text.js';
|
|
17
|
+
import { readAltTexts, undescribedImages, undescribedPhotos } from '../images/alt-text.js';
|
|
15
18
|
import { isPublicDocument } from '../web/documents.js';
|
|
16
19
|
import { LANG_FRONT_MATTER_KEY } from '../web/locale.js';
|
|
17
20
|
import { absoluteUrl } from '../web/negotiate.js';
|
|
@@ -26,6 +29,9 @@ import { PREVIEW_PATH } from './preview.js';
|
|
|
26
29
|
import { ADMIN_PREFIX } from './session.js';
|
|
27
30
|
import { ADMIN_TEMPLATES } from './templates.js';
|
|
28
31
|
import { UPLOADS_PATH } from './uploads.js';
|
|
32
|
+
import { BLANK_ALTERNATE_ROW, BLANK_ENCLOSURE_FORM, ENCLOSURE_FIELDS, enclosureChoices, enclosureForm, enclosureFrontMatter, readEnclosureForm, resolveEnclosure, } from './enclosure-field.js';
|
|
33
|
+
import { PHOTO_FIELDS, photoChoices, photoRows, photoRowViews, readPhotoForm, resolvePhotos, } from './photo-field.js';
|
|
34
|
+
import { SYNDICATE_TO_FRONT_MATTER_KEY, syndicateToOf, syndicationTargetsReader, } from '../webmention/syndication.js';
|
|
29
35
|
/**
|
|
30
36
|
* The Eleventy key that keeps a document out of every collection.
|
|
31
37
|
*
|
|
@@ -183,14 +189,9 @@ export function mountDocumentScreens(app, options) {
|
|
|
183
189
|
});
|
|
184
190
|
}
|
|
185
191
|
/**
|
|
186
|
-
* Write what the editor submitted
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* the on-disk file is re-hashed and compared with the hash the form loaded with
|
|
190
|
-
* (doc-1's conflict rule), and only then is a file written. A save that is
|
|
191
|
-
* refused — a page with no title, an unreadable date, a stale hash, a URL
|
|
192
|
-
* another document already holds — leaves the content directory exactly as it
|
|
193
|
-
* was.
|
|
192
|
+
* Write what the editor submitted through {@link writeDocument}, and answer
|
|
193
|
+
* with the editor again: the refusal on the form, the conflict side by side,
|
|
194
|
+
* or the saved document under a flash.
|
|
194
195
|
*/
|
|
195
196
|
async function saveFromForm(c, options) {
|
|
196
197
|
const { kind, render, document, body } = options;
|
|
@@ -206,12 +207,20 @@ async function saveFromForm(c, options) {
|
|
|
206
207
|
description: text(body['description']).trim(),
|
|
207
208
|
author: text(body['author']).trim(),
|
|
208
209
|
inReplyTo: text(body['in-reply-to']).trim(),
|
|
210
|
+
...citationFields((property) => (kind.type === 'post' ? text(body[property]).trim() : '')),
|
|
209
211
|
lang: text(body['lang']).trim(),
|
|
210
212
|
draft: body['draft'] !== undefined,
|
|
211
213
|
exclude: body['exclude'] !== undefined,
|
|
212
214
|
contact: body['contact'] !== undefined,
|
|
213
215
|
pinned: kind.type === 'post' && body['pinned'] !== undefined,
|
|
214
216
|
comments: commentSetting(text(body['comments'])),
|
|
217
|
+
enclosure: kind.type === 'post' ? readEnclosureForm(body) : BLANK_ENCLOSURE_FORM,
|
|
218
|
+
photos: kind.type === 'post' ? readPhotoForm(body) : [],
|
|
219
|
+
syndicateTo: kind.type === 'post'
|
|
220
|
+
? syndicationTargetsReader(contentDir)()
|
|
221
|
+
.filter((target) => body[syndicateToField(target)] !== undefined)
|
|
222
|
+
.map((target) => target.id)
|
|
223
|
+
: [],
|
|
215
224
|
body: normalizeBody(text(body['body'])),
|
|
216
225
|
hash: text(body['hash']),
|
|
217
226
|
};
|
|
@@ -229,46 +238,109 @@ async function saveFromForm(c, options) {
|
|
|
229
238
|
status: 400,
|
|
230
239
|
});
|
|
231
240
|
}
|
|
241
|
+
const written = await writeDocument({
|
|
242
|
+
store,
|
|
243
|
+
config: c.var.config,
|
|
244
|
+
announce: c.var.announce,
|
|
245
|
+
writer: currentUsername(c),
|
|
246
|
+
}, { kind, document, form, draft });
|
|
247
|
+
if (written.outcome === 'refused')
|
|
248
|
+
return refuse(written.message);
|
|
249
|
+
if (written.outcome === 'conflict') {
|
|
250
|
+
return renderConflict(c, {
|
|
251
|
+
kind,
|
|
252
|
+
render,
|
|
253
|
+
document: written.document,
|
|
254
|
+
form: { ...form, draft },
|
|
255
|
+
media: written.media,
|
|
256
|
+
conflict: written.conflict,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const { saved, undescribed } = written;
|
|
260
|
+
flash(c, 'notice', savedMessage(kind, document, saved, store.now()));
|
|
261
|
+
if (undescribed.length > 0)
|
|
262
|
+
flash(c, 'warning', missingAltText(undescribed));
|
|
263
|
+
return c.redirect(editorPath(kind, saved.slug), 303);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The write path behind the editor and Micropub (TASK-164): one form in, one
|
|
267
|
+
* file out, announced to every subscriber.
|
|
268
|
+
*
|
|
269
|
+
* The order matters: the form is checked before anything is touched, the
|
|
270
|
+
* on-disk file is re-hashed and compared with the hash the form loaded with
|
|
271
|
+
* (doc-1's conflict rule), and only then is a file written. A write that is
|
|
272
|
+
* refused leaves the content directory exactly as it was.
|
|
273
|
+
*/
|
|
274
|
+
export async function writeDocument(site, write) {
|
|
275
|
+
const { store, config, writer } = site;
|
|
276
|
+
const { kind, document, form, draft } = write;
|
|
277
|
+
const contentDir = config.contentDir;
|
|
278
|
+
function refused(message) {
|
|
279
|
+
return { outcome: 'refused', message };
|
|
280
|
+
}
|
|
232
281
|
// A post with no title is a note; a page is always named.
|
|
233
282
|
if (form.title === '' && kind.type === 'page') {
|
|
234
|
-
return
|
|
283
|
+
return refused(`A ${kind.singular} needs a title.`);
|
|
235
284
|
}
|
|
236
285
|
// A post is a reply only when the target is a URL (Post Type Discovery), so
|
|
237
286
|
// anything else would be saved as a reply that is not one.
|
|
238
287
|
if (kind.type === 'post' && form.inReplyTo !== '' && replyTarget(form) === undefined) {
|
|
239
|
-
return
|
|
288
|
+
return refused('In reply to has to be a web address, like https://example.com/a-post/.');
|
|
289
|
+
}
|
|
290
|
+
if (kind.type === 'post') {
|
|
291
|
+
for (const property of CITATION_PROPERTIES) {
|
|
292
|
+
const cited = form[CITATION_FIELDS[property]];
|
|
293
|
+
if (cited !== '' && !isWebUrl(cited)) {
|
|
294
|
+
return refused(`${CITATION_LABELS[property]} has to be a web address, like https://example.com/a-post/.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
240
297
|
}
|
|
241
298
|
if (form.lang !== '' && !LANGUAGE_TAG_PATTERN.test(form.lang)) {
|
|
242
|
-
return
|
|
299
|
+
return refused('That is not a language tag, such as en, fr or pt-BR.');
|
|
300
|
+
}
|
|
301
|
+
let media = { recording: undefined, photos: [] };
|
|
302
|
+
if (kind.type === 'post') {
|
|
303
|
+
const resolved = resolveEnclosure(form.enclosure, document === undefined ? undefined : enclosureOf(document.extra), contentDir);
|
|
304
|
+
if ('error' in resolved)
|
|
305
|
+
return refused(resolved.error);
|
|
306
|
+
const photos = resolvePhotos(form.photos, contentDir);
|
|
307
|
+
if ('error' in photos)
|
|
308
|
+
return refused(photos.error);
|
|
309
|
+
media = { recording: resolved.enclosure, photos: photos.photos };
|
|
243
310
|
}
|
|
244
311
|
// TASK-207: a pin is new when the file does not carry one yet, and only a
|
|
245
312
|
// new one can take an author past Mastodon's limit.
|
|
246
313
|
if (form.pinned && (document === undefined || pinnedAt(document) === undefined)) {
|
|
247
|
-
const users = listUsers(
|
|
248
|
-
const author = userForAuthor(users, chosenAuthor(
|
|
314
|
+
const users = listUsers(config.dataDir);
|
|
315
|
+
const author = userForAuthor(users, chosenAuthor(config.dataDir, form.author, document, writer) ?? '');
|
|
249
316
|
const names = author === undefined ? [] : authorNames(users, author);
|
|
250
317
|
if (store.listPinnedByAuthor(names).length >= PINNED_POST_LIMIT) {
|
|
251
|
-
return
|
|
318
|
+
return refused(`You can pin up to ${String(PINNED_POST_LIMIT)} posts. Unpin one first.`);
|
|
252
319
|
}
|
|
253
320
|
}
|
|
254
321
|
// TASK-141: an image nobody described is a problem to fix before readers
|
|
255
322
|
// meet it. A draft is not checked, since nobody meets a draft.
|
|
323
|
+
// A photo counts too, unless the media library describes it (TASK-166).
|
|
324
|
+
const library = readAltTexts(contentDir);
|
|
256
325
|
const undescribed = draft
|
|
257
326
|
? []
|
|
258
|
-
:
|
|
259
|
-
|
|
260
|
-
|
|
327
|
+
: [
|
|
328
|
+
...undescribedPhotos(media.photos, library),
|
|
329
|
+
...undescribedImages(renderMarkdown(form.body), library),
|
|
330
|
+
];
|
|
331
|
+
if (undescribed.length > 0 && config.requireAltText) {
|
|
332
|
+
return refused(`This site publishes no image without alt text. ${missingAltText(undescribed)}`);
|
|
261
333
|
}
|
|
262
|
-
const timezone =
|
|
334
|
+
const timezone = readSiteSettings(contentDir).timezone;
|
|
263
335
|
// decision-11: what the file gets is a UTC instant, and an offset-less field
|
|
264
336
|
// is the site's own wall clock rather than the server's.
|
|
265
337
|
const typed = kind.dated ? (form.date === '' ? store.now().toISOString() : form.date) : undefined;
|
|
266
338
|
if (typed !== undefined && !/^\d{4}-\d{2}-\d{2}/.test(typed)) {
|
|
267
|
-
return
|
|
339
|
+
return refused('A date has to start with a year, a month and a day, like 2026-03-04.');
|
|
268
340
|
}
|
|
269
341
|
const date = typed === undefined ? undefined : toUtcInstant(typed, timezone);
|
|
270
342
|
if (typed !== undefined && date === undefined) {
|
|
271
|
-
return
|
|
343
|
+
return refused('That date is not one anybody can read. Try 2026-03-04 09:00.');
|
|
272
344
|
}
|
|
273
345
|
const slug = slugify(form.slug) ||
|
|
274
346
|
slugify(form.title) ||
|
|
@@ -307,11 +379,11 @@ async function saveFromForm(c, options) {
|
|
|
307
379
|
if (document !== undefined) {
|
|
308
380
|
const conflict = await conflictWith(contentDir, document, form.hash);
|
|
309
381
|
if (conflict !== undefined) {
|
|
310
|
-
return
|
|
382
|
+
return { outcome: 'conflict', document, media, conflict };
|
|
311
383
|
}
|
|
312
384
|
}
|
|
313
385
|
if (target !== document?.path && store.getByPath(target) !== undefined) {
|
|
314
|
-
return
|
|
386
|
+
return refused(`Another ${kind.singular} already lives in ${target}.`);
|
|
315
387
|
}
|
|
316
388
|
const content = {
|
|
317
389
|
title: form.title,
|
|
@@ -323,10 +395,10 @@ async function saveFromForm(c, options) {
|
|
|
323
395
|
categories: kind.categorised ? splitTags(form.categories) : [],
|
|
324
396
|
draft,
|
|
325
397
|
...(form.description === '' ? {} : { description: form.description }),
|
|
326
|
-
...optional('author', chosenAuthor(
|
|
398
|
+
...optional('author', chosenAuthor(config.dataDir, form.author, document, writer)),
|
|
327
399
|
...optional('inReplyTo', replyTo(kind, form, document)),
|
|
328
|
-
...optional('activitypub', keptIdentity(document, promised, permalink,
|
|
329
|
-
extra: resolveExtra(kind, document, form, store.now()),
|
|
400
|
+
...optional('activitypub', keptIdentity(document, promised, permalink, config.baseUrl)),
|
|
401
|
+
extra: resolveExtra(kind, document, form, store.now(), media, syndicationTargetsReader(contentDir)()),
|
|
330
402
|
body: form.body,
|
|
331
403
|
};
|
|
332
404
|
// The row for the old path goes first: a rename briefly leaves two rows
|
|
@@ -343,7 +415,7 @@ async function saveFromForm(c, options) {
|
|
|
343
415
|
if (renamedFrom !== undefined)
|
|
344
416
|
store.upsert(renamedFrom);
|
|
345
417
|
if (error instanceof DuplicatePermalinkError)
|
|
346
|
-
return
|
|
418
|
+
return refused(error.message);
|
|
347
419
|
throw error;
|
|
348
420
|
}
|
|
349
421
|
if (renamedFrom !== undefined) {
|
|
@@ -355,17 +427,14 @@ async function saveFromForm(c, options) {
|
|
|
355
427
|
// back to the file — the federation stamping `activitypub` into a post it
|
|
356
428
|
// has just announced — has finished before the editor is reloaded with a
|
|
357
429
|
// hash that would otherwise be one save behind.
|
|
358
|
-
await
|
|
430
|
+
await site.announce({
|
|
359
431
|
type: document === undefined ? 'created' : 'updated',
|
|
360
432
|
path: saved.path,
|
|
361
433
|
previous: document,
|
|
362
434
|
next: saved,
|
|
363
435
|
origin: 'admin',
|
|
364
436
|
});
|
|
365
|
-
|
|
366
|
-
if (undescribed.length > 0)
|
|
367
|
-
flash(c, 'warning', missingAltText(undescribed));
|
|
368
|
-
return c.redirect(editorPath(kind, saved.slug), 303);
|
|
437
|
+
return { outcome: 'saved', saved, undescribed };
|
|
369
438
|
}
|
|
370
439
|
/** What a save says about the images it found with no alt text, naming each. */
|
|
371
440
|
function missingAltText(images) {
|
|
@@ -546,8 +615,38 @@ function normalizePermalink(value) {
|
|
|
546
615
|
* the `false` is written, because a key somebody wrote by hand is not the
|
|
547
616
|
* editor's to delete.
|
|
548
617
|
*/
|
|
549
|
-
function resolveExtra(kind, document, form, now) {
|
|
618
|
+
function resolveExtra(kind, document, form, now, media, declared) {
|
|
550
619
|
const extra = { ...(document?.extra ?? {}) };
|
|
620
|
+
if (kind.type === 'post') {
|
|
621
|
+
if (media.recording === undefined)
|
|
622
|
+
delete extra[ENCLOSURE_FRONT_MATTER_KEY];
|
|
623
|
+
else
|
|
624
|
+
extra[ENCLOSURE_FRONT_MATTER_KEY] = enclosureFrontMatter(media.recording);
|
|
625
|
+
if (media.photos.length === 0)
|
|
626
|
+
delete extra[PHOTO_FRONT_MATTER_KEY];
|
|
627
|
+
else
|
|
628
|
+
extra[PHOTO_FRONT_MATTER_KEY] = photoFrontMatter(media.photos);
|
|
629
|
+
for (const property of CITATION_PROPERTIES) {
|
|
630
|
+
const cited = form[CITATION_FIELDS[property]];
|
|
631
|
+
if (cited === '')
|
|
632
|
+
delete extra[property];
|
|
633
|
+
else
|
|
634
|
+
extra[property] = cited;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// The checkboxes speak for the targets the site declares (TASK-155). An id
|
|
638
|
+
// the file lists that names no declared target has no checkbox, so it is
|
|
639
|
+
// kept as written rather than lost to a save.
|
|
640
|
+
if (kind.type === 'post') {
|
|
641
|
+
const offered = new Set(declared.map((target) => target.id));
|
|
642
|
+
const kept = syndicateToOf(extra).filter((id) => !offered.has(id));
|
|
643
|
+
// A form loaded from the file (a Micropub update) already holds those ids.
|
|
644
|
+
const listed = [...new Set([...form.syndicateTo, ...kept])];
|
|
645
|
+
if (listed.length === 0)
|
|
646
|
+
delete extra[SYNDICATE_TO_FRONT_MATTER_KEY];
|
|
647
|
+
else
|
|
648
|
+
extra[SYNDICATE_TO_FRONT_MATTER_KEY] = listed;
|
|
649
|
+
}
|
|
551
650
|
// The moment a post was pinned is what orders the featured collection, so a
|
|
552
651
|
// pin the file already carries keeps its moment through every later save,
|
|
553
652
|
// and unpinning takes the key out rather than writing `false` (TASK-207).
|
|
@@ -589,6 +688,10 @@ function resolveExtra(kind, document, form, now) {
|
|
|
589
688
|
}
|
|
590
689
|
return extra;
|
|
591
690
|
}
|
|
691
|
+
/** The editor checkbox that selects one syndication target. */
|
|
692
|
+
function syndicateToField(target) {
|
|
693
|
+
return `${SYNDICATE_TO_FRONT_MATTER_KEY}-${target.id}`;
|
|
694
|
+
}
|
|
592
695
|
/**
|
|
593
696
|
* A comma-separated taxonomy field as a list, without the blanks and the
|
|
594
697
|
* repeats. Tags and categories are both entered this way.
|
|
@@ -624,11 +727,11 @@ function currentUsername(c) {
|
|
|
624
727
|
* is either a hand-made request or a user deleted between the load and the
|
|
625
728
|
* save, and neither is a reason to reattribute somebody's post.
|
|
626
729
|
*/
|
|
627
|
-
function chosenAuthor(
|
|
628
|
-
const named = userForAuthor(listUsers(
|
|
730
|
+
function chosenAuthor(dataDir, submitted, document, writer) {
|
|
731
|
+
const named = userForAuthor(listUsers(dataDir), submitted);
|
|
629
732
|
if (named !== undefined)
|
|
630
733
|
return named.username;
|
|
631
|
-
return document?.author ??
|
|
734
|
+
return document?.author ?? writer;
|
|
632
735
|
}
|
|
633
736
|
/**
|
|
634
737
|
* The Author select: every user, whoever the document names, and — for a file
|
|
@@ -714,7 +817,7 @@ function renderConflict(c, options) {
|
|
|
714
817
|
...optional('author', document.author),
|
|
715
818
|
...optional('inReplyTo', replyTo(kind, form, document)),
|
|
716
819
|
...optional('activitypub', document.activitypub),
|
|
717
|
-
extra: resolveExtra(kind, document, form, c.var.store.now()),
|
|
820
|
+
extra: resolveExtra(kind, document, form, c.var.store.now(), options.media, syndicationTargetsReader(c.var.config.contentDir)()),
|
|
718
821
|
body: form.body,
|
|
719
822
|
});
|
|
720
823
|
c.status(409);
|
|
@@ -735,23 +838,41 @@ function renderConflict(c, options) {
|
|
|
735
838
|
});
|
|
736
839
|
}
|
|
737
840
|
/**
|
|
738
|
-
* Move a document into
|
|
739
|
-
*
|
|
740
|
-
* The trash mirrors the content tree — `posts/2026-03-04-x.md` becomes
|
|
741
|
-
* `_trash/posts/2026-03-04-x.md` — so restoring is the same move backwards and
|
|
742
|
-
* needs to remember nothing. The index is corrected as soon as the file has
|
|
743
|
-
* landed rather than waiting for the watcher, so the public site stops or
|
|
744
|
-
* starts serving the document with this request (doc-1).
|
|
841
|
+
* Move a document into the trash or back out of it from the editor, and go
|
|
842
|
+
* back where the form came from.
|
|
745
843
|
*/
|
|
746
844
|
async function moveDocument(c, options) {
|
|
747
845
|
const { kind, document, action } = options;
|
|
748
|
-
const store = c.var.store;
|
|
749
|
-
const contentDir = c.var.config.contentDir;
|
|
750
846
|
const trashed = isTrashedPath(document.path);
|
|
751
847
|
if (action === 'trash' && trashed)
|
|
752
848
|
return backTo(c, options, `It is already in the trash.`);
|
|
753
849
|
if (action === 'restore' && !trashed)
|
|
754
850
|
return backTo(c, options, `It is not in the trash.`);
|
|
851
|
+
const moved = await moveDocumentFile({ store: c.var.store, contentDir: c.var.config.contentDir, announce: c.var.announce }, document, action);
|
|
852
|
+
if (moved === undefined) {
|
|
853
|
+
return backTo(c, options, `Could not move ${document.path}. Is the file still there?`);
|
|
854
|
+
}
|
|
855
|
+
const message = action === 'trash'
|
|
856
|
+
? `Moved to the trash: ${postLabel(document)}`
|
|
857
|
+
: `Restored: ${postLabel(document)}`;
|
|
858
|
+
flash(c, 'notice', message);
|
|
859
|
+
return c.redirect(returnPath(options.returnTo) ??
|
|
860
|
+
(action === 'trash' ? kind.basePath : editorPath(kind, document.slug)), 303);
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Move a document into `content/_trash/` or back out of it, the move behind
|
|
864
|
+
* the editor's trash and restore and Micropub's delete and undelete
|
|
865
|
+
* (TASK-167). The document is the moved one, or `undefined` when its file
|
|
866
|
+
* could not be moved.
|
|
867
|
+
*
|
|
868
|
+
* The trash mirrors the content tree — `posts/2026-03-04-x.md` becomes
|
|
869
|
+
* `_trash/posts/2026-03-04-x.md` — so restoring is the same move backwards and
|
|
870
|
+
* needs to remember nothing. The index is corrected as soon as the file has
|
|
871
|
+
* landed rather than waiting for the watcher, so the public site stops or
|
|
872
|
+
* starts serving the document with this request (doc-1).
|
|
873
|
+
*/
|
|
874
|
+
export async function moveDocumentFile(site, document, action) {
|
|
875
|
+
const { store, contentDir } = site;
|
|
755
876
|
const target = action === 'trash'
|
|
756
877
|
? `${TRASH_DIRECTORY}/${document.path}`
|
|
757
878
|
: document.path
|
|
@@ -767,26 +888,21 @@ async function moveDocument(c, options) {
|
|
|
767
888
|
await rename(from, to);
|
|
768
889
|
}
|
|
769
890
|
catch {
|
|
770
|
-
return
|
|
891
|
+
return undefined;
|
|
771
892
|
}
|
|
772
893
|
const moved = parseDocument(source, { path: target, type: document.type });
|
|
773
894
|
store.remove(document.path);
|
|
774
895
|
store.upsert(moved);
|
|
775
896
|
// Trashing takes a post off the public site and restoring puts it back, so
|
|
776
897
|
// both are visibility changes a subscriber has to hear about.
|
|
777
|
-
await
|
|
898
|
+
await site.announce({
|
|
778
899
|
type: 'updated',
|
|
779
900
|
path: target,
|
|
780
901
|
previous: document,
|
|
781
902
|
next: moved,
|
|
782
903
|
origin: 'admin',
|
|
783
904
|
});
|
|
784
|
-
|
|
785
|
-
? `Moved to the trash: ${postLabel(document)}`
|
|
786
|
-
: `Restored: ${postLabel(document)}`;
|
|
787
|
-
flash(c, 'notice', message);
|
|
788
|
-
return c.redirect(returnPath(options.returnTo) ??
|
|
789
|
-
(action === 'trash' ? kind.basePath : editorPath(kind, document.slug)), 303);
|
|
905
|
+
return moved;
|
|
790
906
|
}
|
|
791
907
|
/** Report a move that did not happen and go back where the form came from. */
|
|
792
908
|
function backTo(c, options, message) {
|
|
@@ -823,6 +939,29 @@ function replyTo(kind, form, document) {
|
|
|
823
939
|
return document?.inReplyTo;
|
|
824
940
|
return form.inReplyTo === '' ? undefined : form.inReplyTo;
|
|
825
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* The editor field each citing property fills (TASK-169). The form submits it
|
|
944
|
+
* under the property's own name, as `in-reply-to` is.
|
|
945
|
+
*/
|
|
946
|
+
export const CITATION_FIELDS = {
|
|
947
|
+
'repost-of': 'repostOf',
|
|
948
|
+
'like-of': 'likeOf',
|
|
949
|
+
'bookmark-of': 'bookmarkOf',
|
|
950
|
+
};
|
|
951
|
+
/** What the editor calls each citing field, which a refusal names. */
|
|
952
|
+
const CITATION_LABELS = {
|
|
953
|
+
'repost-of': 'Repost of',
|
|
954
|
+
'like-of': 'Like of',
|
|
955
|
+
'bookmark-of': 'Bookmark of',
|
|
956
|
+
};
|
|
957
|
+
/** The three citing fields, each filled from its property. */
|
|
958
|
+
function citationFields(value) {
|
|
959
|
+
return {
|
|
960
|
+
repostOf: value('repost-of'),
|
|
961
|
+
likeOf: value('like-of'),
|
|
962
|
+
bookmarkOf: value('bookmark-of'),
|
|
963
|
+
};
|
|
964
|
+
}
|
|
826
965
|
/**
|
|
827
966
|
* The editor for a document that does not exist yet.
|
|
828
967
|
*
|
|
@@ -844,12 +983,16 @@ export function blankForm(kind, timezone = DEFAULT_TIMEZONE, now = new Date()) {
|
|
|
844
983
|
// {@link authorChoices} is where the default is applied.
|
|
845
984
|
author: '',
|
|
846
985
|
inReplyTo: '',
|
|
986
|
+
...citationFields(() => ''),
|
|
847
987
|
lang: '',
|
|
848
988
|
draft: false,
|
|
849
989
|
exclude: false,
|
|
850
990
|
contact: false,
|
|
851
991
|
pinned: false,
|
|
852
992
|
comments: COMMENT_SETTINGS.site,
|
|
993
|
+
enclosure: BLANK_ENCLOSURE_FORM,
|
|
994
|
+
photos: [],
|
|
995
|
+
syndicateTo: [],
|
|
853
996
|
body: '',
|
|
854
997
|
hash: '',
|
|
855
998
|
};
|
|
@@ -873,6 +1016,8 @@ export function formFor(document, timezone = DEFAULT_TIMEZONE) {
|
|
|
873
1016
|
description: document.description ?? '',
|
|
874
1017
|
author: document.author ?? '',
|
|
875
1018
|
inReplyTo: document.inReplyTo ?? '',
|
|
1019
|
+
// As the file spells it, so a save writes back what it read.
|
|
1020
|
+
...citationFields((property) => document.type === 'post' ? citationText(document.extra[property]) : ''),
|
|
876
1021
|
lang: typeof document.extra[LANG_FRONT_MATTER_KEY] === 'string'
|
|
877
1022
|
? document.extra[LANG_FRONT_MATTER_KEY]
|
|
878
1023
|
: '',
|
|
@@ -881,6 +1026,9 @@ export function formFor(document, timezone = DEFAULT_TIMEZONE) {
|
|
|
881
1026
|
contact: document.extra[CONTACT_FRONT_MATTER_KEY] === true,
|
|
882
1027
|
pinned: pinnedAt(document) !== undefined,
|
|
883
1028
|
comments: commentSettingOf(document),
|
|
1029
|
+
enclosure: document.type === 'post' ? enclosureForm(document) : BLANK_ENCLOSURE_FORM,
|
|
1030
|
+
photos: document.type === 'post' ? photoRows(document) : [],
|
|
1031
|
+
syndicateTo: document.type === 'post' ? syndicateToOf(document.extra) : [],
|
|
884
1032
|
body: document.body,
|
|
885
1033
|
hash: document.hash,
|
|
886
1034
|
};
|
|
@@ -893,7 +1041,7 @@ export function formFor(document, timezone = DEFAULT_TIMEZONE) {
|
|
|
893
1041
|
* Update, and anything that exists can be thrown away or, if it already has
|
|
894
1042
|
* been, restored.
|
|
895
1043
|
*/
|
|
896
|
-
function renderEditor(c, options) {
|
|
1044
|
+
async function renderEditor(c, options) {
|
|
897
1045
|
const { kind, document, form } = options;
|
|
898
1046
|
const trashed = document !== undefined && isTrashedPath(document.path);
|
|
899
1047
|
const now = c.var.store.now();
|
|
@@ -925,6 +1073,23 @@ function renderEditor(c, options) {
|
|
|
925
1073
|
actions,
|
|
926
1074
|
trashed,
|
|
927
1075
|
commentSettings: COMMENT_SETTINGS,
|
|
1076
|
+
...(kind.type === 'post'
|
|
1077
|
+
? {
|
|
1078
|
+
enclosureFields: ENCLOSURE_FIELDS,
|
|
1079
|
+
enclosureChoices: await enclosureChoices(c.var.config.contentDir, form.enclosure.url),
|
|
1080
|
+
transcriptTypes: TRANSCRIPT_TYPES,
|
|
1081
|
+
alternateRows: [...form.enclosure.alternates, BLANK_ALTERNATE_ROW],
|
|
1082
|
+
photoFields: PHOTO_FIELDS,
|
|
1083
|
+
photoRows: photoRowViews(form.photos, readAltTexts(c.var.config.contentDir)),
|
|
1084
|
+
photoChoices: await photoChoices(c.var.config.contentDir),
|
|
1085
|
+
// TASK-155: one checkbox per target the site declares.
|
|
1086
|
+
syndicationTargets: syndicationTargetsReader(c.var.config.contentDir)().map((target) => ({
|
|
1087
|
+
...target,
|
|
1088
|
+
field: syndicateToField(target),
|
|
1089
|
+
checked: form.syndicateTo.includes(target.id),
|
|
1090
|
+
})),
|
|
1091
|
+
}
|
|
1092
|
+
: {}),
|
|
928
1093
|
// Who this can be attributed to, and who it is attributed to now.
|
|
929
1094
|
authors: authorChoices(c, form.author),
|
|
930
1095
|
// What an empty Language field means.
|