@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.
Files changed (209) hide show
  1. package/README.md +270 -52
  2. package/admin/pages/documents/editor.njk +120 -0
  3. package/admin/pages/documents/syndication.njk +110 -0
  4. package/admin/pages/federation/followers.njk +7 -0
  5. package/admin/pages/media/library.njk +4 -3
  6. package/admin/pages/settings/general.njk +44 -0
  7. package/admin/static/admin.css +44 -0
  8. package/dist/admin/documents.d.ts +104 -0
  9. package/dist/admin/documents.d.ts.map +1 -1
  10. package/dist/admin/documents.js +220 -55
  11. package/dist/admin/documents.js.map +1 -1
  12. package/dist/admin/enclosure-field.d.ts +91 -0
  13. package/dist/admin/enclosure-field.d.ts.map +1 -0
  14. package/dist/admin/enclosure-field.js +282 -0
  15. package/dist/admin/enclosure-field.js.map +1 -0
  16. package/dist/admin/federation.d.ts +15 -0
  17. package/dist/admin/federation.d.ts.map +1 -1
  18. package/dist/admin/federation.js +19 -0
  19. package/dist/admin/federation.js.map +1 -1
  20. package/dist/admin/media.d.ts +7 -2
  21. package/dist/admin/media.d.ts.map +1 -1
  22. package/dist/admin/media.js +4 -2
  23. package/dist/admin/media.js.map +1 -1
  24. package/dist/admin/menu.d.ts.map +1 -1
  25. package/dist/admin/menu.js +3 -0
  26. package/dist/admin/menu.js.map +1 -1
  27. package/dist/admin/photo-field.d.ts +39 -0
  28. package/dist/admin/photo-field.d.ts.map +1 -0
  29. package/dist/admin/photo-field.js +91 -0
  30. package/dist/admin/photo-field.js.map +1 -0
  31. package/dist/admin/preview.d.ts.map +1 -1
  32. package/dist/admin/preview.js +6 -1
  33. package/dist/admin/preview.js.map +1 -1
  34. package/dist/admin/routes.d.ts.map +1 -1
  35. package/dist/admin/routes.js +4 -0
  36. package/dist/admin/routes.js.map +1 -1
  37. package/dist/admin/settings-general.d.ts.map +1 -1
  38. package/dist/admin/settings-general.js +36 -2
  39. package/dist/admin/settings-general.js.map +1 -1
  40. package/dist/admin/settings-page.d.ts.map +1 -1
  41. package/dist/admin/settings-page.js +1 -2
  42. package/dist/admin/settings-page.js.map +1 -1
  43. package/dist/admin/settings.d.ts +26 -0
  44. package/dist/admin/settings.d.ts.map +1 -1
  45. package/dist/admin/settings.js +88 -2
  46. package/dist/admin/settings.js.map +1 -1
  47. package/dist/admin/syndication-targets.d.ts +57 -0
  48. package/dist/admin/syndication-targets.d.ts.map +1 -0
  49. package/dist/admin/syndication-targets.js +266 -0
  50. package/dist/admin/syndication-targets.js.map +1 -0
  51. package/dist/admin/templates.d.ts +1 -0
  52. package/dist/admin/templates.d.ts.map +1 -1
  53. package/dist/admin/templates.js +1 -0
  54. package/dist/admin/templates.js.map +1 -1
  55. package/dist/admin/uploads.d.ts +13 -2
  56. package/dist/admin/uploads.d.ts.map +1 -1
  57. package/dist/admin/uploads.js +23 -5
  58. package/dist/admin/uploads.js.map +1 -1
  59. package/dist/config.d.ts +17 -3
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js +11 -7
  62. package/dist/config.js.map +1 -1
  63. package/dist/content/citation.d.ts +36 -0
  64. package/dist/content/citation.d.ts.map +1 -0
  65. package/dist/content/citation.js +31 -0
  66. package/dist/content/citation.js.map +1 -0
  67. package/dist/content/enclosure.d.ts +69 -0
  68. package/dist/content/enclosure.d.ts.map +1 -0
  69. package/dist/content/enclosure.js +165 -0
  70. package/dist/content/enclosure.js.map +1 -0
  71. package/dist/content/index.d.ts +5 -1
  72. package/dist/content/index.d.ts.map +1 -1
  73. package/dist/content/index.js +2 -0
  74. package/dist/content/index.js.map +1 -1
  75. package/dist/content/media.d.ts +15 -2
  76. package/dist/content/media.d.ts.map +1 -1
  77. package/dist/content/media.js +92 -9
  78. package/dist/content/media.js.map +1 -1
  79. package/dist/content/photo.d.ts +45 -0
  80. package/dist/content/photo.d.ts.map +1 -0
  81. package/dist/content/photo.js +55 -0
  82. package/dist/content/photo.js.map +1 -0
  83. package/dist/content/post-type.d.ts +20 -8
  84. package/dist/content/post-type.d.ts.map +1 -1
  85. package/dist/content/post-type.js +17 -0
  86. package/dist/content/post-type.js.map +1 -1
  87. package/dist/federation/article.d.ts.map +1 -1
  88. package/dist/federation/article.js +93 -10
  89. package/dist/federation/article.js.map +1 -1
  90. package/dist/federation/citations.d.ts +38 -0
  91. package/dist/federation/citations.d.ts.map +1 -0
  92. package/dist/federation/citations.js +98 -0
  93. package/dist/federation/citations.js.map +1 -0
  94. package/dist/federation/delivery.d.ts.map +1 -1
  95. package/dist/federation/delivery.js +79 -31
  96. package/dist/federation/delivery.js.map +1 -1
  97. package/dist/images/alt-text.d.ts +6 -0
  98. package/dist/images/alt-text.d.ts.map +1 -1
  99. package/dist/images/alt-text.js +10 -0
  100. package/dist/images/alt-text.js.map +1 -1
  101. package/dist/images/icons.d.ts +5 -0
  102. package/dist/images/icons.d.ts.map +1 -1
  103. package/dist/images/icons.js +13 -8
  104. package/dist/images/icons.js.map +1 -1
  105. package/dist/images/paths.d.ts +1 -1
  106. package/dist/images/paths.d.ts.map +1 -1
  107. package/dist/images/paths.js.map +1 -1
  108. package/dist/index.d.ts +7 -7
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +25 -4
  111. package/dist/index.js.map +1 -1
  112. package/dist/indieauth/bearer.d.ts +6 -1
  113. package/dist/indieauth/bearer.d.ts.map +1 -1
  114. package/dist/indieauth/bearer.js +39 -12
  115. package/dist/indieauth/bearer.js.map +1 -1
  116. package/dist/indieauth/discovery.d.ts +9 -4
  117. package/dist/indieauth/discovery.d.ts.map +1 -1
  118. package/dist/indieauth/discovery.js +18 -7
  119. package/dist/indieauth/discovery.js.map +1 -1
  120. package/dist/micropub/create.d.ts +56 -0
  121. package/dist/micropub/create.d.ts.map +1 -0
  122. package/dist/micropub/create.js +200 -0
  123. package/dist/micropub/create.js.map +1 -0
  124. package/dist/micropub/endpoint.d.ts +21 -0
  125. package/dist/micropub/endpoint.d.ts.map +1 -0
  126. package/dist/micropub/endpoint.js +353 -0
  127. package/dist/micropub/endpoint.js.map +1 -0
  128. package/dist/micropub/media.d.ts +14 -0
  129. package/dist/micropub/media.d.ts.map +1 -0
  130. package/dist/micropub/media.js +93 -0
  131. package/dist/micropub/media.js.map +1 -0
  132. package/dist/micropub/update.d.ts +44 -0
  133. package/dist/micropub/update.d.ts.map +1 -0
  134. package/dist/micropub/update.js +133 -0
  135. package/dist/micropub/update.js.map +1 -0
  136. package/dist/web/assets.d.ts +14 -0
  137. package/dist/web/assets.d.ts.map +1 -1
  138. package/dist/web/assets.js +56 -1
  139. package/dist/web/assets.js.map +1 -1
  140. package/dist/web/context.d.ts +68 -3
  141. package/dist/web/context.d.ts.map +1 -1
  142. package/dist/web/context.js +56 -1
  143. package/dist/web/context.js.map +1 -1
  144. package/dist/web/feed-atom.d.ts.map +1 -1
  145. package/dist/web/feed-atom.js +20 -0
  146. package/dist/web/feed-atom.js.map +1 -1
  147. package/dist/web/feed-item.d.ts +19 -0
  148. package/dist/web/feed-item.d.ts.map +1 -1
  149. package/dist/web/feed-item.js +22 -0
  150. package/dist/web/feed-item.js.map +1 -1
  151. package/dist/web/feed-json.d.ts +13 -0
  152. package/dist/web/feed-json.d.ts.map +1 -1
  153. package/dist/web/feed-json.js +15 -0
  154. package/dist/web/feed-json.js.map +1 -1
  155. package/dist/web/feed-rss.d.ts.map +1 -1
  156. package/dist/web/feed-rss.js +59 -2
  157. package/dist/web/feed-rss.js.map +1 -1
  158. package/dist/web/feed-xml.d.ts +7 -1
  159. package/dist/web/feed-xml.d.ts.map +1 -1
  160. package/dist/web/feed-xml.js +8 -2
  161. package/dist/web/feed-xml.js.map +1 -1
  162. package/dist/web/feeds.d.ts +1 -1
  163. package/dist/web/feeds.d.ts.map +1 -1
  164. package/dist/web/feeds.js +6 -0
  165. package/dist/web/feeds.js.map +1 -1
  166. package/dist/web/index.d.ts +4 -1
  167. package/dist/web/index.d.ts.map +1 -1
  168. package/dist/web/index.js +2 -0
  169. package/dist/web/index.js.map +1 -1
  170. package/dist/web/license.d.ts +94 -0
  171. package/dist/web/license.d.ts.map +1 -0
  172. package/dist/web/license.js +116 -0
  173. package/dist/web/license.js.map +1 -0
  174. package/dist/web/oembed.d.ts.map +1 -1
  175. package/dist/web/oembed.js +1 -2
  176. package/dist/web/oembed.js.map +1 -1
  177. package/dist/web/render.d.ts +15 -0
  178. package/dist/web/render.d.ts.map +1 -1
  179. package/dist/web/render.js +27 -2
  180. package/dist/web/render.js.map +1 -1
  181. package/dist/web/routes.d.ts.map +1 -1
  182. package/dist/web/routes.js +10 -6
  183. package/dist/web/routes.js.map +1 -1
  184. package/dist/web/share-image.d.ts +7 -7
  185. package/dist/web/share-image.d.ts.map +1 -1
  186. package/dist/web/share-image.js +8 -7
  187. package/dist/web/share-image.js.map +1 -1
  188. package/dist/webmention/index.d.ts +2 -0
  189. package/dist/webmention/index.d.ts.map +1 -1
  190. package/dist/webmention/index.js +1 -0
  191. package/dist/webmention/index.js.map +1 -1
  192. package/dist/webmention/service.d.ts.map +1 -1
  193. package/dist/webmention/service.js +96 -18
  194. package/dist/webmention/service.js.map +1 -1
  195. package/dist/webmention/syndication.d.ts +135 -0
  196. package/dist/webmention/syndication.d.ts.map +1 -0
  197. package/dist/webmention/syndication.js +337 -0
  198. package/dist/webmention/syndication.js.map +1 -0
  199. package/package.json +1 -1
  200. package/themes/default/README.md +121 -37
  201. package/themes/default/layouts/base.njk +3 -4
  202. package/themes/default/layouts/post.njk +23 -6
  203. package/themes/default/partials/citations.njk +17 -0
  204. package/themes/default/partials/jsonld.njk +15 -6
  205. package/themes/default/partials/photos.njk +10 -0
  206. package/themes/default/partials/post-list.njk +7 -0
  207. package/themes/default/partials/recording.njk +41 -0
  208. package/themes/default/src/style.css +25 -0
  209. package/themes/default/static/style.css +41 -0
@@ -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
- * The order matters: the form is read and checked before anything is touched,
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 refuse(`A ${kind.singular} needs a title.`);
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 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
+ }
240
297
  }
241
298
  if (form.lang !== '' && !LANGUAGE_TAG_PATTERN.test(form.lang)) {
242
- 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.');
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(c.var.config.dataDir);
248
- 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) ?? '');
249
316
  const names = author === undefined ? [] : authorNames(users, author);
250
317
  if (store.listPinnedByAuthor(names).length >= PINNED_POST_LIMIT) {
251
- 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.`);
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
- : undescribedImages(renderMarkdown(form.body), readAltTexts(contentDir));
259
- if (undescribed.length > 0 && c.var.config.requireAltText) {
260
- 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)}`);
261
333
  }
262
- const timezone = siteTimezone(c);
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 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.');
268
340
  }
269
341
  const date = typed === undefined ? undefined : toUtcInstant(typed, timezone);
270
342
  if (typed !== undefined && date === undefined) {
271
- 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.');
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 renderConflict(c, { kind, render, document, form: { ...form, draft }, conflict });
382
+ return { outcome: 'conflict', document, media, conflict };
311
383
  }
312
384
  }
313
385
  if (target !== document?.path && store.getByPath(target) !== undefined) {
314
- return refuse(`Another ${kind.singular} already lives in ${target}.`);
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(c, form.author, document)),
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, c.var.config.baseUrl)),
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 refuse(error.message);
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 c.var.announce({
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
- flash(c, 'notice', savedMessage(kind, document, saved, store.now()));
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(c, submitted, document) {
628
- const named = userForAuthor(listUsers(c.var.config.dataDir), submitted);
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 ?? currentUsername(c);
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 `content/_trash/` or back out of it.
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 backTo(c, options, `Could not move ${document.path}. Is the file still there?`);
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 c.var.announce({
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
- const message = action === 'trash'
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.