@zvndev/darkmown 1.2.0 → 1.3.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.
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;
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Build-time syntax highlighting: the markdown-it `highlight` callback (wired
3
+ // onto both markdown-it instances in `src/compiler/markdown.js`) plus the
4
+ // framework stylesheet that maps highlight.js token classes onto `$code-*` skin
5
+ // tokens. Highlighting is HTML + CSS only — no runtime, no client JS. The
6
+ // stylesheet is emitted (and linked) ONLY on pages that contain a highlighted
7
+ // code block, mirroring the pay-for-what-you-use behavior modules.
8
+ //
9
+ // Closed-whitelist philosophy: the highlighter is highlight.js and is NOT
10
+ // configurable. A fenced block whose language is unknown or absent is left for
11
+ // markdown-it to render as plain escaped `<code>` (graceful degradation); inline
12
+ // `` `code` `` is never highlighted.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import hljs from "highlight.js";
16
+
17
+ // Stamped onto highlighted output so the compiler can detect (in the rendered
18
+ // HTML) that a block was highlighted and flag `assets.hasCode` for this page —
19
+ // the markdown-it `highlight` callback has no access to the compilation object.
20
+ // Guarantees a marker even for a token-less snippet that produced no `hljs-*`
21
+ // spans, so detection never misses.
22
+ export const HLJS_MARKER = "hljs";
23
+
24
+ /**
25
+ * markdown-it `highlight` callback. Returns highlight.js's escaped token HTML for
26
+ * a known language (wrapped so the `hljs` marker is always present), or `""` to
27
+ * defer to markdown-it's own escaping for an unknown/absent language — the
28
+ * graceful-degradation path that renders a plain `<code>` with no highlighting.
29
+ *
30
+ * markdown-it does NOT escape a non-empty return value (it is trusted HTML), so
31
+ * we must return already-escaped content — `hljs.highlight` escapes its output.
32
+ * @param {string} code The fenced block's raw source.
33
+ * @param {string} lang The info string (language) after the opening fence, if any.
34
+ * @returns {string} Highlighted, escaped HTML, or `""` to defer to markdown-it.
35
+ */
36
+ export function highlightCode(code, lang) {
37
+ // No language, or a language highlight.js doesn't know → plain escaped code.
38
+ if (!lang || !hljs.getLanguage(lang)) return "";
39
+ // `ignoreIllegals` keeps a snippet that doesn't fully parse from throwing — it
40
+ // still highlights best-effort instead of degrading the whole build.
41
+ const { value } = hljs.highlight(code, { language: lang, ignoreIllegals: true });
42
+ return `<span class="${HLJS_MARKER}">${value}</span>`;
43
+ }
44
+
45
+ /**
46
+ * Detect whether a rendered HTML fragment contains a highlighted code block, so
47
+ * the page-level `assets.hasCode` flag (and thus the highlight stylesheet) is set
48
+ * only when highlighting actually happened. Matches the `hljs` marker stamped by
49
+ * {@link highlightCode}; a page with only plain/unknown-language code never trips it.
50
+ * @param {string} html Rendered body HTML.
51
+ * @returns {boolean}
52
+ */
53
+ export function htmlHasHighlight(html) {
54
+ return html.includes(`class="${HLJS_MARKER}"`);
55
+ }
56
+
57
+ // The framework highlight stylesheet. Maps highlight.js token classes onto
58
+ // `$code-*` skin tokens via CSS custom properties, so code blocks dark-mode for
59
+ // free through the existing `tokens dark` / `:theme` system with ZERO JS — the
60
+ // same `--code-*` variables flip under the OS dark preference or a manual theme
61
+ // toggle. The `var(..., fallback)` second argument keeps highlighting legible
62
+ // even on a project whose skin omits the `$code-*` tokens entirely.
63
+ //
64
+ // Emitted to `/__wd/highlight.css` and linked only on pages that use it.
65
+ export const HIGHLIGHT_CSS = `/* Darkmown syntax highlighting — highlight.js classes mapped to $code-* skin
66
+ tokens. Dark mode is automatic: the tokens swap under tokens dark / :theme,
67
+ so this stylesheet ships zero JavaScript. */
68
+ .hljs {
69
+ color: var(--code-fg, #e9efe7);
70
+ background: var(--code-bg, #1b2420);
71
+ }
72
+ .hljs-comment,
73
+ .hljs-quote {
74
+ color: var(--code-comment, #7d8a82);
75
+ font-style: italic;
76
+ }
77
+ .hljs-keyword,
78
+ .hljs-selector-tag,
79
+ .hljs-literal,
80
+ .hljs-section,
81
+ .hljs-doctag,
82
+ .hljs-name {
83
+ color: var(--code-keyword, #d39bda);
84
+ }
85
+ .hljs-string,
86
+ .hljs-regexp,
87
+ .hljs-meta .hljs-string,
88
+ .hljs-attr,
89
+ .hljs-selector-attr,
90
+ .hljs-selector-pseudo,
91
+ .hljs-addition {
92
+ color: var(--code-string, #8fd28a);
93
+ }
94
+ .hljs-title,
95
+ .hljs-title.function_,
96
+ .hljs-built_in,
97
+ .hljs-class .hljs-title,
98
+ .hljs-selector-class,
99
+ .hljs-selector-id {
100
+ color: var(--code-function, #82c0e9);
101
+ }
102
+ .hljs-number,
103
+ .hljs-symbol,
104
+ .hljs-bullet,
105
+ .hljs-link,
106
+ .hljs-meta {
107
+ color: var(--code-number, #e3a76f);
108
+ }
109
+ .hljs-variable,
110
+ .hljs-template-variable,
111
+ .hljs-type,
112
+ .hljs-attribute,
113
+ .hljs-params,
114
+ .hljs-tag,
115
+ .hljs-deletion,
116
+ .hljs-punctuation,
117
+ .hljs-operator {
118
+ color: var(--code-punctuation, #b9c4be);
119
+ }
120
+ .hljs-emphasis {
121
+ font-style: italic;
122
+ }
123
+ .hljs-strong {
124
+ font-weight: 600;
125
+ }
126
+ `;
package/src/router.js CHANGED
@@ -1,20 +1,38 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { parseFrontmatter } from "./compiler.js";
3
4
  import { isHiddenName, pageExtensions } from "./config.js";
4
5
 
5
6
  /**
6
- * A discovered page route: the source file and its public route path.
7
+ * @typedef {import("./compiler.js").Meta} Meta
8
+ */
9
+
10
+ /**
11
+ * A discovered page route: the source file, its public route path, and its
12
+ * parsed frontmatter. `meta` is read ONCE here — the single source of truth for
13
+ * "is this a draft" and for the downstream sitemap/RSS feeds (which never see a
14
+ * draft, since drafts are filtered out below).
7
15
  * @typedef {object} Route
8
16
  * @property {string} file Absolute path to the source `.md`/`.wd` file.
9
17
  * @property {string} route Public route path (always trailing-slashed, e.g. `/about/`).
18
+ * @property {Meta} meta Parsed frontmatter for the file.
10
19
  */
11
20
 
12
21
  /**
13
22
  * Walk `routesRoot` and collect page routes, sorted and de-duplicated.
23
+ *
24
+ * Drafts are filtered HERE, at the one route-discovery chokepoint, so every
25
+ * downstream consumer (the build, `routes.json`, the sitemap, the RSS feed)
26
+ * only ever sees the post-filter list — a `draft: true` page can never leak. By
27
+ * default drafts are excluded; pass `{ includeDrafts: true }` (`darkmown dev`
28
+ * and `darkmown build --drafts`) to keep them. This is distinct from, and
29
+ * orthogonal to, the permanent `.`/`-`/`_` filename hiding above: hidden names
30
+ * are private forever; `draft:` is a toggleable, publish-when-ready gate.
14
31
  * @param {string} routesRoot Absolute path to `site/pages`.
32
+ * @param {{ includeDrafts?: boolean }} [options]
15
33
  * @returns {Route[]}
16
34
  */
17
- export function discoverRoutes(routesRoot) {
35
+ export function discoverRoutes(routesRoot, options = {}) {
18
36
  if (!fs.existsSync(routesRoot)) return [];
19
37
  /** @type {Route[]} */
20
38
  const routes = [];
@@ -32,9 +50,12 @@ export function discoverRoutes(routesRoot) {
32
50
  if (!pageExtensions.has(ext)) continue;
33
51
  const base = path.basename(entry.name, ext);
34
52
  if (isHiddenName(base)) continue;
53
+ const { meta } = parseFrontmatter(fs.readFileSync(abs, "utf8"), abs);
54
+ if (isDraft(meta) && !options.includeDrafts) continue;
35
55
  routes.push({
36
56
  file: abs,
37
- route: routeFromFile(routesRoot, abs)
57
+ route: routeFromFile(routesRoot, abs),
58
+ meta
38
59
  });
39
60
  }
40
61
  }
@@ -52,6 +73,17 @@ export function discoverRoutes(routesRoot) {
52
73
  return routes.sort((a, b) => a.route.localeCompare(b.route));
53
74
  }
54
75
 
76
+ /**
77
+ * Whether a page's frontmatter marks it a draft (`draft: true`). The scalar
78
+ * frontmatter parser yields a boolean `true` or the string `"true"`, so accept
79
+ * both; any other value (absent, `false`, `"false"`) is not a draft.
80
+ * @param {Meta} meta
81
+ * @returns {boolean}
82
+ */
83
+ export function isDraft(meta) {
84
+ return meta.draft === true || meta.draft === "true";
85
+ }
86
+
55
87
  /**
56
88
  * Derive a public route path from a source file path.
57
89
  * @param {string} routesRoot Absolute path to `site/pages`.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  title: My Blog
3
3
  description: A markdown-native blog built with Darkmown.
4
+ site_url: https://example.com
4
5
  transitions: true
5
6
  ---
6
7
 
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  title: Hello, Darkmown
3
3
  description: Why I rewrote my blog as plain Markdown files.
4
+ date: 2026-01-15
5
+ excerpt: I rewrote my blog as plain Markdown files — no React, no build ceremony, and it ships zero JavaScript.
4
6
  transitions: true
5
7
  ---
6
8
 
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  title: Markdown-native, by default
3
3
  description: Every post is a .md file; the index is one .wd loop.
4
+ date: 2026-02-02
5
+ excerpt: Every post is a plain .md file; the index is one .wd loop. The date in each post's frontmatter is what lands it in rss.xml.
4
6
  transitions: true
5
7
  ---
6
8