@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,184 @@
1
+ /**
2
+ * An exported site's feeds keep the entry IDs Jant's feeds served.
3
+ *
4
+ * A feed reader recognises an entry it has already shown by its `<id>` alone.
5
+ * The Hugo page a post moves to has a different URL — a trailing slash
6
+ * always, and the slug where Jant used a custom path — so a feed that took
7
+ * `<id>` from the page URL would show every post again to every subscriber
8
+ * after a site moves to its export. This serves Jant's feed and builds the
9
+ * export with the real Hugo binary from the same database, and compares the
10
+ * IDs.
11
+ *
12
+ * Skips when `hugo` is not on PATH, like the other Hugo build tests.
13
+ */
14
+
15
+ import { readFile, rm } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { Hono } from "hono";
18
+ import { afterAll, describe, expect, it } from "vitest";
19
+ import { createTestDatabase, DEFAULT_TEST_SITE_ID } from "./helpers/db.js";
20
+ import { makeSiteConfig } from "./helpers/export-fixtures.js";
21
+ import { buildHugoSite, hugoAvailable } from "./helpers/hugo-site.js";
22
+ import { resolveConfig } from "../lib/resolve-config.js";
23
+ import { latestRoutes } from "../routes/pages/latest.js";
24
+ import { createCollectionService } from "../services/collection.js";
25
+ import { createExportService } from "../services/export.js";
26
+ import { createMediaService } from "../services/media.js";
27
+ import { createPathService } from "../services/path.js";
28
+ import { createPostService } from "../services/post.js";
29
+ import { createSettingsService } from "../services/settings.js";
30
+ import type { Database } from "../db/index.js";
31
+ import type { Bindings } from "../types.js";
32
+ import type { AppVariables } from "../types/app-context.js";
33
+
34
+ type Env = { Bindings: Bindings; Variables: AppVariables };
35
+
36
+ const ORIGIN = "https://example.com";
37
+ const CUSTOM_PATH = "/blog/links/2024-06-09-4";
38
+ const CUSTOM_SLUG = "blog-links-2024-06-09-4";
39
+ const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
40
+
41
+ interface Entry {
42
+ id: string;
43
+ alternate: string;
44
+ }
45
+
46
+ /** Each entry's `<id>` and `<link rel="alternate">`, in document order. */
47
+ function readEntries(xml: string): Entry[] {
48
+ return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map(([, body]) => ({
49
+ id: body?.match(/<id>([^<]*)<\/id>/)?.[1] ?? "",
50
+ alternate:
51
+ body?.match(/<link href="([^"]*)" rel="alternate"\/>/)?.[1] ?? "",
52
+ }));
53
+ }
54
+
55
+ const ids = (entries: Entry[]) => entries.map((entry) => entry.id).sort();
56
+
57
+ describe.skipIf(!hugoAvailable)("feed entry IDs on an exported site", () => {
58
+ const siteDirs: string[] = [];
59
+
60
+ afterAll(async () => {
61
+ for (const dir of siteDirs) await rm(dir, { recursive: true, force: true });
62
+ });
63
+
64
+ it.each([
65
+ { where: "at the domain root", prefix: "" },
66
+ { where: "under a site path prefix", prefix: "/journal" },
67
+ ])(
68
+ "match what Jant's feed served, $where",
69
+ async ({ prefix }) => {
70
+ const { db: testDb } = createTestDatabase();
71
+ const db = testDb as unknown as Database;
72
+ const siteId = DEFAULT_TEST_SITE_ID;
73
+ const paths = createPathService(db, siteId);
74
+ const posts = createPostService(db, { slugIdLength: 5 }, siteId, paths);
75
+ const collections = createCollectionService(db, siteId, paths);
76
+ const media = createMediaService(db, siteId);
77
+ const settings = createSettingsService(db, siteId);
78
+
79
+ // Served at its random slug, which is what an untitled note gets.
80
+ const plain = await posts.create({
81
+ format: "note",
82
+ bodyMarkdown: "A plain note.",
83
+ status: "published",
84
+ publishedAt: at("2024-06-01T00:00:00Z"),
85
+ });
86
+ // Served at a custom path, which its entry is named by. Its slug is
87
+ // where the Hugo page goes.
88
+ const custom = await posts.create({
89
+ format: "link",
90
+ slug: CUSTOM_SLUG,
91
+ title: "A link",
92
+ url: "https://other.example/article",
93
+ status: "published",
94
+ publishedAt: at("2024-06-09T00:00:00Z"),
95
+ });
96
+ await paths.create({
97
+ path: CUSTOM_PATH,
98
+ kind: "alias",
99
+ postId: custom.id,
100
+ });
101
+
102
+ const env = {
103
+ SITE_ORIGIN: ORIGIN,
104
+ SITE_PATH_PREFIX: prefix,
105
+ RSS_PUBLISH_DELAY_SECONDS: 0,
106
+ } as Bindings;
107
+ const appConfig = resolveConfig(env, await settings.getAll());
108
+ const app = new Hono<Env>();
109
+ app.use("*", async (c, next) => {
110
+ c.env = env;
111
+ c.set("services", {
112
+ posts,
113
+ paths,
114
+ media,
115
+ settings,
116
+ } as unknown as AppVariables["services"]);
117
+ c.set("appConfig", appConfig);
118
+ c.set("i18n", {
119
+ _(value: string | { message?: string }) {
120
+ return typeof value === "string" ? value : (value.message ?? "");
121
+ },
122
+ } as AppVariables["i18n"]);
123
+ await next();
124
+ });
125
+ app.route("/latest", latestRoutes);
126
+ const served = readEntries(
127
+ await (await app.request("/latest/feed")).text(),
128
+ );
129
+
130
+ const siteDir = await buildHugoSite(
131
+ await createExportService(
132
+ { posts, paths, collections, media },
133
+ makeSiteConfig({
134
+ siteUrl: appConfig.siteUrl,
135
+ sitePathPrefix: appConfig.sitePathPrefix,
136
+ }),
137
+ { storage: null, bundleMedia: false },
138
+ ).generateHugoFiles(),
139
+ );
140
+ siteDirs.push(siteDir);
141
+ const readFeed = async (path: string) =>
142
+ readEntries(await readFile(join(siteDir, "public", path), "utf8"));
143
+ const exportedLatest = await readFeed("index.xml");
144
+ const exportedArchive = await readFeed("archive/index.xml");
145
+
146
+ // What Jant serves: the random slug, and the custom path in place of
147
+ // the slug, neither with a trailing slash.
148
+ const base = `${ORIGIN}${prefix}`;
149
+ expect(ids(served)).toEqual(
150
+ [`${base}/${plain.slug}`, `${base}${CUSTOM_PATH}`].sort(),
151
+ );
152
+
153
+ expect(ids(exportedLatest)).toEqual(ids(served));
154
+ expect(ids(exportedArchive)).toEqual(ids(served));
155
+
156
+ // The entry still links to the page the post now lives on, not to the
157
+ // alias page that redirects there.
158
+ const exportedCustom = exportedLatest.find(
159
+ (entry) => entry.id === `${base}${CUSTOM_PATH}`,
160
+ );
161
+ expect(exportedCustom?.alternate).toBe("https://other.example/article");
162
+ const exportedFeed = await readFile(
163
+ join(siteDir, "public", "index.xml"),
164
+ "utf8",
165
+ );
166
+ expect(exportedFeed).toContain(
167
+ `<link href="${base}/${CUSTOM_SLUG}/" rel="related"/>`,
168
+ );
169
+ expect(
170
+ exportedLatest.find((entry) => entry.id === `${base}/${plain.slug}`)
171
+ ?.alternate,
172
+ ).toBe(`${base}/${plain.slug}/`);
173
+ // The ID still resolves: the custom path is an alias page that sends
174
+ // its visitor on to the post.
175
+ expect(
176
+ await readFile(
177
+ join(siteDir, "public", CUSTOM_PATH, "index.html"),
178
+ "utf8",
179
+ ),
180
+ ).toContain(`${base}/${CUSTOM_SLUG}/`);
181
+ },
182
+ 60_000,
183
+ );
184
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * An exported site lists Latest and Featured the way Jant does.
3
+ *
4
+ * The theme's `latest-members` and `featured-members` partials restate the
5
+ * order Jant's home page, `/latest/feed`, Featured page, and `/featured/feed`
6
+ * read from the database: pins first on the home page only, Thread activity
7
+ * (which a reply moves and a quiet reply doesn't), the newest featured post in
8
+ * a Thread, and the root ID when times tie. This builds the export with the
9
+ * real Hugo binary and compares every page, page by page, and both feeds with
10
+ * what Jant's services return.
11
+ *
12
+ * Skips when `hugo` is not on PATH, like the other Hugo build tests.
13
+ */
14
+
15
+ import { rm } from "node:fs/promises";
16
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
17
+ import { createTestDatabase, DEFAULT_TEST_SITE_ID } from "./helpers/db.js";
18
+ import { makeSiteConfig } from "./helpers/export-fixtures.js";
19
+ import {
20
+ buildHugoSite,
21
+ hugoAvailable,
22
+ readFeedSlugs,
23
+ readRootSlugsByPage,
24
+ } from "./helpers/hugo-site.js";
25
+ import { createCollectionService } from "../services/collection.js";
26
+ import { createExportService } from "../services/export.js";
27
+ import { createMediaService } from "../services/media.js";
28
+ import { createPathService } from "../services/path.js";
29
+ import { createPostService } from "../services/post.js";
30
+ import {
31
+ featuredFeedSelection,
32
+ latestFeedSelection,
33
+ } from "../lib/feed-policy.js";
34
+ import type { Database } from "../db/index.js";
35
+ import type { CreatePost } from "../types/operations.js";
36
+
37
+ const SITE_URL = "https://example.com";
38
+ const PAGE_SIZE = 4;
39
+ const FEED_LIMIT = 6;
40
+ const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
41
+ /** No RSS delay: the static site has none to apply. */
42
+ const NO_DELAY = at("2100-01-01T00:00:00Z");
43
+
44
+ const chunk = (list: string[], size: number) =>
45
+ Array.from({ length: Math.ceil(list.length / size) }, (_, index) =>
46
+ list.slice(index * size, index * size + size),
47
+ );
48
+
49
+ describe.skipIf(!hugoAvailable)(
50
+ "Latest and Featured on an exported site",
51
+ () => {
52
+ let siteDir: string;
53
+ const roots = new Set<string>();
54
+ const expected: Record<string, string[]> = {};
55
+ const built: Record<string, string[]> = {};
56
+ const expectedPages: Record<string, string[][]> = {};
57
+ const builtPages: Record<string, string[][]> = {};
58
+
59
+ beforeAll(async () => {
60
+ const { db: testDb } = createTestDatabase();
61
+ const db = testDb as unknown as Database;
62
+ const siteId = DEFAULT_TEST_SITE_ID;
63
+ const paths = createPathService(db, siteId);
64
+ const posts = createPostService(db, { slugIdLength: 5 }, siteId);
65
+ const collections = createCollectionService(db, siteId, paths);
66
+ const media = createMediaService(db, siteId);
67
+
68
+ const slugById = new Map<string, string>();
69
+ async function post(slug: string, data: Omit<CreatePost, "slug">) {
70
+ const created = await posts.create({
71
+ slug,
72
+ format: "note",
73
+ bodyMarkdown: `Body of ${slug}.`,
74
+ status: "published",
75
+ ...data,
76
+ createdAt: data.createdAt ?? data.publishedAt,
77
+ });
78
+ slugById.set(created.id, slug);
79
+ if (!data.replyToId) roots.add(slug);
80
+ return created;
81
+ }
82
+ const reply = (
83
+ root: { id: string },
84
+ slug: string,
85
+ data: Omit<CreatePost, "slug" | "replyToId" | "format">,
86
+ ) => post(slug, { format: "note", replyToId: root.id, ...data });
87
+
88
+ // Older posts, so the feeds and pages run past their limits.
89
+ for (const [index, month] of ["02", "03", "04", "05"].entries()) {
90
+ await post(`k${index}-filler`, {
91
+ format: "note",
92
+ publishedAt: at(`2024-${month}-01T00:00:00Z`),
93
+ });
94
+ }
95
+
96
+ // A reply brings an old Thread back to the top of Latest.
97
+ const bumped = await post("a-bumped-by-reply", {
98
+ format: "note",
99
+ publishedAt: at("2025-01-01T00:00:00Z"),
100
+ });
101
+ await reply(bumped, "a-reply", {
102
+ publishedAt: at("2025-08-01T00:00:00Z"),
103
+ });
104
+
105
+ // A quiet reply doesn't.
106
+ const quiet = await post("b-quiet-reply", {
107
+ format: "note",
108
+ publishedAt: at("2025-02-01T00:00:00Z"),
109
+ });
110
+ await reply(quiet, "b-reply", {
111
+ publishedAt: at("2025-09-01T00:00:00Z"),
112
+ quietReply: true,
113
+ });
114
+
115
+ await post("c-featured", {
116
+ format: "note",
117
+ publishedAt: at("2025-03-01T00:00:00Z"),
118
+ featuredAt: at("2025-03-02T00:00:00Z"),
119
+ });
120
+
121
+ // Published in the same second, and both featured: the ID decides.
122
+ await post("d-tied", {
123
+ format: "note",
124
+ publishedAt: at("2025-04-01T12:00:00Z"),
125
+ featuredAt: at("2025-04-02T00:00:00Z"),
126
+ });
127
+ await post("e-tied", {
128
+ format: "note",
129
+ publishedAt: at("2025-04-01T12:00:00Z"),
130
+ featuredAt: at("2025-04-02T00:00:00Z"),
131
+ });
132
+
133
+ // Featured, but Hidden from Latest: on Featured, not on Latest.
134
+ await post("f-hidden-featured", {
135
+ format: "note",
136
+ visibility: "latest_hidden",
137
+ publishedAt: at("2025-05-01T00:00:00Z"),
138
+ featuredAt: at("2025-05-02T00:00:00Z"),
139
+ });
140
+
141
+ // An old root whose featured post is a recent reply.
142
+ const replyFeatured = await post("g-reply-featured", {
143
+ format: "note",
144
+ publishedAt: at("2024-01-01T00:00:00Z"),
145
+ });
146
+ await reply(replyFeatured, "g-featured-reply", {
147
+ publishedAt: at("2025-06-15T00:00:00Z"),
148
+ featuredAt: at("2025-06-16T00:00:00Z"),
149
+ quietReply: true,
150
+ });
151
+
152
+ // Pinned: first on the home page, most recent pin first; not in the feed.
153
+ await post("p-pinned-early", {
154
+ format: "note",
155
+ publishedAt: at("2024-06-01T00:00:00Z"),
156
+ pinnedAt: at("2025-06-01T00:00:00Z"),
157
+ });
158
+ await post("q-pinned-late", {
159
+ format: "note",
160
+ publishedAt: at("2024-07-01T00:00:00Z"),
161
+ pinnedAt: at("2025-07-01T00:00:00Z"),
162
+ });
163
+ // Pinned but Hidden from Latest: not on the home page at all.
164
+ await post("r-hidden-pinned", {
165
+ format: "note",
166
+ visibility: "latest_hidden",
167
+ publishedAt: at("2024-08-01T00:00:00Z"),
168
+ pinnedAt: at("2025-07-15T00:00:00Z"),
169
+ });
170
+
171
+ // On none of them.
172
+ await post("s-private-featured", {
173
+ format: "note",
174
+ visibility: "private",
175
+ publishedAt: at("2025-05-15T00:00:00Z"),
176
+ featuredAt: at("2025-05-16T00:00:00Z"),
177
+ });
178
+ await post("t-draft", { format: "note", status: "draft" });
179
+
180
+ const slugs = (ids: string[]) =>
181
+ ids.map((id) => slugById.get(id) as string);
182
+
183
+ // The home page, as `assembleTimeline` asks for it.
184
+ const home = await posts.list({
185
+ status: "published",
186
+ excludeReplies: true,
187
+ excludeLatestHidden: true,
188
+ excludePrivate: true,
189
+ limit: 1000,
190
+ });
191
+ expected.home = slugs(home.map((item) => item.id));
192
+ expectedPages.home = chunk(expected.home, PAGE_SIZE);
193
+
194
+ // `/latest/feed`, as `buildLatestFeedData` asks for it.
195
+ const latestFeed = await posts.list({
196
+ ...latestFeedSelection({ publishedBefore: NO_DELAY }),
197
+ ignorePinnedSort: true,
198
+ limit: FEED_LIMIT,
199
+ });
200
+ expected.latestFeed = slugs(latestFeed.map((item) => item.id));
201
+
202
+ // The Featured page, as `assembleFeaturedTimeline` asks for it.
203
+ expected.featured = slugs(
204
+ await posts.listFeaturedThreadRootIds({
205
+ status: "published",
206
+ excludePrivate: true,
207
+ }),
208
+ );
209
+ expectedPages.featured = chunk(expected.featured, PAGE_SIZE);
210
+
211
+ // `/featured/feed`, as `buildFeaturedFeedData` asks for it.
212
+ expected.featuredFeed = slugs(
213
+ await posts.listFeaturedThreadRootIds({
214
+ ...featuredFeedSelection({ publishedBefore: NO_DELAY }),
215
+ limit: FEED_LIMIT,
216
+ }),
217
+ );
218
+
219
+ siteDir = await buildHugoSite(
220
+ await createExportService(
221
+ { posts, paths, collections, media },
222
+ makeSiteConfig({
223
+ siteUrl: SITE_URL,
224
+ pageSize: PAGE_SIZE,
225
+ rssFeedLimit: FEED_LIMIT,
226
+ }),
227
+ { storage: null, bundleMedia: false },
228
+ ).generateHugoFiles(),
229
+ );
230
+ builtPages.home = await readRootSlugsByPage(siteDir, "", roots);
231
+ builtPages.featured = await readRootSlugsByPage(
232
+ siteDir,
233
+ "featured",
234
+ roots,
235
+ );
236
+ built.latestFeed = await readFeedSlugs(siteDir, "", SITE_URL);
237
+ built.featuredFeed = await readFeedSlugs(siteDir, "featured", SITE_URL);
238
+ }, 60_000);
239
+
240
+ afterAll(async () => {
241
+ if (siteDir) await rm(siteDir, { recursive: true, force: true });
242
+ });
243
+
244
+ it("lists the home page as Jant does, page by page", () => {
245
+ expect(builtPages.home).toEqual(expectedPages.home);
246
+ });
247
+
248
+ it("carries Jant's Latest feed", () => {
249
+ expect(built.latestFeed).toEqual(expected.latestFeed);
250
+ });
251
+
252
+ it("lists the Featured page as Jant does, page by page", () => {
253
+ expect(builtPages.featured).toEqual(expectedPages.featured);
254
+ });
255
+
256
+ it("carries Jant's Featured feed", () => {
257
+ expect(built.featuredFeed).toEqual(expected.featuredFeed);
258
+ });
259
+
260
+ // Equal lists prove nothing if the fixture can't tell the rules apart.
261
+ it("runs on a fixture where each rule changes the order", () => {
262
+ // Pins lead the home page, most recent first; Hidden from Latest stays
263
+ // off it even when pinned.
264
+ expect(expected.home?.slice(0, 3)).toEqual([
265
+ "q-pinned-late",
266
+ "p-pinned-early",
267
+ "a-bumped-by-reply",
268
+ ]);
269
+ expect(expected.home).not.toContain("r-hidden-pinned");
270
+ expect(expected.home).not.toContain("f-hidden-featured");
271
+ // A reply moves its Thread up; a quiet one doesn't. The feed ignores
272
+ // pins and stops at its limit.
273
+ expect(expected.latestFeed).toEqual([
274
+ "a-bumped-by-reply",
275
+ "e-tied",
276
+ "d-tied",
277
+ "c-featured",
278
+ "b-quiet-reply",
279
+ "q-pinned-late",
280
+ ]);
281
+ // A featured reply dates its Thread; same-second Threads go by ID;
282
+ // Hidden from Latest is on Featured, private isn't.
283
+ expect(expected.featured).toEqual([
284
+ "g-reply-featured",
285
+ "f-hidden-featured",
286
+ "e-tied",
287
+ "d-tied",
288
+ "c-featured",
289
+ ]);
290
+ // Enough roots to run past a page and the feed limit.
291
+ expect(expectedPages.home?.length).toBeGreaterThan(2);
292
+ });
293
+ },
294
+ );
@@ -90,6 +90,7 @@ function buildFixtureServices(): ServicesArg {
90
90
  ["pst_root", "hello-world"],
91
91
  ["pst_reply", "hello-reply"],
92
92
  ]),
93
+ listStandalonePaths: async () => [],
93
94
  getPostAliases: async () => new Map(),
94
95
  getCollectionSlugMap: async () => new Map([["col-1", "ideas"]]),
95
96
  },
@@ -325,6 +326,7 @@ describe("Hugo smoke build", () => {
325
326
  ["pst_long", "blue-long"],
326
327
  ["pst_short", "blue-short"],
327
328
  ]),
329
+ listStandalonePaths: async () => [],
328
330
  getPostAliases: async () => new Map(),
329
331
  getCollectionSlugMap: async () => new Map(),
330
332
  },
@@ -387,6 +389,133 @@ describe("Hugo smoke build", () => {
387
389
  }
388
390
  }, 60_000);
389
391
 
392
+ it("never emits an alias page that redirects to nowhere", async () => {
393
+ if (!hugoOk) {
394
+ console.log("hugo binary not found on PATH — skipping hugo build test");
395
+ return;
396
+ }
397
+
398
+ const root = makePost({
399
+ id: "pst_root",
400
+ slug: "hello-world",
401
+ title: "Hello World",
402
+ threadId: "pst_root",
403
+ });
404
+ const draft = makePost({
405
+ id: "pst_draft",
406
+ slug: "secret-plan",
407
+ title: "Secret Plan",
408
+ threadId: "pst_draft",
409
+ status: "draft",
410
+ });
411
+ const services = {
412
+ posts: { list: async () => [root, draft] },
413
+ paths: {
414
+ getPostSlugMap: async () =>
415
+ new Map([
416
+ ["pst_root", "hello-world"],
417
+ ["pst_draft", "secret-plan"],
418
+ ]),
419
+ listStandalonePaths: async () => [],
420
+ getPostAliases: async () =>
421
+ new Map([
422
+ ["pst_root", ["/blog/hello-world/"]],
423
+ ["pst_draft", ["/blog/nixos-setup/"]],
424
+ ]),
425
+ getCollectionSlugMap: async () => new Map(),
426
+ },
427
+ collections: {
428
+ list: async () => [],
429
+ listDirectoryData: async () => ({
430
+ collections: [],
431
+ items: [],
432
+ directoryItems: [],
433
+ }),
434
+ getCollectionsByPostIds: async () => new Map(),
435
+ getCollectionEntriesByThreadIds: async () => new Map(),
436
+ },
437
+ media: { getByPostIds: async () => new Map() },
438
+ } as unknown as ServicesArg;
439
+
440
+ const siteDir = await mkdtemp(join(tmpdir(), "jant-hugo-alias-"));
441
+ try {
442
+ const files = await createExportService(
443
+ services,
444
+ makeSiteConfig(),
445
+ ).generateHugoFiles();
446
+ for (const file of files) {
447
+ const target = join(siteDir, file.path);
448
+ await mkdir(dirname(target), { recursive: true });
449
+ const data =
450
+ typeof file.content === "string"
451
+ ? new TextEncoder().encode(file.content)
452
+ : file.content;
453
+ await writeFile(target, data);
454
+ }
455
+ // A page whose `aliases:` were written by hand rather than by Jant —
456
+ // editing content on GitHub is a supported workflow, so the theme
457
+ // cannot assume every alias came from the exporter.
458
+ await mkdir(join(siteDir, "content/hand-written"), { recursive: true });
459
+ await writeFile(
460
+ join(siteDir, "content/hand-written/_index.md"),
461
+ [
462
+ "---",
463
+ 'title: "Hand written"',
464
+ 'date: "2026-03-09T00:00:00.000Z"',
465
+ 'slug: "hand-written"',
466
+ 'type: "post"',
467
+ "draft: true",
468
+ "aliases:",
469
+ ' - "/blog/hand-written/"',
470
+ "---",
471
+ "",
472
+ "Body.",
473
+ "",
474
+ ].join("\n"),
475
+ );
476
+
477
+ const { code, stdout, stderr } = await runHugo(siteDir);
478
+ if (code !== 0) {
479
+ console.error("hugo stdout:", stdout);
480
+ console.error("hugo stderr:", stderr);
481
+ }
482
+ expect(code).toBe(0);
483
+
484
+ // The published thread's alias redirects where it always did.
485
+ const liveAlias = await readFile(
486
+ join(siteDir, "public/blog/hello-world/index.html"),
487
+ "utf-8",
488
+ );
489
+ expect(liveAlias).toContain(
490
+ 'content="0; url=https://example.com/hello-world/"',
491
+ );
492
+ // `jsonify` alone is escaped into a JS string by Hugo's contextual
493
+ // escaping, which leaves the historical-root-alias branch comparing
494
+ // single characters and never taking. `safeJS` keeps it an array.
495
+ expect(liveAlias).toContain('["/blog/hello-world/"]');
496
+ expect(liveAlias).not.toContain("'[\"/blog/hello-world/\"]'");
497
+
498
+ // The unpublished thread gets no alias page at all: the exporter left
499
+ // `aliases:` off a root Hugo was never going to build.
500
+ expect(
501
+ await fileExists(join(siteDir, "public/blog/nixos-setup/index.html")),
502
+ ).toBe(false);
503
+
504
+ // The hand-written one does get a page, and it has to be inert. An
505
+ // empty `url=` is read as "reload this page", so the alias would
506
+ // refresh itself forever.
507
+ const orphanAlias = await readFile(
508
+ join(siteDir, "public/blog/hand-written/index.html"),
509
+ "utf-8",
510
+ );
511
+ expect(orphanAlias).not.toContain("http-equiv");
512
+ expect(orphanAlias).toContain("This page is not available");
513
+ expect(orphanAlias).toContain('content="noindex,nofollow"');
514
+ } finally {
515
+ await rm(siteDir, { recursive: true, force: true });
516
+ }
517
+ }, 60_000);
518
+
390
519
  it("folds a long thread's preview the way the site does", async () => {
391
520
  if (!hugoOk) {
392
521
  console.log("hugo binary not found on PATH — skipping hugo build test");
@@ -432,6 +561,7 @@ describe("Hugo smoke build", () => {
432
561
  `fold-${word}`,
433
562
  ]),
434
563
  ]),
564
+ listStandalonePaths: async () => [],
435
565
  getPostAliases: async () => new Map(),
436
566
  getCollectionSlugMap: async () => new Map(),
437
567
  },