@zvndev/darkmown 1.0.0 → 1.2.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/CHANGELOG.md +25 -0
- package/README.md +64 -3
- package/docs/cli.md +46 -16
- package/docs/spec-alignment.md +16 -3
- package/package.json +2 -2
- package/site/_/roadmap.json +3 -1
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +2 -2
- package/site/pages/docs/index.wd +22 -0
- package/site/pages/folio/cart.wd +2 -2
- package/site/pages/forge/index.wd +1 -1
- package/site/pages/interactions/index.skin +113 -0
- package/site/pages/interactions/index.wd +86 -0
- package/site/pages/ledger/index.wd +10 -1
- package/src/api-runner.js +219 -0
- package/src/behaviors/carousel.js +153 -0
- package/src/behaviors/sortable.js +175 -0
- package/src/builder.js +199 -4
- package/src/cli.js +79 -54
- package/src/compiler/body.js +78 -3
- package/src/compiler/context.js +12 -1
- package/src/compiler/directives.js +123 -0
- package/src/compiler/interpolation.js +15 -14
- package/src/compiler/loops.js +57 -8
- package/src/compiler/page.js +9 -3
- package/src/deploy.js +174 -0
- package/src/headers.js +4 -0
- package/src/runtime.js +3 -1
- package/src/scaffold.js +79 -147
- package/src/templates/blog/README.md +25 -0
- package/src/templates/blog/site/_/nav.wd +6 -0
- package/src/templates/blog/site/_/posts.json +16 -0
- package/src/templates/blog/site/pages/index.skin +51 -0
- package/src/templates/blog/site/pages/index.wd +24 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
- package/src/templates/dashboard/README.md +25 -0
- package/src/templates/dashboard/api/metrics.js +15 -0
- package/src/templates/dashboard/site/_/nav.wd +6 -0
- package/src/templates/dashboard/site/pages/index.skin +74 -0
- package/src/templates/dashboard/site/pages/index.wd +75 -0
- package/src/templates/landing/README.md +25 -0
- package/src/templates/landing/site/_/nav.wd +6 -0
- package/src/templates/landing/site/pages/index.skin +140 -0
- package/src/templates/landing/site/pages/index.wd +54 -0
- package/src/templates/starter/README.md +32 -0
- package/src/templates/starter/site/_/nav.wd +7 -0
- package/src/templates/starter/site/pages/about.md +10 -0
- package/src/templates/starter/site/pages/index.skin +47 -0
- package/src/templates/starter/site/pages/index.wd +27 -0
- package/src/templates/store/README.md +28 -0
- package/src/templates/store/api/checkout.js +18 -0
- package/src/templates/store/site/_/nav.wd +6 -0
- package/src/templates/store/site/_/products.json +8 -0
- package/src/templates/store/site/pages/index.skin +100 -0
- package/src/templates/store/site/pages/index.wd +83 -0
package/src/builder.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { discoverApiRoutes } from "./api-runner.js";
|
|
4
5
|
import { compilePage } from "./compiler.js";
|
|
5
6
|
import { createPaths } from "./config.js";
|
|
6
7
|
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
@@ -24,10 +25,19 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Compile the whole site under `cwd` into `dist`, returning the route manifest.
|
|
28
|
+
*
|
|
29
|
+
* The build is always 100% static, CDN-cacheable HTML. Backend `api/*.js`
|
|
30
|
+
* functions are NOT compiled — they are the host's concern. `target` only
|
|
31
|
+
* decides the small amount of host glue Darkmown emits alongside the static
|
|
32
|
+
* output: the default (`vercel`/anything) leaves `api/` for the platform to pick
|
|
33
|
+
* up natively; `cloudflare` emits a `dist/_worker.js/` module that routes
|
|
34
|
+
* `/api/*` to those same functions and serves everything else as a static asset.
|
|
35
|
+
*
|
|
27
36
|
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
37
|
+
* @param {{ target?: string }} [options] `target: "cloudflare"` emits the Pages worker.
|
|
28
38
|
* @returns {{ routes: RouteManifestEntry[], distRoot: string }}
|
|
29
39
|
*/
|
|
30
|
-
export function buildSite(cwd = process.cwd()) {
|
|
40
|
+
export function buildSite(cwd = process.cwd(), options = {}) {
|
|
31
41
|
const paths = createPaths(cwd);
|
|
32
42
|
fs.rmSync(paths.distRoot, { recursive: true, force: true });
|
|
33
43
|
fs.mkdirSync(paths.distRoot, { recursive: true });
|
|
@@ -55,14 +65,18 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
55
65
|
if (route.route === "/404/") notFoundHtml = page.html;
|
|
56
66
|
emitAssets(page.assets, paths);
|
|
57
67
|
if (page.assets.runtime) emitRuntime(paths);
|
|
68
|
+
emitBehaviors(page.assets, paths);
|
|
69
|
+
const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
|
|
58
70
|
manifest.push({
|
|
59
71
|
route: route.route,
|
|
60
72
|
file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
|
|
61
73
|
assets: {
|
|
62
74
|
skins: [...page.assets.skins],
|
|
63
|
-
scripts:
|
|
64
|
-
? ["/__wd/runtime.js"
|
|
65
|
-
|
|
75
|
+
scripts: [
|
|
76
|
+
...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
|
|
77
|
+
...behaviorSrcs,
|
|
78
|
+
...page.assets.scripts
|
|
79
|
+
],
|
|
66
80
|
runtime: page.assets.runtime
|
|
67
81
|
}
|
|
68
82
|
});
|
|
@@ -85,9 +99,173 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
85
99
|
// sees every emitted route/framework file already on disk.
|
|
86
100
|
emitPageAssets(paths);
|
|
87
101
|
|
|
102
|
+
// Surface api/ routing footguns on every build (target-independent): two files
|
|
103
|
+
// that resolve to the same /api path mean one is silently unreachable.
|
|
104
|
+
const apiDir = path.join(cwd, "api");
|
|
105
|
+
const apiRoutes = discoverApiRoutes(apiDir);
|
|
106
|
+
warnApiRouteCollisions(apiRoutes, apiDir);
|
|
107
|
+
|
|
108
|
+
if (options.target === "cloudflare") {
|
|
109
|
+
emitCloudflareWorker(paths, apiDir, apiRoutes);
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
89
113
|
}
|
|
90
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Warn when more than one handler resolves to the same `/api` path, since only
|
|
117
|
+
* one is ever reachable and readdir order silently decides which. Two collisions
|
|
118
|
+
* are caught: an exact duplicate (`api/users.js` + `api/users/index.js`) and two
|
|
119
|
+
* dynamic siblings at the same position (`api/[id].js` + `api/[slug].js`), which
|
|
120
|
+
* is why dynamic segments are normalized to `[*]` before grouping.
|
|
121
|
+
* @param {import("./api-runner.js").ApiRoute[]} routes
|
|
122
|
+
* @param {string} apiDir
|
|
123
|
+
* @returns {void}
|
|
124
|
+
*/
|
|
125
|
+
function warnApiRouteCollisions(routes, apiDir) {
|
|
126
|
+
/** @type {Map<string, string[]>} */
|
|
127
|
+
const byPath = new Map();
|
|
128
|
+
for (const route of routes) {
|
|
129
|
+
const key = route.segments.map((s) => (s.startsWith("[") ? "[*]" : s)).join("/");
|
|
130
|
+
const list = byPath.get(key) || [];
|
|
131
|
+
list.push(path.relative(apiDir, route.file).replaceAll(path.sep, "/"));
|
|
132
|
+
byPath.set(key, list);
|
|
133
|
+
}
|
|
134
|
+
for (const [key, files] of byPath) {
|
|
135
|
+
if (files.length > 1) {
|
|
136
|
+
console.warn(
|
|
137
|
+
`hint: ${files.length} api handlers resolve to /api/${key} — only one is reachable. ` +
|
|
138
|
+
`Rename or remove: ${files.join(", ")}.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Emit `dist/_worker.js/` for a Cloudflare Pages deploy (advanced mode). The
|
|
146
|
+
* project's `api/*.js` functions are copied in, and a generated `index.js` routes
|
|
147
|
+
* `/api/*` to them (Web-standard `(request, { params, env }) => Response`) and
|
|
148
|
+
* falls through to `env.ASSETS.fetch(request)` for static files — so `_headers`
|
|
149
|
+
* still applies to assets. No-op when the project has no `api/` functions.
|
|
150
|
+
*
|
|
151
|
+
* Handlers must be dependency-free (or pre-bundled): advanced mode does not run a
|
|
152
|
+
* bundler, so bare npm imports won't resolve. We scan copied handlers and warn on
|
|
153
|
+
* any bare specifier. Same source still runs on Vercel (which does bundle) and in
|
|
154
|
+
* `darkmown dev` — the shape is identical.
|
|
155
|
+
* @param {Paths} paths
|
|
156
|
+
* @param {string} apiDir Absolute path to the project's `api/` directory.
|
|
157
|
+
* @param {import("./api-runner.js").ApiRoute[]} routes Discovered api routes.
|
|
158
|
+
* @returns {void}
|
|
159
|
+
*/
|
|
160
|
+
function emitCloudflareWorker(paths, apiDir, routes) {
|
|
161
|
+
if (routes.length === 0) return;
|
|
162
|
+
|
|
163
|
+
const workerDir = path.join(paths.distRoot, "_worker.js");
|
|
164
|
+
fs.mkdirSync(workerDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
/** @type {{ ident: string, spec: string, segments: string[] }[]} */
|
|
167
|
+
const entries = [];
|
|
168
|
+
routes.forEach((route, i) => {
|
|
169
|
+
const rel = path.relative(apiDir, route.file).replaceAll(path.sep, "/");
|
|
170
|
+
const out = path.join(workerDir, "api", rel);
|
|
171
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
172
|
+
const source = fs.readFileSync(route.file, "utf8");
|
|
173
|
+
warnBareImports(source, `api/${rel}`);
|
|
174
|
+
fs.writeFileSync(out, source);
|
|
175
|
+
entries.push({ ident: `h${i}`, spec: `./api/${rel}`, segments: route.segments });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const imports = entries
|
|
179
|
+
.map((e) => `import ${e.ident} from ${JSON.stringify(e.spec)};`)
|
|
180
|
+
.join("\n");
|
|
181
|
+
const table = entries
|
|
182
|
+
.map((e) => ` { segments: ${JSON.stringify(e.segments)}, handler: ${e.ident} }`)
|
|
183
|
+
.join(",\n");
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(path.join(workerDir, "index.js"), cloudflareWorkerSource(imports, table));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Warn when a Cloudflare-bound handler imports a bare npm package. Advanced mode
|
|
190
|
+
* runs no bundler, so only relative (`./`, `../`) and `node:` specifiers resolve
|
|
191
|
+
* at the edge; a bare `import x from "some-pkg"` would fail at runtime with no
|
|
192
|
+
* build-time signal. Advisory only — relative/`node:`/data imports pass clean.
|
|
193
|
+
* @param {string} source Handler source.
|
|
194
|
+
* @param {string} label Human-readable handler path for the message.
|
|
195
|
+
* @returns {void}
|
|
196
|
+
*/
|
|
197
|
+
function warnBareImports(source, label) {
|
|
198
|
+
/** @type {Set<string>} */
|
|
199
|
+
const bare = new Set();
|
|
200
|
+
const re =
|
|
201
|
+
/(?:^|[^.\w])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]|(?:^|[^.\w])import\s*\(?\s*['"]([^'"]+)['"]/g;
|
|
202
|
+
for (let m = re.exec(source); m; m = re.exec(source)) {
|
|
203
|
+
const spec = m[1] || m[2];
|
|
204
|
+
if (spec && !/^[./]/.test(spec) && !spec.startsWith("node:")) bare.add(spec);
|
|
205
|
+
}
|
|
206
|
+
if (bare.size > 0) {
|
|
207
|
+
console.warn(
|
|
208
|
+
`hint: ${label} imports ${[...bare].map((s) => `"${s}"`).join(", ")} — Cloudflare ` +
|
|
209
|
+
"advanced mode runs no bundler, so bare npm imports won't resolve at the edge. " +
|
|
210
|
+
"Inline the dependency or pre-bundle the handler."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The generated Cloudflare Pages `_worker.js/index.js` source. Mirrors the
|
|
217
|
+
* dev runner's matching (static beats dynamic; `[param]` capture; `index`
|
|
218
|
+
* collapses to its directory) so routing is identical across dev and prod.
|
|
219
|
+
* @param {string} imports
|
|
220
|
+
* @param {string} table
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
function cloudflareWorkerSource(imports, table) {
|
|
224
|
+
return `// Generated by Darkmown for Cloudflare Pages (advanced mode). Routes /api/* to
|
|
225
|
+
// the project's api functions; every other request is a static asset.
|
|
226
|
+
${imports}
|
|
227
|
+
|
|
228
|
+
const routes = [
|
|
229
|
+
${table}
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
function match(pathname) {
|
|
233
|
+
if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
|
|
234
|
+
const parts = pathname.split("/").filter(Boolean).slice(1);
|
|
235
|
+
for (const route of routes) {
|
|
236
|
+
if (route.segments.length !== parts.length) continue;
|
|
237
|
+
const params = {};
|
|
238
|
+
let ok = true;
|
|
239
|
+
for (let i = 0; i < parts.length; i++) {
|
|
240
|
+
const seg = route.segments[i];
|
|
241
|
+
if (seg[0] === "[" && seg[seg.length - 1] === "]") {
|
|
242
|
+
params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
|
|
243
|
+
} else if (seg !== parts[i]) {
|
|
244
|
+
ok = false;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (ok) return { handler: route.handler, params };
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default {
|
|
254
|
+
async fetch(request, env, ctx) {
|
|
255
|
+
const matched = match(new URL(request.url).pathname);
|
|
256
|
+
if (!matched) return env.ASSETS.fetch(request);
|
|
257
|
+
try {
|
|
258
|
+
const response = await matched.handler(request, { params: matched.params, env });
|
|
259
|
+
if (response instanceof Response) return response;
|
|
260
|
+
return Response.json({ ok: false, error: "Handler did not return a Response" }, { status: 500 });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return Response.json({ ok: false, error: String((err && err.message) || err) }, { status: 500 });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
`;
|
|
267
|
+
}
|
|
268
|
+
|
|
91
269
|
/**
|
|
92
270
|
* Strip developer comments from the runtime before it ships. The source keeps
|
|
93
271
|
* full JSDoc type annotations (for `checkJs` + `.d.ts`) and explanatory comments
|
|
@@ -192,6 +370,23 @@ function emitRuntime(paths) {
|
|
|
192
370
|
fs.writeFileSync(out, stripRuntimeComments(source));
|
|
193
371
|
}
|
|
194
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Emit the pay-for-what-you-use behavior modules a page asked for, comment-
|
|
375
|
+
* stripped like the runtime. Each is a standalone `/__wd/behaviors/<name>.js`
|
|
376
|
+
* module, NOT part of `runtime.js`, so the core runtime budget is untouched.
|
|
377
|
+
* @param {Assets} assets
|
|
378
|
+
* @param {Paths} paths
|
|
379
|
+
* @returns {void}
|
|
380
|
+
*/
|
|
381
|
+
function emitBehaviors(assets, paths) {
|
|
382
|
+
for (const name of assets.behaviors) {
|
|
383
|
+
const source = fs.readFileSync(path.join(moduleDir, "behaviors", `${name}.js`), "utf8");
|
|
384
|
+
const out = path.join(paths.distRoot, "__wd/behaviors", `${name}.js`);
|
|
385
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
386
|
+
fs.writeFileSync(out, stripRuntimeComments(source));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
195
390
|
/**
|
|
196
391
|
* @param {Assets} assets
|
|
197
392
|
* @param {Paths} paths
|
package/src/cli.js
CHANGED
|
@@ -4,9 +4,11 @@ import fs from "node:fs";
|
|
|
4
4
|
import http from "node:http";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { handleApiRequest } from "./api-runner.js";
|
|
7
8
|
import { buildSite } from "./builder.js";
|
|
9
|
+
import { DEPLOY_TARGETS, deploy } from "./deploy.js";
|
|
8
10
|
import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
|
|
9
|
-
import { initProject } from "./scaffold.js";
|
|
11
|
+
import { availableTemplates, initProject } from "./scaffold.js";
|
|
10
12
|
import { contentType, resolvePublicFile, serve } from "./statics.js";
|
|
11
13
|
|
|
12
14
|
const cliPath = fileURLToPath(import.meta.url);
|
|
@@ -27,6 +29,7 @@ const cliPath = fileURLToPath(import.meta.url);
|
|
|
27
29
|
* @property {(message?: unknown, ...rest: unknown[]) => void} [log] stdout sink.
|
|
28
30
|
* @property {(message?: unknown, ...rest: unknown[]) => void} [warn] stderr (warn) sink.
|
|
29
31
|
* @property {(message?: unknown, ...rest: unknown[]) => void} [error] stderr (error) sink.
|
|
32
|
+
* @property {(file: string, args: string[], options: object) => Promise<{ code: number, stdout: string, stderr: string }>} [spawn] Process runner for `deploy` (defaults to a real spawn); injectable for tests.
|
|
30
33
|
*
|
|
31
34
|
* @param {string[]} argv The argv tail (i.e. `process.argv.slice(2)`).
|
|
32
35
|
* @param {RunEnv} [env]
|
|
@@ -51,16 +54,20 @@ export async function run(argv, env = {}) {
|
|
|
51
54
|
return { command: "version" };
|
|
52
55
|
}
|
|
53
56
|
if (command === "init") {
|
|
54
|
-
const target = argv
|
|
55
|
-
const
|
|
57
|
+
const target = argv.slice(1).find((arg) => !arg.startsWith("-")) || ".";
|
|
58
|
+
const template = flagValue(argv, "--template");
|
|
59
|
+
const result = initProject(path.resolve(cwd, target), { template });
|
|
56
60
|
const relativeRoot = path.relative(cwd, result.root) || ".";
|
|
57
|
-
log(`Created Darkmown project at ${relativeRoot}`);
|
|
61
|
+
log(`Created Darkmown project (${result.template}) at ${relativeRoot}`);
|
|
58
62
|
log(`Next: ${nextStep(relativeRoot)}`);
|
|
59
63
|
return { command: "init" };
|
|
60
64
|
}
|
|
61
65
|
if (command === "build") {
|
|
62
|
-
const
|
|
66
|
+
const target = flagValue(argv, "--target");
|
|
67
|
+
const result = buildSite(cwd, { target });
|
|
63
68
|
log(`Built ${result.routes.length} routes into ${path.relative(cwd, result.distRoot)}`);
|
|
69
|
+
if (target === "cloudflare")
|
|
70
|
+
log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
|
|
64
71
|
return { command: "build" };
|
|
65
72
|
}
|
|
66
73
|
if (command === "dev") {
|
|
@@ -69,6 +76,15 @@ export async function run(argv, env = {}) {
|
|
|
69
76
|
if (command === "serve") {
|
|
70
77
|
return startPreviewServer({ cwd, log, error });
|
|
71
78
|
}
|
|
79
|
+
if (command === "deploy") {
|
|
80
|
+
const target = argv.slice(1).find((arg) => !arg.startsWith("-"));
|
|
81
|
+
if (!target) {
|
|
82
|
+
error(`Usage: darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod]`);
|
|
83
|
+
throw new CliError("Missing deploy target", { silent: true });
|
|
84
|
+
}
|
|
85
|
+
await deploy({ cwd, target, prod: argv.includes("--prod"), log, run: env.spawn });
|
|
86
|
+
return { command: "deploy" };
|
|
87
|
+
}
|
|
72
88
|
// No command matched: report it, show help, and fail like the binary always
|
|
73
89
|
// did (exit 1). `silent` keeps `main` from re-printing a `✗` line on top.
|
|
74
90
|
error(`Unknown command: ${command}`);
|
|
@@ -157,52 +173,43 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
const apiDir = path.join(cwd, "api");
|
|
160
177
|
const server = http.createServer((req, res) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
req.on("close", () => clients.delete(res));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (url.split("?")[0] === devClientPath) {
|
|
182
|
-
res.writeHead(200, { "content-type": "text/javascript" });
|
|
183
|
-
res.end(devClientScript());
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (url.split("?")[0] === "/__wd/echo" && req.method === "POST") {
|
|
187
|
-
let body = "";
|
|
188
|
-
req.on("data", (chunk) => (body += chunk));
|
|
189
|
-
req.on("end", () => {
|
|
190
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
191
|
-
res.end(
|
|
192
|
-
JSON.stringify({
|
|
193
|
-
ok: true,
|
|
194
|
-
received: Object.fromEntries(new URLSearchParams(body)),
|
|
195
|
-
at: new Date().toISOString()
|
|
196
|
-
})
|
|
197
|
-
);
|
|
198
|
-
});
|
|
199
|
-
return;
|
|
178
|
+
const url = req.url || "/";
|
|
179
|
+
if (url.split("?")[0] === devEventsPath) {
|
|
180
|
+
res.writeHead(200, {
|
|
181
|
+
"content-type": "text/event-stream",
|
|
182
|
+
"cache-control": "no-cache",
|
|
183
|
+
connection: "keep-alive"
|
|
184
|
+
});
|
|
185
|
+
res.write("\n");
|
|
186
|
+
clients.add(res);
|
|
187
|
+
// Replay a broken build to clients that connect while it's still failing,
|
|
188
|
+
// so the overlay shows even after a startup or pre-connect compile error.
|
|
189
|
+
if (lastBuildError) {
|
|
190
|
+
res.write(
|
|
191
|
+
`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`
|
|
192
|
+
);
|
|
200
193
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
res.writeHead(500, { "content-type": "text/plain" });
|
|
204
|
-
res.end((err instanceof Error && err.stack) || String(err));
|
|
194
|
+
req.on("close", () => clients.delete(res));
|
|
195
|
+
return;
|
|
205
196
|
}
|
|
197
|
+
if (url.split("?")[0] === devClientPath) {
|
|
198
|
+
res.writeHead(200, { "content-type": "text/javascript" });
|
|
199
|
+
res.end(devClientScript());
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Serverless parity: run a project `api/*.js` function if the path matches,
|
|
203
|
+
// exactly as Vercel/Cloudflare would in production; otherwise serve static. A
|
|
204
|
+
// throw on either path (e.g. a stat/read error) becomes a clean 500.
|
|
205
|
+
handleApiRequest({ apiDir, req, res })
|
|
206
|
+
.then((handled) => {
|
|
207
|
+
if (!handled) serveDev(distRoot, url, res);
|
|
208
|
+
})
|
|
209
|
+
.catch((err) => {
|
|
210
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
211
|
+
res.end((err instanceof Error && err.stack) || String(err));
|
|
212
|
+
});
|
|
206
213
|
});
|
|
207
214
|
|
|
208
215
|
return new Promise((resolve) => {
|
|
@@ -293,16 +300,20 @@ export function helpText() {
|
|
|
293
300
|
return `Darkmown
|
|
294
301
|
|
|
295
302
|
Usage:
|
|
296
|
-
darkmown init [dir] Scaffold a new
|
|
297
|
-
darkmown dev
|
|
298
|
-
darkmown build
|
|
299
|
-
darkmown
|
|
300
|
-
darkmown
|
|
301
|
-
darkmown
|
|
303
|
+
darkmown init [dir] [--template <name>] Scaffold a new project from a template
|
|
304
|
+
darkmown dev Start the live compiler dev server
|
|
305
|
+
darkmown build [--target cloudflare] Compile site/pages into dist
|
|
306
|
+
darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
|
|
307
|
+
darkmown serve Preview the built dist locally
|
|
308
|
+
darkmown version Print the installed Darkmown version
|
|
309
|
+
darkmown help Show this help
|
|
310
|
+
|
|
311
|
+
Templates (init --template <name>): ${availableTemplates().join(", ")}
|
|
302
312
|
|
|
303
313
|
Authoring:
|
|
304
314
|
site/pages File-based routes: .md stays plain, .wd adds directives
|
|
305
315
|
site/_ Include shelf for @include /name.wd
|
|
316
|
+
api/ Serverless functions: export default (request) => Response
|
|
306
317
|
@loop x into item Loop over JSON files, in-scope values, or :state lists
|
|
307
318
|
*.skin Colocated indentation-based CSS
|
|
308
319
|
*.js Colocated page behavior
|
|
@@ -318,6 +329,20 @@ export function nextStep(relativeRoot) {
|
|
|
318
329
|
return `cd ${relativeRoot} && npm install && npm run dev`;
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Read a `--flag value` or `--flag=value` option out of an argv tail.
|
|
334
|
+
* @param {string[]} argv The argv tail.
|
|
335
|
+
* @param {string} flag The flag name, e.g. `"--target"`.
|
|
336
|
+
* @returns {string | undefined} The value, or undefined when the flag is absent.
|
|
337
|
+
*/
|
|
338
|
+
export function flagValue(argv, flag) {
|
|
339
|
+
const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
|
|
340
|
+
if (inline) return inline.slice(flag.length + 1);
|
|
341
|
+
const index = argv.indexOf(flag);
|
|
342
|
+
if (index !== -1 && index + 1 < argv.length) return argv[index + 1];
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
321
346
|
/**
|
|
322
347
|
* Binary entry point: read argv, dispatch, and map failures to a clean
|
|
323
348
|
* `✗`-prefixed line + exit code 1 — never a Node stack trace. The thrown Error
|
package/src/compiler/body.js
CHANGED
|
@@ -11,6 +11,7 @@ import { at } from "./context.js";
|
|
|
11
11
|
import {
|
|
12
12
|
handleBind,
|
|
13
13
|
handleButton,
|
|
14
|
+
handleCarousel,
|
|
14
15
|
handleChoiceGroup,
|
|
15
16
|
handleComputed,
|
|
16
17
|
handleContainer,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
handleInput,
|
|
25
26
|
handleMedia,
|
|
26
27
|
handleSelect,
|
|
28
|
+
handleSlider,
|
|
27
29
|
handleState,
|
|
28
30
|
handleStore,
|
|
29
31
|
handleSubmit,
|
|
@@ -98,12 +100,16 @@ export function compileBody(lines, ctx) {
|
|
|
98
100
|
}
|
|
99
101
|
if (/^:state\s/.test(line)) {
|
|
100
102
|
flush();
|
|
101
|
-
|
|
103
|
+
const v = joinValueDirective(lines, i);
|
|
104
|
+
out.push(handleState(v.line, ctx, i));
|
|
105
|
+
i = v.end;
|
|
102
106
|
continue;
|
|
103
107
|
}
|
|
104
108
|
if (/^:store\s/.test(line)) {
|
|
105
109
|
flush();
|
|
106
|
-
|
|
110
|
+
const v = joinValueDirective(lines, i);
|
|
111
|
+
out.push(handleStore(v.line, ctx, i));
|
|
112
|
+
i = v.end;
|
|
107
113
|
continue;
|
|
108
114
|
}
|
|
109
115
|
if (/^:fetch\s/.test(line)) {
|
|
@@ -128,7 +134,9 @@ export function compileBody(lines, ctx) {
|
|
|
128
134
|
}
|
|
129
135
|
if (/^:theme(?:\s|$)/.test(line)) {
|
|
130
136
|
flush();
|
|
131
|
-
|
|
137
|
+
const v = joinValueDirective(lines, i);
|
|
138
|
+
out.push(handleTheme(v.line, ctx, i));
|
|
139
|
+
i = v.end;
|
|
132
140
|
continue;
|
|
133
141
|
}
|
|
134
142
|
const media = line.match(/^:(video|audio)\s/);
|
|
@@ -149,6 +157,13 @@ export function compileBody(lines, ctx) {
|
|
|
149
157
|
i = block.end;
|
|
150
158
|
continue;
|
|
151
159
|
}
|
|
160
|
+
if (/^:carousel(?:\s|$)/.test(line)) {
|
|
161
|
+
flush();
|
|
162
|
+
const block = scanBlock(lines, i, /^:carousel(?:\s|$)/, ":endcarousel", ctx.file);
|
|
163
|
+
out.push(handleCarousel(line, block.body, ctx, i));
|
|
164
|
+
i = block.end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
152
167
|
if (/^:input\s/.test(line)) {
|
|
153
168
|
flush();
|
|
154
169
|
out.push(handleInput(line, ctx, i));
|
|
@@ -191,6 +206,11 @@ export function compileBody(lines, ctx) {
|
|
|
191
206
|
out.push(handleBind(line, ctx, i));
|
|
192
207
|
continue;
|
|
193
208
|
}
|
|
209
|
+
if (/^:slider\s/.test(line)) {
|
|
210
|
+
flush();
|
|
211
|
+
out.push(handleSlider(line, ctx, i));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
194
214
|
if (/^:submit\s/.test(line)) {
|
|
195
215
|
flush();
|
|
196
216
|
out.push(handleSubmit(line, ctx, i));
|
|
@@ -261,6 +281,61 @@ function warnUnknownDirective(line, ctx) {
|
|
|
261
281
|
}
|
|
262
282
|
}
|
|
263
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Net bracket depth of a string, ignoring brackets inside JSON string literals.
|
|
286
|
+
* Positive means more openers (`[`/`{`) than closers — the value is unbalanced.
|
|
287
|
+
* @param {string} s
|
|
288
|
+
* @returns {number}
|
|
289
|
+
*/
|
|
290
|
+
function bracketDepth(s) {
|
|
291
|
+
let depth = 0;
|
|
292
|
+
let inStr = false;
|
|
293
|
+
let esc = false;
|
|
294
|
+
for (const ch of s) {
|
|
295
|
+
if (inStr) {
|
|
296
|
+
if (esc) esc = false;
|
|
297
|
+
else if (ch === "\\") esc = true;
|
|
298
|
+
else if (ch === '"') inStr = false;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ch === '"') inStr = true;
|
|
302
|
+
else if (ch === "[" || ch === "{") depth++;
|
|
303
|
+
else if (ch === "]" || ch === "}") depth--;
|
|
304
|
+
}
|
|
305
|
+
return depth;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `:state`/`:store`/`:theme` take a single-line value, but a JSON array/object
|
|
310
|
+
* seed reads far better across several lines. When the value after `=` opens an
|
|
311
|
+
* unbalanced `[`/`{` on the directive line, gather the contiguous continuation
|
|
312
|
+
* lines (stopping at a blank line, which delimits the literal) until the brackets
|
|
313
|
+
* balance, then collapse them into one physical line the existing single-line
|
|
314
|
+
* handler parses unchanged — JSON ignores the inter-token whitespace. A literal
|
|
315
|
+
* that never balances falls through as-is, so `parseStateValue` reports it.
|
|
316
|
+
* @param {string[]} lines
|
|
317
|
+
* @param {number} start Index of the directive line.
|
|
318
|
+
* @returns {{ line: string, end: number }} `end` = last consumed line index.
|
|
319
|
+
*/
|
|
320
|
+
function joinValueDirective(lines, start) {
|
|
321
|
+
const opener = lines[start];
|
|
322
|
+
const eq = opener.indexOf("=");
|
|
323
|
+
if (eq === -1) return { line: opener, end: start };
|
|
324
|
+
const value = opener.slice(eq + 1);
|
|
325
|
+
// Only a JSON array/object literal can span lines; bare scalars/strings cannot.
|
|
326
|
+
if (!/^\s*[[{]/.test(value)) return { line: opener, end: start };
|
|
327
|
+
let depth = bracketDepth(value);
|
|
328
|
+
if (depth <= 0) return { line: opener, end: start }; // already balanced on one line
|
|
329
|
+
const parts = [opener];
|
|
330
|
+
let i = start;
|
|
331
|
+
while (depth > 0 && i + 1 < lines.length && lines[i + 1].trim() !== "") {
|
|
332
|
+
i++;
|
|
333
|
+
parts.push(lines[i]);
|
|
334
|
+
depth += bracketDepth(lines[i]);
|
|
335
|
+
}
|
|
336
|
+
return { line: parts.join(" "), end: i };
|
|
337
|
+
}
|
|
338
|
+
|
|
264
339
|
/**
|
|
265
340
|
* Capture lines from an opener until a matching close token, honoring nesting and fences.
|
|
266
341
|
* @param {string[]} lines
|
package/src/compiler/context.js
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* @property {Set<string>} scripts Public hrefs of colocated page scripts.
|
|
30
30
|
* @property {Map<string, string>} files Source path → public href for emitted assets.
|
|
31
31
|
* @property {boolean} runtime Whether the reactive runtime is required.
|
|
32
|
+
* @property {Set<string>} behaviors Names of framework behavior modules this page
|
|
33
|
+
* needs (`slider` is compile-time only and never appears here; `sortable`,
|
|
34
|
+
* `carousel` each emit a pay-for-what-you-use `/__wd/behaviors/<name>.js`).
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -103,6 +106,8 @@
|
|
|
103
106
|
* @property {NumArg | null} limit
|
|
104
107
|
* @property {string[] | null} empty Empty-branch body lines, if any.
|
|
105
108
|
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
109
|
+
* @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
|
|
110
|
+
* (the `sortable` clause). Only valid on a plain reactive state-key loop.
|
|
106
111
|
*/
|
|
107
112
|
|
|
108
113
|
/**
|
|
@@ -130,7 +135,13 @@ export const LOOP_META = {
|
|
|
130
135
|
/** @returns {Compilation} */
|
|
131
136
|
export function createCompilation() {
|
|
132
137
|
return {
|
|
133
|
-
assets: {
|
|
138
|
+
assets: {
|
|
139
|
+
skins: new Set(),
|
|
140
|
+
scripts: new Set(),
|
|
141
|
+
files: new Map(),
|
|
142
|
+
runtime: false,
|
|
143
|
+
behaviors: new Set()
|
|
144
|
+
},
|
|
134
145
|
state: new Map(),
|
|
135
146
|
stores: new Set(),
|
|
136
147
|
warnings: [],
|