@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.
- package/README.md +355 -53
- package/admin/pages/comments/all.njk +3 -1
- package/admin/pages/federation/followers.njk +8 -2
- package/dist/admin/comments.d.ts +2 -0
- package/dist/admin/comments.d.ts.map +1 -1
- package/dist/admin/comments.js +19 -49
- package/dist/admin/comments.js.map +1 -1
- package/dist/admin/documents.d.ts +5 -0
- package/dist/admin/documents.d.ts.map +1 -1
- package/dist/admin/documents.js +45 -17
- package/dist/admin/documents.js.map +1 -1
- package/dist/admin/federation.d.ts +21 -1
- package/dist/admin/federation.d.ts.map +1 -1
- package/dist/admin/federation.js +17 -7
- package/dist/admin/federation.js.map +1 -1
- package/dist/admin/store.d.ts +95 -14
- package/dist/admin/store.d.ts.map +1 -1
- package/dist/admin/store.js +206 -17
- package/dist/admin/store.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -1
- package/dist/cli.js.map +1 -1
- package/dist/comments/form.d.ts +3 -1
- package/dist/comments/form.d.ts.map +1 -1
- package/dist/comments/form.js +2 -1
- package/dist/comments/form.js.map +1 -1
- package/dist/comments/index.d.ts +3 -3
- package/dist/comments/index.d.ts.map +1 -1
- package/dist/comments/index.js +1 -1
- package/dist/comments/index.js.map +1 -1
- package/dist/comments/records.d.ts +56 -24
- package/dist/comments/records.d.ts.map +1 -1
- package/dist/comments/records.js +255 -31
- package/dist/comments/records.js.map +1 -1
- package/dist/comments/reply-notices.d.ts +42 -0
- package/dist/comments/reply-notices.d.ts.map +1 -0
- package/dist/comments/reply-notices.js +76 -0
- package/dist/comments/reply-notices.js.map +1 -0
- package/dist/comments/reply-post.d.ts +49 -0
- package/dist/comments/reply-post.d.ts.map +1 -0
- package/dist/comments/reply-post.js +66 -0
- package/dist/comments/reply-post.js.map +1 -0
- package/dist/comments/routes.d.ts +6 -1
- package/dist/comments/routes.d.ts.map +1 -1
- package/dist/comments/routes.js +49 -21
- package/dist/comments/routes.js.map +1 -1
- package/dist/comments/submission.d.ts +8 -25
- package/dist/comments/submission.d.ts.map +1 -1
- package/dist/comments/submission.js +19 -38
- package/dist/comments/submission.js.map +1 -1
- package/dist/comments/viewer.d.ts.map +1 -1
- package/dist/comments/viewer.js +1 -0
- package/dist/comments/viewer.js.map +1 -1
- package/dist/content/citation.d.ts +1 -0
- package/dist/content/citation.d.ts.map +1 -1
- package/dist/content/citation.js +2 -0
- package/dist/content/citation.js.map +1 -1
- package/dist/content/original.d.ts +6 -1
- package/dist/content/original.d.ts.map +1 -1
- package/dist/content/original.js +7 -4
- package/dist/content/original.js.map +1 -1
- package/dist/content/store.d.ts +43 -11
- package/dist/content/store.d.ts.map +1 -1
- package/dist/content/store.js +170 -10
- package/dist/content/store.js.map +1 -1
- package/dist/content/sync.d.ts +8 -0
- package/dist/content/sync.d.ts.map +1 -1
- package/dist/content/sync.js +164 -71
- package/dist/content/sync.js.map +1 -1
- package/dist/federation/article.d.ts.map +1 -1
- package/dist/federation/article.js +2 -1
- package/dist/federation/article.js.map +1 -1
- package/dist/federation/backfill.d.ts +114 -0
- package/dist/federation/backfill.d.ts.map +1 -0
- package/dist/federation/backfill.js +314 -0
- package/dist/federation/backfill.js.map +1 -0
- package/dist/federation/inbox.js +1 -1
- package/dist/federation/inbox.js.map +1 -1
- package/dist/federation/records.d.ts +13 -0
- package/dist/federation/records.d.ts.map +1 -1
- package/dist/federation/records.js +49 -10
- package/dist/federation/records.js.map +1 -1
- package/dist/federation/replies.d.ts +6 -0
- package/dist/federation/replies.d.ts.map +1 -1
- package/dist/federation/replies.js +18 -1
- package/dist/federation/replies.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -11
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1 -7
- package/dist/init.js.map +1 -1
- package/dist/plugin.d.ts +28 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +10 -2
- package/dist/plugin.js.map +1 -1
- package/dist/plugins/site.d.ts.map +1 -1
- package/dist/plugins/site.js +7 -1
- package/dist/plugins/site.js.map +1 -1
- package/dist/web/context.d.ts +18 -3
- package/dist/web/context.d.ts.map +1 -1
- package/dist/web/context.js +17 -4
- package/dist/web/context.js.map +1 -1
- package/dist/web/conversation.d.ts +191 -28
- package/dist/web/conversation.d.ts.map +1 -1
- package/dist/web/conversation.js +670 -172
- package/dist/web/conversation.js.map +1 -1
- package/dist/web/documents.d.ts +0 -22
- package/dist/web/documents.d.ts.map +1 -1
- package/dist/web/documents.js +1 -34
- package/dist/web/documents.js.map +1 -1
- package/dist/web/feed-item.d.ts +12 -8
- package/dist/web/feed-item.d.ts.map +1 -1
- package/dist/web/feed-item.js +10 -10
- package/dist/web/feed-item.js.map +1 -1
- package/dist/web/feed-rss.d.ts.map +1 -1
- package/dist/web/feed-rss.js +34 -10
- package/dist/web/feed-rss.js.map +1 -1
- package/dist/web/feed-source.d.ts +33 -1
- package/dist/web/feed-source.d.ts.map +1 -1
- package/dist/web/feed-source.js +33 -0
- package/dist/web/feed-source.js.map +1 -1
- package/dist/web/feeds.d.ts +1 -1
- package/dist/web/feeds.d.ts.map +1 -1
- package/dist/web/feeds.js +7 -2
- package/dist/web/feeds.js.map +1 -1
- package/dist/web/guids.d.ts +47 -0
- package/dist/web/guids.d.ts.map +1 -0
- package/dist/web/guids.js +67 -0
- package/dist/web/guids.js.map +1 -0
- package/dist/web/index.d.ts +5 -6
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/redirects.d.ts +12 -0
- package/dist/web/redirects.d.ts.map +1 -1
- package/dist/web/redirects.js +16 -4
- package/dist/web/redirects.js.map +1 -1
- package/dist/web/render.d.ts +19 -9
- package/dist/web/render.d.ts.map +1 -1
- package/dist/web/render.js +44 -42
- package/dist/web/render.js.map +1 -1
- package/dist/web/routes.d.ts +2 -2
- package/dist/web/routes.d.ts.map +1 -1
- package/dist/web/routes.js +157 -84
- package/dist/web/routes.js.map +1 -1
- package/dist/webmention/index.d.ts +1 -1
- package/dist/webmention/index.d.ts.map +1 -1
- package/dist/webmention/index.js +1 -1
- package/dist/webmention/index.js.map +1 -1
- package/dist/webmention/links.d.ts +55 -0
- package/dist/webmention/links.d.ts.map +1 -1
- package/dist/webmention/links.js +162 -0
- package/dist/webmention/links.js.map +1 -1
- package/dist/webmention/microformats.d.ts +15 -0
- package/dist/webmention/microformats.d.ts.map +1 -1
- package/dist/webmention/microformats.js +63 -1
- package/dist/webmention/microformats.js.map +1 -1
- package/dist/webmention/pingback.js +2 -1
- package/dist/webmention/pingback.js.map +1 -1
- package/dist/webmention/receive.d.ts +5 -0
- package/dist/webmention/receive.d.ts.map +1 -1
- package/dist/webmention/receive.js +99 -3
- package/dist/webmention/receive.js.map +1 -1
- package/dist/webmention/reply-context.d.ts +15 -0
- package/dist/webmention/reply-context.d.ts.map +1 -1
- package/dist/webmention/reply-context.js +29 -0
- package/dist/webmention/reply-context.js.map +1 -1
- package/dist/webmention/reply-contexts.d.ts +6 -0
- package/dist/webmention/reply-contexts.d.ts.map +1 -1
- package/dist/webmention/reply-contexts.js +16 -10
- package/dist/webmention/reply-contexts.js.map +1 -1
- package/dist/webmention/routes.d.ts.map +1 -1
- package/dist/webmention/routes.js +2 -1
- package/dist/webmention/routes.js.map +1 -1
- package/dist/webmention/service.d.ts +30 -1
- package/dist/webmention/service.d.ts.map +1 -1
- package/dist/webmention/service.js +172 -32
- package/dist/webmention/service.js.map +1 -1
- package/package.json +1 -2
- package/themes/default/README.md +269 -95
- package/themes/default/layouts/base.njk +8 -0
- package/themes/default/layouts/comment.njk +90 -0
- package/themes/default/layouts/front-page.njk +9 -47
- package/themes/default/layouts/page.njk +2 -0
- package/themes/default/layouts/post.njk +14 -2
- package/themes/default/partials/backlinks.njk +19 -0
- package/themes/default/partials/cited-page.njk +3 -1
- package/themes/default/partials/comment-form.njk +10 -0
- package/themes/default/partials/comment.njk +79 -0
- package/themes/default/partials/conversation.njk +14 -59
- package/themes/default/src/style.css +42 -0
- package/themes/default/static/style.css +67 -0
package/dist/web/routes.js
CHANGED
|
@@ -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 {
|
|
24
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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
|
|
325
|
-
// feed, which is the only feed a
|
|
326
|
-
// name a format a comments feed does not come in, so they are
|
|
327
|
-
// request at all and the page is served.
|
|
328
|
-
if (
|
|
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.
|
|
389
|
-
|
|
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
|
|
415
|
-
*
|
|
416
|
-
*
|
|
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
|
|
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
|
|
475
|
-
* records no
|
|
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
|
|
478
|
-
const
|
|
479
|
-
|
|
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
|
|
500
|
+
* cannot be in the route table — every document's comments feed, and every alias.
|
|
489
501
|
*/
|
|
490
|
-
function parseFeedPath(
|
|
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
|
|
509
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
711
|
+
const feedRequest = parseFeedPath(c, pathname, bases, authors);
|
|
695
712
|
if (feedRequest !== undefined) {
|
|
696
|
-
|
|
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:
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
1161
|
-
* and a reader that subscribed before anybody answered should keep
|
|
1162
|
-
*
|
|
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)), {
|
|
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
|
|
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
|
|
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) {
|