@jant/core 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
|
@@ -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
|
-
*
|
|
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 -
|
|
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/
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
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.
|
|
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$
|
|
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>…</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>…</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\" }}
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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 :=
|
|
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 :=
|
|
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 :=
|
|
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.
|
|
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 →\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.
|
|
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 →\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\"> ★ </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
|
-
*/
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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$
|
|
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$
|
|
6555
|
+
content: list_default$4
|
|
6153
6556
|
});
|
|
6154
6557
|
exportFiles.push({
|
|
6155
6558
|
path: "themes/jant/layouts/featured/list.html",
|
|
6156
|
-
content: list_default$
|
|
6559
|
+
content: list_default$3
|
|
6157
6560
|
});
|
|
6158
6561
|
exportFiles.push({
|
|
6159
6562
|
path: "themes/jant/layouts/archive/list.html",
|
|
6160
|
-
content: list_default$
|
|
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
|
|
6259
|
-
const
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
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
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
aliases.push(
|
|
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:
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
6542
|
-
*
|
|
6543
|
-
*
|
|
6544
|
-
*
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
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
|
|
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
|
-
*/
|
|
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.
|
|
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
|
-
|
|
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
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|