@zvndev/darkmown 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +18 -0
- package/README.md +129 -1
- package/docs/spec-alignment.md +24 -0
- package/package.json +2 -1
- package/site/_/base.skin +14 -0
- package/site/_/nav.wd +1 -0
- package/site/_/scoped-promo.skin +43 -0
- package/site/_/scoped-promo.wd +10 -0
- package/site/_/scoped-stat.skin +32 -0
- package/site/_/scoped-stat.wd +9 -0
- package/site/pages/blog/drafts-that-never-leak.md +16 -0
- package/site/pages/blog/index.md +13 -0
- package/site/pages/blog/shipping-feeds.md +17 -0
- package/site/pages/blog/unfinished-thoughts.md +13 -0
- package/site/pages/docs/index.wd +70 -0
- package/site/pages/highlight/index.skin +78 -0
- package/site/pages/highlight/index.wd +142 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/scoped/index.skin +23 -0
- package/site/pages/scoped/index.wd +47 -0
- package/src/builder.js +239 -10
- package/src/cli.js +51 -7
- package/src/compiler/context.js +10 -1
- package/src/compiler/directives.js +11 -5
- package/src/compiler/includes.js +48 -1
- package/src/compiler/index.js +1 -1
- package/src/compiler/markdown.js +7 -2
- package/src/compiler/page.js +58 -7
- package/src/compiler.js +2 -1
- package/src/dev.js +34 -5
- package/src/feeds.js +276 -0
- package/src/highlight.js +126 -0
- package/src/router.js +35 -3
- package/src/skin.js +147 -3
- package/src/templates/blog/site/pages/index.wd +1 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +2 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Syntax highlighting — code that dark-modes for free
|
|
3
|
+
description: Build-time syntax highlighting in plain Markdown. Fenced code blocks highlight at compile time (zero client JS) and recolor with your theme through the same tokens dark / :theme system — flip the toggle and watch every language recolor at once.
|
|
4
|
+
image: https://darkmown.com/__wd/media/og-showcase.png
|
|
5
|
+
transitions: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
@include /nav.wd
|
|
9
|
+
@include /base.wd
|
|
10
|
+
|
|
11
|
+
<main class="wide app-highlight">
|
|
12
|
+
|
|
13
|
+
::: section #highlight
|
|
14
|
+
|
|
15
|
+
:theme
|
|
16
|
+
|
|
17
|
+
::: hl-head
|
|
18
|
+
::: eyebrow .hl-eyebrow
|
|
19
|
+
Build-time syntax highlighting
|
|
20
|
+
:::
|
|
21
|
+
|
|
22
|
+
# *Highlighted code*
|
|
23
|
+
|
|
24
|
+
::: hl-tagline
|
|
25
|
+
Fenced code blocks highlight at **build time** — HTML and CSS only, no client
|
|
26
|
+
JavaScript. Colors come from your skin's `$code-*` tokens, so highlighting
|
|
27
|
+
recolors with the theme through the same `tokens dark` / `:theme` system that
|
|
28
|
+
drives the rest of the page. Flip the toggle and watch every block below recolor
|
|
29
|
+
at once — no toggle script for the code, no flash.
|
|
30
|
+
:::
|
|
31
|
+
:::
|
|
32
|
+
|
|
33
|
+
::: theme-switch
|
|
34
|
+
Theme
|
|
35
|
+
:button "Auto" -> theme = "auto"
|
|
36
|
+
:button "Light" -> theme = "light"
|
|
37
|
+
:button "Dark" -> theme = "dark"
|
|
38
|
+
:::
|
|
39
|
+
|
|
40
|
+
::: grid .cols-2 .hl-grid
|
|
41
|
+
|
|
42
|
+
::: card .hl-panel
|
|
43
|
+
### JavaScript
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// A tiny reactive island, declared in Markdown.
|
|
47
|
+
const cart = wd.state("cart");
|
|
48
|
+
function total(items) {
|
|
49
|
+
return items.reduce((sum, it) => sum + it.price, 0);
|
|
50
|
+
}
|
|
51
|
+
wd.subscribe("cart", (items) => {
|
|
52
|
+
document.title = `Cart (${items.length})`;
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
:::
|
|
56
|
+
|
|
57
|
+
::: card .hl-panel
|
|
58
|
+
### Python
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from dataclasses import dataclass
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Expense:
|
|
65
|
+
merchant: str
|
|
66
|
+
amount: float # in dollars
|
|
67
|
+
|
|
68
|
+
def total(rows: list[Expense]) -> float:
|
|
69
|
+
return sum(r.amount for r in rows)
|
|
70
|
+
```
|
|
71
|
+
:::
|
|
72
|
+
|
|
73
|
+
::: card .hl-panel
|
|
74
|
+
### CSS
|
|
75
|
+
|
|
76
|
+
```css
|
|
77
|
+
.card {
|
|
78
|
+
background: var(--panel, #fff);
|
|
79
|
+
border: 1px solid var(--line);
|
|
80
|
+
border-radius: 12px;
|
|
81
|
+
padding: 1.4rem;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
:::
|
|
85
|
+
|
|
86
|
+
::: card .hl-panel
|
|
87
|
+
### JSON
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"name": "@zvndev/darkmown",
|
|
92
|
+
"type": "module",
|
|
93
|
+
"zeroJsOnStaticPages": true,
|
|
94
|
+
"runtimeBudgetKb": 8
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
:::
|
|
98
|
+
|
|
99
|
+
::: card .hl-panel
|
|
100
|
+
### Bash
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx @zvndev/darkmown init --template blog
|
|
104
|
+
cd blog && npx darkmown dev # live reload on :5173
|
|
105
|
+
```
|
|
106
|
+
:::
|
|
107
|
+
|
|
108
|
+
::: card .hl-panel
|
|
109
|
+
### HTML
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<main class="wide">
|
|
113
|
+
<a class="btn primary" href="/docs/">Read the docs</a>
|
|
114
|
+
</main>
|
|
115
|
+
```
|
|
116
|
+
:::
|
|
117
|
+
|
|
118
|
+
:::
|
|
119
|
+
|
|
120
|
+
::: hl-note
|
|
121
|
+
**How it works.** The highlighter is [highlight.js](https://highlightjs.org/),
|
|
122
|
+
wired into Markdown's fenced-code path and mapped onto `$code-*` skin tokens.
|
|
123
|
+
The stylesheet is **pay-for-what-you-use** — emitted and linked only on pages
|
|
124
|
+
with a highlighted block. The highlighting itself ships zero JavaScript; the only
|
|
125
|
+
reactive thing on this page is the theme toggle above (so you can demo the
|
|
126
|
+
recolor without changing your OS setting). A fence with an unknown or absent
|
|
127
|
+
language renders as plain escaped `<code>`, and inline `` `code` `` is never
|
|
128
|
+
highlighted.
|
|
129
|
+
:::
|
|
130
|
+
|
|
131
|
+
::: hl-note .hl-degrade
|
|
132
|
+
**Graceful degradation.** A bare fence (no language) or an unknown one stays
|
|
133
|
+
plain — no highlighting, no error:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
just a plain block — no language, so no highlighting
|
|
137
|
+
```
|
|
138
|
+
:::
|
|
139
|
+
|
|
140
|
+
:::
|
|
141
|
+
|
|
142
|
+
</main>
|
package/site/pages/index.wd
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
title: Darkmown — Markdown that runs, not Markdown that needs React
|
|
3
3
|
description: A live store widget — search filter, add-to-cart, running total — written as one Markdown file that runs. The same outcome in Markdoc needs a React component, hydration, and a build step. Compare them side by side.
|
|
4
4
|
image: https://darkmown.com/__wd/media/og.png
|
|
5
|
+
site_url: https://darkmown.com
|
|
5
6
|
transitions: true
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
scoped
|
|
2
|
+
|
|
3
|
+
/* Page-scoped skin for this route. The whole page body is stamped, so these
|
|
4
|
+
selectors style only this page's own markup — proving page-level scoping
|
|
5
|
+
alongside the two include-scoped components. A :global() escape hatch shows a
|
|
6
|
+
single rule opting back out to a plain, unscoped selector. */
|
|
7
|
+
|
|
8
|
+
.intro
|
|
9
|
+
margin 2rem 0 3rem
|
|
10
|
+
|
|
11
|
+
.duo
|
|
12
|
+
margin 3rem 0
|
|
13
|
+
|
|
14
|
+
.how
|
|
15
|
+
margin 4rem 0 2rem
|
|
16
|
+
|
|
17
|
+
.how ul
|
|
18
|
+
color $ink-soft
|
|
19
|
+
|
|
20
|
+
/* :global escape — this selector ships UNSCOPED, matching site-wide. */
|
|
21
|
+
:global(.scoped-note)
|
|
22
|
+
font-size .85rem
|
|
23
|
+
color $muted
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scoped styles — same class name, zero collision
|
|
3
|
+
description: Two components reuse the class name .card and never clash. Compile-time scoped styles stamp each component's subtree with a path-derived id and rewrite its skin's selectors to match only there. Zero runtime JS — a static page stays static.
|
|
4
|
+
transitions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
@include /nav.wd
|
|
8
|
+
@include /base.wd
|
|
9
|
+
|
|
10
|
+
<main>
|
|
11
|
+
|
|
12
|
+
<section class="intro">
|
|
13
|
+
<span class="eyebrow">Darkmown 1.4</span>
|
|
14
|
+
|
|
15
|
+
# Scoped styles
|
|
16
|
+
|
|
17
|
+
Two components on this page both call their root element `.card`. In plain CSS the second skin would overwrite the first. Here they coexist: a `.skin` whose first line is `scoped` only ever styles the subtree it ships with. It's pure compile time — a path-derived id stamped onto the HTML and appended to the skin's selectors. No runtime, no class hashing in your markup, no collision.
|
|
18
|
+
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
<section class="duo">
|
|
22
|
+
|
|
23
|
+
<div class="grid cols-2">
|
|
24
|
+
|
|
25
|
+
@include /scoped-promo.wd
|
|
26
|
+
@include /scoped-stat.wd
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<section class="how">
|
|
33
|
+
|
|
34
|
+
## What actually shipped
|
|
35
|
+
|
|
36
|
+
- The `.card` in the warm promo and the `.card` in the dark stat tile are **different rules**, scoped to different ids.
|
|
37
|
+
- This page also uses a **global** skin (`base.skin`, via `/base.wd`) — `.btn`, `.eyebrow`, `.grid` still work everywhere, unscoped.
|
|
38
|
+
- Design **tokens stay global**: every scoped skin still reads `$accent`, `$warm`, `$radius` from `:root`, so dark mode keeps working for free.
|
|
39
|
+
- A `:global(.x)` selector inside a scoped skin opts a single rule back out.
|
|
40
|
+
|
|
41
|
+
<a class="btn primary" href="/docs/">Read the docs</a>
|
|
42
|
+
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
</main>
|
|
46
|
+
|
|
47
|
+
@include /footer.wd
|
package/src/builder.js
CHANGED
|
@@ -2,15 +2,28 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { discoverApiRoutes } from "./api-runner.js";
|
|
5
|
-
import { compilePage } from "./compiler.js";
|
|
5
|
+
import { compilePage, parseFrontmatter } from "./compiler.js";
|
|
6
6
|
import { createPaths } from "./config.js";
|
|
7
|
+
import {
|
|
8
|
+
absoluteUrl,
|
|
9
|
+
buildRobots,
|
|
10
|
+
buildRss,
|
|
11
|
+
buildSitemap,
|
|
12
|
+
lastmodFor,
|
|
13
|
+
RSS_ITEM_LIMIT,
|
|
14
|
+
rfc822,
|
|
15
|
+
rssDescription
|
|
16
|
+
} from "./feeds.js";
|
|
7
17
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
18
|
+
import { HIGHLIGHT_CSS } from "./highlight.js";
|
|
8
19
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
9
|
-
import { compileSkin } from "./skin.js";
|
|
20
|
+
import { compileSkin, scopeIdFor } from "./skin.js";
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
23
|
* @typedef {import("./config.js").Paths} Paths
|
|
13
24
|
* @typedef {import("./compiler.js").Assets} Assets
|
|
25
|
+
* @typedef {import("./compiler.js").Meta} Meta
|
|
26
|
+
* @typedef {import("./router.js").Route} Route
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
29
|
/**
|
|
@@ -34,15 +47,30 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
34
47
|
* `/api/*` to those same functions and serves everything else as a static asset.
|
|
35
48
|
*
|
|
36
49
|
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
37
|
-
* @param {{ target?: string }} [options] `target:
|
|
38
|
-
*
|
|
50
|
+
* @param {{ target?: string, includeDrafts?: boolean }} [options] `target:
|
|
51
|
+
* "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps `draft:
|
|
52
|
+
* true` pages in the build + feeds (dev / `build --drafts`); default excludes
|
|
53
|
+
* them at route discovery so feeds never see a draft.
|
|
54
|
+
* @returns {{ routes: RouteManifestEntry[], distRoot: string, feeds: { sitemap: number | null, rss: number | null } }}
|
|
39
55
|
*/
|
|
40
56
|
export function buildSite(cwd = process.cwd(), options = {}) {
|
|
41
57
|
const paths = createPaths(cwd);
|
|
42
58
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
43
59
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
44
60
|
emitShelfAssets(paths);
|
|
45
|
-
const routes = discoverRoutes(paths.routesRoot);
|
|
61
|
+
const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
|
|
62
|
+
// Site identity lives in the HOME page frontmatter (no config loader): the
|
|
63
|
+
// `site_url` origin triggers + prefixes the feeds, reusing `title`/`description`
|
|
64
|
+
// as the RSS channel fields. Resolved once, up front, so every page can link
|
|
65
|
+
// the feed during the loop.
|
|
66
|
+
const identity = siteIdentity(routes);
|
|
67
|
+
// Advertise the feed in every page's <head> only when rss.xml will actually be
|
|
68
|
+
// emitted: the home set `site_url` AND there is at least one dated post. A
|
|
69
|
+
// stale `<link>` to a missing feed would 404 for aggregators.
|
|
70
|
+
const feedLink =
|
|
71
|
+
identity.siteUrl && hasDatedPost(routes)
|
|
72
|
+
? { href: absoluteUrl(identity.siteUrl, "/rss.xml"), title: identity.title }
|
|
73
|
+
: undefined;
|
|
46
74
|
/** @type {RouteManifestEntry[]} */
|
|
47
75
|
const manifest = [];
|
|
48
76
|
|
|
@@ -53,7 +81,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
53
81
|
let notFoundHtml;
|
|
54
82
|
|
|
55
83
|
for (const route of routes) {
|
|
56
|
-
const page = compilePage(route.file, paths);
|
|
84
|
+
const page = compilePage(route.file, paths, { feed: feedLink });
|
|
57
85
|
for (const warning of page.warnings || []) {
|
|
58
86
|
if (warned.has(warning)) continue;
|
|
59
87
|
warned.add(warning);
|
|
@@ -63,9 +91,10 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
63
91
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
64
92
|
fs.writeFileSync(outFile, page.html);
|
|
65
93
|
if (route.route === "/404/") notFoundHtml = page.html;
|
|
66
|
-
emitAssets(page.assets, paths);
|
|
94
|
+
emitAssets(page.assets, paths, page.html, warned);
|
|
67
95
|
if (page.assets.runtime) emitRuntime(paths);
|
|
68
96
|
emitBehaviors(page.assets, paths);
|
|
97
|
+
if (page.assets.hasCode) emitHighlight(paths);
|
|
69
98
|
const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
|
|
70
99
|
manifest.push({
|
|
71
100
|
route: route.route,
|
|
@@ -95,6 +124,11 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
95
124
|
// one. A catch-all keeps assets and any future path covered.
|
|
96
125
|
fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
|
|
97
126
|
|
|
127
|
+
// Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
|
|
128
|
+
// need the home `site_url` (else a loud, actionable build hint). Drafts are
|
|
129
|
+
// already filtered out of `routes`, so neither feed can ever see one.
|
|
130
|
+
const feeds = emitFeeds(routes, identity, paths);
|
|
131
|
+
|
|
98
132
|
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
99
133
|
// sees every emitted route/framework file already on disk.
|
|
100
134
|
emitPageAssets(paths);
|
|
@@ -109,7 +143,113 @@ export function buildSite(cwd = process.cwd(), options = {}) {
|
|
|
109
143
|
emitCloudflareWorker(paths, apiDir, apiRoutes);
|
|
110
144
|
}
|
|
111
145
|
|
|
112
|
-
return { routes: manifest, distRoot: paths.distRoot };
|
|
146
|
+
return { routes: manifest, distRoot: paths.distRoot, feeds };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the site's identity from the HOME route's frontmatter — the single
|
|
151
|
+
* place site-wide metadata lives this cycle (no config loader). `site_url` is
|
|
152
|
+
* the absolute origin (no trailing slash) that triggers and prefixes the feeds;
|
|
153
|
+
* `title`/`description` double as the RSS channel fields. A site with no `/`
|
|
154
|
+
* route, or a home page without `site_url`, yields an empty `siteUrl` and the
|
|
155
|
+
* feeds are skipped (robots still emits).
|
|
156
|
+
* @param {Route[]} routes
|
|
157
|
+
* @returns {{ siteUrl: string, title: string, description: string }}
|
|
158
|
+
*/
|
|
159
|
+
function siteIdentity(routes) {
|
|
160
|
+
const home = routes.find((route) => route.route === "/");
|
|
161
|
+
const meta = home ? home.meta : {};
|
|
162
|
+
const read = (/** @type {string} */ key) =>
|
|
163
|
+
typeof meta[key] === "string" ? String(meta[key]).trim() : "";
|
|
164
|
+
return {
|
|
165
|
+
siteUrl: read("site_url").replace(/\/$/, ""),
|
|
166
|
+
title: read("title") || "Darkmown",
|
|
167
|
+
description: read("description")
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether any emitted route is a "post" — has a frontmatter `date:`. That's the
|
|
173
|
+
* RSS inclusion signal, and (collectively) the trigger for emitting rss.xml at
|
|
174
|
+
* all alongside the home `site_url`.
|
|
175
|
+
* @param {Route[]} routes
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function hasDatedPost(routes) {
|
|
179
|
+
return routes.some((route) => typeof route.meta.date === "string" && route.meta.date.trim());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Emit robots.txt (always) and, when the home page set `site_url`, sitemap.xml
|
|
184
|
+
* (every non-404 route) and rss.xml (dated posts only). Returns the URL/item
|
|
185
|
+
* counts (`null` when a feed was skipped) for the CLI build summary.
|
|
186
|
+
*
|
|
187
|
+
* The sitemap `<lastmod>` for each route is the frontmatter `date:` if present,
|
|
188
|
+
* else `lastmodFor(file)` (git last-commit date → file mtime). RSS items are the
|
|
189
|
+
* routes with a `date:`, newest first, capped at {@link RSS_ITEM_LIMIT}.
|
|
190
|
+
* @param {Route[]} routes Emitted (post-draft-filter) routes.
|
|
191
|
+
* @param {{ siteUrl: string, title: string, description: string }} identity
|
|
192
|
+
* @param {Paths} paths
|
|
193
|
+
* @returns {{ sitemap: number | null, rss: number | null }}
|
|
194
|
+
*/
|
|
195
|
+
function emitFeeds(routes, identity, paths) {
|
|
196
|
+
fs.writeFileSync(path.join(paths.distRoot, "robots.txt"), buildRobots(identity.siteUrl));
|
|
197
|
+
|
|
198
|
+
if (!identity.siteUrl) {
|
|
199
|
+
console.warn(
|
|
200
|
+
"hint: set site_url in site/pages/index frontmatter to emit sitemap.xml + rss.xml " +
|
|
201
|
+
"(e.g. `site_url: https://example.com`). robots.txt was still written."
|
|
202
|
+
);
|
|
203
|
+
return { sitemap: null, rss: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The `/404/` route is never a feed entry — it's not an indexable page and
|
|
207
|
+
// not a post. Filter it out once; both feeds work off the result.
|
|
208
|
+
const feedable = routes.filter((route) => route.route !== "/404/");
|
|
209
|
+
|
|
210
|
+
// Sitemap: one <url> per emitted route (404 excluded above).
|
|
211
|
+
const sitemapEntries = feedable.map((route) => ({
|
|
212
|
+
loc: absoluteUrl(identity.siteUrl, route.route),
|
|
213
|
+
lastmod:
|
|
214
|
+
typeof route.meta.date === "string" && route.meta.date.trim()
|
|
215
|
+
? String(route.meta.date).trim().slice(0, 10)
|
|
216
|
+
: lastmodFor(route.file)
|
|
217
|
+
}));
|
|
218
|
+
fs.writeFileSync(path.join(paths.distRoot, "sitemap.xml"), buildSitemap(sitemapEntries));
|
|
219
|
+
|
|
220
|
+
// RSS: dated posts only, newest first, capped. The `date:` is the "this is a
|
|
221
|
+
// post" signal. With no dated post there is nothing to syndicate, so rss.xml
|
|
222
|
+
// is skipped entirely (and the per-page `<link rel=alternate>` is too) — an
|
|
223
|
+
// empty feed file would just 404-by-content for aggregators.
|
|
224
|
+
const posts = feedable
|
|
225
|
+
.filter((route) => typeof route.meta.date === "string" && route.meta.date.trim())
|
|
226
|
+
.sort((a, b) => String(b.meta.date).localeCompare(String(a.meta.date)))
|
|
227
|
+
.slice(0, RSS_ITEM_LIMIT);
|
|
228
|
+
if (posts.length === 0) return { sitemap: sitemapEntries.length, rss: null };
|
|
229
|
+
|
|
230
|
+
const items = posts.map((route) => {
|
|
231
|
+
const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
|
|
232
|
+
return {
|
|
233
|
+
title: typeof route.meta.title === "string" ? route.meta.title : "Untitled",
|
|
234
|
+
link: absoluteUrl(identity.siteUrl, route.route),
|
|
235
|
+
pubDate: rfc822(String(route.meta.date)),
|
|
236
|
+
description: rssDescription(route.meta, route.file, body)
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
fs.writeFileSync(
|
|
240
|
+
path.join(paths.distRoot, "rss.xml"),
|
|
241
|
+
buildRss(
|
|
242
|
+
{
|
|
243
|
+
title: identity.title,
|
|
244
|
+
description: identity.description,
|
|
245
|
+
siteUrl: identity.siteUrl,
|
|
246
|
+
feedUrl: absoluteUrl(identity.siteUrl, "/rss.xml")
|
|
247
|
+
},
|
|
248
|
+
items
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return { sitemap: sitemapEntries.length, rss: items.length };
|
|
113
253
|
}
|
|
114
254
|
|
|
115
255
|
/**
|
|
@@ -388,22 +528,111 @@ function emitBehaviors(assets, paths) {
|
|
|
388
528
|
}
|
|
389
529
|
|
|
390
530
|
/**
|
|
531
|
+
* Emit the framework syntax-highlighting stylesheet `/__wd/highlight.css`, called
|
|
532
|
+
* only for a page that has a build-time-highlighted code block (`assets.hasCode`).
|
|
533
|
+
* It is CSS only — highlighting is build-time HTML + this stylesheet, with zero
|
|
534
|
+
* runtime JS — and maps highlight.js token classes onto the `$code-*` skin tokens,
|
|
535
|
+
* so code dark-modes for free through `tokens dark` / `:theme`. Idempotent: writing
|
|
536
|
+
* the same content per code-bearing page is harmless and keeps the call site simple.
|
|
537
|
+
* @param {Paths} paths
|
|
538
|
+
* @returns {void}
|
|
539
|
+
*/
|
|
540
|
+
function emitHighlight(paths) {
|
|
541
|
+
const out = path.join(paths.distRoot, "__wd/highlight.css");
|
|
542
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
543
|
+
fs.writeFileSync(out, HIGHLIGHT_CSS);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Emit a page's colocated assets. `.skin` files compile through {@link compileSkin}
|
|
548
|
+
* (other files copy verbatim). A skin that opted into scoping (`assets.scopedSkins`)
|
|
549
|
+
* compiles in scoped mode: the scope id is derived here from its PROJECT-RELATIVE
|
|
550
|
+
* PATH — the single coordination point with the HTML stamp, which derives the same
|
|
551
|
+
* id from the same path — and threaded into `compileSkin({ scope })`. The compiler
|
|
552
|
+
* also reports the scoped subject selectors, which we check against the stamped page
|
|
553
|
+
* HTML to warn (never remove) on a scoped selector that matches no element.
|
|
391
554
|
* @param {Assets} assets
|
|
392
555
|
* @param {Paths} paths
|
|
556
|
+
* @param {string} pageHtml Assembled page HTML (carries the `data-wd-scope` stamps).
|
|
557
|
+
* @param {Set<string>} warned Cross-route dedupe set for emitted hints.
|
|
393
558
|
* @returns {void}
|
|
394
559
|
*/
|
|
395
|
-
function emitAssets(assets, paths) {
|
|
560
|
+
function emitAssets(assets, paths, pageHtml, warned) {
|
|
396
561
|
for (const [source, href] of assets.files) {
|
|
397
562
|
const out = path.join(paths.distRoot, href);
|
|
398
563
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
399
564
|
if (source.endsWith(".skin")) {
|
|
400
|
-
|
|
565
|
+
const src = fs.readFileSync(source, "utf8");
|
|
566
|
+
if (assets.scopedSkins.has(source)) {
|
|
567
|
+
const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
|
|
568
|
+
const scope = scopeIdFor(rel);
|
|
569
|
+
/** @type {Set<string>} */
|
|
570
|
+
const subjects = new Set();
|
|
571
|
+
fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
|
|
572
|
+
warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
|
|
573
|
+
} else {
|
|
574
|
+
fs.writeFileSync(out, compileSkin(src));
|
|
575
|
+
}
|
|
401
576
|
} else {
|
|
402
577
|
fs.copyFileSync(source, out);
|
|
403
578
|
}
|
|
404
579
|
}
|
|
405
580
|
}
|
|
406
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Warn (do NOT remove) when a scoped selector's subject never appears in its
|
|
584
|
+
* stamped subtree — a likely typo or stale rule. We only have a class/element/id
|
|
585
|
+
* to look for, so we scan the elements actually stamped with THIS scope id in the
|
|
586
|
+
* assembled page and check each subject against them. A colocated `.js` may add
|
|
587
|
+
* classes at runtime, so this stays advisory: the rule is always emitted. An empty
|
|
588
|
+
* subject token (a bare attribute-selector subject) is unknowable here and skipped.
|
|
589
|
+
* @param {Set<string>} subjects Scoped subject tokens (`.card`, `h2`, `#id`).
|
|
590
|
+
* @param {string} scope Scope id.
|
|
591
|
+
* @param {string} pageHtml Assembled page HTML.
|
|
592
|
+
* @param {string} skinName Skin filename for the message.
|
|
593
|
+
* @param {Set<string>} warned Cross-route dedupe set.
|
|
594
|
+
* @returns {void}
|
|
595
|
+
*/
|
|
596
|
+
function warnUnusedScoped(subjects, scope, pageHtml, skinName, warned) {
|
|
597
|
+
// The opening tags carrying this scope id — the only elements the scoped
|
|
598
|
+
// stylesheet can match. One scan, reused for every subject.
|
|
599
|
+
const stamped = pageHtml.match(
|
|
600
|
+
new RegExp(`<[a-zA-Z][\\w-]*[^>]*\\bdata-wd-scope="${scope}"[^>]*>`, "g")
|
|
601
|
+
);
|
|
602
|
+
const tags = stamped ?? [];
|
|
603
|
+
for (const subject of subjects) {
|
|
604
|
+
if (!subject) continue;
|
|
605
|
+
const present = tags.some((tag) => subjectMatchesTag(subject, tag));
|
|
606
|
+
if (present) continue;
|
|
607
|
+
const warning = `scoped selector "${subject}" in ${skinName} matches no element`;
|
|
608
|
+
if (warned.has(warning)) continue;
|
|
609
|
+
warned.add(warning);
|
|
610
|
+
console.warn(`hint: ${warning}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Whether a scoped subject token (`.card` / `#id` / a bare tag like `h2`) could
|
|
616
|
+
* match a single stamped opening tag. Class subjects look in the tag's `class`
|
|
617
|
+
* attribute; id subjects in `id`; a bare tag compares the element name.
|
|
618
|
+
* @param {string} subject
|
|
619
|
+
* @param {string} tag A single opening tag string.
|
|
620
|
+
* @returns {boolean}
|
|
621
|
+
*/
|
|
622
|
+
function subjectMatchesTag(subject, tag) {
|
|
623
|
+
if (subject.startsWith(".")) {
|
|
624
|
+
const cls = subject.slice(1);
|
|
625
|
+
const m = tag.match(/\bclass\s*=\s*"([^"]*)"/);
|
|
626
|
+
return m ? m[1].split(/\s+/).includes(cls) : false;
|
|
627
|
+
}
|
|
628
|
+
if (subject.startsWith("#")) {
|
|
629
|
+
const m = tag.match(/\bid\s*=\s*"([^"]*)"/);
|
|
630
|
+
return m ? m[1] === subject.slice(1) : false;
|
|
631
|
+
}
|
|
632
|
+
const name = tag.match(/^<([a-zA-Z][\w-]*)/);
|
|
633
|
+
return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
|
|
634
|
+
}
|
|
635
|
+
|
|
407
636
|
/**
|
|
408
637
|
* Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
|
|
409
638
|
* @param {Paths} paths
|
package/src/cli.js
CHANGED
|
@@ -6,8 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { handleApiRequest } from "./api-runner.js";
|
|
8
8
|
import { buildSite } from "./builder.js";
|
|
9
|
+
import { createPaths } from "./config.js";
|
|
9
10
|
import { DEPLOY_TARGETS, deploy } from "./deploy.js";
|
|
10
11
|
import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
|
|
12
|
+
import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
|
|
11
13
|
import { availableTemplates, initProject } from "./scaffold.js";
|
|
12
14
|
import { contentType, resolvePublicFile, serve } from "./statics.js";
|
|
13
15
|
|
|
@@ -64,8 +66,11 @@ export async function run(argv, env = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
if (command === "build") {
|
|
66
68
|
const target = flagValue(argv, "--target");
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// `--drafts` includes `draft: true` pages in the build + feeds (staging);
|
|
70
|
+
// the default production build excludes them at route discovery.
|
|
71
|
+
const includeDrafts = argv.includes("--drafts");
|
|
72
|
+
const result = buildSite(cwd, { target, includeDrafts });
|
|
73
|
+
log(buildSummary(result, path.relative(cwd, result.distRoot)));
|
|
69
74
|
if (target === "cloudflare")
|
|
70
75
|
log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
|
|
71
76
|
return { command: "build" };
|
|
@@ -131,8 +136,10 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
131
136
|
// A broken initial build must NOT crash the process before the HTTP server —
|
|
132
137
|
// and thus the error overlay — comes up. Record the error so new SSE clients
|
|
133
138
|
// see it on connect; the next successful rebuild clears it via `reload`.
|
|
139
|
+
// Dev builds + serves `draft: true` pages (they're work-in-progress you want
|
|
140
|
+
// to preview); the dev client stamps a visible banner so a draft is obvious.
|
|
134
141
|
try {
|
|
135
|
-
buildSite(cwd);
|
|
142
|
+
buildSite(cwd, { includeDrafts: true });
|
|
136
143
|
} catch (err) {
|
|
137
144
|
lastBuildError = (err instanceof Error ? err.message : String(err)).trim();
|
|
138
145
|
error(lastBuildError);
|
|
@@ -144,7 +151,7 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
144
151
|
clearTimeout(timer);
|
|
145
152
|
timer = setTimeout(() => {
|
|
146
153
|
const started = performance.now();
|
|
147
|
-
execFile(process.execPath, [cliPath, "build"], { cwd }, (err, stdout, stderr) => {
|
|
154
|
+
execFile(process.execPath, [cliPath, "build", "--drafts"], { cwd }, (err, stdout, stderr) => {
|
|
148
155
|
if (err) {
|
|
149
156
|
const message = (stderr || stdout || String(err)).trim();
|
|
150
157
|
lastBuildError = message;
|
|
@@ -204,7 +211,7 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
204
211
|
// throw on either path (e.g. a stat/read error) becomes a clean 500.
|
|
205
212
|
handleApiRequest({ apiDir, req, res })
|
|
206
213
|
.then((handled) => {
|
|
207
|
-
if (!handled) serveDev(distRoot, url, res);
|
|
214
|
+
if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd));
|
|
208
215
|
})
|
|
209
216
|
.catch((err) => {
|
|
210
217
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
@@ -262,9 +269,11 @@ function startPreviewServer({ cwd, log, error }) {
|
|
|
262
269
|
* @param {string} distRoot
|
|
263
270
|
* @param {string} url
|
|
264
271
|
* @param {import("node:http").ServerResponse} res
|
|
272
|
+
* @param {Set<string>} draftFiles Absolute dist HTML paths of draft pages — each
|
|
273
|
+
* gets the dev-only draft banner injected (never written to disk).
|
|
265
274
|
* @returns {void}
|
|
266
275
|
*/
|
|
267
|
-
function serveDev(distRoot, url, res) {
|
|
276
|
+
function serveDev(distRoot, url, res, draftFiles) {
|
|
268
277
|
const file = resolvePublicFile(distRoot, url);
|
|
269
278
|
if (!file || !fs.existsSync(file)) {
|
|
270
279
|
serve(distRoot, url, res);
|
|
@@ -274,7 +283,7 @@ function serveDev(distRoot, url, res) {
|
|
|
274
283
|
// Read BEFORE writing headers: if the read throws (e.g. the path is a
|
|
275
284
|
// directory), the handler's catch can still send a clean 500 instead of
|
|
276
285
|
// failing to overwrite an already-sent 200.
|
|
277
|
-
const html = injectDevClient(fs.readFileSync(file, "utf8"));
|
|
286
|
+
const html = injectDevClient(fs.readFileSync(file, "utf8"), { draft: draftFiles.has(file) });
|
|
278
287
|
res.writeHead(200, { "content-type": "text/html" });
|
|
279
288
|
res.end(html);
|
|
280
289
|
return;
|
|
@@ -283,6 +292,24 @@ function serveDev(distRoot, url, res) {
|
|
|
283
292
|
fs.createReadStream(file).pipe(res);
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The set of absolute dist HTML paths that correspond to `draft: true` source
|
|
297
|
+
* pages, so the dev server can banner exactly those. Recomputed per HTML request
|
|
298
|
+
* (cheap, and stays correct as a page gains/loses `draft:` during a dev session).
|
|
299
|
+
* Returns an empty set when the project has no `site/pages` yet.
|
|
300
|
+
* @param {string} cwd Project working directory.
|
|
301
|
+
* @returns {Set<string>}
|
|
302
|
+
*/
|
|
303
|
+
function draftRoutes(cwd) {
|
|
304
|
+
const paths = createPaths(cwd);
|
|
305
|
+
/** @type {Set<string>} */
|
|
306
|
+
const drafts = new Set();
|
|
307
|
+
for (const route of discoverRoutes(paths.routesRoot, { includeDrafts: true })) {
|
|
308
|
+
if (isDraft(route.meta)) drafts.add(outputPathForRoute(paths.distRoot, route.route));
|
|
309
|
+
}
|
|
310
|
+
return drafts;
|
|
311
|
+
}
|
|
312
|
+
|
|
286
313
|
/**
|
|
287
314
|
* @param {Set<import("node:http").ServerResponse>} clients
|
|
288
315
|
* @param {string} message
|
|
@@ -303,6 +330,7 @@ Usage:
|
|
|
303
330
|
darkmown init [dir] [--template <name>] Scaffold a new project from a template
|
|
304
331
|
darkmown dev Start the live compiler dev server
|
|
305
332
|
darkmown build [--target cloudflare] Compile site/pages into dist
|
|
333
|
+
[--drafts] Include draft: true pages (staging)
|
|
306
334
|
darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
|
|
307
335
|
darkmown serve Preview the built dist locally
|
|
308
336
|
darkmown version Print the installed Darkmown version
|
|
@@ -320,6 +348,22 @@ Authoring:
|
|
|
320
348
|
`;
|
|
321
349
|
}
|
|
322
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The one-line `build` summary: route count plus, when the feeds were emitted
|
|
353
|
+
* (home `site_url` set), the sitemap URL and RSS post counts — e.g.
|
|
354
|
+
* `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`. When no
|
|
355
|
+
* `site_url` is set the feed clauses are omitted (a hint was already warned).
|
|
356
|
+
* @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null } }} result
|
|
357
|
+
* @param {string} relativeRoot `dist` relative to cwd, for the message tail.
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
export function buildSummary(result, relativeRoot) {
|
|
361
|
+
const parts = [`Built ${result.routes.length} routes`];
|
|
362
|
+
if (result.feeds.sitemap !== null) parts.push(`sitemap (${result.feeds.sitemap} urls)`);
|
|
363
|
+
if (result.feeds.rss !== null) parts.push(`rss (${result.feeds.rss} posts)`);
|
|
364
|
+
return `${parts.join(", ")} into ${relativeRoot}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
323
367
|
/**
|
|
324
368
|
* @param {string} relativeRoot
|
|
325
369
|
* @returns {string}
|