@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
@@ -272,7 +272,7 @@ async function buildCuratedThreadItems(
272
272
 
273
273
  return orderedThreads.reduce<TimelineItemView[]>((items, thread) => {
274
274
  const rootEntry = thread.posts[0];
275
- if (!rootEntry || rootEntry.position !== 0) {
275
+ if (!rootEntry || rootEntry.post.id !== rootEntry.post.threadId) {
276
276
  return items;
277
277
  }
278
278
 
@@ -11,8 +11,14 @@ import { serializeMarkdownDocument } from "./markdown-manager.js";
11
11
  /**
12
12
  * Converts a Tiptap JSON document to a Markdown string.
13
13
  *
14
- * @param json - Tiptap JSON string or parsed document object
14
+ * Throws rather than returning an empty string: a caller writing the result
15
+ * somewhere (an export, a text attachment) would otherwise replace the
16
+ * content with nothing and say nothing.
17
+ *
18
+ * @param json - Tiptap JSON document string
15
19
  * @returns Markdown string
20
+ * @throws {SyntaxError} When `json` is not JSON
21
+ * @throws {Error} When the root node is not a `doc`
16
22
  *
17
23
  * @example
18
24
  * ```ts
@@ -21,11 +27,11 @@ import { serializeMarkdownDocument } from "./markdown-manager.js";
21
27
  * ```
22
28
  */
23
29
  export function tiptapJsonToMarkdown(json: string): string {
24
- try {
25
- const doc = JSON.parse(json) as JSONContent;
26
- if (doc.type !== "doc") return "";
27
- return serializeMarkdownDocument(doc).trimEnd();
28
- } catch {
29
- return "";
30
+ const doc = JSON.parse(json) as JSONContent;
31
+ if (doc.type !== "doc") {
32
+ throw new Error(
33
+ `A TipTap body's root must be a doc node, not ${JSON.stringify(doc.type)}.`,
34
+ );
30
35
  }
36
+ return serializeMarkdownDocument(doc).trimEnd();
31
37
  }
package/src/lib/url.ts CHANGED
@@ -486,6 +486,26 @@ export function toPublicPath(path: string, sitePathPrefix = ""): string {
486
486
  return `${sitePathPrefix}${normalizedPath}`;
487
487
  }
488
488
 
489
+ /**
490
+ * The internal path a Post is served at: its oldest custom path when it has
491
+ * one, else its slug.
492
+ *
493
+ * This is the Post's permalink before the site path prefix, and so the `<id>`
494
+ * of its Atom entry. The Hugo export writes that `<id>` into front matter so
495
+ * an exported feed keeps it, which is why the rule lives here rather than
496
+ * inside `toPostView`.
497
+ *
498
+ * @param slug - The Post's current slug
499
+ * @param aliasPath - Its oldest custom path, `paths.getPostAliases()`'s first
500
+ * @returns Internal path rooted at `/`
501
+ * @example
502
+ * getPostPath("xta29"); // "/xta29"
503
+ * getPostPath("links-4", "/blog/links/4"); // "/blog/links/4"
504
+ */
505
+ export function getPostPath(slug: string, aliasPath?: string): string {
506
+ return aliasPath ?? `/${slug}`;
507
+ }
508
+
489
509
  /**
490
510
  * Convert an app-local href to its public path while leaving external URLs
491
511
  * unchanged.
package/src/lib/view.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  } from "./summary.js";
48
48
  import { renderTiptapDocumentAroundBoundary } from "./tiptap-render.js";
49
49
  import { highlightText } from "./search-snippet.js";
50
- import { isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
50
+ import { getPostPath, isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
51
51
 
52
52
  // =============================================================================
53
53
  // Media Context
@@ -260,7 +260,7 @@ export function toPostView(
260
260
  ): PostView {
261
261
  const id = post.id;
262
262
  const permalink = toPublicPath(
263
- aliasPath ?? `/${post.slug}`,
263
+ getPostPath(post.slug, aliasPath),
264
264
  ctx.sitePathPrefix,
265
265
  );
266
266
  const timeZone = ctx.timeZone ?? "UTC";
@@ -0,0 +1,163 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import Database from "better-sqlite3";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { run } from "../../../bin/commands/setup.js";
8
+ import { verifyPassword } from "../../lib/password.js";
9
+ import type { Bindings } from "../../types.js";
10
+ import { migrate } from "../runtime.js";
11
+
12
+ const SITE_ID = "sit_01kn8jq3t4famtyg9hjd074ckr";
13
+ const ENV_KEYS = [
14
+ "DATABASE_URL",
15
+ "DATA_DIR",
16
+ "SITE_ORIGIN",
17
+ "SITE_RESOLUTION_MODE",
18
+ ] as const;
19
+
20
+ function pipe(text: string) {
21
+ return { stdin: Readable.from([text]) };
22
+ }
23
+
24
+ describe("jant setup", () => {
25
+ const originalEnv = Object.fromEntries(
26
+ ENV_KEYS.map((key) => [key, process.env[key]]),
27
+ );
28
+ let root: string;
29
+ let databasePath: string;
30
+
31
+ beforeEach(async () => {
32
+ for (const key of ENV_KEYS) delete process.env[key];
33
+ root = await mkdtemp(join(tmpdir(), "jant-setup-"));
34
+ databasePath = join(root, "jant.sqlite");
35
+ await migrate({ DATABASE_URL: `file:${databasePath}` } as Bindings);
36
+ process.env.DATABASE_URL = `file:${databasePath}`;
37
+ vi.spyOn(console, "log").mockImplementation(() => {});
38
+ });
39
+
40
+ afterEach(async () => {
41
+ for (const [key, value] of Object.entries(originalEnv)) {
42
+ if (value === undefined) delete process.env[key];
43
+ else process.env[key] = value;
44
+ }
45
+ await rm(root, { recursive: true, force: true });
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ function readDatabase<T>(read: (sqlite: Database.Database) => T): T {
50
+ const sqlite = new Database(databasePath, { readonly: true });
51
+ try {
52
+ return read(sqlite);
53
+ } finally {
54
+ sqlite.close();
55
+ }
56
+ }
57
+
58
+ it("sets up the install with the piped password", async () => {
59
+ process.env.SITE_ORIGIN = "https://notes.example.com";
60
+
61
+ const result = await run(
62
+ [
63
+ "--email",
64
+ "Owner@Example.com",
65
+ "--password-stdin",
66
+ "--site-id",
67
+ SITE_ID,
68
+ "--site-name",
69
+ "Field Notes",
70
+ ],
71
+ // `echo` ends the password with a line break that is not part of it.
72
+ pipe("correct horse\n"),
73
+ );
74
+
75
+ expect(result).toEqual({ outcome: "created", siteId: SITE_ID });
76
+ expect(console.log).toHaveBeenCalledWith(`Set up site ${SITE_ID}.`);
77
+
78
+ const stored = readDatabase((sqlite) => ({
79
+ email: sqlite.prepare('SELECT email FROM "user"').pluck().get(),
80
+ hash: sqlite.prepare('SELECT password FROM "account"').pluck().get(),
81
+ host: sqlite.prepare('SELECT host FROM "site_domain"').pluck().get(),
82
+ status: sqlite
83
+ .prepare(
84
+ `SELECT value FROM "site_setting" WHERE "key" = 'ONBOARDING_STATUS'`,
85
+ )
86
+ .pluck()
87
+ .get(),
88
+ }));
89
+ expect(stored).toMatchObject({
90
+ email: "owner@example.com",
91
+ host: "notes.example.com",
92
+ status: "completed",
93
+ });
94
+ expect(
95
+ await verifyPassword({
96
+ hash: String(stored.hash),
97
+ password: "correct horse",
98
+ }),
99
+ ).toBe(true);
100
+ });
101
+
102
+ it("changes nothing when run again", async () => {
103
+ const args = ["--email", "owner@example.com", "--password-stdin"];
104
+ const first = await run(args, pipe("correct horse"));
105
+ const second = await run(args, pipe("a different password"));
106
+
107
+ expect(second).toEqual({
108
+ outcome: "already-set-up",
109
+ siteId: first.siteId,
110
+ });
111
+ expect(console.log).toHaveBeenLastCalledWith(
112
+ `Site ${first.siteId} was already set up. Nothing changed.`,
113
+ );
114
+ });
115
+
116
+ it("refuses a password passed any way but standard input", async () => {
117
+ await expect(
118
+ run(["--email", "owner@example.com"], pipe("correct horse")),
119
+ ).rejects.toThrow("Pass --password-stdin");
120
+ });
121
+
122
+ it("refuses to wait on a terminal for the password", async () => {
123
+ const stdin = Object.assign(Readable.from([]), { isTTY: true });
124
+
125
+ await expect(
126
+ run(["--email", "owner@example.com", "--password-stdin"], { stdin }),
127
+ ).rejects.toThrow("reads the password from a pipe");
128
+ });
129
+
130
+ it("rejects answers the setup screens would reject", async () => {
131
+ const args = ["--email", "owner@example.com", "--password-stdin"];
132
+
133
+ await expect(run(args, pipe("short"))).rejects.toThrow(
134
+ "Password must be at least 8 characters",
135
+ );
136
+ await expect(
137
+ run(
138
+ [...args, "--site-id", "pst_01kn8jq3t4famtyg9hjd074ckr"],
139
+ pipe("correct horse"),
140
+ ),
141
+ ).rejects.toThrow("Invalid ID");
142
+ await expect(
143
+ run([...args, "--time-zone", "Mars/Olympus"], pipe("correct horse")),
144
+ ).rejects.toThrow("Choose a valid time zone.");
145
+
146
+ expect(
147
+ readDatabase((sqlite) =>
148
+ sqlite.prepare('SELECT COUNT(*) FROM "user"').pluck().get(),
149
+ ),
150
+ ).toBe(0);
151
+ });
152
+
153
+ it("refuses to run without a Node database", async () => {
154
+ delete process.env.DATABASE_URL;
155
+
156
+ await expect(
157
+ run(
158
+ ["--email", "owner@example.com", "--password-stdin"],
159
+ pipe("correct horse"),
160
+ ),
161
+ ).rejects.toThrow("jant setup runs against a Node database");
162
+ });
163
+ });
@@ -780,6 +780,73 @@ describe("jant site snapshot export/import", () => {
780
780
  expect(await targetStorage.get(SNAPSHOT_MEDIA_KEY)).not.toBeNull();
781
781
  });
782
782
 
783
+ // The snapshot was exported where the bytes lived in R2; this import puts
784
+ // them in local storage. Rows left saying `r2` would look for R2_PUBLIC_URL,
785
+ // miss the avatar's existing row on replacement, and never purge their trash.
786
+ it("records imported media under the storage the import wrote it to", async () => {
787
+ const root = await mkdtemp(join(tmpdir(), "jant-site-snapshot-provider-"));
788
+ tempDirs.push(root);
789
+
790
+ const targetDbPath = join(root, "target.sqlite");
791
+ const targetStoragePath = join(root, "target-media");
792
+ const snapshotPath = join(root, "snapshot");
793
+
794
+ await migrate({ DATABASE_URL: `file:${targetDbPath}` } as Bindings);
795
+ const targetSqlite = new Database(targetDbPath);
796
+ try {
797
+ targetSqlite.exec(`
798
+ INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
799
+ VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009000, 1774009000);
800
+ `);
801
+ } finally {
802
+ targetSqlite.close();
803
+ }
804
+
805
+ await mkdir(
806
+ join(snapshotPath, "objects", "media", SNAPSHOT_SITE_ID, "files"),
807
+ {
808
+ recursive: true,
809
+ },
810
+ );
811
+ await writeFile(join(snapshotPath, "objects", SNAPSHOT_MEDIA_KEY), "png");
812
+ await writeFile(
813
+ join(snapshotPath, "meta.json"),
814
+ JSON.stringify({
815
+ format: "jant-site-snapshot",
816
+ version: 2,
817
+ dialect: "sqlite",
818
+ site: { id: SNAPSHOT_SITE_ID, key: SNAPSHOT_SITE_KEY },
819
+ }),
820
+ );
821
+ await writeFile(
822
+ join(snapshotPath, "db.sql"),
823
+ `INSERT INTO "media" ("id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key", "provider", "position", "media_kind", "created_at", "updated_at") VALUES('${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL, '${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 3, '${SNAPSHOT_MEDIA_KEY}', 'r2', 'a0', 'image', 1774009200, 1774009200);\n`,
824
+ );
825
+
826
+ useLocalSnapshotRuntime(`file:${targetDbPath}`, targetStoragePath);
827
+ vi.spyOn(console, "log").mockImplementation(() => {});
828
+ const { run: runImport } =
829
+ await import("../../../bin/commands/site/snapshot/import.js");
830
+ await runImport(["--path", snapshotPath, "--replace"]);
831
+
832
+ const verifySqlite = new Database(targetDbPath, { readonly: true });
833
+ try {
834
+ expect(
835
+ verifySqlite
836
+ .prepare(`SELECT "provider" FROM "media" WHERE "id" = ?`)
837
+ .pluck()
838
+ .get(SNAPSHOT_MEDIA_ID),
839
+ ).toBe("local");
840
+ } finally {
841
+ verifySqlite.close();
842
+ }
843
+ expect(
844
+ await createLocalDriver({ rootPath: targetStoragePath }).get(
845
+ SNAPSHOT_MEDIA_KEY,
846
+ ),
847
+ ).not.toBeNull();
848
+ });
849
+
783
850
  it("requires --replace for snapshot import", async () => {
784
851
  const root = await mkdtemp(join(tmpdir(), "jant-site-snapshot-replace-"));
785
852
  tempDirs.push(root);
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  assertSnapshotDialectMatches,
4
4
  assertSnapshotMeta,
5
+ buildMediaProviderSql,
5
6
  buildSnapshotMeta,
6
7
  getSnapshotDialect,
7
8
  SNAPSHOT_VERSION,
@@ -183,3 +184,22 @@ describe("assertSnapshotDialectMatches", () => {
183
184
  expect(() => assertSnapshotDialectMatches({}, "pg")).not.toThrow();
184
185
  });
185
186
  });
187
+
188
+ describe("buildMediaProviderSql", () => {
189
+ it("records the site's media under the given storage driver", () => {
190
+ expect(buildMediaProviderSql("sit_o'brien", "s3")).toBe(
191
+ `UPDATE "media" SET "provider" = 's3' WHERE "site_id" = 'sit_o''brien';`,
192
+ );
193
+ });
194
+
195
+ // The value is written into SQL and read back as where the bytes live, so
196
+ // anything but a real driver is refused rather than quoted.
197
+ it("refuses anything that is not a storage driver", () => {
198
+ expect(() => buildMediaProviderSql("sit_test", "gcs")).toThrow(
199
+ 'Snapshot import cannot record media under storage driver "gcs".',
200
+ );
201
+ expect(() => buildMediaProviderSql("sit_test", "s3' OR '1")).toThrow(
202
+ "cannot record media under storage driver",
203
+ );
204
+ });
205
+ });
@@ -12,6 +12,7 @@ import {
12
12
  resolveDatabasePath,
13
13
  resolvePublicRequestUrl,
14
14
  } from "../runtime.js";
15
+ import type { App } from "../../types/app-context.js";
15
16
  import type { Bindings } from "../../types.js";
16
17
 
17
18
  const tempDirs: string[] = [];
@@ -204,6 +205,43 @@ describe("resolveNodeAssetRoot", () => {
204
205
  ).rejects.toThrow("single-site mode found multiple sites in the database:");
205
206
  });
206
207
 
208
+ // A deploy check tells a container that just started from one a rollback
209
+ // left running by this stamp, so it has to be the handler's start and not a
210
+ // time taken per request.
211
+ it("stamps the bindings with when the handler started serving", async () => {
212
+ const root = await mkdtemp(join(tmpdir(), "jant-node-started-at-"));
213
+ tempDirs.push(root);
214
+ const databasePath = join(root, "data", "jant.sqlite");
215
+ await migrate({ DATABASE_URL: `file:${databasePath}` } as Bindings);
216
+
217
+ const before = Math.floor(Date.now() / 1000);
218
+ const handler = await createNodeRequestHandler({
219
+ assetRoot: null,
220
+ env: { DATABASE_URL: `file:${databasePath}` } as Bindings,
221
+ app: {
222
+ fetch: (_request: Request, env: Bindings) =>
223
+ Response.json({ startedAt: env.NODE_STARTED_AT }),
224
+ } as unknown as App,
225
+ });
226
+ const after = Math.floor(Date.now() / 1000);
227
+
228
+ try {
229
+ const read = async () =>
230
+ (
231
+ (await (
232
+ await handler.fetch(new Request("http://127.0.0.1:3000/readyz"))
233
+ ).json()) as { startedAt: number }
234
+ ).startedAt;
235
+ const first = await read();
236
+
237
+ expect(first).toBeGreaterThanOrEqual(before);
238
+ expect(first).toBeLessThanOrEqual(after);
239
+ expect(await read()).toBe(first);
240
+ } finally {
241
+ await handler.close();
242
+ }
243
+ });
244
+
207
245
  // The single-site readiness check blames SITE_RESOLUTION_MODE for extra
208
246
  // sites, which on a hosted database are just its tenants.
209
247
  it("starts in host-based mode when the database holds several sites", async () => {
package/src/node/index.ts CHANGED
@@ -6,10 +6,12 @@ export { createNodeRequestHandler } from "./request-handler.js";
6
6
  export {
7
7
  createNodeCliRuntime,
8
8
  createNodeRequestRuntime,
9
+ setUpNodeInstance,
9
10
  } from "../runtime/node.js";
10
11
  export type { CliSiteSelector } from "../runtime/site.js";
11
12
  export { createExportService } from "../services/export.js";
12
13
  export { createStorageDriver } from "../lib/storage.js";
14
+ export { getConfiguredStorageDriver } from "../lib/env.js";
13
15
  export { resolveConfig } from "../lib/resolve-config.js";
14
16
  export { buildThemeStyle } from "../lib/theme.js";
15
17
  export { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
@@ -31,6 +31,7 @@ import {
31
31
  shouldTrustProxy,
32
32
  } from "../lib/env.js";
33
33
  import { getHostBasedStartupConfigurationIssues } from "../lib/startup-config.js";
34
+ import { now } from "../lib/time.js";
34
35
  import { createSiteService } from "../services/site.js";
35
36
  import type { App } from "../types/app-context.js";
36
37
  import type { Bindings } from "../types/bindings.js";
@@ -616,7 +617,8 @@ export async function createNodeRequestHandler(options?: {
616
617
  `Host-based startup configuration is invalid:\n- ${hostBasedStartupIssues.map((issue) => issue.message).join("\n- ")}`,
617
618
  );
618
619
  }
619
- const { bindings, close } = await createNodeBindings(env);
620
+ const { bindings: databaseBindings, close } = await createNodeBindings(env);
621
+ const bindings: Bindings = { ...databaseBindings, NODE_STARTED_AT: now() };
620
622
  try {
621
623
  await assertNodeSiteResolutionReady(bindings);
622
624
 
@@ -449,6 +449,30 @@ describe("Posts API Routes", () => {
449
449
  expect(body.attachments).toEqual([]);
450
450
  });
451
451
 
452
+ it("keeps the creation and edit times a restore sends", async () => {
453
+ const { app } = createTestApp({ authenticated: true });
454
+ app.route("/api/posts", postsApiRoutes);
455
+
456
+ const res = await app.request("/api/posts", {
457
+ method: "POST",
458
+ headers: { "Content-Type": "application/json" },
459
+ body: JSON.stringify({
460
+ format: "note",
461
+ bodyMarkdown: "Moved from another site",
462
+ publishedAt: 1700000500,
463
+ createdAt: 1700000000,
464
+ updatedAt: 1700009000,
465
+ }),
466
+ });
467
+
468
+ expect(res.status).toBe(201);
469
+ expect(await res.json()).toMatchObject({
470
+ publishedAt: 1700000500,
471
+ createdAt: 1700000000,
472
+ updatedAt: 1700009000,
473
+ });
474
+ });
475
+
452
476
  it("creates a post with bodyMarkdown", async () => {
453
477
  const { app } = createTestApp({ authenticated: true });
454
478
  app.route("/api/posts", postsApiRoutes);
@@ -46,6 +46,40 @@ describe("Upload API Routes", () => {
46
46
  });
47
47
  });
48
48
 
49
+ it("pages the media list with nextCursor", async () => {
50
+ const { app, services } = createTestApp({ authenticated: true });
51
+ app.route("/api/upload", uploadApiRoutes);
52
+
53
+ for (const name of ["a", "b", "c"]) {
54
+ await services.media.create({
55
+ filename: `${name}.webp`,
56
+ originalName: `${name}.webp`,
57
+ mimeType: "image/webp",
58
+ size: 1024,
59
+ storageKey: `media/${name}.webp`,
60
+ });
61
+ }
62
+
63
+ const seen: string[] = [];
64
+ let cursor: string | null = null;
65
+ for (let page = 0; page < 4; page += 1) {
66
+ const res = await app.request(
67
+ `/api/upload?limit=2${cursor ? `&cursor=${cursor}` : ""}`,
68
+ );
69
+ expect(res.status).toBe(200);
70
+ const body = (await res.json()) as {
71
+ media: { filename: string }[];
72
+ nextCursor: string | null;
73
+ };
74
+ seen.push(...body.media.map((media) => media.filename));
75
+ cursor = body.nextCursor;
76
+ if (!cursor) break;
77
+ }
78
+
79
+ expect(seen.sort()).toEqual(["a.webp", "b.webp", "c.webp"]);
80
+ expect(cursor).toBeNull();
81
+ });
82
+
49
83
  it("returns a single media item", async () => {
50
84
  const { app, services } = createTestApp({ authenticated: true });
51
85
  app.route("/api/upload", uploadApiRoutes);
@@ -57,12 +57,12 @@ exportApiRoutes.post("/hugo", requireAuthApi(), async (c) => {
57
57
  storage: c.var.storage,
58
58
  },
59
59
  );
60
- const zip = await exportService.generateHugoSite();
61
- return new Response(zip, {
60
+ // Streamed: stored media is read as the archive goes out, so the response
61
+ // starts at once and memory stays flat whatever the site's size.
62
+ return new Response(await exportService.generateHugoSite(), {
62
63
  headers: {
63
64
  "Content-Type": "application/zip",
64
65
  "Content-Disposition": 'attachment; filename="jant-export.zip"',
65
- "Content-Length": String(zip.byteLength),
66
66
  },
67
67
  });
68
68
  });
@@ -240,7 +240,6 @@ internalSitesRoutes.get(
240
240
  return new Response(archive.zip, {
241
241
  headers: {
242
242
  "Content-Disposition": `attachment; filename="${archive.filename}"`,
243
- "Content-Length": String(archive.zip.byteLength),
244
243
  "Content-Type": "application/zip",
245
244
  },
246
245
  });
@@ -203,6 +203,8 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
203
203
  language: body.language,
204
204
  translationOfId: body.translationOfId,
205
205
  publishedAt: body.publishedAt,
206
+ createdAt: body.createdAt,
207
+ updatedAt: body.updatedAt,
206
208
  },
207
209
  body.attachments,
208
210
  {
@@ -109,6 +109,24 @@ export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
109
109
  export type PublicPostResponse =
110
110
  PublicPostRenderedResponse | PublicPostMarkdownResponse;
111
111
 
112
+ /**
113
+ * The public Markdown for a post, or null for a historical body that isn't
114
+ * TipTap JSON. One unreadable row answers null and is logged, rather than
115
+ * failing the whole listing; `bodyHtml` still falls back to stored HTML.
116
+ */
117
+ function toPublicBodyMarkdown(postId: string, body: string): string | null {
118
+ try {
119
+ return tiptapJsonToMarkdown(body);
120
+ } catch (error) {
121
+ // eslint-disable-next-line no-console -- A skipped body must leave a trace
122
+ console.error(
123
+ `Couldn't convert the body of post ${postId} to Markdown`,
124
+ error,
125
+ );
126
+ return null;
127
+ }
128
+ }
129
+
112
130
  function isPublicDetailVisible(post: Post | null): post is Post {
113
131
  return (
114
132
  post !== null &&
@@ -196,7 +214,9 @@ export function toPublicPost(
196
214
  const contentFields =
197
215
  options?.content === "markdown"
198
216
  ? {
199
- bodyMarkdown: post.body ? tiptapJsonToMarkdown(post.body) : null,
217
+ bodyMarkdown: post.body
218
+ ? toPublicBodyMarkdown(post.id, post.body)
219
+ : null,
200
220
  }
201
221
  : {
202
222
  bodyHtml: post.bodyHtml,
@@ -39,7 +39,7 @@ import {
39
39
  } from "../../lib/errors.js";
40
40
  import { getI18n } from "../../i18n/index.js";
41
41
  import { ID_PREFIX } from "../../lib/ids.js";
42
- import { parseValidated } from "../../lib/schemas.js";
42
+ import { MediaIdSchema, parseValidated } from "../../lib/schemas.js";
43
43
  import { toApiMedia } from "../../lib/api-media.js";
44
44
 
45
45
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -49,6 +49,7 @@ export const uploadApiRoutes = new Hono<Env>();
49
49
  const ListMediaQuerySchema = z.object({
50
50
  limit: z.coerce.number().int().min(1).max(200).optional().default(50),
51
51
  mimePrefix: z.string().trim().min(1).optional(),
52
+ cursor: MediaIdSchema.optional(),
52
53
  });
53
54
 
54
55
  const UpdateMediaSchema = z.object({
@@ -421,14 +422,20 @@ uploadApiRoutes.post("/", async (c) => {
421
422
 
422
423
  // List uploaded files (JSON only)
423
424
  uploadApiRoutes.get("/", async (c) => {
424
- const { limit, mimePrefix } = parseValidated(
425
+ const { limit, mimePrefix, cursor } = parseValidated(
425
426
  ListMediaQuerySchema,
426
427
  c.req.query(),
427
428
  );
428
- const mediaList = await c.var.services.media.list({ limit, mimePrefix });
429
+ const mediaList = await c.var.services.media.list({
430
+ limit,
431
+ mimePrefix,
432
+ cursor,
433
+ });
429
434
 
430
435
  return c.json({
431
436
  media: mediaList.map((media) => toApiMedia(media, c.var.appConfig)),
437
+ nextCursor:
438
+ mediaList.length === limit ? (mediaList.at(-1)?.id ?? null) : null,
432
439
  });
433
440
  });
434
441
 
@@ -92,6 +92,8 @@ composeRoutes.post("/", async (c) => {
92
92
  language: data.language,
93
93
  translationOfId: data.translationOfId,
94
94
  publishedAt: data.publishedAt,
95
+ createdAt: data.createdAt,
96
+ updatedAt: data.updatedAt,
95
97
  },
96
98
  data.attachments,
97
99
  {
@@ -239,6 +241,8 @@ composeRoutes.post("/thread", async (c) => {
239
241
  // Replies may carry their own date; when they don't, the service
240
242
  // inherits the root's rather than stamping "now".
241
243
  publishedAt: data.publishedAt,
244
+ createdAt: data.createdAt,
245
+ updatedAt: data.updatedAt,
242
246
  },
243
247
  attachments: data.attachments,
244
248
  })),