@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 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,24 @@
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
+
14
+ ## 1.3.0 — 2026-06-28
15
+
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.
17
+
18
+ - **Syntax highlighting.** Fenced code blocks with a known language are highlighted at compile time (via `highlight.js` through markdown-it) — HTML plus a small stylesheet, **zero client JS**. Colors are driven by a new `$code-*` skin-token set (`code-bg`, `code-fg`, `code-keyword`, `code-string`, `code-comment`, `code-function`, `code-number`, `code-punctuation`), so code **dark-modes for free** through the existing `tokens dark` / `:theme` system — override any token in your own `.skin`. The stylesheet (`/__wd/highlight.css`) ships **only on pages that contain a highlighted block** (pay-for-what-you-use). Unknown/absent languages degrade to plain escaped `<code>`; inline code and line numbers are intentionally excluded; a prose+code page stays `runtime: false`.
19
+ - **RSS + sitemap.xml + robots.txt.** `darkmown build` now emits `sitemap.xml`, `rss.xml`, and `robots.txt` at build time. Site identity lives in **one reserved home-page frontmatter field, `site_url`** (absolute origin) — which reuses the home `title`/`description` as the RSS channel and acts as the trigger; no config file is introduced. The sitemap lists every published route with a `<lastmod>` (frontmatter `date` → git last-commit date → file mtime). Any page with a `date:` becomes an RSS item (newest-first, capped; description from `excerpt:` → `description:` → first paragraph of a `.md`); an `<link rel="alternate" type="application/rss+xml">` is injected into every page `<head>`. With no `site_url`, `robots.txt` still emits (without the `Sitemap:` line) and the feeds are skipped with a build hint — never a crash.
20
+ - **Drafts.** `draft: true` frontmatter excludes a page from a production build — gone from `dist`, `routes.json`, the sitemap, and the feed (even if it has a `date:`). `darkmown dev` still builds and serves drafts with a dev-only banner; `darkmown build --drafts` includes them (for staging). Drafts are filtered at route discovery, so a feed can never leak one. This is distinct from the permanent `.`/`-`/`_` filename-prefix hiding: use a prefix to hide a file forever, `draft: true` to hide a page you intend to publish later.
21
+ - **New public frontmatter:** `site_url` (home page), `draft`, `date`, `excerpt`. New build flag: `--drafts`. The `blog` starter template now ships working feeds out of the box.
22
+
5
23
  ## 1.2.1 — 2026-06-27
6
24
 
7
25
  Repository hygiene and documentation polish from a gold-standard audit (50/50 — every dimension at React-tier). No runtime, API, or behavior change; the shipped package is byte-identical except for one source comment.
package/README.md CHANGED
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
52
52
 
53
53
  - `darkmown init [dir] [--template <name>]` scaffolds a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`).
54
54
  - `darkmown dev` starts the live compiler with browser reload, an in-browser error overlay, and a local runner for `api/*` functions.
55
- - `darkmown build [--target cloudflare]` writes static output to `dist`.
55
+ - `darkmown build [--target cloudflare] [--drafts]` writes static output to `dist` (plus `sitemap.xml`/`rss.xml`/`robots.txt`); `--drafts` includes `draft: true` pages for staging.
56
56
  - `darkmown deploy <vercel|cloudflare> [--prod]` builds and deploys via the platform CLI.
57
57
  - `darkmown serve` previews the built `dist` locally.
58
58
  - `darkmown version` prints the installed package version.
@@ -113,6 +113,8 @@ tags: [sales, revenue, "q1, q2"]
113
113
 
114
114
  Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card).
115
115
 
116
+ A few reserved keys drive **drafts and feeds** (see [SEO & feeds](#seo--feeds-sitemap-rss-robots) below): `draft: true` excludes a page from production builds; `site_url` on the home page turns on `sitemap.xml` + `rss.xml`; `date:` marks a page as a blog post (it lands in `rss.xml` and sets the page's `<lastmod>`); `excerpt:` is the RSS summary.
117
+
116
118
  `transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
117
119
 
118
120
  ## Static assets
@@ -135,6 +137,55 @@ Hidden/private page paths stay private: any `site/pages` asset below a path segm
135
137
 
136
138
  Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
137
139
 
140
+ ## SEO & feeds (sitemap, RSS, robots)
141
+
142
+ `darkmown build` emits the crawler files a site needs — all build-time, zero client JS — driven by a little frontmatter. There is no config file: the **home page** (`site/pages/index.md` or `.wd`) carries the site's identity.
143
+
144
+ ```wd
145
+ ---
146
+ title: My Blog
147
+ description: Notes on shipping Markdown that runs.
148
+ site_url: https://example.com
149
+ ---
150
+ ```
151
+
152
+ - **`site_url`** (absolute origin, **no trailing slash**) turns on `sitemap.xml` and `rss.xml` and is the absolute prefix for every URL in them. The home `title`/`description` become the RSS channel title/description.
153
+ - **`robots.txt`** is *always* emitted (`User-agent: * / Allow: /`); the `Sitemap:` line is added only when `site_url` is set.
154
+ - **`sitemap.xml`** lists every built page (reactive pages included — they're indexable HTML). Each `<lastmod>` is the page's frontmatter `date:` if set, else its git last-commit date, else the file's mtime. No `<priority>`/`<changefreq>`.
155
+ - **`rss.xml`** syndicates your **posts** — any page with a `date:` in its frontmatter. Newest first, capped at the 20 most recent. Each item's `<description>` is the page's `excerpt:`, else its `description:`, else (for a plain `.md` post) its first paragraph. Every page links the feed with `<link rel="alternate" type="application/rss+xml">` so readers can autodiscover it.
156
+
157
+ ```md
158
+ ---
159
+ title: Hello, Darkmown
160
+ date: 2026-01-15
161
+ excerpt: Why I rewrote my blog as plain Markdown files.
162
+ ---
163
+
164
+ # Hello, Darkmown
165
+ …
166
+ ```
167
+
168
+ Without `site_url`, `robots.txt` still emits and the build prints a one-line hint telling you which field to set; it never crashes.
169
+
170
+ ### Drafts
171
+
172
+ Mark any page `draft: true` to keep it out of production:
173
+
174
+ ```md
175
+ ---
176
+ title: Work in progress
177
+ draft: true
178
+ ---
179
+ ```
180
+
181
+ - **`darkmown build`** excludes drafts everywhere — no HTML in `dist`, no entry in `routes.json`, `sitemap.xml`, or `rss.xml` (even a draft that *also* has a `date:` never reaches a feed).
182
+ - **`darkmown dev`** builds and serves drafts so you can preview them, with a visible "DRAFT" banner that exists only in the dev server — never in any shipped HTML.
183
+ - **`darkmown build --drafts`** includes drafts everywhere — for a staging deploy.
184
+
185
+ Drafts are filtered at one point — route discovery — so nothing downstream ever sees them. This is **separate from** the permanent `.`/`-`/`_` filename hiding: a hidden name (`-notes.wd`) is private forever; `draft:` is a toggle you flip when the page is ready to ship.
186
+
187
+ The `build` summary reports what it emitted: `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`.
188
+
138
189
  ## Loops
139
190
 
140
191
  `@loop <things> into <thing>` is the only loop. The source decides the behavior:
@@ -692,6 +743,39 @@ The block must follow the element with no space, and works on links, images, emp
692
743
 
693
744
  Darkmown's shipped CSP pre-authorizes exactly the two embed origins (`youtube-nocookie.com`, `player.vimeo.com`) and `media-src 'self' https:`, so embeds and remote media work out of the box on the bundled server, Cloudflare `_headers`, and Vercel.
694
745
 
746
+ ## Syntax highlighting
747
+
748
+ *New in 1.3.* Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language and it just works:
749
+
750
+ ````md
751
+ ```js
752
+ const greeting = "Darkmown"; // highlighted at build time
753
+ ```
754
+ ````
755
+
756
+ The highlighter is [highlight.js](https://highlightjs.org/) and is not configurable (one closed default, like the rest of the framework). Its token classes map onto your skin's `$code-*` tokens, so highlighted code **dark-modes for free** through the same [`tokens dark` / `:theme`](#dark-mode--tokens-dark) system below — no extra wiring. Tune the palette (or rely on the built-in default set) in your `.skin`:
757
+
758
+ ```skin
759
+ tokens
760
+ code-bg #1b2420
761
+ code-fg #e9efe7
762
+ code-keyword #d9a8e0
763
+ code-string #97d892
764
+ code-comment #859289
765
+ code-function #88c4ee
766
+ code-number #ecae78
767
+ code-punctuation #c1ccc6
768
+ tokens dark
769
+ code-bg #100d0a
770
+ code-keyword #e2b9e8
771
+ ```
772
+
773
+ - **Pay-for-what-you-use.** The stylesheet (`/__wd/highlight.css`) is emitted and linked **only** on pages that actually contain a highlighted block — a page with no code ships nothing extra.
774
+ - **Zero runtime.** Highlighting is build-time output, so a page of prose plus code stays `runtime: false` (no `/__wd/runtime.js`). It never pulls in the reactive runtime.
775
+ - **Graceful degradation.** A fence with an unknown or absent language renders as plain escaped `<code>` (no highlighting, no error). Inline `` `code` `` is never highlighted, and there are deliberately **no line numbers** (they break copy-paste).
776
+
777
+ See it recolor live on the [Syntax highlighting demo](https://darkmown.com/highlight/) — flip the theme toggle and every block recolors at once.
778
+
695
779
  ## Dark mode — `tokens dark`
696
780
 
697
781
  *New in 0.19.0.* A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
@@ -725,6 +809,50 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
725
809
 
726
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.)
727
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
+
728
856
  ## The escape hatch
729
857
 
730
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:
@@ -134,6 +134,30 @@ 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
+
153
+ ## 1.3.0 additions — table-stakes batteries (2026-06-28)
154
+
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.
156
+
157
+ - **Drafts + sitemap.xml + rss.xml + robots.txt:** three coupled SEO/feed capabilities sharing one draft-exclusion gate. Site identity lives in the **home page frontmatter** (no config loader this cycle): one reserved field `site_url` (absolute origin, no trailing slash) reuses the home `title`/`description` as the RSS channel fields and is the absolute prefix for — and the trigger for emitting — `sitemap.xml` + `rss.xml`. **Drafts:** `draft: true` excludes a page from a production build everywhere (not in `dist`, `routes.json`, sitemap, or rss — even a draft that *also* has a `date:`); `darkmown dev` builds/serves drafts with a dev-only visible banner injected at serve time (never in shipped HTML, like the SSE client); `darkmown build --drafts` includes them for staging. Drafts are filtered at the single route-discovery **chokepoint** (`discoverRoutes`), which now reads each file's frontmatter **once** and threads it out as `route.meta` (one source of truth for "is this a draft", and the data the feeds consume — no second parse). This is distinct from the permanent `.`/`-`/`_` filename hiding (private forever vs a publish-when-ready toggle). **sitemap.xml** (emitted whenever `site_url` is set) lists one `<url>` per built non-404 route — reactive pages included (indexable HTML) — with `<lastmod>` = frontmatter `date:` → git last-commit date of the source file → file mtime (git wrapped in try/catch so a scaffolded app or extracted tarball with no `.git` degrades to mtime, never crashes); no `<priority>`/`<changefreq>`. **rss.xml** (emitted only with `site_url` *and* at least one dated post) syndicates the pages with a `date:` (the "this is a post" signal), newest-first, capped at 20; `<description>` resolves `excerpt:` → `description:` → first paragraph of rendered prose (plain `.md` only — `.wd` directive markup is not stripped back to prose); `<pubDate>` is RFC-822; every page advertises the feed with `<link rel="alternate" type="application/rss+xml">`. **robots.txt** is *always* emitted (`User-agent: * / Allow: /`); the `Sitemap:` line appears only with `site_url`. **No `site_url`:** robots still emits (without the Sitemap line), sitemap + rss are skipped with a loud, actionable build hint, and the build never crashes. String/XML building lives in a new `src/feeds.js` (mirroring how `src/highlight.js` keeps `builder.js` lean); the `build` summary reports counts (`Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`). **Zero runtime impact** — all build-time; `src/runtime.js` and the 8 KB budget are untouched. **The leak contract** (a `draft: true` page with a `date:` produces zero sitemap entries and zero rss items; `/404/` never appears in either) is regression-tested on both the production-exclusion and `--drafts`-inclusion paths, plus the git-date vs mtime-fallback paths.
158
+
159
+ - **Build-time syntax highlighting:** fenced code blocks with a known language are highlighted at compile time via [highlight.js](https://highlightjs.org/), wired onto both markdown-it instances (the default `html: true` and the lazy `html: false` strict variant) through markdown-it's `highlight` option in `src/compiler/markdown.js`. Output is **class-based** (`hljs-*` token spans, escaped) — no inline colors — and the framework ships a small stylesheet (`/__wd/highlight.css`) that maps the common token classes onto a `$code-*` skin-token set (`code-bg`, `code-fg`, `code-keyword`, `code-string`, `code-comment`, `code-function`, `code-number`, `code-punctuation`), each `var(--code-*, fallback)`. Because those tokens carry a `tokens dark` override in the base skin, **highlighted code dark-modes for free** through the existing `tokens dark` / `:theme` system with zero JavaScript. The highlighter is **not configurable** (closed-whitelist philosophy). Emission/injection is **pay-for-what-you-use**: a per-page `assets.hasCode` flag (set when the rendered HTML carries the `hljs` marker) emits and links the stylesheet **only** on pages that contain a highlighted block, mirroring the behavior modules — a page with no code ships nothing extra. **Zero runtime impact:** highlighting is build-time HTML + CSS only (`src/runtime.js` untouched, the 8 KB runtime budget unchanged), so a page of prose plus a code block stays `runtime: false`. **Graceful degradation:** a fence with an unknown or absent language renders as plain escaped `<code>` (no throw, no highlighting); inline `` `code` `` is never highlighted; there are deliberately **no line numbers** (they break copy-paste). **Honest caveat:** the stylesheet maps a curated common subset of highlight.js classes — any token class outside that subset inherits the base `--code-fg` color, so it stays legible but is not individually themed.
160
+
137
161
  ## 1.2.0 additions — backends, deploy & interactions (2026-06-27)
138
162
 
139
163
  - **Serverless `api/` model:** backend endpoints are plain-JS Web-standard handlers — `export default (request, context) => Response` — in a top-level `api/` directory (`api/echo.js` → `/api/echo`; `api/users/[id].js` → `/api/users/:id` with `context.params.id`). Darkmown owns no server; it *adapts* to the host. The build stays 100% static. On Vercel the `api/` functions run natively (Edge); `darkmown deploy cloudflare` (or `build --target cloudflare`) emits `dist/_worker.js` that routes `/api/*` to the same functions and serves everything else from `env.ASSETS`. `darkmown dev` runs a local runner so `/api/*` behaves identically before deploy. No `.wd` backend DSL — minimal new learning, zero lock-in. `form-action 'self'` was added to both CSP variants (and `vercel.json`) so a native `:form action="/api/…"` POST is allowed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -57,6 +57,7 @@
57
57
  "node": ">=20"
58
58
  },
59
59
  "dependencies": {
60
+ "highlight.js": "^11.11.1",
60
61
  "image-size": "^1.2.1",
61
62
  "markdown-it": "^14.2.0"
62
63
  },
package/site/_/base.skin CHANGED
@@ -26,6 +26,13 @@ tokens
26
26
  code-bg #1b2420
27
27
  code-ink #e9efe7
28
28
  code-line #2c3b34
29
+ code-fg #e9efe7
30
+ code-keyword #d9a8e0
31
+ code-string #97d892
32
+ code-comment #859289
33
+ code-function #88c4ee
34
+ code-number #ecae78
35
+ code-punctuation #c1ccc6
29
36
  ring #0f6b5e
30
37
  radius 12px
31
38
  radius-sm 8px
@@ -51,6 +58,13 @@ tokens dark
51
58
  code-bg #100d0a
52
59
  code-ink #e4ebe2
53
60
  code-line #2a231b
61
+ code-fg #e4ebe2
62
+ code-keyword #e2b9e8
63
+ code-string #a6e0a1
64
+ code-comment #7c8983
65
+ code-function #9bd0f4
66
+ code-number #f2bb8a
67
+ code-punctuation #b6c2bc
54
68
  ring #5ccab4
55
69
 
56
70
  /* ── Reset & base ───────────────────────────────────────────────── */
package/site/_/nav.wd CHANGED
@@ -5,6 +5,7 @@
5
5
  <a href="/showcase/">Showcase</a>
6
6
  <a href="/reactive/">Reactive</a>
7
7
  <a href="/data/">Data</a>
8
+ <a href="/highlight/">Highlight</a>
8
9
  </span>
9
10
  <a class="nav-gh" href="https://github.com/ZVN-DEV/darkmown">GitHub ↗</a>
10
11
  </nav>
@@ -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>
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Drafts that never leak
3
+ description: draft true excludes a page everywhere in a production build — dist, routes.json, sitemap, and rss.
4
+ date: 2026-06-27
5
+ ---
6
+
7
+ # Drafts that never leak
8
+
9
+ Set `draft: true` on any page and it disappears from a production build — no
10
+ HTML in `dist`, no entry in `routes.json`, and crucially no entry in
11
+ `sitemap.xml` or `rss.xml`, even if the draft also carries a `date:`.
12
+
13
+ The filter lives at one chokepoint — route discovery — so every downstream
14
+ consumer only ever sees published pages. `darkmown dev` still builds and serves
15
+ your drafts (with a visible banner), and `darkmown build --drafts` includes them
16
+ for a staging preview.
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: The Darkmown Blog
3
+ description: Notes on shipping Markdown that runs — feeds, drafts, and zero-JS releases.
4
+ ---
5
+
6
+ # The Darkmown Blog
7
+
8
+ Short notes on the framework. Every post here is a plain `.md` file with a
9
+ `date:` in its frontmatter — that `date:` is what lands a page in `rss.xml` and
10
+ stamps its `<lastmod>` in `sitemap.xml`.
11
+
12
+ - [Shipping feeds without a config file](/blog/shipping-feeds/)
13
+ - [Drafts that never leak](/blog/drafts-that-never-leak/)
@@ -0,0 +1,17 @@
1
+ ---
2
+ title: Shipping feeds without a config file
3
+ description: Site identity lives in the home page frontmatter — one site_url field turns on sitemap.xml and rss.xml.
4
+ date: 2026-06-26
5
+ excerpt: One reserved frontmatter field on the home page — site_url — turns on the whole feeds story. No config loader, no plugin.
6
+ ---
7
+
8
+ # Shipping feeds without a config file
9
+
10
+ Darkmown has no config file, so the site's public origin lives where the rest of
11
+ its identity already does: the home page frontmatter. Add `site_url` to
12
+ `site/pages/index` and the build emits `sitemap.xml` and `rss.xml`, reusing the
13
+ home `title` and `description` as the RSS channel fields.
14
+
15
+ Every page picks up a `<link rel="alternate" type="application/rss+xml">` so
16
+ feed readers can autodiscover the feed, and `robots.txt` points crawlers at the
17
+ sitemap. All build-time — not a byte of client JavaScript.
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: Unfinished thoughts (do not ship)
3
+ description: A work-in-progress post that demonstrates draft exclusion — it carries a date but never reaches a production feed.
4
+ date: 2026-06-28
5
+ draft: true
6
+ ---
7
+
8
+ # Unfinished thoughts
9
+
10
+ This post is marked `draft: true`. It also has a `date:`, which would normally
11
+ land it in `rss.xml` and `sitemap.xml` — but because it is a draft, a production
12
+ build excludes it from both, and from `dist` and `routes.json` entirely. You are
13
+ only reading this in `darkmown dev` or a `build --drafts` staging build.
@@ -176,6 +176,24 @@ tags: [sales, revenue, "q1, q2"]
176
176
 
177
177
  `{ meta.title }` prints a scalar and `{ meta.tags }` joins an array with `, `. You can loop a frontmatter array at build time — `@loop meta.tags into tag` — and the page stays static. Arrays are inline flow only (`[a, b]`); quoted items keep their commas.
178
178
 
179
+ ## SEO & feeds
180
+
181
+ `darkmown build` writes `sitemap.xml`, `rss.xml`, and `robots.txt` — all build-time, zero client JS. There's no config file: the **home page** frontmatter carries the site's identity. Add `site_url` (absolute origin, no trailing slash) and the sitemap and feed switch on, prefixed with that origin and titled with the home `title`/`description`.
182
+
183
+ ```wd
184
+ ---
185
+ title: My Blog
186
+ description: Notes
187
+ site_url: https://example.com
188
+ ---
189
+ ```
190
+
191
+ A page becomes an **RSS post** by carrying a `date:`; `excerpt:` is its feed summary (else `description:`, else — for a plain `.md` — its first paragraph). The sitemap's `<lastmod>` is the `date:` if set, else the file's git/last-modified date. `robots.txt` is always written; the `Sitemap:` line only appears with a `site_url`. Without `site_url`, robots still emits and the build prints a hint — it never breaks.
192
+
193
+ ## Drafts
194
+
195
+ Mark a page `draft: true` and it stays out of production: `darkmown build` leaves it out of `dist`, `routes.json`, the sitemap, and the feed — even if it has a `date:`. `darkmown dev` still builds and serves it (with a "DRAFT" banner you only see in dev), and `darkmown build --drafts` includes drafts for a staging deploy. This is separate from the permanent `.`/`-`/`_` filename hiding — a hidden name is private forever; `draft:` is a switch you flip when the page is ready.
196
+
179
197
  ## Instant navigation
180
198
 
181
199
  Add `transitions: true` to a page's frontmatter for **instant, flash-free navigation** — zero JavaScript. It emits a directional fade+slide view transition for the page swap and a `<script type="speculationrules">` prerender hint, so the browser renders the next same-origin page on hover/pointerdown and the click activates an already-painted page (no white render-gap). It honors `prefers-reduced-motion`, and only same-origin pages that both opt in transition. Off by default; opt out with `transitions: false`, or exclude a single link from prerendering with `{.no-prefetch}`. (Chrome turns prerendering off while DevTools is open — test the built site with DevTools closed.)
@@ -396,6 +414,29 @@ For an explicit light/dark switch alongside the OS preference, declare `:theme`
396
414
 
397
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/).
398
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
+
399
440
  ## Media — `:video`, `:audio`, `:embed`
400
441
 
401
442
  Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
@@ -408,6 +449,35 @@ Three compile-time-only directives (they emit no `data-wd-*`, so a media page st
408
449
 
409
450
  `:video`/`:audio` emit a hardened HTML5 player — `preload="metadata"` and `controls` by default, whitelisted flags (`autoplay` implies `muted`), and the `:fetch` URL scheme guard. `:embed` rewrites a YouTube/Vimeo link to its no-cookie/player form in a lazy, responsive `16/9` iframe. See the zero-JS [Media demo](/media/).
410
451
 
452
+ ## Syntax highlighting
453
+
454
+ Fenced code blocks with a language are highlighted at **build time** — HTML and CSS only, no client JavaScript. Tag the fence with a language:
455
+
456
+ ````md
457
+ ```js
458
+ const greeting = "Darkmown"; // highlighted at build time
459
+ ```
460
+ ````
461
+
462
+ The highlighter is [highlight.js](https://highlightjs.org/) and isn't configurable (one closed default, like everything else). It maps onto your skin's `$code-*` tokens, so highlighted code **dark-modes for free** through the same `tokens dark` / `:theme` system — no extra wiring. Define the palette once:
463
+
464
+ ```
465
+ tokens
466
+ code-bg #1b2420
467
+ code-fg #e9efe7
468
+ code-keyword #d9a8e0
469
+ code-string #97d892
470
+ code-comment #859289
471
+ code-function #88c4ee
472
+ code-number #ecae78
473
+ code-punctuation #c1ccc6
474
+ tokens dark
475
+ code-bg #100d0a
476
+ code-keyword #e2b9e8
477
+ ```
478
+
479
+ The framework ships a small default `$code-*` set, so highlighting looks right out of the box. The stylesheet is **pay-for-what-you-use**: it's emitted and linked only on pages that actually contain a highlighted block — a page with no code ships nothing extra. A fence with an unknown or absent language renders as plain escaped `<code>` (no highlighting, no error); inline `` `code` `` is never highlighted, and there are deliberately no line numbers (they break copy-paste). See the live [Syntax highlighting demo](/highlight/).
480
+
411
481
  ## Trust boundary
412
482
 
413
483
  Darkmown is a trusted-author site generator: you compile content you wrote, the way you trust your own source. Three things define that boundary.
@@ -0,0 +1,78 @@
1
+ main.app-highlight
2
+ max-width 1000px
3
+ margin 0 auto
4
+ padding 2.5rem 1.5rem
5
+
6
+ .hl-eyebrow
7
+ text-transform uppercase
8
+ letter-spacing .08em
9
+ font-size .8rem
10
+ opacity .7
11
+
12
+ .hl-tagline
13
+ font-size 1.05rem
14
+ opacity .85
15
+ margin-top .5rem
16
+ max-width 60ch
17
+
18
+ /* Theme toggle — reuse the ledger's pill-button switch so flipping the theme
19
+ recolors every highlighted block at once. */
20
+ .app-highlight .theme-switch
21
+ display inline-flex
22
+ align-items center
23
+ gap .4rem
24
+ font-size .75rem
25
+ font-weight 600
26
+ letter-spacing .04em
27
+ text-transform uppercase
28
+ color $muted
29
+ margin 1.5rem 0 .5rem
30
+
31
+ .app-highlight .theme-switch button[data-wd-action]
32
+ border 1px solid $line
33
+ bg $panel
34
+ color $ink-soft
35
+ font-size .8rem
36
+ font-weight 600
37
+ padding .4rem .8rem
38
+ radius 999px
39
+ cursor pointer
40
+ transition color .14s ease, border-color .14s ease, background .14s ease
41
+
42
+ .app-highlight .theme-switch button[data-wd-action]:hover
43
+ color $ink
44
+ border-color $accent
45
+
46
+ .hl-grid
47
+ margin 1rem 0 1.5rem
48
+
49
+ .hl-panel
50
+ padding 1.4rem 1.5rem
51
+
52
+ .hl-panel h3
53
+ margin-top 0
54
+ margin-bottom .6rem
55
+ font-size 1rem
56
+ color $muted
57
+ text-transform uppercase
58
+ letter-spacing .04em
59
+
60
+ .hl-panel pre
61
+ margin 0
62
+
63
+ .hl-note
64
+ margin-top 1.5rem
65
+ padding 1rem 1.25rem
66
+ border 1px solid $line
67
+ border-left 3px solid $accent
68
+ bg $panel
69
+ radius $radius-sm
70
+ color $ink-soft
71
+ font-size .95rem
72
+
73
+ .hl-degrade
74
+ border-left-color $muted
75
+
76
+ @media (max-width: 760px)
77
+ .hl-grid
78
+ grid-template-columns 1fr