lecodes-cli 0.5.2 → 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 +874 -124
- package/package.json +3 -3
- package/runtime/sdk/inject.ts +3 -0
- package/runtime/sdk/ui/UINode.ts +8 -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
|
|
@@ -170,6 +170,8 @@ var uploadBlob = (apiUrl, token, uuid, name, bytes) => {
|
|
|
170
170
|
};
|
|
171
171
|
var sync = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uuid}/sync`, { token, body: payload });
|
|
172
172
|
var publishProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/publish`, { token, body: {} });
|
|
173
|
+
var enableDesignShare = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/design/share`, { token, body: {} });
|
|
174
|
+
var disableDesignShare = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/design/share`, { token, method: "DELETE" });
|
|
173
175
|
var downloadBlob = async (apiUrl, src) => {
|
|
174
176
|
const resp = await fetch(`${apiUrl}${src}`);
|
|
175
177
|
if (!resp.ok)
|
|
@@ -284,7 +286,8 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
|
|
|
284
286
|
|
|
285
287
|
// src/types.ts
|
|
286
288
|
import { dirname as dirname3, join as join4 } from "node:path";
|
|
287
|
-
import {
|
|
289
|
+
import { fileURLToPath } from "node:url";
|
|
290
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
288
291
|
var TSCONFIG = {
|
|
289
292
|
compilerOptions: {
|
|
290
293
|
target: "ES2020",
|
|
@@ -301,8 +304,7 @@ var TSCONFIG = {
|
|
|
301
304
|
include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
|
|
302
305
|
exclude: ["node_modules"]
|
|
303
306
|
};
|
|
304
|
-
var
|
|
305
|
-
const bundle = await getTypes(apiUrl);
|
|
307
|
+
var writeTypeBundle = (root, bundle) => {
|
|
306
308
|
const typesDir = join4(root, LECODES_DIR, "types");
|
|
307
309
|
for (const [path, text] of Object.entries(bundle)) {
|
|
308
310
|
const dest = join4(typesDir, path);
|
|
@@ -310,12 +312,37 @@ var materializeTypes = async (root, apiUrl) => {
|
|
|
310
312
|
writeFileSync4(dest, text);
|
|
311
313
|
}
|
|
312
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
|
+
};
|
|
313
340
|
var writeTsconfig = (root) => {
|
|
314
341
|
writeFileSync4(join4(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
|
|
315
342
|
};
|
|
316
343
|
|
|
317
344
|
// src/ignore.ts
|
|
318
|
-
import { existsSync as
|
|
345
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
319
346
|
import { join as join5 } from "node:path";
|
|
320
347
|
var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
|
|
321
348
|
var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
@@ -393,9 +420,9 @@ var buildMatcher = (rules) => (key, isDir) => {
|
|
|
393
420
|
var loadIgnore = (root) => {
|
|
394
421
|
const lines = [...BUILTIN_PATTERNS];
|
|
395
422
|
const file = join5(root, ".lecodesignore");
|
|
396
|
-
if (
|
|
423
|
+
if (existsSync3(file)) {
|
|
397
424
|
try {
|
|
398
|
-
lines.push(...
|
|
425
|
+
lines.push(...readFileSync4(file, "utf8").split(/\r?\n/));
|
|
399
426
|
} catch {}
|
|
400
427
|
}
|
|
401
428
|
const rules = lines.map(compile).filter((r) => r !== null);
|
|
@@ -403,7 +430,7 @@ var loadIgnore = (root) => {
|
|
|
403
430
|
};
|
|
404
431
|
var writeDefaultIgnore = (root) => {
|
|
405
432
|
const file = join5(root, ".lecodesignore");
|
|
406
|
-
if (!
|
|
433
|
+
if (!existsSync3(file))
|
|
407
434
|
writeFileSync5(file, DEFAULT_IGNORE_FILE);
|
|
408
435
|
};
|
|
409
436
|
|
|
@@ -426,7 +453,7 @@ var clone = async (args) => {
|
|
|
426
453
|
const project = await getProject(apiUrl, token, uuid);
|
|
427
454
|
const dir = args._[1] ?? slug(project.name);
|
|
428
455
|
const root = resolve(dir);
|
|
429
|
-
if (
|
|
456
|
+
if (existsSync4(root) && readdirSync(root).length > 0) {
|
|
430
457
|
throw new CliError(`Directory "${dir}" already exists and is not empty.`);
|
|
431
458
|
}
|
|
432
459
|
mkdirSync5(root, { recursive: true });
|
|
@@ -446,7 +473,7 @@ var clone = async (args) => {
|
|
|
446
473
|
};
|
|
447
474
|
|
|
448
475
|
// src/commands/create.ts
|
|
449
|
-
import { existsSync as
|
|
476
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync6 } from "node:fs";
|
|
450
477
|
import { join as join6, resolve as resolve2 } from "node:path";
|
|
451
478
|
var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
452
479
|
var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
|
|
@@ -466,7 +493,7 @@ var create = async (args) => {
|
|
|
466
493
|
throw new CliError("Usage: lecodes create <name> [directory]");
|
|
467
494
|
const dir = args._[1] ?? slug2(name);
|
|
468
495
|
const root = resolve2(dir);
|
|
469
|
-
if (
|
|
496
|
+
if (existsSync5(root) && readdirSync2(root).length > 0) {
|
|
470
497
|
throw new CliError(`Directory "${dir}" already exists and is not empty.`);
|
|
471
498
|
}
|
|
472
499
|
mkdirSync6(root, { recursive: true });
|
|
@@ -484,15 +511,15 @@ var create = async (args) => {
|
|
|
484
511
|
};
|
|
485
512
|
|
|
486
513
|
// src/commands/design.ts
|
|
487
|
-
import { existsSync as
|
|
488
|
-
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";
|
|
489
516
|
|
|
490
517
|
// src/compile/designCompile.ts
|
|
491
|
-
import { readFileSync as
|
|
518
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
492
519
|
import { basename } from "node:path";
|
|
493
520
|
|
|
494
521
|
// src/localFiles.ts
|
|
495
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
522
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
496
523
|
import { join as join7, relative } from "node:path";
|
|
497
524
|
var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
|
|
498
525
|
var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
|
|
@@ -567,7 +594,7 @@ var scanLocal = (root, opts = {}) => {
|
|
|
567
594
|
ignored.push(key);
|
|
568
595
|
continue;
|
|
569
596
|
}
|
|
570
|
-
files.push({ path: key, absPath: abs, sha: sha256(
|
|
597
|
+
files.push({ path: key, absPath: abs, sha: sha256(readFileSync5(abs)) });
|
|
571
598
|
}
|
|
572
599
|
}
|
|
573
600
|
};
|
|
@@ -601,16 +628,16 @@ var diffLocal = (manifest, scan) => {
|
|
|
601
628
|
var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
|
|
602
629
|
|
|
603
630
|
// src/compile/projectCompile.ts
|
|
604
|
-
import { existsSync as
|
|
631
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
605
632
|
import { createRequire as createRequire3 } from "node:module";
|
|
606
633
|
import { dirname as dirname5, join as join9 } from "node:path";
|
|
607
|
-
import { fileURLToPath as
|
|
634
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
608
635
|
// ../sdk/src/compile/bundler.ts
|
|
609
|
-
import { existsSync as
|
|
636
|
+
import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync } from "node:fs";
|
|
610
637
|
import { spawnSync } from "node:child_process";
|
|
611
638
|
import { createRequire as createRequire2 } from "node:module";
|
|
612
639
|
import { dirname as dirname4, join as join8, relative as relative2 } from "node:path";
|
|
613
|
-
import { fileURLToPath } from "node:url";
|
|
640
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
614
641
|
|
|
615
642
|
// ../sdk/src/compile/assetMacro.ts
|
|
616
643
|
var ASSET_RE = /\basset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g;
|
|
@@ -670,11 +697,11 @@ var classBodyDeclaresAspect = (src, from) => {
|
|
|
670
697
|
// ../sdk/src/compile/bundler.ts
|
|
671
698
|
var resolveInject = () => {
|
|
672
699
|
const override = process.env.LECODES_SDK_INJECT;
|
|
673
|
-
if (override &&
|
|
700
|
+
if (override && existsSync6(override))
|
|
674
701
|
return override;
|
|
675
|
-
const here = dirname4(
|
|
702
|
+
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
676
703
|
const src = join8(here, "..", "inject.ts");
|
|
677
|
-
if (
|
|
704
|
+
if (existsSync6(src))
|
|
678
705
|
return src;
|
|
679
706
|
throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
|
|
680
707
|
};
|
|
@@ -712,7 +739,7 @@ var collectSdkFiles = (sdkSrc) => {
|
|
|
712
739
|
if (statSync(p).isDirectory())
|
|
713
740
|
walk(p);
|
|
714
741
|
else if (isSdkSource(p))
|
|
715
|
-
out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] =
|
|
742
|
+
out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync6(p, "utf8");
|
|
716
743
|
}
|
|
717
744
|
};
|
|
718
745
|
walk(sdkSrc);
|
|
@@ -722,13 +749,13 @@ var collectSdkFiles = (sdkSrc) => {
|
|
|
722
749
|
var resolveChiselBin = () => {
|
|
723
750
|
const override = process.env.LECODES_CHISEL_BIN;
|
|
724
751
|
if (override) {
|
|
725
|
-
if (
|
|
752
|
+
if (existsSync6(override))
|
|
726
753
|
return override;
|
|
727
754
|
throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
|
|
728
755
|
}
|
|
729
756
|
try {
|
|
730
757
|
const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
|
|
731
|
-
if (bin &&
|
|
758
|
+
if (bin && existsSync6(bin))
|
|
732
759
|
return bin;
|
|
733
760
|
} catch {}
|
|
734
761
|
throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
|
|
@@ -888,9 +915,9 @@ ${inlineSourceMapComment(map)}` : code;
|
|
|
888
915
|
${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`;
|
|
889
916
|
};
|
|
890
917
|
// src/compile/projectCompile.ts
|
|
891
|
-
var here = dirname5(
|
|
918
|
+
var here = dirname5(fileURLToPath3(import.meta.url));
|
|
892
919
|
var vendoredInject = join9(here, "..", "runtime", "sdk", "inject.ts");
|
|
893
|
-
if (
|
|
920
|
+
if (existsSync7(vendoredInject))
|
|
894
921
|
process.env.LECODES_SDK_INJECT = vendoredInject;
|
|
895
922
|
var resolveChiselBin2 = () => {
|
|
896
923
|
if (process.env.LECODES_CHISEL_BIN)
|
|
@@ -914,10 +941,379 @@ var compileProject2 = (opts) => compileProject({
|
|
|
914
941
|
localAssets: opts.localAssets
|
|
915
942
|
});
|
|
916
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
|
+
|
|
917
1313
|
// src/compile/designCompile.ts
|
|
918
1314
|
var LOCAL_ASSET_SCHEME = "lecodesfile://";
|
|
919
1315
|
var compileDesignScreen = async (opts) => {
|
|
920
|
-
const { root, manifest, designKey, screenId } = opts;
|
|
1316
|
+
const { root, manifest, designDir, designKey, screenId, state } = opts;
|
|
921
1317
|
const entries = [];
|
|
922
1318
|
const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
|
|
923
1319
|
for (const file of scan.files) {
|
|
@@ -932,17 +1328,20 @@ var compileDesignScreen = async (opts) => {
|
|
|
932
1328
|
} else if (type === "shader") {
|
|
933
1329
|
entries.push({ path: file.path, type, shaders: [] });
|
|
934
1330
|
} else {
|
|
935
|
-
entries.push({ path: file.path, type, text:
|
|
1331
|
+
entries.push({ path: file.path, type, text: readFileSync8(file.absPath, "utf8") });
|
|
936
1332
|
}
|
|
937
1333
|
}
|
|
1334
|
+
const invoke = state !== undefined ? `screen(${JSON.stringify(state)})` : "screen()";
|
|
938
1335
|
const entryPath = `${designKey}/.screen-entry.ts`;
|
|
939
1336
|
entries.push({
|
|
940
1337
|
path: entryPath,
|
|
941
1338
|
type: "text",
|
|
942
1339
|
text: `import screen from "./screens/${screenId}"
|
|
943
|
-
screen
|
|
1340
|
+
` + `const __screen = typeof screen === "function" ? ${invoke} : screen
|
|
1341
|
+
` + `__screen.open()
|
|
944
1342
|
`
|
|
945
1343
|
});
|
|
1344
|
+
await applyAssetIcons(entries, designDir);
|
|
946
1345
|
return compileProject2({
|
|
947
1346
|
entries,
|
|
948
1347
|
name: opts.name,
|
|
@@ -1034,11 +1433,45 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
|
|
|
1034
1433
|
// src/commands/designTemplates.ts
|
|
1035
1434
|
var META_JSON = `{
|
|
1036
1435
|
"screens": {
|
|
1037
|
-
"home": { "pos": [0, 0], "desc": "Example screen — replace me" }
|
|
1436
|
+
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
1038
1437
|
},
|
|
1039
1438
|
"edges": []
|
|
1040
1439
|
}
|
|
1041
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
|
+
`;
|
|
1042
1475
|
var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
|
|
1043
1476
|
export const colors = {
|
|
1044
1477
|
bg: "#101114",
|
|
@@ -1108,24 +1541,60 @@ This is an intent-capture stage — screens run on inline mock data, before any
|
|
|
1108
1541
|
|
|
1109
1542
|
\`meta.json\` is the app map: every screen (position, description) and the edges between them
|
|
1110
1543
|
(navigation intent). **Read it before opening any screen file.** It is the human's file — the
|
|
1111
|
-
canvas and the developer own positions and descriptions.
|
|
1112
|
-
|
|
1113
|
-
|
|
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.
|
|
1114
1550
|
|
|
1115
1551
|
\`\`\`json
|
|
1116
1552
|
{
|
|
1117
|
-
"screens": {
|
|
1118
|
-
|
|
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
|
+
]
|
|
1119
1562
|
}
|
|
1120
1563
|
\`\`\`
|
|
1121
1564
|
|
|
1122
1565
|
- An edge's optional \`activator\` names the element (its \`name\` style option) that triggers the
|
|
1123
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.
|
|
1124
1592
|
|
|
1125
1593
|
## Screen rules
|
|
1126
1594
|
|
|
1127
|
-
- **One file = one screen.** \`screens/<id>.ts\` default-exports
|
|
1128
|
-
\`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.
|
|
1129
1598
|
- **Styles go through the chained \`.style({ … })\` method** — e.g.
|
|
1130
1599
|
\`UIText("Today").style({ fontSize: 28, fontWeight: 700 })\`. Passing a style object as the
|
|
1131
1600
|
first argument is a legacy form.
|
|
@@ -1137,28 +1606,111 @@ is clear, but never reposition or rewrite existing human-authored descriptions.
|
|
|
1137
1606
|
every screen. Promoting a repeated pattern into \`shared/\` is an explicit, deliberate step.
|
|
1138
1607
|
- The LeCodes UI API (\`UIScreen\`, \`UIColumn\`, \`UIRow\`, \`UIBox\`, \`UIText\`, \`UIImage\`,
|
|
1139
1608
|
\`UIButton\`, \`UIInput\`, \`UISpacer\`, \`UIScrollable\`, …) is **global** — no imports needed.
|
|
1140
|
-
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.
|
|
1141
1663
|
|
|
1142
1664
|
## See what you built
|
|
1143
1665
|
|
|
1144
1666
|
The developer watches the canvas live (screens hot-reload on save). To see a screen yourself:
|
|
1145
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
|
+
|
|
1146
1681
|
\`\`\`sh
|
|
1147
|
-
lecodes design snapshot <id>
|
|
1148
|
-
lecodes design snapshot <id> --png
|
|
1149
|
-
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)
|
|
1150
1688
|
\`\`\`
|
|
1151
1689
|
|
|
1152
|
-
|
|
1153
|
-
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.
|
|
1154
1692
|
|
|
1155
1693
|
## Workflow
|
|
1156
1694
|
|
|
1157
|
-
1.
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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 }
|
|
1162
1714
|
`;
|
|
1163
1715
|
var README_HINT = `# design/
|
|
1164
1716
|
|
|
@@ -1185,48 +1737,72 @@ var getContext = (args) => {
|
|
|
1185
1737
|
manifest,
|
|
1186
1738
|
name: manifest?.name ?? basename2(root),
|
|
1187
1739
|
dirName,
|
|
1188
|
-
designDir:
|
|
1740
|
+
designDir: join11(root, dirName),
|
|
1189
1741
|
designKey: "/" + dirName.replace(/\\/g, "/")
|
|
1190
1742
|
};
|
|
1191
1743
|
};
|
|
1192
1744
|
var listScreens = (designDir) => {
|
|
1193
|
-
const dir =
|
|
1194
|
-
if (!
|
|
1745
|
+
const dir = join11(designDir, "screens");
|
|
1746
|
+
if (!existsSync9(dir))
|
|
1195
1747
|
return [];
|
|
1196
1748
|
return readdirSync5(dir).filter((f) => f.endsWith(".ts") && !f.startsWith(".")).map((f) => f.slice(0, -3)).sort();
|
|
1197
1749
|
};
|
|
1198
1750
|
var readMetaSizes = (designDir) => {
|
|
1199
1751
|
try {
|
|
1200
|
-
const meta = JSON.parse(
|
|
1752
|
+
const meta = JSON.parse(readFileSync9(join11(designDir, "meta.json"), "utf8"));
|
|
1201
1753
|
return meta?.screens ?? {};
|
|
1202
1754
|
} catch {
|
|
1203
1755
|
return {};
|
|
1204
1756
|
}
|
|
1205
1757
|
};
|
|
1206
|
-
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) => {
|
|
1207
1782
|
const files = [
|
|
1208
1783
|
["meta.json", META_JSON],
|
|
1784
|
+
["spec.md", SPEC_MD],
|
|
1209
1785
|
["README.md", README_HINT],
|
|
1210
1786
|
["CLAUDE.md", CLAUDE_MD],
|
|
1211
|
-
[
|
|
1212
|
-
[
|
|
1787
|
+
[join11("shared", "tokens.ts"), TOKENS_TS],
|
|
1788
|
+
[join11("screens", "home.ts"), HOME_SCREEN_TS]
|
|
1213
1789
|
];
|
|
1214
|
-
|
|
1215
|
-
|
|
1790
|
+
mkdirSync8(join11(ctx.designDir, "screens"), { recursive: true });
|
|
1791
|
+
mkdirSync8(join11(ctx.designDir, "shared"), { recursive: true });
|
|
1216
1792
|
let created = 0;
|
|
1217
1793
|
for (const [rel, content] of files) {
|
|
1218
|
-
const abs =
|
|
1219
|
-
if (
|
|
1794
|
+
const abs = join11(ctx.designDir, rel);
|
|
1795
|
+
if (existsSync9(abs)) {
|
|
1220
1796
|
note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
|
|
1221
1797
|
continue;
|
|
1222
1798
|
}
|
|
1223
|
-
|
|
1799
|
+
writeFileSync8(abs, content);
|
|
1224
1800
|
log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
|
|
1225
1801
|
created++;
|
|
1226
1802
|
}
|
|
1227
|
-
const ignorePath =
|
|
1803
|
+
const ignorePath = join11(ctx.root, ".lecodesignore");
|
|
1228
1804
|
const ignoreLine = `${ctx.dirName}/.snapshots/`;
|
|
1229
|
-
const current =
|
|
1805
|
+
const current = existsSync9(ignorePath) ? readFileSync9(ignorePath, "utf8") : "";
|
|
1230
1806
|
if (!current.split(/\r?\n/).includes(ignoreLine)) {
|
|
1231
1807
|
appendFileSync(ignorePath, (current && !current.endsWith(`
|
|
1232
1808
|
`) ? `
|
|
@@ -1234,6 +1810,14 @@ var init = async (ctx) => {
|
|
|
1234
1810
|
`);
|
|
1235
1811
|
log(`${c.green("ignored")} ${ignoreLine} (in .lecodesignore)`);
|
|
1236
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.");
|
|
1237
1821
|
log("");
|
|
1238
1822
|
log(created ? "Design canvas ready." : "Design canvas already initialized.");
|
|
1239
1823
|
log(` ${c.bold("lecodes design")} start the canvas (browser)`);
|
|
@@ -1250,12 +1834,28 @@ var snapshot = async (ctx, args) => {
|
|
|
1250
1834
|
throw new CliError(`No screen "${id}" (have: ${all.join(", ")}).`);
|
|
1251
1835
|
}
|
|
1252
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.");
|
|
1253
1841
|
let renderer;
|
|
1254
1842
|
try {
|
|
1255
1843
|
renderer = await import("lecodes-renderer/headless");
|
|
1256
1844
|
} catch {
|
|
1257
1845
|
throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
|
|
1258
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
|
+
}
|
|
1259
1859
|
const killer = setTimeout(() => {
|
|
1260
1860
|
warnErr("Snapshot timed out after 120s.");
|
|
1261
1861
|
process.exit(1);
|
|
@@ -1263,45 +1863,68 @@ var snapshot = async (ctx, args) => {
|
|
|
1263
1863
|
killer.unref?.();
|
|
1264
1864
|
const png = flagBool(args, "png") || flagStr(args, "png") !== undefined;
|
|
1265
1865
|
const sizes = readMetaSizes(ctx.designDir);
|
|
1266
|
-
const outDir = flagStr(args, "out-dir") ??
|
|
1267
|
-
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");
|
|
1268
1868
|
const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
|
|
1269
1869
|
if (!single)
|
|
1270
|
-
|
|
1870
|
+
mkdirSync8(outDir, { recursive: true });
|
|
1271
1871
|
for (const id of targets) {
|
|
1272
|
-
note(`Rendering ${id}…`);
|
|
1273
|
-
const js = await compileDesignScreen({
|
|
1274
|
-
root: ctx.root,
|
|
1275
|
-
manifest: ctx.manifest,
|
|
1276
|
-
name: ctx.name,
|
|
1277
|
-
designKey: ctx.designKey,
|
|
1278
|
-
screenId: id,
|
|
1279
|
-
assets: "disk"
|
|
1280
|
-
});
|
|
1281
1872
|
const [width, height] = sizes[id]?.size ?? [390, 844];
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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(`
|
|
1287
1906
|
`) ? result.text : result.text + `
|
|
1288
1907
|
`;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
for (const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
+
}
|
|
1300
1923
|
}
|
|
1301
1924
|
}
|
|
1302
1925
|
};
|
|
1303
1926
|
var serve = async (ctx, args) => {
|
|
1304
|
-
if (!
|
|
1927
|
+
if (!existsSync9(ctx.designDir)) {
|
|
1305
1928
|
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
|
|
1306
1929
|
}
|
|
1307
1930
|
let designServer;
|
|
@@ -1311,44 +1934,171 @@ var serve = async (ctx, args) => {
|
|
|
1311
1934
|
throw new CliError("The design canvas isn't installed. Run: npm i -D lecodes-design");
|
|
1312
1935
|
}
|
|
1313
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;
|
|
1314
1971
|
const server = await designServer.startDesignServer({
|
|
1315
1972
|
root: ctx.root,
|
|
1316
1973
|
designDir: ctx.designDir,
|
|
1317
1974
|
name: ctx.name,
|
|
1318
1975
|
port,
|
|
1319
1976
|
log: note,
|
|
1320
|
-
compileScreen: async (id) => ({
|
|
1977
|
+
compileScreen: async (id, state) => ({
|
|
1321
1978
|
js: await compileDesignScreen({
|
|
1322
1979
|
root: ctx.root,
|
|
1323
1980
|
manifest: ctx.manifest,
|
|
1324
1981
|
name: ctx.name,
|
|
1982
|
+
designDir: ctx.designDir,
|
|
1325
1983
|
designKey: ctx.designKey,
|
|
1326
1984
|
screenId: id,
|
|
1985
|
+
state,
|
|
1327
1986
|
assets: "server"
|
|
1328
1987
|
}),
|
|
1329
1988
|
warnings: []
|
|
1330
|
-
})
|
|
1989
|
+
}),
|
|
1990
|
+
renderScreen
|
|
1331
1991
|
});
|
|
1332
1992
|
log(`${c.bold("LeCodes Design")} — ${ctx.name}`);
|
|
1333
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)")}`);
|
|
1334
1995
|
log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`);
|
|
1335
1996
|
if (!flagBool(args, "no-open"))
|
|
1336
1997
|
openBrowser(server.url);
|
|
1337
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
|
+
};
|
|
2017
|
+
var share = async (ctx, args) => {
|
|
2018
|
+
if (!ctx.manifest) {
|
|
2019
|
+
throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).");
|
|
2020
|
+
}
|
|
2021
|
+
const config = loadConfig();
|
|
2022
|
+
const apiUrl = requireApiUrl(config);
|
|
2023
|
+
const token = requireToken(config);
|
|
2024
|
+
const uuid = ctx.manifest.uuid;
|
|
2025
|
+
if (flagBool(args, "off")) {
|
|
2026
|
+
await disableDesignShare(apiUrl, token, uuid);
|
|
2027
|
+
log(`${c.green("✓")} Design sharing is off.`);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const res = await enableDesignShare(apiUrl, token, uuid);
|
|
2031
|
+
log(`${c.bold("Design shared")} — anyone with this link can view the mockups (your app code stays private):`);
|
|
2032
|
+
log(` ${c.bold(res.url ?? "")}`);
|
|
2033
|
+
note("Turn it off with: lecodes design share --off");
|
|
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
|
+
};
|
|
1338
2082
|
var design = async (args) => {
|
|
1339
2083
|
const sub = args._[0] ?? "serve";
|
|
1340
2084
|
const ctx = getContext(args);
|
|
1341
2085
|
if (sub === "serve")
|
|
1342
2086
|
return serve(ctx, args);
|
|
1343
2087
|
if (sub === "init")
|
|
1344
|
-
return init(ctx);
|
|
2088
|
+
return init(ctx, args);
|
|
1345
2089
|
if (sub === "snapshot")
|
|
1346
2090
|
return snapshot(ctx, args);
|
|
1347
|
-
|
|
2091
|
+
if (sub === "arrange")
|
|
2092
|
+
return arrange(ctx, args);
|
|
2093
|
+
if (sub === "check")
|
|
2094
|
+
return check(ctx, args);
|
|
2095
|
+
if (sub === "share")
|
|
2096
|
+
return share(ctx, args);
|
|
2097
|
+
throw new CliError(`Unknown design subcommand "${sub}" (serve | init | snapshot | arrange | check | share).`);
|
|
1348
2098
|
};
|
|
1349
2099
|
|
|
1350
2100
|
// src/serverDiff.ts
|
|
1351
|
-
import { readFileSync as
|
|
2101
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
1352
2102
|
import { basename as basename3 } from "node:path";
|
|
1353
2103
|
var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
|
|
1354
2104
|
var computeServerDiff = (project, scan) => {
|
|
@@ -1367,7 +2117,7 @@ var computeServerDiff = (project, scan) => {
|
|
|
1367
2117
|
const local = localByPath.get(path);
|
|
1368
2118
|
if (local && !server) {
|
|
1369
2119
|
const text = isTextKind(classifyKind(basename3(path)));
|
|
1370
|
-
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 });
|
|
1371
2121
|
continue;
|
|
1372
2122
|
}
|
|
1373
2123
|
if (server && !local) {
|
|
@@ -1378,12 +2128,12 @@ var computeServerDiff = (project, scan) => {
|
|
|
1378
2128
|
if (server && local) {
|
|
1379
2129
|
if (isTextAsset(server.type)) {
|
|
1380
2130
|
const serverText = server.text ?? "";
|
|
1381
|
-
const localText =
|
|
2131
|
+
const localText = readFileSync10(local.absPath, "utf8");
|
|
1382
2132
|
if (serverText !== localText)
|
|
1383
2133
|
entries.push({ path, status: "modified", binary: false, serverText, localText });
|
|
1384
2134
|
} else {
|
|
1385
2135
|
const serverSize = server.file?.size;
|
|
1386
|
-
const localSize = Buffer.byteLength(
|
|
2136
|
+
const localSize = Buffer.byteLength(readFileSync10(local.absPath));
|
|
1387
2137
|
if (serverSize !== undefined && serverSize !== localSize) {
|
|
1388
2138
|
entries.push({ path, status: "modified", binary: true });
|
|
1389
2139
|
}
|
|
@@ -1547,11 +2297,11 @@ var login = async (args) => {
|
|
|
1547
2297
|
};
|
|
1548
2298
|
|
|
1549
2299
|
// src/commands/preview.ts
|
|
1550
|
-
import { writeFileSync as
|
|
2300
|
+
import { writeFileSync as writeFileSync9 } from "node:fs";
|
|
1551
2301
|
import { resolve as resolve3 } from "node:path";
|
|
1552
2302
|
|
|
1553
2303
|
// src/compile/collect.ts
|
|
1554
|
-
import { readFileSync as
|
|
2304
|
+
import { readFileSync as readFileSync11 } from "node:fs";
|
|
1555
2305
|
import { basename as basename4 } from "node:path";
|
|
1556
2306
|
var collectEntries = (root, manifest, project) => {
|
|
1557
2307
|
const pathById = buildPaths(project.assets);
|
|
@@ -1576,7 +2326,7 @@ var collectEntries = (root, manifest, project) => {
|
|
|
1576
2326
|
warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
|
|
1577
2327
|
entries.push({ path: file.path, type, shaders });
|
|
1578
2328
|
} else {
|
|
1579
|
-
entries.push({ path: file.path, type, text:
|
|
2329
|
+
entries.push({ path: file.path, type, text: readFileSync11(file.absPath, "utf8") });
|
|
1580
2330
|
}
|
|
1581
2331
|
}
|
|
1582
2332
|
return { entries, warnings };
|
|
@@ -1606,7 +2356,7 @@ var preview = async (args) => {
|
|
|
1606
2356
|
if (out && isNullSink(out)) {
|
|
1607
2357
|
note(`Discarded output (${out})`);
|
|
1608
2358
|
} else if (out) {
|
|
1609
|
-
|
|
2359
|
+
writeFileSync9(resolve3(out), js);
|
|
1610
2360
|
note(`Wrote ${out} (${js.length} bytes)`);
|
|
1611
2361
|
} else {
|
|
1612
2362
|
process.stdout.write(js);
|
|
@@ -1618,7 +2368,7 @@ var preview = async (args) => {
|
|
|
1618
2368
|
};
|
|
1619
2369
|
|
|
1620
2370
|
// src/commands/render.ts
|
|
1621
|
-
import { writeFileSync as
|
|
2371
|
+
import { writeFileSync as writeFileSync10 } from "node:fs";
|
|
1622
2372
|
import { resolve as resolve4 } from "node:path";
|
|
1623
2373
|
var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
|
|
1624
2374
|
var DEVICE_SAFE_AREA = {
|
|
@@ -1715,7 +2465,7 @@ var render = async (args) => {
|
|
|
1715
2465
|
if (isNullSink(pngFlag)) {
|
|
1716
2466
|
note(`Discarded PNG (${pngFlag})`);
|
|
1717
2467
|
} else {
|
|
1718
|
-
|
|
2468
|
+
writeFileSync10(resolve4(pngFlag), png);
|
|
1719
2469
|
note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
|
|
1720
2470
|
}
|
|
1721
2471
|
return done((cb) => cb());
|
|
@@ -1730,7 +2480,7 @@ var render = async (args) => {
|
|
|
1730
2480
|
return done((cb) => cb());
|
|
1731
2481
|
}
|
|
1732
2482
|
if (out) {
|
|
1733
|
-
|
|
2483
|
+
writeFileSync10(resolve4(out), result.text);
|
|
1734
2484
|
note(`Wrote ${out} (${result.text.length} bytes)`);
|
|
1735
2485
|
return done((cb) => cb());
|
|
1736
2486
|
}
|
|
@@ -1741,8 +2491,8 @@ var render = async (args) => {
|
|
|
1741
2491
|
};
|
|
1742
2492
|
|
|
1743
2493
|
// src/commands/pull.ts
|
|
1744
|
-
import { existsSync as
|
|
1745
|
-
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";
|
|
1746
2496
|
var pull = async (args) => {
|
|
1747
2497
|
const root = findProjectRoot(process.cwd());
|
|
1748
2498
|
const manifest = readManifest(root);
|
|
@@ -1762,8 +2512,8 @@ var pull = async (args) => {
|
|
|
1762
2512
|
const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
|
|
1763
2513
|
for (const path of Object.keys(manifest.files)) {
|
|
1764
2514
|
if (!remotePaths.has(path)) {
|
|
1765
|
-
const abs =
|
|
1766
|
-
if (
|
|
2515
|
+
const abs = join12(root, ...path.replace(/^\//, "").split("/"));
|
|
2516
|
+
if (existsSync10(abs))
|
|
1767
2517
|
rmSync(abs);
|
|
1768
2518
|
}
|
|
1769
2519
|
}
|
|
@@ -1801,18 +2551,18 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
1801
2551
|
const files = { ...manifest.files };
|
|
1802
2552
|
for (const asset of targets) {
|
|
1803
2553
|
const key = pathById.get(asset.id);
|
|
1804
|
-
const abs =
|
|
1805
|
-
|
|
2554
|
+
const abs = join12(root, ...key.replace(/^\//, "").split("/"));
|
|
2555
|
+
mkdirSync9(dirname7(abs), { recursive: true });
|
|
1806
2556
|
let sha;
|
|
1807
2557
|
if (asset.type === "file") {
|
|
1808
2558
|
if (!asset.file?.src)
|
|
1809
2559
|
continue;
|
|
1810
2560
|
const bytes = await downloadBlob(apiUrl, asset.file.src);
|
|
1811
|
-
|
|
2561
|
+
writeFileSync11(abs, bytes);
|
|
1812
2562
|
sha = sha256(bytes);
|
|
1813
2563
|
} else {
|
|
1814
2564
|
const text = asset.text ?? "";
|
|
1815
|
-
|
|
2565
|
+
writeFileSync11(abs, text);
|
|
1816
2566
|
sha = sha256(text);
|
|
1817
2567
|
}
|
|
1818
2568
|
files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
|
|
@@ -1825,7 +2575,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
1825
2575
|
|
|
1826
2576
|
// src/commands/push.ts
|
|
1827
2577
|
import { basename as basename5 } from "node:path";
|
|
1828
|
-
import { readFileSync as
|
|
2578
|
+
import { readFileSync as readFileSync12 } from "node:fs";
|
|
1829
2579
|
var push = async (args) => {
|
|
1830
2580
|
const root = findProjectRoot(process.cwd());
|
|
1831
2581
|
const manifest = readManifest(root);
|
|
@@ -1884,9 +2634,9 @@ var push = async (args) => {
|
|
|
1884
2634
|
if (source)
|
|
1885
2635
|
entry.assetId = source.id;
|
|
1886
2636
|
if (isTextKind(kind)) {
|
|
1887
|
-
entry.text =
|
|
2637
|
+
entry.text = readFileSync12(file.absPath, "utf8");
|
|
1888
2638
|
} else if (!source || source.sha !== file.sha) {
|
|
1889
|
-
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));
|
|
1890
2640
|
entry.blobFileId = blob.id;
|
|
1891
2641
|
uploaded++;
|
|
1892
2642
|
}
|
|
@@ -2027,8 +2777,8 @@ Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKE
|
|
|
2027
2777
|
var commands = { login, create, clone, status, diff, pull, push, preview, render, design };
|
|
2028
2778
|
var readVersion = () => {
|
|
2029
2779
|
try {
|
|
2030
|
-
const here2 =
|
|
2031
|
-
return JSON.parse(
|
|
2780
|
+
const here2 = dirname8(fileURLToPath4(import.meta.url));
|
|
2781
|
+
return JSON.parse(readFileSync13(join13(here2, "..", "package.json"), "utf8")).version ?? "unknown";
|
|
2032
2782
|
} catch {
|
|
2033
2783
|
return "unknown";
|
|
2034
2784
|
}
|