@jant/core 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
package/src/services/post.ts
CHANGED
|
@@ -275,7 +275,7 @@ export interface CollectionFeedEntry {
|
|
|
275
275
|
|
|
276
276
|
export interface FeaturedThreadTimelinePost {
|
|
277
277
|
post: Post;
|
|
278
|
-
/** Zero-based position among the Thread's published Posts. */
|
|
278
|
+
/** Zero-based position among the Thread's published Posts; the root is 0. */
|
|
279
279
|
position: number;
|
|
280
280
|
}
|
|
281
281
|
|
|
@@ -344,8 +344,37 @@ export type TranslationCandidateResolution =
|
|
|
344
344
|
/** Its group already holds this Thread's language. */
|
|
345
345
|
| { status: "group_language_taken"; language: string };
|
|
346
346
|
|
|
347
|
+
/** A Post together with the custom URL that has taken over as its address. */
|
|
348
|
+
export interface PostWithCanonicalAlias {
|
|
349
|
+
post: Post;
|
|
350
|
+
/**
|
|
351
|
+
* The newest registered `alias`, which is the address a slug URL redirects
|
|
352
|
+
* to. Null when the Post still answers at its slug.
|
|
353
|
+
*/
|
|
354
|
+
canonicalAlias: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
347
357
|
export interface PostService {
|
|
348
358
|
getById(id: string): Promise<Post | null>;
|
|
359
|
+
/**
|
|
360
|
+
* The Post a resolved path points at, plus its canonical address, in one
|
|
361
|
+
* read.
|
|
362
|
+
*
|
|
363
|
+
* Serving a Post URL needs three things the registry and the Post row hold
|
|
364
|
+
* between them: the row, the Post's slug, and whether a custom URL has taken
|
|
365
|
+
* over as its address. Asking for those separately put three round trips at
|
|
366
|
+
* the head of every Post page, so they come back together.
|
|
367
|
+
*
|
|
368
|
+
* @param postId - TypeID of the Post
|
|
369
|
+
* @returns The hydrated Post and its canonical alias, or null when no such
|
|
370
|
+
* Post exists or it has no slug
|
|
371
|
+
* @example
|
|
372
|
+
* ```ts
|
|
373
|
+
* const loaded = await posts.getWithCanonicalAlias(resolved.postId);
|
|
374
|
+
* if (loaded?.canonicalAlias) redirect(loaded.canonicalAlias);
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
getWithCanonicalAlias(postId: string): Promise<PostWithCanonicalAlias | null>;
|
|
349
378
|
getBodyContent(id: string): Promise<PostBodyContent | null>;
|
|
350
379
|
getBySlug(slug: string): Promise<Post | null>;
|
|
351
380
|
suggestSlug(input: {
|
|
@@ -476,6 +505,10 @@ export interface PostService {
|
|
|
476
505
|
* Used when replacing a saved thread draft with a new version.
|
|
477
506
|
*/
|
|
478
507
|
deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
508
|
+
/**
|
|
509
|
+
* Every Post in a Thread, drafts included, in Thread order: the root first,
|
|
510
|
+
* then replies by creation time, then ID.
|
|
511
|
+
*/
|
|
479
512
|
getThread(rootId: string): Promise<Post[]>;
|
|
480
513
|
/**
|
|
481
514
|
* 1-based position of a Post in the reply chain running from its Thread root
|
|
@@ -663,7 +696,10 @@ export interface PostService {
|
|
|
663
696
|
collectionIds: string[],
|
|
664
697
|
options?: CollectionFeedEntryOptions,
|
|
665
698
|
): Promise<CollectionFeedEntry[]>;
|
|
666
|
-
/**
|
|
699
|
+
/**
|
|
700
|
+
* Fetch published Posts for each requested Thread root, in Thread order: the
|
|
701
|
+
* root first, then replies by creation time, then ID.
|
|
702
|
+
*/
|
|
667
703
|
getPublishedThreads(
|
|
668
704
|
rootIds: string[],
|
|
669
705
|
options?: Pick<PostFilters, "publishedBefore">,
|
|
@@ -671,7 +707,8 @@ export interface PostService {
|
|
|
671
707
|
/** Get distinct years with posts, bucketed on the `sortBy` time axis */
|
|
672
708
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
673
709
|
/**
|
|
674
|
-
* For each Thread ID, resolve the Post that currently ends the chain
|
|
710
|
+
* For each Thread ID, resolve the Post that currently ends the chain: the
|
|
711
|
+
* last in Thread order, so the root only while it has no replies.
|
|
675
712
|
*
|
|
676
713
|
* Two callers need two different answers. Readers ask what the audience can
|
|
677
714
|
* see, so drafts must not count — the Reply affordance belongs on the last
|
|
@@ -757,6 +794,9 @@ export async function rebuildPostBodyHtmlWithRuntimeSettings(
|
|
|
757
794
|
|
|
758
795
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
759
796
|
|
|
797
|
+
const INVALID_POST_BODY_MESSAGE =
|
|
798
|
+
"The body isn't a TipTap JSON document. Send a doc node as a JSON string, or send Markdown as bodyMarkdown.";
|
|
799
|
+
|
|
760
800
|
function isValidSlug(value: string): boolean {
|
|
761
801
|
return SLUG_RE.test(value);
|
|
762
802
|
}
|
|
@@ -952,6 +992,33 @@ export function createPostService(
|
|
|
952
992
|
return posts.publishedAt;
|
|
953
993
|
}
|
|
954
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Thread order: the root first, then replies by creation time, then ID.
|
|
997
|
+
*
|
|
998
|
+
* The root leads whatever its own `createdAt` says. A reply can be older
|
|
999
|
+
* than its root — a post moved into a Thread keeps its creation time, and an
|
|
1000
|
+
* export without `created` dates every post by publication — and ordering
|
|
1001
|
+
* on time alone would open such a Thread on a reply. Every surface that
|
|
1002
|
+
* reads the first post as the root would then lose it: the Thread page's
|
|
1003
|
+
* canonical URL, Featured, Collections, the feeds, and the reply guard's
|
|
1004
|
+
* idea of where the Thread ends. Every query that orders a Thread's posts
|
|
1005
|
+
* goes through here so they agree.
|
|
1006
|
+
*
|
|
1007
|
+
* @param direction - `desc` walks from the Thread's end back to its root
|
|
1008
|
+
* @returns ORDER BY terms, placed after any partitioning column
|
|
1009
|
+
* @example
|
|
1010
|
+
* db.select().from(posts).orderBy(posts.threadId, ...threadOrder());
|
|
1011
|
+
* sql`ROW_NUMBER() OVER (ORDER BY ${sql.join(threadOrder(), sql`, `)})`;
|
|
1012
|
+
*/
|
|
1013
|
+
function threadOrder(direction: "asc" | "desc" = "asc"): SQL[] {
|
|
1014
|
+
const terms = [
|
|
1015
|
+
sql`CASE WHEN ${posts.replyToId} IS NULL THEN 0 ELSE 1 END`,
|
|
1016
|
+
sql`${posts.createdAt}`,
|
|
1017
|
+
sql`${posts.id}`,
|
|
1018
|
+
];
|
|
1019
|
+
return direction === "asc" ? terms : terms.map((term) => desc(term));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
955
1022
|
function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
|
|
956
1023
|
return databaseDialect === "pg"
|
|
957
1024
|
? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
|
|
@@ -1542,14 +1609,80 @@ export function createPostService(
|
|
|
1542
1609
|
};
|
|
1543
1610
|
}
|
|
1544
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* Thread visibility the loaded rows already answer, plus the thread ids they
|
|
1614
|
+
* do not.
|
|
1615
|
+
*
|
|
1616
|
+
* A reply's own `visibility` column is null — it inherits the root's — so a
|
|
1617
|
+
* row only speaks for its thread when it *is* the root. List surfaces query
|
|
1618
|
+
* thread roots exclusively (`excludeReplies`), so for them this covers every
|
|
1619
|
+
* thread and the follow-up read never runs.
|
|
1620
|
+
*
|
|
1621
|
+
* Entries are set only for a non-null visibility, matching
|
|
1622
|
+
* {@link getThreadVisibilityMap}: a root row with no visibility is treated as
|
|
1623
|
+
* unreadable by both paths.
|
|
1624
|
+
*/
|
|
1625
|
+
function seedThreadVisibility(rows: (typeof posts.$inferSelect)[]): {
|
|
1626
|
+
known: Map<string, Visibility>;
|
|
1627
|
+
missingThreadIds: string[];
|
|
1628
|
+
} {
|
|
1629
|
+
const known = new Map<string, Visibility>();
|
|
1630
|
+
const covered = new Set<string>();
|
|
1631
|
+
for (const row of rows) {
|
|
1632
|
+
if (row.id !== row.threadId) continue;
|
|
1633
|
+
covered.add(row.threadId);
|
|
1634
|
+
if (row.visibility) {
|
|
1635
|
+
known.set(row.threadId, ensurePostVisibility(row.visibility, Error));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const missingThreadIds = [
|
|
1639
|
+
...new Set(
|
|
1640
|
+
rows
|
|
1641
|
+
.map((row) => row.threadId)
|
|
1642
|
+
.filter((threadId) => !covered.has(threadId)),
|
|
1643
|
+
),
|
|
1644
|
+
];
|
|
1645
|
+
return { known, missingThreadIds };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Slug lookup and root-visibility lookup are independent, so they go out
|
|
1650
|
+
* together rather than one after the other.
|
|
1651
|
+
*/
|
|
1652
|
+
async function loadHydrationMaps(
|
|
1653
|
+
rows: (typeof posts.$inferSelect)[],
|
|
1654
|
+
): Promise<{
|
|
1655
|
+
slugMap: Map<string, string>;
|
|
1656
|
+
visibilityMap: Map<string, Visibility>;
|
|
1657
|
+
}> {
|
|
1658
|
+
const { known, missingThreadIds } = seedThreadVisibility(rows);
|
|
1659
|
+
const [slugMap, fetched] = await Promise.all([
|
|
1660
|
+
resolvedPaths.getPostSlugMap(rows.map((row) => row.id)),
|
|
1661
|
+
missingThreadIds.length > 0
|
|
1662
|
+
? getThreadVisibilityMap(missingThreadIds)
|
|
1663
|
+
: Promise.resolve(new Map<string, Visibility>()),
|
|
1664
|
+
]);
|
|
1665
|
+
for (const [threadId, visibility] of fetched) {
|
|
1666
|
+
known.set(threadId, visibility);
|
|
1667
|
+
}
|
|
1668
|
+
return { slugMap, visibilityMap: known };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1545
1671
|
async function hydratePost(
|
|
1546
1672
|
row: typeof posts.$inferSelect | undefined,
|
|
1673
|
+
knownSlug?: string,
|
|
1547
1674
|
): Promise<Post | null> {
|
|
1548
1675
|
if (!row) return null;
|
|
1549
|
-
const
|
|
1676
|
+
const { known, missingThreadIds } = seedThreadVisibility([row]);
|
|
1677
|
+
const [slug, fetched] = await Promise.all([
|
|
1678
|
+
knownSlug ?? resolvedPaths.getPostSlug(row.id),
|
|
1679
|
+
missingThreadIds.length > 0
|
|
1680
|
+
? getThreadVisibilityMap(missingThreadIds)
|
|
1681
|
+
: Promise.resolve(new Map<string, Visibility>()),
|
|
1682
|
+
]);
|
|
1550
1683
|
if (!slug) return null;
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1684
|
+
const visibility =
|
|
1685
|
+
known.get(row.threadId) ?? fetched.get(row.threadId) ?? row.visibility;
|
|
1553
1686
|
if (!visibility) return null;
|
|
1554
1687
|
return toPost(row, slug, visibility);
|
|
1555
1688
|
}
|
|
@@ -1558,17 +1691,11 @@ export function createPostService(
|
|
|
1558
1691
|
rows: (typeof posts.$inferSelect)[],
|
|
1559
1692
|
): Promise<Post[]> {
|
|
1560
1693
|
if (rows.length === 0) return [];
|
|
1561
|
-
const slugMap = await
|
|
1562
|
-
rows.map((row) => row.id),
|
|
1563
|
-
);
|
|
1564
|
-
const rootVisibilityMap = await getThreadVisibilityMap(
|
|
1565
|
-
rows.map((row) => row.threadId),
|
|
1566
|
-
);
|
|
1694
|
+
const { slugMap, visibilityMap } = await loadHydrationMaps(rows);
|
|
1567
1695
|
return rows
|
|
1568
1696
|
.map((row) => {
|
|
1569
1697
|
const slug = slugMap.get(row.id);
|
|
1570
|
-
const visibility =
|
|
1571
|
-
rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
1698
|
+
const visibility = visibilityMap.get(row.threadId) ?? row.visibility;
|
|
1572
1699
|
return slug && visibility ? toPost(row, slug, visibility) : null;
|
|
1573
1700
|
})
|
|
1574
1701
|
.filter((row): row is Post => row !== null);
|
|
@@ -1883,6 +2010,45 @@ export function createPostService(
|
|
|
1883
2010
|
return hydratePost(result[0]);
|
|
1884
2011
|
},
|
|
1885
2012
|
|
|
2013
|
+
async getWithCanonicalAlias(postId) {
|
|
2014
|
+
const rows = await db
|
|
2015
|
+
.select({ post: posts, path: pathRegistry })
|
|
2016
|
+
.from(posts)
|
|
2017
|
+
.leftJoin(
|
|
2018
|
+
pathRegistry,
|
|
2019
|
+
and(
|
|
2020
|
+
eq(pathRegistry.siteId, siteId),
|
|
2021
|
+
eq(pathRegistry.postId, posts.id),
|
|
2022
|
+
),
|
|
2023
|
+
)
|
|
2024
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
2025
|
+
.orderBy(asc(pathRegistry.createdAt), asc(pathRegistry.id));
|
|
2026
|
+
|
|
2027
|
+
const row = rows[0];
|
|
2028
|
+
if (!row) return null;
|
|
2029
|
+
|
|
2030
|
+
let slug: string | null = null;
|
|
2031
|
+
let canonicalAlias: string | null = null;
|
|
2032
|
+
for (const { path } of rows) {
|
|
2033
|
+
if (!path) continue;
|
|
2034
|
+
if (path.kind === "slug") {
|
|
2035
|
+
slug = path.path;
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
// Rows arrive oldest first, so the last `alias` seen is the newest —
|
|
2039
|
+
// the one a slug URL redirects to. Ties within a second fall to the
|
|
2040
|
+
// higher id rather than to whatever the storage engine returned first.
|
|
2041
|
+
if (path.kind === "alias") canonicalAlias = `/${path.path}`;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// A Post with no slug row has no address, which is what `hydratePost`
|
|
2045
|
+
// says by returning null for it.
|
|
2046
|
+
if (!slug) return null;
|
|
2047
|
+
|
|
2048
|
+
const post = await hydratePost(row.post, slug);
|
|
2049
|
+
return post ? { post, canonicalAlias } : null;
|
|
2050
|
+
},
|
|
2051
|
+
|
|
1886
2052
|
async getBodyContent(id) {
|
|
1887
2053
|
const post = await this.getById(id);
|
|
1888
2054
|
if (!post) return null;
|
|
@@ -1902,7 +2068,14 @@ export function createPostService(
|
|
|
1902
2068
|
if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
|
|
1903
2069
|
return null;
|
|
1904
2070
|
}
|
|
1905
|
-
|
|
2071
|
+
const result = await db
|
|
2072
|
+
.select()
|
|
2073
|
+
.from(posts)
|
|
2074
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, resolved.postId)))
|
|
2075
|
+
.limit(1);
|
|
2076
|
+
// The registry row that answered is the slug, so hydration does not go
|
|
2077
|
+
// back for it.
|
|
2078
|
+
return hydratePost(result[0], resolved.path);
|
|
1906
2079
|
},
|
|
1907
2080
|
|
|
1908
2081
|
async suggestSlug(input) {
|
|
@@ -2224,6 +2397,9 @@ export function createPostService(
|
|
|
2224
2397
|
const preparedBody = trimmedBody
|
|
2225
2398
|
? tryPreparePostBodyHtml(id, trimmedBody)
|
|
2226
2399
|
: null;
|
|
2400
|
+
if (preparedBody && !preparedBody.ok) {
|
|
2401
|
+
throw new ValidationError(INVALID_POST_BODY_MESSAGE);
|
|
2402
|
+
}
|
|
2227
2403
|
const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
|
|
2228
2404
|
const title = data.title?.trim() || null;
|
|
2229
2405
|
const quoteText = data.quoteText?.trim() || null;
|
|
@@ -2404,6 +2580,8 @@ export function createPostService(
|
|
|
2404
2580
|
assertDraftPublishedAt(status, data.publishedAt);
|
|
2405
2581
|
const publishedAt =
|
|
2406
2582
|
status === "published" ? (data.publishedAt ?? timestamp) : null;
|
|
2583
|
+
const createdAt = data.createdAt ?? timestamp;
|
|
2584
|
+
const updatedAt = data.updatedAt ?? createdAt;
|
|
2407
2585
|
|
|
2408
2586
|
// Resolve slug from slug, path, or title
|
|
2409
2587
|
let slug: string;
|
|
@@ -2563,10 +2741,10 @@ export function createPostService(
|
|
|
2563
2741
|
translationGroupId,
|
|
2564
2742
|
quietReply: isQuietReply,
|
|
2565
2743
|
publishedAt,
|
|
2566
|
-
lastActivityAt: publishedAt ??
|
|
2567
|
-
threadUpdatedAt: publishedAt ??
|
|
2568
|
-
createdAt
|
|
2569
|
-
updatedAt
|
|
2744
|
+
lastActivityAt: publishedAt ?? updatedAt,
|
|
2745
|
+
threadUpdatedAt: publishedAt ?? updatedAt,
|
|
2746
|
+
createdAt,
|
|
2747
|
+
updatedAt,
|
|
2570
2748
|
}),
|
|
2571
2749
|
);
|
|
2572
2750
|
|
|
@@ -2657,10 +2835,10 @@ export function createPostService(
|
|
|
2657
2835
|
translationGroupId,
|
|
2658
2836
|
quietReply: isQuietReply,
|
|
2659
2837
|
publishedAt,
|
|
2660
|
-
lastActivityAt: publishedAt ??
|
|
2661
|
-
threadUpdatedAt: publishedAt ??
|
|
2662
|
-
createdAt
|
|
2663
|
-
updatedAt
|
|
2838
|
+
lastActivityAt: publishedAt ?? updatedAt,
|
|
2839
|
+
threadUpdatedAt: publishedAt ?? updatedAt,
|
|
2840
|
+
createdAt,
|
|
2841
|
+
updatedAt,
|
|
2664
2842
|
});
|
|
2665
2843
|
|
|
2666
2844
|
await tx.insert(pathRegistry).values({
|
|
@@ -2787,8 +2965,8 @@ export function createPostService(
|
|
|
2787
2965
|
// A reply with no date of its own belongs to the root's moment, not to
|
|
2788
2966
|
// whenever the request happened to run. Without this, backdating the
|
|
2789
2967
|
// root leaves its replies stamped today and the thread reads as if it
|
|
2790
|
-
// spanned years. Thread order is
|
|
2791
|
-
//
|
|
2968
|
+
// spanned years. Thread order is the root, then `createdAt`/`id` (see
|
|
2969
|
+
// `threadOrder`), so sharing one `publishedAt` cannot reorder anything.
|
|
2792
2970
|
const rootPublishedAt = created[0]?.publishedAt ?? undefined;
|
|
2793
2971
|
const postData: CreatePost = {
|
|
2794
2972
|
...data,
|
|
@@ -2948,6 +3126,9 @@ export function createPostService(
|
|
|
2948
3126
|
const preparedBody = normalizedBody
|
|
2949
3127
|
? tryPreparePostBodyHtml(existing.id, normalizedBody)
|
|
2950
3128
|
: null;
|
|
3129
|
+
if (preparedBody && !preparedBody.ok) {
|
|
3130
|
+
throw new ValidationError(INVALID_POST_BODY_MESSAGE);
|
|
3131
|
+
}
|
|
2951
3132
|
updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
|
|
2952
3133
|
updates.body = updatedBody;
|
|
2953
3134
|
updates.bodyHtml = preparedBody?.ok
|
|
@@ -3486,7 +3667,7 @@ export function createPostService(
|
|
|
3486
3667
|
.select()
|
|
3487
3668
|
.from(posts)
|
|
3488
3669
|
.where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
|
|
3489
|
-
.orderBy(
|
|
3670
|
+
.orderBy(...threadOrder());
|
|
3490
3671
|
|
|
3491
3672
|
return hydratePosts(rows);
|
|
3492
3673
|
},
|
|
@@ -4020,10 +4201,9 @@ export function createPostService(
|
|
|
4020
4201
|
.select({
|
|
4021
4202
|
id: posts.id,
|
|
4022
4203
|
threadId: posts.threadId,
|
|
4023
|
-
createdAt: posts.createdAt,
|
|
4024
4204
|
previewRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
4025
4205
|
PARTITION BY ${posts.threadId}
|
|
4026
|
-
ORDER BY ${
|
|
4206
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
4027
4207
|
) AS INTEGER)`.as("preview_rank"),
|
|
4028
4208
|
})
|
|
4029
4209
|
.from(posts)
|
|
@@ -4041,15 +4221,10 @@ export function createPostService(
|
|
|
4041
4221
|
.select({
|
|
4042
4222
|
id: rankedReplies.id,
|
|
4043
4223
|
threadId: rankedReplies.threadId,
|
|
4044
|
-
createdAt: rankedReplies.createdAt,
|
|
4045
4224
|
})
|
|
4046
4225
|
.from(rankedReplies)
|
|
4047
4226
|
.where(lte(rankedReplies.previewRank, previewCount))
|
|
4048
|
-
.orderBy(
|
|
4049
|
-
rankedReplies.threadId,
|
|
4050
|
-
rankedReplies.createdAt,
|
|
4051
|
-
rankedReplies.id,
|
|
4052
|
-
);
|
|
4227
|
+
.orderBy(rankedReplies.threadId, sql`${rankedReplies.previewRank}`);
|
|
4053
4228
|
|
|
4054
4229
|
const hydratedPosts = await hydratePostsById(
|
|
4055
4230
|
rankedRows.map((row) => row.id),
|
|
@@ -4079,11 +4254,11 @@ export function createPostService(
|
|
|
4079
4254
|
threadId: posts.threadId,
|
|
4080
4255
|
firstReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
4081
4256
|
PARTITION BY ${posts.threadId}
|
|
4082
|
-
ORDER BY ${
|
|
4257
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
4083
4258
|
) AS INTEGER)`.as("first_reply_rank"),
|
|
4084
4259
|
latestReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
4085
4260
|
PARTITION BY ${posts.threadId}
|
|
4086
|
-
ORDER BY ${
|
|
4261
|
+
ORDER BY ${sql.join(threadOrder("desc"), sql`, `)}
|
|
4087
4262
|
) AS INTEGER)`.as("latest_reply_rank"),
|
|
4088
4263
|
totalReplyCount: sql<number>`CAST(COUNT(*) OVER (
|
|
4089
4264
|
PARTITION BY ${posts.threadId}
|
|
@@ -4268,7 +4443,7 @@ export function createPostService(
|
|
|
4268
4443
|
const threadPosition = sql<number>`CAST(
|
|
4269
4444
|
row_number() OVER (
|
|
4270
4445
|
PARTITION BY ${posts.threadId}
|
|
4271
|
-
ORDER BY ${
|
|
4446
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
4272
4447
|
) - 1 AS INTEGER
|
|
4273
4448
|
)`.as("thread_position");
|
|
4274
4449
|
const threadPostCount = sql<number>`CAST(
|
|
@@ -4609,7 +4784,7 @@ export function createPostService(
|
|
|
4609
4784
|
.select()
|
|
4610
4785
|
.from(posts)
|
|
4611
4786
|
.where(and(...conditions))
|
|
4612
|
-
.orderBy(posts.threadId,
|
|
4787
|
+
.orderBy(posts.threadId, ...threadOrder());
|
|
4613
4788
|
});
|
|
4614
4789
|
|
|
4615
4790
|
for (const post of await hydratePosts(rows)) {
|
|
@@ -4642,7 +4817,7 @@ export function createPostService(
|
|
|
4642
4817
|
...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
|
|
4643
4818
|
),
|
|
4644
4819
|
)
|
|
4645
|
-
.orderBy(posts.threadId,
|
|
4820
|
+
.orderBy(posts.threadId, ...threadOrder("desc"));
|
|
4646
4821
|
|
|
4647
4822
|
for (const row of rows) {
|
|
4648
4823
|
if (!result.has(row.threadId)) {
|
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
} from "../ui/font-themes.js";
|
|
30
30
|
import type { Site, SiteDomain } from "../types.js";
|
|
31
31
|
import { createCollectionService } from "./collection.js";
|
|
32
|
-
|
|
33
|
-
// pulls in Vite-specific `?raw` asset imports (CSS/HTML/JS templates) at
|
|
34
|
-
// module-load time. Importing it eagerly here would force every consumer of
|
|
35
|
-
// `services/index.ts` — including Node-only dev scripts run under tsx — to
|
|
36
|
-
// resolve those Vite-only imports just to construct the services bundle.
|
|
32
|
+
import { createExportService } from "./export.js";
|
|
37
33
|
import { createMediaService } from "./media.js";
|
|
38
34
|
import { createNavItemService } from "./navigation.js";
|
|
39
35
|
import { createPathService } from "./path.js";
|
|
@@ -84,7 +80,8 @@ export interface ExportManagedSiteDeps {
|
|
|
84
80
|
|
|
85
81
|
export interface ManagedSiteExportResult {
|
|
86
82
|
filename: string;
|
|
87
|
-
|
|
83
|
+
/** The site's Hugo export as a ZIP stream; see `generateHugoSite`. */
|
|
84
|
+
zip: ReadableStream<Uint8Array>;
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
export interface ManagedSiteMediaUsageResult {
|
|
@@ -810,7 +807,6 @@ export function createSiteAdminService(
|
|
|
810
807
|
);
|
|
811
808
|
const navItemList = await navItems.list();
|
|
812
809
|
const appleTouchKey = allSettings[SETTINGS_KEYS.SITE_FAVICON_APPLE_TOUCH];
|
|
813
|
-
const { createExportService } = await import("./export.js");
|
|
814
810
|
const exportService = createExportService(
|
|
815
811
|
{
|
|
816
812
|
collections,
|
package/src/services/site.ts
CHANGED
|
@@ -17,8 +17,18 @@ export interface SiteLookupResult {
|
|
|
17
17
|
domain: SiteDomain | null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Where a single-site install's row and domain come from. Every field applies
|
|
22
|
+
* only when the site is created; an existing site keeps its own.
|
|
23
|
+
*/
|
|
20
24
|
export interface EnsureSingleSiteOptions {
|
|
21
25
|
host?: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The id to create the site with, instead of a random one. A deployment
|
|
28
|
+
* that restores a snapshot on every start gives the site the snapshot's id,
|
|
29
|
+
* so media URLs — which carry the site id — stay the same across rebuilds.
|
|
30
|
+
*/
|
|
31
|
+
id?: string;
|
|
22
32
|
key?: string;
|
|
23
33
|
pathPrefix?: string | null;
|
|
24
34
|
}
|
|
@@ -131,6 +141,9 @@ function toSiteDomain(row: typeof _sqliteSiteDomains.$inferSelect): SiteDomain {
|
|
|
131
141
|
|
|
132
142
|
export const TRANSIENT_SINGLE_SITE_ID = "sit_pending";
|
|
133
143
|
|
|
144
|
+
/** Joined-row ceiling for the single-site read — see `loadSingleSiteWithDomain`. */
|
|
145
|
+
const SINGLE_SITE_JOIN_SCAN_LIMIT = 25;
|
|
146
|
+
|
|
134
147
|
export function createTransientSite(key = "default"): Site {
|
|
135
148
|
return {
|
|
136
149
|
id: TRANSIENT_SINGLE_SITE_ID,
|
|
@@ -175,6 +188,49 @@ export function createSiteService(
|
|
|
175
188
|
return rows[0];
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The instance's one site together with its oldest domain, in one read.
|
|
193
|
+
*
|
|
194
|
+
* Every request resolves the site before it can do anything else, so the two
|
|
195
|
+
* reads this replaces sat at the head of the chain, one waiting on the other.
|
|
196
|
+
* Joining them keeps the domain ordering the separate read had — oldest
|
|
197
|
+
* first — and the multi-site guard that read carried.
|
|
198
|
+
*
|
|
199
|
+
* The limit bounds the scan, but it now bounds *joined* rows rather than
|
|
200
|
+
* sites, so one site with many domains could in principle fill the window and
|
|
201
|
+
* hide a second site from the guard. That case falls back to
|
|
202
|
+
* {@link loadSingleSiteRow}, which bounds sites; a single-site install has a
|
|
203
|
+
* handful of domains, so it is not reached in practice.
|
|
204
|
+
*/
|
|
205
|
+
async function loadSingleSiteWithDomain(): Promise<{
|
|
206
|
+
site: typeof sites.$inferSelect | undefined;
|
|
207
|
+
domain: typeof siteDomains.$inferSelect | undefined;
|
|
208
|
+
}> {
|
|
209
|
+
const rows = await db
|
|
210
|
+
.select({ site: sites, domain: siteDomains })
|
|
211
|
+
.from(sites)
|
|
212
|
+
.leftJoin(siteDomains, eq(siteDomains.siteId, sites.id))
|
|
213
|
+
.orderBy(asc(sites.createdAt), asc(siteDomains.createdAt))
|
|
214
|
+
.limit(SINGLE_SITE_JOIN_SCAN_LIMIT);
|
|
215
|
+
|
|
216
|
+
const distinctSites = new Map<string, typeof sites.$inferSelect>();
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
distinctSites.set(row.site.id, row.site);
|
|
219
|
+
}
|
|
220
|
+
if (distinctSites.size > 1) {
|
|
221
|
+
throw createSingleSiteModeConfigurationError([...distinctSites.values()]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Only one site is in play, so the first non-null domain is its oldest.
|
|
225
|
+
const domain = rows.find((row) => row.domain)?.domain ?? undefined;
|
|
226
|
+
|
|
227
|
+
if (rows.length === SINGLE_SITE_JOIN_SCAN_LIMIT) {
|
|
228
|
+
return { site: await loadSingleSiteRow(), domain };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { site: rows[0]?.site, domain };
|
|
232
|
+
}
|
|
233
|
+
|
|
178
234
|
return {
|
|
179
235
|
async list() {
|
|
180
236
|
const rows = await db.select().from(sites).orderBy(asc(sites.createdAt));
|
|
@@ -223,7 +279,12 @@ export function createSiteService(
|
|
|
223
279
|
|
|
224
280
|
async resolveSingleSite(options = {}) {
|
|
225
281
|
const shouldCreateIfMissing = options.createIfMissing ?? false;
|
|
226
|
-
|
|
282
|
+
// The domain is only read when the caller names a host, so only that
|
|
283
|
+
// path pays for the join.
|
|
284
|
+
const loaded = options.host
|
|
285
|
+
? await loadSingleSiteWithDomain()
|
|
286
|
+
: { site: await loadSingleSiteRow(), domain: undefined };
|
|
287
|
+
const existingRow = loaded.site;
|
|
227
288
|
const timestamp = now();
|
|
228
289
|
const created = existingRow
|
|
229
290
|
? existingRow
|
|
@@ -232,7 +293,7 @@ export function createSiteService(
|
|
|
232
293
|
await db
|
|
233
294
|
.insert(sites)
|
|
234
295
|
.values({
|
|
235
|
-
id: createEntityId("site"),
|
|
296
|
+
id: options.id ?? createEntityId("site"),
|
|
236
297
|
key: options.key?.trim() || "default",
|
|
237
298
|
status: "active",
|
|
238
299
|
createdAt: timestamp,
|
|
@@ -249,35 +310,26 @@ export function createSiteService(
|
|
|
249
310
|
};
|
|
250
311
|
}
|
|
251
312
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
pathPrefix: options.pathPrefix?.trim() || null,
|
|
273
|
-
kind: "primary",
|
|
274
|
-
redirectToPrimary: true,
|
|
275
|
-
createdAt: timestamp,
|
|
276
|
-
updatedAt: timestamp,
|
|
277
|
-
})
|
|
278
|
-
.returning()
|
|
279
|
-
)[0];
|
|
280
|
-
}
|
|
313
|
+
// A site that was just inserted has no domains yet, whatever the read
|
|
314
|
+
// above returned.
|
|
315
|
+
let domainRow = existingRow ? loaded.domain : undefined;
|
|
316
|
+
|
|
317
|
+
if (options.host && !domainRow && shouldCreateIfMissing) {
|
|
318
|
+
domainRow = (
|
|
319
|
+
await db
|
|
320
|
+
.insert(siteDomains)
|
|
321
|
+
.values({
|
|
322
|
+
id: createEntityId("siteDomain"),
|
|
323
|
+
siteId: created.id,
|
|
324
|
+
host: options.host,
|
|
325
|
+
pathPrefix: options.pathPrefix?.trim() || null,
|
|
326
|
+
kind: "primary",
|
|
327
|
+
redirectToPrimary: true,
|
|
328
|
+
createdAt: timestamp,
|
|
329
|
+
updatedAt: timestamp,
|
|
330
|
+
})
|
|
331
|
+
.returning()
|
|
332
|
+
)[0];
|
|
281
333
|
}
|
|
282
334
|
|
|
283
335
|
return {
|
package/src/styles/ui.css
CHANGED
|
@@ -2346,6 +2346,13 @@
|
|
|
2346
2346
|
margin-top: 0.65rem;
|
|
2347
2347
|
}
|
|
2348
2348
|
|
|
2349
|
+
/* A preview is a block of image, not a line of text: commentary set 0.65rem
|
|
2350
|
+
under it reads as a caption. Give it the same air the title leaves above
|
|
2351
|
+
the preview, so the image sits between the two rather than on the body. */
|
|
2352
|
+
[data-format="link"] .link-preview + [data-post-body] {
|
|
2353
|
+
margin-top: 1.4rem;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2349
2356
|
.feed-link-domain {
|
|
2350
2357
|
display: inline-flex;
|
|
2351
2358
|
align-items: center;
|
|
@@ -5354,7 +5361,6 @@
|
|
|
5354
5361
|
.home-branding-credit {
|
|
5355
5362
|
margin-top: var(--space-xl);
|
|
5356
5363
|
padding-bottom: var(--space-xl);
|
|
5357
|
-
text-align: center;
|
|
5358
5364
|
color: var(--site-text-secondary);
|
|
5359
5365
|
font-size: var(--type-base);
|
|
5360
5366
|
}
|
package/src/types/app-context.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Hono } from "hono";
|
|
10
10
|
import type { Services } from "../services/index.js";
|
|
11
|
+
import type { ResolvedPath } from "../services/path.js";
|
|
11
12
|
import type { HostedHandoffService } from "../services/hosted-handoff.js";
|
|
12
13
|
import type { Auth } from "../auth.js";
|
|
13
14
|
import type { AppConfig } from "./config.js";
|
|
@@ -40,6 +41,21 @@ export interface AppVariables {
|
|
|
40
41
|
storage: StorageDriver | null;
|
|
41
42
|
publicRequestUrl: string;
|
|
42
43
|
publicPath: string;
|
|
44
|
+
/**
|
|
45
|
+
* The `path_registry` row for this request's own path, read once by the
|
|
46
|
+
* stored-redirect middleware so the catch-all route does not resolve the same
|
|
47
|
+
* address a second time.
|
|
48
|
+
*
|
|
49
|
+
* `record` is null when nothing is registered at `path`, which is a real
|
|
50
|
+
* answer and worth reusing. The whole field is absent when the middleware
|
|
51
|
+
* skipped the lookup (API and asset paths) — readers must fall back to
|
|
52
|
+
* `services.paths.resolve` rather than treat absence as "nothing registered".
|
|
53
|
+
*
|
|
54
|
+
* `path` is the stored form the lookup used, so a reader compares its own
|
|
55
|
+
* normalized path against it before trusting the record: a language view
|
|
56
|
+
* strips its prefix and is asking about a different address.
|
|
57
|
+
*/
|
|
58
|
+
pathLookup?: { path: string; record: ResolvedPath | null };
|
|
43
59
|
/**
|
|
44
60
|
* Cached session for the current request. `null` when unauthenticated or
|
|
45
61
|
* when the session lookup errored. Populated by `attachSession` middleware.
|
package/src/types/bindings.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface Bindings {
|
|
|
28
28
|
R2?: R2Bucket;
|
|
29
29
|
NODE_DATABASE?: NodeDatabaseBinding;
|
|
30
30
|
NODE_SQLITE?: BetterSqlite3.Database;
|
|
31
|
+
/**
|
|
32
|
+
* When this Node process started serving, in Unix seconds. Set by the
|
|
33
|
+
* request handler; a Worker has no process to date.
|
|
34
|
+
*/
|
|
35
|
+
NODE_STARTED_AT?: number;
|
|
31
36
|
SITE_ORIGIN?: EnvBindingValue;
|
|
32
37
|
SITE_PATH_PREFIX?: EnvBindingValue;
|
|
33
38
|
DEFAULT_THEME?: EnvBindingValue;
|