@zvndev/darkmown 1.1.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 +9 -0
- package/README.md +54 -3
- package/docs/cli.md +46 -16
- package/docs/spec-alignment.md +9 -3
- package/package.json +1 -1
- 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 +21 -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/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 +14 -0
- package/src/compiler/context.js +12 -1
- package/src/compiler/directives.js +123 -0
- 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,
|
|
@@ -155,6 +157,13 @@ export function compileBody(lines, ctx) {
|
|
|
155
157
|
i = block.end;
|
|
156
158
|
continue;
|
|
157
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
|
+
}
|
|
158
167
|
if (/^:input\s/.test(line)) {
|
|
159
168
|
flush();
|
|
160
169
|
out.push(handleInput(line, ctx, i));
|
|
@@ -197,6 +206,11 @@ export function compileBody(lines, ctx) {
|
|
|
197
206
|
out.push(handleBind(line, ctx, i));
|
|
198
207
|
continue;
|
|
199
208
|
}
|
|
209
|
+
if (/^:slider\s/.test(line)) {
|
|
210
|
+
flush();
|
|
211
|
+
out.push(handleSlider(line, ctx, i));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
200
214
|
if (/^:submit\s/.test(line)) {
|
|
201
215
|
flush();
|
|
202
216
|
out.push(handleSubmit(line, ctx, i));
|
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: [],
|
|
@@ -578,6 +578,129 @@ export function handleBind(line, ctx, index) {
|
|
|
578
578
|
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/**
|
|
582
|
+
* `:slider name [= value] [min=N] [max=N] [step=N] [aria-label="…"] [persist]` — a
|
|
583
|
+
* range input two-way bound to a NUMBER :state. With `= value` it declares the state
|
|
584
|
+
* inline (seeding it numeric) and may `persist`; without `=` it binds to an already-
|
|
585
|
+
* declared :state. Pure compile-time: it reuses the runtime's input binding (range
|
|
586
|
+
* values coerce to Number), so it ships NO behavior module.
|
|
587
|
+
* @param {string} line
|
|
588
|
+
* @param {Ctx} ctx
|
|
589
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
590
|
+
* @returns {string}
|
|
591
|
+
*/
|
|
592
|
+
export function handleSlider(line, ctx, index) {
|
|
593
|
+
const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
594
|
+
if (!head)
|
|
595
|
+
throw new Error(
|
|
596
|
+
`Malformed :slider in ${at(ctx.file, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
|
|
597
|
+
);
|
|
598
|
+
const name = head[1];
|
|
599
|
+
let rest = head[2].trim();
|
|
600
|
+
|
|
601
|
+
/** @type {string | undefined} */
|
|
602
|
+
let initialRaw;
|
|
603
|
+
if (rest.startsWith("=")) {
|
|
604
|
+
const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
|
|
605
|
+
if (!valueMatch)
|
|
606
|
+
throw new Error(
|
|
607
|
+
`Malformed :slider initial value in ${at(ctx.file, index)}: ${line}. Use: :slider ${name} = 50`
|
|
608
|
+
);
|
|
609
|
+
initialRaw = valueMatch[1];
|
|
610
|
+
rest = valueMatch[2].trim();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let min = "0";
|
|
614
|
+
let max = "100";
|
|
615
|
+
let step = "1";
|
|
616
|
+
/** @type {string | undefined} */
|
|
617
|
+
let ariaLabel;
|
|
618
|
+
let persist = false;
|
|
619
|
+
for (const token of rest.matchAll(/([A-Za-z-]+)=("[^"]*"|\S+)|(\bpersist\b)/g)) {
|
|
620
|
+
if (token[3]) {
|
|
621
|
+
persist = true;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const value = stripQuotes(token[2]);
|
|
625
|
+
if (token[1] === "min") min = value;
|
|
626
|
+
else if (token[1] === "max") max = value;
|
|
627
|
+
else if (token[1] === "step") step = value;
|
|
628
|
+
else if (token[1] === "aria-label") ariaLabel = value;
|
|
629
|
+
else throw new Error(`Unknown :slider attribute "${token[1]}" in ${at(ctx.file, index)}`);
|
|
630
|
+
}
|
|
631
|
+
for (const [label, raw] of [
|
|
632
|
+
["min", min],
|
|
633
|
+
["max", max],
|
|
634
|
+
["step", step]
|
|
635
|
+
]) {
|
|
636
|
+
if (!/^-?\d+(?:\.\d+)?$/.test(raw))
|
|
637
|
+
throw new Error(`:slider ${label} must be a number in ${at(ctx.file, index)}: ${raw}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
ctx.comp.assets.runtime = true;
|
|
641
|
+
/** @type {string} */
|
|
642
|
+
let key;
|
|
643
|
+
let seed = "";
|
|
644
|
+
if (initialRaw !== undefined) {
|
|
645
|
+
const value = parseStateValue(initialRaw, at(ctx.file, index));
|
|
646
|
+
if (typeof value !== "number")
|
|
647
|
+
throw new Error(
|
|
648
|
+
`:slider ${name} initial value must be a number in ${at(ctx.file, index)}: ${initialRaw}`
|
|
649
|
+
);
|
|
650
|
+
key = declareState(name, value, ctx);
|
|
651
|
+
const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
|
|
652
|
+
seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
653
|
+
} else {
|
|
654
|
+
if (persist)
|
|
655
|
+
throw new Error(
|
|
656
|
+
`:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx.file, index)}`
|
|
657
|
+
);
|
|
658
|
+
const resolved = resolveStateKey(name, ctx);
|
|
659
|
+
if (!resolved)
|
|
660
|
+
throw new Error(
|
|
661
|
+
`:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`
|
|
662
|
+
);
|
|
663
|
+
key = resolved;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const initial = ctx.comp.state.get(key);
|
|
667
|
+
const valueAttr =
|
|
668
|
+
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
669
|
+
const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
|
|
670
|
+
return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* `:carousel [autoplay=N]` … `:endcarousel` — a horizontally scroll-snapping
|
|
675
|
+
* carousel. Contract: each DIRECT child element of the track is one slide, so put
|
|
676
|
+
* each slide in its own block (e.g. a `::: slide` container) and give that block
|
|
677
|
+
* the slide sizing in your skin — loose prose lines would each count as a slide.
|
|
678
|
+
* Registers the `carousel` behavior (prev/next, dot nav, optional autoplay, mouse
|
|
679
|
+
* drag); native CSS scroll-snap + the page skin handle layout and touch swipe.
|
|
680
|
+
* `autoplay` is suppressed under `prefers-reduced-motion`. No runtime required.
|
|
681
|
+
* @param {string} line
|
|
682
|
+
* @param {string[]} bodyLines
|
|
683
|
+
* @param {Ctx} ctx
|
|
684
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
685
|
+
* @returns {string}
|
|
686
|
+
*/
|
|
687
|
+
export function handleCarousel(line, bodyLines, ctx, index) {
|
|
688
|
+
const match = line.match(/^:carousel\s*(.*)$/);
|
|
689
|
+
const rest = (match?.[1] || "").trim();
|
|
690
|
+
let autoplayAttr = "";
|
|
691
|
+
if (rest) {
|
|
692
|
+
const auto = rest.match(/^autoplay=(\d+)$/);
|
|
693
|
+
if (!auto)
|
|
694
|
+
throw new Error(
|
|
695
|
+
`Malformed :carousel in ${at(ctx.file, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
|
|
696
|
+
);
|
|
697
|
+
autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
|
|
698
|
+
}
|
|
699
|
+
ctx.comp.assets.behaviors.add("carousel");
|
|
700
|
+
const inner = compileBody(bodyLines, ctx);
|
|
701
|
+
return `<div class="wd-carousel" data-wd-carousel${autoplayAttr}>\n<div class="wd-carousel-track" data-wd-carousel-track>\n${inner}\n</div>\n</div>`;
|
|
702
|
+
}
|
|
703
|
+
|
|
581
704
|
/**
|
|
582
705
|
* @param {string} line
|
|
583
706
|
* @param {Ctx} ctx
|