@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.
Files changed (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -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
- /** Fetch published Posts for each requested Thread root. */
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 slug = await resolvedPaths.getPostSlug(row.id);
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 rootVisibilityMap = await getThreadVisibilityMap([row.threadId]);
1552
- const visibility = rootVisibilityMap.get(row.threadId) ?? row.visibility;
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 resolvedPaths.getPostSlugMap(
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
- return this.getById(resolved.postId);
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 ?? timestamp,
2567
- threadUpdatedAt: publishedAt ?? timestamp,
2568
- createdAt: timestamp,
2569
- updatedAt: timestamp,
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 ?? timestamp,
2661
- threadUpdatedAt: publishedAt ?? timestamp,
2662
- createdAt: timestamp,
2663
- updatedAt: timestamp,
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 by `createdAt`/`id` (see the thread
2791
- // queries), so sharing one `publishedAt` cannot reorder anything.
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(posts.createdAt, posts.id);
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 ${posts.createdAt}, ${posts.id}
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 ${posts.createdAt}, ${posts.id}
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 ${posts.createdAt} DESC, ${posts.id} DESC
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 ${posts.createdAt}, ${posts.id}
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, posts.createdAt, posts.id);
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, desc(posts.createdAt), desc(posts.id));
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
- // Note: `./export.js` is loaded lazily inside `exportManagedSite` because it
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
- zip: Uint8Array;
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,
@@ -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
- const existingRow = await loadSingleSiteRow();
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
- let domainRow: typeof siteDomains.$inferSelect | undefined;
253
-
254
- if (options.host) {
255
- const domainRows = await db
256
- .select()
257
- .from(siteDomains)
258
- .where(eq(siteDomains.siteId, created.id))
259
- .orderBy(asc(siteDomains.createdAt))
260
- .limit(1);
261
-
262
- domainRow = domainRows[0];
263
-
264
- if (!domainRow && shouldCreateIfMissing) {
265
- domainRow = (
266
- await db
267
- .insert(siteDomains)
268
- .values({
269
- id: createEntityId("siteDomain"),
270
- siteId: created.id,
271
- host: options.host,
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
  }
@@ -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.
@@ -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;