jig-ui 0.2.0 → 0.3.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 +16 -4
- package/dist/index.js +1794 -22
- package/package.json +1 -1
- package/templates/command-metadata.json +2 -2
- package/tokens/brand.default.css +9 -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 readFileSync12 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join14 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -427,20 +427,59 @@ function install(opts) {
|
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
// src/commands/update.ts
|
|
430
|
-
import { basename, dirname as
|
|
431
|
-
import { existsSync as
|
|
430
|
+
import { basename, dirname as dirname5, join as join5 } from "path";
|
|
431
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
432
|
+
|
|
433
|
+
// src/install/target.ts
|
|
434
|
+
import { resolve } from "path";
|
|
435
|
+
function resolveInstalled(projectRoot, homeDir) {
|
|
436
|
+
const projectManifest = readManifest(projectRoot);
|
|
437
|
+
const existing = projectManifest ?? readManifest(homeDir);
|
|
438
|
+
if (!existing) return null;
|
|
439
|
+
const sameRoot = resolve(projectRoot) === resolve(homeDir);
|
|
440
|
+
const scope = !projectManifest ? "global" : sameRoot ? existing.scope : "project";
|
|
441
|
+
const installRoot = scope === "global" ? homeDir : projectRoot;
|
|
442
|
+
return { installRoot, scope, manifest: existing };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/init/state.ts
|
|
446
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
447
|
+
import { dirname as dirname4, join as join4 } from "path";
|
|
448
|
+
var INIT_MANIFEST_REL = join4(".jig", "init-manifest.json");
|
|
449
|
+
function readInitManifest(projectRoot) {
|
|
450
|
+
const path = join4(projectRoot, INIT_MANIFEST_REL);
|
|
451
|
+
if (!existsSync4(path)) return null;
|
|
452
|
+
try {
|
|
453
|
+
const raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
454
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
455
|
+
const files = raw.files;
|
|
456
|
+
if (typeof files !== "object" || files === null) return null;
|
|
457
|
+
return { files };
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function writeInitManifest(projectRoot, m) {
|
|
463
|
+
const path = join4(projectRoot, INIT_MANIFEST_REL);
|
|
464
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
465
|
+
writeFileSync3(path, `${JSON.stringify(m, null, 2)}
|
|
466
|
+
`, "utf8");
|
|
467
|
+
}
|
|
468
|
+
function isInitFileModified(projectRoot, relPath, m) {
|
|
469
|
+
const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
|
|
470
|
+
return isModified(projectRoot, relPath, asManifest);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/commands/update.ts
|
|
432
474
|
var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
|
|
433
475
|
function update(opts) {
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
if (!existing) {
|
|
476
|
+
const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
|
|
477
|
+
if (!resolved) {
|
|
437
478
|
throw new Error(
|
|
438
479
|
`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`
|
|
439
480
|
);
|
|
440
481
|
}
|
|
441
|
-
const
|
|
442
|
-
const discoveredScope = !projectManifest ? "global" : sameRoot ? existing.scope : "project";
|
|
443
|
-
const installRoot = discoveredScope === "global" ? opts.homeDir : opts.projectRoot;
|
|
482
|
+
const { installRoot, scope: discoveredScope, manifest: existing } = resolved;
|
|
444
483
|
const adapter = getAdapter(existing.agent);
|
|
445
484
|
if (!adapter.supportsScope(discoveredScope)) {
|
|
446
485
|
throw new Error(
|
|
@@ -451,35 +490,35 @@ function update(opts) {
|
|
|
451
490
|
const skipped = [];
|
|
452
491
|
const files = { ...existing.files };
|
|
453
492
|
const write = (key, content) => {
|
|
454
|
-
const abs =
|
|
455
|
-
|
|
456
|
-
|
|
493
|
+
const abs = join5(installRoot, ...key.split("/"));
|
|
494
|
+
mkdirSync4(dirname5(abs), { recursive: true });
|
|
495
|
+
writeFileSync4(abs, content, "utf8");
|
|
457
496
|
files[key] = checksum(content);
|
|
458
497
|
updated.push(key);
|
|
459
498
|
};
|
|
460
|
-
const rulesDir =
|
|
499
|
+
const rulesDir = join5(opts.packageRoot, "rules");
|
|
461
500
|
for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
462
501
|
const key = relKey(".jig", file);
|
|
463
502
|
if (isModified(installRoot, key, existing)) {
|
|
464
503
|
skipped.push(key);
|
|
465
504
|
continue;
|
|
466
505
|
}
|
|
467
|
-
write(key, vendorHeader(file, opts.version) +
|
|
506
|
+
write(key, vendorHeader(file, opts.version) + readFileSync4(join5(rulesDir, file), "utf8"));
|
|
468
507
|
}
|
|
469
|
-
const tokensDir =
|
|
508
|
+
const tokensDir = join5(opts.packageRoot, "tokens");
|
|
470
509
|
for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
471
510
|
const key = relKey(".jig", "tokens", file);
|
|
472
511
|
if (isModified(installRoot, key, existing)) {
|
|
473
512
|
skipped.push(key);
|
|
474
513
|
continue;
|
|
475
514
|
}
|
|
476
|
-
write(key, vendorHeader(file, opts.version, "css") +
|
|
515
|
+
write(key, vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8"));
|
|
477
516
|
}
|
|
478
517
|
const indexKey = relKey(".jig", "rules.index.json");
|
|
479
518
|
if (isModified(installRoot, indexKey, existing)) {
|
|
480
519
|
skipped.push(indexKey);
|
|
481
520
|
} else {
|
|
482
|
-
write(indexKey,
|
|
521
|
+
write(indexKey, readFileSync4(join5(opts.packageRoot, "rules.index.json"), "utf8"));
|
|
483
522
|
}
|
|
484
523
|
const skillBody = buildSkillBody(opts.packageRoot, discoveredScope);
|
|
485
524
|
const skillFiles = skillFilesFor(adapter, {
|
|
@@ -492,8 +531,8 @@ function update(opts) {
|
|
|
492
531
|
const key = file.relPath;
|
|
493
532
|
const isBlockFile = file.content.includes(BLOCK_START);
|
|
494
533
|
if (isBlockFile) {
|
|
495
|
-
const abs =
|
|
496
|
-
const current =
|
|
534
|
+
const abs = join5(installRoot, ...key.split("/"));
|
|
535
|
+
const current = existsSync5(abs) ? readFileSync4(abs, "utf8") : "";
|
|
497
536
|
write(key, upsertBlock(current, file.content));
|
|
498
537
|
continue;
|
|
499
538
|
}
|
|
@@ -504,7 +543,28 @@ function update(opts) {
|
|
|
504
543
|
write(key, file.content);
|
|
505
544
|
}
|
|
506
545
|
for (const key of ALWAYS_REPLACE) {
|
|
507
|
-
write(key,
|
|
546
|
+
write(key, readFileSync4(join5(opts.packageRoot, basename(key)), "utf8"));
|
|
547
|
+
}
|
|
548
|
+
const initManifest = readInitManifest(opts.projectRoot);
|
|
549
|
+
if (initManifest) {
|
|
550
|
+
const initFiles = { ...initManifest.files };
|
|
551
|
+
let initChanged = false;
|
|
552
|
+
for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
553
|
+
const key = relKey(".jig", "tokens", file);
|
|
554
|
+
if (!(key in initManifest.files)) continue;
|
|
555
|
+
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
556
|
+
skipped.push(key);
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const content = vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8");
|
|
560
|
+
const abs = join5(opts.projectRoot, ...key.split("/"));
|
|
561
|
+
mkdirSync4(dirname5(abs), { recursive: true });
|
|
562
|
+
writeFileSync4(abs, content, "utf8");
|
|
563
|
+
initFiles[key] = checksum(content);
|
|
564
|
+
initChanged = true;
|
|
565
|
+
updated.push(key);
|
|
566
|
+
}
|
|
567
|
+
if (initChanged) writeInitManifest(opts.projectRoot, { files: initFiles });
|
|
508
568
|
}
|
|
509
569
|
const manifest = {
|
|
510
570
|
...existing,
|
|
@@ -517,9 +577,1689 @@ function update(opts) {
|
|
|
517
577
|
return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
|
|
518
578
|
}
|
|
519
579
|
|
|
580
|
+
// src/commands/check.ts
|
|
581
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
582
|
+
import { join as join9 } from "path";
|
|
583
|
+
|
|
584
|
+
// src/rules/schema.ts
|
|
585
|
+
var BUCKETS = ["mechanical", "judgment", "hybrid"];
|
|
586
|
+
var SEVERITIES = ["error", "warning", "note"];
|
|
587
|
+
function validateIndex(entries) {
|
|
588
|
+
if (!Array.isArray(entries)) throw new Error("rules.index.json must be an array");
|
|
589
|
+
return entries.map((raw, i) => {
|
|
590
|
+
const e = raw;
|
|
591
|
+
const at = `rules.index.json[${i}]`;
|
|
592
|
+
if (typeof e.id !== "string" || !/^[A-Z]-\d+$/.test(e.id)) {
|
|
593
|
+
throw new Error(`${at}: missing or malformed id`);
|
|
594
|
+
}
|
|
595
|
+
if (!BUCKETS.includes(e.bucket)) {
|
|
596
|
+
throw new Error(`${at} (${e.id}): bucket must be one of ${BUCKETS.join(", ")}`);
|
|
597
|
+
}
|
|
598
|
+
if (!SEVERITIES.includes(e.severity)) {
|
|
599
|
+
throw new Error(`${at} (${e.id}): severity must be one of ${SEVERITIES.join(", ")}`);
|
|
600
|
+
}
|
|
601
|
+
if (typeof e.since !== "string") {
|
|
602
|
+
throw new Error(`${at} (${e.id}): since is required`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
id: e.id,
|
|
606
|
+
bucket: e.bucket,
|
|
607
|
+
severity: e.severity,
|
|
608
|
+
detector: typeof e.detector === "string" ? e.detector : void 0,
|
|
609
|
+
fix: typeof e.fix === "string" ? e.fix : void 0,
|
|
610
|
+
since: e.since
|
|
611
|
+
};
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/check/files.ts
|
|
616
|
+
import { execFileSync } from "child_process";
|
|
617
|
+
import { readdirSync as readdirSync3 } from "fs";
|
|
618
|
+
import { join as join6, relative, sep } from "path";
|
|
619
|
+
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
620
|
+
"node_modules",
|
|
621
|
+
"dist",
|
|
622
|
+
".git",
|
|
623
|
+
".jig",
|
|
624
|
+
".next",
|
|
625
|
+
"build",
|
|
626
|
+
"out",
|
|
627
|
+
"coverage",
|
|
628
|
+
".svelte-kit",
|
|
629
|
+
"vendor"
|
|
630
|
+
]);
|
|
631
|
+
function isGitRepo(root) {
|
|
632
|
+
try {
|
|
633
|
+
const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
634
|
+
cwd: root,
|
|
635
|
+
encoding: "utf8",
|
|
636
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
637
|
+
});
|
|
638
|
+
return out.trim() === "true";
|
|
639
|
+
} catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function gitChangedFiles(root) {
|
|
644
|
+
let diffOut;
|
|
645
|
+
try {
|
|
646
|
+
diffOut = execFileSync(
|
|
647
|
+
"git",
|
|
648
|
+
["-c", "core.quotePath=false", "diff", "--name-only", "--relative", "-z", "HEAD"],
|
|
649
|
+
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
650
|
+
);
|
|
651
|
+
} catch {
|
|
652
|
+
diffOut = "";
|
|
653
|
+
}
|
|
654
|
+
let untrackedOut;
|
|
655
|
+
try {
|
|
656
|
+
untrackedOut = execFileSync(
|
|
657
|
+
"git",
|
|
658
|
+
["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
659
|
+
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
660
|
+
);
|
|
661
|
+
} catch {
|
|
662
|
+
untrackedOut = "";
|
|
663
|
+
}
|
|
664
|
+
const files = /* @__PURE__ */ new Set();
|
|
665
|
+
for (const entry of [...diffOut.split("\0"), ...untrackedOut.split("\0")]) {
|
|
666
|
+
const f = entry.trim();
|
|
667
|
+
if (f) files.add(f);
|
|
668
|
+
}
|
|
669
|
+
return [...files];
|
|
670
|
+
}
|
|
671
|
+
function isExcluded(relPath) {
|
|
672
|
+
return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
|
|
673
|
+
}
|
|
674
|
+
function walk(root, dir, out) {
|
|
675
|
+
let entries;
|
|
676
|
+
try {
|
|
677
|
+
entries = readdirSync3(dir, { withFileTypes: true });
|
|
678
|
+
} catch {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
for (const entry of entries) {
|
|
682
|
+
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
683
|
+
const abs = join6(dir, entry.name);
|
|
684
|
+
if (entry.isDirectory()) {
|
|
685
|
+
walk(root, abs, out);
|
|
686
|
+
} else if (entry.isFile()) {
|
|
687
|
+
out.push(relative(root, abs).split(sep).join("/"));
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function filterGitIgnored(root, files) {
|
|
692
|
+
if (files.length === 0) return files;
|
|
693
|
+
const parseIgnored = (out) => new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
694
|
+
try {
|
|
695
|
+
const out = execFileSync("git", ["check-ignore", "--stdin"], {
|
|
696
|
+
cwd: root,
|
|
697
|
+
input: `${files.join("\n")}
|
|
698
|
+
`,
|
|
699
|
+
encoding: "utf8",
|
|
700
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
701
|
+
});
|
|
702
|
+
const ignored = parseIgnored(out);
|
|
703
|
+
return files.filter((f) => !ignored.has(f));
|
|
704
|
+
} catch (err) {
|
|
705
|
+
const status = err.status;
|
|
706
|
+
if (status === 1) {
|
|
707
|
+
const stdout = err.stdout;
|
|
708
|
+
const out = typeof stdout === "string" ? stdout : stdout?.toString("utf8") ?? "";
|
|
709
|
+
const ignored = parseIgnored(out);
|
|
710
|
+
return files.filter((f) => !ignored.has(f));
|
|
711
|
+
}
|
|
712
|
+
return files;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
function wholeRepoFiles(root) {
|
|
716
|
+
const out = [];
|
|
717
|
+
walk(root, root, out);
|
|
718
|
+
return isGitRepo(root) ? filterGitIgnored(root, out) : out;
|
|
719
|
+
}
|
|
720
|
+
function selectFiles(root, all) {
|
|
721
|
+
if (all || !isGitRepo(root)) return { files: wholeRepoFiles(root), mode: "all" };
|
|
722
|
+
const changed = gitChangedFiles(root).filter((f) => !isExcluded(f));
|
|
723
|
+
if (changed.length === 0) return { files: wholeRepoFiles(root), mode: "all" };
|
|
724
|
+
return { files: changed, mode: "changed" };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/check/report.ts
|
|
728
|
+
var MAX_ROWS_PER_RULE_PER_FILE = 3;
|
|
729
|
+
var SEVERITY_SYMBOL = { error: "\u2717", warning: "\u26A0", note: "\xB7" };
|
|
730
|
+
function plural(n, word) {
|
|
731
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
732
|
+
}
|
|
733
|
+
function formatReport(findings, meta) {
|
|
734
|
+
const lines = [];
|
|
735
|
+
const seenRule = /* @__PURE__ */ new Set();
|
|
736
|
+
const rows = findings.map((f) => {
|
|
737
|
+
const symbol = SEVERITY_SYMBOL[f.severity] ?? "\xB7";
|
|
738
|
+
const loc = `${f.file}:${f.line}`;
|
|
739
|
+
const firstOccurrence = !seenRule.has(f.ruleId);
|
|
740
|
+
seenRule.add(f.ruleId);
|
|
741
|
+
const hint = firstOccurrence ? ` (see .jig/00-anti-patterns.md#${f.ruleId.toLowerCase()})` : "";
|
|
742
|
+
return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
|
|
743
|
+
});
|
|
744
|
+
if (rows.length > 0) {
|
|
745
|
+
const msgW = Math.max(0, ...rows.map((r) => r.message.length));
|
|
746
|
+
const locW = Math.max(0, ...rows.map((r) => r.loc.length));
|
|
747
|
+
const shown = /* @__PURE__ */ new Map();
|
|
748
|
+
const suppressed = /* @__PURE__ */ new Map();
|
|
749
|
+
for (const r of rows) {
|
|
750
|
+
const key = `${r.ruleId}\0${r.fileOnly}`;
|
|
751
|
+
const n = (shown.get(key) ?? 0) + 1;
|
|
752
|
+
shown.set(key, n);
|
|
753
|
+
if (n <= MAX_ROWS_PER_RULE_PER_FILE) {
|
|
754
|
+
lines.push(
|
|
755
|
+
` ${r.symbol} ${r.ruleId.padEnd(6)}${r.message.padEnd(msgW)} ${r.loc.padEnd(locW)} [${r.bucket}]${r.hint}`
|
|
756
|
+
);
|
|
757
|
+
} else {
|
|
758
|
+
suppressed.set(key, (suppressed.get(key) ?? 0) + 1);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const [key, n] of suppressed) {
|
|
762
|
+
const [ruleId, file] = key.split("\0");
|
|
763
|
+
lines.push(` \u2026 ${n} more ${ruleId} in ${file}. Run with --json for all of them.`);
|
|
764
|
+
}
|
|
765
|
+
lines.push("");
|
|
766
|
+
} else {
|
|
767
|
+
lines.push(" No findings.");
|
|
768
|
+
lines.push("");
|
|
769
|
+
if (meta.noTokenLayer) {
|
|
770
|
+
lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
|
|
771
|
+
lines.push(" Import a brand and a mode file to bring your CSS under the token layer:");
|
|
772
|
+
lines.push(' @import ".jig/tokens/brand.default.css";');
|
|
773
|
+
lines.push(' @import ".jig/tokens/mode.product.css";');
|
|
774
|
+
lines.push("");
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
778
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
779
|
+
const notes = findings.filter((f) => f.severity === "note").length;
|
|
780
|
+
const summaryParts = [plural(errors, "error")];
|
|
781
|
+
if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
|
|
782
|
+
if (notes > 0) summaryParts.push(plural(notes, "note"));
|
|
783
|
+
const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
|
|
784
|
+
lines.push(` ${summaryParts.join(", ")} \xB7 ${meta.totalRules} rules, ${rulesFired} fired`);
|
|
785
|
+
const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
786
|
+
const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
|
|
787
|
+
lines.push(` JIG_CHECK: version=${meta.version} mechanical=${mechStatus} judgment=not-run`);
|
|
788
|
+
return lines.join("\n");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/check/token-layer.ts
|
|
792
|
+
var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
|
|
793
|
+
var VAR_RE = /var\(\s*--([\w-]+)/g;
|
|
794
|
+
function participatesInTokenLayer(source, tokens) {
|
|
795
|
+
if (IMPORT_RE.test(source)) return true;
|
|
796
|
+
VAR_RE.lastIndex = 0;
|
|
797
|
+
let m;
|
|
798
|
+
while (m = VAR_RE.exec(source)) {
|
|
799
|
+
if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/check/run.ts
|
|
805
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
806
|
+
import { join as join7 } from "path";
|
|
807
|
+
|
|
808
|
+
// src/check/css.ts
|
|
809
|
+
function maskComments(source) {
|
|
810
|
+
let out = "";
|
|
811
|
+
let i = 0;
|
|
812
|
+
while (i < source.length) {
|
|
813
|
+
const ch = source[i];
|
|
814
|
+
if (ch === '"' || ch === "'") {
|
|
815
|
+
const quote = ch;
|
|
816
|
+
out += ch;
|
|
817
|
+
i++;
|
|
818
|
+
while (i < source.length) {
|
|
819
|
+
if (source[i] === "\\" && i + 1 < source.length) {
|
|
820
|
+
out += source[i] + source[i + 1];
|
|
821
|
+
i += 2;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
out += source[i];
|
|
825
|
+
if (source[i] === quote) {
|
|
826
|
+
i++;
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
i++;
|
|
830
|
+
}
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
834
|
+
const end = source.indexOf("*/", i + 2);
|
|
835
|
+
const stop = end === -1 ? source.length : end + 2;
|
|
836
|
+
for (let j = i; j < stop; j++) out += source[j] === "\n" ? "\n" : " ";
|
|
837
|
+
i = stop;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
out += ch;
|
|
841
|
+
i++;
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
function splitRuleBlocks(source) {
|
|
846
|
+
source = maskComments(source);
|
|
847
|
+
const blocks = [];
|
|
848
|
+
const stack = [];
|
|
849
|
+
let line = 1;
|
|
850
|
+
let lastBoundary = 0;
|
|
851
|
+
for (let i = 0; i < source.length; i++) {
|
|
852
|
+
const ch = source[i];
|
|
853
|
+
if (ch === "\n") line++;
|
|
854
|
+
if (ch === "{") {
|
|
855
|
+
stack.push({ selectorStart: lastBoundary, bodyStart: i + 1, bodyStartLine: line });
|
|
856
|
+
lastBoundary = i + 1;
|
|
857
|
+
} else if (ch === "}") {
|
|
858
|
+
const top = stack.pop();
|
|
859
|
+
if (top) {
|
|
860
|
+
blocks.push({
|
|
861
|
+
selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
|
|
862
|
+
body: source.slice(top.bodyStart, i),
|
|
863
|
+
bodyStartLine: top.bodyStartLine
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
lastBoundary = i + 1;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return blocks;
|
|
870
|
+
}
|
|
871
|
+
function blankNested(body) {
|
|
872
|
+
const out = body.split("");
|
|
873
|
+
const blank = (from, to) => {
|
|
874
|
+
for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
|
|
875
|
+
};
|
|
876
|
+
let depth = 0;
|
|
877
|
+
let segmentStart = 0;
|
|
878
|
+
for (let i = 0; i < body.length; i++) {
|
|
879
|
+
const ch = body[i];
|
|
880
|
+
if (ch === "{") {
|
|
881
|
+
if (depth === 0) blank(segmentStart, i);
|
|
882
|
+
depth++;
|
|
883
|
+
out[i] = " ";
|
|
884
|
+
} else if (ch === "}") {
|
|
885
|
+
depth = Math.max(0, depth - 1);
|
|
886
|
+
out[i] = " ";
|
|
887
|
+
if (depth === 0) segmentStart = i + 1;
|
|
888
|
+
} else if (depth > 0) {
|
|
889
|
+
if (ch !== "\n") out[i] = " ";
|
|
890
|
+
} else if (ch === ";") {
|
|
891
|
+
segmentStart = i + 1;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return out.join("");
|
|
895
|
+
}
|
|
896
|
+
function cleanSelector(raw) {
|
|
897
|
+
const cut = Math.max(raw.lastIndexOf(";"), raw.lastIndexOf("}"));
|
|
898
|
+
return (cut === -1 ? raw : raw.slice(cut + 1)).trim();
|
|
899
|
+
}
|
|
900
|
+
function leafBlocks(source) {
|
|
901
|
+
return splitRuleBlocks(source).map((b) => b.body.includes("{") ? { ...b, body: blankNested(b.body) } : b).filter((b) => b.body.trim() !== "");
|
|
902
|
+
}
|
|
903
|
+
function lineOfOffset(block, offsetInBody) {
|
|
904
|
+
let n = 0;
|
|
905
|
+
const end = Math.min(offsetInBody, block.body.length);
|
|
906
|
+
for (let i = 0; i < end; i++) {
|
|
907
|
+
if (block.body[i] === "\n") n++;
|
|
908
|
+
}
|
|
909
|
+
return block.bodyStartLine + n;
|
|
910
|
+
}
|
|
911
|
+
function buildLineIndex(source) {
|
|
912
|
+
const starts = [0];
|
|
913
|
+
for (let i = 0; i < source.length; i++) {
|
|
914
|
+
if (source[i] === "\n") starts.push(i + 1);
|
|
915
|
+
}
|
|
916
|
+
return starts;
|
|
917
|
+
}
|
|
918
|
+
function lineForOffset(starts, offset) {
|
|
919
|
+
let lo = 0, hi = starts.length - 1;
|
|
920
|
+
while (lo < hi) {
|
|
921
|
+
const mid = lo + hi + 1 >> 1;
|
|
922
|
+
if (starts[mid] <= offset) lo = mid;
|
|
923
|
+
else hi = mid - 1;
|
|
924
|
+
}
|
|
925
|
+
return lo + 1;
|
|
926
|
+
}
|
|
927
|
+
function sourceLine(source, line) {
|
|
928
|
+
const lines = source.split("\n");
|
|
929
|
+
return (lines[line - 1] ?? "").trim();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/check/ext.ts
|
|
933
|
+
var CSS_EXTENSIONS = [".css", ".scss", ".less"];
|
|
934
|
+
function hasExtension(file, exts) {
|
|
935
|
+
const lower = file.toLowerCase();
|
|
936
|
+
return exts.some((e) => lower.endsWith(e));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/check/finding.ts
|
|
940
|
+
function mkFinding(ctx, detector, file, line, message, excerpt) {
|
|
941
|
+
return {
|
|
942
|
+
ruleId: ctx.ruleId,
|
|
943
|
+
detector,
|
|
944
|
+
bucket: ctx.bucket,
|
|
945
|
+
severity: ctx.severity,
|
|
946
|
+
file,
|
|
947
|
+
line,
|
|
948
|
+
message,
|
|
949
|
+
excerpt
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// src/check/detectors/gradient-text.ts
|
|
954
|
+
var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
|
|
955
|
+
var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
|
|
956
|
+
var gradientText = {
|
|
957
|
+
name: "gradient-text",
|
|
958
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
959
|
+
run(source, file, ctx) {
|
|
960
|
+
const findings = [];
|
|
961
|
+
for (const block of leafBlocks(source)) {
|
|
962
|
+
const clipMatch = CLIP_RE.exec(block.body);
|
|
963
|
+
if (!clipMatch) continue;
|
|
964
|
+
if (!GRADIENT_RE.test(block.body)) continue;
|
|
965
|
+
const line = lineOfOffset(block, clipMatch.index);
|
|
966
|
+
findings.push(
|
|
967
|
+
mkFinding(
|
|
968
|
+
ctx,
|
|
969
|
+
"gradient-text",
|
|
970
|
+
file,
|
|
971
|
+
line,
|
|
972
|
+
"Gradient text via background-clip: text \u2014 use solid --color-text-strong instead",
|
|
973
|
+
sourceLine(source, line)
|
|
974
|
+
)
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
return findings;
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// src/check/color.ts
|
|
982
|
+
var NAMED = {
|
|
983
|
+
white: { r: 255, g: 255, b: 255 },
|
|
984
|
+
black: { r: 0, g: 0, b: 0 }
|
|
985
|
+
};
|
|
986
|
+
function parseComponent(raw) {
|
|
987
|
+
const v = raw.trim();
|
|
988
|
+
if (v.endsWith("%")) return Math.round(parseFloat(v) / 100 * 255);
|
|
989
|
+
return Math.round(parseFloat(v));
|
|
990
|
+
}
|
|
991
|
+
function parseAlpha(raw) {
|
|
992
|
+
if (raw === void 0 || raw.trim() === "") return 1;
|
|
993
|
+
const v = raw.trim();
|
|
994
|
+
return v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v);
|
|
995
|
+
}
|
|
996
|
+
function splitFnArgs(inner) {
|
|
997
|
+
if (inner.includes(",")) {
|
|
998
|
+
const parts = inner.split(",").map((p) => p.trim());
|
|
999
|
+
const alphaStr2 = parts.length === 4 ? parts.pop() : void 0;
|
|
1000
|
+
return { parts, alphaStr: alphaStr2 };
|
|
1001
|
+
}
|
|
1002
|
+
const [main, alphaStr] = inner.split("/").map((p) => p.trim());
|
|
1003
|
+
return { parts: main.split(/\s+/).filter(Boolean), alphaStr };
|
|
1004
|
+
}
|
|
1005
|
+
function parseHex(value) {
|
|
1006
|
+
const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(value.trim());
|
|
1007
|
+
if (!m) return null;
|
|
1008
|
+
const hex = m[1];
|
|
1009
|
+
let r, g, b, a = 255;
|
|
1010
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
1011
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
1012
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
1013
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
1014
|
+
if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16);
|
|
1015
|
+
} else {
|
|
1016
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
1017
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
1018
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
1019
|
+
if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16);
|
|
1020
|
+
}
|
|
1021
|
+
return { r, g, b, a: a / 255 };
|
|
1022
|
+
}
|
|
1023
|
+
function parseRgbFn(value) {
|
|
1024
|
+
const m = /^rgba?\(([^)]*)\)$/i.exec(value.trim());
|
|
1025
|
+
if (!m) return null;
|
|
1026
|
+
const { parts, alphaStr } = splitFnArgs(m[1].trim());
|
|
1027
|
+
if (parts.length !== 3) return null;
|
|
1028
|
+
const [r, g, b] = parts.map(parseComponent);
|
|
1029
|
+
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
|
|
1030
|
+
return { r, g, b, a: parseAlpha(alphaStr) };
|
|
1031
|
+
}
|
|
1032
|
+
function parseHslFn(value) {
|
|
1033
|
+
const m = /^hsla?\(([^)]*)\)$/i.exec(value.trim());
|
|
1034
|
+
if (!m) return null;
|
|
1035
|
+
const { parts, alphaStr } = splitFnArgs(m[1].trim());
|
|
1036
|
+
if (parts.length !== 3) return null;
|
|
1037
|
+
const h = parseFloat(parts[0]);
|
|
1038
|
+
const s = parseFloat(parts[1]);
|
|
1039
|
+
const l = parseFloat(parts[2]);
|
|
1040
|
+
if ([h, s, l].some((n) => Number.isNaN(n))) return null;
|
|
1041
|
+
return { h: (h % 360 + 360) % 360, s, l, a: parseAlpha(alphaStr) };
|
|
1042
|
+
}
|
|
1043
|
+
function hslToRgb(h, s, l) {
|
|
1044
|
+
const sN = s / 100;
|
|
1045
|
+
const lN = l / 100;
|
|
1046
|
+
const c = (1 - Math.abs(2 * lN - 1)) * sN;
|
|
1047
|
+
const hp = (h % 360 + 360) % 360 / 60;
|
|
1048
|
+
const x = c * (1 - Math.abs(hp % 2 - 1));
|
|
1049
|
+
let r1 = 0, g1 = 0, b1 = 0;
|
|
1050
|
+
if (hp < 1) [r1, g1, b1] = [c, x, 0];
|
|
1051
|
+
else if (hp < 2) [r1, g1, b1] = [x, c, 0];
|
|
1052
|
+
else if (hp < 3) [r1, g1, b1] = [0, c, x];
|
|
1053
|
+
else if (hp < 4) [r1, g1, b1] = [0, x, c];
|
|
1054
|
+
else if (hp < 5) [r1, g1, b1] = [x, 0, c];
|
|
1055
|
+
else [r1, g1, b1] = [c, 0, x];
|
|
1056
|
+
const m = lN - c / 2;
|
|
1057
|
+
return { r: Math.round((r1 + m) * 255), g: Math.round((g1 + m) * 255), b: Math.round((b1 + m) * 255) };
|
|
1058
|
+
}
|
|
1059
|
+
function rgbToHsl(rgb) {
|
|
1060
|
+
const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
|
|
1061
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
1062
|
+
const l = (max + min) / 2;
|
|
1063
|
+
let h = 0, s = 0;
|
|
1064
|
+
if (max !== min) {
|
|
1065
|
+
const d = max - min;
|
|
1066
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
1067
|
+
switch (max) {
|
|
1068
|
+
case r:
|
|
1069
|
+
h = (g - b) / d + (g < b ? 6 : 0);
|
|
1070
|
+
break;
|
|
1071
|
+
case g:
|
|
1072
|
+
h = (b - r) / d + 2;
|
|
1073
|
+
break;
|
|
1074
|
+
default:
|
|
1075
|
+
h = (r - g) / d + 4;
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
h *= 60;
|
|
1079
|
+
}
|
|
1080
|
+
return { h, s: s * 100, l: l * 100 };
|
|
1081
|
+
}
|
|
1082
|
+
function substituteVars(value, tokens, depth = 0) {
|
|
1083
|
+
if (depth > 6) return value;
|
|
1084
|
+
let changed = false;
|
|
1085
|
+
const result = value.replace(/var\(\s*--([\w-]+)\s*(?:,\s*([^)]*))?\)/gi, (match, name, fallback) => {
|
|
1086
|
+
if (Object.prototype.hasOwnProperty.call(tokens, name)) {
|
|
1087
|
+
changed = true;
|
|
1088
|
+
return tokens[name];
|
|
1089
|
+
}
|
|
1090
|
+
if (fallback !== void 0) {
|
|
1091
|
+
changed = true;
|
|
1092
|
+
return fallback;
|
|
1093
|
+
}
|
|
1094
|
+
return match;
|
|
1095
|
+
});
|
|
1096
|
+
return changed ? substituteVars(result, tokens, depth + 1) : result;
|
|
1097
|
+
}
|
|
1098
|
+
var OKLCH_RE = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([-\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i;
|
|
1099
|
+
function gammaEncode(c) {
|
|
1100
|
+
return c <= 31308e-7 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
1101
|
+
}
|
|
1102
|
+
function parseOklchFn(value) {
|
|
1103
|
+
const m = OKLCH_RE.exec(value.trim());
|
|
1104
|
+
if (!m) return null;
|
|
1105
|
+
const num = (raw, pctBasis) => raw.endsWith("%") ? Number.parseFloat(raw) / 100 * pctBasis : Number.parseFloat(raw);
|
|
1106
|
+
const L = num(m[1], 1);
|
|
1107
|
+
const C = num(m[2], 0.4);
|
|
1108
|
+
const H = Number.parseFloat(m[3]);
|
|
1109
|
+
const a = m[4] === void 0 ? 1 : Math.min(1, Math.max(0, num(m[4], 1)));
|
|
1110
|
+
if ([L, C, H].some((n) => !Number.isFinite(n))) return null;
|
|
1111
|
+
const hRad = H * Math.PI / 180;
|
|
1112
|
+
const oa = C * Math.cos(hRad);
|
|
1113
|
+
const ob = C * Math.sin(hRad);
|
|
1114
|
+
const l_ = L + 0.3963377774 * oa + 0.2158037573 * ob;
|
|
1115
|
+
const m_ = L - 0.1055613458 * oa - 0.0638541728 * ob;
|
|
1116
|
+
const s_ = L - 0.0894841775 * oa - 1.291485548 * ob;
|
|
1117
|
+
const l = l_ ** 3;
|
|
1118
|
+
const mm = m_ ** 3;
|
|
1119
|
+
const ss = s_ ** 3;
|
|
1120
|
+
const lr = 4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * ss;
|
|
1121
|
+
const lg = -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * ss;
|
|
1122
|
+
const lb = -0.0041960863 * l - 0.7034186147 * mm + 1.707614701 * ss;
|
|
1123
|
+
const to255 = (lin) => Math.round(Math.min(1, Math.max(0, gammaEncode(lin))) * 255);
|
|
1124
|
+
return { r: to255(lr), g: to255(lg), b: to255(lb), a };
|
|
1125
|
+
}
|
|
1126
|
+
function extractColorComponents(value, tokens) {
|
|
1127
|
+
const substituted = substituteVars(value, tokens).trim();
|
|
1128
|
+
if (/var\(/i.test(substituted)) return null;
|
|
1129
|
+
const named = NAMED[substituted.toLowerCase()];
|
|
1130
|
+
if (named) return { rgb: named, hsl: rgbToHsl(named), alpha: 1 };
|
|
1131
|
+
const hex = parseHex(substituted);
|
|
1132
|
+
if (hex) {
|
|
1133
|
+
const rgb = { r: hex.r, g: hex.g, b: hex.b };
|
|
1134
|
+
return { rgb, hsl: rgbToHsl(rgb), alpha: hex.a };
|
|
1135
|
+
}
|
|
1136
|
+
const rgbFn = parseRgbFn(substituted);
|
|
1137
|
+
if (rgbFn) {
|
|
1138
|
+
const rgb = { r: rgbFn.r, g: rgbFn.g, b: rgbFn.b };
|
|
1139
|
+
return { rgb, hsl: rgbToHsl(rgb), alpha: rgbFn.a };
|
|
1140
|
+
}
|
|
1141
|
+
const hslFn = parseHslFn(substituted);
|
|
1142
|
+
if (hslFn) {
|
|
1143
|
+
const rgb = hslToRgb(hslFn.h, hslFn.s, hslFn.l);
|
|
1144
|
+
return { rgb, hsl: { h: hslFn.h, s: hslFn.s, l: hslFn.l }, alpha: hslFn.a };
|
|
1145
|
+
}
|
|
1146
|
+
const oklch = parseOklchFn(substituted);
|
|
1147
|
+
if (oklch) {
|
|
1148
|
+
const rgb = { r: oklch.r, g: oklch.g, b: oklch.b };
|
|
1149
|
+
return { rgb, hsl: rgbToHsl(rgb), alpha: oklch.a };
|
|
1150
|
+
}
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
var VAR_WITH_FALLBACK_RE = /var\(\s*--[\w-]+\s*,/i;
|
|
1154
|
+
function resolveOpaqueColor(value, tokens) {
|
|
1155
|
+
if (VAR_WITH_FALLBACK_RE.test(value)) return null;
|
|
1156
|
+
const c = extractColorComponents(value, tokens);
|
|
1157
|
+
if (!c || c.alpha < 0.999) return null;
|
|
1158
|
+
return c.rgb;
|
|
1159
|
+
}
|
|
1160
|
+
function relativeLuminance(rgb) {
|
|
1161
|
+
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
|
|
1162
|
+
const c = v / 255;
|
|
1163
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
1164
|
+
});
|
|
1165
|
+
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
|
1166
|
+
}
|
|
1167
|
+
function contrastRatio(a, b) {
|
|
1168
|
+
const la = relativeLuminance(a) + 0.05;
|
|
1169
|
+
const lb = relativeLuminance(b) + 0.05;
|
|
1170
|
+
return la > lb ? la / lb : lb / la;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/check/detectors/backdrop-blur.ts
|
|
1174
|
+
var BLUR_RE = /(?:-webkit-)?backdrop-filter\s*:[^;]*blur\s*\(/i;
|
|
1175
|
+
var BG_DECL_RE = /(?<![-\w])background(?:-color)?\s*:\s*([^;]+);?/gi;
|
|
1176
|
+
function hasTranslucentBackground(body, tokens) {
|
|
1177
|
+
BG_DECL_RE.lastIndex = 0;
|
|
1178
|
+
let m;
|
|
1179
|
+
while (m = BG_DECL_RE.exec(body)) {
|
|
1180
|
+
const c = extractColorComponents(m[1].trim(), tokens);
|
|
1181
|
+
if (c && c.alpha < 0.999) return true;
|
|
1182
|
+
}
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
var backdropBlur = {
|
|
1186
|
+
name: "backdrop-blur",
|
|
1187
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1188
|
+
run(source, file, ctx) {
|
|
1189
|
+
const findings = [];
|
|
1190
|
+
for (const block of leafBlocks(source)) {
|
|
1191
|
+
const blurMatch = BLUR_RE.exec(block.body);
|
|
1192
|
+
if (!blurMatch) continue;
|
|
1193
|
+
if (!hasTranslucentBackground(block.body, ctx.tokens)) continue;
|
|
1194
|
+
const line = lineOfOffset(block, blurMatch.index);
|
|
1195
|
+
findings.push(
|
|
1196
|
+
mkFinding(
|
|
1197
|
+
ctx,
|
|
1198
|
+
"backdrop-blur",
|
|
1199
|
+
file,
|
|
1200
|
+
line,
|
|
1201
|
+
"Glassmorphism \u2014 translucent fill + backdrop-filter: blur() fights legibility; use an opaque surface with a --color-stroke-weak edge",
|
|
1202
|
+
sourceLine(source, line)
|
|
1203
|
+
)
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
return findings;
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
// src/check/detectors/pure-black-white.ts
|
|
1211
|
+
var COLOR_DECL_RE = /(?<![-\w])color\s*:\s*([^;]+)(?:;|$)/i;
|
|
1212
|
+
var BG_DECL_RE2 = /(?<![-\w])background(?:-color)?\s*:\s*([^;]+)(?:;|$)/gi;
|
|
1213
|
+
function isPure(rgb, target) {
|
|
1214
|
+
return rgb.r === target && rgb.g === target && rgb.b === target;
|
|
1215
|
+
}
|
|
1216
|
+
var pureBlackWhite = {
|
|
1217
|
+
name: "pure-black-white",
|
|
1218
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1219
|
+
run(source, file, ctx) {
|
|
1220
|
+
const findings = [];
|
|
1221
|
+
for (const block of leafBlocks(source)) {
|
|
1222
|
+
const colorMatch = COLOR_DECL_RE.exec(block.body);
|
|
1223
|
+
if (!colorMatch) continue;
|
|
1224
|
+
const fg = extractColorComponents(colorMatch[1].trim(), ctx.tokens);
|
|
1225
|
+
if (!fg || fg.alpha < 0.999 || !isPure(fg.rgb, 0)) continue;
|
|
1226
|
+
BG_DECL_RE2.lastIndex = 0;
|
|
1227
|
+
let hasWhiteBackground = false;
|
|
1228
|
+
let m;
|
|
1229
|
+
while (m = BG_DECL_RE2.exec(block.body)) {
|
|
1230
|
+
const bg = extractColorComponents(m[1].trim(), ctx.tokens);
|
|
1231
|
+
if (bg && bg.alpha >= 0.999 && isPure(bg.rgb, 255)) {
|
|
1232
|
+
hasWhiteBackground = true;
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (!hasWhiteBackground) continue;
|
|
1237
|
+
const line = lineOfOffset(block, colorMatch.index);
|
|
1238
|
+
findings.push(
|
|
1239
|
+
mkFinding(
|
|
1240
|
+
ctx,
|
|
1241
|
+
"pure-black-white",
|
|
1242
|
+
file,
|
|
1243
|
+
line,
|
|
1244
|
+
"Pure #000 on pure #fff \u2014 use near-black text on an off-white background instead",
|
|
1245
|
+
sourceLine(source, line)
|
|
1246
|
+
)
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
return findings;
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
// src/check/detectors/contrast-floor.ts
|
|
1254
|
+
var FLOOR = 4.5;
|
|
1255
|
+
var LARGE_FLOOR = 3;
|
|
1256
|
+
var REM_TO_PX = 16;
|
|
1257
|
+
var FONT_SIZE_DECL_RE = /(?<![-\w])font-size\s*:\s*([^;]+?)(?:;|$)/i;
|
|
1258
|
+
var FONT_SIZE_PX_RE = /^(-?\d*\.?\d+)px$/i;
|
|
1259
|
+
var FONT_SIZE_REM_RE = /^(-?\d*\.?\d+)rem$/i;
|
|
1260
|
+
var BOLD_RE = /(?<![-\w])font-weight\s*:\s*(bold|[6-9]\d\d)/i;
|
|
1261
|
+
function resolveFloor(body) {
|
|
1262
|
+
const m = FONT_SIZE_DECL_RE.exec(body);
|
|
1263
|
+
if (!m) return FLOOR;
|
|
1264
|
+
const raw = m[1].trim();
|
|
1265
|
+
const pxMatch = FONT_SIZE_PX_RE.exec(raw);
|
|
1266
|
+
const remMatch = FONT_SIZE_REM_RE.exec(raw);
|
|
1267
|
+
let px;
|
|
1268
|
+
if (pxMatch) px = parseFloat(pxMatch[1]);
|
|
1269
|
+
else if (remMatch) px = parseFloat(remMatch[1]) * REM_TO_PX;
|
|
1270
|
+
else return LARGE_FLOOR;
|
|
1271
|
+
const large = BOLD_RE.test(body) ? px >= 18.66 : px >= 24;
|
|
1272
|
+
return large ? LARGE_FLOOR : FLOOR;
|
|
1273
|
+
}
|
|
1274
|
+
var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
|
|
1275
|
+
var contrastFloor = {
|
|
1276
|
+
name: "contrast-floor",
|
|
1277
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1278
|
+
run(source, file, ctx) {
|
|
1279
|
+
const findings = [];
|
|
1280
|
+
for (const block of leafBlocks(source)) {
|
|
1281
|
+
let fgValue = null;
|
|
1282
|
+
let fgOffset = -1;
|
|
1283
|
+
let bgValue = null;
|
|
1284
|
+
DECL_RE.lastIndex = 0;
|
|
1285
|
+
let m;
|
|
1286
|
+
while (m = DECL_RE.exec(block.body)) {
|
|
1287
|
+
const prop = m[1].toLowerCase();
|
|
1288
|
+
if (prop === "color") {
|
|
1289
|
+
fgValue = m[2].trim();
|
|
1290
|
+
fgOffset = m.index;
|
|
1291
|
+
} else {
|
|
1292
|
+
bgValue = m[2].trim();
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (!fgValue || !bgValue) continue;
|
|
1296
|
+
const fg = resolveOpaqueColor(fgValue, ctx.tokens);
|
|
1297
|
+
const bg = resolveOpaqueColor(bgValue, ctx.tokens);
|
|
1298
|
+
if (!fg || !bg) continue;
|
|
1299
|
+
const floor = resolveFloor(block.body);
|
|
1300
|
+
const ratio = contrastRatio(fg, bg);
|
|
1301
|
+
if (ratio >= floor) continue;
|
|
1302
|
+
const line = lineOfOffset(block, fgOffset);
|
|
1303
|
+
findings.push(
|
|
1304
|
+
mkFinding(
|
|
1305
|
+
ctx,
|
|
1306
|
+
"contrast-floor",
|
|
1307
|
+
file,
|
|
1308
|
+
line,
|
|
1309
|
+
`Foreground/background contrast is ${ratio.toFixed(2)}:1, below the ${floor}:1 floor`,
|
|
1310
|
+
sourceLine(source, line)
|
|
1311
|
+
)
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
return findings;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
// src/check/detectors/focus-removed.ts
|
|
1319
|
+
var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?\s*(?:;|\}|$)/gi;
|
|
1320
|
+
var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
|
|
1321
|
+
var focusRemoved = {
|
|
1322
|
+
name: "focus-removed",
|
|
1323
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1324
|
+
run(source, file, ctx) {
|
|
1325
|
+
if (FOCUS_VISIBLE_RE.test(source)) return [];
|
|
1326
|
+
const findings = [];
|
|
1327
|
+
const starts = buildLineIndex(source);
|
|
1328
|
+
OUTLINE_NONE_RE.lastIndex = 0;
|
|
1329
|
+
let m;
|
|
1330
|
+
while (m = OUTLINE_NONE_RE.exec(source)) {
|
|
1331
|
+
const line = lineForOffset(starts, m.index);
|
|
1332
|
+
findings.push(
|
|
1333
|
+
mkFinding(
|
|
1334
|
+
ctx,
|
|
1335
|
+
"focus-removed",
|
|
1336
|
+
file,
|
|
1337
|
+
line,
|
|
1338
|
+
"outline removed with no :focus-visible replacement anywhere in this file",
|
|
1339
|
+
sourceLine(source, line)
|
|
1340
|
+
)
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
return findings;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// src/check/detectors/hardcoded-value.ts
|
|
1348
|
+
var COLOR_PROPS = /* @__PURE__ */ new Set([
|
|
1349
|
+
"color",
|
|
1350
|
+
"background-color",
|
|
1351
|
+
"background",
|
|
1352
|
+
"border-color",
|
|
1353
|
+
"border-top-color",
|
|
1354
|
+
"border-right-color",
|
|
1355
|
+
"border-bottom-color",
|
|
1356
|
+
"border-left-color",
|
|
1357
|
+
"fill",
|
|
1358
|
+
"stroke",
|
|
1359
|
+
"outline-color"
|
|
1360
|
+
]);
|
|
1361
|
+
var SPACING_PROPS = /* @__PURE__ */ new Set([
|
|
1362
|
+
"font-size",
|
|
1363
|
+
"margin",
|
|
1364
|
+
"margin-top",
|
|
1365
|
+
"margin-right",
|
|
1366
|
+
"margin-bottom",
|
|
1367
|
+
"margin-left",
|
|
1368
|
+
"padding",
|
|
1369
|
+
"padding-top",
|
|
1370
|
+
"padding-right",
|
|
1371
|
+
"padding-bottom",
|
|
1372
|
+
"padding-left",
|
|
1373
|
+
"gap",
|
|
1374
|
+
"row-gap",
|
|
1375
|
+
"column-gap"
|
|
1376
|
+
]);
|
|
1377
|
+
var DECL_RE2 = /(?<![-\w])([a-zA-Z-]+)\s*:\s*([^;]+)(?:;|$)/g;
|
|
1378
|
+
var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/;
|
|
1379
|
+
var PX_RE = /(-?\d*\.?\d+)px/g;
|
|
1380
|
+
var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
|
|
1381
|
+
var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
|
|
1382
|
+
var hardcodedValue = {
|
|
1383
|
+
name: "hardcoded-value",
|
|
1384
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1385
|
+
run(source, file, ctx) {
|
|
1386
|
+
if (!participatesInTokenLayer(source, ctx.tokens)) return [];
|
|
1387
|
+
const findings = [];
|
|
1388
|
+
for (const block of leafBlocks(source)) {
|
|
1389
|
+
if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
|
|
1390
|
+
DECL_RE2.lastIndex = 0;
|
|
1391
|
+
let m;
|
|
1392
|
+
while (m = DECL_RE2.exec(block.body)) {
|
|
1393
|
+
const prop = m[1].toLowerCase();
|
|
1394
|
+
const value = m[2].trim();
|
|
1395
|
+
if (value.includes("var(")) continue;
|
|
1396
|
+
if (COLOR_PROPS.has(prop) && COLOR_LITERAL_RE.test(value)) {
|
|
1397
|
+
const line = lineOfOffset(block, m.index);
|
|
1398
|
+
findings.push(
|
|
1399
|
+
mkFinding(
|
|
1400
|
+
ctx,
|
|
1401
|
+
"hardcoded-value",
|
|
1402
|
+
file,
|
|
1403
|
+
line,
|
|
1404
|
+
`Hard-coded colour \`${value}\` past the token layer`,
|
|
1405
|
+
sourceLine(source, line)
|
|
1406
|
+
)
|
|
1407
|
+
);
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
if (SPACING_PROPS.has(prop)) {
|
|
1411
|
+
PX_RE.lastIndex = 0;
|
|
1412
|
+
let px;
|
|
1413
|
+
let flagged = false;
|
|
1414
|
+
while (px = PX_RE.exec(value)) {
|
|
1415
|
+
if (!EXCLUDED_PX.has(Math.abs(parseFloat(px[1])))) {
|
|
1416
|
+
flagged = true;
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (flagged) {
|
|
1421
|
+
const line = lineOfOffset(block, m.index);
|
|
1422
|
+
findings.push(
|
|
1423
|
+
mkFinding(
|
|
1424
|
+
ctx,
|
|
1425
|
+
"hardcoded-value",
|
|
1426
|
+
file,
|
|
1427
|
+
line,
|
|
1428
|
+
`Hard-coded \`${value}\` past the token layer`,
|
|
1429
|
+
sourceLine(source, line)
|
|
1430
|
+
)
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return findings;
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
// src/check/detectors/violet-band-hue.ts
|
|
1441
|
+
var COLOR_TOKEN_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/g;
|
|
1442
|
+
var HUE_MIN = 235;
|
|
1443
|
+
var HUE_MAX = 290;
|
|
1444
|
+
var MIN_SATURATION = 15;
|
|
1445
|
+
var MIN_LIGHTNESS = 8;
|
|
1446
|
+
var MAX_LIGHTNESS = 92;
|
|
1447
|
+
var violetBandHue = {
|
|
1448
|
+
name: "violet-band-hue",
|
|
1449
|
+
appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
|
|
1450
|
+
run(source, file, ctx) {
|
|
1451
|
+
const findings = [];
|
|
1452
|
+
const starts = buildLineIndex(source);
|
|
1453
|
+
COLOR_TOKEN_RE.lastIndex = 0;
|
|
1454
|
+
let m;
|
|
1455
|
+
while (m = COLOR_TOKEN_RE.exec(source)) {
|
|
1456
|
+
const c = extractColorComponents(m[0], ctx.tokens);
|
|
1457
|
+
if (!c) continue;
|
|
1458
|
+
const { h, s, l } = c.hsl;
|
|
1459
|
+
if (s < MIN_SATURATION || l < MIN_LIGHTNESS || l > MAX_LIGHTNESS) continue;
|
|
1460
|
+
if (h < HUE_MIN || h > HUE_MAX) continue;
|
|
1461
|
+
const line = lineForOffset(starts, m.index);
|
|
1462
|
+
findings.push(
|
|
1463
|
+
mkFinding(
|
|
1464
|
+
ctx,
|
|
1465
|
+
"violet-band-hue",
|
|
1466
|
+
file,
|
|
1467
|
+
line,
|
|
1468
|
+
`Violet/indigo hue (~${Math.round(h)}\xB0) \u2014 is this a deliberate brand choice, or the unspecified default?`,
|
|
1469
|
+
sourceLine(source, line)
|
|
1470
|
+
)
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
return findings;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
// src/check/registry.ts
|
|
1478
|
+
var DETECTORS = [
|
|
1479
|
+
gradientText,
|
|
1480
|
+
backdropBlur,
|
|
1481
|
+
pureBlackWhite,
|
|
1482
|
+
contrastFloor,
|
|
1483
|
+
focusRemoved,
|
|
1484
|
+
hardcodedValue,
|
|
1485
|
+
violetBandHue
|
|
1486
|
+
];
|
|
1487
|
+
function getDetector(name) {
|
|
1488
|
+
return DETECTORS.find((d) => d.name === name);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/check/run.ts
|
|
1492
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter) {
|
|
1493
|
+
const findings = [];
|
|
1494
|
+
const active = index.filter(
|
|
1495
|
+
(e) => !!e.detector && !!getDetector(e.detector)
|
|
1496
|
+
);
|
|
1497
|
+
const relevant = bucketFilter ? active.filter((e) => bucketFilter(e.bucket)) : active;
|
|
1498
|
+
if (relevant.length === 0) return findings;
|
|
1499
|
+
for (const file of files) {
|
|
1500
|
+
const applicable = relevant.map((entry) => ({ entry, detector: getDetector(entry.detector) })).filter(({ detector }) => detector.appliesTo(file));
|
|
1501
|
+
if (applicable.length === 0) continue;
|
|
1502
|
+
let raw;
|
|
1503
|
+
try {
|
|
1504
|
+
raw = readFileSync5(join7(projectRoot, file), "utf8");
|
|
1505
|
+
} catch {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
const source = maskComments(raw);
|
|
1509
|
+
for (const { entry, detector } of applicable) {
|
|
1510
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens };
|
|
1511
|
+
findings.push(...detector.run(source, file, ctx));
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return findings;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/check/tokens.ts
|
|
1518
|
+
import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "fs";
|
|
1519
|
+
import { join as join8 } from "path";
|
|
1520
|
+
function loadTokenMap(installRoot) {
|
|
1521
|
+
const tokensDir = join8(installRoot, ".jig", "tokens");
|
|
1522
|
+
const tokens = {};
|
|
1523
|
+
if (!existsSync6(tokensDir)) return tokens;
|
|
1524
|
+
const files = readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort();
|
|
1525
|
+
for (const file of files) {
|
|
1526
|
+
const source = readFileSync6(join8(tokensDir, file), "utf8");
|
|
1527
|
+
for (const block of splitRuleBlocks(source)) {
|
|
1528
|
+
if (block.body.includes("{")) continue;
|
|
1529
|
+
if (block.selector.trim() !== ":root") continue;
|
|
1530
|
+
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
1531
|
+
let m;
|
|
1532
|
+
while (m = re.exec(block.body)) {
|
|
1533
|
+
tokens[m[1]] = m[2].trim();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return tokens;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// src/commands/check.ts
|
|
1541
|
+
function check(opts) {
|
|
1542
|
+
const target = resolveInstalled(opts.projectRoot, opts.homeDir);
|
|
1543
|
+
if (!target) {
|
|
1544
|
+
throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
|
|
1545
|
+
}
|
|
1546
|
+
const indexPath = join9(target.installRoot, ".jig", "rules.index.json");
|
|
1547
|
+
const index = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
|
|
1548
|
+
const tokens = loadTokenMap(target.installRoot);
|
|
1549
|
+
const { files } = selectFiles(opts.projectRoot, opts.all);
|
|
1550
|
+
const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
|
|
1551
|
+
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter);
|
|
1552
|
+
const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
|
|
1553
|
+
const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
|
|
1554
|
+
const noTokenLayer = (
|
|
1555
|
+
// `[].every()` is true, so without this guard a repo with no CSS at all is
|
|
1556
|
+
// told to add a token @import it has nowhere to put.
|
|
1557
|
+
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
1558
|
+
try {
|
|
1559
|
+
return !participatesInTokenLayer(readFileSync7(join9(opts.projectRoot, f), "utf8"), tokens);
|
|
1560
|
+
} catch {
|
|
1561
|
+
return true;
|
|
1562
|
+
}
|
|
1563
|
+
})
|
|
1564
|
+
);
|
|
1565
|
+
const report = formatReport(findings, {
|
|
1566
|
+
totalRules: index.length,
|
|
1567
|
+
version: opts.version,
|
|
1568
|
+
noTokenLayer
|
|
1569
|
+
});
|
|
1570
|
+
return { findings, report, hasError };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/commands/init.ts
|
|
1574
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
|
|
1575
|
+
import { dirname as dirname6, join as join13 } from "path";
|
|
1576
|
+
|
|
1577
|
+
// src/init/detect.ts
|
|
1578
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1579
|
+
import { join as join10 } from "path";
|
|
1580
|
+
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
1581
|
+
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
1582
|
+
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
1583
|
+
var FRAMEWORK_DEPS = [
|
|
1584
|
+
["next", "Next.js"],
|
|
1585
|
+
["nuxt", "Nuxt"],
|
|
1586
|
+
["@remix-run/react", "Remix"],
|
|
1587
|
+
["astro", "Astro"],
|
|
1588
|
+
["svelte", "Svelte"],
|
|
1589
|
+
["vue", "Vue"],
|
|
1590
|
+
["react", "React"],
|
|
1591
|
+
["solid-js", "Solid"]
|
|
1592
|
+
];
|
|
1593
|
+
function detectFramework(projectRoot) {
|
|
1594
|
+
let pkg;
|
|
1595
|
+
try {
|
|
1596
|
+
pkg = JSON.parse(readFileSync8(join10(projectRoot, "package.json"), "utf8"));
|
|
1597
|
+
} catch {
|
|
1598
|
+
return void 0;
|
|
1599
|
+
}
|
|
1600
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1601
|
+
for (const [dep, label] of FRAMEWORK_DEPS) {
|
|
1602
|
+
if (deps[dep]) return label;
|
|
1603
|
+
}
|
|
1604
|
+
return void 0;
|
|
1605
|
+
}
|
|
1606
|
+
function detect(projectRoot) {
|
|
1607
|
+
const allFiles = wholeRepoFiles(projectRoot);
|
|
1608
|
+
const cssFiles = allFiles.filter((f) => hasExtension(f, CSS_EXTENSIONS));
|
|
1609
|
+
const tailwindConfigFile = allFiles.find((f) => TAILWIND_CONFIG_RE.test(f.split("/").pop() ?? ""));
|
|
1610
|
+
let tailwindV4EntryFile;
|
|
1611
|
+
for (const f of cssFiles) {
|
|
1612
|
+
let source;
|
|
1613
|
+
try {
|
|
1614
|
+
source = readFileSync8(join10(projectRoot, f), "utf8");
|
|
1615
|
+
} catch {
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
if (TAILWIND_V4_IMPORT_RE.test(source) || TAILWIND_V4_THEME_RE.test(source)) {
|
|
1619
|
+
tailwindV4EntryFile = f;
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
let cssSystem;
|
|
1624
|
+
if (tailwindV4EntryFile) cssSystem = "tailwind-v4";
|
|
1625
|
+
else if (tailwindConfigFile) cssSystem = "tailwind-v3";
|
|
1626
|
+
else if (cssFiles.length > 0) cssSystem = "plain-css";
|
|
1627
|
+
else cssSystem = "unknown";
|
|
1628
|
+
return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// src/init/derive.ts
|
|
1632
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1633
|
+
import { join as join11 } from "path";
|
|
1634
|
+
var DEFAULT_PROPOSAL = {
|
|
1635
|
+
h: 264,
|
|
1636
|
+
s: 0,
|
|
1637
|
+
l: 15,
|
|
1638
|
+
source: "default",
|
|
1639
|
+
detail: "no colour found in the project; using the unbranded default (near-black, matches tokens/brand.default.css)"
|
|
1640
|
+
};
|
|
1641
|
+
var NAME_PRIORITY_RE = /brand|primary|accent/i;
|
|
1642
|
+
var EXACT_NAMES = /* @__PURE__ */ new Set(["brand", "primary", "accent"]);
|
|
1643
|
+
var MIN_CHROMATIC_SATURATION = 15;
|
|
1644
|
+
function round(n) {
|
|
1645
|
+
return Math.round(n * 10) / 10;
|
|
1646
|
+
}
|
|
1647
|
+
function toProposal(c, source, why) {
|
|
1648
|
+
return { h: round(c.hsl.h), s: round(c.hsl.s), l: round(c.hsl.l), source, detail: why };
|
|
1649
|
+
}
|
|
1650
|
+
function mostFrequent(candidates) {
|
|
1651
|
+
if (candidates.length === 0) return void 0;
|
|
1652
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1653
|
+
for (const c of candidates) {
|
|
1654
|
+
const key = `${Math.round(c.hsl.h)}|${Math.round(c.hsl.s)}|${Math.round(c.hsl.l)}`;
|
|
1655
|
+
const existing = counts.get(key);
|
|
1656
|
+
if (existing) existing.count += 1;
|
|
1657
|
+
else counts.set(key, { count: 1, candidate: c });
|
|
1658
|
+
}
|
|
1659
|
+
return [...counts.values()].sort((a, b) => b.count - a.count || b.candidate.hsl.s - a.candidate.hsl.s)[0].candidate;
|
|
1660
|
+
}
|
|
1661
|
+
function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
1662
|
+
const candidates = [];
|
|
1663
|
+
for (const file of cssFiles) {
|
|
1664
|
+
let source;
|
|
1665
|
+
try {
|
|
1666
|
+
source = readFileSync9(join11(projectRoot, file), "utf8");
|
|
1667
|
+
} catch {
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
for (const block of splitRuleBlocks(source)) {
|
|
1671
|
+
if (block.body.includes("{")) continue;
|
|
1672
|
+
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
1673
|
+
let m;
|
|
1674
|
+
while (m = re.exec(block.body)) {
|
|
1675
|
+
const [, name, rawValue] = m;
|
|
1676
|
+
const value = rawValue.trim();
|
|
1677
|
+
const comps = extractColorComponents(value, {});
|
|
1678
|
+
if (!comps || comps.alpha < 0.999) continue;
|
|
1679
|
+
candidates.push({ value, hsl: comps.hsl, name, file });
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (candidates.length === 0) return null;
|
|
1684
|
+
const namedChromatic = candidates.filter(
|
|
1685
|
+
(c) => c.name && NAME_PRIORITY_RE.test(c.name) && c.hsl.s >= MIN_CHROMATIC_SATURATION
|
|
1686
|
+
);
|
|
1687
|
+
if (namedChromatic.length > 0) {
|
|
1688
|
+
const exact = namedChromatic.find((c) => EXACT_NAMES.has(c.name.toLowerCase()));
|
|
1689
|
+
const named = exact ?? namedChromatic[0];
|
|
1690
|
+
return toProposal(named, "css-custom-property", `--${named.name}: ${named.value} in ${named.file}`);
|
|
1691
|
+
}
|
|
1692
|
+
const chromatic = candidates.filter((c) => c.hsl.s >= MIN_CHROMATIC_SATURATION);
|
|
1693
|
+
const pool = chromatic.length > 0 ? chromatic : candidates;
|
|
1694
|
+
const chosen = mostFrequent(pool) ?? pool[0];
|
|
1695
|
+
return toProposal(
|
|
1696
|
+
chosen,
|
|
1697
|
+
"css-custom-property",
|
|
1698
|
+
`--${chosen.name}: ${chosen.value} in ${chosen.file} (most repeated custom-property colour)`
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
function balancedBraceBody(source, openIdx) {
|
|
1702
|
+
let depth = 0;
|
|
1703
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
1704
|
+
if (source[i] === "{") depth++;
|
|
1705
|
+
else if (source[i] === "}") {
|
|
1706
|
+
depth--;
|
|
1707
|
+
if (depth === 0) return source.slice(openIdx + 1, i);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
1713
|
+
let source;
|
|
1714
|
+
try {
|
|
1715
|
+
source = readFileSync9(join11(projectRoot, tailwindConfigFile), "utf8");
|
|
1716
|
+
} catch {
|
|
1717
|
+
return null;
|
|
1718
|
+
}
|
|
1719
|
+
const colorsBlock = (() => {
|
|
1720
|
+
const extendIdx = source.search(/\bextend\s*:\s*\{/);
|
|
1721
|
+
if (extendIdx !== -1) {
|
|
1722
|
+
const extendBraceIdx = source.indexOf("{", extendIdx);
|
|
1723
|
+
const extendBody = balancedBraceBody(source, extendBraceIdx);
|
|
1724
|
+
if (extendBody) {
|
|
1725
|
+
const colorsIdx2 = extendBody.search(/\bcolors\s*:\s*\{/);
|
|
1726
|
+
if (colorsIdx2 !== -1) {
|
|
1727
|
+
const braceIdx = extendBody.indexOf("{", colorsIdx2);
|
|
1728
|
+
const body = balancedBraceBody(extendBody, braceIdx);
|
|
1729
|
+
if (body) return body;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
const colorsIdx = source.search(/\bcolors\s*:\s*\{/);
|
|
1734
|
+
if (colorsIdx !== -1) {
|
|
1735
|
+
const braceIdx = source.indexOf("{", colorsIdx);
|
|
1736
|
+
return balancedBraceBody(source, braceIdx);
|
|
1737
|
+
}
|
|
1738
|
+
return null;
|
|
1739
|
+
})();
|
|
1740
|
+
if (!colorsBlock) return null;
|
|
1741
|
+
const candidates = [];
|
|
1742
|
+
const re = /['"]?([\w-]+)['"]?\s*:\s*['"]([^'"]+)['"]/g;
|
|
1743
|
+
let m;
|
|
1744
|
+
while (m = re.exec(colorsBlock)) {
|
|
1745
|
+
const [, name, value] = m;
|
|
1746
|
+
const comps = extractColorComponents(value, {});
|
|
1747
|
+
if (!comps || comps.alpha < 0.999) continue;
|
|
1748
|
+
candidates.push({ value, hsl: comps.hsl, name, file: tailwindConfigFile });
|
|
1749
|
+
}
|
|
1750
|
+
if (candidates.length === 0) return null;
|
|
1751
|
+
const namedChromatic = candidates.filter(
|
|
1752
|
+
(c) => NAME_PRIORITY_RE.test(c.name) && c.hsl.s >= MIN_CHROMATIC_SATURATION
|
|
1753
|
+
);
|
|
1754
|
+
const named = namedChromatic.length > 0 ? namedChromatic.find((c) => EXACT_NAMES.has(c.name.toLowerCase())) ?? namedChromatic[0] : void 0;
|
|
1755
|
+
const chromatic = candidates.filter((c) => c.hsl.s >= MIN_CHROMATIC_SATURATION);
|
|
1756
|
+
const pool = chromatic.length > 0 ? chromatic : candidates;
|
|
1757
|
+
const chosen = named ?? mostFrequent(pool) ?? pool[0];
|
|
1758
|
+
return toProposal(
|
|
1759
|
+
chosen,
|
|
1760
|
+
"tailwind-config",
|
|
1761
|
+
`theme.extend.colors.${chosen.name}: '${chosen.value}' in ${tailwindConfigFile}`
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
var HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
|
1765
|
+
var RGB_HSL_RE = /(?:rgb|hsl)a?\([^)]*\)/g;
|
|
1766
|
+
function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
1767
|
+
const candidates = [];
|
|
1768
|
+
for (const file of cssFiles) {
|
|
1769
|
+
let source;
|
|
1770
|
+
try {
|
|
1771
|
+
source = readFileSync9(join11(projectRoot, file), "utf8");
|
|
1772
|
+
} catch {
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
for (const re of [HEX_RE, RGB_HSL_RE]) {
|
|
1776
|
+
re.lastIndex = 0;
|
|
1777
|
+
let m;
|
|
1778
|
+
while (m = re.exec(source)) {
|
|
1779
|
+
const comps = extractColorComponents(m[0], {});
|
|
1780
|
+
if (!comps || comps.alpha < 0.999) continue;
|
|
1781
|
+
if (comps.hsl.s < MIN_CHROMATIC_SATURATION) continue;
|
|
1782
|
+
candidates.push({ value: m[0], hsl: comps.hsl, file });
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
if (candidates.length === 0) return null;
|
|
1787
|
+
const chosen = mostFrequent(candidates) ?? candidates[0];
|
|
1788
|
+
return toProposal(
|
|
1789
|
+
chosen,
|
|
1790
|
+
"literal-frequency",
|
|
1791
|
+
`${chosen.value} \u2014 the most frequent literal chromatic colour across ${cssFiles.length} stylesheet(s), in ${chosen.file}`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
function deriveBrandColor(projectRoot, cssFiles, tailwindConfigFile) {
|
|
1795
|
+
return fromCssCustomProperties(projectRoot, cssFiles) ?? (tailwindConfigFile ? fromTailwindConfig(projectRoot, tailwindConfigFile) : null) ?? fromLiteralFrequency(projectRoot, cssFiles) ?? DEFAULT_PROPOSAL;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/init/validate.ts
|
|
1799
|
+
var BG_RAISED = { r: 255, g: 255, b: 255 };
|
|
1800
|
+
var BG_BASE_APPROX = { r: 250, g: 250, b: 248 };
|
|
1801
|
+
function compositeOver(fg, alpha, bg) {
|
|
1802
|
+
return {
|
|
1803
|
+
r: Math.round(fg.r * alpha + bg.r * (1 - alpha)),
|
|
1804
|
+
g: Math.round(fg.g * alpha + bg.g * (1 - alpha)),
|
|
1805
|
+
b: Math.round(fg.b * alpha + bg.b * (1 - alpha))
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
var FILL_COMPOSITED = compositeOver({ r: 0, g: 0, b: 0 }, 0.04, BG_BASE_APPROX);
|
|
1809
|
+
var CONTRAST_FLOOR = 4.5;
|
|
1810
|
+
function toRgb(h, s, l) {
|
|
1811
|
+
const comps = extractColorComponents(`hsl(${h} ${s}% ${l}%)`, {});
|
|
1812
|
+
if (!comps) throw new Error(`validate: could not resolve hsl(${h} ${s}% ${l}%) \u2014 this should never happen`);
|
|
1813
|
+
return comps.rgb;
|
|
1814
|
+
}
|
|
1815
|
+
function worstRatio(h, s, l) {
|
|
1816
|
+
const rgb = toRgb(h, s, l);
|
|
1817
|
+
const vsRaised = contrastRatio(rgb, BG_RAISED);
|
|
1818
|
+
const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
|
|
1819
|
+
return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
|
|
1820
|
+
}
|
|
1821
|
+
function nearestPassingLightness(h, s, l) {
|
|
1822
|
+
if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
|
|
1823
|
+
for (let delta = 1; delta <= 100; delta++) {
|
|
1824
|
+
for (const candidate of [l - delta, l + delta]) {
|
|
1825
|
+
if (candidate < 0 || candidate > 100) continue;
|
|
1826
|
+
if (worstRatio(h, s, candidate).worst >= CONTRAST_FLOOR) return candidate;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return null;
|
|
1830
|
+
}
|
|
1831
|
+
var VIOLET_HUE_MIN = 235;
|
|
1832
|
+
var VIOLET_HUE_MAX = 290;
|
|
1833
|
+
var HUE_MIN_SATURATION = 15;
|
|
1834
|
+
var HUE_MIN_LIGHTNESS = 8;
|
|
1835
|
+
var HUE_MAX_LIGHTNESS = 92;
|
|
1836
|
+
function isHueVisible(s, l) {
|
|
1837
|
+
return s >= HUE_MIN_SATURATION && l >= HUE_MIN_LIGHTNESS && l <= HUE_MAX_LIGHTNESS;
|
|
1838
|
+
}
|
|
1839
|
+
function isInVioletBand(h, s, l) {
|
|
1840
|
+
return isHueVisible(s, l) && h >= VIOLET_HUE_MIN && h <= VIOLET_HUE_MAX;
|
|
1841
|
+
}
|
|
1842
|
+
function systemCollision(h, s, l) {
|
|
1843
|
+
if (!isHueVisible(s, l)) return null;
|
|
1844
|
+
if (h <= 20 || h >= 340) return "red";
|
|
1845
|
+
if (h >= 25 && h <= 65) return "amber";
|
|
1846
|
+
if (h >= 100 && h <= 170) return "green";
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
function validateBrandColor(h, s, l) {
|
|
1850
|
+
const { vsRaised, vsFill, worst } = worstRatio(h, s, l);
|
|
1851
|
+
const passesContrast = worst >= CONTRAST_FLOOR;
|
|
1852
|
+
return {
|
|
1853
|
+
ratioVsRaised: vsRaised,
|
|
1854
|
+
ratioVsFill: vsFill,
|
|
1855
|
+
worstRatio: worst,
|
|
1856
|
+
passesContrast,
|
|
1857
|
+
nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
|
|
1858
|
+
violetBand: isInVioletBand(h, s, l),
|
|
1859
|
+
systemCollision: systemCollision(h, s, l)
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// src/init/brand-file.ts
|
|
1864
|
+
var TOP_COMMENT_FIRST_LINE = "brand.default.css \u2014 unbranded default.";
|
|
1865
|
+
var TOP_COMMENT_LAST_LINE = "Copy to brand.<client>.css and override. Never edit this file per project.";
|
|
1866
|
+
function round2(n) {
|
|
1867
|
+
return Math.round(n);
|
|
1868
|
+
}
|
|
1869
|
+
function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
|
|
1870
|
+
const fileName = `brand.${projectSlug}.css`;
|
|
1871
|
+
const header = vendorHeader(fileName, version2, "css");
|
|
1872
|
+
if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
|
|
1873
|
+
throw new Error(
|
|
1874
|
+
`brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
let body = defaultCssRaw.replace(
|
|
1878
|
+
TOP_COMMENT_FIRST_LINE,
|
|
1879
|
+
`${fileName} \u2014 generated by \`jig init\`.`
|
|
1880
|
+
);
|
|
1881
|
+
body = body.replace(
|
|
1882
|
+
TOP_COMMENT_LAST_LINE,
|
|
1883
|
+
`Brand colour derived from: ${proposal.detail} (${proposal.source}). Edit --brand-h/-s/-l below directly \u2014 \`jig update\` will not touch this file.`
|
|
1884
|
+
);
|
|
1885
|
+
const substitute = (source, pattern, replacement, label) => {
|
|
1886
|
+
if (!pattern.test(source)) {
|
|
1887
|
+
throw new Error(`brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${label} not found.`);
|
|
1888
|
+
}
|
|
1889
|
+
return source.replace(pattern, replacement);
|
|
1890
|
+
};
|
|
1891
|
+
body = substitute(body, /--brand-h:\s*[\d.]+;/, `--brand-h: ${round2(proposal.h)};`, "--brand-h");
|
|
1892
|
+
body = substitute(body, /--brand-s:\s*[\d.]+%;/, `--brand-s: ${round2(proposal.s)}%;`, "--brand-s");
|
|
1893
|
+
body = substitute(body, /--brand-l:\s*[\d.]+%;/, `--brand-l: ${round2(proposal.l)}%;`, "--brand-l");
|
|
1894
|
+
return header + body;
|
|
1895
|
+
}
|
|
1896
|
+
function brandFileName(projectSlug) {
|
|
1897
|
+
return `brand.${projectSlug}.css`;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// src/init/import-path.ts
|
|
1901
|
+
import { relative as relative2, sep as sep2 } from "path";
|
|
1902
|
+
function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
1903
|
+
let rel = relative2(fromAbsDir, toAbsFile).split(sep2).join("/");
|
|
1904
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
1905
|
+
return rel;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// src/init/project-name.ts
|
|
1909
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1910
|
+
import { basename as basename2, join as join12 } from "path";
|
|
1911
|
+
function slugify(name) {
|
|
1912
|
+
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
1913
|
+
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1914
|
+
return slug || "project";
|
|
1915
|
+
}
|
|
1916
|
+
function deriveProjectSlug(projectRoot) {
|
|
1917
|
+
try {
|
|
1918
|
+
const pkg = JSON.parse(readFileSync10(join12(projectRoot, "package.json"), "utf8"));
|
|
1919
|
+
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
1920
|
+
} catch {
|
|
1921
|
+
}
|
|
1922
|
+
return slugify(basename2(projectRoot));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// src/commands/init.ts
|
|
1926
|
+
var MODES = ["editorial", "product", "operator"];
|
|
1927
|
+
var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
|
|
1928
|
+
async function defaultPrompt(question) {
|
|
1929
|
+
const { createInterface } = await import("readline/promises");
|
|
1930
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1931
|
+
try {
|
|
1932
|
+
return (await rl.question(question)).trim();
|
|
1933
|
+
} finally {
|
|
1934
|
+
rl.close();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
function validateRounded(p) {
|
|
1938
|
+
return validateBrandColor(Math.round(p.h), Math.round(p.s), Math.round(p.l));
|
|
1939
|
+
}
|
|
1940
|
+
function describeValidation(v) {
|
|
1941
|
+
const lines = [];
|
|
1942
|
+
if (v.passesContrast) {
|
|
1943
|
+
lines.push(
|
|
1944
|
+
` contrast OK \u2014 ${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill (floor 4.5:1)`
|
|
1945
|
+
);
|
|
1946
|
+
} else {
|
|
1947
|
+
lines.push(
|
|
1948
|
+
` contrast FAILS \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill)`
|
|
1949
|
+
);
|
|
1950
|
+
if (v.nearestPassingLightness !== void 0) {
|
|
1951
|
+
lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
if (v.violetBand) {
|
|
1955
|
+
lines.push(
|
|
1956
|
+
" A-01: this hue falls in the violet/indigo band \u2014 the unspecified-default hue. Confirm this was a deliberate choice."
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1959
|
+
if (v.systemCollision) {
|
|
1960
|
+
lines.push(
|
|
1961
|
+
` E-64: this hue reads as ${v.systemCollision} \u2014 a system colour. Do not use it for interactive elements (links, buttons); keep it decorative.`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
return lines;
|
|
1965
|
+
}
|
|
1966
|
+
function parseSurfaces(answer, fallback) {
|
|
1967
|
+
const trimmed = answer.trim();
|
|
1968
|
+
if (!trimmed) return fallback;
|
|
1969
|
+
const parts = trimmed.split(",").map((p) => p.trim()).filter(Boolean);
|
|
1970
|
+
const parsed = [];
|
|
1971
|
+
for (const part of parts) {
|
|
1972
|
+
const idx = part.lastIndexOf(":");
|
|
1973
|
+
if (idx === -1) return fallback;
|
|
1974
|
+
const match = part.slice(0, idx).trim();
|
|
1975
|
+
const mode = part.slice(idx + 1).trim();
|
|
1976
|
+
if (!match || !MODES.includes(mode)) return fallback;
|
|
1977
|
+
parsed.push({ match, mode });
|
|
1978
|
+
}
|
|
1979
|
+
return parsed.length > 0 ? parsed : fallback;
|
|
1980
|
+
}
|
|
1981
|
+
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
1982
|
+
const existsOnDisk = existsSync7(absPath);
|
|
1983
|
+
const tracked = !!initManifest && relPath in initManifest.files;
|
|
1984
|
+
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
1985
|
+
return { existsOnDisk, tracked, modified };
|
|
1986
|
+
}
|
|
1987
|
+
function isValidSurfaceArray(v) {
|
|
1988
|
+
return Array.isArray(v) && v.length > 0 && v.every(
|
|
1989
|
+
(s) => typeof s === "object" && s !== null && typeof s.match === "string" && MODES.includes(s.mode)
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
1993
|
+
if (!existsSync7(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
1994
|
+
try {
|
|
1995
|
+
const parsed = JSON.parse(readFileSync11(configAbsPath, "utf8"));
|
|
1996
|
+
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
1997
|
+
const brandResolves = brandCandidate ? existsSync7(join13(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
1998
|
+
return {
|
|
1999
|
+
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
2000
|
+
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
2001
|
+
};
|
|
2002
|
+
} catch {
|
|
2003
|
+
return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
function isCssModule(path) {
|
|
2007
|
+
return /\.module\.(css|scss)$/i.test(path);
|
|
2008
|
+
}
|
|
2009
|
+
function findWireTarget(d) {
|
|
2010
|
+
if (d.cssSystem === "tailwind-v4" && d.tailwindV4EntryFile) return d.tailwindV4EntryFile;
|
|
2011
|
+
const candidates = d.cssFiles.filter((f) => !isCssModule(f));
|
|
2012
|
+
if (candidates.length === 1) return candidates[0];
|
|
2013
|
+
return null;
|
|
2014
|
+
}
|
|
2015
|
+
var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${MODES.join("|")})\\.css["'];?\\r?\\n?`);
|
|
2016
|
+
var CHARSET_RE = /^@charset\s+["'][^"']*["'];\r?\n?/;
|
|
2017
|
+
function insertAfterCharset(content, insertion) {
|
|
2018
|
+
const m = CHARSET_RE.exec(content);
|
|
2019
|
+
const charsetLen = m ? m[0].length : 0;
|
|
2020
|
+
return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
|
|
2021
|
+
}
|
|
2022
|
+
function wireImport(absPath, brandImport, modeImport, mode) {
|
|
2023
|
+
const content = existsSync7(absPath) ? readFileSync11(absPath, "utf8") : "";
|
|
2024
|
+
const brandImportLine = `@import "${brandImport}";`;
|
|
2025
|
+
const modeImportLine = `@import "${modeImport}";`;
|
|
2026
|
+
const brandPresent = content.includes(brandImportLine);
|
|
2027
|
+
const existingModeMatch = MODE_IMPORT_RE.exec(content);
|
|
2028
|
+
if (brandPresent) {
|
|
2029
|
+
if (existingModeMatch && existingModeMatch[0].trim() === modeImportLine) {
|
|
2030
|
+
return { status: "already-present" };
|
|
2031
|
+
}
|
|
2032
|
+
if (existingModeMatch) {
|
|
2033
|
+
const previousMode = existingModeMatch[2];
|
|
2034
|
+
const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
|
|
2035
|
+
` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
|
|
2036
|
+
writeFileSync5(absPath, next3, "utf8");
|
|
2037
|
+
return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
|
|
2038
|
+
}
|
|
2039
|
+
const brandIdx = content.indexOf(brandImportLine);
|
|
2040
|
+
let insertAt = brandIdx + brandImportLine.length;
|
|
2041
|
+
if (content[insertAt] === "\r") insertAt++;
|
|
2042
|
+
if (content[insertAt] === "\n") insertAt++;
|
|
2043
|
+
const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
|
|
2044
|
+
writeFileSync5(absPath, next2, "utf8");
|
|
2045
|
+
return { status: "rewired", detail: `added missing mode import: ${mode}` };
|
|
2046
|
+
}
|
|
2047
|
+
const lines = `${brandImportLine}
|
|
2048
|
+
${modeImportLine}
|
|
2049
|
+
`;
|
|
2050
|
+
const tailwindImportMatch = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
|
|
2051
|
+
let next;
|
|
2052
|
+
if (tailwindImportMatch) {
|
|
2053
|
+
const idx = tailwindImportMatch.index + tailwindImportMatch[0].length;
|
|
2054
|
+
next = content.slice(0, idx) + lines + content.slice(idx);
|
|
2055
|
+
} else {
|
|
2056
|
+
next = insertAfterCharset(content, content ? `${lines}
|
|
2057
|
+
` : lines);
|
|
2058
|
+
}
|
|
2059
|
+
mkdirSync5(dirname6(absPath), { recursive: true });
|
|
2060
|
+
writeFileSync5(absPath, next, "utf8");
|
|
2061
|
+
return { status: "wired" };
|
|
2062
|
+
}
|
|
2063
|
+
async function init(opts) {
|
|
2064
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
2065
|
+
const prompt = opts.prompt ?? defaultPrompt;
|
|
2066
|
+
const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
|
|
2067
|
+
if (!resolved) {
|
|
2068
|
+
throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
|
|
2069
|
+
}
|
|
2070
|
+
const detection = detect(opts.projectRoot);
|
|
2071
|
+
log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
|
|
2072
|
+
if (detection.cssFiles.length === 0) {
|
|
2073
|
+
log(" No stylesheets found.");
|
|
2074
|
+
} else {
|
|
2075
|
+
log(` ${detection.cssFiles.length} stylesheet(s) found.`);
|
|
2076
|
+
}
|
|
2077
|
+
let proposal = deriveBrandColor(opts.projectRoot, detection.cssFiles, detection.tailwindConfigFile);
|
|
2078
|
+
let validation = validateRounded(proposal);
|
|
2079
|
+
log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
|
|
2080
|
+
for (const line of describeValidation(validation)) log(line);
|
|
2081
|
+
let surfaces = DEFAULT_SURFACES;
|
|
2082
|
+
if (!opts.yes) {
|
|
2083
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2084
|
+
const answer = await prompt(
|
|
2085
|
+
`Use this brand colour? [Y/n, or paste a hex to override]: `
|
|
2086
|
+
);
|
|
2087
|
+
const normalized = answer.toLowerCase();
|
|
2088
|
+
if (normalized === "" || normalized === "y" || normalized === "yes") break;
|
|
2089
|
+
if (normalized === "n" || normalized === "no") break;
|
|
2090
|
+
const comps = extractColorComponents(answer, {});
|
|
2091
|
+
if (!comps) {
|
|
2092
|
+
log(` Could not parse '${answer}' as a colour \u2014 keeping the proposal.`);
|
|
2093
|
+
break;
|
|
2094
|
+
}
|
|
2095
|
+
proposal = { h: comps.hsl.h, s: comps.hsl.s, l: comps.hsl.l, source: "default", detail: `user-provided: ${answer}` };
|
|
2096
|
+
validation = validateRounded(proposal);
|
|
2097
|
+
log(`Brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%)`);
|
|
2098
|
+
for (const line of describeValidation(validation)) log(line);
|
|
2099
|
+
}
|
|
2100
|
+
const surfaceAnswer = await prompt(
|
|
2101
|
+
`Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${MODES.join("/")}): `
|
|
2102
|
+
);
|
|
2103
|
+
surfaces = parseSurfaces(surfaceAnswer, DEFAULT_SURFACES);
|
|
2104
|
+
if (surfaceAnswer.trim() && surfaces === DEFAULT_SURFACES) {
|
|
2105
|
+
log(
|
|
2106
|
+
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
|
|
2111
|
+
const originalL = proposal.l;
|
|
2112
|
+
proposal = { ...proposal, l: validation.nearestPassingLightness };
|
|
2113
|
+
validation = validateRounded(proposal);
|
|
2114
|
+
log(
|
|
2115
|
+
`
|
|
2116
|
+
Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192 l:${proposal.l}% (hue/saturation unchanged).`
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
2120
|
+
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
2121
|
+
const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
|
|
2122
|
+
const brandAbsPath = join13(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
|
|
2123
|
+
const configRelPath = "jig.config.json";
|
|
2124
|
+
const configAbsPath = join13(opts.projectRoot, configRelPath);
|
|
2125
|
+
const initManifest = readInitManifest(opts.projectRoot);
|
|
2126
|
+
const files = { ...initManifest?.files ?? {} };
|
|
2127
|
+
const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
|
|
2128
|
+
const configState = fileState(opts.projectRoot, configAbsPath, configRelPath, initManifest);
|
|
2129
|
+
let brandAction = "written";
|
|
2130
|
+
if (brandState.existsOnDisk && !brandState.tracked) brandAction = "skipped-untracked";
|
|
2131
|
+
else if (brandState.modified) brandAction = "skipped-edited";
|
|
2132
|
+
let configAction = "written";
|
|
2133
|
+
if (configState.existsOnDisk && !configState.tracked) configAction = "skipped-untracked";
|
|
2134
|
+
else if (configState.modified) configAction = "skipped-edited";
|
|
2135
|
+
if (!opts.yes && (brandAction !== "written" || configAction !== "written")) {
|
|
2136
|
+
if (brandAction !== "written") {
|
|
2137
|
+
log(`
|
|
2138
|
+
${brandRelPath} already exists and was not created by 'jig init' (or has been edited).`);
|
|
2139
|
+
const answer = (await prompt(` [k]eep it, or [r]efresh it with the freshly derived colour? [k/r]: `)).toLowerCase();
|
|
2140
|
+
brandAction = answer.startsWith("r") ? "written" : "kept";
|
|
2141
|
+
}
|
|
2142
|
+
if (configAction !== "written") {
|
|
2143
|
+
log(`
|
|
2144
|
+
${configRelPath} already exists and was not created by 'jig init' (or has been edited).`);
|
|
2145
|
+
const answer = (await prompt(` [k]eep it, [r]efresh it, or [m]erge (keep your surfaces, only fix a missing brand path)? [k/r/m]: `)).toLowerCase();
|
|
2146
|
+
configAction = answer.startsWith("r") ? "written" : answer.startsWith("m") ? "merged" : "kept";
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
if (brandAction !== "written") log(` ${brandRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
2150
|
+
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
2151
|
+
}
|
|
2152
|
+
if (brandAction === "written") {
|
|
2153
|
+
const defaultCssRaw = readFileSync11(join13(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
2154
|
+
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
2155
|
+
mkdirSync5(dirname6(brandAbsPath), { recursive: true });
|
|
2156
|
+
writeFileSync5(brandAbsPath, content, "utf8");
|
|
2157
|
+
files[brandRelPath] = checksum(content);
|
|
2158
|
+
}
|
|
2159
|
+
if (configAction === "written") {
|
|
2160
|
+
const config = { brand: brandRelPath, surfaces };
|
|
2161
|
+
const content = `${JSON.stringify(config, null, 2)}
|
|
2162
|
+
`;
|
|
2163
|
+
writeFileSync5(configAbsPath, content, "utf8");
|
|
2164
|
+
files[configRelPath] = checksum(content);
|
|
2165
|
+
} else if (configAction === "merged") {
|
|
2166
|
+
let existing = {};
|
|
2167
|
+
try {
|
|
2168
|
+
existing = JSON.parse(readFileSync11(configAbsPath, "utf8"));
|
|
2169
|
+
} catch {
|
|
2170
|
+
existing = {};
|
|
2171
|
+
}
|
|
2172
|
+
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
2173
|
+
const brandStillResolves = existingBrand ? existsSync7(join13(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
2174
|
+
const merged = {
|
|
2175
|
+
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
2176
|
+
surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
|
|
2177
|
+
};
|
|
2178
|
+
const content = `${JSON.stringify(merged, null, 2)}
|
|
2179
|
+
`;
|
|
2180
|
+
writeFileSync5(configAbsPath, content, "utf8");
|
|
2181
|
+
files[configRelPath] = checksum(content);
|
|
2182
|
+
}
|
|
2183
|
+
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
2184
|
+
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
2185
|
+
const wiringBrandAbsPath = join13(opts.projectRoot, ...effectiveConfig.brand.split("/"));
|
|
2186
|
+
const modeFileName = `mode.${primaryMode}.css`;
|
|
2187
|
+
const modeAbsPath = join13(opts.projectRoot, ".jig", "tokens", modeFileName);
|
|
2188
|
+
if (resolved.scope === "global") {
|
|
2189
|
+
const modeRelPath = relKey(".jig", "tokens", modeFileName);
|
|
2190
|
+
const modeState = fileState(opts.projectRoot, modeAbsPath, modeRelPath, initManifest);
|
|
2191
|
+
let modeAction = "written";
|
|
2192
|
+
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
2193
|
+
else if (modeState.modified) modeAction = "skipped-edited";
|
|
2194
|
+
if (modeAction === "written") {
|
|
2195
|
+
const modeSourceRaw = readFileSync11(join13(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
2196
|
+
const content = vendorHeader(modeFileName, opts.version, "css") + modeSourceRaw;
|
|
2197
|
+
mkdirSync5(dirname6(modeAbsPath), { recursive: true });
|
|
2198
|
+
writeFileSync5(modeAbsPath, content, "utf8");
|
|
2199
|
+
files[modeRelPath] = checksum(content);
|
|
2200
|
+
} else {
|
|
2201
|
+
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
writeInitManifest(opts.projectRoot, { files });
|
|
2205
|
+
const wireTarget = findWireTarget(detection);
|
|
2206
|
+
let wiring;
|
|
2207
|
+
if (wireTarget) {
|
|
2208
|
+
const targetAbsDir = dirname6(join13(opts.projectRoot, wireTarget));
|
|
2209
|
+
const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
|
|
2210
|
+
const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
|
|
2211
|
+
const snippet = `@import "${brandImport}";
|
|
2212
|
+
@import "${modeImport}";`;
|
|
2213
|
+
try {
|
|
2214
|
+
const outcome = wireImport(join13(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
|
|
2215
|
+
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
2216
|
+
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
2217
|
+
log(`
|
|
2218
|
+
${verb} ${wireTarget}:`);
|
|
2219
|
+
if (outcome.detail) log(` ${outcome.detail}`);
|
|
2220
|
+
log(` ${snippet.split("\n").join("\n ")}`);
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
wiring = { target: null, status: "print-only", snippet };
|
|
2223
|
+
log(`
|
|
2224
|
+
Could not write to ${wireTarget}: ${err.message}`);
|
|
2225
|
+
log("Add this near the top of your global stylesheet instead (adjust the path to wherever you paste it):");
|
|
2226
|
+
log(` ${snippet.split("\n").join("\n ")}`);
|
|
2227
|
+
}
|
|
2228
|
+
} else {
|
|
2229
|
+
const brandImport = relativeImportPath(opts.projectRoot, wiringBrandAbsPath);
|
|
2230
|
+
const modeImport = relativeImportPath(opts.projectRoot, modeAbsPath);
|
|
2231
|
+
const snippet = `@import "${brandImport}";
|
|
2232
|
+
@import "${modeImport}";`;
|
|
2233
|
+
wiring = { target: null, status: "print-only", snippet };
|
|
2234
|
+
log(
|
|
2235
|
+
`
|
|
2236
|
+
Could not find a single unambiguous stylesheet to wire the import into. Add this near the top of your global stylesheet (paths shown relative to the project root \u2014 adjust to wherever you paste them):`
|
|
2237
|
+
);
|
|
2238
|
+
log(` ${snippet.split("\n").join("\n ")}`);
|
|
2239
|
+
}
|
|
2240
|
+
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
2241
|
+
log("\nBaseline:");
|
|
2242
|
+
log(baselineResult.report);
|
|
2243
|
+
return {
|
|
2244
|
+
detection,
|
|
2245
|
+
proposal,
|
|
2246
|
+
validation,
|
|
2247
|
+
finalColor,
|
|
2248
|
+
// I2: report the surfaces that actually govern wiring (effectiveConfig),
|
|
2249
|
+
// not the freshly-derived/prompted `surfaces` local — the two differ
|
|
2250
|
+
// exactly when an existing config was kept, and this field should
|
|
2251
|
+
// describe what happened, not what would have happened on a fresh write.
|
|
2252
|
+
surfaces: effectiveConfig.surfaces,
|
|
2253
|
+
brand: { relPath: brandRelPath, action: brandAction },
|
|
2254
|
+
config: { relPath: configRelPath, action: configAction },
|
|
2255
|
+
wiring,
|
|
2256
|
+
baseline: { report: baselineResult.report, findingsCount: baselineResult.findings.length }
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
|
|
520
2260
|
// src/index.ts
|
|
521
2261
|
var packageRoot = getPackageRoot();
|
|
522
|
-
var { version } = JSON.parse(
|
|
2262
|
+
var { version } = JSON.parse(readFileSync12(join14(packageRoot, "package.json"), "utf8"));
|
|
523
2263
|
var program = new Command();
|
|
524
2264
|
program.name("jig").description("A design system for coding agents.").version(version);
|
|
525
2265
|
program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
|
|
@@ -564,4 +2304,36 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
564
2304
|
process.exit(1);
|
|
565
2305
|
}
|
|
566
2306
|
});
|
|
2307
|
+
program.command("check").description("Check the repo against Jig's mechanical + hybrid rules.").option("--all", "check the whole repo instead of just changed files", false).option("--ci", "mechanical bucket only; exits non-zero on any error, deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
|
|
2308
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
2309
|
+
try {
|
|
2310
|
+
const result = check({
|
|
2311
|
+
projectRoot,
|
|
2312
|
+
homeDir: homedir(),
|
|
2313
|
+
version,
|
|
2314
|
+
all: opts.all,
|
|
2315
|
+
ci: opts.ci
|
|
2316
|
+
});
|
|
2317
|
+
console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
|
|
2318
|
+
if (opts.ci && result.hasError) process.exit(1);
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
console.error(err.message);
|
|
2321
|
+
process.exit(1);
|
|
2322
|
+
}
|
|
2323
|
+
});
|
|
2324
|
+
program.command("init").description("Set the project up to use Jig: a brand file, jig.config.json, and a baseline check.").option("--yes", "non-interactive: derive everything, accept the proposal, ask nothing", false).action(async (opts) => {
|
|
2325
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
2326
|
+
try {
|
|
2327
|
+
await init({
|
|
2328
|
+
projectRoot,
|
|
2329
|
+
packageRoot: assetRoot(),
|
|
2330
|
+
homeDir: homedir(),
|
|
2331
|
+
version,
|
|
2332
|
+
yes: opts.yes
|
|
2333
|
+
});
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
console.error(err.message);
|
|
2336
|
+
process.exit(1);
|
|
2337
|
+
}
|
|
2338
|
+
});
|
|
567
2339
|
program.parse();
|