jig-ui 0.4.0 → 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/README.md +52 -72
- package/dist/index.js +675 -243
- package/package.json +1 -1
- package/rules/00-anti-patterns.md +43 -7
- package/rules/01-modes.md +8 -7
- package/rules/02-tokens.md +215 -13
- package/rules/03-patterns.md +71 -4
- package/templates/COMMAND.md.tmpl +56 -10
- package/templates/command-metadata.json +2 -2
- package/tokens/brand.default.css +50 -2
- package/tokens/mode.editorial.css +44 -2
- package/tokens/mode.operator.css +16 -1
- package/tokens/mode.product.css +24 -2
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync as
|
|
5
|
+
import { readFileSync as readFileSync19 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join24 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -42,16 +42,16 @@ function isPublishedBuild(packageRoot2) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// src/commands/install.ts
|
|
45
|
-
import { existsSync as
|
|
46
|
-
import {
|
|
45
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
46
|
+
import { join as join7 } from "path";
|
|
47
47
|
|
|
48
48
|
// src/adapters/types.ts
|
|
49
49
|
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, review the UI against the rules, or refresh the install.";
|
|
50
50
|
var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
|
|
51
51
|
function assertSafeRelPath(relPath, adapterName) {
|
|
52
|
-
const
|
|
52
|
+
const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
|
|
53
53
|
const hasDotDotSegment = relPath.split(/[\\/]/).includes("..");
|
|
54
|
-
if (
|
|
54
|
+
if (isAbsolute2 || hasDotDotSegment) {
|
|
55
55
|
throw new Error(`Adapter '${adapterName}' produced an unsafe relPath: ${relPath}`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -477,6 +477,10 @@ function upsertBlock(existing, block) {
|
|
|
477
477
|
return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
// src/install/writer.ts
|
|
481
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
482
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
483
|
+
|
|
480
484
|
// src/install/line-endings.ts
|
|
481
485
|
function dominantEnding(existing) {
|
|
482
486
|
const crlf = (existing.match(/\r\n/g) ?? []).length;
|
|
@@ -490,23 +494,36 @@ function matchLineEndings(content, existing) {
|
|
|
490
494
|
return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
// src/
|
|
494
|
-
var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
|
|
497
|
+
// src/install/writer.ts
|
|
495
498
|
function relKey(...parts) {
|
|
496
499
|
return parts.join("/");
|
|
497
500
|
}
|
|
498
|
-
function
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
501
|
+
function createWriter(root, seed = {}) {
|
|
502
|
+
const files = { ...seed };
|
|
503
|
+
return {
|
|
504
|
+
files,
|
|
505
|
+
write(key, content) {
|
|
506
|
+
const abs = join6(root, ...key.split("/"));
|
|
507
|
+
mkdirSync3(dirname4(abs), { recursive: true });
|
|
508
|
+
const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
|
|
509
|
+
const toWrite = matchLineEndings(content, existing);
|
|
510
|
+
writeFileAtomic(abs, toWrite);
|
|
511
|
+
files[key] = checksum(toWrite);
|
|
512
|
+
return key;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function bundleFiles(packageRoot2, dir) {
|
|
517
|
+
const ext = dir === "rules" ? ".md" : ".css";
|
|
518
|
+
return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
|
|
505
519
|
}
|
|
520
|
+
|
|
521
|
+
// src/commands/install.ts
|
|
522
|
+
var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
|
|
506
523
|
function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
507
|
-
const template =
|
|
524
|
+
const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
|
|
508
525
|
const metadata = JSON.parse(
|
|
509
|
-
|
|
526
|
+
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
510
527
|
);
|
|
511
528
|
return render(template, {
|
|
512
529
|
command_prefix: "/jig ",
|
|
@@ -523,13 +540,13 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
|
|
|
523
540
|
}
|
|
524
541
|
function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
525
542
|
const metadata = JSON.parse(
|
|
526
|
-
|
|
543
|
+
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
527
544
|
);
|
|
528
545
|
const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
|
|
529
|
-
const templatePath =
|
|
530
|
-
if (!
|
|
531
|
-
const template =
|
|
532
|
-
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{rules_path\}\}/g, rulesPath);
|
|
546
|
+
const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
547
|
+
if (!existsSync5(templatePath)) return { body: "", subcommands };
|
|
548
|
+
const template = readFileSync4(templatePath, "utf8");
|
|
549
|
+
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath);
|
|
533
550
|
return { body, subcommands };
|
|
534
551
|
}
|
|
535
552
|
function rulesPathFor(referenceDir, scope) {
|
|
@@ -562,9 +579,9 @@ function install(opts) {
|
|
|
562
579
|
existingManifest = null;
|
|
563
580
|
}
|
|
564
581
|
const planned = [];
|
|
565
|
-
const rulesDir =
|
|
566
|
-
for (const file of
|
|
567
|
-
const body =
|
|
582
|
+
const rulesDir = join7(opts.packageRoot, "rules");
|
|
583
|
+
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
584
|
+
const body = readFileSync4(join7(rulesDir, file), "utf8");
|
|
568
585
|
planned.push({
|
|
569
586
|
key: relKey(referenceDir, "rules", file),
|
|
570
587
|
content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
|
|
@@ -580,13 +597,13 @@ function install(opts) {
|
|
|
580
597
|
}
|
|
581
598
|
planned.push({
|
|
582
599
|
key: relKey(referenceDir, "rules.index.json"),
|
|
583
|
-
content:
|
|
600
|
+
content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
|
|
584
601
|
checkable: true
|
|
585
602
|
});
|
|
586
603
|
for (const src of ["LICENSE", "NOTICE"]) {
|
|
587
604
|
planned.push({
|
|
588
605
|
key: relKey(referenceDir, src),
|
|
589
|
-
content:
|
|
606
|
+
content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
|
|
590
607
|
checkable: false
|
|
591
608
|
});
|
|
592
609
|
}
|
|
@@ -608,12 +625,13 @@ function install(opts) {
|
|
|
608
625
|
});
|
|
609
626
|
for (const file of skillFiles) {
|
|
610
627
|
const key = file.relPath;
|
|
611
|
-
const abs =
|
|
628
|
+
const abs = join7(installRoot, ...key.split("/"));
|
|
612
629
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
613
|
-
const content = isBlockFile &&
|
|
630
|
+
const content = isBlockFile && existsSync5(abs) ? upsertBlock(readFileSync4(abs, "utf8"), file.content) : file.content;
|
|
614
631
|
planned.push({ key, content, checkable: !isBlockFile });
|
|
615
632
|
}
|
|
616
|
-
const
|
|
633
|
+
const writer = createWriter(installRoot);
|
|
634
|
+
const files = writer.files;
|
|
617
635
|
const written = [];
|
|
618
636
|
const skipped = [];
|
|
619
637
|
for (const { key, content, checkable } of planned) {
|
|
@@ -623,7 +641,7 @@ function install(opts) {
|
|
|
623
641
|
if (recorded) files[key] = recorded;
|
|
624
642
|
continue;
|
|
625
643
|
}
|
|
626
|
-
|
|
644
|
+
writer.write(key, content);
|
|
627
645
|
written.push(key);
|
|
628
646
|
}
|
|
629
647
|
const manifest = {
|
|
@@ -639,8 +657,8 @@ function install(opts) {
|
|
|
639
657
|
}
|
|
640
658
|
|
|
641
659
|
// src/commands/update.ts
|
|
642
|
-
import {
|
|
643
|
-
import { existsSync as
|
|
660
|
+
import { join as join10 } from "path";
|
|
661
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
644
662
|
|
|
645
663
|
// src/install/target.ts
|
|
646
664
|
import { resolve } from "path";
|
|
@@ -683,14 +701,14 @@ function resolveAllInstalled(projectRoot, homeDir) {
|
|
|
683
701
|
}
|
|
684
702
|
|
|
685
703
|
// src/init/state.ts
|
|
686
|
-
import { existsSync as
|
|
687
|
-
import { dirname as dirname5, join as
|
|
688
|
-
var INIT_MANIFEST_REL =
|
|
704
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5 } from "fs";
|
|
705
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
706
|
+
var INIT_MANIFEST_REL = join8(".jig", "state.json");
|
|
689
707
|
function readInitManifest(projectRoot) {
|
|
690
|
-
const path =
|
|
691
|
-
if (!
|
|
708
|
+
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
709
|
+
if (!existsSync6(path)) return null;
|
|
692
710
|
try {
|
|
693
|
-
const raw = JSON.parse(
|
|
711
|
+
const raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
694
712
|
if (typeof raw !== "object" || raw === null) return null;
|
|
695
713
|
const { files, version: version2, modes } = raw;
|
|
696
714
|
if (typeof files !== "object" || files === null) return null;
|
|
@@ -705,7 +723,7 @@ function readInitManifest(projectRoot) {
|
|
|
705
723
|
}
|
|
706
724
|
var MERGE_ATTEMPTS2 = 5;
|
|
707
725
|
function writeInitManifest(projectRoot, m) {
|
|
708
|
-
const path =
|
|
726
|
+
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
709
727
|
mkdirSync4(dirname5(path), { recursive: true });
|
|
710
728
|
for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
|
|
711
729
|
const onDisk = readInitManifest(projectRoot);
|
|
@@ -726,15 +744,15 @@ function isInitFileModified(projectRoot, relPath, m) {
|
|
|
726
744
|
}
|
|
727
745
|
|
|
728
746
|
// src/init/migrate.ts
|
|
729
|
-
import { existsSync as
|
|
730
|
-
import { join as
|
|
747
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
|
|
748
|
+
import { join as join9 } from "path";
|
|
731
749
|
var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
|
|
732
750
|
function isLegacyInstallArtifact(name) {
|
|
733
751
|
return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
|
|
734
752
|
}
|
|
735
753
|
function readLegacyManifest(dirAbs) {
|
|
736
754
|
try {
|
|
737
|
-
const raw = JSON.parse(
|
|
755
|
+
const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
|
|
738
756
|
if (raw && typeof raw === "object" && typeof raw.files === "object") {
|
|
739
757
|
return raw;
|
|
740
758
|
}
|
|
@@ -746,8 +764,8 @@ function classify(projectRoot, relPath, legacyManifest) {
|
|
|
746
764
|
return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
|
|
747
765
|
}
|
|
748
766
|
function detectLegacyRules(projectRoot) {
|
|
749
|
-
const jigDir =
|
|
750
|
-
if (!
|
|
767
|
+
const jigDir = join9(projectRoot, ".jig");
|
|
768
|
+
if (!existsSync7(jigDir)) return { present: false, files: [] };
|
|
751
769
|
const legacyManifest = readLegacyManifest(jigDir);
|
|
752
770
|
let entries;
|
|
753
771
|
try {
|
|
@@ -763,12 +781,12 @@ function detectLegacyRules(projectRoot) {
|
|
|
763
781
|
}
|
|
764
782
|
function detectLegacyCursorRules(projectRoot) {
|
|
765
783
|
const bundleDirRel = ".cursor/rules/jig";
|
|
766
|
-
const bundleDir =
|
|
784
|
+
const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
|
|
767
785
|
const mdcRel = ".cursor/rules/jig.mdc";
|
|
768
|
-
const mdcAbs =
|
|
786
|
+
const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
|
|
769
787
|
const legacyManifest = readLegacyManifest(bundleDir);
|
|
770
788
|
const files = [];
|
|
771
|
-
if (
|
|
789
|
+
if (existsSync7(mdcAbs)) {
|
|
772
790
|
files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
|
|
773
791
|
}
|
|
774
792
|
let entries;
|
|
@@ -822,7 +840,7 @@ function removableLegacyFiles(report) {
|
|
|
822
840
|
function removeLegacyFiles(projectRoot, relPaths) {
|
|
823
841
|
for (const relPath of relPaths) {
|
|
824
842
|
try {
|
|
825
|
-
rmSync(
|
|
843
|
+
rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
|
|
826
844
|
} catch {
|
|
827
845
|
}
|
|
828
846
|
}
|
|
@@ -868,24 +886,19 @@ function updateTarget(opts, resolved) {
|
|
|
868
886
|
const adapter = getAdapter(existing.agent);
|
|
869
887
|
const updated = [];
|
|
870
888
|
const skipped = [];
|
|
871
|
-
const
|
|
889
|
+
const writer = createWriter(installRoot, existing.files);
|
|
890
|
+
const files = writer.files;
|
|
872
891
|
const write = (key, content) => {
|
|
873
|
-
|
|
874
|
-
mkdirSync5(dirname6(abs), { recursive: true });
|
|
875
|
-
const existing2 = existsSync7(abs) ? readFileSync6(abs, "utf8") : void 0;
|
|
876
|
-
const toWrite = matchLineEndings(content, existing2);
|
|
877
|
-
writeFileSync5(abs, toWrite, "utf8");
|
|
878
|
-
files[key] = checksum(toWrite);
|
|
879
|
-
updated.push(key);
|
|
892
|
+
updated.push(writer.write(key, content));
|
|
880
893
|
};
|
|
881
|
-
const rulesDir =
|
|
882
|
-
for (const file of
|
|
894
|
+
const rulesDir = join10(opts.packageRoot, "rules");
|
|
895
|
+
for (const file of bundleFiles(opts.packageRoot, "rules")) {
|
|
883
896
|
const key = relKey(referenceDir, "rules", file);
|
|
884
897
|
if (isModified(installRoot, key, existing)) {
|
|
885
898
|
skipped.push(key);
|
|
886
899
|
continue;
|
|
887
900
|
}
|
|
888
|
-
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) +
|
|
901
|
+
write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync7(join10(rulesDir, file), "utf8"));
|
|
889
902
|
}
|
|
890
903
|
for (const ref of referenceFiles(opts.packageRoot)) {
|
|
891
904
|
const key = relKey(referenceDir, ...ref.relPath.split("/"));
|
|
@@ -899,7 +912,7 @@ function updateTarget(opts, resolved) {
|
|
|
899
912
|
if (isModified(installRoot, indexKey, existing)) {
|
|
900
913
|
skipped.push(indexKey);
|
|
901
914
|
} else {
|
|
902
|
-
write(indexKey,
|
|
915
|
+
write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
|
|
903
916
|
}
|
|
904
917
|
const rulesPath = rulesPathFor(referenceDir, discoveredScope);
|
|
905
918
|
const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
|
|
@@ -921,8 +934,8 @@ function updateTarget(opts, resolved) {
|
|
|
921
934
|
const key = file.relPath;
|
|
922
935
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
923
936
|
if (isBlockFile) {
|
|
924
|
-
const abs =
|
|
925
|
-
const current =
|
|
937
|
+
const abs = join10(installRoot, ...key.split("/"));
|
|
938
|
+
const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
|
|
926
939
|
write(key, upsertBlock(current, file.content));
|
|
927
940
|
continue;
|
|
928
941
|
}
|
|
@@ -933,7 +946,7 @@ function updateTarget(opts, resolved) {
|
|
|
933
946
|
write(key, file.content);
|
|
934
947
|
}
|
|
935
948
|
for (const src of ["LICENSE", "NOTICE"]) {
|
|
936
|
-
write(relKey(referenceDir, src),
|
|
949
|
+
write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
|
|
937
950
|
}
|
|
938
951
|
const manifest = {
|
|
939
952
|
...existing,
|
|
@@ -948,37 +961,35 @@ function updateTarget(opts, resolved) {
|
|
|
948
961
|
function updateInitFiles(opts) {
|
|
949
962
|
const updated = [];
|
|
950
963
|
const skipped = [];
|
|
951
|
-
const tokensDir =
|
|
964
|
+
const tokensDir = join10(opts.packageRoot, "tokens");
|
|
952
965
|
const initManifest = readInitManifest(opts.projectRoot);
|
|
953
966
|
if (initManifest) {
|
|
954
967
|
const initFiles = { ...initManifest.files };
|
|
955
968
|
let initChanged = false;
|
|
956
|
-
|
|
969
|
+
const writer = createWriter(opts.projectRoot, initFiles);
|
|
970
|
+
for (const file of bundleFiles(opts.packageRoot, "tokens")) {
|
|
957
971
|
const key = relKey(".jig", "tokens", file);
|
|
958
972
|
if (!(key in initManifest.files)) continue;
|
|
959
973
|
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
960
974
|
skipped.push(key);
|
|
961
975
|
continue;
|
|
962
976
|
}
|
|
963
|
-
const content = vendorHeader(file, opts.version, "css", null) +
|
|
964
|
-
|
|
965
|
-
mkdirSync5(dirname6(abs), { recursive: true });
|
|
966
|
-
writeFileSync5(abs, content, "utf8");
|
|
967
|
-
initFiles[key] = checksum(content);
|
|
977
|
+
const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
|
|
978
|
+
updated.push(writer.write(key, content));
|
|
968
979
|
initChanged = true;
|
|
969
|
-
updated.push(key);
|
|
970
980
|
}
|
|
981
|
+
Object.assign(initFiles, writer.files);
|
|
971
982
|
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
972
983
|
}
|
|
973
984
|
return { updated, skipped };
|
|
974
985
|
}
|
|
975
986
|
|
|
976
987
|
// src/commands/explain.ts
|
|
977
|
-
import { join as
|
|
988
|
+
import { join as join13 } from "path";
|
|
978
989
|
|
|
979
990
|
// src/rules/load.ts
|
|
980
|
-
import { readFileSync as
|
|
981
|
-
import { join as
|
|
991
|
+
import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
|
|
992
|
+
import { join as join11 } from "path";
|
|
982
993
|
|
|
983
994
|
// src/rules/parse.ts
|
|
984
995
|
var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
|
|
@@ -1050,12 +1061,12 @@ function validateIndex(entries) {
|
|
|
1050
1061
|
|
|
1051
1062
|
// src/rules/load.ts
|
|
1052
1063
|
function loadRules(rulesDir, indexPath) {
|
|
1053
|
-
const files =
|
|
1064
|
+
const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
|
|
1054
1065
|
const parsed = [];
|
|
1055
1066
|
for (const file of files) {
|
|
1056
|
-
parsed.push(...parseRules(
|
|
1067
|
+
parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
|
|
1057
1068
|
}
|
|
1058
|
-
const entries = validateIndex(JSON.parse(
|
|
1069
|
+
const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
|
|
1059
1070
|
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
1060
1071
|
const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
|
|
1061
1072
|
if (missing.length) {
|
|
@@ -1077,8 +1088,8 @@ function loadRules(rulesDir, indexPath) {
|
|
|
1077
1088
|
}
|
|
1078
1089
|
|
|
1079
1090
|
// src/rules/specs.ts
|
|
1080
|
-
import { readFileSync as
|
|
1081
|
-
import { join as
|
|
1091
|
+
import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
|
|
1092
|
+
import { join as join12 } from "path";
|
|
1082
1093
|
var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
|
|
1083
1094
|
function parseSpecs(markdown, sourceFile) {
|
|
1084
1095
|
const lines = markdown.split("\n");
|
|
@@ -1110,68 +1121,121 @@ function parseSpecs(markdown, sourceFile) {
|
|
|
1110
1121
|
}
|
|
1111
1122
|
function loadSpecs(rulesDir) {
|
|
1112
1123
|
const specs = [];
|
|
1113
|
-
for (const file of
|
|
1114
|
-
specs.push(...parseSpecs(
|
|
1124
|
+
for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
1125
|
+
specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
|
|
1115
1126
|
}
|
|
1116
1127
|
return specs;
|
|
1117
1128
|
}
|
|
1118
1129
|
|
|
1119
1130
|
// src/commands/explain.ts
|
|
1120
|
-
var ID_SHAPE = /^([A-Z])
|
|
1131
|
+
var ID_SHAPE = /^([A-Z])-?(\d+)$/;
|
|
1132
|
+
var SECTION_SHAPE = /^([A-Z])$/;
|
|
1133
|
+
var matches = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1134
|
+
var byNumber = (a, b) => {
|
|
1135
|
+
const [as, an] = a.split("-");
|
|
1136
|
+
const [bs, bn] = b.split("-");
|
|
1137
|
+
return as === bs ? Number(an) - Number(bn) : as.localeCompare(bs);
|
|
1138
|
+
};
|
|
1139
|
+
function renderRule(rule) {
|
|
1140
|
+
return [
|
|
1141
|
+
`${rule.id} ${rule.title}`,
|
|
1142
|
+
"",
|
|
1143
|
+
`\u274C ${rule.wrong}`,
|
|
1144
|
+
`\u2705 ${rule.correction}`,
|
|
1145
|
+
"",
|
|
1146
|
+
` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
|
|
1147
|
+
` since ${rule.since} \xB7 ${rule.source}`
|
|
1148
|
+
].join("\n");
|
|
1149
|
+
}
|
|
1150
|
+
function renderSpec(spec) {
|
|
1151
|
+
return [
|
|
1152
|
+
`${spec.id} ${spec.title}`,
|
|
1153
|
+
"",
|
|
1154
|
+
spec.body,
|
|
1155
|
+
"",
|
|
1156
|
+
` specification \u2014 component anatomy and behaviour, not a single rule,`,
|
|
1157
|
+
` so it carries no \u274C/\u2705 pair and no detector.`,
|
|
1158
|
+
` ${spec.source}`
|
|
1159
|
+
].join("\n");
|
|
1160
|
+
}
|
|
1121
1161
|
function explain(opts) {
|
|
1122
1162
|
const raw = opts.ruleId.trim().toUpperCase();
|
|
1163
|
+
const root = opts.packageRoot ?? assetRoot();
|
|
1164
|
+
const rulesDir = join13(root, "rules");
|
|
1165
|
+
const rules = loadRules(rulesDir, join13(root, "rules.index.json"));
|
|
1166
|
+
const specs = loadSpecs(rulesDir);
|
|
1167
|
+
const entries = [
|
|
1168
|
+
...rules.map((r) => ({ id: r.id, title: r.title, text: `${r.wrong}
|
|
1169
|
+
${r.correction}` })),
|
|
1170
|
+
...specs.map((s) => ({ id: s.id, title: s.title, text: s.body }))
|
|
1171
|
+
];
|
|
1172
|
+
if (opts.list) {
|
|
1173
|
+
const section = SECTION_SHAPE.exec(raw)?.[1];
|
|
1174
|
+
const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
|
|
1175
|
+
if (shown.length === 0) {
|
|
1176
|
+
throw new Error(`No section '${raw}'. Sections: ${sectionsOf(entries).join(", ")}.`);
|
|
1177
|
+
}
|
|
1178
|
+
return [
|
|
1179
|
+
section ? `Section ${section} \u2014 ${shown.length} entries` : `${shown.length} rules and specs`,
|
|
1180
|
+
"",
|
|
1181
|
+
...shown.map((e) => `${e.id.padEnd(6)} ${e.title}`)
|
|
1182
|
+
].join("\n");
|
|
1183
|
+
}
|
|
1123
1184
|
const shape = ID_SHAPE.exec(raw);
|
|
1124
|
-
if (
|
|
1185
|
+
if (shape) {
|
|
1186
|
+
const id = `${shape[1]}-${shape[2]}`;
|
|
1187
|
+
const rule = rules.find((r) => r.id === id);
|
|
1188
|
+
if (rule) return renderRule(rule);
|
|
1189
|
+
const spec = specs.find((s) => s.id === id);
|
|
1190
|
+
if (spec) return renderSpec(spec);
|
|
1191
|
+
const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
|
|
1125
1192
|
throw new Error(
|
|
1126
|
-
`'${
|
|
1193
|
+
siblings.length > 0 ? `No rule '${id}'. Section ${shape[1]} has: ${siblings.join(", ")}.` : `No rule '${id}', and no section '${shape[1]}' exists.`
|
|
1127
1194
|
);
|
|
1128
1195
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
if (rule) {
|
|
1134
|
-
const lines = [
|
|
1135
|
-
`${rule.id} ${rule.title}`,
|
|
1136
|
-
"",
|
|
1137
|
-
`\u274C ${rule.wrong}`,
|
|
1138
|
-
`\u2705 ${rule.correction}`,
|
|
1139
|
-
"",
|
|
1140
|
-
` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
|
|
1141
|
-
` since ${rule.since} \xB7 ${rule.source}`
|
|
1142
|
-
];
|
|
1143
|
-
return lines.join("\n");
|
|
1196
|
+
if (raw === "") {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`Nothing to explain. Give a rule id ('C-19'), a word to search for ('contrast'), or --list.`
|
|
1199
|
+
);
|
|
1144
1200
|
}
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
`
|
|
1155
|
-
|
|
1201
|
+
const needle = opts.ruleId.trim();
|
|
1202
|
+
const inTitle = entries.filter((e) => matches(e.title, needle) || matches(e.id, needle));
|
|
1203
|
+
const inBody = entries.filter((e) => !inTitle.includes(e) && matches(e.text, needle));
|
|
1204
|
+
const hits = [
|
|
1205
|
+
...inTitle.sort((a, b) => byNumber(a.id, b.id)),
|
|
1206
|
+
...inBody.sort((a, b) => byNumber(a.id, b.id))
|
|
1207
|
+
];
|
|
1208
|
+
if (hits.length === 0) {
|
|
1209
|
+
throw new Error(
|
|
1210
|
+
`No rule matches '${needle}'. Try --list to see every id, or a broader word.`
|
|
1211
|
+
);
|
|
1156
1212
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1213
|
+
if (hits.length === 1) {
|
|
1214
|
+
const rule = rules.find((r) => r.id === hits[0].id);
|
|
1215
|
+
return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
|
|
1216
|
+
}
|
|
1217
|
+
return [
|
|
1218
|
+
// Title matches lead, so the list is ranked rather than sorted. Say so:
|
|
1219
|
+
// otherwise C-19 sitting above A-02 reads as a sorting bug.
|
|
1220
|
+
`${hits.length} entries match '${needle}' \u2014 title matches first:`,
|
|
1221
|
+
"",
|
|
1222
|
+
...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
|
|
1223
|
+
"",
|
|
1224
|
+
`Run 'jig explain <id>' for any of them.`
|
|
1225
|
+
].join("\n");
|
|
1226
|
+
}
|
|
1227
|
+
function sectionsOf(entries) {
|
|
1228
|
+
return [...new Set(entries.map((e) => e.id.split("-")[0]))].sort();
|
|
1165
1229
|
}
|
|
1166
1230
|
|
|
1167
1231
|
// src/commands/check.ts
|
|
1168
|
-
import { existsSync as
|
|
1169
|
-
import { join as
|
|
1232
|
+
import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
|
|
1233
|
+
import { join as join19 } from "path";
|
|
1170
1234
|
|
|
1171
1235
|
// src/check/files.ts
|
|
1172
1236
|
import { execFileSync } from "child_process";
|
|
1173
|
-
import { readdirSync as
|
|
1174
|
-
import { join as
|
|
1237
|
+
import { readdirSync as readdirSync6, realpathSync, statSync } from "fs";
|
|
1238
|
+
import { join as join14, relative, sep } from "path";
|
|
1175
1239
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
1176
1240
|
"node_modules",
|
|
1177
1241
|
"dist",
|
|
@@ -1230,13 +1294,13 @@ function isExcluded(relPath) {
|
|
|
1230
1294
|
function walk(root, dir, out, seen) {
|
|
1231
1295
|
let entries;
|
|
1232
1296
|
try {
|
|
1233
|
-
entries =
|
|
1297
|
+
entries = readdirSync6(dir, { withFileTypes: true });
|
|
1234
1298
|
} catch {
|
|
1235
1299
|
return;
|
|
1236
1300
|
}
|
|
1237
1301
|
for (const entry of entries) {
|
|
1238
1302
|
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
1239
|
-
const abs =
|
|
1303
|
+
const abs = join14(dir, entry.name);
|
|
1240
1304
|
let isDir = entry.isDirectory();
|
|
1241
1305
|
let isFile = entry.isFile();
|
|
1242
1306
|
if (entry.isSymbolicLink()) {
|
|
@@ -1363,6 +1427,17 @@ function formatReport(findings, meta) {
|
|
|
1363
1427
|
const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
|
|
1364
1428
|
const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
|
|
1365
1429
|
lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
|
|
1430
|
+
if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
|
|
1431
|
+
const n = meta.exempt?.length ?? 0;
|
|
1432
|
+
lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
|
|
1433
|
+
for (const { pattern, count, tooBroad } of meta.exemptPatterns) {
|
|
1434
|
+
const note = count === 0 ? "matches nothing \u2014 check the path" : tooBroad ? `${count} files \u2014 likely too broad, review it` : `${count} file${count > 1 ? "s" : ""}`;
|
|
1435
|
+
lines.push(` ${pattern} (${note})`);
|
|
1436
|
+
}
|
|
1437
|
+
if (n > 0) {
|
|
1438
|
+
lines.push(` ${meta.exempt.slice(0, 8).join(", ")}${n > 8 ? `, and ${n - 8} more` : ""}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1366
1441
|
if (meta.unscanned && meta.unscanned.count > 0) {
|
|
1367
1442
|
const exts = meta.unscanned.extensions.join(", ");
|
|
1368
1443
|
lines.push("");
|
|
@@ -1473,8 +1548,8 @@ function isStyleBearing(file) {
|
|
|
1473
1548
|
}
|
|
1474
1549
|
|
|
1475
1550
|
// src/check/run.ts
|
|
1476
|
-
import { readFileSync as
|
|
1477
|
-
import { join as
|
|
1551
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1552
|
+
import { join as join15 } from "path";
|
|
1478
1553
|
|
|
1479
1554
|
// src/check/css.ts
|
|
1480
1555
|
function maskComments(source) {
|
|
@@ -1523,7 +1598,12 @@ function splitRuleBlocks(source) {
|
|
|
1523
1598
|
const ch = source[i];
|
|
1524
1599
|
if (ch === "\n") line++;
|
|
1525
1600
|
if (ch === "{") {
|
|
1526
|
-
stack.push({
|
|
1601
|
+
stack.push({
|
|
1602
|
+
selectorStart: lastBoundary,
|
|
1603
|
+
bodyStart: i + 1,
|
|
1604
|
+
bodyStartLine: line,
|
|
1605
|
+
isAtRule: cleanSelector(source.slice(lastBoundary, i)).startsWith("@")
|
|
1606
|
+
});
|
|
1527
1607
|
lastBoundary = i + 1;
|
|
1528
1608
|
} else if (ch === "}") {
|
|
1529
1609
|
const top = stack.pop();
|
|
@@ -1531,7 +1611,8 @@ function splitRuleBlocks(source) {
|
|
|
1531
1611
|
blocks.push({
|
|
1532
1612
|
selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
|
|
1533
1613
|
body: source.slice(top.bodyStart, i),
|
|
1534
|
-
bodyStartLine: top.bodyStartLine
|
|
1614
|
+
bodyStartLine: top.bodyStartLine,
|
|
1615
|
+
atRuleDepth: stack.filter((f) => f.isAtRule).length
|
|
1535
1616
|
});
|
|
1536
1617
|
}
|
|
1537
1618
|
lastBoundary = i + 1;
|
|
@@ -2377,13 +2458,38 @@ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/;
|
|
|
2377
2458
|
var PX_RE = /(-?\d*\.?\d+)px/g;
|
|
2378
2459
|
var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
|
|
2379
2460
|
var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
|
|
2461
|
+
var PRIMITIVE_RE = /var\(\s*(--(?:brand|error|warning|success|info)-(?:h|s|l|fill-a))\s*[,)]/g;
|
|
2462
|
+
var TOKEN_LAYER_RE = /(^|\/)(\.jig\/tokens|jig)\/[^/]+\.css$/;
|
|
2463
|
+
function primitiveConsumption(source, file, ctx) {
|
|
2464
|
+
if (TOKEN_LAYER_RE.test(file)) return [];
|
|
2465
|
+
const out = [];
|
|
2466
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2467
|
+
for (const m of source.matchAll(PRIMITIVE_RE)) {
|
|
2468
|
+
const token = m[1];
|
|
2469
|
+
const line = source.slice(0, m.index ?? 0).split("\n").length;
|
|
2470
|
+
const key = `${token}:${line}`;
|
|
2471
|
+
if (seen.has(key)) continue;
|
|
2472
|
+
seen.add(key);
|
|
2473
|
+
out.push(
|
|
2474
|
+
mkFinding(
|
|
2475
|
+
ctx,
|
|
2476
|
+
"hardcoded-value",
|
|
2477
|
+
file,
|
|
2478
|
+
line,
|
|
2479
|
+
`${token} is a raw channel input, not a semantic role \u2014 consuming it bypasses the theme overrides, which remap \`--color-*\` and never the channels. Use the semantic token for what this is (\`--color-text-*\`, \`--color-fill-*\`, \`--color-stroke-*\`).`,
|
|
2480
|
+
sourceLine(source, line)
|
|
2481
|
+
)
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
return out;
|
|
2485
|
+
}
|
|
2380
2486
|
var hardcodedValue = {
|
|
2381
2487
|
name: "hardcoded-value",
|
|
2382
2488
|
appliesTo: (file) => isStyleBearing(file),
|
|
2383
2489
|
run(source, file, ctx) {
|
|
2490
|
+
const findings = primitiveConsumption(source, file, ctx);
|
|
2384
2491
|
const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
|
|
2385
|
-
if (!participates) return
|
|
2386
|
-
const findings = [];
|
|
2492
|
+
if (!participates) return findings;
|
|
2387
2493
|
for (const block of leafBlocks(source)) {
|
|
2388
2494
|
if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
|
|
2389
2495
|
DECL_RE2.lastIndex = 0;
|
|
@@ -2514,7 +2620,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
2514
2620
|
if (applicable.length === 0) continue;
|
|
2515
2621
|
let raw;
|
|
2516
2622
|
try {
|
|
2517
|
-
raw =
|
|
2623
|
+
raw = readFileSync10(join15(projectRoot, file), "utf8");
|
|
2518
2624
|
} catch {
|
|
2519
2625
|
continue;
|
|
2520
2626
|
}
|
|
@@ -2528,38 +2634,261 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
2528
2634
|
}
|
|
2529
2635
|
|
|
2530
2636
|
// src/check/tokens.ts
|
|
2531
|
-
import { existsSync as
|
|
2532
|
-
import { join as
|
|
2533
|
-
function
|
|
2534
|
-
const
|
|
2535
|
-
const
|
|
2536
|
-
|
|
2537
|
-
|
|
2637
|
+
import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync11 } from "fs";
|
|
2638
|
+
import { join as join16 } from "path";
|
|
2639
|
+
function rootDeclarations(source) {
|
|
2640
|
+
const out = {};
|
|
2641
|
+
for (const block of splitRuleBlocks(source)) {
|
|
2642
|
+
if (block.atRuleDepth !== 0) continue;
|
|
2643
|
+
if (block.body.includes("{")) continue;
|
|
2644
|
+
if (block.selector.trim() !== ":root") continue;
|
|
2645
|
+
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
2646
|
+
let m;
|
|
2647
|
+
while (m = re.exec(block.body)) out[m[1]] = m[2].trim();
|
|
2648
|
+
}
|
|
2649
|
+
return out;
|
|
2650
|
+
}
|
|
2651
|
+
function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
2652
|
+
const jig = {};
|
|
2653
|
+
const tokensDir = join16(projectRoot, ".jig", "tokens");
|
|
2654
|
+
if (existsSync9(tokensDir)) {
|
|
2655
|
+
for (const file of readdirSync7(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
2656
|
+
Object.assign(jig, rootDeclarations(readFileSync11(join16(tokensDir, file), "utf8")));
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
const consumer = {};
|
|
2660
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
2661
|
+
const vendored = join16(".jig", "tokens");
|
|
2662
|
+
for (const file of consumerFiles) {
|
|
2663
|
+
if (file.includes(vendored)) continue;
|
|
2664
|
+
let source;
|
|
2665
|
+
try {
|
|
2666
|
+
source = readFileSync11(join16(projectRoot, file), "utf8");
|
|
2667
|
+
} catch {
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
for (const [name, value] of Object.entries(rootDeclarations(maskNonStyleRegions(source, file)))) {
|
|
2671
|
+
if (name in consumer && consumer[name] !== value) ambiguous.add(name);
|
|
2672
|
+
consumer[name] = value;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const tokens = { ...consumer };
|
|
2676
|
+
for (const [name, value] of Object.entries(jig)) {
|
|
2677
|
+
if (name in consumer && consumer[name] !== value) ambiguous.add(name);
|
|
2678
|
+
tokens[name] = value;
|
|
2679
|
+
}
|
|
2680
|
+
for (const name of ambiguous) delete tokens[name];
|
|
2681
|
+
return tokens;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// src/check/exempt.ts
|
|
2685
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
|
|
2686
|
+
import { join as join17 } from "path";
|
|
2687
|
+
function globToRegExp(glob) {
|
|
2688
|
+
let out = "";
|
|
2689
|
+
for (let i = 0; i < glob.length; i++) {
|
|
2690
|
+
const c = glob[i];
|
|
2691
|
+
if (c === "*") {
|
|
2692
|
+
if (glob[i + 1] === "*") {
|
|
2693
|
+
out += ".*";
|
|
2694
|
+
i++;
|
|
2695
|
+
if (glob[i + 1] === "/") i++;
|
|
2696
|
+
} else {
|
|
2697
|
+
out += "[^/]*";
|
|
2698
|
+
}
|
|
2699
|
+
} else if (c === "?") out += "[^/]";
|
|
2700
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2701
|
+
}
|
|
2702
|
+
return new RegExp(`^${out}$`);
|
|
2703
|
+
}
|
|
2704
|
+
function readExemptions(projectRoot) {
|
|
2705
|
+
const configPath = join17(projectRoot, "jig.config.json");
|
|
2706
|
+
if (!existsSync10(configPath)) return [];
|
|
2707
|
+
let declared;
|
|
2708
|
+
try {
|
|
2709
|
+
declared = JSON.parse(readFileSync12(configPath, "utf8")).exempt;
|
|
2710
|
+
} catch {
|
|
2711
|
+
return [];
|
|
2712
|
+
}
|
|
2713
|
+
if (!Array.isArray(declared)) return [];
|
|
2714
|
+
return declared.filter(
|
|
2715
|
+
(p) => typeof p === "string" && p.trim() !== "" && !p.startsWith("/") && !p.split("/").includes("..")
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
var LIKELY_TOO_BROAD = 3;
|
|
2719
|
+
function applyExemptions(files, patterns) {
|
|
2720
|
+
if (patterns.length === 0) return { scanned: files, exempt: [], byPattern: [] };
|
|
2721
|
+
const compiled = patterns.map((pattern) => ({ pattern, re: globToRegExp(pattern), count: 0 }));
|
|
2722
|
+
const scanned = [];
|
|
2723
|
+
const exempt = [];
|
|
2538
2724
|
for (const file of files) {
|
|
2539
|
-
const
|
|
2725
|
+
const matches2 = compiled.filter((c) => c.re.test(file));
|
|
2726
|
+
for (const m of matches2) m.count++;
|
|
2727
|
+
(matches2.length > 0 ? exempt : scanned).push(file);
|
|
2728
|
+
}
|
|
2729
|
+
return {
|
|
2730
|
+
scanned,
|
|
2731
|
+
exempt,
|
|
2732
|
+
byPattern: compiled.map(({ pattern, count }) => ({
|
|
2733
|
+
pattern,
|
|
2734
|
+
count,
|
|
2735
|
+
tooBroad: count > LIKELY_TOO_BROAD
|
|
2736
|
+
}))
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// src/check/token-audit.ts
|
|
2741
|
+
import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync13 } from "fs";
|
|
2742
|
+
import { join as join18 } from "path";
|
|
2743
|
+
var TEXT_FLOOR = 4.5;
|
|
2744
|
+
var UI_FLOOR = 3;
|
|
2745
|
+
var FOREGROUNDS = [
|
|
2746
|
+
{ prefix: "--color-text-", floor: TEXT_FLOOR },
|
|
2747
|
+
{ prefix: "--color-stroke-", floor: UI_FLOOR },
|
|
2748
|
+
{ prefix: "--color-focus", floor: UI_FLOOR }
|
|
2749
|
+
];
|
|
2750
|
+
var SURFACES = ["--color-bg-base", "--color-bg-raised", "--color-bg-overlay", "--color-fill"];
|
|
2751
|
+
var NUMERIC_FLOORS = [
|
|
2752
|
+
{ token: "--text-prose", min: 18, why: "sustained reading (B-75)" },
|
|
2753
|
+
{ token: "--size-touch-target", min: 48, why: "hit area, unchanged in every mode" }
|
|
2754
|
+
];
|
|
2755
|
+
function composite(fg, alpha, bg) {
|
|
2756
|
+
return {
|
|
2757
|
+
r: Math.round(fg.r * alpha + bg.r * (1 - alpha)),
|
|
2758
|
+
g: Math.round(fg.g * alpha + bg.g * (1 - alpha)),
|
|
2759
|
+
b: Math.round(fg.b * alpha + bg.b * (1 - alpha))
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
function isDarkBlock(selector, atRuleDepth) {
|
|
2763
|
+
return /data-theme\s*=\s*["']?dark/.test(selector) || atRuleDepth > 0;
|
|
2764
|
+
}
|
|
2765
|
+
function collectScopes(files) {
|
|
2766
|
+
const light = {};
|
|
2767
|
+
const darkOverrides = {};
|
|
2768
|
+
for (const { path, source } of files) {
|
|
2540
2769
|
for (const block of splitRuleBlocks(source)) {
|
|
2541
2770
|
if (block.body.includes("{")) continue;
|
|
2542
|
-
if (block.selector.trim()
|
|
2543
|
-
const
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2771
|
+
if (!/^:root/.test(block.selector.trim())) continue;
|
|
2772
|
+
const target = isDarkBlock(block.selector, block.atRuleDepth) ? darkOverrides : light;
|
|
2773
|
+
for (const m of block.body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
|
2774
|
+
const offsetInBody = m.index ?? 0;
|
|
2775
|
+
target[m[1]] = {
|
|
2776
|
+
value: m[2].trim(),
|
|
2777
|
+
file: path,
|
|
2778
|
+
// `bodyStartLine` is where the block's body begins; add the newlines
|
|
2779
|
+
// before this declaration inside it.
|
|
2780
|
+
line: block.bodyStartLine + block.body.slice(0, offsetInBody).split("\n").length - 1
|
|
2781
|
+
};
|
|
2547
2782
|
}
|
|
2548
2783
|
}
|
|
2549
2784
|
}
|
|
2550
|
-
return
|
|
2785
|
+
return { light, dark: { ...light, ...darkOverrides } };
|
|
2786
|
+
}
|
|
2787
|
+
var asTokenMap = (scope) => Object.fromEntries(Object.entries(scope).map(([k, v]) => [k.replace(/^--/, ""), v.value]));
|
|
2788
|
+
function floorFor(token) {
|
|
2789
|
+
if (/^--color-stroke-.*-weak$/.test(token) || token === "--color-stroke-weak") return null;
|
|
2790
|
+
for (const { prefix, floor } of FOREGROUNDS) {
|
|
2791
|
+
if (token.startsWith(prefix)) return floor;
|
|
2792
|
+
}
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
function tokenFiles(projectRoot) {
|
|
2796
|
+
const read = (rel) => {
|
|
2797
|
+
try {
|
|
2798
|
+
return readFileSync13(join18(projectRoot, ...rel.split("/")), "utf8");
|
|
2799
|
+
} catch {
|
|
2800
|
+
return null;
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
const statePath = join18(projectRoot, ".jig", "state.json");
|
|
2804
|
+
if (existsSync11(statePath)) {
|
|
2805
|
+
try {
|
|
2806
|
+
const state = JSON.parse(readFileSync13(statePath, "utf8"));
|
|
2807
|
+
const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
|
|
2808
|
+
const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
|
|
2809
|
+
if (out.length > 0) return out;
|
|
2810
|
+
} catch {
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const legacy = join18(projectRoot, ".jig", "tokens");
|
|
2814
|
+
if (!existsSync11(legacy)) return [];
|
|
2815
|
+
return readdirSync8(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync13(join18(legacy, f), "utf8") }));
|
|
2816
|
+
}
|
|
2817
|
+
function auditTokenLayer(projectRoot) {
|
|
2818
|
+
const files = tokenFiles(projectRoot);
|
|
2819
|
+
if (files.length === 0) return [];
|
|
2820
|
+
const scopes = collectScopes(files);
|
|
2821
|
+
const problems = [];
|
|
2822
|
+
for (const theme of ["light", "dark"]) {
|
|
2823
|
+
const scope = scopes[theme];
|
|
2824
|
+
const map = asTokenMap(scope);
|
|
2825
|
+
const base = extractColorComponents(scope["--color-bg-base"]?.value ?? "", map);
|
|
2826
|
+
const surfaces = [];
|
|
2827
|
+
for (const name of SURFACES) {
|
|
2828
|
+
const decl = scope[name];
|
|
2829
|
+
if (!decl) continue;
|
|
2830
|
+
const c = extractColorComponents(decl.value, map);
|
|
2831
|
+
if (!c) continue;
|
|
2832
|
+
if (c.alpha >= 0.999) surfaces.push({ name, rgb: c.rgb });
|
|
2833
|
+
else if (base && base.alpha >= 0.999) {
|
|
2834
|
+
surfaces.push({ name, rgb: composite(c.rgb, c.alpha, base.rgb) });
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
if (surfaces.length === 0) continue;
|
|
2838
|
+
for (const [token, decl] of Object.entries(scope)) {
|
|
2839
|
+
const floor = floorFor(token);
|
|
2840
|
+
if (floor === null) continue;
|
|
2841
|
+
const fg = extractColorComponents(decl.value, map);
|
|
2842
|
+
if (!fg) continue;
|
|
2843
|
+
const failures = surfaces.map((surface) => {
|
|
2844
|
+
const solid = fg.alpha >= 0.999 ? fg.rgb : composite(fg.rgb, fg.alpha, surface.rgb);
|
|
2845
|
+
return { surface: surface.name, ratio: contrastRatio(solid, surface.rgb) };
|
|
2846
|
+
}).filter((f) => f.ratio < floor).sort((a, b) => a.ratio - b.ratio);
|
|
2847
|
+
if (failures.length === 0) continue;
|
|
2848
|
+
const worst = failures[0];
|
|
2849
|
+
const others = failures.length - 1;
|
|
2850
|
+
problems.push({
|
|
2851
|
+
token,
|
|
2852
|
+
surface: worst.surface,
|
|
2853
|
+
theme,
|
|
2854
|
+
ratio: Math.round(worst.ratio * 100) / 100,
|
|
2855
|
+
floor,
|
|
2856
|
+
file: decl.file,
|
|
2857
|
+
line: decl.line,
|
|
2858
|
+
message: `${token} is ${worst.ratio.toFixed(2)}:1 against ${worst.surface} in ${theme} mode, below the ${floor}:1 floor the token layer states for this role` + (others > 0 ? ` (and below it on ${others} other surface${others > 1 ? "s" : ""})` : "") + "."
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
for (const { token, min, why } of NUMERIC_FLOORS) {
|
|
2862
|
+
const decl = scope[token];
|
|
2863
|
+
if (!decl) continue;
|
|
2864
|
+
const px = /^([\d.]+)px$/.exec(decl.value.trim());
|
|
2865
|
+
if (!px) continue;
|
|
2866
|
+
const value = Number(px[1]);
|
|
2867
|
+
if (value >= min) continue;
|
|
2868
|
+
problems.push({
|
|
2869
|
+
token,
|
|
2870
|
+
theme,
|
|
2871
|
+
value,
|
|
2872
|
+
floor: min,
|
|
2873
|
+
file: decl.file,
|
|
2874
|
+
line: decl.line,
|
|
2875
|
+
message: `${token} is ${value}px, below the ${min}px floor for ${why}. This is an accessibility limit, not a density setting.`
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return problems;
|
|
2551
2880
|
}
|
|
2552
2881
|
|
|
2553
2882
|
// src/commands/check.ts
|
|
2554
|
-
var LEGACY_INDEX_REL =
|
|
2883
|
+
var LEGACY_INDEX_REL = join19(".jig", "rules.index.json");
|
|
2555
2884
|
function resolveIndexPath(projectRoot) {
|
|
2556
|
-
const legacy =
|
|
2557
|
-
if (
|
|
2558
|
-
return
|
|
2885
|
+
const legacy = join19(projectRoot, LEGACY_INDEX_REL);
|
|
2886
|
+
if (existsSync12(legacy)) return legacy;
|
|
2887
|
+
return join19(assetRoot(), "rules.index.json");
|
|
2559
2888
|
}
|
|
2560
2889
|
function resolveMode(projectRoot) {
|
|
2561
2890
|
try {
|
|
2562
|
-
const config = JSON.parse(
|
|
2891
|
+
const config = JSON.parse(readFileSync14(join19(projectRoot, "jig.config.json"), "utf8"));
|
|
2563
2892
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
2564
2893
|
if (modes.length === 1) return modes[0];
|
|
2565
2894
|
return modes.length > 1 ? "mixed" : "unknown";
|
|
@@ -2576,28 +2905,46 @@ function summariseUnscanned(files) {
|
|
|
2576
2905
|
}
|
|
2577
2906
|
function countSpecs() {
|
|
2578
2907
|
try {
|
|
2579
|
-
return loadSpecs(
|
|
2908
|
+
return loadSpecs(join19(assetRoot(), "rules")).length;
|
|
2580
2909
|
} catch {
|
|
2581
2910
|
return 0;
|
|
2582
2911
|
}
|
|
2583
2912
|
}
|
|
2584
2913
|
function check(opts) {
|
|
2585
2914
|
const indexPath = resolveIndexPath(opts.projectRoot);
|
|
2586
|
-
const index = validateIndex(JSON.parse(
|
|
2587
|
-
const tokens = loadTokenMap(opts.projectRoot);
|
|
2915
|
+
const index = validateIndex(JSON.parse(readFileSync14(indexPath, "utf8")));
|
|
2588
2916
|
const selection = selectFiles(opts.projectRoot, opts.all);
|
|
2589
|
-
const
|
|
2917
|
+
const exemptions = readExemptions(opts.projectRoot);
|
|
2918
|
+
const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
|
|
2590
2919
|
const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
|
|
2591
2920
|
const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
|
|
2921
|
+
const tokens = loadTokenMap(opts.projectRoot, stylesheets);
|
|
2592
2922
|
const projectParticipates = stylesheets.some((f) => {
|
|
2593
2923
|
if (!hasExtension(f, CSS_EXTENSIONS)) return false;
|
|
2594
2924
|
try {
|
|
2595
|
-
return participatesInTokenLayer(
|
|
2925
|
+
return participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
|
|
2596
2926
|
} catch {
|
|
2597
2927
|
return false;
|
|
2598
2928
|
}
|
|
2599
2929
|
});
|
|
2600
2930
|
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
|
|
2931
|
+
const TOKEN_RULE = {
|
|
2932
|
+
"--text-prose": "B-75",
|
|
2933
|
+
"--size-touch-target": "E-51"
|
|
2934
|
+
};
|
|
2935
|
+
for (const problem of auditTokenLayer(opts.projectRoot)) {
|
|
2936
|
+
const ruleId = TOKEN_RULE[problem.token] ?? "C-19";
|
|
2937
|
+
const entry = index.find((e) => e.id === ruleId);
|
|
2938
|
+
findings.push({
|
|
2939
|
+
ruleId,
|
|
2940
|
+
detector: "token-layer",
|
|
2941
|
+
bucket: entry?.bucket ?? "mechanical",
|
|
2942
|
+
severity: entry?.severity ?? "error",
|
|
2943
|
+
file: problem.file,
|
|
2944
|
+
line: problem.line,
|
|
2945
|
+
message: problem.message
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2601
2948
|
const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
|
|
2602
2949
|
const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
|
|
2603
2950
|
const noTokenLayer = (
|
|
@@ -2605,7 +2952,7 @@ function check(opts) {
|
|
|
2605
2952
|
// told to add a token @import it has nowhere to put.
|
|
2606
2953
|
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
2607
2954
|
try {
|
|
2608
|
-
return !participatesInTokenLayer(
|
|
2955
|
+
return !participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
|
|
2609
2956
|
} catch {
|
|
2610
2957
|
return true;
|
|
2611
2958
|
}
|
|
@@ -2620,18 +2967,20 @@ function check(opts) {
|
|
|
2620
2967
|
version: opts.version,
|
|
2621
2968
|
noTokenLayer,
|
|
2622
2969
|
mode: resolveMode(opts.projectRoot),
|
|
2623
|
-
unscanned: summariseUnscanned(files)
|
|
2970
|
+
unscanned: summariseUnscanned(files),
|
|
2971
|
+
exempt,
|
|
2972
|
+
exemptPatterns: byPattern
|
|
2624
2973
|
});
|
|
2625
2974
|
return { findings, report, hasError };
|
|
2626
2975
|
}
|
|
2627
2976
|
|
|
2628
2977
|
// src/commands/init.ts
|
|
2629
|
-
import { existsSync as
|
|
2630
|
-
import { dirname as
|
|
2978
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync5, readdirSync as readdirSync9, readFileSync as readFileSync18, writeFileSync as writeFileSync4 } from "fs";
|
|
2979
|
+
import { dirname as dirname6, isAbsolute, join as join23, resolve as resolve2, sep as sep3 } from "path";
|
|
2631
2980
|
|
|
2632
2981
|
// src/init/detect.ts
|
|
2633
|
-
import { readFileSync as
|
|
2634
|
-
import { join as
|
|
2982
|
+
import { readFileSync as readFileSync15 } from "fs";
|
|
2983
|
+
import { join as join20 } from "path";
|
|
2635
2984
|
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
2636
2985
|
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
2637
2986
|
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
@@ -2648,7 +2997,7 @@ var FRAMEWORK_DEPS = [
|
|
|
2648
2997
|
function detectFramework(projectRoot) {
|
|
2649
2998
|
let pkg;
|
|
2650
2999
|
try {
|
|
2651
|
-
pkg = JSON.parse(
|
|
3000
|
+
pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
|
|
2652
3001
|
} catch {
|
|
2653
3002
|
return void 0;
|
|
2654
3003
|
}
|
|
@@ -2666,7 +3015,7 @@ function detect(projectRoot) {
|
|
|
2666
3015
|
for (const f of cssFiles) {
|
|
2667
3016
|
let source;
|
|
2668
3017
|
try {
|
|
2669
|
-
source =
|
|
3018
|
+
source = readFileSync15(join20(projectRoot, f), "utf8");
|
|
2670
3019
|
} catch {
|
|
2671
3020
|
continue;
|
|
2672
3021
|
}
|
|
@@ -2701,8 +3050,8 @@ function isIgnored(projectRoot, relPath) {
|
|
|
2701
3050
|
}
|
|
2702
3051
|
|
|
2703
3052
|
// src/init/derive.ts
|
|
2704
|
-
import { readFileSync as
|
|
2705
|
-
import { join as
|
|
3053
|
+
import { readFileSync as readFileSync16 } from "fs";
|
|
3054
|
+
import { join as join21 } from "path";
|
|
2706
3055
|
var DEFAULT_PROPOSAL = {
|
|
2707
3056
|
h: 264,
|
|
2708
3057
|
s: 0,
|
|
@@ -2735,7 +3084,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
|
2735
3084
|
for (const file of cssFiles) {
|
|
2736
3085
|
let source;
|
|
2737
3086
|
try {
|
|
2738
|
-
source =
|
|
3087
|
+
source = readFileSync16(join21(projectRoot, file), "utf8");
|
|
2739
3088
|
} catch {
|
|
2740
3089
|
continue;
|
|
2741
3090
|
}
|
|
@@ -2784,7 +3133,7 @@ function balancedBraceBody(source, openIdx) {
|
|
|
2784
3133
|
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
2785
3134
|
let source;
|
|
2786
3135
|
try {
|
|
2787
|
-
source =
|
|
3136
|
+
source = readFileSync16(join21(projectRoot, tailwindConfigFile), "utf8");
|
|
2788
3137
|
} catch {
|
|
2789
3138
|
return null;
|
|
2790
3139
|
}
|
|
@@ -2840,7 +3189,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
|
2840
3189
|
for (const file of cssFiles) {
|
|
2841
3190
|
let source;
|
|
2842
3191
|
try {
|
|
2843
|
-
source =
|
|
3192
|
+
source = readFileSync16(join21(projectRoot, file), "utf8");
|
|
2844
3193
|
} catch {
|
|
2845
3194
|
continue;
|
|
2846
3195
|
}
|
|
@@ -3000,8 +3349,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
|
3000
3349
|
}
|
|
3001
3350
|
|
|
3002
3351
|
// src/init/project-name.ts
|
|
3003
|
-
import { readFileSync as
|
|
3004
|
-
import { basename, join as
|
|
3352
|
+
import { readFileSync as readFileSync17 } from "fs";
|
|
3353
|
+
import { basename, join as join22 } from "path";
|
|
3005
3354
|
function slugify(name) {
|
|
3006
3355
|
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
3007
3356
|
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -3009,7 +3358,7 @@ function slugify(name) {
|
|
|
3009
3358
|
}
|
|
3010
3359
|
function deriveProjectSlug(projectRoot) {
|
|
3011
3360
|
try {
|
|
3012
|
-
const pkg = JSON.parse(
|
|
3361
|
+
const pkg = JSON.parse(readFileSync17(join22(projectRoot, "package.json"), "utf8"));
|
|
3013
3362
|
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
3014
3363
|
} catch {
|
|
3015
3364
|
}
|
|
@@ -3075,7 +3424,7 @@ function parseSurfaces(answer, fallback) {
|
|
|
3075
3424
|
return parsed.length > 0 ? parsed : fallback;
|
|
3076
3425
|
}
|
|
3077
3426
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
3078
|
-
const existsOnDisk =
|
|
3427
|
+
const existsOnDisk = existsSync13(absPath);
|
|
3079
3428
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
3080
3429
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
3081
3430
|
return { existsOnDisk, tracked, modified };
|
|
@@ -3086,11 +3435,11 @@ function isValidSurfaceArray(v) {
|
|
|
3086
3435
|
);
|
|
3087
3436
|
}
|
|
3088
3437
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
3089
|
-
if (!
|
|
3438
|
+
if (!existsSync13(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
3090
3439
|
try {
|
|
3091
|
-
const parsed = JSON.parse(
|
|
3440
|
+
const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
|
|
3092
3441
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
3093
|
-
const brandResolves = brandCandidate ?
|
|
3442
|
+
const brandResolves = brandCandidate ? existsSync13(join23(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
3094
3443
|
return {
|
|
3095
3444
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
3096
3445
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -3099,12 +3448,61 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
|
|
|
3099
3448
|
return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
3100
3449
|
}
|
|
3101
3450
|
}
|
|
3451
|
+
function configuredBrandPath(projectRoot, configAbsPath) {
|
|
3452
|
+
if (!existsSync13(configAbsPath)) return { path: null };
|
|
3453
|
+
let declared;
|
|
3454
|
+
try {
|
|
3455
|
+
declared = JSON.parse(readFileSync18(configAbsPath, "utf8")).brand;
|
|
3456
|
+
} catch {
|
|
3457
|
+
return { path: null };
|
|
3458
|
+
}
|
|
3459
|
+
if (typeof declared !== "string" || !declared.trim()) return { path: null };
|
|
3460
|
+
const rel = declared.trim().replace(/\\/g, "/");
|
|
3461
|
+
const abs = resolve2(projectRoot, rel);
|
|
3462
|
+
const root = resolve2(projectRoot);
|
|
3463
|
+
if (isAbsolute(rel) || abs !== root && !abs.startsWith(root + sep3)) {
|
|
3464
|
+
return { path: null, refused: rel };
|
|
3465
|
+
}
|
|
3466
|
+
if (!/\.css$/i.test(rel)) return { path: null, refused: rel };
|
|
3467
|
+
return { path: rel };
|
|
3468
|
+
}
|
|
3469
|
+
function defaultTokenDir(detection) {
|
|
3470
|
+
const target = findWireTarget(detection);
|
|
3471
|
+
if (target) return [...target.split("/").slice(0, -1), "jig"];
|
|
3472
|
+
const candidates = detection.cssFiles.filter((f) => !isCssModule(f) && !isTokenLayerFile(f));
|
|
3473
|
+
if (candidates.length > 1) {
|
|
3474
|
+
const dirs = new Set(candidates.map((f) => f.split("/").slice(0, -1).join("/")));
|
|
3475
|
+
if (dirs.size === 1) {
|
|
3476
|
+
const only = [...dirs][0];
|
|
3477
|
+
return only ? [...only.split("/"), "jig"] : ["jig"];
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
return ["jig"];
|
|
3481
|
+
}
|
|
3482
|
+
function readConfiguredSurfaces(configAbsPath) {
|
|
3483
|
+
if (!existsSync13(configAbsPath)) return null;
|
|
3484
|
+
try {
|
|
3485
|
+
const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
|
|
3486
|
+
return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
|
|
3487
|
+
} catch {
|
|
3488
|
+
return null;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3102
3491
|
function isCssModule(path) {
|
|
3103
3492
|
return /\.module\.(css|scss)$/i.test(path);
|
|
3104
3493
|
}
|
|
3494
|
+
function legacyBrandFile(projectRoot) {
|
|
3495
|
+
const dir = join23(projectRoot, ".jig", "tokens");
|
|
3496
|
+
if (!existsSync13(dir)) return void 0;
|
|
3497
|
+
const found = readdirSync9(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
|
|
3498
|
+
return found ? relKey(".jig", "tokens", found) : void 0;
|
|
3499
|
+
}
|
|
3500
|
+
function isTokenLayerFile(f) {
|
|
3501
|
+
return /(^|\/)(\.jig\/tokens|jig)\/[^/]+\.css$/.test(f);
|
|
3502
|
+
}
|
|
3105
3503
|
function findWireTarget(d) {
|
|
3106
3504
|
if (d.cssSystem === "tailwind-v4" && d.tailwindV4EntryFile) return d.tailwindV4EntryFile;
|
|
3107
|
-
const candidates = d.cssFiles.filter((f) => !isCssModule(f));
|
|
3505
|
+
const candidates = d.cssFiles.filter((f) => !isCssModule(f) && !isTokenLayerFile(f));
|
|
3108
3506
|
if (candidates.length === 1) return candidates[0];
|
|
3109
3507
|
return null;
|
|
3110
3508
|
}
|
|
@@ -3115,45 +3513,35 @@ function insertAfterCharset(content, insertion) {
|
|
|
3115
3513
|
const charsetLen = m ? m[0].length : 0;
|
|
3116
3514
|
return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
|
|
3117
3515
|
}
|
|
3118
|
-
function
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
const existingModeMatch = MODE_IMPORT_RE.exec(content);
|
|
3124
|
-
if (brandPresent) {
|
|
3125
|
-
if (existingModeMatch && existingModeMatch[0].trim() === modeImportLine) {
|
|
3126
|
-
return { status: "already-present" };
|
|
3127
|
-
}
|
|
3128
|
-
if (existingModeMatch) {
|
|
3129
|
-
const previousMode = existingModeMatch[2];
|
|
3130
|
-
const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
|
|
3131
|
-
` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
|
|
3132
|
-
writeFileSync6(absPath, next3, "utf8");
|
|
3133
|
-
return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
|
|
3134
|
-
}
|
|
3135
|
-
const brandIdx = content.indexOf(brandImportLine);
|
|
3136
|
-
let insertAt = brandIdx + brandImportLine.length;
|
|
3137
|
-
if (content[insertAt] === "\r") insertAt++;
|
|
3138
|
-
if (content[insertAt] === "\n") insertAt++;
|
|
3139
|
-
const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
|
|
3140
|
-
writeFileSync6(absPath, next2, "utf8");
|
|
3141
|
-
return { status: "rewired", detail: `added missing mode import: ${mode}` };
|
|
3142
|
-
}
|
|
3143
|
-
const lines = `${brandImportLine}
|
|
3144
|
-
${modeImportLine}
|
|
3516
|
+
function barrelBody(fileName, brandFile, mode, version2) {
|
|
3517
|
+
return vendorHeader(fileName, version2, "css", null) + `/* The token layer for one surface, in one import.
|
|
3518
|
+
Brand declares the options; the mode selects from them. */
|
|
3519
|
+
@import "./${brandFile}";
|
|
3520
|
+
@import "./mode.${mode}.css";
|
|
3145
3521
|
`;
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3522
|
+
}
|
|
3523
|
+
var LEGACY_PAIR_RE = new RegExp(
|
|
3524
|
+
`@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES.join("|")})\\.css["'];?\\r?\\n?`
|
|
3525
|
+
);
|
|
3526
|
+
function wireBarrel(absPath, barrelImport) {
|
|
3527
|
+
const content = existsSync13(absPath) ? readFileSync18(absPath, "utf8") : "";
|
|
3528
|
+
const line = `@import "${barrelImport}";`;
|
|
3529
|
+
if (content.includes(line)) return { status: "already-present" };
|
|
3530
|
+
const legacy = LEGACY_PAIR_RE.exec(content);
|
|
3531
|
+
if (legacy) {
|
|
3532
|
+
const next2 = content.slice(0, legacy.index) + `${line}
|
|
3533
|
+
` + content.slice(legacy.index + legacy[0].length);
|
|
3534
|
+
writeFileSync4(absPath, next2, "utf8");
|
|
3535
|
+
return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
|
|
3536
|
+
}
|
|
3537
|
+
const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
|
|
3538
|
+
const next = tailwind ? content.slice(0, tailwind.index + tailwind[0].length) + `${line}
|
|
3539
|
+
` + content.slice(tailwind.index + tailwind[0].length) : insertAfterCharset(content, content ? `${line}
|
|
3540
|
+
|
|
3541
|
+
` : `${line}
|
|
3542
|
+
`);
|
|
3543
|
+
mkdirSync5(dirname6(absPath), { recursive: true });
|
|
3544
|
+
writeFileSync4(next === content ? absPath : absPath, next, "utf8");
|
|
3157
3545
|
return { status: "wired" };
|
|
3158
3546
|
}
|
|
3159
3547
|
async function init(opts) {
|
|
@@ -3219,8 +3607,11 @@ async function init(opts) {
|
|
|
3219
3607
|
}
|
|
3220
3608
|
}
|
|
3221
3609
|
if (opts.yes) {
|
|
3610
|
+
const declared = readConfiguredSurfaces(join23(opts.projectRoot, "jig.config.json"));
|
|
3611
|
+
const inEffect = declared ?? surfaces;
|
|
3612
|
+
const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
|
|
3222
3613
|
log(
|
|
3223
|
-
`Surface \u2192 mode: ${
|
|
3614
|
+
declared ? `Surface \u2192 mode: ${map} \u2014 from jig.config.json. Mode sets density, type scale and control sizes, and it outranks an agent's own reading of the project.` : `Surface \u2192 mode: ${map} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
|
|
3224
3615
|
);
|
|
3225
3616
|
}
|
|
3226
3617
|
if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
|
|
@@ -3234,11 +3625,30 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
|
|
|
3234
3625
|
}
|
|
3235
3626
|
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
3236
3627
|
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
3237
|
-
const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
|
|
3238
|
-
const brandAbsPath = join20(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
|
|
3239
3628
|
const configRelPath = "jig.config.json";
|
|
3240
|
-
const configAbsPath =
|
|
3241
|
-
const
|
|
3629
|
+
const configAbsPath = join23(opts.projectRoot, configRelPath);
|
|
3630
|
+
const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
|
|
3631
|
+
if (configuredBrand.refused) {
|
|
3632
|
+
log(
|
|
3633
|
+
`jig.config.json names brand '${configuredBrand.refused}', which is outside the project or is not a .css file \u2014 ignoring it and using the default location.`
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
const priorManifest = readInitManifest(opts.projectRoot);
|
|
3637
|
+
const priorBrand = Object.keys(priorManifest?.files ?? {}).find((f) => /(^|\/)brand\.[\w.-]+\.css$/.test(f)) ?? // Also honour a brand file that is simply THERE. A project may hold one
|
|
3638
|
+
// init never recorded — written by hand, or by a version that predates the
|
|
3639
|
+
// sidecar — and writing a second one somewhere else would leave two token
|
|
3640
|
+
// layers, which is worse than either location.
|
|
3641
|
+
detection.cssFiles.find((f) => isTokenLayerFile(f) && /(^|\/)brand\./.test(f)) ?? // `.jig/` is a dotdir, so detection never walks into it. Probe it directly:
|
|
3642
|
+
// a project holding a legacy token layer must keep it rather than gain a
|
|
3643
|
+
// second one somewhere else.
|
|
3644
|
+
legacyBrandFile(opts.projectRoot);
|
|
3645
|
+
const brandRelPath = configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
|
|
3646
|
+
const brandAbsPath = join23(opts.projectRoot, ...brandRelPath.split("/"));
|
|
3647
|
+
const tokensRelDir = brandRelPath.split("/").slice(0, -1);
|
|
3648
|
+
log(
|
|
3649
|
+
`Token layer: ${tokensRelDir.join("/") || "."}/ \u2014 ` + (configuredBrand.path ? "from jig.config.json." : priorBrand ? "the layout this project already had. Set `brand` in jig.config.json to move it." : findWireTarget(detection) ? "beside the stylesheet being wired. Set `brand` in jig.config.json to put it elsewhere." : "no stylesheet found to follow, so the project root. Set `brand` in jig.config.json to move it.")
|
|
3650
|
+
);
|
|
3651
|
+
const initManifest = priorManifest;
|
|
3242
3652
|
const files = { ...initManifest?.files ?? {} };
|
|
3243
3653
|
const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
|
|
3244
3654
|
const configState = fileState(opts.projectRoot, configAbsPath, configRelPath, initManifest);
|
|
@@ -3266,72 +3676,94 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
3266
3676
|
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
3267
3677
|
}
|
|
3268
3678
|
if (brandAction === "written") {
|
|
3269
|
-
const defaultCssRaw =
|
|
3679
|
+
const defaultCssRaw = readFileSync18(join23(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
3270
3680
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
3271
|
-
|
|
3272
|
-
|
|
3681
|
+
mkdirSync5(dirname6(brandAbsPath), { recursive: true });
|
|
3682
|
+
writeFileSync4(brandAbsPath, content, "utf8");
|
|
3273
3683
|
files[brandRelPath] = checksum(content);
|
|
3274
3684
|
}
|
|
3275
3685
|
if (configAction === "written") {
|
|
3276
3686
|
const config = { brand: brandRelPath, surfaces };
|
|
3277
3687
|
const content = `${JSON.stringify(config, null, 2)}
|
|
3278
3688
|
`;
|
|
3279
|
-
|
|
3689
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
3280
3690
|
files[configRelPath] = checksum(content);
|
|
3281
3691
|
} else if (configAction === "merged") {
|
|
3282
3692
|
let existing = {};
|
|
3283
3693
|
try {
|
|
3284
|
-
existing = JSON.parse(
|
|
3694
|
+
existing = JSON.parse(readFileSync18(configAbsPath, "utf8"));
|
|
3285
3695
|
} catch {
|
|
3286
3696
|
existing = {};
|
|
3287
3697
|
}
|
|
3288
3698
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
3289
|
-
const brandStillResolves = existingBrand ?
|
|
3699
|
+
const brandStillResolves = existingBrand ? existsSync13(join23(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
3290
3700
|
const merged = {
|
|
3291
3701
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
3292
3702
|
surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
|
|
3293
3703
|
};
|
|
3294
3704
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
3295
3705
|
`;
|
|
3296
|
-
|
|
3706
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
3297
3707
|
files[configRelPath] = checksum(content);
|
|
3298
3708
|
}
|
|
3299
3709
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
3300
3710
|
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
3301
|
-
const wiringBrandAbsPath =
|
|
3711
|
+
const wiringBrandAbsPath = join23(opts.projectRoot, ...effectiveConfig.brand.split("/"));
|
|
3302
3712
|
const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
|
|
3303
3713
|
const modeAbsPaths = {};
|
|
3304
3714
|
for (const mode of declaredModes) {
|
|
3305
3715
|
const modeFileName = `mode.${mode}.css`;
|
|
3306
|
-
const modeAbsPath2 =
|
|
3716
|
+
const modeAbsPath2 = join23(opts.projectRoot, ...tokensRelDir, modeFileName);
|
|
3307
3717
|
modeAbsPaths[mode] = modeAbsPath2;
|
|
3308
|
-
const modeRelPath = relKey(
|
|
3718
|
+
const modeRelPath = relKey(...tokensRelDir, modeFileName);
|
|
3309
3719
|
const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
|
|
3310
3720
|
let modeAction = "written";
|
|
3311
3721
|
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
3312
3722
|
else if (modeState.modified) modeAction = "skipped-edited";
|
|
3313
3723
|
if (modeAction === "written") {
|
|
3314
|
-
const modeSourceRaw =
|
|
3724
|
+
const modeSourceRaw = readFileSync18(join23(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
3315
3725
|
const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
|
|
3316
|
-
|
|
3317
|
-
|
|
3726
|
+
mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
|
|
3727
|
+
writeFileSync4(modeAbsPath2, content, "utf8");
|
|
3318
3728
|
files[modeRelPath] = checksum(content);
|
|
3319
3729
|
} else {
|
|
3320
3730
|
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
3321
3731
|
}
|
|
3322
3732
|
}
|
|
3323
3733
|
const modeAbsPath = modeAbsPaths[primaryMode];
|
|
3734
|
+
const brandFileOnly = brandRelPath.split("/").pop();
|
|
3735
|
+
const barrelFor = (mode) => mode === primaryMode ? "theme.css" : `theme.${mode}.css`;
|
|
3736
|
+
for (const mode of declaredModes) {
|
|
3737
|
+
const rel = relKey(...tokensRelDir, barrelFor(mode));
|
|
3738
|
+
const abs = join23(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
|
|
3739
|
+
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
3740
|
+
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
3741
|
+
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
3742
|
+
continue;
|
|
3743
|
+
}
|
|
3744
|
+
const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
|
|
3745
|
+
mkdirSync5(dirname6(abs), { recursive: true });
|
|
3746
|
+
writeFileSync4(abs, content, "utf8");
|
|
3747
|
+
files[rel] = checksum(content);
|
|
3748
|
+
}
|
|
3749
|
+
if (declaredModes.length > 1) {
|
|
3750
|
+
log("\nOne barrel per surface. Import each at that route's entry point:");
|
|
3751
|
+
for (const surface of effectiveConfig.surfaces) {
|
|
3752
|
+
log(` '${surface.match}' \u2192 ${relKey(...tokensRelDir, barrelFor(surface.mode))}`);
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3324
3755
|
writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
|
|
3325
3756
|
const wireTarget = findWireTarget(detection);
|
|
3326
3757
|
let wiring;
|
|
3327
3758
|
if (wireTarget) {
|
|
3328
|
-
const targetAbsDir =
|
|
3329
|
-
const
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3759
|
+
const targetAbsDir = dirname6(join23(opts.projectRoot, wireTarget));
|
|
3760
|
+
const barrelImport = relativeImportPath(
|
|
3761
|
+
targetAbsDir,
|
|
3762
|
+
join23(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
3763
|
+
);
|
|
3764
|
+
const snippet = `@import "${barrelImport}";`;
|
|
3333
3765
|
try {
|
|
3334
|
-
const outcome =
|
|
3766
|
+
const outcome = wireBarrel(join23(opts.projectRoot, wireTarget), barrelImport);
|
|
3335
3767
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
3336
3768
|
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
3337
3769
|
log(`
|
|
@@ -3356,7 +3788,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
3356
3788
|
if (pasteTargets.length > 0) {
|
|
3357
3789
|
log("Paste the block for whichever of these is your global stylesheet:");
|
|
3358
3790
|
for (const target of pasteTargets) {
|
|
3359
|
-
const dir =
|
|
3791
|
+
const dir = dirname6(join23(opts.projectRoot, target));
|
|
3360
3792
|
const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
|
|
3361
3793
|
@import "${relativeImportPath(dir, modeAbsPath)}";`;
|
|
3362
3794
|
log(`
|
|
@@ -3397,7 +3829,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
3397
3829
|
|
|
3398
3830
|
// src/index.ts
|
|
3399
3831
|
var packageRoot = getPackageRoot();
|
|
3400
|
-
var { version } = JSON.parse(
|
|
3832
|
+
var { version } = JSON.parse(readFileSync19(join24(packageRoot, "package.json"), "utf8"));
|
|
3401
3833
|
function warnIfUnpublishedPin() {
|
|
3402
3834
|
if (isPublishedBuild(packageRoot)) return;
|
|
3403
3835
|
console.warn(
|
|
@@ -3434,9 +3866,9 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
3434
3866
|
process.exit(1);
|
|
3435
3867
|
}
|
|
3436
3868
|
});
|
|
3437
|
-
program.command("explain").description("
|
|
3869
|
+
program.command("explain").description("Explain a rule, or find the rules you cannot name.").argument("[query]", "a rule id ('C-19'), a word to search for ('contrast'), or a section letter with --list").option("--list", "list every rule id and title, or one section with a section letter").action((query, options) => {
|
|
3438
3870
|
try {
|
|
3439
|
-
console.log(explain({ ruleId, version }));
|
|
3871
|
+
console.log(explain({ ruleId: query ?? "", version, list: options.list }));
|
|
3440
3872
|
} catch (err) {
|
|
3441
3873
|
console.error(err.message);
|
|
3442
3874
|
process.exit(1);
|