@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
@@ -1,11 +1,19 @@
1
1
  import { strToU8, zipSync } from "fflate";
2
- import { Extension, Node } from "@tiptap/core";
2
+ import { Extension, Node, getSchema } from "@tiptap/core";
3
+ import { Fragment } from "@tiptap/pm/model";
3
4
  import { MarkdownManager } from "@tiptap/markdown";
4
5
  import CodeBlock from "@tiptap/extension-code-block";
6
+ import { OrderedList } from "@tiptap/extension-list";
7
+ import HardBreak from "@tiptap/extension-hard-break";
8
+ import Paragraph from "@tiptap/extension-paragraph";
9
+ import Bold from "@tiptap/extension-bold";
10
+ import Italic from "@tiptap/extension-italic";
11
+ import Strike from "@tiptap/extension-strike";
5
12
  import Link from "@tiptap/extension-link";
6
13
  import StarterKit from "@tiptap/starter-kit";
7
14
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
8
15
  import { sql } from "drizzle-orm";
16
+ import { makeZip } from "client-zip";
9
17
  //#region \0rolldown/runtime.js
10
18
  var __create = Object.create;
11
19
  var __defProp = Object.defineProperty;
@@ -60,6 +68,7 @@ var url_exports = /* @__PURE__ */ __exportAll({
60
68
  buildSiteUrl: () => buildSiteUrl,
61
69
  extractDisplayDomain: () => extractDisplayDomain,
62
70
  extractDomain: () => extractDomain,
71
+ getPostPath: () => getPostPath,
63
72
  getSiteOrigin: () => getSiteOrigin$1,
64
73
  getSitePathPrefix: () => getSitePathPrefix$1,
65
74
  isFullUrl: () => isFullUrl,
@@ -447,6 +456,24 @@ function sanitizeUrlWithProtocols(url, protocols) {
447
456
  return `${sitePathPrefix}${normalizedPath}`;
448
457
  }
449
458
  /**
459
+ * The internal path a Post is served at: its oldest custom path when it has
460
+ * one, else its slug.
461
+ *
462
+ * This is the Post's permalink before the site path prefix, and so the `<id>`
463
+ * of its Atom entry. The Hugo export writes that `<id>` into front matter so
464
+ * an exported feed keeps it, which is why the rule lives here rather than
465
+ * inside `toPostView`.
466
+ *
467
+ * @param slug - The Post's current slug
468
+ * @param aliasPath - Its oldest custom path, `paths.getPostAliases()`'s first
469
+ * @returns Internal path rooted at `/`
470
+ * @example
471
+ * getPostPath("xta29"); // "/xta29"
472
+ * getPostPath("links-4", "/blog/links/4"); // "/blog/links/4"
473
+ */ function getPostPath(slug, aliasPath) {
474
+ return aliasPath ?? `/${slug}`;
475
+ }
476
+ /**
450
477
  * Convert an app-local href to its public path while leaving external URLs
451
478
  * unchanged.
452
479
  *
@@ -1858,7 +1885,11 @@ function buildJantBrandPackReadme() {
1858
1885
  * - Cloudinary
1859
1886
  * - Any service with similar URL-based transformation API
1860
1887
  *
1861
- * @param originalUrl - The original image URL
1888
+ * A root-relative source is written without its leading slash, the form the
1889
+ * transformation service resolves against its own host. Kept, it would produce
1890
+ * `…/width=200//media/abc123`, which Cloudflare cannot fetch (`err=9404`).
1891
+ *
1892
+ * @param originalUrl - The original image URL, absolute or root-relative
1862
1893
  * @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
1863
1894
  * @param options - Transformation options (width, height, quality, format, fit)
1864
1895
  * @returns The transformed URL or original URL if transformations are not configured
@@ -1869,9 +1900,13 @@ function buildJantBrandPackReadme() {
1869
1900
  * getImageUrl("/media/abc123", undefined, { width: 200 });
1870
1901
  * // Returns: "/media/abc123"
1871
1902
  *
1872
- * // With transform URL - returns transformed
1903
+ * // With transform URL and a root-relative source
1873
1904
  * getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
1874
- * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/https://example.com/media/abc123"
1905
+ * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/media/abc123"
1906
+ *
1907
+ * // With transform URL and an absolute source
1908
+ * getImageUrl("https://cdn.example.com/media/abc123", "https://example.com/cdn-cgi/image", { width: 200 });
1909
+ * // Returns: "https://example.com/cdn-cgi/image/width=200/https://cdn.example.com/media/abc123"
1875
1910
  * ```
1876
1911
  */ function getImageUrl(originalUrl, transformUrl, options) {
1877
1912
  if (!transformUrl || !options || Object.keys(options).length === 0) return originalUrl;
@@ -1882,7 +1917,9 @@ function buildJantBrandPackReadme() {
1882
1917
  if (options.format) params.push(`format=${options.format}`);
1883
1918
  if (options.fit) params.push(`fit=${options.fit}`);
1884
1919
  if (params.length === 0) return originalUrl;
1885
- return `${transformUrl}/${params.join(",")}/${originalUrl}`;
1920
+ const base = transformUrl.replace(/\/+$/, "");
1921
+ const source = originalUrl.startsWith("/") && !originalUrl.startsWith("//") ? originalUrl.slice(1) : originalUrl;
1922
+ return `${base}/${params.join(",")}/${source}`;
1886
1923
  }
1887
1924
  /**
1888
1925
  * Returns the appropriate public URL base for a given storage provider.
@@ -4735,6 +4772,226 @@ var MarkdownCodeBlock = CodeBlock.extend({ renderMarkdown(node, helpers) {
4735
4772
  return `${fence}${language}\n${content}\n${fence}`;
4736
4773
  } });
4737
4774
  var SemanticLink = Link.extend({ clearable: false });
4775
+ var LINE_START_BLOCK_SYNTAX = [
4776
+ [/^([ \t]{0,3})(\d{1,9})([.)])(?=[ \t]|$)/, "$1$2\\$3"],
4777
+ [/^([ \t]{0,3})([-+])(?=[ \t]|$)/, "$1\\$2"],
4778
+ [/^([ \t]{0,3})(#{1,6})(?=[ \t]|$)/, "$1\\$2"],
4779
+ [/^([ \t]{0,3})([-=])(?=[-= \t]*$)/, "$1\\$2"]
4780
+ ];
4781
+ /**
4782
+ * Backslash-escape block syntax that a paragraph line happens to open with.
4783
+ *
4784
+ * Text nodes only get inline escaping, so a paragraph reading `1. Pony`, or a
4785
+ * hard break followed by `- note`, came back from the Markdown as a list.
4786
+ *
4787
+ * @param markdown - A paragraph's rendered Markdown
4788
+ * @returns The same Markdown with each line's leading block syntax escaped
4789
+ * @example
4790
+ * escapeLineStartBlockSyntax("Update: \n1. Pony"); // "Update: \n1\\. Pony"
4791
+ */ function escapeLineStartBlockSyntax(markdown) {
4792
+ return markdown.split("\n").map((line) => LINE_START_BLOCK_SYNTAX.reduce((escaped, [pattern, replacement]) => escaped.replace(pattern, replacement), line)).join("\n");
4793
+ }
4794
+ /** The HTML tag each emphasis mark falls back to. */ var HTML_EMPHASIS_TAGS = {
4795
+ bold: "strong",
4796
+ italic: "em",
4797
+ strike: "s"
4798
+ };
4799
+ var EMPHASIS_MARK_TYPES = Object.keys(HTML_EMPHASIS_TAGS);
4800
+ /** Marks that write their own delimiter characters around the text. */ var DELIMITED_MARK_TYPES = /* @__PURE__ */ new Set([
4801
+ ...EMPHASIS_MARK_TYPES,
4802
+ "code",
4803
+ "link"
4804
+ ]);
4805
+ /**
4806
+ * Serialization-only mark attribute: write this run with HTML tags. It is
4807
+ * set by `markEmphasisDelimiters` on a copy of the document and never stored.
4808
+ */ var HTML_EMPHASIS_ATTR = "markdownAsHtml";
4809
+ function classifyFlankingChar(char) {
4810
+ if (char === void 0 || /\s/u.test(char)) return "space";
4811
+ return /[\p{P}\p{S}]/u.test(char) ? "punctuation" : "other";
4812
+ }
4813
+ function hasMark(node, type) {
4814
+ return node?.marks?.some((mark) => mark.type === type) ?? false;
4815
+ }
4816
+ /**
4817
+ * Whether a `**`, `*`, or `~~` at one end of a run can open or close it.
4818
+ *
4819
+ * CommonMark's flanking rule, which GFM strikethrough shares: the character
4820
+ * on the text side must not be whitespace, and when it is punctuation, the
4821
+ * character on the outside must be whitespace or punctuation too. The rule is
4822
+ * symmetric, so one check covers the opening and the closing delimiter.
4823
+ *
4824
+ * @param nodes - The inline nodes of one block
4825
+ * @param index - The run's first node (`side: "start"`) or last (`"end"`)
4826
+ * @param side - Which end of the run
4827
+ * @param markType - The run's mark
4828
+ * @returns True when Markdown delimiters work at this end
4829
+ */ function delimiterFlanks(nodes, index, side, markType) {
4830
+ const node = nodes[index];
4831
+ const neighbor = nodes[side === "start" ? index - 1 : index + 1];
4832
+ const chars = [...node?.text ?? ""];
4833
+ const edge = side === "start" ? chars : chars.slice().reverse();
4834
+ const innerChar = edge.find((char) => !/\s/u.test(char));
4835
+ const inner = node?.marks?.some((mark) => mark.type !== markType && DELIMITED_MARK_TYPES.has(mark.type) && !hasMark(neighbor, mark.type)) ?? false ? "punctuation" : classifyFlankingChar(innerChar);
4836
+ let outer;
4837
+ if (edge[0] !== void 0 && /\s/u.test(edge[0])) outer = "space";
4838
+ else if (!neighbor || neighbor.type === "hardBreak") outer = "space";
4839
+ else if (neighbor.type === "text") {
4840
+ const neighborChars = [...neighbor.text ?? ""];
4841
+ outer = classifyFlankingChar(side === "start" ? neighborChars.at(-1) : neighborChars[0]);
4842
+ } else outer = "punctuation";
4843
+ return inner !== "space" && (inner !== "punctuation" || outer !== "other");
4844
+ }
4845
+ function markEmphasisRuns(nodes) {
4846
+ const result = nodes.map((node) => ({ ...node }));
4847
+ for (const markType of EMPHASIS_MARK_TYPES) {
4848
+ let start = 0;
4849
+ while (start < result.length) {
4850
+ if (result[start]?.type !== "text" || !hasMark(result[start], markType)) {
4851
+ start += 1;
4852
+ continue;
4853
+ }
4854
+ let end = start;
4855
+ while (result[end + 1]?.type === "text" && hasMark(result[end + 1], markType)) end += 1;
4856
+ if (!delimiterFlanks(result, start, "start", markType) || !delimiterFlanks(result, end, "end", markType)) for (let index = start; index <= end; index += 1) {
4857
+ const node = result[index];
4858
+ node.marks = node.marks?.map((mark) => mark.type === markType ? {
4859
+ ...mark,
4860
+ attrs: {
4861
+ ...mark.attrs,
4862
+ [HTML_EMPHASIS_ATTR]: true
4863
+ }
4864
+ } : mark);
4865
+ }
4866
+ start = end + 1;
4867
+ }
4868
+ }
4869
+ return result;
4870
+ }
4871
+ /**
4872
+ * Flag the emphasis runs whose Markdown delimiters a CommonMark parser would
4873
+ * leave as literal characters, so they serialize as HTML tags instead.
4874
+ *
4875
+ * Chinese and Japanese put no space around punctuation, so `**说话。**来的人`
4876
+ * is common, and neither Hugo (goldmark) nor Jant's own parser reads the
4877
+ * closing `**` after `。` followed by `来`. `<strong>…</strong>` reads the
4878
+ * same in both, and the rest of the Markdown stays as it was.
4879
+ *
4880
+ * @param node - A TipTap document or descendant
4881
+ * @returns A copy with unflankable runs flagged
4882
+ * @example
4883
+ * markEmphasisDelimiters(doc); // bold "说话。" before "来" gets the flag
4884
+ */ function markEmphasisDelimiters(node) {
4885
+ if (!node.content || node.type === "codeBlock") return node;
4886
+ const content = node.content.map(markEmphasisDelimiters);
4887
+ const hasInline = content.some((child) => child.type === "text" || child.type === "hardBreak");
4888
+ return {
4889
+ ...node,
4890
+ content: hasInline ? markEmphasisRuns(content) : content
4891
+ };
4892
+ }
4893
+ function renderEmphasis(node, content, markType, delimiter) {
4894
+ if (!node.attrs?.[HTML_EMPHASIS_ATTR]) return `${delimiter}${content}${delimiter}`;
4895
+ const tag = HTML_EMPHASIS_TAGS[markType];
4896
+ return `<${tag}>${content}</${tag}>`;
4897
+ }
4898
+ var MarkdownBold = Bold.extend({ renderMarkdown(node, helpers) {
4899
+ return renderEmphasis(node, helpers.renderChildren(node), "bold", "**");
4900
+ } });
4901
+ var MarkdownItalic = Italic.extend({ renderMarkdown(node, helpers) {
4902
+ return renderEmphasis(node, helpers.renderChildren(node), "italic", "*");
4903
+ } });
4904
+ var MarkdownStrike = Strike.extend({ renderMarkdown(node, helpers) {
4905
+ return renderEmphasis(node, helpers.renderChildren(node), "strike", "~~");
4906
+ } });
4907
+ /** Marked token for each HTML emphasis tag the parser accepts. */ var HTML_EMPHASIS_TOKEN_TYPES = {
4908
+ strong: "strong",
4909
+ b: "strong",
4910
+ em: "em",
4911
+ i: "em",
4912
+ s: "del",
4913
+ del: "del"
4914
+ };
4915
+ var HTML_EMPHASIS_OPEN_PATTERN = /<(?:strong|b|em|i|s|del)>/i;
4916
+ var HTML_EMPHASIS_PATTERN = /^<(strong|b|em|i|s|del)>([\s\S]*?)<\/\1>/i;
4917
+ /**
4918
+ * Reads the HTML tags `markEmphasisDelimiters` writes back as marks.
4919
+ *
4920
+ * Only bare tags: `<strong onclick=…>` and every other tag stay text, as
4921
+ * inline HTML always has. The content between the tags is Markdown.
4922
+ */ var MarkdownHtmlEmphasis = Extension.create({
4923
+ name: "markdownHtmlEmphasis",
4924
+ markdownTokenizer: {
4925
+ name: "htmlEmphasis",
4926
+ level: "inline",
4927
+ start(src) {
4928
+ return src.search(HTML_EMPHASIS_OPEN_PATTERN);
4929
+ },
4930
+ tokenize(src, _tokens, helpers) {
4931
+ const match = HTML_EMPHASIS_PATTERN.exec(src);
4932
+ if (!match) return void 0;
4933
+ const [raw, tag = "", text = ""] = match;
4934
+ const type = HTML_EMPHASIS_TOKEN_TYPES[tag.toLowerCase()];
4935
+ if (!type) return void 0;
4936
+ return {
4937
+ type,
4938
+ raw,
4939
+ text,
4940
+ tokens: helpers.inlineTokens(text)
4941
+ };
4942
+ }
4943
+ }
4944
+ });
4945
+ /**
4946
+ * Hard breaks as two trailing spaces, except where that line would be blank.
4947
+ *
4948
+ * A break at the start of a paragraph, or right after another break, puts
4949
+ * the spaces on a line of their own. A line of spaces is blank in Markdown:
4950
+ * it ended the paragraph, and `- ` left a list item empty with the text
4951
+ * after it outside the list. The backslash form keeps something on the line.
4952
+ */ var MarkdownHardBreak = HardBreak.extend({ renderMarkdown(_node, _helpers, context) {
4953
+ const previous = context?.previousNode;
4954
+ return !previous || previous.type === "hardBreak" ? "\\\n" : " \n";
4955
+ } });
4956
+ var renderParagraphMarkdown = Paragraph.config.renderMarkdown;
4957
+ var MarkdownParagraph = Paragraph.extend({ renderMarkdown(node, helpers, context) {
4958
+ return escapeLineStartBlockSyntax(renderParagraphMarkdown?.call(this, node, helpers, context) ?? "");
4959
+ } });
4960
+ /**
4961
+ * Ordered lists with CommonMark markers: digits only.
4962
+ *
4963
+ * Tiptap's ordered list also reads letters and roman numerals as markers
4964
+ * (`a.`, `IV.`, anything of one or two letters), so a line such as
4965
+ * `PS. 补充一句` or `Mr. Smith went` became a list item and lost its first
4966
+ * word. Its tokenizer also measured a marker's width without the `.`, which
4967
+ * left one stray space on every line of a code block inside a list item.
4968
+ *
4969
+ * Jant's Markdown is CommonMark plus GFM (docs/internal/markdown-contract.md),
4970
+ * the same dialect Hugo reads in an export, so list tokenizing goes back to
4971
+ * marked: a tokenizer that never matches leaves the built-in one in charge,
4972
+ * and marked's list items go to `listItem` the way bullet lists' do. Tiptap's
4973
+ * plain-text paste plugin goes too; it applied the same markers, and the
4974
+ * editors already parse pasted plain text as Markdown (`MarkdownClipboard`).
4975
+ */ var CommonMarkOrderedList = OrderedList.extend({
4976
+ markdownTokenizer: {
4977
+ name: "orderedList",
4978
+ level: "block",
4979
+ start: () => -1,
4980
+ tokenize: () => void 0
4981
+ },
4982
+ parseMarkdown: (token, helpers) => {
4983
+ if (token.type !== "list" || !token.ordered) return [];
4984
+ const start = typeof token.start === "number" ? token.start : 1;
4985
+ return {
4986
+ type: "orderedList",
4987
+ ...start === 1 ? {} : { attrs: { start } },
4988
+ content: token.items ? helpers.parseChildren(token.items) : []
4989
+ };
4990
+ },
4991
+ addProseMirrorPlugins() {
4992
+ return [];
4993
+ }
4994
+ });
4738
4995
  var MarkdownFigureImageSupport = Extension.create({
4739
4996
  name: "markdownFigureImageSupport",
4740
4997
  markdownTokenName: "imageFigure",
@@ -5163,8 +5420,21 @@ function createMarkdownContentExtensions(options = {}) {
5163
5420
  ] },
5164
5421
  link: false,
5165
5422
  codeBlock: false,
5423
+ orderedList: false,
5424
+ paragraph: false,
5425
+ hardBreak: false,
5426
+ bold: false,
5427
+ italic: false,
5428
+ strike: false,
5166
5429
  trailingNode: { notAfter: ["footnoteDefinition"] }
5167
5430
  }),
5431
+ MarkdownParagraph,
5432
+ MarkdownHardBreak,
5433
+ MarkdownBold,
5434
+ MarkdownItalic,
5435
+ MarkdownStrike,
5436
+ MarkdownHtmlEmphasis,
5437
+ CommonMarkOrderedList,
5168
5438
  SemanticLink.configure({
5169
5439
  openOnClick: false,
5170
5440
  autolink: false,
@@ -5308,11 +5578,45 @@ function getMarkdownManager() {
5308
5578
  sharedMarkdownManager ??= createMarkdownManager();
5309
5579
  return sharedMarkdownManager;
5310
5580
  }
5581
+ var sharedContentSchema = null;
5582
+ function getContentSchema() {
5583
+ sharedContentSchema ??= getSchema(createMarkdownContentExtensions());
5584
+ return sharedContentSchema;
5585
+ }
5586
+ /**
5587
+ * Fill in the children a node's schema requires but the document left out.
5588
+ *
5589
+ * A list item must hold a paragraph, a blockquote a block, a doc a block.
5590
+ * Documents that skipped one (an empty `1. ` item from an older Markdown
5591
+ * parser, or JSON posted through the API) made the Markdown serializer throw.
5592
+ * Each empty node that cannot be empty gets what `createAndFill` would give it.
5593
+ *
5594
+ * @param node - A TipTap document or descendant
5595
+ * @returns A copy whose empty required containers are filled
5596
+ * @example
5597
+ * fillRequiredContent({ type: "listItem", content: [] });
5598
+ * // { type: "listItem", content: [{ type: "paragraph" }] }
5599
+ */ function fillRequiredContent(node) {
5600
+ const nodeType = node.type ? getContentSchema().nodes[node.type] : void 0;
5601
+ if (!nodeType || nodeType.isLeaf) return node;
5602
+ const content = node.content?.map(fillRequiredContent);
5603
+ if ((content?.length ?? 0) === 0 && !nodeType.contentMatch.validEnd) {
5604
+ const filled = nodeType.contentMatch.fillBefore(Fragment.empty, true);
5605
+ if (filled) return {
5606
+ ...node,
5607
+ content: filled.toJSON()
5608
+ };
5609
+ }
5610
+ return content ? {
5611
+ ...node,
5612
+ content
5613
+ } : node;
5614
+ }
5311
5615
  function parseMarkdownDocument(markdown) {
5312
- return normalizeMarkdownDocument(getMarkdownManager().parse(markdown));
5616
+ return fillRequiredContent(normalizeMarkdownDocument(getMarkdownManager().parse(markdown)));
5313
5617
  }
5314
5618
  function serializeMarkdownDocument(doc) {
5315
- return expandCodeBlockFences(getMarkdownManager().serialize(normalizeFootnoteArtifacts(doc)));
5619
+ return expandCodeBlockFences(getMarkdownManager().serialize(normalizeFootnoteArtifacts(markEmphasisDelimiters(fillRequiredContent(doc)))));
5316
5620
  }
5317
5621
  //#endregion
5318
5622
  //#region src/lib/markdown.ts
@@ -5421,8 +5725,14 @@ function serializeMarkdownDocument(doc) {
5421
5725
  /**
5422
5726
  * Converts a Tiptap JSON document to a Markdown string.
5423
5727
  *
5424
- * @param json - Tiptap JSON string or parsed document object
5728
+ * Throws rather than returning an empty string: a caller writing the result
5729
+ * somewhere (an export, a text attachment) would otherwise replace the
5730
+ * content with nothing and say nothing.
5731
+ *
5732
+ * @param json - Tiptap JSON document string
5425
5733
  * @returns Markdown string
5734
+ * @throws {SyntaxError} When `json` is not JSON
5735
+ * @throws {Error} When the root node is not a `doc`
5426
5736
  *
5427
5737
  * @example
5428
5738
  * ```ts
@@ -5430,13 +5740,9 @@ function serializeMarkdownDocument(doc) {
5430
5740
  * // "Hello"
5431
5741
  * ```
5432
5742
  */ function tiptapJsonToMarkdown(json) {
5433
- try {
5434
- const doc = JSON.parse(json);
5435
- if (doc.type !== "doc") return "";
5436
- return serializeMarkdownDocument(doc).trimEnd();
5437
- } catch {
5438
- return "";
5439
- }
5743
+ const doc = JSON.parse(json);
5744
+ if (doc.type !== "doc") throw new Error(`A TipTap body's root must be a doc node, not ${JSON.stringify(doc.type)}.`);
5745
+ return serializeMarkdownDocument(doc).trimEnd();
5440
5746
  }
5441
5747
  //#endregion
5442
5748
  //#region src/lib/github-app.ts
@@ -5712,6 +6018,42 @@ async function importPrivateKey(pem) {
5712
6018
  };
5713
6019
  }
5714
6020
  //#endregion
6021
+ //#region src/lib/github-sync-repo-name.ts
6022
+ /**
6023
+ * The repository name Jant proposes for a site's GitHub Sync mirror.
6024
+ *
6025
+ * The settings page prefills it on github.com/new, and a site export with no
6026
+ * repository behind it uses it as the Worker name in `wrangler.jsonc`.
6027
+ * Cloudflare names a Worker imported from a repository after the repository,
6028
+ * so an export and a repository created with the default name agree without
6029
+ * anyone editing either.
6030
+ */ /** Used when the site URL yields no host label. */ var FALLBACK_REPO_NAME = "jant-site-sync";
6031
+ /**
6032
+ * Derive the default repository name for a site's sync mirror.
6033
+ *
6034
+ * Uses the first DNS label of the site's host — a stable, URL-safe
6035
+ * identifier tied to this Jant instance. A leading `www` is skipped: it
6036
+ * names nothing about the site, and every `www.` site would otherwise
6037
+ * propose the same `www-jant-sync`. It is kept when only a top-level domain
6038
+ * follows it, since that label would say even less. The `-jant-sync` suffix
6039
+ * tells the sync mirror apart from a user's own `{slug}-jant` source repo.
6040
+ *
6041
+ * @param siteUrl - The site's public URL.
6042
+ * @returns A GitHub-safe repository name; `jant-site-sync` when the URL has
6043
+ * no usable host.
6044
+ * @example
6045
+ * suggestSyncRepoName("https://notes.example.com"); // "notes-jant-sync"
6046
+ * suggestSyncRepoName("https://www.example.com"); // "example-jant-sync"
6047
+ */ function suggestSyncRepoName(siteUrl) {
6048
+ let labels = [];
6049
+ try {
6050
+ labels = new URL(siteUrl).host.split(".");
6051
+ } catch {}
6052
+ const [first = "", second = "", ...more] = labels;
6053
+ const slug = (first === "www" && more.length > 0 ? second : first).toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
6054
+ return slug ? `${slug}-jant-sync` : FALLBACK_REPO_NAME;
6055
+ }
6056
+ //#endregion
5715
6057
  //#region src/db/thread-activity.ts
5716
6058
  /**
5717
6059
  * Thread activity — one definition, shared by every surface that orders by it.
@@ -5864,6 +6206,7 @@ async function importPrivateKey(pem) {
5864
6206
  "type",
5865
6207
  "draft",
5866
6208
  "aliases",
6209
+ "feed_id",
5867
6210
  "build",
5868
6211
  "format",
5869
6212
  "status",
@@ -5921,10 +6264,10 @@ async function importPrivateKey(pem) {
5921
6264
  var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n /*\n * CJK fallback slots, kept in sync with `DEFAULT_FONT_CJK_*_FALLBACK` in\n * `ui/font-themes.ts` (a test asserts they match). A page whose language names\n * a CJK profile overrides both with a stack that leads on its own glyph\n * shapes; every other page keeps these, because the Latin families and the\n * `serif` / `sans-serif` generics around them have no Han coverage and would\n * otherwise drop the text to the OS last-resort font.\n */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Noto Serif SC\", \"Noto Serif CJK SC\",\n \"Songti TC\", PMingLiU, MingLiU, \"Noto Serif TC\", \"Noto Serif CJK TC\",\n \"Hiragino Mincho ProN\", \"Hiragino Mincho Pro\", \"Yu Mincho\", YuMincho,\n \"Noto Serif JP\", \"Noto Serif CJK JP\", \"MS PMincho\", \"MS Mincho\", Batang,\n \"Noto Serif KR\", \"Noto Serif CJK KR\", NanumMyeongjo;\n --font-cjk-sans-fallback:\n \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Noto Sans SC\",\n \"Noto Sans CJK SC\", \"PingFang TC\", \"Hiragino Sans CNS\",\n \"Microsoft JhengHei\", \"Noto Sans TC\", \"Noto Sans CJK TC\", \"Hiragino Sans\",\n \"Hiragino Kaku Gothic ProN\", \"Yu Gothic\", YuGothic, \"Noto Sans JP\",\n \"Noto Sans CJK JP\", Meiryo, \"Apple SD Gothic Neo\", \"Noto Sans KR\",\n \"Noto Sans CJK KR\", \"Malgun Gothic\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — UI buttons, controls\n * --type-sm 0.9rem (13.5px) — small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.8;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n --type-content-quote: calc(var(--type-content-body) * 1.16);\n --type-content-quote-leading: 1.4;\n\n /* Semantic aliases */\n --type-body-size: var(--type-content-body);\n --type-footnote-ref: calc(var(--type-body-size) * 0.75);\n --type-code: calc(var(--type-body-size) * 0.94);\n --type-code-block: calc(var(--type-body-size) * 0.9);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: calc(var(--type-content-body) * 1.36);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.7;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-medium);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-medium);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n /*\n * Two widths, one column — keep the distinction straight.\n *\n * `--layout-content-width` is the Tufte proportion: how much of a page\n * section its content occupies, leaving the rest as sidenote gutter. Page\n * shells that should fill the frame once the gutter is gone (settings,\n * the collections directory) read this one, which is why it flattens to\n * 100% below 1024px.\n *\n * `--layout-reading-width` is the reading column a post's text actually\n * gets. It tracks the proportion while the gutter exists, then keeps a\n * measure of its own instead of going full-width. Anything that must line\n * up with body copy — the text blocks in preset.css, a lone image\n * (MediaGallery `getSingleVisualWidth`), a link preview, the feed divider\n * that centers over the column — reads this one. Before it existed those\n * consumers capped on the proportion and quietly outgrew the text between\n * 700px and 1024px.\n */\n --layout-content-width: 55%;\n --layout-reading-width: var(--layout-content-width);\n --layout-sidenote-width: 50%;\n --layout-sidenote-gap: 10%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 20px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n /* Despite the name, this is the page colour and ~60 rules use it that way —\n including three that use it as *text* on a dark button. It cannot be\n lifted without repainting the whole site; `--site-raised-bg` below is the\n one that actually means \"above the page\". Read this as\n \"--site-default-surface\" until the call sites are triaged. */\n --site-elevated-bg: var(--background);\n /* What sits *above* the page: dialogs, popovers, menus, sheets. On a light\n page this is the page colour, because the separating is done by the\n backdrop dim and the drop shadows — both black over white. Neither works\n on a dark page: 30% black over `#110f0d` composites to `#0c0b09`, and a\n black shadow on near-black is nothing at all, which left the compose\n dialog at 1.03:1 against its own backdrop. So in dark mode this lifts\n instead (see the dark blocks below). Elevation there is lightness, not\n shadow. */\n --site-raised-bg: var(--site-page-bg);\n /* The edge of a raised layer. On a light page the fill already separates and\n this is just a hairline; on a dark one it does most of the work, because\n the fill has almost no room to lift into. Same formula as\n `--compose-control-border` in light so nothing moves there. */\n --site-raised-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-footnote-text: var(--site-reading-caption);\n --site-footnote-marker: color-mix(\n in oklch,\n var(--site-text-secondary) 88%,\n var(--site-page-bg)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-code-text: color-mix(\n in srgb,\n var(--site-reading-heading) 86%,\n var(--site-reading-body)\n );\n --site-code-bg: color-mix(in srgb, var(--site-reading-meta) 12%, transparent);\n --site-code-block-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 94%,\n var(--site-nav-hover-bg)\n );\n --site-code-block-border: color-mix(\n in srgb,\n var(--site-divider) 62%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n /* The composer is a floating sheet in every mode but one, so its paper is\n the raised surface. `.compose-page` — the full-page `/new` composer, which\n *is* the page — puts this back to `--site-page-bg` locally. */\n --compose-paper-bg: var(--site-raised-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n /* The tools row sits directly under the sentence being written, so its icons\n stay a step behind the body's own muted ink and only come forward when\n pointed at or switched on. 72% keeps the faded state above the 3:1\n non-text contrast floor. */\n --compose-tool-ink: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n/* =========================================================================\n * Breakpoint ledger\n *\n * Every width breakpoint in the stylesheets comes from this list, and each\n * entry names the one job it does. `src/__tests__/stylesheet-breakpoints.test.ts`\n * fails the build on anything else, because a second number for a job that\n * already has one is how the phone switch drifted apart before: the composer\n * once went full-screen at 700px while its own controls took their touch\n * sizing at 760px, so a 750px window got thumb-sized buttons inside a windowed\n * dialog.\n *\n * 480px Header hamburger; the composer's post-meta pill sheds its words.\n * 580px Nav collapse tier-sm (hides the 3rd inline link).\n * 640px Media grid and lightbox chrome.\n * 700px THE PHONE SWITCH. Below it the layout is a phone's: the composer\n * is a full-screen sheet, the compose FAB appears, page gutters and\n * the content type scale go narrow. Written `max-width: 699px` on\n * the phone side and `min-width: 700px` on the desktop side, so the\n * two never both match at 700px itself.\n * 780px Nav collapse tier-md (also hides the 4th inline link).\n * 960px Nav collapse tier-lg (hides the 5th+ inline link into More).\n * 1024px Tufte two-column → single-column (see the note below).\n * 1079px Collections sidebar drops out.\n * 1200px Header search collapses to an icon.\n *\n * Touch is NOT a width. Thumb-sized controls and always-visible affordances\n * belong to `(hover: none) and (pointer: coarse)`, which is what actually\n * catches a phone or a tablet; a narrow desktop window is still a mouse.\n * Rules that want both say both.\n * ========================================================================= */\n\n@media (max-width: 699px) {\n :root {\n --site-padding: 1.875rem;\n }\n}\n\n/* Tufte two-column → single-column collapse.\n *\n * There is no room for a 45% sidenote gutter here, so page shells go\n * full-width — but the reading column does not follow them. It keeps a 35rem\n * measure, and everything sized to the text (preset.css blocks, single images,\n * link previews, the feed divider) tracks that through\n * `--layout-reading-width` rather than restating the number.\n *\n * Keep this breakpoint in sync with the sidenote float→inline collapse\n * (ui.css). They are one layout switch; do not let them drift apart. */\n@media (max-width: 1024px) {\n :root {\n --layout-content-width: 100%;\n --layout-reading-width: min(100%, 35rem);\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n /* 12% of the foreground mixed into the page. The headroom here is small and\n bounded: `--site-divider` sits 1.36:1 above the page, and a surface lifted\n past it would swallow every hairline drawn on it. 12% lands at 1.24:1 —\n most of the range that exists, with the dividers still on top of it. */\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n /* Carries the separation the fill cannot: 12% of lift is most of the room\n the palette has, so the edge has to say the rest. */\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n}\n\n@media (max-width: 699px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading.\n Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).\n At 15px root × 0.8 content-scale: 29.4 / 22.2 / 18 / 15.6px.\n Body is floored to a 16px minimum below (max()) for readability;\n the floor still yields to calc() when the user zooms the root up. */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
5922
6265
  //#endregion
5923
6266
  //#region src/services/export-theme/theme.toml?raw
5924
- var theme_default = "name = \"jant\"\nlicense = \"MIT\"\nlicenselink = \"https://github.com/jant-me/jant/blob/main/LICENSE\"\ndescription = \"Default theme packaged with Jant exports.\"\nhomepage = \"https://jant.so\"\ntags = [\"blog\", \"microblog\", \"minimal\"]\nfeatures = [\"pagination\", \"aliases\"]\nmin_version = \"0.160.1\"\n\n[author]\n name = \"Jant\"\n homepage = \"https://jant.so\"\n";
6267
+ var theme_default = "name = \"jant\"\nlicense = \"MIT\"\nlicenselink = \"https://github.com/jant-me/jant/blob/main/LICENSE\"\ndescription = \"Default theme packaged with Jant exports.\"\nhomepage = \"https://jant.so\"\ntags = [\"blog\", \"microblog\", \"minimal\"]\nfeatures = [\"pagination\", \"aliases\"]\nmin_version = \"0.147.7\"\n\n[author]\n name = \"Jant\"\n homepage = \"https://jant.so\"\n";
5925
6268
  //#endregion
5926
6269
  //#region src/services/export-theme/styles/main.css?raw
5927
- var main_default = "/*\n * Jant Hugo Export — main.css\n *\n * Fresh minimal text-first design. All colors, spacing, and type sizing\n * come from tokens.css (loaded first in the <head>). Color theme values\n * are supplied by theme.css; customizations live in custom.css.\n *\n * Load order in <head>: tokens.css → main.css → theme.css → custom.css\n *\n * This file intentionally avoids any hardcoded hex, rgb, or px color\n * values. Everything token-driven so the design evolves with the theme.\n */\n\n/* -------------------------------------------------------------------------\n * Reset + box model\n * ------------------------------------------------------------------------- */\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 15px;\n -webkit-text-size-adjust: 100%;\n text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--font-body);\n font-size: var(--type-body-size, var(--type-content-body));\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n background-color: var(--site-page-bg);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n /* Every text node in an exported page is authored content, and authored text\n can contain a run with no break opportunity — a long URL, an ID, a word\n typed without spaces. Such a run does not wrap on its own and spills past\n the content column across the page. `break-word` splits it only when it\n cannot fit a line by itself, so ordinary text keeps its natural line breaks\n and intrinsic sizing is unaffected. */\n overflow-wrap: break-word;\n}\n\nimg,\nvideo,\naudio,\niframe {\n max-width: 100%;\n height: auto;\n}\n\nfigure {\n margin: 0;\n}\n\nhr {\n border: 0;\n border-top: 1px solid var(--site-divider);\n margin: var(--space-xl) 0;\n}\n\n/* -------------------------------------------------------------------------\n * Typography\n * ------------------------------------------------------------------------- */\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-family: var(--font-heading);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n letter-spacing: var(--type-heading-tracking);\n color: var(--site-reading-heading);\n margin: 1.6em 0 0.6em;\n}\n\nh1 {\n font-size: var(--type-content-display);\n line-height: var(--type-display-leading);\n font-weight: var(--type-display-weight);\n letter-spacing: var(--type-display-tracking);\n margin: 4rem 0 1.5rem;\n}\n\nh2 {\n font-size: var(--type-content-title);\n margin: 2.1rem 0 1.4rem;\n}\n\nh3 {\n font-size: var(--type-content-subtitle);\n margin: 2rem 0 1.4rem;\n}\n\nh4 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n}\n\nh5,\nh6 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n color: var(--site-reading-meta);\n}\n\np {\n margin: 1.4rem 0;\n}\n\nsmall {\n font-size: var(--type-sm);\n}\n\ncode,\nkbd,\nsamp {\n font-family: var(--font-mono);\n font-size: var(--type-code);\n line-height: 1.42;\n}\n\ncode {\n color: var(--site-code-text);\n overflow-wrap: break-word;\n}\n\npre {\n font-family: var(--font-mono);\n font-size: var(--type-code-block);\n line-height: 1.5;\n padding: 1rem;\n overflow-x: auto;\n color: var(--site-code-text);\n background-color: var(--site-code-block-bg);\n border: 1px solid var(--site-code-block-border);\n border-radius: 6px;\n}\n\npre code {\n padding: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font-size: inherit;\n}\n\n:not(pre) > code {\n padding: 0.08em 0.28em;\n background-color: var(--site-code-bg);\n border-radius: 0.22em;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n}\n\n/* Blockquotes — tinted background card with quote icon (matches main site). */\nblockquote {\n position: relative;\n margin: 1.4rem 0;\n padding: 1.4rem 1rem 0.75rem;\n border: none;\n background: var(--site-blockquote-bg);\n border-radius: 6px;\n color: var(--site-blockquote-text);\n font-family: var(--font-blockquote);\n font-style: italic;\n font-weight: inherit;\n quotes: none;\n text-wrap: pretty;\n}\n\nblockquote::before {\n content: \"\";\n display: block;\n width: 1.3rem;\n height: 1.3rem;\n margin-bottom: 0.35rem;\n background: var(--site-blockquote-rail);\n opacity: 0.6;\n mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n mask-size: contain;\n mask-repeat: no-repeat;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n}\n\nblockquote :where(p:first-of-type)::before,\nblockquote :where(p:last-of-type)::after {\n content: none;\n}\n\nblockquote > :first-child {\n margin-top: 0;\n}\n\nblockquote > :last-child {\n margin-bottom: 0;\n}\n\nblockquote p {\n margin: 0;\n}\n\nblockquote p + p,\nblockquote ul,\nblockquote ol,\nblockquote pre {\n margin-top: 0.6em;\n}\n\nblockquote cite {\n font-style: normal;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* CJK: no italic for blockquotes (no true italic glyph) */\n:lang(zh) blockquote,\n:lang(ja) blockquote,\n:lang(ko) blockquote {\n font-style: normal;\n}\n\nul,\nol {\n padding-left: 1.4em;\n margin: 1.25em 0;\n}\n\nol ol {\n list-style-type: lower-alpha;\n margin-top: 0.4em;\n margin-bottom: 0.4em;\n}\n\nol ol ol {\n list-style-type: lower-roman;\n}\n\nli {\n margin-top: 0.5em;\n margin-bottom: 0.5em;\n}\n\n/* Normalize li > p so list spacing is controlled by li alone,\n regardless of whether the markdown renderer wraps li contents in <p>. */\nli > p:first-child {\n margin-top: 0;\n}\n\nli > p:last-child {\n margin-bottom: 0;\n}\n\nli > p:has(+ ol) {\n margin-bottom: 0;\n}\n\na {\n color: var(--site-reading-link);\n text-decoration: underline;\n text-decoration-color: var(--site-reading-link-underline);\n text-underline-offset: 0.15em;\n transition:\n color 0.2s ease,\n text-decoration-color 0.2s ease;\n}\n\na:hover,\na:focus {\n color: var(--site-reading-link-hover);\n text-decoration-color: currentColor;\n}\n\na:focus-visible {\n outline: 2px solid var(--site-accent);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\ntime {\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n/* -------------------------------------------------------------------------\n * Page layout — Tufte horizontal frame\n *\n * Mirrors the main site's `.site-page > header/main/footer` rule. Every\n * top-level section gets the same asymmetric padding so the reading column\n * aligns with the rest of the site.\n *\n * 12.5% left + 4% right = 16.5% padding → content = 83.5% of the box.\n * max-width = body-max-width / 0.835 so the inner content area exactly\n * equals `--layout-body-max-width` on wide viewports. min() caps keep\n * padding from growing beyond 210px / 67px. Mobile widens to 5% / 5%\n * and the 55% content column collapses to 100% (via tokens.css).\n * ------------------------------------------------------------------------- */\n\n.site-page {\n min-height: 100vh;\n min-height: 100dvh;\n background-color: var(--site-page-bg);\n}\n\n.site-page > header,\n.site-page > main,\n.site-page > footer,\n.site-page > .home-branding-credit {\n width: 100%;\n max-width: calc(var(--layout-body-max-width) / 0.835);\n padding-left: min(12.5%, 210px);\n padding-right: min(4%, 67px);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (max-width: 760px) {\n .site-page > header,\n .site-page > main,\n .site-page > footer,\n .site-page > .home-branding-credit {\n padding-left: max(5%, 28px);\n padding-right: 5%;\n }\n}\n\n.site-main {\n padding-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Header\n * ------------------------------------------------------------------------- */\n\n.site-header {\n padding-top: 24px;\n background-color: var(--site-page-bg);\n}\n\n@media (min-width: 700px) {\n .site-header {\n padding-top: 30px;\n }\n}\n\n.site-header-inner {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 0;\n}\n\n.site-header-top {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n flex-wrap: nowrap;\n min-height: 2.75rem;\n width: 100%;\n}\n\n.site-header-top-bordered {\n padding-bottom: 15px;\n}\n\n@media (min-width: 700px) {\n .site-header-top-bordered {\n padding-bottom: 18px;\n }\n}\n\n.site-logo {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: 10px;\n min-width: 0;\n padding: 0.15rem 0;\n font-family: var(--font-site-title);\n font-size: var(--type-subtitle);\n font-weight: var(--fw-regular);\n letter-spacing: -0.02em;\n line-height: 1.15;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo:hover,\n.site-logo:focus {\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo-avatar {\n box-sizing: border-box;\n width: calc(var(--avatar-size) + 4px);\n height: calc(var(--avatar-size) + 4px);\n border-radius: var(--avatar-radius);\n object-fit: cover;\n border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);\n flex: none;\n}\n\n.site-logo-text {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.site-header-nav {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n justify-content: flex-end;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n margin-left: auto;\n min-width: 0;\n font-family: var(--font-ui);\n}\n\n.site-header-link {\n display: inline-flex;\n flex: none;\n align-items: center;\n position: relative;\n min-height: 2rem;\n padding: 0.15rem 0;\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n white-space: nowrap;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n text-decoration: none;\n transition: color 0.15s;\n}\n\n.site-header-link:hover,\n.site-header-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n text-decoration: none;\n}\n\n.site-header-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n/* --- \"More\" dropdown ---------------------------------------------------- */\n\n.site-header-more {\n position: relative;\n display: inline-flex;\n align-items: center;\n}\n\n.site-header-more-responsive-only {\n display: none;\n}\n\n.site-header-more-btn {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n min-height: 2rem;\n padding: 0.15rem 0;\n border: none;\n background: transparent;\n cursor: pointer;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n transition: color 0.15s;\n}\n\n.site-header-more-btn svg {\n width: 0.82rem;\n height: 0.82rem;\n transition: transform 0.18s ease;\n}\n\n.site-header-more-btn:hover,\n.site-header-more-btn[aria-expanded=\"true\"] {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-btn[aria-expanded=\"true\"] svg {\n transform: rotate(180deg);\n}\n\n.site-header-more-popover {\n display: block;\n position: absolute;\n top: 100%;\n right: 0;\n margin-top: 0.6rem;\n min-width: 12.25rem;\n padding: 0.3rem 0;\n background: var(--site-page-bg);\n border: 0.5px solid color-mix(in srgb, var(--site-divider) 80%, transparent);\n border-radius: 0.4rem;\n box-shadow:\n 0 4px 20px -8px rgba(0, 0, 0, 0.12),\n 0 2px 6px -2px rgba(0, 0, 0, 0.06);\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n transform: translateY(-6px);\n transform-origin: top right;\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n visibility 0s linear 0.18s;\n z-index: 50;\n}\n\n.site-header-more-popover[aria-hidden=\"false\"] {\n opacity: 1;\n visibility: visible;\n pointer-events: auto;\n transform: translateY(0);\n transition-delay: 0s;\n}\n\n.site-header-more-link {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.35rem;\n padding: 0.45rem 1rem;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.15s,\n background-color 0.15s;\n}\n\n.site-header-more-link:hover,\n.site-header-more-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n background: color-mix(in srgb, var(--site-nav-hover-bg) 58%, transparent);\n text-decoration: none;\n}\n\n.site-header-more-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-link-responsive,\n.site-header-more-divider-responsive {\n display: none;\n}\n\n.site-header-more-divider {\n height: 0;\n margin: 0.35rem 0.75rem;\n border-top: 0.5px solid\n color-mix(in srgb, var(--site-divider) 60%, transparent);\n}\n\n/* --- Tiered responsive collapse ---------------------------------------- *\n *\n * ≤960px — 5th+ inline link collapses into More (tier-lg)\n * ≤780px — also 4th collapses (tier-md)\n * ≤580px — also 3rd collapses (tier-sm)\n * The first two links always stay inline. No hamburger on static export —\n * at very narrow widths 2 inline links + More button is the floor.\n */\n\n@media (max-width: 960px) {\n .site-header-link-collapse-lg {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-lg {\n display: inline-flex;\n }\n\n .site-header-more-link-show-lg {\n display: flex;\n }\n\n .site-header-more-divider-responsive {\n display: block;\n }\n}\n\n@media (max-width: 780px) {\n .site-header-link-collapse-md {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-md {\n display: inline-flex;\n }\n\n .site-header-more-link-show-md {\n display: flex;\n }\n}\n\n@media (max-width: 580px) {\n .site-header-link-collapse-sm {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-sm {\n display: inline-flex;\n }\n\n .site-header-more-link-show-sm {\n display: flex;\n }\n}\n\n/* -------------------------------------------------------------------------\n * Footer\n * ------------------------------------------------------------------------- */\n\n.site-footer {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n color: var(--site-text-secondary);\n font-size: var(--type-xs);\n background-color: var(--site-page-bg);\n}\n\n.site-footer-inner {\n border-top: 0.5px solid var(--site-divider);\n padding-top: var(--space-xl);\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.site-footer-content {\n color: var(--site-text-secondary);\n}\n\n.site-footer-content p {\n margin: 0 0 0.5em;\n}\n\n.site-footer-nav-list {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.home-branding-credit {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n text-align: center;\n color: var(--site-text-secondary);\n font-size: var(--type-base);\n}\n\n.home-branding-credit a {\n display: inline-flex;\n align-items: center;\n gap: 0.38rem;\n color: inherit;\n text-decoration: none;\n border-bottom: 0.5px solid\n color-mix(in srgb, var(--site-text-secondary) 45%, transparent);\n transition:\n color 160ms ease,\n border-color 160ms ease;\n}\n\n.home-branding-credit a:hover,\n.home-branding-credit a:focus-visible {\n color: var(--site-text-primary);\n border-color: currentColor;\n}\n\n/* -------------------------------------------------------------------------\n * Tufte content-width constraint\n *\n * Mirrors the main site's 55% rule: reading text occupies a narrow\n * column inside the Tufte frame, leaving a wide right margin that\n * would host sidenotes on the main site. Media (images, video, audio)\n * is NOT included — galleries intentionally span the full frame so\n * they can breathe.\n *\n * Mobile (<=760px): `--layout-content-width` collapses to 100% via\n * tokens.css. Tablet: cap at 35rem for readability.\n * ------------------------------------------------------------------------- */\n\n.section-header,\n.section-body,\n.post-card-title,\n.post-card-summary,\n.post-card-link-domain,\n.post-card-quote-content,\n.post-card-quote-attribution,\n.post-card-quote-commentary,\n.post-card-footer,\n.reply-title,\n.reply-body,\n.reply-link-domain,\n.reply-footer,\n.thread-title,\n.thread-body,\n.thread-link-domain,\n.thread-footer,\n.collection-directory,\n.pagination,\n.empty-state,\n.page-summary {\n width: var(--layout-content-width);\n max-width: 100%;\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .section-header,\n .section-body,\n .post-card-title,\n .post-card-summary,\n .post-card-link-domain,\n .post-card-quote-content,\n .post-card-quote-attribution,\n .post-card-quote-commentary,\n .post-card-footer,\n .reply-title,\n .reply-body,\n .reply-link-domain,\n .reply-footer,\n .thread-title,\n .thread-body,\n .thread-link-domain,\n .thread-footer,\n .collection-directory,\n .pagination,\n .empty-state,\n .page-summary {\n width: min(100%, 35rem);\n }\n}\n\n/* -------------------------------------------------------------------------\n * Section headers\n * ------------------------------------------------------------------------- */\n\n.section-header {\n margin-bottom: var(--space-xl);\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--site-border-light);\n}\n\n.section-title {\n margin: 0 0 0.25em;\n}\n\n.section-summary {\n margin: 0;\n color: var(--site-reading-meta);\n font-size: var(--type-secondary);\n}\n\n.section-meta {\n margin: 0.5em 0 0;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* -------------------------------------------------------------------------\n * Post list + post cards\n * ------------------------------------------------------------------------- */\n\n.post-list {\n display: flex;\n flex-direction: column;\n gap: calc(var(--space-xl) * 1.25);\n}\n\n.post-list-pinned {\n margin-bottom: calc(var(--space-xl) * 1.25);\n padding-bottom: calc(var(--space-xl) * 1.25);\n border-bottom: 1px solid var(--site-border-light);\n}\n\n/* Decorative divider between posts in a timeline feed. Mirrors the main\n site's `hr.feed-divider`: a trio of small chevron marks masked from the\n current text color, so it picks up the theme automatically.\n `margin-left` centers the divider within the 55% reading column so it\n visually sits at the middle of the post-card text stack. */\nhr.feed-divider {\n border: none;\n width: 30px;\n height: 9px;\n margin: 0;\n margin-left: calc(var(--layout-content-width) / 2 - 15px);\n color: var(--site-feed-divider-color);\n background-color: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n.post-card {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.post-card-title {\n font-family: var(--font-heading);\n font-size: var(--feed-note-title-size);\n font-weight: var(--type-heading-weight);\n line-height: var(--feed-note-title-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.post-card-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.post-card-title a:hover,\n.post-card-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-card-summary {\n margin: 0;\n color: var(--site-reading-body);\n}\n\n.post-card > .post-card-summary :is(h1, h2),\n.post-card > .post-card-quote-commentary :is(h1, h2) {\n font-size: calc(var(--type-content-body) * 1.12);\n font-weight: var(--fw-medium);\n margin-top: 1.45rem;\n margin-bottom: 0.55rem;\n}\n\n.post-card > .post-card-summary :is(h3, h4),\n.post-card > .post-card-quote-commentary :is(h3, h4) {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n margin-top: 1.2rem;\n margin-bottom: 0.45rem;\n}\n\n.post-card > .post-card-summary :is(h5, h6),\n.post-card > .post-card-quote-commentary :is(h5, h6) {\n font-size: var(--type-secondary);\n font-weight: var(--fw-medium);\n color: var(--site-text-secondary);\n margin-top: 1rem;\n margin-bottom: 0.35rem;\n}\n\n/* Link card domain row — shown ABOVE the title (matches main site's\n `.feed-link-domain` pattern). Inline flex with icon + host text. */\n.post-card-link-domain,\n.thread-link-domain,\n.reply-link-domain {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n max-width: 100%;\n margin: 0 0 0.4rem 0;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-regular);\n line-height: 1.3;\n color: var(--site-text-secondary);\n text-decoration: none;\n word-break: break-all;\n transition: color 0.18s ease;\n}\n\n.post-card-link-domain:hover,\n.post-card-link-domain:focus,\n.thread-link-domain:hover,\n.thread-link-domain:focus,\n.reply-link-domain:hover,\n.reply-link-domain:focus {\n color: var(--site-text-primary);\n}\n\n.post-card-link-domain-icon {\n width: 0.72rem;\n height: 0.72rem;\n flex-shrink: 0;\n}\n\n/* Slight extra breathing room below link-card titles to match main site. */\n.post-card-link-title,\n.thread-link-title,\n.reply-link-title {\n text-wrap: pretty;\n}\n\n.post-card-link-title a,\n.thread-link-title a,\n.reply-link-title a {\n text-decoration: none;\n}\n\n.post-card-link-title a:hover,\n.post-card-link-title a:focus,\n.thread-link-title a:hover,\n.thread-link-title a:focus,\n.reply-link-title a:hover,\n.reply-link-title a:focus {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.post-card-media,\n.reply-media,\n.thread-media {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.75rem;\n}\n\n.post-card-figure img,\n.reply-figure img,\n.thread-figure img {\n display: block;\n width: 100%;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n}\n\n.post-card-figure video,\n.reply-figure video,\n.thread-figure video {\n display: block;\n width: 100%;\n height: auto;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n background: #000;\n}\n\n.post-card-figure audio,\n.reply-figure audio,\n.thread-figure audio {\n display: block;\n width: 100%;\n}\n\n.post-card-figure-video a {\n position: relative;\n display: block;\n}\n\n.post-card-video-badge {\n position: absolute;\n left: 0.5rem;\n bottom: 0.5rem;\n padding: 0.125rem 0.5rem;\n font-size: var(--type-xs);\n color: #fff;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 999px;\n pointer-events: none;\n}\n\n.thread-file a,\n.reply-file a {\n color: var(--site-link);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Quote format — decorative mark, serif body, attribution line\n * ------------------------------------------------------------------------- */\n\n.post-card-quote {\n position: static;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: var(--site-reading-quote);\n font-family: inherit;\n font-style: normal;\n}\n\n/* Quote-format posts have their own decorative `.post-card-quote-mark`\n SVG inside the blockquote — suppress the global blockquote icon. */\n.post-card-quote::before {\n content: none;\n}\n\n.post-card-quote-mark {\n display: block;\n position: relative;\n width: 1.7rem;\n margin-bottom: -0.1rem;\n margin-left: -0.04rem;\n line-height: 0;\n pointer-events: none;\n color: color-mix(in srgb, var(--site-accent) 14%, var(--site-divider));\n opacity: 0.66;\n}\n\n.post-card-quote-mark svg {\n display: block;\n width: 100%;\n height: auto;\n}\n\n.post-card-quote-content {\n font-family: var(--font-serif);\n color: var(--site-text-primary);\n font-size: var(--type-content-quote);\n line-height: var(--type-content-quote-leading);\n white-space: pre-line;\n text-wrap: pretty;\n margin: 0;\n}\n\n.post-card-quote-attribution {\n display: flex;\n align-items: center;\n gap: 0.45rem;\n flex-wrap: wrap;\n /* The name and source are flex items, which refuse to shrink below their\n min-content width. The inherited `break-word` leaves min-content at the\n longest word and the item still overflows; `anywhere` also shrinks\n min-content, which is what lets the row wrap. */\n overflow-wrap: anywhere;\n margin-top: 0.95rem;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-style: normal;\n line-height: 1.3;\n}\n\n.post-card-quote-attribution::before {\n content: \"\";\n width: 0.9rem;\n height: 1px;\n background: color-mix(\n in srgb,\n var(--site-text-secondary) 38%,\n var(--site-divider)\n );\n}\n\n.post-card-quote-source {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: color-mix(\n in srgb,\n var(--site-text-secondary) 55%,\n transparent\n );\n text-underline-offset: 3px;\n}\n\n.post-card-quote-source:hover,\n.post-card-quote-source:focus {\n color: var(--site-text-primary);\n text-decoration-color: currentColor;\n}\n\n.post-card-quote-commentary {\n position: relative;\n margin-top: 1.1rem;\n padding-top: 0.95rem;\n color: color-mix(\n in srgb,\n var(--site-text-secondary) 84%,\n var(--site-text-primary)\n );\n text-wrap: pretty;\n}\n\n.post-card-quote-commentary::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent 0%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 16%,\n color-mix(in srgb, var(--site-divider) 78%, transparent) 50%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 84%,\n transparent 100%\n );\n}\n\n.post-card-quote-commentary.prose > :first-child {\n margin-top: 0;\n}\n\n.post-card-quote-commentary.prose > :last-child {\n margin-bottom: 0;\n}\n\n.post-card-quote-commentary p {\n margin: 0;\n}\n\n.post-card-quote-commentary p + p,\n.post-card-quote-commentary ul,\n.post-card-quote-commentary ol,\n.post-card-quote-commentary blockquote,\n.post-card-quote-commentary pre {\n margin-top: 0.55rem;\n}\n\n/* Fade the post meta on quote cards so the quote body stays visually primary. */\n.post-card-quote ~ .post-card-footer,\n.post-card-quote-commentary + .post-card-footer {\n opacity: 0.72;\n}\n\n/* -------------------------------------------------------------------------\n * Post footer — meta (featured, time, external link, collections, pinned)\n * ------------------------------------------------------------------------- */\n\n.post-card-footer,\n.reply-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 10px;\n min-height: 2rem;\n font-size: var(--type-ui-hint);\n}\n\n.post-footer-detail {\n margin-top: 24px;\n /* Match the feed/reply footer size so the root post's footer doesn't\n visually dominate. Main site uses a larger size because every post\n on the detail page is rendered at detail size; here only the root\n post gets this class, which otherwise creates a mismatch with the\n replies below. */\n font-size: var(--type-ui-hint);\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n min-width: 0;\n font-family: var(--font-ui);\n line-height: 1.35;\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta time {\n font-size: inherit;\n color: inherit;\n}\n\n.post-footer-featured {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: color-mix(\n in srgb,\n var(--search-mark-color) 72%,\n var(--site-text-secondary)\n );\n flex-shrink: 0;\n}\n\n.post-footer-featured svg {\n width: 1rem;\n height: 1rem;\n opacity: 0.9;\n}\n\n.post-footer-link {\n color: var(--site-text-secondary);\n text-decoration: none;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.post-footer-link:hover,\n.post-footer-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n.post-footer-external-link {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.6rem;\n height: 1.6rem;\n border-radius: 0.6rem;\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.18s ease,\n background-color 0.16s ease;\n flex-shrink: 0;\n}\n\n.post-footer-external-link:hover,\n.post-footer-external-link:focus {\n color: var(--site-text-primary);\n}\n\n.post-footer-external-link svg {\n width: 1rem;\n height: 1rem;\n}\n\n.post-collection-tags {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n min-width: 0;\n max-width: 100%;\n color: var(--site-text-secondary);\n}\n\n.post-collection-tag {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: inherit;\n text-decoration: none;\n min-width: 0;\n max-width: min(100%, 22ch);\n}\n\n.post-collection-tag:hover,\n.post-collection-tag:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-collection-tag-text {\n display: block;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.post-card-pin {\n font-size: var(--type-2xs);\n text-transform: uppercase;\n letter-spacing: var(--type-label-tracking);\n color: var(--site-accent);\n border: 1px solid var(--site-accent);\n padding: 0.1em 0.5em;\n border-radius: 999px;\n flex-shrink: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Thread preview (list pages: root context + hero latest reply)\n *\n * Mirrors the main site's `.thread-group.thread-group-preview` layout:\n * content sits flush-left inside the preview, and the vertical rail +\n * dot markers are positioned OUTSIDE the content (overflowing into the\n * container's left gutter) via a negative `left` on the rail/dots.\n * ------------------------------------------------------------------------- */\n\n.thread-preview {\n /* Rail position: negative = overflow outside content. Mirrors\n `--site-thread-rail-line-left` on the main site. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n --thread-item-spacing: 0.35rem;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread-preview {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail — subtle gradient so the line fades into\n whitespace at the top and tail. Sits outside the content via negative\n `left`. */\n.thread-preview::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n.thread-preview-context {\n position: relative;\n display: flex;\n flex-direction: column;\n /* Match runtime `.thread-group-preview .thread-item` which uses\n `padding: var(--site-thread-item-spacing) 0` — adjacent items have\n 2x the token between them, so the flex `gap` here is 2x to match. */\n gap: calc(var(--site-thread-item-spacing) * 2);\n margin: 0;\n padding: 0;\n border-left: 0;\n}\n\n/* Individual entry in a thread preview — wraps a full `.post-card` so\n the root, second, and penultimate replies render with their own\n title/body/footer. Dot marker sits on the rail at the card's\n vertical midpoint. */\n.thread-preview .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-preview .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n/* Gap \"N more posts\" row has no card so we place the marker at its\n vertical midpoint instead of the card's top area. Swap the single dot\n for a short column of small beads (a vertical \"⋮\") so the rail visibly\n \"skips\" a run of posts — mirrors the main site's `.thread-item-gap`. */\n.thread-preview .thread-item-gap {\n display: flex;\n align-items: center;\n padding: 0.15rem 0 0.35rem;\n}\n\n.thread-preview .thread-item-gap::before {\n top: 50%;\n transform: translateY(-50%);\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 - 2px -\n var(--thread-rail-indent)\n );\n width: 4px;\n height: 24px;\n border: 0;\n border-radius: 0;\n background: radial-gradient(\n circle,\n var(--site-threadline) 1.75px,\n transparent 2px\n );\n background-size: 4px 8px;\n background-repeat: repeat-y;\n background-position: center top;\n box-shadow: none;\n}\n\n/* Hidden-posts count — a calm route into the thread, matching the main\n site's `.thread-gap-link`: styled like a sibling of the Show more toggle\n with a small right-pointing chevron rather than an underline, firming up\n to the primary text colour on hover. */\n.thread-preview-gap {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n align-self: flex-start;\n margin: 0.15rem 0;\n padding: 0.25rem 0;\n color: var(--site-text-secondary);\n font-size: var(--type-thread-context-meta);\n font-weight: 500;\n line-height: 1.2;\n text-decoration: none;\n transition: color 0.18s ease;\n}\n\n.thread-preview-gap::after {\n content: \"\";\n width: 0.36em;\n height: 0.36em;\n border: 1.5px solid currentColor;\n border-left: 0;\n border-bottom: 0;\n opacity: 0.65;\n transform: rotate(45deg);\n transition:\n transform 0.18s ease,\n opacity 0.18s ease;\n}\n\n.thread-preview-gap:hover,\n.thread-preview-gap:focus {\n color: var(--site-text-primary);\n}\n\n.thread-preview-gap:hover::after,\n.thread-preview-gap:focus::after {\n opacity: 1;\n transform: translateX(2px) rotate(45deg);\n}\n\n/* Hero (latest reply) row: spacing above and below matches the main\n site's `.thread-item-hero`, and the dot is larger + accent-colored. */\n.thread-preview-hero {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-top: calc(var(--space-xl) * 1.1);\n margin-top: 0;\n}\n\n/* Featured timelines keep Post-level selection while grouping by Thread.\n Selected Root/Child Posts use the accent marker; context Posts retain the\n neutral rail marker. */\n.thread-preview .thread-item-featured::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview .thread-item-hero::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: calc(var(--space-xl) * 1.1 + 1.4rem);\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview-thread-link {\n align-self: flex-start;\n font-size: var(--type-sm);\n color: var(--site-reading-meta);\n text-decoration: none;\n}\n\n.thread-preview-thread-link:hover,\n.thread-preview-thread-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Thread (single post page with inline replies)\n * ------------------------------------------------------------------------- */\n\n.thread {\n /* Rail variables match `.thread-preview` so the detail-page rail\n shares the same visual position. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail spanning the entire thread (root post + replies).\n Only shown when the root post actually has replies — a lone post should\n not have a rail or dot. Matches `.thread-preview`. */\n.thread-has-replies::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n/* Dot marker for each post in the thread (root + replies). Shown only when\n there are replies so a solo post doesn't get an orphaned dot. */\n.thread .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-has-replies .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n.thread-item-root {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.thread-header {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.thread-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-display);\n font-weight: var(--type-display-weight);\n line-height: var(--type-display-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.thread-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.thread-title a:hover,\n.thread-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.thread-body {\n font-size: var(--type-content-body);\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n}\n\n.thread-body > :first-child {\n margin-top: 0;\n}\n\n.thread-body > :last-child {\n margin-bottom: 0;\n}\n\n.thread-replies {\n /* Flex container for replies. The rail and dots are provided by\n `.thread::before` and `.thread-item::before` so replies stay visually\n connected to the root post. */\n display: flex;\n flex-direction: column;\n gap: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Replies\n * ------------------------------------------------------------------------- */\n\n.reply {\n scroll-margin-top: 1.5rem;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.reply:target {\n background-color: var(--search-mark-bg);\n border-radius: 0.25rem;\n padding: 0.75rem 1rem;\n margin-left: -1rem;\n margin-right: -1rem;\n}\n\n.reply:target::before {\n background-color: var(--site-accent);\n}\n\n.reply-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-subtitle);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n margin: 0;\n color: var(--site-reading-heading);\n}\n\n.reply-body {\n color: var(--site-reading-body);\n}\n\n.reply-body > :first-child {\n margin-top: 0;\n}\n\n.reply-body > :last-child {\n margin-bottom: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Collections page\n *\n * Mirrors the main site's authenticated-less collections view\n * (`ui/pages/CollectionsPage.tsx` + `ui/shared/CollectionDirectory.tsx`):\n * a page-intro block with count, then a two-column grid per row where a\n * monospace sequence label sits beside the collection title, description,\n * and entry/activity meta.\n * ------------------------------------------------------------------------- */\n\n.collections-page-shell {\n position: relative;\n display: flex;\n flex-direction: column;\n width: var(--layout-content-width);\n max-width: 100%;\n gap: clamp(1.25rem, 3vw, 1.75rem);\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .collections-page-shell {\n width: min(100%, 35rem);\n }\n}\n\n@media (max-width: 760px) {\n .collections-page-shell {\n width: 100%;\n }\n}\n\n.collections-page-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n padding-bottom: 0.2rem;\n}\n\n.collections-page-heading {\n min-width: 0;\n flex: 1 1 18rem;\n}\n\n.page-intro {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding-bottom: 0.1rem;\n}\n\n.page-intro-title-row {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0.7rem;\n min-width: 0;\n}\n\n.page-intro-title {\n margin: 0;\n font-family: var(--font-heading);\n font-size: var(--type-title, 2rem);\n font-weight: var(--type-heading-weight);\n line-height: 1.15;\n letter-spacing: -0.01em;\n color: var(--site-text-primary);\n}\n\n.page-intro-meta-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n}\n\n.page-intro-meta,\n.page-intro-description {\n margin: 0;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-secondary);\n line-height: 1.3;\n}\n\n.collection-directory {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.collection-directory-item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: 0.75rem;\n padding: 0.95rem 0;\n background: transparent;\n text-decoration: none;\n}\n\n.collection-directory-main {\n --collection-directory-sequence-width: 3.5ch;\n --collection-directory-title-line-height: 1.18;\n min-width: 0;\n flex: 1;\n display: grid;\n grid-template-columns: var(--collection-directory-sequence-width) minmax(\n 0,\n 1fr\n );\n align-items: start;\n column-gap: 0.8rem;\n row-gap: 0.25rem;\n}\n\n.collection-directory-sequence {\n grid-column: 1;\n grid-row: 1;\n display: block;\n width: var(--collection-directory-sequence-width);\n padding-top: 0.2rem;\n font-family: var(--font-mono);\n font-size: var(--type-xs);\n font-variant-numeric: tabular-nums;\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: 0.14em;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-title-row {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n display: flex;\n align-items: flex-start;\n}\n\n.collection-directory-title-link {\n color: inherit;\n text-decoration: none;\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover,\n.collection-directory-title-link:focus-visible {\n color: var(--site-text-primary);\n}\n\n.collection-directory-title-link:hover .collection-directory-title,\n.collection-directory-title-link:focus-visible .collection-directory-title {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.collection-directory-title {\n min-width: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n font-family: var(--font-heading);\n font-size: var(--type-content-body);\n font-weight: var(--type-heading-weight);\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: -0.02em;\n text-wrap: pretty;\n}\n\n.collection-directory-title-marker {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 0.95rem;\n min-width: 0.95rem;\n height: 0.95rem;\n color: var(--site-text-secondary);\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover .collection-directory-title-marker,\n.collection-directory-title-link:focus-visible\n .collection-directory-title-marker {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description {\n grid-column: 2;\n grid-row: 2;\n margin: 0;\n color: color-mix(in srgb, var(--site-text-secondary) 80%, transparent);\n font-family: var(--font-body);\n font-size: var(--type-sm);\n line-height: 1.45;\n}\n\n.collection-directory-description :where(p) {\n margin-top: 0.25em;\n margin-bottom: 0.25em;\n}\n\n/* Links inside the description inherit the description color so they sit in\n the same gray tone as the copy around them (matches the main site's\n `.prose { --tw-prose-links: var(--site-content-link); }` which resolves\n to `inherit`). The underline stays, drawn in currentColor. */\n.collection-directory-description :where(a) {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: currentColor;\n text-underline-offset: 0.15em;\n}\n\n.collection-directory-description :where(a:hover),\n.collection-directory-description :where(a:focus-visible) {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description :where(p:first-child) {\n margin-top: 0;\n}\n\n.collection-directory-description :where(p:last-child) {\n margin-bottom: 0;\n}\n\n.collection-directory-description + .collection-directory-summary {\n grid-row: 3;\n}\n\n.collection-directory-summary {\n grid-column: 2;\n grid-row: 2;\n display: flex;\n min-width: 0;\n overflow: hidden;\n align-items: center;\n gap: 0.2rem 0.5rem;\n margin: 0;\n color: var(--site-reading-meta);\n font-family: var(--font-ui);\n font-size: var(--type-sm);\n line-height: 1.3;\n white-space: nowrap;\n}\n\n.collection-directory-meta {\n flex: 0 0 auto;\n color: inherit;\n}\n\n.collection-directory-meta-separator {\n flex: 0 0 auto;\n color: color-mix(in srgb, var(--site-divider) 88%, transparent);\n}\n\n.collection-directory-updated {\n flex: 0 0 auto;\n color: inherit;\n white-space: nowrap;\n}\n\n.collection-directory-divider {\n padding: 1.5rem 0 0.85rem;\n}\n\n.collection-directory-divider-row {\n display: flex;\n align-items: center;\n gap: 0.95rem;\n}\n\n.collection-directory-divider-text {\n font-family: var(--font-heading);\n font-size: var(--type-secondary);\n letter-spacing: 0;\n font-style: normal;\n white-space: nowrap;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-divider-line {\n flex: 1;\n height: 1px;\n border: none;\n margin: 0;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--site-divider) 100%, transparent),\n color-mix(in srgb, var(--site-divider) 54%, transparent) 34%,\n transparent 86%\n );\n}\n\n/* -------------------------------------------------------------------------\n * Pagination\n * ------------------------------------------------------------------------- */\n\n.pagination {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n flex-wrap: wrap;\n gap: 1rem;\n padding: 1.5rem 0;\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n.pagination-link {\n color: var(--site-text-secondary);\n text-decoration: underline;\n text-underline-offset: 3px;\n transition: color 0.15s ease;\n}\n\n.pagination-link:hover,\n.pagination-link:focus {\n color: var(--site-text-primary);\n}\n\n.pagination-link.is-disabled {\n color: color-mix(in srgb, var(--site-text-secondary) 50%, transparent);\n cursor: default;\n text-decoration: none;\n}\n\n.pagination-current {\n color: var(--site-text-primary);\n font-weight: var(--fw-medium);\n}\n\n.pagination-ellipsis {\n color: var(--site-text-secondary);\n}\n\n/* -------------------------------------------------------------------------\n * Empty states + utility\n * ------------------------------------------------------------------------- */\n\n.empty-state {\n color: var(--site-reading-meta);\n font-style: italic;\n text-align: center;\n padding: var(--space-xl) 0;\n}\n\n.page,\n.section {\n display: block;\n}\n\n.page-summary,\n.section-summary {\n font-size: var(--type-secondary);\n color: var(--site-reading-meta);\n}\n\n/* -------------------------------------------------------------------------\n * Wider viewport refinements\n * ------------------------------------------------------------------------- */\n\n@media (min-width: 768px) {\n .site-main {\n padding-top: calc(var(--space-xl) * 1.5);\n padding-bottom: calc(var(--space-xl) * 1.5);\n }\n\n .post-card-media,\n .thread-media,\n .reply-media {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .post-card-media:has(> :only-child),\n .thread-media:has(> :only-child),\n .reply-media:has(> :only-child) {\n grid-template-columns: 1fr;\n }\n}\n";
6270
+ var main_default = "/*\n * Jant Hugo Export — main.css\n *\n * Fresh minimal text-first design. All colors, spacing, and type sizing\n * come from tokens.css (loaded first in the <head>). Color theme values\n * are supplied by theme.css; customizations live in custom.css.\n *\n * Load order in <head>: tokens.css → main.css → theme.css → custom.css\n *\n * This file intentionally avoids any hardcoded hex, rgb, or px color\n * values. Everything token-driven so the design evolves with the theme.\n */\n\n/* -------------------------------------------------------------------------\n * Reset + box model\n * ------------------------------------------------------------------------- */\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 15px;\n -webkit-text-size-adjust: 100%;\n text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--font-body);\n font-size: var(--type-body-size, var(--type-content-body));\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n background-color: var(--site-page-bg);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n /* Every text node in an exported page is authored content, and authored text\n can contain a run with no break opportunity — a long URL, an ID, a word\n typed without spaces. Such a run does not wrap on its own and spills past\n the content column across the page. `break-word` splits it only when it\n cannot fit a line by itself, so ordinary text keeps its natural line breaks\n and intrinsic sizing is unaffected. */\n overflow-wrap: break-word;\n}\n\nimg,\nvideo,\naudio,\niframe {\n max-width: 100%;\n height: auto;\n}\n\nfigure {\n margin: 0;\n}\n\nhr {\n border: 0;\n border-top: 1px solid var(--site-divider);\n margin: var(--space-xl) 0;\n}\n\n/* -------------------------------------------------------------------------\n * Typography\n * ------------------------------------------------------------------------- */\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-family: var(--font-heading);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n letter-spacing: var(--type-heading-tracking);\n color: var(--site-reading-heading);\n margin: 1.6em 0 0.6em;\n}\n\nh1 {\n font-size: var(--type-content-display);\n line-height: var(--type-display-leading);\n font-weight: var(--type-display-weight);\n letter-spacing: var(--type-display-tracking);\n margin: 4rem 0 1.5rem;\n}\n\nh2 {\n font-size: var(--type-content-title);\n margin: 2.1rem 0 1.4rem;\n}\n\nh3 {\n font-size: var(--type-content-subtitle);\n margin: 2rem 0 1.4rem;\n}\n\nh4 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n}\n\nh5,\nh6 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n color: var(--site-reading-meta);\n}\n\np {\n margin: 1.4rem 0;\n}\n\nsmall {\n font-size: var(--type-sm);\n}\n\ncode,\nkbd,\nsamp {\n font-family: var(--font-mono);\n font-size: var(--type-code);\n line-height: 1.42;\n}\n\ncode {\n color: var(--site-code-text);\n overflow-wrap: break-word;\n}\n\npre {\n font-family: var(--font-mono);\n font-size: var(--type-code-block);\n line-height: 1.5;\n padding: 1rem;\n overflow-x: auto;\n color: var(--site-code-text);\n background-color: var(--site-code-block-bg);\n border: 1px solid var(--site-code-block-border);\n border-radius: 6px;\n}\n\npre code {\n padding: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font-size: inherit;\n}\n\n:not(pre) > code {\n padding: 0.08em 0.28em;\n background-color: var(--site-code-bg);\n border-radius: 0.22em;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n}\n\n/* Blockquotes — tinted background card with quote icon (matches main site). */\nblockquote {\n position: relative;\n margin: 1.4rem 0;\n padding: 1.4rem 1rem 0.75rem;\n border: none;\n background: var(--site-blockquote-bg);\n border-radius: 6px;\n color: var(--site-blockquote-text);\n font-family: var(--font-blockquote);\n font-style: italic;\n font-weight: inherit;\n quotes: none;\n text-wrap: pretty;\n}\n\nblockquote::before {\n content: \"\";\n display: block;\n width: 1.3rem;\n height: 1.3rem;\n margin-bottom: 0.35rem;\n background: var(--site-blockquote-rail);\n opacity: 0.6;\n mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n mask-size: contain;\n mask-repeat: no-repeat;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n}\n\nblockquote :where(p:first-of-type)::before,\nblockquote :where(p:last-of-type)::after {\n content: none;\n}\n\nblockquote > :first-child {\n margin-top: 0;\n}\n\nblockquote > :last-child {\n margin-bottom: 0;\n}\n\nblockquote p {\n margin: 0;\n}\n\nblockquote p + p,\nblockquote ul,\nblockquote ol,\nblockquote pre {\n margin-top: 0.6em;\n}\n\nblockquote cite {\n font-style: normal;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* CJK: no italic for blockquotes (no true italic glyph) */\n:lang(zh) blockquote,\n:lang(ja) blockquote,\n:lang(ko) blockquote {\n font-style: normal;\n}\n\nul,\nol {\n padding-left: 1.4em;\n margin: 1.25em 0;\n}\n\nol ol {\n list-style-type: lower-alpha;\n margin-top: 0.4em;\n margin-bottom: 0.4em;\n}\n\nol ol ol {\n list-style-type: lower-roman;\n}\n\nli {\n margin-top: 0.5em;\n margin-bottom: 0.5em;\n}\n\n/* Normalize li > p so list spacing is controlled by li alone,\n regardless of whether the markdown renderer wraps li contents in <p>. */\nli > p:first-child {\n margin-top: 0;\n}\n\nli > p:last-child {\n margin-bottom: 0;\n}\n\nli > p:has(+ ol) {\n margin-bottom: 0;\n}\n\na {\n color: var(--site-reading-link);\n text-decoration: underline;\n text-decoration-color: var(--site-reading-link-underline);\n text-underline-offset: 0.15em;\n transition:\n color 0.2s ease,\n text-decoration-color 0.2s ease;\n}\n\na:hover,\na:focus {\n color: var(--site-reading-link-hover);\n text-decoration-color: currentColor;\n}\n\na:focus-visible {\n outline: 2px solid var(--site-accent);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\ntime {\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n/* -------------------------------------------------------------------------\n * Page layout — Tufte horizontal frame\n *\n * Mirrors the main site's `.site-page > header/main/footer` rule. Every\n * top-level section gets the same asymmetric padding so the reading column\n * aligns with the rest of the site.\n *\n * 12.5% left + 4% right = 16.5% padding → content = 83.5% of the box.\n * max-width = body-max-width / 0.835 so the inner content area exactly\n * equals `--layout-body-max-width` on wide viewports. min() caps keep\n * padding from growing beyond 210px / 67px. Mobile widens to 5% / 5%\n * and the 55% content column collapses to 100% (via tokens.css).\n * ------------------------------------------------------------------------- */\n\n.site-page {\n min-height: 100vh;\n min-height: 100dvh;\n background-color: var(--site-page-bg);\n}\n\n.site-page > header,\n.site-page > main,\n.site-page > footer,\n.site-page > .home-branding-credit {\n width: 100%;\n max-width: calc(var(--layout-body-max-width) / 0.835);\n padding-left: min(12.5%, 210px);\n padding-right: min(4%, 67px);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (max-width: 760px) {\n .site-page > header,\n .site-page > main,\n .site-page > footer,\n .site-page > .home-branding-credit {\n padding-left: max(5%, 28px);\n padding-right: 5%;\n }\n}\n\n.site-main {\n padding-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Header\n * ------------------------------------------------------------------------- */\n\n.site-header {\n padding-top: 24px;\n background-color: var(--site-page-bg);\n}\n\n@media (min-width: 700px) {\n .site-header {\n padding-top: 30px;\n }\n}\n\n.site-header-inner {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 0;\n}\n\n.site-header-top {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n flex-wrap: nowrap;\n min-height: 2.75rem;\n width: 100%;\n}\n\n.site-header-top-bordered {\n padding-bottom: 15px;\n}\n\n@media (min-width: 700px) {\n .site-header-top-bordered {\n padding-bottom: 18px;\n }\n}\n\n.site-logo {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: 10px;\n min-width: 0;\n padding: 0.15rem 0;\n font-family: var(--font-site-title);\n font-size: var(--type-subtitle);\n font-weight: var(--fw-regular);\n letter-spacing: -0.02em;\n line-height: 1.15;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo:hover,\n.site-logo:focus {\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo-avatar {\n box-sizing: border-box;\n width: calc(var(--avatar-size) + 4px);\n height: calc(var(--avatar-size) + 4px);\n border-radius: var(--avatar-radius);\n object-fit: cover;\n border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);\n flex: none;\n}\n\n.site-logo-text {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.site-header-nav {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n justify-content: flex-end;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n margin-left: auto;\n min-width: 0;\n font-family: var(--font-ui);\n}\n\n.site-header-link {\n display: inline-flex;\n flex: none;\n align-items: center;\n position: relative;\n min-height: 2rem;\n padding: 0.15rem 0;\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n white-space: nowrap;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n text-decoration: none;\n transition: color 0.15s;\n}\n\n.site-header-link:hover,\n.site-header-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n text-decoration: none;\n}\n\n.site-header-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n/* --- \"More\" dropdown ---------------------------------------------------- */\n\n.site-header-more {\n position: relative;\n display: inline-flex;\n align-items: center;\n}\n\n.site-header-more-responsive-only {\n display: none;\n}\n\n.site-header-more-btn {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n min-height: 2rem;\n padding: 0.15rem 0;\n border: none;\n background: transparent;\n cursor: pointer;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n transition: color 0.15s;\n}\n\n.site-header-more-btn svg {\n width: 0.82rem;\n height: 0.82rem;\n transition: transform 0.18s ease;\n}\n\n.site-header-more-btn:hover,\n.site-header-more-btn[aria-expanded=\"true\"] {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-btn[aria-expanded=\"true\"] svg {\n transform: rotate(180deg);\n}\n\n.site-header-more-popover {\n display: block;\n position: absolute;\n top: 100%;\n right: 0;\n margin-top: 0.6rem;\n min-width: 12.25rem;\n padding: 0.3rem 0;\n background: var(--site-page-bg);\n border: 0.5px solid color-mix(in srgb, var(--site-divider) 80%, transparent);\n border-radius: 0.4rem;\n box-shadow:\n 0 4px 20px -8px rgba(0, 0, 0, 0.12),\n 0 2px 6px -2px rgba(0, 0, 0, 0.06);\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n transform: translateY(-6px);\n transform-origin: top right;\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n visibility 0s linear 0.18s;\n z-index: 50;\n}\n\n.site-header-more-popover[aria-hidden=\"false\"] {\n opacity: 1;\n visibility: visible;\n pointer-events: auto;\n transform: translateY(0);\n transition-delay: 0s;\n}\n\n.site-header-more-link {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.35rem;\n padding: 0.45rem 1rem;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.15s,\n background-color 0.15s;\n}\n\n.site-header-more-link:hover,\n.site-header-more-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n background: color-mix(in srgb, var(--site-nav-hover-bg) 58%, transparent);\n text-decoration: none;\n}\n\n.site-header-more-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-link-responsive,\n.site-header-more-divider-responsive {\n display: none;\n}\n\n.site-header-more-divider {\n height: 0;\n margin: 0.35rem 0.75rem;\n border-top: 0.5px solid\n color-mix(in srgb, var(--site-divider) 60%, transparent);\n}\n\n/* --- Tiered responsive collapse ---------------------------------------- *\n *\n * ≤960px — 5th+ inline link collapses into More (tier-lg)\n * ≤780px — also 4th collapses (tier-md)\n * ≤580px — also 3rd collapses (tier-sm)\n * The first two links always stay inline. No hamburger on static export —\n * at very narrow widths 2 inline links + More button is the floor.\n */\n\n@media (max-width: 960px) {\n .site-header-link-collapse-lg {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-lg {\n display: inline-flex;\n }\n\n .site-header-more-link-show-lg {\n display: flex;\n }\n\n .site-header-more-divider-responsive {\n display: block;\n }\n}\n\n@media (max-width: 780px) {\n .site-header-link-collapse-md {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-md {\n display: inline-flex;\n }\n\n .site-header-more-link-show-md {\n display: flex;\n }\n}\n\n@media (max-width: 580px) {\n .site-header-link-collapse-sm {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-sm {\n display: inline-flex;\n }\n\n .site-header-more-link-show-sm {\n display: flex;\n }\n}\n\n/* -------------------------------------------------------------------------\n * Footer\n * ------------------------------------------------------------------------- */\n\n.site-footer {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n color: var(--site-text-secondary);\n font-size: var(--type-xs);\n background-color: var(--site-page-bg);\n}\n\n.site-footer-inner {\n border-top: 0.5px solid var(--site-divider);\n padding-top: var(--space-xl);\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.site-footer-content {\n color: var(--site-text-secondary);\n}\n\n.site-footer-content p {\n margin: 0 0 0.5em;\n}\n\n.site-footer-nav-list {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.home-branding-credit {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n color: var(--site-text-secondary);\n font-size: var(--type-base);\n}\n\n.home-branding-credit a {\n display: inline-flex;\n align-items: center;\n gap: 0.38rem;\n color: inherit;\n text-decoration: none;\n border-bottom: 0.5px solid\n color-mix(in srgb, var(--site-text-secondary) 45%, transparent);\n transition:\n color 160ms ease,\n border-color 160ms ease;\n}\n\n.home-branding-credit a:hover,\n.home-branding-credit a:focus-visible {\n color: var(--site-text-primary);\n border-color: currentColor;\n}\n\n/* -------------------------------------------------------------------------\n * Tufte content-width constraint\n *\n * Mirrors the main site's 55% rule: reading text occupies a narrow\n * column inside the Tufte frame, leaving a wide right margin that\n * would host sidenotes on the main site. Media (images, video, audio)\n * is NOT included — galleries intentionally span the full frame so\n * they can breathe.\n *\n * Mobile (<=760px): `--layout-content-width` collapses to 100% via\n * tokens.css. Tablet: cap at 35rem for readability.\n * ------------------------------------------------------------------------- */\n\n.section-header,\n.section-body,\n.post-card-title,\n.post-card-summary,\n.post-card-link-domain,\n.post-card-quote-content,\n.post-card-quote-attribution,\n.post-card-quote-commentary,\n.post-card-footer,\n.reply-title,\n.reply-body,\n.reply-link-domain,\n.reply-footer,\n.thread-title,\n.thread-body,\n.thread-link-domain,\n.thread-footer,\n.collection-directory,\n.pagination,\n.empty-state,\n.page-summary {\n width: var(--layout-content-width);\n max-width: 100%;\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .section-header,\n .section-body,\n .post-card-title,\n .post-card-summary,\n .post-card-link-domain,\n .post-card-quote-content,\n .post-card-quote-attribution,\n .post-card-quote-commentary,\n .post-card-footer,\n .reply-title,\n .reply-body,\n .reply-link-domain,\n .reply-footer,\n .thread-title,\n .thread-body,\n .thread-link-domain,\n .thread-footer,\n .collection-directory,\n .pagination,\n .empty-state,\n .page-summary {\n width: min(100%, 35rem);\n }\n}\n\n/* -------------------------------------------------------------------------\n * Section headers\n * ------------------------------------------------------------------------- */\n\n.section-header {\n margin-bottom: var(--space-xl);\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--site-border-light);\n}\n\n.section-title {\n margin: 0 0 0.25em;\n}\n\n.section-summary {\n margin: 0;\n color: var(--site-reading-meta);\n font-size: var(--type-secondary);\n}\n\n.section-meta {\n margin: 0.5em 0 0;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* -------------------------------------------------------------------------\n * Post list + post cards\n * ------------------------------------------------------------------------- */\n\n.post-list {\n display: flex;\n flex-direction: column;\n gap: calc(var(--space-xl) * 1.25);\n}\n\n.post-list-pinned {\n margin-bottom: calc(var(--space-xl) * 1.25);\n padding-bottom: calc(var(--space-xl) * 1.25);\n border-bottom: 1px solid var(--site-border-light);\n}\n\n/* Decorative divider between posts in a timeline feed. Mirrors the main\n site's `hr.feed-divider`: a trio of small chevron marks masked from the\n current text color, so it picks up the theme automatically.\n `margin-left` centers the divider within the 55% reading column so it\n visually sits at the middle of the post-card text stack. */\nhr.feed-divider {\n border: none;\n width: 30px;\n height: 9px;\n margin: 0;\n margin-left: calc(var(--layout-content-width) / 2 - 15px);\n color: var(--site-feed-divider-color);\n background-color: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n.post-card {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.post-card-title {\n font-family: var(--font-heading);\n font-size: var(--feed-note-title-size);\n font-weight: var(--type-heading-weight);\n line-height: var(--feed-note-title-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.post-card-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.post-card-title a:hover,\n.post-card-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-card-summary {\n margin: 0;\n color: var(--site-reading-body);\n}\n\n.post-card > .post-card-summary :is(h1, h2),\n.post-card > .post-card-quote-commentary :is(h1, h2) {\n font-size: calc(var(--type-content-body) * 1.12);\n font-weight: var(--fw-medium);\n margin-top: 1.45rem;\n margin-bottom: 0.55rem;\n}\n\n.post-card > .post-card-summary :is(h3, h4),\n.post-card > .post-card-quote-commentary :is(h3, h4) {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n margin-top: 1.2rem;\n margin-bottom: 0.45rem;\n}\n\n.post-card > .post-card-summary :is(h5, h6),\n.post-card > .post-card-quote-commentary :is(h5, h6) {\n font-size: var(--type-secondary);\n font-weight: var(--fw-medium);\n color: var(--site-text-secondary);\n margin-top: 1rem;\n margin-bottom: 0.35rem;\n}\n\n/* Link card domain row — shown ABOVE the title (matches main site's\n `.feed-link-domain` pattern). Inline flex with icon + host text. */\n.post-card-link-domain,\n.thread-link-domain,\n.reply-link-domain {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n max-width: 100%;\n margin: 0 0 0.4rem 0;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-regular);\n line-height: 1.3;\n color: var(--site-text-secondary);\n text-decoration: none;\n word-break: break-all;\n transition: color 0.18s ease;\n}\n\n.post-card-link-domain:hover,\n.post-card-link-domain:focus,\n.thread-link-domain:hover,\n.thread-link-domain:focus,\n.reply-link-domain:hover,\n.reply-link-domain:focus {\n color: var(--site-text-primary);\n}\n\n.post-card-link-domain-icon {\n width: 0.72rem;\n height: 0.72rem;\n flex-shrink: 0;\n}\n\n/* Slight extra breathing room below link-card titles to match main site. */\n.post-card-link-title,\n.thread-link-title,\n.reply-link-title {\n text-wrap: pretty;\n}\n\n.post-card-link-title a,\n.thread-link-title a,\n.reply-link-title a {\n text-decoration: none;\n}\n\n.post-card-link-title a:hover,\n.post-card-link-title a:focus,\n.thread-link-title a:hover,\n.thread-link-title a:focus,\n.reply-link-title a:hover,\n.reply-link-title a:focus {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.post-card-media,\n.reply-media,\n.thread-media {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.75rem;\n}\n\n.post-card-figure img,\n.reply-figure img,\n.thread-figure img {\n display: block;\n width: 100%;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n}\n\n.post-card-figure video,\n.reply-figure video,\n.thread-figure video {\n display: block;\n width: 100%;\n height: auto;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n background: #000;\n}\n\n.post-card-figure audio,\n.reply-figure audio,\n.thread-figure audio {\n display: block;\n width: 100%;\n}\n\n.post-card-figure-video a {\n position: relative;\n display: block;\n}\n\n.post-card-video-badge {\n position: absolute;\n left: 0.5rem;\n bottom: 0.5rem;\n padding: 0.125rem 0.5rem;\n font-size: var(--type-xs);\n color: #fff;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 999px;\n pointer-events: none;\n}\n\n.thread-file a,\n.reply-file a {\n color: var(--site-link);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Quote format — decorative mark, serif body, attribution line\n * ------------------------------------------------------------------------- */\n\n.post-card-quote {\n position: static;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: var(--site-reading-quote);\n font-family: inherit;\n font-style: normal;\n}\n\n/* Quote-format posts have their own decorative `.post-card-quote-mark`\n SVG inside the blockquote — suppress the global blockquote icon. */\n.post-card-quote::before {\n content: none;\n}\n\n.post-card-quote-mark {\n display: block;\n position: relative;\n width: 1.7rem;\n margin-bottom: -0.1rem;\n margin-left: -0.04rem;\n line-height: 0;\n pointer-events: none;\n color: color-mix(in srgb, var(--site-accent) 14%, var(--site-divider));\n opacity: 0.66;\n}\n\n.post-card-quote-mark svg {\n display: block;\n width: 100%;\n height: auto;\n}\n\n.post-card-quote-content {\n font-family: var(--font-serif);\n color: var(--site-text-primary);\n font-size: var(--type-content-quote);\n line-height: var(--type-content-quote-leading);\n white-space: pre-line;\n text-wrap: pretty;\n margin: 0;\n}\n\n.post-card-quote-attribution {\n display: flex;\n align-items: center;\n gap: 0.45rem;\n flex-wrap: wrap;\n /* The name and source are flex items, which refuse to shrink below their\n min-content width. The inherited `break-word` leaves min-content at the\n longest word and the item still overflows; `anywhere` also shrinks\n min-content, which is what lets the row wrap. */\n overflow-wrap: anywhere;\n margin-top: 0.95rem;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-style: normal;\n line-height: 1.3;\n}\n\n.post-card-quote-attribution::before {\n content: \"\";\n width: 0.9rem;\n height: 1px;\n background: color-mix(\n in srgb,\n var(--site-text-secondary) 38%,\n var(--site-divider)\n );\n}\n\n.post-card-quote-source {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: color-mix(\n in srgb,\n var(--site-text-secondary) 55%,\n transparent\n );\n text-underline-offset: 3px;\n}\n\n.post-card-quote-source:hover,\n.post-card-quote-source:focus {\n color: var(--site-text-primary);\n text-decoration-color: currentColor;\n}\n\n.post-card-quote-commentary {\n position: relative;\n margin-top: 1.1rem;\n padding-top: 0.95rem;\n color: color-mix(\n in srgb,\n var(--site-text-secondary) 84%,\n var(--site-text-primary)\n );\n text-wrap: pretty;\n}\n\n.post-card-quote-commentary::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent 0%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 16%,\n color-mix(in srgb, var(--site-divider) 78%, transparent) 50%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 84%,\n transparent 100%\n );\n}\n\n.post-card-quote-commentary.prose > :first-child {\n margin-top: 0;\n}\n\n.post-card-quote-commentary.prose > :last-child {\n margin-bottom: 0;\n}\n\n.post-card-quote-commentary p {\n margin: 0;\n}\n\n.post-card-quote-commentary p + p,\n.post-card-quote-commentary ul,\n.post-card-quote-commentary ol,\n.post-card-quote-commentary blockquote,\n.post-card-quote-commentary pre {\n margin-top: 0.55rem;\n}\n\n/* Fade the post meta on quote cards so the quote body stays visually primary. */\n.post-card-quote ~ .post-card-footer,\n.post-card-quote-commentary + .post-card-footer {\n opacity: 0.72;\n}\n\n/* -------------------------------------------------------------------------\n * Post footer — meta (featured, time, external link, collections, pinned)\n * ------------------------------------------------------------------------- */\n\n.post-card-footer,\n.reply-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 10px;\n min-height: 2rem;\n font-size: var(--type-ui-hint);\n}\n\n.post-footer-detail {\n margin-top: 24px;\n /* Match the feed/reply footer size so the root post's footer doesn't\n visually dominate. Main site uses a larger size because every post\n on the detail page is rendered at detail size; here only the root\n post gets this class, which otherwise creates a mismatch with the\n replies below. */\n font-size: var(--type-ui-hint);\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n min-width: 0;\n font-family: var(--font-ui);\n line-height: 1.35;\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta time {\n font-size: inherit;\n color: inherit;\n}\n\n.post-footer-featured {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: color-mix(\n in srgb,\n var(--search-mark-color) 72%,\n var(--site-text-secondary)\n );\n flex-shrink: 0;\n}\n\n.post-footer-featured svg {\n width: 1rem;\n height: 1rem;\n opacity: 0.9;\n}\n\n.post-footer-link {\n color: var(--site-text-secondary);\n text-decoration: none;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.post-footer-link:hover,\n.post-footer-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n.post-footer-external-link {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.6rem;\n height: 1.6rem;\n border-radius: 0.6rem;\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.18s ease,\n background-color 0.16s ease;\n flex-shrink: 0;\n}\n\n.post-footer-external-link:hover,\n.post-footer-external-link:focus {\n color: var(--site-text-primary);\n}\n\n.post-footer-external-link svg {\n width: 1rem;\n height: 1rem;\n}\n\n.post-collection-tags {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n min-width: 0;\n max-width: 100%;\n color: var(--site-text-secondary);\n}\n\n.post-collection-tag {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: inherit;\n text-decoration: none;\n min-width: 0;\n max-width: min(100%, 22ch);\n}\n\n.post-collection-tag:hover,\n.post-collection-tag:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-collection-tag-text {\n display: block;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.post-card-pin {\n font-size: var(--type-2xs);\n text-transform: uppercase;\n letter-spacing: var(--type-label-tracking);\n color: var(--site-accent);\n border: 1px solid var(--site-accent);\n padding: 0.1em 0.5em;\n border-radius: 999px;\n flex-shrink: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Thread preview (list pages: root context + hero latest reply)\n *\n * Mirrors the main site's `.thread-group.thread-group-preview` layout:\n * content sits flush-left inside the preview, and the vertical rail +\n * dot markers are positioned OUTSIDE the content (overflowing into the\n * container's left gutter) via a negative `left` on the rail/dots.\n * ------------------------------------------------------------------------- */\n\n.thread-preview {\n /* Rail position: negative = overflow outside content. Mirrors\n `--site-thread-rail-line-left` on the main site. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n --thread-item-spacing: 0.35rem;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread-preview {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail — subtle gradient so the line fades into\n whitespace at the top and tail. Sits outside the content via negative\n `left`. */\n.thread-preview::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n.thread-preview-context {\n position: relative;\n display: flex;\n flex-direction: column;\n /* Match runtime `.thread-group-preview .thread-item` which uses\n `padding: var(--site-thread-item-spacing) 0` — adjacent items have\n 2x the token between them, so the flex `gap` here is 2x to match. */\n gap: calc(var(--site-thread-item-spacing) * 2);\n margin: 0;\n padding: 0;\n border-left: 0;\n}\n\n/* Individual entry in a thread preview — wraps a full `.post-card` so\n the root, second, and penultimate replies render with their own\n title/body/footer. Dot marker sits on the rail at the card's\n vertical midpoint. */\n.thread-preview .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-preview .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n/* Gap \"N more posts\" row has no card so we place the marker at its\n vertical midpoint instead of the card's top area. Swap the single dot\n for a short column of small beads (a vertical \"⋮\") so the rail visibly\n \"skips\" a run of posts — mirrors the main site's `.thread-item-gap`. */\n.thread-preview .thread-item-gap {\n display: flex;\n align-items: center;\n padding: 0.15rem 0 0.35rem;\n}\n\n.thread-preview .thread-item-gap::before {\n top: 50%;\n transform: translateY(-50%);\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 - 2px -\n var(--thread-rail-indent)\n );\n width: 4px;\n height: 24px;\n border: 0;\n border-radius: 0;\n background: radial-gradient(\n circle,\n var(--site-threadline) 1.75px,\n transparent 2px\n );\n background-size: 4px 8px;\n background-repeat: repeat-y;\n background-position: center top;\n box-shadow: none;\n}\n\n/* Hidden-posts count — a calm route into the thread, matching the main\n site's `.thread-gap-link`: styled like a sibling of the Show more toggle\n with a small right-pointing chevron rather than an underline, firming up\n to the primary text colour on hover. */\n.thread-preview-gap {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n align-self: flex-start;\n margin: 0.15rem 0;\n padding: 0.25rem 0;\n color: var(--site-text-secondary);\n font-size: var(--type-thread-context-meta);\n font-weight: 500;\n line-height: 1.2;\n text-decoration: none;\n transition: color 0.18s ease;\n}\n\n.thread-preview-gap::after {\n content: \"\";\n width: 0.36em;\n height: 0.36em;\n border: 1.5px solid currentColor;\n border-left: 0;\n border-bottom: 0;\n opacity: 0.65;\n transform: rotate(45deg);\n transition:\n transform 0.18s ease,\n opacity 0.18s ease;\n}\n\n.thread-preview-gap:hover,\n.thread-preview-gap:focus {\n color: var(--site-text-primary);\n}\n\n.thread-preview-gap:hover::after,\n.thread-preview-gap:focus::after {\n opacity: 1;\n transform: translateX(2px) rotate(45deg);\n}\n\n/* Hero (latest reply) row: spacing above and below matches the main\n site's `.thread-item-hero`, and the dot is larger + accent-colored. */\n.thread-preview-hero {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-top: calc(var(--space-xl) * 1.1);\n margin-top: 0;\n}\n\n/* Featured timelines keep Post-level selection while grouping by Thread.\n Selected Root/Child Posts use the accent marker; context Posts retain the\n neutral rail marker. */\n.thread-preview .thread-item-featured::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview .thread-item-hero::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: calc(var(--space-xl) * 1.1 + 1.4rem);\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview-thread-link {\n align-self: flex-start;\n font-size: var(--type-sm);\n color: var(--site-reading-meta);\n text-decoration: none;\n}\n\n.thread-preview-thread-link:hover,\n.thread-preview-thread-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Thread (single post page with inline replies)\n * ------------------------------------------------------------------------- */\n\n.thread {\n /* Rail variables match `.thread-preview` so the detail-page rail\n shares the same visual position. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail spanning the entire thread (root post + replies).\n Only shown when the root post actually has replies — a lone post should\n not have a rail or dot. Matches `.thread-preview`. */\n.thread-has-replies::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n/* Dot marker for each post in the thread (root + replies). Shown only when\n there are replies so a solo post doesn't get an orphaned dot. */\n.thread .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-has-replies .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n.thread-item-root {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.thread-header {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.thread-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-display);\n font-weight: var(--type-display-weight);\n line-height: var(--type-display-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.thread-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.thread-title a:hover,\n.thread-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.thread-body {\n font-size: var(--type-content-body);\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n}\n\n.thread-body > :first-child {\n margin-top: 0;\n}\n\n.thread-body > :last-child {\n margin-bottom: 0;\n}\n\n.thread-replies {\n /* Flex container for replies. The rail and dots are provided by\n `.thread::before` and `.thread-item::before` so replies stay visually\n connected to the root post. */\n display: flex;\n flex-direction: column;\n gap: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Replies\n * ------------------------------------------------------------------------- */\n\n.reply {\n scroll-margin-top: 1.5rem;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.reply:target {\n background-color: var(--search-mark-bg);\n border-radius: 0.25rem;\n padding: 0.75rem 1rem;\n margin-left: -1rem;\n margin-right: -1rem;\n}\n\n.reply:target::before {\n background-color: var(--site-accent);\n}\n\n.reply-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-subtitle);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n margin: 0;\n color: var(--site-reading-heading);\n}\n\n.reply-body {\n color: var(--site-reading-body);\n}\n\n.reply-body > :first-child {\n margin-top: 0;\n}\n\n.reply-body > :last-child {\n margin-bottom: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Collections page\n *\n * Mirrors the main site's authenticated-less collections view\n * (`ui/pages/CollectionsPage.tsx` + `ui/shared/CollectionDirectory.tsx`):\n * a page-intro block with count, then a two-column grid per row where a\n * monospace sequence label sits beside the collection title, description,\n * and entry/activity meta.\n * ------------------------------------------------------------------------- */\n\n.collections-page-shell {\n position: relative;\n display: flex;\n flex-direction: column;\n width: var(--layout-content-width);\n max-width: 100%;\n gap: clamp(1.25rem, 3vw, 1.75rem);\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .collections-page-shell {\n width: min(100%, 35rem);\n }\n}\n\n@media (max-width: 760px) {\n .collections-page-shell {\n width: 100%;\n }\n}\n\n.collections-page-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n padding-bottom: 0.2rem;\n}\n\n.collections-page-heading {\n min-width: 0;\n flex: 1 1 18rem;\n}\n\n.page-intro {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding-bottom: 0.1rem;\n}\n\n.page-intro-title-row {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0.7rem;\n min-width: 0;\n}\n\n.page-intro-title {\n margin: 0;\n font-family: var(--font-heading);\n font-size: var(--type-title, 2rem);\n font-weight: var(--type-heading-weight);\n line-height: 1.15;\n letter-spacing: -0.01em;\n color: var(--site-text-primary);\n}\n\n.page-intro-meta-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n}\n\n.page-intro-meta,\n.page-intro-description {\n margin: 0;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-secondary);\n line-height: 1.3;\n}\n\n.collection-directory {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.collection-directory-item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: 0.75rem;\n padding: 0.95rem 0;\n background: transparent;\n text-decoration: none;\n}\n\n.collection-directory-main {\n --collection-directory-sequence-width: 3.5ch;\n --collection-directory-title-line-height: 1.18;\n min-width: 0;\n flex: 1;\n display: grid;\n grid-template-columns: var(--collection-directory-sequence-width) minmax(\n 0,\n 1fr\n );\n align-items: start;\n column-gap: 0.8rem;\n row-gap: 0.25rem;\n}\n\n.collection-directory-sequence {\n grid-column: 1;\n grid-row: 1;\n display: block;\n width: var(--collection-directory-sequence-width);\n padding-top: 0.2rem;\n font-family: var(--font-mono);\n font-size: var(--type-xs);\n font-variant-numeric: tabular-nums;\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: 0.14em;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-title-row {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n display: flex;\n align-items: flex-start;\n}\n\n.collection-directory-title-link {\n color: inherit;\n text-decoration: none;\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover,\n.collection-directory-title-link:focus-visible {\n color: var(--site-text-primary);\n}\n\n.collection-directory-title-link:hover .collection-directory-title,\n.collection-directory-title-link:focus-visible .collection-directory-title {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.collection-directory-title {\n min-width: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n font-family: var(--font-heading);\n font-size: var(--type-content-body);\n font-weight: var(--type-heading-weight);\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: -0.02em;\n text-wrap: pretty;\n}\n\n.collection-directory-title-marker {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 0.95rem;\n min-width: 0.95rem;\n height: 0.95rem;\n color: var(--site-text-secondary);\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover .collection-directory-title-marker,\n.collection-directory-title-link:focus-visible\n .collection-directory-title-marker {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description {\n grid-column: 2;\n grid-row: 2;\n margin: 0;\n color: color-mix(in srgb, var(--site-text-secondary) 80%, transparent);\n font-family: var(--font-body);\n font-size: var(--type-sm);\n line-height: 1.45;\n}\n\n.collection-directory-description :where(p) {\n margin-top: 0.25em;\n margin-bottom: 0.25em;\n}\n\n/* Links inside the description inherit the description color so they sit in\n the same gray tone as the copy around them (matches the main site's\n `.prose { --tw-prose-links: var(--site-content-link); }` which resolves\n to `inherit`). The underline stays, drawn in currentColor. */\n.collection-directory-description :where(a) {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: currentColor;\n text-underline-offset: 0.15em;\n}\n\n.collection-directory-description :where(a:hover),\n.collection-directory-description :where(a:focus-visible) {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description :where(p:first-child) {\n margin-top: 0;\n}\n\n.collection-directory-description :where(p:last-child) {\n margin-bottom: 0;\n}\n\n.collection-directory-description + .collection-directory-summary {\n grid-row: 3;\n}\n\n.collection-directory-summary {\n grid-column: 2;\n grid-row: 2;\n display: flex;\n min-width: 0;\n overflow: hidden;\n align-items: center;\n gap: 0.2rem 0.5rem;\n margin: 0;\n color: var(--site-reading-meta);\n font-family: var(--font-ui);\n font-size: var(--type-sm);\n line-height: 1.3;\n white-space: nowrap;\n}\n\n.collection-directory-meta {\n flex: 0 0 auto;\n color: inherit;\n}\n\n.collection-directory-meta-separator {\n flex: 0 0 auto;\n color: color-mix(in srgb, var(--site-divider) 88%, transparent);\n}\n\n.collection-directory-updated {\n flex: 0 0 auto;\n color: inherit;\n white-space: nowrap;\n}\n\n.collection-directory-divider {\n padding: 1.5rem 0 0.85rem;\n}\n\n.collection-directory-divider-row {\n display: flex;\n align-items: center;\n gap: 0.95rem;\n}\n\n.collection-directory-divider-text {\n font-family: var(--font-heading);\n font-size: var(--type-secondary);\n letter-spacing: 0;\n font-style: normal;\n white-space: nowrap;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-divider-line {\n flex: 1;\n height: 1px;\n border: none;\n margin: 0;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--site-divider) 100%, transparent),\n color-mix(in srgb, var(--site-divider) 54%, transparent) 34%,\n transparent 86%\n );\n}\n\n/* -------------------------------------------------------------------------\n * Pagination\n * ------------------------------------------------------------------------- */\n\n.pagination {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n flex-wrap: wrap;\n gap: 1rem;\n padding: 1.5rem 0;\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n.pagination-link {\n color: var(--site-text-secondary);\n text-decoration: underline;\n text-underline-offset: 3px;\n transition: color 0.15s ease;\n}\n\n.pagination-link:hover,\n.pagination-link:focus {\n color: var(--site-text-primary);\n}\n\n.pagination-link.is-disabled {\n color: color-mix(in srgb, var(--site-text-secondary) 50%, transparent);\n cursor: default;\n text-decoration: none;\n}\n\n.pagination-current {\n color: var(--site-text-primary);\n font-weight: var(--fw-medium);\n}\n\n.pagination-ellipsis {\n color: var(--site-text-secondary);\n}\n\n/* -------------------------------------------------------------------------\n * Empty states + utility\n * ------------------------------------------------------------------------- */\n\n.empty-state {\n color: var(--site-reading-meta);\n font-style: italic;\n text-align: center;\n padding: var(--space-xl) 0;\n}\n\n.page,\n.section {\n display: block;\n}\n\n.page-summary,\n.section-summary {\n font-size: var(--type-secondary);\n color: var(--site-reading-meta);\n}\n\n/* -------------------------------------------------------------------------\n * Wider viewport refinements\n * ------------------------------------------------------------------------- */\n\n@media (min-width: 768px) {\n .site-main {\n padding-top: calc(var(--space-xl) * 1.5);\n padding-bottom: calc(var(--space-xl) * 1.5);\n }\n\n .post-card-media,\n .thread-media,\n .reply-media {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .post-card-media:has(> :only-child),\n .thread-media:has(> :only-child),\n .reply-media:has(> :only-child) {\n grid-template-columns: 1fr;\n }\n}\n";
5928
6271
  //#endregion
5929
6272
  //#region src/services/export-theme/assets/client-site.js?raw
5930
6273
  var client_site_default$1 = "var e=null,t=0,n=!1,r=null,i=null,a=0;function o(e){return!isFinite(e)||e<0?`0:00`:`${Math.floor(e/60)}:${String(Math.floor(e%60)).padStart(2,`0`)}`}function s(e){return e.querySelector(`audio.media-audio-el`)}function c(e){return e.querySelector(`[data-audio-range]`)}function l(e){return e.querySelector(`[data-audio-waveform]`)}function u(e,t){let n=`${(t*100).toFixed(1)}%`;e.style.background=`linear-gradient(to right, var(--site-text-primary) ${n}, transparent ${n})`}var d=new WeakMap,f=new WeakSet;async function p(e,t){let n=await(await fetch(e)).arrayBuffer(),r=new AudioContext;try{let e=(await r.decodeAudioData(n)).getChannelData(0),i=Math.max(1,Math.floor(e.length/t)),a=Array(t);for(let n=0;n<t;n++){let t=0,r=n*i,o=Math.min(r+i,e.length);for(let n=r;n<o;n++){let r=Math.abs(e[n]);r>t&&(t=r)}a[n]=t}let o=0;for(let e of a)e>o&&(o=e);if(o>0)for(let e=0;e<t;e++)a[e]/=o;return a}finally{await r.close()}}function ee(e=document){let t=e.querySelectorAll(`[data-audio-peaks]`),n=[];for(let e of t){let t=e.dataset.audioPeaks;if(!t)continue;let r=e.closest(`.media-audio-card`);if(r&&!d.has(r))try{let e=JSON.parse(t);if(!Array.isArray(e))continue;d.set(r,e),r.classList.add(`has-waveform`),n.push(r)}catch{}}n.length>0&&requestAnimationFrame(()=>{for(let e of n)h(e,0)})}async function m(e){if(d.has(e)||f.has(e))return;f.add(e);let t=e.querySelector(`audio.media-audio-el source`),n=l(e);if(!t?.src||!n)return;let r=n.getBoundingClientRect().width,i=Math.max(20,Math.floor(r/3));try{let n=await p(t.src,i);d.set(e,n),e.classList.add(`has-waveform`);let r=s(e),a=r?.duration??0;h(e,r&&isFinite(a)&&a>0?r.currentTime/a:0)}catch{}}function h(e,t){let n=d.get(e),r=l(e);if(!n||!r)return;let i=window.devicePixelRatio||1,a=r.getBoundingClientRect(),o=Math.round(a.width*i),s=Math.round(a.height*i);if(o===0||s===0)return;(r.width!==o||r.height!==s)&&(r.width=o,r.height=s);let c=r.getContext(`2d`);if(!c)return;c.clearRect(0,0,o,s);let u=n.length,f=o/u,p=Math.max(1,Math.round(f*.6)),ee=Math.round(2*i),m=s*.85,h=getComputedStyle(r).getPropertyValue(`--site-text-primary`).trim()||`#000`;for(let e=0;e<u;e++){let r=Math.round(e*f+(f-p)/2),a=Math.max(ee,Math.round(n[e]*m)),o=Math.round((s-a)/2);c.globalAlpha=(e+.5)/u<=t?.9:.2,c.fillStyle=h;let l=Math.min(p/2,i);c.beginPath(),c.roundRect(r,o,p,a,l),c.fill()}c.globalAlpha=1}function te(e,t){let{currentTime:r,duration:i}=t,a=isFinite(i)&&i>0,s=a?r/i:0;if(!n){let t=c(e);t&&a&&(t.value=String(Math.round(s*1e3)),u(t,s)),d.has(e)&&h(e,s);let n=e.querySelector(`[data-audio-time]`);n&&(n.textContent=a?`${o(r)} / ${o(i)}`:o(r))}}function ne(){if(!e)return;let n=s(e);n&&!n.paused&&(te(e,n),t=requestAnimationFrame(ne))}function re(){if(e){let t=s(e);t&&!t.paused&&t.pause(),e.classList.remove(`is-playing`),e=null}cancelAnimationFrame(t)}async function ie(n){let r=s(n);if(r){if(e&&e!==n&&re(),r.paused){e=n,n.classList.add(`is-playing`);try{await r.play()}catch{n.classList.remove(`is-playing`),e=null;return}t=requestAnimationFrame(ne),m(n)}else r.pause(),n.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null}}function ae(e){let t=e.closest(`.media-audio-card`);if(!t)return;let n=s(t);if(!n)return;let r=Number(e.value)/1e3,i=n.duration;isFinite(i)&&i>0&&(n.currentTime=r*i)}function oe(e,t){let n=e.getBoundingClientRect();a=Math.max(0,Math.min(1,(t.clientX-n.left)/n.width));let r=e.closest(`.media-audio-card`);if(!r)return;h(r,a);let i=s(r),c=r.querySelector(`[data-audio-time]`);if(i&&c){let e=i.duration;isFinite(e)&&e>0&&(c.textContent=`${o(a*e)} / ${o(e)}`)}}async function se(n){let r=n.closest(`.media-audio-card`);if(!r)return;let i=s(r);if(i){if(i.paused){e&&e!==r&&re(),e=r,r.classList.add(`is-playing`);try{await i.play()}catch{r.classList.remove(`is-playing`),e=null;return}let n=i.duration;isFinite(n)&&n>0&&(i.currentTime=a*n),t=requestAnimationFrame(ne),m(r)}else{let e=i.duration;isFinite(e)&&e>0&&(i.currentTime=a*e)}}}document.addEventListener(`pointerdown`,e=>{let t=e.target;t.matches(`[data-audio-range]`)?(n=!0,r=t):t.matches(`[data-audio-waveform]`)&&(n=!0,i=t,oe(t,e))},!0),document.addEventListener(`pointermove`,e=>{n&&i&&oe(i,e)},!0),document.addEventListener(`pointerup`,()=>{n&&(r?(ae(r),r=null):i&&=(se(i),null)),n=!1},!0),document.addEventListener(`pointercancel`,()=>{r=null,i=null,n=!1},!0),document.addEventListener(`click`,e=>{let t=e.target.closest(`[data-audio-play]`);if(!t)return;e.preventDefault();let n=t.closest(`.media-audio-card`);n&&ie(n)}),document.addEventListener(`input`,e=>{let t=e.target;if(!t.matches(`[data-audio-range]`))return;let n=t.closest(`.media-audio-card`);if(!n)return;let r=s(n);if(!r)return;let i=Number(t.value)/1e3;u(t,i);let a=r.duration,c=n.querySelector(`[data-audio-time]`);c&&isFinite(a)&&a>0&&(c.textContent=`${o(i*a)} / ${o(a)}`)},!0),document.addEventListener(`ended`,n=>{let r=n.target;if(!r.closest)return;let i=r.closest(`.media-audio-card`);if(!i)return;i.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null;let a=c(i);a&&(a.value=`0`,u(a,0)),d.has(i)&&h(i,0);let o=i.querySelector(`[data-audio-time]`);o&&(o.textContent=`0:00`)},!0),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>ee()):ee();var g=`jant:media-lightbox-toggle`,ce=`jant:media-video-playback-intent`,le=new Set;function ue(e){let t=e?.trim();return!!t&&le.has(t)}function de(e,t){let n=e?.trim();n&&(t?le.add(n):le.delete(n),document.dispatchEvent(new CustomEvent(ce,{detail:{mediaId:n,paused:t}})))}var fe=`75% 0px`,pe=.6,_=.25,me=160,v=new Set,he=new WeakMap,ge=new WeakSet,y=null,b=null,x=null,_e=!1,S=null,C=null,w=null;function ve(e){return e.length===0?null:[...e].sort((e,t)=>t.visibleArea===e.visibleArea?e.centerDistance===t.centerDistance?t.intersectionRatio-e.intersectionRatio:e.centerDistance-t.centerDistance:t.visibleArea-e.visibleArea)[0]??null}function ye(){return{x:(globalThis.innerWidth||document.documentElement.clientWidth||0)/2,y:(globalThis.innerHeight||document.documentElement.clientHeight||0)/2}}function be(e){return he.get(e)}function xe(e){return ue(e)}function T(e){return xe(e.dataset.feedVideoId)}function Se(e){return e.closest(`.media-video-wrap`)?.querySelector(`[data-feed-video-mute-toggle]`)??null}function E(e){let t=Se(e);if(!t)return;let n=x!==e||e.muted;t.dataset.muted=n?`true`:`false`,t.setAttribute(`aria-label`,n?`Play with sound`:`Mute video`)}function Ce(){for(let e of v)E(e)}function we(e){if(ge.has(e))return;let t=e.dataset.videoSrc;t&&(e.getAttribute(`src`)!==t&&(e.src=t),e.load(),ge.add(e))}function D(e){e?.pause()}function Te(e){if(T(e)){D(e);return}we(e),e.muted=x!==e,e.playsInline=!0,e.loop=!0,E(e),e.play().catch(()=>{})}function Ee(){for(let e of v)e.isConnected||(C?.unobserve(e),w?.unobserve(e),v.delete(e),e===y&&(y=null),e===b&&(b=null),e===x&&(x=null))}function De(){if(S=null,Ee(),document.hidden||_e){D(y);return}let e=[];for(let t of v){let n=be(t);n&&(T(t)||n.intersectionRatio<pe||e.push({video:t,...n}))}let t=null;if(b?.isConnected&&!T(b)){let e=be(b);e&&e.intersectionRatio>_?t=b:(!e||e.intersectionRatio<=_)&&(b=null)}else b&&T(b)&&(b=null);if(t||=ve(e)?.video??null,!t){let e=y?be(y):void 0;if(y&&!T(y)&&e&&e.intersectionRatio>_)return;D(y),y=null;return}t!==y&&(D(y),y=t),Te(t);for(let e of v)e!==t&&(D(e),E(e))}function O(){S!==null&&globalThis.clearTimeout(S),S=globalThis.setTimeout(De,me)}function Oe(e){let t=ye();for(let n of e){let e=n.target,r=n.boundingClientRect,i=r.left+r.width/2,a=r.top+r.height/2,o=n.intersectionRect.width*n.intersectionRect.height,s=Math.hypot(i-t.x,a-t.y);he.set(e,{intersectionRatio:n.intersectionRatio,visibleArea:o,centerDistance:s})}O()}function ke(e){for(let t of e)t.isIntersecting&&we(t.target)}function Ae(){C&&w||globalThis.IntersectionObserver!==void 0&&(C=new globalThis.IntersectionObserver(Oe,{threshold:[0,_,pe,1]}),w=new globalThis.IntersectionObserver(ke,{rootMargin:fe,threshold:0}))}function je(e){v.has(e)||(Ae(),C&&w&&(v.add(e),C.observe(e),w.observe(e),Se(e)?.addEventListener(`click`,Me),E(e)))}function Me(e){e.preventDefault(),e.stopPropagation();let t=e.currentTarget.closest(`.media-video-wrap`)?.querySelector(`[data-feed-short-video]`);if(!t)return;let n=t.dataset.feedVideoId?.trim();n&&de(n,!1),b=t,x!==t||t.muted?(x&&x!==t&&(x.muted=!0),x=t,y!==t&&(D(y),y=t),Te(t)):(x=null,t.muted=!0,E(t)),Ce(),O()}function Ne(e=document){let t=e.querySelectorAll(`[data-feed-short-video]`);for(let e of t)je(e);O()}document.addEventListener(g,e=>{_e=e.detail?.open===!0,_e&&D(y),O()}),document.addEventListener(ce,e=>{let t=e.detail,n=t?.mediaId?.trim();if(n&&typeof t.paused==`boolean`){for(let e of v)e.dataset.feedVideoId?.trim()===n&&t.paused&&(D(e),y===e&&(y=null),b===e&&(b=null));O()}}),document.addEventListener(`visibilitychange`,()=>{document.hidden&&D(y),O()}),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>Ne(),{once:!0}):queueMicrotask(()=>Ne());var Pe=4;function Fe(e){return e.querySelector(`[data-post-media]`)}function Ie(e){let t=Fe(e);if(!t)return;let{scrollLeft:n,scrollWidth:r,clientWidth:i}=t;e.classList.toggle(`can-scroll-start`,n>Pe),e.classList.toggle(`can-scroll-end`,n+i<r-Pe)}function Le(e){return Math.max(160,Math.round(e.clientWidth*.85))}function k(e,t){e.scrollBy({left:t*Le(e),behavior:`smooth`})}function Re(e,t){if(!(`ResizeObserver`in globalThis))return;let n=0,r=()=>{cancelAnimationFrame(n),n=requestAnimationFrame(()=>Ie(e))},i=new globalThis.ResizeObserver(r),a=()=>{i.disconnect(),i.observe(t);for(let e of t.children)i.observe(e)};a(),new globalThis.MutationObserver(()=>{a(),r()}).observe(t,{childList:!0})}function A(e){if(e.dataset.scrollHintReady===`1`)return;let t=Fe(e);t&&(e.dataset.scrollHintReady=`1`,Ie(e),t.addEventListener(`scroll`,()=>Ie(e),{passive:!0}),Re(e,t),e.querySelector(`.media-gallery-nav-prev`)?.addEventListener(`click`,()=>k(t,-1)),e.querySelector(`.media-gallery-nav-next`)?.addEventListener(`click`,()=>k(t,1)),t.addEventListener(`keydown`,e=>{if(e.target===t)switch(e.key){case`ArrowRight`:e.preventDefault(),k(t,1);break;case`ArrowLeft`:e.preventDefault(),k(t,-1);break;case`Home`:e.preventDefault(),t.scrollTo({left:0,behavior:`smooth`});break;case`End`:e.preventDefault(),t.scrollTo({left:t.scrollWidth,behavior:`smooth`})}}))}function ze(){document.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(A)}var Be=new globalThis.MutationObserver(e=>{for(let t of e)for(let e of t.addedNodes)e instanceof HTMLElement&&(e.matches(`.media-gallery-scroll-wrap`)&&A(e),e.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(A))});document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>{ze(),Be.observe(document.body,{childList:!0,subtree:!0})}):(ze(),Be.observe(document.body,{childList:!0,subtree:!0}));var Ve=new WeakMap,He=new WeakMap,Ue=[{trigger:`.site-header-more-btn`,popover:`.site-header-more-popover`},{trigger:`.site-header-lang-btn`,popover:`.site-header-lang-popover`}];function We(e,t,n){let r=e.querySelector(t),i=e.querySelector(n);if(!r||!i||r.dataset.moreInitialized===`true`)return;r.dataset.moreInitialized=`true`;let a=new AbortController,o=a.signal;Ve.set(r,a);function s(){i.setAttribute(`aria-hidden`,`false`),r.setAttribute(`aria-expanded`,`true`),document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:r.parentElement}}))}function c(e=!1){i.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-expanded`,`false`),e&&r.focus()}r.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),r.getAttribute(`aria-expanded`)===`true`?c():s()},{signal:o}),document.addEventListener(`click`,e=>{e.target instanceof Node&&(r.parentElement?.contains(e.target)||c())},{signal:o}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&i.getAttribute(`aria-hidden`)===`false`&&c(!0)},{signal:o}),document.addEventListener(`basecoat:popover`,e=>{e.detail?.source!==r.parentElement&&c()},{signal:o})}var Ge=`jant:nav-fresh-visits`;function Ke(){let e=document.createElement(`span`);return e.className=`site-header-link-fresh`,e.setAttribute(`aria-hidden`,`true`),e.textContent=`*`,e}function qe(e){try{let t=JSON.parse(localStorage.getItem(Ge)||`{}`),n=location.pathname,r=e.querySelectorAll(`[data-fresh-at]`);for(let e of r){let r=new URL(e.href).pathname,i=parseInt(e.dataset.freshAt,10);if(r===n)t[r]=Math.floor(Date.now()/1e3);else{let n=t[r];if(!n||n<i){let t=e.querySelector(`svg`);e.insertBefore(Ke(),t)}}}localStorage.setItem(Ge,JSON.stringify(t))}catch{}}function Je(e=document){let t=e.querySelector(`.site-header-hamburger`),n=e.querySelector(`#site-nav-drawer`),r=e.querySelector(`.site-nav-drawer-backdrop`),i=n?.querySelector(`.site-nav-drawer-close`);qe(e);for(let{trigger:t,popover:n}of Ue)We(e,t,n);if(!t||!n||!r||t.dataset.drawerInitialized===`true`)return;t.dataset.drawerInitialized=`true`;let a=new AbortController,o=a.signal;He.set(t,a);function s(){n.setAttribute(`aria-hidden`,`false`),n.removeAttribute(`inert`),r.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.documentElement.classList.add(`drawer-open`);let e=n.querySelector(`.site-nav-drawer-close`)??n.querySelector(`a[href], button`);e&&e.focus()}function c(e=!0){n.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),document.documentElement.classList.remove(`drawer-open`),n.addEventListener(`transitionend`,()=>{n.getAttribute(`aria-hidden`)===`true`&&n.setAttribute(`inert`,``)},{once:!0,signal:o}),e&&t.focus()}t.addEventListener(`click`,()=>{t.getAttribute(`aria-expanded`)===`true`?c():s()},{signal:o}),i?.addEventListener(`click`,()=>c(),{signal:o}),r.addEventListener(`click`,()=>c(),{signal:o}),n.addEventListener(`click`,e=>{e.target instanceof Element&&e.target.closest(`a[href]`)&&c(!1)},{signal:o}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),c())},{signal:o})}Je();var Ye=8;function Xe(e,t){let n=Number.parseFloat(e);return Number.isFinite(n)?n:t}function Ze(e){return Xe(getComputedStyle(e).getPropertyValue(`--site-thread-context-max-height`).trim(),240)}function Qe(e){if(e.dataset.threadContextToggleBound===`1`)return;e.dataset.threadContextToggleBound=`1`;let t=e.previousElementSibling;if(!(t instanceof HTMLElement)||t.dataset.threadContext===void 0)return;let n=e.querySelector(`.thread-context-toggle-label`),r=e.dataset.labelMore??`Show more`,i=e.dataset.labelLess??`Show less`,a=!1,o=t=>{e.setAttribute(`aria-expanded`,t?`true`:`false`),n&&(n.textContent=t?i:r)},s=()=>Array.from(t.querySelectorAll(`img`)).every(e=>e.complete),c=()=>{if(a)return;let n=Ze(t),r=t.scrollHeight>n+Ye;t.dataset.collapsed===void 0&&(t.dataset.collapsed=``),r?(e.hidden=!1,o(!1)):s()&&(e.hidden=!0)};if(c(),t.querySelectorAll(`img`).forEach(e=>{e.complete||(e.addEventListener(`load`,c,{once:!0}),e.addEventListener(`error`,c,{once:!0}))}),`ResizeObserver`in globalThis){let e=0;new globalThis.ResizeObserver(()=>{cancelAnimationFrame(e),e=requestAnimationFrame(c)}).observe(t)}let l=()=>{let e=!1,n=r=>{e||r&&r.propertyName!==`max-height`||(e=!0,t.removeEventListener(`transitionend`,n),t.dataset.collapsed===void 0&&(t.style.maxHeight=``))};t.addEventListener(`transitionend`,n),window.setTimeout(n,600)};e.addEventListener(`click`,()=>{a=!0,t.dataset.collapsed===void 0?(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,requestAnimationFrame(()=>{t.dataset.collapsed=``,t.style.maxHeight=``}),o(!1)):(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,delete t.dataset.collapsed,o(!0)),l()})}function $e(e=document){e.querySelectorAll(`[data-thread-context-toggle]`).forEach(Qe)}function et(e){let t=e.closest(`.thread-group-detail`);return t?t.querySelector(`.thread-detail-item`)===e:!1}function tt(){return globalThis.location.hash===`#continue`}function nt(e=document){let t=e.querySelector(`[data-post-current]`);if(!(t instanceof HTMLElement))return;let n=tt();if(globalThis.location.hash&&!n)return;let r=n?`auto`:`smooth`,i=et(t);requestAnimationFrame(()=>{(!i||n)&&t.scrollIntoView({behavior:r,block:`start`})})}document.addEventListener(`DOMContentLoaded`,()=>{$e(document),nt(document)});var j=globalThis,M=j.ShadowRoot&&(j.ShadyCSS===void 0||j.ShadyCSS.nativeShadow)&&`adoptedStyleSheets`in Document.prototype&&`replace`in CSSStyleSheet.prototype,rt=Symbol(),it=new WeakMap,at=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==rt)throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(M&&e===void 0){let n=t!==void 0&&t.length===1;n&&(e=it.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&it.set(t,e))}return e}toString(){return this.cssText}},ot=e=>new at(typeof e==`string`?e:e+``,void 0,rt),st=(e,t)=>{if(M)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let n of t){let t=document.createElement(`style`),r=j.litNonce;r!==void 0&&t.setAttribute(`nonce`,r),t.textContent=n.cssText,e.appendChild(t)}},ct=M?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t=``;for(let n of e.cssRules)t+=n.cssText;return ot(t)})(e):e,{is:lt,defineProperty:ut,getOwnPropertyDescriptor:dt,getOwnPropertyNames:ft,getOwnPropertySymbols:pt,getPrototypeOf:mt}=Object,N=globalThis,ht=N.trustedTypes,gt=ht?ht.emptyScript:``,_t=N.reactiveElementPolyfillSupport,P=(e,t)=>e,F={toAttribute(e,t){switch(t){case Boolean:e=e?gt:null;break;case Object:case Array:e=e==null?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=e!==null;break;case Number:n=e===null?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch{n=null}}return n}},vt=(e,t)=>!lt(e,t),yt={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:vt};Symbol.metadata??=Symbol(`metadata`),N.litPropertyMetadata??=new WeakMap;var I=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=yt){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let n=Symbol(),r=this.getPropertyDescriptor(e,n,t);r!==void 0&&ut(this.prototype,e,r)}}static getPropertyDescriptor(e,t,n){let{get:r,set:i}=dt(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:r,set(t){let a=r?.call(this);i?.call(this,t),this.requestUpdate(e,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??yt}static _$Ei(){if(this.hasOwnProperty(P(`elementProperties`)))return;let e=mt(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(P(`finalized`)))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(P(`properties`))){let e=this.properties,t=[...ft(e),...pt(e)];for(let n of t)this.createProperty(n,e[n])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let n=this._$Eu(e,t);n!==void 0&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let n=new Set(e.flat(1/0).reverse());for(let e of n)t.unshift(ct(e))}else e!==void 0&&t.push(ct(e));return t}static _$Eu(e,t){let n=t.attribute;return!1===n?void 0:typeof n==`string`?n:typeof e==`string`?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return st(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){let n=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,n);if(r!==void 0&&!0===n.reflect){let i=(n.converter?.toAttribute===void 0?F:n.converter).toAttribute(t,n.type);this._$Em=e,i==null?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(e,t){let n=this.constructor,r=n._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let e=n.getPropertyOptions(r),i=typeof e.converter==`function`?{fromAttribute:e.converter}:e.converter?.fromAttribute===void 0?F:e.converter;this._$Em=r;let a=i.fromAttribute(t,e.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(e,t,n,r=!1,i){if(e!==void 0){let a=this.constructor;if(!1===r&&(i=this[e]),n??=a.getPropertyOptions(e),!((n.hasChanged??vt)(i,t)||n.useDefault&&n.reflect&&i===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,a??t??this[e]),!0!==i||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===r&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}let e=this.constructor.elementProperties;if(e.size>0)for(let[t,n]of e){let{wrapped:e}=n,r=this[t];!0!==e||this._$AL.has(t)||r===void 0||this.C(t,void 0,n,r)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};I.elementStyles=[],I.shadowRootOptions={mode:`open`},I[P(`elementProperties`)]=new Map,I[P(`finalized`)]=new Map,_t?.({ReactiveElement:I}),(N.reactiveElementVersions??=[]).push(`2.1.2`);var bt=globalThis,xt=e=>e,L=bt.trustedTypes,St=L?L.createPolicy(`lit-html`,{createHTML:e=>e}):void 0,Ct=`$lit$`,R=`lit$${Math.random().toFixed(9).slice(2)}$`,wt=`?`+R,Tt=`<${wt}>`,z=document,B=()=>z.createComment(``),V=e=>e===null||typeof e!=`object`&&typeof e!=`function`,Et=Array.isArray,Dt=e=>Et(e)||typeof e?.[Symbol.iterator]==`function`,Ot=`[ \n\\f\\r]`,H=/<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g,kt=/-->/g,At=/>/g,U=RegExp(`>|${Ot}(?:([^\\\\s\"'>=/]+)(${Ot}*=${Ot}*(?:[^ \\t\\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`,`g`),jt=/'/g,Mt=/\"/g,Nt=/^(?:script|style|textarea|title)$/i,Pt=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),W=Pt(1),G=Pt(2),K=Symbol.for(`lit-noChange`),q=Symbol.for(`lit-nothing`),Ft=new WeakMap,J=z.createTreeWalker(z,129);function It(e,t){if(!Et(e)||!e.hasOwnProperty(`raw`))throw Error(`invalid template strings array`);return St===void 0?t:St.createHTML(t)}var Lt=(e,t)=>{let n=e.length-1,r=[],i,a=t===2?`<svg>`:t===3?`<math>`:``,o=H;for(let t=0;t<n;t++){let n=e[t],s,c,l=-1,u=0;for(;u<n.length&&(o.lastIndex=u,c=o.exec(n),c!==null);)u=o.lastIndex,o===H?c[1]===`!--`?o=kt:c[1]===void 0?c[2]===void 0?c[3]!==void 0&&(o=U):(Nt.test(c[2])&&(i=RegExp(`</`+c[2],`g`)),o=U):o=At:o===U?c[0]===`>`?(o=i??H,l=-1):c[1]===void 0?l=-2:(l=o.lastIndex-c[2].length,s=c[1],o=c[3]===void 0?U:c[3]===`\"`?Mt:jt):o===Mt||o===jt?o=U:o===kt||o===At?o=H:(o=U,i=void 0);let d=o===U&&e[t+1].startsWith(`/>`)?` `:``;a+=o===H?n+Tt:l>=0?(r.push(s),n.slice(0,l)+Ct+n.slice(l)+R+d):n+R+(l===-2?t:d)}return[It(e,a+(e[n]||`<?>`)+(t===2?`</svg>`:t===3?`</math>`:``)),r]},Rt=class e{constructor({strings:t,_$litType$:n},r){let i;this.parts=[];let a=0,o=0,s=t.length-1,c=this.parts,[l,u]=Lt(t,n);if(this.el=e.createElement(l,r),J.currentNode=this.el.content,n===2||n===3){let e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;(i=J.nextNode())!==null&&c.length<s;){if(i.nodeType===1){if(i.hasAttributes())for(let e of i.getAttributeNames())if(e.endsWith(Ct)){let t=u[o++],n=i.getAttribute(e).split(R),r=/([.?@])?(.*)/.exec(t);c.push({type:1,index:a,name:r[2],strings:n,ctor:r[1]===`.`?Vt:r[1]===`?`?Ht:r[1]===`@`?Ut:X}),i.removeAttribute(e)}else e.startsWith(R)&&(c.push({type:6,index:a}),i.removeAttribute(e));if(Nt.test(i.tagName)){let e=i.textContent.split(R),t=e.length-1;if(t>0){i.textContent=L?L.emptyScript:``;for(let n=0;n<t;n++)i.append(e[n],B()),J.nextNode(),c.push({type:2,index:++a});i.append(e[t],B())}}}else if(i.nodeType===8){if(i.data===wt)c.push({type:2,index:a});else{let e=-1;for(;(e=i.data.indexOf(R,e+1))!==-1;)c.push({type:7,index:a}),e+=R.length-1}}a++}}static createElement(e,t){let n=z.createElement(`template`);return n.innerHTML=e,n}};function Y(e,t,n=e,r){if(t===K)return t;let i=r===void 0?n._$Cl:n._$Co?.[r],a=V(t)?void 0:t._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),a===void 0?i=void 0:(i=new a(e),i._$AT(e,n,r)),r===void 0?n._$Cl=i:(n._$Co??=[])[r]=i),i!==void 0&&(t=Y(e,i._$AS(e,t.values),i,r)),t}var zt=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:n}=this._$AD,r=(e?.creationScope??z).importNode(t,!0);J.currentNode=r;let i=J.nextNode(),a=0,o=0,s=n[0];for(;s!==void 0;){if(a===s.index){let t;s.type===2?t=new Bt(i,i.nextSibling,this,e):s.type===1?t=new s.ctor(i,s.name,s.strings,this,e):s.type===6&&(t=new Wt(i,this,e)),this._$AV.push(t),s=n[++o]}a!==s?.index&&(i=J.nextNode(),a++)}return J.currentNode=z,r}p(e){let t=0;for(let n of this._$AV)n!==void 0&&(n.strings===void 0?n._$AI(e[t]):(n._$AI(e,n,t),t+=n.strings.length-2)),t++}},Bt=class e{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,n,r){this.type=2,this._$AH=q,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=Y(this,e,t),V(e)?e===q||e==null||e===``?(this._$AH!==q&&this._$AR(),this._$AH=q):e!==this._$AH&&e!==K&&this._(e):e._$litType$===void 0?e.nodeType===void 0?Dt(e)?this.k(e):this._(e):this.T(e):this.$(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==q&&V(this._$AH)?this._$AA.nextSibling.data=e:this.T(z.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:n}=e,r=typeof n==`number`?this._$AC(e):(n.el===void 0&&(n.el=Rt.createElement(It(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(t);else{let e=new zt(r,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=Ft.get(e.strings);return t===void 0&&Ft.set(e.strings,t=new Rt(e)),t}k(t){Et(this._$AH)||(this._$AH=[],this._$AR());let n=this._$AH,r,i=0;for(let a of t)i===n.length?n.push(r=new e(this.O(B()),this.O(B()),this,this.options)):r=n[i],r._$AI(a),i++;i<n.length&&(this._$AR(r&&r._$AB.nextSibling,i),n.length=i)}_$AR(e=this._$AA.nextSibling,t){for(this._$AP?.(!1,!0,t);e!==this._$AB;){let t=xt(e).nextSibling;xt(e).remove(),e=t}}setConnected(e){this._$AM===void 0&&(this._$Cv=e,this._$AP?.(e))}},X=class{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(e,t,n,r,i){this.type=1,this._$AH=q,this._$AN=void 0,this.element=e,this.name=t,this._$AM=r,this.options=i,n.length>2||n[0]!==``||n[1]!==``?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=q}_$AI(e,t=this,n,r){let i=this.strings,a=!1;if(i===void 0)e=Y(this,e,t,0),a=!V(e)||e!==this._$AH&&e!==K,a&&(this._$AH=e);else{let r=e,o,s;for(e=i[0],o=0;o<i.length-1;o++)s=Y(this,r[n+o],t,o),s===K&&(s=this._$AH[o]),a||=!V(s)||s!==this._$AH[o],s===q?e=q:e!==q&&(e+=(s??``)+i[o+1]),this._$AH[o]=s}a&&!r&&this.j(e)}j(e){e===q?this.element.removeAttribute(this.name):this.element.setAttribute(this.name,e??``)}},Vt=class extends X{constructor(){super(...arguments),this.type=3}j(e){this.element[this.name]=e===q?void 0:e}},Ht=class extends X{constructor(){super(...arguments),this.type=4}j(e){this.element.toggleAttribute(this.name,!!e&&e!==q)}},Ut=class extends X{constructor(e,t,n,r,i){super(e,t,n,r,i),this.type=5}_$AI(e,t=this){if((e=Y(this,e,t,0)??q)===K)return;let n=this._$AH,r=e===q&&n!==q||e.capture!==n.capture||e.once!==n.once||e.passive!==n.passive,i=e!==q&&(n===q||r);r&&this.element.removeEventListener(this.name,this,n),i&&this.element.addEventListener(this.name,this,e),this._$AH=e}handleEvent(e){typeof this._$AH==`function`?this._$AH.call(this.options?.host??this.element,e):this._$AH.handleEvent(e)}},Wt=class{constructor(e,t,n){this.element=e,this.type=6,this._$AN=void 0,this._$AM=t,this.options=n}get _$AU(){return this._$AM._$AU}_$AI(e){Y(this,e)}},Gt=bt.litHtmlPolyfillSupport;Gt?.(Rt,Bt),(bt.litHtmlVersions??=[]).push(`3.3.3`);var Kt=(e,t,n)=>{let r=n?.renderBefore??t,i=r._$litPart$;if(i===void 0){let e=n?.renderBefore??null;r._$litPart$=i=new Bt(t.insertBefore(B(),e),e,void 0,n??{})}return i._$AI(e),i},qt=globalThis,Z=class extends I{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=Kt(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return K}};Z._$litElement$=!0,Z.finalized=!0,qt.litElementHydrateSupport?.({LitElement:Z});var Jt=qt.litElementPolyfillSupport;Jt?.({LitElement:Z}),(qt.litElementVersions??=[]).push(`4.2.2`);function Q(e){if(!e.mimeType?.startsWith(`video/`)||!Number.isFinite(e.durationSeconds)||!e.durationSeconds||e.durationSeconds<=0||e.durationSeconds>15)return!1;let t=e.size;return!(typeof t==`number`&&t>12582912)}var Yt=640,Xt=8,Zt=72,Qt=704,$t=.9,en=.85;function $(e){if(!(!Number.isFinite(e)||!e||e<=0))return e}function tn(){return{width:globalThis.innerWidth||document.documentElement.clientWidth||0,height:globalThis.innerHeight||document.documentElement.clientHeight||0}}function nn(e,t){return{width:Math.max(0,e-(e<=Yt?Xt:Zt)*2),height:Math.max(0,t-32)}}function rn(e,t,n){let r=$(e?.width),i=$(e?.height);if(!r||!i)return null;let a=nn(t,n);if(a.width<=0||a.height<=0)return null;let o=Math.min(a.width/r,a.height/i);return{width:Math.max(1,Math.round(r*o)),height:Math.max(1,Math.round(i*o))}}function an(e,t,n){if(!e||e.mimeType?.startsWith(`video/`)||!$(e.width)||!$(e.height)||t<=0||n<=0)return!1;let r=$(e.width),i=$(e.height);if(!r||!i)return!1;let a=t<=Yt,o=nn(t,n),s=o.width,c=o.height;if(s<=0||c<=0)return!1;let l=r/i,u=Math.min(s,c*l);return l<$t&&u<(a?s:Math.min(s,Qt))*en}var on=class extends Z{static properties={_images:{state:!0},_currentIndex:{state:!0},_open:{state:!0},_viewportWidth:{state:!0},_viewportHeight:{state:!0},_videoCurrentTime:{state:!0},_videoDuration:{state:!0},_videoMuted:{state:!0},_videoPaused:{state:!0},_imageZoomed:{state:!0}};createRenderRoot(){return this.innerHTML=``,this}constructor(){super();let e=tn();this._images=[],this._currentIndex=0,this._open=!1,this._viewportWidth=e.width,this._viewportHeight=e.height,this._videoCurrentTime=0,this._videoDuration=0,this._videoMuted=!1,this._videoPaused=!1,this._imageZoomed=!1}connectedCallback(){super.connectedCallback(),document.addEventListener(`click`,this.#e),window.addEventListener(`resize`,this.#l),this.#u()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener(`click`,this.#e),window.removeEventListener(`resize`,this.#l)}open(e,t){this.#u(),this.#d(),this._images=e,this._currentIndex=Math.max(0,Math.min(t,e.length-1)),this.#m(this._images[this._currentIndex]),this._imageZoomed=!1,this._open=!0,document.dispatchEvent(new CustomEvent(g,{detail:{open:!0}})),this.updateComplete.then(()=>{this.querySelector(`.media-lightbox`)?.showModal(),this.#f()})}close(){this.#d(),this.querySelector(`.media-lightbox`)?.close(),this._open=!1,document.dispatchEvent(new CustomEvent(g,{detail:{open:!1}}))}#e=e=>{let t=e.target,n=t.closest(`[data-post-media] a[data-lightbox-index]`);if(n){let t=n.closest(`[data-lightbox-group]`);if(!t)return;e.preventDefault();let r=parseInt(n.dataset.lightboxIndex??`0`,10);try{let e=JSON.parse(t.dataset.lightboxGroup??`[]`);e.length>0&&this.open(e,r)}catch{}return}let r=t.closest(`[data-post-body] img`);if(r){e.preventDefault();let t=r.closest(`[data-post-body]`);if(!t)return;let n=Array.from(t.querySelectorAll(`img`)),i=n.map(e=>({url:e.src,alt:e.alt||``,width:$(e.naturalWidth||Number(e.getAttribute(`width`))),height:$(e.naturalHeight||Number(e.getAttribute(`height`)))})),a=n.indexOf(r);i.length>0&&this.open(i,Math.max(0,a))}};#t(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex-1+this._images.length)%this._images.length)}#n(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex+1)%this._images.length)}#r=e=>{let t=this._images[this._currentIndex];an(t,this._viewportWidth,this._viewportHeight)&&(e.stopPropagation(),this._imageZoomed=!this._imageZoomed)};#i=e=>{let t=e,n=e.target;if(t.key===`Escape`){e.preventDefault(),this.close();return}if(n instanceof HTMLInputElement||n instanceof HTMLButtonElement||n instanceof HTMLVideoElement)return;if(!this._images[this._currentIndex]?.mimeType?.startsWith(`video/`)){t.key===`ArrowLeft`?(e.preventDefault(),this.#t()):t.key===`ArrowRight`&&(e.preventDefault(),this.#n());return}let r=this.querySelector(`.media-lightbox-video`);r&&this.#a(t,r)};#a(e,t){let n=Number.isFinite(t.duration)&&t.duration>0?t.duration:null,r=e=>{let r=n==null?Math.max(0,e):Math.max(0,Math.min(e,n));t.currentTime=r,this._videoCurrentTime=r},i=e.key,a=i.toLowerCase();if(i===` `||a===`k`)e.preventDefault(),this.#x(t);else if(i===`ArrowLeft`)e.preventDefault(),r(t.currentTime-2);else if(i===`ArrowRight`)e.preventDefault(),r(t.currentTime+2);else if(i===`Home`)e.preventDefault(),r(0);else if(i===`End`)n!=null&&(e.preventDefault(),r(n));else if(i.length===1&&i>=`0`&&i<=`9`)n!=null&&(e.preventDefault(),r(Number(i)/10*n));else if(i===`ArrowUp`)e.preventDefault(),t.volume=Math.min(1,t.volume+.05);else if(i===`ArrowDown`)e.preventDefault(),t.volume=Math.max(0,t.volume-.05);else if(a===`m`){e.preventDefault();let n=!t.muted;t.muted=n,this._videoMuted=n}else a===`f`&&(e.preventDefault(),this.#o(t))}#o(e){let t=document,n=e;if(document.fullscreenElement??t.webkitFullscreenElement){document.exitFullscreen?document.exitFullscreen().catch(()=>{}):t.webkitExitFullscreen?.();return}e.requestFullscreen?e.requestFullscreen().catch(()=>{}):n.webkitRequestFullscreen?n.webkitRequestFullscreen():n.webkitEnterFullscreen&&n.webkitEnterFullscreen()}#s=e=>{let t=e.target;(t===e.currentTarget||t.classList.contains(`media-lightbox-content`)||t.classList.contains(`media-lightbox-stage`))&&this.close()};#c=()=>{this.#d(),this._open&&document.dispatchEvent(new CustomEvent(g,{detail:{open:!1}})),this._open=!1};#l=()=>{this.#u()};#u(){let e=tn();(e.width!==this._viewportWidth||e.height!==this._viewportHeight)&&(this._viewportWidth=e.width,this._viewportHeight=e.height)}#d(){this.querySelector(`.media-lightbox-video`)?.pause()}#f(){this.querySelector(`.media-lightbox-content`)?.focus()}#p=()=>{this.querySelector(`.media-lightbox-content`)?.focus({preventScroll:!0})};#m(e){this._videoCurrentTime=0,this._videoDuration=e?.durationSeconds&&e.durationSeconds>0?e.durationSeconds:0,this._videoMuted=!1,this._videoPaused=ue(e?.id)}#h(){let e=this._images[this._currentIndex];if(!Q(e)){this.#m(e);return}let t=this.querySelector(`.media-lightbox-video`);if(t){if(t.currentTime=0,t.muted=this._videoMuted,this._videoPaused=ue(e.id),this._videoPaused){t.pause();return}t.play().catch(()=>{this._videoPaused=!0})}}#g=e=>{let t=e.currentTarget;Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration),this._videoCurrentTime=t.currentTime,t.muted=this._videoMuted};#_=e=>{let t=e.currentTarget;this._videoCurrentTime=t.currentTime,Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration)};#v=()=>{this._videoPaused=!1};#y=()=>{this._videoPaused=!0};#b(e){let t=this._images[this._currentIndex],n=t?.id?.trim();n&&Q(t)&&de(n,e)}#x(e){if(e.paused){this._videoPaused=!1,this.#b(!1),e.play().catch(()=>{this._videoPaused=!0,this.#b(!0)});return}e.pause(),this._videoPaused=!0,this.#b(!0)}#S=()=>{let e=this.querySelector(`.media-lightbox-video`);e&&this.#x(e)};#C=e=>{let t=e.currentTarget,n=this.querySelector(`.media-lightbox-video`),r=Number.parseFloat(t.value);!n||!Number.isFinite(r)||r<0||(n.currentTime=r,this._videoCurrentTime=r)};#w=()=>{this._videoMuted=!this._videoMuted;let e=this.querySelector(`.media-lightbox-video`);e&&(e.muted=this._videoMuted)};updated(e){if(super.updated(e),!this._open||!e.has(`_currentIndex`)&&!e.has(`_open`)&&!e.has(`_imageZoomed`))return;let t=this.querySelector(`.media-lightbox-stage`);t&&(t.scrollTop=0,t.scrollLeft=0,(e.has(`_currentIndex`)||e.has(`_open`))&&(this.#h(),this.#f()))}render(){if(!this._open)return q;let e=this._images[this._currentIndex],t=this._images.length>1,n=e?.mimeType?.startsWith(`video/`),r=Q(e),i=an(e,this._viewportWidth,this._viewportHeight),a=i&&this._imageZoomed,o=r?rn(e,this._viewportWidth,this._viewportHeight):null,s=r&&!!o&&o.height>o.width,c=o?`--media-lightbox-short-width:${o.width}px;--media-lightbox-short-height:${o.height}px;`:q,l=this._videoDuration>0?this._videoDuration:e?.durationSeconds??1,u=Math.min(this._videoCurrentTime,l),d=l>0?u/l*100:0;return W`\n <dialog\n class=${`media-lightbox${r?` media-lightbox-short`:``}`}\n @keydown=${this.#i}\n @click=${this.#s}\n @close=${this.#c}\n >\n <div class=\"media-lightbox-content\" tabindex=\"-1\">\n <button\n type=\"button\"\n class=\"media-lightbox-close\"\n @click=${()=>this.close()}\n aria-label=\"Close\"\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </button>\n\n ${t?W`<div class=\"media-lightbox-counter\">\n ${this._currentIndex+1} / ${this._images.length}\n </div>`:q}\n <div\n class=${`media-lightbox-stage${a?` media-lightbox-stage-scroll`:``}`}\n >\n ${n?r?W`<div\n class=${`media-lightbox-short-frame${o?` media-lightbox-short-frame-contained`:``}${s?` media-lightbox-short-frame-portrait`:` media-lightbox-short-frame-landscape`}`}\n style=${c}\n >\n <div class=\"media-lightbox-short-viewport\">\n <video\n class=\"media-lightbox-video media-lightbox-video-short\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n ?autoplay=${!this._videoPaused}\n playsinline\n loop\n ?muted=${this._videoMuted}\n @click=${this.#S}\n @focus=${this.#p}\n @loadedmetadata=${this.#g}\n @timeupdate=${this.#_}\n @play=${this.#v}\n @pause=${this.#y}\n ></video>\n </div>\n <div\n class=${`media-lightbox-short-controls${s?` media-lightbox-short-controls-portrait`:``}`}\n >\n <button\n type=\"button\"\n class=\"media-lightbox-short-playback\"\n @click=${this.#S}\n aria-label=${this._videoPaused?`Play video`:`Pause video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n aria-hidden=\"true\"\n >\n ${this._videoPaused?G`<path d=\"M8 5v14l11-7z\" />`:G`<path d=\"M6 5h4v14H6zM14 5h4v14h-4z\" />`}\n </svg>\n </button>\n <input\n class=\"media-lightbox-short-progress\"\n type=\"range\"\n min=\"0\"\n max=${l}\n step=\"0.01\"\n .value=${String(u)}\n style=${`--media-progress:${d}%`}\n aria-label=\"Video progress\"\n @input=${this.#C}\n />\n <button\n type=\"button\"\n class=\"media-lightbox-short-mute\"\n @click=${this.#w}\n aria-label=${this._videoMuted?`Unmute video`:`Mute video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 48 48\"\n fill=\"currentColor\"\n color=\"#fff\"\n aria-hidden=\"true\"\n >\n ${this._videoMuted?G`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z\" />\n `:G`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1V1.4c0-1.3-1.6-1.9-2.5-1L10.2 13.3H1.5z\" />\n <path d=\"M30.1 15.9c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 4.1 3.5 4.1 11.7s-4.1 11.7-4.1 11.7c-.6.6-1.6.6-2.2 0l-1.4-1.4c-.6-.6-.6-1.6 0-2.2 0 0 2.6-2 2.6-8.1s-2.6-8.1-2.6-8.1z\" />\n <path d=\"M37.2 8c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 5.7 5.1 5.7 19.6s-5.7 19.6-5.7 19.6c-.6.6-1.6.6-2.2 0L37.2 42c-.6-.6-.6-1.6 0-2.2 0 0 4.3-4.4 4.3-15.8S37.2 8 37.2 8z\" />\n `}\n </svg>\n </button>\n </div>\n </div>`:W`<video\n class=\"media-lightbox-video\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n controls\n autoplay\n playsinline\n @focus=${this.#p}\n ></video>`:W`<img\n class=${`media-lightbox-img${i?` media-lightbox-img-zoomable`:``}${a?` media-lightbox-img-scroll`:``}`}\n src=${e?.url??``}\n alt=${e?.alt??``}\n @click=${this.#r}\n />`}\n </div>\n ${t?W`\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-prev\"\n @click=${()=>this.#t()}\n aria-label=\"Previous\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m15 18-6-6 6-6\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-next\"\n @click=${()=>this.#n()}\n aria-label=\"Next\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m9 18 6-6-6-6\" />\n </svg>\n </button>\n `:q}\n </div>\n </dialog>\n `}};customElements.define(`jant-media-lightbox`,on);";
@@ -5939,37 +6282,43 @@ var baseof_default = "<!doctype html>\n{{- $lang := .Site.LanguageCode | default
5939
6282
  var single_default$1 = "{{ define \"main\" }}\n <article class=\"page\">\n {{- with .Title -}}\n <header class=\"page-header\">\n <h1 class=\"page-title\">{{ . }}</h1>\n </header>\n {{- end -}}\n {{- with .Params.summary_text -}}\n <p class=\"page-summary\">{{ . }}</p>\n {{- end -}}\n <div class=\"page-body\">\n {{ .Content }}\n </div>\n </article>\n{{ end }}\n";
5940
6283
  //#endregion
5941
6284
  //#region src/services/export-theme/layouts/_default/list.html?raw
5942
- var list_default$4 = "{{ define \"main\" }}\n {{- /*\n _default/list.html is the section/list template fallback. It branches on\n page type so section pages with `type: collection` (branch bundles under\n content/{collection-slug}/_index.md) render a filtered, per-collection\n timeline. Other section types (archive, featured, etc.) have dedicated\n templates; this block only fires for collection sections and as a final\n fallback for unknown section types.\n */ -}}\n {{- if eq .Type \"collection\" -}}\n {{- $collectionSlug := .Params.slug | default .Slug -}}\n {{- /* Thread collection membership is stored only on root bundles. */ -}}\n {{- $allPosts := where (where .Site.Pages \"Type\" \"post\") \"Kind\" \"section\" -}}\n {{- $members := slice -}}\n {{- range $allPosts -}}\n {{- $post := . -}}\n {{- with $post.Params.collections -}}\n {{- range . -}}\n {{- if eq .slug $collectionSlug -}}\n {{- $members = $members | append (dict \"post\" $post \"entry\" .) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n\n {{- /* Split into pinned + unpinned, sort each, then concat. */ -}}\n {{- $pinned := slice -}}\n {{- $unpinned := slice -}}\n {{- range $members -}}\n {{- if .entry.pinned_at -}}\n {{- $pinned = $pinned | append . -}}\n {{- else -}}\n {{- $unpinned = $unpinned | append . -}}\n {{- end -}}\n {{- end -}}\n {{- if $pinned -}}\n {{- $pinned = sort $pinned \"entry.pinned_at\" \"desc\" -}}\n {{- end -}}\n {{- $sortOrder := .Params.sort_order | default \"position\" -}}\n {{- if $unpinned -}}\n {{- if eq $sortOrder \"collected_at_desc\" -}}\n {{- $unpinned = sort $unpinned \"entry.collected_at\" \"desc\" -}}\n {{- else -}}\n {{- $unpinned = sort $unpinned \"entry.position\" \"asc\" -}}\n {{- end -}}\n {{- end -}}\n {{- $ordered := $pinned | append $unpinned -}}\n {{- $posts := slice -}}\n {{- range $ordered -}}{{- $posts = $posts | append .post -}}{{- end -}}\n\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n\n <section class=\"section section-collection\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n {{- with .Params.entry_count -}}\n <p class=\"section-meta\">{{ . }} {{ if eq (int .) 1 }}thread{{ else }}threads{{ end }}</p>\n {{- end -}}\n {{ .Content }}\n </header>\n {{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{- $replies := $p.Pages.ByDate -}}\n {{- $hasReplies := gt (len $replies) 0 -}}\n <div class=\"thread thread-full{{ if $hasReplies }} thread-has-replies{{ end }}\" data-slug=\"{{ $p.Params.slug | default $p.Slug }}\">\n <div class=\"thread-item thread-item-root\">\n {{ partial \"post-card.html\" $p }}\n </div>\n {{- if $hasReplies -}}\n <section class=\"thread-replies\" aria-label=\"Replies\">\n {{- range $replies -}}\n {{ partial \"reply.html\" . }}\n {{- end -}}\n </section>\n {{- end -}}\n </div>\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">This collection doesn't contain any threads.</p>\n {{- end -}}\n </section>\n {{- else -}}\n <section class=\"section\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n <div class=\"section-body\">\n {{ .Content }}\n </div>\n {{- $posts := where .Site.Pages \"Type\" \"post\" -}}\n {{- $posts = $posts.ByDate.Reverse -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n {{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"post-card.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing here yet.</p>\n {{- end -}}\n </section>\n {{- end -}}\n{{ end }}\n";
6285
+ var list_default$5 = "{{ define \"main\" }}\n {{- /*\n _default/list.html is the section/list template fallback. It branches on\n page type so section pages with `type: collection` (branch bundles under\n content/{collection-slug}/_index.md) render a filtered, per-collection\n timeline. Other section types (archive, featured, etc.) have dedicated\n templates; this block only fires for collection sections and as a final\n fallback for unknown section types.\n */ -}}\n {{- if eq .Type \"collection\" -}}\n {{- $posts := partial \"collection-members.html\" (dict \"page\" . \"feed\" false) -}}\n\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n\n <section class=\"section section-collection\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n {{- with .Params.entry_count -}}\n <p class=\"section-meta\">{{ . }} {{ if eq (int .) 1 }}thread{{ else }}threads{{ end }}</p>\n {{- end -}}\n {{ .Content }}\n </header>\n {{ partial \"collection-threads.html\" (dict \"paginator\" $paginator \"empty\" \"This collection doesn't contain any threads.\") }}\n </section>\n {{- else -}}\n <section class=\"section\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n <div class=\"section-body\">\n {{ .Content }}\n </div>\n {{- $posts := where .Site.Pages \"Type\" \"post\" -}}\n {{- $posts = $posts.ByDate.Reverse -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n {{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"post-card.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing here yet.</p>\n {{- end -}}\n </section>\n {{- end -}}\n{{ end }}\n";
5943
6286
  //#endregion
5944
6287
  //#region src/services/export-theme/layouts/_default/alias.html?raw
5945
- var alias_default = "<!doctype html>\n<html lang=\"{{ .Site.LanguageCode | default \"en\" }}\">\n <head>\n <meta charset=\"utf-8\">\n <title>{{ .Site.Title }}</title>\n <meta name=\"robots\" content=\"noindex,follow\">\n <meta http-equiv=\"refresh\" content=\"0; url={{ .Permalink }}\">\n <link rel=\"canonical\" href=\"{{ .Permalink }}\">\n <script>\n (function () {\n var target = {{ .Permalink }};\n var rootAliases = {{ with .Params.root_aliases }}{{ . | jsonify }}{{ else }}[]{{ end }};\n try {\n var path = window.location.pathname || \"\";\n var normalized = \"/\" + path.replace(/^\\/+|\\/+$/g, \"\") + \"/\";\n var isHistoricalRootAlias = false;\n for (var i = 0; i < rootAliases.length; i++) {\n var alias = String(rootAliases[i]);\n var aliasNorm = \"/\" + alias.replace(/^\\/+|\\/+$/g, \"\") + \"/\";\n if (aliasNorm === normalized) {\n isHistoricalRootAlias = true;\n break;\n }\n }\n if (isHistoricalRootAlias) {\n // Historical root-slug alias — redirect to the post itself\n // without injecting an anchor.\n window.location.replace(target);\n return;\n }\n // Thread reply alias — append the reply slug as an anchor so\n // the thread page scrolls to the original reply.\n var parts = normalized.split(\"/\").filter(Boolean);\n var slug = parts.length > 0 ? parts[parts.length - 1] : \"\";\n if (slug) {\n window.location.replace(target + \"#\" + slug);\n } else {\n window.location.replace(target);\n }\n } catch (_err) {\n window.location.replace(target);\n }\n })();\n <\/script>\n </head>\n <body>\n <p>Redirecting to <a href=\"{{ .Permalink }}\">{{ .Permalink }}</a>&hellip;</p>\n </body>\n</html>\n";
6288
+ var alias_default = "{{- /*\n Alias page. Hugo renders one of these for every entry in a post's\n `aliases:` front matter.\n\n `.Permalink` is the alias target. It is empty whenever the page the alias\n points at was not built — an empty `content=0; url=` is read by browsers as\n \"reload this page\", which turns the alias into an infinite refresh loop. So\n the redirect markup is only emitted when there is somewhere to redirect to.\n*/ -}}\n{{- if .Permalink -}}\n<!doctype html>\n<html lang=\"{{ .Site.LanguageCode | default \"en\" }}\">\n <head>\n <meta charset=\"utf-8\">\n <title>{{ .Site.Title }}</title>\n <meta name=\"robots\" content=\"noindex,follow\">\n <meta http-equiv=\"refresh\" content=\"0; url={{ .Permalink }}\">\n <link rel=\"canonical\" href=\"{{ .Permalink }}\">\n <script>\n (function () {\n var target = {{ .Permalink }};\n // `safeJS` keeps this an array literal. Without it Hugo's contextual\n // escaping emits a quoted JSON string, and the loop below compares\n // single characters instead of paths.\n var rootAliases = {{ with .Params.root_aliases }}{{ . | jsonify | safeJS }}{{ else }}[]{{ end }};\n try {\n var path = window.location.pathname || \"\";\n var normalized = \"/\" + path.replace(/^\\/+|\\/+$/g, \"\") + \"/\";\n var isHistoricalRootAlias = false;\n for (var i = 0; i < rootAliases.length; i++) {\n var alias = String(rootAliases[i]);\n var aliasNorm = \"/\" + alias.replace(/^\\/+|\\/+$/g, \"\") + \"/\";\n if (aliasNorm === normalized) {\n isHistoricalRootAlias = true;\n break;\n }\n }\n if (isHistoricalRootAlias) {\n // Historical root-slug alias — redirect to the post itself\n // without injecting an anchor.\n window.location.replace(target);\n return;\n }\n // Thread reply alias — append the reply slug as an anchor so\n // the thread page scrolls to the original reply.\n var parts = normalized.split(\"/\").filter(Boolean);\n var slug = parts.length > 0 ? parts[parts.length - 1] : \"\";\n if (slug) {\n window.location.replace(target + \"#\" + slug);\n } else {\n window.location.replace(target);\n }\n } catch (_err) {\n window.location.replace(target);\n }\n })();\n <\/script>\n </head>\n <body>\n <p>Redirecting to <a href=\"{{ .Permalink }}\">{{ .Permalink }}</a>&hellip;</p>\n </body>\n</html>\n{{- else -}}\n<!doctype html>\n<html lang=\"{{ .Site.LanguageCode | default \"en\" }}\">\n <head>\n <meta charset=\"utf-8\">\n <title>{{ .Site.Title }}</title>\n <meta name=\"robots\" content=\"noindex,nofollow\">\n </head>\n <body>\n <p>This page is not available.</p>\n </body>\n</html>\n{{- end -}}\n";
5946
6289
  //#endregion
5947
6290
  //#region src/services/export-theme/layouts/index.html?raw
5948
- var layouts_default = "{{ define \"main\" }} {{- $pageSize := .Site.Params.page_size | default 10 -}} {{-\n/* Root posts are branch bundles (kind=section), so we iterate .Site.Pages, not\n.Site.RegularPages. */ -}} {{- $allPosts := where .Site.Pages \"Type\" \"post\" -}}\n{{- $pinned := where $allPosts \"Params.pinned_at\" \"ne\" nil -}} {{- $pinned =\n$pinned.ByDate.Reverse -}} {{- $public := where $allPosts \"Params.visibility\"\n\"public\" -}} {{- $public = where $public \"Params.pinned_at\" nil -}} {{- $main :=\n$public.ByDate.Reverse -}} {{- $paginator := .Paginate $main $pageSize -}}\n\n<section class=\"home\">\n {{- if and (eq $paginator.PageNumber 1) $pinned -}}\n <div class=\"post-list post-list-pinned\" aria-label=\"Pinned posts\">\n {{- range $i, $p := $pinned -}} {{- if gt $i 0 -}}\n <hr class=\"feed-divider\" aria-hidden=\"true\" />\n {{- end -}} {{ partial \"thread-preview.html\" $p }} {{- end -}}\n </div>\n {{- end -}} {{- if $paginator.Pages -}}\n <div class=\"post-list post-list-main\">\n {{- range $i, $p := $paginator.Pages -}} {{- if gt $i 0 -}}\n <hr class=\"feed-divider\" aria-hidden=\"true\" />\n {{- end -}} {{ partial \"thread-preview.html\" $p }} {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }} {{- else if not $pinned -}}\n <p class=\"empty-state\">\n Nothing published yet. Write your first post to get started.\n </p>\n {{- end -}}\n</section>\n{{ end }}\n";
6291
+ var layouts_default = "{{ define \"main\" }}\n {{- /*\n The home page lists Latest the way Jant's does: one list, pinned roots\n first, paginated as a whole (`latest-members`). The pinned roots on a\n page, which only lead the list, keep a block of their own.\n */ -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate (partial \"latest-members.html\" (dict \"feed\" false)) $pageSize -}}\n {{- $pinned := slice -}}\n {{- $main := slice -}}\n {{- range $paginator.Pages -}}\n {{- if .Params.pinned_at -}}\n {{- $pinned = $pinned | append . -}}\n {{- else -}}\n {{- $main = $main | append . -}}\n {{- end -}}\n {{- end -}}\n\n <section class=\"home\">\n {{- if $pinned -}}\n <div class=\"post-list post-list-pinned\" aria-label=\"Pinned posts\">\n {{- range $i, $p := $pinned -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"thread-preview.html\" $p }}\n {{- end -}}\n </div>\n {{- end -}}\n {{- if $main -}}\n <div class=\"post-list post-list-main\">\n {{- range $i, $p := $main -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"thread-preview.html\" $p }}\n {{- end -}}\n </div>\n {{- end -}}\n {{- if $paginator.Pages -}}\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing published yet. Write your first post to get started.</p>\n {{- end -}}\n </section>\n{{ end }}\n";
5949
6292
  //#endregion
5950
6293
  //#region src/services/export-theme/layouts/post/list.html?raw
5951
- var list_default$3 = "{{ define \"main\" }}\n {{- $root := . -}}\n {{- $format := $root.Params.format | default \"note\" -}}\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n {{- $media := $root.Params.media | default slice -}}\n {{- $collections := $root.Params.collections | default slice -}}\n {{- $linkUrl := $root.Params.link_url -}}\n {{- $replies := $root.Pages.ByDate -}}\n {{- $hasReplies := gt (len $replies) 0 -}}\n <article class=\"thread thread-{{ $format }}{{ if $hasReplies }} thread-has-replies{{ end }}\" id=\"{{ $slug }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\"\n {{- if $root.Params.pinned_at }} data-post-pinned{{ end -}}\n {{- if $root.Params.featured_at }} data-post-featured{{ end -}}\n >\n <div class=\"thread-item thread-item-root\">\n <header class=\"thread-header\">\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"thread-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if $root.Title -}}\n <h1 class=\"thread-title thread-link-title\">\n {{- if $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $root.Title }}</a>\n {{- else -}}\n {{ $root.Title }}\n {{- end -}}\n </h1>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"thread-quote post-card-quote\">\n {{- with $root.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $root.Params.source_name $root.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $root.Params.source_name $root.Params.source_url -}}\n <a href=\"{{ $root.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $root.Params.source_name }}</a>\n {{- else if $root.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $root.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $root.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $root.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $root.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- else -}}\n {{- with $root.Title -}}<h1 class=\"thread-title\">{{ . }}</h1>{{- end -}}\n {{- end -}}\n </header>\n\n {{- if eq $format \"quote\" -}}\n {{- with $root.Content -}}\n <div class=\"thread-body post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n <div class=\"thread-body prose\">\n {{ $root.Content }}\n </div>\n {{- end -}}\n\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" $root.RelPermalink) }}\n\n <footer class=\"thread-footer post-menu-footer post-footer-detail\" data-post-meta>\n <div class=\"post-footer-meta\">\n {{- if $root.Params.featured_at -}}\n <span class=\"post-footer-featured\" aria-label=\"Featured\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M12 3 10.1 10.1 3 12l7.1 1.9L12 21l1.9-7.1L21 12l-7.1-1.9Z\" />\n </svg>\n </span>\n {{- end -}}\n <a class=\"post-footer-link\" href=\"{{ $root.RelPermalink }}\">\n <time datetime=\"{{ $root.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $root.Date.Format \"Jan 2, 2006 · 15:04\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n {{- if $collections -}}\n <span class=\"post-collection-tags\">\n {{- range $i, $c := $collections -}}\n {{- if gt $i 0 -}}<span class=\"post-collection-sep\" aria-hidden=\"true\">, </span>{{- end -}}\n <a class=\"post-collection-tag\" href=\"{{ printf \"/%s/\" .slug | relURL }}\">\n <span class=\"post-collection-tag-text\">{{ .title }}</span>\n </a>\n {{- end -}}\n </span>\n {{- end -}}\n {{- if $root.Params.pinned_at -}}\n <span class=\"post-card-pin\" aria-label=\"Pinned\">Pinned</span>\n {{- end -}}\n </div>\n </footer>\n </div>\n\n {{- if $hasReplies -}}\n <section class=\"thread-replies\" aria-label=\"Replies\">\n {{- range $replies -}}\n {{ partial \"reply.html\" . }}\n {{- end -}}\n </section>\n {{- end -}}\n </article>\n{{ end }}\n";
6294
+ var list_default$4 = "{{ define \"main\" }}\n {{- $root := . -}}\n {{- $format := $root.Params.format | default \"note\" -}}\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n {{- $media := $root.Params.media | default slice -}}\n {{- $collections := $root.Params.collections | default slice -}}\n {{- $linkUrl := $root.Params.link_url -}}\n {{- $replies := $root.Pages.ByWeight -}}\n {{- $hasReplies := gt (len $replies) 0 -}}\n <article class=\"thread thread-{{ $format }}{{ if $hasReplies }} thread-has-replies{{ end }}\" id=\"{{ $slug }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\"\n {{- if $root.Params.pinned_at }} data-post-pinned{{ end -}}\n {{- if $root.Params.featured_at }} data-post-featured{{ end -}}\n >\n <div class=\"thread-item thread-item-root\">\n <header class=\"thread-header\">\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"thread-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if $root.Title -}}\n <h1 class=\"thread-title thread-link-title\">\n {{- if $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $root.Title }}</a>\n {{- else -}}\n {{ $root.Title }}\n {{- end -}}\n </h1>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"thread-quote post-card-quote\">\n {{- with $root.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $root.Params.source_name $root.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $root.Params.source_name $root.Params.source_url -}}\n <a href=\"{{ $root.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $root.Params.source_name }}</a>\n {{- else if $root.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $root.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $root.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $root.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $root.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- else -}}\n {{- with $root.Title -}}<h1 class=\"thread-title\">{{ . }}</h1>{{- end -}}\n {{- end -}}\n </header>\n\n {{- if eq $format \"quote\" -}}\n {{- with $root.Content -}}\n <div class=\"thread-body post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n <div class=\"thread-body prose\">\n {{ $root.Content }}\n </div>\n {{- end -}}\n\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" $root.RelPermalink) }}\n\n <footer class=\"thread-footer post-menu-footer post-footer-detail\" data-post-meta>\n <div class=\"post-footer-meta\">\n {{- if $root.Params.featured_at -}}\n <span class=\"post-footer-featured\" aria-label=\"Featured\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M12 3 10.1 10.1 3 12l7.1 1.9L12 21l1.9-7.1L21 12l-7.1-1.9Z\" />\n </svg>\n </span>\n {{- end -}}\n <a class=\"post-footer-link\" href=\"{{ $root.RelPermalink }}\">\n <time datetime=\"{{ $root.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $root.Date.Format \"Jan 2, 2006 · 15:04\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n {{- if $collections -}}\n <span class=\"post-collection-tags\">\n {{- range $i, $c := $collections -}}\n {{- if gt $i 0 -}}<span class=\"post-collection-sep\" aria-hidden=\"true\">, </span>{{- end -}}\n <a class=\"post-collection-tag\" href=\"{{ printf \"/%s/\" .slug | relURL }}\">\n <span class=\"post-collection-tag-text\">{{ .title }}</span>\n </a>\n {{- end -}}\n </span>\n {{- end -}}\n {{- if $root.Params.pinned_at -}}\n <span class=\"post-card-pin\" aria-label=\"Pinned\">Pinned</span>\n {{- end -}}\n </div>\n </footer>\n </div>\n\n {{- if $hasReplies -}}\n <section class=\"thread-replies\" aria-label=\"Replies\">\n {{- range $replies -}}\n {{ partial \"reply.html\" . }}\n {{- end -}}\n </section>\n {{- end -}}\n </article>\n{{ end }}\n";
5952
6295
  //#endregion
5953
6296
  //#region src/services/export-theme/layouts/featured/list.html?raw
5954
- var list_default$2 = "{{ define \"main\" }}\n {{- /* Root bundles carry a derived projection of every Featured Post in the Thread. */ -}}\n {{- $posts := where .Site.Pages \"Type\" \"post\" -}}\n {{- $roots := where $posts \"Kind\" \"section\" -}}\n {{- $featured := where $roots \"Params.featured_sort_at\" \"ne\" nil -}}\n {{- $featured = sort $featured \"Params.featured_sort_at\" \"desc\" -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $featured $pageSize -}}\n\n <section class=\"section section-featured\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title | default \"Featured\" }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n {{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"featured-thread.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing featured yet. Star a post to highlight it here.</p>\n {{- end -}}\n </section>\n{{ end }}\n";
6297
+ var list_default$3 = "{{ define \"main\" }}\n {{- /* Root bundles carry a derived projection of every Featured Post in the Thread. */ -}}\n {{- $featured := partial \"featured-members.html\" . -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $featured $pageSize -}}\n\n <section class=\"section section-featured\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title | default \"Featured\" }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n {{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"featured-thread.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing featured yet. Star a post to highlight it here.</p>\n {{- end -}}\n </section>\n{{ end }}\n";
5955
6298
  //#endregion
5956
6299
  //#region src/services/export-theme/layouts/archive/list.html?raw
5957
- var list_default$1 = "{{ define \"main\" }}\n {{- /* Root posts are branch bundles (kind=section), so we iterate .Site.Pages.\n Hugo already excludes draft pages, so `private` posts (emitted with\n `draft: true`) are filtered out automatically. This leaves `public`\n and `latest_hidden` posts — both visible in the archive timeline. */ -}}\n {{- $posts := where .Site.Pages \"Type\" \"post\" -}}\n {{- $posts = $posts.ByDate.Reverse -}}\n {{- $pageSize := .Site.Params.archive_page_size | default (.Site.Params.page_size | default 10) -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n\n <section class=\"section section-archive home\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title | default \"Archive\" }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n\n {{- if $paginator.Pages -}}\n <div class=\"post-list post-list-main\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"thread-preview.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing published yet. Write your first post to get started.</p>\n {{- end -}}\n </section>\n{{ end }}\n";
6300
+ var list_default$2 = "{{ define \"main\" }}\n {{- /* Root posts are branch bundles (kind=section), so we iterate .Site.Pages.\n Hugo already excludes draft pages, so `private` posts (emitted with\n `draft: true`) are filtered out automatically. This leaves `public`\n and `latest_hidden` posts — both visible in the archive timeline. */ -}}\n {{- $posts := where .Site.Pages \"Type\" \"post\" -}}\n {{- $posts = $posts.ByDate.Reverse -}}\n {{- $pageSize := .Site.Params.archive_page_size | default (.Site.Params.page_size | default 10) -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n\n <section class=\"section section-archive home\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title | default \"Archive\" }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n\n {{- if $paginator.Pages -}}\n <div class=\"post-list post-list-main\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{ partial \"thread-preview.html\" $p }}\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n {{- else -}}\n <p class=\"empty-state\">Nothing published yet. Write your first post to get started.</p>\n {{- end -}}\n </section>\n{{ end }}\n";
5958
6301
  //#endregion
5959
6302
  //#region src/services/export-theme/layouts/collections/list.html?raw
5960
- var list_default = "{{ define \"main\" }}\n {{- $items := slice -}}\n {{- with hugo.Data.jant -}}{{- with .directory -}}{{- $items = . -}}{{- end -}}{{- end -}}\n {{- $collectionCount := 0 -}}\n {{- range $items -}}\n {{- if eq .type \"collection\" -}}{{- $collectionCount = add $collectionCount 1 -}}{{- end -}}\n {{- end -}}\n\n <div class=\"section section-collections\" data-page=\"collections\">\n <div class=\"collections-page-shell\">\n <header class=\"collections-page-header\">\n <div class=\"collections-page-heading page-intro\">\n <div class=\"page-intro-title-row\">\n <h1 class=\"page-intro-title\">{{ .Title | default \"Collections\" }}</h1>\n </div>\n <div class=\"page-intro-meta-row\">\n <p class=\"page-intro-meta\">\n {{- $collectionCount }} {{ if eq $collectionCount 1 }}collection{{ else }}collections{{ end -}}\n </p>\n </div>\n {{- with .Params.summary_text -}}\n <p class=\"page-intro-description\">{{ . }}</p>\n {{- end -}}\n {{ .Content }}\n </div>\n </header>\n\n {{- if $items -}}\n <div class=\"collection-directory\">\n {{- range $items -}}\n {{- if eq .type \"divider\" -}}\n <div class=\"collection-directory-divider\">\n <div class=\"collection-directory-divider-row\"{{ if not .label }} aria-hidden=\"true\"{{ end }}>\n {{- with .label -}}\n <span class=\"collection-directory-divider-text\">{{ . }}</span>\n {{- end -}}\n <hr class=\"collection-directory-divider-line\">\n </div>\n </div>\n {{- else if eq .type \"link\" -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n <div class=\"collection-directory-item collection-directory-item-link\">\n <div class=\"collection-directory-main\">\n <span class=\"collection-directory-sequence\" aria-hidden=\"true\">{{ .sequence }}</span>\n <div class=\"collection-directory-title-row\">\n <a\n href=\"{{ .url }}\"\n class=\"collection-directory-title-link\"\n {{- if $isExternal }} target=\"_blank\" rel=\"noopener noreferrer\"{{ end }}\n >\n <span class=\"collection-directory-title\">\n {{- .label -}}\n <span class=\"collection-directory-title-marker\" aria-hidden=\"true\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10 13a5 5 0 0 0 7.54.54l2.92-2.92a5 5 0 0 0-7.07-7.08L11.7 5.24\"/>\n <path d=\"M14 11a5 5 0 0 0-7.54-.54l-2.92 2.92a5 5 0 0 0 7.07 7.08l1.69-1.7\"/>\n </svg>\n </span>\n </span>\n </a>\n </div>\n {{- if .description_html -}}\n <div class=\"collection-directory-description prose\">{{ .description_html | safeHTML }}</div>\n {{- else -}}\n <p class=\"collection-directory-summary\">\n <span class=\"collection-directory-meta\">Link</span>\n </p>\n {{- end -}}\n </div>\n </div>\n {{- else if eq .type \"collection\" -}}\n {{- $entryCount := .entry_count -}}\n {{- $activityLabel := .recent_activity_label -}}\n {{- $activityIso := .recent_activity_iso -}}\n <div class=\"collection-directory-item\">\n <div class=\"collection-directory-main\">\n <span class=\"collection-directory-sequence\" aria-hidden=\"true\">{{ .sequence }}</span>\n <div class=\"collection-directory-title-row\">\n <a class=\"collection-directory-title-link\" href=\"{{ printf \"/%s/\" .slug | relURL }}\">\n <span class=\"collection-directory-title\">{{ .title }}</span>\n </a>\n </div>\n {{- with .description_html -}}\n <div class=\"collection-directory-description prose\">{{ . | safeHTML }}</div>\n {{- end -}}\n {{- if or $entryCount $activityLabel -}}\n <p class=\"collection-directory-summary\">\n {{- if $entryCount -}}\n <span class=\"collection-directory-meta\">\n {{- $entryCount }} {{ if eq $entryCount 1 }}entry{{ else }}entries{{ end -}}\n </span>\n {{- end -}}\n {{- if and $entryCount $activityLabel -}}\n <span class=\"collection-directory-meta-separator\" aria-hidden=\"true\">/</span>\n {{- end -}}\n {{- if $activityLabel -}}\n <time class=\"collection-directory-updated\"{{ with $activityIso }} datetime=\"{{ . }}\"{{ end }}>{{ $activityLabel }}</time>\n {{- end -}}\n </p>\n {{- end -}}\n </div>\n </div>\n {{- end -}}\n {{- end -}}\n </div>\n {{- else -}}\n <p class=\"empty-state\">Nothing published yet. Group related posts to organize them here.</p>\n {{- end -}}\n </div>\n </div>\n{{ end }}\n";
6303
+ var list_default$1 = "{{ define \"main\" }}\n {{- $items := slice -}}\n {{- with partial \"jant-data.html\" . -}}{{- with .directory -}}{{- $items = . -}}{{- end -}}{{- end -}}\n {{- $collectionCount := 0 -}}\n {{- range $items -}}\n {{- if in (slice \"collection\" \"smart_collection\") .type -}}{{- $collectionCount = add $collectionCount 1 -}}{{- end -}}\n {{- end -}}\n\n <div class=\"section section-collections\" data-page=\"collections\">\n <div class=\"collections-page-shell\">\n <header class=\"collections-page-header\">\n <div class=\"collections-page-heading page-intro\">\n <div class=\"page-intro-title-row\">\n <h1 class=\"page-intro-title\">{{ .Title | default \"Collections\" }}</h1>\n </div>\n <div class=\"page-intro-meta-row\">\n <p class=\"page-intro-meta\">\n {{- $collectionCount }} {{ if eq $collectionCount 1 }}collection{{ else }}collections{{ end -}}\n </p>\n </div>\n {{- with .Params.summary_text -}}\n <p class=\"page-intro-description\">{{ . }}</p>\n {{- end -}}\n {{ .Content }}\n </div>\n </header>\n\n {{- if $items -}}\n <div class=\"collection-directory\">\n {{- range $items -}}\n {{- if eq .type \"divider\" -}}\n <div class=\"collection-directory-divider\">\n <div class=\"collection-directory-divider-row\"{{ if not .label }} aria-hidden=\"true\"{{ end }}>\n {{- with .label -}}\n <span class=\"collection-directory-divider-text\">{{ . }}</span>\n {{- end -}}\n <hr class=\"collection-directory-divider-line\">\n </div>\n </div>\n {{- else if eq .type \"link\" -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n <div class=\"collection-directory-item collection-directory-item-link\">\n <div class=\"collection-directory-main\">\n <span class=\"collection-directory-sequence\" aria-hidden=\"true\">{{ .sequence }}</span>\n <div class=\"collection-directory-title-row\">\n <a\n href=\"{{ .url }}\"\n class=\"collection-directory-title-link\"\n {{- if $isExternal }} target=\"_blank\" rel=\"noopener noreferrer\"{{ end }}\n >\n <span class=\"collection-directory-title\">\n {{- .label -}}\n <span class=\"collection-directory-title-marker\" aria-hidden=\"true\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10 13a5 5 0 0 0 7.54.54l2.92-2.92a5 5 0 0 0-7.07-7.08L11.7 5.24\"/>\n <path d=\"M14 11a5 5 0 0 0-7.54-.54l-2.92 2.92a5 5 0 0 0 7.07 7.08l1.69-1.7\"/>\n </svg>\n </span>\n </span>\n </a>\n </div>\n {{- if .description_html -}}\n <div class=\"collection-directory-description prose\">{{ .description_html | safeHTML }}</div>\n {{- else -}}\n <p class=\"collection-directory-summary\">\n <span class=\"collection-directory-meta\">Link</span>\n </p>\n {{- end -}}\n </div>\n </div>\n {{- else if in (slice \"collection\" \"smart_collection\") .type -}}\n {{- $entryCount := .entry_count -}}\n {{- $activityLabel := .recent_activity_label -}}\n {{- $activityIso := .recent_activity_iso -}}\n <div class=\"collection-directory-item\">\n <div class=\"collection-directory-main\">\n <span class=\"collection-directory-sequence\" aria-hidden=\"true\">{{ .sequence }}</span>\n <div class=\"collection-directory-title-row\">\n <a class=\"collection-directory-title-link\" href=\"{{ printf \"/%s/\" .slug | relURL }}\">\n <span class=\"collection-directory-title\">{{ .title }}</span>\n </a>\n </div>\n {{- with .description_html -}}\n <div class=\"collection-directory-description prose\">{{ . | safeHTML }}</div>\n {{- end -}}\n {{- if or $entryCount $activityLabel -}}\n <p class=\"collection-directory-summary\">\n {{- if $entryCount -}}\n <span class=\"collection-directory-meta\">\n {{- $entryCount }} {{ if eq $entryCount 1 }}entry{{ else }}entries{{ end -}}\n </span>\n {{- end -}}\n {{- if and $entryCount $activityLabel -}}\n <span class=\"collection-directory-meta-separator\" aria-hidden=\"true\">/</span>\n {{- end -}}\n {{- if $activityLabel -}}\n <time class=\"collection-directory-updated\"{{ with $activityIso }} datetime=\"{{ . }}\"{{ end }}>{{ $activityLabel }}</time>\n {{- end -}}\n </p>\n {{- end -}}\n </div>\n </div>\n {{- end -}}\n {{- end -}}\n </div>\n {{- else -}}\n <p class=\"empty-state\">Nothing published yet. Group related posts to organize them here.</p>\n {{- end -}}\n </div>\n </div>\n{{ end }}\n";
5961
6304
  //#endregion
5962
6305
  //#region src/services/export-theme/layouts/collection/single.html?raw
5963
6306
  var single_default = "{{/*\n Fallback single-page template for type=collection leaf bundles. Jant's\n exporter writes collection pages as branch bundles (kind=section), which\n are rendered by `_default/list.html`'s `type=collection` branch. This\n template only fires if a user hand-authors a collection as a leaf bundle.\n*/}}\n{{ define \"main\" }}\n <article class=\"page page-collection\">\n <header class=\"page-header\">\n <h1 class=\"page-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"page-summary\">{{ . }}</p>\n {{- end -}}\n </header>\n <div class=\"page-body\">\n {{ .Content }}\n </div>\n </article>\n{{ end }}\n";
5964
6307
  //#endregion
6308
+ //#region src/services/export-theme/layouts/smart_collection/list.html?raw
6309
+ var list_default = "{{ define \"main\" }}\n {{- /*\n A smart collection's page (`type: smart_collection`). The members come\n from its conditions, applied to the posts in this site by the\n `smart-collection-members` partial.\n */ -}}\n {{- $posts := partial \"smart-collection-members.html\" (dict \"page\" . \"feed\" false) -}}\n {{- $pageSize := .Site.Params.page_size | default 10 -}}\n {{- $paginator := .Paginate $posts $pageSize -}}\n\n <section class=\"section section-collection section-smart-collection\">\n <header class=\"section-header\">\n <h1 class=\"section-title\">{{ .Title }}</h1>\n {{- with .Params.summary_text -}}\n <p class=\"section-summary\">{{ . }}</p>\n {{- end -}}\n {{- with len $posts -}}\n <p class=\"section-meta\">{{ . }} {{ if eq . 1 }}thread{{ else }}threads{{ end }}</p>\n {{- end -}}\n {{ .Content }}\n </header>\n {{ partial \"collection-threads.html\" (dict \"paginator\" $paginator \"empty\" \"Nothing matches these conditions yet.\") }}\n </section>\n{{ end }}\n";
6310
+ //#endregion
6311
+ //#region src/services/export-theme/layouts/partials/jant-data.html?raw
6312
+ var jant_data_default = "{{- /*\n Returns the `jant` table from `data/jant.toml` — nav, branding, and the\n collections directory.\n\n Hugo moved site data to `hugo.Data` in v0.160.0 and deprecated `site.Data`\n in v0.156.0, so neither accessor works everywhere: `hugo.Data` is a render\n error before it existed, and `site.Data` is a deprecation warning today and\n a removal tomorrow. Only the taken branch of an `if` is evaluated, so\n branching on the running version reads whichever one that version has.\n `hugo.Version` compares semantically, not as a string.\n\n Hugo rewrites a single `return` per template, hence the assignment rather\n than a `return` in each branch. Every template reads the data through this\n partial, so the day the old branch stops being worth carrying there is one\n place to change.\n*/ -}}\n{{- $data := dict -}}\n{{- if ge hugo.Version \"0.160.0\" -}}\n {{- $data = hugo.Data -}}\n{{- else -}}\n {{- $data = site.Data -}}\n{{- end -}}\n{{- return (index $data \"jant\") -}}\n";
6313
+ //#endregion
5965
6314
  //#region src/services/export-theme/layouts/partials/head.html?raw
5966
- var head_default = "{{- $jant := hugo.Data.jant -}}\n{{- $siteTitle := .Site.Title -}}\n{{- $pageTitle := \"\" -}}\n{{- if .IsHome -}}\n {{- $pageTitle = $siteTitle -}}\n{{- else if .Title -}}\n {{- $pageTitle = printf \"%s | %s\" .Title $siteTitle -}}\n{{- else -}}\n {{- $pageTitle = $siteTitle -}}\n{{- end -}}\n{{- $description := \"\" -}}\n{{- with .Params.summary_text -}}{{- $description = . -}}{{- end -}}\n{{- if not $description -}}{{- with $jant -}}{{- with .site_description -}}{{- $description = . -}}{{- end -}}{{- end -}}{{- end -}}\n{{- $faviconVersion := \"\" -}}\n{{- with $jant -}}{{- with .favicon_version -}}{{- $faviconVersion = . -}}{{- end -}}{{- end -}}\n{{- $faviconPath := \"/favicon.ico\" -}}\n{{- with $jant -}}{{- with .favicon_path -}}{{- $faviconPath = . -}}{{- end -}}{{- end -}}\n{{- $appleTouchPath := \"/apple-touch-icon.png\" -}}\n{{- with $jant -}}{{- with .apple_touch_icon_path -}}{{- $appleTouchPath = . -}}{{- end -}}{{- end -}}\n{{- $faviconHref := $faviconPath | relURL -}}\n{{- $appleTouchHref := $appleTouchPath | relURL -}}\n{{- if $faviconVersion -}}\n {{- $faviconHref = printf \"%s?v=%s\" $faviconHref $faviconVersion -}}\n {{- $appleTouchHref = printf \"%s?v=%s\" $appleTouchHref $faviconVersion -}}\n{{- end -}}\n{{- $noindex := false -}}\n{{- with $jant -}}{{- if eq .noindex true -}}{{- $noindex = true -}}{{- end -}}{{- end -}}\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{{ $pageTitle }}</title>\n{{- with $description }}\n<meta name=\"description\" content=\"{{ . }}\">\n{{- end }}\n{{- if $noindex }}\n<meta name=\"robots\" content=\"noindex,nofollow\">\n{{- end }}\n<link rel=\"canonical\" href=\"{{ .Permalink }}\">\n<meta property=\"og:site_name\" content=\"{{ $siteTitle }}\">\n<meta property=\"og:title\" content=\"{{ if .Title }}{{ .Title }}{{ else }}{{ $siteTitle }}{{ end }}\">\n{{- with $description }}\n<meta property=\"og:description\" content=\"{{ . }}\">\n{{- end }}\n<meta property=\"og:url\" content=\"{{ .Permalink }}\">\n<meta property=\"og:type\" content=\"{{ if .IsPage }}article{{ else }}website{{ end }}\">\n<link rel=\"icon\" type=\"image/x-icon\" href=\"{{ $faviconHref }}\">\n<link rel=\"apple-touch-icon\" href=\"{{ $appleTouchHref }}\">\n<link rel=\"stylesheet\" href=\"{{ \"tokens.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"main.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"_jant/client-site.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"theme.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"custom.css\" | relURL }}\">\n<script type=\"module\" src=\"{{ \"_jant/client-site.js\" | relURL }}\"><\/script>\n";
6315
+ var head_default = "{{- $jant := partial \"jant-data.html\" . -}}\n{{- $siteTitle := .Site.Title -}}\n{{- $pageTitle := \"\" -}}\n{{- if .IsHome -}}\n {{- $pageTitle = $siteTitle -}}\n{{- else if .Title -}}\n {{- $pageTitle = printf \"%s | %s\" .Title $siteTitle -}}\n{{- else -}}\n {{- $pageTitle = $siteTitle -}}\n{{- end -}}\n{{- $description := \"\" -}}\n{{- with .Params.summary_text -}}{{- $description = . -}}{{- end -}}\n{{- if not $description -}}{{- with $jant -}}{{- with .site_description -}}{{- $description = . -}}{{- end -}}{{- end -}}{{- end -}}\n{{- $faviconVersion := \"\" -}}\n{{- with $jant -}}{{- with .favicon_version -}}{{- $faviconVersion = . -}}{{- end -}}{{- end -}}\n{{- $faviconPath := \"/favicon.ico\" -}}\n{{- with $jant -}}{{- with .favicon_path -}}{{- $faviconPath = . -}}{{- end -}}{{- end -}}\n{{- $appleTouchPath := \"/apple-touch-icon.png\" -}}\n{{- with $jant -}}{{- with .apple_touch_icon_path -}}{{- $appleTouchPath = . -}}{{- end -}}{{- end -}}\n{{- $faviconHref := $faviconPath | relURL -}}\n{{- $appleTouchHref := $appleTouchPath | relURL -}}\n{{- if $faviconVersion -}}\n {{- $faviconHref = printf \"%s?v=%s\" $faviconHref $faviconVersion -}}\n {{- $appleTouchHref = printf \"%s?v=%s\" $appleTouchHref $faviconVersion -}}\n{{- end -}}\n{{- $noindex := false -}}\n{{- with $jant -}}{{- if eq .noindex true -}}{{- $noindex = true -}}{{- end -}}{{- end -}}\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{{ $pageTitle }}</title>\n{{- with $description }}\n<meta name=\"description\" content=\"{{ . }}\">\n{{- end }}\n{{- if $noindex }}\n<meta name=\"robots\" content=\"noindex,nofollow\">\n{{- end }}\n<link rel=\"canonical\" href=\"{{ .Permalink }}\">\n<meta property=\"og:site_name\" content=\"{{ $siteTitle }}\">\n<meta property=\"og:title\" content=\"{{ if .Title }}{{ .Title }}{{ else }}{{ $siteTitle }}{{ end }}\">\n{{- with $description }}\n<meta property=\"og:description\" content=\"{{ . }}\">\n{{- end }}\n<meta property=\"og:url\" content=\"{{ .Permalink }}\">\n<meta property=\"og:type\" content=\"{{ if .IsPage }}article{{ else }}website{{ end }}\">\n<link rel=\"icon\" type=\"image/x-icon\" href=\"{{ $faviconHref }}\">\n<link rel=\"apple-touch-icon\" href=\"{{ $appleTouchHref }}\">\n<link rel=\"stylesheet\" href=\"{{ \"tokens.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"main.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"_jant/client-site.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"theme.css\" | relURL }}\">\n<link rel=\"stylesheet\" href=\"{{ \"custom.css\" | relURL }}\">\n<script type=\"module\" src=\"{{ \"_jant/client-site.js\" | relURL }}\"><\/script>\n";
5967
6316
  //#endregion
5968
6317
  //#region src/services/export-theme/layouts/partials/header.html?raw
5969
- var header_default = "{{- $jant := hugo.Data.jant -}}\n{{- $showAvatar := false -}}\n{{- with $jant -}}{{- if eq .show_header_avatar true -}}{{- $showAvatar = true -}}{{- end -}}{{- end -}}\n{{- $avatarUrl := \"\" -}}\n{{- with $jant -}}{{- with .site_avatar_url -}}{{- $avatarUrl = . -}}{{- end -}}{{- end -}}\n\n{{- /* Split nav items by placement: \"header\" (inline) vs \"more\" (supplemental dropdown). */ -}}\n{{- $headerItems := slice -}}\n{{- $moreItems := slice -}}\n{{- with $jant -}}{{- range .nav -}}\n {{- if eq .placement \"more\" -}}\n {{- $moreItems = $moreItems | append . -}}\n {{- else -}}\n {{- $headerItems = $headerItems | append . -}}\n {{- end -}}\n{{- end -}}{{- end -}}\n\n{{- $homeURL := \"/\" | relURL -}}\n{{- $currentURL := .RelPermalink -}}\n{{- $headerCount := len $headerItems -}}\n{{- $hasResponsiveOverflow := gt $headerCount 2 -}}\n{{- $hasSupplementalMore := gt (len $moreItems) 0 -}}\n{{- $showMoreMenu := or $hasResponsiveOverflow $hasSupplementalMore -}}\n\n{{- /* When only responsive overflow triggers the More button (no supplemental\n items), hide it until the narrowest tier where overflow actually kicks in. */ -}}\n{{- $moreWrapClass := \"site-header-more\" -}}\n{{- if and $hasResponsiveOverflow (not $hasSupplementalMore) -}}\n {{- $tier := \"site-header-more-tier-sm\" -}}\n {{- if ge $headerCount 5 -}}{{- $tier = \"site-header-more-tier-lg\" -}}\n {{- else if eq $headerCount 4 -}}{{- $tier = \"site-header-more-tier-md\" -}}{{- end -}}\n {{- $moreWrapClass = printf \"%s site-header-more-responsive-only %s\" $moreWrapClass $tier -}}\n{{- end -}}\n\n<header class=\"site-header\" role=\"banner\">\n <div class=\"site-header-inner\">\n <div class=\"site-header-top site-header-top-bordered\">\n <a class=\"site-logo\" href=\"{{ \"/\" | relURL }}\">\n {{- if and $showAvatar $avatarUrl -}}\n <img class=\"site-logo-avatar\" src=\"{{ $avatarUrl }}\" alt=\"\" width=\"40\" height=\"40\" loading=\"eager\">\n {{- end -}}\n <span class=\"site-logo-text\">{{ .Site.Title }}</span>\n </a>\n {{- if or $headerItems $showMoreMenu -}}\n <nav class=\"site-header-nav\" aria-label=\"Primary\">\n {{- range $i, $item := $headerItems -}}\n {{- $isExternal := or (hasPrefix $item.url \"http://\") (hasPrefix $item.url \"https://\") -}}\n {{- $navURL := $item.url -}}\n {{- if not $isExternal -}}{{- $navURL = $item.url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n {{- $tierClass := \"\" -}}\n {{- if eq $i 2 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-sm\" -}}\n {{- else if eq $i 3 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-md\" -}}\n {{- else if gt $i 3 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-lg\" -}}\n {{- else -}}{{- $tierClass = \"site-header-link-primary\" -}}{{- end -}}\n <a class=\"site-header-link {{ $tierClass }}{{ if $isActive }} site-header-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ $item.label }}</a>\n {{- end -}}\n {{- if $showMoreMenu -}}\n <div class=\"{{ $moreWrapClass }}\">\n <button type=\"button\" class=\"site-header-more-btn\" aria-haspopup=\"menu\" aria-expanded=\"false\">\n More\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"/></svg>\n </button>\n <div class=\"site-header-more-popover\" aria-hidden=\"true\">\n {{- range $i, $item := $headerItems -}}\n {{- if ge $i 2 -}}\n {{- $isExternal := or (hasPrefix $item.url \"http://\") (hasPrefix $item.url \"https://\") -}}\n {{- $navURL := $item.url -}}\n {{- if not $isExternal -}}{{- $navURL = $item.url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n {{- $show := \"site-header-more-link-show-lg\" -}}\n {{- if eq $i 2 -}}{{- $show = \"site-header-more-link-show-sm\" -}}\n {{- else if eq $i 3 -}}{{- $show = \"site-header-more-link-show-md\" -}}{{- end -}}\n <a class=\"site-header-more-link site-header-more-link-responsive {{ $show }}{{ if $isActive }} site-header-more-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ $item.label }}</a>\n {{- end -}}\n {{- end -}}\n {{- if and $hasResponsiveOverflow $hasSupplementalMore -}}\n <div class=\"site-header-more-divider site-header-more-divider-responsive\"></div>\n {{- end -}}\n {{- range $moreItems -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n {{- $navURL := .url -}}\n {{- if not $isExternal -}}{{- $navURL = .url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n <a class=\"site-header-more-link site-header-more-link-supplemental{{ if $isActive }} site-header-more-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ .label }}</a>\n {{- end -}}\n </div>\n </div>\n {{- end -}}\n </nav>\n {{- end -}}\n </div>\n </div>\n</header>\n";
6318
+ var header_default = "{{- $jant := partial \"jant-data.html\" . -}}\n{{- $showAvatar := false -}}\n{{- with $jant -}}{{- if eq .show_header_avatar true -}}{{- $showAvatar = true -}}{{- end -}}{{- end -}}\n{{- $avatarUrl := \"\" -}}\n{{- with $jant -}}{{- with .site_avatar_url -}}{{- $avatarUrl = . -}}{{- end -}}{{- end -}}\n\n{{- /* Split nav items by placement: \"header\" (inline) vs \"more\" (supplemental dropdown).\n `settings` is exported for an import back into Jant; the static site has no page for it. */ -}}\n{{- $headerItems := slice -}}\n{{- $moreItems := slice -}}\n{{- with $jant -}}{{- range .nav -}}\n {{- if eq .system_key \"settings\" -}}\n {{- else if eq .placement \"more\" -}}\n {{- $moreItems = $moreItems | append . -}}\n {{- else -}}\n {{- $headerItems = $headerItems | append . -}}\n {{- end -}}\n{{- end -}}{{- end -}}\n\n{{- $homeURL := \"/\" | relURL -}}\n{{- $currentURL := .RelPermalink -}}\n{{- $headerCount := len $headerItems -}}\n{{- $hasResponsiveOverflow := gt $headerCount 2 -}}\n{{- $hasSupplementalMore := gt (len $moreItems) 0 -}}\n{{- $showMoreMenu := or $hasResponsiveOverflow $hasSupplementalMore -}}\n\n{{- /* When only responsive overflow triggers the More button (no supplemental\n items), hide it until the narrowest tier where overflow actually kicks in. */ -}}\n{{- $moreWrapClass := \"site-header-more\" -}}\n{{- if and $hasResponsiveOverflow (not $hasSupplementalMore) -}}\n {{- $tier := \"site-header-more-tier-sm\" -}}\n {{- if ge $headerCount 5 -}}{{- $tier = \"site-header-more-tier-lg\" -}}\n {{- else if eq $headerCount 4 -}}{{- $tier = \"site-header-more-tier-md\" -}}{{- end -}}\n {{- $moreWrapClass = printf \"%s site-header-more-responsive-only %s\" $moreWrapClass $tier -}}\n{{- end -}}\n\n<header class=\"site-header\" role=\"banner\">\n <div class=\"site-header-inner\">\n <div class=\"site-header-top site-header-top-bordered\">\n <a class=\"site-logo\" href=\"{{ \"/\" | relURL }}\">\n {{- if and $showAvatar $avatarUrl -}}\n <img class=\"site-logo-avatar\" src=\"{{ $avatarUrl }}\" alt=\"\" width=\"40\" height=\"40\" loading=\"eager\">\n {{- end -}}\n <span class=\"site-logo-text\">{{ .Site.Title }}</span>\n </a>\n {{- if or $headerItems $showMoreMenu -}}\n <nav class=\"site-header-nav\" aria-label=\"Primary\">\n {{- range $i, $item := $headerItems -}}\n {{- $isExternal := or (hasPrefix $item.url \"http://\") (hasPrefix $item.url \"https://\") -}}\n {{- $navURL := $item.url -}}\n {{- if not $isExternal -}}{{- $navURL = $item.url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n {{- $tierClass := \"\" -}}\n {{- if eq $i 2 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-sm\" -}}\n {{- else if eq $i 3 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-md\" -}}\n {{- else if gt $i 3 -}}{{- $tierClass = \"site-header-link-overflow site-header-link-collapse-lg\" -}}\n {{- else -}}{{- $tierClass = \"site-header-link-primary\" -}}{{- end -}}\n <a class=\"site-header-link {{ $tierClass }}{{ if $isActive }} site-header-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ $item.label }}</a>\n {{- end -}}\n {{- if $showMoreMenu -}}\n <div class=\"{{ $moreWrapClass }}\">\n <button type=\"button\" class=\"site-header-more-btn\" aria-haspopup=\"menu\" aria-expanded=\"false\">\n More\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"/></svg>\n </button>\n <div class=\"site-header-more-popover\" aria-hidden=\"true\">\n {{- range $i, $item := $headerItems -}}\n {{- if ge $i 2 -}}\n {{- $isExternal := or (hasPrefix $item.url \"http://\") (hasPrefix $item.url \"https://\") -}}\n {{- $navURL := $item.url -}}\n {{- if not $isExternal -}}{{- $navURL = $item.url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n {{- $show := \"site-header-more-link-show-lg\" -}}\n {{- if eq $i 2 -}}{{- $show = \"site-header-more-link-show-sm\" -}}\n {{- else if eq $i 3 -}}{{- $show = \"site-header-more-link-show-md\" -}}{{- end -}}\n <a class=\"site-header-more-link site-header-more-link-responsive {{ $show }}{{ if $isActive }} site-header-more-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ $item.label }}</a>\n {{- end -}}\n {{- end -}}\n {{- if and $hasResponsiveOverflow $hasSupplementalMore -}}\n <div class=\"site-header-more-divider site-header-more-divider-responsive\"></div>\n {{- end -}}\n {{- range $moreItems -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n {{- $navURL := .url -}}\n {{- if not $isExternal -}}{{- $navURL = .url | relURL -}}{{- end -}}\n {{- $isActive := false -}}\n {{- if not $isExternal -}}\n {{- if eq $navURL $homeURL -}}\n {{- if eq $currentURL $homeURL -}}{{- $isActive = true -}}{{- end -}}\n {{- else if or (eq $currentURL $navURL) (hasPrefix $currentURL $navURL) -}}\n {{- $isActive = true -}}\n {{- end -}}\n {{- end -}}\n <a class=\"site-header-more-link site-header-more-link-supplemental{{ if $isActive }} site-header-more-link-active{{ end }}\" href=\"{{ $navURL }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ .label }}</a>\n {{- end -}}\n </div>\n </div>\n {{- end -}}\n </nav>\n {{- end -}}\n </div>\n </div>\n</header>\n";
5970
6319
  //#endregion
5971
6320
  //#region src/services/export-theme/layouts/partials/footer.html?raw
5972
- var footer_default = "{{- $jant := hugo.Data.jant -}}\n{{- $footerHtml := \"\" -}}\n{{- with $jant -}}{{- with .site_footer_html -}}{{- $footerHtml = . -}}{{- end -}}{{- end -}}\n{{- $showBranding := false -}}\n{{- with $jant -}}{{- if eq .show_jant_branding_on_home true -}}{{- $showBranding = true -}}{{- end -}}{{- end -}}\n{{- $navFooterItems := slice -}}\n{{- with $jant -}}{{- range .nav -}}{{- if eq .placement \"footer\" -}}{{- $navFooterItems = $navFooterItems | append . -}}{{- end -}}{{- end -}}{{- end -}}\n{{- $hasFooter := or $navFooterItems $footerHtml -}}\n{{- if $hasFooter -}}\n<footer class=\"site-footer\" role=\"contentinfo\">\n <div class=\"site-footer-inner\">\n {{- if $navFooterItems -}}\n <nav class=\"site-footer-nav\" aria-label=\"Footer\">\n <ul class=\"site-footer-nav-list\">\n {{- range $navFooterItems -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n <li><a href=\"{{ .url }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ .label }}</a></li>\n {{- end -}}\n </ul>\n </nav>\n {{- end -}}\n {{- with $footerHtml -}}\n <div class=\"site-footer-content\">{{ . | safeHTML }}</div>\n {{- end -}}\n </div>\n</footer>\n{{- end -}}\n{{- if and $showBranding $.IsHome -}}\n<footer class=\"home-branding-credit\">\n Build with <a href=\"https://jant.me\" rel=\"noopener noreferrer\" target=\"_blank\">Jant</a>\n</footer>\n{{- end -}}\n";
6321
+ var footer_default = "{{- $jant := partial \"jant-data.html\" . -}}\n{{- $footerHtml := \"\" -}}\n{{- with $jant -}}{{- with .site_footer_html -}}{{- $footerHtml = . -}}{{- end -}}{{- end -}}\n{{- $showBranding := false -}}\n{{- with $jant -}}{{- if eq .show_jant_branding_on_home true -}}{{- $showBranding = true -}}{{- end -}}{{- end -}}\n{{- $navFooterItems := slice -}}\n{{- with $jant -}}{{- range .nav -}}{{- if eq .placement \"footer\" -}}{{- $navFooterItems = $navFooterItems | append . -}}{{- end -}}{{- end -}}{{- end -}}\n{{- $hasFooter := or $navFooterItems $footerHtml -}}\n{{- if $hasFooter -}}\n<footer class=\"site-footer\" role=\"contentinfo\">\n <div class=\"site-footer-inner\">\n {{- if $navFooterItems -}}\n <nav class=\"site-footer-nav\" aria-label=\"Footer\">\n <ul class=\"site-footer-nav-list\">\n {{- range $navFooterItems -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n <li><a href=\"{{ .url }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ .label }}</a></li>\n {{- end -}}\n </ul>\n </nav>\n {{- end -}}\n {{- with $footerHtml -}}\n <div class=\"site-footer-content\">{{ . | safeHTML }}</div>\n {{- end -}}\n </div>\n</footer>\n{{- end -}}\n{{- if and $showBranding $.IsHome -}}\n<footer class=\"home-branding-credit\">\n Build with <a href=\"https://jant.me\" rel=\"noopener noreferrer\" target=\"_blank\">Jant</a>\n</footer>\n{{- end -}}\n";
5973
6322
  //#endregion
5974
6323
  //#region src/services/export-theme/layouts/partials/pagination.html?raw
5975
6324
  var pagination_default = "{{- $paginator := . -}}\n{{- $total := $paginator.TotalPages -}}\n{{- $current := $paginator.PageNumber -}}\n{{- if gt $total 1 -}}\n {{- /* Mirror main site's getPageNumbers: past seven pages, always seven\n slots — 1, last, and a three-page window around the current page.\n The second and second-to-last slots hold a page or 0 (an ellipsis\n marker), so an ellipsis never stands in for a single page. */ -}}\n {{- $pages := slice -}}\n {{- if le $total 7 -}}\n {{- range seq 1 $total -}}\n {{- $pages = $pages | append . -}}\n {{- end -}}\n {{- else -}}\n {{- $start := sub $current 1 -}}\n {{- if lt $start 3 -}}{{- $start = 3 -}}{{- end -}}\n {{- if gt $start (sub $total 4) -}}{{- $start = sub $total 4 -}}{{- end -}}\n {{- $end := add $start 2 -}}\n {{- $pages = $pages | append 1 -}}\n {{- $pages = $pages | append (cond (eq $start 3) 2 0) -}}\n {{- range seq $start $end -}}\n {{- $pages = $pages | append . -}}\n {{- end -}}\n {{- $pages = $pages | append (cond (eq $end (sub $total 2)) (sub $total 1) 0) -}}\n {{- $pages = $pages | append $total -}}\n {{- end -}}\n <nav class=\"pagination\" aria-label=\"Pagination\">\n {{- if $paginator.HasPrev -}}\n <a class=\"pagination-link pagination-prev\" href=\"{{ $paginator.Prev.URL }}\" rel=\"prev\">Previous</a>\n {{- else -}}\n <span class=\"pagination-link pagination-prev is-disabled\" aria-disabled=\"true\">Previous</span>\n {{- end -}}\n {{- range $pages -}}\n {{- if eq . 0 -}}\n <span class=\"pagination-ellipsis\" aria-hidden=\"true\">...</span>\n {{- else if eq . $current -}}\n <span class=\"pagination-current\" aria-current=\"page\">{{ . }}</span>\n {{- else -}}\n {{- $href := \"\" -}}\n {{- if eq . 1 -}}\n {{- $href = ($paginator.First.URL | default \"/\") -}}\n {{- else -}}\n {{- with index $paginator.Pagers (sub . 1) -}}\n {{- $href = .URL -}}\n {{- end -}}\n {{- end -}}\n <a class=\"pagination-link\" href=\"{{ $href }}\">{{ . }}</a>\n {{- end -}}\n {{- end -}}\n {{- if $paginator.HasNext -}}\n <a class=\"pagination-link pagination-next\" href=\"{{ $paginator.Next.URL }}\" rel=\"next\">Next</a>\n {{- else -}}\n <span class=\"pagination-link pagination-next is-disabled\" aria-disabled=\"true\">Next</span>\n {{- end -}}\n </nav>\n{{- end -}}\n";
@@ -5984,13 +6333,28 @@ var media_gallery_default = "{{- /*\n Media gallery — unified horizontal scro
5984
6333
  var reply_default = "{{- $reply := . -}}\n{{- $format := $reply.Params.format | default \"note\" -}}\n{{- $slug := $reply.Params.slug | default $reply.Slug -}}\n{{- $media := $reply.Params.media | default slice -}}\n{{- $linkUrl := $reply.Params.link_url -}}\n<article class=\"reply thread-item reply-{{ $format }}\" id=\"{{ $slug }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\">\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"reply-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if $reply.Title -}}\n <h3 class=\"reply-title reply-link-title\">\n {{- if $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Title }}</a>\n {{- else -}}\n {{ $reply.Title }}\n {{- end -}}\n </h3>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"reply-quote post-card-quote\">\n {{- with $reply.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $reply.Params.source_name $reply.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $reply.Params.source_name $reply.Params.source_url -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Params.source_name }}</a>\n {{- else if $reply.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $reply.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $reply.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $reply.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- else -}}\n {{- with $reply.Title -}}\n <h3 class=\"reply-title\">{{ . }}</h3>\n {{- end -}}\n {{- end -}}\n\n {{- if eq $format \"quote\" -}}\n {{- with $reply.Content -}}\n <div class=\"reply-body post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n <div class=\"reply-body prose\">\n {{ $reply.Content }}\n </div>\n {{- end -}}\n\n {{- /* Replies are not rendered as pages, so the address is the reply's alias on the root. */ -}}\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" ($reply.RelPermalink | default (printf \"/%s/\" $slug | relURL))) }}\n\n <footer class=\"reply-footer post-menu-footer\" data-post-meta>\n <div class=\"post-footer-meta\">\n <a class=\"post-footer-link reply-anchor\" href=\"#{{ $slug }}\">\n <time datetime=\"{{ $reply.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $reply.Date.Format \"Jan 2, 2006 · 15:04\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n </div>\n </footer>\n</article>\n";
5985
6334
  //#endregion
5986
6335
  //#region src/services/export-theme/layouts/partials/thread-preview.html?raw
5987
- var thread_preview_default = "{{- /*\n Thread preview partial.\n\n Matches the main site's `ThreadPreview` component, and the fold both it and\n the feed take from `lib/thread-fold.ts` (`foldThreadReplies`):\n root post (full card, as \"context\")\n the first THREAD_LEADING_REPLIES (2) (full cards, as \"context\")\n \"N more posts\" gap pill ← only when the fold hides any\n the two before the newest reply (full cards, as \"context\")\n newest reply (full card, as \"hero\")\n\n The trailing window is THREAD_TRAILING_REPLIES (3) with the hero inside it.\n On a short thread the windows overlap, so each list drops whatever an\n earlier one already shows, hero first — the same order the site dedupes in.\n\n Each entry is wrapped in a `.thread-item` so the rail dot markers\n line up the same way the main site does via `.thread-item::before`.\n\n When the root has no replies, falls through to a plain post-card.\n*/ -}}\n{{- $root := . -}}\n{{- $replies := where $root.Pages \"Params.visibility\" \"public\" -}}\n{{- $replies = $replies.ByDate -}}\n{{- $replyCount := len $replies -}}\n{{- if eq $replyCount 0 -}}\n {{ partial \"post-card.html\" $root }}\n{{- else -}}\n {{- $latest := index $replies (sub $replyCount 1) -}}\n {{- $leading := slice -}}\n {{- range first 2 $replies -}}\n {{- if ne .File.UniqueID $latest.File.UniqueID -}}\n {{- $leading = $leading | append . -}}\n {{- end -}}\n {{- end -}}\n {{- $trailing := slice -}}\n {{- range last 3 $replies -}}\n {{- $reply := . -}}\n {{- $shown := eq $reply.File.UniqueID $latest.File.UniqueID -}}\n {{- range $leading -}}\n {{- if eq .File.UniqueID $reply.File.UniqueID -}}{{- $shown = true -}}{{- end -}}\n {{- end -}}\n {{- if not $shown -}}{{- $trailing = $trailing | append $reply -}}{{- end -}}\n {{- end -}}\n\n {{- /* Whatever neither window shows is the hidden middle. It is never\n negative: the three lists are disjoint and all drawn from $replies. */ -}}\n {{- $hiddenCount := sub $replyCount (add (add (len $leading) (len $trailing)) 1) -}}\n {{- /* The gap leads to the first hidden reply, as the site's does. A fold\n only hides anything once both windows are full, so that reply sits\n right after the leading window. Replies are not rendered as pages\n (`build: render: never`), so their `.RelPermalink` is empty; the\n exporter registers every reply slug as an alias on the root, and\n that is the address a reply has. */ -}}\n {{- $gapHref := $root.RelPermalink -}}\n {{- if gt $hiddenCount 0 -}}{{- $gapHref = printf \"/%s/\" (index $replies 2).Slug | relURL -}}{{- end -}}\n\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n <article class=\"thread-preview\" data-slug=\"{{ $slug }}\" data-reply-count=\"{{ $replyCount }}\">\n <div class=\"thread-preview-context\">\n <div class=\"thread-item thread-item-context thread-item-root\">\n {{ partial \"post-card.html\" $root }}\n </div>\n\n {{- range $leading -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $gapHref }}\">\n {{ $hiddenCount }} more {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n\n {{- range $trailing -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n </div>\n\n <div class=\"thread-item thread-item-hero thread-preview-hero\">\n {{ partial \"post-card.html\" $latest }}\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread &rarr;\n </a>\n </div>\n </article>\n{{- end -}}\n";
6336
+ var thread_preview_default = "{{- /*\n Thread preview partial.\n\n Matches the main site's `ThreadPreview` component, and the fold both it and\n the feed take from `lib/thread-fold.ts` (`foldThreadReplies`):\n root post (full card, as \"context\")\n the first THREAD_LEADING_REPLIES (2) (full cards, as \"context\")\n \"N more posts\" gap pill ← only when the fold hides any\n the two before the newest reply (full cards, as \"context\")\n newest reply (full card, as \"hero\")\n\n The trailing window is THREAD_TRAILING_REPLIES (3) with the hero inside it.\n On a short thread the windows overlap, so each list drops whatever an\n earlier one already shows, hero first — the same order the site dedupes in.\n\n Each entry is wrapped in a `.thread-item` so the rail dot markers\n line up the same way the main site does via `.thread-item::before`.\n\n When the root has no replies, falls through to a plain post-card.\n*/ -}}\n{{- $root := . -}}\n{{- $replies := where $root.Pages \"Params.visibility\" \"public\" -}}\n{{- $replies = $replies.ByWeight -}}\n{{- $replyCount := len $replies -}}\n{{- if eq $replyCount 0 -}}\n {{ partial \"post-card.html\" $root }}\n{{- else -}}\n {{- $latest := index $replies (sub $replyCount 1) -}}\n {{- $leading := slice -}}\n {{- range first 2 $replies -}}\n {{- if ne .File.UniqueID $latest.File.UniqueID -}}\n {{- $leading = $leading | append . -}}\n {{- end -}}\n {{- end -}}\n {{- $trailing := slice -}}\n {{- range last 3 $replies -}}\n {{- $reply := . -}}\n {{- $shown := eq $reply.File.UniqueID $latest.File.UniqueID -}}\n {{- range $leading -}}\n {{- if eq .File.UniqueID $reply.File.UniqueID -}}{{- $shown = true -}}{{- end -}}\n {{- end -}}\n {{- if not $shown -}}{{- $trailing = $trailing | append $reply -}}{{- end -}}\n {{- end -}}\n\n {{- /* Whatever neither window shows is the hidden middle. It is never\n negative: the three lists are disjoint and all drawn from $replies. */ -}}\n {{- $hiddenCount := sub $replyCount (add (add (len $leading) (len $trailing)) 1) -}}\n {{- /* The gap leads to the first hidden reply, as the site's does. A fold\n only hides anything once both windows are full, so that reply sits\n right after the leading window. Replies are not rendered as pages\n (`build: render: never`), so their `.RelPermalink` is empty; the\n exporter registers every reply slug as an alias on the root, and\n that is the address a reply has. */ -}}\n {{- $gapHref := $root.RelPermalink -}}\n {{- if gt $hiddenCount 0 -}}{{- $gapHref = printf \"/%s/\" (index $replies 2).Slug | relURL -}}{{- end -}}\n\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n <article class=\"thread-preview\" data-slug=\"{{ $slug }}\" data-reply-count=\"{{ $replyCount }}\">\n <div class=\"thread-preview-context\">\n <div class=\"thread-item thread-item-context thread-item-root\">\n {{ partial \"post-card.html\" $root }}\n </div>\n\n {{- range $leading -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $gapHref }}\">\n {{ $hiddenCount }} more {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n\n {{- range $trailing -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n </div>\n\n <div class=\"thread-item thread-item-hero thread-preview-hero\">\n {{ partial \"post-card.html\" $latest }}\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread &rarr;\n </a>\n </div>\n </article>\n{{- end -}}\n";
5988
6337
  //#endregion
5989
6338
  //#region src/services/export-theme/layouts/partials/featured-thread.html?raw
5990
- var featured_thread_default = "{{- /*\n Curated Featured Thread.\n\n The Root carries a derived `featured_post_ids` projection. Render the Root,\n every selected Featured Post, and the final Post once, preserving the real\n Thread positions so omitted runs can be represented as gap links.\n*/ -}}\n{{- $root := . -}}\n{{- $featuredIds := $root.Params.featured_post_ids | default slice -}}\n{{- $posts := slice $root -}}\n{{- range $root.Pages.ByDate -}}\n {{- $posts = $posts | append . -}}\n{{- end -}}\n{{- $lastIndex := sub (len $posts) 1 -}}\n{{- $visible := slice -}}\n\n{{- range $index, $post := $posts -}}\n {{- $postId := string ($post.Params.id | default \"\") -}}\n {{- $highlighted := in $featuredIds $postId -}}\n {{- if or (eq $index 0) $highlighted (eq $index $lastIndex) -}}\n {{- $visible = $visible | append (dict\n \"post\" $post\n \"position\" $index\n \"highlighted\" $highlighted\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- if eq (len $visible) 1 -}}\n {{- partial \"post-card.html\" $root -}}\n{{- else -}}\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n {{- $previousPosition := -1 -}}\n <article class=\"thread-preview thread-preview-curated\" data-slug=\"{{ $slug }}\">\n <div class=\"thread-preview-context\">\n {{- range $entry := $visible -}}\n {{- $position := int $entry.position -}}\n {{- $hiddenCount := sub $position (add $previousPosition 1) -}}\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $root.RelPermalink }}\">\n {{ $hiddenCount }} hidden {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n <div class=\"thread-item {{ if $entry.highlighted }}thread-item-featured{{ else }}thread-item-context{{ end }}\">\n {{ partial \"post-card.html\" $entry.post }}\n </div>\n {{- $previousPosition = $position -}}\n {{- end -}}\n </div>\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread &rarr;\n </a>\n </article>\n{{- end -}}\n";
6339
+ var featured_thread_default = "{{- /*\n Curated Featured Thread.\n\n The Root carries a derived `featured_post_ids` projection. Render the Root,\n every selected Featured Post, and the final Post once, preserving the real\n Thread positions so omitted runs can be represented as gap links.\n*/ -}}\n{{- $root := . -}}\n{{- $featuredIds := $root.Params.featured_post_ids | default slice -}}\n{{- $posts := slice $root -}}\n{{- range $root.Pages.ByWeight -}}\n {{- $posts = $posts | append . -}}\n{{- end -}}\n{{- $lastIndex := sub (len $posts) 1 -}}\n{{- $visible := slice -}}\n\n{{- range $index, $post := $posts -}}\n {{- $postId := string ($post.Params.id | default \"\") -}}\n {{- $highlighted := in $featuredIds $postId -}}\n {{- if or (eq $index 0) $highlighted (eq $index $lastIndex) -}}\n {{- $visible = $visible | append (dict\n \"post\" $post\n \"position\" $index\n \"highlighted\" $highlighted\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- if eq (len $visible) 1 -}}\n {{- partial \"post-card.html\" $root -}}\n{{- else -}}\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n {{- $previousPosition := -1 -}}\n <article class=\"thread-preview thread-preview-curated\" data-slug=\"{{ $slug }}\">\n <div class=\"thread-preview-context\">\n {{- range $entry := $visible -}}\n {{- $position := int $entry.position -}}\n {{- $hiddenCount := sub $position (add $previousPosition 1) -}}\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $root.RelPermalink }}\">\n {{ $hiddenCount }} hidden {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n <div class=\"thread-item {{ if $entry.highlighted }}thread-item-featured{{ else }}thread-item-context{{ end }}\">\n {{ partial \"post-card.html\" $entry.post }}\n </div>\n {{- $previousPosition = $position -}}\n {{- end -}}\n </div>\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread &rarr;\n </a>\n </article>\n{{- end -}}\n";
6340
+ //#endregion
6341
+ //#region src/services/export-theme/layouts/partials/smart-collection-members.html?raw
6342
+ var smart_collection_members_default = "{{- /*\n The Thread roots a smart collection's conditions select, in its order.\n\n The same set and order Jant shows a signed-out reader on the smart\n collection's page (`smartCollections.toPostFilters` handed to\n `posts.list`), computed from the exported front matter so the page keeps\n up with the posts in this repository. Every condition reads the root:\n\n format `format`\n title `title`; for a quote, `source_name`, which is what Jant\n stores in the title column\n year `date`, the publication time, in UTC\n media the root's own `media` entries: `any`, `none`, or a list of\n kinds, any one of which matches\n replies whether the root has published replies\n visibility `public` and `latest_hidden` match `visibility`; `featured`\n matches a root with `featured_at`\n collection a `collections` entry with that slug\n\n Drafts and private roots are exported as Hugo drafts and never reach here;\n they are checked again so a `--buildDrafts` preview agrees with Jant.\n\n Takes a dict: `page`, the smart collection's section page, and `feed`,\n true for its Atom feed.\n\n Orders, each ending on the post ID so no two roots tie:\n newest Thread activity (`last_activity_at`, else `date`), newest first\n oldest publication, oldest first\n rating_desc rated before unrated, higher rating first, then as newest.\n On the page, fewer than two rated roots fall back to\n newest, as Jant's page does; the feed keeps the stored\n order, as Jant's feed does.\n\n `src/__tests__/export-smart-collection.test.ts` builds a site with Hugo\n and compares every condition and order against Jant's own list.\n*/ -}}\n{{- $page := .page -}}\n{{- $feed := .feed -}}\n{{- $selection := $page.Params.selection | default dict -}}\n{{- $roots := where (where site.Pages \"Type\" \"post\") \"Kind\" \"section\" -}}\n{{- $members := slice -}}\n{{- range $roots -}}\n {{- $post := . -}}\n {{- $params := .Params -}}\n {{- $matches := and (eq ($params.status | default \"published\") \"published\") (ne $params.visibility \"private\") -}}\n {{- if and $matches (isset $selection \"format\") -}}\n {{- $matches = eq $params.format $selection.format -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"title\") -}}\n {{- $title := $params.title -}}\n {{- if eq $params.format \"quote\" -}}{{- $title = $params.source_name -}}{{- end -}}\n {{- $matches = eq (gt (len (string ($title | default \"\"))) 0) $selection.title -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"year\") -}}\n {{- $matches = eq $post.Date.UTC.Year (int $selection.year) -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"media\") -}}\n {{- $media := $params.media | default slice -}}\n {{- $wanted := $selection.media -}}\n {{- if reflect.IsSlice $wanted -}}\n {{- $found := false -}}\n {{- range $media -}}{{- if in $wanted .kind -}}{{- $found = true -}}{{- end -}}{{- end -}}\n {{- $matches = $found -}}\n {{- else if eq $wanted \"any\" -}}\n {{- $matches = gt (len $media) 0 -}}\n {{- else -}}\n {{- $matches = eq (len $media) 0 -}}\n {{- end -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"replies\") -}}\n {{- $matches = eq (gt (len $post.Pages) 0) $selection.replies -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"visibility\") -}}\n {{- if eq $selection.visibility \"featured\" -}}\n {{- $matches = ne ($params.featured_at | default \"\") \"\" -}}\n {{- else -}}\n {{- $matches = eq $params.visibility $selection.visibility -}}\n {{- end -}}\n {{- end -}}\n {{- if and $matches (isset $selection \"collection\") -}}\n {{- $inCollection := false -}}\n {{- range $params.collections -}}\n {{- if eq .slug $selection.collection -}}{{- $inCollection = true -}}{{- end -}}\n {{- end -}}\n {{- $matches = $inCollection -}}\n {{- end -}}\n\n {{- if $matches -}}\n {{- $activity := $post.Date.Unix -}}\n {{- with $params.last_activity_at -}}{{- $activity = (time.AsTime .).Unix -}}{{- end -}}\n {{- $rated := ne $params.rating nil -}}\n {{- $rating := 0 -}}\n {{- if $rated -}}{{- $rating = int $params.rating -}}{{- end -}}\n {{- $members = $members | append (dict\n \"post\" $post\n \"id\" $params.id\n \"published\" $post.Date.Unix\n \"activity\" $activity\n \"rated\" $rated\n \"rating\" $rating\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- $order := $page.Params.sort_order | default \"newest\" -}}\n{{- if and (not $feed) (eq $order \"rating_desc\") (le (len (where $members \"rated\" true)) 1) -}}\n {{- $order = \"newest\" -}}\n{{- end -}}\n\n{{- /* One string key per root, fixed-width numbers first, so a single sort\n orders by every key in turn. */ -}}\n{{- $keyed := slice -}}\n{{- range $members -}}\n {{- $key := printf \"%020d %s\" .activity .id -}}\n {{- if eq $order \"oldest\" -}}\n {{- $key = printf \"%020d %s\" .published .id -}}\n {{- else if eq $order \"rating_desc\" -}}\n {{- $key = printf \"%d %02d %020d %s\" (cond .rated 1 0) .rating .activity .id -}}\n {{- end -}}\n {{- $keyed = $keyed | append (dict \"key\" $key \"post\" .post) -}}\n{{- end -}}\n\n{{- $ordered := slice -}}\n{{- if $keyed -}}\n {{- range sort $keyed \"key\" (cond (eq $order \"oldest\") \"asc\" \"desc\") -}}\n {{- $ordered = $ordered | append .post -}}\n {{- end -}}\n{{- end -}}\n{{- return $ordered -}}\n";
6343
+ //#endregion
6344
+ //#region src/services/export-theme/layouts/partials/collection-threads.html?raw
6345
+ var collection_threads_default = "{{- /*\n A collection page's Threads, each root with its replies in full, then the\n pagination. Shared by manual and smart collections, which look the same to\n a reader.\n\n Takes a dict: `paginator`, and `empty`, the text shown when there are no\n Threads.\n*/ -}}\n{{- $paginator := .paginator -}}\n{{- if $paginator.Pages -}}\n <div class=\"post-list\">\n {{- range $i, $p := $paginator.Pages -}}\n {{- if gt $i 0 -}}<hr class=\"feed-divider\" aria-hidden=\"true\" />{{- end -}}\n {{- $replies := $p.Pages.ByWeight -}}\n {{- $hasReplies := gt (len $replies) 0 -}}\n <div class=\"thread thread-full{{ if $hasReplies }} thread-has-replies{{ end }}\" data-slug=\"{{ $p.Params.slug | default $p.Slug }}\">\n <div class=\"thread-item thread-item-root\">\n {{ partial \"post-card.html\" $p }}\n </div>\n {{- if $hasReplies -}}\n <section class=\"thread-replies\" aria-label=\"Replies\">\n {{- range $replies -}}\n {{ partial \"reply.html\" . }}\n {{- end -}}\n </section>\n {{- end -}}\n </div>\n {{- end -}}\n </div>\n {{ partial \"pagination.html\" $paginator }}\n{{- else -}}\n <p class=\"empty-state\">{{ .empty }}</p>\n{{- end -}}\n";
6346
+ //#endregion
6347
+ //#region src/services/export-theme/layouts/partials/collection-members.html?raw
6348
+ var collection_members_default = "{{- /*\n The Thread roots in a collection, in the order Jant lists them: the page\n (`listCollectionThreadRootIdsForCollections` in `services/post.ts`) or the\n feed (`listCollectionFeedEntriesForCollections`).\n\n Takes a dict: `page`, the collection's section page, and `feed`, true for\n its Atom feed.\n\n A Thread belongs when its root lists the collection in `collections`.\n Drafts and private roots are exported as Hugo drafts and never reach here;\n they are checked again so a `--buildDrafts` preview agrees with Jant.\n\n The page puts Threads pinned in this collection first, most recently\n pinned first, then orders by `sort_order`, each order ending on the root\n ID so no two Threads tie:\n newest Thread activity (`last_activity_at`, else `date`), newest first\n oldest the Thread's earliest published post, oldest first\n rating_desc Threads with a rating before those without, by the highest\n rating on any published post in the Thread, then as newest.\n With fewer than two rated Threads it falls back to newest,\n as Jant's page does.\n The feed ignores pins and `sort_order`: Thread activity, newest first.\n\n `src/__tests__/export-collection-order.test.ts` builds a site with Hugo\n and compares the page and the feed with Jant's.\n*/ -}}\n{{- $page := .page -}}\n{{- $feed := .feed -}}\n{{- $slug := $page.Params.slug | default $page.Slug -}}\n{{- $roots := where (where site.Pages \"Type\" \"post\") \"Kind\" \"section\" -}}\n{{- $members := slice -}}\n{{- range $roots -}}\n {{- $post := . -}}\n {{- $params := .Params -}}\n {{- $entry := false -}}\n {{- range $params.collections -}}\n {{- if eq .slug $slug -}}{{- $entry = . -}}{{- end -}}\n {{- end -}}\n {{- $visible := and (eq ($params.status | default \"published\") \"published\") (ne $params.visibility \"private\") -}}\n {{- if and $entry $visible -}}\n {{- $pinned := 0 -}}\n {{- with $entry.pinned_at -}}{{- $pinned = (time.AsTime .).Unix -}}{{- end -}}\n {{- $activity := $post.Date.Unix -}}\n {{- with $params.last_activity_at -}}{{- $activity = (time.AsTime .).Unix -}}{{- end -}}\n {{- $first := $post.Date.Unix -}}\n {{- $rated := ne $params.rating nil -}}\n {{- $rating := 0 -}}\n {{- if $rated -}}{{- $rating = int $params.rating -}}{{- end -}}\n {{- range $post.Pages -}}\n {{- if eq (.Params.status | default \"published\") \"published\" -}}\n {{- if lt .Date.Unix $first -}}{{- $first = .Date.Unix -}}{{- end -}}\n {{- if ne .Params.rating nil -}}\n {{- $rated = true -}}\n {{- if gt (int .Params.rating) $rating -}}{{- $rating = int .Params.rating -}}{{- end -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $members = $members | append (dict\n \"post\" $post\n \"id\" $params.id\n \"pinned\" (cond $feed 0 $pinned)\n \"first\" $first\n \"activity\" $activity\n \"rated\" $rated\n \"rating\" $rating\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- $order := \"newest\" -}}\n{{- if not $feed -}}\n {{- $order = $page.Params.sort_order | default \"newest\" -}}\n {{- if and (eq $order \"rating_desc\") (le (len (where $members \"rated\" true)) 1) -}}\n {{- $order = \"newest\" -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* One string key per Thread, fixed-width numbers first, so a single sort\n orders by every key in turn. `oldest` sorts ascending, so its pin rank\n counts down: the most recently pinned Thread gets the smallest. */ -}}\n{{- $unpinnedRank := 99999999999 -}}\n{{- $keyed := slice -}}\n{{- range $members -}}\n {{- $key := printf \"%020d %020d %s\" .pinned .activity .id -}}\n {{- if eq $order \"oldest\" -}}\n {{- $pinRank := cond (gt .pinned 0) (sub $unpinnedRank .pinned) $unpinnedRank -}}\n {{- $key = printf \"%020d %020d %s\" $pinRank .first .id -}}\n {{- else if eq $order \"rating_desc\" -}}\n {{- $key = printf \"%020d %d %02d %020d %s\" .pinned (cond .rated 1 0) .rating .activity .id -}}\n {{- end -}}\n {{- $keyed = $keyed | append (dict \"key\" $key \"post\" .post) -}}\n{{- end -}}\n\n{{- $ordered := slice -}}\n{{- if $keyed -}}\n {{- range sort $keyed \"key\" (cond (eq $order \"oldest\") \"asc\" \"desc\") -}}\n {{- $ordered = $ordered | append .post -}}\n {{- end -}}\n{{- end -}}\n{{- return $ordered -}}\n";
6349
+ //#endregion
6350
+ //#region src/services/export-theme/layouts/partials/latest-members.html?raw
6351
+ var latest_members_default = "{{- /*\n The Thread roots on Latest, in the order Jant lists them: the home page\n (`assembleTimeline`, `posts.list`) or `/latest/feed`\n (`latestFeedSelection` with `ignorePinnedSort`).\n\n Takes a dict: `feed`, true for the Atom feed.\n\n A root is on Latest when it is published and public: Hidden from Latest\n and private roots are left out, as are drafts, which are exported as Hugo\n drafts and never reach here; status and visibility are checked again so a\n `--buildDrafts` preview agrees with Jant.\n\n Order, each key after the one before it:\n pinned page only: pinned roots first, most recently pinned first\n activity Thread activity (`last_activity_at`, else `date`), newest\n first, so a Thread that gains a reply comes back up; a quiet\n reply doesn't move it, because Jant doesn't count it\n ID newest first, so no two roots tie\n\n `src/__tests__/export-feed-order.test.ts` builds a site with Hugo and\n compares the page and the feed with Jant's.\n*/ -}}\n{{- $feed := .feed -}}\n{{- $roots := where (where site.Pages \"Type\" \"post\") \"Kind\" \"section\" -}}\n{{- $keyed := slice -}}\n{{- range $roots -}}\n {{- $params := .Params -}}\n {{- if and (eq ($params.status | default \"published\") \"published\") (eq $params.visibility \"public\") -}}\n {{- $pinned := 0 -}}\n {{- if not $feed -}}\n {{- with $params.pinned_at -}}{{- $pinned = (time.AsTime .).Unix -}}{{- end -}}\n {{- end -}}\n {{- $activity := .Date.Unix -}}\n {{- with $params.last_activity_at -}}{{- $activity = (time.AsTime .).Unix -}}{{- end -}}\n {{- $keyed = $keyed | append (dict\n \"key\" (printf \"%020d %020d %s\" $pinned $activity $params.id)\n \"post\" .\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- $ordered := slice -}}\n{{- if $keyed -}}\n {{- range sort $keyed \"key\" \"desc\" -}}\n {{- $ordered = $ordered | append .post -}}\n {{- end -}}\n{{- end -}}\n{{- return $ordered -}}\n";
6352
+ //#endregion
6353
+ //#region src/services/export-theme/layouts/partials/featured-members.html?raw
6354
+ var featured_members_default = "{{- /*\n The Threads on Featured, in the order Jant lists them on the Featured page\n and in `/featured/feed` (`listFeaturedThreadRootIds`): a Thread belongs\n when any published post in it is featured and it is not private. Hidden\n from Latest is no bar here.\n\n Order: the newest publication among the Thread's featured posts\n (`featured_sort_at`, which the export derives exactly that way), newest\n first, then the root ID, newest first, so Threads whose featured posts\n share a second don't tie.\n\n `src/__tests__/export-feed-order.test.ts` builds a site with Hugo and\n compares the page and the feed with Jant's.\n*/ -}}\n{{- $roots := where (where site.Pages \"Type\" \"post\") \"Kind\" \"section\" -}}\n{{- $keyed := slice -}}\n{{- range $roots -}}\n {{- $post := . -}}\n {{- $params := .Params -}}\n {{- $visible := and (eq ($params.status | default \"published\") \"published\") (ne $params.visibility \"private\") -}}\n {{- if and $visible $params.featured_sort_at -}}\n {{- $keyed = $keyed | append (dict\n \"key\" (printf \"%020d %s\" (time.AsTime $params.featured_sort_at).Unix $params.id)\n \"post\" $post\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- $ordered := slice -}}\n{{- if $keyed -}}\n {{- range sort $keyed \"key\" \"desc\" -}}\n {{- $ordered = $ordered | append .post -}}\n {{- end -}}\n{{- end -}}\n{{- return $ordered -}}\n";
5991
6355
  //#endregion
5992
6356
  //#region src/services/export-theme/layouts/_default/rss.xml?raw
5993
- var rss_default = "{{- /*\n Atom 2005 feed template — mirrors the main site's `lib/feed.ts`.\n\n One template covers every page that opts into the custom `RSS` output\n format (home, featured, archive, and each collection section). Root-post\n sections do NOT opt in, so no `/{slug}/index.xml` gets emitted.\n\n Feed filter parity with the corresponding HTML list view:\n - home (`.Kind == \"home\"`): visibility == \"public\" (drops latest_hidden)\n - featured (`.Type == \"featured\"`): one Root per Thread whose derived\n featured projection is non-empty, ordered by selected Post publication\n - archive (`.Type == \"archive\"`): all published (includes latest_hidden)\n - collection (`.Type == \"collection\"`): Params.collections.slug match,\n ordered per `sort_order`; pinning is ignored for RSS\n\n Per-entry payload mirrors `buildSinglePostContent` + `buildFeedContent`:\n - Title: empty for quote posts, else .Title\n - Link rel=alternate: external URL for `link` format, else permalink;\n link-format posts also get a <link rel=\"related\"> back to the\n permalink\n - Content (CDATA):\n quote → <blockquote cite><p>{quote}</p></blockquote>\n + <p>— <a href=url>{source}</a></p>\n body → Hugo-rendered .Content (stripped of <script>/<style>)\n rating → <p>★★★★☆ 4/5</p>\n link → trailing <p><a href=permalink> ★ </a></p>\n replies → <hr/> + <p><small><time>{dt}</time></small></p>\n + inline title/link metadata + content\n\n Hugo's default RSS media-type override (`[outputFormats.RSS]`) makes this\n template produce application/atom+xml at /{section}/index.xml.\n*/ -}}\n{{- $page := . -}}\n{{- $limit := int (.Site.Params.rss_feed_limit | default 50) -}}\n{{- $allPosts := where .Site.Pages \"Type\" \"post\" -}}\n{{- $rootPosts := where $allPosts \"Kind\" \"section\" -}}\n\n{{- /* ------------------------------------------------------------------\n Post selection per feed kind\n ------------------------------------------------------------------ */ -}}\n{{- $posts := slice -}}\n{{- $isFeatured := eq $page.Type \"featured\" -}}\n\n{{- if eq $page.Kind \"home\" -}}\n {{- $public := where $allPosts \"Params.visibility\" \"public\" -}}\n {{- $posts = $public.ByDate.Reverse -}}\n{{- else if $isFeatured -}}\n {{- $featured := where $rootPosts \"Params.featured_sort_at\" \"ne\" nil -}}\n {{- $posts = sort $featured \"Params.featured_sort_at\" \"desc\" -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $posts = $allPosts.ByDate.Reverse -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $collectionSlug := $page.Params.slug | default $page.Slug -}}\n {{- $members := slice -}}\n {{- range $rootPosts -}}\n {{- $post := . -}}\n {{- with $post.Params.collections -}}\n {{- range . -}}\n {{- if eq .slug $collectionSlug -}}\n {{- $members = $members | append (dict \"post\" $post \"entry\" .) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $sortOrder := $page.Params.sort_order | default \"position\" -}}\n {{- $ordered := $members -}}\n {{- if $ordered -}}\n {{- if eq $sortOrder \"collected_at_desc\" -}}\n {{- $ordered = sort $ordered \"entry.collected_at\" \"desc\" -}}\n {{- else -}}\n {{- $ordered = sort $ordered \"entry.position\" \"asc\" -}}\n {{- end -}}\n {{- end -}}\n {{- range $ordered -}}{{- $posts = $posts | append .post -}}{{- end -}}\n{{- end -}}\n\n{{- if gt (len $posts) $limit -}}\n {{- $posts = first $limit $posts -}}\n{{- end -}}\n\n{{- /* ------------------------------------------------------------------\n Feed title (mirrors the main site's Atom feed titles)\n ------------------------------------------------------------------ */ -}}\n{{- $feedTitle := .Site.Title -}}\n{{- if eq $page.Kind \"home\" -}}\n {{- $feedTitle = printf \"%s - Latest posts\" .Site.Title -}}\n{{- else if $isFeatured -}}\n {{- $feedTitle = printf \"%s - Featured posts\" .Site.Title -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $feedTitle = printf \"%s - Archive\" .Site.Title -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $feedTitle = printf \"%s - %s\" .Site.Title $page.Title -}}\n{{- end -}}\n\n{{- $siteDescription := .Site.Params.description | default \"\" -}}\n{{- $siteBase := .Site.BaseURL -}}\n{{- $selfUrl := .Permalink -}}\n{{- $now := now.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{{- /* Namespaces ride along only when something in them is emitted, the same\n rule `lib/feed.ts` follows. */ -}}\n{{- $anyMedia := false -}}\n{{- range $posts -}}\n {{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}\n {{- range .Pages -}}{{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}{{- end -}}\n{{- end -}}\n{{- $jantNs := cond (gt (len $posts) 0) ` xmlns:jant=\"https://jant.me/ns\"` \"\" -}}\n{{- $mediaNs := cond $anyMedia ` xmlns:media=\"http://search.yahoo.com/mrss/\"` \"\" -}}\n<feed xmlns=\"http://www.w3.org/2005/Atom\"{{ $jantNs | safeHTMLAttr }}{{ $mediaNs | safeHTMLAttr }}>\n <title>{{ $feedTitle | transform.XMLEscape }}</title>\n <subtitle>{{ $siteDescription | transform.XMLEscape }}</subtitle>\n <link href=\"{{ $siteBase | transform.XMLEscape }}\" rel=\"alternate\"/>\n <link href=\"{{ $selfUrl | transform.XMLEscape }}\" rel=\"self\"/>\n <id>{{ $selfUrl | transform.XMLEscape }}</id>\n <updated>{{ $now }}</updated>\n {{- range $posts }}\n {{- $post := . -}}\n {{- $format := .Params.format | default \"note\" -}}\n {{- $permalink := .Permalink -}}\n {{- $isLink := and (eq $format \"link\") .Params.link_url -}}\n {{- $alternate := cond $isLink (string .Params.link_url) $permalink -}}\n {{- /* Atom <title>: empty for quotes (matches main site's getAtomTitle). */ -}}\n {{- $entryTitle := cond (eq $format \"quote\") \"\" (.Title | default \"\") -}}\n {{- /* <published>: from `date` frontmatter (= publishedAt).\n <updated>: prefer `last_activity_at` (thread-bumped), then `updated`\n (root edit time), else `date`. Featured membership does not rewrite\n content time or chronological ordering. */ -}}\n {{- $published := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- $updatedSrc := .Date -}}\n {{- with .Params.last_activity_at -}}{{- $updatedSrc = time . -}}\n {{- else -}}{{- with .Params.updated -}}{{- $updatedSrc = time . -}}{{- end -}}{{- end -}}\n {{- $updated := $updatedSrc.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Plain-text summary fallback chain (mirrors getFeedSummaryText). */ -}}\n {{- $summaryText := \"\" -}}\n {{- if eq $format \"quote\" -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (string (.Params.quote_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) (string (.Params.source_url | default \"\")) -}}\n {{- else -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) -}}\n {{- end -}}\n {{- if eq $summaryText \"\" -}}{{- $summaryText = printf \"Post %s\" (string (.Params.id | default \"\")) -}}{{- end -}}\n\n {{- /* Whether the timeline cut the text of any post the entry's card shows.\n Per post it is written into front matter by the exporter, which shares\n the boundary with the served feed; Hugo's own summary rule would cut\n somewhere else. Per entry the rule is `isEntryTruncated` in\n `lib/feed.ts`: the root plus every reply the fold keeps — the first\n THREAD_LEADING_REPLIES (2) and the last THREAD_TRAILING_REPLIES (3),\n newest included — as `lib/thread-fold.ts` defines it. A thread of five\n replies or fewer is shown whole; `first` and `last` clamp to the slice,\n so the two windows simply overlap there. */ -}}\n {{- $replies := (where $post.Pages \"Params.visibility\" \"public\").ByDate -}}\n {{- $truncated := $post.Params.truncated | default false -}}\n {{- range first 2 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n {{- range last 3 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n\n {{- /* Every attachment in the thread, each paired with the post that carries\n it — a text attachment's page lives under its own post. */ -}}\n {{- $entryMedia := slice -}}\n {{- range ($post.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $permalink) -}}\n {{- end -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- range (.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $replyPermalink) -}}\n {{- end -}}\n {{- end -}}\n <entry>\n <title>{{ $entryTitle | transform.XMLEscape }}</title>\n <link href=\"{{ $alternate | transform.XMLEscape }}\" rel=\"alternate\"/>\n {{- if $isLink }}\n <link href=\"{{ $permalink | transform.XMLEscape }}\" rel=\"related\"/>\n {{- end }}\n {{- /* Images carry no enclosure: the content already shows them full size\n inside a link to the original, so one would only ask an attachment\n shelf to list the picture again. */ -}}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- if ne (string ($m.kind | default \"document\")) \"image\" }}\n <link rel=\"enclosure\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" href=\"{{ (string $m.src) | absURL | transform.XMLEscape }}\"{{ with $m.size }} length=\"{{ . }}\"{{ end }}{{ with $m.original_name }} title=\"{{ . | transform.XMLEscape }}\"{{ end }}/>\n {{- end }}\n {{- end }}\n <id>{{ $permalink | transform.XMLEscape }}</id>\n <published>{{ $published }}</published>\n <updated>{{ $updated }}</updated>\n <jant:format>{{ $format | transform.XMLEscape }}</jant:format>\n {{- if $truncated }}\n <jant:truncated/>\n {{- end }}\n {{- /* A collection is a label the author chose, which is what <category>\n is for. `jant:page` carries the URL because a single collection lives\n in the root URL namespace. */ -}}\n {{- range ($post.Params.collections | default slice) }}\n <category term=\"{{ (string .slug) | transform.XMLEscape }}\" label=\"{{ (string (.title | default .slug)) | transform.XMLEscape }}\" jant:page=\"{{ (printf \"/%s/\" (string .slug)) | absURL | transform.XMLEscape }}\"/>\n {{- end }}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- $kind := string ($m.kind | default \"document\") }}\n {{- $medium := cond (or (eq $kind \"image\") (or (eq $kind \"video\") (eq $kind \"audio\"))) $kind \"document\" }}\n {{- $fileUrl := (string $m.src) | absURL }}\n {{- /* Where a click should land. Only emitted where it is not the file:\n a text attachment's markdown would download or dump unstyled. */ -}}\n {{- $pageUrl := $fileUrl }}\n {{- if eq $kind \"text\" }}{{ $pageUrl = printf \"%s/text/%s\" (strings.TrimSuffix \"/\" .page) (string $m.id) }}{{ end }}\n {{- $desc := string ($m.alt | default ($m.summary | default \"\")) }}\n {{- $poster := string ($m.poster | default \"\") }}\n <media:content url=\"{{ $fileUrl | transform.XMLEscape }}\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" medium=\"{{ $medium }}\"{{ with $m.size }} fileSize=\"{{ . }}\"{{ end }}{{ with $m.width }} width=\"{{ . }}\"{{ end }}{{ with $m.height }} height=\"{{ . }}\"{{ end }}{{ with $m.duration_seconds }} duration=\"{{ math.Round . }}\"{{ end }}{{ if ne $pageUrl $fileUrl }} jant:page=\"{{ $pageUrl | transform.XMLEscape }}\"{{ end }}>\n {{- with $m.original_name }}<media:title type=\"plain\">{{ . | transform.XMLEscape }}</media:title>{{ end }}\n {{- if ne $desc \"\" }}<media:description type=\"plain\">{{ $desc | transform.XMLEscape }}</media:description>{{ end }}\n {{- if ne $poster \"\" }}<media:thumbnail url=\"{{ $poster | absURL | transform.XMLEscape }}\"/>{{ end -}}\n </media:content>\n {{- end }}\n <summary type=\"text\">{{ $summaryText | transform.XMLEscape }}</summary>\n <content type=\"html\"><![CDATA[\n{{- partial \"feed-post-content.xml\" (dict \"post\" $post \"permalink\" $permalink \"includeReplies\" true) -}}\n]]></content>\n </entry>\n {{- end }}\n</feed>\n";
6357
+ var rss_default = "{{- /*\n Atom 2005 feed template — mirrors the main site's `lib/feed.ts`.\n\n One template covers every page that opts into the custom `RSS` output\n format (home, featured, archive, and each collection and smart collection\n section). Root-post sections do NOT opt in, so no `/{slug}/index.xml` gets\n emitted.\n\n Each feed carries what the matching Jant feed carries, in its order:\n - home (`.Kind == \"home\"`): `/latest/feed`, public roots by Thread\n activity, pins ignored (`latest-members`)\n - featured (`.Type == \"featured\"`): one root per Thread with a featured\n post, by that post's publication (`featured-members`)\n - archive (`.Type == \"archive\"`): all published (includes latest_hidden)\n - collection (`.Type == \"collection\"`): its Threads by activity, newest\n first, pins and `sort_order` ignored (`collection-members`)\n - smart collection (`.Type == \"smart_collection\"`): the roots its\n conditions select, in its stored order (`smart-collection-members`)\n\n Per-entry payload mirrors `buildSinglePostContent` + `buildFeedContent`:\n - Id: `feed_id` from front matter, the id Jant's feed used; see below\n - Title: empty for quote posts, else .Title\n - Link rel=alternate: external URL for `link` format, else permalink;\n link-format posts also get a <link rel=\"related\"> back to the\n permalink\n - Content (CDATA):\n quote → <blockquote cite><p>{quote}</p></blockquote>\n + <p>— <a href=url>{source}</a></p>\n body → Hugo-rendered .Content (stripped of <script>/<style>)\n rating → <p>★★★★☆ 4/5</p>\n link → trailing <p><a href=permalink> ★ </a></p>\n replies → <hr/> + <p><small><time>{dt}</time></small></p>\n + inline title/link metadata + content\n\n Hugo's default RSS media-type override (`[outputFormats.RSS]`) makes this\n template produce application/atom+xml at /{section}/index.xml.\n*/ -}}\n{{- $page := . -}}\n{{- $limit := int (.Site.Params.rss_feed_limit | default 50) -}}\n{{- $allPosts := where .Site.Pages \"Type\" \"post\" -}}\n\n{{- /* ------------------------------------------------------------------\n Post selection per feed kind\n ------------------------------------------------------------------ */ -}}\n{{- $posts := slice -}}\n{{- $isFeatured := eq $page.Type \"featured\" -}}\n\n{{- if eq $page.Kind \"home\" -}}\n {{- $posts = partial \"latest-members.html\" (dict \"feed\" true) -}}\n{{- else if $isFeatured -}}\n {{- $posts = partial \"featured-members.html\" . -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $posts = $allPosts.ByDate.Reverse -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $posts = partial \"collection-members.html\" (dict \"page\" $page \"feed\" true) -}}\n{{- else if eq $page.Type \"smart_collection\" -}}\n {{- $posts = partial \"smart-collection-members.html\" (dict \"page\" $page \"feed\" true) -}}\n{{- end -}}\n\n{{- if gt (len $posts) $limit -}}\n {{- $posts = first $limit $posts -}}\n{{- end -}}\n\n{{- /* ------------------------------------------------------------------\n Feed title (mirrors the main site's Atom feed titles)\n ------------------------------------------------------------------ */ -}}\n{{- $feedTitle := .Site.Title -}}\n{{- if eq $page.Kind \"home\" -}}\n {{- $feedTitle = printf \"%s - Latest posts\" .Site.Title -}}\n{{- else if $isFeatured -}}\n {{- $feedTitle = printf \"%s - Featured posts\" .Site.Title -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $feedTitle = printf \"%s - Archive\" .Site.Title -}}\n{{- else if in (slice \"collection\" \"smart_collection\") $page.Type -}}\n {{- $feedTitle = printf \"%s - %s\" .Site.Title $page.Title -}}\n{{- end -}}\n\n{{- $siteDescription := .Site.Params.description | default \"\" -}}\n{{- $siteBase := .Site.BaseURL -}}\n{{- $selfUrl := .Permalink -}}\n{{- $now := now.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{{- /* Namespaces ride along only when something in them is emitted, the same\n rule `lib/feed.ts` follows. */ -}}\n{{- $anyMedia := false -}}\n{{- range $posts -}}\n {{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}\n {{- range .Pages -}}{{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}{{- end -}}\n{{- end -}}\n{{- $jantNs := cond (gt (len $posts) 0) ` xmlns:jant=\"https://jant.me/ns\"` \"\" -}}\n{{- $mediaNs := cond $anyMedia ` xmlns:media=\"http://search.yahoo.com/mrss/\"` \"\" -}}\n<feed xmlns=\"http://www.w3.org/2005/Atom\"{{ $jantNs | safeHTMLAttr }}{{ $mediaNs | safeHTMLAttr }}>\n <title>{{ $feedTitle | transform.XMLEscape }}</title>\n <subtitle>{{ $siteDescription | transform.XMLEscape }}</subtitle>\n <link href=\"{{ $siteBase | transform.XMLEscape }}\" rel=\"alternate\"/>\n <link href=\"{{ $selfUrl | transform.XMLEscape }}\" rel=\"self\"/>\n <id>{{ $selfUrl | transform.XMLEscape }}</id>\n <updated>{{ $now }}</updated>\n {{- range $posts }}\n {{- $post := . -}}\n {{- $format := .Params.format | default \"note\" -}}\n {{- $permalink := .Permalink -}}\n {{- /* <id>: the string Jant's feeds gave this entry, which the exporter\n writes as `feed_id`. Readers recognise an entry by it alone, and\n `.Permalink` differs from it (trailing slash; slug in place of a custom\n path), so using the page URL would show every post again after a move.\n A post written in Hugo has no `feed_id` and takes its page URL. */ -}}\n {{- $entryId := string (.Params.feed_id | default $permalink) -}}\n {{- $isLink := and (eq $format \"link\") .Params.link_url -}}\n {{- $alternate := cond $isLink (string .Params.link_url) $permalink -}}\n {{- /* Atom <title>: empty for quotes (matches main site's getAtomTitle). */ -}}\n {{- $entryTitle := cond (eq $format \"quote\") \"\" (.Title | default \"\") -}}\n {{- /* <published>: from `date` frontmatter (= publishedAt).\n <updated>: prefer `last_activity_at` (thread-bumped), then `updated`\n (root edit time), else `date`. Featured membership does not rewrite\n content time or chronological ordering. */ -}}\n {{- $published := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- $updatedSrc := .Date -}}\n {{- with .Params.last_activity_at -}}{{- $updatedSrc = time . -}}\n {{- else -}}{{- with .Params.updated -}}{{- $updatedSrc = time . -}}{{- end -}}{{- end -}}\n {{- $updated := $updatedSrc.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Plain-text summary fallback chain (mirrors getFeedSummaryText). */ -}}\n {{- $summaryText := \"\" -}}\n {{- if eq $format \"quote\" -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (string (.Params.quote_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) (string (.Params.source_url | default \"\")) -}}\n {{- else -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) -}}\n {{- end -}}\n {{- if eq $summaryText \"\" -}}{{- $summaryText = printf \"Post %s\" (string (.Params.id | default \"\")) -}}{{- end -}}\n\n {{- /* Whether the timeline cut the text of any post the entry's card shows.\n Per post it is written into front matter by the exporter, which shares\n the boundary with the served feed; Hugo's own summary rule would cut\n somewhere else. Per entry the rule is `isEntryTruncated` in\n `lib/feed.ts`: the root plus every reply the fold keeps — the first\n THREAD_LEADING_REPLIES (2) and the last THREAD_TRAILING_REPLIES (3),\n newest included — as `lib/thread-fold.ts` defines it. A thread of five\n replies or fewer is shown whole; `first` and `last` clamp to the slice,\n so the two windows simply overlap there. */ -}}\n {{- $replies := (where $post.Pages \"Params.visibility\" \"public\").ByWeight -}}\n {{- $truncated := $post.Params.truncated | default false -}}\n {{- range first 2 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n {{- range last 3 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n\n {{- /* Every attachment in the thread, each paired with the post that carries\n it — a text attachment's page lives under its own post. */ -}}\n {{- $entryMedia := slice -}}\n {{- range ($post.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $permalink) -}}\n {{- end -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- range (.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $replyPermalink) -}}\n {{- end -}}\n {{- end -}}\n <entry>\n <title>{{ $entryTitle | transform.XMLEscape }}</title>\n <link href=\"{{ $alternate | transform.XMLEscape }}\" rel=\"alternate\"/>\n {{- if $isLink }}\n <link href=\"{{ $permalink | transform.XMLEscape }}\" rel=\"related\"/>\n {{- end }}\n {{- /* Images carry no enclosure: the content already shows them full size\n inside a link to the original, so one would only ask an attachment\n shelf to list the picture again. */ -}}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- if ne (string ($m.kind | default \"document\")) \"image\" }}\n <link rel=\"enclosure\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" href=\"{{ (string $m.src) | absURL | transform.XMLEscape }}\"{{ with $m.size }} length=\"{{ . }}\"{{ end }}{{ with $m.original_name }} title=\"{{ . | transform.XMLEscape }}\"{{ end }}/>\n {{- end }}\n {{- end }}\n <id>{{ $entryId | transform.XMLEscape }}</id>\n <published>{{ $published }}</published>\n <updated>{{ $updated }}</updated>\n <jant:format>{{ $format | transform.XMLEscape }}</jant:format>\n {{- if $truncated }}\n <jant:truncated/>\n {{- end }}\n {{- /* A collection is a label the author chose, which is what <category>\n is for. `jant:page` carries the URL because a single collection lives\n in the root URL namespace. */ -}}\n {{- range ($post.Params.collections | default slice) }}\n <category term=\"{{ (string .slug) | transform.XMLEscape }}\" label=\"{{ (string (.title | default .slug)) | transform.XMLEscape }}\" jant:page=\"{{ (printf \"/%s/\" (string .slug)) | absURL | transform.XMLEscape }}\"/>\n {{- end }}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- $kind := string ($m.kind | default \"document\") }}\n {{- $medium := cond (or (eq $kind \"image\") (or (eq $kind \"video\") (eq $kind \"audio\"))) $kind \"document\" }}\n {{- $fileUrl := (string $m.src) | absURL }}\n {{- /* Where a click should land. Only emitted where it is not the file:\n a text attachment's markdown would download or dump unstyled. */ -}}\n {{- $pageUrl := $fileUrl }}\n {{- if eq $kind \"text\" }}{{ $pageUrl = printf \"%s/text/%s\" (strings.TrimSuffix \"/\" .page) (string $m.id) }}{{ end }}\n {{- $desc := string ($m.alt | default ($m.summary | default \"\")) }}\n {{- $poster := string ($m.poster | default \"\") }}\n <media:content url=\"{{ $fileUrl | transform.XMLEscape }}\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" medium=\"{{ $medium }}\"{{ with $m.size }} fileSize=\"{{ . }}\"{{ end }}{{ with $m.width }} width=\"{{ . }}\"{{ end }}{{ with $m.height }} height=\"{{ . }}\"{{ end }}{{ with $m.duration_seconds }} duration=\"{{ math.Round . }}\"{{ end }}{{ if ne $pageUrl $fileUrl }} jant:page=\"{{ $pageUrl | transform.XMLEscape }}\"{{ end }}>\n {{- with $m.original_name }}<media:title type=\"plain\">{{ . | transform.XMLEscape }}</media:title>{{ end }}\n {{- if ne $desc \"\" }}<media:description type=\"plain\">{{ $desc | transform.XMLEscape }}</media:description>{{ end }}\n {{- if ne $poster \"\" }}<media:thumbnail url=\"{{ $poster | absURL | transform.XMLEscape }}\"/>{{ end -}}\n </media:content>\n {{- end }}\n <summary type=\"text\">{{ $summaryText | transform.XMLEscape }}</summary>\n <content type=\"html\"><![CDATA[\n{{- partial \"feed-post-content.xml\" (dict \"post\" $post \"permalink\" $permalink \"includeReplies\" true) -}}\n]]></content>\n </entry>\n {{- end }}\n</feed>\n";
5994
6358
  //#endregion
5995
6359
  //#region src/services/export-theme/layouts/partials/feed-post-content.xml?raw
5996
6360
  var feed_post_content_default = "{{- /*\n Feed entry content builder — mirrors `buildSinglePostContent` +\n `buildFeedContent` from `packages/core/src/lib/feed.ts`.\n\n Arguments (dict):\n post — the Hugo Page for this post\n permalink — absolute permalink to the blog post (used for the\n link-format \"★\" suffix)\n includeReplies — when true, appends each public reply separated by\n <hr/> + <small><time></time></small>\n inline — when true, renders title/link metadata that top-level\n Atom entries expose through their entry fields\n\n Output is raw HTML suitable for embedding inside Atom\n <content type=\"html\"><![CDATA[...]]></content>. Since CDATA ends at the\n first `]]>`, any such sequence in rendered bodies is split using\n `replace ... \"]]>\" \"]]]]><![CDATA[>\"` — the same trick the main site uses\n in `escapeCdata`.\n*/ -}}\n{{- $post := .post -}}\n{{- $permalink := .permalink -}}\n{{- $includeReplies := .includeReplies -}}\n{{- $inline := false -}}\n{{- with .inline -}}{{- $inline = . -}}{{- end -}}\n{{- $format := $post.Params.format | default \"note\" -}}\n{{- $parts := slice -}}\n\n{{- /* --- Inline title/link metadata for thread replies --- */ -}}\n{{- if and $inline (ne $format \"quote\") -}}\n {{- $title := $post.Title | default \"\" -}}\n {{- $linkUrl := string ($post.Params.link_url | default \"\") -}}\n {{- if eq $format \"link\" -}}\n {{- if ne $linkUrl \"\" -}}\n {{- $domain := $linkUrl -}}\n {{- with urls.Parse $linkUrl -}}{{- if .Host -}}{{- $domain = replaceRE `^(www|m|mobile)\\.` \"\" .Host -}}{{- end -}}{{- end -}}\n {{- $parts = $parts | append (printf `<p><a href=%q>%s</a></p>` $linkUrl (transform.XMLEscape $domain)) -}}\n {{- end -}}\n {{- if ne $title \"\" -}}\n {{- $titleHref := $permalink -}}\n {{- if ne $linkUrl \"\" -}}{{- $titleHref = $linkUrl -}}{{- end -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $titleHref (transform.XMLEscape $title)) -}}\n {{- end -}}\n {{- else if ne $title \"\" -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $permalink (transform.XMLEscape $title)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Quote block (format=quote) --- */ -}}\n{{- if eq $format \"quote\" -}}\n {{- $quoteText := string ($post.Params.quote_text | default \"\") -}}\n {{- if ne $quoteText \"\" -}}\n {{- $sourceName := string ($post.Params.source_name | default \"\") -}}\n {{- $sourceUrl := string ($post.Params.source_url | default \"\") -}}\n {{- $cite := \"\" -}}\n {{- if ne $sourceUrl \"\" -}}{{- $cite = printf ` cite=%q` $sourceUrl -}}{{- end -}}\n {{- /* Line breaks the author typed must survive as markup: the site\n keeps them with `white-space: pre-line`, but feed readers strip\n CSS. Blank lines split <p> blocks, single newlines become <br/>\n (mirrors `renderPlainTextHtml` in `lib/feed.ts`). */ -}}\n {{- $quoteBlocks := slice -}}\n {{- range split (replaceRE `\\r\\n?` \"\\n\" $quoteText) \"\\n\\n\" -}}\n {{- $para := trim . \" \\t\\n\" -}}\n {{- if ne $para \"\" -}}\n {{- $lines := slice -}}\n {{- range split $para \"\\n\" -}}{{- $lines = $lines | append (transform.XMLEscape .) -}}{{- end -}}\n {{- $quoteBlocks = $quoteBlocks | append (printf \"<p>%s</p>\" (delimit $lines \"<br/>\")) -}}\n {{- end -}}\n {{- end -}}\n {{- if gt (len $quoteBlocks) 0 -}}\n {{- $block := printf \"<blockquote%s>%s</blockquote>\" $cite (delimit $quoteBlocks \"\\n\") -}}\n {{- $parts = $parts | append $block -}}\n {{- end -}}\n {{- /* Attribution line — prefer name, fall back to URL host or the\n raw URL itself (mirrors main site's extractDisplayDomain fallback\n chain in `lib/url.ts`). */ -}}\n {{- $attribution := \"\" -}}\n {{- if and (ne $sourceName \"\") (ne $sourceUrl \"\") -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $sourceName) -}}\n {{- else if ne $sourceName \"\" -}}\n {{- $attribution = transform.XMLEscape $sourceName -}}\n {{- else if ne $sourceUrl \"\" -}}\n {{- $host := $sourceUrl -}}\n {{- with urls.Parse $sourceUrl -}}{{- if .Host -}}{{- $host = .Host -}}{{- end -}}{{- end -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $host) -}}\n {{- end -}}\n {{- if ne $attribution \"\" -}}\n {{- $parts = $parts | append (printf \"<p>— %s</p>\" $attribution) -}}\n {{- end -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Rendered body HTML (with <script>/<style> stripped) --- */ -}}\n{{- $body := $post.Content -}}\n{{- if ne (string $body) \"\" -}}\n {{- $bodyStr := string $body -}}\n {{- $bodyStr = replaceRE `(?is)<script\\b[^>]*>.*?<\/script>` \"\" $bodyStr -}}\n {{- $bodyStr = replaceRE `(?is)<style\\b[^>]*>.*?</style>` \"\" $bodyStr -}}\n {{- /* Split any CDATA-terminator sequences so the content can be safely\n wrapped in <![CDATA[...]]> by the caller. */ -}}\n {{- $bodyStr = replace $bodyStr \"]]>\" \"]]]]><![CDATA[>\" -}}\n {{- if ne (trim $bodyStr \" \\t\\n\\r\") \"\" -}}\n {{- $parts = $parts | append $bodyStr -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Attachments --- */ -}}\n{{- /* One container per post, marked `data-post-media` — the attribute the\n site puts on its gallery strip, so a consumer styles the strip instead of\n reassembling it from the Media RSS elements. In a thread this is also the\n only surface that says which post an attachment belongs to. Mirrors\n `renderMediaItem` in `packages/core/src/lib/feed.ts`. */ -}}\n{{- $mediaList := $post.Params.media | default slice -}}\n{{- if gt (len $mediaList) 0 -}}\n {{- $items := slice -}}\n {{- range $mediaList -}}\n {{- $kind := string (.kind | default \"document\") -}}\n {{- $mime := string (.mime_type | default \"application/octet-stream\") -}}\n {{- /* `trim` wraps the index rather than piping into it: a Go template\n pipe binds to the last argument, so `index … 0 | trim \" \"` would trim\n the index instead of the result. */ -}}\n {{- $mimeLabel := trim (index (split $mime \";\") 0) \" \" -}}\n {{- $src := (string .src) | absURL -}}\n {{- $name := string (.original_name | default \"\") -}}\n {{- $alt := string (.alt | default \"\") -}}\n {{- /* Duration and size, in the shapes `formatFeedDuration` and\n `formatFeedBytes` produce. */ -}}\n {{- $metaParts := slice -}}\n {{- with .duration_seconds -}}\n {{- $total := int (math.Round .) -}}\n {{- if gt $total 0 -}}\n {{- $metaParts = $metaParts | append (printf \"%d:%02d\" (div $total 60) (mod $total 60)) -}}\n {{- end -}}\n {{- end -}}\n {{- with .size -}}\n {{- $b := int . -}}\n {{- if gt $b 0 -}}\n {{- if lt $b 1024 -}}\n {{- $metaParts = $metaParts | append (printf \"%d B\" $b) -}}\n {{- else if lt $b 1048576 -}}\n {{- $metaParts = $metaParts | append (printf \"%d KB\" (int (math.Round (div (float $b) 1024.0)))) -}}\n {{- else -}}\n {{- $metaParts = $metaParts | append (printf \"%.1f MB\" (div (float $b) 1048576.0)) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $meta := delimit $metaParts \" · \" -}}\n {{- $metaSuffix := \"\" -}}\n {{- if ne $meta \"\" -}}{{- $metaSuffix = printf \" (%s)\" (transform.XMLEscape $meta) -}}{{- end -}}\n {{- $dims := \"\" -}}\n {{- if and .width .height -}}{{- $dims = printf ` width=\"%d\" height=\"%d\"` (int .width) (int .height) -}}{{- end -}}\n\n {{- if eq $kind \"image\" -}}\n {{- $caption := \"\" -}}\n {{- if ne $alt \"\" -}}{{- $caption = printf \"<figcaption>%s</figcaption>\" (transform.XMLEscape $alt) -}}{{- end -}}\n {{- /* `%q` is Go string quoting, not attribute escaping: a `\"` or `\\`\n in the alt would leave the attribute malformed. URLs go through\n `%q` because they never carry either; author text goes through\n `transform.XMLEscape`, the same as the caption beside it. */ -}}\n {{- $items = $items | append (printf `<figure><a href=%q><img src=%q alt=\"%s\"%s/></a>%s</figure>` $src $src (transform.XMLEscape $alt) $dims $caption) -}}\n {{- else if eq $kind \"video\" -}}\n {{- /* A poster is written only when there is a real still; the pipeline\n leaves nothing usable otherwise, and `<img src=\"clip.mp4\">` was the\n bug that taught us. The link sits outside the <video> so it survives\n a sanitizer that drops the player with its children, and\n `preload=\"none\"` keeps a reader from pulling the whole file. */ -}}\n {{- $posterAttr := \"\" -}}\n {{- with .poster -}}{{- $posterAttr = printf ` poster=%q` ((string .) | absURL) -}}{{- end -}}\n {{- $altSuffix := \"\" -}}\n {{- if ne $alt \"\" -}}{{- $altSuffix = printf \": %s\" (transform.XMLEscape $alt) -}}{{- end -}}\n {{- $items = $items | append (printf `<figure><video controls preload=\"none\"%s%s><source src=%q type=%q/></video><figcaption><a href=%q>▶ Watch video</a>%s%s</figcaption></figure>` $posterAttr $dims $src $mimeLabel $src $metaSuffix $altSuffix) -}}\n {{- else if eq $kind \"text\" -}}\n {{- /* Markdown a browser would download or dump unstyled, so the link\n goes to the page that renders it. */ -}}\n {{- $href := printf \"%s/text/%s\" (strings.TrimSuffix \"/\" $permalink) (string .id) -}}\n {{- $label := printf \"📎 [%s] %s\" (transform.XMLEscape $mimeLabel) (transform.XMLEscape (or $name \"Attached text\")) -}}\n {{- $textMeta := $metaSuffix -}}\n {{- with .chars -}}\n {{- if gt (int .) 0 -}}{{- $textMeta = printf \" (%d chars)\" (int .) -}}{{- end -}}\n {{- end -}}\n {{- $summarySuffix := \"\" -}}\n {{- with .summary -}}\n {{- if ne (string .) \"\" -}}{{- $summarySuffix = printf \": %s\" (transform.XMLEscape (string .)) -}}{{- end -}}\n {{- end -}}\n {{- $items = $items | append (printf `<p><a href=%q>%s</a>%s%s</p>` $href $label $textMeta $summarySuffix) -}}\n {{- else -}}\n {{- $fallbackName := cond (eq $kind \"audio\") \"Audio\" \"Attachment\" -}}\n {{- $label := printf \"📎 [%s] %s\" (transform.XMLEscape $mimeLabel) (transform.XMLEscape (or $name $fallbackName)) -}}\n {{- $items = $items | append (printf `<p><a href=%q>%s</a>%s</p>` $src $label $metaSuffix) -}}\n {{- end -}}\n {{- end -}}\n {{- $parts = $parts | append (printf \"<div data-post-media>\\n%s\\n</div>\" (delimit $items \"\\n\")) -}}\n{{- end -}}\n\n{{- /* --- Star rating --- */ -}}\n{{- with $post.Params.rating -}}\n {{- $r := int . -}}\n {{- if gt $r 0 -}}\n {{- $filled := strings.Repeat $r \"★\" -}}\n {{- $empty := strings.Repeat (sub 5 $r) \"☆\" -}}\n {{- $parts = $parts | append (printf \"<p>%s%s %d/5</p>\" $filled $empty $r) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Empty-body fallback (plain-text summary) --- */ -}}\n{{- if eq (len $parts) 0 -}}\n {{- $fallback := string ($post.Params.summary_text | default \"\") -}}\n {{- if eq $fallback \"\" -}}{{- $fallback = $post.Title | default (printf \"Post %s\" (string ($post.Params.id | default \"\"))) -}}{{- end -}}\n {{- $parts = $parts | append (printf \"<p>%s</p>\" (transform.XMLEscape $fallback)) -}}\n{{- end -}}\n\n{{- /* --- link-format permalink suffix (Daring Fireball ★) --- */ -}}\n{{- if and (eq $format \"link\") (ne $permalink \"\") -}}\n {{- $parts = $parts | append (printf `<p><a href=%q title=\"Permalink\">&nbsp;★&nbsp;</a></p>` $permalink) -}}\n{{- end -}}\n\n{{- /* --- Thread replies --- */ -}}\n{{- if $includeReplies -}}\n {{- $replies := where $post.Pages \"Params.visibility\" \"public\" -}}\n {{- $replies = $replies.ByDate -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- $replyDatetime := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Human-readable timestamp; mirrors the main site's\n `publishedAtFormatted` in the feed — there it's locale-formatted\n by `lib/format-timestamp.ts`, but Hugo doesn't have that at\n export time, so use a simple ISO-style fallback. */ -}}\n {{- $replyLabel := .Date.Format \"Jan 2, 2006 15:04\" -}}\n {{- $parts = $parts | append \"<hr/>\" -}}\n {{- $parts = $parts | append (printf `<p><small><time datetime=%q>%s</time></small></p>` $replyDatetime (transform.XMLEscape $replyLabel)) -}}\n {{- $parts = $parts | append (partial \"feed-post-content.xml\" (dict \"post\" . \"permalink\" $replyPermalink \"includeReplies\" false \"inline\" true)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- delimit $parts \"\\n\" | safeHTML -}}\n";
@@ -6015,16 +6379,28 @@ var feed_post_content_default = "{{- /*\n Feed entry content builder — mirror
6015
6379
  *
6016
6380
  * Real Hugo templates and CSS are scaffolded as placeholders here and
6017
6381
  * filled in by Commit 5.
6018
- */ var export_exports = /* @__PURE__ */ __exportAll({
6019
- buildExportedCollectionDirectoryItems: () => buildExportedCollectionDirectoryItems,
6020
- buildExportedCollectionMetrics: () => buildExportedCollectionMetrics,
6021
- buildSiteIconAssets: () => buildSiteIconAssets,
6022
- createExportService: () => createExportService,
6023
- getArchiveSummaryText: () => getArchiveSummaryText,
6024
- getMediaUrl: () => getMediaUrl,
6025
- getPublicUrlForProvider: () => getPublicUrlForProvider,
6026
- readStorageObjectBytes: () => readStorageObjectBytes
6027
- });
6382
+ */
6383
+ /**
6384
+ * @param file - An export file entry
6385
+ * @returns Whether its bytes are still in storage
6386
+ * @example
6387
+ * if (isStoredExportFile(file)) await readStoredExportFile(file, storage);
6388
+ */ function isStoredExportFile(file) {
6389
+ return "storageKey" in file;
6390
+ }
6391
+ /**
6392
+ * Read a stored export file's bytes, for a consumer that needs them whole.
6393
+ *
6394
+ * @param file - A stored export file
6395
+ * @param storage - The site's storage
6396
+ * @returns The bytes, or null when the object is gone
6397
+ * @example
6398
+ * const bytes = await readStoredExportFile(file, storage);
6399
+ */ async function readStoredExportFile(file, storage) {
6400
+ const object = await storage.get(file.storageKey);
6401
+ if (!object?.body) return null;
6402
+ return new Uint8Array(await new Response(object.body).arrayBuffer());
6403
+ }
6028
6404
  function buildDefaultAppleTouchAsset() {
6029
6405
  return {
6030
6406
  appleTouchBytes: getDefaultJantAppleTouchIconBytes(),
@@ -6047,24 +6423,40 @@ function createExportService(services, siteConfig, deps = {}) {
6047
6423
  const roots = allPosts.filter((p) => p.replyToId === null);
6048
6424
  const replies = allPosts.filter((p) => p.replyToId !== null);
6049
6425
  const rootPostIds = roots.map((p) => p.id);
6050
- const [collectionsByRoot, collectionEntriesByThread, rawMediaByPost, slugMap, aliasMap, collectionSlugMap] = await Promise.all([
6426
+ const [collectionsByRoot, collectionEntriesByThread, rawMediaByPost, slugMap, aliasMap, collectionSlugMap, standalonePaths] = await Promise.all([
6051
6427
  services.collections.getCollectionsByPostIds(rootPostIds),
6052
6428
  services.collections.getCollectionEntriesByThreadIds(rootPostIds),
6053
6429
  services.media.getByPostIds(allPostIds),
6054
6430
  services.paths.getPostSlugMap(allPostIds),
6055
6431
  services.paths.getPostAliases(rootPostIds),
6056
- services.paths.getCollectionSlugMap(allCollections.map((c) => c.id))
6432
+ services.paths.getCollectionSlugMap(allCollections.map((c) => c.id)),
6433
+ services.paths.listStandalonePaths()
6057
6434
  ]);
6058
6435
  const collectionTitleMap = /* @__PURE__ */ new Map();
6059
6436
  for (const collection of allCollections) collectionTitleMap.set(collection.id, collection.title);
6060
6437
  const iconAssets = await buildSiteIconAssets(siteConfig, deps.storage);
6061
6438
  const collectionMetrics = buildExportedCollectionMetrics(allCollections, allPosts, collectionsByRoot);
6062
- const exportedCollectionDirectoryItems = buildExportedCollectionDirectoryItems(collectionDirectoryData?.items ? collectionDirectoryData.items.filter((item) => item.type !== "smart_collection").map((item) => ({
6439
+ const exportedSmartCollections = [];
6440
+ const smartCollectionSlugMap = /* @__PURE__ */ new Map();
6441
+ for (const smartCollection of collectionDirectoryData?.smartCollections ?? []) {
6442
+ const selection = toExportedSelection(smartCollection.selection, collectionSlugMap);
6443
+ if (!selection) {
6444
+ console.warn(`Export: smart collection /${smartCollection.slug} filters by a collection that no longer exists, so it was left out.`);
6445
+ continue;
6446
+ }
6447
+ smartCollectionSlugMap.set(smartCollection.id, smartCollection.slug);
6448
+ exportedSmartCollections.push({
6449
+ smartCollection,
6450
+ selection
6451
+ });
6452
+ }
6453
+ const exportedCollectionDirectoryItems = buildExportedCollectionDirectoryItems(collectionDirectoryData?.items ? collectionDirectoryData.items.filter((item) => item.type !== "smart_collection" || item.smartCollection !== void 0 && smartCollectionSlugMap.has(item.smartCollection.id)).map((item) => ({
6063
6454
  type: item.type,
6064
6455
  label: item.label,
6065
6456
  url: item.url,
6066
6457
  description: item.description,
6067
- collection: item.collection
6458
+ collection: item.collection,
6459
+ smartCollection: item.smartCollection
6068
6460
  })) : allCollections.map((collection) => ({
6069
6461
  type: "collection",
6070
6462
  collection
@@ -6075,7 +6467,7 @@ function createExportService(services, siteConfig, deps = {}) {
6075
6467
  list.push(reply);
6076
6468
  repliesByThread.set(reply.threadId, list);
6077
6469
  }
6078
- for (const list of repliesByThread.values()) list.sort((a, b) => a.createdAt - b.createdAt);
6470
+ for (const list of repliesByThread.values()) list.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
6079
6471
  const exportFiles = [];
6080
6472
  const bundleMedia = deps.bundleMedia ?? true;
6081
6473
  for (const root of roots) {
@@ -6091,6 +6483,10 @@ function createExportService(services, siteConfig, deps = {}) {
6091
6483
  content: await buildCollectionSection(collection, slug, entryCount, siteConfig.rssFeedsEnabled)
6092
6484
  });
6093
6485
  }
6486
+ for (const { smartCollection, selection } of exportedSmartCollections) exportFiles.push({
6487
+ path: `content/${smartCollection.slug}/_index.md`,
6488
+ content: await buildSmartCollectionSection(smartCollection, selection, siteConfig.rssFeedsEnabled)
6489
+ });
6094
6490
  exportFiles.push({
6095
6491
  path: "hugo.toml",
6096
6492
  content: buildHugoToml(siteConfig)
@@ -6110,18 +6506,25 @@ function createExportService(services, siteConfig, deps = {}) {
6110
6506
  const usedSlugs = /* @__PURE__ */ new Set();
6111
6507
  for (const s of slugMap.values()) usedSlugs.add(s);
6112
6508
  for (const s of collectionSlugMap.values()) usedSlugs.add(s);
6509
+ for (const s of smartCollectionSlugMap.values()) usedSlugs.add(s);
6113
6510
  const hasFeaturedSection = !usedSlugs.has("featured");
6114
6511
  if (hasFeaturedSection) exportFiles.push({
6115
6512
  path: "content/featured/_index.md",
6116
6513
  content: await buildFeaturedSection(siteConfig.rssFeedsEnabled)
6117
6514
  });
6118
- if (siteConfig.rssFeedsEnabled) exportFiles.push({
6515
+ const redirectSections = [siteConfig.rssFeedsEnabled ? buildFeedRedirects(siteConfig.mainRssFeed, hasFeaturedSection) : null, buildCustomUrlRedirects(standalonePaths)].filter((section) => section !== null);
6516
+ if (redirectSections.length > 0) exportFiles.push({
6119
6517
  path: "static/_redirects",
6120
- content: buildFeedRedirects(siteConfig.mainRssFeed, hasFeaturedSection)
6518
+ content: redirectSections.join("\n")
6121
6519
  });
6122
6520
  exportFiles.push({
6123
6521
  path: "data/jant.toml",
6124
- content: buildJantDataToml(siteConfig, iconAssets, exportedCollectionDirectoryItems)
6522
+ content: buildJantDataToml(siteConfig, iconAssets, exportedCollectionDirectoryItems, {
6523
+ postSlugs: slugMap,
6524
+ collectionSlugs: collectionSlugMap,
6525
+ smartCollectionSlugs: smartCollectionSlugMap,
6526
+ standalonePaths
6527
+ })
6125
6528
  });
6126
6529
  exportFiles.push({
6127
6530
  path: "themes/jant/theme.toml",
@@ -6137,7 +6540,7 @@ function createExportService(services, siteConfig, deps = {}) {
6137
6540
  });
6138
6541
  exportFiles.push({
6139
6542
  path: "themes/jant/layouts/_default/list.html",
6140
- content: list_default$4
6543
+ content: list_default$5
6141
6544
  });
6142
6545
  exportFiles.push({
6143
6546
  path: "themes/jant/layouts/_default/alias.html",
@@ -6149,24 +6552,28 @@ function createExportService(services, siteConfig, deps = {}) {
6149
6552
  });
6150
6553
  exportFiles.push({
6151
6554
  path: "themes/jant/layouts/post/list.html",
6152
- content: list_default$3
6555
+ content: list_default$4
6153
6556
  });
6154
6557
  exportFiles.push({
6155
6558
  path: "themes/jant/layouts/featured/list.html",
6156
- content: list_default$2
6559
+ content: list_default$3
6157
6560
  });
6158
6561
  exportFiles.push({
6159
6562
  path: "themes/jant/layouts/archive/list.html",
6160
- content: list_default$1
6563
+ content: list_default$2
6161
6564
  });
6162
6565
  exportFiles.push({
6163
6566
  path: "themes/jant/layouts/collections/list.html",
6164
- content: list_default
6567
+ content: list_default$1
6165
6568
  });
6166
6569
  exportFiles.push({
6167
6570
  path: "themes/jant/layouts/collection/single.html",
6168
6571
  content: single_default
6169
6572
  });
6573
+ exportFiles.push({
6574
+ path: "themes/jant/layouts/partials/jant-data.html",
6575
+ content: jant_data_default
6576
+ });
6170
6577
  exportFiles.push({
6171
6578
  path: "themes/jant/layouts/partials/head.html",
6172
6579
  content: head_default
@@ -6203,6 +6610,30 @@ function createExportService(services, siteConfig, deps = {}) {
6203
6610
  path: "themes/jant/layouts/partials/featured-thread.html",
6204
6611
  content: featured_thread_default
6205
6612
  });
6613
+ exportFiles.push({
6614
+ path: "themes/jant/layouts/smart_collection/list.html",
6615
+ content: list_default
6616
+ });
6617
+ exportFiles.push({
6618
+ path: "themes/jant/layouts/partials/smart-collection-members.html",
6619
+ content: smart_collection_members_default
6620
+ });
6621
+ exportFiles.push({
6622
+ path: "themes/jant/layouts/partials/collection-threads.html",
6623
+ content: collection_threads_default
6624
+ });
6625
+ exportFiles.push({
6626
+ path: "themes/jant/layouts/partials/collection-members.html",
6627
+ content: collection_members_default
6628
+ });
6629
+ exportFiles.push({
6630
+ path: "themes/jant/layouts/partials/latest-members.html",
6631
+ content: latest_members_default
6632
+ });
6633
+ exportFiles.push({
6634
+ path: "themes/jant/layouts/partials/featured-members.html",
6635
+ content: featured_members_default
6636
+ });
6206
6637
  exportFiles.push({
6207
6638
  path: "themes/jant/layouts/_default/rss.xml",
6208
6639
  content: rss_default
@@ -6251,15 +6682,40 @@ function createExportService(services, siteConfig, deps = {}) {
6251
6682
  path: ".gitignore",
6252
6683
  content: buildGitignore()
6253
6684
  });
6685
+ exportFiles.push({
6686
+ path: WRANGLER_CONFIG_PATH,
6687
+ content: buildWranglerConfig(siteConfig, deps.repoName),
6688
+ scaffoldOnce: true
6689
+ });
6254
6690
  return exportFiles;
6255
6691
  },
6256
6692
  async generateHugoSite() {
6257
6693
  const exportFiles = await this.generateHugoFiles();
6258
- const { zipSync } = await import("fflate");
6259
- const encoder = new TextEncoder();
6260
- const files = {};
6261
- for (const file of exportFiles) files[file.path] = typeof file.content === "string" ? encoder.encode(file.content) : file.content;
6262
- return zipSync(files);
6694
+ const storage = deps.storage ?? null;
6695
+ const lastModified = /* @__PURE__ */ new Date();
6696
+ async function* entries() {
6697
+ for (const file of exportFiles) {
6698
+ if (!isStoredExportFile(file)) {
6699
+ yield {
6700
+ name: file.path,
6701
+ lastModified,
6702
+ input: file.content
6703
+ };
6704
+ continue;
6705
+ }
6706
+ const object = storage ? await storage.get(file.storageKey) : null;
6707
+ if (!object?.body) {
6708
+ console.warn(`Export: ${file.storageKey} is missing from storage, so ${file.path} is not in the archive.`);
6709
+ continue;
6710
+ }
6711
+ yield {
6712
+ name: file.path,
6713
+ lastModified,
6714
+ input: object.body
6715
+ };
6716
+ }
6717
+ }
6718
+ return makeZip(entries());
6263
6719
  }
6264
6720
  };
6265
6721
  }
@@ -6428,11 +6884,17 @@ function extOfStorageKey(key) {
6428
6884
  const featuredPosts = [root, ...threadReplies].filter((post) => post.status === "published" && post.featuredAt !== null);
6429
6885
  const featuredPostIds = featuredPosts.map((post) => post.id);
6430
6886
  const featuredSortAt = featuredPosts.reduce((latest, post) => Math.max(latest ?? -1, post.publishedAt ?? post.createdAt), null);
6431
- const aliases = [...rootAliases];
6432
- for (const reply of threadReplies) {
6433
- const replySlug = slugMap.get(reply.id) ?? reply.slug;
6434
- aliases.push(`/${replySlug}/`);
6887
+ const rootIsUnpublished = root.status === "draft" || root.visibility === "private";
6888
+ const aliases = [];
6889
+ if (!rootIsUnpublished) {
6890
+ aliases.push(...rootAliases);
6891
+ for (const reply of threadReplies) {
6892
+ const replySlug = slugMap.get(reply.id) ?? reply.slug;
6893
+ aliases.push(`/${replySlug}/`);
6894
+ }
6435
6895
  }
6896
+ const siteUrl = siteConfig.siteUrl.trim();
6897
+ const feedId = !rootIsUnpublished && siteUrl ? toAbsoluteSiteUrl(getPostPath(rootSlug, rootAliases[0]), siteUrl, siteConfig.sitePathPrefix) : void 0;
6436
6898
  const rootMedia = mediaByPost.get(root.id) ?? [];
6437
6899
  const rootEmissions = rootMedia.map((m) => buildMediaEmission(m, siteConfig, bundleMedia));
6438
6900
  const rootMediaList = rootEmissions.map((e) => e.entry);
@@ -6440,12 +6902,14 @@ function extOfStorageKey(key) {
6440
6902
  id: root.id,
6441
6903
  title: root.format !== "quote" ? root.title ?? void 0 : void 0,
6442
6904
  date: root.publishedAt !== null ? toISOString(root.publishedAt) : toISOString(root.createdAt),
6905
+ created: root.publishedAt !== null && root.createdAt !== root.publishedAt ? toISOString(root.createdAt) : void 0,
6443
6906
  updated: root.updatedAt && root.updatedAt !== root.publishedAt ? toISOString(root.updatedAt) : void 0,
6444
6907
  last_activity_at: root.lastActivityAt !== null && root.lastActivityAt !== root.publishedAt ? toISOString(root.lastActivityAt) : void 0,
6445
6908
  slug: rootSlug,
6446
6909
  type: "post",
6447
- draft: root.status === "draft" || root.visibility === "private" ? true : void 0,
6910
+ draft: rootIsUnpublished ? true : void 0,
6448
6911
  aliases: aliases.length > 0 ? aliases : void 0,
6912
+ feed_id: feedId,
6449
6913
  format: root.format,
6450
6914
  status: root.status,
6451
6915
  visibility: root.visibility,
@@ -6466,7 +6930,7 @@ function extOfStorageKey(key) {
6466
6930
  collections: rootCollectionEntries.length > 0 ? collectionEntriesToRefs(rootCollectionEntries) : void 0,
6467
6931
  media: rootMediaList.length > 0 ? rootMediaList : void 0
6468
6932
  };
6469
- const rootBody = root.body ? tiptapJsonToMarkdown(root.body) : "";
6933
+ const rootBody = root.body ? postBodyToMarkdown(root.body, rootSlug) : "";
6470
6934
  files.push({
6471
6935
  path: `content/${rootSlug}/_index.md`,
6472
6936
  content: `${await formatFrontMatter(rootFrontMatter)}\n${rootBody}${rootBody.endsWith("\n") ? "" : "\n"}`
@@ -6476,15 +6940,15 @@ function extOfStorageKey(key) {
6476
6940
  media: rootMedia[i]
6477
6941
  }))) {
6478
6942
  if (emission.inlinePath) {
6479
- const file = await readMediaResourceFile(storage, media.storageKey, emission.inlinePath);
6943
+ const file = toStoredMediaFile(storage, media.storageKey, emission.inlinePath);
6480
6944
  if (file) files.push(file);
6481
6945
  }
6482
6946
  if (emission.inlinePosterPath && media.posterKey) {
6483
- const posterFile = await readMediaResourceFile(storage, media.posterKey, emission.inlinePosterPath);
6947
+ const posterFile = toStoredMediaFile(storage, media.posterKey, emission.inlinePosterPath);
6484
6948
  if (posterFile) files.push(posterFile);
6485
6949
  }
6486
6950
  }
6487
- for (const reply of threadReplies) {
6951
+ for (const [replyIndex, reply] of threadReplies.entries()) {
6488
6952
  const replySlug = slugMap.get(reply.id) ?? reply.slug;
6489
6953
  const replyMedia = mediaByPost.get(reply.id) ?? [];
6490
6954
  const replyEmissions = replyMedia.map((m) => buildMediaEmission(m, siteConfig, bundleMedia));
@@ -6493,9 +6957,11 @@ function extOfStorageKey(key) {
6493
6957
  id: reply.id,
6494
6958
  title: reply.format !== "quote" ? reply.title ?? void 0 : void 0,
6495
6959
  date: reply.publishedAt !== null ? toISOString(reply.publishedAt) : toISOString(reply.createdAt),
6960
+ created: reply.publishedAt !== null && reply.createdAt !== reply.publishedAt ? toISOString(reply.createdAt) : void 0,
6496
6961
  updated: reply.updatedAt && reply.updatedAt !== reply.publishedAt ? toISOString(reply.updatedAt) : void 0,
6497
6962
  slug: replySlug,
6498
6963
  type: "post",
6964
+ weight: replyIndex + 1,
6499
6965
  draft: reply.status === "draft" || reply.visibility === "private" ? true : void 0,
6500
6966
  build: {
6501
6967
  render: "never",
@@ -6516,7 +6982,7 @@ function extOfStorageKey(key) {
6516
6982
  pinned_at: reply.pinnedAt !== null ? toISOString(reply.pinnedAt) : void 0,
6517
6983
  media: replyMediaList.length > 0 ? replyMediaList : void 0
6518
6984
  };
6519
- const replyBody = reply.body ? tiptapJsonToMarkdown(reply.body) : "";
6985
+ const replyBody = reply.body ? postBodyToMarkdown(reply.body, replySlug) : "";
6520
6986
  files.push({
6521
6987
  path: `content/${rootSlug}/${replySlug}/index.md`,
6522
6988
  content: `${await formatFrontMatter(replyFrontMatter)}\n${replyBody}${replyBody.endsWith("\n") ? "" : "\n"}`
@@ -6526,11 +6992,11 @@ function extOfStorageKey(key) {
6526
6992
  media: replyMedia[i]
6527
6993
  }))) {
6528
6994
  if (emission.inlinePath) {
6529
- const file = await readMediaResourceFile(storage, media.storageKey, emission.inlinePath);
6995
+ const file = toStoredMediaFile(storage, media.storageKey, emission.inlinePath);
6530
6996
  if (file) files.push(file);
6531
6997
  }
6532
6998
  if (emission.inlinePosterPath && media.posterKey) {
6533
- const posterFile = await readMediaResourceFile(storage, media.posterKey, emission.inlinePosterPath);
6999
+ const posterFile = toStoredMediaFile(storage, media.posterKey, emission.inlinePosterPath);
6534
7000
  if (posterFile) files.push(posterFile);
6535
7001
  }
6536
7002
  }
@@ -6538,23 +7004,15 @@ function extOfStorageKey(key) {
6538
7004
  return files;
6539
7005
  }
6540
7006
  /**
6541
- * Read a media record's bytes from storage and return an ExportFile so
6542
- * they can be bundled next to the post as a Hugo page resource. Returns
6543
- * null when storage is unavailable or the object cannot be read, in
6544
- * which case the front matter entry still points at the resource name
6545
- * and the CLI's pull-media step (or a later sync) can fill it in.
6546
- */ async function readMediaResourceFile(storage, storageKey, bundlePath) {
6547
- if (!storage) return null;
6548
- try {
6549
- const bytes = await readStorageObjectBytes(storage, storageKey);
6550
- if (!bytes) return null;
6551
- return {
6552
- path: bundlePath,
6553
- content: bytes
6554
- };
6555
- } catch {
6556
- return null;
6557
- }
7007
+ * Name a media object for the archive to bundle as `static/media/…`. Its
7008
+ * bytes are read when the archive is written. Null when the export has no
7009
+ * storage, in which case the front matter entry still points at the file and
7010
+ * the CLI's pull-media step can fill it in.
7011
+ */ function toStoredMediaFile(storage, storageKey, bundlePath) {
7012
+ return storage ? {
7013
+ path: bundlePath,
7014
+ storageKey
7015
+ } : null;
6558
7016
  }
6559
7017
  async function buildHomeSection(siteConfig) {
6560
7018
  return `${await formatFrontMatter({
@@ -6593,6 +7051,34 @@ async function buildCollectionSection(collection, slug, entryCount, rssFeedsEnab
6593
7051
  outputs: rssFeedsEnabled ? ["html", "rss"] : ["html"]
6594
7052
  })}\n`;
6595
7053
  }
7054
+ /**
7055
+ * A smart collection's conditions as its section page carries them: the
7056
+ * stored selection, with the collection named by slug rather than ID so an
7057
+ * import into another site can resolve it. Null when the collection it names
7058
+ * is gone.
7059
+ */ function toExportedSelection(selection, collectionSlugs) {
7060
+ const { collection, media, ...rest } = selection;
7061
+ const exported = { ...rest };
7062
+ if (collection !== void 0) {
7063
+ const slug = collection[0] ? collectionSlugs.get(collection[0]) : void 0;
7064
+ if (!slug) return null;
7065
+ exported.collection = slug;
7066
+ }
7067
+ if (media !== void 0) exported.media = typeof media === "string" ? media : [...media];
7068
+ return exported;
7069
+ }
7070
+ async function buildSmartCollectionSection(smartCollection, selection, rssFeedsEnabled) {
7071
+ return `${await formatFrontMatter({
7072
+ title: smartCollection.title,
7073
+ slug: smartCollection.slug,
7074
+ type: "smart_collection",
7075
+ summary_text: smartCollection.description ?? void 0,
7076
+ sort_order: smartCollection.sort,
7077
+ display_layout: smartCollection.layout ?? void 0,
7078
+ selection,
7079
+ outputs: rssFeedsEnabled ? ["html", "rss"] : ["html"]
7080
+ })}\n`;
7081
+ }
6596
7082
  function normalizeArchiveText(text) {
6597
7083
  return (text ?? "").replace(/\s+/g, " ").trim();
6598
7084
  }
@@ -6680,10 +7166,27 @@ function buildExportedCollectionDirectoryItems(items, collectionSlugMap, collect
6680
7166
  sequence: sequenceLabels[index] ?? "",
6681
7167
  label: item.label,
6682
7168
  url: item.url,
7169
+ description: description || null,
6683
7170
  descriptionHtml: description ? render(description, { namespace: `collection-directory-link-${sequenceLabels[index] ?? index}` }) : null
6684
7171
  });
6685
7172
  return;
6686
7173
  }
7174
+ if (item.type === "smart_collection") {
7175
+ const smartCollection = item.smartCollection;
7176
+ if (!smartCollection?.slug) return;
7177
+ const smartDescription = smartCollection.description?.trim();
7178
+ exportedItems.push({
7179
+ type: "smart_collection",
7180
+ sequence: sequenceLabels[index] ?? "",
7181
+ slug: smartCollection.slug,
7182
+ title: smartCollection.title || smartCollection.slug,
7183
+ descriptionHtml: smartDescription ? render(smartDescription, { namespace: `smart-collection-${smartCollection.slug}` }) : null,
7184
+ entryCount: smartCollection.threadCount,
7185
+ recentActivityLabel: formatCollectionActivityLabel(smartCollection.recentActivityAt),
7186
+ recentActivityIso: formatCollectionActivityIso(smartCollection.recentActivityAt)
7187
+ });
7188
+ return;
7189
+ }
6687
7190
  const collection = item.collection;
6688
7191
  if (!collection?.id) return;
6689
7192
  const slug = collectionSlugMap.get(collection.id) ?? collection.slug;
@@ -6771,7 +7274,6 @@ function buildHugoToml(config) {
6771
7274
  `languageCode = "${escapeTomlString(language)}"`,
6772
7275
  `defaultContentLanguage = "${escapeTomlString(language)}"`,
6773
7276
  "theme = \"jant\"",
6774
- `paginate = ${config.pageSize}`,
6775
7277
  "enableRobotsTXT = true",
6776
7278
  "disableKinds = ['taxonomy', 'term']",
6777
7279
  "",
@@ -6819,7 +7321,15 @@ function buildHugoToml(config) {
6819
7321
  if (config.faviconVersion) parts.push(` favicon_version = "${escapeTomlString(config.faviconVersion)}"`);
6820
7322
  return `${parts.join("\n")}\n`;
6821
7323
  }
6822
- function buildJantDataToml(config, iconAssets, directoryItems) {
7324
+ /** The key and slug `data/jant.toml` names a nav item's target by. */ function resolveNavItemTarget(item, targets) {
7325
+ const slug = item.type === "collection" && item.collectionId ? targets.collectionSlugs.get(item.collectionId) : item.type === "smart_collection" && item.smartCollectionId ? targets.smartCollectionSlugs.get(item.smartCollectionId) : item.type === "page" && item.postId ? targets.postSlugs.get(item.postId) : void 0;
7326
+ if (!slug) return null;
7327
+ return {
7328
+ key: item.type === "collection" ? "collection_slug" : item.type === "smart_collection" ? "smart_collection_slug" : "post_slug",
7329
+ slug
7330
+ };
7331
+ }
7332
+ function buildJantDataToml(config, iconAssets, directoryItems, targets) {
6823
7333
  const footerHtml = config.siteFooter ? render(config.siteFooter, { namespace: "site-footer" }) : "";
6824
7334
  const parts = [
6825
7335
  "format = \"jant-site\"",
@@ -6855,7 +7365,6 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
6855
7365
  if (footerHtml) parts.push(`site_footer_html = "${escapeTomlString(footerHtml)}"`);
6856
7366
  if (config.siteFooter) parts.push(`site_footer_markdown = "${escapeTomlString(config.siteFooter)}"`);
6857
7367
  for (const item of config.navItems) {
6858
- if (item.systemKey === "settings") continue;
6859
7368
  if (!config.rssFeedsEnabled && item.type === "system" && isFeedNavKey(item.systemKey)) continue;
6860
7369
  parts.push("");
6861
7370
  parts.push("[[nav]]");
@@ -6864,12 +7373,15 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
6864
7373
  parts.push(`url = "${escapeTomlString(resolveNavItemUrl(item, config.mainRssFeed))}"`);
6865
7374
  parts.push(`system_key = "${escapeTomlString(item.systemKey ?? "")}"`);
6866
7375
  parts.push(`placement = "${escapeTomlString(item.placement ?? "header")}"`);
7376
+ if (item.label) parts.push(`custom_label = "${escapeTomlString(item.label)}"`);
7377
+ const target = resolveNavItemTarget(item, targets);
7378
+ if (target) parts.push(`${target.key} = "${escapeTomlString(target.slug)}"`);
6867
7379
  }
6868
7380
  for (const item of directoryItems) {
6869
7381
  parts.push("");
6870
7382
  parts.push("[[directory]]");
6871
7383
  parts.push(`type = "${escapeTomlString(item.type)}"`);
6872
- if (item.type === "collection") {
7384
+ if (item.type === "collection" || item.type === "smart_collection") {
6873
7385
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
6874
7386
  parts.push(`slug = "${escapeTomlString(item.slug)}"`);
6875
7387
  parts.push(`title = "${escapeTomlString(item.title)}"`);
@@ -6883,12 +7395,127 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
6883
7395
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
6884
7396
  parts.push(`label = "${escapeTomlString(item.label)}"`);
6885
7397
  parts.push(`url = "${escapeTomlString(item.url)}"`);
7398
+ if (item.description) parts.push(`description = "${escapeTomlString(item.description)}"`);
6886
7399
  if (item.descriptionHtml) parts.push(`description_html = "${escapeTomlString(item.descriptionHtml)}"`);
6887
7400
  }
6888
7401
  }
7402
+ for (const record of targets.standalonePaths) {
7403
+ parts.push("");
7404
+ parts.push("[[custom_url]]");
7405
+ parts.push(`path = "${escapeTomlString(record.path)}"`);
7406
+ parts.push(`kind = "${escapeTomlString(record.kind)}"`);
7407
+ if (record.kind === "redirect" && record.redirectToPath) {
7408
+ parts.push(`to = "/${escapeTomlString(record.redirectToPath)}"`);
7409
+ parts.push(`status = ${record.redirectType ?? 301}`);
7410
+ }
7411
+ if (record.kind === "archive" && record.archiveQuery) parts.push(`archive_query = "${escapeTomlString(record.archiveQuery)}"`);
7412
+ }
6889
7413
  return `${parts.join("\n")}\n`;
6890
7414
  }
6891
7415
  /**
7416
+ * The author's redirects as `_redirects` rules, or null when there are none.
7417
+ *
7418
+ * @param standalonePaths - Custom URLs that name no post or collection
7419
+ * @returns The `_redirects` section
7420
+ * @example
7421
+ * buildCustomUrlRedirects([redirectFromAtomXml]); // "# Redirects...\n/atom.xml /feed 301\n"
7422
+ */ function buildCustomUrlRedirects(standalonePaths) {
7423
+ const rules = standalonePaths.filter((record) => record.kind === "redirect" && record.redirectToPath).map((record) => [
7424
+ `/${record.path}`,
7425
+ `/${record.redirectToPath}`,
7426
+ record.redirectType ?? 301
7427
+ ]);
7428
+ if (rules.length === 0) return null;
7429
+ const width = Math.max(...rules.map(([from]) => from.length));
7430
+ return `# Redirects set up under Settings → Custom URLs on the live site.
7431
+
7432
+ ${rules.map(([from, to, status]) => `${from.padEnd(width)} ${to} ${status}`).join("\n")}
7433
+ `;
7434
+ }
7435
+ /** Repo-relative path of the Cloudflare Workers deploy config. */ var WRANGLER_CONFIG_PATH = "wrangler.jsonc";
7436
+ /**
7437
+ * Normalize a string into a name Cloudflare accepts for a Worker: lowercase
7438
+ * letters, digits, and hyphens, at most 63 characters.
7439
+ */ function toWorkerName(raw) {
7440
+ return raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 63).replace(/^-+|-+$/g, "");
7441
+ }
7442
+ /**
7443
+ * Pick the Worker name for `wrangler.jsonc`.
7444
+ *
7445
+ * Cloudflare requires this name to match the Worker in the dashboard, and its
7446
+ * repository-import flow names a new Worker after the repository — so the
7447
+ * repository name is the one value that lines up without the user editing
7448
+ * anything. An export with no repository behind it (a ZIP, or
7449
+ * `site export --directory`) uses the repository name the GitHub Sync
7450
+ * settings page prefills for this site, so pushing the export to a repository
7451
+ * created with that default still matches.
7452
+ *
7453
+ * @param repoName - The destination repository's name, without the owner.
7454
+ * @param siteUrl - The exported site's URL, used when there is no repository.
7455
+ * @returns A name Cloudflare accepts.
7456
+ * @example
7457
+ * deriveWorkerName("owenyoung-blog", "https://notes.example.com"); // "owenyoung-blog"
7458
+ * deriveWorkerName(null, "https://notes.example.com"); // "notes-jant-sync"
7459
+ */ function deriveWorkerName(repoName, siteUrl) {
7460
+ return toWorkerName(repoName ?? "") || toWorkerName(suggestSyncRepoName(siteUrl));
7461
+ }
7462
+ /**
7463
+ * Build the Cloudflare Workers deploy config for the exported site.
7464
+ *
7465
+ * Workers Builds has no "build output directory" field — that one belongs to
7466
+ * Pages — so `public/` can only be declared here. Without this file the
7467
+ * dashboard's default deploy command (`npx wrangler deploy`) fails on a repo
7468
+ * that has no Worker name to deploy under.
7469
+ *
7470
+ * `build.command` is here because nothing else runs Hugo. Workers Builds
7471
+ * detects frameworks from a `package.json`, which a Hugo site does not have,
7472
+ * so importing this repository leaves the build command empty and the deploy
7473
+ * fails on a `public/` that was never generated. Wrangler runs a custom build
7474
+ * before deploying, assets-only Workers included, which makes
7475
+ * `npx wrangler deploy` self-sufficient — on Cloudflare's image, on any CI,
7476
+ * and in a local checkout. Cloudflare's image ships Hugo extended, so the
7477
+ * command needs no install step; anyone who does fill in a dashboard build
7478
+ * command should leave it empty here to keep Hugo from running twice.
7479
+ *
7480
+ * Deliberately absent:
7481
+ * - `main`: a site with only static assets is a valid assets-only Worker.
7482
+ * - `not_found_handling`: the theme emits no `404.html` to point it at.
7483
+ *
7484
+ * @param config - The exported site's configuration.
7485
+ * @param repoName - The destination repository's name, when there is one.
7486
+ * @returns The contents of `wrangler.jsonc`.
7487
+ * @example
7488
+ * buildWranglerConfig(config, "my-blog"); // '{\n // Cloudflare Workers …'
7489
+ */ function buildWranglerConfig(config, repoName) {
7490
+ const name = deriveWorkerName(repoName, config.siteUrl);
7491
+ const compatibilityDate = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
7492
+ return `{
7493
+ // Cloudflare Workers deploy config for the built site.
7494
+ //
7495
+ // "name" must match the Worker's name in the Cloudflare dashboard: Workers
7496
+ // Builds fails the build when they differ, and a deploy run by hand under
7497
+ // another name goes to another Worker. A Worker imported from a repository
7498
+ // is named after the repository, so Jant uses the repository name when it
7499
+ // knows it, and otherwise the name GitHub Sync suggests for this site's
7500
+ // repository.
7501
+ //
7502
+ // Jant writes this file once and never overwrites it, so your edits stay.
7503
+ "$schema": "node_modules/wrangler/config-schema.json",
7504
+ "name": ${JSON.stringify(name)},
7505
+ "compatibility_date": ${JSON.stringify(compatibilityDate)},
7506
+ // Wrangler runs this before it uploads, so "npx wrangler deploy" builds the
7507
+ // site first. Leave the build command in the Cloudflare dashboard empty, or
7508
+ // Hugo runs twice.
7509
+ "build": {
7510
+ "command": "hugo --gc --minify"
7511
+ },
7512
+ "assets": {
7513
+ "directory": "./public"
7514
+ }
7515
+ }
7516
+ `;
7517
+ }
7518
+ /**
6892
7519
  * Build the `static/_redirects` file that keeps existing feed subscribers
6893
7520
  * working after the site moves to this export.
6894
7521
  *
@@ -6981,7 +7608,25 @@ Thumbs.db
6981
7608
  * @returns The table as Markdown, without a trailing newline.
6982
7609
  * @example
6983
7610
  * renderMarkdownTable(["Jant", "This export"], [["/feed", "/index.xml"]]);
6984
- */ function renderMarkdownTable(headers, rows) {
7611
+ */ /**
7612
+ * Convert a stored post body to the export's Markdown, naming the post when
7613
+ * it can't be converted. An export that dropped the body would read as a
7614
+ * complete archive and restore as an empty post.
7615
+ *
7616
+ * @param body - Stored TipTap JSON
7617
+ * @param slug - The post's slug, for the error
7618
+ * @returns The body as Markdown
7619
+ * @throws {Error} When the stored body isn't a TipTap document
7620
+ * @example
7621
+ * postBodyToMarkdown('{"type":"doc","content":[]}', "hello"); // ""
7622
+ */ function postBodyToMarkdown(body, slug) {
7623
+ try {
7624
+ return tiptapJsonToMarkdown(body);
7625
+ } catch (error) {
7626
+ throw new Error(`Couldn't convert the body of /${slug} to Markdown: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
7627
+ }
7628
+ }
7629
+ function renderMarkdownTable(headers, rows) {
6985
7630
  const cells = rows.map(([from, to]) => [`\`${from}\``, `\`${to}\``]);
6986
7631
  const widths = [Math.max(headers[0].length, ...cells.map((r) => r[0].length)), Math.max(headers[1].length, ...cells.map((r) => r[1].length))];
6987
7632
  const line = (a, b) => `| ${a.padEnd(widths[0])} | ${b.padEnd(widths[1])} |`;
@@ -7006,6 +7651,7 @@ Thumbs.db
7006
7651
  const mainFeed = config.mainRssFeed === "featured" ? featuredFeed : "/index.xml";
7007
7652
  const staticListing = config.rssFeedsEnabled ? "\n _redirects — Feed redirects (see Feeds above)" : "";
7008
7653
  const feedNote = config.rssFeedsEnabled ? "\n- Feed addresses are the exception: they move to `index.xml` and stay reachable only through `static/_redirects`. See [Feeds](#feeds)." : "";
7654
+ const redirectsNote = config.rssFeedsEnabled ? "`static/_redirects` needs no configuration here. Hugo copies it to `public/_redirects` and Workers applies the rules as published.\n\n" : "";
7009
7655
  const feedTable = renderMarkdownTable(["Jant", "This export"], [
7010
7656
  ["/feed", mainFeed],
7011
7657
  ["/latest/feed", "/index.xml"],
@@ -7019,7 +7665,7 @@ This is a static site exported from [Jant](https://github.com/jant-me/jant), rea
7019
7665
 
7020
7666
  ## Install Hugo
7021
7667
 
7022
- This export targets Hugo **extended 0.160.1+**.
7668
+ This export targets Hugo **extended 0.147.7+**.
7023
7669
 
7024
7670
  **macOS (Homebrew):**
7025
7671
 
@@ -7057,7 +7703,23 @@ hugo --minify
7057
7703
 
7058
7704
  The output goes to the \`public/\` directory. Upload it to any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, etc.).
7059
7705
 
7060
- ${config.rssFeedsEnabled ? `## Feeds
7706
+ ## Deploy to Cloudflare Workers
7707
+
7708
+ \`wrangler.jsonc\` at the root is the deploy config: it names the Worker, runs \`hugo --gc --minify\`, and points the upload at \`public/\`. Connect this repository to Cloudflare Workers Builds and leave the commands Cloudflare offers as they are:
7709
+
7710
+ | Field | Value |
7711
+ | --------------- | ------------------------------ |
7712
+ | Build command | leave empty |
7713
+ | Deploy command | \`npx wrangler deploy\` |
7714
+ | Version command | \`npx wrangler versions upload\` |
7715
+
7716
+ The build belongs to \`wrangler.jsonc\` rather than to that field: Workers Builds reads a \`package.json\` to detect a framework, a Hugo site has none, and an empty build command deploys a \`public/\` that was never built. Filling the field in as well makes Hugo run twice.
7717
+
7718
+ Check one thing before the first deploy: \`name\` in \`wrangler.jsonc\` has to match the Worker's name in the Cloudflare dashboard. Workers Builds fails the build when they differ, and a deploy run by hand under another name goes to another Worker. A Worker imported from a repository is named after the repository, so an export pushed by GitHub Sync uses the repository name. A downloaded export has no repository and uses the name GitHub Sync suggests when it creates one for this site. If the Worker is named something else, change \`name\` to match — Cloudflare names each build token \`<worker-name> build token\`, so the token list is one place to read it.
7719
+
7720
+ Jant writes \`wrangler.jsonc\` once and never overwrites it, so a corrected name survives later syncs.
7721
+
7722
+ ${redirectsNote}${config.rssFeedsEnabled ? `## Feeds
7061
7723
 
7062
7724
  The feed addresses changed. Jant served them under \`/feed\`; Hugo serves them as \`index.xml\` inside each section:
7063
7725
 
@@ -7067,12 +7729,15 @@ A reader who is already subscribed holds one of the old addresses, and a feed re
7067
7729
 
7068
7730
  Hugo's \`aliases:\` cannot cover this. An alias page redirects with a meta refresh and a script, and feed readers fetch XML without running either — only an HTTP redirect reaches them.
7069
7731
 
7732
+ Feed entries keep the IDs Jant gave them, so feed readers don't show old posts again. Each root post stores its ID in \`feed_id\`: the post's address on Jant, which is not its page URL here. Don't change \`feed_id\`, or feed readers show that post again. A post you add here without one uses its page URL.
7733
+
7070
7734
  The **Subscribe** entry in the site navigation points at \`${mainFeed}\`. The exported site has no \`/subscribe\` page; that page belongs to the Jant runtime.
7071
7735
 
7072
7736
  ` : ""}## Project structure
7073
7737
 
7074
7738
  \`\`\`
7075
7739
  hugo.toml — Site configuration (baseURL, title, theme, params)
7740
+ wrangler.jsonc — Cloudflare Workers deploy config (see Deploy above)
7076
7741
  content/
7077
7742
  _index.md — Home section
7078
7743
  archive/_index.md — Archive section
@@ -7322,7 +7987,8 @@ var GitHubApiError = class extends Error {
7322
7987
  computeManagedDeletions: () => computeManagedDeletions,
7323
7988
  createGitHubSyncService: () => createGitHubSyncService,
7324
7989
  isManagedPath: () => isManagedPath,
7325
- pathMatchesManagedGlob: () => pathMatchesManagedGlob
7990
+ pathMatchesManagedGlob: () => pathMatchesManagedGlob,
7991
+ selectFilesToWrite: () => selectFilesToWrite
7326
7992
  });
7327
7993
  /** Marker included in commit messages to prevent webhook loops. */ var SYNC_COMMIT_MARKER = "[jant-sync]";
7328
7994
  /**
@@ -7384,6 +8050,23 @@ var GitHubApiError = class extends Error {
7384
8050
  return JANT_MANAGED_GLOBS.some((g) => pathMatchesManagedGlob(path, g));
7385
8051
  }
7386
8052
  /**
8053
+ * Pick the export files this push should write.
8054
+ *
8055
+ * Everything is written every push except scaffolding the repo already has.
8056
+ * `wrangler.jsonc` is the case that needs this: its Worker name defaults to
8057
+ * the repository name, and a user whose Worker is named differently has to
8058
+ * correct it by hand. Rewriting the file every push would revert that, and
8059
+ * every build after it would fail on the name mismatch.
8060
+ *
8061
+ * @param exportFiles - Everything the export generated.
8062
+ * @param existingPaths - Repo-relative paths already present on the remote.
8063
+ * @returns The subset to include in the commit.
8064
+ * @example
8065
+ * selectFilesToWrite(files, new Set(["wrangler.jsonc"]));
8066
+ */ function selectFilesToWrite(exportFiles, existingPaths) {
8067
+ return exportFiles.filter((file) => isStoredExportFile(file) || !file.scaffoldOnce || !existingPaths.has(file.path));
8068
+ }
8069
+ /**
7387
8070
  * Compute the tree items that should null-out files on the remote HEAD
7388
8071
  * which Jant claims ownership of (matches `JANT_MANAGED_GLOBS`) but is
7389
8072
  * not writing in the current push (not in `writtenPaths`).
@@ -7554,37 +8237,43 @@ function createGitHubSyncService(services, siteId, siteConfig, deps = {}) {
7554
8237
  const { client, owner, repo } = createClient(config);
7555
8238
  const exportFiles = await createExportService(services, siteConfig, {
7556
8239
  storage: deps.storage,
7557
- bundleMedia: false
8240
+ bundleMedia: false,
8241
+ repoName: repo
7558
8242
  }).generateHugoFiles();
7559
8243
  const defaultBranch = (await client.getRepo(owner, repo)).default_branch;
7560
8244
  const now = Math.floor(Date.now() / 1e3);
7561
8245
  const existingMarkerBeforeInit = await client.getFileContent(owner, repo, JANT_SYNC_MARKER_PATH).catch(() => null);
7562
8246
  const marker = buildMarker(existingMarkerBeforeInit ? decodeMarkerContent(existingMarkerBeforeInit) : null, now);
7563
8247
  const { sha: headSha } = await getOrInitHead(client, owner, repo, defaultBranch, marker);
8248
+ const headCommit = await client.getCommit(owner, repo, headSha);
8249
+ const headTree = await client.getTree(owner, repo, headCommit.treeSha, { recursive: true });
8250
+ if (headTree.truncated) throw new Error("GitHub tree exceeds API limits (>100k entries or >7MB); incremental deletion cannot run safely against this repo.");
8251
+ const existingPaths = new Set(headTree.tree.filter((item) => item.type === "blob").map((item) => item.path));
7564
8252
  const treeItems = [{
7565
8253
  path: JANT_SYNC_MARKER_PATH,
7566
8254
  mode: "100644",
7567
8255
  type: "blob",
7568
8256
  content: formatMarker(marker)
7569
8257
  }];
7570
- for (const file of exportFiles) if (typeof file.content === "string") treeItems.push({
7571
- path: file.path,
7572
- mode: "100644",
7573
- type: "blob",
7574
- content: file.content
7575
- });
7576
- else {
7577
- const blob = await client.createBlob(owner, repo, uint8ArrayToBase64(file.content), "base64");
7578
- treeItems.push({
8258
+ for (const file of selectFilesToWrite(exportFiles, existingPaths)) {
8259
+ const content = isStoredExportFile(file) ? deps.storage ? await readStoredExportFile(file, deps.storage) : null : file.content;
8260
+ if (content === null) continue;
8261
+ if (typeof content === "string") treeItems.push({
7579
8262
  path: file.path,
7580
8263
  mode: "100644",
7581
8264
  type: "blob",
7582
- sha: blob.sha
8265
+ content
7583
8266
  });
8267
+ else {
8268
+ const blob = await client.createBlob(owner, repo, uint8ArrayToBase64(content), "base64");
8269
+ treeItems.push({
8270
+ path: file.path,
8271
+ mode: "100644",
8272
+ type: "blob",
8273
+ sha: blob.sha
8274
+ });
8275
+ }
7584
8276
  }
7585
- const headCommit = await client.getCommit(owner, repo, headSha);
7586
- const headTree = await client.getTree(owner, repo, headCommit.treeSha, { recursive: true });
7587
- if (headTree.truncated) throw new Error("GitHub tree exceeds API limits (>100k entries or >7MB); incremental deletion cannot run safely against this repo.");
7588
8277
  const writtenPaths = new Set(treeItems.map((item) => item.path));
7589
8278
  treeItems.push(...computeManagedDeletions(headTree.tree, writtenPaths));
7590
8279
  const tree = await client.createTree(owner, repo, treeItems, headCommit.treeSha);
@@ -7706,4 +8395,4 @@ function uint8ArrayToBase64(bytes) {
7706
8395
  return btoa(binary);
7707
8396
  }
7708
8397
  //#endregion
7709
- export { getHostedControlPlaneBaseUrl as $, HOME_BRANDING_LINK_LABEL as $t, upgradeLegacyFootnotes as A, toAbsoluteSiteUrl as An, MAX_SITE_DESCRIPTION_LENGTH as At, env_exports as B, SMART_COLLECTION_SORT_ORDERS as Bt, extractSummary as C, isFullUrl as Cn, EARLIEST_FILTERABLE_YEAR as Ct, renderTiptapDocumentAroundBoundary as D, sanitizeUrl as Dn, MAX_COLLECTION_DESCRIPTION_LENGTH as Dt, renderTiptapDocument as E, normalizeSiteUrl as En, LATEST_FILTERABLE_YEAR as Et, formatYearMonth as F, url_exports as Fn, PATH_KINDS as Ft, getConfiguredStorageDriver as G, SYSTEM_NAV_KEY_VALUES as Gt, getConfiguredSingleSiteOrigin as H, STATUSES as Ht, formatYearMonthLabel as I, escapeHtml as In, PUBLIC_ARCHIVE_VISIBILITIES as It, getDiscoverDefault as J, VISIBILITIES as Jt, getCorsOrigins as K, TEXT_ATTACHMENT_CONTENT_FORMATS as Kt, now as L, __commonJSMin as Ln, SITE_DOMAIN_KINDS as Lt, formatRelativeAge as M, toPublicHref as Mn, MEDIA_KINDS as Mt, formatRelativeTime as N, toPublicPath as Nn, NAV_ITEM_PLACEMENTS as Nt, renderTiptapJson as O, stripSitePathPrefix as On, MAX_MEDIA_ATTACHMENTS as Ot, formatTime as P, toSameSitePath as Pn, NAV_ITEM_TYPES as Pt, getGitHubAppConfig as Q, getPublicUrlForProvider as Qt, time_exports as R, __exportAll as Rn, SITE_MEMBER_ROLES as Rt, extractBodyText as S, getSitePathPrefix$1 as Sn, DEFAULT_NAVIGATION_PROFILE as St, extractTimelineSummary as T, normalizePath as Tn, GITHUB_APP_ACCOUNT_TYPES as Tt, getConfiguredSingleSitePathPrefix as U, STORAGE_DRIVERS as Ut, getAuthSecret as V, SORT_ORDERS as Vt, getConfiguredSingleSiteUrl as W, SYSTEM_NAV_KEYS as Wt, getDiscoverPingUrl as X, getImageUrl as Xt, getDiscoverDirectoryBaseUrl as Y, isFeedNavKey as Yt, getEnvString as Z, getMediaUrl as Zt, markdownToTiptapJson as _, base64ToUint8Array as _n, ARCHIVE_VISIBILITIES as _t, github_api_exports as a, getDefaultJantFaviconIcoBytes as an, getInternalAdminToken as at, toPlainText as b, extractDomain as bn, COLLECTION_SORT_ORDERS as bt, export_exports as c, getJantIconFilename as cn, getSiteResolutionMode as ct, buildInstallUrl as d, getJantLogoFills as dn, shouldTrustProxy as dt, HOME_BRANDING_PREFIX as en, getHostedControlPlaneDomainCheckSecret as et, getInstallation as f, getJantLogoHref as fn, shouldUseSecureCookies as ft, tiptapJsonToMarkdown as g, arrayBufferToBase64 as gn, ARCHIVE_LAYOUTS as gt, searchInstallationRepos as h, JANT_LOGO_VIEW_BOX as hn, THEME_MODES as ht, createGitHubClient as i, getDefaultJantAppleTouchIconBytes as in, getHostedControlPlaneSsoSecret as it, formatDate as j, toInternalPath as jn, MAX_SITE_FOOTER_LENGTH as jt, trimTiptapBody as k, toAbsoluteAssetUrl as kn, MAX_PINNED_POSTS as kt, buildRootActivityExpr as l, getJantIconHref as ln, getTelegramBotPool as lt, listInstallationReposPage as m, JANT_LOGO_PATH_DATA as mn, CONFIG_FIELDS as mt, createGitHubSyncService as n, JANT_HOME_URL as nn, getHostedControlPlaneInternalToken as nt, parseRepoSlug as o, getJantBrandPackHref as on, getLocalStoragePath as ot, github_app_exports as p, getJantPositiveLogoPngHref as pn, coalesceDisplayText as pt, getDevApiToken as q, UPLOAD_SESSION_STATES as qt, github_sync_exports as r, JANT_POSITIVE_LOGO_PNG_FILENAME as rn, getHostedControlPlaneProviderLabel as rt, createExportService as s, getJantBundledAsset as sn, getPort as st, SYNC_COMMIT_MARKER as t, JANT_BRAND_PACK_FILENAME as tn, getHostedControlPlaneInternalBaseUrl as tt, rootActivityColumns as u, getJantLogoFilename as un, getTelegramWebhookSecret as ut, markdown_exports as v, buildSiteUrl as vn, COLLECTION_DIRECTORY_ENTRY_TYPES as vt, extractSummaryHtml as w, isSafeInternalRedirect as wn, FORMATS as wt, NOTE_SUMMARY_MAX_CHARS as x, getSiteOrigin$1 as xn, CONTENT_DISPOSITIONS as xt, render as y, extractDisplayDomain as yn, COLLECTION_FRESHNESS_WINDOW_SECONDS as yt, toISOString as z, __toESM as zn, SITE_STATUSES as zt };
8398
+ export { getGitHubAppConfig as $, getPublicUrlForProvider as $t, trimTiptapBody as A, stripSitePathPrefix as An, MAX_PINNED_POSTS as At, toISOString as B, __exportAll as Bn, SITE_STATUSES as Bt, extractBodyText as C, getSiteOrigin$1 as Cn, DEFAULT_NAVIGATION_PROFILE as Ct, renderTiptapDocument as D, normalizePath as Dn, LATEST_FILTERABLE_YEAR as Dt, extractTimelineSummary as E, isSafeInternalRedirect as En, GITHUB_APP_ACCOUNT_TYPES as Et, formatTime as F, toPublicPath as Fn, NAV_ITEM_TYPES as Ft, getConfiguredSingleSiteUrl as G, SYSTEM_NAV_KEYS as Gt, getAuthSecret as H, SORT_ORDERS as Ht, formatYearMonth as I, toSameSitePath as In, PATH_KINDS as It, getDevApiToken as J, UPLOAD_SESSION_STATES as Jt, getConfiguredStorageDriver as K, SYSTEM_NAV_KEY_VALUES as Kt, formatYearMonthLabel as L, url_exports as Ln, PUBLIC_ARCHIVE_VISIBILITIES as Lt, formatDate as M, toAbsoluteSiteUrl as Mn, MAX_SITE_FOOTER_LENGTH as Mt, formatRelativeAge as N, toInternalPath as Nn, MEDIA_KINDS as Nt, renderTiptapDocumentAroundBoundary as O, normalizeSiteUrl as On, MAX_COLLECTION_DESCRIPTION_LENGTH as Ot, formatRelativeTime as P, toPublicHref as Pn, NAV_ITEM_PLACEMENTS as Pt, getEnvString as Q, getMediaUrl as Qt, now as R, escapeHtml as Rn, SITE_DOMAIN_KINDS as Rt, NOTE_SUMMARY_MAX_CHARS as S, getPostPath as Sn, CONTENT_DISPOSITIONS as St, extractSummaryHtml as T, isFullUrl as Tn, FORMATS as Tt, getConfiguredSingleSiteOrigin as U, STATUSES as Ut, env_exports as V, __toESM as Vn, SMART_COLLECTION_SORT_ORDERS as Vt, getConfiguredSingleSitePathPrefix as W, STORAGE_DRIVERS as Wt, getDiscoverDirectoryBaseUrl as X, isFeedNavKey as Xt, getDiscoverDefault as Y, VISIBILITIES as Yt, getDiscoverPingUrl as Z, getImageUrl as Zt, markdownToTiptapJson as _, arrayBufferToBase64 as _n, ARCHIVE_LAYOUTS as _t, github_api_exports as a, getDefaultJantAppleTouchIconBytes as an, getHostedControlPlaneSsoSecret as at, toPlainText as b, extractDisplayDomain as bn, COLLECTION_FRESHNESS_WINDOW_SECONDS as bt, buildRootActivityExpr as c, getJantBundledAsset as cn, getPort as ct, buildInstallUrl as d, getJantLogoFilename as dn, getTelegramWebhookSecret as dt, HOME_BRANDING_LINK_LABEL as en, getHostedControlPlaneBaseUrl as et, getInstallation as f, getJantLogoFills as fn, shouldTrustProxy as ft, tiptapJsonToMarkdown as g, JANT_LOGO_VIEW_BOX as gn, THEME_MODES as gt, searchInstallationRepos as h, JANT_LOGO_PATH_DATA as hn, CONFIG_FIELDS as ht, createGitHubClient as i, JANT_POSITIVE_LOGO_PNG_FILENAME as in, getHostedControlPlaneProviderLabel as it, upgradeLegacyFootnotes as j, toAbsoluteAssetUrl as jn, MAX_SITE_DESCRIPTION_LENGTH as jt, renderTiptapJson as k, sanitizeUrl as kn, MAX_MEDIA_ATTACHMENTS as kt, rootActivityColumns as l, getJantIconFilename as ln, getSiteResolutionMode as lt, listInstallationReposPage as m, getJantPositiveLogoPngHref as mn, coalesceDisplayText as mt, createGitHubSyncService as n, JANT_BRAND_PACK_FILENAME as nn, getHostedControlPlaneInternalBaseUrl as nt, parseRepoSlug as o, getDefaultJantFaviconIcoBytes as on, getInternalAdminToken as ot, github_app_exports as p, getJantLogoHref as pn, shouldUseSecureCookies as pt, getCorsOrigins as q, TEXT_ATTACHMENT_CONTENT_FORMATS as qt, github_sync_exports as r, JANT_HOME_URL as rn, getHostedControlPlaneInternalToken as rt, createExportService as s, getJantBrandPackHref as sn, getLocalStoragePath as st, SYNC_COMMIT_MARKER as t, HOME_BRANDING_PREFIX as tn, getHostedControlPlaneDomainCheckSecret as tt, suggestSyncRepoName as u, getJantIconHref as un, getTelegramBotPool as ut, markdown_exports as v, base64ToUint8Array as vn, ARCHIVE_VISIBILITIES as vt, extractSummary as w, getSitePathPrefix$1 as wn, EARLIEST_FILTERABLE_YEAR as wt, fillRequiredContent as x, extractDomain as xn, COLLECTION_SORT_ORDERS as xt, render as y, buildSiteUrl as yn, COLLECTION_DIRECTORY_ENTRY_TYPES as yt, time_exports as z, __commonJSMin as zn, SITE_MEMBER_ROLES as zt };