@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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +18 -0
- package/README.md +129 -1
- package/docs/spec-alignment.md +24 -0
- package/package.json +2 -1
- package/site/_/base.skin +14 -0
- package/site/_/nav.wd +1 -0
- package/site/_/scoped-promo.skin +43 -0
- package/site/_/scoped-promo.wd +10 -0
- package/site/_/scoped-stat.skin +32 -0
- package/site/_/scoped-stat.wd +9 -0
- package/site/pages/blog/drafts-that-never-leak.md +16 -0
- package/site/pages/blog/index.md +13 -0
- package/site/pages/blog/shipping-feeds.md +17 -0
- package/site/pages/blog/unfinished-thoughts.md +13 -0
- package/site/pages/docs/index.wd +70 -0
- package/site/pages/highlight/index.skin +78 -0
- package/site/pages/highlight/index.wd +142 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/src/builder.js +239 -10
- package/src/cli.js +51 -7
- package/src/compiler/context.js +10 -1
- package/src/compiler/directives.js +11 -5
- package/src/compiler/includes.js +48 -1
- package/src/compiler/index.js +1 -1
- package/src/compiler/markdown.js +7 -2
- package/src/compiler/page.js +58 -7
- package/src/compiler.js +2 -1
- package/src/dev.js +34 -5
- package/src/feeds.js +276 -0
- package/src/highlight.js +126 -0
- package/src/router.js +35 -3
- package/src/skin.js +147 -3
- package/src/templates/blog/site/pages/index.wd +1 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +2 -0
package/src/highlight.js
ADDED
|
@@ -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
|
-
*
|
|
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 =
|
|
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
|
|
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,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
|
|