@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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A smart collection on an exported site lists what it lists on Jant.
|
|
3
|
+
*
|
|
4
|
+
* The export carries a smart collection's conditions, not its matches, and
|
|
5
|
+
* the theme's `smart-collection-members` partial evaluates them against the
|
|
6
|
+
* posts' front matter. That is a second implementation of every condition and
|
|
7
|
+
* every order, in Go templates, so this builds the export with the real Hugo
|
|
8
|
+
* binary and compares each page with the list Jant's own services produce
|
|
9
|
+
* from the same database.
|
|
10
|
+
*
|
|
11
|
+
* Skips when `hugo` is not on PATH, like the other Hugo build tests.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { rm } from "node:fs/promises";
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
16
|
+
import { createTestDatabase, DEFAULT_TEST_SITE_ID } from "./helpers/db.js";
|
|
17
|
+
import { makeSiteConfig } from "./helpers/export-fixtures.js";
|
|
18
|
+
import {
|
|
19
|
+
buildHugoSite,
|
|
20
|
+
hugoAvailable,
|
|
21
|
+
readFeedSlugs,
|
|
22
|
+
readThreadSlugs,
|
|
23
|
+
} from "./helpers/hugo-site.js";
|
|
24
|
+
import { createCollectionService } from "../services/collection.js";
|
|
25
|
+
import { createExportService } from "../services/export.js";
|
|
26
|
+
import { createMediaService } from "../services/media.js";
|
|
27
|
+
import { createPathService } from "../services/path.js";
|
|
28
|
+
import { createPostService } from "../services/post.js";
|
|
29
|
+
import { createSmartCollectionService } from "../services/smart-collection.js";
|
|
30
|
+
import { supportsCollectionRatingSort } from "../lib/collection-sort.js";
|
|
31
|
+
import type { Database } from "../db/index.js";
|
|
32
|
+
import type { CreatePost } from "../types/operations.js";
|
|
33
|
+
import type { MediaKind, SmartCollection } from "../types.js";
|
|
34
|
+
|
|
35
|
+
const SITE_URL = "https://example.com";
|
|
36
|
+
|
|
37
|
+
const at = (iso: string) => Math.floor(Date.parse(iso) / 1000);
|
|
38
|
+
|
|
39
|
+
/** Every condition value and every order, each on its own smart collection. */
|
|
40
|
+
const SMART_COLLECTIONS: {
|
|
41
|
+
slug: string;
|
|
42
|
+
selection: SmartCollection["selection"] | "ideas";
|
|
43
|
+
sort?: SmartCollection["sort"];
|
|
44
|
+
}[] = [
|
|
45
|
+
{ slug: "sc-all-newest", selection: {} },
|
|
46
|
+
{ slug: "sc-all-oldest", selection: {}, sort: "oldest" },
|
|
47
|
+
{ slug: "sc-all-rated", selection: {}, sort: "rating_desc" },
|
|
48
|
+
{ slug: "sc-notes", selection: { format: "note" } },
|
|
49
|
+
// One rated link of two: too few to rank, so the page falls back to newest.
|
|
50
|
+
{ slug: "sc-links", selection: { format: "link" }, sort: "rating_desc" },
|
|
51
|
+
{ slug: "sc-quotes", selection: { format: "quote" } },
|
|
52
|
+
{ slug: "sc-titled", selection: { title: true } },
|
|
53
|
+
{ slug: "sc-untitled", selection: { title: false } },
|
|
54
|
+
{ slug: "sc-2024", selection: { year: 2024 } },
|
|
55
|
+
{ slug: "sc-2025", selection: { year: 2025 } },
|
|
56
|
+
{ slug: "sc-media", selection: { media: "any" } },
|
|
57
|
+
{ slug: "sc-no-media", selection: { media: "none" } },
|
|
58
|
+
{ slug: "sc-images", selection: { media: ["image"] } },
|
|
59
|
+
{ slug: "sc-text-or-video", selection: { media: ["text", "video"] } },
|
|
60
|
+
{ slug: "sc-replies", selection: { replies: true } },
|
|
61
|
+
{ slug: "sc-no-replies", selection: { replies: false } },
|
|
62
|
+
{ slug: "sc-public", selection: { visibility: "public" } },
|
|
63
|
+
{ slug: "sc-hidden", selection: { visibility: "latest_hidden" } },
|
|
64
|
+
{ slug: "sc-featured", selection: { visibility: "featured" } },
|
|
65
|
+
{ slug: "sc-ideas", selection: "ideas" },
|
|
66
|
+
{
|
|
67
|
+
slug: "sc-thoughts",
|
|
68
|
+
selection: { format: "note", title: false },
|
|
69
|
+
sort: "oldest",
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
describe.skipIf(!hugoAvailable)("smart collections on an exported site", () => {
|
|
74
|
+
let siteDir: string;
|
|
75
|
+
const expected = new Map<string, string[]>();
|
|
76
|
+
const built = new Map<string, string[]>();
|
|
77
|
+
const expectedFeed = new Map<string, string[]>();
|
|
78
|
+
const builtFeed = new Map<string, string[]>();
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
const { db: testDb } = createTestDatabase();
|
|
82
|
+
const db = testDb as unknown as Database;
|
|
83
|
+
const siteId = DEFAULT_TEST_SITE_ID;
|
|
84
|
+
const paths = createPathService(db, siteId);
|
|
85
|
+
const posts = createPostService(db, { slugIdLength: 5 }, siteId);
|
|
86
|
+
const smartCollections = createSmartCollectionService(
|
|
87
|
+
db,
|
|
88
|
+
siteId,
|
|
89
|
+
paths,
|
|
90
|
+
posts,
|
|
91
|
+
);
|
|
92
|
+
const collections = createCollectionService(
|
|
93
|
+
db,
|
|
94
|
+
siteId,
|
|
95
|
+
paths,
|
|
96
|
+
undefined,
|
|
97
|
+
undefined,
|
|
98
|
+
smartCollections,
|
|
99
|
+
);
|
|
100
|
+
const media = createMediaService(db, siteId);
|
|
101
|
+
|
|
102
|
+
const ideas = await collections.create({ slug: "ideas", title: "Ideas" });
|
|
103
|
+
|
|
104
|
+
async function post(
|
|
105
|
+
slug: string,
|
|
106
|
+
data: Omit<CreatePost, "slug" | "bodyMarkdown">,
|
|
107
|
+
attachments: MediaKind[] = [],
|
|
108
|
+
) {
|
|
109
|
+
const created = await posts.create({
|
|
110
|
+
slug,
|
|
111
|
+
bodyMarkdown: `Body of ${slug}.`,
|
|
112
|
+
status: "published",
|
|
113
|
+
...data,
|
|
114
|
+
createdAt: data.createdAt ?? data.publishedAt,
|
|
115
|
+
});
|
|
116
|
+
for (const [index, kind] of attachments.entries()) {
|
|
117
|
+
await media.create({
|
|
118
|
+
postId: created.id,
|
|
119
|
+
filename: `${slug}-${index}.bin`,
|
|
120
|
+
originalName: `${slug}-${index}.bin`,
|
|
121
|
+
mimeType: "application/octet-stream",
|
|
122
|
+
size: 1,
|
|
123
|
+
storageKey: `media/${slug}-${index}.bin`,
|
|
124
|
+
mediaKind: kind,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return created;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await post("a-2024-note", {
|
|
131
|
+
format: "note",
|
|
132
|
+
publishedAt: at("2024-03-01T00:00:00Z"),
|
|
133
|
+
rating: 5,
|
|
134
|
+
});
|
|
135
|
+
const titled = await post(
|
|
136
|
+
"b-titled-note",
|
|
137
|
+
{
|
|
138
|
+
format: "note",
|
|
139
|
+
title: "Titled note",
|
|
140
|
+
publishedAt: at("2025-01-10T00:00:00Z"),
|
|
141
|
+
collectionIds: [ideas.id],
|
|
142
|
+
},
|
|
143
|
+
["image"],
|
|
144
|
+
);
|
|
145
|
+
await post("b-reply", {
|
|
146
|
+
format: "note",
|
|
147
|
+
replyToId: titled.id,
|
|
148
|
+
publishedAt: at("2025-06-01T00:00:00Z"),
|
|
149
|
+
});
|
|
150
|
+
await post(
|
|
151
|
+
"c-hidden-link",
|
|
152
|
+
{
|
|
153
|
+
format: "link",
|
|
154
|
+
title: "A link",
|
|
155
|
+
url: "https://example.org/",
|
|
156
|
+
visibility: "latest_hidden",
|
|
157
|
+
publishedAt: at("2025-02-01T00:00:00Z"),
|
|
158
|
+
rating: 3,
|
|
159
|
+
},
|
|
160
|
+
["video"],
|
|
161
|
+
);
|
|
162
|
+
await post("k-newer-link", {
|
|
163
|
+
format: "link",
|
|
164
|
+
title: "Another link",
|
|
165
|
+
url: "https://example.net/",
|
|
166
|
+
publishedAt: at("2025-05-01T00:00:00Z"),
|
|
167
|
+
});
|
|
168
|
+
await post("d-featured-quote", {
|
|
169
|
+
format: "quote",
|
|
170
|
+
title: "Seneca",
|
|
171
|
+
quoteText: "We suffer more in imagination than in reality.",
|
|
172
|
+
publishedAt: at("2023-05-05T00:00:00Z"),
|
|
173
|
+
featuredAt: at("2023-06-01T00:00:00Z"),
|
|
174
|
+
});
|
|
175
|
+
await post(
|
|
176
|
+
"e-untitled-quote",
|
|
177
|
+
{
|
|
178
|
+
format: "quote",
|
|
179
|
+
quoteText: "Unattributed.",
|
|
180
|
+
publishedAt: at("2025-03-01T00:00:00Z"),
|
|
181
|
+
collectionIds: [ideas.id],
|
|
182
|
+
},
|
|
183
|
+
["text"],
|
|
184
|
+
);
|
|
185
|
+
await post("f-private", {
|
|
186
|
+
format: "note",
|
|
187
|
+
visibility: "private",
|
|
188
|
+
publishedAt: at("2025-01-01T00:00:00Z"),
|
|
189
|
+
});
|
|
190
|
+
await post("g-draft", { format: "note", status: "draft" });
|
|
191
|
+
// Two roots published in the same second: the ID decides.
|
|
192
|
+
const tied = await post("h-tied", {
|
|
193
|
+
format: "note",
|
|
194
|
+
publishedAt: at("2025-04-01T12:00:00Z"),
|
|
195
|
+
rating: 4,
|
|
196
|
+
});
|
|
197
|
+
await post("h-draft-reply", {
|
|
198
|
+
format: "note",
|
|
199
|
+
replyToId: tied.id,
|
|
200
|
+
status: "draft",
|
|
201
|
+
});
|
|
202
|
+
await post("i-tied", {
|
|
203
|
+
format: "note",
|
|
204
|
+
publishedAt: at("2025-04-01T12:00:00Z"),
|
|
205
|
+
});
|
|
206
|
+
// The last second of 2024 in UTC, whatever the build machine's zone.
|
|
207
|
+
await post(
|
|
208
|
+
"j-new-years-eve",
|
|
209
|
+
{ format: "note", publishedAt: at("2024-12-31T23:59:59Z") },
|
|
210
|
+
["audio"],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const anonymous = { isAuthenticated: false };
|
|
214
|
+
for (const definition of SMART_COLLECTIONS) {
|
|
215
|
+
const smartCollection = await smartCollections.create({
|
|
216
|
+
slug: definition.slug,
|
|
217
|
+
title: definition.slug,
|
|
218
|
+
selection:
|
|
219
|
+
definition.selection === "ideas"
|
|
220
|
+
? { collection: [ideas.id] }
|
|
221
|
+
: definition.selection,
|
|
222
|
+
sort: definition.sort,
|
|
223
|
+
});
|
|
224
|
+
// What the page shows: the stored order, unless it ranks by rating
|
|
225
|
+
// and too little is rated for that to mean anything.
|
|
226
|
+
const rated = await posts.countUpTo(
|
|
227
|
+
{
|
|
228
|
+
...smartCollections.toPostFilters(smartCollection, anonymous),
|
|
229
|
+
hasRating: true,
|
|
230
|
+
},
|
|
231
|
+
2,
|
|
232
|
+
);
|
|
233
|
+
const sort =
|
|
234
|
+
smartCollection.sort === "rating_desc" &&
|
|
235
|
+
!supportsCollectionRatingSort(rated)
|
|
236
|
+
? "newest"
|
|
237
|
+
: smartCollection.sort;
|
|
238
|
+
const list = await posts.list({
|
|
239
|
+
...smartCollections.toPostFilters(
|
|
240
|
+
{ ...smartCollection, sort },
|
|
241
|
+
anonymous,
|
|
242
|
+
),
|
|
243
|
+
limit: 1000,
|
|
244
|
+
});
|
|
245
|
+
expected.set(
|
|
246
|
+
definition.slug,
|
|
247
|
+
list.map((item) => item.slug),
|
|
248
|
+
);
|
|
249
|
+
// The feed keeps the stored order, fallback or not.
|
|
250
|
+
const feedList = await posts.list({
|
|
251
|
+
...smartCollections.toPostFilters(smartCollection, anonymous),
|
|
252
|
+
limit: 50,
|
|
253
|
+
});
|
|
254
|
+
expectedFeed.set(
|
|
255
|
+
definition.slug,
|
|
256
|
+
feedList.map((item) => item.slug),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A small page size, so the pages are read across pagination.
|
|
261
|
+
siteDir = await buildHugoSite(
|
|
262
|
+
await createExportService(
|
|
263
|
+
{ posts, paths, collections, media },
|
|
264
|
+
makeSiteConfig({ siteUrl: SITE_URL, pageSize: 4 }),
|
|
265
|
+
{ storage: null, bundleMedia: false },
|
|
266
|
+
).generateHugoFiles(),
|
|
267
|
+
);
|
|
268
|
+
for (const { slug } of SMART_COLLECTIONS) {
|
|
269
|
+
built.set(slug, await readThreadSlugs(siteDir, slug));
|
|
270
|
+
builtFeed.set(slug, await readFeedSlugs(siteDir, slug, SITE_URL));
|
|
271
|
+
}
|
|
272
|
+
}, 60_000);
|
|
273
|
+
|
|
274
|
+
afterAll(async () => {
|
|
275
|
+
if (siteDir) await rm(siteDir, { recursive: true, force: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it.each(SMART_COLLECTIONS.map(({ slug }) => slug))(
|
|
279
|
+
"%s lists the posts Jant lists, in its order",
|
|
280
|
+
(slug) => {
|
|
281
|
+
expect(built.get(slug)).toEqual(expected.get(slug));
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Equal lists prove nothing if the fixture leaves them empty or alike.
|
|
286
|
+
it("runs on a fixture that tells the conditions apart", () => {
|
|
287
|
+
expect(expected.get("sc-thoughts")).toEqual([
|
|
288
|
+
"a-2024-note",
|
|
289
|
+
"j-new-years-eve",
|
|
290
|
+
"h-tied",
|
|
291
|
+
"i-tied",
|
|
292
|
+
]);
|
|
293
|
+
expect(expected.get("sc-all-rated")?.slice(0, 3)).toEqual([
|
|
294
|
+
"a-2024-note",
|
|
295
|
+
"h-tied",
|
|
296
|
+
"c-hidden-link",
|
|
297
|
+
]);
|
|
298
|
+
expect(expected.get("sc-links")).toEqual(["k-newer-link", "c-hidden-link"]);
|
|
299
|
+
// The page falls back to newest with one rated link; the feed doesn't.
|
|
300
|
+
expect(expectedFeed.get("sc-links")).toEqual([
|
|
301
|
+
"c-hidden-link",
|
|
302
|
+
"k-newer-link",
|
|
303
|
+
]);
|
|
304
|
+
expect(expected.get("sc-titled")).toEqual([
|
|
305
|
+
"b-titled-note",
|
|
306
|
+
"k-newer-link",
|
|
307
|
+
"c-hidden-link",
|
|
308
|
+
"d-featured-quote",
|
|
309
|
+
]);
|
|
310
|
+
expect(expected.get("sc-2024")).toEqual(["j-new-years-eve", "a-2024-note"]);
|
|
311
|
+
expect(expected.get("sc-replies")).toEqual(["b-titled-note"]);
|
|
312
|
+
expect(expected.get("sc-text-or-video")).toEqual([
|
|
313
|
+
"e-untitled-quote",
|
|
314
|
+
"c-hidden-link",
|
|
315
|
+
]);
|
|
316
|
+
expect(expected.get("sc-all-newest")).not.toContain("f-private");
|
|
317
|
+
expect(expected.get("sc-all-newest")).not.toContain("g-draft");
|
|
318
|
+
for (const { slug } of SMART_COLLECTIONS) {
|
|
319
|
+
expect(expected.get(slug)?.length, slug).toBeGreaterThan(0);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it.each(SMART_COLLECTIONS.map(({ slug }) => slug))(
|
|
324
|
+
"%s feed carries what Jant's feed carries, in its order",
|
|
325
|
+
(slug) => {
|
|
326
|
+
expect(builtFeed.get(slug)).toEqual(expectedFeed.get(slug));
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build an export with the real Hugo binary and read lists back out of it.
|
|
3
|
+
*
|
|
4
|
+
* For the tests that hold an exported page to the list Jant shows: the theme
|
|
5
|
+
* reimplements membership and order in Go templates, and only a real build
|
|
6
|
+
* shows what a reader of the static site gets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { isStoredExportFile, type ExportFile } from "../../services/export.js";
|
|
14
|
+
|
|
15
|
+
/** Whether `hugo` is on PATH. Tests that need it skip without it. */
|
|
16
|
+
export const hugoAvailable =
|
|
17
|
+
spawnSync("hugo", ["version"], { stdio: "ignore" }).status === 0;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write an export to a temporary directory and build it, unminified so the
|
|
21
|
+
* markup reads back predictably.
|
|
22
|
+
*
|
|
23
|
+
* @param files - What `generateHugoFiles` returned; stored media is skipped
|
|
24
|
+
* @returns The site directory; the build is in its `public/`
|
|
25
|
+
* @throws {Error} With Hugo's output when the build fails
|
|
26
|
+
* @example
|
|
27
|
+
* const siteDir = await buildHugoSite(await exporter.generateHugoFiles());
|
|
28
|
+
*/
|
|
29
|
+
export async function buildHugoSite(files: ExportFile[]): Promise<string> {
|
|
30
|
+
const siteDir = await mkdtemp(join(tmpdir(), "jant-hugo-site-"));
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
if (isStoredExportFile(file)) continue;
|
|
33
|
+
const target = join(siteDir, file.path);
|
|
34
|
+
await mkdir(dirname(target), { recursive: true });
|
|
35
|
+
await writeFile(target, file.content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { code, log } = await new Promise<{ code: number; log: string }>(
|
|
39
|
+
(resolve) => {
|
|
40
|
+
const child = spawn("hugo", ["--source", siteDir], {
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
});
|
|
43
|
+
let output = "";
|
|
44
|
+
child.stdout?.on("data", (chunk) => (output += chunk.toString()));
|
|
45
|
+
child.stderr?.on("data", (chunk) => (output += chunk.toString()));
|
|
46
|
+
child.on("close", (exitCode) =>
|
|
47
|
+
resolve({ code: exitCode ?? -1, log: output }),
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
if (code !== 0) throw new Error(`Hugo build failed:\n${log}`);
|
|
52
|
+
return siteDir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Thread roots a collection-style page lists, in order, across all of
|
|
57
|
+
* its pages.
|
|
58
|
+
*
|
|
59
|
+
* @param siteDir - From {@link buildHugoSite}
|
|
60
|
+
* @param section - The section's slug
|
|
61
|
+
* @returns Root slugs, first page first
|
|
62
|
+
* @example
|
|
63
|
+
* await readThreadSlugs(siteDir, "ideas"); // ["newest-thread", ...]
|
|
64
|
+
*/
|
|
65
|
+
export async function readThreadSlugs(
|
|
66
|
+
siteDir: string,
|
|
67
|
+
section: string,
|
|
68
|
+
): Promise<string[]> {
|
|
69
|
+
const slugs: string[] = [];
|
|
70
|
+
for (let page = 1; ; page++) {
|
|
71
|
+
const path =
|
|
72
|
+
page === 1
|
|
73
|
+
? join(siteDir, "public", section, "index.html")
|
|
74
|
+
: join(siteDir, "public", section, "page", String(page), "index.html");
|
|
75
|
+
const html = await readFile(path, "utf8").catch(() => null);
|
|
76
|
+
if (html === null) return slugs;
|
|
77
|
+
for (const match of html.matchAll(
|
|
78
|
+
/<div class="thread thread-full[^"]*" data-slug="([^"]+)"/g,
|
|
79
|
+
)) {
|
|
80
|
+
slugs.push(match[1] as string);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The posts a section's Atom feed carries, in order, by slug.
|
|
87
|
+
*
|
|
88
|
+
* @param siteDir - From {@link buildHugoSite}
|
|
89
|
+
* @param section - The section's slug
|
|
90
|
+
* @param siteUrl - The export's site URL, which permalinks start with
|
|
91
|
+
* @returns One slug per entry
|
|
92
|
+
* @example
|
|
93
|
+
* await readFeedSlugs(siteDir, "ideas", "https://example.com");
|
|
94
|
+
*/
|
|
95
|
+
export async function readFeedSlugs(
|
|
96
|
+
siteDir: string,
|
|
97
|
+
section: string,
|
|
98
|
+
siteUrl: string,
|
|
99
|
+
): Promise<string[]> {
|
|
100
|
+
const xml = await readFile(
|
|
101
|
+
join(siteDir, "public", section, "index.xml"),
|
|
102
|
+
"utf8",
|
|
103
|
+
);
|
|
104
|
+
const permalink = new RegExp(
|
|
105
|
+
`href="${siteUrl.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}/([^/"]+)/"`,
|
|
106
|
+
);
|
|
107
|
+
return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map(
|
|
108
|
+
(entry) => entry[1]?.match(permalink)?.[1] ?? "",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The Thread roots a timeline page lists, page by page.
|
|
114
|
+
*
|
|
115
|
+
* A timeline renders each root as a thread preview or a lone post card, and
|
|
116
|
+
* both nest cards that carry their own `data-slug`, so this reads every
|
|
117
|
+
* `data-slug` in order and keeps the first mention of each root.
|
|
118
|
+
*
|
|
119
|
+
* @param siteDir - From {@link buildHugoSite}
|
|
120
|
+
* @param section - The section's slug, or `""` for the home page
|
|
121
|
+
* @param roots - Slugs of every Thread root in the export
|
|
122
|
+
* @returns One list of root slugs per page
|
|
123
|
+
* @example
|
|
124
|
+
* await readRootSlugsByPage(siteDir, "featured", new Set(["hello"]));
|
|
125
|
+
*/
|
|
126
|
+
export async function readRootSlugsByPage(
|
|
127
|
+
siteDir: string,
|
|
128
|
+
section: string,
|
|
129
|
+
roots: ReadonlySet<string>,
|
|
130
|
+
): Promise<string[][]> {
|
|
131
|
+
const pages: string[][] = [];
|
|
132
|
+
for (let page = 1; ; page++) {
|
|
133
|
+
const path =
|
|
134
|
+
page === 1
|
|
135
|
+
? join(siteDir, "public", section, "index.html")
|
|
136
|
+
: join(siteDir, "public", section, "page", String(page), "index.html");
|
|
137
|
+
const html = await readFile(path, "utf8").catch(() => null);
|
|
138
|
+
if (html === null) return pages;
|
|
139
|
+
const slugs: string[] = [];
|
|
140
|
+
for (const match of html.matchAll(/data-slug="([^"]+)"/g)) {
|
|
141
|
+
const slug = match[1] as string;
|
|
142
|
+
if (roots.has(slug) && !slugs.includes(slug)) slugs.push(slug);
|
|
143
|
+
}
|
|
144
|
+
pages.push(slugs);
|
|
145
|
+
}
|
|
146
|
+
}
|