jig-ui 0.16.0 → 0.17.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/CHANGELOG.md +65 -0
- package/README.md +7 -6
- package/dist/index.js +730 -235
- package/examples/A-01.html +7 -0
- package/examples/A-02.html +7 -0
- package/examples/A-03.html +7 -0
- package/examples/A-04.html +7 -0
- package/examples/A-05.html +7 -0
- package/examples/A-06.html +7 -0
- package/examples/A-07.html +7 -0
- package/examples/A-08.html +7 -0
- package/examples/A-09.html +7 -0
- package/examples/A-10.html +7 -0
- package/examples/A-135.html +7 -0
- package/examples/A-136.html +7 -0
- package/examples/A-137.html +7 -0
- package/examples/A-138.html +7 -0
- package/examples/A-139.html +7 -0
- package/examples/A-140.html +7 -0
- package/examples/A-141.html +7 -0
- package/examples/A-142.html +7 -0
- package/examples/A-143.html +7 -0
- package/examples/A-144.html +7 -0
- package/examples/A-146.html +7 -0
- package/examples/A-58.html +7 -0
- package/examples/A-59.html +7 -0
- package/examples/A-60.html +7 -0
- package/examples/A-67.html +7 -0
- package/examples/B-105.html +7 -0
- package/examples/B-106.html +7 -0
- package/examples/B-11.html +7 -0
- package/examples/B-12.html +7 -0
- package/examples/B-13.html +7 -0
- package/examples/B-14.html +7 -0
- package/examples/B-15.html +7 -0
- package/examples/B-16.html +7 -0
- package/examples/B-17.html +7 -0
- package/examples/B-75.html +7 -0
- package/examples/B-76.html +7 -0
- package/examples/B-77.html +7 -0
- package/examples/B-78.html +7 -0
- package/examples/C-18.html +7 -0
- package/examples/C-19.html +7 -0
- package/examples/C-20.html +7 -0
- package/examples/C-21.html +7 -0
- package/examples/C-22.html +11 -0
- package/examples/C-49.html +7 -0
- package/examples/C-50.html +7 -0
- package/examples/C-66.html +7 -0
- package/examples/C-68.html +7 -0
- package/examples/D-111.html +7 -0
- package/examples/D-112.html +7 -0
- package/examples/D-114.html +7 -0
- package/examples/D-115.html +7 -0
- package/examples/D-23.html +11 -0
- package/examples/D-24.html +7 -0
- package/examples/D-25.html +7 -0
- package/examples/D-26.html +7 -0
- package/examples/D-27.html +7 -0
- package/examples/D-69.html +7 -0
- package/examples/D-70.html +7 -0
- package/examples/D-71.html +7 -0
- package/examples/D-72.html +7 -0
- package/examples/D-96.html +7 -0
- package/examples/E-116.html +7 -0
- package/examples/E-28.html +7 -0
- package/examples/E-29.html +7 -0
- package/examples/E-30.html +7 -0
- package/examples/E-31.html +7 -0
- package/examples/E-32.html +7 -0
- package/examples/E-33.html +11 -0
- package/examples/E-34.html +7 -0
- package/examples/E-35.html +7 -0
- package/examples/E-51.html +7 -0
- package/examples/E-52.html +7 -0
- package/examples/E-61.html +7 -0
- package/examples/E-62.html +7 -0
- package/examples/E-63.html +7 -0
- package/examples/E-64.html +7 -0
- package/examples/E-65.html +7 -0
- package/examples/E-73.html +7 -0
- package/examples/E-74.html +7 -0
- package/examples/E-91.html +7 -0
- package/examples/E-92.html +7 -0
- package/examples/E-93.html +7 -0
- package/examples/E-94.html +7 -0
- package/examples/E-95.html +7 -0
- package/examples/F-100.html +7 -0
- package/examples/F-101.html +7 -0
- package/examples/F-102.html +7 -0
- package/examples/F-103.html +7 -0
- package/examples/F-104.html +7 -0
- package/examples/F-113.html +7 -0
- package/examples/F-147.html +7 -0
- package/examples/F-36.html +7 -0
- package/examples/F-37.html +7 -0
- package/examples/F-38.html +7 -0
- package/examples/F-39.html +7 -0
- package/examples/F-40.html +7 -0
- package/examples/F-41.html +11 -0
- package/examples/F-97.html +7 -0
- package/examples/F-98.html +7 -0
- package/examples/F-99.html +7 -0
- package/examples/G-145.html +7 -0
- package/examples/G-42.html +7 -0
- package/examples/G-43.html +10 -0
- package/examples/G-44.html +7 -0
- package/examples/H-117.html +11 -0
- package/examples/H-119.html +13 -0
- package/examples/H-45.html +13 -0
- package/examples/H-46.html +10 -0
- package/examples/H-47.html +15 -0
- package/examples/H-48.html +8 -0
- package/examples/I-118.html +7 -0
- package/examples/I-53.html +7 -0
- package/examples/I-54.html +7 -0
- package/examples/I-55.html +7 -0
- package/examples/I-56.html +7 -0
- package/examples/I-57.html +7 -0
- package/examples/I-79.html +7 -0
- package/examples/I-80.html +7 -0
- package/examples/I-81.html +7 -0
- package/examples/I-82.html +7 -0
- package/examples/I-83.html +7 -0
- package/examples/I-84.html +7 -0
- package/examples/I-85.html +7 -0
- package/examples/I-86.html +7 -0
- package/examples/I-87.html +7 -0
- package/examples/I-88.html +7 -0
- package/examples/I-89.html +7 -0
- package/examples/I-90.html +7 -0
- package/examples/J-120.html +7 -0
- package/examples/J-121.html +7 -0
- package/examples/J-122.html +7 -0
- package/examples/J-123.html +7 -0
- package/examples/J-124.html +8 -0
- package/examples/J-125.html +10 -0
- package/examples/J-126.html +9 -0
- package/examples/J-127.html +10 -0
- package/examples/K-128.html +9 -0
- package/examples/K-129.html +7 -0
- package/examples/K-130.html +7 -0
- package/examples/K-131.html +11 -0
- package/examples/K-132.html +7 -0
- package/examples/K-133.html +8 -0
- package/examples/K-134.html +9 -0
- package/examples/README.md +44 -0
- package/package.json +3 -2
- package/rules/00-anti-patterns.md +67 -0
- package/rules/01-modes.md +1 -1
- package/rules/03-patterns.md +6 -6
- package/rules/04-principles.md +12 -12
- package/rules/05-copy.md +3 -0
- package/rules.index.json +101 -0
- package/templates/COMMAND.md.tmpl +10 -3
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 readFileSync32 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join39 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -844,15 +844,15 @@ function detectLegacyCursorRules(projectRoot) {
|
|
|
844
844
|
}
|
|
845
845
|
return { present: files.length > 0, files };
|
|
846
846
|
}
|
|
847
|
-
function describeLegacyFiles(
|
|
848
|
-
if (!
|
|
847
|
+
function describeLegacyFiles(report2, headerLine, subheaderLine) {
|
|
848
|
+
if (!report2.present) return [];
|
|
849
849
|
const lines = [];
|
|
850
850
|
lines.push(`
|
|
851
851
|
${headerLine}`);
|
|
852
852
|
lines.push(subheaderLine);
|
|
853
|
-
for (const f of
|
|
854
|
-
const edited =
|
|
855
|
-
const unknown =
|
|
853
|
+
for (const f of report2.files) lines.push(` ${f.relPath}`);
|
|
854
|
+
const edited = report2.files.filter((f) => f.modified === true).map((f) => f.relPath);
|
|
855
|
+
const unknown = report2.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
|
|
856
856
|
if (edited.length) {
|
|
857
857
|
lines.push(" Edited since install \u2014 kept, never removed automatically:");
|
|
858
858
|
for (const relPath of edited) lines.push(` ${relPath}`);
|
|
@@ -863,22 +863,22 @@ ${headerLine}`);
|
|
|
863
863
|
}
|
|
864
864
|
return lines;
|
|
865
865
|
}
|
|
866
|
-
function describeLegacyReport(
|
|
866
|
+
function describeLegacyReport(report2) {
|
|
867
867
|
return describeLegacyFiles(
|
|
868
|
-
|
|
869
|
-
`Found a pre-0.4.0 Jig install in .jig/ (${
|
|
868
|
+
report2,
|
|
869
|
+
`Found a pre-0.4.0 Jig install in .jig/ (${report2.files.length} file(s)). These are no longer used \u2014`,
|
|
870
870
|
" since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
|
|
871
871
|
);
|
|
872
872
|
}
|
|
873
|
-
function describeLegacyCursorReport(
|
|
873
|
+
function describeLegacyCursorReport(report2) {
|
|
874
874
|
return describeLegacyFiles(
|
|
875
|
-
|
|
876
|
-
`Found a legacy Cursor install (${
|
|
875
|
+
report2,
|
|
876
|
+
`Found a legacy Cursor install (${report2.files.length} file(s)). Cursor's skill has moved \u2014`,
|
|
877
877
|
" from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
|
|
878
878
|
);
|
|
879
879
|
}
|
|
880
|
-
function removableLegacyFiles(
|
|
881
|
-
return
|
|
880
|
+
function removableLegacyFiles(report2) {
|
|
881
|
+
return report2.files.filter((f) => f.modified === false).map((f) => f.relPath);
|
|
882
882
|
}
|
|
883
883
|
function removeLegacyFiles(projectRoot, relPaths) {
|
|
884
884
|
for (const relPath of relPaths) {
|
|
@@ -1031,6 +1031,7 @@ function updateInitFiles(opts) {
|
|
|
1031
1031
|
}
|
|
1032
1032
|
|
|
1033
1033
|
// src/commands/explain.ts
|
|
1034
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1034
1035
|
import { join as join15 } from "path";
|
|
1035
1036
|
|
|
1036
1037
|
// src/rules/load.ts
|
|
@@ -1410,7 +1411,12 @@ function explain(opts) {
|
|
|
1410
1411
|
if (shape) {
|
|
1411
1412
|
const id = `${shape[1]}-${shape[2]}`;
|
|
1412
1413
|
const rule = rules.find((r) => r.id === id);
|
|
1413
|
-
if (rule)
|
|
1414
|
+
if (rule) {
|
|
1415
|
+
const example = join15(root, "examples", `${id}.html`);
|
|
1416
|
+
return existsSync9(example) ? `${renderRule(rule)}
|
|
1417
|
+
|
|
1418
|
+
Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : renderRule(rule);
|
|
1419
|
+
}
|
|
1414
1420
|
const spec = specs.find((s) => s.id === id);
|
|
1415
1421
|
if (spec) return renderSpec(spec);
|
|
1416
1422
|
const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
|
|
@@ -1463,7 +1469,7 @@ function sectionsOf(entries) {
|
|
|
1463
1469
|
}
|
|
1464
1470
|
|
|
1465
1471
|
// src/commands/check.ts
|
|
1466
|
-
import { existsSync as
|
|
1472
|
+
import { existsSync as existsSync15, readFileSync as readFileSync19 } from "fs";
|
|
1467
1473
|
import { join as join24 } from "path";
|
|
1468
1474
|
|
|
1469
1475
|
// src/check/files.ts
|
|
@@ -2764,9 +2770,9 @@ function palettePairs(classes) {
|
|
|
2764
2770
|
let background;
|
|
2765
2771
|
let foreground;
|
|
2766
2772
|
for (const token of classes.split(/\s+/)) {
|
|
2767
|
-
const
|
|
2768
|
-
const bg = /^bg-(.+)$/.exec(
|
|
2769
|
-
const fg = /^text-(.+)$/.exec(
|
|
2773
|
+
const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
|
|
2774
|
+
const bg = /^bg-(.+)$/.exec(bare2);
|
|
2775
|
+
const fg = /^text-(.+)$/.exec(bare2);
|
|
2770
2776
|
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
2771
2777
|
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
2772
2778
|
}
|
|
@@ -2787,11 +2793,11 @@ function resolveFloor(body) {
|
|
|
2787
2793
|
const raw = m[1].trim();
|
|
2788
2794
|
const pxMatch = FONT_SIZE_PX_RE.exec(raw);
|
|
2789
2795
|
const remMatch = FONT_SIZE_REM_RE.exec(raw);
|
|
2790
|
-
let
|
|
2791
|
-
if (pxMatch)
|
|
2792
|
-
else if (remMatch)
|
|
2796
|
+
let px2;
|
|
2797
|
+
if (pxMatch) px2 = parseFloat(pxMatch[1]);
|
|
2798
|
+
else if (remMatch) px2 = parseFloat(remMatch[1]) * REM_TO_PX;
|
|
2793
2799
|
else return LARGE_FLOOR;
|
|
2794
|
-
const large = BOLD_RE.test(body) ?
|
|
2800
|
+
const large = BOLD_RE.test(body) ? px2 >= 18.66 : px2 >= 24;
|
|
2795
2801
|
return large ? LARGE_FLOOR : FLOOR;
|
|
2796
2802
|
}
|
|
2797
2803
|
var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
|
|
@@ -3142,8 +3148,8 @@ var inputZoom = {
|
|
|
3142
3148
|
SIZE_RE.lastIndex = 0;
|
|
3143
3149
|
let m;
|
|
3144
3150
|
while (m = SIZE_RE.exec(block.body)) {
|
|
3145
|
-
const
|
|
3146
|
-
if (
|
|
3151
|
+
const px2 = pixels(m[1], ctx.mode);
|
|
3152
|
+
if (px2 === void 0 || px2 >= ROOT_PX) continue;
|
|
3147
3153
|
const line = lineOfOffset(block, m.index);
|
|
3148
3154
|
findings.push(
|
|
3149
3155
|
mkFinding(
|
|
@@ -3151,7 +3157,7 @@ var inputZoom = {
|
|
|
3151
3157
|
"input-zoom",
|
|
3152
3158
|
file,
|
|
3153
3159
|
line,
|
|
3154
|
-
`form control text resolves to ${
|
|
3160
|
+
`form control text resolves to ${px2}px; iOS zooms the page on focus below 16px`,
|
|
3155
3161
|
sourceLine(source, line)
|
|
3156
3162
|
)
|
|
3157
3163
|
);
|
|
@@ -3255,7 +3261,7 @@ var menuState = {
|
|
|
3255
3261
|
};
|
|
3256
3262
|
|
|
3257
3263
|
// src/check/declared-properties.ts
|
|
3258
|
-
import { existsSync as
|
|
3264
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
|
|
3259
3265
|
import { join as join17 } from "path";
|
|
3260
3266
|
var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
|
|
3261
3267
|
var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
|
|
@@ -3271,7 +3277,7 @@ function readPackageImport(projectRoot, spec) {
|
|
|
3271
3277
|
const base = join17(projectRoot, "node_modules", spec);
|
|
3272
3278
|
for (const candidate of [base, `${base}.css`]) {
|
|
3273
3279
|
try {
|
|
3274
|
-
if (
|
|
3280
|
+
if (existsSync10(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
|
|
3275
3281
|
} catch {
|
|
3276
3282
|
}
|
|
3277
3283
|
}
|
|
@@ -3635,6 +3641,335 @@ var interfaceSafety = {
|
|
|
3635
3641
|
}
|
|
3636
3642
|
};
|
|
3637
3643
|
|
|
3644
|
+
// src/check/detectors/generated-look.ts
|
|
3645
|
+
function report(ctx, name, file, raw, hits) {
|
|
3646
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3647
|
+
return hits.filter((h) => seen.has(h.line) ? false : (seen.add(h.line), true)).map((h) => mkFinding(ctx, name, file, h.line, h.message, sourceLine(raw, h.line))).sort((a, b) => a.line - b.line);
|
|
3648
|
+
}
|
|
3649
|
+
function classLists(file, raw) {
|
|
3650
|
+
if (!isMarkupHost(file)) return [];
|
|
3651
|
+
return classAttributeValues(raw).map((a) => ({ classes: a.classes.split(/\s+/).filter(Boolean), line: a.line }));
|
|
3652
|
+
}
|
|
3653
|
+
var bare = (c) => c.replace(/^(?:[\w-]+:)+/, "").replace(/^!/, "");
|
|
3654
|
+
var px = (v) => {
|
|
3655
|
+
const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(v.trim());
|
|
3656
|
+
if (!m) return void 0;
|
|
3657
|
+
const n = parseFloat(m[1]);
|
|
3658
|
+
return m[2] === "rem" || m[2] === "em" ? n * 16 : n;
|
|
3659
|
+
};
|
|
3660
|
+
function twBorderWidth(suffix) {
|
|
3661
|
+
if (!suffix) return 1;
|
|
3662
|
+
const arb = /^\[(.+)\]$/.exec(suffix);
|
|
3663
|
+
if (arb) return px(arb[1]) ?? 0;
|
|
3664
|
+
return Number(suffix) || 0;
|
|
3665
|
+
}
|
|
3666
|
+
var TW_COLOUR = /^(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}/;
|
|
3667
|
+
function saturated(value, tokens, minS = 25, minAlpha = 0.2) {
|
|
3668
|
+
const c = extractColorComponents(value.trim(), tokens);
|
|
3669
|
+
return !!c && c.alpha >= minAlpha && c.hsl.s >= minS && c.hsl.l > 8 && c.hsl.l < 92;
|
|
3670
|
+
}
|
|
3671
|
+
function colourIn(value) {
|
|
3672
|
+
return value.replace(/\b\d*\.?\d+(px|rem|em)\b/g, " ").replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)\b/gi, " ").trim();
|
|
3673
|
+
}
|
|
3674
|
+
var SIDE_DECL = /(?<![-\w])border-(left|inline-start|right|inline-end)(?:-width)?\s*:\s*([^;}]+)/gi;
|
|
3675
|
+
var OTHER_SIDE_WIDE = /(?<![-\w])border(?:-(?:top|bottom))?(?:-width)?\s*:\s*(\d*\.?\d+)px/gi;
|
|
3676
|
+
var sideTabBorder = {
|
|
3677
|
+
name: "side-tab-border",
|
|
3678
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3679
|
+
run(source, file, ctx) {
|
|
3680
|
+
const hits = [];
|
|
3681
|
+
for (const block of leafBlocks(source)) {
|
|
3682
|
+
if (/\bblockquote\b|\bq\b/.test(block.selector)) continue;
|
|
3683
|
+
SIDE_DECL.lastIndex = 0;
|
|
3684
|
+
let m;
|
|
3685
|
+
while (m = SIDE_DECL.exec(block.body)) {
|
|
3686
|
+
const width = Math.max(0, ...[...m[2].matchAll(/(\d*\.?\d+)(px|rem)/g)].map((w) => px(w[0]) ?? 0));
|
|
3687
|
+
if (width < 3) continue;
|
|
3688
|
+
const colour = colourIn(m[2]);
|
|
3689
|
+
const colourDecl = new RegExp(`border-${m[1]}-color\\s*:\\s*([^;}]+)`, "i").exec(block.body)?.[1] ?? colour;
|
|
3690
|
+
if (colourDecl && !saturated(colourDecl, ctx.tokens) && !/var\(--color-(?:brand|text-brand|stroke-brand|accent)/.test(colourDecl)) continue;
|
|
3691
|
+
OTHER_SIDE_WIDE.lastIndex = 0;
|
|
3692
|
+
if ([...block.body.matchAll(OTHER_SIDE_WIDE)].some((o) => parseFloat(o[1]) >= width)) continue;
|
|
3693
|
+
hits.push({ line: lineOfOffset(block, m.index), message: `a ${width}px coloured stripe on one side of ${block.selector.trim()}: the shape of an alert on something that is not one` });
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
for (const { classes, line } of classLists(file, ctx.raw)) {
|
|
3697
|
+
const plain = classes.map(bare);
|
|
3698
|
+
const side = plain.map((c) => /^border-(l|s|r|e)(?:-(\d+|\[[^\]]+\]))?$/.exec(c)).find((x) => x && twBorderWidth(x[2]) >= 3);
|
|
3699
|
+
if (!side) continue;
|
|
3700
|
+
const colour = plain.find((c) => new RegExp(`^border-(?:${side[1]}-)?(.+)$`).test(c) && TW_COLOUR.test(c.replace(/^border-(?:[lsre]-)?/, "")));
|
|
3701
|
+
if (!colour) continue;
|
|
3702
|
+
if (plain.some((c) => /^border-(?:\d+|\[[^\]]+\])$/.test(c))) continue;
|
|
3703
|
+
hits.push({ line, message: `\`${side[0]}\` with \`${colour}\`: a coloured stripe on one side, the shape of an alert on something that is not one` });
|
|
3704
|
+
}
|
|
3705
|
+
return report(ctx, "side-tab-border", file, ctx.raw, hits);
|
|
3706
|
+
}
|
|
3707
|
+
};
|
|
3708
|
+
var BORDER_ALL = /(?<![-\w])border(?:-width)?\s*:\s*([^;}]+)/i;
|
|
3709
|
+
var RADIUS = /(?<![-\w])border-radius\s*:\s*([^;}]+)/i;
|
|
3710
|
+
var roundedAccentBorder = {
|
|
3711
|
+
name: "rounded-accent-border",
|
|
3712
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3713
|
+
run(source, file, ctx) {
|
|
3714
|
+
const hits = [];
|
|
3715
|
+
for (const block of leafBlocks(source)) {
|
|
3716
|
+
const border = BORDER_ALL.exec(block.body);
|
|
3717
|
+
const radius = RADIUS.exec(block.body);
|
|
3718
|
+
if (!border || !radius) continue;
|
|
3719
|
+
const width = Math.max(0, ...[...border[1].matchAll(/(\d*\.?\d+)px/g)].map((w) => parseFloat(w[1])));
|
|
3720
|
+
const r = /%|9999|999px/.test(radius[1]) ? 999 : Math.max(0, ...[...radius[1].matchAll(/(\d*\.?\d+)(px|rem)/g)].map((w) => px(w[0]) ?? 0));
|
|
3721
|
+
if (width < 2 || r < 12) continue;
|
|
3722
|
+
const colour = /border-color\s*:\s*([^;}]+)/i.exec(block.body)?.[1] ?? colourIn(border[1]);
|
|
3723
|
+
if (!saturated(colour, ctx.tokens)) continue;
|
|
3724
|
+
hits.push({ line: lineOfOffset(block, border.index), message: `a ${width}px coloured border on a ${r >= 999 ? "fully" : `${r}px`} rounded element: the outline becomes the most visible shape in it` });
|
|
3725
|
+
}
|
|
3726
|
+
for (const { classes, line } of classLists(file, ctx.raw)) {
|
|
3727
|
+
const plain = classes.map(bare);
|
|
3728
|
+
const width = plain.map((c) => /^border-(\d+|\[[^\]]+\])$/.exec(c)).find((x) => x && twBorderWidth(x[1]) >= 2);
|
|
3729
|
+
const colour = plain.find((c) => /^border-/.test(c) && TW_COLOUR.test(c.slice("border-".length)));
|
|
3730
|
+
const round3 = plain.find((c) => /^rounded-(?:xl|2xl|3xl|full|\[(?:1[2-9]|[2-9]\d|\d{3,})px\])$/.test(c));
|
|
3731
|
+
if (width && colour && round3) hits.push({ line, message: `\`${width[0]} ${colour} ${round3}\`: a heavy coloured outline on a large curve` });
|
|
3732
|
+
}
|
|
3733
|
+
return report(ctx, "rounded-accent-border", file, ctx.raw, hits);
|
|
3734
|
+
}
|
|
3735
|
+
};
|
|
3736
|
+
var BG_IMAGE = /(?<![-\w])background(?:-image)?\s*:\s*([^;}]+)/i;
|
|
3737
|
+
function gridLike(value, block, sizeHint) {
|
|
3738
|
+
if (/repeating-(?:linear|radial)-gradient\(/i.test(value)) return true;
|
|
3739
|
+
const linear = (value.match(/linear-gradient\(/gi) ?? []).length;
|
|
3740
|
+
const radial = (value.match(/radial-gradient\(/gi) ?? []).length;
|
|
3741
|
+
const lines = /\b1px\b|transparent\s+1px|\s1px\s*,\s*transparent/i.test(value);
|
|
3742
|
+
const sized = sizeHint || (block ? /background-size\s*:/i.test(block.body) : false);
|
|
3743
|
+
return sized && (linear >= 2 && lines || radial >= 1 && /\b1px\b|\b2px\b/.test(value));
|
|
3744
|
+
}
|
|
3745
|
+
var decorativeGridBackground = {
|
|
3746
|
+
name: "decorative-grid-background",
|
|
3747
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3748
|
+
run(source, file, ctx) {
|
|
3749
|
+
const hits = [];
|
|
3750
|
+
for (const block of leafBlocks(source)) {
|
|
3751
|
+
const bg = BG_IMAGE.exec(block.body);
|
|
3752
|
+
if (bg && gridLike(bg[1], block, false)) {
|
|
3753
|
+
hits.push({ line: lineOfOffset(block, bg.index), message: "a background of grid lines, dots or stripes behind content: noise at the contrast that makes text harder to read" });
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
for (const { classes, line } of classLists(file, ctx.raw)) {
|
|
3757
|
+
const arb = classes.map(bare).filter((c) => /^bg-\[/.test(c)).map((c) => c.replace(/_/g, " "));
|
|
3758
|
+
const image = arb.find((c) => /gradient\(/.test(c));
|
|
3759
|
+
if (image && gridLike(image, void 0, arb.some((c) => /^bg-\[size:/.test(c)) || /repeating-/.test(image))) {
|
|
3760
|
+
hits.push({ line, message: "a grid, dot or stripe background drawn with utility classes: decoration behind content" });
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
return report(ctx, "decorative-grid-background", file, ctx.raw, hits);
|
|
3764
|
+
}
|
|
3765
|
+
};
|
|
3766
|
+
var SHADOW = /(?<![-\w])(box-shadow|text-shadow)\s*:\s*([^;}]+)/gi;
|
|
3767
|
+
var DROP = /drop-shadow\(\s*0(?:px)?\s+0(?:px)?\s+(\d*\.?\d+)px\s+([^)]+\)?)\s*\)/gi;
|
|
3768
|
+
var COLOUR_IN = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-f]{3,8}\b|var\([^)]*\)|\b[a-z]{3,}\b/i;
|
|
3769
|
+
function glowLayers(value, tokens) {
|
|
3770
|
+
return value.split(/,(?![^(]*\))/).some((layer) => {
|
|
3771
|
+
if (/\binset\b/.test(layer)) return false;
|
|
3772
|
+
const colour = COLOUR_IN.exec(layer.replace(/\binset\b/, ""))?.[0];
|
|
3773
|
+
if (!colour) return false;
|
|
3774
|
+
const nums = [...layer.replace(colour, " ").matchAll(/-?\d*\.?\d+/g)].map((n) => parseFloat(n[0]));
|
|
3775
|
+
if (nums.length < 3) return false;
|
|
3776
|
+
const [x, y, blur] = nums;
|
|
3777
|
+
if (x !== 0 || y !== 0 || blur < 8) return false;
|
|
3778
|
+
return saturated(colour, tokens, 40, 0.15);
|
|
3779
|
+
});
|
|
3780
|
+
}
|
|
3781
|
+
var glowAccent = {
|
|
3782
|
+
name: "glow-accent",
|
|
3783
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3784
|
+
run(source, file, ctx) {
|
|
3785
|
+
const hits = [];
|
|
3786
|
+
for (const block of leafBlocks(source)) {
|
|
3787
|
+
SHADOW.lastIndex = 0;
|
|
3788
|
+
let m;
|
|
3789
|
+
while (m = SHADOW.exec(block.body)) {
|
|
3790
|
+
if (glowLayers(m[2], ctx.tokens)) hits.push({ line: lineOfOffset(block, m.index), message: `a coloured ${m[1]} glow: contrast added without meaning` });
|
|
3791
|
+
}
|
|
3792
|
+
DROP.lastIndex = 0;
|
|
3793
|
+
while (m = DROP.exec(block.body)) {
|
|
3794
|
+
if (parseFloat(m[1]) >= 8 && saturated(m[2], ctx.tokens, 40)) hits.push({ line: lineOfOffset(block, m.index), message: "a coloured drop-shadow glow: contrast added without meaning" });
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
for (const { classes, line } of classLists(file, ctx.raw)) {
|
|
3798
|
+
const plain = classes.map(bare);
|
|
3799
|
+
const arb = plain.find((c) => /^(?:shadow|drop-shadow)-\[/.test(c) && glowLayers(c.replace(/^[\w-]+-\[|\]$/g, "").replace(/_/g, " "), ctx.tokens));
|
|
3800
|
+
const tinted = plain.find((c) => /^shadow-/.test(c) && TW_COLOUR.test(c.slice("shadow-".length)));
|
|
3801
|
+
if (arb) hits.push({ line, message: `\`${arb}\`: a coloured glow` });
|
|
3802
|
+
else if (tinted && plain.some((c) => /^shadow-(?:lg|xl|2xl)$/.test(c))) hits.push({ line, message: `\`${tinted}\`: a large shadow in a saturated colour, which reads as a glow` });
|
|
3803
|
+
}
|
|
3804
|
+
return report(ctx, "glow-accent", file, ctx.raw, hits);
|
|
3805
|
+
}
|
|
3806
|
+
};
|
|
3807
|
+
var PAGE_SELECTOR = /(?:^|[\s,>])(?:html|body|:root|main)\b|--[\w-]*(?:bg|background|surface|page|canvas)[\w-]*/i;
|
|
3808
|
+
var TW_CREAM = /^bg-(?:amber|orange|yellow)-(?:50|100)$|^bg-stone-(?:100|200)$/;
|
|
3809
|
+
function cream(value, tokens) {
|
|
3810
|
+
const c = extractColorComponents(value.trim(), tokens);
|
|
3811
|
+
if (!c || c.alpha < 0.9) return false;
|
|
3812
|
+
const { r, g, b } = c.rgb;
|
|
3813
|
+
return Math.min(r, g, b) >= 215 && r >= g && g >= b && r - b >= 6;
|
|
3814
|
+
}
|
|
3815
|
+
var creamPalette = {
|
|
3816
|
+
name: "cream-palette",
|
|
3817
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3818
|
+
run(source, file, ctx) {
|
|
3819
|
+
const hits = [];
|
|
3820
|
+
for (const block of leafBlocks(source)) {
|
|
3821
|
+
for (const m of block.body.matchAll(/(?:^|[;{\s])((?:--[\w-]+|background(?:-color)?))\s*:\s*([^;}]+)/g)) {
|
|
3822
|
+
const prop = m[1];
|
|
3823
|
+
const onPage = prop.startsWith("--") ? PAGE_SELECTOR.test(prop) : PAGE_SELECTOR.test(block.selector);
|
|
3824
|
+
if (!onPage || !cream(m[2], ctx.tokens)) continue;
|
|
3825
|
+
hits.push({ line: lineOfOffset(block, m.index), message: `a cream page surface (${m[2].trim()}): the palette a model reaches for when asked to look tasteful` });
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
if (isMarkupHost(file)) {
|
|
3829
|
+
for (const m of ctx.raw.matchAll(/<(html|body|main)\b[^>]*\b(?:class|className)\s*=\s*["']([^"']*)["']/gi)) {
|
|
3830
|
+
const hit = m[2].split(/\s+/).map(bare).find((c) => TW_CREAM.test(c) || /^bg-\[#?[\w(),.%\s]+\]$/.test(c) && cream(c.slice(4, -1), ctx.tokens));
|
|
3831
|
+
if (hit) hits.push({ line: lineForOffset(buildLineIndex(ctx.raw), m.index), message: `\`${hit}\` on <${m[1]}>: a cream page surface by default` });
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
return report(ctx, "cream-palette", file, ctx.raw, hits);
|
|
3835
|
+
}
|
|
3836
|
+
};
|
|
3837
|
+
var pulsingDot = {
|
|
3838
|
+
name: "pulsing-dot",
|
|
3839
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3840
|
+
run(source, file, ctx) {
|
|
3841
|
+
const hits = [];
|
|
3842
|
+
const pulsingClasses = /* @__PURE__ */ new Set();
|
|
3843
|
+
for (const block of leafBlocks(source)) {
|
|
3844
|
+
const anim = /(?<![-\w])animation(?:-name)?\s*:\s*([^;}]+)/i.exec(block.body);
|
|
3845
|
+
if (!anim || !/\b(?:pulse|ping|blink|breathe|beacon|glow)[\w-]*/i.test(anim[1])) continue;
|
|
3846
|
+
if (!/infinite/i.test(anim[1]) && !/animation-iteration-count\s*:\s*infinite/i.test(block.body)) continue;
|
|
3847
|
+
const single = /^\s*\.([\w-]+)\s*$/.exec(block.selector);
|
|
3848
|
+
if (single) pulsingClasses.add(single[1]);
|
|
3849
|
+
if (!/border-radius\s*:\s*(?:50%|9999px|999px|100%)/i.test(block.body)) continue;
|
|
3850
|
+
hits.push({ line: lineOfOffset(block, anim.index), message: "a round element pulsing forever: an alarm on a state that is not changing" });
|
|
3851
|
+
}
|
|
3852
|
+
for (const { classes, line } of classLists(file, ctx.raw)) {
|
|
3853
|
+
const plain = classes.map(bare);
|
|
3854
|
+
const ping = plain.includes("animate-ping");
|
|
3855
|
+
const dot = plain.includes("rounded-full") && plain.some((c) => /^(?:size|w|h)-(?:1|1\.5|2|2\.5|3|3\.5)$/.test(c));
|
|
3856
|
+
const pulsing = plain.includes("animate-pulse") || plain.some((c) => pulsingClasses.has(c));
|
|
3857
|
+
if (ping || pulsing && dot) hits.push({ line, message: `a status dot that ${ping ? "pings" : "pulses"} forever: motion on a state that is not changing` });
|
|
3858
|
+
}
|
|
3859
|
+
return report(ctx, "pulsing-dot", file, ctx.raw, hits);
|
|
3860
|
+
}
|
|
3861
|
+
};
|
|
3862
|
+
var HEADLINE_SELECTOR = /\bh1\b|\bh2\b|hero|display|headline|title/i;
|
|
3863
|
+
var SERIF = /font-family\s*:\s*([^;}]+)/i;
|
|
3864
|
+
function serifFamily(value) {
|
|
3865
|
+
const first = value.split(",")[0].trim().toLowerCase();
|
|
3866
|
+
if (/sans|mono|system-ui|ui-sans|inter\b|helvetica|arial/.test(first)) return false;
|
|
3867
|
+
return /serif|georgia|garamond|playfair|fraunces|lora|merriweather|instrument serif|dm serif|cormorant|newsreader|times|baskerville|bodoni|caslon|libre caslon|source serif|pt serif|crimson/.test(value.toLowerCase());
|
|
3868
|
+
}
|
|
3869
|
+
var italicSerifDisplay = {
|
|
3870
|
+
name: "italic-serif-display",
|
|
3871
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3872
|
+
run(source, file, ctx) {
|
|
3873
|
+
const hits = [];
|
|
3874
|
+
const serifClasses = /* @__PURE__ */ new Set();
|
|
3875
|
+
for (const block of leafBlocks(source)) {
|
|
3876
|
+
const family = SERIF.exec(block.body);
|
|
3877
|
+
const single = /^\s*\.([\w-]+)\s*$/.exec(block.selector);
|
|
3878
|
+
if (family && single && serifFamily(family[1])) serifClasses.add(single[1]);
|
|
3879
|
+
}
|
|
3880
|
+
for (const block of leafBlocks(source)) {
|
|
3881
|
+
if (!HEADLINE_SELECTOR.test(block.selector) && !/\bh1\b.*\b(?:em|i)\b/.test(block.selector)) continue;
|
|
3882
|
+
const italic = /font-style\s*:\s*italic/i.exec(block.body);
|
|
3883
|
+
const family = SERIF.exec(block.body);
|
|
3884
|
+
if (italic && family && serifFamily(family[1])) {
|
|
3885
|
+
hits.push({ line: lineOfOffset(block, italic.index), message: `${block.selector.trim()} is an italic serif headline: the shortcut to an editorial look` });
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
if (isMarkupHost(file)) {
|
|
3889
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3890
|
+
for (const m of ctx.raw.matchAll(/<(h[12])\b([^>]*)>([\s\S]{0,400}?)<\/\1\s*>/gi)) {
|
|
3891
|
+
const own = (/\b(?:class|className)\s*=\s*["']([^"']*)["']/.exec(m[2])?.[1] ?? "").split(/\s+/).map(bare);
|
|
3892
|
+
const serif = own.includes("font-serif") || own.some((c) => serifClasses.has(c) || /^font-\[.*serif/i.test(c) && !/sans/i.test(c));
|
|
3893
|
+
const italicSelf = own.includes("italic");
|
|
3894
|
+
const italicChild = /<(em|i)\b(?![^>]*\bnot-italic\b)/i.test(m[3]) || /\bclass(?:Name)?\s*=\s*["'][^"']*\bitalic\b/.test(m[3]);
|
|
3895
|
+
const childSerif = /\bclass(?:Name)?\s*=\s*["'][^"']*\bfont-serif\b/.test(m[3]);
|
|
3896
|
+
if (serif && (italicSelf || italicChild) || italicChild && childSerif) {
|
|
3897
|
+
hits.push({ line: lineForOffset(starts, m.index), message: `an italic serif <${m[1].toLowerCase()}>: the shortcut to an editorial look` });
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
return report(ctx, "italic-serif-display", file, ctx.raw, hits);
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
var BEFORE_HEADING = /<(p|span|div|small|a)\b([^>]*)>((?:[^<]|<\/?(?:span|svg|path|circle|i|b|strong|em|img)\b[^>]*>){1,400}?)<\/\1\s*>\s*(?:<[^>]*>\s*){0,2}<(h[1-3])\b/gi;
|
|
3905
|
+
var LABEL_CLASS = /\b(?:eyebrow|kicker|overline|pretitle|pre-title|tagline|section-label|superscript-label)\b/i;
|
|
3906
|
+
function labelKind(attrs) {
|
|
3907
|
+
const cls = /\b(?:class|className)\s*=\s*["']([^"']*)["']/.exec(attrs)?.[1] ?? "";
|
|
3908
|
+
const plain = cls.split(/\s+/).map(bare);
|
|
3909
|
+
const pill = plain.includes("rounded-full") && plain.some((c) => /^(?:border|bg-|ring)/.test(c)) && plain.some((c) => /^(?:px|py|p)-/.test(c));
|
|
3910
|
+
if (pill) return "chip";
|
|
3911
|
+
const caps = plain.includes("uppercase") && plain.some((c) => /^tracking-/.test(c) && !/tracking-(?:tight|tighter|normal)/.test(c));
|
|
3912
|
+
if (caps || LABEL_CLASS.test(cls)) return "kicker";
|
|
3913
|
+
return void 0;
|
|
3914
|
+
}
|
|
3915
|
+
function labelHits(raw) {
|
|
3916
|
+
const out = [];
|
|
3917
|
+
for (const m of raw.matchAll(BEFORE_HEADING)) {
|
|
3918
|
+
const kind = labelKind(m[2]);
|
|
3919
|
+
const text = m[3].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
3920
|
+
if (kind && text && text.length <= 60) out.push({ kind, heading: m[4].toLowerCase(), offset: m.index, text });
|
|
3921
|
+
}
|
|
3922
|
+
return out;
|
|
3923
|
+
}
|
|
3924
|
+
var headingKicker = {
|
|
3925
|
+
name: "heading-kicker",
|
|
3926
|
+
appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
|
|
3927
|
+
run(_source, file, ctx) {
|
|
3928
|
+
const kickers = labelHits(ctx.raw).filter((h) => h.kind === "kicker");
|
|
3929
|
+
if (kickers.length < 2) return [];
|
|
3930
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3931
|
+
return report(ctx, "heading-kicker", file, ctx.raw, kickers.map((k) => ({
|
|
3932
|
+
line: lineForOffset(starts, k.offset),
|
|
3933
|
+
message: `"${k.text}" as a small caps line over an <${k.heading}>, one of ${kickers.length} in this file: a kicker over every heading`
|
|
3934
|
+
})));
|
|
3935
|
+
}
|
|
3936
|
+
};
|
|
3937
|
+
var eyebrowChip = {
|
|
3938
|
+
name: "eyebrow-chip",
|
|
3939
|
+
appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
|
|
3940
|
+
run(_source, file, ctx) {
|
|
3941
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3942
|
+
return report(ctx, "eyebrow-chip", file, ctx.raw, labelHits(ctx.raw).filter((h) => h.kind === "chip" && h.heading === "h1").map((h) => ({ line: lineForOffset(starts, h.offset), message: `"${h.text}" in a pill over the headline: the shape of a control, pressed and doing nothing` })));
|
|
3943
|
+
}
|
|
3944
|
+
};
|
|
3945
|
+
var NUMBER_LABEL = /<(span|p|div|small)\b[^>]*>\s*(?:\(|№\s*)?0[1-9](?:\s*[./)]|\s*\/\s*\d{2})?\s*<\/\1\s*>\s*(?:<[^>]*>\s*){0,2}<h[2-4]\b|<h[2-4]\b[^>]*>\s*(?:<[^>]+>\s*)?0[1-9]\s*(?:<\/[^>]+>)?\s*[.·—–/|:]?\s*(?:<[^>]+>)?\s*[A-Za-z]/gi;
|
|
3946
|
+
var numberedSectionLabels = {
|
|
3947
|
+
name: "numbered-section-labels",
|
|
3948
|
+
appliesTo: (file) => isReaderText(file) && isMarkupHost(file),
|
|
3949
|
+
run(_source, file, ctx) {
|
|
3950
|
+
const raw = ctx.raw.replace(/<ol\b[\s\S]*?<\/ol\s*>/gi, (m) => m.replace(/[^\n]/g, " "));
|
|
3951
|
+
const found = [...raw.matchAll(NUMBER_LABEL)];
|
|
3952
|
+
if (found.length < 2) return [];
|
|
3953
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3954
|
+
return report(ctx, "numbered-section-labels", file, ctx.raw, found.map((m) => ({
|
|
3955
|
+
line: lineForOffset(starts, m.index),
|
|
3956
|
+
message: `a "0N" label on a section heading, one of ${found.length} here: a number promises an order`
|
|
3957
|
+
})));
|
|
3958
|
+
}
|
|
3959
|
+
};
|
|
3960
|
+
var generatedLookDetectors = [
|
|
3961
|
+
sideTabBorder,
|
|
3962
|
+
roundedAccentBorder,
|
|
3963
|
+
decorativeGridBackground,
|
|
3964
|
+
glowAccent,
|
|
3965
|
+
creamPalette,
|
|
3966
|
+
pulsingDot,
|
|
3967
|
+
italicSerifDisplay,
|
|
3968
|
+
headingKicker,
|
|
3969
|
+
eyebrowChip,
|
|
3970
|
+
numberedSectionLabels
|
|
3971
|
+
];
|
|
3972
|
+
|
|
3638
3973
|
// src/check/registry.ts
|
|
3639
3974
|
var DETECTORS = [
|
|
3640
3975
|
gradientText,
|
|
@@ -3656,7 +3991,8 @@ var DETECTORS = [
|
|
|
3656
3991
|
emDash,
|
|
3657
3992
|
semanticElement,
|
|
3658
3993
|
metadata,
|
|
3659
|
-
interfaceSafety
|
|
3994
|
+
interfaceSafety,
|
|
3995
|
+
...generatedLookDetectors
|
|
3660
3996
|
];
|
|
3661
3997
|
function getDetector(name) {
|
|
3662
3998
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -3689,7 +4025,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
3689
4025
|
}
|
|
3690
4026
|
|
|
3691
4027
|
// src/check/tokens.ts
|
|
3692
|
-
import { existsSync as
|
|
4028
|
+
import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
|
|
3693
4029
|
import { join as join19 } from "path";
|
|
3694
4030
|
function rootDeclarations(source) {
|
|
3695
4031
|
const out = {};
|
|
@@ -3706,7 +4042,7 @@ function rootDeclarations(source) {
|
|
|
3706
4042
|
function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
3707
4043
|
const jig = {};
|
|
3708
4044
|
const tokensDir = join19(projectRoot, ".jig", "tokens");
|
|
3709
|
-
if (
|
|
4045
|
+
if (existsSync11(tokensDir)) {
|
|
3710
4046
|
for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
3711
4047
|
Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
|
|
3712
4048
|
}
|
|
@@ -3737,7 +4073,7 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
|
3737
4073
|
}
|
|
3738
4074
|
|
|
3739
4075
|
// src/check/exempt.ts
|
|
3740
|
-
import { existsSync as
|
|
4076
|
+
import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
|
|
3741
4077
|
import { join as join20 } from "path";
|
|
3742
4078
|
function globToRegExp(glob) {
|
|
3743
4079
|
let out = "";
|
|
@@ -3758,7 +4094,7 @@ function globToRegExp(glob) {
|
|
|
3758
4094
|
}
|
|
3759
4095
|
function readExemptions(projectRoot) {
|
|
3760
4096
|
const configPath = join20(projectRoot, "jig.config.json");
|
|
3761
|
-
if (!
|
|
4097
|
+
if (!existsSync12(configPath)) return [];
|
|
3762
4098
|
let declared;
|
|
3763
4099
|
try {
|
|
3764
4100
|
declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
|
|
@@ -3874,7 +4210,7 @@ function hasMenuToggle(raw) {
|
|
|
3874
4210
|
}
|
|
3875
4211
|
|
|
3876
4212
|
// src/check/mode-wiring.ts
|
|
3877
|
-
import { existsSync as
|
|
4213
|
+
import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
|
|
3878
4214
|
import { dirname as dirname6, join as join22 } from "path";
|
|
3879
4215
|
var MODES = ["editorial", "product", "operator"];
|
|
3880
4216
|
function configProblems(config) {
|
|
@@ -3898,7 +4234,7 @@ function configProblems(config) {
|
|
|
3898
4234
|
function modeWiringProblems(projectRoot) {
|
|
3899
4235
|
let config;
|
|
3900
4236
|
const path = join22(projectRoot, "jig.config.json");
|
|
3901
|
-
if (!
|
|
4237
|
+
if (!existsSync13(path)) return [];
|
|
3902
4238
|
try {
|
|
3903
4239
|
config = JSON.parse(readFileSync17(path, "utf8"));
|
|
3904
4240
|
} catch {
|
|
@@ -3913,10 +4249,10 @@ function modeWiringProblems(projectRoot) {
|
|
|
3913
4249
|
const rel = (f) => dir === "." ? f : `${dir}/${f}`;
|
|
3914
4250
|
const multi = modes.length > 1;
|
|
3915
4251
|
const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
|
|
3916
|
-
const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) =>
|
|
4252
|
+
const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync13(join22(projectRoot, rel(f))));
|
|
3917
4253
|
if (!anyBarrel) return [];
|
|
3918
4254
|
const problems = [];
|
|
3919
|
-
if (multi &&
|
|
4255
|
+
if (multi && existsSync13(join22(projectRoot, rel("theme.css")))) {
|
|
3920
4256
|
problems.push({
|
|
3921
4257
|
mode: modes[0],
|
|
3922
4258
|
barrel: rel("theme.css"),
|
|
@@ -3940,7 +4276,7 @@ function modeWiringProblems(projectRoot) {
|
|
|
3940
4276
|
barrel,
|
|
3941
4277
|
message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
|
|
3942
4278
|
});
|
|
3943
|
-
} else if (!
|
|
4279
|
+
} else if (!existsSync13(join22(projectRoot, rel(modeFile)))) {
|
|
3944
4280
|
problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
|
|
3945
4281
|
}
|
|
3946
4282
|
});
|
|
@@ -3948,7 +4284,7 @@ function modeWiringProblems(projectRoot) {
|
|
|
3948
4284
|
}
|
|
3949
4285
|
|
|
3950
4286
|
// src/check/token-audit.ts
|
|
3951
|
-
import { existsSync as
|
|
4287
|
+
import { existsSync as existsSync14, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
|
|
3952
4288
|
import { join as join23 } from "path";
|
|
3953
4289
|
var TEXT_FLOOR = 4.5;
|
|
3954
4290
|
var UI_FLOOR = 3;
|
|
@@ -4011,7 +4347,7 @@ function tokenFiles(projectRoot) {
|
|
|
4011
4347
|
}
|
|
4012
4348
|
};
|
|
4013
4349
|
const statePath = join23(projectRoot, ".jig", "state.json");
|
|
4014
|
-
if (
|
|
4350
|
+
if (existsSync14(statePath)) {
|
|
4015
4351
|
try {
|
|
4016
4352
|
const state = JSON.parse(readFileSync18(statePath, "utf8"));
|
|
4017
4353
|
const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
|
|
@@ -4021,7 +4357,7 @@ function tokenFiles(projectRoot) {
|
|
|
4021
4357
|
}
|
|
4022
4358
|
}
|
|
4023
4359
|
const legacy = join23(projectRoot, ".jig", "tokens");
|
|
4024
|
-
if (!
|
|
4360
|
+
if (!existsSync14(legacy)) return [];
|
|
4025
4361
|
return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync18(join23(legacy, f), "utf8") }));
|
|
4026
4362
|
}
|
|
4027
4363
|
function auditTokenLayer(projectRoot) {
|
|
@@ -4071,9 +4407,9 @@ function auditTokenLayer(projectRoot) {
|
|
|
4071
4407
|
for (const { token, min, why } of NUMERIC_FLOORS) {
|
|
4072
4408
|
const decl = scope[token];
|
|
4073
4409
|
if (!decl) continue;
|
|
4074
|
-
const
|
|
4075
|
-
if (!
|
|
4076
|
-
const value = Number(
|
|
4410
|
+
const px2 = /^([\d.]+)px$/.exec(decl.value.trim());
|
|
4411
|
+
if (!px2) continue;
|
|
4412
|
+
const value = Number(px2[1]);
|
|
4077
4413
|
if (value >= min) continue;
|
|
4078
4414
|
problems.push({
|
|
4079
4415
|
token,
|
|
@@ -4093,7 +4429,7 @@ function auditTokenLayer(projectRoot) {
|
|
|
4093
4429
|
var LEGACY_INDEX_REL = join24(".jig", "rules.index.json");
|
|
4094
4430
|
function resolveIndexPath(projectRoot) {
|
|
4095
4431
|
const legacy = join24(projectRoot, LEGACY_INDEX_REL);
|
|
4096
|
-
if (
|
|
4432
|
+
if (existsSync15(legacy)) return legacy;
|
|
4097
4433
|
return join24(assetRoot(), "rules.index.json");
|
|
4098
4434
|
}
|
|
4099
4435
|
function resolveMode(projectRoot) {
|
|
@@ -4203,7 +4539,7 @@ function check(opts) {
|
|
|
4203
4539
|
}
|
|
4204
4540
|
})
|
|
4205
4541
|
);
|
|
4206
|
-
const
|
|
4542
|
+
const report2 = formatReport(findings, {
|
|
4207
4543
|
totalRules: index.length,
|
|
4208
4544
|
// From the CLI's own bundle, like `explain` — the spec count describes the
|
|
4209
4545
|
// system, not whatever a project happens to have vendored. A legacy project
|
|
@@ -4221,11 +4557,11 @@ function check(opts) {
|
|
|
4221
4557
|
modeUnwired: modeWiringProblems(opts.projectRoot),
|
|
4222
4558
|
waived
|
|
4223
4559
|
});
|
|
4224
|
-
return { findings, report, hasError, waived };
|
|
4560
|
+
return { findings, report: report2, hasError, waived };
|
|
4225
4561
|
}
|
|
4226
4562
|
|
|
4227
4563
|
// src/commands/init.ts
|
|
4228
|
-
import { existsSync as
|
|
4564
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
4229
4565
|
import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
|
|
4230
4566
|
|
|
4231
4567
|
// src/init/detect.ts
|
|
@@ -4749,7 +5085,7 @@ function parseSurfaces(answer, fallback) {
|
|
|
4749
5085
|
return parsed.length > 0 ? parsed : fallback;
|
|
4750
5086
|
}
|
|
4751
5087
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
4752
|
-
const existsOnDisk =
|
|
5088
|
+
const existsOnDisk = existsSync16(absPath);
|
|
4753
5089
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
4754
5090
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
4755
5091
|
return { existsOnDisk, tracked, modified };
|
|
@@ -4760,11 +5096,11 @@ function isValidSurfaceArray(v) {
|
|
|
4760
5096
|
);
|
|
4761
5097
|
}
|
|
4762
5098
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
4763
|
-
if (!
|
|
5099
|
+
if (!existsSync16(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
4764
5100
|
try {
|
|
4765
5101
|
const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
|
|
4766
5102
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
4767
|
-
const brandResolves = brandCandidate ?
|
|
5103
|
+
const brandResolves = brandCandidate ? existsSync16(join28(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
4768
5104
|
return {
|
|
4769
5105
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
4770
5106
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -4774,7 +5110,7 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
|
|
|
4774
5110
|
}
|
|
4775
5111
|
}
|
|
4776
5112
|
function configuredBrandPath(projectRoot, configAbsPath) {
|
|
4777
|
-
if (!
|
|
5113
|
+
if (!existsSync16(configAbsPath)) return { path: null };
|
|
4778
5114
|
let declared;
|
|
4779
5115
|
try {
|
|
4780
5116
|
declared = JSON.parse(readFileSync23(configAbsPath, "utf8")).brand;
|
|
@@ -4805,7 +5141,7 @@ function defaultTokenDir(detection) {
|
|
|
4805
5141
|
return ["jig"];
|
|
4806
5142
|
}
|
|
4807
5143
|
function readConfiguredSurfaces(configAbsPath) {
|
|
4808
|
-
if (!
|
|
5144
|
+
if (!existsSync16(configAbsPath)) return null;
|
|
4809
5145
|
try {
|
|
4810
5146
|
const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
|
|
4811
5147
|
return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
|
|
@@ -4818,7 +5154,7 @@ function isCssModule(path) {
|
|
|
4818
5154
|
}
|
|
4819
5155
|
function legacyBrandFile(projectRoot) {
|
|
4820
5156
|
const dir = join28(projectRoot, ".jig", "tokens");
|
|
4821
|
-
if (!
|
|
5157
|
+
if (!existsSync16(dir)) return void 0;
|
|
4822
5158
|
const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
|
|
4823
5159
|
return found ? relKey(".jig", "tokens", found) : void 0;
|
|
4824
5160
|
}
|
|
@@ -4849,7 +5185,7 @@ var LEGACY_PAIR_RE = new RegExp(
|
|
|
4849
5185
|
`@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
|
|
4850
5186
|
);
|
|
4851
5187
|
function wireBarrel(absPath, barrelImport) {
|
|
4852
|
-
const content =
|
|
5188
|
+
const content = existsSync16(absPath) ? readFileSync23(absPath, "utf8") : "";
|
|
4853
5189
|
const line = `@import "${barrelImport}";`;
|
|
4854
5190
|
if (content.includes(line)) return { status: "already-present" };
|
|
4855
5191
|
const legacy = LEGACY_PAIR_RE.exec(content);
|
|
@@ -4877,10 +5213,10 @@ async function init(opts) {
|
|
|
4877
5213
|
"'jig init' asks questions and stdin is not a terminal, so it cannot. Re-run with --yes to accept the derived defaults, or run it in a terminal. A jig.config.json does not replace --yes, because this check runs before it is read. With both, init takes the mode from the config instead of deriving it."
|
|
4878
5214
|
);
|
|
4879
5215
|
}
|
|
4880
|
-
const migrateLegacy = async (
|
|
4881
|
-
if (!
|
|
4882
|
-
for (const line of describe(
|
|
4883
|
-
const removable = removableLegacyFiles(
|
|
5216
|
+
const migrateLegacy = async (report2, describe) => {
|
|
5217
|
+
if (!report2.present) return;
|
|
5218
|
+
for (const line of describe(report2)) log(line);
|
|
5219
|
+
const removable = removableLegacyFiles(report2);
|
|
4884
5220
|
if (removable.length === 0) return;
|
|
4885
5221
|
if (opts.yes) {
|
|
4886
5222
|
log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
|
|
@@ -5055,7 +5391,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5055
5391
|
existing = {};
|
|
5056
5392
|
}
|
|
5057
5393
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
5058
|
-
const brandStillResolves = existingBrand ?
|
|
5394
|
+
const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
5059
5395
|
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
5060
5396
|
const merged = {
|
|
5061
5397
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
@@ -5112,7 +5448,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5112
5448
|
if (multiMode) {
|
|
5113
5449
|
const rel = relKey(...tokensRelDir, "theme.css");
|
|
5114
5450
|
const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5115
|
-
if (
|
|
5451
|
+
if (existsSync16(abs)) {
|
|
5116
5452
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5117
5453
|
if (state.tracked && !state.modified) {
|
|
5118
5454
|
rmSync2(abs);
|
|
@@ -5184,7 +5520,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5184
5520
|
if (relocateTo && currentBrand) {
|
|
5185
5521
|
const oldDir = currentBrand.split("/").slice(0, -1);
|
|
5186
5522
|
const oldAbsDir = join28(opts.projectRoot, ...oldDir);
|
|
5187
|
-
if (
|
|
5523
|
+
if (existsSync16(oldAbsDir)) {
|
|
5188
5524
|
for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
|
|
5189
5525
|
const rel = relKey(...oldDir, name);
|
|
5190
5526
|
const abs = join28(oldAbsDir, name);
|
|
@@ -5201,14 +5537,14 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5201
5537
|
const wireTarget = findWireTarget(detection);
|
|
5202
5538
|
let wiring;
|
|
5203
5539
|
if (multiMode) {
|
|
5204
|
-
const
|
|
5540
|
+
const bare2 = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5205
5541
|
for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
|
|
5206
5542
|
const abs = join28(opts.projectRoot, file);
|
|
5207
5543
|
try {
|
|
5208
5544
|
const before = readFileSync23(abs, "utf8");
|
|
5209
5545
|
const after = before.split("\n").filter((l) => {
|
|
5210
5546
|
const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
|
|
5211
|
-
return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) ===
|
|
5547
|
+
return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
|
|
5212
5548
|
}).join("\n");
|
|
5213
5549
|
if (after !== before) {
|
|
5214
5550
|
writeFileSync5(abs, after, "utf8");
|
|
@@ -5237,7 +5573,7 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
|
|
|
5237
5573
|
if (relocateTo && currentBrand) {
|
|
5238
5574
|
const abs = join28(opts.projectRoot, wireTarget);
|
|
5239
5575
|
const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
|
|
5240
|
-
const before =
|
|
5576
|
+
const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
|
|
5241
5577
|
const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
|
|
5242
5578
|
if (after !== before) writeFileSync5(abs, after, "utf8");
|
|
5243
5579
|
}
|
|
@@ -5314,8 +5650,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5314
5650
|
}
|
|
5315
5651
|
|
|
5316
5652
|
// src/commands/verdicts.ts
|
|
5317
|
-
import { existsSync as
|
|
5318
|
-
import { join as
|
|
5653
|
+
import { existsSync as existsSync23, readFileSync as readFileSync29 } from "fs";
|
|
5654
|
+
import { join as join36, resolve as resolve5 } from "path";
|
|
5319
5655
|
|
|
5320
5656
|
// src/rules/citations.ts
|
|
5321
5657
|
import { join as join29 } from "path";
|
|
@@ -5329,13 +5665,13 @@ function citableIds(packageRoot2) {
|
|
|
5329
5665
|
}
|
|
5330
5666
|
|
|
5331
5667
|
// src/probe/check.ts
|
|
5332
|
-
import { existsSync as
|
|
5333
|
-
import { join as
|
|
5668
|
+
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync26 } from "fs";
|
|
5669
|
+
import { join as join33 } from "path";
|
|
5334
5670
|
|
|
5335
5671
|
// src/probe/save.ts
|
|
5336
|
-
import { existsSync as
|
|
5672
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync25, writeFileSync as writeFileSync6 } from "fs";
|
|
5337
5673
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5338
|
-
import { join as
|
|
5674
|
+
import { join as join32, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5339
5675
|
|
|
5340
5676
|
// src/probe/script.ts
|
|
5341
5677
|
var PROBE_VERSION = 5;
|
|
@@ -5348,6 +5684,14 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5348
5684
|
const defaultFont = getComputedStyle(probe).fontFamily;
|
|
5349
5685
|
probe.remove();
|
|
5350
5686
|
const text = document.body.innerText || '';
|
|
5687
|
+
// I-118: a verbatim quotation keeps its own punctuation. Text marked as a
|
|
5688
|
+
// quotation is the source's words, so it is taken out before the dash scan;
|
|
5689
|
+
// the page's own copy around it still counts.
|
|
5690
|
+
let ownText = text;
|
|
5691
|
+
for (const q of document.querySelectorAll('blockquote, q')) {
|
|
5692
|
+
const quoted = (q.innerText || '').trim();
|
|
5693
|
+
if (quoted) ownText = ownText.split(quoted).join('\\n');
|
|
5694
|
+
}
|
|
5351
5695
|
const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
|
|
5352
5696
|
const unresolved = new Set();
|
|
5353
5697
|
for (const sheet of document.styleSheets) {
|
|
@@ -5458,7 +5802,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5458
5802
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
5459
5803
|
unresolvedTokens: [...unresolved],
|
|
5460
5804
|
junkText: junk,
|
|
5461
|
-
emDashes: [...new Set((
|
|
5805
|
+
emDashes: [...new Set((ownText.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5462
5806
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5463
5807
|
navLinksVisible: navAtRest,
|
|
5464
5808
|
head: {
|
|
@@ -5480,7 +5824,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5480
5824
|
|
|
5481
5825
|
// src/probe/browser.ts
|
|
5482
5826
|
import { spawn } from "child_process";
|
|
5483
|
-
import { existsSync as
|
|
5827
|
+
import { existsSync as existsSync17, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
|
|
5484
5828
|
import { homedir, tmpdir } from "os";
|
|
5485
5829
|
import { join as join30 } from "path";
|
|
5486
5830
|
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
@@ -5518,7 +5862,7 @@ function cachedBrowsers() {
|
|
|
5518
5862
|
["chrome-win64", "chrome.exe"]
|
|
5519
5863
|
]) {
|
|
5520
5864
|
const candidate = join30(root, entry, ...rel);
|
|
5521
|
-
if (
|
|
5865
|
+
if (existsSync17(candidate)) found.push(candidate);
|
|
5522
5866
|
}
|
|
5523
5867
|
}
|
|
5524
5868
|
}
|
|
@@ -5527,13 +5871,13 @@ function cachedBrowsers() {
|
|
|
5527
5871
|
function findChrome() {
|
|
5528
5872
|
for (const key of CHROME_ENV) {
|
|
5529
5873
|
const value = process.env[key];
|
|
5530
|
-
if (value &&
|
|
5874
|
+
if (value && existsSync17(value)) return value;
|
|
5531
5875
|
}
|
|
5532
5876
|
for (const command of CHROME_COMMANDS) {
|
|
5533
5877
|
const resolved = which(command);
|
|
5534
5878
|
if (resolved) return resolved;
|
|
5535
5879
|
}
|
|
5536
|
-
for (const app of CHROME_APPS) if (
|
|
5880
|
+
for (const app of CHROME_APPS) if (existsSync17(app)) return app;
|
|
5537
5881
|
return cachedBrowsers()[0];
|
|
5538
5882
|
}
|
|
5539
5883
|
function which(command) {
|
|
@@ -5541,7 +5885,7 @@ function which(command) {
|
|
|
5541
5885
|
for (const dir of dirs) {
|
|
5542
5886
|
if (!dir) continue;
|
|
5543
5887
|
const candidate = join30(dir, command);
|
|
5544
|
-
if (
|
|
5888
|
+
if (existsSync17(candidate)) return candidate;
|
|
5545
5889
|
}
|
|
5546
5890
|
return void 0;
|
|
5547
5891
|
}
|
|
@@ -5586,8 +5930,13 @@ async function runProbe(opts) {
|
|
|
5586
5930
|
});
|
|
5587
5931
|
return await evaluate(endpoint, opts.url, opts.width, opts.height ?? 900, timeoutMs);
|
|
5588
5932
|
} finally {
|
|
5933
|
+
const exited = child.exitCode !== null ? Promise.resolve() : new Promise((done) => child.once("exit", () => done()));
|
|
5589
5934
|
child.kill();
|
|
5590
|
-
|
|
5935
|
+
await Promise.race([exited, new Promise((done) => setTimeout(done, 5e3))]);
|
|
5936
|
+
try {
|
|
5937
|
+
rmSync3(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
5938
|
+
} catch {
|
|
5939
|
+
}
|
|
5591
5940
|
}
|
|
5592
5941
|
}
|
|
5593
5942
|
async function evaluate(endpoint, url, width, height, timeoutMs) {
|
|
@@ -5646,6 +5995,59 @@ async function evaluate(endpoint, url, width, height, timeoutMs) {
|
|
|
5646
5995
|
}
|
|
5647
5996
|
}
|
|
5648
5997
|
|
|
5998
|
+
// src/probe/serve.ts
|
|
5999
|
+
import { createServer } from "http";
|
|
6000
|
+
import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync2 } from "fs";
|
|
6001
|
+
import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
|
|
6002
|
+
var TYPES = {
|
|
6003
|
+
".html": "text/html; charset=utf-8",
|
|
6004
|
+
".htm": "text/html; charset=utf-8",
|
|
6005
|
+
".css": "text/css; charset=utf-8",
|
|
6006
|
+
".js": "text/javascript; charset=utf-8",
|
|
6007
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
6008
|
+
".json": "application/json",
|
|
6009
|
+
".txt": "text/plain; charset=utf-8",
|
|
6010
|
+
".xml": "application/xml",
|
|
6011
|
+
".svg": "image/svg+xml",
|
|
6012
|
+
".png": "image/png",
|
|
6013
|
+
".jpg": "image/jpeg",
|
|
6014
|
+
".jpeg": "image/jpeg",
|
|
6015
|
+
".gif": "image/gif",
|
|
6016
|
+
".webp": "image/webp",
|
|
6017
|
+
".avif": "image/avif",
|
|
6018
|
+
".ico": "image/x-icon",
|
|
6019
|
+
".woff": "font/woff",
|
|
6020
|
+
".woff2": "font/woff2",
|
|
6021
|
+
".ttf": "font/ttf",
|
|
6022
|
+
".otf": "font/otf"
|
|
6023
|
+
};
|
|
6024
|
+
async function serveDirectory(dir) {
|
|
6025
|
+
const root = resolve3(dir);
|
|
6026
|
+
const server = createServer((req, res) => {
|
|
6027
|
+
let path = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
|
|
6028
|
+
let file = resolve3(join31(root, path));
|
|
6029
|
+
if (file !== root && !file.startsWith(root + sep4)) {
|
|
6030
|
+
res.writeHead(403).end();
|
|
6031
|
+
return;
|
|
6032
|
+
}
|
|
6033
|
+
if (existsSync18(file) && statSync2(file).isDirectory()) file = join31(file, "index.html");
|
|
6034
|
+
else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
|
|
6035
|
+
if (!existsSync18(file)) {
|
|
6036
|
+
res.writeHead(404).end();
|
|
6037
|
+
return;
|
|
6038
|
+
}
|
|
6039
|
+
res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream" });
|
|
6040
|
+
res.end(readFileSync24(file));
|
|
6041
|
+
});
|
|
6042
|
+
await new Promise((done) => server.listen(0, "127.0.0.1", done));
|
|
6043
|
+
const address = server.address();
|
|
6044
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
6045
|
+
return {
|
|
6046
|
+
origin: `http://127.0.0.1:${port}`,
|
|
6047
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
6048
|
+
};
|
|
6049
|
+
}
|
|
6050
|
+
|
|
5649
6051
|
// src/probe/save.ts
|
|
5650
6052
|
function saveProbe(opts) {
|
|
5651
6053
|
let probe;
|
|
@@ -5660,19 +6062,25 @@ function saveProbe(opts) {
|
|
|
5660
6062
|
const width = typeof probe.width === "number" ? probe.width : NaN;
|
|
5661
6063
|
if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
|
|
5662
6064
|
const url = typeof probe.url === "string" ? probe.url : "";
|
|
5663
|
-
const page = pageFile(opts.projectRoot, url);
|
|
6065
|
+
const page = opts.page ? projectFile(opts.projectRoot, opts.page) : pageFile(opts.projectRoot, url);
|
|
5664
6066
|
if (!page) {
|
|
5665
|
-
throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built
|
|
6067
|
+
throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built, as a file:// URL under the project, or let \`jig probe --run <page> --serve <dir>\` serve it.`);
|
|
5666
6068
|
}
|
|
5667
6069
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
5668
|
-
probe.
|
|
6070
|
+
if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
|
|
6071
|
+
probe.pageChecksum = checksum(readFileSync25(page, "utf8"));
|
|
5669
6072
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5670
|
-
const dir =
|
|
6073
|
+
const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5671
6074
|
mkdirSync7(dir, { recursive: true });
|
|
5672
|
-
const path =
|
|
6075
|
+
const path = join32(dir, `probe-${width}.json`);
|
|
5673
6076
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5674
6077
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5675
6078
|
}
|
|
6079
|
+
function projectFile(projectRoot, path) {
|
|
6080
|
+
const abs = resolve4(projectRoot, path);
|
|
6081
|
+
if (abs !== resolve4(projectRoot) && !abs.startsWith(resolve4(projectRoot) + sep5)) return void 0;
|
|
6082
|
+
return existsSync19(abs) ? abs : void 0;
|
|
6083
|
+
}
|
|
5676
6084
|
function pageFile(projectRoot, url) {
|
|
5677
6085
|
if (!url.startsWith("file://")) return void 0;
|
|
5678
6086
|
let path;
|
|
@@ -5681,34 +6089,48 @@ function pageFile(projectRoot, url) {
|
|
|
5681
6089
|
} catch {
|
|
5682
6090
|
return void 0;
|
|
5683
6091
|
}
|
|
5684
|
-
const abs =
|
|
5685
|
-
if (!abs.startsWith(
|
|
5686
|
-
return
|
|
6092
|
+
const abs = resolve4(path);
|
|
6093
|
+
if (!abs.startsWith(resolve4(projectRoot))) return void 0;
|
|
6094
|
+
return existsSync19(abs) ? abs : void 0;
|
|
5687
6095
|
}
|
|
5688
6096
|
var PROBE_WIDTHS = [360, 768, 1280, 1600];
|
|
5689
6097
|
async function runAndSaveProbes(opts) {
|
|
5690
|
-
const abs =
|
|
5691
|
-
if (!
|
|
5692
|
-
const url = /^https?:/i.test(opts.page) ? opts.page : `file://${abs}`;
|
|
6098
|
+
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6099
|
+
if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
5693
6100
|
const saved = [];
|
|
5694
|
-
|
|
5695
|
-
const
|
|
5696
|
-
|
|
5697
|
-
|
|
6101
|
+
await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
|
|
6102
|
+
for (const width of opts.widths ?? PROBE_WIDTHS) {
|
|
6103
|
+
const json = await runProbe({ url, width });
|
|
6104
|
+
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
|
|
6105
|
+
}
|
|
6106
|
+
});
|
|
5698
6107
|
return saved;
|
|
5699
6108
|
}
|
|
6109
|
+
async function withPageUrl(projectRoot, abs, serve, use) {
|
|
6110
|
+
if (!serve) return use(`file://${abs}`);
|
|
6111
|
+
const root = resolve4(projectRoot, serve);
|
|
6112
|
+
if (!existsSync19(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
|
|
6113
|
+
if (!abs.startsWith(root + sep5)) throw new Error(`The page is not inside ${serve}, so serving ${serve} cannot show it.`);
|
|
6114
|
+
const server = await serveDirectory(root);
|
|
6115
|
+
try {
|
|
6116
|
+
const path = relative3(root, abs).split(sep5).join("/");
|
|
6117
|
+
await use(`${server.origin}/${path}`);
|
|
6118
|
+
} finally {
|
|
6119
|
+
await server.close();
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
5700
6122
|
async function ensureProbes(opts) {
|
|
5701
|
-
const dir =
|
|
5702
|
-
const abs =
|
|
6123
|
+
const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6124
|
+
const abs = resolve4(opts.projectRoot, opts.page);
|
|
5703
6125
|
let current;
|
|
5704
6126
|
try {
|
|
5705
|
-
current = checksum(
|
|
6127
|
+
current = checksum(readFileSync25(abs, "utf8"));
|
|
5706
6128
|
} catch {
|
|
5707
6129
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
5708
6130
|
}
|
|
5709
6131
|
const missing = PROBE_WIDTHS.filter((width) => {
|
|
5710
6132
|
try {
|
|
5711
|
-
const probe = JSON.parse(
|
|
6133
|
+
const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
|
|
5712
6134
|
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
5713
6135
|
} catch {
|
|
5714
6136
|
return true;
|
|
@@ -5716,16 +6138,28 @@ async function ensureProbes(opts) {
|
|
|
5716
6138
|
});
|
|
5717
6139
|
if (missing.length === 0) return { recorded: [] };
|
|
5718
6140
|
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
5719
|
-
|
|
5720
|
-
for (const width of
|
|
5721
|
-
|
|
6141
|
+
let serve;
|
|
6142
|
+
for (const width of PROBE_WIDTHS) {
|
|
6143
|
+
try {
|
|
6144
|
+
const probe = JSON.parse(readFileSync25(join32(dir, `probe-${width}.json`), "utf8"));
|
|
6145
|
+
if (probe.serveRoot) {
|
|
6146
|
+
serve = probe.serveRoot;
|
|
6147
|
+
break;
|
|
6148
|
+
}
|
|
6149
|
+
} catch {
|
|
6150
|
+
}
|
|
5722
6151
|
}
|
|
6152
|
+
await withPageUrl(opts.projectRoot, abs, serve, async (url) => {
|
|
6153
|
+
for (const width of missing) {
|
|
6154
|
+
saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json: await runProbe({ url, width }), page: abs, serveRoot: serve });
|
|
6155
|
+
}
|
|
6156
|
+
});
|
|
5723
6157
|
return { recorded: missing };
|
|
5724
6158
|
}
|
|
5725
6159
|
function critiquedSurfaces(projectRoot) {
|
|
5726
|
-
const root =
|
|
5727
|
-
if (!
|
|
5728
|
-
return readdirSync12(root).filter((surface) =>
|
|
6160
|
+
const root = join32(projectRoot, ".jig", "critique");
|
|
6161
|
+
if (!existsSync19(root)) return [];
|
|
6162
|
+
return readdirSync12(root).filter((surface) => existsSync19(join32(root, surface, "screen.json")) || existsSync19(join32(root, surface, "code.json")));
|
|
5729
6163
|
}
|
|
5730
6164
|
|
|
5731
6165
|
// src/probe/check.ts
|
|
@@ -5733,11 +6167,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
5733
6167
|
if (!p.pageChecksum || !p.pageFile) {
|
|
5734
6168
|
return `${file} was not written by \`jig probe --save\`, so nothing measured it. Evaluate \`jig probe\` in the browser and pipe its output into \`jig probe --save <surface>\`.`;
|
|
5735
6169
|
}
|
|
5736
|
-
const page = pageFile(projectRoot, p.url ?? "");
|
|
5737
|
-
if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
|
|
6170
|
+
const page = p.serveRoot ? projectFile(projectRoot, p.pageFile) : pageFile(projectRoot, p.url ?? "");
|
|
6171
|
+
if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
|
|
5738
6172
|
let current;
|
|
5739
6173
|
try {
|
|
5740
|
-
current =
|
|
6174
|
+
current = readFileSync26(page, "utf8");
|
|
5741
6175
|
} catch {
|
|
5742
6176
|
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
5743
6177
|
}
|
|
@@ -5747,11 +6181,11 @@ function stampProblem(projectRoot, file, p) {
|
|
|
5747
6181
|
return void 0;
|
|
5748
6182
|
}
|
|
5749
6183
|
function readProbes(projectRoot, dir, errors) {
|
|
5750
|
-
if (!
|
|
6184
|
+
if (!existsSync20(dir)) return [];
|
|
5751
6185
|
const probes = [];
|
|
5752
6186
|
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
5753
6187
|
try {
|
|
5754
|
-
const p = JSON.parse(
|
|
6188
|
+
const p = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
|
|
5755
6189
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
5756
6190
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
5757
6191
|
continue;
|
|
@@ -5843,19 +6277,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
5843
6277
|
}
|
|
5844
6278
|
|
|
5845
6279
|
// src/check/decisions.ts
|
|
5846
|
-
import { existsSync as
|
|
5847
|
-
import { join as
|
|
6280
|
+
import { existsSync as existsSync21, readFileSync as readFileSync27 } from "fs";
|
|
6281
|
+
import { join as join34, posix as posix3 } from "path";
|
|
5848
6282
|
var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
5849
6283
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
5850
6284
|
function decisionsFile(projectRoot) {
|
|
5851
6285
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
5852
6286
|
try {
|
|
5853
|
-
const brand = JSON.parse(
|
|
6287
|
+
const brand = JSON.parse(readFileSync27(join34(projectRoot, "jig.config.json"), "utf8")).brand;
|
|
5854
6288
|
if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
|
|
5855
6289
|
} catch {
|
|
5856
6290
|
}
|
|
5857
6291
|
for (const candidate of candidates) {
|
|
5858
|
-
if (
|
|
6292
|
+
if (existsSync21(join34(projectRoot, candidate))) return candidate;
|
|
5859
6293
|
}
|
|
5860
6294
|
return void 0;
|
|
5861
6295
|
}
|
|
@@ -5864,7 +6298,7 @@ function decisionNames(projectRoot) {
|
|
|
5864
6298
|
if (!path) return [];
|
|
5865
6299
|
let body;
|
|
5866
6300
|
try {
|
|
5867
|
-
body =
|
|
6301
|
+
body = readFileSync27(join34(projectRoot, path), "utf8");
|
|
5868
6302
|
} catch {
|
|
5869
6303
|
return [];
|
|
5870
6304
|
}
|
|
@@ -5877,35 +6311,120 @@ function decisionNames(projectRoot) {
|
|
|
5877
6311
|
return names;
|
|
5878
6312
|
}
|
|
5879
6313
|
|
|
6314
|
+
// src/check/spec-shape.ts
|
|
6315
|
+
import { existsSync as existsSync22, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
|
|
6316
|
+
import { join as join35 } from "path";
|
|
6317
|
+
function newestSpec(projectRoot) {
|
|
6318
|
+
const dir = join35(projectRoot, ".jig", "specs");
|
|
6319
|
+
if (!existsSync22(dir)) return void 0;
|
|
6320
|
+
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6321
|
+
if (files.length === 0) return void 0;
|
|
6322
|
+
const newest = files.map((f) => ({ f, at: statSync3(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6323
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
|
|
6324
|
+
}
|
|
6325
|
+
function specProblems(spec) {
|
|
6326
|
+
const parts = spec.body.split(/^---\s*$/m);
|
|
6327
|
+
const front = parts.length >= 3 ? parts[1] : "";
|
|
6328
|
+
if (!front.trim()) {
|
|
6329
|
+
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
6330
|
+
}
|
|
6331
|
+
const problems = [];
|
|
6332
|
+
const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
|
|
6333
|
+
if (specIndexableField(front) === "unreadable") {
|
|
6334
|
+
problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
|
|
6335
|
+
}
|
|
6336
|
+
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6337
|
+
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6338
|
+
}
|
|
6339
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6340
|
+
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
6341
|
+
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
6342
|
+
if (!line) {
|
|
6343
|
+
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
6344
|
+
continue;
|
|
6345
|
+
}
|
|
6346
|
+
if (line[1].trim()) {
|
|
6347
|
+
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
6348
|
+
continue;
|
|
6349
|
+
}
|
|
6350
|
+
const block = sizeBlock(sizes, size);
|
|
6351
|
+
if (/^\s*same-as\s*:/im.test(block)) {
|
|
6352
|
+
if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
|
|
6353
|
+
continue;
|
|
6354
|
+
}
|
|
6355
|
+
for (const field of ["regions", "nav"]) {
|
|
6356
|
+
if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
|
|
6357
|
+
problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
|
|
6358
|
+
}
|
|
6359
|
+
}
|
|
6360
|
+
}
|
|
6361
|
+
return problems;
|
|
6362
|
+
}
|
|
6363
|
+
function sizeBlock(sizes, size) {
|
|
6364
|
+
const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
|
|
6365
|
+
if (!start) return "";
|
|
6366
|
+
const rest = sizes.slice(start.index + start[0].length);
|
|
6367
|
+
const indent = /^\s*/.exec(start[0])[0].length;
|
|
6368
|
+
const lines = [];
|
|
6369
|
+
for (const line of rest.split("\n")) {
|
|
6370
|
+
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
6371
|
+
lines.push(line);
|
|
6372
|
+
}
|
|
6373
|
+
return lines.join("\n");
|
|
6374
|
+
}
|
|
6375
|
+
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
6376
|
+
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
6377
|
+
function navProblems(spec) {
|
|
6378
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
6379
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6380
|
+
const problems = [];
|
|
6381
|
+
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
6382
|
+
const block = sizeBlock(sizes, size);
|
|
6383
|
+
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
6384
|
+
if (!nav || NONE_RE.test(nav)) continue;
|
|
6385
|
+
if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
|
|
6386
|
+
problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
|
|
6387
|
+
}
|
|
6388
|
+
}
|
|
6389
|
+
return problems;
|
|
6390
|
+
}
|
|
6391
|
+
function specIndexableField(front) {
|
|
6392
|
+
const line = /^\s*indexable\s*:(.*)$/im.exec(front);
|
|
6393
|
+
if (!line) return "absent";
|
|
6394
|
+
const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
|
|
6395
|
+
if (value === "true" || value === "yes") return true;
|
|
6396
|
+
if (value === "false" || value === "no") return false;
|
|
6397
|
+
return "unreadable";
|
|
6398
|
+
}
|
|
6399
|
+
|
|
5880
6400
|
// src/commands/verdicts.ts
|
|
5881
6401
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5882
6402
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5883
6403
|
function readJson(path, errors) {
|
|
5884
|
-
if (!
|
|
6404
|
+
if (!existsSync23(path)) return null;
|
|
5885
6405
|
try {
|
|
5886
|
-
return JSON.parse(
|
|
6406
|
+
return JSON.parse(readFileSync29(path, "utf8"));
|
|
5887
6407
|
} catch (e) {
|
|
5888
6408
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5889
6409
|
return {};
|
|
5890
6410
|
}
|
|
5891
6411
|
}
|
|
5892
6412
|
function specIndexable(projectRoot, surface) {
|
|
5893
|
-
const path =
|
|
6413
|
+
const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5894
6414
|
let front = "";
|
|
5895
6415
|
try {
|
|
5896
|
-
front =
|
|
6416
|
+
front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5897
6417
|
} catch {
|
|
5898
6418
|
}
|
|
5899
|
-
const declared =
|
|
5900
|
-
if (declared ===
|
|
5901
|
-
if (declared === "false" || declared === "no") return false;
|
|
6419
|
+
const declared = specIndexableField(front);
|
|
6420
|
+
if (declared === true || declared === false) return declared;
|
|
5902
6421
|
const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
|
|
5903
6422
|
return mode !== "product" && mode !== "operator";
|
|
5904
6423
|
}
|
|
5905
6424
|
function specNeedsNav(projectRoot, surface) {
|
|
5906
|
-
const path =
|
|
5907
|
-
if (!
|
|
5908
|
-
const front =
|
|
6425
|
+
const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6426
|
+
if (!existsSync23(path)) return false;
|
|
6427
|
+
const front = readFileSync29(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5909
6428
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5910
6429
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5911
6430
|
return navField || navRegion;
|
|
@@ -5956,7 +6475,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5956
6475
|
function checkDecisions(projectRoot, dir, errors) {
|
|
5957
6476
|
const required = decisionNames(projectRoot);
|
|
5958
6477
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
5959
|
-
const file = readJson(
|
|
6478
|
+
const file = readJson(join36(dir, "decisions.json"), errors);
|
|
5960
6479
|
if (!file) {
|
|
5961
6480
|
errors.push(
|
|
5962
6481
|
`decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
|
|
@@ -6001,7 +6520,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6001
6520
|
function verifyVerdicts(opts) {
|
|
6002
6521
|
const root = opts.packageRoot ?? assetRoot();
|
|
6003
6522
|
const errors = [];
|
|
6004
|
-
const index = JSON.parse(
|
|
6523
|
+
const index = JSON.parse(readFileSync29(join36(root, "rules.index.json"), "utf8"));
|
|
6005
6524
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
6006
6525
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
6007
6526
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -6010,16 +6529,16 @@ function verifyVerdicts(opts) {
|
|
|
6010
6529
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
6011
6530
|
]);
|
|
6012
6531
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
6013
|
-
const dir =
|
|
6014
|
-
const screenFile = readJson(
|
|
6015
|
-
const codeFile = readJson(
|
|
6532
|
+
const dir = join36(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6533
|
+
const screenFile = readJson(join36(dir, "screen.json"), errors);
|
|
6534
|
+
const codeFile = readJson(join36(dir, "code.json"), errors);
|
|
6016
6535
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6017
6536
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
6018
6537
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
6019
6538
|
let rendered = false;
|
|
6020
6539
|
if (screenFile && screenFile.rendered === true) {
|
|
6021
6540
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
6022
|
-
const absent = artefacts.filter((a) => !
|
|
6541
|
+
const absent = artefacts.filter((a) => !existsSync23(resolve5(opts.projectRoot, a)));
|
|
6023
6542
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
6024
6543
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
6025
6544
|
else rendered = true;
|
|
@@ -6038,7 +6557,16 @@ function verifyVerdicts(opts) {
|
|
|
6038
6557
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
6039
6558
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
6040
6559
|
};
|
|
6041
|
-
|
|
6560
|
+
let specFront = "";
|
|
6561
|
+
try {
|
|
6562
|
+
specFront = readFileSync29(join36(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6563
|
+
} catch {
|
|
6564
|
+
}
|
|
6565
|
+
if (specIndexableField(specFront) === "unreadable") {
|
|
6566
|
+
errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
|
|
6567
|
+
} else {
|
|
6568
|
+
errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
|
|
6569
|
+
}
|
|
6042
6570
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
6043
6571
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
6044
6572
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
@@ -6047,92 +6575,15 @@ function verifyVerdicts(opts) {
|
|
|
6047
6575
|
}
|
|
6048
6576
|
|
|
6049
6577
|
// src/commands/gate.ts
|
|
6050
|
-
import { existsSync as
|
|
6578
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync30, writeFileSync as writeFileSync7 } from "fs";
|
|
6051
6579
|
import { createHash as createHash2 } from "crypto";
|
|
6052
|
-
import { join as
|
|
6053
|
-
|
|
6054
|
-
// src/check/spec-shape.ts
|
|
6055
|
-
import { existsSync as existsSync21, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
|
|
6056
|
-
import { join as join35 } from "path";
|
|
6057
|
-
function newestSpec(projectRoot) {
|
|
6058
|
-
const dir = join35(projectRoot, ".jig", "specs");
|
|
6059
|
-
if (!existsSync21(dir)) return void 0;
|
|
6060
|
-
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6061
|
-
if (files.length === 0) return void 0;
|
|
6062
|
-
const newest = files.map((f) => ({ f, at: statSync2(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6063
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
|
|
6064
|
-
}
|
|
6065
|
-
function specProblems(spec) {
|
|
6066
|
-
const parts = spec.body.split(/^---\s*$/m);
|
|
6067
|
-
const front = parts.length >= 3 ? parts[1] : "";
|
|
6068
|
-
if (!front.trim()) {
|
|
6069
|
-
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
6070
|
-
}
|
|
6071
|
-
const problems = [];
|
|
6072
|
-
const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
|
|
6073
|
-
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6074
|
-
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6075
|
-
}
|
|
6076
|
-
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6077
|
-
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
6078
|
-
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
6079
|
-
if (!line) {
|
|
6080
|
-
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
6081
|
-
continue;
|
|
6082
|
-
}
|
|
6083
|
-
if (line[1].trim()) {
|
|
6084
|
-
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
6085
|
-
continue;
|
|
6086
|
-
}
|
|
6087
|
-
const block = sizeBlock(sizes, size);
|
|
6088
|
-
if (/^\s*same-as\s*:/im.test(block)) {
|
|
6089
|
-
if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
|
|
6090
|
-
continue;
|
|
6091
|
-
}
|
|
6092
|
-
for (const field of ["regions", "nav"]) {
|
|
6093
|
-
if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
|
|
6094
|
-
problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
|
|
6095
|
-
}
|
|
6096
|
-
}
|
|
6097
|
-
}
|
|
6098
|
-
return problems;
|
|
6099
|
-
}
|
|
6100
|
-
function sizeBlock(sizes, size) {
|
|
6101
|
-
const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
|
|
6102
|
-
if (!start) return "";
|
|
6103
|
-
const rest = sizes.slice(start.index + start[0].length);
|
|
6104
|
-
const indent = /^\s*/.exec(start[0])[0].length;
|
|
6105
|
-
const lines = [];
|
|
6106
|
-
for (const line of rest.split("\n")) {
|
|
6107
|
-
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
6108
|
-
lines.push(line);
|
|
6109
|
-
}
|
|
6110
|
-
return lines.join("\n");
|
|
6111
|
-
}
|
|
6112
|
-
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
6113
|
-
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
6114
|
-
function navProblems(spec) {
|
|
6115
|
-
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
6116
|
-
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6117
|
-
const problems = [];
|
|
6118
|
-
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
6119
|
-
const block = sizeBlock(sizes, size);
|
|
6120
|
-
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
6121
|
-
if (!nav || NONE_RE.test(nav)) continue;
|
|
6122
|
-
if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
|
|
6123
|
-
problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
|
|
6124
|
-
}
|
|
6125
|
-
}
|
|
6126
|
-
return problems;
|
|
6127
|
-
}
|
|
6128
|
-
|
|
6129
|
-
// src/commands/gate.ts
|
|
6580
|
+
import { join as join37 } from "path";
|
|
6130
6581
|
var MAX_BLOCKS = 3;
|
|
6131
6582
|
function lastJigCommand(transcriptPath) {
|
|
6132
|
-
if (!transcriptPath || !
|
|
6583
|
+
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6133
6584
|
let text;
|
|
6134
6585
|
try {
|
|
6135
|
-
text =
|
|
6586
|
+
text = readFileSync30(transcriptPath, "utf8");
|
|
6136
6587
|
} catch {
|
|
6137
6588
|
return void 0;
|
|
6138
6589
|
}
|
|
@@ -6147,10 +6598,10 @@ function lastJigCommand(transcriptPath) {
|
|
|
6147
6598
|
return found;
|
|
6148
6599
|
}
|
|
6149
6600
|
function lastAssistantText(transcriptPath) {
|
|
6150
|
-
if (!transcriptPath || !
|
|
6601
|
+
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6151
6602
|
let found;
|
|
6152
6603
|
try {
|
|
6153
|
-
for (const line of
|
|
6604
|
+
for (const line of readFileSync30(transcriptPath, "utf8").split("\n")) {
|
|
6154
6605
|
if (!line.includes('"assistant"')) continue;
|
|
6155
6606
|
let entry;
|
|
6156
6607
|
try {
|
|
@@ -6180,7 +6631,7 @@ function commandProblems(root, command) {
|
|
|
6180
6631
|
const found = decisionsFile(root);
|
|
6181
6632
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
6182
6633
|
else {
|
|
6183
|
-
const body =
|
|
6634
|
+
const body = readFileSync30(join37(root, found), "utf8");
|
|
6184
6635
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
6185
6636
|
problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
|
|
6186
6637
|
}
|
|
@@ -6201,13 +6652,13 @@ function commandProblems(root, command) {
|
|
|
6201
6652
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
6202
6653
|
if (/^approved/i.test(mockup)) {
|
|
6203
6654
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
6204
|
-
else if (!/^https?:/i.test(at) && !
|
|
6655
|
+
else if (!/^https?:/i.test(at) && !existsSync24(join37(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
6205
6656
|
else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
|
|
6206
6657
|
}
|
|
6207
6658
|
}
|
|
6208
6659
|
if (command === "critique") {
|
|
6209
|
-
const dir =
|
|
6210
|
-
const surfaces =
|
|
6660
|
+
const dir = join37(root, ".jig", "critique");
|
|
6661
|
+
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join37(dir, s, "screen.json")) || existsSync24(join37(dir, s, "code.json"))) : [];
|
|
6211
6662
|
for (const surface of surfaces) {
|
|
6212
6663
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6213
6664
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -6229,13 +6680,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
6229
6680
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6230
6681
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6231
6682
|
for (const candidate of candidates) {
|
|
6232
|
-
if (candidate &&
|
|
6683
|
+
if (candidate && existsSync24(join37(projectRoot, candidate))) return candidate;
|
|
6233
6684
|
}
|
|
6234
6685
|
return void 0;
|
|
6235
6686
|
}
|
|
6236
6687
|
function gate(opts) {
|
|
6237
6688
|
const root = opts.projectRoot;
|
|
6238
|
-
if (!
|
|
6689
|
+
if (!existsSync24(join37(root, "jig.config.json")) && !existsSync24(join37(root, ".jig"))) {
|
|
6239
6690
|
return { block: false, reason: "" };
|
|
6240
6691
|
}
|
|
6241
6692
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -6261,11 +6712,12 @@ function gate(opts) {
|
|
|
6261
6712
|
);
|
|
6262
6713
|
}
|
|
6263
6714
|
}
|
|
6264
|
-
|
|
6265
|
-
|
|
6715
|
+
problems.push(...verdictGuard(root, command));
|
|
6716
|
+
const critiqueDir = join37(root, ".jig", "critique");
|
|
6717
|
+
if (existsSync24(critiqueDir)) {
|
|
6266
6718
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
6267
|
-
const dir =
|
|
6268
|
-
if (!
|
|
6719
|
+
const dir = join37(critiqueDir, surface);
|
|
6720
|
+
if (!existsSync24(join37(dir, "screen.json")) && !existsSync24(join37(dir, "code.json"))) continue;
|
|
6269
6721
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6270
6722
|
if (!v.ok) {
|
|
6271
6723
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -6279,10 +6731,10 @@ function gate(opts) {
|
|
|
6279
6731
|
}
|
|
6280
6732
|
const session = opts.input.session_id ?? "unknown";
|
|
6281
6733
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
6282
|
-
const stateFile =
|
|
6734
|
+
const stateFile = join37(root, ".jig", "gate.json");
|
|
6283
6735
|
let state = {};
|
|
6284
6736
|
try {
|
|
6285
|
-
state = JSON.parse(
|
|
6737
|
+
state = JSON.parse(readFileSync30(stateFile, "utf8"));
|
|
6286
6738
|
} catch {
|
|
6287
6739
|
}
|
|
6288
6740
|
if (problems.length === 0) {
|
|
@@ -6310,17 +6762,60 @@ ${problems.join("\n\n")}`
|
|
|
6310
6762
|
${problems.join("\n\n")}`
|
|
6311
6763
|
};
|
|
6312
6764
|
}
|
|
6765
|
+
var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
|
|
6766
|
+
var LOCK = "verdicts.lock";
|
|
6767
|
+
function verdictChecksum(dir) {
|
|
6768
|
+
const parts = VERDICT_FILES.map((f) => {
|
|
6769
|
+
try {
|
|
6770
|
+
return `${f}
|
|
6771
|
+
${readFileSync30(join37(dir, f), "utf8")}`;
|
|
6772
|
+
} catch {
|
|
6773
|
+
return "";
|
|
6774
|
+
}
|
|
6775
|
+
});
|
|
6776
|
+
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
6777
|
+
}
|
|
6778
|
+
function verdictGuard(root, command) {
|
|
6779
|
+
const critiqueDir = join37(root, ".jig", "critique");
|
|
6780
|
+
if (!existsSync24(critiqueDir)) return [];
|
|
6781
|
+
const problems = [];
|
|
6782
|
+
for (const surface of readdirSync15(critiqueDir)) {
|
|
6783
|
+
const dir = join37(critiqueDir, surface);
|
|
6784
|
+
const now = verdictChecksum(dir);
|
|
6785
|
+
if (!now) continue;
|
|
6786
|
+
const lockPath = join37(dir, LOCK);
|
|
6787
|
+
if (command === "critique") {
|
|
6788
|
+
try {
|
|
6789
|
+
writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
6790
|
+
} catch {
|
|
6791
|
+
}
|
|
6792
|
+
continue;
|
|
6793
|
+
}
|
|
6794
|
+
let locked;
|
|
6795
|
+
try {
|
|
6796
|
+
locked = JSON.parse(readFileSync30(lockPath, "utf8")).checksum;
|
|
6797
|
+
} catch {
|
|
6798
|
+
continue;
|
|
6799
|
+
}
|
|
6800
|
+
if (locked && locked !== now) {
|
|
6801
|
+
problems.push(
|
|
6802
|
+
`.jig/critique/${surface}: the verdict files changed after \`/jig critique\` wrote them${command ? `, in a session that ran \`/jig ${command}\`` : ""}. Verdicts are the review's, not the builder's: restore them (\`git checkout -- .jig/critique/${surface}\`) and run \`/jig critique\` to judge the fix.`
|
|
6803
|
+
);
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6806
|
+
return problems;
|
|
6807
|
+
}
|
|
6313
6808
|
function save(file, state) {
|
|
6314
6809
|
try {
|
|
6315
|
-
mkdirSync8(
|
|
6810
|
+
mkdirSync8(join37(file, ".."), { recursive: true });
|
|
6316
6811
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
6317
6812
|
} catch {
|
|
6318
6813
|
}
|
|
6319
6814
|
}
|
|
6320
6815
|
|
|
6321
6816
|
// src/commands/seo.ts
|
|
6322
|
-
import { readFileSync as
|
|
6323
|
-
import { join as
|
|
6817
|
+
import { readFileSync as readFileSync31 } from "fs";
|
|
6818
|
+
import { join as join38 } from "path";
|
|
6324
6819
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6325
6820
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6326
6821
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -6361,7 +6856,7 @@ function seo(opts) {
|
|
|
6361
6856
|
for (const file of files) {
|
|
6362
6857
|
let raw;
|
|
6363
6858
|
try {
|
|
6364
|
-
raw =
|
|
6859
|
+
raw = readFileSync31(join38(root, file), "utf8");
|
|
6365
6860
|
} catch {
|
|
6366
6861
|
continue;
|
|
6367
6862
|
}
|
|
@@ -6417,7 +6912,7 @@ function seo(opts) {
|
|
|
6417
6912
|
|
|
6418
6913
|
// src/index.ts
|
|
6419
6914
|
var packageRoot = getPackageRoot();
|
|
6420
|
-
var { version } = JSON.parse(
|
|
6915
|
+
var { version } = JSON.parse(readFileSync32(join39(packageRoot, "package.json"), "utf8"));
|
|
6421
6916
|
function warnIfUnpublishedPin() {
|
|
6422
6917
|
if (isPublishedBuild(packageRoot)) return;
|
|
6423
6918
|
console.warn(
|
|
@@ -6567,7 +7062,7 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
6567
7062
|
process.exit(1);
|
|
6568
7063
|
}
|
|
6569
7064
|
});
|
|
6570
|
-
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768 and
|
|
7065
|
+
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
|
|
6571
7066
|
if (opts.run) {
|
|
6572
7067
|
if (!opts.save) {
|
|
6573
7068
|
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
@@ -6575,7 +7070,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
6575
7070
|
}
|
|
6576
7071
|
const projectRoot2 = findProjectRoot(process.cwd());
|
|
6577
7072
|
try {
|
|
6578
|
-
for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run })) {
|
|
7073
|
+
for (const saved of await runAndSaveProbes({ projectRoot: projectRoot2, surface: opts.save, page: opts.run, serve: opts.serve })) {
|
|
6579
7074
|
console.log(` Recorded ${saved.path} \u2014 ${saved.page} at ${saved.width}px.`);
|
|
6580
7075
|
}
|
|
6581
7076
|
} catch (err) {
|
|
@@ -6609,7 +7104,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
6609
7104
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
6610
7105
|
let input = {};
|
|
6611
7106
|
try {
|
|
6612
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
7107
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync32(0, "utf8") || "{}");
|
|
6613
7108
|
} catch {
|
|
6614
7109
|
}
|
|
6615
7110
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -6648,7 +7143,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
6648
7143
|
});
|
|
6649
7144
|
function resolveSurfaceMode(projectRoot) {
|
|
6650
7145
|
try {
|
|
6651
|
-
const config = JSON.parse(
|
|
7146
|
+
const config = JSON.parse(readFileSync32(join39(projectRoot, "jig.config.json"), "utf8"));
|
|
6652
7147
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
6653
7148
|
return modes.length === 1 ? modes[0] : void 0;
|
|
6654
7149
|
} catch {
|