@nebutra/design-sync 0.1.1 → 2.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 -676
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +32 -3
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +4 -2
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +5 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/io.js +1 -1
- package/dist/providers/design-md.d.ts +49 -0
- package/dist/providers/design-md.d.ts.map +1 -0
- package/dist/providers/design-md.js +264 -0
- package/dist/serialize/from-design-md.d.ts +80 -0
- package/dist/serialize/from-design-md.d.ts.map +1 -0
- package/dist/serialize/from-design-md.js +1329 -0
- package/dist/serialize/to-brand-package.d.ts +37 -0
- package/dist/serialize/to-brand-package.d.ts.map +1 -0
- package/dist/serialize/to-brand-package.js +87 -0
- package/dist/serialize/to-design-md.d.ts +48 -0
- package/dist/serialize/to-design-md.d.ts.map +1 -0
- package/dist/serialize/to-design-md.js +114 -0
- package/dist/serialize/to-design-md.prose.d.ts +55 -0
- package/dist/serialize/to-design-md.prose.d.ts.map +1 -0
- package/dist/serialize/to-design-md.prose.js +143 -0
- package/dist/serialize/to-design-md.resolve.d.ts +36 -0
- package/dist/serialize/to-design-md.resolve.d.ts.map +1 -0
- package/dist/serialize/to-design-md.resolve.js +248 -0
- package/dist/serialize/to-preview-html.d.ts +48 -0
- package/dist/serialize/to-preview-html.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.js +250 -0
- package/dist/serialize/to-preview-html.template.d.ts +75 -0
- package/dist/serialize/to-preview-html.template.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.template.js +267 -0
- package/dist/types.d.ts +22 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +22 -6
- package/src/cli/index.ts +36 -3
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md → DTCG theme importer
|
|
3
|
+
*
|
|
4
|
+
* Parse approach: programmatic via `lint()` from `@google/design.md/linter`.
|
|
5
|
+
* The `lint()` function parses the DESIGN.md content synchronously and returns
|
|
6
|
+
* a `DesignSystemState` with typed Maps for colors, rounded, spacing, and
|
|
7
|
+
* typography. We reshape that state into our DTCG naming convention:
|
|
8
|
+
* - colors.<x> → color.<x> ($type: "color", $value: hex string)
|
|
9
|
+
* - rounded.<x> → radius.<x> ($type: "dimension", $value: "<n><unit>" string)
|
|
10
|
+
* - spacing.<x> → spacing.<x> ($type: "dimension", $value: "<n><unit>" string)
|
|
11
|
+
* - body font → fontFamily.sans ($type: "fontFamily", $value: string)
|
|
12
|
+
*
|
|
13
|
+
* PROSE-COLOR FALLBACK: When @google/design.md extracts zero colors (e.g. the
|
|
14
|
+
* VoltAgent extended DESIGN.md format where colors are written as markdown prose
|
|
15
|
+
* rather than YAML front-matter), `extractColorsFromProse` is called to scrape
|
|
16
|
+
* labeled color literals of the form `**Label** (\`<value>\`)`.
|
|
17
|
+
*
|
|
18
|
+
* NEVER import `@google/design.md` (main entry — auto-runs CLI). Only the
|
|
19
|
+
* `@google/design.md/linter` subpath export is used here.
|
|
20
|
+
*
|
|
21
|
+
* All @google/design.md coupling is isolated in THIS file.
|
|
22
|
+
*/
|
|
23
|
+
import { lint } from "@google/design.md/linter";
|
|
24
|
+
import { validateDtcgTree } from "../io";
|
|
25
|
+
// ─── Required token keys (registry contract) ──────────────────────────────────
|
|
26
|
+
const REQUIRED_TOKEN_PATHS = [
|
|
27
|
+
"color.primary",
|
|
28
|
+
"color.primary-foreground",
|
|
29
|
+
"color.background",
|
|
30
|
+
"color.foreground",
|
|
31
|
+
"color.card",
|
|
32
|
+
"color.border",
|
|
33
|
+
"color.ring",
|
|
34
|
+
"radius.md",
|
|
35
|
+
"fontFamily.sans",
|
|
36
|
+
];
|
|
37
|
+
// ─── Prose-only section headings (not emittable as DTCG leaves) ───────────────
|
|
38
|
+
/** Lowercase heading names that are prose/reference-only and cannot map to DTCG leaves. */
|
|
39
|
+
const PROSE_ONLY_HEADINGS = new Set(["elevation", "components", "shapes"]);
|
|
40
|
+
// ─── Prose dimension extraction helpers ──────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Scope content to a named section. Returns the text under the first heading whose
|
|
43
|
+
* lowercased text contains any of `keywords`, up to the next same/higher-level heading.
|
|
44
|
+
* Falls back to the entire `content` if no matching heading is found.
|
|
45
|
+
*/
|
|
46
|
+
function scopeToSection(content, keywords) {
|
|
47
|
+
const lines = content.split("\n");
|
|
48
|
+
let startLine = -1;
|
|
49
|
+
let headingLevel = 0;
|
|
50
|
+
for (let index = 0; index < lines.length; index++) {
|
|
51
|
+
const heading = parseMarkdownHeading(lines[index] ?? "");
|
|
52
|
+
if (!heading || heading.level > 3)
|
|
53
|
+
continue;
|
|
54
|
+
const headingText = heading.text.toLowerCase();
|
|
55
|
+
if (!keywords.some((kw) => headingText.includes(kw)))
|
|
56
|
+
continue;
|
|
57
|
+
startLine = index + 1;
|
|
58
|
+
headingLevel = heading.level;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (startLine === -1)
|
|
62
|
+
return content;
|
|
63
|
+
let endLine = lines.length;
|
|
64
|
+
for (let index = startLine; index < lines.length; index++) {
|
|
65
|
+
const heading = parseMarkdownHeading(lines[index] ?? "");
|
|
66
|
+
if (heading && heading.level <= headingLevel) {
|
|
67
|
+
endLine = index;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return lines.slice(startLine, endLine).join("\n");
|
|
72
|
+
}
|
|
73
|
+
function parseMarkdownHeading(line) {
|
|
74
|
+
let level = 0;
|
|
75
|
+
while (level < line.length && line[level] === "#")
|
|
76
|
+
level++;
|
|
77
|
+
if (level === 0 || level > 6 || line[level] !== " ")
|
|
78
|
+
return undefined;
|
|
79
|
+
return { level, text: line.slice(level + 1).trim() };
|
|
80
|
+
}
|
|
81
|
+
function splitSentences(content) {
|
|
82
|
+
const sentences = [];
|
|
83
|
+
for (const line of content.split("\n")) {
|
|
84
|
+
let start = 0;
|
|
85
|
+
for (let index = 0; index < line.length; index++) {
|
|
86
|
+
if (line[index] !== "." && line[index] !== "!" && line[index] !== "?")
|
|
87
|
+
continue;
|
|
88
|
+
const prev = line[index - 1];
|
|
89
|
+
const next = line[index + 1];
|
|
90
|
+
if (line[index] === "." && prev && next && /[0-9]/.test(prev) && /[0-9]/.test(next)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const sentence = line.slice(start, index + 1).trim();
|
|
94
|
+
if (sentence)
|
|
95
|
+
sentences.push(sentence);
|
|
96
|
+
start = index + 1;
|
|
97
|
+
}
|
|
98
|
+
const rest = line.slice(start).trim();
|
|
99
|
+
if (rest)
|
|
100
|
+
sentences.push(rest);
|
|
101
|
+
}
|
|
102
|
+
return sentences;
|
|
103
|
+
}
|
|
104
|
+
function readDimensionTokens(value, units) {
|
|
105
|
+
const tokens = [];
|
|
106
|
+
for (let index = 0; index < value.length; index++) {
|
|
107
|
+
const lower = value.slice(index).toLowerCase();
|
|
108
|
+
if (lower.startsWith("full")) {
|
|
109
|
+
const before = value[index - 1];
|
|
110
|
+
const after = value[index + 4];
|
|
111
|
+
const hasWordBoundaryBefore = !before || !/[a-z0-9-]/i.test(before);
|
|
112
|
+
const hasWordBoundaryAfter = !after || !/[a-z0-9-]/i.test(after);
|
|
113
|
+
if (hasWordBoundaryBefore && hasWordBoundaryAfter) {
|
|
114
|
+
tokens.push("full");
|
|
115
|
+
index += 3;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const end = readNumberEnd(value, index);
|
|
120
|
+
if (end === -1)
|
|
121
|
+
continue;
|
|
122
|
+
const unit = units.find((candidate) => value.slice(end, end + candidate.length).toLowerCase() === candidate);
|
|
123
|
+
if (!unit)
|
|
124
|
+
continue;
|
|
125
|
+
tokens.push(`${value.slice(index, end)}${unit}`);
|
|
126
|
+
index = end + unit.length - 1;
|
|
127
|
+
}
|
|
128
|
+
return tokens;
|
|
129
|
+
}
|
|
130
|
+
function readNumberEnd(value, start) {
|
|
131
|
+
const isDigit = (ch) => ch !== undefined && ch >= "0" && ch <= "9";
|
|
132
|
+
let index = start;
|
|
133
|
+
if (value[index] === "-")
|
|
134
|
+
index++;
|
|
135
|
+
let hasDigit = false;
|
|
136
|
+
while (isDigit(value[index])) {
|
|
137
|
+
hasDigit = true;
|
|
138
|
+
index++;
|
|
139
|
+
}
|
|
140
|
+
if (value[index] === ".") {
|
|
141
|
+
index++;
|
|
142
|
+
while (isDigit(value[index])) {
|
|
143
|
+
hasDigit = true;
|
|
144
|
+
index++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return hasDigit ? index : -1;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Extract CSS box-shadow values from prose content.
|
|
151
|
+
* Matches both color-first (`rgba(…) x y blur`) and offset-first (`x y blur color`) forms.
|
|
152
|
+
* Returns de-duplicated list of raw shadow strings (trimmed, from backticks or inline).
|
|
153
|
+
* Capped at 3; returns empty array if none found.
|
|
154
|
+
*/
|
|
155
|
+
function extractShadowsFromProse(content) {
|
|
156
|
+
// Prefer elevation/depth/shadow section if present
|
|
157
|
+
const searchText = scopeToSection(content, ["elevation", "depth", "shadow"]);
|
|
158
|
+
const found = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
// Pattern for backtick-wrapped shadow values containing px lengths + a color
|
|
161
|
+
// Matches: `rgba(...) 0px 8px 24px` or `0px 8px 24px rgba(...)` etc.
|
|
162
|
+
const BACKTICK_RE = /`([^`\n]{8,120})`/g;
|
|
163
|
+
let m;
|
|
164
|
+
// Extract from backticks first
|
|
165
|
+
BACKTICK_RE.lastIndex = 0;
|
|
166
|
+
while ((m = BACKTICK_RE.exec(searchText)) !== null) {
|
|
167
|
+
const candidate = (m[1] ?? "").trim();
|
|
168
|
+
if (isShadowValue(candidate) && !seen.has(candidate)) {
|
|
169
|
+
seen.add(candidate);
|
|
170
|
+
found.push(candidate);
|
|
171
|
+
}
|
|
172
|
+
if (found.length >= 3)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (found.length < 3) {
|
|
176
|
+
// Also scan for inline (non-backtick) box-shadow values, line by line.
|
|
177
|
+
for (const line of searchText.split("\n")) {
|
|
178
|
+
const markerIndex = line.toLowerCase().indexOf("box-shadow:");
|
|
179
|
+
if (markerIndex === -1)
|
|
180
|
+
continue;
|
|
181
|
+
const raw = line.slice(markerIndex + "box-shadow:".length).split(/[;`"]/)[0] ?? "";
|
|
182
|
+
const candidateSource = raw
|
|
183
|
+
.slice(0, 120)
|
|
184
|
+
.trim()
|
|
185
|
+
.replace(/[;,\s]+$/, "");
|
|
186
|
+
// Trim trailing prose: find the last px-length or color token and cut there.
|
|
187
|
+
// This handles "rgba(0,0,0,0.4) 0px 10px 30px for elevation." → "rgba(0,0,0,0.4) 0px 10px 30px"
|
|
188
|
+
const candidate = trimTrailingProse(candidateSource);
|
|
189
|
+
if (isShadowValue(candidate) && !seen.has(candidate)) {
|
|
190
|
+
seen.add(candidate);
|
|
191
|
+
found.push(candidate);
|
|
192
|
+
}
|
|
193
|
+
if (found.length >= 3)
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Trim trailing prose words from an extracted inline box-shadow string.
|
|
201
|
+
*
|
|
202
|
+
* The INLINE_SHADOW_RE captures until a newline, so a value like:
|
|
203
|
+
* `rgba(0,0,0,0.4) 0px 10px 30px for elevation.`
|
|
204
|
+
* must be cut back to the last valid CSS box-shadow token. A valid token is
|
|
205
|
+
* either a px/rem length (`-?\d+(?:\.\d+)?(?:px|rem)`) or a color function
|
|
206
|
+
* (rgba?/hsla?/oklch/color, or a #hex). Everything after the last such token
|
|
207
|
+
* (including the trailing prose word and period) is dropped.
|
|
208
|
+
*/
|
|
209
|
+
function trimTrailingProse(raw) {
|
|
210
|
+
const lastEnd = findLastShadowTokenEnd(raw);
|
|
211
|
+
if (lastEnd === -1)
|
|
212
|
+
return raw; // no tokens found — return unchanged
|
|
213
|
+
return raw.slice(0, lastEnd).trim();
|
|
214
|
+
}
|
|
215
|
+
function findLastShadowTokenEnd(raw) {
|
|
216
|
+
let lastEnd = -1;
|
|
217
|
+
for (let index = 0; index < raw.length; index++) {
|
|
218
|
+
const char = raw[index] ?? "";
|
|
219
|
+
if (char === "#") {
|
|
220
|
+
let end = index + 1;
|
|
221
|
+
while (end < raw.length && isHexDigit(raw[end] ?? ""))
|
|
222
|
+
end++;
|
|
223
|
+
const count = end - index - 1;
|
|
224
|
+
if (count >= 3 && count <= 8)
|
|
225
|
+
lastEnd = end;
|
|
226
|
+
index = Math.max(index, end - 1);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const lower = raw.slice(index).toLowerCase();
|
|
230
|
+
const fnName = ["rgba", "rgb", "hsla", "hsl", "oklch", "color"].find((name) => lower.startsWith(`${name}(`));
|
|
231
|
+
if (fnName) {
|
|
232
|
+
const close = raw.indexOf(")", index + fnName.length + 1);
|
|
233
|
+
if (close !== -1) {
|
|
234
|
+
lastEnd = close + 1;
|
|
235
|
+
index = close;
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const lengthEnd = readCssLengthEnd(raw, index);
|
|
240
|
+
if (lengthEnd !== -1) {
|
|
241
|
+
lastEnd = lengthEnd;
|
|
242
|
+
index = lengthEnd - 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lastEnd;
|
|
246
|
+
}
|
|
247
|
+
function isHexDigit(char) {
|
|
248
|
+
return ((char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"));
|
|
249
|
+
}
|
|
250
|
+
function readCssLengthEnd(value, start) {
|
|
251
|
+
const isDigit = (ch) => ch !== undefined && ch >= "0" && ch <= "9";
|
|
252
|
+
let index = start;
|
|
253
|
+
if (value[index] === "-")
|
|
254
|
+
index++;
|
|
255
|
+
let hasDigit = false;
|
|
256
|
+
while (isDigit(value[index])) {
|
|
257
|
+
hasDigit = true;
|
|
258
|
+
index++;
|
|
259
|
+
}
|
|
260
|
+
if (value[index] === ".") {
|
|
261
|
+
index++;
|
|
262
|
+
while (isDigit(value[index])) {
|
|
263
|
+
hasDigit = true;
|
|
264
|
+
index++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!hasDigit)
|
|
268
|
+
return -1;
|
|
269
|
+
if (value.slice(index, index + 2) === "px")
|
|
270
|
+
return index + 2;
|
|
271
|
+
if (value.slice(index, index + 3) === "rem")
|
|
272
|
+
return index + 3;
|
|
273
|
+
return -1;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Validate that a string looks like a CSS box-shadow value.
|
|
277
|
+
* Must contain: ≥2 px-length tokens AND a color component (rgba/rgb/#hex/hsla).
|
|
278
|
+
* Rejects values that are clearly not shadows (transition strings, border values without offset, etc.).
|
|
279
|
+
*/
|
|
280
|
+
function isShadowValue(candidate) {
|
|
281
|
+
let pxCount = 0;
|
|
282
|
+
for (let index = 0; index < candidate.length; index++) {
|
|
283
|
+
const end = readCssLengthEnd(candidate, index);
|
|
284
|
+
if (end !== -1 && candidate.slice(end - 2, end) === "px") {
|
|
285
|
+
pxCount++;
|
|
286
|
+
index = end - 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (pxCount < 2)
|
|
290
|
+
return false;
|
|
291
|
+
const hasColor = /rgba?\s*\([^)]+\)/i.test(candidate) ||
|
|
292
|
+
/hsla?\s*\([^)]+\)/i.test(candidate) ||
|
|
293
|
+
/#[0-9a-fA-F]{3,8}\b/.test(candidate);
|
|
294
|
+
if (!hasColor)
|
|
295
|
+
return false;
|
|
296
|
+
// Reject pure transition strings (contain "0.33s", "cubic-bezier", "ease", etc.)
|
|
297
|
+
if (/\d+\.?\d*s\b|cubic-bezier|ease/i.test(candidate))
|
|
298
|
+
return false;
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Compute a shadow magnitude for sorting (sum of absolute px values in the value string).
|
|
303
|
+
* Used to sort shadows from smallest → largest for sm/md/lg assignment.
|
|
304
|
+
*/
|
|
305
|
+
function shadowMagnitude(value) {
|
|
306
|
+
let sum = 0;
|
|
307
|
+
for (let index = 0; index < value.length; index++) {
|
|
308
|
+
const end = readCssLengthEnd(value, index);
|
|
309
|
+
if (end !== -1 && value.slice(end - 2, end) === "px") {
|
|
310
|
+
sum += Math.abs(parseFloat(value.slice(index, end - 2)));
|
|
311
|
+
index = end - 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return sum;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Extract radius dimension values from prose content.
|
|
318
|
+
* Returns a map of key → dimension string (e.g. `{ full: "9999px", md: "8px" }`).
|
|
319
|
+
* Prefers a Shape/Radius/Geometry/Border section; falls back to whole doc.
|
|
320
|
+
* Best-effort: always emits at least `radius.md` if any radius value found.
|
|
321
|
+
*/
|
|
322
|
+
function extractRadiusFromProse(content) {
|
|
323
|
+
const searchText = scopeToSection(content, ["shape", "radius", "geometry", "border", "corner"]);
|
|
324
|
+
const values = []; // store numeric px values for categorization
|
|
325
|
+
let hasFull = false;
|
|
326
|
+
let has50pct = false;
|
|
327
|
+
for (const sentence of splitSentences(searchText)) {
|
|
328
|
+
const lower = sentence.toLowerCase();
|
|
329
|
+
if (!["radius", "rounded", "pill", "corner", "border-radius"].some((keyword) => lower.includes(keyword))) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
for (const raw of readDimensionTokens(sentence, ["px", "rem", "%"])) {
|
|
333
|
+
if (raw.toLowerCase() === "full") {
|
|
334
|
+
hasFull = true;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (raw === "50%") {
|
|
338
|
+
has50pct = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (raw === "9999px" || parseInt(raw, 10) >= 500) {
|
|
342
|
+
hasFull = true;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const numVal = parseFloat(raw);
|
|
346
|
+
if (!isNaN(numVal) && numVal > 0 && numVal < 500) {
|
|
347
|
+
values.push(numVal);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Deduplicate and sort ascending
|
|
352
|
+
const uniqueValues = [...new Set(values.map(String))].map(Number).sort((a, b) => a - b);
|
|
353
|
+
if (uniqueValues.length === 0 && !hasFull && !has50pct) {
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
const result = {};
|
|
357
|
+
if (hasFull || has50pct) {
|
|
358
|
+
result["full"] = hasFull ? "9999px" : "50%";
|
|
359
|
+
}
|
|
360
|
+
// Assign sm / md / lg from sorted unique values
|
|
361
|
+
if (uniqueValues.length >= 3) {
|
|
362
|
+
result["sm"] = `${uniqueValues[0]}px`;
|
|
363
|
+
result["md"] = `${uniqueValues[Math.floor(uniqueValues.length / 2)]}px`;
|
|
364
|
+
result["lg"] = `${uniqueValues[uniqueValues.length - 1]}px`;
|
|
365
|
+
}
|
|
366
|
+
else if (uniqueValues.length === 2) {
|
|
367
|
+
result["sm"] = `${uniqueValues[0]}px`;
|
|
368
|
+
result["lg"] = `${uniqueValues[1]}px`;
|
|
369
|
+
// Always emit md — pick the smaller
|
|
370
|
+
result["md"] = `${uniqueValues[0]}px`;
|
|
371
|
+
}
|
|
372
|
+
else if (uniqueValues.length === 1) {
|
|
373
|
+
result["md"] = `${uniqueValues[0]}px`;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
// Only full/50% was found — emit a sensible md default
|
|
377
|
+
result["md"] = "9999px";
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Extract a font family name from prose content.
|
|
383
|
+
* Scans the Typography section (preferred) or whole doc for mentions of a font family.
|
|
384
|
+
* Returns the first confidently-identified font name, or undefined if none found.
|
|
385
|
+
*/
|
|
386
|
+
function extractFontFamilyFromProse(content) {
|
|
387
|
+
const searchText = scopeToSection(content, ["typography", "typeface", "font"]);
|
|
388
|
+
for (const line of searchText.split("\n")) {
|
|
389
|
+
const candidate = extractFontCandidateFromLine(line);
|
|
390
|
+
if (candidate && isLikelyFontName(candidate)) {
|
|
391
|
+
return candidate;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Pattern 2: bold-formatted font name `**FontName**` near "font"/"typeface"/"body"/"display"
|
|
395
|
+
const BOLD_FONT_RE = /\*\*([A-Z][A-Za-z0-9\s-]{1,40}?)\*\*/g;
|
|
396
|
+
let m;
|
|
397
|
+
BOLD_FONT_RE.lastIndex = 0;
|
|
398
|
+
while ((m = BOLD_FONT_RE.exec(searchText)) !== null) {
|
|
399
|
+
const candidate = (m[1] ?? "").trim();
|
|
400
|
+
const context = searchText.slice(Math.max(0, (m.index ?? 0) - 100), (m.index ?? 0) + 200);
|
|
401
|
+
if (isLikelyFontName(candidate) && /font|typeface|body|display|family/i.test(context)) {
|
|
402
|
+
return candidate;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Pattern 3: backtick-wrapped font name near font context
|
|
406
|
+
const BACKTICK_FONT_RE = /`([A-Z][A-Za-z0-9\s-]{1,40}?)`/g;
|
|
407
|
+
BACKTICK_FONT_RE.lastIndex = 0;
|
|
408
|
+
while ((m = BACKTICK_FONT_RE.exec(searchText)) !== null) {
|
|
409
|
+
const candidate = (m[1] ?? "").trim();
|
|
410
|
+
const context = searchText.slice(Math.max(0, (m.index ?? 0) - 100), (m.index ?? 0) + 200);
|
|
411
|
+
if (isLikelyFontName(candidate) && /font|typeface|body|family/i.test(context)) {
|
|
412
|
+
return candidate;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
/** Recognisable monospace family names (lowercased substrings). */
|
|
418
|
+
const KNOWN_MONO_FONTS = [
|
|
419
|
+
"jetbrains mono",
|
|
420
|
+
"fira code",
|
|
421
|
+
"fira mono",
|
|
422
|
+
"sf mono",
|
|
423
|
+
"roboto mono",
|
|
424
|
+
"ibm plex mono",
|
|
425
|
+
"source code pro",
|
|
426
|
+
"space mono",
|
|
427
|
+
"ubuntu mono",
|
|
428
|
+
"cascadia",
|
|
429
|
+
"geist mono",
|
|
430
|
+
"monaco",
|
|
431
|
+
"menlo",
|
|
432
|
+
"consolas",
|
|
433
|
+
"courier",
|
|
434
|
+
"inconsolata",
|
|
435
|
+
"overpass mono",
|
|
436
|
+
"anonymous pro",
|
|
437
|
+
"iosevka",
|
|
438
|
+
"commit mono",
|
|
439
|
+
];
|
|
440
|
+
/** A name is "mono-like" if it literally says mono or matches a known mono family. */
|
|
441
|
+
function isMonoFontName(name) {
|
|
442
|
+
const n = name.toLowerCase();
|
|
443
|
+
return n.includes("mono") || KNOWN_MONO_FONTS.some((f) => n.includes(f));
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Conservative prose fallback for the CODE/mono font. Only returns a candidate
|
|
447
|
+
* whose NAME is itself mono-identifying (contains "mono" or is a known mono
|
|
448
|
+
* family) — this keeps the false-positive rate low (a sans font mentioned in
|
|
449
|
+
* prose is never mistaken for the code font). Used only when no structured
|
|
450
|
+
* `typography.<code|*-mono>.fontFamily` entry exists.
|
|
451
|
+
*/
|
|
452
|
+
function extractMonoFromProse(content) {
|
|
453
|
+
const searchText = scopeToSection(content, ["typography", "code", "mono", "monospace", "font"]);
|
|
454
|
+
for (const line of searchText.split("\n")) {
|
|
455
|
+
const candidate = extractFontCandidateFromLine(line);
|
|
456
|
+
if (candidate && isLikelyFontName(candidate) && isMonoFontName(candidate)) {
|
|
457
|
+
return candidate;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Bold **Name** or backtick `Name` that is itself a mono family.
|
|
461
|
+
for (const re of [/\*\*([A-Z][A-Za-z0-9\s-]{1,40}?)\*\*/g, /`([A-Z][A-Za-z0-9\s-]{1,40}?)`/g]) {
|
|
462
|
+
re.lastIndex = 0;
|
|
463
|
+
let m;
|
|
464
|
+
while ((m = re.exec(searchText)) !== null) {
|
|
465
|
+
const candidate = (m[1] ?? "").trim();
|
|
466
|
+
if (isLikelyFontName(candidate) && isMonoFontName(candidate)) {
|
|
467
|
+
return candidate;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
function extractFontCandidateFromLine(line) {
|
|
474
|
+
const lower = line.toLowerCase();
|
|
475
|
+
const markers = ["font-family:", "font:", "typeface:", "family:", "uses "];
|
|
476
|
+
const marker = markers.find((item) => lower.includes(item));
|
|
477
|
+
if (!marker)
|
|
478
|
+
return undefined;
|
|
479
|
+
const start = lower.indexOf(marker) + marker.length;
|
|
480
|
+
const afterMarker = line.slice(start).trim();
|
|
481
|
+
const stopWords = new Set(["as", "for", "with", "and", "font", "typeface"]);
|
|
482
|
+
const words = [];
|
|
483
|
+
for (const rawWord of afterMarker.split(/\s+/)) {
|
|
484
|
+
const word = rawWord.replace(/^[`"']+|[`"',.;:]+$/g, "");
|
|
485
|
+
if (!word || stopWords.has(word.toLowerCase()))
|
|
486
|
+
break;
|
|
487
|
+
words.push(word);
|
|
488
|
+
if (words.length >= 4)
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
return words.join(" ").trim() || undefined;
|
|
492
|
+
}
|
|
493
|
+
/** Common non-font words that might otherwise be mistaken for font names. */
|
|
494
|
+
const NON_FONT_WORDS = new Set([
|
|
495
|
+
"The",
|
|
496
|
+
"This",
|
|
497
|
+
"Design",
|
|
498
|
+
"System",
|
|
499
|
+
"Brand",
|
|
500
|
+
"Color",
|
|
501
|
+
"Palette",
|
|
502
|
+
"Primary",
|
|
503
|
+
"Background",
|
|
504
|
+
"Foreground",
|
|
505
|
+
"Card",
|
|
506
|
+
"Border",
|
|
507
|
+
"Ring",
|
|
508
|
+
"Accent",
|
|
509
|
+
"Surface",
|
|
510
|
+
"Canvas",
|
|
511
|
+
"Dark",
|
|
512
|
+
"Light",
|
|
513
|
+
"White",
|
|
514
|
+
"Black",
|
|
515
|
+
"Gray",
|
|
516
|
+
"Grey",
|
|
517
|
+
"Blue",
|
|
518
|
+
"Red",
|
|
519
|
+
"Green",
|
|
520
|
+
"Orange",
|
|
521
|
+
"Purple",
|
|
522
|
+
"Yellow",
|
|
523
|
+
"Pink",
|
|
524
|
+
"Teal",
|
|
525
|
+
"Cyan",
|
|
526
|
+
"Neutral",
|
|
527
|
+
"Muted",
|
|
528
|
+
"Bold",
|
|
529
|
+
"Medium",
|
|
530
|
+
"Regular",
|
|
531
|
+
"Semibold",
|
|
532
|
+
"Typography",
|
|
533
|
+
"Spacing",
|
|
534
|
+
"Radius",
|
|
535
|
+
"Button",
|
|
536
|
+
"Input",
|
|
537
|
+
"Modal",
|
|
538
|
+
"Icon",
|
|
539
|
+
"Logo",
|
|
540
|
+
"Text",
|
|
541
|
+
"Display",
|
|
542
|
+
"Body",
|
|
543
|
+
"Heading",
|
|
544
|
+
"Caption",
|
|
545
|
+
"Label",
|
|
546
|
+
"Navigation",
|
|
547
|
+
"Footer",
|
|
548
|
+
"Header",
|
|
549
|
+
"Section",
|
|
550
|
+
"Content",
|
|
551
|
+
"Container",
|
|
552
|
+
"Grid",
|
|
553
|
+
"Column",
|
|
554
|
+
"Row",
|
|
555
|
+
"Layout",
|
|
556
|
+
"Page",
|
|
557
|
+
"View",
|
|
558
|
+
"Component",
|
|
559
|
+
"Element",
|
|
560
|
+
"Token",
|
|
561
|
+
"Variable",
|
|
562
|
+
"Style",
|
|
563
|
+
"Theme",
|
|
564
|
+
"Mode",
|
|
565
|
+
"Scale",
|
|
566
|
+
"Elevation",
|
|
567
|
+
"Shadow",
|
|
568
|
+
"Depth",
|
|
569
|
+
"Blur",
|
|
570
|
+
"Opacity",
|
|
571
|
+
"Gradient",
|
|
572
|
+
"Motion",
|
|
573
|
+
"Animation",
|
|
574
|
+
"Transition",
|
|
575
|
+
"Transform",
|
|
576
|
+
"Filter",
|
|
577
|
+
"Effect",
|
|
578
|
+
"Layer",
|
|
579
|
+
"Stack",
|
|
580
|
+
"Group",
|
|
581
|
+
"Set",
|
|
582
|
+
"Inspired",
|
|
583
|
+
"Interface",
|
|
584
|
+
"Experience",
|
|
585
|
+
"Platform",
|
|
586
|
+
"Product",
|
|
587
|
+
"Service",
|
|
588
|
+
"Feature",
|
|
589
|
+
"Base",
|
|
590
|
+
"Kit",
|
|
591
|
+
"Specimen",
|
|
592
|
+
"Circular",
|
|
593
|
+
"Universal",
|
|
594
|
+
]);
|
|
595
|
+
/** Return true if the typography key looks like a body/text entry. */
|
|
596
|
+
function isBodyLikeKey(key) {
|
|
597
|
+
const k = key.toLowerCase();
|
|
598
|
+
return k.startsWith("body") || k === "text" || k === "ui" || k.includes("body");
|
|
599
|
+
}
|
|
600
|
+
/** Return true if the typography key looks like a heading/display entry. */
|
|
601
|
+
function isHeadingLikeKey(key) {
|
|
602
|
+
const k = key.toLowerCase();
|
|
603
|
+
return (k.startsWith("h1") ||
|
|
604
|
+
k.startsWith("display") ||
|
|
605
|
+
k.startsWith("heading") ||
|
|
606
|
+
k === "h1" ||
|
|
607
|
+
k === "h2" ||
|
|
608
|
+
k.includes("display") ||
|
|
609
|
+
k.includes("heading") ||
|
|
610
|
+
k.includes("title"));
|
|
611
|
+
}
|
|
612
|
+
/** Return true if the typography key looks like a code/monospace entry. */
|
|
613
|
+
function isMonoLikeKey(key) {
|
|
614
|
+
const k = key.toLowerCase();
|
|
615
|
+
return k.includes("mono") || k.includes("code") || k === "kbd" || k === "pre";
|
|
616
|
+
}
|
|
617
|
+
function isLikelyFontName(candidate) {
|
|
618
|
+
if (!candidate || candidate.length < 2 || candidate.length > 60)
|
|
619
|
+
return false;
|
|
620
|
+
// Must start with a capital letter
|
|
621
|
+
if (!/^[A-Z]/.test(candidate))
|
|
622
|
+
return false;
|
|
623
|
+
// Must not be an obviously non-font word
|
|
624
|
+
const words = candidate.trim().split(/\s+/);
|
|
625
|
+
if (words.length > 4)
|
|
626
|
+
return false; // font names rarely exceed 4 words
|
|
627
|
+
// Each word must look like a proper noun token (mostly letters, maybe numbers/hyphens)
|
|
628
|
+
if (!words.every((w) => /^[A-Za-z][A-Za-z0-9-]*$/.test(w)))
|
|
629
|
+
return false;
|
|
630
|
+
// Must not be a single common non-font word
|
|
631
|
+
if (words.length === 1 && NON_FONT_WORDS.has(words[0] ?? ""))
|
|
632
|
+
return false;
|
|
633
|
+
// Multi-word: reject if all words are non-font words
|
|
634
|
+
if (words.every((w) => NON_FONT_WORDS.has(w)))
|
|
635
|
+
return false;
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Extract a base spacing unit from prose and return sm/md/lg entries derived from it.
|
|
640
|
+
* Scans for phrases like "8px base", "base unit: 8px", "spacing scale of 8px".
|
|
641
|
+
* Returns empty object if no base unit confidently found.
|
|
642
|
+
*/
|
|
643
|
+
function extractSpacingFromProse(content) {
|
|
644
|
+
// Prefer spacing/layout section if present
|
|
645
|
+
const searchText = scopeToSection(content, ["spacing", "layout", "grid", "space"]);
|
|
646
|
+
for (const sentence of splitSentences(searchText)) {
|
|
647
|
+
const lower = sentence.toLowerCase();
|
|
648
|
+
if (!["base", "unit", "grid", "scale", "step", "spacing", "anchored"].some((keyword) => lower.includes(keyword))) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
for (const raw of readDimensionTokens(sentence, ["px", "rem"])) {
|
|
652
|
+
const num = parseFloat(raw);
|
|
653
|
+
if (!isNaN(num) && num > 0 && num <= 32) {
|
|
654
|
+
const unit = raw.includes("rem") ? "rem" : "px";
|
|
655
|
+
// Derive sm / md / lg as base, 2×, 3×
|
|
656
|
+
return {
|
|
657
|
+
sm: `${num}${unit}`,
|
|
658
|
+
md: `${num * 2}${unit}`,
|
|
659
|
+
lg: `${num * 3}${unit}`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {};
|
|
665
|
+
}
|
|
666
|
+
// ─── Effect label filter ──────────────────────────────────────────────────────
|
|
667
|
+
/**
|
|
668
|
+
* Labels (lowercased) that describe visual effects rather than palette colors.
|
|
669
|
+
* These are excluded from extraction everywhere (scoped section and whole-doc scan).
|
|
670
|
+
*/
|
|
671
|
+
const EFFECT_LABEL_KEYWORDS = [
|
|
672
|
+
"shadow",
|
|
673
|
+
"overlay",
|
|
674
|
+
"glow",
|
|
675
|
+
"gradient",
|
|
676
|
+
"elevation",
|
|
677
|
+
"blur",
|
|
678
|
+
"scrim",
|
|
679
|
+
"inset",
|
|
680
|
+
];
|
|
681
|
+
function isEffectLabel(label) {
|
|
682
|
+
const lower = label.toLowerCase();
|
|
683
|
+
return EFFECT_LABEL_KEYWORDS.some((kw) => lower.includes(kw));
|
|
684
|
+
}
|
|
685
|
+
// ─── Slug helper ──────────────────────────────────────────────────────────────
|
|
686
|
+
/**
|
|
687
|
+
* Convert an arbitrary string into a URL-safe, lowercase, hyphenated slug.
|
|
688
|
+
* Non-alphanumeric characters are replaced with hyphens; leading/trailing
|
|
689
|
+
* hyphens and consecutive hyphens are collapsed.
|
|
690
|
+
*/
|
|
691
|
+
function toKebabSlug(raw) {
|
|
692
|
+
// Split on runs of non-alphanumerics and drop empty segments, then join.
|
|
693
|
+
// Avoids the end-anchored /-+$/ trim, which CodeQL flags as polynomial ReDoS.
|
|
694
|
+
return raw
|
|
695
|
+
.toLowerCase()
|
|
696
|
+
.split(/[^a-z0-9]+/)
|
|
697
|
+
.filter(Boolean)
|
|
698
|
+
.join("-");
|
|
699
|
+
}
|
|
700
|
+
function findBoldLabelColorFragments(content) {
|
|
701
|
+
const fragments = [];
|
|
702
|
+
for (const line of content.split("\n")) {
|
|
703
|
+
let searchStart = 0;
|
|
704
|
+
while (searchStart < line.length) {
|
|
705
|
+
const labelStart = line.indexOf("**", searchStart);
|
|
706
|
+
if (labelStart === -1)
|
|
707
|
+
break;
|
|
708
|
+
const labelEnd = line.indexOf("**", labelStart + 2);
|
|
709
|
+
if (labelEnd === -1)
|
|
710
|
+
break;
|
|
711
|
+
const label = line.slice(labelStart + 2, labelEnd).trim();
|
|
712
|
+
let cursor = labelEnd + 2;
|
|
713
|
+
while (line[cursor] === " " || line[cursor] === "\t")
|
|
714
|
+
cursor++;
|
|
715
|
+
if (label && line[cursor] === "(") {
|
|
716
|
+
fragments.push({ label, fragment: line.slice(labelStart) });
|
|
717
|
+
}
|
|
718
|
+
searchStart = labelEnd + 2;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return fragments;
|
|
722
|
+
}
|
|
723
|
+
function extractDescriptionAfterParen(fragment) {
|
|
724
|
+
const closeIndex = fragment.indexOf(")");
|
|
725
|
+
if (closeIndex === -1)
|
|
726
|
+
return "";
|
|
727
|
+
const colonIndex = fragment.indexOf(":", closeIndex + 1);
|
|
728
|
+
return colonIndex === -1 ? "" : fragment.slice(colonIndex + 1).trim();
|
|
729
|
+
}
|
|
730
|
+
// ─── Token presence checker ───────────────────────────────────────────────────
|
|
731
|
+
/**
|
|
732
|
+
* Walk the DTCG tree and check whether the given dot-path (e.g. "color.primary")
|
|
733
|
+
* resolves to a leaf node with `$value`.
|
|
734
|
+
*/
|
|
735
|
+
function hasToken(tree, dotPath) {
|
|
736
|
+
const segments = dotPath.split(".");
|
|
737
|
+
let node = tree;
|
|
738
|
+
for (const seg of segments) {
|
|
739
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
node = node[seg];
|
|
743
|
+
}
|
|
744
|
+
return node !== null && typeof node === "object" && "$value" in node;
|
|
745
|
+
}
|
|
746
|
+
// ─── DTCG leaf-group builder ──────────────────────────────────────────────────
|
|
747
|
+
/**
|
|
748
|
+
* Cast a plain record of typed leaf objects to `DesignTokenTree`.
|
|
749
|
+
* Having the unsafe cast in one helper keeps the top-level code clean.
|
|
750
|
+
*/
|
|
751
|
+
function leafGroup(entries) {
|
|
752
|
+
return entries;
|
|
753
|
+
}
|
|
754
|
+
// ─── Prose-color fallback ─────────────────────────────────────────────────────
|
|
755
|
+
/**
|
|
756
|
+
* Test whether a raw string looks like a CSS color value.
|
|
757
|
+
* Accepts: #rgb, #rrggbb, #rrggbbaa, rgb(...), rgba(...), hsl(...), hsla(...), oklch(...)
|
|
758
|
+
* Rejects: CSS variables (--*), font names, size values (12px, 1rem), malformed functional
|
|
759
|
+
* notations (must have a closing parenthesis), etc.
|
|
760
|
+
*/
|
|
761
|
+
function isCssColor(value) {
|
|
762
|
+
const v = value.trim();
|
|
763
|
+
// Hex: 3, 4, 6, or 8 hex digits
|
|
764
|
+
if (/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(v))
|
|
765
|
+
return true;
|
|
766
|
+
// Functional notations — require closing paren to reject malformed inputs
|
|
767
|
+
if (/^rgba?\([^)]*\)$/i.test(v))
|
|
768
|
+
return true;
|
|
769
|
+
if (/^hsla?\([^)]*\)$/i.test(v))
|
|
770
|
+
return true;
|
|
771
|
+
if (/^oklch\([^)]*\)$/i.test(v))
|
|
772
|
+
return true;
|
|
773
|
+
if (/^color\([^)]*\)$/i.test(v))
|
|
774
|
+
return true;
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Scope the content to the color section (if one exists) and extract all
|
|
779
|
+
* labeled color matches as `{label, slug, value, description}` tuples.
|
|
780
|
+
*
|
|
781
|
+
* Effect labels (shadow, overlay, glow, gradient, elevation, blur, scrim) are
|
|
782
|
+
* excluded everywhere — they describe visual effects, not palette colors.
|
|
783
|
+
*
|
|
784
|
+
* This is the single implementation of the prose-color extraction algorithm.
|
|
785
|
+
* Both the public `extractColorsFromProse` and the fallback wiring in
|
|
786
|
+
* `importFromDesignMd` delegate here.
|
|
787
|
+
*/
|
|
788
|
+
function extractProseColorMatches(content) {
|
|
789
|
+
// ── 1. Scope to the color section if one exists ──────────────────────────
|
|
790
|
+
const searchText = scopeToSection(content, ["color", "colour"]);
|
|
791
|
+
// ── 2. Match labeled color literals ──────────────────────────────────────
|
|
792
|
+
// Match `**Label** (` and the remainder of the line. We intentionally do NOT
|
|
793
|
+
// try to match the closing `)` of the outer parenthetical because backtick-
|
|
794
|
+
// wrapped functional colors like `rgb(30, 215, 96)` contain nested `)` which
|
|
795
|
+
// would trip a simple `[^)]` stop. Instead, we grab the full line fragment
|
|
796
|
+
// and extract the CSS color value from it.
|
|
797
|
+
//
|
|
798
|
+
// Handles all VoltAgent extended DESIGN.md formats:
|
|
799
|
+
// (A) **Label** (`#hex`) — simple hex
|
|
800
|
+
// (B) **Label** (`{colors.xxx}` — `#hex`) — template-ref, backtick hex
|
|
801
|
+
// (C) **Label** (`{colors.xxx}` — #hex) — template-ref, unquoted hex
|
|
802
|
+
// (D) **Label** (`rgb(...)`) — functional color in backticks
|
|
803
|
+
// Reusable patterns (reset lastIndex before each use).
|
|
804
|
+
// Quantifiers are bounded to prevent polynomial backtracking on adversarial input.
|
|
805
|
+
const BACKTICK_VALUE = /`([^`\n]{1,500})`/g;
|
|
806
|
+
const UNQUOTED_HEX = /#[0-9a-fA-F]{3,8}\b/g;
|
|
807
|
+
const UNQUOTED_FN = /rgba?\s*\([^)\n]{1,200}\)|hsla?\s*\([^)\n]{1,200}\)|oklch\s*\([^)\n]{1,200}\)|color\s*\([^)\n]{1,200}\)/gi;
|
|
808
|
+
const matches = [];
|
|
809
|
+
const seenSlugs = new Set();
|
|
810
|
+
let droppedCount = 0;
|
|
811
|
+
for (const lineFragment of findBoldLabelColorFragments(searchText)) {
|
|
812
|
+
const label = lineFragment.label;
|
|
813
|
+
// Skip effect labels everywhere (shadow, overlay, glow, gradient, etc.)
|
|
814
|
+
if (isEffectLabel(label))
|
|
815
|
+
continue;
|
|
816
|
+
const fragment = lineFragment.fragment; // **Label** (...rest of line)
|
|
817
|
+
// Priority 1: last backtick-wrapped value that is a valid CSS color
|
|
818
|
+
BACKTICK_VALUE.lastIndex = 0;
|
|
819
|
+
let colorValue;
|
|
820
|
+
let btm;
|
|
821
|
+
while ((btm = BACKTICK_VALUE.exec(fragment)) !== null) {
|
|
822
|
+
const v = (btm[1] ?? "").trim();
|
|
823
|
+
if (isCssColor(v))
|
|
824
|
+
colorValue = v; // last valid wins (hex comes last in template-ref)
|
|
825
|
+
}
|
|
826
|
+
if (!colorValue) {
|
|
827
|
+
// Priority 2: unquoted hex (format C)
|
|
828
|
+
UNQUOTED_HEX.lastIndex = 0;
|
|
829
|
+
let hm;
|
|
830
|
+
while ((hm = UNQUOTED_HEX.exec(fragment)) !== null) {
|
|
831
|
+
const v = hm[0].trim();
|
|
832
|
+
if (isCssColor(v))
|
|
833
|
+
colorValue = v;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (!colorValue) {
|
|
837
|
+
// Priority 3: unquoted functional color not in backticks
|
|
838
|
+
UNQUOTED_FN.lastIndex = 0;
|
|
839
|
+
let fm;
|
|
840
|
+
while ((fm = UNQUOTED_FN.exec(fragment)) !== null) {
|
|
841
|
+
const v = fm[0].trim();
|
|
842
|
+
if (isCssColor(v))
|
|
843
|
+
colorValue = v;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (!colorValue)
|
|
847
|
+
continue;
|
|
848
|
+
const slug = toKebabSlug(label);
|
|
849
|
+
if (!slug)
|
|
850
|
+
continue;
|
|
851
|
+
if (seenSlugs.has(slug))
|
|
852
|
+
continue; // first occurrence wins
|
|
853
|
+
// Cap at 48 entries — count dropped extras
|
|
854
|
+
if (matches.length >= 48) {
|
|
855
|
+
droppedCount++;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
// Extract description: text after the closing `)` of the label's parens on the same line.
|
|
859
|
+
// The description is used by assignSemanticRoles for smarter primary detection.
|
|
860
|
+
// fragment shape: **Label** (...): description text...
|
|
861
|
+
// We grab everything after the first `)` that follows the label parens block.
|
|
862
|
+
const description = extractDescriptionAfterParen(fragment);
|
|
863
|
+
seenSlugs.add(slug);
|
|
864
|
+
matches.push({ label, slug, value: colorValue, description });
|
|
865
|
+
}
|
|
866
|
+
return { matches, droppedCount };
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Extract color entries from DESIGN.md prose content (VoltAgent extended format).
|
|
870
|
+
*
|
|
871
|
+
* Looks for labeled color literals matching: `**Label** (\`<value>\`)`
|
|
872
|
+
* where `<value>` is a valid CSS color (hex, rgb, rgba, hsl, hsla, oklch).
|
|
873
|
+
*
|
|
874
|
+
* Also handles the `{colors.xxx} — #hex` variant (backtick-quoted or unquoted hex).
|
|
875
|
+
*
|
|
876
|
+
* Scoping: if a heading matching `/^#{1,3}\s+.*colou?r/i` exists in the
|
|
877
|
+
* document, extraction is limited to the content under that heading (up to
|
|
878
|
+
* the next same-or-higher heading). Otherwise the whole document is scanned.
|
|
879
|
+
*
|
|
880
|
+
* Effect labels (shadow, overlay, glow, gradient, elevation, blur, scrim) are
|
|
881
|
+
* excluded regardless of scoping — they describe visual effects, not palette colors.
|
|
882
|
+
*
|
|
883
|
+
* Token naming: labels are kebab-cased (`"Spotify Green"` → `"spotify-green"`).
|
|
884
|
+
* De-duplicates by token name (first wins). Capped at 48 entries.
|
|
885
|
+
*
|
|
886
|
+
* Returns a plain `Record<string, string>` mapping slug → raw CSS color string.
|
|
887
|
+
*
|
|
888
|
+
* @public — exported so tests can unit-test the helper directly.
|
|
889
|
+
*/
|
|
890
|
+
export function extractColorsFromProse(content) {
|
|
891
|
+
const { matches } = extractProseColorMatches(content);
|
|
892
|
+
const result = {};
|
|
893
|
+
for (const { slug, value } of matches) {
|
|
894
|
+
result[slug] = value;
|
|
895
|
+
}
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
// ─── Neutral color detector ───────────────────────────────────────────────────
|
|
899
|
+
/**
|
|
900
|
+
* Return true if the CSS color is "neutral" — meaning it is near-black, near-white,
|
|
901
|
+
* or achromatic (low channel spread).
|
|
902
|
+
*
|
|
903
|
+
* Only operates on hex colors (#rgb / #rrggbb / #rrggbbaa) and rgb(...)/rgba(...).
|
|
904
|
+
* For oklch/hsl/color() values that cannot be trivially parsed, returns false
|
|
905
|
+
* (treats them as potentially chromatic — conservative to avoid silent omission).
|
|
906
|
+
*
|
|
907
|
+
* Neutral threshold: max(r,g,b) − min(r,g,b) < 25 (out of 255), OR
|
|
908
|
+
* all channels < 10 (near-black), OR
|
|
909
|
+
* all channels > 245 (near-white).
|
|
910
|
+
*
|
|
911
|
+
* The spread threshold of 25/255 ≈ 10% filters very low-saturation blue-grays
|
|
912
|
+
* (e.g. #767d88 cool-slate, #6b7280 tailwind-gray) that are perceptually neutral
|
|
913
|
+
* even if the raw channel spread is technically > 0.
|
|
914
|
+
*/
|
|
915
|
+
function isNeutralColor(value) {
|
|
916
|
+
const v = value.trim();
|
|
917
|
+
// Parse hex
|
|
918
|
+
const hexMatch = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(v);
|
|
919
|
+
if (hexMatch) {
|
|
920
|
+
const hex = hexMatch[1] ?? "";
|
|
921
|
+
let r, g, b;
|
|
922
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
923
|
+
r = parseInt((hex[0] ?? "0") + (hex[0] ?? "0"), 16);
|
|
924
|
+
g = parseInt((hex[1] ?? "0") + (hex[1] ?? "0"), 16);
|
|
925
|
+
b = parseInt((hex[2] ?? "0") + (hex[2] ?? "0"), 16);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
929
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
930
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
931
|
+
}
|
|
932
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
933
|
+
if (spread < 25)
|
|
934
|
+
return true; // achromatic or very low saturation
|
|
935
|
+
if (r < 10 && g < 10 && b < 10)
|
|
936
|
+
return true; // near-black
|
|
937
|
+
if (r > 245 && g > 245 && b > 245)
|
|
938
|
+
return true; // near-white
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
// Parse rgb() / rgba()
|
|
942
|
+
const rgbMatch = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
|
|
943
|
+
if (rgbMatch) {
|
|
944
|
+
const r = parseInt(rgbMatch[1] ?? "0", 10);
|
|
945
|
+
const g = parseInt(rgbMatch[2] ?? "0", 10);
|
|
946
|
+
const b = parseInt(rgbMatch[3] ?? "0", 10);
|
|
947
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
948
|
+
if (spread < 25)
|
|
949
|
+
return true;
|
|
950
|
+
if (r < 10 && g < 10 && b < 10)
|
|
951
|
+
return true;
|
|
952
|
+
if (r > 245 && g > 245 && b > 245)
|
|
953
|
+
return true;
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
// Unparseable (hsl, oklch, color()) — conservatively treat as chromatic
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Assign semantic roles to prose-extracted colors (best-effort, heuristic).
|
|
961
|
+
*
|
|
962
|
+
* Returns a NEW object containing only the role entries that should be merged
|
|
963
|
+
* in — never modifies its inputs. Roles that already exist in `existingKeys`
|
|
964
|
+
* are not emitted.
|
|
965
|
+
*
|
|
966
|
+
* Role assignment logic:
|
|
967
|
+
*
|
|
968
|
+
* **primary**: Single document-order scan — first entry whose LABEL contains
|
|
969
|
+
* primary|brand|accent|cta|signature OR whose DESCRIPTION contains cta|brand|signature wins.
|
|
970
|
+
* "green" is excluded from label matching (too common in semantic success colors like
|
|
971
|
+
* "#149e61 Green"). "primary" and "accent" are excluded from description matching:
|
|
972
|
+
* "primary text"/"primary canvas" and "border accents"/"accent backgrounds" are common
|
|
973
|
+
* phrases that do NOT indicate brand-primary hue. Scanning in document order ensures that
|
|
974
|
+
* an earlier entry with a CTA/brand description beats a later entry with an Accent label.
|
|
975
|
+
* Fallback — first NON-NEUTRAL (chromatic) color when no keyword match fires.
|
|
976
|
+
* Omit — if ALL extracted colors are neutral (no chromatic fallback exists).
|
|
977
|
+
*
|
|
978
|
+
* **background**: keyword match (background|canvas|surface|base) against label or
|
|
979
|
+
* description; first match wins.
|
|
980
|
+
*
|
|
981
|
+
* **foreground**: keyword match (text|ink|foreground|body) against label or
|
|
982
|
+
* description; first match wins.
|
|
983
|
+
*/
|
|
984
|
+
function assignSemanticRoles(proseMatches, existingKeys) {
|
|
985
|
+
const roles = {};
|
|
986
|
+
// For label matching: deliberate label names that indicate brand-primary role.
|
|
987
|
+
// "green" intentionally excluded — it matches semantic success colors (e.g. Kraken's
|
|
988
|
+
// "#149e61 Green" is a success state, not the brand primary purple). Spotify Green and
|
|
989
|
+
// Starbucks Green carry "brand" / "cta" / "accent" in their descriptions, so they are
|
|
990
|
+
// correctly identified by the description pass below.
|
|
991
|
+
const PRIMARY_LABEL_KEYWORDS = ["primary", "brand", "accent", "cta", "signature"];
|
|
992
|
+
// For description matching: very narrow set — "primary" excluded (too common: "primary text",
|
|
993
|
+
// "primary canvas") and "accent" excluded ("border accents" is common but not brand-primary).
|
|
994
|
+
// Only "cta", "brand", and "signature" are specific enough to reliably indicate brand-primary.
|
|
995
|
+
const PRIMARY_DESC_KEYWORDS = ["cta", "brand", "signature"];
|
|
996
|
+
const BACKGROUND_KEYWORDS = ["background", "canvas", "surface", "base"];
|
|
997
|
+
const FOREGROUND_KEYWORDS = ["text", "ink", "foreground", "body"];
|
|
998
|
+
function labelMatches(match, keywords) {
|
|
999
|
+
const label = match.label.toLowerCase();
|
|
1000
|
+
return keywords.some((kw) => label.includes(kw));
|
|
1001
|
+
}
|
|
1002
|
+
function descMatches(match, keywords) {
|
|
1003
|
+
const desc = match.description.toLowerCase();
|
|
1004
|
+
return keywords.some((kw) => desc.includes(kw));
|
|
1005
|
+
}
|
|
1006
|
+
function matchesAny(match, keywords) {
|
|
1007
|
+
const label = match.label.toLowerCase();
|
|
1008
|
+
const desc = match.description.toLowerCase();
|
|
1009
|
+
return keywords.some((kw) => label.includes(kw) || desc.includes(kw));
|
|
1010
|
+
}
|
|
1011
|
+
// primary — scan in document order; first entry that hits label OR description keywords wins.
|
|
1012
|
+
// This ensures a color whose description says "CTA" is preferred over a later one whose
|
|
1013
|
+
// label happens to say "Accent" (e.g. Sanity Red via "brand CTA" desc beats Accent Magenta).
|
|
1014
|
+
if (!existingKeys.has("primary")) {
|
|
1015
|
+
const keywordMatch = proseMatches.find((m) => labelMatches(m, PRIMARY_LABEL_KEYWORDS) || descMatches(m, PRIMARY_DESC_KEYWORDS));
|
|
1016
|
+
if (keywordMatch) {
|
|
1017
|
+
roles["primary"] = { $value: keywordMatch.value, $type: "color" };
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
// Fall back to first NON-NEUTRAL (chromatic) color
|
|
1021
|
+
const chromaticMatch = proseMatches.find((m) => !isNeutralColor(m.value));
|
|
1022
|
+
if (chromaticMatch) {
|
|
1023
|
+
roles["primary"] = { $value: chromaticMatch.value, $type: "color" };
|
|
1024
|
+
}
|
|
1025
|
+
// If ALL colors are neutral, omit primary entirely — do not force a near-black/white
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// background
|
|
1029
|
+
if (!existingKeys.has("background")) {
|
|
1030
|
+
const bgMatch = proseMatches.find((m) => matchesAny(m, BACKGROUND_KEYWORDS));
|
|
1031
|
+
if (bgMatch) {
|
|
1032
|
+
roles["background"] = { $value: bgMatch.value, $type: "color" };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// foreground / text
|
|
1036
|
+
if (!existingKeys.has("foreground")) {
|
|
1037
|
+
const fgMatch = proseMatches.find((m) => matchesAny(m, FOREGROUND_KEYWORDS));
|
|
1038
|
+
if (fgMatch) {
|
|
1039
|
+
roles["foreground"] = { $value: fgMatch.value, $type: "color" };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return roles;
|
|
1043
|
+
}
|
|
1044
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
1045
|
+
/**
|
|
1046
|
+
* Parse a DESIGN.md document and produce a DTCG-compliant `DesignTokenSet`
|
|
1047
|
+
* scoped to the `themes/` bucket (SSOT-safe — never overwrites core or semantic).
|
|
1048
|
+
*
|
|
1049
|
+
* The function is SYNCHRONOUS; it never reads from or writes to the filesystem.
|
|
1050
|
+
*
|
|
1051
|
+
* @param content - Raw DESIGN.md content (markdown + YAML front matter).
|
|
1052
|
+
* @param options - Optional: `brandName` to override the slug.
|
|
1053
|
+
* @returns `ImportResult` containing the token set and a diagnostic report.
|
|
1054
|
+
* @throws If `@google/design.md` fails to parse the content, or if the produced
|
|
1055
|
+
* DTCG tree is invalid (invalid leaves). Empty input produces an
|
|
1056
|
+
* empty-but-valid tree and does NOT throw.
|
|
1057
|
+
*/
|
|
1058
|
+
export function importFromDesignMd(content, options) {
|
|
1059
|
+
// ── 1. Parse via @google/design.md/linter ──────────────────────────────────
|
|
1060
|
+
let lintReport;
|
|
1061
|
+
try {
|
|
1062
|
+
lintReport = lint(content);
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
throw new Error(`[from-design-md] @google/design.md failed to parse content: ${err?.message ?? String(err)}`);
|
|
1066
|
+
}
|
|
1067
|
+
const state = lintReport.designSystem;
|
|
1068
|
+
// ── 2. Derive slug / name ──────────────────────────────────────────────────
|
|
1069
|
+
// Compute the kebab slug FIRST; if it degenerates to "" (e.g. brandName:"!!!"),
|
|
1070
|
+
// fall back to "imported" so relativePath is never "themes/.json".
|
|
1071
|
+
const rawName = options?.brandName ?? state.name ?? "";
|
|
1072
|
+
const computedSlug = toKebabSlug(rawName);
|
|
1073
|
+
const slug = computedSlug.length > 0 ? computedSlug : "imported";
|
|
1074
|
+
const relativePath = `themes/${slug}.json`;
|
|
1075
|
+
const name = `themes/${slug}`;
|
|
1076
|
+
// ── 3. Build DTCG token tree ───────────────────────────────────────────────
|
|
1077
|
+
const tokens = {};
|
|
1078
|
+
// 3a. colors.<x> → color.<x> ($type: "color", $value: hex string)
|
|
1079
|
+
// If @google/design.md extracts zero colors (VoltAgent extended prose format),
|
|
1080
|
+
// fall back to scraping the prose content for labeled color literals.
|
|
1081
|
+
let usedProseFallback = false;
|
|
1082
|
+
const warnings = [];
|
|
1083
|
+
if (state.colors.size > 0) {
|
|
1084
|
+
const colorEntries = {};
|
|
1085
|
+
for (const [key, resolved] of state.colors.entries()) {
|
|
1086
|
+
colorEntries[key] = { $value: resolved.hex, $type: "color" };
|
|
1087
|
+
}
|
|
1088
|
+
tokens["color"] = leafGroup(colorEntries);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
// Prose-color fallback: extract labeled color literals from markdown prose
|
|
1092
|
+
const { matches: proseMatchList, droppedCount } = extractProseColorMatches(content);
|
|
1093
|
+
if (proseMatchList.length > 0) {
|
|
1094
|
+
usedProseFallback = true;
|
|
1095
|
+
// Build base color entries (immutable — no direct mutation)
|
|
1096
|
+
const baseEntries = {};
|
|
1097
|
+
for (const { slug: s, value } of proseMatchList) {
|
|
1098
|
+
baseEntries[s] = { $value: value, $type: "color" };
|
|
1099
|
+
}
|
|
1100
|
+
// Cap warning — emitted before roles assignment
|
|
1101
|
+
if (droppedCount > 0) {
|
|
1102
|
+
warnings.push(`prose fallback: capped at 48 colors (${droppedCount} more dropped)`);
|
|
1103
|
+
}
|
|
1104
|
+
// Assign semantic roles (returns new object, no mutation)
|
|
1105
|
+
const roles = assignSemanticRoles(proseMatchList, new Set(Object.keys(baseEntries)));
|
|
1106
|
+
// Merge immutably: base entries + roles (base entries take precedence for existing keys)
|
|
1107
|
+
tokens["color"] = leafGroup({ ...roles, ...baseEntries });
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// 3b. rounded.<x> → radius.<x> ($type: "dimension", $value: "<n><unit>" string)
|
|
1111
|
+
// Prose fallback: when front-matter rounded is empty, scan prose for radius dimensions.
|
|
1112
|
+
if (state.rounded.size > 0) {
|
|
1113
|
+
const radiusEntries = {};
|
|
1114
|
+
for (const [key, resolved] of state.rounded.entries()) {
|
|
1115
|
+
radiusEntries[key] = {
|
|
1116
|
+
$value: `${resolved.value}${resolved.unit}`,
|
|
1117
|
+
$type: "dimension",
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
tokens["radius"] = leafGroup(radiusEntries);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
const proseRadius = extractRadiusFromProse(content);
|
|
1124
|
+
if (Object.keys(proseRadius).length > 0) {
|
|
1125
|
+
const radiusEntries = {};
|
|
1126
|
+
for (const [key, val] of Object.entries(proseRadius)) {
|
|
1127
|
+
radiusEntries[key] = { $value: val, $type: "dimension" };
|
|
1128
|
+
}
|
|
1129
|
+
tokens["radius"] = leafGroup(radiusEntries);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// 3c. spacing.<x> → spacing.<x> ($type: "dimension", $value: "<n><unit>" string)
|
|
1133
|
+
// Prose fallback: when front-matter spacing is empty, scan prose for a base spacing unit.
|
|
1134
|
+
if (state.spacing.size > 0) {
|
|
1135
|
+
const spacingEntries = {};
|
|
1136
|
+
for (const [key, resolved] of state.spacing.entries()) {
|
|
1137
|
+
spacingEntries[key] = {
|
|
1138
|
+
$value: `${resolved.value}${resolved.unit}`,
|
|
1139
|
+
$type: "dimension",
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
tokens["spacing"] = leafGroup(spacingEntries);
|
|
1143
|
+
}
|
|
1144
|
+
else {
|
|
1145
|
+
const proseSpacing = extractSpacingFromProse(content);
|
|
1146
|
+
if (Object.keys(proseSpacing).length > 0) {
|
|
1147
|
+
const spacingEntries = {};
|
|
1148
|
+
for (const [key, val] of Object.entries(proseSpacing)) {
|
|
1149
|
+
spacingEntries[key] = { $value: val, $type: "dimension" };
|
|
1150
|
+
}
|
|
1151
|
+
tokens["spacing"] = leafGroup(spacingEntries);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// 3d. typography font-family → fontFamily.{sans,heading,mono} ($type: "fontFamily")
|
|
1155
|
+
// @google/design.md 0.2.0 preserves the exact YAML front-matter key (e.g. "body",
|
|
1156
|
+
// "body-md", "h1", "display", "code", "caption-mono"). Markdown-section typography is
|
|
1157
|
+
// NOT parsed into state.typography. Each role has its own pick order with a like-key
|
|
1158
|
+
// fallback. sans also has a prose fallback when front-matter typography is empty.
|
|
1159
|
+
const FONT_FAMILY_PICK_ORDER = ["body", "body-md", "h1"];
|
|
1160
|
+
const HEADING_FONT_PICK_ORDER = ["display", "h1", "heading", "display-xl", "display-lg"];
|
|
1161
|
+
const MONO_FONT_PICK_ORDER = [
|
|
1162
|
+
"code",
|
|
1163
|
+
"mono",
|
|
1164
|
+
"caption-mono",
|
|
1165
|
+
"code-block",
|
|
1166
|
+
"code-inline",
|
|
1167
|
+
];
|
|
1168
|
+
const typographyPairs = [...state.typography.entries()];
|
|
1169
|
+
const pickFontFamily = (order, likeKey) => {
|
|
1170
|
+
const byOrder = order
|
|
1171
|
+
.map((k) => state.typography.get(k))
|
|
1172
|
+
.find((t) => t?.fontFamily !== undefined)?.fontFamily;
|
|
1173
|
+
if (byOrder)
|
|
1174
|
+
return byOrder;
|
|
1175
|
+
return typographyPairs.find(([k, t]) => likeKey(k) && t?.fontFamily !== undefined)?.[1]
|
|
1176
|
+
?.fontFamily;
|
|
1177
|
+
};
|
|
1178
|
+
// sans — unchanged precedence (body → body-md → h1 → first available → prose fallback)
|
|
1179
|
+
const pickedTypography = FONT_FAMILY_PICK_ORDER.map((k) => state.typography.get(k)).find((t) => t?.fontFamily !== undefined) ?? [...state.typography.values()].find((t) => t?.fontFamily !== undefined);
|
|
1180
|
+
let sansValue = pickedTypography?.fontFamily;
|
|
1181
|
+
if (!sansValue) {
|
|
1182
|
+
const proseFontFamily = extractFontFamilyFromProse(content);
|
|
1183
|
+
if (proseFontFamily)
|
|
1184
|
+
sansValue = `${proseFontFamily}, sans-serif`;
|
|
1185
|
+
}
|
|
1186
|
+
const headingValue = pickFontFamily(HEADING_FONT_PICK_ORDER, isHeadingLikeKey);
|
|
1187
|
+
// Structured typography first; conservative prose fallback (mono-named fonts only).
|
|
1188
|
+
let monoValue = pickFontFamily(MONO_FONT_PICK_ORDER, isMonoLikeKey);
|
|
1189
|
+
if (!monoValue) {
|
|
1190
|
+
const proseMono = extractMonoFromProse(content);
|
|
1191
|
+
if (proseMono)
|
|
1192
|
+
monoValue = /,/.test(proseMono) ? proseMono : `${proseMono}, monospace`;
|
|
1193
|
+
}
|
|
1194
|
+
const fontFamilyEntries = {};
|
|
1195
|
+
if (sansValue)
|
|
1196
|
+
fontFamilyEntries["sans"] = { $value: sansValue, $type: "fontFamily" };
|
|
1197
|
+
// Only emit heading when it differs from sans — the consumer falls back
|
|
1198
|
+
// heading → sans, so a duplicate would be noise.
|
|
1199
|
+
if (headingValue && headingValue !== sansValue) {
|
|
1200
|
+
fontFamilyEntries["heading"] = { $value: headingValue, $type: "fontFamily" };
|
|
1201
|
+
}
|
|
1202
|
+
if (monoValue)
|
|
1203
|
+
fontFamilyEntries["mono"] = { $value: monoValue, $type: "fontFamily" };
|
|
1204
|
+
if (Object.keys(fontFamilyEntries).length > 0) {
|
|
1205
|
+
tokens["fontFamily"] = leafGroup(fontFamilyEntries);
|
|
1206
|
+
}
|
|
1207
|
+
// 3e. Type-scale: emit fontSize.base / fontSize.heading / fontWeight.heading
|
|
1208
|
+
// Only from front-matter typography (state.typography) — pick body entry for base,
|
|
1209
|
+
// heading-ish entry for heading. Only emitted when fontSize/fontWeight fields exist.
|
|
1210
|
+
// state.typography values have fontSize as ResolvedDimension ({type,value,unit}).
|
|
1211
|
+
{
|
|
1212
|
+
const BODY_PICK_ORDER = ["body", "body-md", "body-sm"];
|
|
1213
|
+
const HEADING_PICK_ORDER = ["h1", "display", "heading", "display-xl", "display-lg"];
|
|
1214
|
+
// Helper to format a ResolvedDimension to a string (same pattern as radius/spacing)
|
|
1215
|
+
function fmtDim(dim) {
|
|
1216
|
+
return `${dim.value}${dim.unit}`;
|
|
1217
|
+
}
|
|
1218
|
+
const typographyEntries = [...state.typography.entries()];
|
|
1219
|
+
const bodyEntry = BODY_PICK_ORDER.map((k) => state.typography.get(k)).find((t) => t?.fontSize !== undefined) ??
|
|
1220
|
+
typographyEntries.find(([k, t]) => isBodyLikeKey(k) && t?.fontSize !== undefined)?.[1];
|
|
1221
|
+
const headingEntry = HEADING_PICK_ORDER.map((k) => state.typography.get(k)).find((t) => t?.fontSize !== undefined) ??
|
|
1222
|
+
typographyEntries.find(([k, t]) => isHeadingLikeKey(k) && t?.fontSize !== undefined)?.[1];
|
|
1223
|
+
const fontSizeEntries = {};
|
|
1224
|
+
const fontWeightEntries = {};
|
|
1225
|
+
if (bodyEntry?.fontSize) {
|
|
1226
|
+
fontSizeEntries["base"] = { $value: fmtDim(bodyEntry.fontSize), $type: "dimension" };
|
|
1227
|
+
}
|
|
1228
|
+
if (headingEntry?.fontSize) {
|
|
1229
|
+
fontSizeEntries["heading"] = {
|
|
1230
|
+
$value: fmtDim(headingEntry.fontSize),
|
|
1231
|
+
$type: "dimension",
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
if (headingEntry?.fontWeight !== undefined && headingEntry.fontWeight !== null) {
|
|
1235
|
+
fontWeightEntries["heading"] = {
|
|
1236
|
+
$value: String(headingEntry.fontWeight),
|
|
1237
|
+
$type: "fontWeight",
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
if (Object.keys(fontSizeEntries).length > 0) {
|
|
1241
|
+
tokens["fontSize"] = leafGroup(fontSizeEntries);
|
|
1242
|
+
}
|
|
1243
|
+
if (Object.keys(fontWeightEntries).length > 0) {
|
|
1244
|
+
tokens["fontWeight"] = leafGroup(fontWeightEntries);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
// 3f. Shadow from prose (heuristic — no front-matter equivalent in the DESIGN.md spec)
|
|
1248
|
+
// Scan prose for CSS box-shadow values; assign to md/sm/lg so the PRIMARY shadow
|
|
1249
|
+
// always lands in `md` (which consumers read for `--shadow-md`).
|
|
1250
|
+
// 1 shadow → { md }
|
|
1251
|
+
// 2 shadows → { sm: smaller, md: larger }
|
|
1252
|
+
// 3 shadows → { sm, md, lg } (ascending by magnitude)
|
|
1253
|
+
{
|
|
1254
|
+
let shadowExtracted = false;
|
|
1255
|
+
const rawShadows = extractShadowsFromProse(content);
|
|
1256
|
+
if (rawShadows.length > 0) {
|
|
1257
|
+
// Sort ascending by magnitude (smallest → largest)
|
|
1258
|
+
const sorted = [...rawShadows].sort((a, b) => shadowMagnitude(a) - shadowMagnitude(b));
|
|
1259
|
+
const shadowEntries = {};
|
|
1260
|
+
if (sorted.length === 1) {
|
|
1261
|
+
shadowEntries["md"] = { $value: sorted[0] ?? "", $type: "shadow" };
|
|
1262
|
+
}
|
|
1263
|
+
else if (sorted.length === 2) {
|
|
1264
|
+
shadowEntries["sm"] = { $value: sorted[0] ?? "", $type: "shadow" };
|
|
1265
|
+
shadowEntries["md"] = { $value: sorted[1] ?? "", $type: "shadow" };
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
// 3 shadows → sm / md / lg
|
|
1269
|
+
shadowEntries["sm"] = { $value: sorted[0] ?? "", $type: "shadow" };
|
|
1270
|
+
shadowEntries["md"] = { $value: sorted[1] ?? "", $type: "shadow" };
|
|
1271
|
+
shadowEntries["lg"] = { $value: sorted[2] ?? "", $type: "shadow" };
|
|
1272
|
+
}
|
|
1273
|
+
tokens["shadow"] = leafGroup(shadowEntries);
|
|
1274
|
+
shadowExtracted = true;
|
|
1275
|
+
}
|
|
1276
|
+
if (shadowExtracted) {
|
|
1277
|
+
warnings.push("shadow extracted from prose (heuristic) — review shadow.sm/md/lg values");
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// ── 4. Validate DTCG tree ──────────────────────────────────────────────────
|
|
1281
|
+
const dtcgErrors = validateDtcgTree(tokens);
|
|
1282
|
+
if (dtcgErrors.length > 0) {
|
|
1283
|
+
throw new Error(`[from-design-md] Produced DTCG tree has invalid leaves:\n ${dtcgErrors.join("\n ")}`);
|
|
1284
|
+
}
|
|
1285
|
+
// ── 5. Build ImportReport ──────────────────────────────────────────────────
|
|
1286
|
+
const missingRequired = REQUIRED_TOKEN_PATHS.filter((path) => !hasToken(tokens, path));
|
|
1287
|
+
// Derive unmapped from the DESIGN.md section headings (h1–h3) present in this document.
|
|
1288
|
+
// DESIGN.md canonical form uses ## (h2) for top-level sections, e.g. "## Elevation & Depth",
|
|
1289
|
+
// "## Components", "## Shapes". We scan h1–h3 to be robust to minor spec drift.
|
|
1290
|
+
// Matching is substring-based (lowercased heading CONTAINS the keyword) so that headings
|
|
1291
|
+
// like "Elevation & Depth" still match the "elevation" keyword.
|
|
1292
|
+
const unmappedSet = new Set();
|
|
1293
|
+
const docSections = [];
|
|
1294
|
+
for (const line of content.split("\n")) {
|
|
1295
|
+
const heading = parseMarkdownHeading(line);
|
|
1296
|
+
if (heading && heading.level <= 3) {
|
|
1297
|
+
docSections.push(heading.text);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const labels = {
|
|
1301
|
+
elevation: "elevation (prose-only — no structured token in DESIGN.md spec)",
|
|
1302
|
+
components: "components (prose/reference tokens — not imported as DTCG leaves)",
|
|
1303
|
+
shapes: "shapes (prose-only — no structured token in DESIGN.md spec)",
|
|
1304
|
+
};
|
|
1305
|
+
for (const heading of docSections) {
|
|
1306
|
+
const lower = heading.toLowerCase();
|
|
1307
|
+
const matched = [...PROSE_ONLY_HEADINGS].find((k) => lower.includes(k));
|
|
1308
|
+
if (matched) {
|
|
1309
|
+
unmappedSet.add(labels[matched] ?? `${heading} (prose-only — not imported as DTCG leaves)`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const unmapped = [...unmappedSet];
|
|
1313
|
+
// Prose fallback warning — emitted when colors were scraped from prose instead of front matter
|
|
1314
|
+
if (usedProseFallback) {
|
|
1315
|
+
warnings.push("colors extracted from prose fallback — source has no/empty front-matter colors block");
|
|
1316
|
+
}
|
|
1317
|
+
// Emit a warning if linter found any errors/warnings about the input
|
|
1318
|
+
for (const finding of lintReport.findings) {
|
|
1319
|
+
if (finding.severity === "error" || finding.severity === "warning") {
|
|
1320
|
+
warnings.push(finding.path
|
|
1321
|
+
? `[${finding.severity}] ${finding.path}: ${finding.message}`
|
|
1322
|
+
: `[${finding.severity}] ${finding.message}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
// ── 6. Assemble result ─────────────────────────────────────────────────────
|
|
1326
|
+
const set = { name, relativePath, tokens };
|
|
1327
|
+
const report = { unmapped, missingRequired, warnings };
|
|
1328
|
+
return { set, report };
|
|
1329
|
+
}
|