@jant/core 0.7.0 → 0.7.1

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 (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Thread order on the public surfaces when a reply is older than its root.
3
+ *
4
+ * A post moved into a Thread keeps its own creation time, and an export
5
+ * without `created` dates every post by its publication, so a Thread's replies
6
+ * can carry a `createdAt` earlier than the root's. Thread order still opens on
7
+ * the root: every page and feed that reads the first post as the root has to
8
+ * find it there, or the Thread drops out of Featured and Collections and its
9
+ * page canonicalizes to a reply.
10
+ */
11
+
12
+ import { eq } from "drizzle-orm";
13
+ import { describe, expect, it } from "vitest";
14
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
15
+ import { posts as postTable } from "../../../db/schema.js";
16
+ import { toISOString } from "../../../lib/time.js";
17
+ import { collectionRoutes } from "../collection.js";
18
+ import { collectionsPageRoutes } from "../collections.js";
19
+ import { featuredRoutes } from "../featured.js";
20
+ import { pageRoutes } from "../page.js";
21
+
22
+ const ROOT_TEXT = "Root body text";
23
+ const FIRST_TEXT = "First reply text";
24
+ const SECOND_TEXT = "Second reply text";
25
+
26
+ function createThreadOrderTestApp(options: { authenticated?: boolean } = {}) {
27
+ const testApp = createTestApp(options);
28
+ const { app } = testApp;
29
+
30
+ app.use("*", async (c, next) => {
31
+ c.set("publicPath", c.req.path);
32
+ c.set("publicRequestUrl", c.req.url);
33
+ await next();
34
+ });
35
+
36
+ app.route("/featured", featuredRoutes);
37
+ app.route("/collections", collectionsPageRoutes);
38
+ app.route("/collections", collectionRoutes);
39
+ app.route("/", pageRoutes);
40
+
41
+ return testApp;
42
+ }
43
+
44
+ /**
45
+ * A featured root published after its two replies, with creation times to
46
+ * match — the shape a site rebuilt from a pre-`created` export holds.
47
+ */
48
+ async function createThreadWithOlderReplies(
49
+ testApp: ReturnType<typeof createThreadOrderTestApp>,
50
+ ) {
51
+ const { services, db } = testApp;
52
+ const root = await services.posts.create({
53
+ format: "note",
54
+ title: "Thread root",
55
+ bodyMarkdown: ROOT_TEXT,
56
+ featured: true,
57
+ publishedAt: 3000,
58
+ });
59
+ const first = await services.posts.create({
60
+ format: "note",
61
+ bodyMarkdown: FIRST_TEXT,
62
+ replyToId: root.id,
63
+ publishedAt: 1000,
64
+ });
65
+ const second = await services.posts.create({
66
+ format: "note",
67
+ bodyMarkdown: SECOND_TEXT,
68
+ replyToId: first.id,
69
+ publishedAt: 2000,
70
+ });
71
+ for (const [id, createdAt] of [
72
+ [root.id, 3000],
73
+ [first.id, 1000],
74
+ [second.id, 2000],
75
+ ] as const) {
76
+ await db.update(postTable).set({ createdAt }).where(eq(postTable.id, id));
77
+ }
78
+ return { root, first, second };
79
+ }
80
+
81
+ /**
82
+ * Assert the three posts appear root first, in Thread order. A page's `<head>`
83
+ * repeats the visited post's text, so only the body counts.
84
+ */
85
+ function expectRootFirst(markup: string) {
86
+ const bodyStart = markup.indexOf("<body");
87
+ const body = bodyStart === -1 ? markup : markup.slice(bodyStart);
88
+ const root = body.indexOf(ROOT_TEXT);
89
+ const first = body.indexOf(FIRST_TEXT);
90
+ const second = body.indexOf(SECOND_TEXT);
91
+ expect(root).toBeGreaterThan(-1);
92
+ expect(first).toBeGreaterThan(root);
93
+ expect(second).toBeGreaterThan(first);
94
+ }
95
+
96
+ /** The post whose `<article>` holds the markup at `index`. */
97
+ function postIdAt(html: string, index: number): string | undefined {
98
+ const articles = html
99
+ .slice(0, index)
100
+ .matchAll(/<article\b[^>]*\bdata-post-id="([^"]+)"/g);
101
+ return [...articles].at(-1)?.[1];
102
+ }
103
+
104
+ function extractCanonicalHref(html: string): string | null {
105
+ const match = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"\s*\/?>/i);
106
+ return match?.[1] ?? null;
107
+ }
108
+
109
+ describe("Thread order with replies older than the root", () => {
110
+ it("opens the Thread page on the root and points its canonical there", async () => {
111
+ const testApp = createThreadOrderTestApp();
112
+ const { root, second } = await createThreadWithOlderReplies(testApp);
113
+
114
+ const res = await testApp.app.request(`/${second.slug}`);
115
+ expect(res.status).toBe(200);
116
+ const html = await res.text();
117
+
118
+ expectRootFirst(html);
119
+ expect(extractCanonicalHref(html)).toMatch(new RegExp(`/${root.slug}$`));
120
+ // The page describes the Thread as one article, dated by its root.
121
+ expect(html).toContain(
122
+ `<meta property="article:published_time" content="${toISOString(3000)}"/>`,
123
+ );
124
+ expect(html).toContain(`"datePublished":"${toISOString(3000)}"`);
125
+ });
126
+
127
+ it("offers the author a reply on the newest reply, not the root", async () => {
128
+ const testApp = createThreadOrderTestApp({ authenticated: true });
129
+ const { root, second } = await createThreadWithOlderReplies(testApp);
130
+
131
+ const html = await (await testApp.app.request(`/${root.slug}`)).text();
132
+
133
+ expect(html.match(/data-reply-trigger/g)).toHaveLength(1);
134
+ expect(postIdAt(html, html.indexOf("data-reply-trigger"))).toBe(second.id);
135
+ });
136
+
137
+ it("keeps the Thread on the Featured page", async () => {
138
+ const testApp = createThreadOrderTestApp();
139
+ await createThreadWithOlderReplies(testApp);
140
+
141
+ const res = await testApp.app.request("/featured");
142
+ expect(res.status).toBe(200);
143
+ const html = await res.text();
144
+
145
+ // The curated view shows the root and the Thread's final post, with the
146
+ // reply between them folded into a gap.
147
+ expect(html).toContain(ROOT_TEXT);
148
+ expect(html).not.toContain(FIRST_TEXT);
149
+ expect(html.indexOf(SECOND_TEXT)).toBeGreaterThan(html.indexOf(ROOT_TEXT));
150
+ });
151
+
152
+ it("keeps the Thread in the Featured feed", async () => {
153
+ const testApp = createThreadOrderTestApp();
154
+ const { root } = await createThreadWithOlderReplies(testApp);
155
+
156
+ const res = await testApp.app.request("/featured/feed");
157
+ expect(res.status).toBe(200);
158
+ const xml = await res.text();
159
+
160
+ expect(xml.match(/<entry>/g)).toHaveLength(1);
161
+ expect(xml).toContain(`/${root.slug}`);
162
+ expectRootFirst(xml);
163
+ });
164
+
165
+ it("opens the Thread on its root on a Collection page", async () => {
166
+ const testApp = createThreadOrderTestApp();
167
+ const collection = await testApp.services.collections.create({
168
+ slug: "reading",
169
+ title: "Reading",
170
+ });
171
+ const { root } = await createThreadWithOlderReplies(testApp);
172
+ await testApp.services.collections.addThread(collection.id, root.id);
173
+
174
+ const res = await testApp.app.request(`/${collection.slug}`);
175
+ expect(res.status).toBe(200);
176
+
177
+ expectRootFirst(await res.text());
178
+ });
179
+ });
@@ -432,10 +432,14 @@ export async function renderArchivePage(
432
432
 
433
433
  if (params.collectionMissing) return c.notFound();
434
434
 
435
- const navData = await getNavigationData(c);
435
+ // The chrome is independent of every query below, so it goes out with them
436
+ // rather than ahead of them. Its `isAuthenticated` is `c.var.isAuthenticated`
437
+ // verbatim, which is what the filters below read.
438
+ const navDataPromise = getNavigationData(c);
439
+ const isAuthenticated = c.var.isAuthenticated;
436
440
 
437
441
  const filters = buildArchivePostFilters(params, {
438
- isAuthenticated: navData.isAuthenticated,
442
+ isAuthenticated,
439
443
  lang: getViewLang(c) ?? undefined,
440
444
  });
441
445
 
@@ -456,13 +460,14 @@ export async function renderArchivePage(
456
460
  // media EXISTS subqueries.
457
461
  const baselineFilters = hasActiveArchiveFilter(params.selection)
458
462
  ? buildArchivePostFilters(params, {
459
- isAuthenticated: navData.isAuthenticated,
463
+ isAuthenticated,
460
464
  lang: filters.lang,
461
465
  selection: {},
462
466
  })
463
467
  : undefined;
464
468
 
465
469
  const [
470
+ navData,
466
471
  totalCount,
467
472
  baselineCount,
468
473
  monthlyCounts,
@@ -470,6 +475,7 @@ export async function renderArchivePage(
470
475
  availableYears,
471
476
  listedCollections,
472
477
  ] = await Promise.all([
478
+ navDataPromise,
473
479
  services.posts.count(filters),
474
480
  baselineFilters
475
481
  ? services.posts.count(baselineFilters)
@@ -502,13 +508,6 @@ export async function renderArchivePage(
502
508
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
503
509
 
504
510
  const mediaCtx = createMediaContext(appConfig);
505
- const allPostIds = posts.map((p) => p.id);
506
- const archiveAliasesMap =
507
- await c.var.services.paths.getPostAliases(allPostIds);
508
- const archiveAliasMap = new Map<string, string>();
509
- for (const [id, aliases] of archiveAliasesMap) {
510
- if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
511
- }
512
511
 
513
512
  // --- List view: flat timeline items (no month grouping) ------------------
514
513
 
@@ -543,10 +542,17 @@ export async function renderArchivePage(
543
542
  );
544
543
 
545
544
  const postIds = posts.map((p) => p.id);
546
- const [rawMediaMap, replyCounts] = await Promise.all([
545
+ // Only the grid renders permalinks from this map. The list view goes
546
+ // through `assembleTimelineItems`, which loads the same aliases itself.
547
+ const [rawMediaMap, replyCounts, archiveAliasesMap] = await Promise.all([
547
548
  services.media.getByPostIds(postIds),
548
549
  services.posts.getReplyCounts(postIds),
550
+ services.paths.getPostAliases(postIds),
549
551
  ]);
552
+ const archiveAliasMap = new Map<string, string>();
553
+ for (const [id, aliases] of archiveAliasesMap) {
554
+ if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
555
+ }
550
556
  const mediaMap = buildMediaMap(
551
557
  rawMediaMap,
552
558
  mediaCtx.r2PublicUrl,
@@ -32,7 +32,10 @@ export const featuredRoutes = new Hono<Env>();
32
32
  * @returns Featured page response
33
33
  */
34
34
  export async function renderFeaturedPage(c: Context<Env>): Promise<Response> {
35
- const navData = await getNavigationData(c);
35
+ // The chrome and the timeline are independent, so they load together. The
36
+ // timeline only ever wanted `navData.isAuthenticated`, which is
37
+ // `c.var.isAuthenticated` verbatim.
38
+ const navDataPromise = getNavigationData(c);
36
39
  const i18n = getI18n(c);
37
40
 
38
41
  const page = parsePageNumber(c.req.query("page"));
@@ -43,10 +46,13 @@ export async function renderFeaturedPage(c: Context<Env>): Promise<Response> {
43
46
  }),
44
47
  );
45
48
  const paginatedPageTitle = formatPageLabel(page);
46
- const { items, currentPage, totalPages } = await assembleFeaturedTimeline(c, {
47
- page,
48
- isAuthenticated: navData.isAuthenticated,
49
- });
49
+ const [navData, { items, currentPage, totalPages }] = await Promise.all([
50
+ navDataPromise,
51
+ assembleFeaturedTimeline(c, {
52
+ page,
53
+ isAuthenticated: c.var.isAuthenticated,
54
+ }),
55
+ ]);
50
56
 
51
57
  return renderPublicPage(c, {
52
58
  title:
@@ -24,6 +24,7 @@ import {
24
24
  } from "../../lib/post-display.js";
25
25
  import { buildArticleJsonLd } from "../../lib/structured-data.js";
26
26
  import {
27
+ normalizePath,
27
28
  toAbsoluteAssetUrl,
28
29
  toAbsoluteSiteUrl,
29
30
  toPublicHref,
@@ -39,6 +40,7 @@ import {
39
40
  import { getOrBuildEntry } from "../../i18n/supported-locales.js";
40
41
  import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
41
42
  import { isTextAttachment } from "../../services/media.js";
43
+ import type { ResolvedPath } from "../../services/path.js";
42
44
  import type { Post } from "../../types.js";
43
45
  import { renderArchivePage } from "./archive.js";
44
46
  import { renderCollectionFeed, renderCollectionPage } from "./collection.js";
@@ -68,10 +70,10 @@ interface TextPreviewAutoOpen {
68
70
  * content at every reply URL. Point the canonical to the thread root so
69
71
  * crawlers consolidate ranking on one URL.
70
72
  *
71
- * The root post is always at index 0 of `threadPostViews` (getThread orders
72
- * by createdAt ASC, and the DB check constraint guarantees root has the
73
- * smallest createdAt in its thread). When `threadPostViews` is undefined the
74
- * post is not part of a multi-post thread, so the post itself is the root.
73
+ * The root post is always at index 0 of `threadPostViews`: Thread order puts
74
+ * the root first even when a reply is older than it. When `threadPostViews` is
75
+ * undefined the post is not part of a multi-post thread, so the post itself is
76
+ * the root.
75
77
  */
76
78
  function buildPostCanonicalHref(
77
79
  postView: { permalink: string },
@@ -432,6 +434,29 @@ pageRoutes.get("/preview/:slug", requireAuth(), async (c) => {
432
434
  return renderPost(c, post, { allowDraft: true, isPreview: true });
433
435
  });
434
436
 
437
+ /**
438
+ * Resolve a stored path, reusing the lookup the stored-redirect middleware
439
+ * already made for this request's own address.
440
+ *
441
+ * Only that one address is reusable: a `/feed` suffix or a text deep link asks
442
+ * about a different path, and a language view strips its prefix before asking,
443
+ * so the stored form is compared rather than assumed.
444
+ *
445
+ * @param c - Hono context
446
+ * @param storedPath - Registry path form, without a leading slash
447
+ * @returns The resolved record, or null when nothing is registered there
448
+ */
449
+ async function resolveStoredPath(
450
+ c: Context<Env>,
451
+ storedPath: string,
452
+ ): Promise<ResolvedPath | null> {
453
+ const lookup = c.var.pathLookup;
454
+ if (lookup && lookup.path === normalizePath(storedPath)) {
455
+ return lookup.record;
456
+ }
457
+ return c.var.services.paths.resolve(storedPath);
458
+ }
459
+
435
460
  /**
436
461
  * Resolve a path through the path registry: posts, collections, their aliases,
437
462
  * archive URLs, stored redirects, and text-attachment deep links.
@@ -459,7 +484,7 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
459
484
 
460
485
  // Stored redirects outrank the normal post/collection/deep-link resolvers,
461
486
  // but can never shadow an explicit or reserved application route.
462
- const resolved = await c.var.services.paths.resolve(fullPath);
487
+ const resolved = await resolveStoredPath(c, fullPath);
463
488
  if (resolved?.kind === "redirect" && resolved.redirectToPath) {
464
489
  const target = `/${resolved.redirectToPath}`;
465
490
  // Follow the stored redirect within the current view; the hop that lands
@@ -528,26 +553,25 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
528
553
  const resolvedPost = await c.var.services.paths.resolve(slugPart);
529
554
 
530
555
  if (resolvedPost?.postId) {
531
- const post = await c.var.services.posts.getById(resolvedPost.postId);
556
+ const loaded = await c.var.services.posts.getWithCanonicalAlias(
557
+ resolvedPost.postId,
558
+ );
559
+ const post = loaded?.post;
532
560
  if (!post || post.status === "draft") return c.notFound();
533
561
 
534
- if (post.visibility === "private") {
535
- const navData = await getNavigationData(c);
536
- if (!navData.isAuthenticated) return c.notFound();
562
+ if (post.visibility === "private" && !c.var.isAuthenticated) {
563
+ return c.notFound();
537
564
  }
538
565
 
539
566
  // Redirect slug → alias if one exists (same pattern as post pages)
540
- if (resolvedPost.kind === "slug") {
541
- const alias = await c.var.services.customUrls.getByTarget(
542
- "post",
543
- post.id,
567
+ if (resolvedPost.kind === "slug" && loaded.canonicalAlias) {
568
+ return c.redirect(
569
+ toPublicPath(
570
+ `${loaded.canonicalAlias}/text/${mediaId}`,
571
+ sitePathPrefix,
572
+ ),
573
+ 301,
544
574
  );
545
- if (alias) {
546
- return c.redirect(
547
- toPublicPath(`/${alias.path}/text/${mediaId}`, sitePathPrefix),
548
- 301,
549
- );
550
- }
551
575
  }
552
576
 
553
577
  // A text attachment belongs to a post, so it inherits that post's single
@@ -597,26 +621,24 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
597
621
  }
598
622
 
599
623
  if (resolved.postId) {
600
- const post = await c.var.services.posts.getById(resolved.postId);
624
+ const loaded = await c.var.services.posts.getWithCanonicalAlias(
625
+ resolved.postId,
626
+ );
627
+ const post = loaded?.post;
601
628
  if (!post) return c.notFound();
602
629
 
603
630
  const allowDraft = canRenderDraftAboutEditor(c, fullPath, post);
604
631
  if (post.status === "draft" && !allowDraft) return c.notFound();
605
632
 
606
- if (post.visibility === "private") {
607
- const navData = await getNavigationData(c);
608
- if (!navData.isAuthenticated) return c.notFound();
633
+ if (post.visibility === "private" && !c.var.isAuthenticated) {
634
+ return c.notFound();
609
635
  }
610
636
 
611
637
  // If accessed via slug but an alias exists, the alias is canonical.
612
- let canonicalPath = `/${fullPath}`;
613
- if (resolved.kind === "slug") {
614
- const alias = await c.var.services.customUrls.getByTarget(
615
- "post",
616
- post.id,
617
- );
618
- if (alias) canonicalPath = `/${alias.path}`;
619
- }
638
+ const canonicalPath =
639
+ resolved.kind === "slug" && loaded.canonicalAlias
640
+ ? loaded.canonicalAlias
641
+ : `/${fullPath}`;
620
642
 
621
643
  // One address per post: a language-prefixed URL is only ever a way in.
622
644
  if (inLanguageView || canonicalPath !== `/${fullPath}`) {
@@ -31,7 +31,8 @@ export async function renderSearchPage(c: Context<Env>): Promise<Response> {
31
31
  const pageParam = c.req.query("page");
32
32
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
33
33
 
34
- const navData = await getNavigationData(c);
34
+ // The chrome does not gate the query, so it loads alongside it.
35
+ const navDataPromise = getNavigationData(c);
35
36
 
36
37
  // Only search if there's a query
37
38
  let results: SearchResult[] = [];
@@ -63,7 +64,8 @@ export async function renderSearchPage(c: Context<Env>): Promise<Response> {
63
64
  // Transform to View Models
64
65
  const mediaCtx = createMediaContext(c.var.appConfig);
65
66
  const postIds = results.map((r) => r.post.id);
66
- const [aliasesMap, collectionsMap] = await Promise.all([
67
+ const [navData, aliasesMap, collectionsMap] = await Promise.all([
68
+ navDataPromise,
67
69
  c.var.services.paths.getPostAliases(postIds),
68
70
  c.var.services.collections.getCollectionsByPostIds(postIds),
69
71
  ]);
@@ -24,6 +24,7 @@ describe("getInstanceReadiness", () => {
24
24
  }),
25
25
  ).resolves.toEqual({
26
26
  status: "ok",
27
+ version: "test-version",
27
28
  checks: {
28
29
  startupConfig: { ok: true },
29
30
  database: { ok: true },
@@ -40,6 +41,7 @@ describe("getInstanceReadiness", () => {
40
41
  }),
41
42
  ).resolves.toEqual({
42
43
  status: "error",
44
+ version: "test-version",
43
45
  checks: {
44
46
  startupConfig: {
45
47
  ok: false,
@@ -104,6 +106,26 @@ describe("getInstanceReadiness", () => {
104
106
  );
105
107
  });
106
108
 
109
+ // What a deploy check compares: the build that answers, and when its
110
+ // process started. A Worker has no process, so it reports only the build.
111
+ it("reports the build, and the process start where there is one", async () => {
112
+ const { sqlite } = createTestDatabase();
113
+ const env = {
114
+ AUTH_SECRET: "test-secret-with-enough-entropy-for-readiness",
115
+ NODE_SQLITE: sqlite,
116
+ };
117
+
118
+ const withoutStart = await getInstanceReadiness(env);
119
+ expect(withoutStart.version).toBe("test-version");
120
+ expect(withoutStart).not.toHaveProperty("startedAt");
121
+
122
+ const withStart = await getInstanceReadiness({
123
+ ...env,
124
+ NODE_STARTED_AT: 1789000000,
125
+ });
126
+ expect(withStart.startedAt).toBe(1789000000);
127
+ });
128
+
107
129
  it("reports a missing database binding", async () => {
108
130
  await expect(
109
131
  getInstanceReadiness({
@@ -111,6 +133,7 @@ describe("getInstanceReadiness", () => {
111
133
  }),
112
134
  ).resolves.toEqual({
113
135
  status: "error",
136
+ version: "test-version",
114
137
  checks: {
115
138
  startupConfig: { ok: true },
116
139
  database: {
@@ -12,14 +12,21 @@ import {
12
12
  shouldUseSecureCookies,
13
13
  } from "../lib/env.js";
14
14
  import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
15
+ import { ValidationError } from "../lib/errors.js";
15
16
  import { createMemoryRateLimiter } from "../lib/rate-limit-memory.js";
16
17
  import type { RateLimiter } from "../lib/rate-limit.js";
18
+ import { InstanceSetupSchema } from "../lib/schemas.js";
17
19
  import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
18
20
  import {
19
21
  createHostedHandoffService,
20
22
  type HostedHandoffService,
21
23
  } from "../services/hosted-handoff.js";
24
+ import {
25
+ createBootstrapService,
26
+ type SetUpInstanceResult,
27
+ } from "../services/bootstrap.js";
22
28
  import { createServices, type Services } from "../services/index.js";
29
+ import { TRANSIENT_SINGLE_SITE_ID } from "../services/site.js";
23
30
  import type { Site, SiteDomain } from "../types/entities.js";
24
31
  import type { Bindings } from "../types/bindings.js";
25
32
  import {
@@ -229,3 +236,45 @@ export async function createNodeCliRuntime(
229
236
  storage: createStorageDriver(env),
230
237
  };
231
238
  }
239
+
240
+ /**
241
+ * Set up a self-hosted Node install without a browser, the way the two setup
242
+ * screens would. Backs `jant setup`.
243
+ *
244
+ * @param env - Bindings with a resolved `NODE_DATABASE`
245
+ * @param input - The owner's credentials and the site's answers, unvalidated
246
+ * @returns What setup found, and the site
247
+ * @throws {ValidationError} When the input fails `InstanceSetupSchema`
248
+ * @throws {Error} Whenever `BootstrapService.setUpInstance` refuses
249
+ * @example
250
+ * ```ts
251
+ * await setUpNodeInstance(bindings, {
252
+ * email: "owner@example.com",
253
+ * password: "correct horse battery",
254
+ * });
255
+ * ```
256
+ */
257
+ export async function setUpNodeInstance(
258
+ env: Bindings,
259
+ input: unknown,
260
+ ): Promise<SetUpInstanceResult> {
261
+ const nodeDatabase = env.NODE_DATABASE;
262
+ if (!nodeDatabase) {
263
+ throw new Error("Node setup requires a resolved database binding.");
264
+ }
265
+
266
+ const parsed = InstanceSetupSchema.safeParse(input);
267
+ if (!parsed.success) {
268
+ throw new ValidationError(
269
+ parsed.error.issues[0]?.message ?? "Invalid setup input.",
270
+ );
271
+ }
272
+
273
+ // No request resolved a site, and setup never reads the one it is bound to.
274
+ return createBootstrapService(nodeDatabase.db, TRANSIENT_SINGLE_SITE_ID, {
275
+ schema: nodeDatabase.schema,
276
+ databaseDialect: nodeDatabase.dialect,
277
+ bootstrapSite: getSingleSiteBootstrapOptions(env),
278
+ siteResolutionMode: getSiteResolutionMode(env),
279
+ }).setUpInstance(parsed.data);
280
+ }
@@ -5,6 +5,7 @@ import {
5
5
  getAuthSecretReadinessError,
6
6
  getHostBasedStartupConfigurationIssues,
7
7
  } from "../lib/startup-config.js";
8
+ import { CORE_VERSION } from "../lib/version.js";
8
9
  import { createSiteService } from "../services/site.js";
9
10
  import type { Bindings } from "../types/bindings.js";
10
11
 
@@ -15,6 +16,15 @@ export interface ReadinessCheckStatus {
15
16
 
16
17
  export interface InstanceReadinessResult {
17
18
  status: "ok" | "error";
19
+ /** The running build: the package version, then the build's commit id. */
20
+ version: string;
21
+ /**
22
+ * When the Node process started serving, in Unix seconds. With `version`,
23
+ * it lets a deploy check from outside that the instance answering is one
24
+ * started after the deploy, not an older one a rollback left running.
25
+ * Absent on Workers.
26
+ */
27
+ startedAt?: number;
18
28
  checks: {
19
29
  startupConfig: ReadinessCheckStatus;
20
30
  database: ReadinessCheckStatus;
@@ -117,6 +127,7 @@ export async function getInstanceReadiness(
117
127
  | "INTERNAL_ADMIN_TOKEN"
118
128
  | "NODE_DATABASE"
119
129
  | "NODE_SQLITE"
130
+ | "NODE_STARTED_AT"
120
131
  | "SITE_RESOLUTION_MODE"
121
132
  >,
122
133
  ): Promise<InstanceReadinessResult> {
@@ -125,6 +136,10 @@ export async function getInstanceReadiness(
125
136
 
126
137
  return {
127
138
  status: startupConfig.ok && database.ok ? "ok" : "error",
139
+ version: CORE_VERSION,
140
+ ...(env.NODE_STARTED_AT === undefined
141
+ ? {}
142
+ : { startedAt: env.NODE_STARTED_AT }),
128
143
  checks: {
129
144
  startupConfig,
130
145
  database,