lecodes-cli 0.5.3 → 0.6.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/dist/index.js +852 -124
- package/package.json +3 -3
- package/runtime/sdk/inject.ts +3 -0
- package/runtime/sdk-types.json +1 -0
package/dist/index.js
CHANGED
|
@@ -3,12 +3,12 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
|
-
import { readFileSync as
|
|
7
|
-
import { dirname as
|
|
8
|
-
import { fileURLToPath as
|
|
6
|
+
import { readFileSync as readFileSync13 } from "node:fs";
|
|
7
|
+
import { dirname as dirname8, join as join13 } from "node:path";
|
|
8
|
+
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
9
9
|
|
|
10
10
|
// src/commands/clone.ts
|
|
11
|
-
import { existsSync as
|
|
11
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
|
|
14
14
|
// src/util.ts
|
|
@@ -286,7 +286,8 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
|
|
|
286
286
|
|
|
287
287
|
// src/types.ts
|
|
288
288
|
import { dirname as dirname3, join as join4 } from "node:path";
|
|
289
|
-
import {
|
|
289
|
+
import { fileURLToPath } from "node:url";
|
|
290
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
290
291
|
var TSCONFIG = {
|
|
291
292
|
compilerOptions: {
|
|
292
293
|
target: "ES2020",
|
|
@@ -303,8 +304,7 @@ var TSCONFIG = {
|
|
|
303
304
|
include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
|
|
304
305
|
exclude: ["node_modules"]
|
|
305
306
|
};
|
|
306
|
-
var
|
|
307
|
-
const bundle = await getTypes(apiUrl);
|
|
307
|
+
var writeTypeBundle = (root, bundle) => {
|
|
308
308
|
const typesDir = join4(root, LECODES_DIR, "types");
|
|
309
309
|
for (const [path, text] of Object.entries(bundle)) {
|
|
310
310
|
const dest = join4(typesDir, path);
|
|
@@ -312,12 +312,37 @@ var materializeTypes = async (root, apiUrl) => {
|
|
|
312
312
|
writeFileSync4(dest, text);
|
|
313
313
|
}
|
|
314
314
|
};
|
|
315
|
+
var materializeTypes = async (root, apiUrl) => {
|
|
316
|
+
writeTypeBundle(root, await getTypes(apiUrl));
|
|
317
|
+
};
|
|
318
|
+
var readLocalTypeBundle = () => {
|
|
319
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
320
|
+
const candidates = [
|
|
321
|
+
join4(here, "..", "runtime", "sdk-types.json"),
|
|
322
|
+
join4(here, "..", "..", "sdk", "dist", "types.json")
|
|
323
|
+
];
|
|
324
|
+
for (const path of candidates) {
|
|
325
|
+
if (existsSync2(path)) {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
};
|
|
333
|
+
var materializeTypesLocal = (root) => {
|
|
334
|
+
const bundle = readLocalTypeBundle();
|
|
335
|
+
if (!bundle)
|
|
336
|
+
return false;
|
|
337
|
+
writeTypeBundle(root, bundle);
|
|
338
|
+
return true;
|
|
339
|
+
};
|
|
315
340
|
var writeTsconfig = (root) => {
|
|
316
341
|
writeFileSync4(join4(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
|
|
317
342
|
};
|
|
318
343
|
|
|
319
344
|
// src/ignore.ts
|
|
320
|
-
import { existsSync as
|
|
345
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
321
346
|
import { join as join5 } from "node:path";
|
|
322
347
|
var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
|
|
323
348
|
var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
@@ -395,9 +420,9 @@ var buildMatcher = (rules) => (key, isDir) => {
|
|
|
395
420
|
var loadIgnore = (root) => {
|
|
396
421
|
const lines = [...BUILTIN_PATTERNS];
|
|
397
422
|
const file = join5(root, ".lecodesignore");
|
|
398
|
-
if (
|
|
423
|
+
if (existsSync3(file)) {
|
|
399
424
|
try {
|
|
400
|
-
lines.push(...
|
|
425
|
+
lines.push(...readFileSync4(file, "utf8").split(/\r?\n/));
|
|
401
426
|
} catch {}
|
|
402
427
|
}
|
|
403
428
|
const rules = lines.map(compile).filter((r) => r !== null);
|
|
@@ -405,7 +430,7 @@ var loadIgnore = (root) => {
|
|
|
405
430
|
};
|
|
406
431
|
var writeDefaultIgnore = (root) => {
|
|
407
432
|
const file = join5(root, ".lecodesignore");
|
|
408
|
-
if (!
|
|
433
|
+
if (!existsSync3(file))
|
|
409
434
|
writeFileSync5(file, DEFAULT_IGNORE_FILE);
|
|
410
435
|
};
|
|
411
436
|
|
|
@@ -428,7 +453,7 @@ var clone = async (args) => {
|
|
|
428
453
|
const project = await getProject(apiUrl, token, uuid);
|
|
429
454
|
const dir = args._[1] ?? slug(project.name);
|
|
430
455
|
const root = resolve(dir);
|
|
431
|
-
if (
|
|
456
|
+
if (existsSync4(root) && readdirSync(root).length > 0) {
|
|
432
457
|
throw new CliError(`Directory "${dir}" already exists and is not empty.`);
|
|
433
458
|
}
|
|
434
459
|
mkdirSync5(root, { recursive: true });
|
|
@@ -448,7 +473,7 @@ var clone = async (args) => {
|
|
|
448
473
|
};
|
|
449
474
|
|
|
450
475
|
// src/commands/create.ts
|
|
451
|
-
import { existsSync as
|
|
476
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync6 } from "node:fs";
|
|
452
477
|
import { join as join6, resolve as resolve2 } from "node:path";
|
|
453
478
|
var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
454
479
|
var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
|
|
@@ -468,7 +493,7 @@ var create = async (args) => {
|
|
|
468
493
|
throw new CliError("Usage: lecodes create <name> [directory]");
|
|
469
494
|
const dir = args._[1] ?? slug2(name);
|
|
470
495
|
const root = resolve2(dir);
|
|
471
|
-
if (
|
|
496
|
+
if (existsSync5(root) && readdirSync2(root).length > 0) {
|
|
472
497
|
throw new CliError(`Directory "${dir}" already exists and is not empty.`);
|
|
473
498
|
}
|
|
474
499
|
mkdirSync6(root, { recursive: true });
|
|
@@ -486,15 +511,15 @@ var create = async (args) => {
|
|
|
486
511
|
};
|
|
487
512
|
|
|
488
513
|
// src/commands/design.ts
|
|
489
|
-
import { existsSync as
|
|
490
|
-
import { basename as basename2, join as
|
|
514
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync9, readdirSync as readdirSync5, writeFileSync as writeFileSync8, appendFileSync } from "node:fs";
|
|
515
|
+
import { basename as basename2, join as join11 } from "node:path";
|
|
491
516
|
|
|
492
517
|
// src/compile/designCompile.ts
|
|
493
|
-
import { readFileSync as
|
|
518
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
494
519
|
import { basename } from "node:path";
|
|
495
520
|
|
|
496
521
|
// src/localFiles.ts
|
|
497
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
522
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
498
523
|
import { join as join7, relative } from "node:path";
|
|
499
524
|
var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
|
|
500
525
|
var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
|
|
@@ -569,7 +594,7 @@ var scanLocal = (root, opts = {}) => {
|
|
|
569
594
|
ignored.push(key);
|
|
570
595
|
continue;
|
|
571
596
|
}
|
|
572
|
-
files.push({ path: key, absPath: abs, sha: sha256(
|
|
597
|
+
files.push({ path: key, absPath: abs, sha: sha256(readFileSync5(abs)) });
|
|
573
598
|
}
|
|
574
599
|
}
|
|
575
600
|
};
|
|
@@ -603,16 +628,16 @@ var diffLocal = (manifest, scan) => {
|
|
|
603
628
|
var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
|
|
604
629
|
|
|
605
630
|
// src/compile/projectCompile.ts
|
|
606
|
-
import { existsSync as
|
|
631
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
607
632
|
import { createRequire as createRequire3 } from "node:module";
|
|
608
633
|
import { dirname as dirname5, join as join9 } from "node:path";
|
|
609
|
-
import { fileURLToPath as
|
|
634
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
610
635
|
// ../sdk/src/compile/bundler.ts
|
|
611
|
-
import { existsSync as
|
|
636
|
+
import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync } from "node:fs";
|
|
612
637
|
import { spawnSync } from "node:child_process";
|
|
613
638
|
import { createRequire as createRequire2 } from "node:module";
|
|
614
639
|
import { dirname as dirname4, join as join8, relative as relative2 } from "node:path";
|
|
615
|
-
import { fileURLToPath } from "node:url";
|
|
640
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
616
641
|
|
|
617
642
|
// ../sdk/src/compile/assetMacro.ts
|
|
618
643
|
var ASSET_RE = /\basset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g;
|
|
@@ -672,11 +697,11 @@ var classBodyDeclaresAspect = (src, from) => {
|
|
|
672
697
|
// ../sdk/src/compile/bundler.ts
|
|
673
698
|
var resolveInject = () => {
|
|
674
699
|
const override = process.env.LECODES_SDK_INJECT;
|
|
675
|
-
if (override &&
|
|
700
|
+
if (override && existsSync6(override))
|
|
676
701
|
return override;
|
|
677
|
-
const here = dirname4(
|
|
702
|
+
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
678
703
|
const src = join8(here, "..", "inject.ts");
|
|
679
|
-
if (
|
|
704
|
+
if (existsSync6(src))
|
|
680
705
|
return src;
|
|
681
706
|
throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
|
|
682
707
|
};
|
|
@@ -714,7 +739,7 @@ var collectSdkFiles = (sdkSrc) => {
|
|
|
714
739
|
if (statSync(p).isDirectory())
|
|
715
740
|
walk(p);
|
|
716
741
|
else if (isSdkSource(p))
|
|
717
|
-
out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] =
|
|
742
|
+
out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync6(p, "utf8");
|
|
718
743
|
}
|
|
719
744
|
};
|
|
720
745
|
walk(sdkSrc);
|
|
@@ -724,13 +749,13 @@ var collectSdkFiles = (sdkSrc) => {
|
|
|
724
749
|
var resolveChiselBin = () => {
|
|
725
750
|
const override = process.env.LECODES_CHISEL_BIN;
|
|
726
751
|
if (override) {
|
|
727
|
-
if (
|
|
752
|
+
if (existsSync6(override))
|
|
728
753
|
return override;
|
|
729
754
|
throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
|
|
730
755
|
}
|
|
731
756
|
try {
|
|
732
757
|
const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
|
|
733
|
-
if (bin &&
|
|
758
|
+
if (bin && existsSync6(bin))
|
|
734
759
|
return bin;
|
|
735
760
|
} catch {}
|
|
736
761
|
throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
|
|
@@ -890,9 +915,9 @@ ${inlineSourceMapComment(map)}` : code;
|
|
|
890
915
|
${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`;
|
|
891
916
|
};
|
|
892
917
|
// src/compile/projectCompile.ts
|
|
893
|
-
var here = dirname5(
|
|
918
|
+
var here = dirname5(fileURLToPath3(import.meta.url));
|
|
894
919
|
var vendoredInject = join9(here, "..", "runtime", "sdk", "inject.ts");
|
|
895
|
-
if (
|
|
920
|
+
if (existsSync7(vendoredInject))
|
|
896
921
|
process.env.LECODES_SDK_INJECT = vendoredInject;
|
|
897
922
|
var resolveChiselBin2 = () => {
|
|
898
923
|
if (process.env.LECODES_CHISEL_BIN)
|
|
@@ -916,10 +941,379 @@ var compileProject2 = (opts) => compileProject({
|
|
|
916
941
|
localAssets: opts.localAssets
|
|
917
942
|
});
|
|
918
943
|
|
|
944
|
+
// src/compile/assetIcons.ts
|
|
945
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
946
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "node:fs";
|
|
947
|
+
import { dirname as dirname6, join as join10 } from "node:path";
|
|
948
|
+
var DEFAULT_REGISTRY = process.env.LECODES_ICON_REGISTRY?.trim() || "https://icon-registry.jt3.ru/v1";
|
|
949
|
+
var ICON_ID = /^([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
950
|
+
var isIdentStart = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$";
|
|
951
|
+
var isIdentPart = (c2) => isIdentStart(c2) || c2 >= "0" && c2 <= "9";
|
|
952
|
+
var skipString = (src, start) => {
|
|
953
|
+
const quote = src[start];
|
|
954
|
+
const n = src.length;
|
|
955
|
+
let i = start + 1;
|
|
956
|
+
while (i < n) {
|
|
957
|
+
const c2 = src[i];
|
|
958
|
+
if (c2 === "\\") {
|
|
959
|
+
i += 2;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (quote === "`" && c2 === "$" && src[i + 1] === "{") {
|
|
963
|
+
i = skipBraces(src, i + 1);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (c2 === quote)
|
|
967
|
+
return i + 1;
|
|
968
|
+
i++;
|
|
969
|
+
}
|
|
970
|
+
return n;
|
|
971
|
+
};
|
|
972
|
+
var skipBraces = (src, open) => {
|
|
973
|
+
const n = src.length;
|
|
974
|
+
let depth = 0;
|
|
975
|
+
let i = open;
|
|
976
|
+
while (i < n) {
|
|
977
|
+
const c2 = src[i];
|
|
978
|
+
if (c2 === '"' || c2 === "'" || c2 === "`") {
|
|
979
|
+
i = skipString(src, i);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (c2 === "{")
|
|
983
|
+
depth++;
|
|
984
|
+
else if (c2 === "}") {
|
|
985
|
+
depth--;
|
|
986
|
+
if (depth === 0)
|
|
987
|
+
return i + 1;
|
|
988
|
+
}
|
|
989
|
+
i++;
|
|
990
|
+
}
|
|
991
|
+
return n;
|
|
992
|
+
};
|
|
993
|
+
var matchParen = (src, open) => {
|
|
994
|
+
const n = src.length;
|
|
995
|
+
let depth = 0;
|
|
996
|
+
let i = open;
|
|
997
|
+
while (i < n) {
|
|
998
|
+
const c2 = src[i];
|
|
999
|
+
if (c2 === '"' || c2 === "'" || c2 === "`") {
|
|
1000
|
+
i = skipString(src, i);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
if (c2 === "/" && src[i + 1] === "/") {
|
|
1004
|
+
i += 2;
|
|
1005
|
+
while (i < n && src[i] !== `
|
|
1006
|
+
`)
|
|
1007
|
+
i++;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (c2 === "/" && src[i + 1] === "*") {
|
|
1011
|
+
i += 2;
|
|
1012
|
+
while (i < n && !(src[i] === "*" && src[i + 1] === "/"))
|
|
1013
|
+
i++;
|
|
1014
|
+
i += 2;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (c2 === "(")
|
|
1018
|
+
depth++;
|
|
1019
|
+
else if (c2 === ")") {
|
|
1020
|
+
depth--;
|
|
1021
|
+
if (depth === 0)
|
|
1022
|
+
return i;
|
|
1023
|
+
}
|
|
1024
|
+
i++;
|
|
1025
|
+
}
|
|
1026
|
+
return -1;
|
|
1027
|
+
};
|
|
1028
|
+
var splitTopLevel = (text, delim) => {
|
|
1029
|
+
const parts = [];
|
|
1030
|
+
const n = text.length;
|
|
1031
|
+
let depth = 0;
|
|
1032
|
+
let last = 0;
|
|
1033
|
+
let i = 0;
|
|
1034
|
+
while (i < n) {
|
|
1035
|
+
const c2 = text[i];
|
|
1036
|
+
if (c2 === '"' || c2 === "'" || c2 === "`") {
|
|
1037
|
+
i = skipString(text, i);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (c2 === "(" || c2 === "[" || c2 === "{")
|
|
1041
|
+
depth++;
|
|
1042
|
+
else if (c2 === ")" || c2 === "]" || c2 === "}")
|
|
1043
|
+
depth = Math.max(0, depth - 1);
|
|
1044
|
+
else if (depth === 0 && c2 === delim) {
|
|
1045
|
+
parts.push(text.slice(last, i));
|
|
1046
|
+
last = i + 1;
|
|
1047
|
+
}
|
|
1048
|
+
i++;
|
|
1049
|
+
}
|
|
1050
|
+
parts.push(text.slice(last));
|
|
1051
|
+
return parts;
|
|
1052
|
+
};
|
|
1053
|
+
var stringLiteralValue = (text) => {
|
|
1054
|
+
const t = text.trim();
|
|
1055
|
+
if (t.length < 2)
|
|
1056
|
+
return null;
|
|
1057
|
+
const q = t[0];
|
|
1058
|
+
if (q !== '"' && q !== "'" && q !== "`" || t[t.length - 1] !== q)
|
|
1059
|
+
return null;
|
|
1060
|
+
const inner = t.slice(1, -1);
|
|
1061
|
+
if (q === "`" && inner.includes("${"))
|
|
1062
|
+
return null;
|
|
1063
|
+
if (inner.includes(q) && !inner.includes("\\" + q))
|
|
1064
|
+
return null;
|
|
1065
|
+
return inner.replace(/\\(.)/g, "$1");
|
|
1066
|
+
};
|
|
1067
|
+
var scanAssetIconCalls = (source) => {
|
|
1068
|
+
const calls = [];
|
|
1069
|
+
const n = source.length;
|
|
1070
|
+
let i = 0;
|
|
1071
|
+
while (i < n) {
|
|
1072
|
+
const c2 = source[i];
|
|
1073
|
+
if (c2 === '"' || c2 === "'" || c2 === "`") {
|
|
1074
|
+
i = skipString(source, i);
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (c2 === "/" && source[i + 1] === "/") {
|
|
1078
|
+
i += 2;
|
|
1079
|
+
while (i < n && source[i] !== `
|
|
1080
|
+
`)
|
|
1081
|
+
i++;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (c2 === "/" && source[i + 1] === "*") {
|
|
1085
|
+
i += 2;
|
|
1086
|
+
while (i < n && !(source[i] === "*" && source[i + 1] === "/"))
|
|
1087
|
+
i++;
|
|
1088
|
+
i += 2;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (isIdentStart(c2)) {
|
|
1092
|
+
let j = i + 1;
|
|
1093
|
+
while (j < n && isIdentPart(source[j]))
|
|
1094
|
+
j++;
|
|
1095
|
+
if (source.slice(i, j) === "assetIcon" && prevNonSpace(source, i) !== ".") {
|
|
1096
|
+
const call = parseCall(source, i, j);
|
|
1097
|
+
if (call) {
|
|
1098
|
+
calls.push(call);
|
|
1099
|
+
i = call.end;
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
i = j;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
i++;
|
|
1107
|
+
}
|
|
1108
|
+
return calls;
|
|
1109
|
+
};
|
|
1110
|
+
var prevNonSpace = (src, i) => {
|
|
1111
|
+
let k = i - 1;
|
|
1112
|
+
while (k >= 0 && /\s/.test(src[k]))
|
|
1113
|
+
k--;
|
|
1114
|
+
return k >= 0 ? src[k] : "";
|
|
1115
|
+
};
|
|
1116
|
+
var parseCall = (src, start, identEnd) => {
|
|
1117
|
+
const n = src.length;
|
|
1118
|
+
let i = identEnd;
|
|
1119
|
+
while (i < n && /\s/.test(src[i]))
|
|
1120
|
+
i++;
|
|
1121
|
+
if (src[i] !== "(")
|
|
1122
|
+
return null;
|
|
1123
|
+
const close = matchParen(src, i);
|
|
1124
|
+
if (close === -1)
|
|
1125
|
+
return null;
|
|
1126
|
+
const call = { start, end: close + 1, id: "", pack: "", name: "" };
|
|
1127
|
+
const args = splitTopLevel(src.slice(i + 1, close), ",");
|
|
1128
|
+
const id = stringLiteralValue(args[0] ?? "");
|
|
1129
|
+
if (id === null) {
|
|
1130
|
+
call.error = `assetIcon(...) needs a string-literal id like "lucide:bell" (got \`${(args[0] ?? "").trim() || "()"}\`).`;
|
|
1131
|
+
return call;
|
|
1132
|
+
}
|
|
1133
|
+
const m = ICON_ID.exec(id);
|
|
1134
|
+
if (!m) {
|
|
1135
|
+
call.error = `assetIcon("${id}"): id must be "pack:name", lowercase [a-z0-9-] (e.g. "lucide:bell").`;
|
|
1136
|
+
return call;
|
|
1137
|
+
}
|
|
1138
|
+
call.id = id;
|
|
1139
|
+
call.pack = m[1];
|
|
1140
|
+
call.name = m[2];
|
|
1141
|
+
const optsText = args[1]?.trim();
|
|
1142
|
+
if (optsText) {
|
|
1143
|
+
const color = extractColor(optsText);
|
|
1144
|
+
if (color.error) {
|
|
1145
|
+
call.error = color.error;
|
|
1146
|
+
return call;
|
|
1147
|
+
}
|
|
1148
|
+
if (color.bake !== undefined)
|
|
1149
|
+
call.bake = color.bake;
|
|
1150
|
+
else if (color.tint !== undefined)
|
|
1151
|
+
call.tint = color.tint;
|
|
1152
|
+
}
|
|
1153
|
+
return call;
|
|
1154
|
+
};
|
|
1155
|
+
var extractColor = (optsText) => {
|
|
1156
|
+
if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
|
|
1157
|
+
return { error: `assetIcon(id, opts): opts must be an object literal like { color: "#5b8cff" }.` };
|
|
1158
|
+
}
|
|
1159
|
+
for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
|
|
1160
|
+
const kv = splitTopLevel(prop, ":");
|
|
1161
|
+
if (kv.length < 2)
|
|
1162
|
+
continue;
|
|
1163
|
+
const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "");
|
|
1164
|
+
if (key !== "color")
|
|
1165
|
+
continue;
|
|
1166
|
+
const raw = kv.slice(1).join(":").trim();
|
|
1167
|
+
if (!raw)
|
|
1168
|
+
continue;
|
|
1169
|
+
const literal = stringLiteralValue(raw);
|
|
1170
|
+
return literal !== null ? { bake: literal } : { tint: raw };
|
|
1171
|
+
}
|
|
1172
|
+
return {};
|
|
1173
|
+
};
|
|
1174
|
+
var rewriteAssetIcons = (source, calls, svgById) => {
|
|
1175
|
+
let out = source;
|
|
1176
|
+
for (const call of [...calls].sort((a, b) => b.start - a.start)) {
|
|
1177
|
+
const svg = svgById.get(call.id);
|
|
1178
|
+
if (svg === undefined)
|
|
1179
|
+
continue;
|
|
1180
|
+
out = out.slice(0, call.start) + iconExpr(svg, call) + out.slice(call.end);
|
|
1181
|
+
}
|
|
1182
|
+
return out;
|
|
1183
|
+
};
|
|
1184
|
+
var iconExpr = (svg, call) => {
|
|
1185
|
+
const body = call.bake ? svg.split("currentColor").join(call.bake) : svg;
|
|
1186
|
+
const src = `SvgSource(\`${escapeTemplate(body)}\`)`;
|
|
1187
|
+
return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src;
|
|
1188
|
+
};
|
|
1189
|
+
var escapeTemplate = (s) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
1190
|
+
var isHttp = (s) => /^https?:/i.test(s);
|
|
1191
|
+
var sha12 = (s) => createHash2("sha256").update(s).digest("hex").slice(0, 12);
|
|
1192
|
+
var registryHome = (registry) => registry.replace(/\/v\d+$/, "");
|
|
1193
|
+
var readText = async (registry, rel) => {
|
|
1194
|
+
if (isHttp(registry)) {
|
|
1195
|
+
const res = await fetch(`${registry}/${rel}`);
|
|
1196
|
+
return res.ok ? await res.text() : null;
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
return readFileSync7(join10(registry, rel), "utf8");
|
|
1200
|
+
} catch {
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
var readLock = (path) => {
|
|
1205
|
+
try {
|
|
1206
|
+
const lock = JSON.parse(readFileSync7(path, "utf8"));
|
|
1207
|
+
if (lock && typeof lock === "object" && lock.icons && lock.packs)
|
|
1208
|
+
return lock;
|
|
1209
|
+
} catch {}
|
|
1210
|
+
return null;
|
|
1211
|
+
};
|
|
1212
|
+
var serializeLock = (lock) => {
|
|
1213
|
+
const sortKeys = (rec) => Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]));
|
|
1214
|
+
return JSON.stringify({ registry: lock.registry, icons: sortKeys(lock.icons), packs: sortKeys(lock.packs) }, null, 2) + `
|
|
1215
|
+
`;
|
|
1216
|
+
};
|
|
1217
|
+
var renderNotices = (lock) => {
|
|
1218
|
+
const packs = [...new Set(Object.values(lock.icons).map((i) => i.pack))].sort();
|
|
1219
|
+
const lines = [
|
|
1220
|
+
"# Icon attribution & notices",
|
|
1221
|
+
"",
|
|
1222
|
+
"Icons vendored from the icon-registry via `assetIcon(...)`. Keep this file in your repo / product.",
|
|
1223
|
+
""
|
|
1224
|
+
];
|
|
1225
|
+
for (const p of packs) {
|
|
1226
|
+
const l = lock.packs[p];
|
|
1227
|
+
if (!l)
|
|
1228
|
+
continue;
|
|
1229
|
+
let note2 = `- **${p}** — ${l.spdx}${l.url ? ` — ${l.url}` : ""}`;
|
|
1230
|
+
if (l.attribution)
|
|
1231
|
+
note2 += `
|
|
1232
|
+
- **Attribution required:** ${l.attribution}`;
|
|
1233
|
+
if (l.modifiedNotice)
|
|
1234
|
+
note2 += `
|
|
1235
|
+
- ${l.spdx}: state any modifications if you alter the glyphs.`;
|
|
1236
|
+
lines.push(note2);
|
|
1237
|
+
}
|
|
1238
|
+
lines.push("");
|
|
1239
|
+
return lines.join(`
|
|
1240
|
+
`);
|
|
1241
|
+
};
|
|
1242
|
+
var writeIfChanged = (path, text) => {
|
|
1243
|
+
const current = existsSync8(path) ? safeRead(path) : null;
|
|
1244
|
+
if (current === text)
|
|
1245
|
+
return false;
|
|
1246
|
+
writeFileSync7(path, text);
|
|
1247
|
+
return true;
|
|
1248
|
+
};
|
|
1249
|
+
var safeRead = (path) => {
|
|
1250
|
+
try {
|
|
1251
|
+
return readFileSync7(path, "utf8");
|
|
1252
|
+
} catch {
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
var vendorIcons = async (designDir, ids, opts = {}) => {
|
|
1257
|
+
const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
1258
|
+
const lockPath = join10(designDir, "icons.lock.json");
|
|
1259
|
+
const lock = readLock(lockPath) ?? { registry, icons: {}, packs: {} };
|
|
1260
|
+
const svgById = new Map;
|
|
1261
|
+
const missing = [];
|
|
1262
|
+
for (const id of ids) {
|
|
1263
|
+
const li = lock.icons[id];
|
|
1264
|
+
const cached = li && safeRead(join10(designDir, li.path));
|
|
1265
|
+
if (cached)
|
|
1266
|
+
svgById.set(id, cached);
|
|
1267
|
+
else
|
|
1268
|
+
missing.push(id);
|
|
1269
|
+
}
|
|
1270
|
+
if (missing.length) {
|
|
1271
|
+
const licRaw = await readText(registry, "licenses.json");
|
|
1272
|
+
if (!licRaw)
|
|
1273
|
+
throw new Error(`assetIcon: can't reach the icon registry (${registry}). Check your connection, or set LECODES_ICON_REGISTRY.`);
|
|
1274
|
+
const licenses = JSON.parse(licRaw);
|
|
1275
|
+
for (const id of missing) {
|
|
1276
|
+
const m = ICON_ID.exec(id);
|
|
1277
|
+
const [, pack, name] = m;
|
|
1278
|
+
const lic = licenses[pack];
|
|
1279
|
+
if (!lic)
|
|
1280
|
+
throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Browse packs at ${registryHome(registry)}.`);
|
|
1281
|
+
const svg = await readText(registry, `${pack}/${name}.svg`);
|
|
1282
|
+
if (svg === null)
|
|
1283
|
+
throw new Error(`assetIcon: icon "${id}" not found in the registry. Check the name at ${registryHome(registry)}.`);
|
|
1284
|
+
const rel = `assets/icons/${pack}/${name}.svg`;
|
|
1285
|
+
const abs = join10(designDir, rel);
|
|
1286
|
+
mkdirSync7(dirname6(abs), { recursive: true });
|
|
1287
|
+
writeFileSync7(abs, svg);
|
|
1288
|
+
lock.icons[id] = { pack, name, hash: sha12(svg), spdx: lic.spdx, path: rel };
|
|
1289
|
+
lock.packs[pack] = { spdx: lic.spdx, title: lic.title, url: lic.url, attribution: lic.attribution, modifiedNotice: lic.modifiedNotice };
|
|
1290
|
+
svgById.set(id, svg);
|
|
1291
|
+
}
|
|
1292
|
+
lock.registry = registry;
|
|
1293
|
+
writeIfChanged(lockPath, serializeLock(lock));
|
|
1294
|
+
}
|
|
1295
|
+
if (Object.keys(lock.icons).length)
|
|
1296
|
+
writeIfChanged(join10(designDir, "ICON-NOTICES.md"), renderNotices(lock));
|
|
1297
|
+
return svgById;
|
|
1298
|
+
};
|
|
1299
|
+
var applyAssetIcons = async (entries, designDir, opts = {}) => {
|
|
1300
|
+
const scanned = entries.filter((e) => e.type === "text" && typeof e.text === "string" && e.text.includes("assetIcon")).map((e) => ({ entry: e, calls: scanAssetIconCalls(e.text) })).filter((s) => s.calls.length > 0);
|
|
1301
|
+
if (scanned.length === 0)
|
|
1302
|
+
return;
|
|
1303
|
+
const errors = scanned.flatMap((s) => s.calls.filter((c2) => c2.error).map((c2) => c2.error));
|
|
1304
|
+
if (errors.length)
|
|
1305
|
+
throw new Error([...new Set(errors)].join(`
|
|
1306
|
+
`));
|
|
1307
|
+
const ids = [...new Set(scanned.flatMap((s) => s.calls.map((c2) => c2.id)))];
|
|
1308
|
+
const svgById = await vendorIcons(designDir, ids, opts);
|
|
1309
|
+
for (const { entry, calls } of scanned)
|
|
1310
|
+
entry.text = rewriteAssetIcons(entry.text, calls, svgById);
|
|
1311
|
+
};
|
|
1312
|
+
|
|
919
1313
|
// src/compile/designCompile.ts
|
|
920
1314
|
var LOCAL_ASSET_SCHEME = "lecodesfile://";
|
|
921
1315
|
var compileDesignScreen = async (opts) => {
|
|
922
|
-
const { root, manifest, designKey, screenId } = opts;
|
|
1316
|
+
const { root, manifest, designDir, designKey, screenId, state } = opts;
|
|
923
1317
|
const entries = [];
|
|
924
1318
|
const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
|
|
925
1319
|
for (const file of scan.files) {
|
|
@@ -934,17 +1328,20 @@ var compileDesignScreen = async (opts) => {
|
|
|
934
1328
|
} else if (type === "shader") {
|
|
935
1329
|
entries.push({ path: file.path, type, shaders: [] });
|
|
936
1330
|
} else {
|
|
937
|
-
entries.push({ path: file.path, type, text:
|
|
1331
|
+
entries.push({ path: file.path, type, text: readFileSync8(file.absPath, "utf8") });
|
|
938
1332
|
}
|
|
939
1333
|
}
|
|
1334
|
+
const invoke = state !== undefined ? `screen(${JSON.stringify(state)})` : "screen()";
|
|
940
1335
|
const entryPath = `${designKey}/.screen-entry.ts`;
|
|
941
1336
|
entries.push({
|
|
942
1337
|
path: entryPath,
|
|
943
1338
|
type: "text",
|
|
944
1339
|
text: `import screen from "./screens/${screenId}"
|
|
945
|
-
screen
|
|
1340
|
+
` + `const __screen = typeof screen === "function" ? ${invoke} : screen
|
|
1341
|
+
` + `__screen.open()
|
|
946
1342
|
`
|
|
947
1343
|
});
|
|
1344
|
+
await applyAssetIcons(entries, designDir);
|
|
948
1345
|
return compileProject2({
|
|
949
1346
|
entries,
|
|
950
1347
|
name: opts.name,
|
|
@@ -1036,11 +1433,45 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
|
|
|
1036
1433
|
// src/commands/designTemplates.ts
|
|
1037
1434
|
var META_JSON = `{
|
|
1038
1435
|
"screens": {
|
|
1039
|
-
"home": { "pos": [0, 0], "desc": "Example screen — replace me" }
|
|
1436
|
+
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
1040
1437
|
},
|
|
1041
1438
|
"edges": []
|
|
1042
1439
|
}
|
|
1043
1440
|
`;
|
|
1441
|
+
var SPEC_MD = `# Concept
|
|
1442
|
+
|
|
1443
|
+
The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
|
|
1444
|
+
positions, edges); this file holds what never renders as a screen — the data model, the actors, and
|
|
1445
|
+
the rules & decisions behind them. Prose, not schema. **Half-filled is fine** — fill it in as the
|
|
1446
|
+
design firms up. This is where the concept you'd otherwise keep in chat lives.
|
|
1447
|
+
|
|
1448
|
+
Link a screen with \`[[screen-id]]\` (its file name). \`lecodes design check\` warns when a link names
|
|
1449
|
+
a screen that doesn't exist.
|
|
1450
|
+
|
|
1451
|
+
## Data model
|
|
1452
|
+
|
|
1453
|
+
The entities the app is about, and their fields. The mock data at the top of each screen file is the
|
|
1454
|
+
first draft of this — promote the entities that stabilize here (one table each).
|
|
1455
|
+
|
|
1456
|
+
<!--
|
|
1457
|
+
### Task
|
|
1458
|
+
| field | type | notes |
|
|
1459
|
+
|-------|------|-------|
|
|
1460
|
+
| id | string | |
|
|
1461
|
+
| title | string | |
|
|
1462
|
+
| done | boolean | defaults to false |
|
|
1463
|
+
-->
|
|
1464
|
+
|
|
1465
|
+
## Actors
|
|
1466
|
+
|
|
1467
|
+
Who uses the app, and what each is allowed to do. (Different from a screen's \`role\` in meta.json,
|
|
1468
|
+
which is the screen's *kind* — list, detail, form…)
|
|
1469
|
+
|
|
1470
|
+
## Rules & decisions
|
|
1471
|
+
|
|
1472
|
+
Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
|
|
1473
|
+
cases — and the decisions you've made and why, so they aren't re-litigated later.
|
|
1474
|
+
`;
|
|
1044
1475
|
var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
|
|
1045
1476
|
export const colors = {
|
|
1046
1477
|
bg: "#101114",
|
|
@@ -1110,24 +1541,60 @@ This is an intent-capture stage — screens run on inline mock data, before any
|
|
|
1110
1541
|
|
|
1111
1542
|
\`meta.json\` is the app map: every screen (position, description) and the edges between them
|
|
1112
1543
|
(navigation intent). **Read it before opening any screen file.** It is the human's file — the
|
|
1113
|
-
canvas and the developer own positions and descriptions.
|
|
1114
|
-
|
|
1115
|
-
|
|
1544
|
+
canvas and the developer own positions and descriptions. When you create a screen, add its meta
|
|
1545
|
+
entry **without a \`pos\`** and add edges — the board **auto-places it next to its flow neighbours**,
|
|
1546
|
+
so you never hand-pick coordinates (you can't see the board, and positions are the human's to own).
|
|
1547
|
+
\`lecodes design arrange --new\` optionally writes those auto-placements into meta.json; the developer's
|
|
1548
|
+
⇥ Arrange button re-tidies the whole board. Never reposition or rewrite existing human-authored
|
|
1549
|
+
positions or descriptions.
|
|
1116
1550
|
|
|
1117
1551
|
\`\`\`json
|
|
1118
1552
|
{
|
|
1119
|
-
"screens": {
|
|
1120
|
-
|
|
1553
|
+
"screens": {
|
|
1554
|
+
"login": { "desc": "Phone login", "group": "Onboarding", "role": "auth" },
|
|
1555
|
+
"code": { "group": "Onboarding", "role": "auth" }
|
|
1556
|
+
},
|
|
1557
|
+
"groups": { "Onboarding": { "desc": "First-run flow" } },
|
|
1558
|
+
"edges": [
|
|
1559
|
+
{ "from": "login", "to": "code", "label": "after phone entry", "activator": "submit" },
|
|
1560
|
+
{ "from": "code", "to": "home@empty", "label": "first sign-in" }
|
|
1561
|
+
]
|
|
1121
1562
|
}
|
|
1122
1563
|
\`\`\`
|
|
1123
1564
|
|
|
1124
1565
|
- An edge's optional \`activator\` names the element (its \`name\` style option) that triggers the
|
|
1125
1566
|
transition. Give a \`name\` to interactive elements an edge refers to — nothing else needs names.
|
|
1567
|
+
- **Group each screen** you create by its user-facing area — \`"group": "Onboarding"\` — 2–6 screens
|
|
1568
|
+
per group (Onboarding, Checkout, Settings…). Groups render as labeled regions and \`arrange\` lays
|
|
1569
|
+
each out as its own sub-flow, so grouping keeps big boards legible. Semantic clustering is your
|
|
1570
|
+
job (you're good at it); coordinates are \`arrange\`'s (it can see the board, you can't). Don't
|
|
1571
|
+
regroup existing screens unless asked — groups are part of the human's map.
|
|
1572
|
+
- **Tag a screen's \`role\`** in its meta entry — its *kind* (\`list\`, \`detail\`, \`form\`, \`auth\`,
|
|
1573
|
+
\`modal\`…). Optional, renders on the board, captures concept the screen source can't;
|
|
1574
|
+
\`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
|
|
1575
|
+
a separate thing — declared in the source, not meta; see "Screen states" below.)*
|
|
1576
|
+
|
|
1577
|
+
## The concept — spec.md
|
|
1578
|
+
|
|
1579
|
+
\`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
|
|
1580
|
+
half — what never renders as a screen and doesn't fit meta.json: the **data model** (entities +
|
|
1581
|
+
fields), the **actors** (who uses the app + their permissions), and the **rules & decisions**
|
|
1582
|
+
(validation, edge cases, and the choices behind them). Write down here what you'd otherwise only
|
|
1583
|
+
keep in the chat.
|
|
1584
|
+
|
|
1585
|
+
- It's prose (markdown), co-owned by you and the developer — edit it with your normal Read/Edit
|
|
1586
|
+
(there's no board writer for it). Half-filled is fine.
|
|
1587
|
+
- The **mock data at the top of each screen is the first draft of the data model** — promote the
|
|
1588
|
+
entities that stabilize into spec.md's tables.
|
|
1589
|
+
- Link a screen with \`[[screen-id]]\`. \`lecodes design check\` (and the \`check_concept\` MCP tool)
|
|
1590
|
+
warn when a link names a screen that doesn't exist, and list screens with no \`role\` yet —
|
|
1591
|
+
coherence, never a blocker.
|
|
1126
1592
|
|
|
1127
1593
|
## Screen rules
|
|
1128
1594
|
|
|
1129
|
-
- **One file = one screen.** \`screens/<id>.ts\` default-exports
|
|
1130
|
-
\`export default UIScreen([ … ]).style({ … })
|
|
1595
|
+
- **One file = one screen.** \`screens/<id>.ts\` default-exports a screen — either a value
|
|
1596
|
+
(\`export default UIScreen([ … ]).style({ … })\`) or a **function of state** for multi-state
|
|
1597
|
+
screens (see "Screen states" below). The filename is the screen's identity.
|
|
1131
1598
|
- **Styles go through the chained \`.style({ … })\` method** — e.g.
|
|
1132
1599
|
\`UIText("Today").style({ fontSize: 28, fontWeight: 700 })\`. Passing a style object as the
|
|
1133
1600
|
first argument is a legacy form.
|
|
@@ -1139,28 +1606,111 @@ is clear, but never reposition or rewrite existing human-authored descriptions.
|
|
|
1139
1606
|
every screen. Promoting a repeated pattern into \`shared/\` is an explicit, deliberate step.
|
|
1140
1607
|
- The LeCodes UI API (\`UIScreen\`, \`UIColumn\`, \`UIRow\`, \`UIBox\`, \`UIText\`, \`UIImage\`,
|
|
1141
1608
|
\`UIButton\`, \`UIInput\`, \`UISpacer\`, \`UIScrollable\`, …) is **global** — no imports needed.
|
|
1142
|
-
Colors must be hex strings.
|
|
1609
|
+
Colors must be hex strings. This folder ships a \`tsconfig.json\` + bundled SDK types
|
|
1610
|
+
(\`.lecodes/types/\`), so the editor resolves those globals — and \`UINode\` / \`UINodeChild\` for
|
|
1611
|
+
typing a component's return value or a container's children — with no imports. Trust the types;
|
|
1612
|
+
don't fall back to \`any\`.
|
|
1613
|
+
|
|
1614
|
+
## Screen states (variants)
|
|
1615
|
+
|
|
1616
|
+
A screen often has more than one look — a list vs. its empty state, a form vs. its error state.
|
|
1617
|
+
Instead of a second file per variant, make the default export a **function of a state string whose
|
|
1618
|
+
type is a string-literal union** — the union IS the state list (no separate schema):
|
|
1619
|
+
|
|
1620
|
+
\`\`\`ts
|
|
1621
|
+
export default (state: "default" | "empty" = "default") => UIScreen([
|
|
1622
|
+
Header(child),
|
|
1623
|
+
state === "empty" ? EmptyState("Nothing here yet") : UIScrollable(items.map(Card)),
|
|
1624
|
+
TabBar(tabs, "home"),
|
|
1625
|
+
])
|
|
1626
|
+
\`\`\`
|
|
1627
|
+
|
|
1628
|
+
- The **union members are the states**, in written order. It may be written inline (as above) or as
|
|
1629
|
+
a named alias in the same file (\`type State = "default" | "empty"; export default (s: State) => …\`).
|
|
1630
|
+
Give the parameter a **default** (\`= "default"\`) — that's the *canonical* state (what a bare
|
|
1631
|
+
render, snapshot, and the board's initial view show). No default → the first member is canonical.
|
|
1632
|
+
- When the data differs per state, derive it from \`state\` at the top of the function — then it's
|
|
1633
|
+
just layout: \`const items = state === "empty" ? [] : MOCK\`.
|
|
1634
|
+
- State ids are lowercase \`[a-z0-9-]\` (filename-safe, no \`@\`). A value export — or a function whose
|
|
1635
|
+
parameter isn't a literal union — is a single state named \`default\` (fully backward compatible).
|
|
1636
|
+
- **Check a state:** \`render_screen\` takes a \`state\`; the board shows a switcher on multi-state
|
|
1637
|
+
screens; \`lecodes design snapshot <id> --state <name>\` / \`--all-states\` write \`<id>@<state>.png\`.
|
|
1638
|
+
- **Edges can target a state:** an edge's \`to\` (or \`from\`) may be \`"<id>@<state>"\` (e.g.
|
|
1639
|
+
\`"home@empty"\`) — still one node per screen. \`lecodes design check\` errors on an edge pointing at
|
|
1640
|
+
a state the screen doesn't declare.
|
|
1641
|
+
|
|
1642
|
+
## Icons
|
|
1643
|
+
|
|
1644
|
+
Use real icons instead of emoji. \`assetIcon("pack:name")\` is a **compile-time macro**: it vendors
|
|
1645
|
+
one SVG from the icon registry at build time and returns an image source (like \`SvgSource(...)\`), so
|
|
1646
|
+
screens render offline and deterministically in headless snapshots — no network at render time.
|
|
1647
|
+
|
|
1648
|
+
\`\`\`ts
|
|
1649
|
+
UIImage(assetIcon("lucide:bell", { color: colors.accent })).style({ width: 24, height: 24 })
|
|
1650
|
+
UIImage(assetIcon("lucide:check")).style({ width: 20, height: 20, tintColor: colors.muted })
|
|
1651
|
+
\`\`\`
|
|
1652
|
+
|
|
1653
|
+
- Pass the result straight to \`UIImage(...)\`. Recolor it with the \`{ color }\` option — a hex **literal**
|
|
1654
|
+
is baked into the SVG at compile time, a **token/expression** (\`{ color: colors.accent }\`) is applied
|
|
1655
|
+
as a tint — or with the \`tintColor\` **style** prop (\`.style({ tintColor: … })\`; there is no
|
|
1656
|
+
\`.tintColor()\` method). All of these render colored in headless snapshots. The \`"pack:name"\` id must
|
|
1657
|
+
be a **string literal** (it's resolved at compile time).
|
|
1658
|
+
- Browse packs and names at **https://icon-registry.jt3.ru**. Common packs: \`lucide\`, \`tabler\`,
|
|
1659
|
+
\`heroicons\`, \`feather\`, \`bi\`, \`carbon\`.
|
|
1660
|
+
- Vendored SVGs land in \`assets/icons/\`, tracked by \`icons.lock.json\` + \`ICON-NOTICES.md\`
|
|
1661
|
+
(attribution) — **commit these**; they make rebuilds offline. An unknown \`pack:name\` is a clear
|
|
1662
|
+
compile error.
|
|
1143
1663
|
|
|
1144
1664
|
## See what you built
|
|
1145
1665
|
|
|
1146
1666
|
The developer watches the canvas live (screens hot-reload on save). To see a screen yourself:
|
|
1147
1667
|
|
|
1668
|
+
**When the design server is running (it usually is), prefer the MCP tools** — they use the
|
|
1669
|
+
server's warm compile cache and return images inline, no files on disk:
|
|
1670
|
+
|
|
1671
|
+
- \`render_screen\` (id, format: json | png | both, state?) — what a screen actually looks like
|
|
1672
|
+
(pass \`state\` to render a specific state; default is canonical).
|
|
1673
|
+
- \`check_screens\` (ids?) — fast compile check after an edit batch; no render.
|
|
1674
|
+
- \`check_concept\` — spec.md ↔ screens coherence + which screens still lack a \`role\`.
|
|
1675
|
+
- \`design_state\` — the live screens + edges (fresher than re-reading meta.json).
|
|
1676
|
+
- \`board_activity\` (since?) — what the developer just did on the canvas + which screen is
|
|
1677
|
+
selected; call it at the start of a turn.
|
|
1678
|
+
|
|
1679
|
+
Fall back to the CLI when the tools report the server is down:
|
|
1680
|
+
|
|
1148
1681
|
\`\`\`sh
|
|
1149
|
-
lecodes design snapshot <id>
|
|
1150
|
-
lecodes design snapshot <id> --png
|
|
1151
|
-
lecodes design snapshot
|
|
1682
|
+
lecodes design snapshot <id> # semantic JSON of the laid-out screen → stdout
|
|
1683
|
+
lecodes design snapshot <id> --png # + PNG screenshot → design/.snapshots/<id>.png
|
|
1684
|
+
lecodes design snapshot <id> --state empty # one state → design/.snapshots/<id>@empty.png|.json
|
|
1685
|
+
lecodes design snapshot <id> --all-states # every state → design/.snapshots/<id>@<state>.png|.json
|
|
1686
|
+
lecodes design snapshot # all screens → design/.snapshots/
|
|
1687
|
+
lecodes design check # concept + flow coherence (spec.md links, states, untagged)
|
|
1152
1688
|
\`\`\`
|
|
1153
1689
|
|
|
1154
|
-
|
|
1155
|
-
unreadable contrast, off-screen content) before reporting back.
|
|
1690
|
+
Either way: after every screen you create or change, look at it and fix what's wrong (overflow,
|
|
1691
|
+
overlap, unreadable contrast, off-screen content) before reporting back.
|
|
1156
1692
|
|
|
1157
1693
|
## Workflow
|
|
1158
1694
|
|
|
1159
|
-
1.
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1695
|
+
1. \`board_activity\` (if the server is up), then read \`meta.json\`, the \`shared/\` signatures, and
|
|
1696
|
+
only the screens you need.
|
|
1697
|
+
2. Create/edit screen files; register new screens in \`meta.json\` **without \`pos\`** and add edges —
|
|
1698
|
+
they auto-place next to their neighbours on the board. (\`lecodes design arrange --new\` commits
|
|
1699
|
+
those positions to meta.json if you want them written.)
|
|
1700
|
+
3. \`check_screens\` → \`render_screen\` (or \`lecodes design snapshot\`) → verify → fix.
|
|
1701
|
+
4. When the developer edits a description, text, or the flow on the canvas — visible in
|
|
1702
|
+
\`board_activity\` and in \`meta.json\`/the screen source — treat it as the updated spec.
|
|
1703
|
+
`;
|
|
1704
|
+
var DESIGN_MACROS_DTS = `// Generated by \`lecodes design init\`. Globals the design compiler resolves — not shipped SDK runtime.
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Vendor one icon from the icon registry at build time and return an image source (the same shape
|
|
1708
|
+
* SvgSource(...) returns). \`id\` is "pack:name" (e.g. "lucide:bell"); pass the result to UIImage(...).
|
|
1709
|
+
* Recolor with the { color } option — a hex literal is baked into the SVG, a token
|
|
1710
|
+
* (\`{ color: colors.accent }\`) is applied as a tint — or the \`tintColor\` style prop. The id must be a
|
|
1711
|
+
* string literal (resolved, and the SVG vendored, at compile time).
|
|
1712
|
+
*/
|
|
1713
|
+
declare function assetIcon(id: string, opts?: { color?: string }): { readonly svg: string, tintColor: string | null }
|
|
1164
1714
|
`;
|
|
1165
1715
|
var README_HINT = `# design/
|
|
1166
1716
|
|
|
@@ -1187,48 +1737,72 @@ var getContext = (args) => {
|
|
|
1187
1737
|
manifest,
|
|
1188
1738
|
name: manifest?.name ?? basename2(root),
|
|
1189
1739
|
dirName,
|
|
1190
|
-
designDir:
|
|
1740
|
+
designDir: join11(root, dirName),
|
|
1191
1741
|
designKey: "/" + dirName.replace(/\\/g, "/")
|
|
1192
1742
|
};
|
|
1193
1743
|
};
|
|
1194
1744
|
var listScreens = (designDir) => {
|
|
1195
|
-
const dir =
|
|
1196
|
-
if (!
|
|
1745
|
+
const dir = join11(designDir, "screens");
|
|
1746
|
+
if (!existsSync9(dir))
|
|
1197
1747
|
return [];
|
|
1198
1748
|
return readdirSync5(dir).filter((f) => f.endsWith(".ts") && !f.startsWith(".")).map((f) => f.slice(0, -3)).sort();
|
|
1199
1749
|
};
|
|
1200
1750
|
var readMetaSizes = (designDir) => {
|
|
1201
1751
|
try {
|
|
1202
|
-
const meta = JSON.parse(
|
|
1752
|
+
const meta = JSON.parse(readFileSync9(join11(designDir, "meta.json"), "utf8"));
|
|
1203
1753
|
return meta?.screens ?? {};
|
|
1204
1754
|
} catch {
|
|
1205
1755
|
return {};
|
|
1206
1756
|
}
|
|
1207
1757
|
};
|
|
1208
|
-
var
|
|
1758
|
+
var writeMcpConfig = (ctx, port) => {
|
|
1759
|
+
const path = join11(ctx.root, ".mcp.json");
|
|
1760
|
+
const entry = { type: "http", url: `http://localhost:${port}/mcp` };
|
|
1761
|
+
let config = {};
|
|
1762
|
+
if (existsSync9(path)) {
|
|
1763
|
+
try {
|
|
1764
|
+
config = JSON.parse(readFileSync9(path, "utf8"));
|
|
1765
|
+
} catch {
|
|
1766
|
+
warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
config.mcpServers ??= {};
|
|
1771
|
+
const existing = config.mcpServers["lecodes-design"];
|
|
1772
|
+
if (existing?.url === entry.url) {
|
|
1773
|
+
note("kept .mcp.json (lecodes-design MCP server)");
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
config.mcpServers["lecodes-design"] = entry;
|
|
1777
|
+
writeFileSync8(path, JSON.stringify(config, null, 2) + `
|
|
1778
|
+
`);
|
|
1779
|
+
log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`);
|
|
1780
|
+
};
|
|
1781
|
+
var init = async (ctx, args) => {
|
|
1209
1782
|
const files = [
|
|
1210
1783
|
["meta.json", META_JSON],
|
|
1784
|
+
["spec.md", SPEC_MD],
|
|
1211
1785
|
["README.md", README_HINT],
|
|
1212
1786
|
["CLAUDE.md", CLAUDE_MD],
|
|
1213
|
-
[
|
|
1214
|
-
[
|
|
1787
|
+
[join11("shared", "tokens.ts"), TOKENS_TS],
|
|
1788
|
+
[join11("screens", "home.ts"), HOME_SCREEN_TS]
|
|
1215
1789
|
];
|
|
1216
|
-
|
|
1217
|
-
|
|
1790
|
+
mkdirSync8(join11(ctx.designDir, "screens"), { recursive: true });
|
|
1791
|
+
mkdirSync8(join11(ctx.designDir, "shared"), { recursive: true });
|
|
1218
1792
|
let created = 0;
|
|
1219
1793
|
for (const [rel, content] of files) {
|
|
1220
|
-
const abs =
|
|
1221
|
-
if (
|
|
1794
|
+
const abs = join11(ctx.designDir, rel);
|
|
1795
|
+
if (existsSync9(abs)) {
|
|
1222
1796
|
note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
|
|
1223
1797
|
continue;
|
|
1224
1798
|
}
|
|
1225
|
-
|
|
1799
|
+
writeFileSync8(abs, content);
|
|
1226
1800
|
log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
|
|
1227
1801
|
created++;
|
|
1228
1802
|
}
|
|
1229
|
-
const ignorePath =
|
|
1803
|
+
const ignorePath = join11(ctx.root, ".lecodesignore");
|
|
1230
1804
|
const ignoreLine = `${ctx.dirName}/.snapshots/`;
|
|
1231
|
-
const current =
|
|
1805
|
+
const current = existsSync9(ignorePath) ? readFileSync9(ignorePath, "utf8") : "";
|
|
1232
1806
|
if (!current.split(/\r?\n/).includes(ignoreLine)) {
|
|
1233
1807
|
appendFileSync(ignorePath, (current && !current.endsWith(`
|
|
1234
1808
|
`) ? `
|
|
@@ -1236,6 +1810,14 @@ var init = async (ctx) => {
|
|
|
1236
1810
|
`);
|
|
1237
1811
|
log(`${c.green("ignored")} ${ignoreLine} (in .lecodesignore)`);
|
|
1238
1812
|
}
|
|
1813
|
+
writeMcpConfig(ctx, Number(flagStr(args, "port")) || DEFAULT_PORT);
|
|
1814
|
+
const wroteTypes = materializeTypesLocal(ctx.designDir);
|
|
1815
|
+
writeTsconfig(ctx.designDir);
|
|
1816
|
+
mkdirSync8(join11(ctx.designDir, ".lecodes", "types"), { recursive: true });
|
|
1817
|
+
writeFileSync8(join11(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS);
|
|
1818
|
+
log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`);
|
|
1819
|
+
if (!wroteTypes)
|
|
1820
|
+
warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.");
|
|
1239
1821
|
log("");
|
|
1240
1822
|
log(created ? "Design canvas ready." : "Design canvas already initialized.");
|
|
1241
1823
|
log(` ${c.bold("lecodes design")} start the canvas (browser)`);
|
|
@@ -1252,12 +1834,28 @@ var snapshot = async (ctx, args) => {
|
|
|
1252
1834
|
throw new CliError(`No screen "${id}" (have: ${all.join(", ")}).`);
|
|
1253
1835
|
}
|
|
1254
1836
|
const targets = requested.length ? requested : all;
|
|
1837
|
+
const stateFlag = flagStr(args, "state");
|
|
1838
|
+
const allStates = flagBool(args, "all-states");
|
|
1839
|
+
if (stateFlag !== undefined && allStates)
|
|
1840
|
+
throw new CliError("Use either --state <name> or --all-states, not both.");
|
|
1255
1841
|
let renderer;
|
|
1256
1842
|
try {
|
|
1257
1843
|
renderer = await import("lecodes-renderer/headless");
|
|
1258
1844
|
} catch {
|
|
1259
1845
|
throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
|
|
1260
1846
|
}
|
|
1847
|
+
let discover = null;
|
|
1848
|
+
if (stateFlag !== undefined || allStates) {
|
|
1849
|
+
try {
|
|
1850
|
+
const ds = await import("lecodes-design/server");
|
|
1851
|
+
discover = (src) => {
|
|
1852
|
+
const d = ds.discoverScreenStates(src);
|
|
1853
|
+
return { states: d.states, canonical: d.canonical };
|
|
1854
|
+
};
|
|
1855
|
+
} catch {
|
|
1856
|
+
throw new CliError("Rendering specific states needs the design canvas. Run: npm i -D lecodes-design");
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1261
1859
|
const killer = setTimeout(() => {
|
|
1262
1860
|
warnErr("Snapshot timed out after 120s.");
|
|
1263
1861
|
process.exit(1);
|
|
@@ -1265,45 +1863,68 @@ var snapshot = async (ctx, args) => {
|
|
|
1265
1863
|
killer.unref?.();
|
|
1266
1864
|
const png = flagBool(args, "png") || flagStr(args, "png") !== undefined;
|
|
1267
1865
|
const sizes = readMetaSizes(ctx.designDir);
|
|
1268
|
-
const outDir = flagStr(args, "out-dir") ??
|
|
1269
|
-
const single = targets.length === 1 && requested.length === 1 && !png && !flagStr(args, "out-dir");
|
|
1866
|
+
const outDir = flagStr(args, "out-dir") ?? join11(ctx.designDir, ".snapshots");
|
|
1867
|
+
const single = targets.length === 1 && requested.length === 1 && !png && !allStates && !flagStr(args, "out-dir");
|
|
1270
1868
|
const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
|
|
1271
1869
|
if (!single)
|
|
1272
|
-
|
|
1870
|
+
mkdirSync8(outDir, { recursive: true });
|
|
1273
1871
|
for (const id of targets) {
|
|
1274
|
-
note(`Rendering ${id}…`);
|
|
1275
|
-
const js = await compileDesignScreen({
|
|
1276
|
-
root: ctx.root,
|
|
1277
|
-
manifest: ctx.manifest,
|
|
1278
|
-
name: ctx.name,
|
|
1279
|
-
designKey: ctx.designKey,
|
|
1280
|
-
screenId: id,
|
|
1281
|
-
assets: "disk"
|
|
1282
|
-
});
|
|
1283
1872
|
const [width, height] = sizes[id]?.size ?? [390, 844];
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1873
|
+
let plan = [{ state: undefined, bases: [id] }];
|
|
1874
|
+
if (discover) {
|
|
1875
|
+
const { states, canonical } = discover(readFileSync9(join11(ctx.designDir, "screens", `${id}.ts`), "utf8"));
|
|
1876
|
+
if (stateFlag !== undefined) {
|
|
1877
|
+
if (!states.includes(stateFlag)) {
|
|
1878
|
+
if (requested.length === 1)
|
|
1879
|
+
throw new CliError(`No state "${stateFlag}" on ${id} (has: ${states.join(", ")}).`);
|
|
1880
|
+
warnErr(`${id}: no state "${stateFlag}" (has: ${states.join(", ")}) — skipped.`);
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
plan = [{ state: stateFlag, bases: [`${id}@${stateFlag}`] }];
|
|
1884
|
+
} else {
|
|
1885
|
+
plan = states.map((st) => ({ state: st, bases: st === canonical ? [`${id}@${st}`, id] : [`${id}@${st}`] }));
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
for (const { state, bases } of plan) {
|
|
1889
|
+
const tag = state ? `${id}@${state}` : id;
|
|
1890
|
+
note(`Rendering ${tag}…`);
|
|
1891
|
+
const js = await compileDesignScreen({
|
|
1892
|
+
root: ctx.root,
|
|
1893
|
+
manifest: ctx.manifest,
|
|
1894
|
+
name: ctx.name,
|
|
1895
|
+
designDir: ctx.designDir,
|
|
1896
|
+
designKey: ctx.designKey,
|
|
1897
|
+
screenId: id,
|
|
1898
|
+
state,
|
|
1899
|
+
assets: "disk"
|
|
1900
|
+
});
|
|
1901
|
+
const result = await renderer.renderToJson(js, { width, height, localAssets: true, onConsole });
|
|
1902
|
+
for (const w of result.warnings)
|
|
1903
|
+
warnErr(`${tag}: ${w}`);
|
|
1904
|
+
if (single) {
|
|
1905
|
+
const text = result.text.endsWith(`
|
|
1289
1906
|
`) ? result.text : result.text + `
|
|
1290
1907
|
`;
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
for (const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1908
|
+
process.stdout.write(text);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const pngResult = png ? await renderer.renderToPng(js, { width, height, localAssets: true, scale: 2, onConsole }) : null;
|
|
1912
|
+
if (pngResult)
|
|
1913
|
+
for (const w of pngResult.warnings)
|
|
1914
|
+
warnErr(`${tag}: ${w}`);
|
|
1915
|
+
for (const base of bases) {
|
|
1916
|
+
writeFileSync8(join11(outDir, `${base}.json`), result.text);
|
|
1917
|
+
log(`${c.green("✓")} ${tag} → ${join11(outDir, `${base}.json`)}`);
|
|
1918
|
+
if (pngResult) {
|
|
1919
|
+
writeFileSync8(join11(outDir, `${base}.png`), pngResult.png);
|
|
1920
|
+
log(`${c.green("✓")} ${tag} → ${join11(outDir, `${base}.png`)}`);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1302
1923
|
}
|
|
1303
1924
|
}
|
|
1304
1925
|
};
|
|
1305
1926
|
var serve = async (ctx, args) => {
|
|
1306
|
-
if (!
|
|
1927
|
+
if (!existsSync9(ctx.designDir)) {
|
|
1307
1928
|
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
|
|
1308
1929
|
}
|
|
1309
1930
|
let designServer;
|
|
@@ -1313,30 +1934,86 @@ var serve = async (ctx, args) => {
|
|
|
1313
1934
|
throw new CliError("The design canvas isn't installed. Run: npm i -D lecodes-design");
|
|
1314
1935
|
}
|
|
1315
1936
|
const port = Number(flagStr(args, "port")) || DEFAULT_PORT;
|
|
1937
|
+
let renderer = null;
|
|
1938
|
+
try {
|
|
1939
|
+
renderer = await import("lecodes-renderer/headless");
|
|
1940
|
+
} catch {
|
|
1941
|
+
renderer = null;
|
|
1942
|
+
}
|
|
1943
|
+
const renderScreen = renderer ? async (id, format, state) => {
|
|
1944
|
+
const r = renderer;
|
|
1945
|
+
const js = await compileDesignScreen({
|
|
1946
|
+
root: ctx.root,
|
|
1947
|
+
manifest: ctx.manifest,
|
|
1948
|
+
name: ctx.name,
|
|
1949
|
+
designDir: ctx.designDir,
|
|
1950
|
+
designKey: ctx.designKey,
|
|
1951
|
+
screenId: id,
|
|
1952
|
+
state,
|
|
1953
|
+
assets: "disk"
|
|
1954
|
+
});
|
|
1955
|
+
const [width, height] = readMetaSizes(ctx.designDir)[id]?.size ?? [390, 844];
|
|
1956
|
+
const warnings = [];
|
|
1957
|
+
let json;
|
|
1958
|
+
let png;
|
|
1959
|
+
if (format === "json" || format === "both") {
|
|
1960
|
+
const res = await r.renderToJson(js, { width, height, localAssets: true });
|
|
1961
|
+
json = res.text;
|
|
1962
|
+
warnings.push(...res.warnings);
|
|
1963
|
+
}
|
|
1964
|
+
if (format === "png" || format === "both") {
|
|
1965
|
+
const res = await r.renderToPng(js, { width, height, localAssets: true, scale: 2 });
|
|
1966
|
+
png = res.png;
|
|
1967
|
+
warnings.push(...res.warnings);
|
|
1968
|
+
}
|
|
1969
|
+
return { json, png, warnings };
|
|
1970
|
+
} : undefined;
|
|
1316
1971
|
const server = await designServer.startDesignServer({
|
|
1317
1972
|
root: ctx.root,
|
|
1318
1973
|
designDir: ctx.designDir,
|
|
1319
1974
|
name: ctx.name,
|
|
1320
1975
|
port,
|
|
1321
1976
|
log: note,
|
|
1322
|
-
compileScreen: async (id) => ({
|
|
1977
|
+
compileScreen: async (id, state) => ({
|
|
1323
1978
|
js: await compileDesignScreen({
|
|
1324
1979
|
root: ctx.root,
|
|
1325
1980
|
manifest: ctx.manifest,
|
|
1326
1981
|
name: ctx.name,
|
|
1982
|
+
designDir: ctx.designDir,
|
|
1327
1983
|
designKey: ctx.designKey,
|
|
1328
1984
|
screenId: id,
|
|
1985
|
+
state,
|
|
1329
1986
|
assets: "server"
|
|
1330
1987
|
}),
|
|
1331
1988
|
warnings: []
|
|
1332
|
-
})
|
|
1989
|
+
}),
|
|
1990
|
+
renderScreen
|
|
1333
1991
|
});
|
|
1334
1992
|
log(`${c.bold("LeCodes Design")} — ${ctx.name}`);
|
|
1335
1993
|
log(`Canvas: ${c.bold(server.url)}`);
|
|
1994
|
+
log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm i -D lecodes-renderer)")}`);
|
|
1336
1995
|
log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`);
|
|
1337
1996
|
if (!flagBool(args, "no-open"))
|
|
1338
1997
|
openBrowser(server.url);
|
|
1339
1998
|
};
|
|
1999
|
+
var arrange = async (ctx, args) => {
|
|
2000
|
+
if (!existsSync9(ctx.designDir)) {
|
|
2001
|
+
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
|
|
2002
|
+
}
|
|
2003
|
+
let designServer;
|
|
2004
|
+
try {
|
|
2005
|
+
designServer = await import("lecodes-design/server");
|
|
2006
|
+
} catch {
|
|
2007
|
+
throw new CliError("The design canvas isn't installed. Run: npm i -D lecodes-design");
|
|
2008
|
+
}
|
|
2009
|
+
const onlyNew = flagBool(args, "new");
|
|
2010
|
+
const summary = designServer.arrangeDesign(ctx.designDir, { onlyNew });
|
|
2011
|
+
if (!summary.changed) {
|
|
2012
|
+
log(onlyNew ? "No unplaced screens — nothing to arrange." : "Already tidy — the current layout wins, nothing moved.");
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json.`);
|
|
2016
|
+
};
|
|
1340
2017
|
var share = async (ctx, args) => {
|
|
1341
2018
|
if (!ctx.manifest) {
|
|
1342
2019
|
throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).");
|
|
@@ -1355,22 +2032,73 @@ var share = async (ctx, args) => {
|
|
|
1355
2032
|
log(` ${c.bold(res.url ?? "")}`);
|
|
1356
2033
|
note("Turn it off with: lecodes design share --off");
|
|
1357
2034
|
};
|
|
2035
|
+
var check = async (ctx, args) => {
|
|
2036
|
+
if (!existsSync9(ctx.designDir)) {
|
|
2037
|
+
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
|
|
2038
|
+
}
|
|
2039
|
+
let designServer;
|
|
2040
|
+
try {
|
|
2041
|
+
designServer = await import("lecodes-design/server");
|
|
2042
|
+
} catch {
|
|
2043
|
+
throw new CliError("The design canvas isn't installed. Run: npm i -D lecodes-design");
|
|
2044
|
+
}
|
|
2045
|
+
const state = designServer.readDesignState(ctx.designDir);
|
|
2046
|
+
const concept = designServer.conceptReport(ctx.designDir, state.screens);
|
|
2047
|
+
if (state.warnings.length) {
|
|
2048
|
+
log(c.bold(`Warnings (${state.warnings.length})`));
|
|
2049
|
+
for (const w of state.warnings)
|
|
2050
|
+
log(` ${c.yellow("!")} ${w}`);
|
|
2051
|
+
log("");
|
|
2052
|
+
}
|
|
2053
|
+
log(c.bold("Concept"));
|
|
2054
|
+
log(` spec.md: ${concept.hasSpec ? c.green("present") : c.dim("missing (optional)")}`);
|
|
2055
|
+
if (concept.screenRefs.length) {
|
|
2056
|
+
const resolved = concept.screenRefs.filter((r) => r.exists).length;
|
|
2057
|
+
log(` [[links]]: ${resolved}/${concept.screenRefs.length} resolve`);
|
|
2058
|
+
}
|
|
2059
|
+
if (concept.screensWithoutRole.length)
|
|
2060
|
+
log(` ${c.dim("no role:")} ${concept.screensWithoutRole.join(", ")}`);
|
|
2061
|
+
log("");
|
|
2062
|
+
const stateful = state.screens.filter((s) => s.states.length > 1);
|
|
2063
|
+
const undiscoverable = state.screens.filter((s) => s.statesWarning);
|
|
2064
|
+
if (stateful.length || undiscoverable.length) {
|
|
2065
|
+
log(c.bold("States"));
|
|
2066
|
+
for (const s of stateful) {
|
|
2067
|
+
log(` ${s.id}: ${s.states.map((st) => st === s.canonical ? c.green(st) : st).join(", ")}`);
|
|
2068
|
+
}
|
|
2069
|
+
for (const s of undiscoverable)
|
|
2070
|
+
log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`);
|
|
2071
|
+
log("");
|
|
2072
|
+
}
|
|
2073
|
+
const hard = state.warnings.length;
|
|
2074
|
+
if (hard === 0) {
|
|
2075
|
+
const n = state.screens.length;
|
|
2076
|
+
log(`${c.green("✓")} No coherence problems${n ? ` across ${n} screen${n === 1 ? "" : "s"}` : ""}.`);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
log(`${c.red("✗")} ${hard} coherence warning${hard === 1 ? "" : "s"} — see above.`);
|
|
2080
|
+
process.exitCode = 1;
|
|
2081
|
+
};
|
|
1358
2082
|
var design = async (args) => {
|
|
1359
2083
|
const sub = args._[0] ?? "serve";
|
|
1360
2084
|
const ctx = getContext(args);
|
|
1361
2085
|
if (sub === "serve")
|
|
1362
2086
|
return serve(ctx, args);
|
|
1363
2087
|
if (sub === "init")
|
|
1364
|
-
return init(ctx);
|
|
2088
|
+
return init(ctx, args);
|
|
1365
2089
|
if (sub === "snapshot")
|
|
1366
2090
|
return snapshot(ctx, args);
|
|
2091
|
+
if (sub === "arrange")
|
|
2092
|
+
return arrange(ctx, args);
|
|
2093
|
+
if (sub === "check")
|
|
2094
|
+
return check(ctx, args);
|
|
1367
2095
|
if (sub === "share")
|
|
1368
2096
|
return share(ctx, args);
|
|
1369
|
-
throw new CliError(`Unknown design subcommand "${sub}" (serve | init | snapshot | share).`);
|
|
2097
|
+
throw new CliError(`Unknown design subcommand "${sub}" (serve | init | snapshot | arrange | check | share).`);
|
|
1370
2098
|
};
|
|
1371
2099
|
|
|
1372
2100
|
// src/serverDiff.ts
|
|
1373
|
-
import { readFileSync as
|
|
2101
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
1374
2102
|
import { basename as basename3 } from "node:path";
|
|
1375
2103
|
var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
|
|
1376
2104
|
var computeServerDiff = (project, scan) => {
|
|
@@ -1389,7 +2117,7 @@ var computeServerDiff = (project, scan) => {
|
|
|
1389
2117
|
const local = localByPath.get(path);
|
|
1390
2118
|
if (local && !server) {
|
|
1391
2119
|
const text = isTextKind(classifyKind(basename3(path)));
|
|
1392
|
-
entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ?
|
|
2120
|
+
entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync10(local.absPath, "utf8") : undefined });
|
|
1393
2121
|
continue;
|
|
1394
2122
|
}
|
|
1395
2123
|
if (server && !local) {
|
|
@@ -1400,12 +2128,12 @@ var computeServerDiff = (project, scan) => {
|
|
|
1400
2128
|
if (server && local) {
|
|
1401
2129
|
if (isTextAsset(server.type)) {
|
|
1402
2130
|
const serverText = server.text ?? "";
|
|
1403
|
-
const localText =
|
|
2131
|
+
const localText = readFileSync10(local.absPath, "utf8");
|
|
1404
2132
|
if (serverText !== localText)
|
|
1405
2133
|
entries.push({ path, status: "modified", binary: false, serverText, localText });
|
|
1406
2134
|
} else {
|
|
1407
2135
|
const serverSize = server.file?.size;
|
|
1408
|
-
const localSize = Buffer.byteLength(
|
|
2136
|
+
const localSize = Buffer.byteLength(readFileSync10(local.absPath));
|
|
1409
2137
|
if (serverSize !== undefined && serverSize !== localSize) {
|
|
1410
2138
|
entries.push({ path, status: "modified", binary: true });
|
|
1411
2139
|
}
|
|
@@ -1569,11 +2297,11 @@ var login = async (args) => {
|
|
|
1569
2297
|
};
|
|
1570
2298
|
|
|
1571
2299
|
// src/commands/preview.ts
|
|
1572
|
-
import { writeFileSync as
|
|
2300
|
+
import { writeFileSync as writeFileSync9 } from "node:fs";
|
|
1573
2301
|
import { resolve as resolve3 } from "node:path";
|
|
1574
2302
|
|
|
1575
2303
|
// src/compile/collect.ts
|
|
1576
|
-
import { readFileSync as
|
|
2304
|
+
import { readFileSync as readFileSync11 } from "node:fs";
|
|
1577
2305
|
import { basename as basename4 } from "node:path";
|
|
1578
2306
|
var collectEntries = (root, manifest, project) => {
|
|
1579
2307
|
const pathById = buildPaths(project.assets);
|
|
@@ -1598,7 +2326,7 @@ var collectEntries = (root, manifest, project) => {
|
|
|
1598
2326
|
warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
|
|
1599
2327
|
entries.push({ path: file.path, type, shaders });
|
|
1600
2328
|
} else {
|
|
1601
|
-
entries.push({ path: file.path, type, text:
|
|
2329
|
+
entries.push({ path: file.path, type, text: readFileSync11(file.absPath, "utf8") });
|
|
1602
2330
|
}
|
|
1603
2331
|
}
|
|
1604
2332
|
return { entries, warnings };
|
|
@@ -1628,7 +2356,7 @@ var preview = async (args) => {
|
|
|
1628
2356
|
if (out && isNullSink(out)) {
|
|
1629
2357
|
note(`Discarded output (${out})`);
|
|
1630
2358
|
} else if (out) {
|
|
1631
|
-
|
|
2359
|
+
writeFileSync9(resolve3(out), js);
|
|
1632
2360
|
note(`Wrote ${out} (${js.length} bytes)`);
|
|
1633
2361
|
} else {
|
|
1634
2362
|
process.stdout.write(js);
|
|
@@ -1640,7 +2368,7 @@ var preview = async (args) => {
|
|
|
1640
2368
|
};
|
|
1641
2369
|
|
|
1642
2370
|
// src/commands/render.ts
|
|
1643
|
-
import { writeFileSync as
|
|
2371
|
+
import { writeFileSync as writeFileSync10 } from "node:fs";
|
|
1644
2372
|
import { resolve as resolve4 } from "node:path";
|
|
1645
2373
|
var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
|
|
1646
2374
|
var DEVICE_SAFE_AREA = {
|
|
@@ -1737,7 +2465,7 @@ var render = async (args) => {
|
|
|
1737
2465
|
if (isNullSink(pngFlag)) {
|
|
1738
2466
|
note(`Discarded PNG (${pngFlag})`);
|
|
1739
2467
|
} else {
|
|
1740
|
-
|
|
2468
|
+
writeFileSync10(resolve4(pngFlag), png);
|
|
1741
2469
|
note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
|
|
1742
2470
|
}
|
|
1743
2471
|
return done((cb) => cb());
|
|
@@ -1752,7 +2480,7 @@ var render = async (args) => {
|
|
|
1752
2480
|
return done((cb) => cb());
|
|
1753
2481
|
}
|
|
1754
2482
|
if (out) {
|
|
1755
|
-
|
|
2483
|
+
writeFileSync10(resolve4(out), result.text);
|
|
1756
2484
|
note(`Wrote ${out} (${result.text.length} bytes)`);
|
|
1757
2485
|
return done((cb) => cb());
|
|
1758
2486
|
}
|
|
@@ -1763,8 +2491,8 @@ var render = async (args) => {
|
|
|
1763
2491
|
};
|
|
1764
2492
|
|
|
1765
2493
|
// src/commands/pull.ts
|
|
1766
|
-
import { existsSync as
|
|
1767
|
-
import { dirname as
|
|
2494
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync9, rmSync, writeFileSync as writeFileSync11 } from "node:fs";
|
|
2495
|
+
import { dirname as dirname7, join as join12 } from "node:path";
|
|
1768
2496
|
var pull = async (args) => {
|
|
1769
2497
|
const root = findProjectRoot(process.cwd());
|
|
1770
2498
|
const manifest = readManifest(root);
|
|
@@ -1784,8 +2512,8 @@ var pull = async (args) => {
|
|
|
1784
2512
|
const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
|
|
1785
2513
|
for (const path of Object.keys(manifest.files)) {
|
|
1786
2514
|
if (!remotePaths.has(path)) {
|
|
1787
|
-
const abs =
|
|
1788
|
-
if (
|
|
2515
|
+
const abs = join12(root, ...path.replace(/^\//, "").split("/"));
|
|
2516
|
+
if (existsSync10(abs))
|
|
1789
2517
|
rmSync(abs);
|
|
1790
2518
|
}
|
|
1791
2519
|
}
|
|
@@ -1823,18 +2551,18 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
1823
2551
|
const files = { ...manifest.files };
|
|
1824
2552
|
for (const asset of targets) {
|
|
1825
2553
|
const key = pathById.get(asset.id);
|
|
1826
|
-
const abs =
|
|
1827
|
-
|
|
2554
|
+
const abs = join12(root, ...key.replace(/^\//, "").split("/"));
|
|
2555
|
+
mkdirSync9(dirname7(abs), { recursive: true });
|
|
1828
2556
|
let sha;
|
|
1829
2557
|
if (asset.type === "file") {
|
|
1830
2558
|
if (!asset.file?.src)
|
|
1831
2559
|
continue;
|
|
1832
2560
|
const bytes = await downloadBlob(apiUrl, asset.file.src);
|
|
1833
|
-
|
|
2561
|
+
writeFileSync11(abs, bytes);
|
|
1834
2562
|
sha = sha256(bytes);
|
|
1835
2563
|
} else {
|
|
1836
2564
|
const text = asset.text ?? "";
|
|
1837
|
-
|
|
2565
|
+
writeFileSync11(abs, text);
|
|
1838
2566
|
sha = sha256(text);
|
|
1839
2567
|
}
|
|
1840
2568
|
files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
|
|
@@ -1847,7 +2575,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
1847
2575
|
|
|
1848
2576
|
// src/commands/push.ts
|
|
1849
2577
|
import { basename as basename5 } from "node:path";
|
|
1850
|
-
import { readFileSync as
|
|
2578
|
+
import { readFileSync as readFileSync12 } from "node:fs";
|
|
1851
2579
|
var push = async (args) => {
|
|
1852
2580
|
const root = findProjectRoot(process.cwd());
|
|
1853
2581
|
const manifest = readManifest(root);
|
|
@@ -1906,9 +2634,9 @@ var push = async (args) => {
|
|
|
1906
2634
|
if (source)
|
|
1907
2635
|
entry.assetId = source.id;
|
|
1908
2636
|
if (isTextKind(kind)) {
|
|
1909
|
-
entry.text =
|
|
2637
|
+
entry.text = readFileSync12(file.absPath, "utf8");
|
|
1910
2638
|
} else if (!source || source.sha !== file.sha) {
|
|
1911
|
-
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename5(file.path),
|
|
2639
|
+
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename5(file.path), readFileSync12(file.absPath));
|
|
1912
2640
|
entry.blobFileId = blob.id;
|
|
1913
2641
|
uploaded++;
|
|
1914
2642
|
}
|
|
@@ -2049,8 +2777,8 @@ Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKE
|
|
|
2049
2777
|
var commands = { login, create, clone, status, diff, pull, push, preview, render, design };
|
|
2050
2778
|
var readVersion = () => {
|
|
2051
2779
|
try {
|
|
2052
|
-
const here2 =
|
|
2053
|
-
return JSON.parse(
|
|
2780
|
+
const here2 = dirname8(fileURLToPath4(import.meta.url));
|
|
2781
|
+
return JSON.parse(readFileSync13(join13(here2, "..", "package.json"), "utf8")).version ?? "unknown";
|
|
2054
2782
|
} catch {
|
|
2055
2783
|
return "unknown";
|
|
2056
2784
|
}
|