@markdy/cli 0.7.15

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hoang Yell (https://hoangyell.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @markdy/cli
2
+
3
+ First-party command-line tooling for MarkdyScript.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -D @markdy/cli
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ markdy # launch a local browser playground on http://127.0.0.1:4242
15
+ markdy lint scene.markdy
16
+ markdy fmt scene.markdy
17
+ markdy fmt scene.markdy --write
18
+ markdy render scene.markdy --out dist/scene.html
19
+ markdy explain scene.markdy
20
+ markdy new explainer demo.markdy
21
+ markdy docs
22
+ markdy ai
23
+ markdy check-all .
24
+ ```
25
+
26
+ ## Notes
27
+
28
+ - The CLI resolves `import "file.markdy" as ns` from disk before parsing.
29
+ - `fmt` prints a canonicalized scene and may expand higher-level sugar into its parsed form.
30
+ - `render --out` writes a self-contained HTML preview you can open in a browser.
@@ -0,0 +1,24 @@
1
+ import { SceneAST } from '@markdy/core';
2
+ import { Server } from 'node:http';
3
+
4
+ interface CliIo {
5
+ stdout(message: string): void;
6
+ stderr(message: string): void;
7
+ }
8
+ interface CliRuntime {
9
+ openBrowser(url: string): Promise<void>;
10
+ }
11
+ interface RunResult {
12
+ exitCode: number;
13
+ server?: Server;
14
+ }
15
+ type LoadedScene = {
16
+ filePath: string;
17
+ source: string;
18
+ ast: SceneAST;
19
+ imports: Record<string, SceneAST>;
20
+ };
21
+ declare function runCli(argv: string[], io?: CliIo, runtime?: CliRuntime): Promise<RunResult>;
22
+ declare function buildStandaloneHtml(scene: LoadedScene): Promise<string>;
23
+
24
+ export { type CliIo, type CliRuntime, type RunResult, buildStandaloneHtml, runCli };
package/dist/index.js ADDED
@@ -0,0 +1,940 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { PRESETS, PRESET_NAMES, parse } from "@markdy/core";
5
+ import { createRequire } from "module";
6
+ import { basename, dirname, extname, join, resolve } from "path";
7
+ import { fileURLToPath, pathToFileURL } from "url";
8
+ import { createServer } from "http";
9
+ import { readdir, readFile, stat, writeFile } from "fs/promises";
10
+ import { spawn } from "child_process";
11
+
12
+ // src/format.ts
13
+ var BARE_TOKEN_RE = /^[\p{L}\p{N}_.$#/+:-]+$/u;
14
+ var MODIFIER_KEYS = ["scale", "rotate", "opacity", "size", "z"];
15
+ function formatScene(ast) {
16
+ const sections = [];
17
+ sections.push(formatSceneHeader(ast));
18
+ const vars = Object.entries(ast.vars);
19
+ if (vars.length > 0) {
20
+ sections.push(vars.map(([name, value]) => `var ${name} = ${value}`).join("\n"));
21
+ }
22
+ if (ast.imports.length > 0) {
23
+ sections.push(ast.imports.map(formatImportDecl).join("\n"));
24
+ }
25
+ const assets = Object.entries(ast.assets);
26
+ if (assets.length > 0) {
27
+ sections.push(
28
+ assets.map(([name, def]) => `asset ${name} = ${def.type}(${formatToken(def.value)})`).join("\n")
29
+ );
30
+ }
31
+ const actors = Object.entries(ast.actors);
32
+ if (actors.length > 0) {
33
+ sections.push(actors.map(([name, actor]) => formatActorDecl(name, actor)).join("\n"));
34
+ }
35
+ const defs = Object.entries(ast.defs);
36
+ if (defs.length > 0) {
37
+ sections.push(defs.map(([name, def]) => formatTemplateDef(name, def)).join("\n\n"));
38
+ }
39
+ const seqs = Object.entries(ast.seqs);
40
+ if (seqs.length > 0) {
41
+ sections.push(seqs.map(([name, seq]) => formatSequenceDef(name, seq)).join("\n\n"));
42
+ }
43
+ const timeline = formatTimeline(ast);
44
+ if (timeline) {
45
+ sections.push(timeline);
46
+ }
47
+ return `${sections.filter(Boolean).join("\n\n")}
48
+ `;
49
+ }
50
+ function formatSceneHeader(ast) {
51
+ const parts = [
52
+ `scene width=${formatNumber(ast.meta.width)}`,
53
+ `height=${formatNumber(ast.meta.height)}`,
54
+ `fps=${formatNumber(ast.meta.fps)}`,
55
+ `bg=${ast.meta.bg}`
56
+ ];
57
+ if (ast.meta.duration !== void 0) {
58
+ parts.push(`duration=${formatNumber(ast.meta.duration)}`);
59
+ }
60
+ return parts.join(" ");
61
+ }
62
+ function formatImportDecl(decl) {
63
+ return `import ${JSON.stringify(decl.path)} as ${decl.namespace}`;
64
+ }
65
+ function formatActorDecl(name, actor) {
66
+ const ctor = `${actor.type}(${actor.args.map(formatToken).join(", ")})`;
67
+ const position = actor.anchor ? `at ${actor.anchor}` : `at (${formatNumber(actor.x)}, ${formatNumber(actor.y)})`;
68
+ const modifiers = MODIFIER_KEYS.flatMap((key) => {
69
+ const value = actor[key];
70
+ return value === void 0 ? [] : [`${key}=${formatNumber(value)}`];
71
+ }).join(", ");
72
+ return modifiers.length > 0 ? `actor ${name} = ${ctor} ${position} with ${modifiers}` : `actor ${name} = ${ctor} ${position}`;
73
+ }
74
+ function formatTemplateDef(name, def) {
75
+ const args = def.bodyArgs.map(formatTemplateArg).join(", ");
76
+ return `def ${name}(${def.params.join(", ")}) {
77
+ ${def.actorType}(${args})
78
+ }`;
79
+ }
80
+ function formatTemplateArg(arg) {
81
+ return arg.startsWith("${") && arg.endsWith("}") ? arg : formatToken(arg);
82
+ }
83
+ function formatSequenceDef(name, seq) {
84
+ const lines = seq.events.map((event) => {
85
+ const params = event.paramsRaw.trim();
86
+ const suffix = params ? `(${params})` : "()";
87
+ return ` @+${formatNumber(event.offset)}: $.${event.action}${suffix}`;
88
+ });
89
+ return `seq ${name}(${seq.params.join(", ")}) {
90
+ ${lines.join("\n")}
91
+ }`;
92
+ }
93
+ function formatTimeline(ast) {
94
+ if (ast.events.length === 0 && ast.chapters.length === 0) {
95
+ return "";
96
+ }
97
+ const blocks = [];
98
+ const topLevelEvents = ast.events.filter((event) => event.chapter === void 0);
99
+ for (const event of topLevelEvents) {
100
+ blocks.push({ line: event.line, text: formatEvent(event) });
101
+ }
102
+ const chaptersByLine = [...ast.chapters].sort((a, b) => a.startLine - b.startLine);
103
+ for (let index = 0; index < chaptersByLine.length; index++) {
104
+ const chapter = chaptersByLine[index];
105
+ const nextStartLine = chaptersByLine[index + 1]?.startLine ?? Number.POSITIVE_INFINITY;
106
+ const events = ast.events.filter(
107
+ (event) => event.chapter === chapter.name && event.line > chapter.startLine && event.line < nextStartLine
108
+ ).sort((a, b) => a.line - b.line);
109
+ blocks.push({ line: chapter.startLine, text: formatChapter(chapter, events) });
110
+ }
111
+ return blocks.sort((a, b) => a.line - b.line).map((block) => block.text).join("\n\n");
112
+ }
113
+ function formatChapter(chapter, events) {
114
+ const body = events.length > 0 ? `
115
+ ${events.map((event) => ` ${formatEvent(event)}`).join("\n")}
116
+ ` : "\n";
117
+ return `scene ${JSON.stringify(chapter.name)} {${body}}`;
118
+ }
119
+ function formatEvent(event) {
120
+ const params = Object.entries(event.params).map(([key, value]) => `${key}=${formatParamValue(value)}`).join(", ");
121
+ return `@${formatNumber(event.time)}: ${event.actor}.${event.action}(${params})`;
122
+ }
123
+ function formatParamValue(value) {
124
+ if (Array.isArray(value)) {
125
+ return `(${value.map(formatTupleValue).join(", ")})`;
126
+ }
127
+ if (typeof value === "number") {
128
+ return formatNumber(value);
129
+ }
130
+ if (typeof value === "string") {
131
+ return formatToken(value);
132
+ }
133
+ if (typeof value === "boolean") {
134
+ return value ? "true" : "false";
135
+ }
136
+ return JSON.stringify(value);
137
+ }
138
+ function formatTupleValue(value) {
139
+ if (typeof value === "number") return formatNumber(value);
140
+ if (typeof value === "string") return formatToken(value);
141
+ return JSON.stringify(value);
142
+ }
143
+ function formatToken(value) {
144
+ return BARE_TOKEN_RE.test(value) ? value : JSON.stringify(value);
145
+ }
146
+ function formatNumber(value) {
147
+ return Number.isInteger(value) ? String(value) : String(round3(value));
148
+ }
149
+ function round3(value) {
150
+ return Math.round(value * 1e3) / 1e3;
151
+ }
152
+
153
+ // src/index.ts
154
+ var DEFAULT_PORT = 4242;
155
+ var IMPORT_RE = /^import\s+"([^"]+)"\s+as\s+(\w+)\s*$/;
156
+ var MARKDY_EXT = ".markdy";
157
+ var PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
158
+ async function runCli(argv, io = defaultIo(), runtime = defaultRuntime()) {
159
+ const parsed = parseArgv(argv);
160
+ if (!parsed.command && hasFlag(parsed, "help")) {
161
+ io.stdout(helpText());
162
+ return { exitCode: 0 };
163
+ }
164
+ if (!parsed.command) {
165
+ return launchPlayground(parsed, io, runtime);
166
+ }
167
+ const command = parsed.command;
168
+ if (command === "--help" || command === "-h") {
169
+ io.stdout(helpText());
170
+ return { exitCode: 0 };
171
+ }
172
+ switch (command) {
173
+ case "lint":
174
+ return lintCommand(parsed, io);
175
+ case "fmt":
176
+ return fmtCommand(parsed, io);
177
+ case "render":
178
+ return renderCommand(parsed, io, runtime);
179
+ case "explain":
180
+ return explainCommand(parsed, io);
181
+ case "new":
182
+ return newCommand(parsed, io);
183
+ case "docs":
184
+ return docsCommand(parsed, io, runtime);
185
+ case "ai":
186
+ return aiCommand(parsed, io, runtime);
187
+ case "check-all":
188
+ return checkAllCommand(parsed, io);
189
+ default:
190
+ io.stderr(`Unknown command: ${command}
191
+
192
+ ${helpText()}`);
193
+ return { exitCode: 1 };
194
+ }
195
+ }
196
+ async function lintCommand(parsed, io) {
197
+ const files = await collectSceneFiles(parsed.positionals);
198
+ if (files.length === 0) {
199
+ io.stderr("markdy lint: expected at least one .markdy file or directory");
200
+ return { exitCode: 1 };
201
+ }
202
+ const strict = hasFlag(parsed, "strict");
203
+ const cache = /* @__PURE__ */ new Map();
204
+ let warningCount = 0;
205
+ let errorCount = 0;
206
+ for (const file of files) {
207
+ try {
208
+ const scene = await loadSceneFromFile(file, cache);
209
+ io.stdout(`OK ${file}`);
210
+ warningCount += printWarnings(scene.ast.warnings, file, io);
211
+ } catch (error) {
212
+ errorCount++;
213
+ io.stderr(`FAIL ${file}`);
214
+ io.stderr(` ${describeError(error)}`);
215
+ }
216
+ }
217
+ if (strict && warningCount > 0) {
218
+ io.stderr(`markdy lint: ${warningCount} warning(s) treated as failures.`);
219
+ return { exitCode: 1 };
220
+ }
221
+ if (errorCount > 0) {
222
+ io.stderr(`markdy lint: ${errorCount} file(s) failed.`);
223
+ return { exitCode: 1 };
224
+ }
225
+ io.stdout(`markdy lint: PASS \u2014 ${files.length} file(s), ${warningCount} warning(s).`);
226
+ return { exitCode: 0 };
227
+ }
228
+ async function fmtCommand(parsed, io) {
229
+ const files = await collectSceneFiles(parsed.positionals);
230
+ if (files.length === 0) {
231
+ io.stderr("markdy fmt: expected at least one .markdy file or directory");
232
+ return { exitCode: 1 };
233
+ }
234
+ const write = hasFlag(parsed, "write");
235
+ const check = hasFlag(parsed, "check");
236
+ const cache = /* @__PURE__ */ new Map();
237
+ let changed = 0;
238
+ if (!write && !check && files.length > 1) {
239
+ io.stderr("markdy fmt: pass exactly one file when printing to stdout, or use --write / --check");
240
+ return { exitCode: 1 };
241
+ }
242
+ for (const file of files) {
243
+ const scene = await loadSceneFromFile(file, cache);
244
+ const formatted = formatScene(scene.ast);
245
+ const isChanged = normalizeNewlines(scene.source) !== normalizeNewlines(formatted);
246
+ if (write) {
247
+ if (isChanged) {
248
+ await writeFile(file, formatted, "utf8");
249
+ changed++;
250
+ io.stdout(`WROTE ${file}`);
251
+ } else {
252
+ io.stdout(`OK ${file}`);
253
+ }
254
+ continue;
255
+ }
256
+ if (check) {
257
+ if (isChanged) {
258
+ changed++;
259
+ io.stderr(`DIFF ${file}`);
260
+ } else {
261
+ io.stdout(`OK ${file}`);
262
+ }
263
+ continue;
264
+ }
265
+ io.stdout(formatted);
266
+ }
267
+ if (check) {
268
+ if (changed > 0) {
269
+ io.stderr(`markdy fmt: ${changed} file(s) need formatting.`);
270
+ return { exitCode: 1 };
271
+ }
272
+ io.stdout(`markdy fmt: PASS \u2014 ${files.length} file(s) already formatted.`);
273
+ }
274
+ return { exitCode: 0 };
275
+ }
276
+ async function renderCommand(parsed, io, runtime) {
277
+ const file = parsed.positionals[0];
278
+ if (!file) {
279
+ io.stderr("markdy render: expected a .markdy input file");
280
+ return { exitCode: 1 };
281
+ }
282
+ const scene = await loadSceneFromFile(file);
283
+ const outPath = getStringFlag(parsed, "out");
284
+ if (outPath) {
285
+ const html = await buildStandaloneHtml(scene);
286
+ const resolvedOut = resolve(outPath);
287
+ await writeFile(resolvedOut, html, "utf8");
288
+ io.stdout(`Wrote ${resolvedOut}`);
289
+ return { exitCode: 0 };
290
+ }
291
+ return launchPlayground(parsed, io, runtime, scene);
292
+ }
293
+ async function explainCommand(parsed, io) {
294
+ const file = parsed.positionals[0];
295
+ if (!file) {
296
+ io.stderr("markdy explain: expected a .markdy input file");
297
+ return { exitCode: 1 };
298
+ }
299
+ const scene = await loadSceneFromFile(file);
300
+ if (hasFlag(parsed, "json")) {
301
+ io.stdout(stableStringify(scene.ast));
302
+ return { exitCode: 0 };
303
+ }
304
+ const summary = [
305
+ `File: ${scene.filePath}`,
306
+ `Viewport: ${scene.ast.meta.width}\xD7${scene.ast.meta.height} @ ${scene.ast.meta.fps}fps`,
307
+ `Background: ${scene.ast.meta.bg}`,
308
+ `Duration: ${scene.ast.meta.duration ?? 0}s`,
309
+ `Actors: ${Object.keys(scene.ast.actors).length}`,
310
+ `Events: ${scene.ast.events.length}`,
311
+ `Chapters: ${scene.ast.chapters.length > 0 ? scene.ast.chapters.map((chapter) => chapter.name).join(", ") : "(none)"}`,
312
+ `Imports: ${scene.ast.imports.length > 0 ? scene.ast.imports.map((item) => `${item.namespace} -> ${item.path}`).join(", ") : "(none)"}`,
313
+ `Warnings: ${scene.ast.warnings.length}`
314
+ ];
315
+ io.stdout(summary.join("\n"));
316
+ if (scene.ast.warnings.length > 0) {
317
+ printWarnings(scene.ast.warnings, scene.filePath, io);
318
+ }
319
+ return { exitCode: 0 };
320
+ }
321
+ async function newCommand(parsed, io) {
322
+ const presetOrTarget = parsed.positionals[0];
323
+ const second = parsed.positionals[1];
324
+ const force = hasFlag(parsed, "force");
325
+ let presetName = "basic";
326
+ let target = "scene.markdy";
327
+ if (presetOrTarget) {
328
+ if (PRESET_NAMES.includes(presetOrTarget)) {
329
+ presetName = presetOrTarget;
330
+ target = second ?? target;
331
+ } else {
332
+ target = presetOrTarget;
333
+ }
334
+ }
335
+ const resolvedTarget = resolve(target);
336
+ const content = presetName === "basic" ? defaultSceneTemplate() : `preset ${presetName}
337
+ `;
338
+ if (!force && await exists(resolvedTarget)) {
339
+ io.stderr(`markdy new: target already exists: ${resolvedTarget} (pass --force to overwrite)`);
340
+ return { exitCode: 1 };
341
+ }
342
+ await writeFile(resolvedTarget, content, "utf8");
343
+ io.stdout(`Created ${resolvedTarget}`);
344
+ return { exitCode: 0 };
345
+ }
346
+ async function docsCommand(parsed, io, runtime) {
347
+ const docsUrl = "https://markdy.com";
348
+ const links = [
349
+ "Markdy docs",
350
+ " Website: https://markdy.com",
351
+ " Syntax: https://github.com/HoangYell/markdy-com/blob/main/docs/SYNTAX.md",
352
+ " Agent: https://github.com/HoangYell/markdy-com/blob/main/docs/AGENT.md",
353
+ " Tutorial:https://github.com/HoangYell/markdy-com/blob/main/docs/TUTORIAL.md"
354
+ ];
355
+ io.stdout(links.join("\n"));
356
+ if (hasFlag(parsed, "open")) {
357
+ await runtime.openBrowser(docsUrl);
358
+ }
359
+ return { exitCode: 0 };
360
+ }
361
+ async function aiCommand(parsed, io, runtime) {
362
+ const agentUrl = "https://github.com/HoangYell/markdy-com/blob/main/docs/AGENT.md";
363
+ io.stdout(
364
+ [
365
+ "Share this with your AI agent:",
366
+ agentUrl,
367
+ "",
368
+ "Starter prompt:",
369
+ "Write a Markdy scene for a short explainer video. Use the AGENT.md grammar exactly and keep the result self-contained."
370
+ ].join("\n")
371
+ );
372
+ if (hasFlag(parsed, "open")) {
373
+ await runtime.openBrowser(agentUrl);
374
+ }
375
+ return { exitCode: 0 };
376
+ }
377
+ async function checkAllCommand(parsed, io) {
378
+ const root = resolve(parsed.positionals[0] ?? process.cwd());
379
+ const files = await collectSceneFiles([root]);
380
+ if (files.length === 0) {
381
+ io.stderr(`markdy check-all: no .markdy files found under ${root}`);
382
+ return { exitCode: 1 };
383
+ }
384
+ const result = await lintCommand(
385
+ {
386
+ command: "lint",
387
+ positionals: files,
388
+ flags: parsed.flags
389
+ },
390
+ io
391
+ );
392
+ if (result.exitCode === 0) {
393
+ io.stdout(`markdy check-all: PASS \u2014 scanned ${files.length} file(s).`);
394
+ }
395
+ return result;
396
+ }
397
+ async function launchPlayground(parsed, io, runtime, scene) {
398
+ const portFlag = getStringFlag(parsed, "port");
399
+ const preferredPort = portFlag ? Number(portFlag) : DEFAULT_PORT;
400
+ const code = scene?.source ?? PRESETS.explainer([]);
401
+ const imports = scene?.imports ?? {};
402
+ const sourcePath = scene?.filePath;
403
+ const server = await startPreviewServer(code, imports, sourcePath, preferredPort);
404
+ const address = server.address();
405
+ const port = typeof address === "object" && address ? address.port : preferredPort;
406
+ const url = `http://127.0.0.1:${port}`;
407
+ io.stdout(`Markdy playground ready at ${url}`);
408
+ if (!hasFlag(parsed, "no-open")) {
409
+ await runtime.openBrowser(url);
410
+ }
411
+ return { exitCode: 0, server };
412
+ }
413
+ async function startPreviewServer(code, imports, sourcePath, preferredPort) {
414
+ const coreDist = resolvePackageDist("@markdy/core");
415
+ const rendererDist = resolvePackageDist("@markdy/renderer-dom");
416
+ const html = buildPlaygroundHtml(code, imports, sourcePath);
417
+ const server = createServer(async (request, response) => {
418
+ try {
419
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
420
+ if (requestUrl.pathname === "/") {
421
+ sendText(response, 200, html, "text/html; charset=utf-8");
422
+ return;
423
+ }
424
+ if (requestUrl.pathname.startsWith("/pkg/core/")) {
425
+ await servePackageFile(response, coreDist, requestUrl.pathname.slice("/pkg/core/".length));
426
+ return;
427
+ }
428
+ if (requestUrl.pathname.startsWith("/pkg/renderer-dom/")) {
429
+ await servePackageFile(response, rendererDist, requestUrl.pathname.slice("/pkg/renderer-dom/".length));
430
+ return;
431
+ }
432
+ sendText(response, 404, "Not found", "text/plain; charset=utf-8");
433
+ } catch (error) {
434
+ sendText(response, 500, describeError(error), "text/plain; charset=utf-8");
435
+ }
436
+ });
437
+ await new Promise((resolvePromise, rejectPromise) => {
438
+ server.once("error", rejectPromise);
439
+ server.listen(preferredPort, "127.0.0.1", () => {
440
+ server.off("error", rejectPromise);
441
+ resolvePromise();
442
+ });
443
+ }).catch(async () => {
444
+ await new Promise((resolvePromise, rejectPromise) => {
445
+ server.once("error", rejectPromise);
446
+ server.listen(0, "127.0.0.1", () => {
447
+ server.off("error", rejectPromise);
448
+ resolvePromise();
449
+ });
450
+ });
451
+ });
452
+ return server;
453
+ }
454
+ async function servePackageFile(response, distDir, relativePath) {
455
+ const safePath = relativePath.replace(/^\/+/, "");
456
+ const fullPath = resolve(distDir, safePath);
457
+ if (!fullPath.startsWith(distDir)) {
458
+ sendText(response, 403, "Forbidden", "text/plain; charset=utf-8");
459
+ return;
460
+ }
461
+ const body = await readFile(fullPath);
462
+ sendBuffer(response, 200, body, contentType(fullPath));
463
+ }
464
+ async function loadSceneFromFile(filePath, cache = /* @__PURE__ */ new Map(), stack = []) {
465
+ const resolvedPath = resolve(filePath);
466
+ const cached = cache.get(resolvedPath);
467
+ if (cached) return cached;
468
+ if (stack.includes(resolvedPath)) {
469
+ const chain = [...stack, resolvedPath].map((entry) => basename(entry)).join(" -> ");
470
+ throw new Error(`Import cycle detected: ${chain}`);
471
+ }
472
+ const source = await readFile(resolvedPath, "utf8").catch((error) => {
473
+ throw new Error(`Unable to read ${resolvedPath}: ${describeError(error)}`);
474
+ });
475
+ const imports = await resolveSceneImports(source, resolvedPath, cache, [...stack, resolvedPath]);
476
+ const ast = parse(source, Object.keys(imports).length > 0 ? { imports } : void 0);
477
+ const loaded = { filePath: resolvedPath, source, ast, imports };
478
+ cache.set(resolvedPath, loaded);
479
+ return loaded;
480
+ }
481
+ async function resolveSceneImports(source, filePath, cache, stack) {
482
+ const imports = {};
483
+ for (const declaration of scanImports(source)) {
484
+ const childPath = resolve(dirname(filePath), declaration.path);
485
+ const child = await loadSceneFromFile(childPath, cache, stack);
486
+ imports[declaration.namespace] = child.ast;
487
+ }
488
+ return imports;
489
+ }
490
+ function scanImports(source) {
491
+ const imports = [];
492
+ for (const line of source.split(/\r?\n/)) {
493
+ const trimmed = line.trim();
494
+ if (!trimmed.startsWith("import ")) continue;
495
+ const match = IMPORT_RE.exec(trimmed);
496
+ if (!match) continue;
497
+ imports.push({ path: match[1], namespace: match[2] });
498
+ }
499
+ return imports;
500
+ }
501
+ async function collectSceneFiles(inputs) {
502
+ const candidates = inputs.length > 0 ? inputs : [process.cwd()];
503
+ const out = /* @__PURE__ */ new Set();
504
+ for (const candidate of candidates) {
505
+ const resolved = resolve(candidate);
506
+ const info = await stat(resolved).catch(() => null);
507
+ if (!info) continue;
508
+ if (info.isDirectory()) {
509
+ for (const file of await walkMarkdyFiles(resolved)) {
510
+ out.add(file);
511
+ }
512
+ continue;
513
+ }
514
+ if (info.isFile() && extname(resolved) === MARKDY_EXT) {
515
+ out.add(resolved);
516
+ }
517
+ }
518
+ return [...out].sort();
519
+ }
520
+ async function walkMarkdyFiles(root) {
521
+ const entries = await readdir(root, { withFileTypes: true });
522
+ const files = [];
523
+ for (const entry of entries) {
524
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "dist") {
525
+ continue;
526
+ }
527
+ const fullPath = join(root, entry.name);
528
+ if (entry.isDirectory()) {
529
+ files.push(...await walkMarkdyFiles(fullPath));
530
+ continue;
531
+ }
532
+ if (entry.isFile() && extname(entry.name) === MARKDY_EXT) {
533
+ files.push(fullPath);
534
+ }
535
+ }
536
+ return files;
537
+ }
538
+ function buildPlaygroundHtml(code, imports, sourcePath) {
539
+ return `<!doctype html>
540
+ <html lang="en">
541
+ <head>
542
+ <meta charset="utf-8" />
543
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
544
+ <title>Markdy Playground</title>
545
+ <style>
546
+ :root { color-scheme: dark; }
547
+ * { box-sizing: border-box; }
548
+ body {
549
+ margin: 0;
550
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
551
+ background: #0d1117;
552
+ color: #e6edf3;
553
+ }
554
+ header {
555
+ padding: 16px 20px;
556
+ border-bottom: 1px solid #30363d;
557
+ display: flex;
558
+ justify-content: space-between;
559
+ gap: 16px;
560
+ align-items: center;
561
+ }
562
+ main {
563
+ display: grid;
564
+ grid-template-columns: minmax(320px, 460px) 1fr;
565
+ min-height: calc(100vh - 66px);
566
+ }
567
+ textarea {
568
+ width: 100%;
569
+ height: 100%;
570
+ border: 0;
571
+ resize: none;
572
+ padding: 20px;
573
+ background: #010409;
574
+ color: #c9d1d9;
575
+ font: 14px/1.55 ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Consolas, monospace;
576
+ }
577
+ .editor {
578
+ border-right: 1px solid #30363d;
579
+ display: flex;
580
+ flex-direction: column;
581
+ }
582
+ .preview {
583
+ padding: 20px;
584
+ overflow: auto;
585
+ }
586
+ .actions {
587
+ display: flex;
588
+ gap: 8px;
589
+ padding: 12px 20px;
590
+ border-top: 1px solid #30363d;
591
+ background: #0d1117;
592
+ }
593
+ button {
594
+ border: 1px solid #30363d;
595
+ border-radius: 8px;
596
+ background: #161b22;
597
+ color: #e6edf3;
598
+ padding: 8px 12px;
599
+ cursor: pointer;
600
+ }
601
+ #warnings {
602
+ margin: 0 0 16px;
603
+ padding: 0;
604
+ list-style: none;
605
+ }
606
+ #warnings li {
607
+ margin-bottom: 8px;
608
+ padding: 10px 12px;
609
+ border-radius: 8px;
610
+ background: rgba(255, 183, 77, 0.12);
611
+ color: #ffd38a;
612
+ }
613
+ #viewport {
614
+ max-width: 960px;
615
+ margin: 0 auto;
616
+ }
617
+ code { color: #8b949e; }
618
+ </style>
619
+ <script type="importmap">
620
+ {
621
+ "imports": {
622
+ "@markdy/core": "/pkg/core/index.js",
623
+ "@markdy/renderer-dom": "/pkg/renderer-dom/index.js"
624
+ }
625
+ }
626
+ </script>
627
+ </head>
628
+ <body>
629
+ <header>
630
+ <div>
631
+ <strong>Markdy Playground</strong>
632
+ <div><code>${escapeHtml(sourcePath ?? "scratch scene")}</code></div>
633
+ </div>
634
+ <div>Resolved imports: ${Object.keys(imports).length}</div>
635
+ </header>
636
+ <main>
637
+ <section class="editor">
638
+ <textarea id="code">${escapeHtml(code)}</textarea>
639
+ <div class="actions">
640
+ <button id="run" type="button">Run</button>
641
+ <button id="pause" type="button">Pause</button>
642
+ <button id="play" type="button">Play</button>
643
+ </div>
644
+ </section>
645
+ <section class="preview">
646
+ <ul id="warnings"></ul>
647
+ <div id="viewport"></div>
648
+ </section>
649
+ </main>
650
+ <script type="module">
651
+ import { createPlayer } from "@markdy/renderer-dom";
652
+
653
+ const imports = ${JSON.stringify(imports)};
654
+ const textarea = document.getElementById("code");
655
+ const viewport = document.getElementById("viewport");
656
+ const warnings = document.getElementById("warnings");
657
+ const runButton = document.getElementById("run");
658
+ const pauseButton = document.getElementById("pause");
659
+ const playButton = document.getElementById("play");
660
+ let player;
661
+
662
+ function render() {
663
+ warnings.innerHTML = "";
664
+ viewport.innerHTML = "";
665
+ player?.destroy?.();
666
+ try {
667
+ player = createPlayer({
668
+ container: viewport,
669
+ code: textarea.value,
670
+ imports,
671
+ onWarning(warning) {
672
+ const item = document.createElement("li");
673
+ item.textContent = \`line \${warning.line}: \${warning.message} (\${warning.kind})\`;
674
+ warnings.appendChild(item);
675
+ }
676
+ });
677
+ } catch (error) {
678
+ const item = document.createElement("li");
679
+ item.textContent = error instanceof Error ? error.message : String(error);
680
+ warnings.appendChild(item);
681
+ }
682
+ }
683
+
684
+ runButton.addEventListener("click", render);
685
+ pauseButton.addEventListener("click", () => player?.pause?.());
686
+ playButton.addEventListener("click", () => player?.play?.());
687
+ render();
688
+ </script>
689
+ </body>
690
+ </html>`;
691
+ }
692
+ async function buildStandaloneHtml(scene) {
693
+ const version = await getPackageVersion();
694
+ return `<!doctype html>
695
+ <html lang="en">
696
+ <head>
697
+ <meta charset="utf-8" />
698
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
699
+ <title>${escapeHtml(basename(scene.filePath))} \u2014 Markdy</title>
700
+ <style>
701
+ body {
702
+ margin: 0;
703
+ padding: 24px;
704
+ background: #0d1117;
705
+ color: #e6edf3;
706
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
707
+ }
708
+ main {
709
+ max-width: 980px;
710
+ margin: 0 auto;
711
+ }
712
+ #app {
713
+ max-width: 960px;
714
+ margin: 0 auto;
715
+ }
716
+ </style>
717
+ </head>
718
+ <body>
719
+ <main>
720
+ <div id="app"></div>
721
+ </main>
722
+ <script type="importmap">
723
+ {
724
+ "imports": {
725
+ "@markdy/core": "https://esm.sh/@markdy/core@${version}",
726
+ "@markdy/renderer-dom": "https://esm.sh/@markdy/renderer-dom@${version}"
727
+ }
728
+ }
729
+ </script>
730
+ <script type="module">
731
+ import { createPlayer } from "@markdy/renderer-dom";
732
+
733
+ createPlayer({
734
+ container: document.getElementById("app"),
735
+ code: ${JSON.stringify(scene.source)},
736
+ imports: ${JSON.stringify(scene.imports)},
737
+ onWarning(warning) {
738
+ console.warn(\`[markdy] line \${warning.line}: \${warning.message} (\${warning.kind})\`);
739
+ }
740
+ });
741
+ </script>
742
+ </body>
743
+ </html>`;
744
+ }
745
+ function helpText() {
746
+ return [
747
+ "markdy \u2014 MarkdyScript command-line tools",
748
+ "",
749
+ "Usage:",
750
+ " markdy",
751
+ " markdy lint <file-or-dir> [--strict]",
752
+ " markdy fmt <file-or-dir> [--write | --check]",
753
+ " markdy render <file.markdy> [--out file.html] [--port 4242] [--no-open]",
754
+ " markdy explain <file.markdy> [--json]",
755
+ " markdy new [preset-name] [target.markdy] [--force]",
756
+ " markdy docs [--open]",
757
+ " markdy ai [--open]",
758
+ " markdy check-all [dir] [--strict]",
759
+ "",
760
+ `Built-in presets: ${PRESET_NAMES.join(", ")}`
761
+ ].join("\n");
762
+ }
763
+ function parseArgv(argv) {
764
+ const flags = /* @__PURE__ */ new Map();
765
+ const positionals = [];
766
+ let command;
767
+ for (let index = 0; index < argv.length; index++) {
768
+ const arg = argv[index];
769
+ if (!command && !arg.startsWith("-")) {
770
+ command = arg;
771
+ continue;
772
+ }
773
+ if (arg.startsWith("--")) {
774
+ const [rawKey, rawValue] = arg.slice(2).split("=", 2);
775
+ if (rawValue !== void 0) {
776
+ flags.set(rawKey, rawValue);
777
+ continue;
778
+ }
779
+ const next = argv[index + 1];
780
+ if (next && !next.startsWith("-") && expectsValue(rawKey)) {
781
+ flags.set(rawKey, next);
782
+ index++;
783
+ } else {
784
+ flags.set(rawKey, true);
785
+ }
786
+ continue;
787
+ }
788
+ if (arg.startsWith("-")) {
789
+ const short = arg.slice(1);
790
+ if (short === "w") flags.set("write", true);
791
+ else if (short === "c") flags.set("check", true);
792
+ else if (short === "j") flags.set("json", true);
793
+ else if (short === "s") flags.set("strict", true);
794
+ else if (short === "f") flags.set("force", true);
795
+ else if (short === "o") {
796
+ const next = argv[index + 1];
797
+ if (!next || next.startsWith("-")) throw new Error("Expected a value after -o");
798
+ flags.set("out", next);
799
+ index++;
800
+ } else if (short === "p") {
801
+ const next = argv[index + 1];
802
+ if (!next || next.startsWith("-")) throw new Error("Expected a value after -p");
803
+ flags.set("port", next);
804
+ index++;
805
+ } else if (short === "h") {
806
+ flags.set("help", true);
807
+ } else {
808
+ flags.set(short, true);
809
+ }
810
+ continue;
811
+ }
812
+ positionals.push(arg);
813
+ }
814
+ return { command, positionals, flags };
815
+ }
816
+ function expectsValue(flag) {
817
+ return flag === "out" || flag === "port";
818
+ }
819
+ function hasFlag(parsed, name) {
820
+ return parsed.flags.get(name) === true;
821
+ }
822
+ function getStringFlag(parsed, name) {
823
+ const value = parsed.flags.get(name);
824
+ return typeof value === "string" ? value : void 0;
825
+ }
826
+ function printWarnings(warnings, file, io) {
827
+ for (const warning of warnings) {
828
+ io.stderr(`WARN ${file}:${warning.line} ${warning.kind} ${warning.message}`);
829
+ }
830
+ return warnings.length;
831
+ }
832
+ function stableStringify(value) {
833
+ const seen = /* @__PURE__ */ new WeakSet();
834
+ const walk = (current) => {
835
+ if (current === null || typeof current !== "object") return current;
836
+ if (seen.has(current)) return "[circular]";
837
+ seen.add(current);
838
+ if (Array.isArray(current)) return current.map(walk);
839
+ const sorted = {};
840
+ for (const key of Object.keys(current).sort()) {
841
+ sorted[key] = walk(current[key]);
842
+ }
843
+ return sorted;
844
+ };
845
+ return `${JSON.stringify(walk(value), null, 2)}
846
+ `;
847
+ }
848
+ function normalizeNewlines(value) {
849
+ return value.replace(/\r\n/g, "\n");
850
+ }
851
+ async function getPackageVersion() {
852
+ const pkg = JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8"));
853
+ return pkg.version;
854
+ }
855
+ function resolvePackageDist(packageName) {
856
+ const require2 = createRequire(import.meta.url);
857
+ const packageJson = require2.resolve(`${packageName}/package.json`);
858
+ return join(dirname(packageJson), "dist");
859
+ }
860
+ function contentType(path) {
861
+ if (path.endsWith(".js")) return "text/javascript; charset=utf-8";
862
+ if (path.endsWith(".d.ts")) return "text/plain; charset=utf-8";
863
+ if (path.endsWith(".json")) return "application/json; charset=utf-8";
864
+ if (path.endsWith(".css")) return "text/css; charset=utf-8";
865
+ return "application/octet-stream";
866
+ }
867
+ function sendText(response, status, body, type) {
868
+ response.writeHead(status, { "content-type": type });
869
+ response.end(body);
870
+ }
871
+ function sendBuffer(response, status, body, type) {
872
+ response.writeHead(status, { "content-type": type });
873
+ response.end(body);
874
+ }
875
+ async function exists(path) {
876
+ try {
877
+ await stat(path);
878
+ return true;
879
+ } catch {
880
+ return false;
881
+ }
882
+ }
883
+ function escapeHtml(value) {
884
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
885
+ }
886
+ function describeError(error) {
887
+ return error instanceof Error ? error.message : String(error);
888
+ }
889
+ function defaultSceneTemplate() {
890
+ return [
891
+ "scene width=960 height=540 fps=30 bg=#0d1117",
892
+ "",
893
+ 'actor title = caption("hello, markdy") at top',
894
+ "actor hero = figure(#c68642, m, \u{1F60E}) at (480, 360)",
895
+ "",
896
+ "@0.0: title.fade_in(dur=0.4)",
897
+ "@0.4: hero.enter(from=bottom, dur=0.5)",
898
+ '@1.2: hero.say("ship it", dur=1.4)',
899
+ ""
900
+ ].join("\n");
901
+ }
902
+ function defaultIo() {
903
+ return {
904
+ stdout(message) {
905
+ process.stdout.write(`${message}
906
+ `);
907
+ },
908
+ stderr(message) {
909
+ process.stderr.write(`${message}
910
+ `);
911
+ }
912
+ };
913
+ }
914
+ function defaultRuntime() {
915
+ return {
916
+ async openBrowser(url) {
917
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
918
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
919
+ const child = spawn(command, args, {
920
+ detached: true,
921
+ stdio: "ignore"
922
+ });
923
+ child.unref();
924
+ }
925
+ };
926
+ }
927
+ var isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
928
+ if (isMain) {
929
+ runCli(process.argv.slice(2)).then(({ exitCode }) => {
930
+ process.exitCode = exitCode;
931
+ }).catch((error) => {
932
+ process.stderr.write(`${describeError(error)}
933
+ `);
934
+ process.exitCode = 1;
935
+ });
936
+ }
937
+ export {
938
+ buildStandaloneHtml,
939
+ runCli
940
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@markdy/cli",
3
+ "version": "0.7.15",
4
+ "description": "First-party CLI for MarkdyScript: lint, format, explain, render, and local playground tooling.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "bin": {
15
+ "markdy": "./dist/index.js"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "keywords": [
24
+ "markdy",
25
+ "cli",
26
+ "animation",
27
+ "dsl",
28
+ "text-to-motion"
29
+ ],
30
+ "author": "Hoang Yell <hoangyell@gmail.com> (https://hoangyell.com)",
31
+ "homepage": "https://markdy.com",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/HoangYell/markdy-com.git",
35
+ "directory": "packages/cli"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/HoangYell/markdy-com/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@markdy/core": "0.7.15",
45
+ "@markdy/renderer-dom": "0.7.15"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.9.5",
49
+ "tsup": "^8.5.1",
50
+ "typescript": "^5.9.3",
51
+ "vitest": "^4.1.7"
52
+ },
53
+ "scripts": {
54
+ "build": "tsup",
55
+ "test": "vitest run",
56
+ "typecheck": "tsc --noEmit",
57
+ "lint": "tsc --noEmit"
58
+ }
59
+ }