@mandujs/core 0.43.1 → 0.45.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/package.json +2 -1
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -0
- package/src/bundler/plugins/index.ts +6 -0
- package/src/bundler/plugins/react-compiler-config.ts +108 -0
- package/src/client/router.ts +12 -2
- package/src/client/spa-nav-helper.ts +1 -1
- package/src/config/validate.ts +36 -0
- package/src/design/__tests__/parser.test.ts +195 -0
- package/src/design/index.ts +49 -0
- package/src/design/parser.ts +555 -0
- package/src/design/scaffold.ts +147 -0
- package/src/design/types.ts +210 -0
- package/src/guard/__tests__/design-inline-class.test.ts +219 -0
- package/src/guard/check.ts +15 -0
- package/src/guard/design-inline-class.ts +353 -0
- package/src/guard/rules.ts +9 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md parser — the 9-section heading walker.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Optional H1 title at the top of the file.
|
|
6
|
+
* 2. Scan H2 headings. Match each against the 9 canonical sections
|
|
7
|
+
* via a fuzzy-but-bounded resolver (lowercase, strip `&` / `'`,
|
|
8
|
+
* collapse whitespace). Unrecognized headings land in
|
|
9
|
+
* `extraSections` so the file round-trips cleanly.
|
|
10
|
+
* 3. For each matched section, parse the body into structured
|
|
11
|
+
* tokens with a section-specific extractor. Extractors are
|
|
12
|
+
* forgiving — a malformed row is skipped, never fatal.
|
|
13
|
+
*
|
|
14
|
+
* Parsing must NEVER throw on real-world DESIGN.md files. The 69
|
|
15
|
+
* brand entries in awesome-design-md vary wildly in formatting; the
|
|
16
|
+
* parser is the contract that smooths them out for the rest of Mandu.
|
|
17
|
+
*
|
|
18
|
+
* @module core/design/parser
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
AgentPrompt,
|
|
23
|
+
AnyDesignSection,
|
|
24
|
+
ColorToken,
|
|
25
|
+
ComponentToken,
|
|
26
|
+
DesignSectionId,
|
|
27
|
+
DesignSpec,
|
|
28
|
+
DoDontRule,
|
|
29
|
+
ResponsiveBreakpoint,
|
|
30
|
+
ShadowToken,
|
|
31
|
+
SpacingToken,
|
|
32
|
+
TypographyToken,
|
|
33
|
+
ValidationIssue,
|
|
34
|
+
ValidationResult,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
import { DESIGN_SECTION_IDS } from "./types";
|
|
38
|
+
|
|
39
|
+
// ────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Heading → section-id resolution
|
|
41
|
+
// ────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Match heading text against the 9 canonical section ids. Returns the
|
|
45
|
+
* id when the heading "looks like" the section by keyword, else null.
|
|
46
|
+
* The keyword sets are intentionally redundant to absorb the wording
|
|
47
|
+
* differences across the awesome-design-md catalog.
|
|
48
|
+
*/
|
|
49
|
+
function resolveSectionId(heading: string): DesignSectionId | null {
|
|
50
|
+
const h = heading
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[&'`*_~]/g, "")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim();
|
|
55
|
+
if (/^visual|^theme|^philosophy|^vibe|^aesthetic/.test(h)) return "theme";
|
|
56
|
+
if (/color|palette/.test(h)) return "color-palette";
|
|
57
|
+
if (/typograph|typeface|font|type scale/.test(h)) return "typography";
|
|
58
|
+
if (/component|button|card|input/.test(h)) return "components";
|
|
59
|
+
if (/layout|spacing|grid|whitespace/.test(h)) return "layout";
|
|
60
|
+
if (/shadow|elevation|depth/.test(h)) return "shadows";
|
|
61
|
+
if (/do.{0,3}dont|guideline|principle|rule/.test(h)) return "dos-donts";
|
|
62
|
+
if (/responsive|breakpoint|mobile|tablet|desktop/.test(h)) return "responsive";
|
|
63
|
+
if (/agent|prompt|llm|ai/.test(h)) return "agent-prompts";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ────────────────────────────────────────────────────────────────────
|
|
68
|
+
// H2 splitter
|
|
69
|
+
// ────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
interface RawSection {
|
|
72
|
+
heading: string;
|
|
73
|
+
/** `1` or `2` — H1 or H2. */
|
|
74
|
+
level: number;
|
|
75
|
+
body: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Strip HTML comments (`<!-- … -->`) before structural parsing. The
|
|
80
|
+
* empty-skeleton template uses HTML comments to show example tokens
|
|
81
|
+
* without having them counted as real tokens; the parser must respect
|
|
82
|
+
* that convention. Multi-line comments are supported.
|
|
83
|
+
*/
|
|
84
|
+
function stripHtmlComments(source: string): string {
|
|
85
|
+
return source.replace(/<!--[\s\S]*?-->/g, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splitByHeadings(source: string): {
|
|
89
|
+
title?: string;
|
|
90
|
+
sections: RawSection[];
|
|
91
|
+
} {
|
|
92
|
+
const lines = stripHtmlComments(source).split(/\r?\n/);
|
|
93
|
+
let title: string | undefined;
|
|
94
|
+
const sections: RawSection[] = [];
|
|
95
|
+
let current: RawSection | null = null;
|
|
96
|
+
|
|
97
|
+
// H1 detection — first non-empty line is `# Foo` or first `# Foo` before
|
|
98
|
+
// any H2. Only the first H1 is treated as title.
|
|
99
|
+
let titleConsumed = false;
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line);
|
|
103
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
104
|
+
if (h1 && !titleConsumed && current === null) {
|
|
105
|
+
title = h1[1].trim();
|
|
106
|
+
titleConsumed = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (h2) {
|
|
110
|
+
if (current) sections.push(current);
|
|
111
|
+
current = { heading: h2[1].trim(), level: 2, body: "" };
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (current) {
|
|
115
|
+
current.body += (current.body ? "\n" : "") + line;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (current) sections.push(current);
|
|
119
|
+
return { title, sections };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ────────────────────────────────────────────────────────────────────
|
|
123
|
+
// Section extractors
|
|
124
|
+
// ────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const COLOR_VALUE_RX =
|
|
127
|
+
/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\))/;
|
|
128
|
+
|
|
129
|
+
function extractColorTokens(body: string): ColorToken[] {
|
|
130
|
+
const tokens: ColorToken[] = [];
|
|
131
|
+
for (const line of body.split(/\r?\n/)) {
|
|
132
|
+
const trimmed = line.trim().replace(/^[-*+]\s*/, "").replace(/^\|\s*/, "").replace(/\s*\|.*$/, "");
|
|
133
|
+
if (!trimmed) continue;
|
|
134
|
+
const valueMatch = COLOR_VALUE_RX.exec(line);
|
|
135
|
+
// Try patterns:
|
|
136
|
+
// - `name — #hex — role`
|
|
137
|
+
// - `name: #hex (role)`
|
|
138
|
+
// - `**name** \`#hex\` — role`
|
|
139
|
+
// - `| name | #hex | role |` (markdown table row)
|
|
140
|
+
const stripped = line
|
|
141
|
+
.replace(/[`*_]/g, "")
|
|
142
|
+
.replace(/^[\s|>-]+/, "")
|
|
143
|
+
.replace(/\s*\|.*$/, "");
|
|
144
|
+
const sepMatch = /^([^:—–\-]+?)[\s]*[:—–\-][\s]*(.+)$/.exec(stripped);
|
|
145
|
+
let name: string | undefined;
|
|
146
|
+
let role: string | undefined;
|
|
147
|
+
if (sepMatch) {
|
|
148
|
+
name = sepMatch[1].trim();
|
|
149
|
+
const rest = sepMatch[2];
|
|
150
|
+
// role is whatever isn't the colour value
|
|
151
|
+
if (valueMatch) {
|
|
152
|
+
role = rest.replace(valueMatch[0], "").replace(/[\s—–\-:|]+/g, " ").trim() || undefined;
|
|
153
|
+
} else {
|
|
154
|
+
role = rest.trim() || undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!name && valueMatch) {
|
|
158
|
+
// Fallback — colour value present but no clear "name : value" split.
|
|
159
|
+
name = stripped.replace(valueMatch[0], "").trim() || valueMatch[0];
|
|
160
|
+
}
|
|
161
|
+
if (!name) continue;
|
|
162
|
+
if (name.length > 100) continue; // garbage line
|
|
163
|
+
// Filter out heading-like rows ("Color Palette", "Functional Roles", ...).
|
|
164
|
+
if (/palette|role/i.test(name) && !valueMatch) continue;
|
|
165
|
+
tokens.push({
|
|
166
|
+
name,
|
|
167
|
+
value: valueMatch ? valueMatch[0] : undefined,
|
|
168
|
+
role,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return tokens;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function extractTypographyTokens(body: string): TypographyToken[] {
|
|
175
|
+
const tokens: TypographyToken[] = [];
|
|
176
|
+
for (const line of body.split(/\r?\n/)) {
|
|
177
|
+
const trimmed = line.trim().replace(/^[-*+]\s*/, "");
|
|
178
|
+
if (!trimmed) continue;
|
|
179
|
+
if (trimmed.startsWith("#")) continue; // sub-headings
|
|
180
|
+
const stripped = trimmed.replace(/[`*_]/g, "");
|
|
181
|
+
const nameMatch = /^([^:—–]+?)[\s]*[:—–]/.exec(stripped);
|
|
182
|
+
const name = nameMatch ? nameMatch[1].trim() : stripped.split(/\s{2,}|—|–/)[0]?.trim();
|
|
183
|
+
if (!name) continue;
|
|
184
|
+
const fontFamily = /font[\s-]*family[:\s]+([^,;]+)/i.exec(stripped)?.[1]?.trim();
|
|
185
|
+
const weight = /weight[:\s]+([0-9]{3}|bold|semi-?bold|medium|regular|light|thin)/i.exec(stripped)?.[1]?.trim();
|
|
186
|
+
const size = /(\d+(?:\.\d+)?(?:px|rem|em|pt))/.exec(stripped)?.[1];
|
|
187
|
+
const lineHeight = /line[\s-]*height[:\s]+([0-9.]+)/i.exec(stripped)?.[1];
|
|
188
|
+
tokens.push({
|
|
189
|
+
name,
|
|
190
|
+
fontFamily,
|
|
191
|
+
weight,
|
|
192
|
+
size,
|
|
193
|
+
lineHeight,
|
|
194
|
+
usage: stripped !== name ? stripped : undefined,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return tokens;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractComponentTokens(body: string): ComponentToken[] {
|
|
201
|
+
// Components are typically organised under H3 sub-headings ("### Button",
|
|
202
|
+
// "### Card"). When present, treat each H3 as one component; otherwise
|
|
203
|
+
// fall back to bullet rows.
|
|
204
|
+
const tokens: ComponentToken[] = [];
|
|
205
|
+
const h3Rx = /^###\s+(.+?)\s*$/;
|
|
206
|
+
const lines = body.split(/\r?\n/);
|
|
207
|
+
|
|
208
|
+
let current: ComponentToken | null = null;
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
const h3 = h3Rx.exec(line);
|
|
211
|
+
if (h3) {
|
|
212
|
+
if (current) tokens.push(current);
|
|
213
|
+
current = { name: h3[1].trim(), variants: {}, notes: "" };
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!current) continue;
|
|
217
|
+
// Variant detection — `variant: primary | secondary | ghost`
|
|
218
|
+
const variantMatch = /^[\s-]*(\w[\w\s-]*?)[\s]*:[\s]*(.+)$/.exec(line.trim());
|
|
219
|
+
if (variantMatch) {
|
|
220
|
+
const key = variantMatch[1].trim();
|
|
221
|
+
const valuesRaw = variantMatch[2].trim();
|
|
222
|
+
if (/[|,]/.test(valuesRaw) && valuesRaw.length < 200) {
|
|
223
|
+
const values = valuesRaw
|
|
224
|
+
.split(/[|,]/)
|
|
225
|
+
.map((v) => v.trim().replace(/[`*_"']/g, ""))
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
if (values.length >= 2) {
|
|
228
|
+
current.variants[key] = values;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (line.trim()) {
|
|
234
|
+
current.notes = (current.notes ?? "") + (current.notes ? "\n" : "") + line;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (current) tokens.push(current);
|
|
238
|
+
|
|
239
|
+
if (tokens.length === 0) {
|
|
240
|
+
// Fallback — flat bullet list of components.
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
const m = /^[\s-*+]+([A-Z][\w\s]+?)(?::|—|–|$)/.exec(line);
|
|
243
|
+
if (m) tokens.push({ name: m[1].trim(), variants: {}, notes: undefined });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return tokens;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractSpacingTokens(body: string): SpacingToken[] {
|
|
250
|
+
const tokens: SpacingToken[] = [];
|
|
251
|
+
for (const line of body.split(/\r?\n/)) {
|
|
252
|
+
const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
|
|
253
|
+
if (!stripped) continue;
|
|
254
|
+
const m = /^([\w-]+)[\s:—–-]+(\d+(?:\.\d+)?(?:px|rem|em|%)?)/.exec(stripped);
|
|
255
|
+
if (m) tokens.push({ name: m[1], value: m[2] });
|
|
256
|
+
}
|
|
257
|
+
return tokens;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function extractShadowTokens(body: string): ShadowToken[] {
|
|
261
|
+
const tokens: ShadowToken[] = [];
|
|
262
|
+
// Shadows are typically `name: <css value>` rows. CSS values contain
|
|
263
|
+
// commas / parens / `0 1px 2px rgba(...)` so we don't attempt to fully
|
|
264
|
+
// parse them — capture the whole right-hand side verbatim.
|
|
265
|
+
for (const line of body.split(/\r?\n/)) {
|
|
266
|
+
const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
|
|
267
|
+
if (!stripped) continue;
|
|
268
|
+
const m = /^([\w-]+)\s*[:—–]\s*(.+)$/.exec(stripped);
|
|
269
|
+
if (m && (m[2].includes("px") || m[2].includes("rgba"))) {
|
|
270
|
+
tokens.push({ name: m[1], value: m[2].trim() });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return tokens;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function extractDoDontRules(body: string): DoDontRule[] {
|
|
277
|
+
const rules: DoDontRule[] = [];
|
|
278
|
+
let mode: "do" | "dont" | null = null;
|
|
279
|
+
for (const line of body.split(/\r?\n/)) {
|
|
280
|
+
const stripped = line.trim();
|
|
281
|
+
if (!stripped) continue;
|
|
282
|
+
if (/^#{2,}\s+do\b|^\*\*do\*\*|^do['s]*[:\s]/i.test(stripped) && !/don.?t/i.test(stripped)) {
|
|
283
|
+
mode = "do";
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (/^#{2,}\s+don.?t|^\*\*don.?t\*\*|^don.?t[s]?[:\s]/i.test(stripped)) {
|
|
287
|
+
mode = "dont";
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const bullet = /^[-*+]\s+(.+)$/.exec(stripped);
|
|
291
|
+
if (bullet && mode) {
|
|
292
|
+
// Preserve backticks — they mark identifier tokens
|
|
293
|
+
// (`btn-hard`, `shadow-hard`) that downstream consumers
|
|
294
|
+
// (Guard `autoFromDesignMd`) extract from the rule text.
|
|
295
|
+
rules.push({ kind: mode, text: bullet[1].replace(/[*_]/g, "").trim() });
|
|
296
|
+
} else if (bullet) {
|
|
297
|
+
// ✅ / ❌ inline markers.
|
|
298
|
+
const text = bullet[1];
|
|
299
|
+
if (text.startsWith("✅") || /^do\b/i.test(text)) {
|
|
300
|
+
rules.push({ kind: "do", text: text.replace(/^[✅do:\s]+/i, "").trim() });
|
|
301
|
+
} else if (text.startsWith("❌") || /^don.?t\b/i.test(text)) {
|
|
302
|
+
rules.push({ kind: "dont", text: text.replace(/^[❌don'?t:\s]+/i, "").trim() });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return rules;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function extractBreakpoints(body: string): ResponsiveBreakpoint[] {
|
|
310
|
+
const bps: ResponsiveBreakpoint[] = [];
|
|
311
|
+
for (const line of body.split(/\r?\n/)) {
|
|
312
|
+
const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
|
|
313
|
+
if (!stripped) continue;
|
|
314
|
+
const m = /^([\w-]+)[\s:—–-]+(\d+(?:px|rem|em)?)\b(.*)$/.exec(stripped);
|
|
315
|
+
if (m) {
|
|
316
|
+
bps.push({
|
|
317
|
+
name: m[1],
|
|
318
|
+
value: m[2],
|
|
319
|
+
notes: m[3].replace(/^[\s—–-]+/, "").trim() || undefined,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return bps;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractAgentPrompts(body: string): AgentPrompt[] {
|
|
327
|
+
// Group by H3; everything else collapses into a single "default" prompt.
|
|
328
|
+
const prompts: AgentPrompt[] = [];
|
|
329
|
+
const lines = body.split(/\r?\n/);
|
|
330
|
+
const h3Rx = /^###\s+(.+?)\s*$/;
|
|
331
|
+
let current: AgentPrompt | null = null;
|
|
332
|
+
let buf: string[] = [];
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
const h3 = h3Rx.exec(line);
|
|
335
|
+
if (h3) {
|
|
336
|
+
if (current) {
|
|
337
|
+
current.body = buf.join("\n").trim();
|
|
338
|
+
prompts.push(current);
|
|
339
|
+
}
|
|
340
|
+
current = { title: h3[1].trim(), body: "" };
|
|
341
|
+
buf = [];
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
buf.push(line);
|
|
345
|
+
}
|
|
346
|
+
if (current) {
|
|
347
|
+
current.body = buf.join("\n").trim();
|
|
348
|
+
prompts.push(current);
|
|
349
|
+
} else if (buf.join("").trim()) {
|
|
350
|
+
prompts.push({ title: "default", body: buf.join("\n").trim() });
|
|
351
|
+
}
|
|
352
|
+
return prompts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ────────────────────────────────────────────────────────────────────
|
|
356
|
+
// Public API
|
|
357
|
+
// ────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function emptySections(): DesignSpec["sections"] {
|
|
360
|
+
return {
|
|
361
|
+
theme: { id: "theme", present: false, rawBody: "" },
|
|
362
|
+
"color-palette": { id: "color-palette", present: false, rawBody: "", tokens: [] },
|
|
363
|
+
typography: { id: "typography", present: false, rawBody: "", tokens: [] },
|
|
364
|
+
components: { id: "components", present: false, rawBody: "", tokens: [] },
|
|
365
|
+
layout: { id: "layout", present: false, rawBody: "", tokens: [] },
|
|
366
|
+
shadows: { id: "shadows", present: false, rawBody: "", tokens: [] },
|
|
367
|
+
"dos-donts": { id: "dos-donts", present: false, rawBody: "", rules: [] },
|
|
368
|
+
responsive: { id: "responsive", present: false, rawBody: "", breakpoints: [] },
|
|
369
|
+
"agent-prompts": { id: "agent-prompts", present: false, rawBody: "", prompts: [] },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Parse a DESIGN.md source string into the structured spec. Never throws. */
|
|
374
|
+
export function parseDesignMd(source: string): DesignSpec {
|
|
375
|
+
const { title, sections: rawSections } = splitByHeadings(source);
|
|
376
|
+
const result: DesignSpec = {
|
|
377
|
+
source,
|
|
378
|
+
title,
|
|
379
|
+
sections: emptySections(),
|
|
380
|
+
extraSections: [],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
for (const raw of rawSections) {
|
|
384
|
+
const id = resolveSectionId(raw.heading);
|
|
385
|
+
if (!id) {
|
|
386
|
+
result.extraSections.push({ heading: raw.heading, body: raw.body });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const body = raw.body.trim();
|
|
390
|
+
switch (id) {
|
|
391
|
+
case "theme": {
|
|
392
|
+
const summary = body.split(/\n\s*\n/)[0]?.trim() || undefined;
|
|
393
|
+
result.sections.theme = {
|
|
394
|
+
id,
|
|
395
|
+
present: true,
|
|
396
|
+
headingText: raw.heading,
|
|
397
|
+
rawBody: body,
|
|
398
|
+
summary,
|
|
399
|
+
};
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case "color-palette":
|
|
403
|
+
result.sections["color-palette"] = {
|
|
404
|
+
id,
|
|
405
|
+
present: true,
|
|
406
|
+
headingText: raw.heading,
|
|
407
|
+
rawBody: body,
|
|
408
|
+
tokens: extractColorTokens(body),
|
|
409
|
+
};
|
|
410
|
+
break;
|
|
411
|
+
case "typography":
|
|
412
|
+
result.sections.typography = {
|
|
413
|
+
id,
|
|
414
|
+
present: true,
|
|
415
|
+
headingText: raw.heading,
|
|
416
|
+
rawBody: body,
|
|
417
|
+
tokens: extractTypographyTokens(body),
|
|
418
|
+
};
|
|
419
|
+
break;
|
|
420
|
+
case "components":
|
|
421
|
+
result.sections.components = {
|
|
422
|
+
id,
|
|
423
|
+
present: true,
|
|
424
|
+
headingText: raw.heading,
|
|
425
|
+
rawBody: body,
|
|
426
|
+
tokens: extractComponentTokens(body),
|
|
427
|
+
};
|
|
428
|
+
break;
|
|
429
|
+
case "layout":
|
|
430
|
+
result.sections.layout = {
|
|
431
|
+
id,
|
|
432
|
+
present: true,
|
|
433
|
+
headingText: raw.heading,
|
|
434
|
+
rawBody: body,
|
|
435
|
+
tokens: extractSpacingTokens(body),
|
|
436
|
+
};
|
|
437
|
+
break;
|
|
438
|
+
case "shadows":
|
|
439
|
+
result.sections.shadows = {
|
|
440
|
+
id,
|
|
441
|
+
present: true,
|
|
442
|
+
headingText: raw.heading,
|
|
443
|
+
rawBody: body,
|
|
444
|
+
tokens: extractShadowTokens(body),
|
|
445
|
+
};
|
|
446
|
+
break;
|
|
447
|
+
case "dos-donts":
|
|
448
|
+
result.sections["dos-donts"] = {
|
|
449
|
+
id,
|
|
450
|
+
present: true,
|
|
451
|
+
headingText: raw.heading,
|
|
452
|
+
rawBody: body,
|
|
453
|
+
rules: extractDoDontRules(body),
|
|
454
|
+
};
|
|
455
|
+
break;
|
|
456
|
+
case "responsive":
|
|
457
|
+
result.sections.responsive = {
|
|
458
|
+
id,
|
|
459
|
+
present: true,
|
|
460
|
+
headingText: raw.heading,
|
|
461
|
+
rawBody: body,
|
|
462
|
+
breakpoints: extractBreakpoints(body),
|
|
463
|
+
};
|
|
464
|
+
break;
|
|
465
|
+
case "agent-prompts":
|
|
466
|
+
result.sections["agent-prompts"] = {
|
|
467
|
+
id,
|
|
468
|
+
present: true,
|
|
469
|
+
headingText: raw.heading,
|
|
470
|
+
rawBody: body,
|
|
471
|
+
prompts: extractAgentPrompts(body),
|
|
472
|
+
};
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ────────────────────────────────────────────────────────────────────
|
|
480
|
+
// Validation
|
|
481
|
+
// ────────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Surface gaps in a parsed DESIGN.md. The validator never returns
|
|
485
|
+
* errors that block builds — Mandu's enforcement layer (Guard) is the
|
|
486
|
+
* gate. This function is the diagnostic that tells the user "your
|
|
487
|
+
* DESIGN.md is missing colour tokens" so they can fill it in.
|
|
488
|
+
*/
|
|
489
|
+
export function validateDesignSpec(spec: DesignSpec): ValidationResult {
|
|
490
|
+
const issues: ValidationIssue[] = [];
|
|
491
|
+
for (const id of DESIGN_SECTION_IDS) {
|
|
492
|
+
const section = spec.sections[id] as AnyDesignSection;
|
|
493
|
+
if (!section.present) {
|
|
494
|
+
issues.push({
|
|
495
|
+
kind: "missing",
|
|
496
|
+
section: id,
|
|
497
|
+
message: `Section "${id}" not found. Add a "## ${humanizeSectionId(id)}" heading.`,
|
|
498
|
+
});
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const empty = isSectionEmpty(section);
|
|
502
|
+
if (empty) {
|
|
503
|
+
issues.push({
|
|
504
|
+
kind: "empty",
|
|
505
|
+
section: id,
|
|
506
|
+
message: `Section "${id}" is present but has no structured tokens.`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return { ok: issues.length === 0, issues };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isSectionEmpty(section: AnyDesignSection): boolean {
|
|
514
|
+
switch (section.id) {
|
|
515
|
+
case "theme":
|
|
516
|
+
return !section.summary || section.summary.length === 0;
|
|
517
|
+
case "color-palette":
|
|
518
|
+
case "typography":
|
|
519
|
+
case "components":
|
|
520
|
+
case "layout":
|
|
521
|
+
case "shadows":
|
|
522
|
+
return section.tokens.length === 0;
|
|
523
|
+
case "dos-donts":
|
|
524
|
+
return section.rules.length === 0;
|
|
525
|
+
case "responsive":
|
|
526
|
+
return section.breakpoints.length === 0;
|
|
527
|
+
case "agent-prompts":
|
|
528
|
+
return section.prompts.length === 0;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function humanizeSectionId(id: DesignSectionId): string {
|
|
533
|
+
switch (id) {
|
|
534
|
+
case "theme":
|
|
535
|
+
return "Visual Theme & Philosophy";
|
|
536
|
+
case "color-palette":
|
|
537
|
+
return "Color Palette";
|
|
538
|
+
case "typography":
|
|
539
|
+
return "Typography";
|
|
540
|
+
case "components":
|
|
541
|
+
return "Components";
|
|
542
|
+
case "layout":
|
|
543
|
+
return "Layout";
|
|
544
|
+
case "shadows":
|
|
545
|
+
return "Depth & Elevation";
|
|
546
|
+
case "dos-donts":
|
|
547
|
+
return "Do's & Don'ts";
|
|
548
|
+
case "responsive":
|
|
549
|
+
return "Responsive";
|
|
550
|
+
case "agent-prompts":
|
|
551
|
+
return "Agent Prompts";
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export { humanizeSectionId };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold + upstream import primitives.
|
|
3
|
+
*
|
|
4
|
+
* `EMPTY_DESIGN_MD` is the canonical 9-section skeleton Mandu ships
|
|
5
|
+
* for `mandu design init` (no `--from`). It contains heading slots and
|
|
6
|
+
* a one-line hint per section so users / agents can fill it in.
|
|
7
|
+
*
|
|
8
|
+
* `fetchUpstreamDesignMd(slug)` pulls a brand DESIGN.md from VoltAgent's
|
|
9
|
+
* awesome-design-md repository (raw GitHub). The function is a thin
|
|
10
|
+
* fetch wrapper — caller decides what to do with the body (validate +
|
|
11
|
+
* write, dry-run + diff, etc.).
|
|
12
|
+
*
|
|
13
|
+
* @module core/design/scaffold
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Raw GitHub base for awesome-design-md. Public, MIT licensed.
|
|
18
|
+
* Each brand lives at `<base>/<slug>/DESIGN.md`.
|
|
19
|
+
*/
|
|
20
|
+
export const AWESOME_DESIGN_MD_RAW_BASE =
|
|
21
|
+
"https://raw.githubusercontent.com/VoltAgent/awesome-design-md/main";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Empty 9-section DESIGN.md skeleton. Meant to be filled in
|
|
25
|
+
* incrementally — Mandu's point is that DESIGN.md is a *living*
|
|
26
|
+
* artifact, not a one-shot deliverable.
|
|
27
|
+
*/
|
|
28
|
+
export const EMPTY_DESIGN_MD = `# DESIGN.md
|
|
29
|
+
|
|
30
|
+
> Living design system spec for this project. AI agents and developers
|
|
31
|
+
> read this file before touching UI. See
|
|
32
|
+
> https://github.com/VoltAgent/awesome-design-md for examples.
|
|
33
|
+
>
|
|
34
|
+
> Fill sections incrementally — \`mandu design extract\` (coming soon)
|
|
35
|
+
> can propose tokens from your existing code.
|
|
36
|
+
|
|
37
|
+
## Visual Theme & Philosophy
|
|
38
|
+
|
|
39
|
+
<!-- One-paragraph "vibe": minimal, playful, dense, premium, … -->
|
|
40
|
+
|
|
41
|
+
## Color Palette
|
|
42
|
+
|
|
43
|
+
<!-- Each row: name — value — role.
|
|
44
|
+
- primary — #000000 — brand / primary action
|
|
45
|
+
- surface — #ffffff — page background
|
|
46
|
+
-->
|
|
47
|
+
|
|
48
|
+
## Typography
|
|
49
|
+
|
|
50
|
+
<!-- Each row: name — font-family / size / weight / line-height — usage.
|
|
51
|
+
- display — Inter, 48px, weight 700, line-height 1.1 — hero
|
|
52
|
+
- body — Inter, 16px, weight 400, line-height 1.6 — paragraphs
|
|
53
|
+
-->
|
|
54
|
+
|
|
55
|
+
## Components
|
|
56
|
+
|
|
57
|
+
<!-- One ### sub-heading per component. Declare variants as
|
|
58
|
+
\`variant: a | b | c\` so tools can index them.
|
|
59
|
+
|
|
60
|
+
### Button
|
|
61
|
+
variant: primary | secondary | ghost
|
|
62
|
+
size: sm | md | lg
|
|
63
|
+
|
|
64
|
+
### Card
|
|
65
|
+
variant: surface | bordered
|
|
66
|
+
-->
|
|
67
|
+
|
|
68
|
+
## Layout
|
|
69
|
+
|
|
70
|
+
<!-- Spacing scale + grid notes.
|
|
71
|
+
- xs — 4px
|
|
72
|
+
- sm — 8px
|
|
73
|
+
- md — 16px
|
|
74
|
+
- lg — 24px
|
|
75
|
+
- xl — 40px
|
|
76
|
+
-->
|
|
77
|
+
|
|
78
|
+
## Depth & Elevation
|
|
79
|
+
|
|
80
|
+
<!-- Shadow tokens.
|
|
81
|
+
- card: 0 1px 2px rgba(0,0,0,.06), 0 1px 3px rgba(0,0,0,.10)
|
|
82
|
+
- popover: 0 4px 12px rgba(0,0,0,.12)
|
|
83
|
+
-->
|
|
84
|
+
|
|
85
|
+
## Do's & Don'ts
|
|
86
|
+
|
|
87
|
+
<!-- Add rules under ### Do and ### Don't sub-headings.
|
|
88
|
+
Mandu's Guard rule can lift the don't items into \`forbidInlineClasses\`.
|
|
89
|
+
|
|
90
|
+
### Do
|
|
91
|
+
- Use design tokens.
|
|
92
|
+
|
|
93
|
+
### Don't
|
|
94
|
+
- Inline raw colour values.
|
|
95
|
+
-->
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Responsive
|
|
99
|
+
|
|
100
|
+
<!-- Breakpoints + scaling notes.
|
|
101
|
+
- mobile — 0–639px
|
|
102
|
+
- tablet — 640–1023px
|
|
103
|
+
- desktop — 1024px+
|
|
104
|
+
-->
|
|
105
|
+
|
|
106
|
+
## Agent Prompts
|
|
107
|
+
|
|
108
|
+
<!-- Ready-made prompts the agent should use when generating UI. Each
|
|
109
|
+
### sub-heading is one prompt; the body is passed verbatim.
|
|
110
|
+
|
|
111
|
+
### Page hero
|
|
112
|
+
Generate a hero section using \`display\` typography and \`primary\`
|
|
113
|
+
color tokens. Avoid inline shadow values; use the \`card\` shadow token.
|
|
114
|
+
-->
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
export interface FetchUpstreamOptions {
|
|
118
|
+
/** Override base URL (test fixtures, mirrors). */
|
|
119
|
+
baseUrl?: string;
|
|
120
|
+
/** AbortSignal so callers can cancel a slow fetch. */
|
|
121
|
+
signal?: AbortSignal;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fetch a DESIGN.md from awesome-design-md by brand slug.
|
|
126
|
+
* Throws on HTTP error — the caller (CLI command) catches and prints
|
|
127
|
+
* a user-facing message rather than letting it bubble.
|
|
128
|
+
*/
|
|
129
|
+
export async function fetchUpstreamDesignMd(
|
|
130
|
+
slugOrUrl: string,
|
|
131
|
+
options: FetchUpstreamOptions = {},
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
const url = isAbsoluteUrl(slugOrUrl)
|
|
134
|
+
? slugOrUrl
|
|
135
|
+
: `${options.baseUrl ?? AWESOME_DESIGN_MD_RAW_BASE}/${encodeURIComponent(slugOrUrl)}/DESIGN.md`;
|
|
136
|
+
const res = await fetch(url, { signal: options.signal });
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`fetchUpstreamDesignMd: GET ${url} → HTTP ${res.status} ${res.statusText}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return res.text();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isAbsoluteUrl(s: string): boolean {
|
|
146
|
+
return /^https?:\/\//i.test(s);
|
|
147
|
+
}
|