@zvndev/darkmown 0.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.
@@ -0,0 +1,68 @@
1
+ ---
2
+ title: Reactive Markdown
3
+ ---
4
+
5
+ @include /nav.wd
6
+
7
+ <main>
8
+
9
+ # Reactive Markdown
10
+
11
+ This page opts into the runtime because it declares state. Static pages ship zero framework JavaScript.
12
+
13
+ :state count = 0
14
+
15
+ The count is { count }.
16
+
17
+ :button "Increment" -> count++
18
+
19
+ :state open = false
20
+
21
+ :button "Show details" -> open = true
22
+ :button "Hide details" -> open = false
23
+
24
+ :if open
25
+ The details are visible now.
26
+ :else
27
+ The details are hidden.
28
+ :endif
29
+
30
+ ## Keyed loops over real objects
31
+
32
+ The same `@loop` that unrolls static data also drives reactive lists. Items are objects, bindings use dotted paths, and the runtime patches the list by key instead of re-rendering it:
33
+
34
+ :state todos = [{"id": 1, "title": "Route pages", "owner": "PM"}, {"id": 2, "title": "Ship tiny runtime", "owner": "Dev"}]
35
+
36
+ @loop todos into todo
37
+ - **{ todo.title }** — { todo.owner }
38
+ @endloop
39
+
40
+ :button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
41
+
42
+ ## Section-scoped state
43
+
44
+ Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
45
+
46
+ ::: section #left .tally-card
47
+ :state tally = 0
48
+
49
+ ### Left section
50
+
51
+ The left tally is { tally }.
52
+
53
+ :button "Bump left" -> tally++
54
+ :::
55
+
56
+ ::: section #right .tally-card
57
+ :state tally = 10
58
+
59
+ ### Right section
60
+
61
+ The right tally is { tally }.
62
+
63
+ :button "Bump right" -> tally++
64
+ :::
65
+
66
+ Static pages like the docs stay runtime-free.
67
+
68
+ </main>
package/src/builder.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { compilePage } from "./compiler.js";
5
+ import { createPaths } from "./config.js";
6
+ import { discoverRoutes, outputPathForRoute } from "./router.js";
7
+ import { compileSkin } from "./skin.js";
8
+
9
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ export function buildSite(cwd = process.cwd()) {
12
+ const paths = createPaths(cwd);
13
+ fs.rmSync(paths.distRoot, { recursive: true, force: true });
14
+ fs.mkdirSync(paths.distRoot, { recursive: true });
15
+ emitShelfAssets(paths);
16
+ const routes = discoverRoutes(paths.routesRoot);
17
+ const manifest = [];
18
+
19
+ const warned = new Set();
20
+
21
+ for (const route of routes) {
22
+ const page = compilePage(route.file, paths);
23
+ for (const warning of page.warnings || []) {
24
+ if (warned.has(warning)) continue;
25
+ warned.add(warning);
26
+ console.warn(`hint: ${warning}`);
27
+ }
28
+ const outFile = outputPathForRoute(paths.distRoot, route.route);
29
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
30
+ fs.writeFileSync(outFile, page.html);
31
+ emitAssets(page.assets, paths);
32
+ if (page.assets.runtime) emitRuntime(paths);
33
+ manifest.push({
34
+ route: route.route,
35
+ file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
36
+ assets: {
37
+ skins: [...page.assets.skins],
38
+ scripts: page.assets.runtime ? ["/__wd/runtime.js", ...page.assets.scripts] : [...page.assets.scripts],
39
+ runtime: page.assets.runtime
40
+ }
41
+ });
42
+ }
43
+
44
+ fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
45
+ return { routes: manifest, distRoot: paths.distRoot };
46
+ }
47
+
48
+ function emitRuntime(paths) {
49
+ const out = path.join(paths.distRoot, "__wd/runtime.js");
50
+ fs.mkdirSync(path.dirname(out), { recursive: true });
51
+ fs.copyFileSync(path.join(moduleDir, "runtime.js"), out);
52
+ }
53
+
54
+ function emitAssets(assets, paths) {
55
+ for (const [source, href] of assets.files) {
56
+ const out = path.join(paths.distRoot, href);
57
+ fs.mkdirSync(path.dirname(out), { recursive: true });
58
+ if (source.endsWith(".skin")) {
59
+ fs.writeFileSync(out, compileSkin(fs.readFileSync(source, "utf8")));
60
+ } else {
61
+ fs.copyFileSync(source, out);
62
+ }
63
+ }
64
+ }
65
+
66
+ function emitShelfAssets(paths) {
67
+ if (!fs.existsSync(paths.shelfRoot)) return;
68
+ for (const file of walk(paths.shelfRoot)) {
69
+ const ext = path.extname(file);
70
+ if ([".md", ".wd"].includes(ext)) continue;
71
+ const rel = path.relative(paths.shelfRoot, file);
72
+ const folder = ext === ".json" ? "__wd/data" : "__wd/media";
73
+ const out = path.join(paths.distRoot, folder, rel);
74
+ fs.mkdirSync(path.dirname(out), { recursive: true });
75
+ fs.copyFileSync(file, out);
76
+ }
77
+ }
78
+
79
+ function walk(dir) {
80
+ const files = [];
81
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
82
+ const abs = path.join(dir, entry.name);
83
+ if (entry.isDirectory()) files.push(...walk(abs));
84
+ else files.push(abs);
85
+ }
86
+ return files;
87
+ }
package/src/cli.js ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import http from "node:http";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { buildSite } from "./builder.js";
8
+ import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
9
+ import { initProject } from "./scaffold.js";
10
+ import { contentType, resolvePublicFile, serve } from "./statics.js";
11
+
12
+ const cliPath = fileURLToPath(import.meta.url);
13
+ const command = process.argv[2] || "build";
14
+
15
+ if (command === "help" || command === "--help" || command === "-h") {
16
+ printHelp();
17
+ } else if (command === "version" || command === "--version" || command === "-v") {
18
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
+ console.log(pkg.version);
20
+ } else if (command === "init") {
21
+ const target = process.argv[3] || ".";
22
+ const result = initProject(path.resolve(process.cwd(), target));
23
+ console.log(`Created Darkmown project at ${path.relative(process.cwd(), result.root) || "."}`);
24
+ console.log(`Next: cd ${path.relative(process.cwd(), result.root) || "."} && npm install && npm run dev`);
25
+ } else if (command === "build") {
26
+ const result = buildSite();
27
+ console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
28
+ } else if (command === "dev") {
29
+ const port = Number(process.env.PORT || 5173);
30
+ const distRoot = path.join(process.cwd(), "dist");
31
+ buildSite();
32
+ const clients = new Set();
33
+ let timer;
34
+
35
+ // Rebuild in a child process so changes to Darkmown's own src/ always load
36
+ // fresh modules — an in-process rebuild would reuse the stale import cache.
37
+ const rebuild = () => {
38
+ clearTimeout(timer);
39
+ timer = setTimeout(() => {
40
+ const started = performance.now();
41
+ execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
42
+ if (error) {
43
+ const message = (stderr || stdout || String(error)).trim();
44
+ console.error(message);
45
+ broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
46
+ return;
47
+ }
48
+ const elapsed = Math.round(performance.now() - started);
49
+ if (stderr.trim()) console.warn(stderr.trim());
50
+ broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
51
+ console.log(`${stdout.trim().split("\n").at(-1)} (${elapsed}ms)`);
52
+ });
53
+ }, 30);
54
+ };
55
+
56
+ for (const dir of ["site", "src"]) {
57
+ if (fs.existsSync(dir)) {
58
+ fs.watch(dir, { recursive: true }, rebuild);
59
+ }
60
+ }
61
+
62
+ const server = http.createServer((req, res) => {
63
+ try {
64
+ const url = req.url || "/";
65
+ if (url.split("?")[0] === devEventsPath) {
66
+ res.writeHead(200, {
67
+ "content-type": "text/event-stream",
68
+ "cache-control": "no-cache",
69
+ connection: "keep-alive"
70
+ });
71
+ res.write("\n");
72
+ clients.add(res);
73
+ req.on("close", () => clients.delete(res));
74
+ return;
75
+ }
76
+ if (url.split("?")[0] === devClientPath) {
77
+ res.writeHead(200, { "content-type": "text/javascript" });
78
+ res.end(devClientScript());
79
+ return;
80
+ }
81
+ if (url.split("?")[0] === "/__wd/echo" && req.method === "POST") {
82
+ let body = "";
83
+ req.on("data", (chunk) => (body += chunk));
84
+ req.on("end", () => {
85
+ res.writeHead(200, { "content-type": "application/json" });
86
+ res.end(JSON.stringify({
87
+ ok: true,
88
+ received: Object.fromEntries(new URLSearchParams(body)),
89
+ at: new Date().toISOString()
90
+ }));
91
+ });
92
+ return;
93
+ }
94
+ serveDev(distRoot, url, res);
95
+ } catch (error) {
96
+ res.writeHead(500, { "content-type": "text/plain" });
97
+ res.end(error.stack || String(error));
98
+ }
99
+ });
100
+ server.listen(port, () => {
101
+ console.log(`Darkmown dev server ready at http://localhost:${port}`);
102
+ console.log(`Live compiler watching site/ and src/`);
103
+ });
104
+ } else if (command === "serve") {
105
+ const port = Number(process.env.PORT || 4173);
106
+ const distRoot = path.join(process.cwd(), "dist");
107
+ if (!fs.existsSync(distRoot)) {
108
+ console.error("No dist directory found. Run `darkmown build` first.");
109
+ process.exit(1);
110
+ }
111
+ http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, () => {
112
+ console.log(`Darkmown preview of dist at http://localhost:${port}`);
113
+ });
114
+ } else {
115
+ console.error(`Unknown command: ${command}`);
116
+ printHelp();
117
+ process.exit(1);
118
+ }
119
+
120
+ function serveDev(distRoot, url, res) {
121
+ const file = resolvePublicFile(distRoot, url);
122
+ if (!file || !fs.existsSync(file)) {
123
+ serve(distRoot, url, res);
124
+ return;
125
+ }
126
+ if (file.endsWith(".html")) {
127
+ res.writeHead(200, { "content-type": "text/html" });
128
+ res.end(injectDevClient(fs.readFileSync(file, "utf8")));
129
+ return;
130
+ }
131
+ res.writeHead(200, { "content-type": contentType(file) });
132
+ fs.createReadStream(file).pipe(res);
133
+ }
134
+
135
+ function broadcast(clients, message) {
136
+ for (const client of clients) client.write(message);
137
+ }
138
+
139
+ function printHelp() {
140
+ console.log(`Darkmown
141
+
142
+ Usage:
143
+ darkmown init [dir] Scaffold a new Darkmown project
144
+ darkmown dev Start the live compiler dev server
145
+ darkmown build Compile site/pages into dist
146
+ darkmown serve Preview the built dist locally
147
+ darkmown help Show this help
148
+
149
+ Authoring:
150
+ site/pages File-based routes: .md stays plain, .wd adds directives
151
+ site/_ Include shelf for @include /name.wd
152
+ @loop x into item Loop over JSON files, in-scope values, or :state lists
153
+ *.skin Colocated indentation-based CSS
154
+ *.js Colocated page behavior
155
+ `);
156
+ }