@geekity/cms 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +108 -0
  2. package/admin/pages/documents/editor.njk +51 -0
  3. package/admin/pages/documents/syndication.njk +110 -0
  4. package/admin/pages/federation/followers.njk +7 -0
  5. package/admin/pages/users/activity-entry.njk +58 -0
  6. package/admin/pages/users/activity.njk +66 -0
  7. package/admin/static/admin.css +11 -0
  8. package/dist/admin/documents.d.ts +101 -0
  9. package/dist/admin/documents.d.ts.map +1 -1
  10. package/dist/admin/documents.js +199 -67
  11. package/dist/admin/documents.js.map +1 -1
  12. package/dist/admin/federation.d.ts +15 -0
  13. package/dist/admin/federation.d.ts.map +1 -1
  14. package/dist/admin/federation.js +19 -0
  15. package/dist/admin/federation.js.map +1 -1
  16. package/dist/admin/menu.d.ts.map +1 -1
  17. package/dist/admin/menu.js +7 -1
  18. package/dist/admin/menu.js.map +1 -1
  19. package/dist/admin/photo-field.d.ts +39 -0
  20. package/dist/admin/photo-field.d.ts.map +1 -0
  21. package/dist/admin/photo-field.js +91 -0
  22. package/dist/admin/photo-field.js.map +1 -0
  23. package/dist/admin/preview.d.ts.map +1 -1
  24. package/dist/admin/preview.js +6 -1
  25. package/dist/admin/preview.js.map +1 -1
  26. package/dist/admin/routes.d.ts.map +1 -1
  27. package/dist/admin/routes.js +8 -0
  28. package/dist/admin/routes.js.map +1 -1
  29. package/dist/admin/syndication-targets.d.ts +57 -0
  30. package/dist/admin/syndication-targets.d.ts.map +1 -0
  31. package/dist/admin/syndication-targets.js +266 -0
  32. package/dist/admin/syndication-targets.js.map +1 -0
  33. package/dist/admin/templates.d.ts +3 -0
  34. package/dist/admin/templates.d.ts.map +1 -1
  35. package/dist/admin/templates.js +3 -0
  36. package/dist/admin/templates.js.map +1 -1
  37. package/dist/admin/uploads.d.ts +6 -0
  38. package/dist/admin/uploads.d.ts.map +1 -1
  39. package/dist/admin/uploads.js +1 -1
  40. package/dist/admin/uploads.js.map +1 -1
  41. package/dist/admin/users.d.ts +8 -0
  42. package/dist/admin/users.d.ts.map +1 -1
  43. package/dist/admin/users.js +8 -0
  44. package/dist/admin/users.js.map +1 -1
  45. package/dist/content/citation.d.ts +36 -0
  46. package/dist/content/citation.d.ts.map +1 -0
  47. package/dist/content/citation.js +31 -0
  48. package/dist/content/citation.js.map +1 -0
  49. package/dist/content/index.d.ts +2 -0
  50. package/dist/content/index.d.ts.map +1 -1
  51. package/dist/content/index.js +1 -0
  52. package/dist/content/index.js.map +1 -1
  53. package/dist/content/photo.d.ts +45 -0
  54. package/dist/content/photo.d.ts.map +1 -0
  55. package/dist/content/photo.js +55 -0
  56. package/dist/content/photo.js.map +1 -0
  57. package/dist/content/post-type.d.ts +20 -8
  58. package/dist/content/post-type.d.ts.map +1 -1
  59. package/dist/content/post-type.js +17 -0
  60. package/dist/content/post-type.js.map +1 -1
  61. package/dist/env.d.ts +7 -0
  62. package/dist/env.d.ts.map +1 -1
  63. package/dist/federation/article.d.ts.map +1 -1
  64. package/dist/federation/article.js +59 -5
  65. package/dist/federation/article.js.map +1 -1
  66. package/dist/federation/citations.d.ts +38 -0
  67. package/dist/federation/citations.d.ts.map +1 -0
  68. package/dist/federation/citations.js +98 -0
  69. package/dist/federation/citations.js.map +1 -0
  70. package/dist/federation/delivery.d.ts.map +1 -1
  71. package/dist/federation/delivery.js +79 -31
  72. package/dist/federation/delivery.js.map +1 -1
  73. package/dist/images/alt-text.d.ts +6 -0
  74. package/dist/images/alt-text.d.ts.map +1 -1
  75. package/dist/images/alt-text.js +10 -0
  76. package/dist/images/alt-text.js.map +1 -1
  77. package/dist/index.d.ts +4 -4
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +25 -2
  80. package/dist/index.js.map +1 -1
  81. package/dist/indieauth/activity-log.d.ts +112 -0
  82. package/dist/indieauth/activity-log.d.ts.map +1 -0
  83. package/dist/indieauth/activity-log.js +325 -0
  84. package/dist/indieauth/activity-log.js.map +1 -0
  85. package/dist/indieauth/app-activity.d.ts +14 -0
  86. package/dist/indieauth/app-activity.d.ts.map +1 -0
  87. package/dist/indieauth/app-activity.js +106 -0
  88. package/dist/indieauth/app-activity.js.map +1 -0
  89. package/dist/indieauth/bearer.d.ts +6 -1
  90. package/dist/indieauth/bearer.d.ts.map +1 -1
  91. package/dist/indieauth/bearer.js +40 -12
  92. package/dist/indieauth/bearer.js.map +1 -1
  93. package/dist/indieauth/consent.d.ts.map +1 -1
  94. package/dist/indieauth/consent.js +5 -2
  95. package/dist/indieauth/consent.js.map +1 -1
  96. package/dist/indieauth/discovery.d.ts +10 -4
  97. package/dist/indieauth/discovery.d.ts.map +1 -1
  98. package/dist/indieauth/discovery.js +22 -7
  99. package/dist/indieauth/discovery.js.map +1 -1
  100. package/dist/indieauth/token.d.ts.map +1 -1
  101. package/dist/indieauth/token.js +3 -1
  102. package/dist/indieauth/token.js.map +1 -1
  103. package/dist/micropub/create.d.ts +56 -0
  104. package/dist/micropub/create.d.ts.map +1 -0
  105. package/dist/micropub/create.js +200 -0
  106. package/dist/micropub/create.js.map +1 -0
  107. package/dist/micropub/endpoint.d.ts +21 -0
  108. package/dist/micropub/endpoint.d.ts.map +1 -0
  109. package/dist/micropub/endpoint.js +354 -0
  110. package/dist/micropub/endpoint.js.map +1 -0
  111. package/dist/micropub/media.d.ts +14 -0
  112. package/dist/micropub/media.d.ts.map +1 -0
  113. package/dist/micropub/media.js +94 -0
  114. package/dist/micropub/media.js.map +1 -0
  115. package/dist/micropub/update.d.ts +44 -0
  116. package/dist/micropub/update.d.ts.map +1 -0
  117. package/dist/micropub/update.js +133 -0
  118. package/dist/micropub/update.js.map +1 -0
  119. package/dist/web/context.d.ts +42 -3
  120. package/dist/web/context.d.ts.map +1 -1
  121. package/dist/web/context.js +41 -1
  122. package/dist/web/context.js.map +1 -1
  123. package/dist/web/render.d.ts +15 -0
  124. package/dist/web/render.d.ts.map +1 -1
  125. package/dist/web/render.js +16 -0
  126. package/dist/web/render.js.map +1 -1
  127. package/dist/web/routes.js +2 -2
  128. package/dist/webmention/index.d.ts +2 -0
  129. package/dist/webmention/index.d.ts.map +1 -1
  130. package/dist/webmention/index.js +1 -0
  131. package/dist/webmention/index.js.map +1 -1
  132. package/dist/webmention/service.d.ts.map +1 -1
  133. package/dist/webmention/service.js +96 -18
  134. package/dist/webmention/service.js.map +1 -1
  135. package/dist/webmention/syndication.d.ts +135 -0
  136. package/dist/webmention/syndication.d.ts.map +1 -0
  137. package/dist/webmention/syndication.js +337 -0
  138. package/dist/webmention/syndication.js.map +1 -0
  139. package/package.json +1 -1
  140. package/themes/default/README.md +40 -9
  141. package/themes/default/layouts/post.njk +19 -6
  142. package/themes/default/partials/citations.njk +17 -0
  143. package/themes/default/partials/jsonld.njk +12 -1
  144. package/themes/default/partials/photos.njk +10 -0
  145. package/themes/default/partials/post-list.njk +7 -0
  146. package/themes/default/src/style.css +9 -0
  147. package/themes/default/static/style.css +16 -0
@@ -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
- * The order matters: the form is read and checked before anything is touched,
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 refuse(`A ${kind.singular} needs a title.`);
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 refuse('In reply to has to be a web address, like https://example.com/a-post/.');
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 refuse('That is not a language tag, such as en, fr or pt-BR.');
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 refuse(resolved.error);
252
- recording = resolved.enclosure;
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(c.var.config.dataDir);
258
- const author = userForAuthor(users, chosenAuthor(c, form.author, document) ?? '');
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 refuse(`You can pin up to ${String(PINNED_POST_LIMIT)} posts. Unpin one first.`);
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
- : undescribedImages(renderMarkdown(form.body), readAltTexts(contentDir));
269
- if (undescribed.length > 0 && c.var.config.requireAltText) {
270
- return refuse(`This site publishes no image without alt text. ${missingAltText(undescribed)}`);
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 = siteTimezone(c);
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 refuse('A date has to start with a year, a month and a day, like 2026-03-04.');
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 refuse('That date is not one anybody can read. Try 2026-03-04 09:00.');
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 renderConflict(c, {
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 refuse(`Another ${kind.singular} already lives in ${target}.`);
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(c, form.author, document)),
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, c.var.config.baseUrl)),
346
- extra: resolveExtra(kind, document, form, store.now(), recording),
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 refuse(error.message);
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 c.var.announce({
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
- flash(c, 'notice', savedMessage(kind, document, saved, store.now()));
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, recording) {
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(c, submitted, document) {
651
- const named = userForAuthor(listUsers(c.var.config.dataDir), submitted);
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 ?? currentUsername(c);
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.recording),
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 `content/_trash/` or back out of it.
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 backTo(c, options, `Could not move ${document.path}. Is the file still there?`);
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 c.var.announce({
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
- const message = action === 'trash'
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.