@usefragments/core 1.0.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/LICENSE +21 -0
- package/dist/chunk-243QYRUF.js +2145 -0
- package/dist/chunk-243QYRUF.js.map +1 -0
- package/dist/chunk-3LLRNCPX.js +111 -0
- package/dist/chunk-3LLRNCPX.js.map +1 -0
- package/dist/chunk-EIYNNS77.js +48 -0
- package/dist/chunk-EIYNNS77.js.map +1 -0
- package/dist/chunk-JNBFJ34I.js +326 -0
- package/dist/chunk-JNBFJ34I.js.map +1 -0
- package/dist/chunk-R3X7UBHU.js +228 -0
- package/dist/chunk-R3X7UBHU.js.map +1 -0
- package/dist/chunk-SH4KPIYH.js +393 -0
- package/dist/chunk-SH4KPIYH.js.map +1 -0
- package/dist/chunk-V4VQB57N.js +56 -0
- package/dist/chunk-V4VQB57N.js.map +1 -0
- package/dist/chunk-X34IA4LR.js +319 -0
- package/dist/chunk-X34IA4LR.js.map +1 -0
- package/dist/chunk-YF65VYRY.js +493 -0
- package/dist/chunk-YF65VYRY.js.map +1 -0
- package/dist/codes/index.d.ts +40 -0
- package/dist/codes/index.js +23 -0
- package/dist/codes/index.js.map +1 -0
- package/dist/compiled-types/index.d.ts +5 -0
- package/dist/compiled-types/index.js +1 -0
- package/dist/compiled-types/index.js.map +1 -0
- package/dist/generate/index.d.ts +47 -0
- package/dist/generate/index.js +11 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/governance-BOa3KyiJ.d.ts +3279 -0
- package/dist/graph/index.d.ts +66 -0
- package/dist/graph/index.js +17 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +11388 -0
- package/dist/index.js +8711 -0
- package/dist/index.js.map +1 -0
- package/dist/package-identity.d.ts +42 -0
- package/dist/package-identity.js +23 -0
- package/dist/package-identity.js.map +1 -0
- package/dist/preview/index.d.ts +484 -0
- package/dist/preview/index.js +1129 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/react-types.d.ts +22 -0
- package/dist/react-types.js +1 -0
- package/dist/react-types.js.map +1 -0
- package/dist/registry.d.ts +2367 -0
- package/dist/registry.js +62 -0
- package/dist/registry.js.map +1 -0
- package/dist/schemas/index.d.ts +2584 -0
- package/dist/schemas/index.js +55 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/severity.d.ts +18 -0
- package/dist/severity.js +27 -0
- package/dist/severity.js.map +1 -0
- package/dist/test-utils.d.ts +14 -0
- package/dist/test-utils.js +63 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/topology/index.d.ts +48 -0
- package/dist/topology/index.js +7 -0
- package/dist/topology/index.js.map +1 -0
- package/dist/types-xJ2xyp_G.d.ts +119 -0
- package/package.json +120 -0
- package/src/__tests__/contract-parser.test.ts +614 -0
- package/src/__tests__/dtcg-generators.test.ts +439 -0
- package/src/__tests__/dtcg-parser.test.ts +884 -0
- package/src/__tests__/preview-runtime.test.tsx +111 -0
- package/src/__tests__/schema.test.ts +31 -0
- package/src/__tests__/severity.test.ts +34 -0
- package/src/__tests__/style-comparison.test.ts +662 -0
- package/src/__tests__/tokens-compat.test.ts +51 -0
- package/src/agent-format.test.ts +105 -0
- package/src/agent-format.ts +141 -0
- package/src/bundle.ts +149 -0
- package/src/codes/__tests__/codes.test.ts +73 -0
- package/src/codes/codes.ts +463 -0
- package/src/codes/index.ts +7 -0
- package/src/codes/source-finding-bridge.ts +113 -0
- package/src/compiled-types/index.ts +293 -0
- package/src/component-contract.ts +231 -0
- package/src/component-discovery.ts +80 -0
- package/src/composition.test.ts +262 -0
- package/src/composition.ts +318 -0
- package/src/config-paths.test.ts +78 -0
- package/src/config-paths.ts +47 -0
- package/src/config.ts +18 -0
- package/src/conform-prove.test.ts +274 -0
- package/src/conform-prove.ts +326 -0
- package/src/conform.ts +160 -0
- package/src/constants.ts +114 -0
- package/src/context.ts +2 -0
- package/src/contract/fixture.ts +110 -0
- package/src/contract/hash.test.ts +86 -0
- package/src/contract/hash.ts +195 -0
- package/src/contract/index.ts +43 -0
- package/src/contract/preimage.test.ts +254 -0
- package/src/contract/preimage.ts +365 -0
- package/src/contract/stamp.test.ts +100 -0
- package/src/contract/stamp.ts +105 -0
- package/src/contract-parser.ts +141 -0
- package/src/defineFragment.test.ts +257 -0
- package/src/defineFragment.ts +293 -0
- package/src/dtcg-generators.ts +324 -0
- package/src/dtcg-parser.ts +1 -0
- package/src/dtcg.ts +237 -0
- package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
- package/src/facts/builders.ts +835 -0
- package/src/facts/compile.ts +422 -0
- package/src/facts/fact-index.ts +394 -0
- package/src/facts/facts.test.ts +494 -0
- package/src/facts/ids.ts +125 -0
- package/src/facts/index.ts +119 -0
- package/src/facts/types.ts +614 -0
- package/src/figma.ts +263 -0
- package/src/fragment-types.ts +214 -0
- package/src/generate/generate.test.ts +594 -0
- package/src/generate/index.ts +438 -0
- package/src/governance-integrity.test.ts +186 -0
- package/src/governance-integrity.ts +282 -0
- package/src/governance.test.ts +300 -0
- package/src/governance.ts +605 -0
- package/src/governance.type-test.ts +75 -0
- package/src/graph/engine.ts +446 -0
- package/src/graph/graph.test.ts +746 -0
- package/src/graph/index.ts +32 -0
- package/src/graph/serialization.ts +55 -0
- package/src/graph/types.ts +178 -0
- package/src/index.ts +891 -0
- package/src/local-canonical.ts +102 -0
- package/src/mcp-snapshot.ts +222 -0
- package/src/package-identity-match.ts +33 -0
- package/src/package-identity.generated.ts +233 -0
- package/src/package-identity.test.ts +99 -0
- package/src/package-identity.ts +157 -0
- package/src/performance-presets.ts +142 -0
- package/src/preview/comparison-artifact.test.ts +228 -0
- package/src/preview/comparison-artifact.ts +181 -0
- package/src/preview/comparison-context.ts +93 -0
- package/src/preview/comparison-evidence.ts +112 -0
- package/src/preview/comparison-format.ts +21 -0
- package/src/preview/comparison-presentation.ts +187 -0
- package/src/preview/comparison-references.test.ts +430 -0
- package/src/preview/comparison-references.ts +72 -0
- package/src/preview/comparison-score.ts +59 -0
- package/src/preview/comparison-styles.ts +197 -0
- package/src/preview/comparison.test.ts +559 -0
- package/src/preview/comparison.ts +488 -0
- package/src/preview/index.ts +10 -0
- package/src/preview/types.ts +309 -0
- package/src/preview/validation.test.ts +290 -0
- package/src/preview/validation.ts +356 -0
- package/src/preview-runtime.tsx +144 -0
- package/src/raw-html-canonical.test.ts +41 -0
- package/src/raw-html-canonical.ts +179 -0
- package/src/react-types.ts +21 -0
- package/src/registry-install-plan.ts +344 -0
- package/src/registry.test.ts +245 -0
- package/src/registry.ts +369 -0
- package/src/rules/a11y-required-accessible-name.ts +93 -0
- package/src/rules/color-math.test.ts +72 -0
- package/src/rules/color-math.ts +192 -0
- package/src/rules/components-forbidden-prop-value.ts +108 -0
- package/src/rules/components-prefer-library.ts +943 -0
- package/src/rules/components-unknown-prop.ts +94 -0
- package/src/rules/composition-pattern-schema.ts +125 -0
- package/src/rules/composition-pattern.test.ts +320 -0
- package/src/rules/composition-pattern.ts +271 -0
- package/src/rules/emit-gate.test.ts +42 -0
- package/src/rules/emit-gate.ts +60 -0
- package/src/rules/finding.ts +54 -0
- package/src/rules/index.ts +235 -0
- package/src/rules/jsx-preferred-component.ts +68 -0
- package/src/rules/jsx-preferred-import-path.ts +67 -0
- package/src/rules/presets.ts +57 -0
- package/src/rules/props-invalid-value.ts +115 -0
- package/src/rules/rules.test.ts +3636 -0
- package/src/rules/spacing-resolution.ts +184 -0
- package/src/rules/styles-no-raw-color.ts +329 -0
- package/src/rules/styles-no-raw-dimensions.ts +357 -0
- package/src/rules/styles-no-raw-spacing.ts +236 -0
- package/src/rules/styles-no-raw-typography.ts +237 -0
- package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
- package/src/rules/tailwind-arbitrary-color.ts +93 -0
- package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
- package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
- package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
- package/src/rules/tailwind-forbidden-palette.ts +107 -0
- package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
- package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
- package/src/rules/tailwind-raw-color-via-token.ts +82 -0
- package/src/rules/tailwind-rule-utils.ts +106 -0
- package/src/rules/tailwind-unknown-class.test.ts +157 -0
- package/src/rules/tailwind-unknown-class.ts +121 -0
- package/src/rules/taxonomy.test.ts +238 -0
- package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
- package/src/rules/tiers.ts +111 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
- package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
- package/src/rules/tokens-require-dual-fallback.ts +78 -0
- package/src/rules/types.ts +27 -0
- package/src/rules/utils.test.ts +110 -0
- package/src/rules/utils.ts +599 -0
- package/src/schema.ts +427 -0
- package/src/schemas/index.ts +275 -0
- package/src/schemas/schemas.test.ts +124 -0
- package/src/severity.ts +54 -0
- package/src/storyAdapter.test.ts +571 -0
- package/src/storyAdapter.ts +761 -0
- package/src/storyFilters.test.ts +350 -0
- package/src/storyFilters.ts +253 -0
- package/src/storybook-csf.ts +11 -0
- package/src/style-comparison.ts +423 -0
- package/src/test-utils.ts +68 -0
- package/src/token-parser.ts +2 -0
- package/src/token-types.ts +287 -0
- package/src/tokens/categories.ts +131 -0
- package/src/tokens/color.ts +193 -0
- package/src/tokens/design-token-parser.test.ts +159 -0
- package/src/tokens/design-token-parser.ts +402 -0
- package/src/tokens/dtcg.ts +428 -0
- package/src/tokens/index.ts +130 -0
- package/src/tokens/lookup.ts +19 -0
- package/src/tokens/scss.ts +171 -0
- package/src/tokens/tailwind-v4.ts +187 -0
- package/src/tokens/types.ts +43 -0
- package/src/topology/index.ts +9 -0
- package/src/topology/resolve-area.test.ts +174 -0
- package/src/topology/resolve-area.ts +215 -0
- package/src/types.ts +1057 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { parseDesignTokenContent } from "./design-token-parser.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Token-drift robustness for the SINGLE producer the CLI's
|
|
7
|
+
* buildContractVocabularyFacts consumes (parseDesignTokenContent). Each format
|
|
8
|
+
* that previously yielded an EMPTY vocabulary (→ FUI2015 silently disabled) is
|
|
9
|
+
* locked here, plus a NON-SILENT 0-token warning so an empty result is never
|
|
10
|
+
* quiet.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function names(content: string, filePath: string): string[] {
|
|
14
|
+
return parseDesignTokenContent(content, { filePath }).tokens.map((t) => t.name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("parseDesignTokenContent — DTCG/JSON format dispatch (#10)", () => {
|
|
18
|
+
it("parses a DTCG token file into --kebab CSS names", () => {
|
|
19
|
+
const content = JSON.stringify({
|
|
20
|
+
color: { accent: { $value: "#fff", $type: "color" } },
|
|
21
|
+
});
|
|
22
|
+
const tokens = names(content, "tokens.tokens.json");
|
|
23
|
+
expect(tokens).toContain("--color-accent");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preserves DTCG token type/category before name heuristics", () => {
|
|
27
|
+
const content = JSON.stringify({
|
|
28
|
+
brand: {
|
|
29
|
+
primary: { $value: "#39594d", $type: "color" },
|
|
30
|
+
corner: { $value: "8px", $type: "dimension" },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const res = parseDesignTokenContent(content, { filePath: "tokens.tokens.json" });
|
|
34
|
+
|
|
35
|
+
expect(res.tokens.find((token) => token.name === "--brand-primary")?.category).toBe("color");
|
|
36
|
+
expect(res.tokens.find((token) => token.name === "--brand-corner")?.category).toBe("spacing");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses DTCG even when the path is generic but content is JSON", () => {
|
|
40
|
+
// `looksDtcg` also triggers on `{`-leading content, so an unknown extension
|
|
41
|
+
// with JSON content still routes to the DTCG parser.
|
|
42
|
+
const content = JSON.stringify({
|
|
43
|
+
space: { $type: "dimension", small: { $value: "8px" } },
|
|
44
|
+
});
|
|
45
|
+
const tokens = names(content, "weird-name.config");
|
|
46
|
+
expect(tokens).toContain("--space-small");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("surfaces a NON-SILENT warning for an empty DTCG file (#10)", () => {
|
|
50
|
+
const res = parseDesignTokenContent("{}", { filePath: "x.tokens.json" });
|
|
51
|
+
expect(res.tokens).toHaveLength(0);
|
|
52
|
+
expect(res.warnings).toHaveLength(1);
|
|
53
|
+
expect(res.warnings[0]).toMatch(/produced no token names/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fails soft on malformed JSON instead of throwing", () => {
|
|
57
|
+
// Malformed `.json`: must NOT throw, must fall through to the CSS path and
|
|
58
|
+
// still emit a non-silent warning (no tokens found anywhere).
|
|
59
|
+
const res = parseDesignTokenContent("{ not valid json", {
|
|
60
|
+
filePath: "broken.tokens.json",
|
|
61
|
+
});
|
|
62
|
+
expect(res.tokens).toHaveLength(0);
|
|
63
|
+
expect(res.warnings.some((w) => /produced no token names/.test(w))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("parseDesignTokenContent — SCSS $-variable format dispatch (#32)", () => {
|
|
68
|
+
it("parses SCSS $vars, normalizing $name to --name", () => {
|
|
69
|
+
const content = "$fui-color-accent: #fff;\n$fui-space-2: 8px;";
|
|
70
|
+
const tokens = names(content, "tokens.scss");
|
|
71
|
+
expect(tokens).toContain("--fui-color-accent");
|
|
72
|
+
expect(tokens).toContain("--fui-space-2");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("dedupes a file declaring BOTH --fui-x and $fui-x to one --fui-x", () => {
|
|
76
|
+
const content = ":root {\n --fui-x: red;\n}\n$fui-x: red;";
|
|
77
|
+
const tokens = names(content, "tokens.scss");
|
|
78
|
+
expect(tokens.filter((n) => n === "--fui-x")).toHaveLength(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("surfaces a NON-SILENT warning for a comment-only SCSS file (#32)", () => {
|
|
82
|
+
const res = parseDesignTokenContent("// just a comment\n", {
|
|
83
|
+
filePath: "empty.scss",
|
|
84
|
+
});
|
|
85
|
+
expect(res.tokens).toHaveLength(0);
|
|
86
|
+
expect(res.warnings).toHaveLength(1);
|
|
87
|
+
expect(res.warnings[0]).toMatch(/produced no token names/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("routes $-only content to SCSS even without a .scss extension", () => {
|
|
91
|
+
// No `--name:` declarations + has `$name:` declarations → SCSS branch.
|
|
92
|
+
const content = "$accent: #fff;";
|
|
93
|
+
const tokens = names(content, "_vars");
|
|
94
|
+
expect(tokens).toContain("--accent");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseDesignTokenContent — CSS terminator robustness (#34/#35)", () => {
|
|
99
|
+
it("captures a final-in-block declaration with NO trailing semicolon (#34)", () => {
|
|
100
|
+
const content = ":root {\n --fui-color-accent: #fff\n}";
|
|
101
|
+
const tokens = names(content, "tokens.css");
|
|
102
|
+
expect(tokens).toContain("--fui-color-accent");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("captures both declarations in a block where the last has no semicolon (#34)", () => {
|
|
106
|
+
const content = ":root { --a: #000; --b: #fff }";
|
|
107
|
+
const tokens = names(content, "tokens.css");
|
|
108
|
+
expect(tokens).toContain("--a");
|
|
109
|
+
expect(tokens).toContain("--b");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("captures a MULTI-LINE declaration value (#35)", () => {
|
|
113
|
+
const content = ":root {\n --fui-shadow: 0 1px 2px\n rgba(0,0,0,.1);\n}";
|
|
114
|
+
const res = parseDesignTokenContent(content, { filePath: "tokens.css" });
|
|
115
|
+
const shadow = res.tokens.find((t) => t.name === "--fui-shadow");
|
|
116
|
+
expect(shadow).toBeDefined();
|
|
117
|
+
// Line attribution survives the index-based recompute (declaration starts L2).
|
|
118
|
+
expect(shadow?.lineNumber).toBe(2);
|
|
119
|
+
// Internal whitespace (incl. the newline) is collapsed.
|
|
120
|
+
expect(shadow?.rawValue).toBe("0 1px 2px rgba(0,0,0,.1)");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("captures a multi-line value that is also the final unterminated decl (#34+#35)", () => {
|
|
124
|
+
const content = ":root {\n --fui-shadow: 0 1px 2px\n rgba(0,0,0,.1)\n}";
|
|
125
|
+
const tokens = names(content, "tokens.css");
|
|
126
|
+
expect(tokens).toContain("--fui-shadow");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("uses value shape before name heuristics for CSS custom properties", () => {
|
|
130
|
+
const res = parseDesignTokenContent(":root { --brand-primary: #39594d; }", {
|
|
131
|
+
filePath: "tokens.css",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(res.tokens.find((token) => token.name === "--brand-primary")?.category).toBe("color");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("surfaces a NON-SILENT warning for a CSS file with no token names", () => {
|
|
138
|
+
const res = parseDesignTokenContent(".foo { color: red; }", {
|
|
139
|
+
filePath: "styles.css",
|
|
140
|
+
});
|
|
141
|
+
expect(res.tokens).toHaveLength(0);
|
|
142
|
+
expect(res.warnings.some((w) => /produced no token names/.test(w))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("still parses a normal CSS token block (regression)", () => {
|
|
146
|
+
const content = ":root {\n --fui-color-accent: #ff0000;\n --fui-space-2: 8px;\n}";
|
|
147
|
+
const tokens = names(content, "tokens.css");
|
|
148
|
+
expect(tokens).toContain("--fui-color-accent");
|
|
149
|
+
expect(tokens).toContain("--fui-space-2");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("categorizes abbreviated --sp-* tokens as spacing", () => {
|
|
153
|
+
const content = ":root {\n --sp-1: 4px;\n --fui-sp-2: 8px;\n}";
|
|
154
|
+
const res = parseDesignTokenContent(content, { filePath: "tokens.css" });
|
|
155
|
+
|
|
156
|
+
expect(res.tokens.find((token) => token.name === "--sp-1")?.category).toBe("spacing");
|
|
157
|
+
expect(res.tokens.find((token) => token.name === "--fui-sp-2")?.category).toBe("spacing");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DesignToken,
|
|
3
|
+
TokenCategory,
|
|
4
|
+
TokenParseError,
|
|
5
|
+
TokenParseResult,
|
|
6
|
+
} from "../token-types.js";
|
|
7
|
+
import { inferTokenCategory } from "./categories.js";
|
|
8
|
+
import { isDTCGFile, parseDtcgTokens } from "./dtcg.js";
|
|
9
|
+
import { parseScssTokens } from "./scss.js";
|
|
10
|
+
import { containsTailwindV4Theme, parseTailwindV4Theme } from "./tailwind-v4.js";
|
|
11
|
+
import type { DesignTokenParseOptions, ParsedToken, TokenParseOutput } from "./types.js";
|
|
12
|
+
|
|
13
|
+
// Token declaration value class excludes `;` (the normal terminator), and `{`/`}`
|
|
14
|
+
// so the final declaration in a block (no trailing `;` — valid CSS, #34) and a
|
|
15
|
+
// value that wraps across newlines (#35) are both captured without swallowing a
|
|
16
|
+
// following block. Matched over the FULL content string (not per-line) so the
|
|
17
|
+
// `[^;{}]+?` class can span `\n`. Terminates on `;`, the next `}`, or EOF.
|
|
18
|
+
const TOKEN_DECLARATION_PATTERN = /--([a-zA-Z0-9_-]+)\s*:\s*([^;{}]+?)\s*(?:;|(?=\})|$)/g;
|
|
19
|
+
const VAR_REFERENCE_PATTERN = /var\(\s*--([a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\s*\)/;
|
|
20
|
+
|
|
21
|
+
export function parseDesignTokenContent(
|
|
22
|
+
content: string,
|
|
23
|
+
options: DesignTokenParseOptions = {}
|
|
24
|
+
): TokenParseResult {
|
|
25
|
+
const startTime = performance.now();
|
|
26
|
+
const tokens: DesignToken[] = [];
|
|
27
|
+
const errors: TokenParseError[] = [];
|
|
28
|
+
const warnings: string[] = [];
|
|
29
|
+
const filePath = options.filePath ?? "unknown";
|
|
30
|
+
const themeSelectors = options.themeSelectors ?? { ":root": "default" };
|
|
31
|
+
const includeTailwindV4 = options.includeTailwindV4 ?? true;
|
|
32
|
+
|
|
33
|
+
// Format dispatch (single chokepoint for the CLI's buildContractVocabularyFacts).
|
|
34
|
+
// DTCG/JSON and SCSS files produce NO `--name:` CSS declarations, so the CSS
|
|
35
|
+
// line-loop below yields an empty vocabulary and FUI2015 silently disables — the
|
|
36
|
+
// worst failure mode (#10 DTCG, #32 SCSS). Route those through the sibling
|
|
37
|
+
// parsers (both already emit `--kebab` CSS names the rule consumes unchanged),
|
|
38
|
+
// each returning early. A 0-token result surfaces a NON-SILENT warning rather
|
|
39
|
+
// than a quietly-empty vocabulary. The CSS path (with the #34/#35 robustness
|
|
40
|
+
// fixes) is the fallthrough default.
|
|
41
|
+
const trimmed = content.trimStart();
|
|
42
|
+
const looksDtcg =
|
|
43
|
+
isDTCGFile(filePath) || /\.jsonc?$|\.tokens$/i.test(filePath) || trimmed.startsWith("{");
|
|
44
|
+
if (looksDtcg) {
|
|
45
|
+
try {
|
|
46
|
+
// Fail-soft per the architecture spine: a malformed .json must never throw
|
|
47
|
+
// or break the scan — fall through to the CSS path on any parse error.
|
|
48
|
+
const out = parseDtcgTokens(content, filePath);
|
|
49
|
+
const dtTokens = parsedOutputToDesignTokens(out, filePath);
|
|
50
|
+
const dtcgWarnings =
|
|
51
|
+
dtTokens.length === 0
|
|
52
|
+
? [
|
|
53
|
+
`Token source file ${filePath}: DTCG/JSON file produced no token names; FUI2015 token-drift is inactive for it.`,
|
|
54
|
+
]
|
|
55
|
+
: [];
|
|
56
|
+
return {
|
|
57
|
+
tokens: dtTokens,
|
|
58
|
+
errors: [],
|
|
59
|
+
warnings: dtcgWarnings,
|
|
60
|
+
parseTimeMs: performance.now() - startTime,
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to the CSS path; if nothing matches there, the 0-token
|
|
64
|
+
// warning at the end of the CSS path still fires (non-silent).
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isScss = /\.(scss|sass)$/i.test(filePath);
|
|
69
|
+
const hasCssVar = /--[\w-]+\s*:/.test(content);
|
|
70
|
+
const hasScssVar = /(^|\n)\s*\$[\w-]+\s*:/.test(content);
|
|
71
|
+
if (isScss || (!hasCssVar && hasScssVar)) {
|
|
72
|
+
// parseScssTokens emits BOTH `--css-var` and `$scss-var` names. Normalize
|
|
73
|
+
// every `$name` to `--name`: the mandated dual-fallback convention
|
|
74
|
+
// `var(--fui-x, $fui-x)` guarantees a 1:1 `$`↔`--` correspondence, and `--x`
|
|
75
|
+
// is exactly the name the rule judges (it reads `var(--x)` references).
|
|
76
|
+
const out = parseScssTokens(content, filePath);
|
|
77
|
+
const scssTokens = scssOutputToDesignTokens(out, filePath);
|
|
78
|
+
const scssWarnings =
|
|
79
|
+
scssTokens.length === 0
|
|
80
|
+
? [
|
|
81
|
+
`Token source file ${filePath}: SCSS file produced no token names; FUI2015 token-drift is inactive for it.`,
|
|
82
|
+
]
|
|
83
|
+
: [];
|
|
84
|
+
return {
|
|
85
|
+
tokens: scssTokens,
|
|
86
|
+
errors: [],
|
|
87
|
+
warnings: scssWarnings,
|
|
88
|
+
parseTimeMs: performance.now() - startTime,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (includeTailwindV4 && containsTailwindV4Theme(content)) {
|
|
93
|
+
const v4Result = parseTailwindV4Theme(content, filePath);
|
|
94
|
+
tokens.push(...v4Result.tokens);
|
|
95
|
+
warnings.push(...v4Result.warnings);
|
|
96
|
+
for (const err of v4Result.errors) {
|
|
97
|
+
errors.push({ message: err, file: filePath });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tokensByName = new Map<string, { rawValue: string; line?: number }>();
|
|
102
|
+
|
|
103
|
+
// Match over the FULL content so a value spanning newlines (#35) is captured.
|
|
104
|
+
// The line number is recomputed from the match index because findSelectorForLine
|
|
105
|
+
// and extractDescription still attribute by line. Internal whitespace (incl.
|
|
106
|
+
// newlines) is collapsed so a multi-line value stores cleanly.
|
|
107
|
+
for (const match of content.matchAll(TOKEN_DECLARATION_PATTERN)) {
|
|
108
|
+
const [, name, rawValue] = match;
|
|
109
|
+
const fullName = `--${name}`;
|
|
110
|
+
const lineNumber = countNewlines(content, match.index ?? 0) + 1;
|
|
111
|
+
tokensByName.set(fullName, {
|
|
112
|
+
rawValue: rawValue.trim().replace(/\s+/g, " "),
|
|
113
|
+
line: lineNumber,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [name, { rawValue, line }] of tokensByName) {
|
|
118
|
+
const selector = findSelectorForLine(content, line ?? 1);
|
|
119
|
+
const theme = themeSelectors[selector] || "default";
|
|
120
|
+
const { resolvedValue, chain, hasCircular, unresolvedRef } = resolveDesignTokenValue(
|
|
121
|
+
rawValue,
|
|
122
|
+
tokensByName
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (hasCircular) {
|
|
126
|
+
warnings.push(`Circular reference detected for ${name} at line ${line}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (unresolvedRef) {
|
|
130
|
+
warnings.push(`Unresolved reference in ${name}: ${unresolvedRef}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
tokens.push({
|
|
134
|
+
name,
|
|
135
|
+
rawValue,
|
|
136
|
+
resolvedValue,
|
|
137
|
+
category: inferTokenCategory(name, resolvedValue || rawValue),
|
|
138
|
+
level: inferTokenLevel(name, rawValue, chain),
|
|
139
|
+
referenceChain: chain,
|
|
140
|
+
sourceFile: filePath,
|
|
141
|
+
lineNumber: line,
|
|
142
|
+
theme,
|
|
143
|
+
selector,
|
|
144
|
+
description: extractDescription(content, line ?? 1),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// NON-SILENT invariant: a non-empty CSS file that yields no token names means
|
|
149
|
+
// FUI2015 has no vocabulary from it — surface a warning instead of a quietly
|
|
150
|
+
// empty result (mirrors the DTCG/SCSS 0-token warnings above).
|
|
151
|
+
if (tokens.length === 0 && trimmed.length > 0) {
|
|
152
|
+
warnings.push(
|
|
153
|
+
`Token source file ${filePath}: CSS file produced no token names; FUI2015 token-drift is inactive for it.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
tokens,
|
|
159
|
+
errors,
|
|
160
|
+
warnings,
|
|
161
|
+
parseTimeMs: performance.now() - startTime,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Count the `\n` characters in `content` up to (not including) `index`. */
|
|
166
|
+
function countNewlines(content: string, index: number): number {
|
|
167
|
+
let count = 0;
|
|
168
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
169
|
+
if (content.charCodeAt(i) === 10) count++;
|
|
170
|
+
}
|
|
171
|
+
return count;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Flatten a DTCG/JSON `TokenParseOutput` into `DesignToken[]`. Names pass through
|
|
176
|
+
* verbatim (parseDtcgTokens already emits `--kebab` CSS names via
|
|
177
|
+
* tokenPathToCSSName). Determinism: walkTokenTree preserves JSON object key order.
|
|
178
|
+
*/
|
|
179
|
+
function parsedOutputToDesignTokens(output: TokenParseOutput, filePath: string): DesignToken[] {
|
|
180
|
+
const tokens: DesignToken[] = [];
|
|
181
|
+
for (const cat of Object.values(output.categories)) {
|
|
182
|
+
for (const t of cat) {
|
|
183
|
+
tokens.push(designTokenFromParsed(t.name, t, filePath));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return tokens;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Flatten an SCSS `TokenParseOutput` into `DesignToken[]`, normalizing every
|
|
191
|
+
* `$name` to `--name` (the mandated dual-fallback `var(--fui-x, $fui-x)` makes
|
|
192
|
+
* this 1:1 and deterministic) and de-duping so a file declaring both `--fui-x`
|
|
193
|
+
* and `$fui-x` yields one `--fui-x`.
|
|
194
|
+
*/
|
|
195
|
+
function scssOutputToDesignTokens(output: TokenParseOutput, filePath: string): DesignToken[] {
|
|
196
|
+
const names = new Set<string>();
|
|
197
|
+
const tokens: DesignToken[] = [];
|
|
198
|
+
for (const cat of Object.values(output.categories)) {
|
|
199
|
+
for (const t of cat) {
|
|
200
|
+
const cssName = t.name.startsWith("$") ? `--${t.name.slice(1)}` : t.name;
|
|
201
|
+
if (!cssName.startsWith("--") || names.has(cssName)) continue;
|
|
202
|
+
names.add(cssName);
|
|
203
|
+
tokens.push(designTokenFromParsed(cssName, t, filePath));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return tokens;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build a `DesignToken` from a sibling-parser `ParsedToken` under a CSS name. */
|
|
210
|
+
function designTokenFromParsed(name: string, parsed: ParsedToken, filePath: string): DesignToken {
|
|
211
|
+
return {
|
|
212
|
+
name,
|
|
213
|
+
rawValue: parsed.value ?? "",
|
|
214
|
+
resolvedValue: parsed.resolvedValue ?? parsed.value ?? "",
|
|
215
|
+
category: normalizeParsedTokenCategory(
|
|
216
|
+
parsed.category,
|
|
217
|
+
name,
|
|
218
|
+
parsed.resolvedValue ?? parsed.value ?? ""
|
|
219
|
+
),
|
|
220
|
+
level: 1,
|
|
221
|
+
referenceChain: [],
|
|
222
|
+
sourceFile: filePath,
|
|
223
|
+
theme: "default",
|
|
224
|
+
selector: ":root",
|
|
225
|
+
description: parsed.description,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeParsedTokenCategory(
|
|
230
|
+
category: string | undefined,
|
|
231
|
+
name: string,
|
|
232
|
+
value: string
|
|
233
|
+
): TokenCategory {
|
|
234
|
+
switch (category?.toLowerCase()) {
|
|
235
|
+
case "colors":
|
|
236
|
+
case "color":
|
|
237
|
+
case "surfaces":
|
|
238
|
+
case "surface":
|
|
239
|
+
case "text":
|
|
240
|
+
return "color";
|
|
241
|
+
case "spacing":
|
|
242
|
+
case "space":
|
|
243
|
+
return "spacing";
|
|
244
|
+
case "typography":
|
|
245
|
+
case "font":
|
|
246
|
+
return "typography";
|
|
247
|
+
case "radius":
|
|
248
|
+
case "radii":
|
|
249
|
+
return "radius";
|
|
250
|
+
case "shadows":
|
|
251
|
+
case "shadow":
|
|
252
|
+
return "shadow";
|
|
253
|
+
case "borders":
|
|
254
|
+
case "border":
|
|
255
|
+
return "border";
|
|
256
|
+
case "animation":
|
|
257
|
+
case "animations":
|
|
258
|
+
case "transitions":
|
|
259
|
+
case "transition":
|
|
260
|
+
return "animation";
|
|
261
|
+
case "sizing":
|
|
262
|
+
case "size":
|
|
263
|
+
return "sizing";
|
|
264
|
+
case "z-index":
|
|
265
|
+
case "zindex":
|
|
266
|
+
return "z-index";
|
|
267
|
+
default:
|
|
268
|
+
return inferTokenCategory(name, value);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function resolveDesignTokenValue(
|
|
273
|
+
rawValue: string,
|
|
274
|
+
tokensByName: Map<string, { rawValue: string; line?: number }>,
|
|
275
|
+
visited = new Set<string>()
|
|
276
|
+
): {
|
|
277
|
+
resolvedValue: string;
|
|
278
|
+
chain: string[];
|
|
279
|
+
hasCircular: boolean;
|
|
280
|
+
unresolvedRef?: string;
|
|
281
|
+
} {
|
|
282
|
+
const chain: string[] = [];
|
|
283
|
+
let current = rawValue;
|
|
284
|
+
let hasCircular = false;
|
|
285
|
+
let unresolvedRef: string | undefined;
|
|
286
|
+
|
|
287
|
+
const maxIterations = 20;
|
|
288
|
+
let iterations = 0;
|
|
289
|
+
|
|
290
|
+
while (iterations < maxIterations) {
|
|
291
|
+
iterations++;
|
|
292
|
+
|
|
293
|
+
const varMatch = current.match(VAR_REFERENCE_PATTERN);
|
|
294
|
+
if (!varMatch) break;
|
|
295
|
+
|
|
296
|
+
const [, refName, fallback] = varMatch;
|
|
297
|
+
const fullRefName = `--${refName}`;
|
|
298
|
+
|
|
299
|
+
if (visited.has(fullRefName)) {
|
|
300
|
+
hasCircular = true;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
visited.add(fullRefName);
|
|
305
|
+
chain.push(fullRefName);
|
|
306
|
+
|
|
307
|
+
const refToken = tokensByName.get(fullRefName);
|
|
308
|
+
if (refToken) {
|
|
309
|
+
current = current.replace(varMatch[0], refToken.rawValue);
|
|
310
|
+
} else if (fallback) {
|
|
311
|
+
current = current.replace(varMatch[0], fallback.trim());
|
|
312
|
+
} else {
|
|
313
|
+
unresolvedRef = fullRefName;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
resolvedValue: normalizeTokenValue(current.trim()),
|
|
320
|
+
chain,
|
|
321
|
+
hasCircular,
|
|
322
|
+
unresolvedRef,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function normalizeTokenValue(value: string): string {
|
|
327
|
+
value = value.replace(/#[0-9a-fA-F]+/g, (match) => match.toLowerCase());
|
|
328
|
+
value = value.replace(/\s+/g, " ").trim();
|
|
329
|
+
value = value.replace(
|
|
330
|
+
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/g,
|
|
331
|
+
(_, r, g, b, a) => (a !== undefined ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function inferTokenLevel(name: string, rawValue: string, referenceChain: string[]): 1 | 2 | 3 {
|
|
338
|
+
const lowerName = name.toLowerCase();
|
|
339
|
+
|
|
340
|
+
if (/btn|button|input|card|modal|dialog|menu|nav|header|footer|table|form/i.test(lowerName)) {
|
|
341
|
+
return 3;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (referenceChain.length > 0) {
|
|
345
|
+
return 2;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (rawValue.match(/^#[0-9a-fA-F]+$/) || rawValue.match(/^\d+(\.\d+)?(px|rem|em|%|vh|vw)?$/)) {
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function findSelectorForLine(content: string, targetLine: number): string {
|
|
356
|
+
const lines = content.split("\n");
|
|
357
|
+
let currentSelector = ":root";
|
|
358
|
+
let braceDepth = 0;
|
|
359
|
+
|
|
360
|
+
for (let i = 0; i < Math.min(targetLine, lines.length); i++) {
|
|
361
|
+
const line = lines[i];
|
|
362
|
+
const selectorMatch = line.match(/^\s*([^{]+)\s*\{/);
|
|
363
|
+
if (selectorMatch) {
|
|
364
|
+
const selector = selectorMatch[1].trim();
|
|
365
|
+
if ((line.match(/\{/g) || []).length > (line.match(/\}/g) || []).length) {
|
|
366
|
+
currentSelector = selector;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
braceDepth += (line.match(/\{/g) || []).length;
|
|
371
|
+
braceDepth -= (line.match(/\}/g) || []).length;
|
|
372
|
+
|
|
373
|
+
if (braceDepth === 0) {
|
|
374
|
+
currentSelector = ":root";
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return currentSelector;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function extractDescription(content: string, line: number): string | undefined {
|
|
382
|
+
const lines = content.split("\n");
|
|
383
|
+
if (line <= 1) return undefined;
|
|
384
|
+
|
|
385
|
+
const prevLine = lines[line - 2]?.trim();
|
|
386
|
+
const singleLineMatch = prevLine?.match(/\/\/\s*(.+)$/);
|
|
387
|
+
if (singleLineMatch) {
|
|
388
|
+
return singleLineMatch[1].trim();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const multiLineMatch = prevLine?.match(/\*\s*(.+)\s*\*\//);
|
|
392
|
+
if (multiLineMatch) {
|
|
393
|
+
return multiLineMatch[1].trim();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const inlineMatch = prevLine?.match(/\/\*\s*(.+)\s*\*\//);
|
|
397
|
+
if (inlineMatch) {
|
|
398
|
+
return inlineMatch[1].trim();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|