canicode 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -45
- package/dist/cli/index.js +1220 -603
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +308 -2
- package/dist/index.js +275 -11
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +494 -12
- package/dist/mcp/server.js.map +1 -1
- package/package.json +7 -5
- package/skills/canicode/SKILL.md +76 -0
- package/skills/canicode-gotchas/SKILL.md +138 -0
- package/skills/canicode-roundtrip/SKILL.md +367 -0
- package/skills/canicode-roundtrip/helpers.js +263 -0
- package/.claude/skills/design-to-code/PROMPT.md +0 -143
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, renameSync, chmodSync
|
|
3
|
-
import { join, resolve, dirname, basename } from 'path';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, copyFileSync, readdirSync, renameSync, chmodSync } from 'fs';
|
|
3
|
+
import { join, resolve, dirname, basename, relative } from 'path';
|
|
4
4
|
import pixelmatch from 'pixelmatch';
|
|
5
5
|
import { PNG } from 'pngjs';
|
|
6
6
|
import { createRequire } from 'module';
|
|
@@ -10,7 +10,8 @@ import { randomUUID } from 'crypto';
|
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { writeFile, readFile } from 'fs/promises';
|
|
13
|
-
import {
|
|
13
|
+
import { createInterface } from 'readline/promises';
|
|
14
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
14
15
|
|
|
15
16
|
var __defProp = Object.defineProperty;
|
|
16
17
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -1369,7 +1370,14 @@ var EVENTS = {
|
|
|
1369
1370
|
MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
|
|
1370
1371
|
// CLI
|
|
1371
1372
|
CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
|
|
1372
|
-
CLI_INIT: `${EVENT_PREFIX}cli_init
|
|
1373
|
+
CLI_INIT: `${EVENT_PREFIX}cli_init`,
|
|
1374
|
+
// Roundtrip (ADR-012)
|
|
1375
|
+
// Wiring point for the roundtrip helper's `telemetry` callback. No Node-side
|
|
1376
|
+
// orchestrator reads this yet — the helper ships in a sandbox-pure IIFE that
|
|
1377
|
+
// cannot import `core/monitoring` directly, so the event fires through a
|
|
1378
|
+
// caller-supplied callback. Define the typed name here so a future consumer
|
|
1379
|
+
// has a single place to wire it up.
|
|
1380
|
+
ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
|
|
1373
1381
|
};
|
|
1374
1382
|
|
|
1375
1383
|
// src/core/monitoring/capture.ts
|
|
@@ -1511,8 +1519,8 @@ CANICODE DOCUMENTATION (v${pkg.version})
|
|
|
1511
1519
|
|
|
1512
1520
|
canicode docs setup Full setup guide (CLI, MCP, Skills)
|
|
1513
1521
|
canicode docs config Config override guide + example
|
|
1514
|
-
canicode docs implement Design-to-code package guide
|
|
1515
1522
|
canicode docs scoring Scoring model explanation
|
|
1523
|
+
canicode docs rules Pointer to rule list (canicode list-rules)
|
|
1516
1524
|
|
|
1517
1525
|
Full documentation: github.com/let-sunny/canicode#readme
|
|
1518
1526
|
`.trimStart());
|
|
@@ -1530,7 +1538,7 @@ CANICODE SETUP GUIDE
|
|
|
1530
1538
|
|
|
1531
1539
|
Setup:
|
|
1532
1540
|
canicode init --token figd_xxxxxxxxxxxxx
|
|
1533
|
-
(
|
|
1541
|
+
(saves token + installs skills \u2014 see section 2 for --no-skills)
|
|
1534
1542
|
|
|
1535
1543
|
Use:
|
|
1536
1544
|
canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
|
|
@@ -1545,14 +1553,27 @@ CANICODE SETUP GUIDE
|
|
|
1545
1553
|
~/.canicode/reports/report-YYYY-MM-DD-HH-mm-<filekey>.html
|
|
1546
1554
|
|
|
1547
1555
|
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1548
|
-
2. CLAUDE CODE
|
|
1556
|
+
2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
|
|
1549
1557
|
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1550
1558
|
|
|
1551
1559
|
Setup:
|
|
1552
|
-
|
|
1560
|
+
canicode init --token figd_xxxxxxxxxxxxx
|
|
1561
|
+
(installs three skills into ./.claude/skills/ alongside the token)
|
|
1562
|
+
|
|
1563
|
+
Installed skills:
|
|
1564
|
+
canicode Lightweight CLI wrapper
|
|
1565
|
+
canicode-gotchas Standalone gotcha survey
|
|
1566
|
+
canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
|
|
1567
|
+
|
|
1568
|
+
Flags:
|
|
1569
|
+
--global Install to ~/.claude/skills/ instead of ./.claude/skills/
|
|
1570
|
+
--no-skills Skip skill install (token only \u2014 legacy behavior)
|
|
1571
|
+
--force Overwrite existing skill files without prompting
|
|
1553
1572
|
|
|
1554
1573
|
Use (in Claude Code):
|
|
1555
1574
|
/canicode https://www.figma.com/design/ABC123/MyDesign?node-id=1-234
|
|
1575
|
+
/canicode-gotchas <url> Run a gotcha survey
|
|
1576
|
+
/canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
|
|
1556
1577
|
|
|
1557
1578
|
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1558
1579
|
TOKEN PRIORITY
|
|
@@ -1618,7 +1639,7 @@ OPTIONS
|
|
|
1618
1639
|
--output <dir> Output directory (default: /tmp/canicode-visual-compare)
|
|
1619
1640
|
--width <px> Logical viewport width in CSS px (omit = infer from Figma PNG \xF7 export scale)
|
|
1620
1641
|
--height <px> Logical viewport height in CSS px (omit = infer from Figma PNG \xF7 export scale)
|
|
1621
|
-
--figma-scale <n> Figma Images API scale (default: 2, matches save-fixture @2x PNGs)
|
|
1642
|
+
--figma-scale <n> Figma Images API scale (default: 2, matches calibrate-save-fixture @2x PNGs)
|
|
1622
1643
|
|
|
1623
1644
|
OUTPUT FILES
|
|
1624
1645
|
/tmp/canicode-visual-compare/
|
|
@@ -1678,46 +1699,17 @@ USE CASES
|
|
|
1678
1699
|
Quick design structure inspection
|
|
1679
1700
|
`.trimStart());
|
|
1680
1701
|
}
|
|
1681
|
-
function
|
|
1702
|
+
function printDocsRules() {
|
|
1682
1703
|
console.log(`
|
|
1683
|
-
|
|
1704
|
+
RULES
|
|
1684
1705
|
|
|
1685
|
-
|
|
1706
|
+
Run 'canicode list-rules' for the full table of rule IDs, scores, and severity.
|
|
1686
1707
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1708
|
+
Customize per-rule via config:
|
|
1709
|
+
canicode docs config
|
|
1689
1710
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
--image-scale <n> Image export scale: 2 for PC (default), 3 for mobile
|
|
1693
|
-
--output <dir> Output directory (default: ./canicode-implement/)
|
|
1694
|
-
--token <token> Figma API token (for live URLs)
|
|
1695
|
-
|
|
1696
|
-
OUTPUT
|
|
1697
|
-
canicode-implement/
|
|
1698
|
-
analysis.json Analysis report with issues and scores
|
|
1699
|
-
design-tree.txt DOM-like tree with CSS styles (~N tokens)
|
|
1700
|
-
images/ PNG assets with human-readable names (hero-banner@2x.png)
|
|
1701
|
-
vectors/ SVG assets for vector nodes
|
|
1702
|
-
PROMPT.md Stack-specific code generation prompt
|
|
1703
|
-
|
|
1704
|
-
WORKFLOW
|
|
1705
|
-
1. Run: canicode implement ./my-fixture --prompt ./my-react-prompt.md
|
|
1706
|
-
2. Feed design-tree.txt + PROMPT.md to your AI assistant
|
|
1707
|
-
3. AI generates code matching the design pixel-perfectly
|
|
1708
|
-
4. Verify with: canicode visual-compare ./output.html --figma-url <url>
|
|
1709
|
-
|
|
1710
|
-
CUSTOM PROMPT
|
|
1711
|
-
Default prompt generates HTML+CSS. For your own stack:
|
|
1712
|
-
1. Write a prompt file (e.g. my-react-prompt.md)
|
|
1713
|
-
2. Pass it: canicode implement ./fixture --prompt ./my-react-prompt.md
|
|
1714
|
-
The design-tree.txt format is stack-agnostic \u2014 your prompt just needs
|
|
1715
|
-
to describe how to convert it to your target framework.
|
|
1716
|
-
|
|
1717
|
-
IMAGE SCALE
|
|
1718
|
-
--image-scale 2 PC/desktop (default) \u2014 @2x retina
|
|
1719
|
-
--image-scale 3 Mobile \u2014 @3x retina
|
|
1720
|
-
SVG vectors are scale-independent and always included.
|
|
1711
|
+
Full reference:
|
|
1712
|
+
https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md
|
|
1721
1713
|
`.trimStart());
|
|
1722
1714
|
}
|
|
1723
1715
|
function printDocsScoring() {
|
|
@@ -1740,8 +1732,8 @@ var DOCS_TOPICS = {
|
|
|
1740
1732
|
install: printDocsSetup,
|
|
1741
1733
|
// alias
|
|
1742
1734
|
config: printDocsConfig,
|
|
1743
|
-
implement: printDocsImplement,
|
|
1744
1735
|
scoring: printDocsScoring,
|
|
1736
|
+
rules: printDocsRules,
|
|
1745
1737
|
"visual-compare": printDocsVisualCompare,
|
|
1746
1738
|
"design-tree": printDocsDesignTree
|
|
1747
1739
|
};
|
|
@@ -1936,6 +1928,44 @@ function getConfigsWithPreset(preset) {
|
|
|
1936
1928
|
}
|
|
1937
1929
|
return configs;
|
|
1938
1930
|
}
|
|
1931
|
+
var RULE_ANNOTATION_PROPERTIES = {
|
|
1932
|
+
"missing-size-constraint": {
|
|
1933
|
+
default: [{ type: "width" }, { type: "height" }]
|
|
1934
|
+
},
|
|
1935
|
+
"irregular-spacing": {
|
|
1936
|
+
bySubType: {
|
|
1937
|
+
gap: [{ type: "itemSpacing" }],
|
|
1938
|
+
padding: [{ type: "padding" }]
|
|
1939
|
+
}
|
|
1940
|
+
},
|
|
1941
|
+
"fixed-size-in-auto-layout": {
|
|
1942
|
+
default: [{ type: "width" }, { type: "height" }, { type: "layoutMode" }]
|
|
1943
|
+
},
|
|
1944
|
+
"raw-value": {
|
|
1945
|
+
bySubType: {
|
|
1946
|
+
color: [{ type: "fills" }],
|
|
1947
|
+
font: [
|
|
1948
|
+
{ type: "fontSize" },
|
|
1949
|
+
{ type: "fontFamily" },
|
|
1950
|
+
{ type: "fontWeight" },
|
|
1951
|
+
{ type: "lineHeight" }
|
|
1952
|
+
],
|
|
1953
|
+
spacing: [{ type: "itemSpacing" }, { type: "padding" }]
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
"absolute-position-in-auto-layout": {
|
|
1957
|
+
default: [{ type: "layoutMode" }]
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
function getAnnotationProperties(ruleId, subType) {
|
|
1961
|
+
const entry = RULE_ANNOTATION_PROPERTIES[ruleId];
|
|
1962
|
+
if (!entry) return void 0;
|
|
1963
|
+
if (subType !== void 0 && entry.bySubType) {
|
|
1964
|
+
const match = entry.bySubType[subType];
|
|
1965
|
+
if (match) return match;
|
|
1966
|
+
}
|
|
1967
|
+
return entry.default;
|
|
1968
|
+
}
|
|
1939
1969
|
function getRuleOption(ruleId, optionKey, defaultValue) {
|
|
1940
1970
|
const config2 = RULE_CONFIGS[ruleId];
|
|
1941
1971
|
if (!config2.options) return defaultValue;
|
|
@@ -3036,6 +3066,10 @@ defineRule({
|
|
|
3036
3066
|
});
|
|
3037
3067
|
|
|
3038
3068
|
// src/core/rules/naming/index.ts
|
|
3069
|
+
function capitalize(s) {
|
|
3070
|
+
if (!s) return s;
|
|
3071
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
3072
|
+
}
|
|
3039
3073
|
function detectNamingConvention(name) {
|
|
3040
3074
|
if (/^[a-z]+(-[a-z]+)*$/.test(name)) return "kebab-case";
|
|
3041
3075
|
if (/^[a-z]+(_[a-z]+)*$/.test(name)) return "snake_case";
|
|
@@ -3161,6 +3195,7 @@ var inconsistentNamingConventionCheck = (node, context) => {
|
|
|
3161
3195
|
ruleId: inconsistentNamingConventionDef.id,
|
|
3162
3196
|
nodeId: node.id,
|
|
3163
3197
|
nodePath: context.path.join(" > "),
|
|
3198
|
+
suggestedName: suggested,
|
|
3164
3199
|
...inconsistentNamingMsg(node.name, nodeConvention, dominantConvention, suggested)
|
|
3165
3200
|
};
|
|
3166
3201
|
}
|
|
@@ -3198,6 +3233,7 @@ var nonStandardNamingCheck = (node, context) => {
|
|
|
3198
3233
|
subType: "state-name",
|
|
3199
3234
|
nodeId: node.id,
|
|
3200
3235
|
nodePath: context.path.join(" > "),
|
|
3236
|
+
suggestedName: capitalize(suggestion),
|
|
3201
3237
|
...nonStandardNamingMsg.stateName(node.name, opt, suggestion)
|
|
3202
3238
|
};
|
|
3203
3239
|
}
|
|
@@ -3889,8 +3925,97 @@ async function loadFromApi(fileKey, nodeId, token) {
|
|
|
3889
3925
|
return { file, nodeId };
|
|
3890
3926
|
}
|
|
3891
3927
|
|
|
3928
|
+
// src/core/adapters/instance-id-parser.ts
|
|
3929
|
+
function isInstanceChildNodeId(nodeId) {
|
|
3930
|
+
return nodeId.startsWith("I") && nodeId.includes(";");
|
|
3931
|
+
}
|
|
3932
|
+
function parseInstanceChildNodeId(nodeId) {
|
|
3933
|
+
if (!isInstanceChildNodeId(nodeId)) return null;
|
|
3934
|
+
const segments = nodeId.split(";");
|
|
3935
|
+
if (segments.length < 2) return null;
|
|
3936
|
+
const parentInstanceId = segments[0].replace(/^I/, "");
|
|
3937
|
+
const sourceNodeId = segments[segments.length - 1];
|
|
3938
|
+
if (!parentInstanceId || !sourceNodeId) return null;
|
|
3939
|
+
return { parentInstanceId, sourceNodeId };
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
// src/core/gotcha/apply-context.ts
|
|
3943
|
+
var STRATEGY_BY_RULE = {
|
|
3944
|
+
// Strategy A — property modification
|
|
3945
|
+
"no-auto-layout": "property-mod",
|
|
3946
|
+
"fixed-size-in-auto-layout": "property-mod",
|
|
3947
|
+
"missing-size-constraint": "property-mod",
|
|
3948
|
+
"irregular-spacing": "property-mod",
|
|
3949
|
+
"non-semantic-name": "property-mod",
|
|
3950
|
+
// Strategy B — structural modification (needs user confirmation)
|
|
3951
|
+
"non-layout-container": "structural-mod",
|
|
3952
|
+
"deep-nesting": "structural-mod",
|
|
3953
|
+
"missing-component": "structural-mod",
|
|
3954
|
+
"detached-instance": "structural-mod",
|
|
3955
|
+
// Strategy C — annotation only
|
|
3956
|
+
"absolute-position-in-auto-layout": "annotation",
|
|
3957
|
+
"variant-structure-mismatch": "annotation",
|
|
3958
|
+
// Strategy D — auto-fix lower-severity issues from analyze output
|
|
3959
|
+
"non-standard-naming": "auto-fix",
|
|
3960
|
+
"inconsistent-naming-convention": "auto-fix",
|
|
3961
|
+
"raw-value": "auto-fix",
|
|
3962
|
+
"missing-interaction-state": "auto-fix",
|
|
3963
|
+
"missing-prototype": "auto-fix"
|
|
3964
|
+
};
|
|
3965
|
+
function resolveTargetProperty(ruleId, subType) {
|
|
3966
|
+
switch (ruleId) {
|
|
3967
|
+
case "no-auto-layout":
|
|
3968
|
+
return ["layoutMode", "itemSpacing"];
|
|
3969
|
+
case "fixed-size-in-auto-layout":
|
|
3970
|
+
if (subType === "horizontal") return "layoutSizingHorizontal";
|
|
3971
|
+
return ["layoutSizingHorizontal", "layoutSizingVertical"];
|
|
3972
|
+
case "missing-size-constraint":
|
|
3973
|
+
if (subType === "wrap") return "minWidth";
|
|
3974
|
+
if (subType === "max-width") return "maxWidth";
|
|
3975
|
+
return ["minWidth", "maxWidth"];
|
|
3976
|
+
case "irregular-spacing":
|
|
3977
|
+
if (subType === "gap") return "itemSpacing";
|
|
3978
|
+
return ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
|
|
3979
|
+
case "non-semantic-name":
|
|
3980
|
+
return "name";
|
|
3981
|
+
case "non-layout-container":
|
|
3982
|
+
return "layoutMode";
|
|
3983
|
+
case "non-standard-naming":
|
|
3984
|
+
case "inconsistent-naming-convention":
|
|
3985
|
+
return "name";
|
|
3986
|
+
case "deep-nesting":
|
|
3987
|
+
case "missing-component":
|
|
3988
|
+
case "detached-instance":
|
|
3989
|
+
case "absolute-position-in-auto-layout":
|
|
3990
|
+
case "variant-structure-mismatch":
|
|
3991
|
+
case "raw-value":
|
|
3992
|
+
case "missing-interaction-state":
|
|
3993
|
+
case "missing-prototype":
|
|
3994
|
+
return void 0;
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
function computeApplyContext(violation, instanceContext) {
|
|
3998
|
+
const ruleId = violation.ruleId;
|
|
3999
|
+
const applyStrategy = STRATEGY_BY_RULE[ruleId] ?? "annotation";
|
|
4000
|
+
const targetProperty = resolveTargetProperty(ruleId, violation.subType);
|
|
4001
|
+
const annotationProperties = getAnnotationProperties(
|
|
4002
|
+
ruleId,
|
|
4003
|
+
violation.subType
|
|
4004
|
+
);
|
|
4005
|
+
const parsed = parseInstanceChildNodeId(violation.nodeId);
|
|
4006
|
+
const isInstanceChild = parsed !== null || isInstanceChildNodeId(violation.nodeId);
|
|
4007
|
+
const sourceChildId = instanceContext?.sourceNodeId ?? parsed?.sourceNodeId;
|
|
4008
|
+
return {
|
|
4009
|
+
applyStrategy,
|
|
4010
|
+
...targetProperty !== void 0 ? { targetProperty } : {},
|
|
4011
|
+
...annotationProperties !== void 0 ? { annotationProperties } : {},
|
|
4012
|
+
isInstanceChild,
|
|
4013
|
+
...sourceChildId !== void 0 ? { sourceChildId } : {}
|
|
4014
|
+
};
|
|
4015
|
+
}
|
|
4016
|
+
|
|
3892
4017
|
// package.json
|
|
3893
|
-
var version2 = "0.
|
|
4018
|
+
var version2 = "0.10.0";
|
|
3894
4019
|
|
|
3895
4020
|
// src/core/engine/scoring.ts
|
|
3896
4021
|
function computeTotalScorePerCategory(configs) {
|
|
@@ -3919,6 +4044,9 @@ function calculateGrade(percentage) {
|
|
|
3919
4044
|
if (percentage >= 50) return "D";
|
|
3920
4045
|
return "F";
|
|
3921
4046
|
}
|
|
4047
|
+
function isReadyForCodeGen(grade) {
|
|
4048
|
+
return grade === "S" || grade === "A+" || grade === "A";
|
|
4049
|
+
}
|
|
3922
4050
|
function clamp(value, min, max) {
|
|
3923
4051
|
return Math.max(min, Math.min(max, value));
|
|
3924
4052
|
}
|
|
@@ -4066,14 +4194,24 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4066
4194
|
const id = issue.violation.ruleId;
|
|
4067
4195
|
issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
|
|
4068
4196
|
}
|
|
4069
|
-
const issues = result.issues.map((issue) =>
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4197
|
+
const issues = result.issues.map((issue) => {
|
|
4198
|
+
const applyContext = computeApplyContext(issue.violation);
|
|
4199
|
+
const suggestedName = issue.violation.suggestedName;
|
|
4200
|
+
return {
|
|
4201
|
+
ruleId: issue.violation.ruleId,
|
|
4202
|
+
...issue.violation.subType && { subType: issue.violation.subType },
|
|
4203
|
+
severity: issue.config.severity,
|
|
4204
|
+
nodeId: issue.violation.nodeId,
|
|
4205
|
+
nodePath: issue.violation.nodePath,
|
|
4206
|
+
message: issue.violation.message,
|
|
4207
|
+
applyStrategy: applyContext.applyStrategy,
|
|
4208
|
+
...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
|
|
4209
|
+
...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
|
|
4210
|
+
...suggestedName !== void 0 ? { suggestedName } : {},
|
|
4211
|
+
isInstanceChild: applyContext.isInstanceChild,
|
|
4212
|
+
...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
|
|
4213
|
+
};
|
|
4214
|
+
});
|
|
4077
4215
|
const json = {
|
|
4078
4216
|
version: version2,
|
|
4079
4217
|
analyzedAt: result.analyzedAt,
|
|
@@ -4082,6 +4220,8 @@ function buildResultJson(fileName, result, scores, options) {
|
|
|
4082
4220
|
nodeCount: result.nodeCount,
|
|
4083
4221
|
maxDepth: result.maxDepth,
|
|
4084
4222
|
issueCount: result.issues.length,
|
|
4223
|
+
isReadyForCodeGen: isReadyForCodeGen(scores.overall.grade),
|
|
4224
|
+
blockingIssueCount: scores.summary.blocking,
|
|
4085
4225
|
scores: {
|
|
4086
4226
|
overall: scores.overall,
|
|
4087
4227
|
categories: scores.byCategory
|
|
@@ -5470,20 +5610,369 @@ Report saved: ${outputPath}`);
|
|
|
5470
5610
|
}
|
|
5471
5611
|
});
|
|
5472
5612
|
}
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5613
|
+
z.object({
|
|
5614
|
+
ruleId: z.string(),
|
|
5615
|
+
question: z.string(),
|
|
5616
|
+
hint: z.string(),
|
|
5617
|
+
example: z.string()
|
|
5618
|
+
});
|
|
5619
|
+
var GOTCHA_QUESTIONS = {
|
|
5620
|
+
// ── Pixel Critical (blocking) ──
|
|
5621
|
+
"no-auto-layout": {
|
|
5622
|
+
ruleId: "no-auto-layout",
|
|
5623
|
+
question: 'Frame "{nodeName}" has no Auto Layout. How should this area be laid out?',
|
|
5624
|
+
hint: "Describe the flex direction, gap, and alignment",
|
|
5625
|
+
example: "Vertical flex, gap 16px, items centered"
|
|
5626
|
+
},
|
|
5627
|
+
"absolute-position-in-auto-layout": {
|
|
5628
|
+
ruleId: "absolute-position-in-auto-layout",
|
|
5629
|
+
question: '"{nodeName}" uses absolute positioning inside an Auto Layout parent. Is this an intentional overlay, or should it flow with the layout?',
|
|
5630
|
+
hint: "Specify if this is a badge/overlay, or should be part of the normal flow",
|
|
5631
|
+
example: "This is a notification badge \u2014 position absolute, top-right corner"
|
|
5632
|
+
},
|
|
5633
|
+
"non-layout-container": {
|
|
5634
|
+
ruleId: "non-layout-container",
|
|
5635
|
+
question: '"{nodeName}" is a Group/Section used as a layout container. What layout structure should it have?',
|
|
5636
|
+
hint: "Describe the intended layout: flex direction, wrap, gap",
|
|
5637
|
+
example: "Horizontal flex, gap 12px, wrap on mobile"
|
|
5638
|
+
},
|
|
5639
|
+
// ── Responsive Critical (risk) ──
|
|
5640
|
+
"fixed-size-in-auto-layout": {
|
|
5641
|
+
ruleId: "fixed-size-in-auto-layout",
|
|
5642
|
+
question: '"{nodeName}" has a fixed size inside Auto Layout. Should it be responsive?',
|
|
5643
|
+
hint: "Specify which axis should be flexible (width, height, or both)",
|
|
5644
|
+
example: "Width should FILL the parent, height can stay fixed"
|
|
5645
|
+
},
|
|
5646
|
+
"missing-size-constraint": {
|
|
5647
|
+
ruleId: "missing-size-constraint",
|
|
5648
|
+
question: '"{nodeName}" uses FILL sizing without min/max constraints. What are the size boundaries?',
|
|
5649
|
+
hint: "Provide min-width, max-width, or both",
|
|
5650
|
+
example: "min-width 320px, max-width 1200px"
|
|
5651
|
+
},
|
|
5652
|
+
// ── Code Quality (risk) ──
|
|
5653
|
+
"missing-component": {
|
|
5654
|
+
ruleId: "missing-component",
|
|
5655
|
+
question: '"{nodeName}" appears to be a repeated structure. Should it be a reusable component?',
|
|
5656
|
+
hint: "Describe if this should be extracted as a component and what props it needs",
|
|
5657
|
+
example: "Yes, extract as ProductCard component with title, image, and price props"
|
|
5658
|
+
},
|
|
5659
|
+
"detached-instance": {
|
|
5660
|
+
ruleId: "detached-instance",
|
|
5661
|
+
question: '"{nodeName}" looks like a detached component instance. Should it use the original component or is it a new variant?',
|
|
5662
|
+
hint: "Specify whether to restore the component link or create a new variant",
|
|
5663
|
+
example: "This is a new variant \u2014 create a 'compact' variant of the original component"
|
|
5664
|
+
},
|
|
5665
|
+
"variant-structure-mismatch": {
|
|
5666
|
+
ruleId: "variant-structure-mismatch",
|
|
5667
|
+
question: '"{nodeName}" has variants with different child structures. Which structure is the canonical one?',
|
|
5668
|
+
hint: "Describe which variant has the correct structure, or if they should all match",
|
|
5669
|
+
example: "Default variant is canonical \u2014 other variants should toggle child visibility instead of adding/removing elements"
|
|
5670
|
+
},
|
|
5671
|
+
"deep-nesting": {
|
|
5672
|
+
ruleId: "deep-nesting",
|
|
5673
|
+
question: '"{nodeName}" is deeply nested. Can some intermediate layers be flattened or extracted?',
|
|
5674
|
+
hint: "Identify which wrapper layers are unnecessary or should become sub-components",
|
|
5675
|
+
example: "The inner wrapper is just for spacing \u2014 flatten it and use padding instead"
|
|
5676
|
+
},
|
|
5677
|
+
// ── Token Management ──
|
|
5678
|
+
"raw-value": {
|
|
5679
|
+
ruleId: "raw-value",
|
|
5680
|
+
question: '"{nodeName}" uses raw values without design tokens. What tokens should be used?',
|
|
5681
|
+
hint: "Specify the token names or variable references for colors, fonts, spacing, etc.",
|
|
5682
|
+
example: "Use $color-primary for the fill, $font-body for the text style"
|
|
5683
|
+
},
|
|
5684
|
+
"irregular-spacing": {
|
|
5685
|
+
ruleId: "irregular-spacing",
|
|
5686
|
+
question: '"{nodeName}" has spacing values that are off the design grid. What should the correct spacing be?',
|
|
5687
|
+
hint: "Provide the intended spacing value aligned to the grid system",
|
|
5688
|
+
example: "Gap should be 16px (4pt grid), not 15px"
|
|
5689
|
+
},
|
|
5690
|
+
// ── Interaction ──
|
|
5691
|
+
"missing-interaction-state": {
|
|
5692
|
+
ruleId: "missing-interaction-state",
|
|
5693
|
+
question: '"{nodeName}" appears interactive but is missing state variants. What interaction states are needed?',
|
|
5694
|
+
hint: "List the needed states: Hover, Active, Disabled, Focus",
|
|
5695
|
+
example: "Needs Hover (darken 10%) and Disabled (opacity 50%, no pointer events)"
|
|
5696
|
+
},
|
|
5697
|
+
"missing-prototype": {
|
|
5698
|
+
ruleId: "missing-prototype",
|
|
5699
|
+
question: '"{nodeName}" looks interactive but has no prototype interaction. What should happen on click/interaction?',
|
|
5700
|
+
hint: "Describe the interaction behavior: navigation, overlay, state change, etc.",
|
|
5701
|
+
example: "On click, navigate to the product detail page"
|
|
5702
|
+
},
|
|
5703
|
+
// ── Semantic ──
|
|
5704
|
+
"non-standard-naming": {
|
|
5705
|
+
ruleId: "non-standard-naming",
|
|
5706
|
+
question: '"{nodeName}" uses non-standard state names. What naming convention should be followed?',
|
|
5707
|
+
hint: "Specify the expected state name format (e.g., Hover, Disabled, Active)",
|
|
5708
|
+
example: 'Use "Hover" instead of "hover_v1", "Disabled" instead of "off"'
|
|
5709
|
+
},
|
|
5710
|
+
"non-semantic-name": {
|
|
5711
|
+
ruleId: "non-semantic-name",
|
|
5712
|
+
question: '"{nodeName}" has a non-semantic name. What is the purpose of this element?',
|
|
5713
|
+
hint: "Provide a descriptive name that reflects the element's role in the UI",
|
|
5714
|
+
example: 'Rename "Frame 12" to "HeroSection" or "ProductGrid"'
|
|
5715
|
+
},
|
|
5716
|
+
"inconsistent-naming-convention": {
|
|
5717
|
+
ruleId: "inconsistent-naming-convention",
|
|
5718
|
+
question: '"{nodeName}" uses a different naming convention than its siblings. Which convention should be used?',
|
|
5719
|
+
hint: "Choose one: camelCase, kebab-case, PascalCase, or Title Case",
|
|
5720
|
+
example: "Use PascalCase for all component layers (e.g., CardTitle, CardBody)"
|
|
5721
|
+
}
|
|
5722
|
+
};
|
|
5723
|
+
|
|
5724
|
+
// src/core/gotcha/survey-generator.ts
|
|
5725
|
+
var NODE_PATH_SEPARATOR = " > ";
|
|
5726
|
+
function generateGotchaSurvey(result, scores) {
|
|
5727
|
+
const grade = scores.overall.grade;
|
|
5728
|
+
const relevantIssues = result.issues.filter(
|
|
5729
|
+
(issue) => issue.config.severity === "blocking" || issue.config.severity === "risk"
|
|
5730
|
+
);
|
|
5731
|
+
const deduped = deduplicateSiblingIssues(relevantIssues);
|
|
5732
|
+
const sorted = stableSortBySeverity(deduped);
|
|
5733
|
+
const questions = sorted.map((issue) => mapToQuestion(issue, result.file)).filter((q) => q !== null);
|
|
5734
|
+
return {
|
|
5735
|
+
designGrade: grade,
|
|
5736
|
+
isReadyForCodeGen: isReadyForCodeGen(grade),
|
|
5737
|
+
questions
|
|
5738
|
+
};
|
|
5739
|
+
}
|
|
5740
|
+
function deduplicateSiblingIssues(issues) {
|
|
5741
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5742
|
+
const result = [];
|
|
5743
|
+
for (const issue of issues) {
|
|
5744
|
+
const parentPath = getParentPath(issue.violation.nodePath);
|
|
5745
|
+
const key = `${parentPath}||${issue.violation.ruleId}`;
|
|
5746
|
+
if (!seen.has(key)) {
|
|
5747
|
+
seen.add(key);
|
|
5748
|
+
result.push(issue);
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
return result;
|
|
5752
|
+
}
|
|
5753
|
+
function getParentPath(nodePath) {
|
|
5754
|
+
const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
|
|
5755
|
+
if (lastSep === -1) return "";
|
|
5756
|
+
return nodePath.slice(0, lastSep);
|
|
5757
|
+
}
|
|
5758
|
+
function getNodeName(nodePath) {
|
|
5759
|
+
const lastSep = nodePath.lastIndexOf(NODE_PATH_SEPARATOR);
|
|
5760
|
+
if (lastSep === -1) return nodePath;
|
|
5761
|
+
return nodePath.slice(lastSep + NODE_PATH_SEPARATOR.length);
|
|
5762
|
+
}
|
|
5763
|
+
function stableSortBySeverity(issues) {
|
|
5764
|
+
const blocking = [];
|
|
5765
|
+
const risk = [];
|
|
5766
|
+
for (const issue of issues) {
|
|
5767
|
+
if (issue.config.severity === "blocking") {
|
|
5768
|
+
blocking.push(issue);
|
|
5769
|
+
} else {
|
|
5770
|
+
risk.push(issue);
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
return [...blocking, ...risk];
|
|
5774
|
+
}
|
|
5775
|
+
function mapToQuestion(issue, file) {
|
|
5776
|
+
const ruleId = issue.violation.ruleId;
|
|
5777
|
+
const template = GOTCHA_QUESTIONS[ruleId];
|
|
5778
|
+
if (!template) return null;
|
|
5779
|
+
const nodeName = getNodeName(issue.violation.nodePath);
|
|
5780
|
+
const instanceContext = buildInstanceContext(issue.violation.nodeId, file);
|
|
5781
|
+
const applyContext = computeApplyContext(
|
|
5782
|
+
issue.violation,
|
|
5783
|
+
instanceContext ?? void 0
|
|
5784
|
+
);
|
|
5785
|
+
const suggestedName = issue.violation.suggestedName;
|
|
5786
|
+
return {
|
|
5787
|
+
nodeId: issue.violation.nodeId,
|
|
5788
|
+
nodeName,
|
|
5789
|
+
ruleId,
|
|
5790
|
+
severity: issue.config.severity,
|
|
5791
|
+
question: template.question.replace("{nodeName}", nodeName),
|
|
5792
|
+
hint: template.hint,
|
|
5793
|
+
example: template.example,
|
|
5794
|
+
...instanceContext ? { instanceContext } : {},
|
|
5795
|
+
applyStrategy: applyContext.applyStrategy,
|
|
5796
|
+
...applyContext.targetProperty !== void 0 ? { targetProperty: applyContext.targetProperty } : {},
|
|
5797
|
+
...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
|
|
5798
|
+
...suggestedName !== void 0 ? { suggestedName } : {},
|
|
5799
|
+
isInstanceChild: applyContext.isInstanceChild,
|
|
5800
|
+
...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
|
|
5801
|
+
};
|
|
5802
|
+
}
|
|
5803
|
+
function buildInstanceContext(nodeId, file) {
|
|
5804
|
+
const parts = parseInstanceChildNodeId(nodeId);
|
|
5805
|
+
if (!parts) return null;
|
|
5806
|
+
const parentInstance = findNodeById2(file.document, parts.parentInstanceId);
|
|
5807
|
+
const componentId = parentInstance?.componentId;
|
|
5808
|
+
const componentName = componentId ? file.components[componentId]?.name : void 0;
|
|
5809
|
+
return {
|
|
5810
|
+
parentInstanceNodeId: parts.parentInstanceId,
|
|
5811
|
+
sourceNodeId: parts.sourceNodeId,
|
|
5812
|
+
...componentId ? { sourceComponentId: componentId } : {},
|
|
5813
|
+
...componentName ? { sourceComponentName: componentName } : {}
|
|
5814
|
+
};
|
|
5815
|
+
}
|
|
5816
|
+
function findNodeById2(node, id) {
|
|
5817
|
+
if (node.id === id) return node;
|
|
5818
|
+
if (node.children) {
|
|
5819
|
+
for (const child of node.children) {
|
|
5820
|
+
const found = findNodeById2(child, id);
|
|
5821
|
+
if (found) return found;
|
|
5822
|
+
}
|
|
5823
|
+
}
|
|
5824
|
+
return null;
|
|
5825
|
+
}
|
|
5826
|
+
|
|
5827
|
+
// src/cli/commands/gotcha-survey.ts
|
|
5828
|
+
var GotchaSurveyOptionsSchema = z.object({
|
|
5829
|
+
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
|
|
5476
5830
|
token: z.string().optional(),
|
|
5477
|
-
|
|
5478
|
-
|
|
5831
|
+
config: z.string().optional(),
|
|
5832
|
+
targetNodeId: z.string().optional(),
|
|
5833
|
+
json: z.boolean().optional()
|
|
5834
|
+
});
|
|
5835
|
+
async function runGotchaSurvey(input, options) {
|
|
5836
|
+
const { file, nodeId } = await loadFile(input, options.token);
|
|
5837
|
+
const effectiveNodeId = options.targetNodeId ?? nodeId;
|
|
5838
|
+
let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
|
|
5839
|
+
if (options.config) {
|
|
5840
|
+
const configFile = await loadConfigFile(options.config);
|
|
5841
|
+
configs = mergeConfigs(configs, configFile);
|
|
5842
|
+
}
|
|
5843
|
+
const result = analyzeFile(file, {
|
|
5844
|
+
configs,
|
|
5845
|
+
...effectiveNodeId ? { targetNodeId: effectiveNodeId } : {}
|
|
5846
|
+
});
|
|
5847
|
+
const scores = calculateScores(result, configs);
|
|
5848
|
+
return generateGotchaSurvey(result, scores);
|
|
5849
|
+
}
|
|
5850
|
+
function formatHumanSummary(survey) {
|
|
5851
|
+
const lines = [
|
|
5852
|
+
`Design grade: ${survey.designGrade}`,
|
|
5853
|
+
`Ready for code generation: ${survey.isReadyForCodeGen ? "yes" : "no"}`,
|
|
5854
|
+
`Questions: ${survey.questions.length}`
|
|
5855
|
+
];
|
|
5856
|
+
if (survey.questions.length > 0) {
|
|
5857
|
+
lines.push("");
|
|
5858
|
+
lines.push("Use --json to get the full GotchaSurvey JSON for programmatic use.");
|
|
5859
|
+
}
|
|
5860
|
+
return lines.join("\n");
|
|
5861
|
+
}
|
|
5862
|
+
function registerGotchaSurvey(cli2) {
|
|
5863
|
+
cli2.command("gotcha-survey <input>", "Generate a gotcha survey from a Figma design analysis").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--target-node-id <id>", "Scope analysis to a specific node ID").option("--json", "Output GotchaSurvey JSON to stdout (same format as MCP)").example(" canicode gotcha-survey https://www.figma.com/design/ABC123/MyDesign --json").example(" canicode gotcha-survey ./fixtures/my-design --json").action(async (input, rawOptions) => {
|
|
5864
|
+
const parseResult = GotchaSurveyOptionsSchema.safeParse(rawOptions);
|
|
5865
|
+
if (!parseResult.success) {
|
|
5866
|
+
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
5867
|
+
console.error(`
|
|
5868
|
+
Invalid options:
|
|
5869
|
+
${msg}`);
|
|
5870
|
+
process.exit(1);
|
|
5871
|
+
}
|
|
5872
|
+
const options = parseResult.data;
|
|
5873
|
+
const analysisStart = Date.now();
|
|
5874
|
+
trackEvent(EVENTS.ANALYSIS_STARTED, {
|
|
5875
|
+
source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma",
|
|
5876
|
+
tool: "gotcha-survey"
|
|
5877
|
+
});
|
|
5878
|
+
const log = options.json ? console.error.bind(console) : console.log.bind(console);
|
|
5879
|
+
try {
|
|
5880
|
+
if (!options.token && !getFigmaToken() && !isJsonFile(input) && !isFixtureDir(input)) {
|
|
5881
|
+
throw new Error(
|
|
5882
|
+
"canicode is not configured. Run 'canicode init --token YOUR_TOKEN' first."
|
|
5883
|
+
);
|
|
5884
|
+
}
|
|
5885
|
+
const survey = await runGotchaSurvey(input, options);
|
|
5886
|
+
if (options.json) {
|
|
5887
|
+
console.log(JSON.stringify(survey, null, 2));
|
|
5888
|
+
} else {
|
|
5889
|
+
log(formatHumanSummary(survey));
|
|
5890
|
+
}
|
|
5891
|
+
trackEvent(EVENTS.ANALYSIS_COMPLETED, {
|
|
5892
|
+
grade: survey.designGrade,
|
|
5893
|
+
questionCount: survey.questions.length,
|
|
5894
|
+
isReadyForCodeGen: survey.isReadyForCodeGen,
|
|
5895
|
+
duration: Date.now() - analysisStart,
|
|
5896
|
+
tool: "gotcha-survey"
|
|
5897
|
+
});
|
|
5898
|
+
} catch (error) {
|
|
5899
|
+
trackError(
|
|
5900
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
5901
|
+
{ command: "gotcha-survey", input }
|
|
5902
|
+
);
|
|
5903
|
+
trackEvent(EVENTS.ANALYSIS_FAILED, {
|
|
5904
|
+
error: error instanceof Error ? error.message : String(error),
|
|
5905
|
+
duration: Date.now() - analysisStart,
|
|
5906
|
+
tool: "gotcha-survey"
|
|
5907
|
+
});
|
|
5908
|
+
console.error(
|
|
5909
|
+
"\nError:",
|
|
5910
|
+
error instanceof Error ? error.message : String(error)
|
|
5911
|
+
);
|
|
5912
|
+
process.exitCode = 1;
|
|
5913
|
+
}
|
|
5914
|
+
});
|
|
5915
|
+
}
|
|
5916
|
+
function registerDesignTree(cli2) {
|
|
5917
|
+
cli2.command(
|
|
5918
|
+
"design-tree <input>",
|
|
5919
|
+
"Generate a DOM-like design tree from a Figma file or fixture"
|
|
5920
|
+
).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <path>", "Output file path (default: stdout)").option("--vector-dir <path>", "Directory with SVG files for VECTOR nodes (auto-detected from fixture path)").option("--image-dir <path>", "Directory with image PNGs for IMAGE fill nodes (auto-detected from fixture path)").example(" canicode design-tree ./fixtures/my-design").example(" canicode design-tree https://www.figma.com/design/ABC/File?node-id=1-234 --output tree.txt").action(async (input, options) => {
|
|
5921
|
+
try {
|
|
5922
|
+
const { file } = await loadFile(input, options.token);
|
|
5923
|
+
const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input);
|
|
5924
|
+
let vectorDir = options.vectorDir;
|
|
5925
|
+
if (!vectorDir) {
|
|
5926
|
+
const autoDir = resolve(fixtureBase, "vectors");
|
|
5927
|
+
if (existsSync(autoDir)) vectorDir = autoDir;
|
|
5928
|
+
}
|
|
5929
|
+
let imageDir = options.imageDir;
|
|
5930
|
+
if (!imageDir) {
|
|
5931
|
+
const autoDir = resolve(fixtureBase, "images");
|
|
5932
|
+
if (existsSync(autoDir)) imageDir = autoDir;
|
|
5933
|
+
}
|
|
5934
|
+
const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
|
|
5935
|
+
const treeOptions = {
|
|
5936
|
+
...vectorDir ? { vectorDir } : {},
|
|
5937
|
+
...imageDir ? { imageDir } : {}
|
|
5938
|
+
};
|
|
5939
|
+
const stats = generateDesignTreeWithStats2(file, treeOptions);
|
|
5940
|
+
if (options.output) {
|
|
5941
|
+
const outputDir = dirname(resolve(options.output));
|
|
5942
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
5943
|
+
const { writeFile: writeFileAsync } = await import('fs/promises');
|
|
5944
|
+
await writeFileAsync(resolve(options.output), stats.tree, "utf-8");
|
|
5945
|
+
console.log(`Design tree saved: ${resolve(options.output)} (${Math.round(stats.bytes / 1024)}KB, ~${stats.estimatedTokens} tokens)`);
|
|
5946
|
+
} else {
|
|
5947
|
+
console.log(stats.tree);
|
|
5948
|
+
}
|
|
5949
|
+
} catch (error) {
|
|
5950
|
+
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
5951
|
+
process.exitCode = 1;
|
|
5952
|
+
}
|
|
5953
|
+
});
|
|
5954
|
+
}
|
|
5955
|
+
var positiveCliNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v > 0, "must be positive");
|
|
5956
|
+
var figmaScaleNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v >= 1, "must be >= 1");
|
|
5957
|
+
var VisualCompareCliOptionsSchema = z.object({
|
|
5958
|
+
figmaUrl: z.string().optional(),
|
|
5959
|
+
figmaScreenshot: z.string().optional(),
|
|
5960
|
+
token: z.string().optional(),
|
|
5961
|
+
output: z.string().optional(),
|
|
5962
|
+
width: positiveCliNumber.optional(),
|
|
5963
|
+
height: positiveCliNumber.optional(),
|
|
5964
|
+
figmaScale: figmaScaleNumber.optional(),
|
|
5965
|
+
expandRoot: z.boolean().optional()
|
|
5479
5966
|
});
|
|
5480
|
-
|
|
5967
|
+
|
|
5968
|
+
// src/cli/commands/visual-compare.ts
|
|
5969
|
+
function registerVisualCompare(cli2) {
|
|
5481
5970
|
cli2.command(
|
|
5482
|
-
"
|
|
5483
|
-
"
|
|
5484
|
-
).option("--
|
|
5971
|
+
"visual-compare <codePath>",
|
|
5972
|
+
"Compare rendered code against Figma screenshot (pixel-level similarity)"
|
|
5973
|
+
).option("--figma-url <url>", "Figma URL with node-id (required for API fetch)").option("--figma-screenshot <path>", "Local Figma screenshot file (skips API fetch)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory for screenshots and diff (default: /tmp/canicode-visual-compare)").option("--width <px>", "Logical viewport width in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--height <px>", "Logical viewport height in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--figma-scale <n>", "Figma export scale (default: 2, matches calibrate-save-fixture / @2x PNGs)").option("--expand-root", "Replace root element's fixed width with 100% before rendering (for responsive comparison)").example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'").action(async (codePath, rawOptions) => {
|
|
5485
5974
|
try {
|
|
5486
|
-
const parseResult =
|
|
5975
|
+
const parseResult = VisualCompareCliOptionsSchema.safeParse(rawOptions);
|
|
5487
5976
|
if (!parseResult.success) {
|
|
5488
5977
|
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
5489
5978
|
console.error(`
|
|
@@ -5492,453 +5981,20 @@ ${msg}`);
|
|
|
5492
5981
|
process.exit(1);
|
|
5493
5982
|
}
|
|
5494
5983
|
const options = parseResult.data;
|
|
5495
|
-
if (!
|
|
5496
|
-
|
|
5984
|
+
if (!options.figmaUrl && !options.figmaScreenshot) {
|
|
5985
|
+
console.error("Error: --figma-url or --figma-screenshot is required");
|
|
5986
|
+
process.exitCode = 1;
|
|
5987
|
+
return;
|
|
5497
5988
|
}
|
|
5498
|
-
if (options.
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
|
|
5502
|
-
process.exit(1);
|
|
5503
|
-
}
|
|
5989
|
+
if (options.figmaUrl && !parseFigmaUrl(options.figmaUrl).nodeId) {
|
|
5990
|
+
console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
|
|
5991
|
+
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
|
|
5504
5992
|
}
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
console.
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
file.sourceUrl = input;
|
|
5511
|
-
const fixtureName = options.name ?? file.fileKey;
|
|
5512
|
-
const fixtureDir = resolve(options.output ?? `fixtures/${fixtureName}`);
|
|
5513
|
-
mkdirSync(fixtureDir, { recursive: true });
|
|
5514
|
-
const figmaTokenForComponents = options.token ?? getFigmaToken();
|
|
5515
|
-
if (figmaTokenForComponents) {
|
|
5516
|
-
const { FigmaClient: FC } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
5517
|
-
const { resolveComponentDefinitions: resolveComponentDefinitions2, resolveInteractionDestinations: resolveInteractionDestinations2 } = await Promise.resolve().then(() => (init_component_resolver(), component_resolver_exports));
|
|
5518
|
-
const componentClient = new FC({ token: figmaTokenForComponents });
|
|
5519
|
-
try {
|
|
5520
|
-
const definitions = await resolveComponentDefinitions2(componentClient, file.fileKey, file.document);
|
|
5521
|
-
const count = Object.keys(definitions).length;
|
|
5522
|
-
if (count > 0) {
|
|
5523
|
-
file.componentDefinitions = definitions;
|
|
5524
|
-
console.log(`Resolved ${count} component master node tree(s)`);
|
|
5525
|
-
}
|
|
5526
|
-
const interactionDests = await resolveInteractionDestinations2(componentClient, file.fileKey, file.document, file.componentDefinitions);
|
|
5527
|
-
const destCount = Object.keys(interactionDests).length;
|
|
5528
|
-
if (destCount > 0) {
|
|
5529
|
-
file.interactionDestinations = interactionDests;
|
|
5530
|
-
console.log(`Resolved ${destCount} interaction destination(s)`);
|
|
5531
|
-
}
|
|
5532
|
-
} catch {
|
|
5533
|
-
console.warn("Warning: failed to resolve component definitions (continuing)");
|
|
5534
|
-
}
|
|
5535
|
-
}
|
|
5536
|
-
const dataPath = resolve(fixtureDir, "data.json");
|
|
5537
|
-
await writeFile(dataPath, JSON.stringify(file, null, 2), "utf-8");
|
|
5538
|
-
console.log(`Fixture saved: ${fixtureDir}/`);
|
|
5539
|
-
console.log(` data.json: ${file.name} (${countNodes2(file.document)} nodes)`);
|
|
5540
|
-
const figmaToken = options.token ?? getFigmaToken();
|
|
5541
|
-
if (figmaToken) {
|
|
5542
|
-
const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
5543
|
-
const client = new FigmaClient2({ token: figmaToken });
|
|
5544
|
-
const { nodeId } = parseFigmaUrl(input);
|
|
5545
|
-
const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
|
|
5546
|
-
try {
|
|
5547
|
-
const imageUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
|
|
5548
|
-
const url = imageUrls[rootNodeId];
|
|
5549
|
-
if (url) {
|
|
5550
|
-
const resp = await fetch(url);
|
|
5551
|
-
if (resp.ok) {
|
|
5552
|
-
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
5553
|
-
const { writeFile: writeFileSync6 } = await import('fs/promises');
|
|
5554
|
-
await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
|
|
5555
|
-
console.log(` screenshot.png: saved`);
|
|
5556
|
-
}
|
|
5557
|
-
}
|
|
5558
|
-
} catch {
|
|
5559
|
-
console.warn(" screenshot.png: failed to download (continuing)");
|
|
5560
|
-
}
|
|
5561
|
-
const vectorNodes = collectVectorNodes(file.document);
|
|
5562
|
-
if (vectorNodes.length > 0) {
|
|
5563
|
-
const vectorDir = resolve(fixtureDir, "vectors");
|
|
5564
|
-
mkdirSync(vectorDir, { recursive: true });
|
|
5565
|
-
const svgUrls = await client.getNodeImages(
|
|
5566
|
-
file.fileKey,
|
|
5567
|
-
vectorNodes.map((n) => n.id),
|
|
5568
|
-
{ format: "svg" }
|
|
5569
|
-
);
|
|
5570
|
-
const mapping = {};
|
|
5571
|
-
const usedNames = /* @__PURE__ */ new Map();
|
|
5572
|
-
let downloaded = 0;
|
|
5573
|
-
for (const { id, name } of vectorNodes) {
|
|
5574
|
-
let base = sanitizeFilename(name);
|
|
5575
|
-
const count = usedNames.get(base) ?? 0;
|
|
5576
|
-
usedNames.set(base, count + 1);
|
|
5577
|
-
if (count > 0) base = `${base}-${count + 1}`;
|
|
5578
|
-
const filename = `${base}.svg`;
|
|
5579
|
-
mapping[id] = filename;
|
|
5580
|
-
const svgUrl = svgUrls[id];
|
|
5581
|
-
if (!svgUrl) continue;
|
|
5582
|
-
try {
|
|
5583
|
-
const resp = await fetch(svgUrl);
|
|
5584
|
-
if (resp.ok) {
|
|
5585
|
-
const svg = await resp.text();
|
|
5586
|
-
await writeFile(resolve(vectorDir, filename), svg, "utf-8");
|
|
5587
|
-
downloaded++;
|
|
5588
|
-
}
|
|
5589
|
-
} catch {
|
|
5590
|
-
}
|
|
5591
|
-
}
|
|
5592
|
-
await writeFile(resolve(vectorDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
5593
|
-
console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
|
|
5594
|
-
}
|
|
5595
|
-
const imageNodes = collectImageNodes(file.document);
|
|
5596
|
-
if (imageNodes.length > 0) {
|
|
5597
|
-
const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
|
|
5598
|
-
const imageDir = resolve(fixtureDir, "images");
|
|
5599
|
-
mkdirSync(imageDir, { recursive: true });
|
|
5600
|
-
const imageFills = await client.getImageFills(file.fileKey);
|
|
5601
|
-
const usedNames = /* @__PURE__ */ new Map();
|
|
5602
|
-
const mapping = {};
|
|
5603
|
-
let imgDownloaded = 0;
|
|
5604
|
-
for (const { id, name, imageRef } of imageNodes) {
|
|
5605
|
-
let base = sanitizeFilename(name);
|
|
5606
|
-
const count = usedNames.get(base) ?? 0;
|
|
5607
|
-
usedNames.set(base, count + 1);
|
|
5608
|
-
if (count > 0) base = `${base}-${count + 1}`;
|
|
5609
|
-
const filename = `${base}@${imgScale}x.png`;
|
|
5610
|
-
mapping[id] = filename;
|
|
5611
|
-
if (!imageRef) continue;
|
|
5612
|
-
const imgUrl = imageFills[imageRef];
|
|
5613
|
-
if (!imgUrl) continue;
|
|
5614
|
-
try {
|
|
5615
|
-
const resp = await fetch(imgUrl);
|
|
5616
|
-
if (resp.ok) {
|
|
5617
|
-
const buf = Buffer.from(await resp.arrayBuffer());
|
|
5618
|
-
await writeFile(resolve(imageDir, filename), buf);
|
|
5619
|
-
imgDownloaded++;
|
|
5620
|
-
}
|
|
5621
|
-
} catch {
|
|
5622
|
-
}
|
|
5623
|
-
}
|
|
5624
|
-
await writeFile(
|
|
5625
|
-
resolve(imageDir, "mapping.json"),
|
|
5626
|
-
JSON.stringify(mapping, null, 2),
|
|
5627
|
-
"utf-8"
|
|
5628
|
-
);
|
|
5629
|
-
console.log(` images/: ${imgDownloaded}/${imageNodes.length} PNGs (@${imgScale}x)`);
|
|
5630
|
-
}
|
|
5631
|
-
}
|
|
5632
|
-
} catch (error) {
|
|
5633
|
-
console.error(
|
|
5634
|
-
"\nError:",
|
|
5635
|
-
error instanceof Error ? error.message : String(error)
|
|
5636
|
-
);
|
|
5637
|
-
process.exitCode = 1;
|
|
5638
|
-
}
|
|
5639
|
-
});
|
|
5640
|
-
}
|
|
5641
|
-
function registerDesignTree(cli2) {
|
|
5642
|
-
cli2.command(
|
|
5643
|
-
"design-tree <input>",
|
|
5644
|
-
"Generate a DOM-like design tree from a Figma file or fixture"
|
|
5645
|
-
).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <path>", "Output file path (default: stdout)").option("--vector-dir <path>", "Directory with SVG files for VECTOR nodes (auto-detected from fixture path)").option("--image-dir <path>", "Directory with image PNGs for IMAGE fill nodes (auto-detected from fixture path)").example(" canicode design-tree ./fixtures/my-design").example(" canicode design-tree https://www.figma.com/design/ABC/File?node-id=1-234 --output tree.txt").action(async (input, options) => {
|
|
5646
|
-
try {
|
|
5647
|
-
const { file } = await loadFile(input, options.token);
|
|
5648
|
-
const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input);
|
|
5649
|
-
let vectorDir = options.vectorDir;
|
|
5650
|
-
if (!vectorDir) {
|
|
5651
|
-
const autoDir = resolve(fixtureBase, "vectors");
|
|
5652
|
-
if (existsSync(autoDir)) vectorDir = autoDir;
|
|
5653
|
-
}
|
|
5654
|
-
let imageDir = options.imageDir;
|
|
5655
|
-
if (!imageDir) {
|
|
5656
|
-
const autoDir = resolve(fixtureBase, "images");
|
|
5657
|
-
if (existsSync(autoDir)) imageDir = autoDir;
|
|
5658
|
-
}
|
|
5659
|
-
const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
|
|
5660
|
-
const treeOptions = {
|
|
5661
|
-
...vectorDir ? { vectorDir } : {},
|
|
5662
|
-
...imageDir ? { imageDir } : {}
|
|
5663
|
-
};
|
|
5664
|
-
const stats = generateDesignTreeWithStats2(file, treeOptions);
|
|
5665
|
-
if (options.output) {
|
|
5666
|
-
const outputDir = dirname(resolve(options.output));
|
|
5667
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
5668
|
-
const { writeFile: writeFileAsync } = await import('fs/promises');
|
|
5669
|
-
await writeFileAsync(resolve(options.output), stats.tree, "utf-8");
|
|
5670
|
-
console.log(`Design tree saved: ${resolve(options.output)} (${Math.round(stats.bytes / 1024)}KB, ~${stats.estimatedTokens} tokens)`);
|
|
5671
|
-
} else {
|
|
5672
|
-
console.log(stats.tree);
|
|
5673
|
-
}
|
|
5674
|
-
} catch (error) {
|
|
5675
|
-
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
5676
|
-
process.exitCode = 1;
|
|
5677
|
-
}
|
|
5678
|
-
});
|
|
5679
|
-
}
|
|
5680
|
-
var ImplementOptionsSchema = z.object({
|
|
5681
|
-
token: z.string().optional(),
|
|
5682
|
-
output: z.string().optional(),
|
|
5683
|
-
prompt: z.string().optional(),
|
|
5684
|
-
imageScale: z.string().optional()
|
|
5685
|
-
});
|
|
5686
|
-
function registerImplement(cli2) {
|
|
5687
|
-
cli2.command(
|
|
5688
|
-
"implement <input>",
|
|
5689
|
-
"Prepare design-to-code package: analysis + design tree + assets + prompt"
|
|
5690
|
-
).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory (default: ./canicode-implement/)").option("--prompt <path>", "Custom prompt file (default: built-in HTML+CSS prompt)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode implement ./fixtures/my-design").example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3").action(async (input, rawOptions) => {
|
|
5691
|
-
try {
|
|
5692
|
-
const parseResult = ImplementOptionsSchema.safeParse(rawOptions);
|
|
5693
|
-
if (!parseResult.success) {
|
|
5694
|
-
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
5695
|
-
console.error(`
|
|
5696
|
-
Invalid options:
|
|
5697
|
-
${msg}`);
|
|
5698
|
-
process.exit(1);
|
|
5699
|
-
}
|
|
5700
|
-
const options = parseResult.data;
|
|
5701
|
-
if (options.imageScale !== void 0) {
|
|
5702
|
-
const scale = Number(options.imageScale);
|
|
5703
|
-
if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
|
|
5704
|
-
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
|
|
5705
|
-
process.exit(1);
|
|
5706
|
-
}
|
|
5707
|
-
}
|
|
5708
|
-
if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
|
|
5709
|
-
console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file.");
|
|
5710
|
-
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
|
|
5711
|
-
}
|
|
5712
|
-
const outputDir = resolve(options.output ?? "canicode-implement");
|
|
5713
|
-
mkdirSync(outputDir, { recursive: true });
|
|
5714
|
-
console.log("\nPreparing implementation package...\n");
|
|
5715
|
-
const { file } = await loadFile(input, options.token);
|
|
5716
|
-
console.log(`Design: ${file.name}`);
|
|
5717
|
-
const result = analyzeFile(file);
|
|
5718
|
-
const scores = calculateScores(result);
|
|
5719
|
-
const resultJson = buildResultJson(file.name, result, scores, { fileKey: file.fileKey });
|
|
5720
|
-
await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8");
|
|
5721
|
-
console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`);
|
|
5722
|
-
const fixtureBase = isJsonFile(input) || isFixtureDir(input) ? isJsonFile(input) ? dirname(resolve(input)) : resolve(input) : void 0;
|
|
5723
|
-
let vectorDir = fixtureBase ? resolve(fixtureBase, "vectors") : void 0;
|
|
5724
|
-
let imageDir = fixtureBase ? resolve(fixtureBase, "images") : void 0;
|
|
5725
|
-
if (vectorDir && existsSync(vectorDir)) {
|
|
5726
|
-
const vecOutputDir = resolve(outputDir, "vectors");
|
|
5727
|
-
mkdirSync(vecOutputDir, { recursive: true });
|
|
5728
|
-
const { readdirSync: readdirSync3, copyFileSync: copyFileSync2 } = await import('fs');
|
|
5729
|
-
const vecFiles = readdirSync3(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
|
|
5730
|
-
for (const f of vecFiles) {
|
|
5731
|
-
copyFileSync2(resolve(vectorDir, f), resolve(vecOutputDir, f));
|
|
5732
|
-
}
|
|
5733
|
-
vectorDir = vecOutputDir;
|
|
5734
|
-
console.log(` vectors/: ${vecFiles.length} SVGs copied`);
|
|
5735
|
-
}
|
|
5736
|
-
if (imageDir && existsSync(imageDir)) {
|
|
5737
|
-
const imgOutputDir = resolve(outputDir, "images");
|
|
5738
|
-
mkdirSync(imgOutputDir, { recursive: true });
|
|
5739
|
-
const { readdirSync: readdirSync3, copyFileSync: copyFileSync2 } = await import('fs');
|
|
5740
|
-
const imgFiles = readdirSync3(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
|
|
5741
|
-
for (const f of imgFiles) {
|
|
5742
|
-
copyFileSync2(resolve(imageDir, f), resolve(imgOutputDir, f));
|
|
5743
|
-
}
|
|
5744
|
-
imageDir = imgOutputDir;
|
|
5745
|
-
const pngCount = imgFiles.filter((f) => f.endsWith(".png")).length;
|
|
5746
|
-
console.log(` images/: ${pngCount} assets copied`);
|
|
5747
|
-
}
|
|
5748
|
-
if (isFigmaUrl(input) && !fixtureBase) {
|
|
5749
|
-
const figmaToken = options.token ?? getFigmaToken();
|
|
5750
|
-
if (figmaToken) {
|
|
5751
|
-
const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
|
|
5752
|
-
const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
5753
|
-
const client = new FigmaClient2({ token: figmaToken });
|
|
5754
|
-
const { nodeId } = parseFigmaUrl(input);
|
|
5755
|
-
const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
|
|
5756
|
-
try {
|
|
5757
|
-
const screenshotUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
|
|
5758
|
-
const screenshotUrl = screenshotUrls[rootNodeId];
|
|
5759
|
-
if (screenshotUrl) {
|
|
5760
|
-
const resp = await fetch(screenshotUrl);
|
|
5761
|
-
if (resp.ok) {
|
|
5762
|
-
const buf = Buffer.from(await resp.arrayBuffer());
|
|
5763
|
-
await writeFile(resolve(outputDir, "screenshot.png"), buf);
|
|
5764
|
-
console.log(` screenshot.png: saved`);
|
|
5765
|
-
}
|
|
5766
|
-
}
|
|
5767
|
-
} catch {
|
|
5768
|
-
console.warn(" screenshot.png: failed to download (continuing)");
|
|
5769
|
-
}
|
|
5770
|
-
const vectorNodes = collectVectorNodes(file.document);
|
|
5771
|
-
if (vectorNodes.length > 0) {
|
|
5772
|
-
const vecOutDir = resolve(outputDir, "vectors");
|
|
5773
|
-
mkdirSync(vecOutDir, { recursive: true });
|
|
5774
|
-
try {
|
|
5775
|
-
const svgUrls = await client.getNodeImages(
|
|
5776
|
-
file.fileKey,
|
|
5777
|
-
vectorNodes.map((n) => n.id),
|
|
5778
|
-
{ format: "svg" }
|
|
5779
|
-
);
|
|
5780
|
-
const mapping = {};
|
|
5781
|
-
const usedNames = /* @__PURE__ */ new Map();
|
|
5782
|
-
let downloaded = 0;
|
|
5783
|
-
for (const { id, name } of vectorNodes) {
|
|
5784
|
-
let base = sanitizeFilename(name);
|
|
5785
|
-
const count = usedNames.get(base) ?? 0;
|
|
5786
|
-
usedNames.set(base, count + 1);
|
|
5787
|
-
if (count > 0) base = `${base}-${count + 1}`;
|
|
5788
|
-
const filename = `${base}.svg`;
|
|
5789
|
-
mapping[id] = filename;
|
|
5790
|
-
const svgUrl = svgUrls[id];
|
|
5791
|
-
if (!svgUrl) continue;
|
|
5792
|
-
try {
|
|
5793
|
-
const resp = await fetch(svgUrl);
|
|
5794
|
-
if (resp.ok) {
|
|
5795
|
-
const svg = await resp.text();
|
|
5796
|
-
await writeFile(resolve(vecOutDir, filename), svg, "utf-8");
|
|
5797
|
-
downloaded++;
|
|
5798
|
-
}
|
|
5799
|
-
} catch {
|
|
5800
|
-
}
|
|
5801
|
-
}
|
|
5802
|
-
await writeFile(resolve(vecOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
5803
|
-
console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
|
|
5804
|
-
} catch {
|
|
5805
|
-
console.warn(" vectors/: failed to download (continuing)");
|
|
5806
|
-
}
|
|
5807
|
-
}
|
|
5808
|
-
const imgNodes = collectImageNodes(file.document);
|
|
5809
|
-
if (imgNodes.length > 0) {
|
|
5810
|
-
const imgOutDir = resolve(outputDir, "images");
|
|
5811
|
-
mkdirSync(imgOutDir, { recursive: true });
|
|
5812
|
-
try {
|
|
5813
|
-
const imageFills = await client.getImageFills(file.fileKey);
|
|
5814
|
-
const mapping = {};
|
|
5815
|
-
const usedNames = /* @__PURE__ */ new Map();
|
|
5816
|
-
let downloaded = 0;
|
|
5817
|
-
for (const { id, name, imageRef } of imgNodes) {
|
|
5818
|
-
let base = sanitizeFilename(name);
|
|
5819
|
-
const count = usedNames.get(base) ?? 0;
|
|
5820
|
-
usedNames.set(base, count + 1);
|
|
5821
|
-
if (count > 0) base = `${base}-${count + 1}`;
|
|
5822
|
-
const filename = `${base}@${imgScale}x.png`;
|
|
5823
|
-
mapping[id] = filename;
|
|
5824
|
-
if (!imageRef) continue;
|
|
5825
|
-
const imgUrl = imageFills[imageRef];
|
|
5826
|
-
if (!imgUrl) continue;
|
|
5827
|
-
try {
|
|
5828
|
-
const resp = await fetch(imgUrl);
|
|
5829
|
-
if (resp.ok) {
|
|
5830
|
-
const buf = Buffer.from(await resp.arrayBuffer());
|
|
5831
|
-
await writeFile(resolve(imgOutDir, filename), buf);
|
|
5832
|
-
downloaded++;
|
|
5833
|
-
}
|
|
5834
|
-
} catch {
|
|
5835
|
-
}
|
|
5836
|
-
}
|
|
5837
|
-
await writeFile(resolve(imgOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
5838
|
-
imageDir = imgOutDir;
|
|
5839
|
-
console.log(` images/: ${downloaded}/${imgNodes.length} PNGs (@${imgScale}x)`);
|
|
5840
|
-
} catch {
|
|
5841
|
-
console.warn(" images/: failed to download (continuing)");
|
|
5842
|
-
}
|
|
5843
|
-
}
|
|
5844
|
-
const vecOutCheck = resolve(outputDir, "vectors");
|
|
5845
|
-
if (existsSync(vecOutCheck)) vectorDir = vecOutCheck;
|
|
5846
|
-
}
|
|
5847
|
-
}
|
|
5848
|
-
const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
|
|
5849
|
-
const treeOptions = {
|
|
5850
|
-
...vectorDir && existsSync(vectorDir) ? { vectorDir } : {},
|
|
5851
|
-
...imageDir && existsSync(imageDir) ? { imageDir } : {}
|
|
5852
|
-
};
|
|
5853
|
-
const stats = generateDesignTreeWithStats2(file, treeOptions);
|
|
5854
|
-
await writeFile(resolve(outputDir, "design-tree.txt"), stats.tree, "utf-8");
|
|
5855
|
-
console.log(` design-tree.txt: ~${stats.estimatedTokens} tokens`);
|
|
5856
|
-
if (options.prompt) {
|
|
5857
|
-
const { readFile: rf } = await import('fs/promises');
|
|
5858
|
-
const customPrompt = await rf(resolve(options.prompt), "utf-8");
|
|
5859
|
-
await writeFile(resolve(outputDir, "PROMPT.md"), customPrompt, "utf-8");
|
|
5860
|
-
console.log(` PROMPT.md: custom (${options.prompt})`);
|
|
5861
|
-
} else {
|
|
5862
|
-
const { readFile: rf } = await import('fs/promises');
|
|
5863
|
-
const { dirname: dirnameFn, resolve: resolveFn } = await import('path');
|
|
5864
|
-
const { fileURLToPath } = await import('url');
|
|
5865
|
-
const cliDir = dirnameFn(fileURLToPath(import.meta.url));
|
|
5866
|
-
const projectRoot = resolveFn(cliDir, "../..");
|
|
5867
|
-
const altRoot = resolveFn(cliDir, "..");
|
|
5868
|
-
let prompt = "";
|
|
5869
|
-
for (const root of [projectRoot, altRoot]) {
|
|
5870
|
-
const p = resolveFn(root, ".claude/skills/design-to-code/PROMPT.md");
|
|
5871
|
-
try {
|
|
5872
|
-
prompt = await rf(p, "utf-8");
|
|
5873
|
-
break;
|
|
5874
|
-
} catch {
|
|
5875
|
-
}
|
|
5876
|
-
}
|
|
5877
|
-
if (prompt) {
|
|
5878
|
-
await writeFile(resolve(outputDir, "PROMPT.md"), prompt, "utf-8");
|
|
5879
|
-
console.log(` PROMPT.md: default (html-css)`);
|
|
5880
|
-
} else {
|
|
5881
|
-
console.warn(" PROMPT.md: built-in prompt not found (skipped)");
|
|
5882
|
-
}
|
|
5883
|
-
}
|
|
5884
|
-
console.log(`
|
|
5885
|
-
${"=".repeat(50)}`);
|
|
5886
|
-
console.log(`Implementation package ready: ${outputDir}/`);
|
|
5887
|
-
console.log(` Grade: ${scores.overall.grade} (${scores.overall.percentage}%)`);
|
|
5888
|
-
console.log(` Issues: ${result.issues.length}`);
|
|
5889
|
-
console.log(` Design tree: ~${stats.estimatedTokens} tokens`);
|
|
5890
|
-
console.log(`${"=".repeat(50)}`);
|
|
5891
|
-
console.log(`
|
|
5892
|
-
Next: Feed design-tree.txt + PROMPT.md to your AI assistant.`);
|
|
5893
|
-
} catch (error) {
|
|
5894
|
-
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
5895
|
-
process.exitCode = 1;
|
|
5896
|
-
}
|
|
5897
|
-
});
|
|
5898
|
-
}
|
|
5899
|
-
var positiveCliNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v > 0, "must be positive");
|
|
5900
|
-
var figmaScaleNumber = z.union([z.string(), z.number()]).transform((v) => Number(v)).refine(Number.isFinite, "must be a valid number").refine((v) => v >= 1, "must be >= 1");
|
|
5901
|
-
var VisualCompareCliOptionsSchema = z.object({
|
|
5902
|
-
figmaUrl: z.string().optional(),
|
|
5903
|
-
figmaScreenshot: z.string().optional(),
|
|
5904
|
-
token: z.string().optional(),
|
|
5905
|
-
output: z.string().optional(),
|
|
5906
|
-
width: positiveCliNumber.optional(),
|
|
5907
|
-
height: positiveCliNumber.optional(),
|
|
5908
|
-
figmaScale: figmaScaleNumber.optional(),
|
|
5909
|
-
expandRoot: z.boolean().optional()
|
|
5910
|
-
});
|
|
5911
|
-
|
|
5912
|
-
// src/cli/commands/visual-compare.ts
|
|
5913
|
-
function registerVisualCompare(cli2) {
|
|
5914
|
-
cli2.command(
|
|
5915
|
-
"visual-compare <codePath>",
|
|
5916
|
-
"Compare rendered code against Figma screenshot (pixel-level similarity)"
|
|
5917
|
-
).option("--figma-url <url>", "Figma URL with node-id (required for API fetch)").option("--figma-screenshot <path>", "Local Figma screenshot file (skips API fetch)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory for screenshots and diff (default: /tmp/canicode-visual-compare)").option("--width <px>", "Logical viewport width in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--height <px>", "Logical viewport height in CSS px (default: infer from Figma PNG \xF7 export scale)").option("--figma-scale <n>", "Figma export scale (default: 2, matches save-fixture / @2x PNGs)").option("--expand-root", "Replace root element's fixed width with 100% before rendering (for responsive comparison)").example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'").action(async (codePath, rawOptions) => {
|
|
5918
|
-
try {
|
|
5919
|
-
const parseResult = VisualCompareCliOptionsSchema.safeParse(rawOptions);
|
|
5920
|
-
if (!parseResult.success) {
|
|
5921
|
-
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
5922
|
-
console.error(`
|
|
5923
|
-
Invalid options:
|
|
5924
|
-
${msg}`);
|
|
5925
|
-
process.exit(1);
|
|
5926
|
-
}
|
|
5927
|
-
const options = parseResult.data;
|
|
5928
|
-
if (!options.figmaUrl && !options.figmaScreenshot) {
|
|
5929
|
-
console.error("Error: --figma-url or --figma-screenshot is required");
|
|
5930
|
-
process.exitCode = 1;
|
|
5931
|
-
return;
|
|
5932
|
-
}
|
|
5933
|
-
if (options.figmaUrl && !parseFigmaUrl(options.figmaUrl).nodeId) {
|
|
5934
|
-
console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
|
|
5935
|
-
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
|
|
5936
|
-
}
|
|
5937
|
-
const token = options.token ?? getFigmaToken();
|
|
5938
|
-
if (!token && !options.figmaScreenshot) {
|
|
5939
|
-
console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var (or use --figma-screenshot for local files).");
|
|
5940
|
-
process.exitCode = 1;
|
|
5941
|
-
return;
|
|
5993
|
+
const token = options.token ?? getFigmaToken();
|
|
5994
|
+
if (!token && !options.figmaScreenshot) {
|
|
5995
|
+
console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var (or use --figma-screenshot for local files).");
|
|
5996
|
+
process.exitCode = 1;
|
|
5997
|
+
return;
|
|
5942
5998
|
}
|
|
5943
5999
|
const { visualCompare: visualCompare2 } = await Promise.resolve().then(() => (init_visual_compare(), visual_compare_exports));
|
|
5944
6000
|
const hasViewportOverride = options.width !== void 0 || options.height !== void 0;
|
|
@@ -5975,13 +6031,150 @@ ${msg}`);
|
|
|
5975
6031
|
);
|
|
5976
6032
|
process.exitCode = 1;
|
|
5977
6033
|
}
|
|
5978
|
-
});
|
|
6034
|
+
});
|
|
6035
|
+
}
|
|
6036
|
+
var InstallSkillsOptionsSchema = z.object({
|
|
6037
|
+
target: z.enum(["project", "global"]),
|
|
6038
|
+
force: z.boolean(),
|
|
6039
|
+
cwd: z.string().optional(),
|
|
6040
|
+
sourceDir: z.string().optional()
|
|
6041
|
+
});
|
|
6042
|
+
var SKILL_NAMES = ["canicode", "canicode-gotchas", "canicode-roundtrip"];
|
|
6043
|
+
function defaultSourceDir() {
|
|
6044
|
+
return fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
6045
|
+
}
|
|
6046
|
+
async function installSkills(rawOptions) {
|
|
6047
|
+
const options = InstallSkillsOptionsSchema.parse(rawOptions);
|
|
6048
|
+
const sourceDir = options.sourceDir ?? defaultSourceDir();
|
|
6049
|
+
if (!existsSync(sourceDir)) {
|
|
6050
|
+
throw new Error(
|
|
6051
|
+
`Bundled skills directory not found: ${sourceDir}
|
|
6052
|
+
If you are developing canicode, run 'pnpm build' first.
|
|
6053
|
+
If you installed canicode from npm, please file a bug report \u2014 the tarball is missing skills/.`
|
|
6054
|
+
);
|
|
6055
|
+
}
|
|
6056
|
+
const cwd = options.cwd ?? process.cwd();
|
|
6057
|
+
const targetDir = options.target === "global" ? join(homedir(), ".claude", "skills") : join(cwd, ".claude", "skills");
|
|
6058
|
+
mkdirSync(targetDir, { recursive: true });
|
|
6059
|
+
const summary = {
|
|
6060
|
+
installed: [],
|
|
6061
|
+
overwritten: [],
|
|
6062
|
+
skipped: [],
|
|
6063
|
+
targetDir
|
|
6064
|
+
};
|
|
6065
|
+
const ops = [];
|
|
6066
|
+
for (const skillName of SKILL_NAMES) {
|
|
6067
|
+
const srcSkillDir = join(sourceDir, skillName);
|
|
6068
|
+
if (!existsSync(srcSkillDir)) {
|
|
6069
|
+
throw new Error(`Bundled skill directory missing: ${srcSkillDir}`);
|
|
6070
|
+
}
|
|
6071
|
+
const destSkillDir = join(targetDir, skillName);
|
|
6072
|
+
mkdirSync(destSkillDir, { recursive: true });
|
|
6073
|
+
const files = listFilesRecursive(srcSkillDir);
|
|
6074
|
+
for (const relPath of files) {
|
|
6075
|
+
const src = join(srcSkillDir, relPath);
|
|
6076
|
+
const dest = join(destSkillDir, relPath);
|
|
6077
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
6078
|
+
const label = join(skillName, relPath);
|
|
6079
|
+
let action;
|
|
6080
|
+
if (!existsSync(dest)) {
|
|
6081
|
+
action = "install";
|
|
6082
|
+
} else if (options.force) {
|
|
6083
|
+
action = "force-overwrite";
|
|
6084
|
+
} else {
|
|
6085
|
+
action = "needs-decision";
|
|
6086
|
+
}
|
|
6087
|
+
ops.push({ src, dest, label, action });
|
|
6088
|
+
}
|
|
6089
|
+
}
|
|
6090
|
+
const candidates = ops.filter((op) => op.action === "needs-decision");
|
|
6091
|
+
const decisions = candidates.length > 0 ? await promptOverwriteBatch(candidates.map((op) => ({ label: op.label, dest: op.dest }))) : /* @__PURE__ */ new Map();
|
|
6092
|
+
for (const op of ops) {
|
|
6093
|
+
if (op.action === "install") {
|
|
6094
|
+
copyFileSync(op.src, op.dest);
|
|
6095
|
+
summary.installed.push(op.label);
|
|
6096
|
+
} else if (op.action === "force-overwrite") {
|
|
6097
|
+
copyFileSync(op.src, op.dest);
|
|
6098
|
+
summary.overwritten.push(op.label);
|
|
6099
|
+
} else {
|
|
6100
|
+
const decision = decisions.get(op.label) ?? "skip";
|
|
6101
|
+
if (decision === "overwrite") {
|
|
6102
|
+
copyFileSync(op.src, op.dest);
|
|
6103
|
+
summary.overwritten.push(op.label);
|
|
6104
|
+
} else {
|
|
6105
|
+
summary.skipped.push(op.label);
|
|
6106
|
+
}
|
|
6107
|
+
}
|
|
6108
|
+
}
|
|
6109
|
+
return summary;
|
|
6110
|
+
}
|
|
6111
|
+
function listFilesRecursive(dir) {
|
|
6112
|
+
const out = [];
|
|
6113
|
+
const walk = (current) => {
|
|
6114
|
+
for (const entry of readdirSync(current)) {
|
|
6115
|
+
const full = join(current, entry);
|
|
6116
|
+
const stat = statSync(full);
|
|
6117
|
+
if (stat.isDirectory()) {
|
|
6118
|
+
walk(full);
|
|
6119
|
+
} else if (stat.isFile()) {
|
|
6120
|
+
out.push(relative(dir, full));
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
};
|
|
6124
|
+
walk(dir);
|
|
6125
|
+
return out;
|
|
6126
|
+
}
|
|
6127
|
+
async function promptOverwriteBatch(candidates) {
|
|
6128
|
+
const decisions = /* @__PURE__ */ new Map();
|
|
6129
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
6130
|
+
for (const { label } of candidates) {
|
|
6131
|
+
decisions.set(label, "skip");
|
|
6132
|
+
}
|
|
6133
|
+
return decisions;
|
|
6134
|
+
}
|
|
6135
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6136
|
+
try {
|
|
6137
|
+
let mode = "ask";
|
|
6138
|
+
for (const { label, dest } of candidates) {
|
|
6139
|
+
if (mode === "all") {
|
|
6140
|
+
decisions.set(label, "overwrite");
|
|
6141
|
+
continue;
|
|
6142
|
+
}
|
|
6143
|
+
if (mode === "none") {
|
|
6144
|
+
decisions.set(label, "skip");
|
|
6145
|
+
continue;
|
|
6146
|
+
}
|
|
6147
|
+
const answer = (await rl.question(
|
|
6148
|
+
`File exists: ${dest}. Overwrite? [y/N/a=all/s=skip-all] `
|
|
6149
|
+
)).trim().toLowerCase();
|
|
6150
|
+
if (answer === "a") {
|
|
6151
|
+
decisions.set(label, "overwrite");
|
|
6152
|
+
mode = "all";
|
|
6153
|
+
} else if (answer === "s") {
|
|
6154
|
+
decisions.set(label, "skip");
|
|
6155
|
+
mode = "none";
|
|
6156
|
+
} else if (answer.startsWith("y")) {
|
|
6157
|
+
decisions.set(label, "overwrite");
|
|
6158
|
+
} else {
|
|
6159
|
+
decisions.set(label, "skip");
|
|
6160
|
+
}
|
|
6161
|
+
}
|
|
6162
|
+
} finally {
|
|
6163
|
+
rl.close();
|
|
6164
|
+
}
|
|
6165
|
+
return decisions;
|
|
5979
6166
|
}
|
|
6167
|
+
|
|
6168
|
+
// src/cli/commands/init.ts
|
|
5980
6169
|
var InitOptionsSchema = z.object({
|
|
5981
|
-
token: z.string().optional()
|
|
6170
|
+
token: z.string().optional(),
|
|
6171
|
+
global: z.boolean().optional(),
|
|
6172
|
+
// cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
|
|
6173
|
+
skills: z.boolean().optional(),
|
|
6174
|
+
force: z.boolean().optional()
|
|
5982
6175
|
});
|
|
5983
6176
|
function registerInit(cli2) {
|
|
5984
|
-
cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token to ~/.
|
|
6177
|
+
cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token and install Claude Code skills to .claude/skills/").option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--no-skills", "Skip skill installation (token only)").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
|
|
5985
6178
|
try {
|
|
5986
6179
|
const parseResult = InitOptionsSchema.safeParse(rawOptions);
|
|
5987
6180
|
if (!parseResult.success) {
|
|
@@ -5996,14 +6189,60 @@ ${msg}`);
|
|
|
5996
6189
|
initAiready(options.token);
|
|
5997
6190
|
console.log(` Config saved: ${getConfigPath()}`);
|
|
5998
6191
|
console.log(` Reports will be saved to: ${getReportsDir()}/`);
|
|
5999
|
-
|
|
6192
|
+
let skillStepOk = true;
|
|
6193
|
+
let skillSummary;
|
|
6194
|
+
if (options.skills !== false) {
|
|
6195
|
+
try {
|
|
6196
|
+
const summary = await installSkills({
|
|
6197
|
+
target: options.global ? "global" : "project",
|
|
6198
|
+
force: options.force ?? false
|
|
6199
|
+
});
|
|
6200
|
+
console.log(`
|
|
6201
|
+
Skills installed to: ${summary.targetDir}/`);
|
|
6202
|
+
console.log(` installed: ${summary.installed.length}`);
|
|
6203
|
+
console.log(` overwritten: ${summary.overwritten.length}`);
|
|
6204
|
+
console.log(` skipped: ${summary.skipped.length}`);
|
|
6205
|
+
if (summary.skipped.length > 0) {
|
|
6206
|
+
console.log(` (Re-run with --force to overwrite skipped files.)`);
|
|
6207
|
+
}
|
|
6208
|
+
skillSummary = {
|
|
6209
|
+
installed: summary.installed.length,
|
|
6210
|
+
overwritten: summary.overwritten.length,
|
|
6211
|
+
skipped: summary.skipped.length
|
|
6212
|
+
};
|
|
6213
|
+
} catch (skillError) {
|
|
6214
|
+
console.error(
|
|
6215
|
+
`
|
|
6216
|
+
Skill install failed: ${skillError instanceof Error ? skillError.message : String(skillError)}`
|
|
6217
|
+
);
|
|
6218
|
+
process.exitCode = 1;
|
|
6219
|
+
skillStepOk = false;
|
|
6220
|
+
}
|
|
6221
|
+
}
|
|
6222
|
+
trackEvent(EVENTS.CLI_INIT, {
|
|
6223
|
+
skillsRequested: options.skills !== false,
|
|
6224
|
+
skillStepOk,
|
|
6225
|
+
target: options.global ? "global" : "project",
|
|
6226
|
+
force: options.force ?? false,
|
|
6227
|
+
...skillSummary ?? {}
|
|
6228
|
+
});
|
|
6229
|
+
if (skillStepOk) {
|
|
6230
|
+
console.log(`
|
|
6000
6231
|
Next: canicode analyze "https://www.figma.com/design/..."`);
|
|
6232
|
+
}
|
|
6001
6233
|
return;
|
|
6002
6234
|
}
|
|
6003
6235
|
console.log(`CANICODE SETUP
|
|
6004
6236
|
`);
|
|
6005
6237
|
console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
|
|
6006
6238
|
console.log(` Get token: figma.com > Settings > Personal access tokens
|
|
6239
|
+
`);
|
|
6240
|
+
console.log(`Skills:`);
|
|
6241
|
+
console.log(` --token also installs three Claude Code skills into ./.claude/skills/`);
|
|
6242
|
+
console.log(` (canicode, canicode-gotchas, canicode-roundtrip).`);
|
|
6243
|
+
console.log(` --global Install to ~/.claude/skills/ instead`);
|
|
6244
|
+
console.log(` --no-skills Skip skill install (token only)`);
|
|
6245
|
+
console.log(` --force Overwrite existing skill files without prompting
|
|
6007
6246
|
`);
|
|
6008
6247
|
console.log(`After setup:`);
|
|
6009
6248
|
console.log(` canicode analyze "https://www.figma.com/design/..."`);
|
|
@@ -6102,35 +6341,24 @@ function registerListRules(cli2) {
|
|
|
6102
6341
|
});
|
|
6103
6342
|
}
|
|
6104
6343
|
|
|
6105
|
-
// src/cli/commands
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
}
|
|
6124
|
-
}
|
|
6125
|
-
console.error("Prompt file not found");
|
|
6126
|
-
process.exitCode = 1;
|
|
6127
|
-
return;
|
|
6128
|
-
} catch (error) {
|
|
6129
|
-
console.error("Error:", error instanceof Error ? error.message : String(error));
|
|
6130
|
-
process.exitCode = 1;
|
|
6131
|
-
}
|
|
6132
|
-
});
|
|
6133
|
-
}
|
|
6344
|
+
// src/cli/internal-commands.ts
|
|
6345
|
+
var INTERNAL_COMMANDS = [
|
|
6346
|
+
"calibrate-analyze",
|
|
6347
|
+
"calibrate-evaluate",
|
|
6348
|
+
"calibrate-implement",
|
|
6349
|
+
"calibrate-gap-report",
|
|
6350
|
+
"calibrate-run",
|
|
6351
|
+
"calibrate-gather-evidence",
|
|
6352
|
+
"calibrate-finalize-debate",
|
|
6353
|
+
"calibrate-enrich-evidence",
|
|
6354
|
+
"calibrate-prune-evidence",
|
|
6355
|
+
"calibrate-save-fixture",
|
|
6356
|
+
"fixture-list",
|
|
6357
|
+
"fixture-done",
|
|
6358
|
+
"design-tree-strip",
|
|
6359
|
+
"html-postprocess",
|
|
6360
|
+
"code-metrics"
|
|
6361
|
+
];
|
|
6134
6362
|
var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
|
|
6135
6363
|
var RuleRelatedStruggleSchema = z.object({
|
|
6136
6364
|
ruleId: z.string(),
|
|
@@ -7092,7 +7320,7 @@ function enrichCalibrationEvidence(reviews, fixture, evidencePath = DEFAULT_CALI
|
|
|
7092
7320
|
writeJsonArray(evidencePath, enriched);
|
|
7093
7321
|
}
|
|
7094
7322
|
|
|
7095
|
-
// src/agents/
|
|
7323
|
+
// src/agents/calibration-compute.ts
|
|
7096
7324
|
function normalizeActualImpact(impact) {
|
|
7097
7325
|
const mapping = {
|
|
7098
7326
|
none: "easy",
|
|
@@ -7943,7 +8171,7 @@ function registerCalibrateGapReport(cli2) {
|
|
|
7943
8171
|
function registerCalibrateRun(cli2) {
|
|
7944
8172
|
cli2.command(
|
|
7945
8173
|
"calibrate-run <input>",
|
|
7946
|
-
"Run full calibration pipeline (analysis-only, conversion via /calibrate
|
|
8174
|
+
"Run full calibration pipeline (analysis-only, conversion via /calibrate)"
|
|
7947
8175
|
).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--max-nodes <count>", "Max nodes to convert", { default: 5 }).option("--sampling <strategy>", "Sampling strategy (all | top-issues | random)", { default: "top-issues" }).action(async (input, options) => {
|
|
7948
8176
|
try {
|
|
7949
8177
|
const figmaToken = options.token ?? getFigmaToken();
|
|
@@ -7963,7 +8191,7 @@ function registerCalibrateRun(cli2) {
|
|
|
7963
8191
|
console.log("\nCalibration complete (analysis-only).");
|
|
7964
8192
|
console.log(` Grade: ${analysisOutput.scoreReport.overall.grade} (${analysisOutput.scoreReport.overall.percentage}%)`);
|
|
7965
8193
|
console.log(` Nodes with issues: ${analysisOutput.nodeIssueSummaries.length}`);
|
|
7966
|
-
console.log(" Note: Use /calibrate
|
|
8194
|
+
console.log(" Note: Use /calibrate in Claude Code for full pipeline with visual comparison.");
|
|
7967
8195
|
} catch (error) {
|
|
7968
8196
|
console.error(
|
|
7969
8197
|
"\nError:",
|
|
@@ -8726,66 +8954,449 @@ function registerDesignTreeStrip(cli2) {
|
|
|
8726
8954
|
await writeFile(outputPath, stripped, "utf-8");
|
|
8727
8955
|
console.log(` ${type}.txt (${Math.round(Buffer.byteLength(stripped) / 1024)}KB)`);
|
|
8728
8956
|
}
|
|
8729
|
-
console.log(`Stripped ${types.length} design-tree variants \u2192 ${outputDir}`);
|
|
8957
|
+
console.log(`Stripped ${types.length} design-tree variants \u2192 ${outputDir}`);
|
|
8958
|
+
} catch (error) {
|
|
8959
|
+
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
8960
|
+
process.exitCode = 1;
|
|
8961
|
+
}
|
|
8962
|
+
});
|
|
8963
|
+
}
|
|
8964
|
+
function sanitizeHtml(html) {
|
|
8965
|
+
let result = html;
|
|
8966
|
+
result = result.replace(/^\/\/\s*filename:.*\n/i, "");
|
|
8967
|
+
result = result.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
8968
|
+
result = result.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
8969
|
+
result = result.replace(
|
|
8970
|
+
/\s+(href|src|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi,
|
|
8971
|
+
(_, attr) => ` ${attr}="#"`
|
|
8972
|
+
);
|
|
8973
|
+
return result;
|
|
8974
|
+
}
|
|
8975
|
+
function injectLocalFont(html) {
|
|
8976
|
+
const fontPath = resolve("assets/fonts/Inter.var.woff2");
|
|
8977
|
+
if (!existsSync(fontPath)) return html;
|
|
8978
|
+
const fontUrl = pathToFileURL(fontPath).href;
|
|
8979
|
+
const fontCss = `@font-face { font-family: "Inter"; src: url("${fontUrl}") format("woff2"); font-weight: 100 900; }`;
|
|
8980
|
+
let result = html;
|
|
8981
|
+
result = result.replace(/<link[^>]*fonts\.googleapis\.com[^>]*>/gi, "");
|
|
8982
|
+
result = result.replace(/<link[^>]*fonts\.gstatic\.com[^>]*>/gi, "");
|
|
8983
|
+
if (result.includes("<style>")) {
|
|
8984
|
+
result = result.replace("<style>", `<style>
|
|
8985
|
+
${fontCss}
|
|
8986
|
+
`);
|
|
8987
|
+
} else if (result.includes("</head>")) {
|
|
8988
|
+
result = result.replace("</head>", `<style>${fontCss}</style>
|
|
8989
|
+
</head>`);
|
|
8990
|
+
}
|
|
8991
|
+
return result;
|
|
8992
|
+
}
|
|
8993
|
+
|
|
8994
|
+
// src/cli/commands/internal/html-postprocess.ts
|
|
8995
|
+
function registerHtmlPostprocess(cli2) {
|
|
8996
|
+
cli2.command(
|
|
8997
|
+
"html-postprocess <input>",
|
|
8998
|
+
"[internal] Sanitize HTML and inject local fonts"
|
|
8999
|
+
).option("--output <path>", "Output path (default: overwrite input)").action(async (input, options) => {
|
|
9000
|
+
try {
|
|
9001
|
+
const inputPath = resolve(input);
|
|
9002
|
+
if (!existsSync(inputPath)) {
|
|
9003
|
+
console.error(`Error: Input file not found: ${inputPath}`);
|
|
9004
|
+
process.exitCode = 1;
|
|
9005
|
+
return;
|
|
9006
|
+
}
|
|
9007
|
+
const raw = readFileSync(inputPath, "utf-8");
|
|
9008
|
+
const html = injectLocalFont(sanitizeHtml(raw));
|
|
9009
|
+
const outputPath = options.output ? resolve(options.output) : inputPath;
|
|
9010
|
+
await writeFile(outputPath, html, "utf-8");
|
|
9011
|
+
console.log(JSON.stringify({
|
|
9012
|
+
inputPath,
|
|
9013
|
+
outputPath,
|
|
9014
|
+
inputBytes: Buffer.byteLength(raw, "utf-8"),
|
|
9015
|
+
outputBytes: Buffer.byteLength(html, "utf-8")
|
|
9016
|
+
}));
|
|
9017
|
+
} catch (error) {
|
|
9018
|
+
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
9019
|
+
process.exitCode = 1;
|
|
9020
|
+
}
|
|
9021
|
+
});
|
|
9022
|
+
}
|
|
9023
|
+
var SaveFixtureOptionsSchema = z.object({
|
|
9024
|
+
output: z.string().optional(),
|
|
9025
|
+
api: z.boolean().optional(),
|
|
9026
|
+
token: z.string().optional(),
|
|
9027
|
+
imageScale: z.string().optional(),
|
|
9028
|
+
name: z.string().optional()
|
|
9029
|
+
});
|
|
9030
|
+
function registerCalibrateSaveFixture(cli2) {
|
|
9031
|
+
cli2.command(
|
|
9032
|
+
"calibrate-save-fixture <input>",
|
|
9033
|
+
"Save Figma design as a fixture directory for calibration"
|
|
9034
|
+
).option("--output <path>", "Output directory (default: fixtures/<name>/)").option("--name <name>", "Fixture name (default: extracted from URL)").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode calibrate-save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234").example(" canicode calibrate-save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3").action(async (input, rawOptions) => {
|
|
9035
|
+
try {
|
|
9036
|
+
const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions);
|
|
9037
|
+
if (!parseResult.success) {
|
|
9038
|
+
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
9039
|
+
console.error(`
|
|
9040
|
+
Invalid options:
|
|
9041
|
+
${msg}`);
|
|
9042
|
+
process.exit(1);
|
|
9043
|
+
}
|
|
9044
|
+
const options = parseResult.data;
|
|
9045
|
+
if (!isFigmaUrl(input)) {
|
|
9046
|
+
throw new Error("calibrate-save-fixture requires a Figma URL as input.");
|
|
9047
|
+
}
|
|
9048
|
+
if (options.imageScale !== void 0) {
|
|
9049
|
+
const scale = Number(options.imageScale);
|
|
9050
|
+
if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
|
|
9051
|
+
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
|
|
9052
|
+
process.exit(1);
|
|
9053
|
+
}
|
|
9054
|
+
}
|
|
9055
|
+
if (!parseFigmaUrl(input).nodeId) {
|
|
9056
|
+
console.warn("\nWarning: No node-id specified. Saving entire file as fixture.");
|
|
9057
|
+
console.warn("Tip: Add ?node-id=XXX to save a specific section.\n");
|
|
9058
|
+
}
|
|
9059
|
+
const { file } = await loadFile(input, options.token);
|
|
9060
|
+
file.sourceUrl = input;
|
|
9061
|
+
const fixtureName = options.name ?? file.fileKey;
|
|
9062
|
+
const fixtureDir = resolve(options.output ?? `fixtures/${fixtureName}`);
|
|
9063
|
+
mkdirSync(fixtureDir, { recursive: true });
|
|
9064
|
+
const figmaTokenForComponents = options.token ?? getFigmaToken();
|
|
9065
|
+
if (figmaTokenForComponents) {
|
|
9066
|
+
const { FigmaClient: FC } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
9067
|
+
const { resolveComponentDefinitions: resolveComponentDefinitions2, resolveInteractionDestinations: resolveInteractionDestinations2 } = await Promise.resolve().then(() => (init_component_resolver(), component_resolver_exports));
|
|
9068
|
+
const componentClient = new FC({ token: figmaTokenForComponents });
|
|
9069
|
+
try {
|
|
9070
|
+
const definitions = await resolveComponentDefinitions2(componentClient, file.fileKey, file.document);
|
|
9071
|
+
const count = Object.keys(definitions).length;
|
|
9072
|
+
if (count > 0) {
|
|
9073
|
+
file.componentDefinitions = definitions;
|
|
9074
|
+
console.log(`Resolved ${count} component master node tree(s)`);
|
|
9075
|
+
}
|
|
9076
|
+
const interactionDests = await resolveInteractionDestinations2(componentClient, file.fileKey, file.document, file.componentDefinitions);
|
|
9077
|
+
const destCount = Object.keys(interactionDests).length;
|
|
9078
|
+
if (destCount > 0) {
|
|
9079
|
+
file.interactionDestinations = interactionDests;
|
|
9080
|
+
console.log(`Resolved ${destCount} interaction destination(s)`);
|
|
9081
|
+
}
|
|
9082
|
+
} catch {
|
|
9083
|
+
console.warn("Warning: failed to resolve component definitions (continuing)");
|
|
9084
|
+
}
|
|
9085
|
+
}
|
|
9086
|
+
const dataPath = resolve(fixtureDir, "data.json");
|
|
9087
|
+
await writeFile(dataPath, JSON.stringify(file, null, 2), "utf-8");
|
|
9088
|
+
console.log(`Fixture saved: ${fixtureDir}/`);
|
|
9089
|
+
console.log(` data.json: ${file.name} (${countNodes2(file.document)} nodes)`);
|
|
9090
|
+
const figmaToken = options.token ?? getFigmaToken();
|
|
9091
|
+
if (figmaToken) {
|
|
9092
|
+
const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
9093
|
+
const client = new FigmaClient2({ token: figmaToken });
|
|
9094
|
+
const { nodeId } = parseFigmaUrl(input);
|
|
9095
|
+
const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
|
|
9096
|
+
try {
|
|
9097
|
+
const imageUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
|
|
9098
|
+
const url = imageUrls[rootNodeId];
|
|
9099
|
+
if (url) {
|
|
9100
|
+
const resp = await fetch(url);
|
|
9101
|
+
if (resp.ok) {
|
|
9102
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
9103
|
+
const { writeFile: writeFileSync6 } = await import('fs/promises');
|
|
9104
|
+
await writeFileSync6(resolve(fixtureDir, "screenshot.png"), buffer);
|
|
9105
|
+
console.log(` screenshot.png: saved`);
|
|
9106
|
+
}
|
|
9107
|
+
}
|
|
9108
|
+
} catch {
|
|
9109
|
+
console.warn(" screenshot.png: failed to download (continuing)");
|
|
9110
|
+
}
|
|
9111
|
+
const vectorNodes = collectVectorNodes(file.document);
|
|
9112
|
+
if (vectorNodes.length > 0) {
|
|
9113
|
+
const vectorDir = resolve(fixtureDir, "vectors");
|
|
9114
|
+
mkdirSync(vectorDir, { recursive: true });
|
|
9115
|
+
const svgUrls = await client.getNodeImages(
|
|
9116
|
+
file.fileKey,
|
|
9117
|
+
vectorNodes.map((n) => n.id),
|
|
9118
|
+
{ format: "svg" }
|
|
9119
|
+
);
|
|
9120
|
+
const mapping = {};
|
|
9121
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
9122
|
+
let downloaded = 0;
|
|
9123
|
+
for (const { id, name } of vectorNodes) {
|
|
9124
|
+
let base = sanitizeFilename(name);
|
|
9125
|
+
const count = usedNames.get(base) ?? 0;
|
|
9126
|
+
usedNames.set(base, count + 1);
|
|
9127
|
+
if (count > 0) base = `${base}-${count + 1}`;
|
|
9128
|
+
const filename = `${base}.svg`;
|
|
9129
|
+
mapping[id] = filename;
|
|
9130
|
+
const svgUrl = svgUrls[id];
|
|
9131
|
+
if (!svgUrl) continue;
|
|
9132
|
+
try {
|
|
9133
|
+
const resp = await fetch(svgUrl);
|
|
9134
|
+
if (resp.ok) {
|
|
9135
|
+
const svg = await resp.text();
|
|
9136
|
+
await writeFile(resolve(vectorDir, filename), svg, "utf-8");
|
|
9137
|
+
downloaded++;
|
|
9138
|
+
}
|
|
9139
|
+
} catch {
|
|
9140
|
+
}
|
|
9141
|
+
}
|
|
9142
|
+
await writeFile(resolve(vectorDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
9143
|
+
console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
|
|
9144
|
+
}
|
|
9145
|
+
const imageNodes = collectImageNodes(file.document);
|
|
9146
|
+
if (imageNodes.length > 0) {
|
|
9147
|
+
const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
|
|
9148
|
+
const imageDir = resolve(fixtureDir, "images");
|
|
9149
|
+
mkdirSync(imageDir, { recursive: true });
|
|
9150
|
+
const imageFills = await client.getImageFills(file.fileKey);
|
|
9151
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
9152
|
+
const mapping = {};
|
|
9153
|
+
let imgDownloaded = 0;
|
|
9154
|
+
for (const { id, name, imageRef } of imageNodes) {
|
|
9155
|
+
let base = sanitizeFilename(name);
|
|
9156
|
+
const count = usedNames.get(base) ?? 0;
|
|
9157
|
+
usedNames.set(base, count + 1);
|
|
9158
|
+
if (count > 0) base = `${base}-${count + 1}`;
|
|
9159
|
+
const filename = `${base}@${imgScale}x.png`;
|
|
9160
|
+
mapping[id] = filename;
|
|
9161
|
+
if (!imageRef) continue;
|
|
9162
|
+
const imgUrl = imageFills[imageRef];
|
|
9163
|
+
if (!imgUrl) continue;
|
|
9164
|
+
try {
|
|
9165
|
+
const resp = await fetch(imgUrl);
|
|
9166
|
+
if (resp.ok) {
|
|
9167
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
9168
|
+
await writeFile(resolve(imageDir, filename), buf);
|
|
9169
|
+
imgDownloaded++;
|
|
9170
|
+
}
|
|
9171
|
+
} catch {
|
|
9172
|
+
}
|
|
9173
|
+
}
|
|
9174
|
+
await writeFile(
|
|
9175
|
+
resolve(imageDir, "mapping.json"),
|
|
9176
|
+
JSON.stringify(mapping, null, 2),
|
|
9177
|
+
"utf-8"
|
|
9178
|
+
);
|
|
9179
|
+
console.log(` images/: ${imgDownloaded}/${imageNodes.length} PNGs (@${imgScale}x)`);
|
|
9180
|
+
}
|
|
9181
|
+
}
|
|
8730
9182
|
} catch (error) {
|
|
8731
|
-
console.error(
|
|
9183
|
+
console.error(
|
|
9184
|
+
"\nError:",
|
|
9185
|
+
error instanceof Error ? error.message : String(error)
|
|
9186
|
+
);
|
|
8732
9187
|
process.exitCode = 1;
|
|
8733
9188
|
}
|
|
8734
9189
|
});
|
|
8735
9190
|
}
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
(_, attr) => ` ${attr}="#"`
|
|
8744
|
-
);
|
|
8745
|
-
return result;
|
|
8746
|
-
}
|
|
8747
|
-
function injectLocalFont(html) {
|
|
8748
|
-
const fontPath = resolve("assets/fonts/Inter.var.woff2");
|
|
8749
|
-
if (!existsSync(fontPath)) return html;
|
|
8750
|
-
const fontUrl = pathToFileURL(fontPath).href;
|
|
8751
|
-
const fontCss = `@font-face { font-family: "Inter"; src: url("${fontUrl}") format("woff2"); font-weight: 100 900; }`;
|
|
8752
|
-
let result = html;
|
|
8753
|
-
result = result.replace(/<link[^>]*fonts\.googleapis\.com[^>]*>/gi, "");
|
|
8754
|
-
result = result.replace(/<link[^>]*fonts\.gstatic\.com[^>]*>/gi, "");
|
|
8755
|
-
if (result.includes("<style>")) {
|
|
8756
|
-
result = result.replace("<style>", `<style>
|
|
8757
|
-
${fontCss}
|
|
8758
|
-
`);
|
|
8759
|
-
} else if (result.includes("</head>")) {
|
|
8760
|
-
result = result.replace("</head>", `<style>${fontCss}</style>
|
|
8761
|
-
</head>`);
|
|
8762
|
-
}
|
|
8763
|
-
return result;
|
|
8764
|
-
}
|
|
8765
|
-
|
|
8766
|
-
// src/cli/commands/internal/html-postprocess.ts
|
|
8767
|
-
function registerHtmlPostprocess(cli2) {
|
|
9191
|
+
var ImplementOptionsSchema = z.object({
|
|
9192
|
+
token: z.string().optional(),
|
|
9193
|
+
output: z.string().optional(),
|
|
9194
|
+
prompt: z.string().optional(),
|
|
9195
|
+
imageScale: z.string().optional()
|
|
9196
|
+
});
|
|
9197
|
+
function registerCalibrateImplement(cli2) {
|
|
8768
9198
|
cli2.command(
|
|
8769
|
-
"
|
|
8770
|
-
"
|
|
8771
|
-
).option("--output <
|
|
9199
|
+
"calibrate-implement <input>",
|
|
9200
|
+
"Prepare design-to-code package for calibration: analysis + design tree + assets + prompt"
|
|
9201
|
+
).option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--output <dir>", "Output directory (default: ./canicode-implement/)").option("--prompt <path>", "Custom prompt file (default: built-in HTML+CSS prompt)").option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile").example(" canicode calibrate-implement ./fixtures/my-design").example(" canicode calibrate-implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3").action(async (input, rawOptions) => {
|
|
8772
9202
|
try {
|
|
8773
|
-
const
|
|
8774
|
-
if (!
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
9203
|
+
const parseResult = ImplementOptionsSchema.safeParse(rawOptions);
|
|
9204
|
+
if (!parseResult.success) {
|
|
9205
|
+
const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
|
|
9206
|
+
console.error(`
|
|
9207
|
+
Invalid options:
|
|
9208
|
+
${msg}`);
|
|
9209
|
+
process.exit(1);
|
|
8778
9210
|
}
|
|
8779
|
-
const
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
9211
|
+
const options = parseResult.data;
|
|
9212
|
+
if (options.imageScale !== void 0) {
|
|
9213
|
+
const scale = Number(options.imageScale);
|
|
9214
|
+
if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
|
|
9215
|
+
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
|
|
9216
|
+
process.exit(1);
|
|
9217
|
+
}
|
|
9218
|
+
}
|
|
9219
|
+
if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
|
|
9220
|
+
console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file.");
|
|
9221
|
+
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
|
|
9222
|
+
}
|
|
9223
|
+
const outputDir = resolve(options.output ?? "canicode-implement");
|
|
9224
|
+
mkdirSync(outputDir, { recursive: true });
|
|
9225
|
+
console.log("\nPreparing implementation package...\n");
|
|
9226
|
+
const { file } = await loadFile(input, options.token);
|
|
9227
|
+
console.log(`Design: ${file.name}`);
|
|
9228
|
+
const result = analyzeFile(file);
|
|
9229
|
+
const scores = calculateScores(result);
|
|
9230
|
+
const resultJson = buildResultJson(file.name, result, scores, { fileKey: file.fileKey });
|
|
9231
|
+
await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8");
|
|
9232
|
+
console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`);
|
|
9233
|
+
const fixtureBase = isJsonFile(input) || isFixtureDir(input) ? isJsonFile(input) ? dirname(resolve(input)) : resolve(input) : void 0;
|
|
9234
|
+
let vectorDir = fixtureBase ? resolve(fixtureBase, "vectors") : void 0;
|
|
9235
|
+
let imageDir = fixtureBase ? resolve(fixtureBase, "images") : void 0;
|
|
9236
|
+
if (vectorDir && existsSync(vectorDir)) {
|
|
9237
|
+
const vecOutputDir = resolve(outputDir, "vectors");
|
|
9238
|
+
mkdirSync(vecOutputDir, { recursive: true });
|
|
9239
|
+
const { readdirSync: readdirSync4, copyFileSync: copyFileSync3 } = await import('fs');
|
|
9240
|
+
const vecFiles = readdirSync4(vectorDir).filter((f) => f.endsWith(".svg") || f === "mapping.json");
|
|
9241
|
+
for (const f of vecFiles) {
|
|
9242
|
+
copyFileSync3(resolve(vectorDir, f), resolve(vecOutputDir, f));
|
|
9243
|
+
}
|
|
9244
|
+
vectorDir = vecOutputDir;
|
|
9245
|
+
console.log(` vectors/: ${vecFiles.length} SVGs copied`);
|
|
9246
|
+
}
|
|
9247
|
+
if (imageDir && existsSync(imageDir)) {
|
|
9248
|
+
const imgOutputDir = resolve(outputDir, "images");
|
|
9249
|
+
mkdirSync(imgOutputDir, { recursive: true });
|
|
9250
|
+
const { readdirSync: readdirSync4, copyFileSync: copyFileSync3 } = await import('fs');
|
|
9251
|
+
const imgFiles = readdirSync4(imageDir).filter((f) => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json"));
|
|
9252
|
+
for (const f of imgFiles) {
|
|
9253
|
+
copyFileSync3(resolve(imageDir, f), resolve(imgOutputDir, f));
|
|
9254
|
+
}
|
|
9255
|
+
imageDir = imgOutputDir;
|
|
9256
|
+
const pngCount = imgFiles.filter((f) => f.endsWith(".png")).length;
|
|
9257
|
+
console.log(` images/: ${pngCount} assets copied`);
|
|
9258
|
+
}
|
|
9259
|
+
if (isFigmaUrl(input) && !fixtureBase) {
|
|
9260
|
+
const figmaToken = options.token ?? getFigmaToken();
|
|
9261
|
+
if (figmaToken) {
|
|
9262
|
+
const imgScale = options.imageScale !== void 0 ? Number(options.imageScale) : 2;
|
|
9263
|
+
const { FigmaClient: FigmaClient2 } = await Promise.resolve().then(() => (init_figma_client(), figma_client_exports));
|
|
9264
|
+
const client = new FigmaClient2({ token: figmaToken });
|
|
9265
|
+
const { nodeId } = parseFigmaUrl(input);
|
|
9266
|
+
const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id;
|
|
9267
|
+
try {
|
|
9268
|
+
const screenshotUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 });
|
|
9269
|
+
const screenshotUrl = screenshotUrls[rootNodeId];
|
|
9270
|
+
if (screenshotUrl) {
|
|
9271
|
+
const resp = await fetch(screenshotUrl);
|
|
9272
|
+
if (resp.ok) {
|
|
9273
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
9274
|
+
await writeFile(resolve(outputDir, "screenshot.png"), buf);
|
|
9275
|
+
console.log(` screenshot.png: saved`);
|
|
9276
|
+
}
|
|
9277
|
+
}
|
|
9278
|
+
} catch {
|
|
9279
|
+
console.warn(" screenshot.png: failed to download (continuing)");
|
|
9280
|
+
}
|
|
9281
|
+
const vectorNodes = collectVectorNodes(file.document);
|
|
9282
|
+
if (vectorNodes.length > 0) {
|
|
9283
|
+
const vecOutDir = resolve(outputDir, "vectors");
|
|
9284
|
+
mkdirSync(vecOutDir, { recursive: true });
|
|
9285
|
+
try {
|
|
9286
|
+
const svgUrls = await client.getNodeImages(
|
|
9287
|
+
file.fileKey,
|
|
9288
|
+
vectorNodes.map((n) => n.id),
|
|
9289
|
+
{ format: "svg" }
|
|
9290
|
+
);
|
|
9291
|
+
const mapping = {};
|
|
9292
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
9293
|
+
let downloaded = 0;
|
|
9294
|
+
for (const { id, name } of vectorNodes) {
|
|
9295
|
+
let base = sanitizeFilename(name);
|
|
9296
|
+
const count = usedNames.get(base) ?? 0;
|
|
9297
|
+
usedNames.set(base, count + 1);
|
|
9298
|
+
if (count > 0) base = `${base}-${count + 1}`;
|
|
9299
|
+
const filename = `${base}.svg`;
|
|
9300
|
+
mapping[id] = filename;
|
|
9301
|
+
const svgUrl = svgUrls[id];
|
|
9302
|
+
if (!svgUrl) continue;
|
|
9303
|
+
try {
|
|
9304
|
+
const resp = await fetch(svgUrl);
|
|
9305
|
+
if (resp.ok) {
|
|
9306
|
+
const svg = await resp.text();
|
|
9307
|
+
await writeFile(resolve(vecOutDir, filename), svg, "utf-8");
|
|
9308
|
+
downloaded++;
|
|
9309
|
+
}
|
|
9310
|
+
} catch {
|
|
9311
|
+
}
|
|
9312
|
+
}
|
|
9313
|
+
await writeFile(resolve(vecOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
9314
|
+
console.log(` vectors/: ${downloaded}/${vectorNodes.length} SVGs`);
|
|
9315
|
+
} catch {
|
|
9316
|
+
console.warn(" vectors/: failed to download (continuing)");
|
|
9317
|
+
}
|
|
9318
|
+
}
|
|
9319
|
+
const imgNodes = collectImageNodes(file.document);
|
|
9320
|
+
if (imgNodes.length > 0) {
|
|
9321
|
+
const imgOutDir = resolve(outputDir, "images");
|
|
9322
|
+
mkdirSync(imgOutDir, { recursive: true });
|
|
9323
|
+
try {
|
|
9324
|
+
const imageFills = await client.getImageFills(file.fileKey);
|
|
9325
|
+
const mapping = {};
|
|
9326
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
9327
|
+
let downloaded = 0;
|
|
9328
|
+
for (const { id, name, imageRef } of imgNodes) {
|
|
9329
|
+
let base = sanitizeFilename(name);
|
|
9330
|
+
const count = usedNames.get(base) ?? 0;
|
|
9331
|
+
usedNames.set(base, count + 1);
|
|
9332
|
+
if (count > 0) base = `${base}-${count + 1}`;
|
|
9333
|
+
const filename = `${base}@${imgScale}x.png`;
|
|
9334
|
+
mapping[id] = filename;
|
|
9335
|
+
if (!imageRef) continue;
|
|
9336
|
+
const imgUrl = imageFills[imageRef];
|
|
9337
|
+
if (!imgUrl) continue;
|
|
9338
|
+
try {
|
|
9339
|
+
const resp = await fetch(imgUrl);
|
|
9340
|
+
if (resp.ok) {
|
|
9341
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
9342
|
+
await writeFile(resolve(imgOutDir, filename), buf);
|
|
9343
|
+
downloaded++;
|
|
9344
|
+
}
|
|
9345
|
+
} catch {
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9348
|
+
await writeFile(resolve(imgOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8");
|
|
9349
|
+
imageDir = imgOutDir;
|
|
9350
|
+
console.log(` images/: ${downloaded}/${imgNodes.length} PNGs (@${imgScale}x)`);
|
|
9351
|
+
} catch {
|
|
9352
|
+
console.warn(" images/: failed to download (continuing)");
|
|
9353
|
+
}
|
|
9354
|
+
}
|
|
9355
|
+
const vecOutCheck = resolve(outputDir, "vectors");
|
|
9356
|
+
if (existsSync(vecOutCheck)) vectorDir = vecOutCheck;
|
|
9357
|
+
}
|
|
9358
|
+
}
|
|
9359
|
+
const { generateDesignTreeWithStats: generateDesignTreeWithStats2 } = await Promise.resolve().then(() => (init_design_tree(), design_tree_exports));
|
|
9360
|
+
const treeOptions = {
|
|
9361
|
+
...vectorDir && existsSync(vectorDir) ? { vectorDir } : {},
|
|
9362
|
+
...imageDir && existsSync(imageDir) ? { imageDir } : {}
|
|
9363
|
+
};
|
|
9364
|
+
const stats = generateDesignTreeWithStats2(file, treeOptions);
|
|
9365
|
+
await writeFile(resolve(outputDir, "design-tree.txt"), stats.tree, "utf-8");
|
|
9366
|
+
console.log(` design-tree.txt: ~${stats.estimatedTokens} tokens`);
|
|
9367
|
+
if (options.prompt) {
|
|
9368
|
+
const { readFile: rf } = await import('fs/promises');
|
|
9369
|
+
const customPrompt = await rf(resolve(options.prompt), "utf-8");
|
|
9370
|
+
await writeFile(resolve(outputDir, "PROMPT.md"), customPrompt, "utf-8");
|
|
9371
|
+
console.log(` PROMPT.md: custom (${options.prompt})`);
|
|
9372
|
+
} else {
|
|
9373
|
+
const { readFile: rf } = await import('fs/promises');
|
|
9374
|
+
const { dirname: dirnameFn, resolve: resolveFn } = await import('path');
|
|
9375
|
+
const { fileURLToPath: fileURLToPath2 } = await import('url');
|
|
9376
|
+
const cliDir = dirnameFn(fileURLToPath2(import.meta.url));
|
|
9377
|
+
const projectRoot = resolveFn(cliDir, "../..");
|
|
9378
|
+
const promptPath = resolveFn(projectRoot, ".claude/agents/calibration/PROMPT.md");
|
|
9379
|
+
let prompt = "";
|
|
9380
|
+
try {
|
|
9381
|
+
prompt = await rf(promptPath, "utf-8");
|
|
9382
|
+
} catch {
|
|
9383
|
+
}
|
|
9384
|
+
if (prompt) {
|
|
9385
|
+
await writeFile(resolve(outputDir, "PROMPT.md"), prompt, "utf-8");
|
|
9386
|
+
console.log(` PROMPT.md: default (html-css)`);
|
|
9387
|
+
} else {
|
|
9388
|
+
console.warn(" PROMPT.md: built-in prompt not found (skipped)");
|
|
9389
|
+
}
|
|
9390
|
+
}
|
|
9391
|
+
console.log(`
|
|
9392
|
+
${"=".repeat(50)}`);
|
|
9393
|
+
console.log(`Implementation package ready: ${outputDir}/`);
|
|
9394
|
+
console.log(` Grade: ${scores.overall.grade} (${scores.overall.percentage}%)`);
|
|
9395
|
+
console.log(` Issues: ${result.issues.length}`);
|
|
9396
|
+
console.log(` Design tree: ~${stats.estimatedTokens} tokens`);
|
|
9397
|
+
console.log(`${"=".repeat(50)}`);
|
|
9398
|
+
console.log(`
|
|
9399
|
+
Next: Feed design-tree.txt + PROMPT.md to your AI assistant.`);
|
|
8789
9400
|
} catch (error) {
|
|
8790
9401
|
console.error("\nError:", error instanceof Error ? error.message : String(error));
|
|
8791
9402
|
process.exitCode = 1;
|
|
@@ -8839,14 +9450,12 @@ process.on("beforeExit", () => {
|
|
|
8839
9450
|
shutdownMonitoring();
|
|
8840
9451
|
});
|
|
8841
9452
|
registerAnalyze(cli);
|
|
8842
|
-
|
|
9453
|
+
registerGotchaSurvey(cli);
|
|
8843
9454
|
registerDesignTree(cli);
|
|
8844
|
-
registerImplement(cli);
|
|
8845
9455
|
registerVisualCompare(cli);
|
|
8846
9456
|
registerInit(cli);
|
|
8847
9457
|
registerConfig(cli);
|
|
8848
9458
|
registerListRules(cli);
|
|
8849
|
-
registerPrompt(cli);
|
|
8850
9459
|
registerCalibrateAnalyze(cli);
|
|
8851
9460
|
registerCalibrateEvaluate(cli);
|
|
8852
9461
|
registerCalibrateGapReport(cli);
|
|
@@ -8858,11 +9467,18 @@ registerEvidenceEnrich(cli);
|
|
|
8858
9467
|
registerEvidencePrune(cli);
|
|
8859
9468
|
registerDesignTreeStrip(cli);
|
|
8860
9469
|
registerHtmlPostprocess(cli);
|
|
9470
|
+
registerCalibrateSaveFixture(cli);
|
|
9471
|
+
registerCalibrateImplement(cli);
|
|
8861
9472
|
registerCodeMetrics(cli);
|
|
8862
|
-
cli.command("docs [topic]", "Show documentation (topics: setup,
|
|
9473
|
+
cli.command("docs [topic]", "Show documentation (topics: setup, config, scoring, rules, visual-compare, design-tree)").action((topic) => {
|
|
8863
9474
|
handleDocs(topic);
|
|
8864
9475
|
});
|
|
8865
9476
|
cli.help((sections) => {
|
|
9477
|
+
for (const section of sections) {
|
|
9478
|
+
if (section.title === "Commands" || section.title?.startsWith("For more info")) {
|
|
9479
|
+
section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
|
|
9480
|
+
}
|
|
9481
|
+
}
|
|
8866
9482
|
sections.push(
|
|
8867
9483
|
{
|
|
8868
9484
|
title: "\nSetup",
|
|
@@ -8884,7 +9500,8 @@ cli.help((sections) => {
|
|
|
8884
9500
|
body: [
|
|
8885
9501
|
` $ canicode analyze "https://www.figma.com/design/..." --api`,
|
|
8886
9502
|
` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
|
|
8887
|
-
` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json
|
|
9503
|
+
` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
|
|
9504
|
+
` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`
|
|
8888
9505
|
].join("\n")
|
|
8889
9506
|
},
|
|
8890
9507
|
{
|