@geekity/cms 0.28.0 → 0.30.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 (195) hide show
  1. package/README.md +355 -53
  2. package/admin/pages/comments/all.njk +3 -1
  3. package/admin/pages/federation/followers.njk +8 -2
  4. package/dist/admin/comments.d.ts +2 -0
  5. package/dist/admin/comments.d.ts.map +1 -1
  6. package/dist/admin/comments.js +19 -49
  7. package/dist/admin/comments.js.map +1 -1
  8. package/dist/admin/documents.d.ts +5 -0
  9. package/dist/admin/documents.d.ts.map +1 -1
  10. package/dist/admin/documents.js +45 -17
  11. package/dist/admin/documents.js.map +1 -1
  12. package/dist/admin/federation.d.ts +21 -1
  13. package/dist/admin/federation.d.ts.map +1 -1
  14. package/dist/admin/federation.js +17 -7
  15. package/dist/admin/federation.js.map +1 -1
  16. package/dist/admin/store.d.ts +95 -14
  17. package/dist/admin/store.d.ts.map +1 -1
  18. package/dist/admin/store.js +206 -17
  19. package/dist/admin/store.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +8 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/comments/form.d.ts +3 -1
  24. package/dist/comments/form.d.ts.map +1 -1
  25. package/dist/comments/form.js +2 -1
  26. package/dist/comments/form.js.map +1 -1
  27. package/dist/comments/index.d.ts +3 -3
  28. package/dist/comments/index.d.ts.map +1 -1
  29. package/dist/comments/index.js +1 -1
  30. package/dist/comments/index.js.map +1 -1
  31. package/dist/comments/records.d.ts +56 -24
  32. package/dist/comments/records.d.ts.map +1 -1
  33. package/dist/comments/records.js +255 -31
  34. package/dist/comments/records.js.map +1 -1
  35. package/dist/comments/reply-notices.d.ts +42 -0
  36. package/dist/comments/reply-notices.d.ts.map +1 -0
  37. package/dist/comments/reply-notices.js +76 -0
  38. package/dist/comments/reply-notices.js.map +1 -0
  39. package/dist/comments/reply-post.d.ts +49 -0
  40. package/dist/comments/reply-post.d.ts.map +1 -0
  41. package/dist/comments/reply-post.js +66 -0
  42. package/dist/comments/reply-post.js.map +1 -0
  43. package/dist/comments/routes.d.ts +6 -1
  44. package/dist/comments/routes.d.ts.map +1 -1
  45. package/dist/comments/routes.js +49 -21
  46. package/dist/comments/routes.js.map +1 -1
  47. package/dist/comments/submission.d.ts +8 -25
  48. package/dist/comments/submission.d.ts.map +1 -1
  49. package/dist/comments/submission.js +19 -38
  50. package/dist/comments/submission.js.map +1 -1
  51. package/dist/comments/viewer.d.ts.map +1 -1
  52. package/dist/comments/viewer.js +1 -0
  53. package/dist/comments/viewer.js.map +1 -1
  54. package/dist/content/citation.d.ts +1 -0
  55. package/dist/content/citation.d.ts.map +1 -1
  56. package/dist/content/citation.js +2 -0
  57. package/dist/content/citation.js.map +1 -1
  58. package/dist/content/original.d.ts +6 -1
  59. package/dist/content/original.d.ts.map +1 -1
  60. package/dist/content/original.js +7 -4
  61. package/dist/content/original.js.map +1 -1
  62. package/dist/content/store.d.ts +43 -11
  63. package/dist/content/store.d.ts.map +1 -1
  64. package/dist/content/store.js +170 -10
  65. package/dist/content/store.js.map +1 -1
  66. package/dist/content/sync.d.ts +8 -0
  67. package/dist/content/sync.d.ts.map +1 -1
  68. package/dist/content/sync.js +164 -71
  69. package/dist/content/sync.js.map +1 -1
  70. package/dist/federation/article.d.ts.map +1 -1
  71. package/dist/federation/article.js +2 -1
  72. package/dist/federation/article.js.map +1 -1
  73. package/dist/federation/backfill.d.ts +114 -0
  74. package/dist/federation/backfill.d.ts.map +1 -0
  75. package/dist/federation/backfill.js +314 -0
  76. package/dist/federation/backfill.js.map +1 -0
  77. package/dist/federation/inbox.js +1 -1
  78. package/dist/federation/inbox.js.map +1 -1
  79. package/dist/federation/records.d.ts +13 -0
  80. package/dist/federation/records.d.ts.map +1 -1
  81. package/dist/federation/records.js +49 -10
  82. package/dist/federation/records.js.map +1 -1
  83. package/dist/federation/replies.d.ts +6 -0
  84. package/dist/federation/replies.d.ts.map +1 -1
  85. package/dist/federation/replies.js +18 -1
  86. package/dist/federation/replies.js.map +1 -1
  87. package/dist/index.d.ts +12 -4
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +81 -11
  90. package/dist/index.js.map +1 -1
  91. package/dist/init.d.ts +1 -1
  92. package/dist/init.d.ts.map +1 -1
  93. package/dist/init.js +1 -7
  94. package/dist/init.js.map +1 -1
  95. package/dist/plugin.d.ts +28 -2
  96. package/dist/plugin.d.ts.map +1 -1
  97. package/dist/plugin.js +10 -2
  98. package/dist/plugin.js.map +1 -1
  99. package/dist/plugins/site.d.ts.map +1 -1
  100. package/dist/plugins/site.js +7 -1
  101. package/dist/plugins/site.js.map +1 -1
  102. package/dist/web/context.d.ts +18 -3
  103. package/dist/web/context.d.ts.map +1 -1
  104. package/dist/web/context.js +17 -4
  105. package/dist/web/context.js.map +1 -1
  106. package/dist/web/conversation.d.ts +191 -28
  107. package/dist/web/conversation.d.ts.map +1 -1
  108. package/dist/web/conversation.js +670 -172
  109. package/dist/web/conversation.js.map +1 -1
  110. package/dist/web/documents.d.ts +0 -22
  111. package/dist/web/documents.d.ts.map +1 -1
  112. package/dist/web/documents.js +1 -34
  113. package/dist/web/documents.js.map +1 -1
  114. package/dist/web/feed-item.d.ts +12 -8
  115. package/dist/web/feed-item.d.ts.map +1 -1
  116. package/dist/web/feed-item.js +10 -10
  117. package/dist/web/feed-item.js.map +1 -1
  118. package/dist/web/feed-rss.d.ts.map +1 -1
  119. package/dist/web/feed-rss.js +34 -10
  120. package/dist/web/feed-rss.js.map +1 -1
  121. package/dist/web/feed-source.d.ts +33 -1
  122. package/dist/web/feed-source.d.ts.map +1 -1
  123. package/dist/web/feed-source.js +33 -0
  124. package/dist/web/feed-source.js.map +1 -1
  125. package/dist/web/feeds.d.ts +1 -1
  126. package/dist/web/feeds.d.ts.map +1 -1
  127. package/dist/web/feeds.js +7 -2
  128. package/dist/web/feeds.js.map +1 -1
  129. package/dist/web/guids.d.ts +47 -0
  130. package/dist/web/guids.d.ts.map +1 -0
  131. package/dist/web/guids.js +67 -0
  132. package/dist/web/guids.js.map +1 -0
  133. package/dist/web/index.d.ts +5 -6
  134. package/dist/web/index.d.ts.map +1 -1
  135. package/dist/web/index.js +3 -3
  136. package/dist/web/index.js.map +1 -1
  137. package/dist/web/redirects.d.ts +12 -0
  138. package/dist/web/redirects.d.ts.map +1 -1
  139. package/dist/web/redirects.js +16 -4
  140. package/dist/web/redirects.js.map +1 -1
  141. package/dist/web/render.d.ts +19 -9
  142. package/dist/web/render.d.ts.map +1 -1
  143. package/dist/web/render.js +44 -42
  144. package/dist/web/render.js.map +1 -1
  145. package/dist/web/routes.d.ts +2 -2
  146. package/dist/web/routes.d.ts.map +1 -1
  147. package/dist/web/routes.js +157 -84
  148. package/dist/web/routes.js.map +1 -1
  149. package/dist/webmention/index.d.ts +1 -1
  150. package/dist/webmention/index.d.ts.map +1 -1
  151. package/dist/webmention/index.js +1 -1
  152. package/dist/webmention/index.js.map +1 -1
  153. package/dist/webmention/links.d.ts +55 -0
  154. package/dist/webmention/links.d.ts.map +1 -1
  155. package/dist/webmention/links.js +162 -0
  156. package/dist/webmention/links.js.map +1 -1
  157. package/dist/webmention/microformats.d.ts +15 -0
  158. package/dist/webmention/microformats.d.ts.map +1 -1
  159. package/dist/webmention/microformats.js +63 -1
  160. package/dist/webmention/microformats.js.map +1 -1
  161. package/dist/webmention/pingback.js +2 -1
  162. package/dist/webmention/pingback.js.map +1 -1
  163. package/dist/webmention/receive.d.ts +5 -0
  164. package/dist/webmention/receive.d.ts.map +1 -1
  165. package/dist/webmention/receive.js +99 -3
  166. package/dist/webmention/receive.js.map +1 -1
  167. package/dist/webmention/reply-context.d.ts +15 -0
  168. package/dist/webmention/reply-context.d.ts.map +1 -1
  169. package/dist/webmention/reply-context.js +29 -0
  170. package/dist/webmention/reply-context.js.map +1 -1
  171. package/dist/webmention/reply-contexts.d.ts +6 -0
  172. package/dist/webmention/reply-contexts.d.ts.map +1 -1
  173. package/dist/webmention/reply-contexts.js +16 -10
  174. package/dist/webmention/reply-contexts.js.map +1 -1
  175. package/dist/webmention/routes.d.ts.map +1 -1
  176. package/dist/webmention/routes.js +2 -1
  177. package/dist/webmention/routes.js.map +1 -1
  178. package/dist/webmention/service.d.ts +30 -1
  179. package/dist/webmention/service.d.ts.map +1 -1
  180. package/dist/webmention/service.js +172 -32
  181. package/dist/webmention/service.js.map +1 -1
  182. package/package.json +1 -2
  183. package/themes/default/README.md +269 -95
  184. package/themes/default/layouts/base.njk +8 -0
  185. package/themes/default/layouts/comment.njk +90 -0
  186. package/themes/default/layouts/front-page.njk +9 -47
  187. package/themes/default/layouts/page.njk +2 -0
  188. package/themes/default/layouts/post.njk +14 -2
  189. package/themes/default/partials/backlinks.njk +19 -0
  190. package/themes/default/partials/cited-page.njk +3 -1
  191. package/themes/default/partials/comment-form.njk +10 -0
  192. package/themes/default/partials/comment.njk +79 -0
  193. package/themes/default/partials/conversation.njk +14 -59
  194. package/themes/default/src/style.css +42 -0
  195. package/themes/default/static/style.css +67 -0
@@ -9,6 +9,7 @@ import { sourceVersion } from '../images/paths.js';
9
9
  import { findImageVariant, VARIANT_ASSET_PREFIX } from '../images/variants.js';
10
10
  import { ASSET_VERSION_PARAM, assetNotModified, assetRangeResponse, assetResponse, assetVersion, findThemeAsset, findUpload, IMMUTABLE_ASSET, matchesEtag, THEME_ASSET_PREFIX, UPLOAD_ASSET_MAX_AGE, UPLOAD_ASSET_PREFIX, } from './assets.js';
11
11
  import { COMMENT_NOTICE_PARAM, COMMENT_REPLY_PARAM } from '../comments/form.js';
12
+ import { answerable, commentPolicyOf } from '../comments/policy.js';
12
13
  import { commentNoticeFor, commentReplyTarget, mountComments } from '../comments/routes.js';
13
14
  import { commenterOf } from '../comments/viewer.js';
14
15
  import { CONTACT_NOTICE_PARAM, contactNoticeFor } from '../contact/form.js';
@@ -20,8 +21,9 @@ import { advertiseIdentityEndpoints } from '../indieauth/discovery.js';
20
21
  import { publicAdminBar } from './admin-bar.js';
21
22
  import { authorFeedHref, authorContext, authorHref, authorNames, parseAuthorPath, profileContext, siteAuthorContext, } from './authors.js';
22
23
  import { feedComments, spokenIn } from './conversation.js';
23
- import { goneDocumentAt, isListed, isServed, permalinkOfObjectId, previewDocumentAt, publicDocumentAt, } from './documents.js';
24
- import { commentsFeedPath, commentsFeedResponse, feedPathUnder, feedResponse, feedSize, splitFeedPath, COMMENTS_ROOT, COMMENTS_TITLE_PREFIX, FEED_FORMATS, FEED_SEGMENTS, } from './feeds.js';
24
+ import { COMMENT_PAGE_PREFIX } from './guids.js';
25
+ import { goneDocumentAt, isGone, isListed, isServed, permalinkOfObjectId, previewDocumentAt, publicDocumentAt, } from './documents.js';
26
+ import { commentsFeedPath, REPLIES_ROOT, repliesFeedPath, commentsFeedResponse, feedPathUnder, feedResponse, feedSize, splitFeedPath, COMMENTS_ROOT, COMMENTS_TITLE_PREFIX, FEED_FORMATS, FEED_SEGMENTS, } from './feeds.js';
25
27
  import { absoluteUrl, DOCUMENT_REPRESENTATIONS, documentJson, LISTING_REPRESENTATIONS, SEARCH_REPRESENTATIONS, lastModifiedOf, latestModified, MEDIA_TYPES, notAcceptableResponse, representationHref, representationEtag, representationResponse, selectRepresentation, splitRepresentationExtension, } from './negotiate.js';
26
28
  import { offsetForPage, paginate } from './pagination.js';
27
29
  import { TEMPLATES } from './render.js';
@@ -33,7 +35,7 @@ import { EMBED_PATH, embedPage, embedResponse, OEMBED_PATH, oEmbedFor, oEmbedReq
33
35
  import { generatedLlmsTxt, llmsListingTxt, LLMS_TXT_LINK, LLMS_TXT_PATH, llmsTxtResponse, ownLlmsTxt, } from './llms.js';
34
36
  import { indexNowKeyPath, indexNowKeyResponse } from './indexnow.js';
35
37
  import { searchHref, searchJson, searchPageIndex, searchQuery, SEARCH_PAGE_PARAM, SEARCH_PATH, SEARCH_QUERY_PARAM, } from './search.js';
36
- import { findQueryRedirect, redirectLocation } from './redirects.js';
38
+ import { findPathRedirect, findQueryRedirect, redirectLocation } from './redirects.js';
37
39
  import { PAGE_SEGMENT, redirectedTerm, taxonomyForSegment, termHref } from './taxonomy.js';
38
40
  /**
39
41
  * Register the public site on a Hono app.
@@ -114,6 +116,11 @@ export function mountPublicSite(app) {
114
116
  }
115
117
  // The site-wide comments feed, for the same reason and at WordPress's URL.
116
118
  app.get(commentsFeedHref(undefined), (c) => comments(c, undefined));
119
+ // A native comment's own page (TASK-318), at a fixed path so a permalink
120
+ // cannot take it.
121
+ app.get(`${COMMENT_PAGE_PREFIX}:id/`, (c) => commentPage(c, c.req.param('id')));
122
+ // The feed of one item's direct replies (TASK-324), fixed for the same reason.
123
+ app.get(`${REPLIES_ROOT}:segment/`, (c) => repliesFeed(c, c.req.param('segment')));
117
124
  // The sitemap, its children and the robots file: fixed paths at the root of
118
125
  // the site, which is the only place a crawler looks, and routes for the same
119
126
  // reason the feeds are — no document permalinked there can take the URL a
@@ -292,12 +299,12 @@ function resolveRequest(c) {
292
299
  return listing(c, request);
293
300
  }
294
301
  }
295
- const feedRequest = parseFeedPath(store, pathname, bases, authors);
302
+ const feedRequest = parseFeedPath(c, pathname, bases, authors);
296
303
  if (feedRequest !== undefined) {
297
304
  const { target, format } = feedRequest;
298
305
  const canonical = feedTargetHref(target, format, bases);
299
306
  if (!feedRequest.canonical ||
300
- isCarriedTagFeedInAnotherCasing(store, target, canonical, pathname)) {
307
+ (target.kind === 'listing' && canonical !== encodePath(pathname))) {
301
308
  return c.redirect(canonical, 301);
302
309
  }
303
310
  return target.kind === 'listing'
@@ -321,11 +328,11 @@ function resolveRequest(c) {
321
328
  // is the front page.
322
329
  if (document.path === pages.home?.path)
323
330
  return c.redirect('/', 301);
324
- // WordPress answered `?feed=rss2` on a permalink with that post's comments
325
- // feed, which is the only feed a post has here too. The other spellings
326
- // name a format a comments feed does not come in, so they are not a feed
327
- // request at all and the page is served.
328
- if (document.type === 'post' && queryFeedFormat(c) === 'rss') {
331
+ // WordPress answered `?feed=rss2` on a permalink with that document's
332
+ // comments feed, which is the only feed a document has here too. The other
333
+ // spellings name a format a comments feed does not come in, so they are
334
+ // not a feed request at all and the page is served.
335
+ if (queryFeedFormat(c) === 'rss' && showsConversation(c, document)) {
329
336
  return c.redirect(commentsFeedHref(document), 301);
330
337
  }
331
338
  return negotiateDocument(c, document, selectFromAccept(c, DOCUMENT_REPRESENTATIONS));
@@ -370,11 +377,13 @@ function resolveRequest(c) {
370
377
  }
371
378
  // Nothing lives here, so a URL something used to live at leads on to where
372
379
  // it lives now. Asked only after every live lookup, which is what lets a new
373
- // document take an old URL over.
380
+ // document take an old URL over, and a term that comes back into use beat
381
+ // the record of what it was renamed to.
374
382
  const formerly = movedHref(store, pages, pathname, undefined) ??
375
383
  extension?.paths
376
384
  .map((candidate) => movedHref(store, pages, candidate, extension.representation))
377
- .find((href) => href !== undefined);
385
+ .find((href) => href !== undefined) ??
386
+ renamedTermHref(c, pathname, bases);
378
387
  if (formerly !== undefined)
379
388
  return c.redirect(`${formerly}${search}`, 301);
380
389
  const deleted = [pathname, ...(extension?.paths ?? [])].some((candidate) => goneDocumentAt(store, candidate) !== undefined);
@@ -385,10 +394,8 @@ function resolveRequest(c) {
385
394
  return c.redirect(canonical, 301);
386
395
  // And a URL the site itself says has moved (TASK-128), after every live
387
396
  // lookup and after the slash that would lead to one, so a declared source
388
- // never shadows a live document. One asked for without its trailing slash
389
- // reaches the target in one hop.
390
- const declared = c.var.redirects.current().paths;
391
- const redirect = declared.get(pathname) ?? (pathname.endsWith('/') ? undefined : declared.get(`${pathname}/`));
397
+ // never shadows a live document.
398
+ const redirect = findPathRedirect(c.var.redirects.current(), pathname);
392
399
  if (redirect !== undefined) {
393
400
  return c.redirect(redirectLocation(redirect, search), redirect.status);
394
401
  }
@@ -411,9 +418,10 @@ function movedHref(store, pages, pathname, representation) {
411
418
  * `undefined` when the path is not one.
412
419
  *
413
420
  * `/{base}/x/page/1/` collapses onto the archive root the way `/page/1/`
414
- * collapses onto the home page, and an archive nothing carries 404s rather
415
- * than redirecting first: a URL that leads nowhere should cost one 404, not a
416
- * redirect and then a 404.
421
+ * collapses onto the home page, and any other casing of a term collapses onto
422
+ * the site's spelling of it (TASK-339). A term nothing carries is not an
423
+ * archive at all, so the request goes on to the renames and the declared
424
+ * redirects that may know where it went.
417
425
  */
418
426
  function taxonomyArchive(c, pathname, bases, authors) {
419
427
  if (!pathname.endsWith('/'))
@@ -421,18 +429,9 @@ function taxonomyArchive(c, pathname, bases, authors) {
421
429
  const request = parseListingPath(c.var.store, pathname, bases, authors);
422
430
  if (request?.term === undefined)
423
431
  return undefined;
424
- // An archive that answers is served; only once nothing carries the term is
425
- // the record of renames consulted, so a term that comes back into use — or
426
- // one that was merged into and then recreated — beats what it used to be
427
- // called. One hop: the list is stored with its chains already collapsed.
428
- if (countListing(c.var.store, request) === 0) {
429
- const moved = movedTerm(c, request.term);
430
- if (moved !== undefined)
431
- return c.redirect(termHref(moved, request.pageNumber, bases), 301);
432
- }
433
432
  const canonical = termHref(request.term, request.pageNumber, bases);
434
433
  if (canonical !== encodePath(pathname)) {
435
- return countListing(c.var.store, request) === 0 ? notFound(c) : c.redirect(canonical, 301);
434
+ return c.redirect(`${canonical}${new URL(c.req.url).search}`, 301);
436
435
  }
437
436
  return listing(c, request);
438
437
  }
@@ -471,12 +470,25 @@ function authorArchive(c, pathname, bases, authors) {
471
470
  return listing(c, request);
472
471
  }
473
472
  /**
474
- * Where a term with nothing left under it went, or `undefined` when the site
475
- * records no such rename.
473
+ * Where a renamed term's archive page or feed lives now, or `undefined` when
474
+ * the path is neither or the site records no rename of its term.
475
+ *
476
+ * One hop: the list is stored with its chains already collapsed. A subscriber
477
+ * to the old feed follows it to the new one, exactly as a reader of the archive
478
+ * does.
476
479
  */
477
- function movedTerm(c, term) {
478
- const moved = redirectedTerm(c.var.renderer.termRedirects(), term);
479
- return moved === undefined ? undefined : { taxonomy: term.taxonomy, term: moved };
480
+ function renamedTermHref(c, pathname, bases) {
481
+ const feedPath = splitFeedPath(pathname);
482
+ const asked = parseTermPath(feedPath?.root ?? pathname, bases);
483
+ if (asked === undefined || (feedPath !== undefined && asked.pageNumber !== 0))
484
+ return undefined;
485
+ const to = redirectedTerm(c.var.renderer.termRedirects(), asked.term);
486
+ if (to === undefined)
487
+ return undefined;
488
+ const moved = { taxonomy: asked.term.taxonomy, term: to };
489
+ return feedPath === undefined
490
+ ? termHref(moved, asked.pageNumber, bases)
491
+ : feedHref(moved, feedPath.format, bases);
480
492
  }
481
493
  /**
482
494
  * A path as the feed it names: `/feed/`, `/feed/atom/`, `/{base}/x/feed/json/`,
@@ -485,9 +497,10 @@ function movedTerm(c, term) {
485
497
  *
486
498
  * The site's own canonical feeds are real routes and never reach here; what
487
499
  * does reach here is every taxonomy feed — their bases are a setting, so they
488
- * cannot be in the route table — every post's comments feed, and every alias.
500
+ * cannot be in the route table — every document's comments feed, and every alias.
489
501
  */
490
- function parseFeedPath(store, pathname, bases, authors) {
502
+ function parseFeedPath(c, pathname, bases, authors) {
503
+ const { store } = c.var;
491
504
  const split = splitFeedPath(pathname);
492
505
  if (split === undefined)
493
506
  return undefined;
@@ -505,16 +518,19 @@ function parseFeedPath(store, pathname, bases, authors) {
505
518
  ? { target: { kind: 'listing', subject: root }, format, canonical }
506
519
  : undefined;
507
520
  }
508
- // Otherwise it may be a post's comments feed. Only a published post has one:
509
- // a page never federates, so nothing in the fediverse can ever have replied
510
- // to it, and a comments feed for it would be empty for ever.
521
+ // Otherwise it may be a document's comments feed, which it has exactly when
522
+ // its page shows its conversation.
511
523
  if (format !== 'rss')
512
524
  return undefined;
513
525
  const document = publicDocumentAt(store, split.root);
514
- if (document?.type !== 'post')
526
+ if (document === undefined || !showsConversation(c, document))
515
527
  return undefined;
516
528
  return { target: { kind: 'comments', document }, format, canonical };
517
529
  }
530
+ /** Whether this document's page shows its conversation, and so has a comments feed. */
531
+ function showsConversation(c, document) {
532
+ return answerable(document, commentPolicyOf(c.var.renderer.site()), c.var.store.now());
533
+ }
518
534
  /** Where the canonical URL of one feed is. */
519
535
  function feedTargetHref(target, format, bases) {
520
536
  return target.kind === 'listing'
@@ -543,7 +559,7 @@ function queryFeedFormat(c) {
543
559
  * URL and is read back here. An unknown value says nothing at all rather than
544
560
  * being printed, so the query cannot be used to put words on somebody's post.
545
561
  */
546
- function commentNotice(c, document) {
562
+ function commentNotice(c, document, viewer) {
547
563
  const notice = commentNoticeFor(c.req.query(COMMENT_NOTICE_PARAM));
548
564
  return {
549
565
  ...(notice === undefined ? {} : { commentNotice: notice }),
@@ -554,6 +570,7 @@ function commentNotice(c, document) {
554
570
  admin: c.var.admin,
555
571
  document,
556
572
  id: c.req.query(COMMENT_REPLY_PARAM),
573
+ thread: viewer === undefined ? undefined : c.var.conversation.thread(document),
557
574
  }),
558
575
  // And the thank-you after a contact form was sent, which travels the same
559
576
  // way for the same reason (TASK-56).
@@ -602,7 +619,7 @@ function negotiateDocument(c, document, representation, href = document.permalin
602
619
  const page = representation === 'html'
603
620
  ? c.var.renderer.renderPage(document, {
604
621
  frontPage: href === '/',
605
- extra: { ...commentNotice(c, document), ...(listed ? {} : { noindex: true }) },
622
+ extra: { ...commentNotice(c, document, viewer), ...(listed ? {} : { noindex: true }) },
606
623
  viewer,
607
624
  })
608
625
  : undefined;
@@ -689,17 +706,11 @@ function canonicalPath(c, pathname, bases, authors) {
689
706
  function canonicalTarget(c, pathname, bases, authors) {
690
707
  const { store, renderer } = c.var;
691
708
  // A feed first: `/feed`, `/feed/atom`, `/{base}/x/feed`, `/comments/feed`
692
- // and a post's `{permalink}feed` all lead somewhere real, and `/feed/rss`
709
+ // and a document's `{permalink}feed` all lead somewhere real, and `/feed/rss`
693
710
  // leads to `/feed/` in the same one hop rather than to a second redirect.
694
- const feedRequest = parseFeedPath(store, pathname, bases, authors);
711
+ const feedRequest = parseFeedPath(c, pathname, bases, authors);
695
712
  if (feedRequest !== undefined) {
696
- const { target } = feedRequest;
697
- if (target.kind === 'listing' &&
698
- target.subject.term !== undefined &&
699
- countListing(store, target.subject) === 0) {
700
- return undefined;
701
- }
702
- return feedTargetHref(target, feedRequest.format, bases);
713
+ return feedTargetHref(feedRequest.target, feedRequest.format, bases);
703
714
  }
704
715
  const pages = frontPages(c);
705
716
  const document = store.getByPermalink(pathname);
@@ -716,11 +727,7 @@ function canonicalTarget(c, pathname, bases, authors) {
716
727
  const listing = listingRequestAt(store, pages, pathname, bases, authors);
717
728
  if (listing === undefined)
718
729
  return undefined;
719
- // The home listing and an author archive both exist with nothing on them; a
720
- // taxonomy archive does not.
721
730
  const total = countListing(store, listing);
722
- if (listing.term !== undefined && total === 0)
723
- return undefined;
724
731
  const totalPages = Math.max(1, Math.ceil(total / renderer.pageSize()));
725
732
  if (listing.pageNumber >= totalPages)
726
733
  return undefined;
@@ -847,11 +854,27 @@ function parseListingPath(store, pathname, bases, authors) {
847
854
  const found = authors(byAuthor.username);
848
855
  return found === undefined ? undefined : { author: found, pageNumber: byAuthor.pageNumber };
849
856
  }
857
+ // A taxonomy archive exists only while a published post carries the term,
858
+ // so a term nothing carries is not a listing, whatever its path says.
859
+ const asked = parseTermPath(pathname, bases);
860
+ if (asked === undefined)
861
+ return undefined;
862
+ const spelled = carriedSpelling(store, asked.term);
863
+ return spelled === undefined
864
+ ? undefined
865
+ : { term: { ...asked.term, term: spelled }, pageNumber: asked.pageNumber };
866
+ }
867
+ /**
868
+ * `/{base}/x/` and `/{base}/x/page/N/` as the term and page they name, as the
869
+ * path spells them, whether or not anything carries the term.
870
+ */
871
+ function parseTermPath(pathname, bases) {
872
+ const segments = pathname.split('/').filter((segment) => segment !== '');
850
873
  const taxonomy = taxonomyForSegment(segments[0], bases);
851
874
  const named = segments[1];
852
875
  if (taxonomy === undefined || named === undefined)
853
876
  return undefined;
854
- const term = { taxonomy, term: asTheSiteSpellsIt(store, taxonomy, named) };
877
+ const term = { taxonomy, term: named };
855
878
  if (segments.length === 2)
856
879
  return { term, pageNumber: 0 };
857
880
  if (segments.length === 4 && segments[2] === PAGE_SEGMENT) {
@@ -860,14 +883,23 @@ function parseListingPath(store, pathname, bases, authors) {
860
883
  }
861
884
  return undefined;
862
885
  }
863
- function asTheSiteSpellsIt(store, taxonomy, term) {
864
- return taxonomy === 'tag' ? (store.tagSpelling(term) ?? term) : term;
865
- }
866
- function isCarriedTagFeedInAnotherCasing(store, target, canonical, pathname) {
867
- return (target.kind === 'listing' &&
868
- target.subject.term?.taxonomy === 'tag' &&
869
- canonical !== encodePath(pathname) &&
870
- countListing(store, target.subject) > 0);
886
+ /**
887
+ * How the site spells a term some published post carries, matched without
888
+ * regard to case, or `undefined` when none carries it (TASK-339).
889
+ *
890
+ * A tag's casings are one term (TASK-308). A category stays as spelled, so a
891
+ * category carried in exactly the asked spelling wins, and otherwise the most
892
+ * used one that differs only in case: WordPress lower-cased every slug, so an
893
+ * imported `/category/rsscloud/` names the category spelled `rssCloud`.
894
+ */
895
+ function carriedSpelling(store, term) {
896
+ if (term.taxonomy === 'tag') {
897
+ return store.countByTag(term.term) > 0 ? store.tagSpelling(term.term) : undefined;
898
+ }
899
+ if (store.countByCategory(term.term) > 0)
900
+ return term.term;
901
+ const key = term.term.toLowerCase();
902
+ return store.listCategories().find(({ category }) => category.toLowerCase() === key)?.category;
871
903
  }
872
904
  /** How many published documents a listing holds. */
873
905
  function countListing(store, subject) {
@@ -940,11 +972,7 @@ function listing(c, request, representation = selectFromAccept(c, LISTING_REPRES
940
972
  const asked = queryFeedFormat(c);
941
973
  if (asked !== undefined)
942
974
  return c.redirect(listingFeedHref(request, asked, bases), 301);
943
- // A taxonomy archive only exists while something carries the term; the home
944
- // listing and a person's archive exist even with nothing on them.
945
975
  const total = countListing(store, request);
946
- if (term !== undefined && total === 0)
947
- return notFound(c);
948
976
  const href = listingHref(request, request.pageNumber, bases, root);
949
977
  const pagination = paginate({
950
978
  total,
@@ -1123,14 +1151,6 @@ function feed(c, format, subject) {
1123
1151
  const site = renderer.site();
1124
1152
  const bases = renderer.taxonomyBases();
1125
1153
  const { term, author } = subject;
1126
- if (term !== undefined && countListing(store, subject) === 0) {
1127
- // A subscriber to a renamed archive's feed follows it to the new one
1128
- // rather than being dropped, exactly as a reader of the archive does.
1129
- const moved = movedTerm(c, term);
1130
- if (moved === undefined)
1131
- return notFound(c);
1132
- return c.redirect(feedHref(moved, format, bases), 301);
1133
- }
1134
1154
  const documents = listListing(store, subject, { limit: feedSize(site) });
1135
1155
  const href = listingHref(subject, 0, bases);
1136
1156
  // What the feed calls itself: the site, and then the term or the person it
@@ -1155,12 +1175,11 @@ function feed(c, format, subject) {
1155
1175
  return feedResponse({ format, source, conditional: conditionalHeaders(c) });
1156
1176
  }
1157
1177
  /**
1158
- * A post's comments, or the whole site's, as RSS 2.0.
1178
+ * A post's or page's comments, or the whole site's, as RSS 2.0.
1159
1179
  *
1160
- * A post with no replies answers an empty feed rather than a 404: it exists,
1161
- * and a reader that subscribed before anybody answered should keep polling.
1162
- * Only a permalink that is no published post 404s, which is the ordinary
1163
- * document lookup rather than anything this feed decides.
1180
+ * A document with no replies answers an empty feed rather than a 404: it
1181
+ * exists, and a reader that subscribed before anybody answered should keep
1182
+ * polling. Which documents have a feed at all is {@link parseFeedPath}'s to say.
1164
1183
  */
1165
1184
  function comments(c, document) {
1166
1185
  const { conversation, renderer, config } = c.var;
@@ -1171,7 +1190,12 @@ function comments(c, document) {
1171
1190
  // looking at one conversation. A post's own feed hands the builder entries
1172
1191
  // with no post attached, and so its items name no post: there, every item
1173
1192
  // answers the same one.
1174
- const found = feedComments(document === undefined ? conversation.latest(limit) : spokenIn(conversation.thread(document)), { baseUrl: config.baseUrl, limit, cited: (url) => c.var.replyContexts.read(url) });
1193
+ const found = feedComments(document === undefined ? conversation.latest(limit) : spokenIn(conversation.thread(document)), {
1194
+ baseUrl: config.baseUrl,
1195
+ limit,
1196
+ cited: (url) => c.var.replyContexts.read(url),
1197
+ post: document,
1198
+ });
1175
1199
  const source = {
1176
1200
  site,
1177
1201
  comments: found,
@@ -1184,6 +1208,55 @@ function comments(c, document) {
1184
1208
  };
1185
1209
  return commentsFeedResponse(source, conditionalHeaders(c));
1186
1210
  }
1211
+ /**
1212
+ * The feed of one item's direct replies (TASK-324): a post's or page's, or a
1213
+ * reply's, so a reader can walk a thread one level at a time through
1214
+ * `source:comments`. An item nobody answered has an empty feed; a segment
1215
+ * naming nothing a reader can see 404s, as does one on a document not showing
1216
+ * its conversation, by the rule `{permalink}feed/` follows.
1217
+ */
1218
+ function repliesFeed(c, segment) {
1219
+ const { conversation, renderer, config } = c.var;
1220
+ const found = conversation.repliesTo(segment);
1221
+ if (found === undefined || !showsConversation(c, found.post))
1222
+ return notFound(c);
1223
+ const site = renderer.site();
1224
+ const cited = (url) => c.var.replyContexts.read(url);
1225
+ const label = postLabel(found.post, cited);
1226
+ const source = {
1227
+ site,
1228
+ comments: feedComments(found.replies, {
1229
+ baseUrl: config.baseUrl,
1230
+ limit: feedSize(site),
1231
+ cited,
1232
+ post: found.post,
1233
+ }),
1234
+ title: found.reply === null
1235
+ ? `Replies to: ${label}`
1236
+ : `Replies to ${found.reply.author.name} on ${label}`,
1237
+ href: found.reply?.url ?? found.post.permalink,
1238
+ feedHref: repliesFeedPath(segment),
1239
+ baseUrl: config.baseUrl,
1240
+ };
1241
+ return commentsFeedResponse(source, conditionalHeaders(c));
1242
+ }
1243
+ /**
1244
+ * One native comment on a page of its own (TASK-318), answering as its post
1245
+ * would: 410 once the post is deleted, and 404 while it is not served or not
1246
+ * showing its conversation. It is never listed anywhere, so it is noindex.
1247
+ */
1248
+ function commentPage(c, id) {
1249
+ const { conversation, renderer, store } = c.var;
1250
+ const found = conversation.comment(id);
1251
+ if (found === undefined)
1252
+ return notFound(c);
1253
+ const now = store.now();
1254
+ if (isGone(found.post, now))
1255
+ return gone(c);
1256
+ if (!isServed(found.post, now) || !showsConversation(c, found.post))
1257
+ return notFound(c);
1258
+ return c.html(renderer.renderComment(found), 200, { 'x-robots-tag': 'noindex' });
1259
+ }
1187
1260
  /**
1188
1261
  * The sitemap, or one of its children once the site is too big for one file.
1189
1262
  *
@@ -1447,10 +1520,10 @@ export function feedHref(term, format, bases) {
1447
1520
  return feedPathUnder(listingHref({ term }, 0, bases), format);
1448
1521
  }
1449
1522
  /**
1450
- * Where the site's comments feed lives, and where one post's does.
1523
+ * Where the site's comments feed lives, and where one document's does.
1451
1524
  *
1452
1525
  * WordPress's URLs, so a site migrated from it keeps both: the whole site's
1453
- * comments at `/comments/feed/`, and a post's under its own permalink. There
1526
+ * comments at `/comments/feed/`, and a document's under its own permalink. There
1454
1527
  * is one format, RSS 2.0, because that is what a comments feed is read in.
1455
1528
  */
1456
1529
  export function commentsFeedHref(document) {