@zvndev/darkmown 1.3.0 → 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 +9 -0
- package/README.md +44 -0
- package/docs/spec-alignment.md +16 -0
- package/package.json +1 -1
- 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/docs/index.wd +23 -0
- package/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/src/builder.js +77 -4
- package/src/compiler/context.js +6 -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/page.js +33 -2
- package/src/compiler.js +2 -1
- package/src/skin.js +147 -3
package/AGENTS.md
CHANGED
|
@@ -253,6 +253,8 @@ input:focus-visible
|
|
|
253
253
|
```
|
|
254
254
|
Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media` — its indented rules are wrapped automatically). The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
|
|
255
255
|
|
|
256
|
+
**Scoped styles — opt in with `scoped`.** A `.skin` is **global** by default (style site-wide; the right default for your design system). To stop a reusable component's classes from clashing with the rest of the page, make the **first line** of its `.skin` the word `scoped` — its selectors then only match the component it's colocated with (a page skin scopes that page; an include skin scopes just that include's subtree). You still write `class="card"`; the build stamps a `data-wd-scope` attribute and rewrites the selectors — zero runtime, a static page stays static. **Tokens stay global** (a `tokens`/`tokens dark` block still emits `:root` vars, so `$accent` and dark mode keep working). Escape a single rule with `:global(.x)` (whole-selector only). `page`/`*`/`html`/`body`/`::selection` are a compile error in a scoped skin — those belong in a global skin.
|
|
257
|
+
|
|
256
258
|
**Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that:
|
|
257
259
|
```wd
|
|
258
260
|
<div class="actions">
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 1.4.0 — 2026-06-28
|
|
6
|
+
|
|
7
|
+
Scoped styles — the long-standing "`.skin` selectors are global" gap, closed. Opt-in, compile-time, and **zero runtime** (the shared runtime is still 7459 B gzipped; nothing new ships to the client but one HTML attribute and one attribute selector).
|
|
8
|
+
|
|
9
|
+
- **`scoped` skins.** Put `scoped` as the first line of a `.skin` file and its selectors apply only to the page or include it ships with — so two components can reuse the same class name (`.card`) without colliding. The compiler appends a per-file `[data-wd-scope="wd-xxxx"]` attribute to each selector's subject and stamps the matching subtree's HTML; the scope id is a stable hash of the skin's path. Selectors compose as you'd expect: descendant selectors scope only the subject, comma lists scope each branch, `@media`/`@supports` wrappers pass through, and `&`-nesting/pseudo-classes resolve before the attribute is attached.
|
|
10
|
+
- **Tokens stay global.** A `tokens` / `tokens dark` block in a `scoped` skin still emits global `:root` custom properties, so `var(--…)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep working across the whole site — only your component's selector rules are scoped.
|
|
11
|
+
- **`:global(...)` escape hatch** for a whole selector that must stay global (e.g. a class you portal elsewhere). Page-level selectors (`page`, `*`, `html`, `body`, `::selection`) are rejected in a scoped skin with a corrective hint — those belong in a global design-system skin.
|
|
12
|
+
- **Fully backward compatible.** A `.skin` without the `scoped` marker is unchanged, byte-for-byte (covered by a golden test). Existing global skins — `base.skin`, `nav.skin`, every scaffolded project — keep styling the whole site exactly as before. Unused scoped selectors produce a build *warning* (never silently removed, since a colocated `.js` behavior may add the class at runtime).
|
|
13
|
+
|
|
5
14
|
## 1.3.0 — 2026-06-28
|
|
6
15
|
|
|
7
16
|
The "table-stakes batteries" release — the build-time, zero-JS features a markdown site is expected to ship. The runtime is unchanged (still 7459 B gzipped, well under the 8 KB budget); everything here is compile-time HTML/CSS or build output.
|
package/README.md
CHANGED
|
@@ -809,6 +809,50 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
|
|
|
809
809
|
|
|
810
810
|
Because `tokens dark` already emits the `[data-theme="dark"]` rule, **no extra skin block is needed** — the same palette drives the toggle. `"light"` forces the light palette even under OS dark; `"auto"` clears the attribute and follows the OS again; `theme` persists across reloads and tabs like any `:store`. (`:theme name = "light"` renames the store and seeds a different default. The older `tokens [data-theme=dark]` block — a manual-only override — still works for bespoke setups.)
|
|
811
811
|
|
|
812
|
+
## Scoped styles — `scoped`
|
|
813
|
+
|
|
814
|
+
*New in 1.4.0.* By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
|
|
815
|
+
|
|
816
|
+
```skin
|
|
817
|
+
scoped
|
|
818
|
+
|
|
819
|
+
.card
|
|
820
|
+
padding 1.5rem
|
|
821
|
+
bg $panel
|
|
822
|
+
radius $radius
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Scoping is **pure compile time** — a short, path-derived id (e.g. `wd-7c21`) is stamped onto the component's HTML (`data-wd-scope="wd-7c21"`) and appended to each of the skin's selectors:
|
|
826
|
+
|
|
827
|
+
```css
|
|
828
|
+
.card[data-wd-scope="wd-7c21"] { padding: 1.5rem; }
|
|
829
|
+
.card[data-wd-scope="wd-7c21"] { background: var(--panel); }
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
A second component's `.card` gets a **different** id, so the two never collide. There is **no runtime cost and no class renaming in your markup** — you still write `class="card"`; the framework adds the attribute during the build. A static page with a scoped skin stays **zero-JS**.
|
|
833
|
+
|
|
834
|
+
**What scopes, what stays global:**
|
|
835
|
+
|
|
836
|
+
| In a `scoped` skin | Result |
|
|
837
|
+
|---|---|
|
|
838
|
+
| A selector rule (`.card`, `.card:hover`, `.card .title`, `h2, h3`) | Scoped — the attribute lands on the **subject** (rightmost) selector, before any `:hover`/`::before` so it stays valid CSS |
|
|
839
|
+
| A descendant selector (`.card .title`) | Only `.title` (the subject) is scoped; `.card` matches inside the subtree |
|
|
840
|
+
| `tokens` / `tokens dark` / `tokens [data-theme=…]` | **Global** — design tokens always emit on `:root`, so `$accent` and dark mode keep working site-wide |
|
|
841
|
+
| `:global(.toast)` (whole selector) | **Opts back out** — emits a plain, unscoped `.toast { … }` |
|
|
842
|
+
| `@media` / `@supports` wrappers | Untouched; only the inner rule's selector is scoped |
|
|
843
|
+
| `page` / `*` / `html` / `body` / `::selection` | **Compile error** — page-level styles belong in a global skin, not a scoped one |
|
|
844
|
+
|
|
845
|
+
Where the scope applies depends on **where the skin lives**:
|
|
846
|
+
|
|
847
|
+
- A skin colocated with a **page** (`about.skin` next to `about.wd`) scopes that page's body.
|
|
848
|
+
- A skin colocated with an **include** (`card.skin` next to `card.wd`) scopes just that include's subtree wherever it's `@include`d — so the same scoped component can appear many times on a page without leaking into its neighbours.
|
|
849
|
+
|
|
850
|
+
**Caveats (honest limits this release):**
|
|
851
|
+
|
|
852
|
+
- **Whole-selector `:global()` only.** A descendant `:global` (`.card :global(.x)`) is **not** supported yet — use a whole-selector `:global(.x)`.
|
|
853
|
+
- **Unused selectors warn, they aren't removed.** If a scoped selector's class/element/id never appears in the stamped subtree, the build prints `hint: scoped selector ".badge" in card.skin matches no element` — but the rule is **kept** (a colocated `.js` may add the class at runtime). It's a typo nudge, not a dead-code remover.
|
|
854
|
+
- **Scoping is opt-in.** Every existing `.skin` (no `scoped` marker) is byte-for-byte unchanged.
|
|
855
|
+
|
|
812
856
|
## The escape hatch
|
|
813
857
|
|
|
814
858
|
Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
|
package/docs/spec-alignment.md
CHANGED
|
@@ -134,6 +134,22 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
|
|
|
134
134
|
- **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
|
|
135
135
|
- **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
|
|
136
136
|
|
|
137
|
+
## 1.4.0 additions — compile-time scoped styles (2026-06-28)
|
|
138
|
+
|
|
139
|
+
The 1.4.0 cycle ships one feature: **opt-in scoped styles** for colocated `.skin` files, closing the "no CSS scoping" gap an audit flagged (two components couldn't reuse a class name without collision). **Pure compile time, zero runtime** — `src/runtime.js` and the 8 KB budget are untouched, and a static page with a scoped skin stays `runtime: false`.
|
|
140
|
+
|
|
141
|
+
- **Authoring surface — the `scoped` marker:** a `.skin` scopes its selectors **only** when its first non-comment, non-blank, non-divider line is exactly `scoped`; colocation alone changes nothing. Every existing `.skin` (no marker) stays **global** and emits **byte-identical CSS** to before (golden-tested against `site/_/base.skin`). `scoped` anywhere but the first line is a compile error (`"scoped" must be the first line of a .skin file`). The only other new surface is `:global()` inside a scoped skin — no new `.wd` syntax.
|
|
142
|
+
|
|
143
|
+
- **Mechanism — id + selector rewrite + HTML stamp.** The scope id is a short, deterministic hash of the skin's **project-relative path** (e.g. `wd-7c21`) — path-based (stable across content edits, unique per file), computed in `src/builder.js` (the coordination point) and re-derived identically by the HTML stamp from the same path, so `src/skin.js` stays a pure string→CSS function (`compileSkin(src, { scope })`). In scoped mode `compileSkin` appends `[data-wd-scope="<id>"]` to the **subject** (rightmost compound) of each comma-separated selector, **after** `&`-nesting resolves and **before** any trailing pseudo-class/element (`.card[data-wd-scope="…"]:hover`, never `.card:hover[…]`; `::before` likewise) — so descendant selectors scope only the subject (`.card .title` → `.title` gets the attribute) and `@media`/`@supports` wrappers are untouched. The HTML half (`stampScope` in `src/compiler/page.js`, an `enhanceImages`-style regex post-pass) stamps `data-wd-scope="<id>"` onto every opening tag of the scoped subtree that doesn't already carry one (first stamp wins, so a nested include keeps its own id). A **page**-colocated scoped skin stamps the page body; an **include**-colocated scoped skin is stamped in `handleInclude` over just that include's returned subtree — so the same scoped component repeats on a page without its scope leaking to siblings.
|
|
144
|
+
|
|
145
|
+
- **Tokens stay global.** A `tokens` / `tokens dark` / `tokens [attr]` block in a scoped skin still emits a global `:root { --name: … }` (and the dark/manual-toggle variants) — never scoped — so `var(--x)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep resolving site-wide. Enforced by only touching the selector-rule construction, leaving the token/`:root` path identical.
|
|
146
|
+
|
|
147
|
+
- **`:global()` escape + disallowed selectors.** A **whole-selector** `:global(.toast)` emits a plain, unscoped `.toast { … }`. Page-level selectors in a scoped skin — `page` (→ body), bare top-level `*`/`html`/`body`/`::selection`, and any bare top-level declaration that would land on `:root` — are a **compile error** with a hint pointing to a global skin (chosen over silently auto-`:global`-ing them).
|
|
148
|
+
|
|
149
|
+
- **Unused-selector handling — warn, never remove.** The builder has both the scoped subject set (collected by `compileSkin`) and the stamped subtree HTML, so it warns (`hint: scoped selector ".badge" in card.skin matches no element`) when a scoped subject's class/element/id never appears in the stamped subtree — but **keeps the rule** (a colocated `.js` may add the class at runtime). A typo nudge, not a dead-code remover.
|
|
150
|
+
|
|
151
|
+
- **Honest caveats:** only **whole-selector** `:global()` this release — descendant `:global` (`.card :global(.x)`) is deferred; unused selectors **warn, are not removed**. Demonstrated end-to-end by the [`/scoped/`](/scoped/) demo (two components reuse `.card` with no collision; a global skin still works alongside).
|
|
152
|
+
|
|
137
153
|
## 1.3.0 additions — table-stakes batteries (2026-06-28)
|
|
138
154
|
|
|
139
155
|
The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Tracks land in parallel; this entry covers the **syntax-highlighting** and **drafts + feeds** tracks.
|
package/package.json
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
scoped
|
|
2
|
+
|
|
3
|
+
/* Component A — a warm promo card. Same class names as scoped-stat.skin
|
|
4
|
+
(.card / .title / .body), but scoped, so neither leaks. Tokens stay global,
|
|
5
|
+
so $accent / $warm / $radius still resolve from base.skin's :root. */
|
|
6
|
+
|
|
7
|
+
.card
|
|
8
|
+
display grid
|
|
9
|
+
gap .6rem
|
|
10
|
+
padding 1.5rem
|
|
11
|
+
bg $warm-soft
|
|
12
|
+
border 1px solid $warm
|
|
13
|
+
border-left 4px solid $warm
|
|
14
|
+
radius $radius
|
|
15
|
+
|
|
16
|
+
.badge
|
|
17
|
+
justify-self start
|
|
18
|
+
font-size .7rem
|
|
19
|
+
font-weight 700
|
|
20
|
+
text-transform uppercase
|
|
21
|
+
letter-spacing .08em
|
|
22
|
+
color #fff
|
|
23
|
+
bg $warm
|
|
24
|
+
padding .2rem .55rem
|
|
25
|
+
radius 999px
|
|
26
|
+
|
|
27
|
+
.title
|
|
28
|
+
margin 0
|
|
29
|
+
font-size 1.25rem
|
|
30
|
+
color $ink
|
|
31
|
+
|
|
32
|
+
.body
|
|
33
|
+
margin 0
|
|
34
|
+
color $ink-soft
|
|
35
|
+
max-width none
|
|
36
|
+
|
|
37
|
+
.cta
|
|
38
|
+
justify-self start
|
|
39
|
+
font-weight 600
|
|
40
|
+
color $warm
|
|
41
|
+
|
|
42
|
+
.cta:hover
|
|
43
|
+
color $ink
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!-- Scoped component A. Its colocated scoped-promo.skin opts into scoping, so the
|
|
2
|
+
`.card` here gets its OWN look — a warm, accent-bordered promo — without
|
|
3
|
+
clashing with the identically-named `.card` in scoped-stat.wd. handleInclude
|
|
4
|
+
stamps just this returned subtree with the component's scope id. -->
|
|
5
|
+
<article class="card">
|
|
6
|
+
<span class="badge">Promo</span>
|
|
7
|
+
<h3 class="title">Same class name, zero collision</h3>
|
|
8
|
+
<p class="body">This <code>.card</code> is a warm promo panel. The component beside it also calls its root <code>.card</code> — and they do not fight, because each scoped skin only matches inside its own stamped subtree.</p>
|
|
9
|
+
<a class="cta" href="/docs/">How scoping works →</a>
|
|
10
|
+
</article>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
scoped
|
|
2
|
+
|
|
3
|
+
/* Component B — a dark stat tile. Identical class names to scoped-promo.skin,
|
|
4
|
+
but a completely different look. Scoping keeps them apart; tokens are global
|
|
5
|
+
so $accent-deep / $accent / $radius-lg come straight from base.skin's :root. */
|
|
6
|
+
|
|
7
|
+
.card
|
|
8
|
+
display grid
|
|
9
|
+
gap .35rem
|
|
10
|
+
justify-items center
|
|
11
|
+
text-align center
|
|
12
|
+
padding 2rem 1.5rem
|
|
13
|
+
bg $accent-deep
|
|
14
|
+
border 1px solid $accent-deep
|
|
15
|
+
radius $radius-lg
|
|
16
|
+
|
|
17
|
+
.title
|
|
18
|
+
font-size 3rem
|
|
19
|
+
font-weight 700
|
|
20
|
+
line-height 1
|
|
21
|
+
color #fff
|
|
22
|
+
|
|
23
|
+
.body
|
|
24
|
+
margin 0
|
|
25
|
+
max-width none
|
|
26
|
+
color $accent-soft
|
|
27
|
+
|
|
28
|
+
.badge
|
|
29
|
+
font-size .68rem
|
|
30
|
+
text-transform uppercase
|
|
31
|
+
letter-spacing .1em
|
|
32
|
+
color $accent
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- Scoped component B. Reuses the SAME class names as scoped-promo.wd
|
|
2
|
+
(.card / .title / .body) but its colocated scoped-stat.skin scopes them to a
|
|
3
|
+
dark, centered stat tile. Proof the leak is gone: two `.card` definitions on
|
|
4
|
+
one page, each only styling its own subtree. -->
|
|
5
|
+
<article class="card">
|
|
6
|
+
<span class="title">99.9%</span>
|
|
7
|
+
<p class="body">uptime, because there is no client framework to crash</p>
|
|
8
|
+
<span class="badge">measured</span>
|
|
9
|
+
</article>
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -414,6 +414,29 @@ For an explicit light/dark switch alongside the OS preference, declare `:theme`
|
|
|
414
414
|
|
|
415
415
|
A single `tokens dark` block in your `.skin` already powers both the OS `prefers-color-scheme` query and this toggle — no extra block needed. `"auto"` clears the attribute and follows the OS again. See it on the [Ledger demo](/ledger/).
|
|
416
416
|
|
|
417
|
+
## Scoped styles — `scoped`
|
|
418
|
+
|
|
419
|
+
A colocated `.skin` is **global** by default. Make its **first line** `scoped` and its selectors only ever match the component it ships with — so two components can both use `.card` without colliding. It's pure compile time: a path-derived id (e.g. `wd-7c21`) is stamped onto the HTML and appended to each selector.
|
|
420
|
+
|
|
421
|
+
```skin
|
|
422
|
+
scoped
|
|
423
|
+
|
|
424
|
+
.card
|
|
425
|
+
padding 1.5rem
|
|
426
|
+
bg $panel
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup is stamped `data-wd-scope="wd-7c21"`. You still write `class="card"` — no renaming, no runtime, **zero JS** on a static page. A page skin scopes the page body; an **include** skin scopes just that include's subtree wherever it's used.
|
|
430
|
+
|
|
431
|
+
| In a `scoped` skin | Result |
|
|
432
|
+
|---|---|
|
|
433
|
+
| A selector rule | Scoped — attribute on the subject, before any `:hover`/`::before` |
|
|
434
|
+
| `tokens` / `tokens dark` | **Global** on `:root` — `$vars` and dark mode keep working site-wide |
|
|
435
|
+
| `:global(.toast)` (whole selector) | Opts back out — plain unscoped `.toast { … }` |
|
|
436
|
+
| `page` / `*` / `html` / `body` / `::selection` | Compile error — page-level styles belong in a global skin |
|
|
437
|
+
|
|
438
|
+
Honest limits this release: only **whole-selector** `:global()` (no `.card :global(.x)` yet), and an **unused** scoped selector **warns but is not removed** (a colocated `.js` may add the class). See the side-by-side [Scoped demo](/scoped/).
|
|
439
|
+
|
|
417
440
|
## Media — `:video`, `:audio`, `:embed`
|
|
418
441
|
|
|
419
442
|
Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
scoped
|
|
2
|
+
|
|
3
|
+
/* Page-scoped skin for this route. The whole page body is stamped, so these
|
|
4
|
+
selectors style only this page's own markup — proving page-level scoping
|
|
5
|
+
alongside the two include-scoped components. A :global() escape hatch shows a
|
|
6
|
+
single rule opting back out to a plain, unscoped selector. */
|
|
7
|
+
|
|
8
|
+
.intro
|
|
9
|
+
margin 2rem 0 3rem
|
|
10
|
+
|
|
11
|
+
.duo
|
|
12
|
+
margin 3rem 0
|
|
13
|
+
|
|
14
|
+
.how
|
|
15
|
+
margin 4rem 0 2rem
|
|
16
|
+
|
|
17
|
+
.how ul
|
|
18
|
+
color $ink-soft
|
|
19
|
+
|
|
20
|
+
/* :global escape — this selector ships UNSCOPED, matching site-wide. */
|
|
21
|
+
:global(.scoped-note)
|
|
22
|
+
font-size .85rem
|
|
23
|
+
color $muted
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scoped styles — same class name, zero collision
|
|
3
|
+
description: Two components reuse the class name .card and never clash. Compile-time scoped styles stamp each component's subtree with a path-derived id and rewrite its skin's selectors to match only there. Zero runtime JS — a static page stays static.
|
|
4
|
+
transitions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
@include /nav.wd
|
|
8
|
+
@include /base.wd
|
|
9
|
+
|
|
10
|
+
<main>
|
|
11
|
+
|
|
12
|
+
<section class="intro">
|
|
13
|
+
<span class="eyebrow">Darkmown 1.4</span>
|
|
14
|
+
|
|
15
|
+
# Scoped styles
|
|
16
|
+
|
|
17
|
+
Two components on this page both call their root element `.card`. In plain CSS the second skin would overwrite the first. Here they coexist: a `.skin` whose first line is `scoped` only ever styles the subtree it ships with. It's pure compile time — a path-derived id stamped onto the HTML and appended to the skin's selectors. No runtime, no class hashing in your markup, no collision.
|
|
18
|
+
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
<section class="duo">
|
|
22
|
+
|
|
23
|
+
<div class="grid cols-2">
|
|
24
|
+
|
|
25
|
+
@include /scoped-promo.wd
|
|
26
|
+
@include /scoped-stat.wd
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<section class="how">
|
|
33
|
+
|
|
34
|
+
## What actually shipped
|
|
35
|
+
|
|
36
|
+
- The `.card` in the warm promo and the `.card` in the dark stat tile are **different rules**, scoped to different ids.
|
|
37
|
+
- This page also uses a **global** skin (`base.skin`, via `/base.wd`) — `.btn`, `.eyebrow`, `.grid` still work everywhere, unscoped.
|
|
38
|
+
- Design **tokens stay global**: every scoped skin still reads `$accent`, `$warm`, `$radius` from `:root`, so dark mode keeps working for free.
|
|
39
|
+
- A `:global(.x)` selector inside a scoped skin opts a single rule back out.
|
|
40
|
+
|
|
41
|
+
<a class="btn primary" href="/docs/">Read the docs</a>
|
|
42
|
+
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
</main>
|
|
46
|
+
|
|
47
|
+
@include /footer.wd
|
package/src/builder.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
18
18
|
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
19
19
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
20
|
-
import { compileSkin } from "./skin.js";
|
|
20
|
+
import { compileSkin, scopeIdFor } from "./skin.js";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* @typedef {import("./config.js").Paths} Paths
|
|
@@ -91,7 +91,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
91
91
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
92
92
|
fs.writeFileSync(outFile, page.html);
|
|
93
93
|
if (route.route === "/404/") notFoundHtml = page.html;
|
|
94
|
-
emitAssets(page.assets, paths);
|
|
94
|
+
emitAssets(page.assets, paths, page.html, warned);
|
|
95
95
|
if (page.assets.runtime) emitRuntime(paths);
|
|
96
96
|
emitBehaviors(page.assets, paths);
|
|
97
97
|
if (page.assets.hasCode) emitHighlight(paths);
|
|
@@ -544,22 +544,95 @@ function emitHighlight(paths) {
|
|
|
544
544
|
}
|
|
545
545
|
|
|
546
546
|
/**
|
|
547
|
+
* Emit a page's colocated assets. `.skin` files compile through {@link compileSkin}
|
|
548
|
+
* (other files copy verbatim). A skin that opted into scoping (`assets.scopedSkins`)
|
|
549
|
+
* compiles in scoped mode: the scope id is derived here from its PROJECT-RELATIVE
|
|
550
|
+
* PATH — the single coordination point with the HTML stamp, which derives the same
|
|
551
|
+
* id from the same path — and threaded into `compileSkin({ scope })`. The compiler
|
|
552
|
+
* also reports the scoped subject selectors, which we check against the stamped page
|
|
553
|
+
* HTML to warn (never remove) on a scoped selector that matches no element.
|
|
547
554
|
* @param {Assets} assets
|
|
548
555
|
* @param {Paths} paths
|
|
556
|
+
* @param {string} pageHtml Assembled page HTML (carries the `data-wd-scope` stamps).
|
|
557
|
+
* @param {Set<string>} warned Cross-route dedupe set for emitted hints.
|
|
549
558
|
* @returns {void}
|
|
550
559
|
*/
|
|
551
|
-
function emitAssets(assets, paths) {
|
|
560
|
+
function emitAssets(assets, paths, pageHtml, warned) {
|
|
552
561
|
for (const [source, href] of assets.files) {
|
|
553
562
|
const out = path.join(paths.distRoot, href);
|
|
554
563
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
555
564
|
if (source.endsWith(".skin")) {
|
|
556
|
-
|
|
565
|
+
const src = fs.readFileSync(source, "utf8");
|
|
566
|
+
if (assets.scopedSkins.has(source)) {
|
|
567
|
+
const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
|
|
568
|
+
const scope = scopeIdFor(rel);
|
|
569
|
+
/** @type {Set<string>} */
|
|
570
|
+
const subjects = new Set();
|
|
571
|
+
fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
|
|
572
|
+
warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
|
|
573
|
+
} else {
|
|
574
|
+
fs.writeFileSync(out, compileSkin(src));
|
|
575
|
+
}
|
|
557
576
|
} else {
|
|
558
577
|
fs.copyFileSync(source, out);
|
|
559
578
|
}
|
|
560
579
|
}
|
|
561
580
|
}
|
|
562
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Warn (do NOT remove) when a scoped selector's subject never appears in its
|
|
584
|
+
* stamped subtree — a likely typo or stale rule. We only have a class/element/id
|
|
585
|
+
* to look for, so we scan the elements actually stamped with THIS scope id in the
|
|
586
|
+
* assembled page and check each subject against them. A colocated `.js` may add
|
|
587
|
+
* classes at runtime, so this stays advisory: the rule is always emitted. An empty
|
|
588
|
+
* subject token (a bare attribute-selector subject) is unknowable here and skipped.
|
|
589
|
+
* @param {Set<string>} subjects Scoped subject tokens (`.card`, `h2`, `#id`).
|
|
590
|
+
* @param {string} scope Scope id.
|
|
591
|
+
* @param {string} pageHtml Assembled page HTML.
|
|
592
|
+
* @param {string} skinName Skin filename for the message.
|
|
593
|
+
* @param {Set<string>} warned Cross-route dedupe set.
|
|
594
|
+
* @returns {void}
|
|
595
|
+
*/
|
|
596
|
+
function warnUnusedScoped(subjects, scope, pageHtml, skinName, warned) {
|
|
597
|
+
// The opening tags carrying this scope id — the only elements the scoped
|
|
598
|
+
// stylesheet can match. One scan, reused for every subject.
|
|
599
|
+
const stamped = pageHtml.match(
|
|
600
|
+
new RegExp(`<[a-zA-Z][\\w-]*[^>]*\\bdata-wd-scope="${scope}"[^>]*>`, "g")
|
|
601
|
+
);
|
|
602
|
+
const tags = stamped ?? [];
|
|
603
|
+
for (const subject of subjects) {
|
|
604
|
+
if (!subject) continue;
|
|
605
|
+
const present = tags.some((tag) => subjectMatchesTag(subject, tag));
|
|
606
|
+
if (present) continue;
|
|
607
|
+
const warning = `scoped selector "${subject}" in ${skinName} matches no element`;
|
|
608
|
+
if (warned.has(warning)) continue;
|
|
609
|
+
warned.add(warning);
|
|
610
|
+
console.warn(`hint: ${warning}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Whether a scoped subject token (`.card` / `#id` / a bare tag like `h2`) could
|
|
616
|
+
* match a single stamped opening tag. Class subjects look in the tag's `class`
|
|
617
|
+
* attribute; id subjects in `id`; a bare tag compares the element name.
|
|
618
|
+
* @param {string} subject
|
|
619
|
+
* @param {string} tag A single opening tag string.
|
|
620
|
+
* @returns {boolean}
|
|
621
|
+
*/
|
|
622
|
+
function subjectMatchesTag(subject, tag) {
|
|
623
|
+
if (subject.startsWith(".")) {
|
|
624
|
+
const cls = subject.slice(1);
|
|
625
|
+
const m = tag.match(/\bclass\s*=\s*"([^"]*)"/);
|
|
626
|
+
return m ? m[1].split(/\s+/).includes(cls) : false;
|
|
627
|
+
}
|
|
628
|
+
if (subject.startsWith("#")) {
|
|
629
|
+
const m = tag.match(/\bid\s*=\s*"([^"]*)"/);
|
|
630
|
+
return m ? m[1] === subject.slice(1) : false;
|
|
631
|
+
}
|
|
632
|
+
const name = tag.match(/^<([a-zA-Z][\w-]*)/);
|
|
633
|
+
return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
|
|
634
|
+
}
|
|
635
|
+
|
|
563
636
|
/**
|
|
564
637
|
* Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
|
|
565
638
|
* @param {Paths} paths
|
package/src/compiler/context.js
CHANGED
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* @property {boolean} hasCode Whether the page has a build-time-highlighted code
|
|
36
36
|
* block, so the framework highlight stylesheet (`/__wd/highlight.css`) is
|
|
37
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.
|
|
38
42
|
*/
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -144,7 +148,8 @@ export function createCompilation() {
|
|
|
144
148
|
files: new Map(),
|
|
145
149
|
runtime: false,
|
|
146
150
|
behaviors: new Set(),
|
|
147
|
-
hasCode: false
|
|
151
|
+
hasCode: false,
|
|
152
|
+
scopedSkins: new Set()
|
|
148
153
|
},
|
|
149
154
|
state: new Map(),
|
|
150
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
|
|
48
|
+
const childScope = createScope(ctx.scope, args);
|
|
49
49
|
const child = compileFile(
|
|
50
50
|
target,
|
|
51
51
|
ctx.context,
|
|
52
52
|
ctx.stack,
|
|
53
|
-
|
|
53
|
+
childScope,
|
|
54
54
|
ctx.comp,
|
|
55
55
|
ctx.sections,
|
|
56
56
|
ctx.loopItem
|
|
57
57
|
);
|
|
58
|
-
|
|
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
|
/**
|
package/src/compiler/includes.js
CHANGED
|
@@ -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")
|
|
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
|
}
|
package/src/compiler/index.js
CHANGED
|
@@ -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";
|
package/src/compiler/page.js
CHANGED
|
@@ -12,7 +12,7 @@ import { htmlHasHighlight } from "../highlight.js";
|
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { createCompilation, createScope } from "./context.js";
|
|
14
14
|
import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
|
|
15
|
-
import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
|
|
15
|
+
import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
|
|
16
16
|
import { escapeHtml } from "./interpolation.js";
|
|
17
17
|
import { selectMd } from "./markdown.js";
|
|
18
18
|
|
|
@@ -132,7 +132,14 @@ export function compilePage(file, context, options = {}) {
|
|
|
132
132
|
? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
|
|
133
133
|
: "";
|
|
134
134
|
|
|
135
|
-
|
|
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);
|
|
136
143
|
|
|
137
144
|
return {
|
|
138
145
|
meta: compiled.meta,
|
|
@@ -192,6 +199,30 @@ export function enhanceImages(html, paths) {
|
|
|
192
199
|
});
|
|
193
200
|
}
|
|
194
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
|
+
|
|
195
226
|
/**
|
|
196
227
|
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
197
228
|
* @param {string} tag
|
package/src/compiler.js
CHANGED
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"]`.
|