@zvndev/darkmown 2.7.1-next.14.g3f67070 → 2.9.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 +24 -0
- package/README.md +82 -4
- package/docs/cli.md +19 -2
- package/docs/spec-alignment.md +18 -1
- package/package.json +8 -2
- 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 +83 -11
- package/src/catalog.js +21 -2
- package/src/cli.js +188 -2
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +6 -0
- package/src/compiler/frontmatter.js +66 -0
- package/src/compiler/markdown.js +282 -2
- package/src/compiler/media.js +104 -9
- package/src/compiler/page.js +250 -12
- package/src/compiler.js +13 -3
- package/src/errors.js +23 -2
- package/src/feeds.js +14 -0
- package/src/image-variant-resize.js +71 -0
- package/src/image-variants.js +294 -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/builder.d.ts +9 -0
- package/types/compiler/context.d.ts +13 -0
- package/types/compiler/frontmatter.d.ts +19 -0
- package/types/compiler/media.d.ts +20 -4
- package/types/compiler/page.d.ts +17 -3
- package/types/compiler.d.ts +5 -2
- package/types/feeds.d.ts +11 -0
- package/types/image-variants.d.ts +131 -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, generated at build time.** With sharp installed (`npm i -D sharp`, an optional dependency; builds work without it), every raster photo in `site/pages` over 700px wide is resized to 640w and 1280w siblings in `dist/` (only widths below the intrinsic size, never upscaled, EXIF orientation applied), and the Markdown image gains a `srcset` with the original kept as `src`. Export `name@<width>.ext` yourself and your file wins that width verbatim, measured from its own header; generation fills only the widths you left uncovered, and other sizes are a hand export away (the compiler probes 64 through 3840). Skipped silently: small images, `@width` files, hidden or symlinked paths, svg/gif/avif, and shelf images (the generator covers page-colocated images only). Without sharp the build behaves as before plus one `npm i -D sharp` hint per build; a corrupt image or a sharp failure on one file is a hint naming the file, never a build failure. Resizes are cached in `node_modules/.cache/darkmown-images/` by content hash + width + format, so second builds and dev rebuilds do no resizing for unchanged images, and the build summary reports `image variants (N generated, M cached)` when variants materialized. `sizes` is never emitted (write raw `<img srcset sizes>` on an `html: true` page for that); bound (``), remote, `data:`, query/hash, page-relative, and extensionless srcs are never wired, and neither are raw-HTML `<img>` tags. 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,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "./feeds.js";
|
|
21
22
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
22
23
|
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
24
|
+
import { generateImageVariants } from "./image-variants.js";
|
|
23
25
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
24
26
|
import { compileSkin, scopeIdFor } from "./skin.js";
|
|
25
27
|
|
|
@@ -104,6 +106,9 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
104
106
|
* @property {RouteManifestEntry[]} routes
|
|
105
107
|
* @property {string} distRoot
|
|
106
108
|
* @property {{ sitemap: number | null, rss: number | null }} feeds
|
|
109
|
+
* @property {{ generated: number, cached: number, unavailable: boolean }} images
|
|
110
|
+
* Responsive-variant pre-pass counts (2.9.0): freshly resized, cache-served,
|
|
111
|
+
* and whether sharp could not be loaded at all.
|
|
107
112
|
* @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
|
|
108
113
|
*/
|
|
109
114
|
|
|
@@ -118,6 +123,12 @@ function buildFull(cwd, paths, options) {
|
|
|
118
123
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
119
124
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
120
125
|
emitShelfAssets(paths);
|
|
126
|
+
// Responsive image variants (2.9.0): resize the large raster photos in
|
|
127
|
+
// site/pages into dist and collect the variant map BEFORE any route compiles,
|
|
128
|
+
// so the srcset wiring can reference what was generated. With the optional
|
|
129
|
+
// sharp dependency absent (or everything cache-served) this is cheap and
|
|
130
|
+
// never fails the build.
|
|
131
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
121
132
|
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
|
|
122
133
|
// Build the collection index from the (post-draft-filter) routes — every
|
|
123
134
|
// site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
|
|
@@ -151,7 +162,17 @@ function buildFull(cwd, paths, options) {
|
|
|
151
162
|
const mapRoutes = {};
|
|
152
163
|
|
|
153
164
|
for (const route of routes) {
|
|
154
|
-
const built = buildRoute(
|
|
165
|
+
const built = buildRoute(
|
|
166
|
+
route,
|
|
167
|
+
cwd,
|
|
168
|
+
paths,
|
|
169
|
+
collections,
|
|
170
|
+
feedLink,
|
|
171
|
+
warned,
|
|
172
|
+
identity,
|
|
173
|
+
titles,
|
|
174
|
+
images.variants
|
|
175
|
+
);
|
|
155
176
|
manifest.push(...built.mapEntry.pages);
|
|
156
177
|
if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
|
|
157
178
|
for (const entry of built.mapEntry.pages) {
|
|
@@ -191,7 +212,12 @@ function buildFull(cwd, paths, options) {
|
|
|
191
212
|
|
|
192
213
|
if (options.depMap) writeDepMap(paths, feedLink, siteInputs(identity, titles), mapRoutes);
|
|
193
214
|
|
|
194
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
routes: manifest,
|
|
217
|
+
distRoot: paths.distRoot,
|
|
218
|
+
feeds,
|
|
219
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable }
|
|
220
|
+
};
|
|
195
221
|
}
|
|
196
222
|
|
|
197
223
|
/**
|
|
@@ -283,13 +309,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
283
309
|
}
|
|
284
310
|
}
|
|
285
311
|
|
|
312
|
+
// Rebuilt routes must compile byte-identically to a full rebuild, so the
|
|
313
|
+
// generated-variant map the srcset wiring consults is recomputed here too.
|
|
314
|
+
// An image CHANGE never reaches this point (no dependency graph tracks
|
|
315
|
+
// images, so it already forced a full rebuild above), which makes this scan
|
|
316
|
+
// all cache hits: it refreshes dist copies and the map, and spawns nothing.
|
|
317
|
+
const images = generateImageVariants(paths, { quiet: options.quietFeedHints });
|
|
318
|
+
|
|
286
319
|
const byRoute = new Map(routes.map((route) => [route.route, route]));
|
|
287
320
|
/** @type {Set<string>} */
|
|
288
321
|
const warned = new Set();
|
|
289
322
|
const rebuilt = [...affected].sort();
|
|
290
323
|
for (const routePath of rebuilt) {
|
|
291
324
|
const route = /** @type {Route} */ (byRoute.get(routePath));
|
|
292
|
-
const built = buildRoute(
|
|
325
|
+
const built = buildRoute(
|
|
326
|
+
route,
|
|
327
|
+
cwd,
|
|
328
|
+
paths,
|
|
329
|
+
collections,
|
|
330
|
+
feedLink,
|
|
331
|
+
warned,
|
|
332
|
+
identity,
|
|
333
|
+
titles,
|
|
334
|
+
images.variants
|
|
335
|
+
);
|
|
293
336
|
// A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
|
|
294
337
|
// previously-emitted page this rebuild no longer produces.
|
|
295
338
|
const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
|
|
@@ -325,6 +368,7 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
325
368
|
routes: manifest,
|
|
326
369
|
distRoot: paths.distRoot,
|
|
327
370
|
feeds,
|
|
371
|
+
images: { generated: images.generated, cached: images.cached, unavailable: images.unavailable },
|
|
328
372
|
incremental: { changed, rebuilt, total: routes.length }
|
|
329
373
|
};
|
|
330
374
|
}
|
|
@@ -343,10 +387,30 @@ function buildIncremental(cwd, paths, options, changed) {
|
|
|
343
387
|
* @param {SiteIdentity} identity Resolved site identity (its `siteUrl` is what
|
|
344
388
|
* makes a canonical URL possible).
|
|
345
389
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
390
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
391
|
+
* (original's absolute source path → widths), threaded into each compile.
|
|
346
392
|
* @returns {{ mapEntry: DepMapEntry, notFoundHtml: string | undefined }}
|
|
347
393
|
*/
|
|
348
|
-
function buildRoute(
|
|
349
|
-
|
|
394
|
+
function buildRoute(
|
|
395
|
+
route,
|
|
396
|
+
cwd,
|
|
397
|
+
paths,
|
|
398
|
+
collections,
|
|
399
|
+
feedLink,
|
|
400
|
+
warned,
|
|
401
|
+
identity,
|
|
402
|
+
titles,
|
|
403
|
+
imageVariants
|
|
404
|
+
) {
|
|
405
|
+
const compiled = compileRoutePages(
|
|
406
|
+
route,
|
|
407
|
+
paths,
|
|
408
|
+
collections,
|
|
409
|
+
feedLink,
|
|
410
|
+
identity,
|
|
411
|
+
titles,
|
|
412
|
+
imageVariants
|
|
413
|
+
);
|
|
350
414
|
/** @type {RouteManifestEntry[]} */
|
|
351
415
|
const entries = [];
|
|
352
416
|
/** @type {string | undefined} */
|
|
@@ -450,7 +514,9 @@ function breadcrumbTrail(routePath, titles, siteUrl) {
|
|
|
450
514
|
*/
|
|
451
515
|
function siteCorpus(routes, identity) {
|
|
452
516
|
const pages = routes
|
|
453
|
-
|
|
517
|
+
// `/404/` is not content, and a `noindex: true` page opted out of being
|
|
518
|
+
// enumerated anywhere — feeds, sitemap, and this corpus alike.
|
|
519
|
+
.filter((route) => route.route !== "/404/" && !isNoindex(route.meta))
|
|
454
520
|
.map((route) => {
|
|
455
521
|
const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
|
|
456
522
|
return {
|
|
@@ -691,9 +757,11 @@ function copyChangedAsset(file, cwd, paths) {
|
|
|
691
757
|
* @param {{ href: string, title: string } | undefined} feedLink
|
|
692
758
|
* @param {SiteIdentity} identity
|
|
693
759
|
* @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
|
|
760
|
+
* @param {Map<string, number[]>} imageVariants Generated responsive variants
|
|
761
|
+
* (original's absolute source path → widths) for the srcset wiring.
|
|
694
762
|
* @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
|
|
695
763
|
*/
|
|
696
|
-
function compileRoutePages(route, paths, collections, feedLink, identity, titles) {
|
|
764
|
+
function compileRoutePages(route, paths, collections, feedLink, identity, titles, imageVariants) {
|
|
697
765
|
/** @type {Set<string>} */
|
|
698
766
|
const deps = new Set();
|
|
699
767
|
/** @type {Set<string>} */
|
|
@@ -718,7 +786,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
718
786
|
feed: feedLink,
|
|
719
787
|
site: siteFor(route.route),
|
|
720
788
|
collections,
|
|
721
|
-
vars: { page: pagerVars(route.route, 1, 1) }
|
|
789
|
+
vars: { page: pagerVars(route.route, 1, 1) },
|
|
790
|
+
imageVariants
|
|
722
791
|
});
|
|
723
792
|
track(first);
|
|
724
793
|
if (!first.pagination || first.pagination.total <= 1) {
|
|
@@ -743,7 +812,8 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
|
|
|
743
812
|
feed: feedLink,
|
|
744
813
|
site: siteFor(pageRoute(route.route, n)),
|
|
745
814
|
collections,
|
|
746
|
-
vars: { page: pagerVars(route.route, n, total) }
|
|
815
|
+
vars: { page: pagerVars(route.route, n, total) },
|
|
816
|
+
imageVariants
|
|
747
817
|
});
|
|
748
818
|
track(page);
|
|
749
819
|
pages.push({
|
|
@@ -898,8 +968,10 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
|
|
|
898
968
|
}
|
|
899
969
|
|
|
900
970
|
// The `/404/` route is never a feed entry — it's not an indexable page and
|
|
901
|
-
// not a post
|
|
902
|
-
|
|
971
|
+
// not a post — and a `noindex: true` route has opted out of both, exactly as
|
|
972
|
+
// it opted out of the index with its robots meta. Filter once; both feeds
|
|
973
|
+
// work off the result.
|
|
974
|
+
const feedable = routes.filter((route) => route.route !== "/404/" && !isNoindex(route.meta));
|
|
903
975
|
|
|
904
976
|
// Sitemap: one <url> per emitted route (404 excluded above). A frontmatter
|
|
905
977
|
// `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).",
|