@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.
@@ -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`.
package/src/skin.js CHANGED
@@ -1,3 +1,5 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  const aliases = new Map([
2
4
  ["radius", "border-radius"],
3
5
  ["shadow", "box-shadow"],
@@ -19,6 +21,29 @@ function resolveProp(prop, rest) {
19
21
  return aliases.get(prop) || prop;
20
22
  }
21
23
 
24
+ /**
25
+ * Derive a short, deterministic scope id from a skin's PROJECT-RELATIVE PATH.
26
+ * Path-based (stable across content edits, unique per component file) and short
27
+ * enough to read in the emitted CSS (`wd-7c21`). The same path always yields the
28
+ * same id, so the CSS rewrite (builder) and the HTML stamp (page/include) agree
29
+ * without sharing state — they just hash the same relative path. The path is
30
+ * normalized to POSIX separators first so a build is identical on Windows.
31
+ * @param {string} relPath Skin path relative to the project root (cwd).
32
+ * @returns {string} The scope id, e.g. `wd-7c21`.
33
+ */
34
+ export function scopeIdFor(relPath) {
35
+ const posix = relPath.replaceAll("\\", "/");
36
+ const hash = crypto.createHash("sha256").update(posix).digest("hex");
37
+ return `wd-${hash.slice(0, 4)}`;
38
+ }
39
+
40
+ // Selectors that style the whole page, not a component subtree. In a `scoped`
41
+ // skin they are a mistake: scoping appends an attribute to the *subject* element,
42
+ // so `page`/`body`/`html`/`*`/`::selection` would either become a no-op (no
43
+ // element in the subtree carries the attribute on <body>/<html>) or silently
44
+ // fail to do what the author means. We error and point them at a global skin.
45
+ const PAGE_LEVEL_SELECTORS = new Set(["page", "*", "html", "body", "::selection"]);
46
+
22
47
  /**
23
48
  * A nesting frame in the indentation stack.
24
49
  * @typedef {object} SkinFrame
@@ -29,10 +54,25 @@ function resolveProp(prop, rest) {
29
54
 
30
55
  /**
31
56
  * Compile indentation-based `.skin` source into CSS.
57
+ *
58
+ * Scoping (opt-in): when `opts.scope` is set the skin is compiled in scoped
59
+ * mode — every selector RULE gets `[data-wd-scope="<id>"]` appended to the
60
+ * subject of each compound selector, so the stylesheet only ever matches inside
61
+ * the stamped subtree. Design tokens (`tokens` / `tokens dark` / `tokens [attr]`)
62
+ * stay GLOBAL on `:root` regardless, so `var(--x)` and dark mode keep working
63
+ * site-wide. A whole-selector `:global(.x)` escapes scoping. Page-level selectors
64
+ * (`page`/`*`/`html`/`body`/`::selection`) are a compile error in scoped mode.
65
+ * Without `opts.scope`, output is byte-identical to before this feature existed.
32
66
  * @param {string} source
67
+ * @param {{ scope?: string, subjects?: Set<string> }} [opts] `scope`: the scope
68
+ * id enabling scoped mode. `subjects`: an optional set the compiler fills with
69
+ * every scoped subject token (`.card`, `h2`, `#id`) for the unused-selector
70
+ * warning, so the caller need not re-parse the source.
33
71
  * @returns {string}
34
72
  */
35
- export function compileSkin(source) {
73
+ export function compileSkin(source, opts = {}) {
74
+ const scope = opts.scope;
75
+ const subjects = opts.subjects;
36
76
  const lines = source
37
77
  .replace(/\r\n?/g, "\n")
38
78
  .replace(/\/\*[\s\S]*?\*\//g, "") // strip /* … */ block comments (any span)
@@ -47,6 +87,19 @@ export function compileSkin(source) {
47
87
  })
48
88
  .map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
49
89
 
90
+ // The opt-in marker: `scoped` must be the FIRST meaningful line (the filter
91
+ // above has already dropped comments, blanks, and dividers). Anywhere else it
92
+ // is an error — a `scoped` mid-file means the author misread the contract.
93
+ let start = 0;
94
+ if (scope && lines.length && lines[0].text === "scoped" && lines[0].indent === 0) {
95
+ start = 1;
96
+ }
97
+ for (let i = start; i < lines.length; i++) {
98
+ if (lines[i].text === "scoped" && lines[i].indent === 0) {
99
+ throw new Error('"scoped" must be the first line of a .skin file');
100
+ }
101
+ }
102
+
50
103
  /** @type {SkinFrame[]} */
51
104
  const stack = [];
52
105
  /** @type {string[]} */
@@ -65,7 +118,7 @@ export function compileSkin(source) {
65
118
  /** @type {Map<string, string[]>} */
66
119
  const attrVars = new Map();
67
120
 
68
- for (let i = 0; i < lines.length; i++) {
121
+ for (let i = start; i < lines.length; i++) {
69
122
  const { indent, text } = lines[i];
70
123
  // Structure decides meaning: a line opens a block (selector) exactly when
71
124
  // the next line is indented deeper. Everything else is a declaration.
@@ -119,17 +172,28 @@ export function compileSkin(source) {
119
172
  if (text.startsWith("@")) {
120
173
  stack.push({ indent, selector: parent ?? null, media: text });
121
174
  } else {
175
+ if (scope) guardPageLevel(text, parent);
122
176
  stack.push({ indent, selector: normalizeSelector(text, parent) });
123
177
  }
124
178
  continue;
125
179
  }
126
180
 
127
181
  const current = stack.at(-1)?.selector || ":root";
182
+ // A leaf with no enclosing selector lands on `:root` — a page-level write. In
183
+ // a scoped skin that is the same mistake `guardPageLevel` rejects (and the
184
+ // place a filtered bare `*`/`page` reset would otherwise leak through to), so
185
+ // it errors with the same hint instead of emitting a never-matching rule.
186
+ if (scope && current === ":root") {
187
+ throw new Error(
188
+ `page-level declaration "${text}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
189
+ );
190
+ }
128
191
  const media = stack.find((frame) => frame.media)?.media;
129
192
  const [prop, ...rest] = text.split(/\s+/);
130
193
  const cssProp = resolveProp(prop, rest);
131
194
  const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
132
- const rule = `${current} { ${cssProp}: ${value}; }`;
195
+ const subject = scope ? scopeSelector(current, scope, subjects) : current;
196
+ const rule = `${subject} { ${cssProp}: ${value}; }`;
133
197
  css.push(media ? `${media} { ${rule} }` : rule);
134
198
  }
135
199
 
@@ -153,6 +217,86 @@ export function compileSkin(source) {
153
217
  return css.join("\n");
154
218
  }
155
219
 
220
+ /**
221
+ * Reject page-level selectors in a scoped skin. Checks the AUTHORED selector
222
+ * text (before `page`→`body` rewrite) so `page` is caught by name. Each
223
+ * comma-separated part is inspected; a bare top-level selector (no parent) that
224
+ * is one of the page-level set, or a top-level `::selection`, errors with a hint
225
+ * pointing the author at a global skin.
226
+ * @param {string} text Raw selector line.
227
+ * @param {string | null | undefined} parent Enclosing selector, if nested.
228
+ * @returns {void}
229
+ */
230
+ function guardPageLevel(text, parent) {
231
+ // Only TOP-LEVEL selectors are page-level mistakes — `.card *` (a descendant
232
+ // wildcard inside a component) is fine, so the check applies when there is no
233
+ // parent selector chain (`:root` is the implicit root, treated as none).
234
+ if (parent && parent !== ":root") return;
235
+ for (const part of text.split(",")) {
236
+ const clean = part.trim();
237
+ if (PAGE_LEVEL_SELECTORS.has(clean)) {
238
+ throw new Error(
239
+ `page-level selector "${clean}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
240
+ );
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Append the scope attribute to a resolved selector list (post `&`-nesting).
247
+ * Each comma-separated compound selector gets `[data-wd-scope="<id>"]` on its
248
+ * SUBJECT (rightmost simple selector), inserted BEFORE any trailing
249
+ * pseudo-class/element so the result stays valid CSS (`.card[…]:hover`, never
250
+ * `.card:hover[…]`). A whole-selector `:global(.x)` unwraps to a plain `.x` with
251
+ * NO attribute — the documented escape hatch (whole-selector only this release).
252
+ * @param {string} selectorList Resolved selector list (may contain commas).
253
+ * @param {string} scope Scope id.
254
+ * @param {Set<string>} [subjects] Optional sink for subject tokens (warning).
255
+ * @returns {string}
256
+ */
257
+ function scopeSelector(selectorList, scope, subjects) {
258
+ return selectorList
259
+ .split(",")
260
+ .map((part) => {
261
+ const clean = part.trim();
262
+ // Whole-selector `:global(.toast)` → `.toast`, unscoped. Descendant
263
+ // `:global` (`.card :global(.x)`) is intentionally NOT supported yet.
264
+ const global = clean.match(/^:global\(\s*(.+?)\s*\)$/);
265
+ if (global) return global[1];
266
+ // The subject is the last whitespace-delimited compound. Combinators
267
+ // (`>`, `+`, `~`) stand as their own tokens, so the subject is whatever
268
+ // follows the final combinator/descendant gap.
269
+ const tokens = clean.split(/\s+/);
270
+ const subjectIndex = tokens.length - 1;
271
+ const lead = tokens.slice(0, subjectIndex);
272
+ const subject = tokens[subjectIndex];
273
+ // Split the subject into its base (tag/class/id, plus attribute selectors)
274
+ // and any trailing pseudo `:hover` / `::before`. The attribute slots in at
275
+ // the base/pseudo seam: `.card::before` → `.card[scope]::before`.
276
+ const seam = subject.search(/::?[a-zA-Z-]/);
277
+ const base = seam === -1 ? subject : subject.slice(0, seam);
278
+ const pseudo = seam === -1 ? "" : subject.slice(seam);
279
+ if (subjects) subjects.add(subjectBase(base));
280
+ const scoped = `${base}[data-wd-scope="${scope}"]${pseudo}`;
281
+ return [...lead, scoped].join(" ");
282
+ })
283
+ .join(", ");
284
+ }
285
+
286
+ /**
287
+ * Reduce a scoped subject base to the single token the unused-selector warning
288
+ * checks against the stamped subtree: the leading `.class`, `#id`, or bare tag,
289
+ * dropping any chained attribute selectors (`a[href]` → `a`). Returns `""` for a
290
+ * subject that has no checkable token (a bare attribute selector), which the
291
+ * caller treats as "always present" (never warns).
292
+ * @param {string} base
293
+ * @returns {string}
294
+ */
295
+ function subjectBase(base) {
296
+ const m = base.match(/^([.#]?[A-Za-z_][\w-]*)/);
297
+ return m ? m[1] : "";
298
+ }
299
+
156
300
  /**
157
301
  * Normalize a bracketed token modifier into a valid attribute selector,
158
302
  * quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.
@@ -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