@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,264 @@
1
+ /**
2
+ * Local Node Dev Task Guard
3
+ *
4
+ * `db-node-bootstrap-shell` and `db-node-clean` were added in 4bc0bfe8 and
5
+ * never touched the Node database. The shell migrated it, then set up the
6
+ * local Wrangler D1 database and wrote credentials to `.dev.vars`, leaving the
7
+ * Node site at `/setup`. Clean deleted `packages/core/.data`, a directory the
8
+ * Node runtime no longer uses. Both exited 0, so nothing noticed.
9
+ *
10
+ * These tests run each task's own script through `dev/run-script.mjs` against
11
+ * a throwaway directory, and check the database and env file it leaves. The
12
+ * runner starts Vite, hence the timeouts.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import {
17
+ existsSync,
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ readFileSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join, resolve } from "node:path";
26
+ import Database from "better-sqlite3";
27
+ import { afterEach, describe, expect, it } from "vitest";
28
+ import { writeScriptEnvValues } from "../../dev/script-env.js";
29
+ import { DEFAULT_NAVIGATION_PROFILE } from "../types/constants.js";
30
+
31
+ const CORE_DIR = resolve(import.meta.dirname, "../..");
32
+
33
+ /** Variables that would point a task somewhere other than the temp directory. */
34
+ const TARGET_VARIABLES = [
35
+ "AUTH_SECRET",
36
+ "DATABASE_URL",
37
+ "DATA_DIR",
38
+ "DEMO_EMAIL",
39
+ "DEMO_PASSWORD",
40
+ "DEV_API_TOKEN",
41
+ "LOCAL_STORAGE_PATH",
42
+ "PORT",
43
+ "SITE_RESOLUTION_MODE",
44
+ "STORAGE_DRIVER",
45
+ ];
46
+
47
+ function taskEnv(
48
+ overrides: Record<string, string>,
49
+ ): Record<string, string | undefined> {
50
+ const env: Record<string, string | undefined> = {
51
+ ...process.env,
52
+ // No announcement to a Discover directory from a test database.
53
+ DISCOVER_PING_URL: "",
54
+ };
55
+ for (const name of TARGET_VARIABLES) {
56
+ delete env[name];
57
+ }
58
+ return { ...env, ...overrides };
59
+ }
60
+
61
+ function runScript(
62
+ script: string,
63
+ args: string[],
64
+ env: Record<string, string | undefined>,
65
+ ) {
66
+ const result = spawnSync(
67
+ process.execPath,
68
+ ["dev/run-script.mjs", `dev/scripts/${script}`, ...args],
69
+ { cwd: CORE_DIR, encoding: "utf8", env, timeout: 120_000 },
70
+ );
71
+ expect(result.error).toBeUndefined();
72
+ return { ...result, output: `${result.stdout}\n${result.stderr}` };
73
+ }
74
+
75
+ function envFileLines(path: string): string[] {
76
+ return readFileSync(path, "utf8").split("\n");
77
+ }
78
+
79
+ describe("local Node dev tasks", () => {
80
+ const tempDirs: string[] = [];
81
+
82
+ afterEach(() => {
83
+ for (const dir of tempDirs.splice(0)) {
84
+ rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ function makeTempDir(prefix: string): string {
89
+ const dir = mkdtempSync(join(tmpdir(), prefix));
90
+ tempDirs.push(dir);
91
+ return dir;
92
+ }
93
+
94
+ it(
95
+ "db-node-bootstrap-shell sets up the Node database and writes dev credentials to the env file",
96
+ { timeout: 300_000 },
97
+ () => {
98
+ const dir = makeTempDir("jant-bootstrap-shell-");
99
+ const envPath = join(dir, "test.env");
100
+ const ownLines = ["# Local settings", `DATA_DIR=${dir}`];
101
+ writeFileSync(envPath, `${ownLines.join("\n")}\n`);
102
+ const env = taskEnv({ JANT_ENV_FILE: envPath });
103
+
104
+ const first = runScript("bootstrap-node-dev.ts", [], env);
105
+ expect(first.status, first.output).toBe(0);
106
+ expect(first.stdout).toContain("created the site and the dev account");
107
+ expect(first.stdout).not.toMatch(/do(es)? not sign in/);
108
+
109
+ // The file's own lines stay as they were; the credentials follow as one
110
+ // block below a blank line.
111
+ const written = envFileLines(envPath);
112
+ expect(written.slice(0, 3)).toEqual([...ownLines, ""]);
113
+ expect(written.slice(3)).toEqual([
114
+ expect.stringMatching(/^AUTH_SECRET=\S{32,}$/),
115
+ expect.stringMatching(/^DEV_API_TOKEN=jnt_dev_[0-9a-f]{32}$/),
116
+ "DEMO_EMAIL=debug@jant.test",
117
+ "DEMO_PASSWORD=jant-dev-debug-login",
118
+ "",
119
+ ]);
120
+
121
+ const sqlite = new Database(join(dir, "jant.sqlite"), {
122
+ readonly: true,
123
+ });
124
+ try {
125
+ expect(
126
+ sqlite
127
+ .prepare(
128
+ `SELECT "user"."email" FROM "user" JOIN "account" ON "account"."user_id" = "user"."id" WHERE "account"."provider_id" = 'credential'`,
129
+ )
130
+ .all(),
131
+ ).toEqual([{ email: "debug@jant.test" }]);
132
+ expect(
133
+ sqlite
134
+ .prepare(
135
+ `SELECT "value" FROM "site_setting" WHERE "key" = 'ONBOARDING_STATUS'`,
136
+ )
137
+ .get(),
138
+ ).toEqual({ value: "completed" });
139
+ expect(
140
+ (
141
+ sqlite.prepare(`SELECT "system_key" FROM "nav_item"`).all() as {
142
+ system_key: string;
143
+ }[]
144
+ )
145
+ .map((row) => row.system_key)
146
+ .sort(),
147
+ ).toEqual([...DEFAULT_NAVIGATION_PROFILE.systemKeys].sort());
148
+ expect(
149
+ sqlite.prepare(`SELECT COUNT(*) AS count FROM "post"`).get(),
150
+ ).toEqual({ count: 0 });
151
+ } finally {
152
+ sqlite.close();
153
+ }
154
+
155
+ // A second run finds the site set up, and keeps the generated secrets.
156
+ const afterFirst = readFileSync(envPath, "utf8");
157
+ const second = runScript("bootstrap-node-dev.ts", [], env);
158
+ expect(second.status, second.output).toBe(0);
159
+ expect(second.stdout).toContain("already set up, left unchanged");
160
+ expect(second.stdout).not.toMatch(/do(es)? not sign in/);
161
+ expect(readFileSync(envPath, "utf8")).toBe(afterFirst);
162
+
163
+ // A new password is recorded, but not set on the existing account, and
164
+ // the task says so.
165
+ const third = runScript(
166
+ "bootstrap-node-dev.ts",
167
+ ["another-password"],
168
+ env,
169
+ );
170
+ expect(third.status, third.output).toBe(0);
171
+ expect(third.stdout).toContain(
172
+ "debug@jant.test has a different password",
173
+ );
174
+ expect(envFileLines(envPath)).toContain("DEMO_PASSWORD=another-password");
175
+ },
176
+ );
177
+
178
+ it(
179
+ "db-node-clean deletes the SQLite database, its WAL files, and media, and nothing else",
180
+ { timeout: 150_000 },
181
+ () => {
182
+ const dir = makeTempDir("jant-clean-");
183
+ const deleted = [
184
+ join(dir, "jant.sqlite"),
185
+ join(dir, "jant.sqlite-wal"),
186
+ join(dir, "jant.sqlite-shm"),
187
+ join(dir, "media"),
188
+ ];
189
+ const kept = join(dir, "notes.txt");
190
+ mkdirSync(join(dir, "media/2026"), { recursive: true });
191
+ writeFileSync(join(dir, "media/2026/photo.webp"), "image");
192
+ for (const file of deleted.slice(0, 3)) {
193
+ writeFileSync(file, "");
194
+ }
195
+ writeFileSync(kept, "keep");
196
+
197
+ const result = runScript(
198
+ "clean-node-dev.ts",
199
+ [],
200
+ taskEnv({ JANT_ENV_FILE: "", DATA_DIR: dir }),
201
+ );
202
+
203
+ expect(result.status, result.output).toBe(0);
204
+ expect(deleted.filter((path) => existsSync(path))).toEqual([]);
205
+ expect(existsSync(kept)).toBe(true);
206
+ },
207
+ );
208
+
209
+ it("db-node-clean refuses a Postgres database", { timeout: 150_000 }, () => {
210
+ const result = runScript(
211
+ "clean-node-dev.ts",
212
+ [],
213
+ taskEnv({
214
+ JANT_ENV_FILE: "",
215
+ DATABASE_URL: "postgres://jant:jant@127.0.0.1:9/jant_dev",
216
+ }),
217
+ );
218
+
219
+ expect(result.status).not.toBe(0);
220
+ expect(result.output).toContain(
221
+ "db-node-clean only supports Node SQLite development databases.",
222
+ );
223
+ });
224
+ });
225
+
226
+ describe("writeScriptEnvValues", () => {
227
+ const tempDirs: string[] = [];
228
+
229
+ afterEach(() => {
230
+ for (const dir of tempDirs.splice(0)) {
231
+ rmSync(dir, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ it("replaces keys in place and appends new ones as one block", () => {
236
+ const dir = mkdtempSync(join(tmpdir(), "jant-script-env-"));
237
+ tempDirs.push(dir);
238
+ const envPath = join(dir, ".env.node");
239
+ writeFileSync(
240
+ envPath,
241
+ "# Database\nDATABASE_URL=file:./data/jant.sqlite\nDEMO_PASSWORD=old\n\n\n",
242
+ );
243
+
244
+ writeScriptEnvValues(envPath, {
245
+ DEMO_PASSWORD: "new",
246
+ AUTH_SECRET: "secret",
247
+ DEV_API_TOKEN: "token",
248
+ });
249
+
250
+ expect(readFileSync(envPath, "utf8")).toBe(
251
+ "# Database\nDATABASE_URL=file:./data/jant.sqlite\nDEMO_PASSWORD=new\n\nAUTH_SECRET=secret\nDEV_API_TOKEN=token\n",
252
+ );
253
+ });
254
+
255
+ it("writes a new file without a leading blank line", () => {
256
+ const dir = mkdtempSync(join(tmpdir(), "jant-script-env-"));
257
+ tempDirs.push(dir);
258
+ const envPath = join(dir, ".env.node");
259
+
260
+ writeScriptEnvValues(envPath, { AUTH_SECRET: "secret" });
261
+
262
+ expect(readFileSync(envPath, "utf8")).toBe("AUTH_SECRET=secret\n");
263
+ });
264
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Canonical Site-Export Import Guard
3
+ *
4
+ * `sites/demo-source/canonical/site-export/` is a committed `jant site export`.
5
+ * `mise run db-node-load-demo` loads it the portable way: migrate a local Node
6
+ * database, set it up, serve the app on a loopback port, and run
7
+ * `jant site import` against that server.
8
+ *
9
+ * Nothing ran that path, and it broke twice without a sign. From b259f4e3 the
10
+ * task called `site import` without the site URL the command had come to
11
+ * require. From 6c79f0b4 the importer sent boolean settings the settings route
12
+ * rejects, so importing any export stopped before it created a post. This test
13
+ * runs the task's own script against a throwaway SQLite database, so a change
14
+ * to the importer, the API it calls, the fixture, or the task fails here.
15
+ *
16
+ * `JANT_ENV_FILE` is empty for the whole suite (`vitest.config.ts`), and the
17
+ * script honours it, so a developer's `.env.node` is neither read nor
18
+ * rewritten. The script runs through `dev/run-script.mjs`, which starts Vite,
19
+ * hence the timeout.
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, join, resolve } from "node:path";
26
+ import Database from "better-sqlite3";
27
+ import { afterEach, describe, expect, it } from "vitest";
28
+ import { __test__ as importSite } from "../../bin/commands/import-site.js";
29
+
30
+ const CORE_DIR = resolve(import.meta.dirname, "../..");
31
+ const CANONICAL_DIR = resolve(
32
+ CORE_DIR,
33
+ "../../sites/demo-source/canonical/site-export",
34
+ );
35
+
36
+ function count(sqlite: Database.Database, sql: string): number {
37
+ return (sqlite.prepare(sql).get() as { count: number }).count;
38
+ }
39
+
40
+ describe("canonical demo site-export", () => {
41
+ const tempDirs: string[] = [];
42
+
43
+ afterEach(() => {
44
+ for (const dir of tempDirs.splice(0)) {
45
+ rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ it(
50
+ "imports through `jant site import` into a local Node database at head",
51
+ { timeout: 150_000 },
52
+ async () => {
53
+ const dir = mkdtempSync(join(tmpdir(), "jant-load-demo-"));
54
+ tempDirs.push(dir);
55
+ const databasePath = join(dir, "jant.sqlite");
56
+
57
+ const env: Record<string, string | undefined> = {
58
+ ...process.env,
59
+ JANT_ENV_FILE: "",
60
+ DATABASE_URL: `file:${databasePath}`,
61
+ DATA_DIR: dir,
62
+ LOCAL_STORAGE_PATH: join(dir, "media"),
63
+ STORAGE_DRIVER: "local",
64
+ // No announcement to a Discover directory from a test database.
65
+ DISCOVER_PING_URL: "",
66
+ // Every request off this machine goes to a closed port, so the export
67
+ // has to import from its own files. The importer falls back to
68
+ // fetching a file from the exported site's URL; with the network up,
69
+ // that fallback hid icons it failed to find in the export.
70
+ NODE_USE_ENV_PROXY: "1",
71
+ HTTP_PROXY: "http://127.0.0.1:9",
72
+ HTTPS_PROXY: "http://127.0.0.1:9",
73
+ NO_PROXY: "127.0.0.1",
74
+ };
75
+ delete env.SITE_RESOLUTION_MODE;
76
+ for (const name of [
77
+ "http_proxy",
78
+ "https_proxy",
79
+ "no_proxy",
80
+ "all_proxy",
81
+ "ALL_PROXY",
82
+ ]) {
83
+ delete env[name];
84
+ }
85
+
86
+ const result = spawnSync(
87
+ process.execPath,
88
+ ["dev/run-script.mjs", "dev/scripts/import-node-demo-site-export.ts"],
89
+ { cwd: CORE_DIR, encoding: "utf8", env, timeout: 120_000 },
90
+ );
91
+ const output = `${result.stdout}\n${result.stderr}`;
92
+
93
+ expect(result.error).toBeUndefined();
94
+ expect(result.status, output).toBe(0);
95
+ expect(result.stdout).toContain("(none: JANT_ENV_FILE is empty)");
96
+ // The importer reports a step it gave up on as a warning and carries on,
97
+ // the way it once dropped the site avatar.
98
+ expect(output).not.toMatch(/^Warning:/m);
99
+
100
+ const { rootBundles, collectionBundles } =
101
+ await importSite.walkHugoContent(CANONICAL_DIR);
102
+ const siteConfig = await importSite.loadSiteConfig(CANONICAL_DIR);
103
+ const replyCount = rootBundles.reduce(
104
+ (sum: number, root: { children: unknown[] }) =>
105
+ sum + root.children.length,
106
+ 0,
107
+ );
108
+ // An empty fixture and an import that did nothing would agree on zero.
109
+ expect(rootBundles.length).toBeGreaterThan(0);
110
+ const avatarFile = basename(siteConfig.extra.jant.site_avatar_url);
111
+ const postMediaFiles = readdirSync(
112
+ join(CANONICAL_DIR, "static/media"),
113
+ ).filter((name) => name !== avatarFile);
114
+
115
+ const sqlite = new Database(databasePath, { readonly: true });
116
+ try {
117
+ expect(count(sqlite, `SELECT COUNT(*) AS count FROM "user"`)).toBe(1);
118
+ expect(count(sqlite, `SELECT COUNT(*) AS count FROM "post"`)).toBe(
119
+ rootBundles.length + replyCount,
120
+ );
121
+ expect(
122
+ count(sqlite, `SELECT COUNT(*) AS count FROM "collection"`),
123
+ ).toBe(collectionBundles.length);
124
+ expect(
125
+ count(
126
+ sqlite,
127
+ `SELECT COUNT(*) AS count FROM "media" WHERE "storage_key" LIKE '%/files/%'`,
128
+ ),
129
+ ).toBe(postMediaFiles.length);
130
+
131
+ const settings = new Map(
132
+ (
133
+ sqlite
134
+ .prepare(`SELECT "key", "value" FROM "site_setting"`)
135
+ .all() as { key: string; value: string }[]
136
+ ).map(({ key, value }) => [key, value]),
137
+ );
138
+ expect(settings.get("SITE_NAME")).toBe(siteConfig.title);
139
+ expect(settings.get("SITE_AVATAR")).toMatch(/\/assets\/avatar\//);
140
+ expect(settings.get("SITE_FAVICON_ICO")).toBeTruthy();
141
+ expect(settings.get("SITE_FAVICON_APPLE_TOUCH")).toMatch(
142
+ /\/assets\/favicon\/apple-touch-icon\.png$/,
143
+ );
144
+ } finally {
145
+ sqlite.close();
146
+ }
147
+ },
148
+ );
149
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Canonical Snapshot Replay Guard
3
+ *
4
+ * `sites/demo-source/canonical/snapshot/` is a committed SQL dump. The nightly
5
+ * demo rebuild migrates demo.jant.me's database to whatever `main` says, then
6
+ * replays that dump into it — so the dump has to stay loadable against head,
7
+ * however long ago it was exported.
8
+ *
9
+ * Nothing checked that. A migration that renamed a column, added a NOT NULL
10
+ * without a default, or reordered the snapshot registries would leave the
11
+ * committed dump unloadable, and the first sign of it would be the reset job
12
+ * failing at ~03:00 UTC — which nothing reports either. This test moves that
13
+ * failure to the pull request that causes it.
14
+ *
15
+ * `createTestDatabase()` applies every migration in order and enables foreign
16
+ * keys, so the replay here is checked at least as strictly as the real target.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { resolve } from "node:path";
21
+ import { describe, expect, it } from "vitest";
22
+ import {
23
+ DEFER_FOREIGN_KEYS_SQL,
24
+ SNAPSHOT_TABLES,
25
+ assertSnapshotMeta,
26
+ buildReplaceSql,
27
+ getSnapshotBootstrapSite,
28
+ getSnapshotSelectSql,
29
+ normalizeD1Sql,
30
+ rewriteSnapshotSiteIdentifiers,
31
+ } from "../../bin/lib/site-snapshot.js";
32
+ import { DEFAULT_TEST_SITE_ID, createTestDatabase } from "./helpers/db.js";
33
+
34
+ const CANONICAL_DIR = resolve(
35
+ import.meta.dirname,
36
+ "../../../../sites/demo-source/canonical/snapshot",
37
+ );
38
+
39
+ function readCanonicalSql(): string {
40
+ const meta = JSON.parse(
41
+ readFileSync(resolve(CANONICAL_DIR, "meta.json"), "utf8"),
42
+ );
43
+ assertSnapshotMeta(meta);
44
+
45
+ const snapshotSite = getSnapshotBootstrapSite(meta);
46
+ if (!snapshotSite) {
47
+ throw new Error("Canonical snapshot has no embedded site metadata.");
48
+ }
49
+
50
+ const rawDbSql = readFileSync(resolve(CANONICAL_DIR, "db.sql"), "utf8");
51
+ return normalizeD1Sql(
52
+ rewriteSnapshotSiteIdentifiers(
53
+ rawDbSql,
54
+ snapshotSite.id,
55
+ DEFAULT_TEST_SITE_ID,
56
+ ),
57
+ );
58
+ }
59
+
60
+ describe("canonical demo snapshot", () => {
61
+ it("replays into a database migrated to head", () => {
62
+ const { sqlite } = createTestDatabase();
63
+ const dbSql = readCanonicalSql();
64
+
65
+ expect(() => {
66
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
67
+ sqlite.exec(dbSql);
68
+ }).not.toThrow();
69
+
70
+ // Guard against a vacuous pass: an empty dump would also "not throw".
71
+ const posts = sqlite
72
+ .prepare(`SELECT COUNT(*) AS count FROM "post" WHERE "site_id" = ?`)
73
+ .get(DEFAULT_TEST_SITE_ID) as { count: number };
74
+ expect(posts.count).toBeGreaterThan(0);
75
+ });
76
+
77
+ it("restores the language setup its posts are stamped for", () => {
78
+ // The demo publishes in two languages. When the snapshot carried each
79
+ // post's `language` but not `MULTILINGUAL_ENABLED`, every rebuild left
80
+ // demo.jant.me serving no per-language views at all, and the Chinese side
81
+ // of the demo disappeared at ~00:00 UTC each night.
82
+ const { sqlite } = createTestDatabase();
83
+
84
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
85
+ sqlite.exec(readCanonicalSql());
86
+
87
+ const languages = sqlite
88
+ .prepare(
89
+ `SELECT DISTINCT "language" FROM "post" WHERE "site_id" = ? AND "language" IS NOT NULL`,
90
+ )
91
+ .all(DEFAULT_TEST_SITE_ID) as { language: string }[];
92
+ if (languages.length < 2) return;
93
+
94
+ const settings = sqlite
95
+ .prepare(
96
+ `SELECT "key", "value" FROM "site_setting" WHERE "site_id" = ? AND "key" IN ('SITE_LANGUAGE', 'MULTILINGUAL_ENABLED', 'ADDITIONAL_LANGUAGES')`,
97
+ )
98
+ .all(DEFAULT_TEST_SITE_ID) as { key: string; value: string }[];
99
+ const value = (key: string) =>
100
+ settings.find((row) => row.key === key)?.value ?? "";
101
+
102
+ expect(value("MULTILINGUAL_ENABLED")).toBe("true");
103
+
104
+ const served = new Set([
105
+ value("SITE_LANGUAGE"),
106
+ ...value("ADDITIONAL_LANGUAGES").split(",").filter(Boolean),
107
+ ]);
108
+ expect(
109
+ languages.map((row) => row.language).filter((tag) => !served.has(tag)),
110
+ ).toEqual([]);
111
+ });
112
+
113
+ it("replays twice, the way a nightly rebuild does", () => {
114
+ // `--replace` runs against a site that already holds the previous rebuild's
115
+ // rows, so the delete order matters as much as the insert order. A first
116
+ // load into an empty database would never exercise it.
117
+ const { sqlite } = createTestDatabase();
118
+ const dbSql = readCanonicalSql();
119
+
120
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
121
+ sqlite.exec(dbSql);
122
+
123
+ expect(() => {
124
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
125
+ sqlite.exec(dbSql);
126
+ }).not.toThrow();
127
+ });
128
+ });
129
+
130
+ describe("snapshot export queries", () => {
131
+ it("runs every content table's export query against head", () => {
132
+ // The registry, the SELECT statement and the schema have to agree on
133
+ // column names, not just table names. A stale `ORDER BY` column would
134
+ // otherwise only surface the next time someone exported a snapshot.
135
+ const { sqlite } = createTestDatabase();
136
+
137
+ for (const table of SNAPSHOT_TABLES) {
138
+ expect(() =>
139
+ sqlite.prepare(getSnapshotSelectSql(table, DEFAULT_TEST_SITE_ID)).all(),
140
+ ).not.toThrow();
141
+ }
142
+ });
143
+
144
+ // Snapshots written before posts were ordered parents-first put a reply
145
+ // older than its root ahead of the root. The import checks foreign keys at
146
+ // commit on SQLite and D1, so those snapshots still load.
147
+ it("loads a snapshot that lists a reply before its root", () => {
148
+ const { sqlite } = createTestDatabase();
149
+ const post = (id: string, threadId: string, replyToId: string | null) =>
150
+ `INSERT INTO "post" ("id", "site_id", "format", "status", "visibility", "reply_to_id", "thread_id", "published_at", "last_activity_at", "created_at", "updated_at", "thread_updated_at") VALUES ('${id}', '${DEFAULT_TEST_SITE_ID}', 'note', 'published', 'public', ${replyToId ? `'${replyToId}'` : "NULL"}, '${threadId}', 1, 1, 1, 1, 1);`;
151
+ const dump = [
152
+ post(
153
+ "pst_01aaaaaaaaaaaaaaaaaaaaaaab",
154
+ "pst_01aaaaaaaaaaaaaaaaaaaaaaaa",
155
+ "pst_01aaaaaaaaaaaaaaaaaaaaaaaa",
156
+ ),
157
+ post(
158
+ "pst_01aaaaaaaaaaaaaaaaaaaaaaaa",
159
+ "pst_01aaaaaaaaaaaaaaaaaaaaaaaa",
160
+ null,
161
+ ),
162
+ ].join("\n");
163
+
164
+ expect(() =>
165
+ sqlite.transaction(() => {
166
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
167
+ sqlite.exec(dump);
168
+ })(),
169
+ ).toThrow(/FOREIGN KEY/);
170
+
171
+ expect(() =>
172
+ sqlite.transaction(() => {
173
+ sqlite.exec(DEFER_FOREIGN_KEYS_SQL);
174
+ sqlite.exec(buildReplaceSql(DEFAULT_TEST_SITE_ID));
175
+ sqlite.exec(dump);
176
+ })(),
177
+ ).not.toThrow();
178
+ });
179
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Snapshot Setting Registry Guard
3
+ *
4
+ * `jant site snapshot export/import` filters `site_setting` through the
5
+ * hand-written `SNAPSHOT_SETTING_KEYS` allowlist in `bin/lib/site-snapshot.js`.
6
+ * `bin/` is plain JavaScript, outside the type system, so a new setting key
7
+ * compiles, lints and tests clean while the snapshot silently skips it.
8
+ *
9
+ * That already happened. `MULTILINGUAL_ENABLED` and `ADDITIONAL_LANGUAGES`
10
+ * arrived with multilingual content and never reached the allowlist, while the
11
+ * whole-row `post` dump kept carrying `language` and `translation_group_id`.
12
+ * Restoring a multilingual site therefore produced posts stamped per language
13
+ * on a site that served no per-language views — and the nightly demo rebuild
14
+ * reset `demo.jant.me` to a single-language site every night.
15
+ *
16
+ * This test closes the gap the same way `snapshot-tables.test.ts` does for
17
+ * tables: every DB-backed setting has to be named in one of the two
18
+ * registries, so a new key forces a decision instead of defaulting to
19
+ * "forgotten".
20
+ *
21
+ * `envOnly` keys are the one exemption, and are asserted to be in neither
22
+ * list: they resolve from the environment and never reach `site_setting`, so
23
+ * there is no row for an export to read or for `--replace` to clear.
24
+ */
25
+
26
+ import { describe, expect, it } from "vitest";
27
+ import {
28
+ SNAPSHOT_EXCLUDED_SETTING_KEYS,
29
+ SNAPSHOT_SETTING_KEYS,
30
+ } from "../../bin/lib/site-snapshot.js";
31
+ import { CONFIG_FIELDS } from "../types/config.js";
32
+
33
+ type ConfigKey = keyof typeof CONFIG_FIELDS;
34
+
35
+ const ALL_KEYS = Object.keys(CONFIG_FIELDS) as ConfigKey[];
36
+
37
+ /** Keys that can hold a `site_setting` row — everything not env-resolved. */
38
+ const DB_BACKED_KEYS = ALL_KEYS.filter(
39
+ (key) => !("envOnly" in CONFIG_FIELDS[key] && CONFIG_FIELDS[key].envOnly),
40
+ );
41
+
42
+ /** Keys resolved from the environment, which never reach `site_setting`. */
43
+ const ENV_ONLY_KEYS = ALL_KEYS.filter((key) => !DB_BACKED_KEYS.includes(key));
44
+
45
+ describe("snapshot setting registry", () => {
46
+ it("names every DB-backed setting in exactly one registry", () => {
47
+ const carried = new Set<string>(SNAPSHOT_SETTING_KEYS);
48
+ const excluded = new Set<string>(SNAPSHOT_EXCLUDED_SETTING_KEYS);
49
+
50
+ const unregistered = DB_BACKED_KEYS.filter(
51
+ (key) => !carried.has(key) && !excluded.has(key),
52
+ );
53
+ const registeredTwice = DB_BACKED_KEYS.filter(
54
+ (key) => carried.has(key) && excluded.has(key),
55
+ );
56
+
57
+ expect(
58
+ { unregistered, registeredTwice },
59
+ "Add each new DB-backed setting to SNAPSHOT_SETTING_KEYS (it belongs to " +
60
+ "the site's published content) or to SNAPSHOT_EXCLUDED_SETTING_KEYS " +
61
+ "(with the reason), in bin/lib/site-snapshot.js.",
62
+ ).toEqual({ unregistered: [], registeredTwice: [] });
63
+ });
64
+
65
+ it("leaves env-only settings out of both registries", () => {
66
+ const listed = ENV_ONLY_KEYS.filter(
67
+ (key) =>
68
+ SNAPSHOT_SETTING_KEYS.includes(key) ||
69
+ SNAPSHOT_EXCLUDED_SETTING_KEYS.includes(key),
70
+ );
71
+
72
+ expect(listed).toEqual([]);
73
+ });
74
+
75
+ it("registers no key that CONFIG_FIELDS does not define", () => {
76
+ const known = new Set<string>(ALL_KEYS);
77
+ const unknown = [
78
+ ...SNAPSHOT_SETTING_KEYS,
79
+ ...SNAPSHOT_EXCLUDED_SETTING_KEYS,
80
+ ].filter((key) => !known.has(key));
81
+
82
+ expect(unknown).toEqual([]);
83
+ });
84
+
85
+ it("carries the whole language trio, so restored posts keep their views", () => {
86
+ // `post.language` and `post.translation_group_id` ride along in the
87
+ // whole-row `post` dump. A snapshot that carried those but not the switch
88
+ // restored a site whose per-language views had silently disappeared.
89
+ expect(SNAPSHOT_SETTING_KEYS).toEqual(
90
+ expect.arrayContaining([
91
+ "SITE_LANGUAGE",
92
+ "MULTILINGUAL_ENABLED",
93
+ "ADDITIONAL_LANGUAGES",
94
+ ]),
95
+ );
96
+ });
97
+ });