@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.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
package/src/lib/timeline.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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);
|
package/src/routes/api/export.ts
CHANGED
|
@@ -57,12 +57,12 @@ exportApiRoutes.post("/hugo", requireAuthApi(), async (c) => {
|
|
|
57
57
|
storage: c.var.storage,
|
|
58
58
|
},
|
|
59
59
|
);
|
|
60
|
-
|
|
61
|
-
|
|
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
|
});
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -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
|
|
217
|
+
bodyMarkdown: post.body
|
|
218
|
+
? toPublicBodyMarkdown(post.id, post.body)
|
|
219
|
+
: null,
|
|
200
220
|
}
|
|
201
221
|
: {
|
|
202
222
|
bodyHtml: post.bodyHtml,
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -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({
|
|
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
|
|
package/src/routes/compose.tsx
CHANGED
|
@@ -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
|
})),
|