@zvndev/darkmown 1.2.1 → 1.4.0

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.
@@ -32,6 +32,13 @@
32
32
  * @property {Set<string>} behaviors Names of framework behavior modules this page
33
33
  * needs (`slider` is compile-time only and never appears here; `sortable`,
34
34
  * `carousel` each emit a pay-for-what-you-use `/__wd/behaviors/<name>.js`).
35
+ * @property {boolean} hasCode Whether the page has a build-time-highlighted code
36
+ * block, so the framework highlight stylesheet (`/__wd/highlight.css`) is
37
+ * emitted and linked — pay-for-what-you-use, zero JS.
38
+ * @property {Set<string>} scopedSkins Source paths of colocated `.skin` files
39
+ * that opted into scoping (`scoped` first line). The builder emits these with
40
+ * the scope attribute and runs the unused-selector warning against the stamped
41
+ * subtree; the HTML stamp uses them to scope the matching page/include subtree.
35
42
  */
36
43
 
37
44
  /**
@@ -140,7 +147,9 @@ export function createCompilation() {
140
147
  scripts: new Set(),
141
148
  files: new Map(),
142
149
  runtime: false,
143
- behaviors: new Set()
150
+ behaviors: new Set(),
151
+ hasCode: false,
152
+ scopedSkins: new Set()
144
153
  },
145
154
  state: new Map(),
146
155
  stores: new Set(),
@@ -12,7 +12,7 @@
12
12
  import { compileBody } from "./body.js";
13
13
  import { at, createScope, LOOP_META } from "./context.js";
14
14
  import { FORMATTERS } from "./format.js";
15
- import { resolveInclude } from "./includes.js";
15
+ import { resolveInclude, scopedSkinFor } from "./includes.js";
16
16
  import {
17
17
  escapeHtml,
18
18
  getPath,
@@ -26,7 +26,7 @@ import {
26
26
  stripQuotes,
27
27
  validatePath
28
28
  } from "./interpolation.js";
29
- import { compileFile } from "./page.js";
29
+ import { compileFile, stampScope } from "./page.js";
30
30
  import { compileComputedExpr, compileWhen, evalPredicate } from "./predicates.js";
31
31
 
32
32
  /**
@@ -45,17 +45,23 @@ export function handleInclude(line, ctx, index) {
45
45
  if (!match) throw new Error(`Malformed @include in ${at(ctx.file, index)}: ${line}`);
46
46
  const target = resolveInclude(match[1], ctx.file, ctx.context);
47
47
  const args = parseIncludeArgs(match[2] || "", ctx);
48
- const scope = createScope(ctx.scope, args);
48
+ const childScope = createScope(ctx.scope, args);
49
49
  const child = compileFile(
50
50
  target,
51
51
  ctx.context,
52
52
  ctx.stack,
53
- scope,
53
+ childScope,
54
54
  ctx.comp,
55
55
  ctx.sections,
56
56
  ctx.loopItem
57
57
  );
58
- return child.html;
58
+ // An include with its OWN scoped colocated skin stamps just its returned
59
+ // subtree — so a scoped `.card` in the include can't collide with a `.card`
60
+ // anywhere else on the page, and the scope never leaks to the include's
61
+ // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
62
+ // the same path-derived id, so attribute and stylesheet line up.
63
+ const scopedSkin = scopedSkinFor(target, ctx.context);
64
+ return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
59
65
  }
60
66
 
61
67
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { scopeIdFor } from "../skin.js";
9
10
  import { pageIncludeExtensions } from "./context.js";
10
11
  import { stripQuotes } from "./interpolation.js";
11
12
 
@@ -15,6 +16,47 @@ import { stripQuotes } from "./interpolation.js";
15
16
  * @typedef {import("./context.js").Compilation} Compilation
16
17
  */
17
18
 
19
+ /**
20
+ * Read a `.skin` file's first meaningful line (skipping `//` line comments,
21
+ * `/* … *\/` block comments, blanks, and decorative dividers — the same skip
22
+ * rules `compileSkin` applies) to decide whether it opts into scoping. A skin
23
+ * scopes its selectors ONLY when that first line is exactly `scoped`; otherwise
24
+ * it is global and untouched. This is the single source for "is this skin
25
+ * scoped + what is its id", so the CSS rewrite (builder) and the HTML stamp
26
+ * (page/include) always agree on both.
27
+ * @param {string} skinPath Absolute path to the colocated `.skin` file.
28
+ * @param {string} cwd Project root, for the path-based scope id.
29
+ * @returns {{ scoped: boolean, scopeId: string }}
30
+ */
31
+ export function scopeInfoForSkin(skinPath, cwd) {
32
+ const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
33
+ const scopeId = scopeIdFor(rel);
34
+ const src = fs.readFileSync(skinPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
35
+ for (const raw of src.split("\n")) {
36
+ const t = raw.trim();
37
+ if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
38
+ return { scoped: t === "scoped", scopeId };
39
+ }
40
+ return { scoped: false, scopeId };
41
+ }
42
+
43
+ /**
44
+ * The scope info for a page/include's OWN colocated `.skin` sibling, or null
45
+ * when there is no sibling skin. Used by the HTML stamp sites: a route's own
46
+ * scoped skin → stamp the page body; an include's scoped skin → stamp the
47
+ * returned subtree. Resolution mirrors `collectColocatedAssets` (same stem,
48
+ * `.skin` extension).
49
+ * @param {string} file Absolute path to the page/include source file.
50
+ * @param {Paths} context
51
+ * @returns {{ scoped: boolean, scopeId: string } | null}
52
+ */
53
+ export function scopedSkinFor(file, context) {
54
+ const ext = path.extname(file);
55
+ const candidate = `${file.slice(0, -ext.length)}.skin`;
56
+ if (!fs.existsSync(candidate)) return null;
57
+ return scopeInfoForSkin(candidate, context.cwd);
58
+ }
59
+
18
60
  /**
19
61
  * Register a page's colocated `.skin`/`.js` siblings as emitted assets.
20
62
  * @param {string} file
@@ -35,7 +77,12 @@ export function collectColocatedAssets(file, context, assets) {
35
77
  const outputExt = assetExt === ".skin" ? ".css" : ".js";
36
78
  const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
37
79
  assets.files.set(candidate, publicPath);
38
- if (assetExt === ".skin") assets.skins.add(publicPath);
80
+ if (assetExt === ".skin") {
81
+ assets.skins.add(publicPath);
82
+ // Surface "this skin is scoped" so the warning pass (builder) and the HTML
83
+ // stamp can find the scoped skins among all colocated ones by source path.
84
+ if (scopeInfoForSkin(candidate, context.cwd).scoped) assets.scopedSkins.add(candidate);
85
+ }
39
86
  if (assetExt === ".js") assets.scripts.add(publicPath);
40
87
  }
41
88
  }
@@ -20,4 +20,4 @@
20
20
  export { parseFrontmatter } from "./frontmatter.js";
21
21
  export { escapeHtml } from "./interpolation.js";
22
22
  export { loopKeyOf } from "./loops.js";
23
- export { compileDocument, compilePage, enhanceImages } from "./page.js";
23
+ export { compileDocument, compilePage, enhanceImages, stampScope } from "./page.js";
@@ -6,6 +6,7 @@
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
8
  import MarkdownIt from "markdown-it";
9
+ import { highlightCode } from "../highlight.js";
9
10
  import { LOOP_META } from "./context.js";
10
11
  import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
11
12
  import {
@@ -21,7 +22,11 @@ import {
21
22
  * @typedef {import("./context.js").Ctx} Ctx
22
23
  */
23
24
 
24
- const md = new MarkdownIt({ html: true });
25
+ // Build-time syntax highlighting: highlight.js runs over every fenced code block
26
+ // with a known language. The callback returns escaped token HTML or `""` to let
27
+ // markdown-it render a plain escaped `<code>` (graceful degradation). Both
28
+ // instances share it so `html: false` pages highlight too.
29
+ const md = new MarkdownIt({ html: true, highlight: highlightCode });
25
30
  md.use(bindingPlugin);
26
31
  md.use(attrsPlugin);
27
32
 
@@ -39,7 +44,7 @@ export function selectMd(meta) {
39
44
  // Frontmatter scalars stay strings (no coercion), so accept both forms.
40
45
  if (meta?.html !== false && meta?.html !== "false") return md;
41
46
  if (!mdNoHtml) {
42
- mdNoHtml = new MarkdownIt({ html: false });
47
+ mdNoHtml = new MarkdownIt({ html: false, highlight: highlightCode });
43
48
  mdNoHtml.use(bindingPlugin);
44
49
  mdNoHtml.use(attrsPlugin);
45
50
  }
@@ -8,10 +8,11 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { imageSize } from "image-size";
11
+ import { htmlHasHighlight } from "../highlight.js";
11
12
  import { compileBody } from "./body.js";
12
13
  import { createCompilation, createScope } from "./context.js";
13
14
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
14
- import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
15
+ import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
15
16
  import { escapeHtml } from "./interpolation.js";
16
17
  import { selectMd } from "./markdown.js";
17
18
 
@@ -29,9 +30,12 @@ import { selectMd } from "./markdown.js";
29
30
  * Compile a page source file into a full HTML document plus its assets.
30
31
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
31
32
  * @param {Paths} context Resolved project paths.
33
+ * @param {{ feed?: { href: string, title: string } }} [options] When a site-wide
34
+ * RSS feed is emitted (the home page set `site_url` and the site has dated
35
+ * posts), `feed` carries its absolute href + title so every page links it.
32
36
  * @returns {CompiledPage}
33
37
  */
34
- export function compilePage(file, context) {
38
+ export function compilePage(file, context, options = {}) {
35
39
  const compiled = compileDocument(file, context);
36
40
  const title = compiled.meta.title || "Darkmown";
37
41
  const description = compiled.meta.description || "";
@@ -58,9 +62,21 @@ export function compilePage(file, context) {
58
62
  const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
59
63
  const favicon =
60
64
  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
61
- const cssLinks = [...compiled.assets.skins]
65
+ // The framework highlight stylesheet rides ahead of page skins so a project's
66
+ // own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
67
+ // linked only on pages with a highlighted code block (`hasCode`).
68
+ const highlightLink = compiled.assets.hasCode
69
+ ? [`<link rel="stylesheet" href="/__wd/highlight.css">`]
70
+ : [];
71
+ const cssLinks = [...highlightLink, ...compiled.assets.skins]
62
72
  .map((href) => `<link rel="stylesheet" href="${href}">`)
63
73
  .join("\n");
74
+ // RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
75
+ // at least one dated post), every page advertises it so readers/aggregators
76
+ // can autodiscover the feed. Build-time only — no client JS.
77
+ const feedLink = options.feed
78
+ ? `\n <link rel="alternate" type="application/rss+xml" title="${escapeHtml(options.feed.title)}" href="${escapeHtml(options.feed.href)}">`
79
+ : "";
64
80
  // Pay-for-what-you-use behavior modules (slider is compile-time and never here;
65
81
  // sortable/carousel each emit one) load after the runtime — sortable depends on
66
82
  // `window.wd` — and before any colocated page script.
@@ -116,7 +132,14 @@ export function compilePage(file, context) {
116
132
  ? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
117
133
  : "";
118
134
 
119
- const body = enhanceImages(compiled.html, context);
135
+ // A route whose OWN colocated skin opted into scoping stamps the whole page
136
+ // body so its scoped selectors match. Pure compile-time string post-pass (like
137
+ // enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
138
+ // static. Include-scoped skins are stamped earlier, in handleInclude.
139
+ const scopedSkin = scopedSkinFor(file, context);
140
+ const stamped =
141
+ scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
142
+ const body = enhanceImages(stamped, context);
120
143
 
121
144
  return {
122
145
  meta: compiled.meta,
@@ -126,7 +149,7 @@ export function compilePage(file, context) {
126
149
  <meta charset="utf-8">
127
150
  <meta name="viewport" content="width=device-width, initial-scale=1">
128
151
  <title>${escapeHtml(title)}</title>${descriptionTag}
129
- <link rel="icon" href="${favicon}">
152
+ <link rel="icon" href="${favicon}">${feedLink}
130
153
  ${cssLinks}${transitions}${speculation}
131
154
  </head>
132
155
  <body>
@@ -176,6 +199,30 @@ export function enhanceImages(html, paths) {
176
199
  });
177
200
  }
178
201
 
202
+ /**
203
+ * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
204
+ * half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
205
+ * post-pass over assembled HTML, zero runtime JS. Every element start tag that
206
+ * doesn't already carry a scope attribute gets one, so the scoped stylesheet
207
+ * (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
208
+ * and nowhere else. Void/self-closing tags are stamped too (the attribute is
209
+ * valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
210
+ * families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
211
+ * nested include with its own scope) keeps its own — first stamp wins.
212
+ * @param {string} html Subtree HTML to scope.
213
+ * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
214
+ * @returns {string}
215
+ */
216
+ export function stampScope(html, scopeId) {
217
+ return html.replace(
218
+ /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
219
+ (tag, name, attrs, slash) => {
220
+ if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
221
+ return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
222
+ }
223
+ );
224
+ }
225
+
179
226
  /**
180
227
  * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
181
228
  * @param {string} tag
@@ -253,7 +300,9 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
253
300
 
254
301
  if (path.extname(file) === ".md") {
255
302
  scanMarkdownHints(body, file, comp);
256
- return { meta, html: selectMd(meta).render(body, {}) };
303
+ const html = selectMd(meta).render(body, {});
304
+ if (htmlHasHighlight(html)) comp.assets.hasCode = true;
305
+ return { meta, html };
257
306
  }
258
307
 
259
308
  // Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
@@ -269,5 +318,7 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
269
318
  loopItem,
270
319
  md: selectMd(meta)
271
320
  };
272
- return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
321
+ const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
322
+ if (htmlHasHighlight(html)) comp.assets.hasCode = true;
323
+ return { meta, html };
273
324
  }
package/src/compiler.js CHANGED
@@ -22,5 +22,6 @@ export {
22
22
  enhanceImages,
23
23
  escapeHtml,
24
24
  loopKeyOf,
25
- parseFrontmatter
25
+ parseFrontmatter,
26
+ stampScope
26
27
  } from "./compiler/index.js";
package/src/dev.js CHANGED
@@ -2,15 +2,44 @@ export const devClientPath = "/__wd/dev-client.js";
2
2
  export const devEventsPath = "/__wd/dev-events";
3
3
 
4
4
  /**
5
- * Inject the dev-client `<script>` before `</body>` (or append it if absent).
5
+ * Inject the dev-client `<script>` before `</body>` (or append it if absent),
6
+ * plus — for a `draft: true` page — an inline draft-banner script. Both are
7
+ * injected at dev-SERVE time only, so neither ever touches the shipped HTML on
8
+ * disk (a production build excludes drafts entirely; a `build --drafts` staging
9
+ * build emits clean HTML with no banner).
6
10
  * @param {string} html Page HTML to augment.
11
+ * @param {{ draft?: boolean }} [options] `draft: true` adds the visible banner.
7
12
  * @returns {string}
8
13
  */
9
- export function injectDevClient(html) {
10
- const script = `<script type="module" src="${devClientPath}"></script>`;
14
+ export function injectDevClient(html, options = {}) {
15
+ const scripts = [`<script type="module" src="${devClientPath}"></script>`];
16
+ if (options.draft) scripts.push(`<script>${draftBannerScript()}</script>`);
17
+ const block = scripts.join("\n");
11
18
  return html.includes("</body>")
12
- ? html.replace("</body>", `${script}\n</body>`)
13
- : `${html}\n${script}`;
19
+ ? html.replace("</body>", `${block}\n</body>`)
20
+ : `${html}\n${block}`;
21
+ }
22
+
23
+ /**
24
+ * The inline dev-only draft banner: a fixed bar marking the page as an unpublished
25
+ * draft, so it's unmistakable in `darkmown dev` that this content is excluded from
26
+ * a production build. Inert (`pointer-events:none`) and high-z so it never blocks
27
+ * the page; dev-serve-injected, so it's never in shipped output.
28
+ * @returns {string}
29
+ */
30
+ export function draftBannerScript() {
31
+ return `
32
+ const banner = document.createElement("div");
33
+ banner.id = "__wd-draft-banner";
34
+ banner.textContent = "DRAFT — excluded from production build";
35
+ banner.style.cssText = [
36
+ "position:fixed", "top:0", "left:0", "right:0", "z-index:99998",
37
+ "background:#b4541b", "color:#fff8f0", "text-align:center",
38
+ "font:600 12px/2.2 ui-monospace,SFMono-Regular,Menlo,monospace",
39
+ "letter-spacing:.08em", "pointer-events:none"
40
+ ].join(";");
41
+ document.addEventListener("DOMContentLoaded", () => document.body.prepend(banner));
42
+ `;
14
43
  }
15
44
 
16
45
  /**
package/src/feeds.js ADDED
@@ -0,0 +1,276 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Feeds & crawler files: sitemap.xml, rss.xml, robots.txt. All build-time only
3
+ // (zero runtime, zero client JS) and emitted from the post-draft-filter route
4
+ // list, so a `draft: true` page never reaches a feed. Site identity (origin,
5
+ // channel title/description) lives in the HOME page frontmatter (`site_url` +
6
+ // the existing `title`/`description`) — there is no config loader this cycle.
7
+ //
8
+ // Mirrors `src/highlight.js`: this module owns the strings; `src/builder.js`
9
+ // owns the emission. Pure string builders + a `lastmodFor(file)` with a
10
+ // git → mtime fallback so scaffolded apps / tarballs (no git repo) still date.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ /**
18
+ * @typedef {import("./router.js").Route} Route
19
+ */
20
+
21
+ /**
22
+ * A route paired with its source file's last-modified date (ISO yyyy-mm-dd),
23
+ * ready for a sitemap `<lastmod>`.
24
+ * @typedef {object} DatedRoute
25
+ * @property {string} loc Absolute URL (`site_url` + route path).
26
+ * @property {string} lastmod ISO date (`yyyy-mm-dd`).
27
+ */
28
+
29
+ /**
30
+ * XML-escape a text value for use inside an element or attribute. Covers the
31
+ * five predefined XML entities — the same set RSS/sitemap consumers expect.
32
+ * @param {string} value
33
+ * @returns {string}
34
+ */
35
+ export function escapeXml(value) {
36
+ return String(value)
37
+ .replaceAll("&", "&amp;")
38
+ .replaceAll("<", "&lt;")
39
+ .replaceAll(">", "&gt;")
40
+ .replaceAll('"', "&quot;")
41
+ .replaceAll("'", "&apos;");
42
+ }
43
+
44
+ /**
45
+ * Join the site origin and a route path into one absolute URL, collapsing the
46
+ * boundary slash so `https://x.com` + `/about/` → `https://x.com/about/` (the
47
+ * home route `/` → `https://x.com/`). `siteUrl` is expected without a trailing
48
+ * slash (the documented contract); a stray one is tolerated.
49
+ * @param {string} siteUrl Absolute origin, e.g. `https://example.com`.
50
+ * @param {string} route Route path (always trailing-slashed, e.g. `/about/`).
51
+ * @returns {string}
52
+ */
53
+ export function absoluteUrl(siteUrl, route) {
54
+ return `${siteUrl.replace(/\/$/, "")}${route}`;
55
+ }
56
+
57
+ /**
58
+ * The last-modified date for a source file, as an ISO `yyyy-mm-dd` string.
59
+ * Resolution order: the file's frontmatter `date:` is handled by the caller
60
+ * (it wins); this helper covers the fallback chain — the git last-commit date
61
+ * of the file, else the filesystem mtime. Git is wrapped in try/catch so a
62
+ * scaffolded app or an extracted tarball (no `.git`) degrades to mtime instead
63
+ * of crashing the build.
64
+ * @param {string} file Absolute path to the source file.
65
+ * @returns {string} ISO date (`yyyy-mm-dd`).
66
+ */
67
+ export function lastmodFor(file) {
68
+ try {
69
+ // `%cs` is the committer date in strict ISO short form (yyyy-mm-dd). `-1`
70
+ // limits to the most recent commit touching this file. A file that is
71
+ // tracked but never committed (or outside a repo) yields empty stdout.
72
+ const out = execFileSync("git", ["log", "-1", "--format=%cs", "--", path.basename(file)], {
73
+ cwd: path.dirname(file),
74
+ encoding: "utf8",
75
+ stdio: ["ignore", "pipe", "ignore"]
76
+ }).trim();
77
+ if (out) return out;
78
+ } catch {
79
+ /* not a git repo, or git unavailable — fall through to mtime */
80
+ }
81
+ return isoDate(fs.statSync(file).mtime);
82
+ }
83
+
84
+ /**
85
+ * Format a `Date` as an ISO `yyyy-mm-dd` string (sitemap `<lastmod>` form).
86
+ * @param {Date} date
87
+ * @returns {string}
88
+ */
89
+ export function isoDate(date) {
90
+ return date.toISOString().slice(0, 10);
91
+ }
92
+
93
+ const RFC822_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
94
+ const RFC822_MONTHS = [
95
+ "Jan",
96
+ "Feb",
97
+ "Mar",
98
+ "Apr",
99
+ "May",
100
+ "Jun",
101
+ "Jul",
102
+ "Aug",
103
+ "Sep",
104
+ "Oct",
105
+ "Nov",
106
+ "Dec"
107
+ ];
108
+
109
+ /**
110
+ * Format a frontmatter `date:` value as an RFC-822 date for an RSS `<pubDate>`
111
+ * (e.g. `Sat, 28 Jun 2026 00:00:00 GMT`). Accepts anything `new Date` parses
112
+ * (an ISO `yyyy-mm-dd`, a full timestamp); an unparseable value returns "" so
113
+ * the caller can omit the element rather than emit `Invalid Date`.
114
+ * @param {string} value Raw frontmatter date string.
115
+ * @returns {string} RFC-822 date in GMT, or "" when unparseable.
116
+ */
117
+ export function rfc822(value) {
118
+ const date = new Date(value);
119
+ if (Number.isNaN(date.getTime())) return "";
120
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, "0");
121
+ const day = RFC822_DAYS[date.getUTCDay()];
122
+ const month = RFC822_MONTHS[date.getUTCMonth()];
123
+ return (
124
+ `${day}, ${pad(date.getUTCDate())} ${month} ${date.getUTCFullYear()} ` +
125
+ `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} GMT`
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Read a frontmatter field as a trimmed string, or "" when absent/non-string
131
+ * (arrays, numbers, booleans don't belong in these single-value feed fields).
132
+ * @param {import("./compiler.js").Meta} meta
133
+ * @param {string} key
134
+ * @returns {string}
135
+ */
136
+ function metaString(meta, key) {
137
+ const value = meta[key];
138
+ return typeof value === "string" ? value.trim() : "";
139
+ }
140
+
141
+ /**
142
+ * Extract the first paragraph of a plain-`.md` body as a single-line snippet —
143
+ * the RSS description fallback when a `.md` post has neither `excerpt:` nor
144
+ * `description:`. Deliberately dumb: skip leading blank/frontmatter-less lines,
145
+ * take the first run of non-blank prose, strip the most common inline markdown
146
+ * (heading marks, emphasis, inline-code, links → their text), and collapse
147
+ * whitespace. Returns "" when the body is empty or starts with a non-prose
148
+ * block (a fence/heading-only doc) — the caller then falls back further.
149
+ * @param {string} body The post-frontmatter markdown body.
150
+ * @returns {string}
151
+ */
152
+ export function firstParagraph(body) {
153
+ const lines = body.replace(/\r\n?/g, "\n").split("\n");
154
+ let i = 0;
155
+ while (i < lines.length && !lines[i].trim()) i++;
156
+ // A heading or fenced block isn't a description paragraph — bail to the
157
+ // caller's next fallback rather than emit "# Title" or code as the summary.
158
+ if (i >= lines.length || /^(#{1,6}\s|```|~~~|>|[-*+]\s|\d+\.\s)/.test(lines[i].trim())) return "";
159
+ /** @type {string[]} */
160
+ const collected = [];
161
+ for (let j = i; j < lines.length && lines[j].trim(); j++) collected.push(lines[j].trim());
162
+ return collected
163
+ .join(" ")
164
+ .replace(/`([^`]+)`/g, "$1") // inline code → its text
165
+ .replace(/\*\*([^*]+)\*\*/g, "$1") // bold
166
+ .replace(/\*([^*]+)\*/g, "$1") // italic
167
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
168
+ .replace(/\s+/g, " ")
169
+ .trim();
170
+ }
171
+
172
+ /**
173
+ * The RSS `<description>` for a post: explicit `excerpt:` wins, else the source
174
+ * `description:`, else (plain `.md` only) the first rendered paragraph. `.wd`
175
+ * bodies are NOT scanned for a first paragraph — stripping reactive/directive
176
+ * markup back to clean prose is not worth the fragility, so a `.wd` post without
177
+ * `excerpt`/`description` simply gets no description.
178
+ * @param {import("./compiler.js").Meta} meta
179
+ * @param {string} file Absolute source path (its extension gates `.md` scanning).
180
+ * @param {string} body The post-frontmatter body.
181
+ * @returns {string}
182
+ */
183
+ export function rssDescription(meta, file, body) {
184
+ const excerpt = metaString(meta, "excerpt");
185
+ if (excerpt) return excerpt;
186
+ const description = metaString(meta, "description");
187
+ if (description) return description;
188
+ if (path.extname(file) === ".md") return firstParagraph(body);
189
+ return "";
190
+ }
191
+
192
+ /**
193
+ * Build `sitemap.xml` from the emitted (post-draft-filter) routes. One `<url>`
194
+ * per route — reactive pages included (they're indexable HTML) — with `<loc>` =
195
+ * `site_url` + path and `<lastmod>` from the supplied date. The `/404/` route is
196
+ * excluded by the caller before this point. No `<priority>`/`<changefreq>` —
197
+ * Google ignores them and they invite bit-rot.
198
+ * @param {DatedRoute[]} entries Absolute loc + ISO lastmod, in route order.
199
+ * @returns {string} The full XML document (trailing newline).
200
+ */
201
+ export function buildSitemap(entries) {
202
+ const urls = entries
203
+ .map(
204
+ (entry) =>
205
+ ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n` +
206
+ ` <lastmod>${entry.lastmod}</lastmod>\n </url>`
207
+ )
208
+ .join("\n");
209
+ return (
210
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
211
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
212
+ `${urls}\n</urlset>\n`
213
+ );
214
+ }
215
+
216
+ /**
217
+ * An RSS item — a dated post in feed order.
218
+ * @typedef {object} RssItem
219
+ * @property {string} title
220
+ * @property {string} link Absolute URL (also used as the `<guid>`).
221
+ * @property {string} pubDate RFC-822 date, or "" to omit.
222
+ * @property {string} description
223
+ */
224
+
225
+ /**
226
+ * Build `rss.xml` (RSS 2.0) for the dated posts. The caller selects, sorts
227
+ * (date desc), and caps the items; this builder just renders. Channel `<title>`
228
+ * / `<description>` come from the home frontmatter; `<link>` is the site origin
229
+ * and the `<atom:link rel="self">` points at the feed itself (RSS best practice).
230
+ * @param {{ title: string, description: string, siteUrl: string, feedUrl: string }} channel
231
+ * @param {RssItem[]} items
232
+ * @returns {string} The full XML document (trailing newline).
233
+ */
234
+ export function buildRss(channel, items) {
235
+ const entries = items.map((item) => {
236
+ const parts = [
237
+ ` <title>${escapeXml(item.title)}</title>`,
238
+ ` <link>${escapeXml(item.link)}</link>`,
239
+ ` <guid isPermaLink="true">${escapeXml(item.link)}</guid>`
240
+ ];
241
+ if (item.pubDate) parts.push(` <pubDate>${item.pubDate}</pubDate>`);
242
+ if (item.description)
243
+ parts.push(` <description>${escapeXml(item.description)}</description>`);
244
+ return ` <item>\n${parts.join("\n")}\n </item>`;
245
+ });
246
+ const channelLines = [
247
+ ` <title>${escapeXml(channel.title)}</title>`,
248
+ ` <link>${escapeXml(channel.siteUrl)}</link>`,
249
+ ` <description>${escapeXml(channel.description)}</description>`,
250
+ ` <atom:link href="${escapeXml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`,
251
+ ...entries
252
+ ];
253
+ return (
254
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
255
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n' +
256
+ " <channel>\n" +
257
+ `${channelLines.join("\n")}\n` +
258
+ " </channel>\n</rss>\n"
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Build `robots.txt`. Always allows everything (hidden/draft routes are never
264
+ * built, so there's nothing to `Disallow`). The `Sitemap:` line is included
265
+ * ONLY when `siteUrl` is set — a sitemap URL is only meaningful with an origin.
266
+ * @param {string} [siteUrl] Absolute origin, or "" / undefined to omit the line.
267
+ * @returns {string} The robots.txt body (trailing newline).
268
+ */
269
+ export function buildRobots(siteUrl) {
270
+ const lines = ["User-agent: *", "Allow: /"];
271
+ if (siteUrl) lines.push(`Sitemap: ${absoluteUrl(siteUrl, "/sitemap.xml")}`);
272
+ return `${lines.join("\n")}\n`;
273
+ }
274
+
275
+ /** Most-recent-first RSS post cap — a feed is a recent window, not an archive. */
276
+ export const RSS_ITEM_LIMIT = 20;