@jant/core 0.3.45 → 0.3.47
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/db/execute-file.js +12 -4
- package/bin/commands/db/rehearse.js +2 -2
- package/bin/commands/export.js +12 -4
- package/bin/commands/import-site.js +99 -305
- package/bin/commands/migrate.js +36 -69
- package/bin/commands/reset-password.js +10 -4
- package/bin/commands/site/export.js +59 -248
- package/bin/commands/site/snapshot/export.js +58 -45
- package/bin/commands/site/snapshot/import.js +104 -52
- package/bin/lib/node-env.js +100 -0
- package/bin/lib/runtime-target.js +64 -0
- package/bin/lib/site-snapshot.js +185 -54
- package/bin/lib/sql-export.js +19 -2
- package/dist/{app-C-L7wL6o.js → app-3REcR-3U.js} +332 -190
- package/dist/app-B67XOEyo.js +6 -0
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/{client-auth-Dcon89Av.js → client-auth-Ce5WEAVS.js} +236 -183
- package/dist/client/_assets/client-s71Js1Cu.css +2 -0
- package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
- package/dist/github-sync-C593r22F.js +4 -0
- package/dist/github-sync-bL1hnx3Q.js +428 -0
- package/dist/index.js +3 -2
- package/dist/node.js +5 -4
- package/package.json +3 -2
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/__tests__/import-site-command.test.ts +18 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
- package/src/client/components/jant-compose-dialog.ts +7 -6
- package/src/client/components/jant-compose-editor.ts +6 -5
- package/src/client/components/jant-settings-general.ts +164 -22
- package/src/client/components/settings-types.ts +4 -6
- package/src/client/random-uuid.ts +23 -0
- package/src/client-auth.ts +1 -1
- package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
- package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
- package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
- package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
- package/src/db/migrations/meta/0021_snapshot.json +2121 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
- package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +21 -26
- package/src/db/rehearsal-fixtures/demo-current.json +1 -1
- package/src/db/schema.ts +16 -20
- package/src/i18n/__tests__/middleware.test.ts +43 -1
- package/src/i18n/coverage.generated.ts +17 -0
- package/src/i18n/i18n.ts +18 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/locales/settings/en.po +16 -11
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +17 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +16 -11
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +84 -2
- package/src/i18n/middleware.ts +25 -16
- package/src/i18n/supported-locales.ts +153 -0
- package/src/lib/__tests__/csp-builder.test.ts +19 -2
- package/src/lib/__tests__/feed.test.ts +242 -1
- package/src/lib/__tests__/post-meta.test.ts +0 -1
- package/src/lib/__tests__/view.test.ts +0 -1
- package/src/lib/csp-builder.ts +28 -10
- package/src/lib/feed.ts +153 -3
- package/src/middleware/__tests__/secure-headers.test.ts +89 -0
- package/src/middleware/auth.ts +1 -1
- package/src/middleware/secure-headers.ts +47 -1
- package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
- package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
- package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
- package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
- package/src/node/__tests__/cli-sql-export.test.ts +49 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +8 -2
- package/src/routes/api/__tests__/settings.test.ts +3 -2
- package/src/routes/api/github-sync.tsx +1 -1
- package/src/routes/api/settings.ts +4 -1
- package/src/routes/auth/signin.tsx +6 -0
- package/src/routes/pages/archive.tsx +4 -2
- package/src/services/__tests__/post.test.ts +19 -19
- package/src/services/__tests__/search.test.ts +0 -1
- package/src/services/__tests__/settings.test.ts +22 -3
- package/src/services/bootstrap.ts +7 -3
- package/src/services/collection.ts +3 -3
- package/src/services/export.ts +0 -3
- package/src/services/navigation.ts +0 -2
- package/src/services/path.ts +1 -38
- package/src/services/post.ts +32 -66
- package/src/services/search.ts +0 -6
- package/src/services/settings.ts +47 -6
- package/src/services/site-admin.ts +6 -1
- package/src/styles/ui.css +12 -23
- package/src/types/entities.ts +0 -1
- package/src/ui/color-themes.ts +1 -1
- package/src/ui/dash/settings/GeneralContent.tsx +17 -19
- package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
- package/src/ui/feed/NoteCard.tsx +1 -11
- package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
- package/src/ui/pages/HomePage.tsx +1 -4
- package/src/ui/pages/PostPage.tsx +2 -0
- package/bin/commands/collections.js +0 -268
- package/bin/commands/media.js +0 -302
- package/bin/commands/posts.js +0 -262
- package/bin/commands/search.js +0 -53
- package/bin/commands/settings.js +0 -93
- package/bin/lib/http-api.js +0 -223
- package/bin/lib/media-upload.js +0 -206
- package/dist/app-Hvqe7Ks_.js +0 -5
- package/dist/client/_assets/client-DDs6NzB3.css +0 -2
- package/src/__tests__/bin/content-cli.test.ts +0 -179
- package/src/__tests__/bin/media-cli.test.ts +0 -192
- /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
- /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assertSnapshotDialectMatches,
|
|
4
|
+
assertSnapshotMeta,
|
|
5
|
+
buildSnapshotMeta,
|
|
6
|
+
getSnapshotDialect,
|
|
7
|
+
} from "../../../bin/lib/site-snapshot.js";
|
|
8
|
+
|
|
9
|
+
const SITE = { id: "sit_test", key: "default" };
|
|
10
|
+
|
|
11
|
+
describe("buildSnapshotMeta", () => {
|
|
12
|
+
it("includes the dialect when provided", () => {
|
|
13
|
+
const meta = buildSnapshotMeta(SITE, { dialect: "pg" });
|
|
14
|
+
expect(meta.dialect).toBe("pg");
|
|
15
|
+
expect(meta.site).toEqual({ id: "sit_test", key: "default" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("omits the dialect field when not provided (back-compat)", () => {
|
|
19
|
+
const meta = buildSnapshotMeta(SITE);
|
|
20
|
+
expect("dialect" in meta).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects unknown dialects at build time", () => {
|
|
24
|
+
expect(() => buildSnapshotMeta(SITE, { dialect: "mysql" })).toThrow(
|
|
25
|
+
/Unsupported snapshot dialect/,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("assertSnapshotMeta", () => {
|
|
31
|
+
it("accepts a known dialect", () => {
|
|
32
|
+
expect(() =>
|
|
33
|
+
assertSnapshotMeta(buildSnapshotMeta(SITE, { dialect: "sqlite" })),
|
|
34
|
+
).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts a snapshot without a dialect (legacy)", () => {
|
|
38
|
+
expect(() => assertSnapshotMeta(buildSnapshotMeta(SITE))).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects an unknown dialect at read time", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
assertSnapshotMeta({
|
|
44
|
+
format: "jant-site-snapshot",
|
|
45
|
+
version: 1,
|
|
46
|
+
dialect: "mysql",
|
|
47
|
+
site: SITE,
|
|
48
|
+
}),
|
|
49
|
+
).toThrow(/Snapshot meta has unsupported dialect/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getSnapshotDialect", () => {
|
|
54
|
+
it("returns the dialect when valid", () => {
|
|
55
|
+
expect(getSnapshotDialect({ dialect: "pg" })).toBe("pg");
|
|
56
|
+
expect(getSnapshotDialect({ dialect: "sqlite" })).toBe("sqlite");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns undefined for missing or invalid dialect", () => {
|
|
60
|
+
expect(getSnapshotDialect({})).toBeUndefined();
|
|
61
|
+
expect(getSnapshotDialect({ dialect: "mysql" })).toBeUndefined();
|
|
62
|
+
expect(getSnapshotDialect(null)).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("assertSnapshotDialectMatches", () => {
|
|
67
|
+
it("passes when source and target dialects match", () => {
|
|
68
|
+
expect(() =>
|
|
69
|
+
assertSnapshotDialectMatches({ dialect: "pg" }, "pg"),
|
|
70
|
+
).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws a descriptive error when dialects mismatch", () => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
assertSnapshotDialectMatches({ dialect: "sqlite" }, "pg"),
|
|
76
|
+
).toThrow(/Snapshot dialect mismatch.*source is sqlite.*target is pg/s);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skips validation when the snapshot predates the dialect field", () => {
|
|
80
|
+
// Older snapshots have no dialect field — we don't know the source, so we
|
|
81
|
+
// can't refuse them. The user opts into the looser check by importing
|
|
82
|
+
// legacy snapshots; cross-dialect SQL errors will surface mid-import.
|
|
83
|
+
expect(() => assertSnapshotDialectMatches({}, "pg")).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getTableColumns } from "../../../bin/lib/sql-export.js";
|
|
3
|
+
|
|
4
|
+
interface CapturedQueryRunner {
|
|
5
|
+
query: (sql: string) => Promise<Array<Record<string, unknown>>>;
|
|
6
|
+
lastSql?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createQueryRunner(
|
|
10
|
+
rows: Array<Record<string, unknown>>,
|
|
11
|
+
): CapturedQueryRunner {
|
|
12
|
+
const runner: CapturedQueryRunner = {
|
|
13
|
+
async query(sql: string) {
|
|
14
|
+
runner.lastSql = sql;
|
|
15
|
+
return rows;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return runner;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("getTableColumns", () => {
|
|
22
|
+
it("filters Postgres GENERATED ALWAYS columns out of the dump column list", async () => {
|
|
23
|
+
const runner = createQueryRunner([
|
|
24
|
+
{ name: "id" },
|
|
25
|
+
{ name: "title" },
|
|
26
|
+
// pg already filters via is_generated = 'NEVER', so the runner only
|
|
27
|
+
// returns the storable columns. We capture the SQL to assert the WHERE.
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const columns = await getTableColumns(runner, "post", "pg");
|
|
31
|
+
|
|
32
|
+
expect(columns).toEqual(["id", "title"]);
|
|
33
|
+
expect(runner.lastSql).toMatch(/is_generated\s*=\s*'NEVER'/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("filters SQLite STORED/VIRTUAL generated columns via table_xinfo.hidden", async () => {
|
|
37
|
+
const runner = createQueryRunner([
|
|
38
|
+
{ cid: 0, name: "id", hidden: 0 },
|
|
39
|
+
{ cid: 1, name: "title", hidden: 0 },
|
|
40
|
+
{ cid: 2, name: "search_virtual", hidden: 2 },
|
|
41
|
+
{ cid: 3, name: "search_stored", hidden: 3 },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const columns = await getTableColumns(runner, "post", "sqlite");
|
|
45
|
+
|
|
46
|
+
expect(columns).toEqual(["id", "title"]);
|
|
47
|
+
expect(runner.lastSql).toMatch(/PRAGMA\s+table_xinfo/);
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/node/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
createNodeRequestRuntime,
|
|
9
9
|
} from "../runtime/node.js";
|
|
10
10
|
export { createExportService } from "../services/export.js";
|
|
11
|
+
export { createStorageDriver } from "../lib/storage.js";
|
|
11
12
|
export { resolveConfig } from "../lib/resolve-config.js";
|
|
12
13
|
export { buildThemeStyle } from "../lib/theme.js";
|
|
13
14
|
export { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
|
package/src/preset.css
CHANGED
|
@@ -490,7 +490,8 @@ html {
|
|
|
490
490
|
.feed-link-domain,
|
|
491
491
|
.feed-continue-link,
|
|
492
492
|
[data-post-body].prose,
|
|
493
|
-
[data-post-meta]
|
|
493
|
+
[data-post-meta],
|
|
494
|
+
[data-post-end] {
|
|
494
495
|
width: var(--layout-content-width);
|
|
495
496
|
}
|
|
496
497
|
|
|
@@ -498,6 +499,10 @@ html {
|
|
|
498
499
|
width: min(80%, 45rem);
|
|
499
500
|
}
|
|
500
501
|
|
|
502
|
+
[data-post-end]:not(:empty) {
|
|
503
|
+
margin-top: 3rem;
|
|
504
|
+
}
|
|
505
|
+
|
|
501
506
|
@media (max-width: 1024px) {
|
|
502
507
|
[data-page="search"],
|
|
503
508
|
.site-home-header,
|
|
@@ -512,7 +517,8 @@ html {
|
|
|
512
517
|
.feed-link-domain,
|
|
513
518
|
.feed-continue-link,
|
|
514
519
|
[data-post-body].prose,
|
|
515
|
-
[data-post-meta]
|
|
520
|
+
[data-post-meta],
|
|
521
|
+
[data-post-end] {
|
|
516
522
|
width: min(100%, 35rem);
|
|
517
523
|
}
|
|
518
524
|
}
|
|
@@ -542,8 +542,9 @@ describe("Settings API Routes", () => {
|
|
|
542
542
|
expect(storage.put).toHaveBeenCalledTimes(2);
|
|
543
543
|
|
|
544
544
|
const mediaList = await services.media.list();
|
|
545
|
-
expect(mediaList).toHaveLength(
|
|
546
|
-
|
|
545
|
+
expect(mediaList).toHaveLength(2);
|
|
546
|
+
const originalNames = mediaList.map((entry) => entry.originalName).sort();
|
|
547
|
+
expect(originalNames).toEqual(["apple-touch-icon.png", "avatar.png"]);
|
|
547
548
|
});
|
|
548
549
|
});
|
|
549
550
|
|
|
@@ -256,7 +256,7 @@ const ConnectSchema = z.object({
|
|
|
256
256
|
// Connect: validate token, save config, create webhook
|
|
257
257
|
githubSyncAdminRoutes.post("/setup", requireAuthApi(), async (c) => {
|
|
258
258
|
// PAT connect is disabled when a GitHub App is configured — see the
|
|
259
|
-
//
|
|
259
|
+
// settings route for rationale.
|
|
260
260
|
if (getGitHubAppConfig(c.env)) {
|
|
261
261
|
return c.json(
|
|
262
262
|
{
|
|
@@ -176,7 +176,10 @@ settingsApiRoutes.post("/avatar", requireAuthApi(), async (c) => {
|
|
|
176
176
|
|
|
177
177
|
// Remove site avatar (requires auth)
|
|
178
178
|
settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
|
|
179
|
-
await c.var.services.settings.removeAvatar(c.var.storage
|
|
179
|
+
await c.var.services.settings.removeAvatar(c.var.storage, {
|
|
180
|
+
media: c.var.services.media,
|
|
181
|
+
storageProvider: c.var.appConfig.storageDriver,
|
|
182
|
+
});
|
|
180
183
|
try {
|
|
181
184
|
await syncHostedControlPlaneSiteAvatar({
|
|
182
185
|
appConfig: c.var.appConfig,
|
|
@@ -128,6 +128,12 @@ signinRoutes.get("/signin", async (c) => {
|
|
|
128
128
|
? rawRedirect
|
|
129
129
|
: undefined;
|
|
130
130
|
|
|
131
|
+
if (c.var.isAuthenticated) {
|
|
132
|
+
return c.redirect(
|
|
133
|
+
toPublicPath(redirect ?? "/", c.var.appConfig.sitePathPrefix),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
131
137
|
const hostedSigninUrl = getHostedControlPlaneSigninUrl(
|
|
132
138
|
c.env,
|
|
133
139
|
c.var.publicRequestUrl,
|
|
@@ -521,13 +521,15 @@ async function buildArchiveFeedData(
|
|
|
521
521
|
? await services.collections.getBySlug(params.collectionSlug)
|
|
522
522
|
: undefined;
|
|
523
523
|
|
|
524
|
-
// Feed
|
|
524
|
+
// Feed mirrors the unauthenticated archive page: published + non-private,
|
|
525
|
+
// including Hidden-from-Latest. /archive is the canonical "all posts" view,
|
|
526
|
+
// so its feed must match.
|
|
525
527
|
const filters: PostFilters = {
|
|
526
528
|
format: params.format,
|
|
527
529
|
status: "published",
|
|
528
530
|
excludeReplies: true,
|
|
529
531
|
excludePrivate: true,
|
|
530
|
-
excludeLatestHidden:
|
|
532
|
+
excludeLatestHidden: false,
|
|
531
533
|
collectionId: collection?.id,
|
|
532
534
|
mediaKinds: params.mediaKinds,
|
|
533
535
|
hasMedia: params.hasMedia,
|
|
@@ -82,7 +82,6 @@ describe("PostService", () => {
|
|
|
82
82
|
expect(post.visibility).toBe("public");
|
|
83
83
|
expect(post.pinnedAt).toBeNull();
|
|
84
84
|
expect(post.bodyHtml).toContain("<p>Hello world</p>");
|
|
85
|
-
expect(post.deletedAt).toBeNull();
|
|
86
85
|
expect(post.threadId).toBe(post.id);
|
|
87
86
|
});
|
|
88
87
|
|
|
@@ -787,7 +786,7 @@ describe("PostService", () => {
|
|
|
787
786
|
]);
|
|
788
787
|
});
|
|
789
788
|
|
|
790
|
-
it("excludes deleted posts
|
|
789
|
+
it("excludes deleted posts", async () => {
|
|
791
790
|
const post = await postService.create({
|
|
792
791
|
format: "note",
|
|
793
792
|
bodyMarkdown: "test",
|
|
@@ -800,17 +799,6 @@ describe("PostService", () => {
|
|
|
800
799
|
expect(posts[0]?.bodyText).toBe("kept");
|
|
801
800
|
});
|
|
802
801
|
|
|
803
|
-
it("includes deleted posts when requested", async () => {
|
|
804
|
-
const post = await postService.create({
|
|
805
|
-
format: "note",
|
|
806
|
-
bodyMarkdown: "test",
|
|
807
|
-
});
|
|
808
|
-
await postService.delete(post.id);
|
|
809
|
-
|
|
810
|
-
const posts = await postService.list({ includeDeleted: true });
|
|
811
|
-
expect(posts).toHaveLength(1);
|
|
812
|
-
});
|
|
813
|
-
|
|
814
802
|
it("supports limit", async () => {
|
|
815
803
|
for (let i = 0; i < 5; i++) {
|
|
816
804
|
await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
|
|
@@ -1485,8 +1473,8 @@ describe("PostService", () => {
|
|
|
1485
1473
|
});
|
|
1486
1474
|
});
|
|
1487
1475
|
|
|
1488
|
-
describe("delete
|
|
1489
|
-
it("
|
|
1476
|
+
describe("delete", () => {
|
|
1477
|
+
it("removes a post", async () => {
|
|
1490
1478
|
const post = await postService.create({
|
|
1491
1479
|
format: "note",
|
|
1492
1480
|
bodyMarkdown: "test",
|
|
@@ -1495,7 +1483,6 @@ describe("PostService", () => {
|
|
|
1495
1483
|
const result = await postService.delete(post.id);
|
|
1496
1484
|
expect(result).toBe(true);
|
|
1497
1485
|
|
|
1498
|
-
// Should not appear in regular queries
|
|
1499
1486
|
const found = await postService.getById(post.id);
|
|
1500
1487
|
expect(found).toBeNull();
|
|
1501
1488
|
});
|
|
@@ -1518,7 +1505,6 @@ describe("PostService", () => {
|
|
|
1518
1505
|
|
|
1519
1506
|
await postService.delete(root.id);
|
|
1520
1507
|
|
|
1521
|
-
// Both root and reply should be soft-deleted
|
|
1522
1508
|
expect(await postService.getById(root.id)).toBeNull();
|
|
1523
1509
|
expect(await postService.getById(reply.id)).toBeNull();
|
|
1524
1510
|
});
|
|
@@ -1541,11 +1527,25 @@ describe("PostService", () => {
|
|
|
1541
1527
|
|
|
1542
1528
|
await postService.delete(reply1.id);
|
|
1543
1529
|
|
|
1544
|
-
// Root should still exist
|
|
1545
1530
|
expect(await postService.getById(root.id)).not.toBeNull();
|
|
1546
|
-
// reply1 should be deleted
|
|
1547
1531
|
expect(await postService.getById(reply1.id)).toBeNull();
|
|
1548
1532
|
});
|
|
1533
|
+
|
|
1534
|
+
it("frees the slug for reuse after deletion", async () => {
|
|
1535
|
+
const post = await postService.create({
|
|
1536
|
+
format: "note",
|
|
1537
|
+
bodyMarkdown: "first",
|
|
1538
|
+
title: "Same Title",
|
|
1539
|
+
});
|
|
1540
|
+
await postService.delete(post.id);
|
|
1541
|
+
|
|
1542
|
+
const reused = await postService.create({
|
|
1543
|
+
format: "note",
|
|
1544
|
+
bodyMarkdown: "second",
|
|
1545
|
+
title: "Same Title",
|
|
1546
|
+
});
|
|
1547
|
+
expect(reused.slug).toBe(post.slug);
|
|
1548
|
+
});
|
|
1549
1549
|
});
|
|
1550
1550
|
|
|
1551
1551
|
describe("threads", () => {
|
|
@@ -44,7 +44,6 @@ function createSearchRow(overrides?: Partial<Record<string, unknown>>) {
|
|
|
44
44
|
collection_id: null,
|
|
45
45
|
reply_to_id: null,
|
|
46
46
|
thread_id: "pst_01jpyz2pvf4m7s2k8r5c9t0qce",
|
|
47
|
-
deleted_at: null,
|
|
48
47
|
published_at: 1774009100,
|
|
49
48
|
last_activity_at: 1774009100,
|
|
50
49
|
created_at: 1774009100,
|
|
@@ -264,13 +264,32 @@ describe("SettingsService", () => {
|
|
|
264
264
|
expect(result.languageChanged).toBe(true);
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
-
it("
|
|
267
|
+
it("accepts a locale without a shipped catalog (e.g. Swedish)", async () => {
|
|
268
|
+
const result = await settingsService.updateGeneral(
|
|
269
|
+
{ ...defaults, siteLanguage: "sv" },
|
|
270
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.languageChanged).toBe(true);
|
|
274
|
+
expect(await settingsService.get("SITE_LANGUAGE")).toBe("sv");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("normalizes BCP 47 casing to canonical form", async () => {
|
|
278
|
+
await settingsService.updateGeneral(
|
|
279
|
+
{ ...defaults, siteLanguage: "ZH-hans" },
|
|
280
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(await settingsService.get("SITE_LANGUAGE")).toBe("zh-Hans");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("rejects values that are not valid BCP 47 tags", async () => {
|
|
268
287
|
await expect(
|
|
269
288
|
settingsService.updateGeneral(
|
|
270
|
-
{ ...defaults, siteLanguage: "
|
|
289
|
+
{ ...defaults, siteLanguage: "not a locale!!!" },
|
|
271
290
|
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
272
291
|
),
|
|
273
|
-
).rejects.toThrow(/
|
|
292
|
+
).rejects.toThrow(/BCP 47/i);
|
|
274
293
|
});
|
|
275
294
|
|
|
276
295
|
it("returns no language change when same", async () => {
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
sqliteSchemaBundle,
|
|
10
10
|
type DatabaseSchema,
|
|
11
11
|
} from "../db/schema-bundle.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
baseLocale,
|
|
14
|
+
isValidContentLanguage,
|
|
15
|
+
normalizeContentLanguage,
|
|
16
|
+
} from "../i18n/locales.js";
|
|
13
17
|
import { createNavItemService } from "./navigation.js";
|
|
14
18
|
import { createSettingsService } from "./settings.js";
|
|
15
19
|
import { createSiteMemberService } from "./site-member.js";
|
|
@@ -57,8 +61,8 @@ export function createBootstrapService(
|
|
|
57
61
|
await settings.set("SITE_NAME", data.siteName.trim());
|
|
58
62
|
await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
|
|
59
63
|
const siteLanguage =
|
|
60
|
-
data.siteLanguage &&
|
|
61
|
-
? data.siteLanguage
|
|
64
|
+
data.siteLanguage && isValidContentLanguage(data.siteLanguage)
|
|
65
|
+
? normalizeContentLanguage(data.siteLanguage)
|
|
62
66
|
: baseLocale;
|
|
63
67
|
await settings.set("SITE_LANGUAGE", siteLanguage);
|
|
64
68
|
if (data.cjkSerifFont && data.cjkSerifFont !== "off") {
|
|
@@ -432,14 +432,14 @@ export function createCollectionService(
|
|
|
432
432
|
const postCount = sql<number>`
|
|
433
433
|
CAST(COUNT(
|
|
434
434
|
CASE
|
|
435
|
-
WHEN ${posts.id} IS NOT NULL
|
|
435
|
+
WHEN ${posts.id} IS NOT NULL THEN 1
|
|
436
436
|
END
|
|
437
437
|
) AS INTEGER)
|
|
438
438
|
`.as("post_count");
|
|
439
439
|
const recentActivityAt = sql<number | null>`
|
|
440
440
|
MAX(
|
|
441
441
|
CASE
|
|
442
|
-
WHEN ${posts.id} IS NOT NULL
|
|
442
|
+
WHEN ${posts.id} IS NOT NULL
|
|
443
443
|
THEN COALESCE(
|
|
444
444
|
${posts.lastActivityAt},
|
|
445
445
|
${posts.publishedAt},
|
|
@@ -1050,7 +1050,7 @@ export function createCollectionService(
|
|
|
1050
1050
|
.from(postCollections)
|
|
1051
1051
|
.innerJoin(
|
|
1052
1052
|
sql`post`,
|
|
1053
|
-
sql`post.id = ${postCollections.postId} AND post.
|
|
1053
|
+
sql`post.id = ${postCollections.postId} AND post.site_id = ${siteId}`,
|
|
1054
1054
|
)
|
|
1055
1055
|
.where(eq(postCollections.siteId, siteId))
|
|
1056
1056
|
.groupBy(postCollections.collectionId);
|
package/src/services/export.ts
CHANGED
|
@@ -1274,9 +1274,6 @@ function buildExportedCollectionMetrics(
|
|
|
1274
1274
|
}
|
|
1275
1275
|
|
|
1276
1276
|
for (const post of posts) {
|
|
1277
|
-
if (post.deletedAt !== null) {
|
|
1278
|
-
continue;
|
|
1279
|
-
}
|
|
1280
1277
|
// Drafts and private posts are excluded — they won't reach Hugo.
|
|
1281
1278
|
if (post.status === "draft" || post.visibility === "private") {
|
|
1282
1279
|
continue;
|
|
@@ -446,14 +446,12 @@ export function createNavItemService(
|
|
|
446
446
|
sql`(
|
|
447
447
|
${postCollections.createdAt} > ${threshold}
|
|
448
448
|
OR (${posts.updatedAt} > ${threshold}
|
|
449
|
-
AND ${posts.deletedAt} IS NULL
|
|
450
449
|
AND ${posts.status} = 'published')
|
|
451
450
|
OR EXISTS (
|
|
452
451
|
SELECT 1 FROM ${posts} reply
|
|
453
452
|
WHERE reply.site_id = ${postCollections.siteId}
|
|
454
453
|
AND reply.thread_id = ${postCollections.postId}
|
|
455
454
|
AND reply.reply_to_id IS NOT NULL
|
|
456
|
-
AND reply.deleted_at IS NULL
|
|
457
455
|
AND reply.status = 'published'
|
|
458
456
|
AND reply.created_at > ${threshold}
|
|
459
457
|
)
|
package/src/services/path.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* slash (for example: "hello-world" or "collections/reading+tools").
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { and, eq, inArray, isNotNull,
|
|
9
|
+
import { and, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
|
10
10
|
import { type Database, batchQuery } from "../db/index.js";
|
|
11
11
|
import {
|
|
12
12
|
sqliteSchemaBundle,
|
|
@@ -95,36 +95,10 @@ export function createPathService(
|
|
|
95
95
|
return normalizePath(path);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
99
|
-
* Removes a path_registry entry for the given normalized path if it belongs
|
|
100
|
-
* to a soft-deleted post, allowing the path to be reused.
|
|
101
|
-
*/
|
|
102
|
-
async function reclaimDeletedPostPath(
|
|
103
|
-
normalizedPath: string,
|
|
104
|
-
excludePostId?: string,
|
|
105
|
-
): Promise<void> {
|
|
106
|
-
const conditions = [
|
|
107
|
-
eq(pathRegistry.siteId, siteId),
|
|
108
|
-
eq(pathRegistry.path, normalizedPath),
|
|
109
|
-
isNotNull(pathRegistry.postId),
|
|
110
|
-
sql`EXISTS (
|
|
111
|
-
SELECT 1 FROM ${posts}
|
|
112
|
-
WHERE ${posts.id} = ${pathRegistry.postId}
|
|
113
|
-
AND ${posts.deletedAt} IS NOT NULL
|
|
114
|
-
)`,
|
|
115
|
-
];
|
|
116
|
-
if (excludePostId) {
|
|
117
|
-
conditions.push(ne(pathRegistry.postId, excludePostId));
|
|
118
|
-
}
|
|
119
|
-
await db.delete(pathRegistry).where(and(...conditions));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
98
|
async function insertPath(input: CreatePathInput): Promise<PathRecord> {
|
|
123
99
|
const timestamp = now();
|
|
124
100
|
const normalizedPath = normalizeStoredPath(input.path);
|
|
125
101
|
|
|
126
|
-
await reclaimDeletedPostPath(normalizedPath);
|
|
127
|
-
|
|
128
102
|
try {
|
|
129
103
|
const result = await db
|
|
130
104
|
.insert(pathRegistry)
|
|
@@ -192,14 +166,6 @@ export function createPathService(
|
|
|
192
166
|
const conditions = [
|
|
193
167
|
eq(pathRegistry.siteId, siteId),
|
|
194
168
|
eq(pathRegistry.path, normalized),
|
|
195
|
-
// Ignore paths owned by soft-deleted posts — those slugs are available
|
|
196
|
-
// for reuse. Paths not linked to a post (collections, redirects,
|
|
197
|
-
// archives) always block.
|
|
198
|
-
sql`(${pathRegistry.postId} IS NULL OR NOT EXISTS (
|
|
199
|
-
SELECT 1 FROM ${posts}
|
|
200
|
-
WHERE ${posts.id} = ${pathRegistry.postId}
|
|
201
|
-
AND ${posts.deletedAt} IS NOT NULL
|
|
202
|
-
))`,
|
|
203
169
|
];
|
|
204
170
|
if (excludeId) conditions.push(ne(pathRegistry.id, excludeId));
|
|
205
171
|
|
|
@@ -310,8 +276,6 @@ export function createPathService(
|
|
|
310
276
|
const timestamp = now();
|
|
311
277
|
const normalized = normalizeStoredPath(slug);
|
|
312
278
|
|
|
313
|
-
await reclaimDeletedPostPath(normalized, postId);
|
|
314
|
-
|
|
315
279
|
try {
|
|
316
280
|
await db
|
|
317
281
|
.update(pathRegistry)
|
|
@@ -424,7 +388,6 @@ export function createPathService(
|
|
|
424
388
|
eq(pathRegistry.kind, "slug"),
|
|
425
389
|
isNotNull(pathRegistry.postId),
|
|
426
390
|
isNotNull(posts.title),
|
|
427
|
-
isNull(posts.deletedAt),
|
|
428
391
|
eq(posts.format, "note"),
|
|
429
392
|
),
|
|
430
393
|
);
|