@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,219 @@
1
+ /**
2
+ * Snapshot Table Registry Guard
3
+ *
4
+ * `jant site snapshot export/import` walks two hand-written arrays in
5
+ * `bin/lib/site-snapshot.js`. Nothing connects them to `db/schema.ts`: `bin/`
6
+ * is plain JavaScript, outside the type system, so a new site-scoped table
7
+ * compiles, lints and tests clean while the snapshot silently skips it.
8
+ *
9
+ * That already happened. `smart_collection` arrived with migration 0033, and
10
+ * the commit that added it updated the schema, both dialects, five services, a
11
+ * route and three test files — but not these arrays. The damage was silent in
12
+ * both directions: export dropped smart collections, and `--replace` could not
13
+ * delete the ones a visitor created, so they outlived every nightly demo
14
+ * rebuild as rows with no slug and no nav entry.
15
+ *
16
+ * These tests close the gap. Every site-scoped table has to be named in one of
17
+ * the two registries, so a new table forces a decision instead of defaulting to
18
+ * "forgotten". The order assertions pin the arrays' other implicit contract:
19
+ * `SNAPSHOT_TABLES` is an INSERT order and `SNAPSHOT_CLEAR_TABLES` is a DELETE
20
+ * order, and both have to respect foreign keys — which are enforced, on D1 and
21
+ * on Node alike (`bin/lib/node-sqlite.js` sets `foreign_keys = ON`).
22
+ *
23
+ * Reading `db/schema.ts` alone covers both dialects: the registries are shared
24
+ * (only the dumped SQL is dialect-specific), and `db/pg/schema.ts` is required
25
+ * to declare the same tables.
26
+ */
27
+
28
+ import { is } from "drizzle-orm";
29
+ import { SQLiteTable, getTableConfig } from "drizzle-orm/sqlite-core";
30
+ import { describe, expect, it } from "vitest";
31
+ import {
32
+ SNAPSHOT_CLEAR_TABLES,
33
+ SNAPSHOT_EXCLUDED_TABLES,
34
+ SNAPSHOT_TABLES,
35
+ getSnapshotSelectSql,
36
+ } from "../../bin/lib/site-snapshot.js";
37
+ import {
38
+ TABLE_EXPORT_ORDER,
39
+ sortExportTables,
40
+ } from "../../bin/lib/sql-export.js";
41
+ import * as schema from "../db/schema.js";
42
+
43
+ /** `site_setting` is cleared by key, not wholesale — see `buildReplaceSql`. */
44
+ const CLEARED_BY_KEY = "site_setting";
45
+
46
+ type SchemaTable = {
47
+ /** Physical table name, as it appears in SQL. */
48
+ name: string;
49
+ /** Tables this one points at with a foreign key, excluding self-references. */
50
+ dependsOn: string[];
51
+ };
52
+
53
+ /**
54
+ * Reads every site-scoped table out of the Drizzle schema with its foreign-key
55
+ * dependencies. "Site-scoped" means the table carries a `site_id` column, which
56
+ * is what makes a row belong to one site's content rather than the instance.
57
+ *
58
+ * @returns One entry per site-scoped table, in declaration order.
59
+ */
60
+ function readSiteScopedTables(): SchemaTable[] {
61
+ const tables: SchemaTable[] = [];
62
+
63
+ for (const exported of Object.values(schema)) {
64
+ if (!is(exported, SQLiteTable)) continue;
65
+ const config = getTableConfig(exported);
66
+ if (!config.columns.some((column) => column.name === "site_id")) continue;
67
+
68
+ const dependsOn = [
69
+ ...new Set(
70
+ config.foreignKeys.map(
71
+ (foreignKey) =>
72
+ getTableConfig(foreignKey.reference().foreignTable).name,
73
+ ),
74
+ ),
75
+ ].filter((target) => target !== config.name);
76
+
77
+ tables.push({ name: config.name, dependsOn });
78
+ }
79
+
80
+ return tables;
81
+ }
82
+
83
+ const siteScopedTables = readSiteScopedTables();
84
+ const siteScopedNames = siteScopedTables.map((table) => table.name);
85
+
86
+ describe("snapshot table registry", () => {
87
+ it("covers every site-scoped table as content or an explicit exclusion", () => {
88
+ const registered = new Set<string>([
89
+ ...SNAPSHOT_TABLES,
90
+ ...SNAPSHOT_EXCLUDED_TABLES,
91
+ ]);
92
+ const unregistered = siteScopedNames.filter(
93
+ (name) => !registered.has(name),
94
+ );
95
+
96
+ expect(
97
+ unregistered,
98
+ [
99
+ "These site-scoped tables are in db/schema.ts but in neither snapshot registry.",
100
+ "A table the snapshot does not know about is dropped on export and survives",
101
+ "`--replace` on import. Add it to SNAPSHOT_TABLES if it is site content, or to",
102
+ "SNAPSHOT_EXCLUDED_TABLES with the reason if it is shell, credential or",
103
+ "transient state, in bin/lib/site-snapshot.js.",
104
+ ].join("\n"),
105
+ ).toEqual([]);
106
+ });
107
+
108
+ it("does not register a table as both content and excluded", () => {
109
+ const excluded = new Set<string>(SNAPSHOT_EXCLUDED_TABLES);
110
+ const both = SNAPSHOT_TABLES.filter((name) => excluded.has(name));
111
+
112
+ expect(both).toEqual([]);
113
+ });
114
+
115
+ it("can build an export query for every content table", () => {
116
+ // `getSnapshotSelectSql` has no default branch: a table listed as content
117
+ // without a matching statement throws "Unsupported snapshot table" at
118
+ // export time, which is the same silent-until-used failure the registry
119
+ // itself had.
120
+ for (const table of SNAPSHOT_TABLES) {
121
+ expect(() =>
122
+ getSnapshotSelectSql(table, "sit_00000000000000000000000000"),
123
+ ).not.toThrow();
124
+ }
125
+ });
126
+
127
+ it("does not keep registry entries for tables the schema dropped", () => {
128
+ const known = new Set(siteScopedNames);
129
+ const stale = [...SNAPSHOT_TABLES, ...SNAPSHOT_EXCLUDED_TABLES].filter(
130
+ (name) => !known.has(name),
131
+ );
132
+
133
+ expect(stale).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("snapshot table ordering", () => {
138
+ it("registers every content table for export ordering", () => {
139
+ // A table missing here is not merely unordered: `sortExportTables` places
140
+ // unknown names after every known one, so a new child table is dumped
141
+ // behind its own parents and the import fails the first time a row exists
142
+ // to violate the constraint.
143
+ const ordered = new Set<string>(TABLE_EXPORT_ORDER);
144
+ expect(SNAPSHOT_TABLES.filter((name) => !ordered.has(name))).toEqual([]);
145
+ });
146
+
147
+ it("inserts a table only after every table it references", () => {
148
+ // The order an export actually follows. `SNAPSHOT_TABLES` is re-sorted by
149
+ // `TABLE_EXPORT_ORDER` on the way out, so asserting on the raw list would
150
+ // check something the dump ignores.
151
+ const position = new Map(
152
+ sortExportTables(SNAPSHOT_TABLES).map((name, index) => [name, index]),
153
+ );
154
+ const violations: string[] = [];
155
+
156
+ for (const table of siteScopedTables) {
157
+ const self = position.get(table.name);
158
+ if (self === undefined) continue;
159
+
160
+ for (const dependency of table.dependsOn) {
161
+ const parent = position.get(dependency);
162
+ if (parent === undefined) continue;
163
+ if (parent > self) {
164
+ violations.push(
165
+ `${table.name} (${self}) references ${dependency} (${parent})`,
166
+ );
167
+ }
168
+ }
169
+ }
170
+
171
+ expect(
172
+ violations,
173
+ [
174
+ "TABLE_EXPORT_ORDER in sql-export.js is the order rows are dumped, and so the",
175
+ "order they are inserted on import. A table has to come after everything it",
176
+ "points at with a foreign key, or the child row lands before its parent exists",
177
+ "and the insert fails the constraint.",
178
+ ].join("\n"),
179
+ ).toEqual([]);
180
+ });
181
+
182
+ it("deletes a table before every table it references", () => {
183
+ const position = new Map(
184
+ SNAPSHOT_CLEAR_TABLES.map((name, index) => [name, index]),
185
+ );
186
+ const violations: string[] = [];
187
+
188
+ for (const table of siteScopedTables) {
189
+ const self = position.get(table.name);
190
+ if (self === undefined) continue;
191
+
192
+ for (const dependency of table.dependsOn) {
193
+ const parent = position.get(dependency);
194
+ if (parent === undefined) continue;
195
+ if (parent < self) {
196
+ violations.push(
197
+ `${table.name} (${self}) references ${dependency} (${parent})`,
198
+ );
199
+ }
200
+ }
201
+ }
202
+
203
+ expect(
204
+ violations,
205
+ [
206
+ "SNAPSHOT_CLEAR_TABLES is the order rows are deleted by `--replace`, so a table",
207
+ "has to come before everything it points at. Deleting the parent first only",
208
+ "appears to work because the cascade takes the children with it — which is",
209
+ "implicit, and stops being true the moment a reference is not ON DELETE CASCADE.",
210
+ ].join("\n"),
211
+ ).toEqual([]);
212
+ });
213
+
214
+ it("clears every content table except the one cleared by key", () => {
215
+ expect([...SNAPSHOT_CLEAR_TABLES].sort()).toEqual(
216
+ SNAPSHOT_TABLES.filter((name) => name !== CLEARED_BY_KEY).sort(),
217
+ );
218
+ });
219
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The SQL dump behind `db export` and `site snapshot export`.
3
+ *
4
+ * On D1 every query goes through `wrangler d1 execute --json`, whose output
5
+ * the CLI buffers. One `SELECT *` of a real site's `post` table overflowed
6
+ * that buffer, so the dump reads D1 a page at a time.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import Database from "better-sqlite3";
11
+ import { dumpDatabaseToSql } from "../../bin/lib/sql-export.js";
12
+ import { orderSnapshotPostRows } from "../../bin/lib/site-snapshot.js";
13
+ import { describeWranglerFailure } from "../../bin/lib/d1-query.js";
14
+ import { WRANGLER_MAX_BUFFER } from "../../bin/lib/wrangler-cli.js";
15
+
16
+ function createQueryRunner() {
17
+ const db = new Database(":memory:");
18
+ db.exec(`
19
+ CREATE TABLE "post" ("id" TEXT PRIMARY KEY, "site_id" TEXT, "body" TEXT);
20
+ `);
21
+ const insert = db.prepare(
22
+ `INSERT INTO "post" ("id", "site_id", "body") VALUES (?, ?, ?)`,
23
+ );
24
+ for (let index = 0; index < 7; index += 1) {
25
+ insert.run(`pst_${index}`, "site", `body ${index}`);
26
+ }
27
+
28
+ const statements: string[] = [];
29
+ return {
30
+ statements,
31
+ runner: {
32
+ async query(sql: string) {
33
+ statements.push(sql);
34
+ return db.prepare(sql).all() as Record<string, unknown>[];
35
+ },
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("dumpDatabaseToSql", () => {
41
+ it("reads a table in pages when given a page size", async () => {
42
+ const { runner, statements } = createQueryRunner();
43
+
44
+ const sql = await dumpDatabaseToSql(runner, {
45
+ source: "local",
46
+ dialect: "sqlite",
47
+ tables: ["post"],
48
+ pageSize: 3,
49
+ });
50
+
51
+ const reads = statements.filter((statement) =>
52
+ statement.includes('FROM "post"'),
53
+ );
54
+ expect(reads).toEqual([
55
+ 'SELECT * FROM "post" ORDER BY rowid LIMIT 3 OFFSET 0',
56
+ 'SELECT * FROM "post" ORDER BY rowid LIMIT 3 OFFSET 3',
57
+ 'SELECT * FROM "post" ORDER BY rowid LIMIT 3 OFFSET 6',
58
+ ]);
59
+ for (let index = 0; index < 7; index += 1) {
60
+ expect(sql).toContain(`'pst_${index}'`);
61
+ }
62
+ });
63
+
64
+ it("pages a table's own ordered select", async () => {
65
+ const { runner, statements } = createQueryRunner();
66
+
67
+ const sql = await dumpDatabaseToSql(runner, {
68
+ source: "local",
69
+ dialect: "sqlite",
70
+ tables: ["post"],
71
+ pageSize: 5,
72
+ selectSqlByTable: {
73
+ post: `SELECT * FROM "post" WHERE "site_id" = 'site' ORDER BY "id"\n`,
74
+ },
75
+ });
76
+
77
+ expect(
78
+ statements.filter((statement) => statement.includes('FROM "post"')),
79
+ ).toEqual([
80
+ `SELECT * FROM "post" WHERE "site_id" = 'site' ORDER BY "id" LIMIT 5 OFFSET 0`,
81
+ `SELECT * FROM "post" WHERE "site_id" = 'site' ORDER BY "id" LIMIT 5 OFFSET 5`,
82
+ ]);
83
+ expect(sql.match(/INSERT INTO "post"/g)).toHaveLength(7);
84
+ });
85
+
86
+ it("reads a table in one query without a page size", async () => {
87
+ const { runner, statements } = createQueryRunner();
88
+
89
+ await dumpDatabaseToSql(runner, {
90
+ source: "node",
91
+ dialect: "sqlite",
92
+ tables: ["post"],
93
+ });
94
+
95
+ expect(
96
+ statements.filter((statement) => statement.includes('FROM "post"')),
97
+ ).toEqual(['SELECT * FROM "post"']);
98
+ });
99
+ });
100
+
101
+ describe("describeWranglerFailure", () => {
102
+ it("names the buffer overflow instead of echoing the rows", () => {
103
+ const error = Object.assign(new Error("spawnSync node ENOBUFS"), {
104
+ code: "ENOBUFS",
105
+ stdout: '[{"results":[' + '{"id":"pst_x","body":"…"},'.repeat(50_000),
106
+ stderr: "",
107
+ });
108
+
109
+ const message = describeWranglerFailure(
110
+ ["d1", "execute", "DB", "--local", "--json"],
111
+ error,
112
+ );
113
+
114
+ expect(message).toBe(
115
+ `\`wrangler d1 execute DB\` printed more than ${WRANGLER_MAX_BUFFER} bytes, the most the CLI buffers.`,
116
+ );
117
+ });
118
+
119
+ it("keeps a long failure's stdout to an excerpt", () => {
120
+ const message = describeWranglerFailure(["d1", "execute", "DB"], {
121
+ name: "Error",
122
+ message: "Command failed",
123
+ stdout: "x".repeat(10_000),
124
+ stderr: "",
125
+ } as Error & { stdout: string; stderr: string });
126
+
127
+ expect(message.length).toBeLessThan(2_200);
128
+ expect(message).toContain("(10000 characters)");
129
+ });
130
+ });
131
+
132
+ describe("snapshot post order", () => {
133
+ it("puts every post after its root and the post it replies to", () => {
134
+ const rows = [
135
+ { id: "reply-2", thread_id: "root", reply_to_id: "reply-1" },
136
+ { id: "reply-1", thread_id: "root", reply_to_id: "root" },
137
+ { id: "other", thread_id: "other", reply_to_id: null },
138
+ { id: "root", thread_id: "root", reply_to_id: null },
139
+ ];
140
+
141
+ expect(orderSnapshotPostRows(rows).map((row) => row.id)).toEqual([
142
+ "root",
143
+ "reply-1",
144
+ "reply-2",
145
+ "other",
146
+ ]);
147
+ });
148
+
149
+ it("keeps the dump order when parents already come first", () => {
150
+ const rows = [
151
+ { id: "a", thread_id: "a", reply_to_id: null },
152
+ { id: "b", thread_id: "a", reply_to_id: "a" },
153
+ { id: "c", thread_id: "c", reply_to_id: null },
154
+ ];
155
+
156
+ expect(orderSnapshotPostRows(rows)).toEqual(rows);
157
+ });
158
+
159
+ it("applies a table's row order to the dump", async () => {
160
+ const { runner } = createQueryRunner();
161
+
162
+ const sql = await dumpDatabaseToSql(runner, {
163
+ source: "node",
164
+ dialect: "sqlite",
165
+ tables: ["post"],
166
+ orderRowsByTable: {
167
+ post: (rows: Record<string, unknown>[]) => [...rows].reverse(),
168
+ },
169
+ });
170
+
171
+ expect(sql.indexOf("'pst_6'")).toBeLessThan(sql.indexOf("'pst_0'"));
172
+ });
173
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Streaming ZIP helpers used by `site export`, `site import`,
3
+ * `site pull-media`, and `site snapshot`.
4
+ */
5
+
6
+ import { createWriteStream } from "node:fs";
7
+ import {
8
+ chmod,
9
+ mkdir,
10
+ mkdtemp,
11
+ readFile,
12
+ rm,
13
+ stat,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { pipeline } from "node:stream/promises";
19
+ import { zipSync } from "fflate";
20
+ import yazl from "yazl";
21
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
22
+ import {
23
+ extractZipFile,
24
+ writeDirectoryToZip,
25
+ } from "../../bin/lib/zip-archive.js";
26
+
27
+ describe("zip-archive", () => {
28
+ let tempDir: string;
29
+
30
+ beforeEach(async () => {
31
+ tempDir = await mkdtemp(join(tmpdir(), "jant-zip-"));
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await chmod(join(tempDir, "source", "locked.md"), 0o644).catch(() => {});
36
+ await rm(tempDir, { recursive: true, force: true });
37
+ });
38
+
39
+ it("writes a directory and reads it back byte for byte", async () => {
40
+ const source = join(tempDir, "source");
41
+ const image = new Uint8Array(4096).map((_, index) => index % 251);
42
+ await mkdir(join(source, "content", "hello"), { recursive: true });
43
+ await mkdir(join(source, "static", "media"), { recursive: true });
44
+ await writeFile(
45
+ join(source, "hugo.toml"),
46
+ 'baseURL = "https://a.example/"',
47
+ );
48
+ await writeFile(
49
+ join(source, "content", "hello", "_index.md"),
50
+ "---\n---\n你好",
51
+ );
52
+ await writeFile(join(source, "static", "media", "a.jpg"), image);
53
+
54
+ const zipPath = join(tempDir, "out", "site.zip");
55
+ expect(await writeDirectoryToZip(source, zipPath)).toEqual({ files: 3 });
56
+
57
+ const target = join(tempDir, "target");
58
+ expect(await extractZipFile(zipPath, target)).toEqual({ files: 3 });
59
+ expect(
60
+ await readFile(join(target, "content", "hello", "_index.md"), "utf8"),
61
+ ).toBe("---\n---\n你好");
62
+ expect(
63
+ new Uint8Array(await readFile(join(target, "static", "media", "a.jpg"))),
64
+ ).toEqual(image);
65
+ });
66
+
67
+ it("reads a ZIP64 archive", async () => {
68
+ const zipFile = new yazl.ZipFile();
69
+ zipFile.addBuffer(Buffer.from("big enough to need ZIP64"), "data/a.txt");
70
+ zipFile.end({ forceZip64Format: true });
71
+ const zipPath = join(tempDir, "zip64.zip");
72
+ await pipeline(zipFile.outputStream, createWriteStream(zipPath));
73
+
74
+ await extractZipFile(zipPath, join(tempDir, "target"));
75
+
76
+ expect(
77
+ await readFile(join(tempDir, "target", "data", "a.txt"), "utf8"),
78
+ ).toBe("big enough to need ZIP64");
79
+ });
80
+
81
+ it("refuses an entry that climbs out of the target directory", async () => {
82
+ const zipPath = join(tempDir, "escape.zip");
83
+ await writeFile(
84
+ zipPath,
85
+ zipSync({ "../escaped.txt": new TextEncoder().encode("x") }),
86
+ );
87
+
88
+ await expect(
89
+ extractZipFile(zipPath, join(tempDir, "target")),
90
+ ).rejects.toThrow();
91
+ await expect(stat(join(tempDir, "escaped.txt"))).rejects.toThrow();
92
+ });
93
+
94
+ it("leaves no archive behind when writing fails", async () => {
95
+ if (process.getuid?.() === 0) return; // root reads a mode-000 file anyway
96
+ const source = join(tempDir, "source");
97
+ await mkdir(source, { recursive: true });
98
+ await writeFile(join(source, "locked.md"), "secret");
99
+ await chmod(join(source, "locked.md"), 0o000);
100
+
101
+ const zipPath = join(tempDir, "site.zip");
102
+ await expect(writeDirectoryToZip(source, zipPath)).rejects.toThrow();
103
+ await expect(stat(zipPath)).rejects.toThrow();
104
+ await expect(stat(`${zipPath}.partial`)).rejects.toThrow();
105
+ });
106
+ });
package/src/app.tsx CHANGED
@@ -93,7 +93,7 @@ import {
93
93
  } from "./lib/jant-branding.js";
94
94
  import { isAssetPath } from "./lib/asset-path.js";
95
95
  import { getHostedCanonicalRedirect } from "./lib/hosted-domain.js";
96
- import { stripSitePathPrefix, toPublicHref } from "./lib/url.js";
96
+ import { normalizePath, stripSitePathPrefix, toPublicHref } from "./lib/url.js";
97
97
  import {
98
98
  matchesIfNoneMatch,
99
99
  withConditionalResponse,
@@ -533,7 +533,13 @@ export function createApp(): App {
533
533
  await next();
534
534
  });
535
535
 
536
- // Redirect middleware — only handles redirect-type custom URLs
536
+ // Redirect middleware — only handles redirect-type custom URLs.
537
+ //
538
+ // The lookup it needs is the same `path_registry` read the catch-all route
539
+ // makes to resolve a post or collection, so the record is stashed on the
540
+ // context and that route reuses it instead of asking again. Nothing between
541
+ // here and the route writes to the registry, so the record cannot go stale
542
+ // within the request.
537
543
  app.use("*", async (c, next) => {
538
544
  const path = new URL(c.req.url).pathname;
539
545
  // Skip redirect lookup for fixed machine routes and static assets.
@@ -541,17 +547,20 @@ export function createApp(): App {
541
547
  return next();
542
548
  }
543
549
 
544
- const customUrl = await c.var.services.customUrls.getByPath(path.slice(1));
545
- if (customUrl?.targetType === "redirect" && customUrl.toPath) {
550
+ const storedPath = normalizePath(path);
551
+ const record = await c.var.services.paths.resolve(storedPath);
552
+ c.set("pathLookup", { path: storedPath, record });
553
+
554
+ if (record?.kind === "redirect" && record.redirectToPath) {
546
555
  return c.redirect(
547
556
  toPublicHref(
548
- customUrl.toPath,
557
+ `/${record.redirectToPath}`,
549
558
  getRuntimeSitePathPrefix({
550
559
  env: c.env,
551
560
  currentSiteDomain: c.var.currentSiteDomain,
552
561
  }),
553
562
  ),
554
- customUrl.redirectType ?? 301,
563
+ record.redirectType ?? 301,
555
564
  );
556
565
  }
557
566
 
@@ -74,7 +74,7 @@ const labels: SettingsLabels = {
74
74
  discoverEnabled: "Allow Jant Discover to list my site",
75
75
  discoverIntro:
76
76
  "Jant Discover is a directory of Jant blogs, curated by hand by the Jant community to help people find new Jant blogs and posts. A post you mark Featured appears on the Discover home page 24 hours later, and link and quote posts appear on the Links and Quotes lists 24 hours after they are published. You can keep editing them in the meantime. See the Discover community rules.",
77
- discoverDemoLocked: "Demo sites are never listed in Discover.",
77
+ discoverDemoLocked: "Demo sites are never listed in Jant Discover.",
78
78
  discoverFeedsOffLocked:
79
79
  "Discover reads your Atom feed, so it needs feeds turned on.",
80
80
  discoverSearchOff:
@@ -123,7 +123,7 @@ const labels: SettingsLabels = {
123
123
  discoverEnabled: "Allow Jant Discover to list my site",
124
124
  discoverIntro:
125
125
  "Jant Discover is a directory of Jant blogs, curated by hand by the Jant community to help people find new Jant blogs and posts. A post you mark Featured appears on the Discover home page 24 hours later, and link and quote posts appear on the Links and Quotes lists 24 hours after they are published. You can keep editing them in the meantime. See the Discover community rules.",
126
- discoverDemoLocked: "Demo sites are never listed in Discover.",
126
+ discoverDemoLocked: "Demo sites are never listed in Jant Discover.",
127
127
  discoverFeedsOffLocked:
128
128
  "Discover reads your Atom feed, so it needs feeds turned on.",
129
129
  discoverSearchOff:
@@ -856,6 +856,26 @@ describe("JantSettingsGeneral", () => {
856
856
  expect(el.textContent).toContain(labels.discoverDemoLocked);
857
857
  });
858
858
 
859
+ // The notice replaces the help line, and with it the only link to the
860
+ // directory, so it carries that link itself.
861
+ it("links the directory from the demo notice", async () => {
862
+ const el = await createElement({ demoMode: true });
863
+ const notice = requireElement(
864
+ Array.from(el.querySelectorAll<HTMLElement>("p")).find(
865
+ (paragraph) =>
866
+ paragraph.textContent?.trim() === labels.discoverDemoLocked,
867
+ ) ?? null,
868
+ "expected the demo notice",
869
+ );
870
+ const links = Array.from(notice.querySelectorAll<HTMLAnchorElement>("a"));
871
+
872
+ expect(
873
+ links.map((link) => [link.textContent, link.getAttribute("href")]),
874
+ ).toEqual([[labels.discoverName, discoverPages.home]]);
875
+ expect(links[0]?.getAttribute("target")).toBe("_blank");
876
+ expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer");
877
+ });
878
+
859
879
  // Discover reads the Atom feed, so with feeds off there is nothing to read.
860
880
  it("locks the control off when the site publishes no feeds", async () => {
861
881
  const el = await createElement({ feedsEnabled: false });
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Types shared between the server-side template and the
3
3
  * `jant-repo-picker` Lit component.
4
+ *
5
+ * Labels arrive translated and already formatted. A `{name}` left in one is
6
+ * deliberate: the value only exists in the browser, so the server keeps the
7
+ * token literal (see `keepForClient` in the settings route) and the component
8
+ * replaces it. Anything the server already knows is interpolated there.
4
9
  */
5
10
 
6
11
  export interface RepoPickerLabels {
@@ -20,7 +25,7 @@ export interface RepoPickerLabels {
20
25
  repoSearchHint: string;
21
26
  refreshRepos: string; // aria-label + tooltip for the refresh button
22
27
  createOnGitHub: string; // primary call-to-action text
23
- createOnGitHubHint: string; // "We'll prefill the name {name} for you"
28
+ createOnGitHubHint: string; // repo name already interpolated
24
29
  classifyLoading: string;
25
30
  classificationEmpty: string;
26
31
  classificationOwned: string;
@@ -557,7 +557,7 @@ export class JantRepoPicker extends LitElement {
557
557
  <li>
558
558
  <button
559
559
  type="button"
560
- class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
560
+ class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
561
561
  @click=${() => this.#selectOwner(inst)}
562
562
  role="option"
563
563
  aria-selected=${
@@ -692,7 +692,7 @@ export class JantRepoPicker extends LitElement {
692
692
  <li>
693
693
  <button
694
694
  type="button"
695
- class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
695
+ class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm hover:bg-muted"
696
696
  @click=${() => this.#selectRepo(r)}
697
697
  role="option"
698
698
  aria-selected=${
@@ -717,7 +717,7 @@ export class JantRepoPicker extends LitElement {
717
717
  <div class="border-t p-2">
718
718
  <button
719
719
  type="button"
720
- class="flex w-full items-start gap-2 px-2 py-2 text-left text-sm text-primary hover:bg-muted rounded-md"
720
+ class="flex w-full cursor-pointer items-start gap-2 rounded-md px-2 py-2 text-left text-sm text-primary hover:bg-muted"
721
721
  @click=${() => this.#openCreateOnGitHub()}
722
722
  >
723
723
  <span class="flex flex-col">
@@ -725,10 +725,7 @@ export class JantRepoPicker extends LitElement {
725
725
  ${
726
726
  this.createRepoNameHint
727
727
  ? html`<span class="text-xs text-muted-foreground">
728
- ${this.labels.createOnGitHubHint.replace(
729
- "{name}",
730
- this.createRepoNameHint,
731
- )}
728
+ ${this.labels.createOnGitHubHint}
732
729
  </span>`
733
730
  : nothing
734
731
  }