@zvndev/darkmown 1.4.0 → 1.5.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/CHANGELOG.md +10 -0
- package/README.md +70 -1
- package/docs/spec-alignment.md +14 -0
- package/package.json +1 -1
- package/site/_/base.skin +231 -18
- package/site/_/nav.wd +1 -1
- package/site/pages/app.skin +27 -5
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +73 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/docs/index.wd +47 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/reactive.skin +38 -6
- package/site/pages/store.skin +43 -6
- package/src/builder.js +137 -29
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +28 -1
- package/src/compiler/loops.js +130 -12
- package/src/compiler/page.js +74 -21
- package/src/templates/blog/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/store/site/pages/index.skin +93 -11
- package/site/pages/blog/index.md +0 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.5.0 — 2026-06-28
|
|
6
|
+
|
|
7
|
+
Content collections — query a folder of markdown as data, with optional typed validation and pagination. Plus a default-styling pass so buttons and form controls look good out of the box, and two fixes from a product review. The runtime is unchanged (still 7459 B gzipped); collections and styling are entirely build-time.
|
|
8
|
+
|
|
9
|
+
- **Content collections.** Any `site/pages/<name>/` subdirectory is a queryable collection, referenced by its **bare name** in the existing loop — no new concept, no config file. `@loop blog into post sort by date desc paginate 10` reads the entries under `site/pages/blog/`, each as a row of its frontmatter plus derived `url`, `slug`, and `excerpt`. The whole `where` / `sort` / format-pipe / `:if` machinery works unchanged, and a pure listing stays zero-JS (`runtime: false`). Drafts are excluded by default (and can never leak into a listing); `build --drafts` includes them.
|
|
10
|
+
- **Typed schemas (optional).** Drop a `_schema.wd` at a collection's root to validate every entry's frontmatter at build time — one `field: type` per line over a closed vocabulary (`string`, `number`, `boolean`, `date`, `string[]`, trailing `?` = optional). Mismatches fail the build with a `file:line` error naming the entry and field. Absent `_schema.wd` = no validation (opt-in, like everything else).
|
|
11
|
+
- **Pagination.** `paginate N` on a collection loop multiplies one listing source into static pages — page 1 stays at `/blog/`, the rest at `/blog/page/2/`, `/blog/page/3/`. A `{ page.current }` / `{ page.total }` / `{ page.prev }` / `{ page.next }` pager is available in the listing scope; every generated page is static HTML in `routes.json` + `sitemap.xml`.
|
|
12
|
+
- **Polished default styling.** `:button`/`:submit` compile to a bare classless `<button>`, so their look came entirely from the base skin — which previously left them as flat/unstyled. The base skin and **all five scaffold templates** now ship a token-driven default for buttons (filled-accent with hover/active/focus-visible/disabled states, plus `.secondary`/`.ghost` variants) and form controls (inputs, selects with a custom chevron, textareas, checkbox/radio/range) — consistent in light **and** dark, with `prefers-reduced-motion` honored. A freshly scaffolded site looks good immediately.
|
|
13
|
+
- **Fixes (from a product review).** (1) Scoped-style stamping no longer corrupts the contents of raw-text elements (`<script>`/`<style>`/`<textarea>`/`<title>`) — it stamps their opening tags but leaves their bodies byte-intact. (2) Nested static routes (e.g. `/blog/<slug>/`, `/blog/page/2/`) now correctly receive the strict (no `unsafe-eval`) CSP on Vercel; the header drift-guard now validates full route paths so the gap can't recur.
|
|
14
|
+
|
|
5
15
|
## 1.4.0 — 2026-06-28
|
|
6
16
|
|
|
7
17
|
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).
|
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ 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.
|
|
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. The same frontmatter is queryable when the page is an entry in a [content collection](#content-collections) — `{ post.date }`, `{ post.excerpt }`, `{ post.tags }`, and any custom key resolve in an `@loop` over the folder.
|
|
117
117
|
|
|
118
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.)
|
|
119
119
|
|
|
@@ -351,6 +351,75 @@ That is a full add-to-cart / remove-line flow — and a to-do list with delete
|
|
|
351
351
|
>
|
|
352
352
|
> **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
|
|
353
353
|
|
|
354
|
+
## Content collections
|
|
355
|
+
|
|
356
|
+
**Any folder under `site/pages/` is a queryable collection** — referenced in `@loop` by its bare name. There is no `content/` root to opt into and no marker file to add: a `site/pages/blog/` directory of posts *is* the `blog` collection.
|
|
357
|
+
|
|
358
|
+
```wd
|
|
359
|
+
@loop blog into post sort by post.date desc
|
|
360
|
+
- [{ post.title }]({ post.url }) — { post.date }
|
|
361
|
+
@endloop
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
It's the same one loop, with the same clauses (`where`, `sort by`, `reverse`, `offset`, `limit`, format pipes). The collection resolves at **build time**, so a pure listing ships **zero JavaScript** (`runtime: false`).
|
|
365
|
+
|
|
366
|
+
Each entry's frontmatter becomes a row, plus three fields the framework derives:
|
|
367
|
+
|
|
368
|
+
| Field | Value |
|
|
369
|
+
|---|---|
|
|
370
|
+
| `{ post.url }` | the entry's route, e.g. `/blog/hello/` |
|
|
371
|
+
| `{ post.slug }` | the filename without extension (an `index.md` uses its folder name) |
|
|
372
|
+
| `{ post.excerpt }` | the frontmatter `excerpt:`, else the first paragraph of a `.md` body |
|
|
373
|
+
|
|
374
|
+
Scalar frontmatter is coerced for querying, so `where post.featured == true` and numeric sorts behave like they do over a JSON file. **Drafts never leak:** a `draft: true` entry is excluded from a default build's listing (included only under `darkmown build --drafts`), exactly like routing and feeds.
|
|
375
|
+
|
|
376
|
+
### Typed schema — `_schema.wd`
|
|
377
|
+
|
|
378
|
+
Drop a `_schema.wd` at a collection's root to validate every entry's frontmatter at build time. It's frontmatter-shaped — one `field: type` rule per line:
|
|
379
|
+
|
|
380
|
+
```wd
|
|
381
|
+
---
|
|
382
|
+
title: string
|
|
383
|
+
date: date
|
|
384
|
+
description: string
|
|
385
|
+
excerpt: string?
|
|
386
|
+
tags: string[]?
|
|
387
|
+
---
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The vocabulary is small and closed: `string`, `number`, `boolean`, `date`, `string[]`, each with a trailing `?` to mark it optional. A missing required field, a wrong type, an unknown extra field (a typo guard), or an unknown type token in the schema itself each **fail the build** with a `file:line` and the offending field. Validation is **opt-in** — no `_schema.wd`, no validation.
|
|
391
|
+
|
|
392
|
+
### Pagination — `paginate N`
|
|
393
|
+
|
|
394
|
+
Add `paginate N` (collections only) to split a listing into static pages: **page 1 keeps the listing's own route**, and pages 2+ live at `/<route>/page/2/`, `/<route>/page/3/`, … Every generated page is static HTML and appears in `routes.json` and `sitemap.xml`.
|
|
395
|
+
|
|
396
|
+
```wd
|
|
397
|
+
@loop blog into post sort by post.date desc paginate 5
|
|
398
|
+
- [{ post.title }]({ post.url })
|
|
399
|
+
@endloop
|
|
400
|
+
|
|
401
|
+
Page { page.current } of { page.total }
|
|
402
|
+
:if page.prev
|
|
403
|
+
[← Newer]({ page.prev })
|
|
404
|
+
:endif
|
|
405
|
+
:if page.next
|
|
406
|
+
[Older →]({ page.next })
|
|
407
|
+
:endif
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
A `page` pager is exposed to the whole page:
|
|
411
|
+
|
|
412
|
+
| Variable | Value |
|
|
413
|
+
|---|---|
|
|
414
|
+
| `{ page.current }` | 1-based current page number |
|
|
415
|
+
| `{ page.total }` | total number of pages |
|
|
416
|
+
| `{ page.prev }` | URL of the previous page, or `""` on page 1 |
|
|
417
|
+
| `{ page.next }` | URL of the next page, or `""` on the last page |
|
|
418
|
+
|
|
419
|
+
The pager is plain `<a href>` links to the generated routes — **zero JavaScript**. `paginate` is for collections only (route-multiplication only makes sense for a folder of entries) and can't combine with `offset`/`limit` (it owns the slice).
|
|
420
|
+
|
|
421
|
+
> See the [live blog demo](https://darkmown.com/blog/) — the whole index, paginated and sorted, is one `@loop` over `site/pages/blog/`, validated by a `_schema.wd`, shipping no framework JS.
|
|
422
|
+
|
|
354
423
|
## Sections
|
|
355
424
|
|
|
356
425
|
```wd
|
package/docs/spec-alignment.md
CHANGED
|
@@ -134,6 +134,20 @@ 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.5.0 additions — typed content collections + pagination (2026-06-29)
|
|
138
|
+
|
|
139
|
+
The 1.5.0 cycle ships the headline **content collections** feature: turning a folder of pages into a queryable, typed, paginated data source — entirely at build time. **Zero runtime impact** — it is build-time glue over the existing `@loop` pipeline; `src/runtime.js` and the 8 KB budget are untouched, and a pure collection listing stays `runtime: false`.
|
|
140
|
+
|
|
141
|
+
- **Zero-ceremony collections — bare folder name as `@loop` source.** ANY `site/pages/<name>/` subdirectory is a collection, referenced by its **bare name** (`@loop blog into post`). There is no `content/` root and no marker file; an existing `site/pages/blog/` becomes a collection with zero file moves. A build-time **collection index** (`src/compiler/collections.js`, `buildCollections`) is assembled from the router's already-parsed `route.meta` — frontmatter is **never re-parsed** — and threaded into the compilation as `comp.collections` (added to `context.js`). **Row shape:** every frontmatter field plus three derived fields — `url` (the entry route), `slug` (filename stem; an `index.*` entry uses its folder name), and `excerpt` (frontmatter `excerpt:` → first paragraph of a `.md` body via `feeds.js`'s `firstParagraph`; `.wd` bodies are not scanned). The derived keys are added **after** the frontmatter spread so a stray `url:`/`slug:` can't shadow real route data. Scalar frontmatter is **coerced** (`true`/`false` → boolean, numeric strings → number) **only** in the collection row (never in the global frontmatter parser), so `where post.featured == true` and numeric sorts behave like they do over a JSON file. **The same one loop** — `where`/`sort by`/`reverse`/`offset`/`limit`/format-pipes/`:if`/`:every` all work unchanged because a collection resolves to the same array of rows; a bare-name resolution sits **after** `:state` (a declared `:state` of the same name still wins) and a `where` that reads `:state` still makes the loop reactive (baked rows). An unresolved bare source errors with a `file:line` + the list of available collection names.
|
|
142
|
+
|
|
143
|
+
- **Drafts never leak into a default-build listing.** Collection rows are built from the **post-draft-filter** route list (the same `discoverRoutes` chokepoint routing/feeds use), so a `draft: true` entry is excluded from a default build's listing and included only under `darkmown build --drafts` — regression-tested on both paths.
|
|
144
|
+
|
|
145
|
+
- **Optional typed schema — `_schema.wd`.** A `_schema.wd` at a collection root opts the collection into build-time validation (absent = no validation, like `scoped`/`draft`). The DSL is **frontmatter-shaped** — one `field: type` rule per line inside the `---` block — written exactly like the frontmatter it validates. The type vocabulary is **small and closed**: `string`, `number`, `boolean`, `date`, `string[]`, each with a trailing `?` for optional. Validation throws on the first violation with a `file:line` and the offending field: a missing required field, a wrong type, or an **unknown extra field** (a typo guard — `titel:` is caught) all fail the build; framework-recognized keys (`url`/`slug`/`excerpt`/`draft`) are allowed implicitly. An **unknown type token in the schema itself** is a compile error pointing at the schema `file:line`.
|
|
146
|
+
|
|
147
|
+
- **Pagination — `@loop … paginate N`.** A new clause (collections only) multiplies the listing into static pages: **page 1 keeps the listing's own clean route**, pages 2+ live at `/<route>/page/<n>/`. The compiler records the pagination intent (`comp.pagination`, post-`where`/`sort` page count) during a discovery compile; `src/builder.js` then recompiles each page with the correct `page` pager seeded into the document scope and emits every generated route into `routes.json` + `sitemap.xml` (RSS is dated-posts-only, unaffected). The **pager** exposed to the template scope is `{ page.current }` / `{ page.total }` (numbers) and `{ page.prev }` / `{ page.next }` (URLs, empty string at the ends) — plain `<a href>` links to the generated routes, **zero JS**. `paginate` is a positive-integer literal, rejects a non-collection source (route-multiplication only makes sense for a folder of entries), and can't combine with `offset`/`limit` (it owns the slice) or `sortable`.
|
|
148
|
+
|
|
149
|
+
- **Demo (four-file rule):** the existing `site/pages/blog/` became a real collection — `blog/index.wd` is one paginated `@loop` over the folder (sorted date-desc, `paginate 3`, linking each entry by `{ post.url }`, with a styled `index.skin` and a `{ page.* }` pager), validated by `blog/_schema.wd`, across five genuine dated posts (so `/blog/` + `/blog/page/2/` both emit) plus a draft that proves exclusion. RSS/sitemap keep working over the collection. Tests in `tests/collections.test.js` (resolution, row shape incl. url/slug/excerpt, where/sort, draft exclusion + `--drafts` inclusion, schema pass+fail with `file:line`, pagination route-multiplication + pager vars + `/blog/page/2/` shape, `runtime: false` on a pure listing, every `paginate` guard). **Honest caveats:** `paginate N` is a build-time route split only — no client-side pagination; pagination is collections-only (a JSON-file/`:state` source is rejected); tag/taxonomy pages and image optimization are deferred to 1.6.0.
|
|
150
|
+
|
|
137
151
|
## 1.4.0 additions — compile-time scoped styles (2026-06-28)
|
|
138
152
|
|
|
139
153
|
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`.
|
package/package.json
CHANGED
package/site/_/base.skin
CHANGED
|
@@ -37,6 +37,13 @@ tokens
|
|
|
37
37
|
radius 12px
|
|
38
38
|
radius-sm 8px
|
|
39
39
|
radius-lg 20px
|
|
40
|
+
field-bg #ffffff
|
|
41
|
+
field-line #d9d2c4
|
|
42
|
+
field-line-strong #c4baa8
|
|
43
|
+
control-shadow 0 1px 2px rgba(28,24,20,.06)
|
|
44
|
+
control-shadow-hover 0 6px 18px -8px rgba(28,24,20,.45)
|
|
45
|
+
accent-press #094339
|
|
46
|
+
on-accent #ffffff
|
|
40
47
|
|
|
41
48
|
tokens dark
|
|
42
49
|
ink #ece6db
|
|
@@ -66,6 +73,13 @@ tokens dark
|
|
|
66
73
|
code-number #f2bb8a
|
|
67
74
|
code-punctuation #b6c2bc
|
|
68
75
|
ring #5ccab4
|
|
76
|
+
field-bg #1a1612
|
|
77
|
+
field-line #38301f
|
|
78
|
+
field-line-strong #4a3f2c
|
|
79
|
+
control-shadow 0 1px 2px rgba(0,0,0,.4)
|
|
80
|
+
control-shadow-hover 0 8px 22px -10px rgba(0,0,0,.7)
|
|
81
|
+
accent-press #b6ecdc
|
|
82
|
+
on-accent #0c1310
|
|
69
83
|
|
|
70
84
|
/* ── Reset & base ───────────────────────────────────────────────── */
|
|
71
85
|
|
|
@@ -156,45 +170,234 @@ main
|
|
|
156
170
|
color $accent
|
|
157
171
|
margin 0 0 .35rem
|
|
158
172
|
|
|
159
|
-
/* ── Buttons
|
|
160
|
-
|
|
161
|
-
.btn
|
|
173
|
+
/* ── Buttons ────────────────────────────────────────────────────────
|
|
174
|
+
Two ways to get a button, ONE consistent look:
|
|
175
|
+
1. The `.btn` class (for link-styled buttons: <a class="btn">).
|
|
176
|
+
2. A bare <button> — what the `:button`/`:submit` directives emit with
|
|
177
|
+
no class. Both share the same sizing, states, and tokens so a demo,
|
|
178
|
+
a scaffold, or a docs page looks designed without per-page styling.
|
|
179
|
+
The bare-button default is the ACCENT (filled) treatment, because a
|
|
180
|
+
directive button is almost always the page's primary action. Opt down
|
|
181
|
+
to a quieter look with class="secondary" / class="ghost".
|
|
182
|
+
─────────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
/* Shared geometry + motion for every button-shaped thing. One-line comma
|
|
185
|
+
selector (multi-line comma lists silently drop all but the last). */
|
|
186
|
+
.btn, button, button[data-wd-action], button[data-wd-actions], [type=submit], [type=button], [type=reset]
|
|
162
187
|
display inline-flex
|
|
163
188
|
align-items center
|
|
189
|
+
justify-content center
|
|
164
190
|
gap .5rem
|
|
165
191
|
margin .4rem .5rem .4rem 0
|
|
166
|
-
padding .
|
|
167
|
-
font-
|
|
168
|
-
font-
|
|
169
|
-
|
|
192
|
+
padding .62rem 1.15rem
|
|
193
|
+
font-family system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
|
|
194
|
+
font-size .94rem
|
|
195
|
+
font-weight 560
|
|
196
|
+
line-height 1.15
|
|
197
|
+
letter-spacing -.005em
|
|
170
198
|
color $ink
|
|
171
199
|
bg $panel
|
|
172
200
|
border 1px solid $line
|
|
173
201
|
radius 999px
|
|
174
202
|
text-decoration none
|
|
175
203
|
cursor pointer
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.
|
|
179
|
-
transform translateY(-1px)
|
|
180
|
-
box-shadow 0 6px 18px -8px rgba(28,24,20,.35)
|
|
204
|
+
-webkit-appearance none
|
|
205
|
+
appearance none
|
|
206
|
+
transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease, background .14s ease, border-color .14s ease, color .14s ease
|
|
181
207
|
|
|
182
|
-
|
|
208
|
+
/* The filled accent default — applies to bare directive buttons and to
|
|
209
|
+
the opt-in `.btn.primary` / `.btn.warm` link variants. One-line list. */
|
|
210
|
+
button, button[data-wd-action], button[data-wd-actions], [type=submit], .btn.primary
|
|
183
211
|
bg $accent
|
|
184
|
-
color
|
|
212
|
+
color $on-accent
|
|
185
213
|
border-color $accent
|
|
214
|
+
box-shadow $control-shadow
|
|
186
215
|
|
|
187
|
-
.btn.primary:hover
|
|
216
|
+
button:hover, button[data-wd-action]:hover, button[data-wd-actions]:hover, [type=submit]:hover, .btn.primary:hover
|
|
188
217
|
bg $accent-deep
|
|
189
|
-
color
|
|
218
|
+
border-color $accent-deep
|
|
219
|
+
color $on-accent
|
|
220
|
+
transform translateY(-1px)
|
|
221
|
+
box-shadow $control-shadow-hover
|
|
222
|
+
|
|
223
|
+
button:active, button[data-wd-action]:active, button[data-wd-actions]:active, [type=submit]:active, .btn.primary:active
|
|
224
|
+
bg $accent-press
|
|
225
|
+
border-color $accent-press
|
|
226
|
+
transform translateY(0)
|
|
227
|
+
box-shadow $control-shadow
|
|
228
|
+
|
|
229
|
+
/* Visible keyboard focus on every interactive control — accessibility. */
|
|
230
|
+
.btn:focus-visible, button:focus-visible, [type=submit]:focus-visible, [type=button]:focus-visible, [type=reset]:focus-visible
|
|
231
|
+
outline 2px solid $ring
|
|
232
|
+
outline-offset 2px
|
|
233
|
+
|
|
234
|
+
button:disabled, [type=submit]:disabled, .btn[aria-disabled=true], [type=button]:disabled
|
|
235
|
+
opacity .5
|
|
236
|
+
cursor not-allowed
|
|
237
|
+
box-shadow none
|
|
238
|
+
transform none
|
|
239
|
+
|
|
240
|
+
/* Quieter, neutral button — a secondary/cancel action. One-line list. */
|
|
241
|
+
.btn, button.secondary, button.ghost, .btn.secondary, .btn.ghost
|
|
242
|
+
bg $panel
|
|
243
|
+
color $ink
|
|
244
|
+
border-color $line
|
|
245
|
+
|
|
246
|
+
.btn:hover
|
|
247
|
+
transform translateY(-1px)
|
|
248
|
+
box-shadow $control-shadow-hover
|
|
249
|
+
|
|
250
|
+
button.secondary:hover, .btn.secondary:hover
|
|
251
|
+
bg $sunk
|
|
252
|
+
border-color $line
|
|
253
|
+
color $ink
|
|
254
|
+
transform translateY(-1px)
|
|
255
|
+
box-shadow $control-shadow-hover
|
|
256
|
+
|
|
257
|
+
button.ghost, .btn.ghost
|
|
258
|
+
bg transparent
|
|
259
|
+
border-color transparent
|
|
260
|
+
box-shadow none
|
|
261
|
+
|
|
262
|
+
button.ghost:hover, .btn.ghost:hover
|
|
263
|
+
bg $sunk
|
|
264
|
+
box-shadow none
|
|
265
|
+
transform none
|
|
190
266
|
|
|
191
267
|
.btn.warm
|
|
192
268
|
bg $warm
|
|
193
269
|
color #fff
|
|
194
270
|
border-color $warm
|
|
195
271
|
|
|
196
|
-
.btn.
|
|
197
|
-
bg
|
|
272
|
+
.btn.warm:hover
|
|
273
|
+
bg $warm
|
|
274
|
+
color #fff
|
|
275
|
+
|
|
276
|
+
/* ── Form controls ──────────────────────────────────────────────────
|
|
277
|
+
`:input`/`:bind`/`:textarea`/`:select` emit bare <input>/<textarea>/
|
|
278
|
+
<select> — style them here so forms look designed by default. ─────── */
|
|
279
|
+
|
|
280
|
+
input, textarea, select
|
|
281
|
+
font-family system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
|
|
282
|
+
font-size .95rem
|
|
283
|
+
line-height 1.4
|
|
284
|
+
color $ink
|
|
285
|
+
bg $field-bg
|
|
286
|
+
border 1px solid $field-line
|
|
287
|
+
radius $radius-sm
|
|
288
|
+
padding .58rem .8rem
|
|
289
|
+
margin .35rem 0
|
|
290
|
+
width 100%
|
|
291
|
+
max-width 28rem
|
|
292
|
+
-webkit-appearance none
|
|
293
|
+
appearance none
|
|
294
|
+
transition border-color .14s ease, box-shadow .14s ease, background .14s ease
|
|
295
|
+
|
|
296
|
+
input::placeholder, textarea::placeholder
|
|
297
|
+
color $faint
|
|
298
|
+
|
|
299
|
+
input:hover, textarea:hover, select:hover
|
|
300
|
+
border-color $field-line-strong
|
|
301
|
+
|
|
302
|
+
input:focus-visible, textarea:focus-visible, select:focus-visible
|
|
303
|
+
outline none
|
|
304
|
+
border-color $accent
|
|
305
|
+
box-shadow 0 0 0 3px $accent-soft
|
|
306
|
+
|
|
307
|
+
input:disabled, textarea:disabled, select:disabled
|
|
308
|
+
opacity .55
|
|
309
|
+
cursor not-allowed
|
|
310
|
+
bg $sunk
|
|
311
|
+
|
|
312
|
+
textarea
|
|
313
|
+
min-height 6.5rem
|
|
314
|
+
resize vertical
|
|
315
|
+
|
|
316
|
+
/* Native select chevron (the appearance reset hides the default arrow). */
|
|
317
|
+
select
|
|
318
|
+
background-image url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23756a5d' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
|
|
319
|
+
background-repeat no-repeat
|
|
320
|
+
background-position right .75rem center
|
|
321
|
+
padding-right 2.25rem
|
|
322
|
+
cursor pointer
|
|
323
|
+
|
|
324
|
+
/* Checkbox / radio — accent-tinted, comfortable hit target. The directive
|
|
325
|
+
emits <label><input type=checkbox|radio> Text</label> in .wd-checkboxes /
|
|
326
|
+
.wd-radios groups. One-line comma list. */
|
|
327
|
+
.wd-checkboxes, .wd-radios
|
|
328
|
+
display flex
|
|
329
|
+
flex-direction column
|
|
330
|
+
gap .55rem
|
|
331
|
+
margin .5rem 0
|
|
332
|
+
|
|
333
|
+
.wd-checkboxes label, .wd-radios label
|
|
334
|
+
display flex
|
|
335
|
+
align-items center
|
|
336
|
+
gap .55rem
|
|
337
|
+
cursor pointer
|
|
338
|
+
|
|
339
|
+
[type=checkbox], [type=radio]
|
|
340
|
+
width 1.1rem
|
|
341
|
+
height 1.1rem
|
|
342
|
+
min-width 1.1rem
|
|
343
|
+
margin 0
|
|
344
|
+
max-width none
|
|
345
|
+
padding 0
|
|
346
|
+
accent-color $accent
|
|
347
|
+
cursor pointer
|
|
348
|
+
|
|
349
|
+
[type=checkbox]:focus-visible, [type=radio]:focus-visible
|
|
350
|
+
outline 2px solid $ring
|
|
351
|
+
outline-offset 2px
|
|
352
|
+
|
|
353
|
+
label
|
|
354
|
+
font-size .95rem
|
|
355
|
+
color $ink-soft
|
|
356
|
+
|
|
357
|
+
/* The range slider (`:slider`) — tint the track to the brand accent. */
|
|
358
|
+
[type=range]
|
|
359
|
+
accent-color $accent
|
|
360
|
+
cursor pointer
|
|
361
|
+
padding 0
|
|
362
|
+
|
|
363
|
+
/* Framework control buttons (`:carousel` injects these at runtime). They
|
|
364
|
+
must NOT inherit the big accent pill above — reset them to a compact,
|
|
365
|
+
neutral round control. Page skins can still override. One-line lists. */
|
|
366
|
+
.wd-carousel-prev, .wd-carousel-next
|
|
367
|
+
width 2.25rem
|
|
368
|
+
height 2.25rem
|
|
369
|
+
padding 0
|
|
370
|
+
margin 0
|
|
371
|
+
bg $panel
|
|
372
|
+
color $ink
|
|
373
|
+
border 1px solid $line
|
|
374
|
+
radius 999px
|
|
375
|
+
box-shadow $control-shadow
|
|
376
|
+
|
|
377
|
+
.wd-carousel-prev:hover, .wd-carousel-next:hover
|
|
378
|
+
bg $panel
|
|
379
|
+
border-color $accent
|
|
380
|
+
color $accent
|
|
381
|
+
transform none
|
|
382
|
+
box-shadow $control-shadow-hover
|
|
383
|
+
|
|
384
|
+
.wd-carousel-dot
|
|
385
|
+
width .6rem
|
|
386
|
+
height .6rem
|
|
387
|
+
padding 0
|
|
388
|
+
margin 0
|
|
389
|
+
border 0
|
|
390
|
+
radius 999px
|
|
391
|
+
bg $line
|
|
392
|
+
box-shadow none
|
|
393
|
+
|
|
394
|
+
.wd-carousel-dot:hover
|
|
395
|
+
bg $faint
|
|
396
|
+
transform none
|
|
397
|
+
box-shadow none
|
|
398
|
+
|
|
399
|
+
.wd-carousel-dot[aria-current=true]
|
|
400
|
+
bg $accent
|
|
198
401
|
|
|
199
402
|
/* ── Cards & surfaces ───────────────────────────────────────────── */
|
|
200
403
|
|
|
@@ -359,6 +562,16 @@ blockquote
|
|
|
359
562
|
margin 0
|
|
360
563
|
radius 0
|
|
361
564
|
|
|
565
|
+
/* ── Motion preference ──────────────────────────────────────────────
|
|
566
|
+
Honour users who ask for less motion: drop the button lift/transition.
|
|
567
|
+
(@media must be indented in a .skin — its rules wrap automatically.) ── */
|
|
568
|
+
|
|
569
|
+
@media (prefers-reduced-motion: reduce)
|
|
570
|
+
.btn, button, [type=submit], [type=button], input, textarea, select
|
|
571
|
+
transition none
|
|
572
|
+
.btn:hover, button:hover, [type=submit]:hover
|
|
573
|
+
transform none
|
|
574
|
+
|
|
362
575
|
/* ── Footer ─────────────────────────────────────────────────────── */
|
|
363
576
|
|
|
364
577
|
.sitefooter
|
package/site/_/nav.wd
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
<a class="brand" href="/">Darkmown</a>
|
|
3
3
|
<span class="nav-links">
|
|
4
4
|
<a href="/docs/">Docs</a>
|
|
5
|
+
<a href="/blog/">Blog</a>
|
|
5
6
|
<a href="/showcase/">Showcase</a>
|
|
6
7
|
<a href="/reactive/">Reactive</a>
|
|
7
8
|
<a href="/data/">Data</a>
|
|
8
|
-
<a href="/highlight/">Highlight</a>
|
|
9
9
|
</span>
|
|
10
10
|
<a class="nav-gh" href="https://github.com/ZVN-DEV/darkmown">GitHub ↗</a>
|
|
11
11
|
</nav>
|
package/site/pages/app.skin
CHANGED
|
@@ -57,20 +57,42 @@ h1
|
|
|
57
57
|
color #9a2a2a
|
|
58
58
|
|
|
59
59
|
button
|
|
60
|
+
display inline-flex
|
|
61
|
+
align-items center
|
|
62
|
+
justify-content center
|
|
63
|
+
gap .4rem
|
|
60
64
|
background $accent
|
|
61
65
|
color white
|
|
62
|
-
border
|
|
63
|
-
padding .55rem
|
|
66
|
+
border 1px solid $accent
|
|
67
|
+
padding .55rem 1rem
|
|
64
68
|
margin .25rem .4rem .25rem 0
|
|
65
|
-
border-radius
|
|
66
|
-
font-size .
|
|
69
|
+
border-radius 999px
|
|
70
|
+
font-size .92rem
|
|
71
|
+
font-weight 560
|
|
67
72
|
cursor pointer
|
|
73
|
+
appearance none
|
|
74
|
+
transition transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .14s ease
|
|
75
|
+
|
|
76
|
+
button:hover
|
|
77
|
+
transform translateY(-1px)
|
|
78
|
+
box-shadow 0 8px 18px -8px rgba(15,107,94,.5)
|
|
79
|
+
|
|
80
|
+
button:focus-visible
|
|
81
|
+
outline 2px solid $accent
|
|
82
|
+
outline-offset 2px
|
|
68
83
|
|
|
69
84
|
input
|
|
70
85
|
font-size 1rem
|
|
71
|
-
padding .
|
|
86
|
+
padding .55rem .75rem
|
|
72
87
|
border 1px solid $line
|
|
73
88
|
border-radius 8px
|
|
74
89
|
width 100%
|
|
75
90
|
max-width 22rem
|
|
76
91
|
margin .3rem 0
|
|
92
|
+
appearance none
|
|
93
|
+
transition border-color .14s ease, box-shadow .14s ease
|
|
94
|
+
|
|
95
|
+
input:focus-visible
|
|
96
|
+
outline none
|
|
97
|
+
border-color $accent
|
|
98
|
+
box-shadow 0 0 0 3px rgba(15,107,94,.18)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Typed schema for the blog collection. Every post under site/pages/blog/ is
|
|
3
|
+
# validated against these rules at build time — a missing required field, a
|
|
4
|
+
# wrong type, or an unknown extra field fails the build with a file:line. Append
|
|
5
|
+
# "?" to a type to make the field optional. Remove this file to turn validation
|
|
6
|
+
# off (it is opt-in).
|
|
7
|
+
title: string
|
|
8
|
+
date: date
|
|
9
|
+
description: string
|
|
10
|
+
excerpt: string?
|
|
11
|
+
tags: string[]?
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
The blog collection schema. Posts are plain `.md` files next to this file;
|
|
15
|
+
their frontmatter must match the rules above.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Collections and pagination, with zero ceremony
|
|
3
|
+
description: Any folder under site/pages is a queryable collection. Loop it by name, sort it, paginate it — all at build time, all zero-JS.
|
|
4
|
+
date: 2026-06-28
|
|
5
|
+
excerpt: This very page is an entry in a collection. The list you came from is one @loop over a folder — no content root, no marker file, no config.
|
|
6
|
+
tags: [collections, content]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Collections and pagination, with zero ceremony
|
|
10
|
+
|
|
11
|
+
The page you came from is not a hand-maintained list of links. It is one
|
|
12
|
+
`@loop` over a *collection* — and a collection is just a folder. Every `.md`
|
|
13
|
+
and `.wd` file under `site/pages/blog/` is an entry, queryable by the folder's
|
|
14
|
+
bare name:
|
|
15
|
+
|
|
16
|
+
```wd
|
|
17
|
+
@loop blog where draft == false sort by date desc paginate 3 into post
|
|
18
|
+
- [{ post.title }]({ post.url }) — { post.date }
|
|
19
|
+
@endloop
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No `content/` root to opt into, no marker file to drop in, no config object.
|
|
23
|
+
A folder of posts *is* the collection. Each entry's frontmatter becomes a row,
|
|
24
|
+
plus three fields the framework derives for you: `url` (the entry's route),
|
|
25
|
+
`slug` (its filename), and `excerpt` (the post's first paragraph when you don't
|
|
26
|
+
write one).
|
|
27
|
+
|
|
28
|
+
## It is the same loop you already know
|
|
29
|
+
|
|
30
|
+
There is no second loop syntax. `where`, `sort by`, `reverse`, `offset`,
|
|
31
|
+
`limit`, and the format pipes all work over a collection exactly as they do over
|
|
32
|
+
a JSON file — because under the hood a collection resolves to the same array of
|
|
33
|
+
rows. The only new word is `paginate N`, which slices the listing into static
|
|
34
|
+
pages: page one keeps this listing's clean route, and pages two and up live at
|
|
35
|
+
`/blog/page/2/`, `/blog/page/3/`, and so on. Every one is plain HTML.
|
|
36
|
+
|
|
37
|
+
A pure listing like this ships **zero** framework JavaScript. The pager links —
|
|
38
|
+
newer, older, numbered — are just `<a href>`s to the generated routes.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
title: Drafts that never leak
|
|
3
3
|
description: draft true excludes a page everywhere in a production build — dist, routes.json, sitemap, and rss.
|
|
4
4
|
date: 2026-06-27
|
|
5
|
+
excerpt: Set draft true on a post and it vanishes from a production build — dist, routes.json, the sitemap, and the feed — even with a date.
|
|
6
|
+
tags: [drafts, workflow]
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Drafts that never leak
|