@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread order on the public surfaces when a reply is older than its root.
|
|
3
|
+
*
|
|
4
|
+
* A post moved into a Thread keeps its own creation time, and an export
|
|
5
|
+
* without `created` dates every post by its publication, so a Thread's replies
|
|
6
|
+
* can carry a `createdAt` earlier than the root's. Thread order still opens on
|
|
7
|
+
* the root: every page and feed that reads the first post as the root has to
|
|
8
|
+
* find it there, or the Thread drops out of Featured and Collections and its
|
|
9
|
+
* page canonicalizes to a reply.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
15
|
+
import { posts as postTable } from "../../../db/schema.js";
|
|
16
|
+
import { toISOString } from "../../../lib/time.js";
|
|
17
|
+
import { collectionRoutes } from "../collection.js";
|
|
18
|
+
import { collectionsPageRoutes } from "../collections.js";
|
|
19
|
+
import { featuredRoutes } from "../featured.js";
|
|
20
|
+
import { pageRoutes } from "../page.js";
|
|
21
|
+
|
|
22
|
+
const ROOT_TEXT = "Root body text";
|
|
23
|
+
const FIRST_TEXT = "First reply text";
|
|
24
|
+
const SECOND_TEXT = "Second reply text";
|
|
25
|
+
|
|
26
|
+
function createThreadOrderTestApp(options: { authenticated?: boolean } = {}) {
|
|
27
|
+
const testApp = createTestApp(options);
|
|
28
|
+
const { app } = testApp;
|
|
29
|
+
|
|
30
|
+
app.use("*", async (c, next) => {
|
|
31
|
+
c.set("publicPath", c.req.path);
|
|
32
|
+
c.set("publicRequestUrl", c.req.url);
|
|
33
|
+
await next();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.route("/featured", featuredRoutes);
|
|
37
|
+
app.route("/collections", collectionsPageRoutes);
|
|
38
|
+
app.route("/collections", collectionRoutes);
|
|
39
|
+
app.route("/", pageRoutes);
|
|
40
|
+
|
|
41
|
+
return testApp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A featured root published after its two replies, with creation times to
|
|
46
|
+
* match — the shape a site rebuilt from a pre-`created` export holds.
|
|
47
|
+
*/
|
|
48
|
+
async function createThreadWithOlderReplies(
|
|
49
|
+
testApp: ReturnType<typeof createThreadOrderTestApp>,
|
|
50
|
+
) {
|
|
51
|
+
const { services, db } = testApp;
|
|
52
|
+
const root = await services.posts.create({
|
|
53
|
+
format: "note",
|
|
54
|
+
title: "Thread root",
|
|
55
|
+
bodyMarkdown: ROOT_TEXT,
|
|
56
|
+
featured: true,
|
|
57
|
+
publishedAt: 3000,
|
|
58
|
+
});
|
|
59
|
+
const first = await services.posts.create({
|
|
60
|
+
format: "note",
|
|
61
|
+
bodyMarkdown: FIRST_TEXT,
|
|
62
|
+
replyToId: root.id,
|
|
63
|
+
publishedAt: 1000,
|
|
64
|
+
});
|
|
65
|
+
const second = await services.posts.create({
|
|
66
|
+
format: "note",
|
|
67
|
+
bodyMarkdown: SECOND_TEXT,
|
|
68
|
+
replyToId: first.id,
|
|
69
|
+
publishedAt: 2000,
|
|
70
|
+
});
|
|
71
|
+
for (const [id, createdAt] of [
|
|
72
|
+
[root.id, 3000],
|
|
73
|
+
[first.id, 1000],
|
|
74
|
+
[second.id, 2000],
|
|
75
|
+
] as const) {
|
|
76
|
+
await db.update(postTable).set({ createdAt }).where(eq(postTable.id, id));
|
|
77
|
+
}
|
|
78
|
+
return { root, first, second };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Assert the three posts appear root first, in Thread order. A page's `<head>`
|
|
83
|
+
* repeats the visited post's text, so only the body counts.
|
|
84
|
+
*/
|
|
85
|
+
function expectRootFirst(markup: string) {
|
|
86
|
+
const bodyStart = markup.indexOf("<body");
|
|
87
|
+
const body = bodyStart === -1 ? markup : markup.slice(bodyStart);
|
|
88
|
+
const root = body.indexOf(ROOT_TEXT);
|
|
89
|
+
const first = body.indexOf(FIRST_TEXT);
|
|
90
|
+
const second = body.indexOf(SECOND_TEXT);
|
|
91
|
+
expect(root).toBeGreaterThan(-1);
|
|
92
|
+
expect(first).toBeGreaterThan(root);
|
|
93
|
+
expect(second).toBeGreaterThan(first);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The post whose `<article>` holds the markup at `index`. */
|
|
97
|
+
function postIdAt(html: string, index: number): string | undefined {
|
|
98
|
+
const articles = html
|
|
99
|
+
.slice(0, index)
|
|
100
|
+
.matchAll(/<article\b[^>]*\bdata-post-id="([^"]+)"/g);
|
|
101
|
+
return [...articles].at(-1)?.[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractCanonicalHref(html: string): string | null {
|
|
105
|
+
const match = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"\s*\/?>/i);
|
|
106
|
+
return match?.[1] ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("Thread order with replies older than the root", () => {
|
|
110
|
+
it("opens the Thread page on the root and points its canonical there", async () => {
|
|
111
|
+
const testApp = createThreadOrderTestApp();
|
|
112
|
+
const { root, second } = await createThreadWithOlderReplies(testApp);
|
|
113
|
+
|
|
114
|
+
const res = await testApp.app.request(`/${second.slug}`);
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
const html = await res.text();
|
|
117
|
+
|
|
118
|
+
expectRootFirst(html);
|
|
119
|
+
expect(extractCanonicalHref(html)).toMatch(new RegExp(`/${root.slug}$`));
|
|
120
|
+
// The page describes the Thread as one article, dated by its root.
|
|
121
|
+
expect(html).toContain(
|
|
122
|
+
`<meta property="article:published_time" content="${toISOString(3000)}"/>`,
|
|
123
|
+
);
|
|
124
|
+
expect(html).toContain(`"datePublished":"${toISOString(3000)}"`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("offers the author a reply on the newest reply, not the root", async () => {
|
|
128
|
+
const testApp = createThreadOrderTestApp({ authenticated: true });
|
|
129
|
+
const { root, second } = await createThreadWithOlderReplies(testApp);
|
|
130
|
+
|
|
131
|
+
const html = await (await testApp.app.request(`/${root.slug}`)).text();
|
|
132
|
+
|
|
133
|
+
expect(html.match(/data-reply-trigger/g)).toHaveLength(1);
|
|
134
|
+
expect(postIdAt(html, html.indexOf("data-reply-trigger"))).toBe(second.id);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps the Thread on the Featured page", async () => {
|
|
138
|
+
const testApp = createThreadOrderTestApp();
|
|
139
|
+
await createThreadWithOlderReplies(testApp);
|
|
140
|
+
|
|
141
|
+
const res = await testApp.app.request("/featured");
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
const html = await res.text();
|
|
144
|
+
|
|
145
|
+
// The curated view shows the root and the Thread's final post, with the
|
|
146
|
+
// reply between them folded into a gap.
|
|
147
|
+
expect(html).toContain(ROOT_TEXT);
|
|
148
|
+
expect(html).not.toContain(FIRST_TEXT);
|
|
149
|
+
expect(html.indexOf(SECOND_TEXT)).toBeGreaterThan(html.indexOf(ROOT_TEXT));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps the Thread in the Featured feed", async () => {
|
|
153
|
+
const testApp = createThreadOrderTestApp();
|
|
154
|
+
const { root } = await createThreadWithOlderReplies(testApp);
|
|
155
|
+
|
|
156
|
+
const res = await testApp.app.request("/featured/feed");
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
const xml = await res.text();
|
|
159
|
+
|
|
160
|
+
expect(xml.match(/<entry>/g)).toHaveLength(1);
|
|
161
|
+
expect(xml).toContain(`/${root.slug}`);
|
|
162
|
+
expectRootFirst(xml);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("opens the Thread on its root on a Collection page", async () => {
|
|
166
|
+
const testApp = createThreadOrderTestApp();
|
|
167
|
+
const collection = await testApp.services.collections.create({
|
|
168
|
+
slug: "reading",
|
|
169
|
+
title: "Reading",
|
|
170
|
+
});
|
|
171
|
+
const { root } = await createThreadWithOlderReplies(testApp);
|
|
172
|
+
await testApp.services.collections.addThread(collection.id, root.id);
|
|
173
|
+
|
|
174
|
+
const res = await testApp.app.request(`/${collection.slug}`);
|
|
175
|
+
expect(res.status).toBe(200);
|
|
176
|
+
|
|
177
|
+
expectRootFirst(await res.text());
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -432,10 +432,14 @@ export async function renderArchivePage(
|
|
|
432
432
|
|
|
433
433
|
if (params.collectionMissing) return c.notFound();
|
|
434
434
|
|
|
435
|
-
|
|
435
|
+
// The chrome is independent of every query below, so it goes out with them
|
|
436
|
+
// rather than ahead of them. Its `isAuthenticated` is `c.var.isAuthenticated`
|
|
437
|
+
// verbatim, which is what the filters below read.
|
|
438
|
+
const navDataPromise = getNavigationData(c);
|
|
439
|
+
const isAuthenticated = c.var.isAuthenticated;
|
|
436
440
|
|
|
437
441
|
const filters = buildArchivePostFilters(params, {
|
|
438
|
-
isAuthenticated
|
|
442
|
+
isAuthenticated,
|
|
439
443
|
lang: getViewLang(c) ?? undefined,
|
|
440
444
|
});
|
|
441
445
|
|
|
@@ -456,13 +460,14 @@ export async function renderArchivePage(
|
|
|
456
460
|
// media EXISTS subqueries.
|
|
457
461
|
const baselineFilters = hasActiveArchiveFilter(params.selection)
|
|
458
462
|
? buildArchivePostFilters(params, {
|
|
459
|
-
isAuthenticated
|
|
463
|
+
isAuthenticated,
|
|
460
464
|
lang: filters.lang,
|
|
461
465
|
selection: {},
|
|
462
466
|
})
|
|
463
467
|
: undefined;
|
|
464
468
|
|
|
465
469
|
const [
|
|
470
|
+
navData,
|
|
466
471
|
totalCount,
|
|
467
472
|
baselineCount,
|
|
468
473
|
monthlyCounts,
|
|
@@ -470,6 +475,7 @@ export async function renderArchivePage(
|
|
|
470
475
|
availableYears,
|
|
471
476
|
listedCollections,
|
|
472
477
|
] = await Promise.all([
|
|
478
|
+
navDataPromise,
|
|
473
479
|
services.posts.count(filters),
|
|
474
480
|
baselineFilters
|
|
475
481
|
? services.posts.count(baselineFilters)
|
|
@@ -502,13 +508,6 @@ export async function renderArchivePage(
|
|
|
502
508
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
503
509
|
|
|
504
510
|
const mediaCtx = createMediaContext(appConfig);
|
|
505
|
-
const allPostIds = posts.map((p) => p.id);
|
|
506
|
-
const archiveAliasesMap =
|
|
507
|
-
await c.var.services.paths.getPostAliases(allPostIds);
|
|
508
|
-
const archiveAliasMap = new Map<string, string>();
|
|
509
|
-
for (const [id, aliases] of archiveAliasesMap) {
|
|
510
|
-
if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
|
|
511
|
-
}
|
|
512
511
|
|
|
513
512
|
// --- List view: flat timeline items (no month grouping) ------------------
|
|
514
513
|
|
|
@@ -543,10 +542,17 @@ export async function renderArchivePage(
|
|
|
543
542
|
);
|
|
544
543
|
|
|
545
544
|
const postIds = posts.map((p) => p.id);
|
|
546
|
-
|
|
545
|
+
// Only the grid renders permalinks from this map. The list view goes
|
|
546
|
+
// through `assembleTimelineItems`, which loads the same aliases itself.
|
|
547
|
+
const [rawMediaMap, replyCounts, archiveAliasesMap] = await Promise.all([
|
|
547
548
|
services.media.getByPostIds(postIds),
|
|
548
549
|
services.posts.getReplyCounts(postIds),
|
|
550
|
+
services.paths.getPostAliases(postIds),
|
|
549
551
|
]);
|
|
552
|
+
const archiveAliasMap = new Map<string, string>();
|
|
553
|
+
for (const [id, aliases] of archiveAliasesMap) {
|
|
554
|
+
if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
|
|
555
|
+
}
|
|
550
556
|
const mediaMap = buildMediaMap(
|
|
551
557
|
rawMediaMap,
|
|
552
558
|
mediaCtx.r2PublicUrl,
|
|
@@ -32,7 +32,10 @@ export const featuredRoutes = new Hono<Env>();
|
|
|
32
32
|
* @returns Featured page response
|
|
33
33
|
*/
|
|
34
34
|
export async function renderFeaturedPage(c: Context<Env>): Promise<Response> {
|
|
35
|
-
|
|
35
|
+
// The chrome and the timeline are independent, so they load together. The
|
|
36
|
+
// timeline only ever wanted `navData.isAuthenticated`, which is
|
|
37
|
+
// `c.var.isAuthenticated` verbatim.
|
|
38
|
+
const navDataPromise = getNavigationData(c);
|
|
36
39
|
const i18n = getI18n(c);
|
|
37
40
|
|
|
38
41
|
const page = parsePageNumber(c.req.query("page"));
|
|
@@ -43,10 +46,13 @@ export async function renderFeaturedPage(c: Context<Env>): Promise<Response> {
|
|
|
43
46
|
}),
|
|
44
47
|
);
|
|
45
48
|
const paginatedPageTitle = formatPageLabel(page);
|
|
46
|
-
const { items, currentPage, totalPages } = await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
const [navData, { items, currentPage, totalPages }] = await Promise.all([
|
|
50
|
+
navDataPromise,
|
|
51
|
+
assembleFeaturedTimeline(c, {
|
|
52
|
+
page,
|
|
53
|
+
isAuthenticated: c.var.isAuthenticated,
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
50
56
|
|
|
51
57
|
return renderPublicPage(c, {
|
|
52
58
|
title:
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../../lib/post-display.js";
|
|
25
25
|
import { buildArticleJsonLd } from "../../lib/structured-data.js";
|
|
26
26
|
import {
|
|
27
|
+
normalizePath,
|
|
27
28
|
toAbsoluteAssetUrl,
|
|
28
29
|
toAbsoluteSiteUrl,
|
|
29
30
|
toPublicHref,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
import { getOrBuildEntry } from "../../i18n/supported-locales.js";
|
|
40
41
|
import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
|
|
41
42
|
import { isTextAttachment } from "../../services/media.js";
|
|
43
|
+
import type { ResolvedPath } from "../../services/path.js";
|
|
42
44
|
import type { Post } from "../../types.js";
|
|
43
45
|
import { renderArchivePage } from "./archive.js";
|
|
44
46
|
import { renderCollectionFeed, renderCollectionPage } from "./collection.js";
|
|
@@ -68,10 +70,10 @@ interface TextPreviewAutoOpen {
|
|
|
68
70
|
* content at every reply URL. Point the canonical to the thread root so
|
|
69
71
|
* crawlers consolidate ranking on one URL.
|
|
70
72
|
*
|
|
71
|
-
* The root post is always at index 0 of `threadPostViews
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* The root post is always at index 0 of `threadPostViews`: Thread order puts
|
|
74
|
+
* the root first even when a reply is older than it. When `threadPostViews` is
|
|
75
|
+
* undefined the post is not part of a multi-post thread, so the post itself is
|
|
76
|
+
* the root.
|
|
75
77
|
*/
|
|
76
78
|
function buildPostCanonicalHref(
|
|
77
79
|
postView: { permalink: string },
|
|
@@ -432,6 +434,29 @@ pageRoutes.get("/preview/:slug", requireAuth(), async (c) => {
|
|
|
432
434
|
return renderPost(c, post, { allowDraft: true, isPreview: true });
|
|
433
435
|
});
|
|
434
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Resolve a stored path, reusing the lookup the stored-redirect middleware
|
|
439
|
+
* already made for this request's own address.
|
|
440
|
+
*
|
|
441
|
+
* Only that one address is reusable: a `/feed` suffix or a text deep link asks
|
|
442
|
+
* about a different path, and a language view strips its prefix before asking,
|
|
443
|
+
* so the stored form is compared rather than assumed.
|
|
444
|
+
*
|
|
445
|
+
* @param c - Hono context
|
|
446
|
+
* @param storedPath - Registry path form, without a leading slash
|
|
447
|
+
* @returns The resolved record, or null when nothing is registered there
|
|
448
|
+
*/
|
|
449
|
+
async function resolveStoredPath(
|
|
450
|
+
c: Context<Env>,
|
|
451
|
+
storedPath: string,
|
|
452
|
+
): Promise<ResolvedPath | null> {
|
|
453
|
+
const lookup = c.var.pathLookup;
|
|
454
|
+
if (lookup && lookup.path === normalizePath(storedPath)) {
|
|
455
|
+
return lookup.record;
|
|
456
|
+
}
|
|
457
|
+
return c.var.services.paths.resolve(storedPath);
|
|
458
|
+
}
|
|
459
|
+
|
|
435
460
|
/**
|
|
436
461
|
* Resolve a path through the path registry: posts, collections, their aliases,
|
|
437
462
|
* archive URLs, stored redirects, and text-attachment deep links.
|
|
@@ -459,7 +484,7 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
|
|
|
459
484
|
|
|
460
485
|
// Stored redirects outrank the normal post/collection/deep-link resolvers,
|
|
461
486
|
// but can never shadow an explicit or reserved application route.
|
|
462
|
-
const resolved = await c
|
|
487
|
+
const resolved = await resolveStoredPath(c, fullPath);
|
|
463
488
|
if (resolved?.kind === "redirect" && resolved.redirectToPath) {
|
|
464
489
|
const target = `/${resolved.redirectToPath}`;
|
|
465
490
|
// Follow the stored redirect within the current view; the hop that lands
|
|
@@ -528,26 +553,25 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
|
|
|
528
553
|
const resolvedPost = await c.var.services.paths.resolve(slugPart);
|
|
529
554
|
|
|
530
555
|
if (resolvedPost?.postId) {
|
|
531
|
-
const
|
|
556
|
+
const loaded = await c.var.services.posts.getWithCanonicalAlias(
|
|
557
|
+
resolvedPost.postId,
|
|
558
|
+
);
|
|
559
|
+
const post = loaded?.post;
|
|
532
560
|
if (!post || post.status === "draft") return c.notFound();
|
|
533
561
|
|
|
534
|
-
if (post.visibility === "private") {
|
|
535
|
-
|
|
536
|
-
if (!navData.isAuthenticated) return c.notFound();
|
|
562
|
+
if (post.visibility === "private" && !c.var.isAuthenticated) {
|
|
563
|
+
return c.notFound();
|
|
537
564
|
}
|
|
538
565
|
|
|
539
566
|
// Redirect slug → alias if one exists (same pattern as post pages)
|
|
540
|
-
if (resolvedPost.kind === "slug") {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
567
|
+
if (resolvedPost.kind === "slug" && loaded.canonicalAlias) {
|
|
568
|
+
return c.redirect(
|
|
569
|
+
toPublicPath(
|
|
570
|
+
`${loaded.canonicalAlias}/text/${mediaId}`,
|
|
571
|
+
sitePathPrefix,
|
|
572
|
+
),
|
|
573
|
+
301,
|
|
544
574
|
);
|
|
545
|
-
if (alias) {
|
|
546
|
-
return c.redirect(
|
|
547
|
-
toPublicPath(`/${alias.path}/text/${mediaId}`, sitePathPrefix),
|
|
548
|
-
301,
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
575
|
}
|
|
552
576
|
|
|
553
577
|
// A text attachment belongs to a post, so it inherits that post's single
|
|
@@ -597,26 +621,24 @@ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
|
|
|
597
621
|
}
|
|
598
622
|
|
|
599
623
|
if (resolved.postId) {
|
|
600
|
-
const
|
|
624
|
+
const loaded = await c.var.services.posts.getWithCanonicalAlias(
|
|
625
|
+
resolved.postId,
|
|
626
|
+
);
|
|
627
|
+
const post = loaded?.post;
|
|
601
628
|
if (!post) return c.notFound();
|
|
602
629
|
|
|
603
630
|
const allowDraft = canRenderDraftAboutEditor(c, fullPath, post);
|
|
604
631
|
if (post.status === "draft" && !allowDraft) return c.notFound();
|
|
605
632
|
|
|
606
|
-
if (post.visibility === "private") {
|
|
607
|
-
|
|
608
|
-
if (!navData.isAuthenticated) return c.notFound();
|
|
633
|
+
if (post.visibility === "private" && !c.var.isAuthenticated) {
|
|
634
|
+
return c.notFound();
|
|
609
635
|
}
|
|
610
636
|
|
|
611
637
|
// If accessed via slug but an alias exists, the alias is canonical.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
post.id,
|
|
617
|
-
);
|
|
618
|
-
if (alias) canonicalPath = `/${alias.path}`;
|
|
619
|
-
}
|
|
638
|
+
const canonicalPath =
|
|
639
|
+
resolved.kind === "slug" && loaded.canonicalAlias
|
|
640
|
+
? loaded.canonicalAlias
|
|
641
|
+
: `/${fullPath}`;
|
|
620
642
|
|
|
621
643
|
// One address per post: a language-prefixed URL is only ever a way in.
|
|
622
644
|
if (inLanguageView || canonicalPath !== `/${fullPath}`) {
|
|
@@ -31,7 +31,8 @@ export async function renderSearchPage(c: Context<Env>): Promise<Response> {
|
|
|
31
31
|
const pageParam = c.req.query("page");
|
|
32
32
|
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// The chrome does not gate the query, so it loads alongside it.
|
|
35
|
+
const navDataPromise = getNavigationData(c);
|
|
35
36
|
|
|
36
37
|
// Only search if there's a query
|
|
37
38
|
let results: SearchResult[] = [];
|
|
@@ -63,7 +64,8 @@ export async function renderSearchPage(c: Context<Env>): Promise<Response> {
|
|
|
63
64
|
// Transform to View Models
|
|
64
65
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
65
66
|
const postIds = results.map((r) => r.post.id);
|
|
66
|
-
const [aliasesMap, collectionsMap] = await Promise.all([
|
|
67
|
+
const [navData, aliasesMap, collectionsMap] = await Promise.all([
|
|
68
|
+
navDataPromise,
|
|
67
69
|
c.var.services.paths.getPostAliases(postIds),
|
|
68
70
|
c.var.services.collections.getCollectionsByPostIds(postIds),
|
|
69
71
|
]);
|
|
@@ -24,6 +24,7 @@ describe("getInstanceReadiness", () => {
|
|
|
24
24
|
}),
|
|
25
25
|
).resolves.toEqual({
|
|
26
26
|
status: "ok",
|
|
27
|
+
version: "test-version",
|
|
27
28
|
checks: {
|
|
28
29
|
startupConfig: { ok: true },
|
|
29
30
|
database: { ok: true },
|
|
@@ -40,6 +41,7 @@ describe("getInstanceReadiness", () => {
|
|
|
40
41
|
}),
|
|
41
42
|
).resolves.toEqual({
|
|
42
43
|
status: "error",
|
|
44
|
+
version: "test-version",
|
|
43
45
|
checks: {
|
|
44
46
|
startupConfig: {
|
|
45
47
|
ok: false,
|
|
@@ -104,6 +106,26 @@ describe("getInstanceReadiness", () => {
|
|
|
104
106
|
);
|
|
105
107
|
});
|
|
106
108
|
|
|
109
|
+
// What a deploy check compares: the build that answers, and when its
|
|
110
|
+
// process started. A Worker has no process, so it reports only the build.
|
|
111
|
+
it("reports the build, and the process start where there is one", async () => {
|
|
112
|
+
const { sqlite } = createTestDatabase();
|
|
113
|
+
const env = {
|
|
114
|
+
AUTH_SECRET: "test-secret-with-enough-entropy-for-readiness",
|
|
115
|
+
NODE_SQLITE: sqlite,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const withoutStart = await getInstanceReadiness(env);
|
|
119
|
+
expect(withoutStart.version).toBe("test-version");
|
|
120
|
+
expect(withoutStart).not.toHaveProperty("startedAt");
|
|
121
|
+
|
|
122
|
+
const withStart = await getInstanceReadiness({
|
|
123
|
+
...env,
|
|
124
|
+
NODE_STARTED_AT: 1789000000,
|
|
125
|
+
});
|
|
126
|
+
expect(withStart.startedAt).toBe(1789000000);
|
|
127
|
+
});
|
|
128
|
+
|
|
107
129
|
it("reports a missing database binding", async () => {
|
|
108
130
|
await expect(
|
|
109
131
|
getInstanceReadiness({
|
|
@@ -111,6 +133,7 @@ describe("getInstanceReadiness", () => {
|
|
|
111
133
|
}),
|
|
112
134
|
).resolves.toEqual({
|
|
113
135
|
status: "error",
|
|
136
|
+
version: "test-version",
|
|
114
137
|
checks: {
|
|
115
138
|
startupConfig: { ok: true },
|
|
116
139
|
database: {
|
package/src/runtime/node.ts
CHANGED
|
@@ -12,14 +12,21 @@ import {
|
|
|
12
12
|
shouldUseSecureCookies,
|
|
13
13
|
} from "../lib/env.js";
|
|
14
14
|
import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
|
|
15
|
+
import { ValidationError } from "../lib/errors.js";
|
|
15
16
|
import { createMemoryRateLimiter } from "../lib/rate-limit-memory.js";
|
|
16
17
|
import type { RateLimiter } from "../lib/rate-limit.js";
|
|
18
|
+
import { InstanceSetupSchema } from "../lib/schemas.js";
|
|
17
19
|
import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
|
|
18
20
|
import {
|
|
19
21
|
createHostedHandoffService,
|
|
20
22
|
type HostedHandoffService,
|
|
21
23
|
} from "../services/hosted-handoff.js";
|
|
24
|
+
import {
|
|
25
|
+
createBootstrapService,
|
|
26
|
+
type SetUpInstanceResult,
|
|
27
|
+
} from "../services/bootstrap.js";
|
|
22
28
|
import { createServices, type Services } from "../services/index.js";
|
|
29
|
+
import { TRANSIENT_SINGLE_SITE_ID } from "../services/site.js";
|
|
23
30
|
import type { Site, SiteDomain } from "../types/entities.js";
|
|
24
31
|
import type { Bindings } from "../types/bindings.js";
|
|
25
32
|
import {
|
|
@@ -229,3 +236,45 @@ export async function createNodeCliRuntime(
|
|
|
229
236
|
storage: createStorageDriver(env),
|
|
230
237
|
};
|
|
231
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Set up a self-hosted Node install without a browser, the way the two setup
|
|
242
|
+
* screens would. Backs `jant setup`.
|
|
243
|
+
*
|
|
244
|
+
* @param env - Bindings with a resolved `NODE_DATABASE`
|
|
245
|
+
* @param input - The owner's credentials and the site's answers, unvalidated
|
|
246
|
+
* @returns What setup found, and the site
|
|
247
|
+
* @throws {ValidationError} When the input fails `InstanceSetupSchema`
|
|
248
|
+
* @throws {Error} Whenever `BootstrapService.setUpInstance` refuses
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* await setUpNodeInstance(bindings, {
|
|
252
|
+
* email: "owner@example.com",
|
|
253
|
+
* password: "correct horse battery",
|
|
254
|
+
* });
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export async function setUpNodeInstance(
|
|
258
|
+
env: Bindings,
|
|
259
|
+
input: unknown,
|
|
260
|
+
): Promise<SetUpInstanceResult> {
|
|
261
|
+
const nodeDatabase = env.NODE_DATABASE;
|
|
262
|
+
if (!nodeDatabase) {
|
|
263
|
+
throw new Error("Node setup requires a resolved database binding.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const parsed = InstanceSetupSchema.safeParse(input);
|
|
267
|
+
if (!parsed.success) {
|
|
268
|
+
throw new ValidationError(
|
|
269
|
+
parsed.error.issues[0]?.message ?? "Invalid setup input.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// No request resolved a site, and setup never reads the one it is bound to.
|
|
274
|
+
return createBootstrapService(nodeDatabase.db, TRANSIENT_SINGLE_SITE_ID, {
|
|
275
|
+
schema: nodeDatabase.schema,
|
|
276
|
+
databaseDialect: nodeDatabase.dialect,
|
|
277
|
+
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
|
278
|
+
siteResolutionMode: getSiteResolutionMode(env),
|
|
279
|
+
}).setUpInstance(parsed.data);
|
|
280
|
+
}
|
package/src/runtime/readiness.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getAuthSecretReadinessError,
|
|
6
6
|
getHostBasedStartupConfigurationIssues,
|
|
7
7
|
} from "../lib/startup-config.js";
|
|
8
|
+
import { CORE_VERSION } from "../lib/version.js";
|
|
8
9
|
import { createSiteService } from "../services/site.js";
|
|
9
10
|
import type { Bindings } from "../types/bindings.js";
|
|
10
11
|
|
|
@@ -15,6 +16,15 @@ export interface ReadinessCheckStatus {
|
|
|
15
16
|
|
|
16
17
|
export interface InstanceReadinessResult {
|
|
17
18
|
status: "ok" | "error";
|
|
19
|
+
/** The running build: the package version, then the build's commit id. */
|
|
20
|
+
version: string;
|
|
21
|
+
/**
|
|
22
|
+
* When the Node process started serving, in Unix seconds. With `version`,
|
|
23
|
+
* it lets a deploy check from outside that the instance answering is one
|
|
24
|
+
* started after the deploy, not an older one a rollback left running.
|
|
25
|
+
* Absent on Workers.
|
|
26
|
+
*/
|
|
27
|
+
startedAt?: number;
|
|
18
28
|
checks: {
|
|
19
29
|
startupConfig: ReadinessCheckStatus;
|
|
20
30
|
database: ReadinessCheckStatus;
|
|
@@ -117,6 +127,7 @@ export async function getInstanceReadiness(
|
|
|
117
127
|
| "INTERNAL_ADMIN_TOKEN"
|
|
118
128
|
| "NODE_DATABASE"
|
|
119
129
|
| "NODE_SQLITE"
|
|
130
|
+
| "NODE_STARTED_AT"
|
|
120
131
|
| "SITE_RESOLUTION_MODE"
|
|
121
132
|
>,
|
|
122
133
|
): Promise<InstanceReadinessResult> {
|
|
@@ -125,6 +136,10 @@ export async function getInstanceReadiness(
|
|
|
125
136
|
|
|
126
137
|
return {
|
|
127
138
|
status: startupConfig.ok && database.ok ? "ok" : "error",
|
|
139
|
+
version: CORE_VERSION,
|
|
140
|
+
...(env.NODE_STARTED_AT === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { startedAt: env.NODE_STARTED_AT }),
|
|
128
143
|
checks: {
|
|
129
144
|
startupConfig,
|
|
130
145
|
database,
|