@geekity/cms 0.15.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 +107 -0
- package/admin/pages/documents/editor.njk +51 -0
- package/admin/pages/documents/syndication.njk +110 -0
- package/admin/pages/federation/followers.njk +7 -0
- package/admin/static/admin.css +11 -0
- package/dist/admin/documents.d.ts +101 -0
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +199 -67
- package/dist/admin/documents.js.map +1 -1
- 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/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/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 +6 -0
- package/dist/admin/uploads.d.ts.map +1 -1
- package/dist/admin/uploads.js +1 -1
- package/dist/admin/uploads.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/index.d.ts +2 -0
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +1 -0
- package/dist/content/index.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 +59 -5
- 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/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -2
- 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/context.d.ts +42 -3
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +41 -1
- package/dist/web/context.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 +16 -0
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.js +2 -2
- 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 +40 -9
- package/themes/default/layouts/post.njk +19 -6
- package/themes/default/partials/citations.njk +17 -0
- package/themes/default/partials/jsonld.njk +12 -1
- package/themes/default/partials/photos.njk +10 -0
- package/themes/default/partials/post-list.njk +7 -0
- package/themes/default/src/style.css +9 -0
- package/themes/default/static/style.css +16 -0
package/dist/admin/documents.js
CHANGED
|
@@ -1,10 +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';
|
|
7
|
-
import { ENCLOSURE_FRONT_MATTER_KEY, enclosureOf, TRANSCRIPT_TYPES } from '../content/enclosure.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';
|
|
8
10
|
import { contentFilePath, freeSlug, saveDocument } from '../content/save.js';
|
|
9
11
|
import { scheduledFor } from '../content/schedule.js';
|
|
10
12
|
import { htmlToText } from '../content/search.js';
|
|
@@ -12,7 +14,7 @@ import { defaultPermalink, slugify } from '../content/slug.js';
|
|
|
12
14
|
import { DuplicatePermalinkError, isTrashedPath, TRASH_DIRECTORY } from '../content/store.js';
|
|
13
15
|
import { calendarDayIn, DEFAULT_TIMEZONE, toUtcInstant, wallClockIn, zoneLabel, } from '../content/time.js';
|
|
14
16
|
import { normalizeBody, serializeDocument } from '../content/writer.js';
|
|
15
|
-
import { readAltTexts, undescribedImages } from '../images/alt-text.js';
|
|
17
|
+
import { readAltTexts, undescribedImages, undescribedPhotos } from '../images/alt-text.js';
|
|
16
18
|
import { isPublicDocument } from '../web/documents.js';
|
|
17
19
|
import { LANG_FRONT_MATTER_KEY } from '../web/locale.js';
|
|
18
20
|
import { absoluteUrl } from '../web/negotiate.js';
|
|
@@ -28,6 +30,8 @@ import { ADMIN_PREFIX } from './session.js';
|
|
|
28
30
|
import { ADMIN_TEMPLATES } from './templates.js';
|
|
29
31
|
import { UPLOADS_PATH } from './uploads.js';
|
|
30
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';
|
|
31
35
|
/**
|
|
32
36
|
* The Eleventy key that keeps a document out of every collection.
|
|
33
37
|
*
|
|
@@ -185,14 +189,9 @@ export function mountDocumentScreens(app, options) {
|
|
|
185
189
|
});
|
|
186
190
|
}
|
|
187
191
|
/**
|
|
188
|
-
* Write what the editor submitted
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* the on-disk file is re-hashed and compared with the hash the form loaded with
|
|
192
|
-
* (doc-1's conflict rule), and only then is a file written. A save that is
|
|
193
|
-
* refused — a page with no title, an unreadable date, a stale hash, a URL
|
|
194
|
-
* another document already holds — leaves the content directory exactly as it
|
|
195
|
-
* 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.
|
|
196
195
|
*/
|
|
197
196
|
async function saveFromForm(c, options) {
|
|
198
197
|
const { kind, render, document, body } = options;
|
|
@@ -208,6 +207,7 @@ async function saveFromForm(c, options) {
|
|
|
208
207
|
description: text(body['description']).trim(),
|
|
209
208
|
author: text(body['author']).trim(),
|
|
210
209
|
inReplyTo: text(body['in-reply-to']).trim(),
|
|
210
|
+
...citationFields((property) => (kind.type === 'post' ? text(body[property]).trim() : '')),
|
|
211
211
|
lang: text(body['lang']).trim(),
|
|
212
212
|
draft: body['draft'] !== undefined,
|
|
213
213
|
exclude: body['exclude'] !== undefined,
|
|
@@ -215,6 +215,12 @@ async function saveFromForm(c, options) {
|
|
|
215
215
|
pinned: kind.type === 'post' && body['pinned'] !== undefined,
|
|
216
216
|
comments: commentSetting(text(body['comments'])),
|
|
217
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
|
+
: [],
|
|
218
224
|
body: normalizeBody(text(body['body'])),
|
|
219
225
|
hash: text(body['hash']),
|
|
220
226
|
};
|
|
@@ -232,53 +238,109 @@ async function saveFromForm(c, options) {
|
|
|
232
238
|
status: 400,
|
|
233
239
|
});
|
|
234
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
|
+
}
|
|
235
281
|
// A post with no title is a note; a page is always named.
|
|
236
282
|
if (form.title === '' && kind.type === 'page') {
|
|
237
|
-
return
|
|
283
|
+
return refused(`A ${kind.singular} needs a title.`);
|
|
238
284
|
}
|
|
239
285
|
// A post is a reply only when the target is a URL (Post Type Discovery), so
|
|
240
286
|
// anything else would be saved as a reply that is not one.
|
|
241
287
|
if (kind.type === 'post' && form.inReplyTo !== '' && replyTarget(form) === undefined) {
|
|
242
|
-
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
|
+
}
|
|
243
297
|
}
|
|
244
298
|
if (form.lang !== '' && !LANGUAGE_TAG_PATTERN.test(form.lang)) {
|
|
245
|
-
return
|
|
299
|
+
return refused('That is not a language tag, such as en, fr or pt-BR.');
|
|
246
300
|
}
|
|
247
|
-
let recording;
|
|
301
|
+
let media = { recording: undefined, photos: [] };
|
|
248
302
|
if (kind.type === 'post') {
|
|
249
303
|
const resolved = resolveEnclosure(form.enclosure, document === undefined ? undefined : enclosureOf(document.extra), contentDir);
|
|
250
304
|
if ('error' in resolved)
|
|
251
|
-
return
|
|
252
|
-
|
|
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 };
|
|
253
310
|
}
|
|
254
311
|
// TASK-207: a pin is new when the file does not carry one yet, and only a
|
|
255
312
|
// new one can take an author past Mastodon's limit.
|
|
256
313
|
if (form.pinned && (document === undefined || pinnedAt(document) === undefined)) {
|
|
257
|
-
const users = listUsers(
|
|
258
|
-
const author = userForAuthor(users, chosenAuthor(
|
|
314
|
+
const users = listUsers(config.dataDir);
|
|
315
|
+
const author = userForAuthor(users, chosenAuthor(config.dataDir, form.author, document, writer) ?? '');
|
|
259
316
|
const names = author === undefined ? [] : authorNames(users, author);
|
|
260
317
|
if (store.listPinnedByAuthor(names).length >= PINNED_POST_LIMIT) {
|
|
261
|
-
return
|
|
318
|
+
return refused(`You can pin up to ${String(PINNED_POST_LIMIT)} posts. Unpin one first.`);
|
|
262
319
|
}
|
|
263
320
|
}
|
|
264
321
|
// TASK-141: an image nobody described is a problem to fix before readers
|
|
265
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);
|
|
266
325
|
const undescribed = draft
|
|
267
326
|
? []
|
|
268
|
-
:
|
|
269
|
-
|
|
270
|
-
|
|
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)}`);
|
|
271
333
|
}
|
|
272
|
-
const timezone =
|
|
334
|
+
const timezone = readSiteSettings(contentDir).timezone;
|
|
273
335
|
// decision-11: what the file gets is a UTC instant, and an offset-less field
|
|
274
336
|
// is the site's own wall clock rather than the server's.
|
|
275
337
|
const typed = kind.dated ? (form.date === '' ? store.now().toISOString() : form.date) : undefined;
|
|
276
338
|
if (typed !== undefined && !/^\d{4}-\d{2}-\d{2}/.test(typed)) {
|
|
277
|
-
return
|
|
339
|
+
return refused('A date has to start with a year, a month and a day, like 2026-03-04.');
|
|
278
340
|
}
|
|
279
341
|
const date = typed === undefined ? undefined : toUtcInstant(typed, timezone);
|
|
280
342
|
if (typed !== undefined && date === undefined) {
|
|
281
|
-
return
|
|
343
|
+
return refused('That date is not one anybody can read. Try 2026-03-04 09:00.');
|
|
282
344
|
}
|
|
283
345
|
const slug = slugify(form.slug) ||
|
|
284
346
|
slugify(form.title) ||
|
|
@@ -317,18 +379,11 @@ async function saveFromForm(c, options) {
|
|
|
317
379
|
if (document !== undefined) {
|
|
318
380
|
const conflict = await conflictWith(contentDir, document, form.hash);
|
|
319
381
|
if (conflict !== undefined) {
|
|
320
|
-
return
|
|
321
|
-
kind,
|
|
322
|
-
render,
|
|
323
|
-
document,
|
|
324
|
-
form: { ...form, draft },
|
|
325
|
-
recording,
|
|
326
|
-
conflict,
|
|
327
|
-
});
|
|
382
|
+
return { outcome: 'conflict', document, media, conflict };
|
|
328
383
|
}
|
|
329
384
|
}
|
|
330
385
|
if (target !== document?.path && store.getByPath(target) !== undefined) {
|
|
331
|
-
return
|
|
386
|
+
return refused(`Another ${kind.singular} already lives in ${target}.`);
|
|
332
387
|
}
|
|
333
388
|
const content = {
|
|
334
389
|
title: form.title,
|
|
@@ -340,10 +395,10 @@ async function saveFromForm(c, options) {
|
|
|
340
395
|
categories: kind.categorised ? splitTags(form.categories) : [],
|
|
341
396
|
draft,
|
|
342
397
|
...(form.description === '' ? {} : { description: form.description }),
|
|
343
|
-
...optional('author', chosenAuthor(
|
|
398
|
+
...optional('author', chosenAuthor(config.dataDir, form.author, document, writer)),
|
|
344
399
|
...optional('inReplyTo', replyTo(kind, form, document)),
|
|
345
|
-
...optional('activitypub', keptIdentity(document, promised, permalink,
|
|
346
|
-
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)()),
|
|
347
402
|
body: form.body,
|
|
348
403
|
};
|
|
349
404
|
// The row for the old path goes first: a rename briefly leaves two rows
|
|
@@ -360,7 +415,7 @@ async function saveFromForm(c, options) {
|
|
|
360
415
|
if (renamedFrom !== undefined)
|
|
361
416
|
store.upsert(renamedFrom);
|
|
362
417
|
if (error instanceof DuplicatePermalinkError)
|
|
363
|
-
return
|
|
418
|
+
return refused(error.message);
|
|
364
419
|
throw error;
|
|
365
420
|
}
|
|
366
421
|
if (renamedFrom !== undefined) {
|
|
@@ -372,17 +427,14 @@ async function saveFromForm(c, options) {
|
|
|
372
427
|
// back to the file — the federation stamping `activitypub` into a post it
|
|
373
428
|
// has just announced — has finished before the editor is reloaded with a
|
|
374
429
|
// hash that would otherwise be one save behind.
|
|
375
|
-
await
|
|
430
|
+
await site.announce({
|
|
376
431
|
type: document === undefined ? 'created' : 'updated',
|
|
377
432
|
path: saved.path,
|
|
378
433
|
previous: document,
|
|
379
434
|
next: saved,
|
|
380
435
|
origin: 'admin',
|
|
381
436
|
});
|
|
382
|
-
|
|
383
|
-
if (undescribed.length > 0)
|
|
384
|
-
flash(c, 'warning', missingAltText(undescribed));
|
|
385
|
-
return c.redirect(editorPath(kind, saved.slug), 303);
|
|
437
|
+
return { outcome: 'saved', saved, undescribed };
|
|
386
438
|
}
|
|
387
439
|
/** What a save says about the images it found with no alt text, naming each. */
|
|
388
440
|
function missingAltText(images) {
|
|
@@ -563,13 +615,37 @@ function normalizePermalink(value) {
|
|
|
563
615
|
* the `false` is written, because a key somebody wrote by hand is not the
|
|
564
616
|
* editor's to delete.
|
|
565
617
|
*/
|
|
566
|
-
function resolveExtra(kind, document, form, now,
|
|
618
|
+
function resolveExtra(kind, document, form, now, media, declared) {
|
|
567
619
|
const extra = { ...(document?.extra ?? {}) };
|
|
568
620
|
if (kind.type === 'post') {
|
|
569
|
-
if (recording === undefined)
|
|
621
|
+
if (media.recording === undefined)
|
|
570
622
|
delete extra[ENCLOSURE_FRONT_MATTER_KEY];
|
|
571
623
|
else
|
|
572
|
-
extra[ENCLOSURE_FRONT_MATTER_KEY] = enclosureFrontMatter(recording);
|
|
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;
|
|
573
649
|
}
|
|
574
650
|
// The moment a post was pinned is what orders the featured collection, so a
|
|
575
651
|
// pin the file already carries keeps its moment through every later save,
|
|
@@ -612,6 +688,10 @@ function resolveExtra(kind, document, form, now, recording) {
|
|
|
612
688
|
}
|
|
613
689
|
return extra;
|
|
614
690
|
}
|
|
691
|
+
/** The editor checkbox that selects one syndication target. */
|
|
692
|
+
function syndicateToField(target) {
|
|
693
|
+
return `${SYNDICATE_TO_FRONT_MATTER_KEY}-${target.id}`;
|
|
694
|
+
}
|
|
615
695
|
/**
|
|
616
696
|
* A comma-separated taxonomy field as a list, without the blanks and the
|
|
617
697
|
* repeats. Tags and categories are both entered this way.
|
|
@@ -647,11 +727,11 @@ function currentUsername(c) {
|
|
|
647
727
|
* is either a hand-made request or a user deleted between the load and the
|
|
648
728
|
* save, and neither is a reason to reattribute somebody's post.
|
|
649
729
|
*/
|
|
650
|
-
function chosenAuthor(
|
|
651
|
-
const named = userForAuthor(listUsers(
|
|
730
|
+
function chosenAuthor(dataDir, submitted, document, writer) {
|
|
731
|
+
const named = userForAuthor(listUsers(dataDir), submitted);
|
|
652
732
|
if (named !== undefined)
|
|
653
733
|
return named.username;
|
|
654
|
-
return document?.author ??
|
|
734
|
+
return document?.author ?? writer;
|
|
655
735
|
}
|
|
656
736
|
/**
|
|
657
737
|
* The Author select: every user, whoever the document names, and — for a file
|
|
@@ -737,7 +817,7 @@ function renderConflict(c, options) {
|
|
|
737
817
|
...optional('author', document.author),
|
|
738
818
|
...optional('inReplyTo', replyTo(kind, form, document)),
|
|
739
819
|
...optional('activitypub', document.activitypub),
|
|
740
|
-
extra: resolveExtra(kind, document, form, c.var.store.now(), options.
|
|
820
|
+
extra: resolveExtra(kind, document, form, c.var.store.now(), options.media, syndicationTargetsReader(c.var.config.contentDir)()),
|
|
741
821
|
body: form.body,
|
|
742
822
|
});
|
|
743
823
|
c.status(409);
|
|
@@ -758,23 +838,41 @@ function renderConflict(c, options) {
|
|
|
758
838
|
});
|
|
759
839
|
}
|
|
760
840
|
/**
|
|
761
|
-
* Move a document into
|
|
762
|
-
*
|
|
763
|
-
* The trash mirrors the content tree — `posts/2026-03-04-x.md` becomes
|
|
764
|
-
* `_trash/posts/2026-03-04-x.md` — so restoring is the same move backwards and
|
|
765
|
-
* needs to remember nothing. The index is corrected as soon as the file has
|
|
766
|
-
* landed rather than waiting for the watcher, so the public site stops or
|
|
767
|
-
* 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.
|
|
768
843
|
*/
|
|
769
844
|
async function moveDocument(c, options) {
|
|
770
845
|
const { kind, document, action } = options;
|
|
771
|
-
const store = c.var.store;
|
|
772
|
-
const contentDir = c.var.config.contentDir;
|
|
773
846
|
const trashed = isTrashedPath(document.path);
|
|
774
847
|
if (action === 'trash' && trashed)
|
|
775
848
|
return backTo(c, options, `It is already in the trash.`);
|
|
776
849
|
if (action === 'restore' && !trashed)
|
|
777
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;
|
|
778
876
|
const target = action === 'trash'
|
|
779
877
|
? `${TRASH_DIRECTORY}/${document.path}`
|
|
780
878
|
: document.path
|
|
@@ -790,26 +888,21 @@ async function moveDocument(c, options) {
|
|
|
790
888
|
await rename(from, to);
|
|
791
889
|
}
|
|
792
890
|
catch {
|
|
793
|
-
return
|
|
891
|
+
return undefined;
|
|
794
892
|
}
|
|
795
893
|
const moved = parseDocument(source, { path: target, type: document.type });
|
|
796
894
|
store.remove(document.path);
|
|
797
895
|
store.upsert(moved);
|
|
798
896
|
// Trashing takes a post off the public site and restoring puts it back, so
|
|
799
897
|
// both are visibility changes a subscriber has to hear about.
|
|
800
|
-
await
|
|
898
|
+
await site.announce({
|
|
801
899
|
type: 'updated',
|
|
802
900
|
path: target,
|
|
803
901
|
previous: document,
|
|
804
902
|
next: moved,
|
|
805
903
|
origin: 'admin',
|
|
806
904
|
});
|
|
807
|
-
|
|
808
|
-
? `Moved to the trash: ${postLabel(document)}`
|
|
809
|
-
: `Restored: ${postLabel(document)}`;
|
|
810
|
-
flash(c, 'notice', message);
|
|
811
|
-
return c.redirect(returnPath(options.returnTo) ??
|
|
812
|
-
(action === 'trash' ? kind.basePath : editorPath(kind, document.slug)), 303);
|
|
905
|
+
return moved;
|
|
813
906
|
}
|
|
814
907
|
/** Report a move that did not happen and go back where the form came from. */
|
|
815
908
|
function backTo(c, options, message) {
|
|
@@ -846,6 +939,29 @@ function replyTo(kind, form, document) {
|
|
|
846
939
|
return document?.inReplyTo;
|
|
847
940
|
return form.inReplyTo === '' ? undefined : form.inReplyTo;
|
|
848
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
|
+
}
|
|
849
965
|
/**
|
|
850
966
|
* The editor for a document that does not exist yet.
|
|
851
967
|
*
|
|
@@ -867,6 +983,7 @@ export function blankForm(kind, timezone = DEFAULT_TIMEZONE, now = new Date()) {
|
|
|
867
983
|
// {@link authorChoices} is where the default is applied.
|
|
868
984
|
author: '',
|
|
869
985
|
inReplyTo: '',
|
|
986
|
+
...citationFields(() => ''),
|
|
870
987
|
lang: '',
|
|
871
988
|
draft: false,
|
|
872
989
|
exclude: false,
|
|
@@ -874,6 +991,8 @@ export function blankForm(kind, timezone = DEFAULT_TIMEZONE, now = new Date()) {
|
|
|
874
991
|
pinned: false,
|
|
875
992
|
comments: COMMENT_SETTINGS.site,
|
|
876
993
|
enclosure: BLANK_ENCLOSURE_FORM,
|
|
994
|
+
photos: [],
|
|
995
|
+
syndicateTo: [],
|
|
877
996
|
body: '',
|
|
878
997
|
hash: '',
|
|
879
998
|
};
|
|
@@ -897,6 +1016,8 @@ export function formFor(document, timezone = DEFAULT_TIMEZONE) {
|
|
|
897
1016
|
description: document.description ?? '',
|
|
898
1017
|
author: document.author ?? '',
|
|
899
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]) : ''),
|
|
900
1021
|
lang: typeof document.extra[LANG_FRONT_MATTER_KEY] === 'string'
|
|
901
1022
|
? document.extra[LANG_FRONT_MATTER_KEY]
|
|
902
1023
|
: '',
|
|
@@ -906,6 +1027,8 @@ export function formFor(document, timezone = DEFAULT_TIMEZONE) {
|
|
|
906
1027
|
pinned: pinnedAt(document) !== undefined,
|
|
907
1028
|
comments: commentSettingOf(document),
|
|
908
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) : [],
|
|
909
1032
|
body: document.body,
|
|
910
1033
|
hash: document.hash,
|
|
911
1034
|
};
|
|
@@ -956,6 +1079,15 @@ async function renderEditor(c, options) {
|
|
|
956
1079
|
enclosureChoices: await enclosureChoices(c.var.config.contentDir, form.enclosure.url),
|
|
957
1080
|
transcriptTypes: TRANSCRIPT_TYPES,
|
|
958
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
|
+
})),
|
|
959
1091
|
}
|
|
960
1092
|
: {}),
|
|
961
1093
|
// Who this can be attributed to, and who it is attributed to now.
|