@zvndev/darkmown 2.7.1-next.14.g3f67070 → 2.8.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 +78 -2
- package/CHANGELOG.md +20 -0
- package/README.md +79 -3
- package/docs/cli.md +19 -2
- package/docs/spec-alignment.md +14 -1
- package/package.json +3 -1
- package/site/_/footer.wd +4 -3
- package/site/_/nav.wd +3 -3
- package/site/pages/404.wd +2 -0
- package/site/pages/data.wd +1 -1
- package/site/pages/docs/index.wd +48 -2
- package/site/pages/index.js +17 -0
- package/src/api-runner.js +27 -6
- package/src/builder.js +8 -3
- package/src/catalog.js +21 -2
- package/src/cli.js +181 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/frontmatter.js +66 -0
- package/src/compiler/markdown.js +259 -2
- package/src/compiler/media.js +104 -9
- package/src/compiler/page.js +231 -8
- package/src/errors.js +23 -2
- package/src/feeds.js +14 -0
- package/src/skin.js +135 -0
- package/src/statics.js +126 -12
- package/src/tools/validate.js +7 -2
- package/types/api-runner.d.ts +13 -2
- package/types/compiler/frontmatter.d.ts +19 -0
- package/types/compiler/media.d.ts +20 -4
- package/types/compiler/page.d.ts +8 -0
- package/types/feeds.d.ts +11 -0
- package/types/skin.d.ts +5 -0
- package/types/statics.d.ts +40 -1
- package/types/tools/validate.d.ts +9 -1
package/site/pages/docs/index.wd
CHANGED
|
@@ -50,6 +50,7 @@ Darkmown is a Markdown-native framework for mostly-static sites with tiny reacti
|
|
|
50
50
|
- [Interactions](#interactions--slider-sortable-carousel)
|
|
51
51
|
- [Theme toggle — `:theme`](#theme-toggle--theme)
|
|
52
52
|
- [Scoped styles — `scoped`](#scoped-styles--scoped)
|
|
53
|
+
- [Skin at-rules](#skin-at-rules-font-face-and-keyframes)
|
|
53
54
|
- [Inline attributes](#inline-attributes)
|
|
54
55
|
- [Media directives](#media--video-audio-embed)
|
|
55
56
|
- [Syntax highlighting](#syntax-highlighting)
|
|
@@ -301,6 +302,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
301
302
|
- Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
|
|
302
303
|
- Values resolve **inside a link or image destination** too, and every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)` works.
|
|
303
304
|
- Values resolve in **raw HTML** as well (attributes and html blocks, HTML-escaped) on an `html: true` page, but those are painted once at build time rather than bound. Fenced code blocks and inline code spans are never rewritten, so a page can still show `` `[x](/p/{ p.slug }/)` `` as syntax.
|
|
305
|
+
- `{ $build.year }`, `{ $build.date }`, and `{ $build.datetime }` fold the build's date at compile time, in prose, raw HTML, and link destinations. They always fold, so a page using only `$build` bindings stays zero-JS. An unknown `$build.*` name stays literal text, and `.md` files are never folded.
|
|
304
306
|
|
|
305
307
|
### Destinations that bind
|
|
306
308
|
|
|
@@ -333,6 +335,8 @@ The value being piped must be **in scope** — a loop variable, `:state`/`:store
|
|
|
333
335
|
|
|
334
336
|
The whitelist: `money`, `number`, `percent`, `round`, `date` / `time` / `datetime` (Intl-backed; `short` / `medium` / `long`), `upper` / `lower` / `capitalize`, `truncate:n` / `trim`, `pluralize:"item"`, and `default:"—"`. The five aggregates double as list pipes — `{ cart | count }`, `{ cart | sum:"price" | money }`, plus `avg` / `min` / `max`. No custom functions, nothing `eval`'d; an unknown pipe is a compile error. There is deliberately no relative "time ago" formatter, so builds stay reproducible.
|
|
335
337
|
|
|
338
|
+
Pipes also fold inside **raw HTML** on an `html: true` page: a static value (a collection row, a JSON row, `meta`) in an attribute or html block formats at build time, so a static loop with pipes in raw HTML stays zero-JS. A reactive value there can never fold (raw HTML is a one-time paint), so it is painted once with a build hint, instead of staying silently literal as before 2.8.
|
|
339
|
+
|
|
336
340
|
## Frontmatter
|
|
337
341
|
|
|
338
342
|
Frontmatter sits between `---` fences. Values are strings, plus inline arrays:
|
|
@@ -348,6 +352,14 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
348
352
|
|
|
349
353
|
`lang:` sets the document language on `<html lang>` per page (`lang: fr`, `lang: pt-BR`, …); it defaults to `en`. Every page also ships a visually-hidden-until-focused **skip-to-content link** targeting the main landmark — your own `<main>` is reused (an existing `id` wins as the target; an id-less one gets `id="main"`), and a page without one is wrapped in `<main id="main">`. Restyle it via `.wd-skip-link` in a skin. All compile-time — static pages stay zero-JS.
|
|
350
354
|
|
|
355
|
+
Three more keys arrived in 2.8:
|
|
356
|
+
|
|
357
|
+
- **`icon: /path`** sets the favicon verbatim (`<link rel="icon">`, with a `type` for `.svg`/`.png`/`.ico`). Without it, the first of `site/pages/icon.svg`, `icon.png`, or `favicon.ico` wins; without either, the built-in mark stays the fallback.
|
|
358
|
+
- **`noindex: true`** builds the page but removes it from discovery: a robots `noindex,nofollow` meta, plus exclusion from `sitemap.xml`, `rss.xml`, and the generated `llms.txt`/`llms-full.txt`. On a collection entry with a `_schema.wd`, declare it as `noindex: boolean?`.
|
|
359
|
+
- **`skins: [/base.skin]`** (an inline array) loads shared `.skin` files for that page only, resolved like `@include` paths; a missing file, a non-`.skin` file, or a path outside `site/pages`/`site/_` is `[WD017]`. A shared skin whose first line is `scoped` scopes to the whole page body, so keep to one scoped skin per page.
|
|
360
|
+
|
|
361
|
+
**Cascade order changed in 2.8.** Stylesheet links emit the highlight sheet first (code pages), then include skins, then `skins:` entries in listed order, and **the page's own colocated `.skin` last**, so the page wins ties at equal specificity. Before 2.8 the page's skin came first; a page rule that unknowingly relied on losing to an include rule now wins.
|
|
362
|
+
|
|
351
363
|
## SEO & feeds
|
|
352
364
|
|
|
353
365
|
`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`.
|
|
@@ -429,6 +441,8 @@ Add `transitions: true` to a page's frontmatter for **instant, flash-free naviga
|
|
|
429
441
|
|
|
430
442
|
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
431
443
|
|
|
444
|
+
**Responsive variants.** Export pre-sized copies next to the source, same directory, same extension, `@<width>` before it (`dr-x@640.webp`, `dr-x@1280.webp`), and a Markdown image gains a `srcset` with the original kept as `src`. Descriptor widths are measured from the file headers (a `@640` file really holding 600px emits `600w`), candidates sort ascending, and the original joins at its intrinsic width. Discovery probes a fixed list of common widths (64 through 3840) rather than reading the directory, so an off-list width is a documented miss, never an error. `sizes` is never emitted; write raw `<img srcset sizes>` on an `html: true` page when you need one. Bound (``), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. Variant files are ordinary colocated assets: they copy to `dist/` and are never routes, and shelf images resolve variants against `/__wd/media/`. A `.md` page gets the hardening but no srcset.
|
|
445
|
+
|
|
432
446
|
## Static assets
|
|
433
447
|
|
|
434
448
|
Non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
|
|
@@ -820,10 +834,33 @@ compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup
|
|
|
820
834
|
| A selector rule | Scoped — attribute on the subject, before any `:hover`/`::before` |
|
|
821
835
|
| `tokens` / `tokens dark` | **Global** on `:root` — `$vars` and dark mode keep working site-wide |
|
|
822
836
|
| `:global(.toast)` (whole selector) | Opts back out — plain unscoped `.toast { … }` |
|
|
837
|
+
| `@font-face` / `@keyframes` | **Global**, never stamped, same contract as `tokens` |
|
|
823
838
|
| `page` / `*` / `html` / `body` / `::selection` | Compile error — page-level styles belong in a global skin |
|
|
824
839
|
|
|
825
840
|
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/).
|
|
826
841
|
|
|
842
|
+
## Skin at-rules: `@font-face` and `@keyframes`
|
|
843
|
+
|
|
844
|
+
Two CSS at-rules compile as self-contained blocks in a `.skin`, because their indented children are font descriptors and keyframe selectors rather than page selectors:
|
|
845
|
+
|
|
846
|
+
```skin
|
|
847
|
+
@font-face
|
|
848
|
+
font-family "Newsreader"
|
|
849
|
+
src url("/fonts/newsreader.woff2") format("woff2")
|
|
850
|
+
font-weight 200 800
|
|
851
|
+
font-display swap
|
|
852
|
+
|
|
853
|
+
@keyframes fade-in
|
|
854
|
+
from
|
|
855
|
+
opacity 0
|
|
856
|
+
50%, 75%
|
|
857
|
+
opacity .5
|
|
858
|
+
to
|
|
859
|
+
opacity 1
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
`@font-face` takes plain declarations, several blocks per skin allowed. `@keyframes <name>` requires a plain identifier; each indented child is a keyframe selector (`from`, `to`, a percentage, or a comma list) with its declarations one level deeper, and anything else is a compile error, so a keyframe selector is never misread as a declaration. Aliases and `$token` refs work inside both, both can nest under an `@media` line (the home for `prefers-reduced-motion` animation guards), and both stay **global** in a `scoped` skin, same as `tokens` blocks.
|
|
863
|
+
|
|
827
864
|
## Inline attributes
|
|
828
865
|
|
|
829
866
|
A trailing `{.class .class #id}` attaches classes or an id to the inline element directly before it, most often to style a link as a button without a wrapper:
|
|
@@ -834,6 +871,14 @@ A trailing `{.class .class #id}` attaches classes or an id to the inline element
|
|
|
834
871
|
|
|
835
872
|
The block must follow the element with no space. It works on links, images, and emphasis, and it does **not** attach to inline code. It never collides with `{ name }` interpolation, because an interpolation always starts with a name, never a `.` or `#`. This page uses it: a link marked `{.no-prefetch}` is excluded from the `transitions: true` prerender hint.
|
|
836
873
|
|
|
874
|
+
**Consecutive raw-HTML lines collapse into one paragraph.** On an `html: true` page, a run of lines that each start with an inline-level or unknown tag (`<a class="card">`, `<picture>`, a custom element) parses as ONE paragraph, so the elements render inline inside a single `<p>` instead of as layout siblings; a card grid collapses to one column this way. Lines starting with a block-level tag (`<div>`, `<section>`, …) never hit this, because CommonMark hoists them into an HTML block. The compiler warns once per run when 2+ consecutive lines of one paragraph each open with a component-looking tag (one carrying a `class` or `id`; classless prose links stay quiet):
|
|
875
|
+
|
|
876
|
+
```
|
|
877
|
+
site/pages/index.wd:6: 16 sibling HTML block lines will render inline in one <p>; wrap them in a container (e.g. a raw <div>)
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
The fix is the wrap: put the run inside a raw `<div class="grid">…</div>`. The hint stays silent inside a raw container, inside fenced code, on blank-line-separated lines, on `{ state }`-led lines, and on `.md` pages; a run inside a `:::` directive container DOES warn, because directive containers are not raw HTML.
|
|
881
|
+
|
|
837
882
|
## Media — `:video`, `:audio`, `:embed`
|
|
838
883
|
|
|
839
884
|
Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
|
|
@@ -844,7 +889,7 @@ Three compile-time-only directives (they emit no `data-wd-*`, so a media page st
|
|
|
844
889
|
:embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
|
|
845
890
|
```
|
|
846
891
|
|
|
847
|
-
`:video`/`:audio` emit a hardened HTML5 player
|
|
892
|
+
`:video`/`:audio` emit a hardened HTML5 player with `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, and takes three sizing and permission attributes: `aspect=4:3` swaps the ratio box, `height=400px` pins the height instead (units `px`/`rem`/`em`/`vh`/`vw`/`%`, wins over `aspect=`), and `allow="payment"` replaces the default iframe permissions with tokens from a fixed whitelist (`payment`, `autoplay`, `encrypted-media`, `fullscreen`, `picture-in-picture`, `clipboard-write`, `camera`, `microphone`, `geolocation`). A bad token is `[WD706]`, a bad ratio or length `[WD707]`. See the zero-JS [Media demo](/media/).
|
|
848
893
|
|
|
849
894
|
## Syntax highlighting
|
|
850
895
|
|
|
@@ -955,7 +1000,8 @@ The `darkmown` command (installed by `npx @zvndev/darkmown`) is the whole tool
|
|
|
955
1000
|
- `darkmown init [dir] [--template <name>]` scaffolds a new site from a template (`starter`, `blog`, `store`, `dashboard`, `landing`). It also writes agent context at the project root: `AGENTS.md` (the full directive reference, copied from the installed package so it can never teach syntax the compiler rejects), a `CLAUDE.md` pointing at it, and a `.gitignore`. Existing files are never overwritten.
|
|
956
1001
|
- `darkmown dev` — the live compiler: browser reload, an in-browser error overlay, incremental rebuilds, and a local runner for `api/*` functions.
|
|
957
1002
|
- `darkmown build [--target cloudflare] [--drafts]` — write 100% static output to `dist/` (plus `sitemap.xml` / `rss.xml` / `robots.txt`); `--drafts` includes `draft: true` pages for a staging deploy.
|
|
958
|
-
- `darkmown
|
|
1003
|
+
- `darkmown validate [path]` — compile-check every route under `site/pages/` (drafts included), or one file or directory, without writing `dist/`. Each page reports `compiles.` with its static/reactive status, warnings as bullets, or the coded `WD` error with file, line, and fix hint; the exit code is 1 when any page fails, so it gates CI.
|
|
1004
|
+
- `darkmown serve`: preview the built `dist/` locally. It runs the project's `api/*` functions with the same contract as dev and the production hosts (so `:form` posts and `:fetch` demos against `/api/…` work in the preview), and it compresses text responses, brotli preferred then gzip, negotiated off `Accept-Encoding` with `Vary: Accept-Encoding` set.
|
|
959
1005
|
- `darkmown catalog [--llms|--llms-full]`: print the `.wd` directive catalog as JSON, a compact cheatsheet for priming an AI model, or the complete reference including every error code (see [AI authoring](#ai-authoring)).
|
|
960
1006
|
- `darkmown deploy <vercel|cloudflare> [--prod]` — build (target-aware) and deploy through the platform CLI, printing your URL; an unauthenticated CLI surfaces the login to run first.
|
|
961
1007
|
- `darkmown version` / `darkmown help` — print the installed version, or the full command summary and directive syntax.
|
package/site/pages/index.js
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
// stays free of raw HTML. The copy button is added here instead: progressive
|
|
7
7
|
// enhancement, so the command is still readable and selectable with JS off.
|
|
8
8
|
|
|
9
|
+
// Little Friend click ids for the calls to action. Markdown links take no
|
|
10
|
+
// data attributes, so they are stamped here.
|
|
11
|
+
const ctaIds = [
|
|
12
|
+
["/docs/", "cta.docs"],
|
|
13
|
+
["/playground/", "cta.playground"],
|
|
14
|
+
["/vs/markdoc/", "cta.compare"],
|
|
15
|
+
["https://github.com/", "cta.github"],
|
|
16
|
+
["https://vercel.com/new/", "cta.deploy_vercel"]
|
|
17
|
+
];
|
|
18
|
+
for (const link of document.querySelectorAll(".hero-cta a")) {
|
|
19
|
+
const href = link.getAttribute("href") || "";
|
|
20
|
+
const match = ctaIds.find(([prefix]) => href.startsWith(prefix));
|
|
21
|
+
if (match) link.dataset.lf = match[1];
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
const installBlock = document.querySelector(".hero pre > code");
|
|
10
25
|
|
|
11
26
|
if (installBlock && navigator.clipboard) {
|
|
@@ -21,6 +36,8 @@ if (installBlock && navigator.clipboard) {
|
|
|
21
36
|
button.addEventListener("click", async () => {
|
|
22
37
|
try {
|
|
23
38
|
await navigator.clipboard.writeText(installBlock.textContent.trim());
|
|
39
|
+
// Little Friend is loaded on production builds only.
|
|
40
|
+
if (typeof window.lf === "function") window.lf("track", "install.copy");
|
|
24
41
|
button.textContent = "Copied";
|
|
25
42
|
button.classList.add("is-copied");
|
|
26
43
|
setTimeout(() => {
|
package/src/api-runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { appendVary, compressBody, isCompressibleType, negotiateEncoding } from "./statics.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The local-dev serverless API runner. Gives `darkmown dev` the SAME `/api/*`
|
|
@@ -140,11 +141,19 @@ function readBody(req) {
|
|
|
140
141
|
|
|
141
142
|
/**
|
|
142
143
|
* Write a Web-standard `Response` back onto a Node `ServerResponse`.
|
|
144
|
+
*
|
|
145
|
+
* `acceptEncoding` is the request's Accept-Encoding header when the caller
|
|
146
|
+
* negotiates compression (the preview server); a JSON/text/svg body is then
|
|
147
|
+
* sent brotli- or gzip-compressed with `Content-Encoding` + `Vary`. When it
|
|
148
|
+
* is omitted (the dev server) the response is written exactly as before — no
|
|
149
|
+
* re-encoding, no extra headers. A handler that already set Content-Encoding
|
|
150
|
+
* is never double-compressed.
|
|
143
151
|
* @param {Response} response
|
|
144
152
|
* @param {import("node:http").ServerResponse} res
|
|
153
|
+
* @param {string | string[] | undefined} [acceptEncoding]
|
|
145
154
|
* @returns {Promise<void>}
|
|
146
155
|
*/
|
|
147
|
-
export async function writeWebResponse(response, res) {
|
|
156
|
+
export async function writeWebResponse(response, res, acceptEncoding) {
|
|
148
157
|
/** @type {Record<string, string | string[]>} */
|
|
149
158
|
const headers = {};
|
|
150
159
|
// `Headers.forEach` folds multiple Set-Cookie into one comma-joined value, which
|
|
@@ -158,8 +167,19 @@ export async function writeWebResponse(response, res) {
|
|
|
158
167
|
const cookies =
|
|
159
168
|
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
|
160
169
|
if (cookies.length > 0) headers["set-cookie"] = cookies;
|
|
170
|
+
let buffer = /** @type {Buffer} */ (Buffer.from(await response.arrayBuffer()));
|
|
171
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
172
|
+
if (encoding && isCompressibleType(String(headers["content-type"] || ""))) {
|
|
173
|
+
if (!headers["content-encoding"]) {
|
|
174
|
+
headers["content-encoding"] = encoding;
|
|
175
|
+
headers.vary = appendVary(
|
|
176
|
+
/** @type {string | undefined} */ (headers.vary),
|
|
177
|
+
"Accept-Encoding"
|
|
178
|
+
);
|
|
179
|
+
buffer = compressBody(buffer, encoding);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
161
182
|
res.writeHead(response.status, headers);
|
|
162
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
163
183
|
res.end(buffer);
|
|
164
184
|
}
|
|
165
185
|
|
|
@@ -173,9 +193,11 @@ export async function writeWebResponse(response, res) {
|
|
|
173
193
|
* @param {string} io.apiDir Absolute path to the project's `api/` directory.
|
|
174
194
|
* @param {import("node:http").IncomingMessage} io.req
|
|
175
195
|
* @param {import("node:http").ServerResponse} io.res
|
|
196
|
+
* @param {string | string[] | undefined} [io.acceptEncoding] The request's
|
|
197
|
+
* Accept-Encoding header, when the caller negotiates compression (preview).
|
|
176
198
|
* @returns {Promise<boolean>}
|
|
177
199
|
*/
|
|
178
|
-
export async function handleApiRequest({ apiDir, req, res }) {
|
|
200
|
+
export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
|
|
179
201
|
const pathname = new URL(req.url || "/", "http://localhost").pathname;
|
|
180
202
|
const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
|
|
181
203
|
if (!match) return false;
|
|
@@ -191,11 +213,10 @@ export async function handleApiRequest({ apiDir, req, res }) {
|
|
|
191
213
|
"Use: return Response.json({ ... })"
|
|
192
214
|
);
|
|
193
215
|
}
|
|
194
|
-
await writeWebResponse(response, res);
|
|
216
|
+
await writeWebResponse(response, res, acceptEncoding);
|
|
195
217
|
} catch (err) {
|
|
196
218
|
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
-
|
|
198
|
-
res.end(JSON.stringify({ ok: false, error: message }));
|
|
219
|
+
await writeWebResponse(Response.json({ ok: false, error: message }, { status: 500 }), res);
|
|
199
220
|
}
|
|
200
221
|
return true;
|
|
201
222
|
}
|
package/src/builder.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
aiCrawlerPolicy,
|
|
12
12
|
buildRobots,
|
|
13
13
|
buildRss,
|
|
14
|
+
isNoindex,
|
|
14
15
|
lastmodFor,
|
|
15
16
|
rfc822,
|
|
16
17
|
rssDescription,
|
|
@@ -450,7 +451,9 @@ function breadcrumbTrail(routePath, titles, siteUrl) {
|
|
|
450
451
|
*/
|
|
451
452
|
function siteCorpus(routes, identity) {
|
|
452
453
|
const pages = routes
|
|
453
|
-
|
|
454
|
+
// `/404/` is not content, and a `noindex: true` page opted out of being
|
|
455
|
+
// enumerated anywhere — feeds, sitemap, and this corpus alike.
|
|
456
|
+
.filter((route) => route.route !== "/404/" && !isNoindex(route.meta))
|
|
454
457
|
.map((route) => {
|
|
455
458
|
const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
|
|
456
459
|
return {
|
|
@@ -898,8 +901,10 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
|
|
|
898
901
|
}
|
|
899
902
|
|
|
900
903
|
// The `/404/` route is never a feed entry — it's not an indexable page and
|
|
901
|
-
// not a post
|
|
902
|
-
|
|
904
|
+
// not a post — and a `noindex: true` route has opted out of both, exactly as
|
|
905
|
+
// it opted out of the index with its robots meta. Filter once; both feeds
|
|
906
|
+
// work off the result.
|
|
907
|
+
const feedable = routes.filter((route) => route.route !== "/404/" && !isNoindex(route.meta));
|
|
903
908
|
|
|
904
909
|
// Sitemap: one <url> per emitted route (404 excluded above). A frontmatter
|
|
905
910
|
// `date:` wins, but only when it IS a date — an unparseable one warns and
|
package/src/catalog.js
CHANGED
|
@@ -296,8 +296,9 @@ const DIRECTIVES = [
|
|
|
296
296
|
{
|
|
297
297
|
name: ":embed",
|
|
298
298
|
kind: "line",
|
|
299
|
-
syntax: ':embed <url> [title="…"]',
|
|
300
|
-
description:
|
|
299
|
+
syntax: ':embed <url> [title="…"] [aspect=<w>:<h>] [height=<length>] [allow="token …"]',
|
|
300
|
+
description:
|
|
301
|
+
"A lazy, privacy-friendly YouTube/Vimeo/iframe embed. aspect= swaps the 16:9 ratio box, height= pins the frame, allow= replaces the permission set (whitelisted tokens).",
|
|
301
302
|
example: EMBED_EXAMPLE,
|
|
302
303
|
reactive: "static"
|
|
303
304
|
},
|
|
@@ -598,6 +599,24 @@ const FRONTMATTER_KEYS = [
|
|
|
598
599
|
description: "Excluded from builds and feeds until published.",
|
|
599
600
|
example: "draft: true"
|
|
600
601
|
},
|
|
602
|
+
{
|
|
603
|
+
name: "noindex",
|
|
604
|
+
description:
|
|
605
|
+
"Keep the page out of discovery surfaces: robots noindex meta, sitemap.xml, rss.xml, llms.txt.",
|
|
606
|
+
example: "noindex: true"
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "icon",
|
|
610
|
+
description:
|
|
611
|
+
"Favicon path or URL. Overrides the site/pages/icon.svg|png|favicon.ico convention and the built-in mark.",
|
|
612
|
+
example: "icon: /icon.svg"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "skins",
|
|
616
|
+
description:
|
|
617
|
+
"Shared .skin files to load for this page, before its colocated skin (inline array).",
|
|
618
|
+
example: "skins: [/base.skin]"
|
|
619
|
+
},
|
|
601
620
|
{
|
|
602
621
|
name: "html",
|
|
603
622
|
description: "Allow raw HTML in the markdown body (default false).",
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { handleApiRequest } from "./api-runner.js";
|
|
8
8
|
import { buildSite } from "./builder.js";
|
|
9
9
|
import { directiveCatalog, llmsFullText, llmsText } from "./catalog.js";
|
|
10
|
+
import { buildCollections } from "./compiler/collections.js";
|
|
10
11
|
import { createPaths } from "./config.js";
|
|
11
12
|
import { DEPLOY_TARGETS, deploy } from "./deploy.js";
|
|
12
13
|
import {
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
|
|
21
22
|
import { availableTemplates, initProject } from "./scaffold.js";
|
|
22
23
|
import { contentType, isServableFile, pipeFile, resolvePublicFile, serve } from "./statics.js";
|
|
24
|
+
import { validate as validateSource } from "./tools/validate.js";
|
|
23
25
|
|
|
24
26
|
const cliPath = fileURLToPath(import.meta.url);
|
|
25
27
|
|
|
@@ -103,6 +105,10 @@ export async function run(argv, env = {}) {
|
|
|
103
105
|
log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
|
|
104
106
|
return { command: "build" };
|
|
105
107
|
}
|
|
108
|
+
if (command === "validate") {
|
|
109
|
+
const target = argv.slice(1).find((arg) => !arg.startsWith("-"));
|
|
110
|
+
return runValidate({ cwd, target, log, error });
|
|
111
|
+
}
|
|
106
112
|
if (command === "dev") {
|
|
107
113
|
return startDevServer({ cwd, log, warn, error });
|
|
108
114
|
}
|
|
@@ -142,6 +148,159 @@ export class CliError extends Error {
|
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
/**
|
|
152
|
+
* File extensions the compiler can read as TEXT during a page compile: page and
|
|
153
|
+
* include sources, colocated `.skin`/`.js` siblings, and `@loop` JSON data.
|
|
154
|
+
* Everything else under `site/` (images, fonts) is either binary or never read
|
|
155
|
+
* by the compiler, so the in-memory copy `validate` runs against skips it.
|
|
156
|
+
* @type {Set<string>}
|
|
157
|
+
*/
|
|
158
|
+
const VALIDATE_SOURCE_EXTENSIONS = new Set([".md", ".wd", ".skin", ".js", ".json"]);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `darkmown validate [path]`: compile-check pages without writing `dist`.
|
|
162
|
+
*
|
|
163
|
+
* Every check goes through the SAME validator the agent tools surface exposes
|
|
164
|
+
* (`src/tools/validate.js`), against an in-memory copy of `site/` — so this
|
|
165
|
+
* command and a tool-loop `validate` call can never disagree about what
|
|
166
|
+
* compiles, and the headline vocabulary (`compiles. …`, `compiles with N
|
|
167
|
+
* warnings. …` plus a bullet per warning) is shared byte-for-byte. Collections
|
|
168
|
+
* are resolved project-wide (validating `_schema.wd` entries first), exactly as
|
|
169
|
+
* the build resolves them, so a `@loop blog into post` page checks clean.
|
|
170
|
+
*
|
|
171
|
+
* Drafts are included: `darkmown dev` serves them, so a broken draft is a
|
|
172
|
+
* broken project even though a production build skips it. Throws `CliError`
|
|
173
|
+
* (exit 1) when any page fails.
|
|
174
|
+
* @param {{ cwd: string, target: string | undefined, log: typeof console.log, error: typeof console.error }} io
|
|
175
|
+
* @returns {{ command: "validate" }}
|
|
176
|
+
*/
|
|
177
|
+
function runValidate({ cwd, target, log, error }) {
|
|
178
|
+
const paths = createPaths(cwd);
|
|
179
|
+
if (!fs.existsSync(paths.routesRoot)) {
|
|
180
|
+
error("No site/pages directory found. Run `darkmown init` to scaffold a project first.");
|
|
181
|
+
throw new CliError("No site/pages directory found.", { silent: true });
|
|
182
|
+
}
|
|
183
|
+
const files = slurpSiteSources(cwd);
|
|
184
|
+
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: true });
|
|
185
|
+
let collections;
|
|
186
|
+
try {
|
|
187
|
+
collections = buildCollections(routes, paths);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// A `_schema.wd` violation is a project-level failure, not one page's: the
|
|
190
|
+
// message already carries the entry file, line, and corrective hint.
|
|
191
|
+
error(`✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
throw new CliError("Collection validation failed.", { silent: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const entries = validateEntries({ cwd, target, routes, files, error });
|
|
196
|
+
let failures = 0;
|
|
197
|
+
let warnings = 0;
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
// The pager vars mirror the build's discovery compile (page 1 of 1, no
|
|
200
|
+
// prev/next), so a paginated listing's `:if page.prev` / `{ page.next }`
|
|
201
|
+
// resolve exactly the way its first compile resolves them.
|
|
202
|
+
const result = validateSource(files, entry, {
|
|
203
|
+
collections,
|
|
204
|
+
vars: { page: { current: 1, total: 1, prev: "", next: "" } }
|
|
205
|
+
});
|
|
206
|
+
const [headline, ...rest] = result.text.split("\n");
|
|
207
|
+
if (result.ok) {
|
|
208
|
+
warnings += rest.length;
|
|
209
|
+
log(`✓ ${entry} — ${headline}`);
|
|
210
|
+
for (const line of rest) log(` ${line}`);
|
|
211
|
+
} else {
|
|
212
|
+
failures++;
|
|
213
|
+
error(`✗ ${entry} — ${headline}`);
|
|
214
|
+
for (const line of rest) error(` ${line}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (failures > 0) {
|
|
218
|
+
error(`✗ ${failures} of ${entries.length} ${plural(entries.length, "page")} failed validation`);
|
|
219
|
+
throw new CliError("Validation failed.", { silent: true });
|
|
220
|
+
}
|
|
221
|
+
log(
|
|
222
|
+
`✓ ${entries.length} ${plural(entries.length, "page")} validated` +
|
|
223
|
+
(warnings ? `, ${warnings} ${plural(warnings, "warning")}` : "")
|
|
224
|
+
);
|
|
225
|
+
return { command: "validate" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Which pages `validate` checks, as project-relative POSIX keys into the
|
|
230
|
+
* in-memory source map: every route for no argument, the routes under a
|
|
231
|
+
* directory argument, or the one file for a file argument (which may be a shelf
|
|
232
|
+
* include — anything under `site/` the compiler can read).
|
|
233
|
+
* @param {{ cwd: string, target: string | undefined, routes: import("./router.js").Route[], files: Record<string, string>, error: typeof console.error }} io
|
|
234
|
+
* @returns {string[]}
|
|
235
|
+
*/
|
|
236
|
+
function validateEntries({ cwd, target, routes, files, error }) {
|
|
237
|
+
const keyOf = (/** @type {string} */ abs) => path.relative(cwd, abs).split(path.sep).join("/");
|
|
238
|
+
if (!target) return routes.map((route) => keyOf(route.file));
|
|
239
|
+
|
|
240
|
+
const abs = path.resolve(cwd, target);
|
|
241
|
+
if (!fs.existsSync(abs)) {
|
|
242
|
+
error(`Nothing to validate at "${target}": no such file or directory.`);
|
|
243
|
+
throw new CliError(`No such file or directory: ${target}`, { silent: true });
|
|
244
|
+
}
|
|
245
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
246
|
+
const prefix = `${abs}${path.sep}`;
|
|
247
|
+
const entries = routes
|
|
248
|
+
.filter((route) => route.file.startsWith(prefix))
|
|
249
|
+
.map((route) => keyOf(route.file));
|
|
250
|
+
if (entries.length === 0) {
|
|
251
|
+
error(`No pages found under "${target}".`);
|
|
252
|
+
throw new CliError(`No pages found under ${target}`, { silent: true });
|
|
253
|
+
}
|
|
254
|
+
return entries;
|
|
255
|
+
}
|
|
256
|
+
const ext = path.extname(abs);
|
|
257
|
+
if (ext !== ".md" && ext !== ".wd") {
|
|
258
|
+
error(`Cannot validate "${target}": only .md and .wd sources compile.`);
|
|
259
|
+
throw new CliError(`Not a page source: ${target}`, { silent: true });
|
|
260
|
+
}
|
|
261
|
+
const key = keyOf(abs);
|
|
262
|
+
if (!(key in files)) {
|
|
263
|
+
error(`Cannot validate "${target}": validate checks pages and includes under site/.`);
|
|
264
|
+
throw new CliError(`Outside site/: ${target}`, { silent: true });
|
|
265
|
+
}
|
|
266
|
+
return [key];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read every compiler-readable text source under `site/` into the
|
|
271
|
+
* project-relative key → contents map `compileFromMemory` compiles against.
|
|
272
|
+
* @param {string} cwd Project working directory.
|
|
273
|
+
* @returns {Record<string, string>}
|
|
274
|
+
*/
|
|
275
|
+
function slurpSiteSources(cwd) {
|
|
276
|
+
/** @type {Record<string, string>} */
|
|
277
|
+
const files = {};
|
|
278
|
+
/** @param {string} dir */
|
|
279
|
+
const walk = (dir) => {
|
|
280
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
281
|
+
const abs = path.join(dir, entry.name);
|
|
282
|
+
if (entry.isDirectory()) {
|
|
283
|
+
walk(abs);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!VALIDATE_SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue;
|
|
287
|
+
files[path.relative(cwd, abs).split(path.sep).join("/")] = fs.readFileSync(abs, "utf8");
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const siteRoot = path.join(cwd, "site");
|
|
291
|
+
if (fs.existsSync(siteRoot)) walk(siteRoot);
|
|
292
|
+
return files;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {number} n
|
|
297
|
+
* @param {string} word
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function plural(n, word) {
|
|
301
|
+
return n === 1 ? word : `${word}s`;
|
|
302
|
+
}
|
|
303
|
+
|
|
145
304
|
/**
|
|
146
305
|
* Start the live-compiler dev server. Builds once (recording, not crashing on, a
|
|
147
306
|
* broken initial build), watches `site/` and `src/`, and serves dist with the
|
|
@@ -293,6 +452,12 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
293
452
|
/**
|
|
294
453
|
* Start the static preview server for an existing `dist/`. Throws `CliError`
|
|
295
454
|
* when there is nothing built to serve.
|
|
455
|
+
*
|
|
456
|
+
* Preview mirrors production more closely than dev in two ways: project
|
|
457
|
+
* `api/*.js` functions run on `/api/*` (same runner and `(request) => Response`
|
|
458
|
+
* contract as dev — a project without `api/` falls straight through to static),
|
|
459
|
+
* and compressible responses are brotli/gzip-negotiated off Accept-Encoding so
|
|
460
|
+
* local weight checks see transfer sizes, not raw bytes.
|
|
296
461
|
* @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
|
|
297
462
|
* @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
|
|
298
463
|
*/
|
|
@@ -304,7 +469,21 @@ function startPreviewServer({ cwd, log, error }) {
|
|
|
304
469
|
error("No dist directory found. Run `darkmown build` first.");
|
|
305
470
|
throw new CliError("No dist directory found. Run `darkmown build` first.", { silent: true });
|
|
306
471
|
}
|
|
307
|
-
const
|
|
472
|
+
const apiDir = path.join(cwd, "api");
|
|
473
|
+
const server = http.createServer((req, res) => {
|
|
474
|
+
const acceptEncoding = req.headers["accept-encoding"];
|
|
475
|
+
// Serverless parity, exactly as in dev: run a project api/*.js function when
|
|
476
|
+
// the path matches; otherwise serve static (compressed when negotiated). A
|
|
477
|
+
// throw on either path (e.g. a malformed dynamic segment) becomes a clean 500.
|
|
478
|
+
handleApiRequest({ apiDir, req, res, acceptEncoding })
|
|
479
|
+
.then((handled) => {
|
|
480
|
+
if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
|
|
481
|
+
})
|
|
482
|
+
.catch((err) => {
|
|
483
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
484
|
+
res.end((err instanceof Error && err.stack) || String(err));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
308
487
|
return new Promise((resolve) => {
|
|
309
488
|
server.listen(port, host, () => {
|
|
310
489
|
log(`Darkmown preview of dist at http://${host}:${port}`);
|
|
@@ -398,6 +577,7 @@ Usage:
|
|
|
398
577
|
darkmown dev Start the live compiler dev server
|
|
399
578
|
darkmown build [--target cloudflare] Compile site/pages into dist
|
|
400
579
|
[--drafts] Include draft: true pages (staging)
|
|
580
|
+
darkmown validate [path] Compile-check pages without building (exit 1 on errors)
|
|
401
581
|
darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
|
|
402
582
|
darkmown serve Preview the built dist locally
|
|
403
583
|
darkmown catalog [--llms|--llms-full] Print the .wd directive catalog (JSON, --llms cheatsheet, or --llms-full reference)
|
package/src/compiler/body.js
CHANGED
|
@@ -88,6 +88,13 @@ export function compileBody(lines, ctx) {
|
|
|
88
88
|
// reports the offending line and not the top of the file.
|
|
89
89
|
let proseStart = 0;
|
|
90
90
|
let fence = null;
|
|
91
|
+
// Raw <style>/<script> block tracking (pages with `html: true`). Inside one,
|
|
92
|
+
// lines are CSS/JS, not directive territory, so the "looks like a directive"
|
|
93
|
+
// hint is suppressed — otherwise every `@font-face {` / `@keyframes x {` in
|
|
94
|
+
// an author's raw <style> warns once per page. Line-based like the fence
|
|
95
|
+
// tracking above; a `</style>` inside a CSS string is out of scope.
|
|
96
|
+
/** @type {"style" | "script" | null} */
|
|
97
|
+
let rawTag = null;
|
|
91
98
|
|
|
92
99
|
// Append one chunk, splicing it into the previous one when the seam between
|
|
93
100
|
// them is a table split in half. Markdown only sees a table when a header row
|
|
@@ -127,6 +134,20 @@ export function compileBody(lines, ctx) {
|
|
|
127
134
|
continue;
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
// Update the raw-block state BEFORE the line is classified, so both the
|
|
138
|
+
// opener's contents and the closing tag line itself count as "inside".
|
|
139
|
+
// A same-line `<script src="…"></script>` opens and closes at once and
|
|
140
|
+
// never suppresses anything.
|
|
141
|
+
const inRawBlock = rawTag !== null;
|
|
142
|
+
if (rawTag) {
|
|
143
|
+
if (RAW_BLOCK_CLOSE.test(line)) rawTag = null;
|
|
144
|
+
} else {
|
|
145
|
+
const rawOpen = line.match(RAW_BLOCK_OPEN);
|
|
146
|
+
if (rawOpen && !RAW_BLOCK_CLOSE.test(line)) {
|
|
147
|
+
rawTag = /** @type {"style" | "script"} */ (rawOpen[1].toLowerCase());
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
// A directive name ALONE on a line is a mistake with one obvious fix: it is
|
|
131
152
|
// missing its arguments. Every dispatcher below requires a space after the
|
|
132
153
|
// keyword, so such a line used to fall through to prose — and because
|
|
@@ -331,7 +352,7 @@ export function compileBody(lines, ctx) {
|
|
|
331
352
|
file: ctx.file
|
|
332
353
|
});
|
|
333
354
|
}
|
|
334
|
-
warnUnknownDirective(line, ctx);
|
|
355
|
+
if (!inRawBlock) warnUnknownDirective(line, ctx);
|
|
335
356
|
prose.push(line);
|
|
336
357
|
}
|
|
337
358
|
|
|
@@ -351,6 +372,14 @@ export function compileBody(lines, ctx) {
|
|
|
351
372
|
// from the demo handler and are gone from here for exactly that reason.)
|
|
352
373
|
const KNOWN_DIRECTIVE =
|
|
353
374
|
/^(?:(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|slider|submit|button|carousel|endcarousel|if|endif|else))(?:\s|$)|:try\s)/;
|
|
375
|
+
|
|
376
|
+
// Raw HTML blocks whose contents are CSS/JS, never directive territory. The
|
|
377
|
+
// lookalike hint is suppressed between `<style …>`/`<script …>` and their
|
|
378
|
+
// close tag (line-based, matching the rest of this dispatcher). The close is
|
|
379
|
+
// deliberately tag-agnostic: `<style>` and `<script>` never nest, so either
|
|
380
|
+
// closer ends the open block.
|
|
381
|
+
const RAW_BLOCK_OPEN = /<(style|script)(?=[\s>])/i;
|
|
382
|
+
const RAW_BLOCK_CLOSE = /<\/(?:style|script)\s*>/i;
|
|
354
383
|
/**
|
|
355
384
|
* @param {string} line
|
|
356
385
|
* @param {Ctx} ctx
|