@zvndev/darkmown 1.5.0 → 2.0.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.
Files changed (88) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +44 -16
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +19 -6
  7. package/package.json +1 -1
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +4 -0
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +1 -0
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.skin +127 -0
  24. package/site/pages/carousel/index.wd +1 -0
  25. package/site/pages/compass/index.wd +1 -0
  26. package/site/pages/data.wd +3 -0
  27. package/site/pages/docs/-relative-note.wd +4 -0
  28. package/site/pages/docs/index.skin +27 -0
  29. package/site/pages/docs/index.wd +70 -13
  30. package/site/pages/folio/-style.wd +4 -0
  31. package/site/pages/folio/cart.wd +1 -0
  32. package/site/pages/folio/index.wd +1 -0
  33. package/site/pages/forge/index.wd +1 -0
  34. package/site/pages/highlight/index.wd +1 -0
  35. package/site/pages/index.wd +1 -0
  36. package/site/pages/interactions/index.wd +1 -0
  37. package/site/pages/ledger/index.wd +1 -0
  38. package/site/pages/loops.wd +1 -0
  39. package/site/pages/media/index.wd +1 -0
  40. package/site/pages/pulse/index.wd +1 -0
  41. package/site/pages/reactive.wd +1 -0
  42. package/site/pages/scoped/index.wd +1 -0
  43. package/site/pages/showcase/index.wd +1 -0
  44. package/site/pages/store.wd +1 -0
  45. package/site/pages/theme.wd +1 -0
  46. package/src/builder.js +444 -65
  47. package/src/cli.js +96 -18
  48. package/src/compiler/actions.js +303 -0
  49. package/src/compiler/body.js +36 -24
  50. package/src/compiler/context.js +56 -6
  51. package/src/compiler/directives.js +31 -1470
  52. package/src/compiler/fetch.js +159 -0
  53. package/src/compiler/format.js +17 -4
  54. package/src/compiler/forms.js +456 -0
  55. package/src/compiler/frontmatter.js +10 -5
  56. package/src/compiler/includes.js +66 -1
  57. package/src/compiler/index.js +15 -3
  58. package/src/compiler/loops.js +53 -21
  59. package/src/compiler/markdown.js +65 -15
  60. package/src/compiler/media.js +133 -0
  61. package/src/compiler/page.js +59 -75
  62. package/src/compiler/state.js +168 -0
  63. package/src/compiler/structure.js +377 -0
  64. package/src/dev.js +76 -0
  65. package/src/headers.js +56 -17
  66. package/src/router.js +12 -1
  67. package/src/runtime.js +5 -3
  68. package/src/statics.js +63 -12
  69. package/src/templates/blog/README.md +13 -4
  70. package/src/templates/blog/site/_/nav.wd +4 -0
  71. package/src/templates/blog/site/pages/index.skin +3 -0
  72. package/src/templates/blog/site/pages/index.wd +10 -4
  73. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  74. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  75. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  76. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  77. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  78. package/src/templates/dashboard/site/_/nav.wd +4 -0
  79. package/src/templates/dashboard/site/pages/index.wd +1 -0
  80. package/src/templates/landing/site/_/nav.wd +4 -0
  81. package/src/templates/landing/site/pages/index.wd +1 -0
  82. package/src/templates/starter/site/_/nav.wd +4 -0
  83. package/src/templates/starter/site/pages/about.md +18 -1
  84. package/src/templates/starter/site/pages/about.skin +69 -0
  85. package/src/templates/starter/site/pages/index.wd +1 -0
  86. package/src/templates/store/site/_/nav.wd +4 -0
  87. package/src/templates/store/site/pages/index.wd +1 -0
  88. package/src/templates/blog/site/_/posts.json +0 -16
package/src/statics.js CHANGED
@@ -1,19 +1,67 @@
1
1
  import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
- import { BASE_SECURITY_HEADERS, REACTIVE_CSP } from "./headers.js";
4
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
5
5
 
6
6
  /**
7
- * Security headers for an HTML response from the local server. The relaxed
8
- * (reactive) CSP is a superset that satisfies both static and reactive pages,
9
- * so the runtime server uses it for every HTML response; the build-time
10
- * outputs (`vercel.json`, `dist/_headers`) tighten static routes per-path.
11
- * @type {Record<string, string>}
7
+ * The security headers (including the right CSP variant) for an HTML response.
8
+ * Mirrors what production ships: a route the build marked reactive gets the
9
+ * relaxed CSP (`'unsafe-eval'` for `new Function`); a static route gets the
10
+ * stricter, eval-free CSP — so a local preview matches the per-route `_headers`
11
+ * / `vercel.json` the same build writes instead of blanket-relaxing every page.
12
+ * Anything not found in `routes.json` (an unknown route, a 404, a missing
13
+ * manifest) falls back to the reactive CSP, exactly like the production
14
+ * `_headers` catch-all block.
15
+ * @param {string} distRoot Absolute path to the build output directory.
16
+ * @param {string} url Request URL (may include a query string).
17
+ * @returns {Record<string, string>}
12
18
  */
13
- const HTML_SECURITY_HEADERS = {
14
- ...BASE_SECURITY_HEADERS,
15
- "Content-Security-Policy": REACTIVE_CSP
16
- };
19
+ function htmlSecurityHeaders(distRoot, url) {
20
+ return {
21
+ ...BASE_SECURITY_HEADERS,
22
+ "Content-Security-Policy": routeIsReactive(distRoot, routeOf(url)) ? REACTIVE_CSP : STATIC_CSP
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Normalize a request URL to its clean, trailing-slashed route path so it can be
28
+ * matched against `routes.json` (`/docs` / `/docs/` / `/docs/index.html` all →
29
+ * `/docs/`). Returns null for an unparseable URL.
30
+ * @param {string} url
31
+ * @returns {string | null}
32
+ */
33
+ function routeOf(url) {
34
+ let pathname;
35
+ try {
36
+ pathname = decodeURIComponent((url || "/").split("?")[0]);
37
+ } catch {
38
+ return null;
39
+ }
40
+ if (pathname.endsWith("/index.html")) pathname = pathname.slice(0, -"index.html".length);
41
+ if (!pathname.endsWith("/")) pathname += "/";
42
+ return pathname;
43
+ }
44
+
45
+ /**
46
+ * Whether the route ships the reactive runtime, per the build's `routes.json`.
47
+ * Defaults to `true` (reactive) when the route is absent or the manifest can't be
48
+ * read — matching the production `_headers` catch-all, so an unknown route is
49
+ * never accidentally served the stricter policy.
50
+ * @param {string} distRoot
51
+ * @param {string | null} route
52
+ * @returns {boolean}
53
+ */
54
+ function routeIsReactive(distRoot, route) {
55
+ if (!route) return true;
56
+ try {
57
+ const manifest = JSON.parse(fs.readFileSync(path.join(distRoot, "routes.json"), "utf8"));
58
+ const entry = manifest.find((/** @type {{ route: string }} */ e) => e.route === route);
59
+ if (entry) return Boolean(entry.assets?.runtime);
60
+ } catch {
61
+ /* no/unreadable manifest → reactive default (the catch-all policy) */
62
+ }
63
+ return true;
64
+ }
17
65
 
18
66
  /**
19
67
  * Serve a built file for a request URL out of `distRoot`, or a 404 page.
@@ -25,7 +73,10 @@ const HTML_SECURITY_HEADERS = {
25
73
  export function serve(distRoot, url, res) {
26
74
  const file = resolvePublicFile(distRoot, url || "/");
27
75
  if (!file || !fs.existsSync(file)) {
28
- res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...HTML_SECURITY_HEADERS });
76
+ res.writeHead(404, {
77
+ "content-type": "text/html; charset=utf-8",
78
+ ...htmlSecurityHeaders(distRoot, url)
79
+ });
29
80
  const notFound = path.join(distRoot, "404.html");
30
81
  if (fs.existsSync(notFound)) {
31
82
  res.end(fs.readFileSync(notFound));
@@ -36,7 +87,7 @@ export function serve(distRoot, url, res) {
36
87
  }
37
88
  const type = contentType(file);
38
89
  const headers = type.startsWith("text/html")
39
- ? { "content-type": type, ...HTML_SECURITY_HEADERS }
90
+ ? { "content-type": type, ...htmlSecurityHeaders(distRoot, url) }
40
91
  : { "content-type": type };
41
92
  res.writeHead(200, headers);
42
93
  fs.createReadStream(file).pipe(res);
@@ -1,12 +1,21 @@
1
1
  # Blog — a Darkmown template
2
2
 
3
- A markdown-native blog. Posts are plain `.md` files in `site/pages/posts/`; the
4
- home page loops `site/_/posts.json` to list them. Static pages, zero JavaScript.
3
+ A markdown-native blog. Posts are plain `.md` files in `site/pages/posts/` —
4
+ that folder is a typed content collection, and the home page is one `@loop`
5
+ over it. No manifest, no config. Static pages, zero JavaScript.
5
6
 
6
7
  ## Add a post
7
8
 
8
- 1. Create `site/pages/posts/<slug>.md` with a `title` in frontmatter.
9
- 2. Add an entry to `site/_/posts.json` (`slug`, `url`, `title`, `date`, `excerpt`).
9
+ 1. Create `site/pages/posts/<slug>.md` with `title`, `date`, and `description`
10
+ in frontmatter (`excerpt` is optional). That's it — the home page lists it
11
+ automatically, newest first, and dated posts land in `rss.xml`.
12
+ 2. Optional: copy an existing post's `.skin` beside the new file — a colocated
13
+ `.skin` attaches automatically, keeping the reading design.
14
+
15
+ `site/pages/posts/_schema.wd` type-checks every post's frontmatter at build
16
+ time, so a typo'd or missing field fails the build with a file and line. When
17
+ the list grows, add `paginate 10` to the loop in `site/pages/index.wd` to split
18
+ it into `/page/2/`, `/page/3/`, … automatically.
10
19
 
11
20
  ## Commands
12
21
 
@@ -1,3 +1,7 @@
1
+ ---
2
+ html: true
3
+ ---
4
+
1
5
  <nav>
2
6
  <strong>My Blog</strong>
3
7
  <span class="links">
@@ -66,6 +66,9 @@ nav a
66
66
  color $muted
67
67
  font-size .9rem
68
68
 
69
+ .post-meta p
70
+ margin 0
71
+
69
72
  /* Buttons & form controls — for newsletter signups, :button actions, etc. */
70
73
  button
71
74
  display inline-flex
@@ -1,4 +1,5 @@
1
1
  ---
2
+ html: true
2
3
  title: My Blog
3
4
  description: A markdown-native blog built with Darkmown.
4
5
  site_url: https://example.com
@@ -11,15 +12,20 @@ transitions: true
11
12
 
12
13
  # My Blog
13
14
 
14
- Posts are plain Markdown files in `site/pages/posts/`. This index is one `.wd`
15
- loop over a small JSON manifest — sorted newest-first, shipping zero JavaScript.
15
+ Posts are plain Markdown files in `site/pages/posts/` — that folder is a typed
16
+ content collection, and this index is one `.wd` loop over it. No manifest, no
17
+ config, zero JavaScript; each post's frontmatter is the data.
16
18
 
17
- @loop /posts.json into post sort by post.date desc
19
+ @loop posts into post sort by post.date desc
18
20
  ::: post-card
19
21
  ### [{ post.title }]({ post.url })
20
- <p class="post-meta">{ post.date }</p>
22
+ ::: post-meta
23
+ { post.date }
24
+ :::
21
25
  { post.excerpt }
22
26
  :::
27
+ @empty
28
+ No posts yet — add a `.md` file to `site/pages/posts/`.
23
29
  @endloop
24
30
 
25
31
  </main>
@@ -0,0 +1,16 @@
1
+ ---
2
+ # Typed schema for the posts collection. Every .md file next to this file is
3
+ # validated against these rules at build time — a missing required field, a
4
+ # wrong type, or an unknown extra field fails the build with a file:line.
5
+ # Append "?" to a type to make a field optional. Page-shell flags a post sets
6
+ # (like transitions) are declared here too. Remove this file to turn
7
+ # validation off (it is opt-in).
8
+ title: string
9
+ date: date
10
+ description: string
11
+ excerpt: string?
12
+ transitions: boolean?
13
+ ---
14
+
15
+ The posts collection schema. Posts are plain `.md` files next to this file;
16
+ their frontmatter must match the rules above.
@@ -6,6 +6,8 @@ excerpt: I rewrote my blog as plain Markdown files — no React, no build ceremo
6
6
  transitions: true
7
7
  ---
8
8
 
9
+ [← All posts](/)
10
+
9
11
  # Hello, Darkmown
10
12
 
11
13
  This post is a plain `.md` file — strict CommonMark, **zero** framework
@@ -0,0 +1,59 @@
1
+ /* Colocated post styling — a .skin next to a .md attaches automatically.
2
+ Copy this file beside each new post to keep the reading design. */
3
+
4
+ tokens
5
+ ink #1d2530
6
+ paper #fbfaf7
7
+ panel #ffffff
8
+ line #e7e3da
9
+ muted #6b7785
10
+ accent #2f6df0
11
+
12
+ tokens dark
13
+ ink #e7ecf3
14
+ paper #0f141b
15
+ panel #161d27
16
+ line #28303c
17
+ muted #93a0b2
18
+ accent #5b8cff
19
+
20
+ html
21
+ bg $paper
22
+
23
+ page
24
+ max-width 680px
25
+ margin 0 auto
26
+ padding 2.5rem 1.5rem 4rem
27
+ font ui-serif, Georgia, Cambria, serif
28
+ color $ink
29
+ bg $paper
30
+ line-height 1.7
31
+ -webkit-font-smoothing antialiased
32
+
33
+ h1
34
+ font-size 2.1rem
35
+ line-height 1.2
36
+ margin 1rem 0 1.25rem
37
+
38
+ a
39
+ color $accent
40
+
41
+ code
42
+ font ui-monospace, SFMono-Regular, Menlo, monospace
43
+ font-size .88em
44
+ bg $panel
45
+ border 1px solid $line
46
+ padding .08rem .28rem
47
+ radius 5px
48
+
49
+ pre code
50
+ display block
51
+ padding .9rem 1.1rem
52
+ overflow-x auto
53
+
54
+ blockquote
55
+ margin 1rem 0
56
+ padding .5rem 1.2rem
57
+ border-left 3px solid $accent
58
+ bg $panel
59
+ color $muted
@@ -6,17 +6,22 @@ excerpt: Every post is a plain .md file; the index is one .wd loop. The date in
6
6
  transitions: true
7
7
  ---
8
8
 
9
+ [← All posts](/)
10
+
9
11
  # Markdown-native, by default
10
12
 
11
- To add a post, drop a Markdown file in `site/pages/posts/` and add a line to
12
- `site/_/posts.json`. The home page loops that manifest:
13
+ To add a post, drop a Markdown file in `site/pages/posts/`. That folder is a
14
+ typed content collection, and the home page loops it directly:
13
15
 
14
16
  ```wd
15
- @loop /posts.json into post sort by post.date desc
17
+ @loop posts into post sort by post.date desc
16
18
  ### [{ post.title }]({ post.url })
17
19
  @endloop
18
20
  ```
19
21
 
20
- No build config, no plugins, no content layer. The file *is* the page.
22
+ No manifest, no build config, no content layer. The file *is* the page, and its
23
+ frontmatter is the data — `site/pages/posts/_schema.wd` type-checks every
24
+ post's `title`, `date`, and `description` at build time, so a typo fails the
25
+ build with a file and line instead of shipping a broken list.
21
26
 
22
27
  [← Back to all posts](/)
@@ -0,0 +1,59 @@
1
+ /* Colocated post styling — a .skin next to a .md attaches automatically.
2
+ Copy this file beside each new post to keep the reading design. */
3
+
4
+ tokens
5
+ ink #1d2530
6
+ paper #fbfaf7
7
+ panel #ffffff
8
+ line #e7e3da
9
+ muted #6b7785
10
+ accent #2f6df0
11
+
12
+ tokens dark
13
+ ink #e7ecf3
14
+ paper #0f141b
15
+ panel #161d27
16
+ line #28303c
17
+ muted #93a0b2
18
+ accent #5b8cff
19
+
20
+ html
21
+ bg $paper
22
+
23
+ page
24
+ max-width 680px
25
+ margin 0 auto
26
+ padding 2.5rem 1.5rem 4rem
27
+ font ui-serif, Georgia, Cambria, serif
28
+ color $ink
29
+ bg $paper
30
+ line-height 1.7
31
+ -webkit-font-smoothing antialiased
32
+
33
+ h1
34
+ font-size 2.1rem
35
+ line-height 1.2
36
+ margin 1rem 0 1.25rem
37
+
38
+ a
39
+ color $accent
40
+
41
+ code
42
+ font ui-monospace, SFMono-Regular, Menlo, monospace
43
+ font-size .88em
44
+ bg $panel
45
+ border 1px solid $line
46
+ padding .08rem .28rem
47
+ radius 5px
48
+
49
+ pre code
50
+ display block
51
+ padding .9rem 1.1rem
52
+ overflow-x auto
53
+
54
+ blockquote
55
+ margin 1rem 0
56
+ padding .5rem 1.2rem
57
+ border-left 3px solid $accent
58
+ bg $panel
59
+ color $muted
@@ -1,3 +1,7 @@
1
+ ---
2
+ html: true
3
+ ---
4
+
1
5
  <nav>
2
6
  <strong>Dashboard</strong>
3
7
  <span class="links">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ html: true
2
3
  title: Dashboard — built with Darkmown
3
4
  description: A live dashboard — stat cards fetched from a serverless function, on an otherwise static page.
4
5
  transitions: true
@@ -1,3 +1,7 @@
1
+ ---
2
+ html: true
3
+ ---
4
+
1
5
  <nav>
2
6
  <strong>Acme</strong>
3
7
  <span class="links">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ html: true
2
3
  title: Acme — ship faster
3
4
  description: A marketing one-pager built with Darkmown. Zero framework JS on the static parts, with a testimonials carousel.
4
5
  transitions: true
@@ -1,3 +1,7 @@
1
+ ---
2
+ html: true
3
+ ---
4
+
1
5
  <nav>
2
6
  <strong>My site</strong>
3
7
  <span class="links">
@@ -1,10 +1,27 @@
1
1
  ---
2
2
  title: About
3
+ html: true
3
4
  ---
4
5
 
6
+ <nav>
7
+ <strong>My site</strong>
8
+ <span class="links">
9
+ <a href="/">Home</a>
10
+ <a href="/about/">About</a>
11
+ </span>
12
+ </nav>
13
+
14
+ <main>
15
+
5
16
  # About
6
17
 
7
18
  This page is plain Markdown (`.md`) — strict CommonMark, zero framework JavaScript.
8
- Rename it to `.wd` when you want directives like state, loops, or forms.
19
+ Rename it to `.wd` when you want directives like state, loops, or forms — a `.wd`
20
+ page can `@include /nav.wd` instead of repeating the markup above.
21
+
22
+ The colocated `about.skin` styles this page; it attaches automatically by
23
+ basename, `.md` or `.wd` alike.
9
24
 
10
25
  [Back home](/)
26
+
27
+ </main>
@@ -0,0 +1,69 @@
1
+ /* Colocated page styling — a .skin next to a .md attaches automatically. */
2
+
3
+ tokens
4
+ ink #172026
5
+ muted #5b6b72
6
+ paper #f7f2e8
7
+ panel #ffffff
8
+ line #e4ddcd
9
+ accent #0f8b8d
10
+
11
+ tokens dark
12
+ ink #e8eef0
13
+ muted #93a3a8
14
+ paper #11181b
15
+ panel #182226
16
+ line #2a363a
17
+ accent #3fc6c8
18
+
19
+ html
20
+ bg $paper
21
+
22
+ page
23
+ margin 0
24
+ font ui-sans-serif, system-ui, sans-serif
25
+ color $ink
26
+ bg $paper
27
+ line-height 1.6
28
+ -webkit-font-smoothing antialiased
29
+
30
+ main
31
+ max-width 760px
32
+ margin 0 auto
33
+ padding 3rem 1.5rem
34
+
35
+ nav
36
+ display flex
37
+ align-items center
38
+ justify-content space-between
39
+ gap 1rem
40
+ max-width 760px
41
+ margin 0 auto
42
+ padding 1.25rem 1.5rem
43
+
44
+ nav strong
45
+ font-size 1.1rem
46
+
47
+ nav .links
48
+ display flex
49
+ gap 1.25rem
50
+
51
+ nav a
52
+ color $ink
53
+ text-decoration none
54
+ opacity .8
55
+
56
+ nav a:hover
57
+ opacity 1
58
+ color $accent
59
+
60
+ a
61
+ color $accent
62
+
63
+ code
64
+ font ui-monospace, SFMono-Regular, Menlo, monospace
65
+ font-size .88em
66
+ bg $panel
67
+ border 1px solid $line
68
+ padding .08rem .28rem
69
+ radius 5px
@@ -1,4 +1,5 @@
1
1
  ---
2
+ html: true
2
3
  title: My Darkmown site
3
4
  ---
4
5
 
@@ -1,3 +1,7 @@
1
+ ---
2
+ html: true
3
+ ---
4
+
1
5
  <nav>
2
6
  <strong>Shop</strong>
3
7
  <span class="links">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ html: true
2
3
  title: Shop — built with Darkmown
3
4
  description: A storefront in Markdown — live search, a shared cart, and a checkout that posts to a serverless function.
4
5
  transitions: true
@@ -1,16 +0,0 @@
1
- [
2
- {
3
- "slug": "hello-darkmown",
4
- "url": "/posts/hello-darkmown/",
5
- "title": "Hello, Darkmown",
6
- "date": "2026-06-15",
7
- "excerpt": "Why I rewrote my blog as plain Markdown files — and shipped zero framework JavaScript."
8
- },
9
- {
10
- "slug": "markdown-native",
11
- "url": "/posts/markdown-native/",
12
- "title": "Markdown-native, by default",
13
- "date": "2026-06-01",
14
- "excerpt": "Every post is a .md file. The index is one .wd loop over this manifest. That's the whole site."
15
- }
16
- ]