@mandujs/core 0.51.0 → 0.52.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 +1 -1
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -0
- package/src/design/diff.ts +138 -0
- package/src/design/extract.ts +284 -0
- package/src/design/index.ts +23 -0
- package/src/design/patch.ts +242 -0
package/package.json
CHANGED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #245 M4 §3.5 — extract / patch / diff helper tests.
|
|
3
|
+
*
|
|
4
|
+
* Cover the three pure surfaces the MCP write-tools layer over:
|
|
5
|
+
* - `extractDesignTokens()` — color/font/component proposals
|
|
6
|
+
* - `patchDesignMd()` — section-safe add/update/remove
|
|
7
|
+
* - `diffDesignSpecs()` — local ↔ upstream per-section diff
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { extractDesignTokens } from "../extract";
|
|
15
|
+
import { patchDesignMd, patchDesignMdBatch } from "../patch";
|
|
16
|
+
import { diffDesignSpecs } from "../diff";
|
|
17
|
+
import { parseDesignMd } from "../parser";
|
|
18
|
+
|
|
19
|
+
// ─── extract ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
async function setupFixture(): Promise<{ root: string; cleanup: () => Promise<void> }> {
|
|
22
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-extract-"));
|
|
23
|
+
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
|
24
|
+
await fs.mkdir(path.join(root, "app"), { recursive: true });
|
|
25
|
+
await fs.writeFile(
|
|
26
|
+
path.join(root, "src/a.tsx"),
|
|
27
|
+
`const a = "#FF8C42"; const b = "#FF8C42"; const c = "#FF8C42";
|
|
28
|
+
const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
|
|
29
|
+
);
|
|
30
|
+
await fs.writeFile(
|
|
31
|
+
path.join(root, "src/b.tsx"),
|
|
32
|
+
`const a = "#FF8C42"; const card = "#FFF8F0";
|
|
33
|
+
const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
|
|
34
|
+
);
|
|
35
|
+
await fs.writeFile(
|
|
36
|
+
path.join(root, "app/c.tsx"),
|
|
37
|
+
`const x = "rgb(99,91,255)"; const y = "rgb(99,91,255)"; const z = "rgb(99,91,255)";
|
|
38
|
+
const cssIsh = \`font-family: "Inter", sans-serif;\`;
|
|
39
|
+
const elem = <div className="rounded-lg bg-orange-500 px-4 py-2 text-white" />;`,
|
|
40
|
+
);
|
|
41
|
+
return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("extractDesignTokens", () => {
|
|
45
|
+
let fix: { root: string; cleanup: () => Promise<void> };
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
fix = await setupFixture();
|
|
48
|
+
});
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await fix.cleanup();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("flags colors that occur ≥ minOccurrences", async () => {
|
|
54
|
+
const result = await extractDesignTokens(fix.root);
|
|
55
|
+
const orange = result.proposals.find((p) => p.key === "#ff8c42");
|
|
56
|
+
expect(orange).toBeDefined();
|
|
57
|
+
expect(orange?.section).toBe("color-palette");
|
|
58
|
+
expect(orange?.occurrences).toBeGreaterThanOrEqual(3);
|
|
59
|
+
expect(orange?.confidence).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores colors below the threshold", async () => {
|
|
63
|
+
const result = await extractDesignTokens(fix.root, { minOccurrences: 4 });
|
|
64
|
+
expect(result.proposals.find((p) => p.key === "#fff8f0")).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("flags font-family declarations", async () => {
|
|
68
|
+
const result = await extractDesignTokens(fix.root, { minOccurrences: 1 });
|
|
69
|
+
const inter = result.proposals.find(
|
|
70
|
+
(p) => p.section === "typography" && p.value.includes("Inter"),
|
|
71
|
+
);
|
|
72
|
+
expect(inter).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("flags repeating className combos", async () => {
|
|
76
|
+
const result = await extractDesignTokens(fix.root, { kinds: ["component"] });
|
|
77
|
+
const combo = result.proposals.find((p) => p.section === "components");
|
|
78
|
+
expect(combo).toBeDefined();
|
|
79
|
+
expect(combo?.occurrences).toBeGreaterThanOrEqual(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("drops proposals already represented in the existing DesignSpec", async () => {
|
|
83
|
+
const existing = parseDesignMd(`# Test
|
|
84
|
+
## Color Palette
|
|
85
|
+
- Primary — #FF8C42 — brand
|
|
86
|
+
`);
|
|
87
|
+
const result = await extractDesignTokens(fix.root, { existing });
|
|
88
|
+
expect(result.proposals.find((p) => p.key === "#ff8c42")).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── patch ────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const SAMPLE = `# Test
|
|
95
|
+
## Color Palette
|
|
96
|
+
- Primary — #FF8C42 — brand
|
|
97
|
+
- Surface — #FFF8F0 — background
|
|
98
|
+
|
|
99
|
+
## Typography
|
|
100
|
+
- body: font-family: "Inter", sans-serif
|
|
101
|
+
|
|
102
|
+
## Layout
|
|
103
|
+
- sm: 0.5rem
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
describe("patchDesignMd — color-palette", () => {
|
|
107
|
+
it("adds a new token in the right section", () => {
|
|
108
|
+
const r = patchDesignMd(SAMPLE, {
|
|
109
|
+
section: "color-palette",
|
|
110
|
+
operation: "add",
|
|
111
|
+
key: "Accent",
|
|
112
|
+
value: "#1A1F36",
|
|
113
|
+
role: "text",
|
|
114
|
+
});
|
|
115
|
+
expect(r.applied).toBe(true);
|
|
116
|
+
expect(r.next).toContain("- Accent — #1A1F36 — text");
|
|
117
|
+
// Other sections untouched.
|
|
118
|
+
expect(r.next).toContain("## Typography");
|
|
119
|
+
expect(r.next).toContain("## Layout");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("refuses to add a duplicate key (slug-insensitive)", () => {
|
|
123
|
+
const r = patchDesignMd(SAMPLE, {
|
|
124
|
+
section: "color-palette",
|
|
125
|
+
operation: "add",
|
|
126
|
+
key: "primary",
|
|
127
|
+
value: "#000",
|
|
128
|
+
});
|
|
129
|
+
expect(r.applied).toBe(false);
|
|
130
|
+
expect(r.reason).toContain("already exists");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("updates a matching key", () => {
|
|
134
|
+
const r = patchDesignMd(SAMPLE, {
|
|
135
|
+
section: "color-palette",
|
|
136
|
+
operation: "update",
|
|
137
|
+
key: "Primary",
|
|
138
|
+
value: "#FF7733",
|
|
139
|
+
});
|
|
140
|
+
expect(r.applied).toBe(true);
|
|
141
|
+
expect(r.before).toContain("#FF8C42");
|
|
142
|
+
expect(r.after).toContain("#FF7733");
|
|
143
|
+
expect(r.next).toContain("#FF7733");
|
|
144
|
+
expect(r.next).not.toContain("#FF8C42");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("removes a matching key", () => {
|
|
148
|
+
const r = patchDesignMd(SAMPLE, {
|
|
149
|
+
section: "color-palette",
|
|
150
|
+
operation: "remove",
|
|
151
|
+
key: "Surface",
|
|
152
|
+
});
|
|
153
|
+
expect(r.applied).toBe(true);
|
|
154
|
+
expect(r.before).toContain("Surface");
|
|
155
|
+
expect(r.next).not.toContain("Surface");
|
|
156
|
+
// Primary still there.
|
|
157
|
+
expect(r.next).toContain("Primary");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns no-op + reason when section is absent", () => {
|
|
161
|
+
const r = patchDesignMd("# Empty\n", {
|
|
162
|
+
section: "color-palette",
|
|
163
|
+
operation: "add",
|
|
164
|
+
key: "X",
|
|
165
|
+
value: "#000",
|
|
166
|
+
});
|
|
167
|
+
expect(r.applied).toBe(false);
|
|
168
|
+
expect(r.reason).toContain("not found");
|
|
169
|
+
expect(r.next).toBe("# Empty\n");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("requires a value on add/update", () => {
|
|
173
|
+
const r = patchDesignMd(SAMPLE, {
|
|
174
|
+
section: "color-palette",
|
|
175
|
+
operation: "add",
|
|
176
|
+
key: "Foo",
|
|
177
|
+
});
|
|
178
|
+
expect(r.applied).toBe(false);
|
|
179
|
+
expect(r.reason).toContain("required");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("patchDesignMd — components (H3)", () => {
|
|
184
|
+
it("adds a new component as an H3 heading", () => {
|
|
185
|
+
const r = patchDesignMd(
|
|
186
|
+
`# Test
|
|
187
|
+
## Components
|
|
188
|
+
|
|
189
|
+
### Button
|
|
190
|
+
A primary action.
|
|
191
|
+
`,
|
|
192
|
+
{ section: "components", operation: "add", key: "Card" },
|
|
193
|
+
);
|
|
194
|
+
expect(r.applied).toBe(true);
|
|
195
|
+
expect(r.next).toContain("### Card");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("patchDesignMdBatch", () => {
|
|
200
|
+
it("applies operations in order against the cumulative source", () => {
|
|
201
|
+
const r = patchDesignMdBatch(SAMPLE, [
|
|
202
|
+
{ section: "color-palette", operation: "add", key: "Accent", value: "#000" },
|
|
203
|
+
{ section: "color-palette", operation: "remove", key: "Surface" },
|
|
204
|
+
{ section: "color-palette", operation: "update", key: "Primary", value: "#111" },
|
|
205
|
+
]);
|
|
206
|
+
expect(r.appliedCount).toBe(3);
|
|
207
|
+
expect(r.next).toContain("#111");
|
|
208
|
+
expect(r.next).toContain("#000");
|
|
209
|
+
expect(r.next).not.toContain("Surface");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("partial success — failed ops surface in results, others still apply", () => {
|
|
213
|
+
const r = patchDesignMdBatch(SAMPLE, [
|
|
214
|
+
{ section: "color-palette", operation: "remove", key: "doesnotexist" },
|
|
215
|
+
{ section: "color-palette", operation: "add", key: "Accent", value: "#000" },
|
|
216
|
+
]);
|
|
217
|
+
expect(r.appliedCount).toBe(1);
|
|
218
|
+
expect(r.results[0]?.applied).toBe(false);
|
|
219
|
+
expect(r.results[1]?.applied).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ─── diff ─────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe("diffDesignSpecs", () => {
|
|
226
|
+
it("flags added / removed / changed color tokens", () => {
|
|
227
|
+
const local = parseDesignMd(`# A
|
|
228
|
+
## Color Palette
|
|
229
|
+
- Primary — #FF8C42 — brand
|
|
230
|
+
- Surface — #FFF8F0 — background
|
|
231
|
+
`);
|
|
232
|
+
const upstream = parseDesignMd(`# B
|
|
233
|
+
## Color Palette
|
|
234
|
+
- Primary — #FF7733 — brand
|
|
235
|
+
- Accent — #1A1F36 — text
|
|
236
|
+
`);
|
|
237
|
+
const d = diffDesignSpecs(local, upstream);
|
|
238
|
+
expect(d.colorPalette.find((e) => e.kind === "changed" && e.name === "Primary")).toBeDefined();
|
|
239
|
+
expect(d.colorPalette.find((e) => e.kind === "added" && e.name === "Accent")).toBeDefined();
|
|
240
|
+
expect(d.colorPalette.find((e) => e.kind === "removed" && e.name === "Surface")).toBeDefined();
|
|
241
|
+
expect(d.totalChanges).toBe(3);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns empty diff when specs are identical at the structured level", () => {
|
|
245
|
+
const a = parseDesignMd(`# T
|
|
246
|
+
## Color Palette
|
|
247
|
+
- Primary — #FF8C42 — brand
|
|
248
|
+
`);
|
|
249
|
+
const b = parseDesignMd(`# Different prose, same tokens
|
|
250
|
+
## Color Palette
|
|
251
|
+
- Primary — #FF8C42 — brand
|
|
252
|
+
`);
|
|
253
|
+
const d = diffDesignSpecs(a, b);
|
|
254
|
+
expect(d.totalChanges).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("surfaces section presence changes", () => {
|
|
258
|
+
const local = parseDesignMd(`# A\n## Color Palette\n- Primary — #FF8C42 — brand\n`);
|
|
259
|
+
const upstream = parseDesignMd(
|
|
260
|
+
`# B\n## Color Palette\n- Primary — #FF8C42 — brand\n## Agent Prompts\n### Brand voice\nTone.\n`,
|
|
261
|
+
);
|
|
262
|
+
const d = diffDesignSpecs(local, upstream);
|
|
263
|
+
expect(d.sectionPresenceChanged).toContain("agent-prompts");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md upstream diff (Issue #245 M4 §3.5 external loop).
|
|
3
|
+
*
|
|
4
|
+
* Compare a local DESIGN.md against an upstream source (typically a
|
|
5
|
+
* brand spec from awesome-design-md). The diff is computed at the
|
|
6
|
+
* **structured-token level** — added / changed / removed entries per
|
|
7
|
+
* section — so agents can patch only the sections the user wants to
|
|
8
|
+
* sync without touching free-form prose.
|
|
9
|
+
*
|
|
10
|
+
* The diff is intentionally narrow: it ignores prose changes inside
|
|
11
|
+
* `rawBody`, comment shifts, and heading wording differences. It
|
|
12
|
+
* answers one question — "did the upstream catalog gain, change, or
|
|
13
|
+
* drop a token compared to my local file?".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ColorToken, DesignSpec, ShadowToken, SpacingToken, TypographyToken } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface DiffEntryAddedRemoved<T> {
|
|
19
|
+
kind: "added" | "removed";
|
|
20
|
+
name: string;
|
|
21
|
+
token: T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiffEntryChanged<T> {
|
|
25
|
+
kind: "changed";
|
|
26
|
+
name: string;
|
|
27
|
+
before: T;
|
|
28
|
+
after: T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DiffEntry<T> =
|
|
32
|
+
| DiffEntryAddedRemoved<T>
|
|
33
|
+
| DiffEntryChanged<T>;
|
|
34
|
+
|
|
35
|
+
export interface DesignSpecDiff {
|
|
36
|
+
/** Per-section diff arrays (only structured sections). */
|
|
37
|
+
colorPalette: DiffEntry<ColorToken>[];
|
|
38
|
+
typography: DiffEntry<TypographyToken>[];
|
|
39
|
+
layout: DiffEntry<SpacingToken>[];
|
|
40
|
+
shadows: DiffEntry<ShadowToken>[];
|
|
41
|
+
/** Total count of differences across all sections — convenience. */
|
|
42
|
+
totalChanges: number;
|
|
43
|
+
/** Sections whose `present` flag flipped between local and upstream. */
|
|
44
|
+
sectionPresenceChanged: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute the diff. Pure — accepts already-parsed specs so callers
|
|
49
|
+
* own fetching / caching of the upstream source.
|
|
50
|
+
*/
|
|
51
|
+
export function diffDesignSpecs(local: DesignSpec, upstream: DesignSpec): DesignSpecDiff {
|
|
52
|
+
const colorPalette = diffByName(
|
|
53
|
+
local.sections["color-palette"].tokens,
|
|
54
|
+
upstream.sections["color-palette"].tokens,
|
|
55
|
+
(a, b) => a.value === b.value && a.role === b.role,
|
|
56
|
+
);
|
|
57
|
+
const typography = diffByName(
|
|
58
|
+
local.sections.typography.tokens,
|
|
59
|
+
upstream.sections.typography.tokens,
|
|
60
|
+
(a, b) =>
|
|
61
|
+
a.fontFamily === b.fontFamily &&
|
|
62
|
+
a.size === b.size &&
|
|
63
|
+
a.weight === b.weight &&
|
|
64
|
+
a.lineHeight === b.lineHeight,
|
|
65
|
+
);
|
|
66
|
+
const layout = diffByName(
|
|
67
|
+
local.sections.layout.tokens,
|
|
68
|
+
upstream.sections.layout.tokens,
|
|
69
|
+
(a, b) => a.value === b.value,
|
|
70
|
+
);
|
|
71
|
+
const shadows = diffByName(
|
|
72
|
+
local.sections.shadows.tokens,
|
|
73
|
+
upstream.sections.shadows.tokens,
|
|
74
|
+
(a, b) => a.value === b.value,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const sectionPresenceChanged: string[] = [];
|
|
78
|
+
for (const id of [
|
|
79
|
+
"theme",
|
|
80
|
+
"color-palette",
|
|
81
|
+
"typography",
|
|
82
|
+
"components",
|
|
83
|
+
"layout",
|
|
84
|
+
"shadows",
|
|
85
|
+
"dos-donts",
|
|
86
|
+
"responsive",
|
|
87
|
+
"agent-prompts",
|
|
88
|
+
] as const) {
|
|
89
|
+
if (local.sections[id].present !== upstream.sections[id].present) {
|
|
90
|
+
sectionPresenceChanged.push(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const totalChanges = colorPalette.length + typography.length + layout.length + shadows.length;
|
|
95
|
+
return { colorPalette, typography, layout, shadows, totalChanges, sectionPresenceChanged };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface NamedToken {
|
|
99
|
+
name: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function diffByName<T extends NamedToken>(
|
|
103
|
+
local: readonly T[],
|
|
104
|
+
upstream: readonly T[],
|
|
105
|
+
same: (a: T, b: T) => boolean,
|
|
106
|
+
): DiffEntry<T>[] {
|
|
107
|
+
const localByKey = new Map<string, T>();
|
|
108
|
+
for (const t of local) localByKey.set(slug(t.name), t);
|
|
109
|
+
const upstreamByKey = new Map<string, T>();
|
|
110
|
+
for (const t of upstream) upstreamByKey.set(slug(t.name), t);
|
|
111
|
+
|
|
112
|
+
const entries: DiffEntry<T>[] = [];
|
|
113
|
+
for (const [key, upstreamToken] of upstreamByKey) {
|
|
114
|
+
const localToken = localByKey.get(key);
|
|
115
|
+
if (!localToken) {
|
|
116
|
+
entries.push({ kind: "added", name: upstreamToken.name, token: upstreamToken });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!same(localToken, upstreamToken)) {
|
|
120
|
+
entries.push({ kind: "changed", name: upstreamToken.name, before: localToken, after: upstreamToken });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const [key, localToken] of localByKey) {
|
|
124
|
+
if (!upstreamByKey.has(key)) {
|
|
125
|
+
entries.push({ kind: "removed", name: localToken.name, token: localToken });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function slug(name: string): string {
|
|
132
|
+
return name
|
|
133
|
+
.normalize("NFKD")
|
|
134
|
+
.replace(/[^\w\s-]/g, "")
|
|
135
|
+
.trim()
|
|
136
|
+
.replace(/\s+/g, "-")
|
|
137
|
+
.toLowerCase();
|
|
138
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md token extractor — scan project source for tokens that
|
|
3
|
+
* should be promoted into DESIGN.md (Issue #245 M4 §3.5 internal loop).
|
|
4
|
+
*
|
|
5
|
+
* Scope:
|
|
6
|
+
* - **color**: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `rgba(...)`,
|
|
7
|
+
* `hsl(...)`, `oklch(...)` literals in TS/TSX/JSX/CSS/MDX sources.
|
|
8
|
+
* - **font-family**: `font-family: "X", sans-serif` declarations and
|
|
9
|
+
* Tailwind v4-style `--font-<slug>: ...;` tokens that don't yet
|
|
10
|
+
* appear in DESIGN.md typography.
|
|
11
|
+
* - **component**: identifier-style className combos that recur 3+
|
|
12
|
+
* times across files, suggesting a missing extraction.
|
|
13
|
+
*
|
|
14
|
+
* The extractor is **proposal-only**: it never edits anything.
|
|
15
|
+
* Callers (CLI / MCP `mandu.design.extract`) decide whether to flow
|
|
16
|
+
* the proposals into DESIGN.md via `patchDesignMd`. Confidence is a
|
|
17
|
+
* coarse 0..1 score driven by occurrence count; agents can filter on
|
|
18
|
+
* it before showing the user.
|
|
19
|
+
*
|
|
20
|
+
* Performance: the walker bounds itself to the conventional source
|
|
21
|
+
* roots and skips dotfile dirs / node_modules. Large monorepos can
|
|
22
|
+
* narrow further with the `scope` option.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import type { DesignSpec } from "./types";
|
|
28
|
+
|
|
29
|
+
// ─── Public surface ───────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export type ExtractKind = "color" | "typography" | "spacing" | "component";
|
|
32
|
+
|
|
33
|
+
export interface ExtractProposal {
|
|
34
|
+
kind: ExtractKind;
|
|
35
|
+
/** DESIGN.md section the proposal would land in. */
|
|
36
|
+
section: "color-palette" | "typography" | "layout" | "components";
|
|
37
|
+
/** Stable key to compare against existing tokens (slug or literal). */
|
|
38
|
+
key: string;
|
|
39
|
+
/** Suggested DESIGN.md value (`#FF8C42`, `Inter sans-serif`, …). */
|
|
40
|
+
value: string;
|
|
41
|
+
/** Total occurrences across the scanned tree. */
|
|
42
|
+
occurrences: number;
|
|
43
|
+
/** Up to 5 distinct file paths the literal/pattern was found in. */
|
|
44
|
+
files: string[];
|
|
45
|
+
/** Coarse 0..1 confidence — higher = more occurrences. */
|
|
46
|
+
confidence: number;
|
|
47
|
+
/** Optional human note ("seen in ButtonPrimary, ButtonGhost"). */
|
|
48
|
+
note?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ExtractOptions {
|
|
52
|
+
/** Glob-rooted scopes (relative). Defaults to `["src", "app"]`. */
|
|
53
|
+
scope?: readonly string[];
|
|
54
|
+
/** Filter the kinds emitted. Defaults to all four. */
|
|
55
|
+
kinds?: readonly ExtractKind[];
|
|
56
|
+
/** Minimum occurrence threshold for color/font/component. Default 3. */
|
|
57
|
+
minOccurrences?: number;
|
|
58
|
+
/**
|
|
59
|
+
* When provided, proposals already represented by an existing
|
|
60
|
+
* DesignSpec token are dropped (so agents only see "new" candidates).
|
|
61
|
+
*/
|
|
62
|
+
existing?: DesignSpec;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ExtractResult {
|
|
66
|
+
proposals: ExtractProposal[];
|
|
67
|
+
/** Total source files scanned. */
|
|
68
|
+
scannedFiles: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walk the project and collect proposals. Pure async — no caching, no
|
|
73
|
+
* side effects beyond reads.
|
|
74
|
+
*/
|
|
75
|
+
export async function extractDesignTokens(
|
|
76
|
+
rootDir: string,
|
|
77
|
+
options: ExtractOptions = {},
|
|
78
|
+
): Promise<ExtractResult> {
|
|
79
|
+
const scope = options.scope ?? ["src", "app"];
|
|
80
|
+
const kinds = new Set<ExtractKind>(options.kinds ?? ["color", "typography", "spacing", "component"]);
|
|
81
|
+
const minOccurrences = options.minOccurrences ?? 3;
|
|
82
|
+
|
|
83
|
+
const colorOccurrences = new Map<string, { files: Set<string>; count: number }>();
|
|
84
|
+
const fontFamilyOccurrences = new Map<string, { files: Set<string>; count: number }>();
|
|
85
|
+
const classnameComboOccurrences = new Map<string, { files: Set<string>; count: number }>();
|
|
86
|
+
|
|
87
|
+
let scannedFiles = 0;
|
|
88
|
+
for (const dir of scope) {
|
|
89
|
+
const root = path.join(rootDir, dir);
|
|
90
|
+
const files = await collectFiles(root);
|
|
91
|
+
scannedFiles += files.length;
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
let content: string;
|
|
94
|
+
try {
|
|
95
|
+
content = await fs.readFile(file, "utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const rel = path.relative(rootDir, file).replace(/\\/g, "/");
|
|
100
|
+
if (kinds.has("color")) collectColors(content, rel, colorOccurrences);
|
|
101
|
+
if (kinds.has("typography")) collectFontFamilies(content, rel, fontFamilyOccurrences);
|
|
102
|
+
if (kinds.has("component")) collectClassNameCombos(content, rel, classnameComboOccurrences);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const proposals: ExtractProposal[] = [];
|
|
107
|
+
if (kinds.has("color")) {
|
|
108
|
+
proposals.push(
|
|
109
|
+
...buildProposals({
|
|
110
|
+
section: "color-palette",
|
|
111
|
+
kind: "color",
|
|
112
|
+
keyer: (literal) => literal,
|
|
113
|
+
valuer: (literal) => literal,
|
|
114
|
+
seed: colorOccurrences,
|
|
115
|
+
minOccurrences,
|
|
116
|
+
existingKeys: collectExistingColorValues(options.existing),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (kinds.has("typography")) {
|
|
121
|
+
proposals.push(
|
|
122
|
+
...buildProposals({
|
|
123
|
+
section: "typography",
|
|
124
|
+
kind: "typography",
|
|
125
|
+
keyer: (literal) => literal,
|
|
126
|
+
valuer: (literal) => literal,
|
|
127
|
+
seed: fontFamilyOccurrences,
|
|
128
|
+
minOccurrences,
|
|
129
|
+
existingKeys: collectExistingFontFamilies(options.existing),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (kinds.has("component")) {
|
|
134
|
+
proposals.push(
|
|
135
|
+
...buildProposals({
|
|
136
|
+
section: "components",
|
|
137
|
+
kind: "component",
|
|
138
|
+
keyer: (literal) => literal,
|
|
139
|
+
valuer: (literal) => literal,
|
|
140
|
+
seed: classnameComboOccurrences,
|
|
141
|
+
minOccurrences,
|
|
142
|
+
existingKeys: new Set(),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
proposals.sort((a, b) => b.occurrences - a.occurrences);
|
|
148
|
+
return { proposals, scannedFiles };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Walker ───────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const SOURCE_RX = /\.(?:tsx?|jsx?|mdx?|css)$/;
|
|
154
|
+
|
|
155
|
+
async function collectFiles(root: string): Promise<string[]> {
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
async function walk(dir: string): Promise<void> {
|
|
158
|
+
let entries: import("node:fs").Dirent[];
|
|
159
|
+
try {
|
|
160
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
161
|
+
} catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const full = path.join(dir, entry.name);
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
168
|
+
await walk(full);
|
|
169
|
+
} else if (entry.isFile() && SOURCE_RX.test(entry.name)) {
|
|
170
|
+
out.push(full);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await walk(root);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Collectors ───────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const COLOR_RX = /#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|(?:rgba?|hsla?|oklch|hwb|lab|lch)\([^)]+\)/g;
|
|
181
|
+
|
|
182
|
+
function collectColors(
|
|
183
|
+
content: string,
|
|
184
|
+
rel: string,
|
|
185
|
+
bucket: Map<string, { files: Set<string>; count: number }>,
|
|
186
|
+
): void {
|
|
187
|
+
let m: RegExpExecArray | null;
|
|
188
|
+
while ((m = COLOR_RX.exec(content)) !== null) {
|
|
189
|
+
const literal = m[0]!.toLowerCase();
|
|
190
|
+
const entry = bucket.get(literal) ?? { files: new Set(), count: 0 };
|
|
191
|
+
entry.files.add(rel);
|
|
192
|
+
entry.count++;
|
|
193
|
+
bucket.set(literal, entry);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const FONT_FAMILY_RX = /font-family\s*:\s*([^;\n}]+)/gi;
|
|
198
|
+
|
|
199
|
+
function collectFontFamilies(
|
|
200
|
+
content: string,
|
|
201
|
+
rel: string,
|
|
202
|
+
bucket: Map<string, { files: Set<string>; count: number }>,
|
|
203
|
+
): void {
|
|
204
|
+
let m: RegExpExecArray | null;
|
|
205
|
+
while ((m = FONT_FAMILY_RX.exec(content)) !== null) {
|
|
206
|
+
const value = m[1]!.trim().replace(/[`'"]/g, "");
|
|
207
|
+
if (!value || value.length > 200) continue;
|
|
208
|
+
const entry = bucket.get(value) ?? { files: new Set(), count: 0 };
|
|
209
|
+
entry.files.add(rel);
|
|
210
|
+
entry.count++;
|
|
211
|
+
bucket.set(value, entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const CLASSNAME_RX = /className\s*=\s*["']([^"']{16,200})["']/g;
|
|
216
|
+
|
|
217
|
+
function collectClassNameCombos(
|
|
218
|
+
content: string,
|
|
219
|
+
rel: string,
|
|
220
|
+
bucket: Map<string, { files: Set<string>; count: number }>,
|
|
221
|
+
): void {
|
|
222
|
+
let m: RegExpExecArray | null;
|
|
223
|
+
while ((m = CLASSNAME_RX.exec(content)) !== null) {
|
|
224
|
+
const literal = m[1]!.trim().split(/\s+/).sort().join(" ");
|
|
225
|
+
if (!literal) continue;
|
|
226
|
+
// Skip combos that are mostly variant prefixes — they're rarely
|
|
227
|
+
// good extraction candidates.
|
|
228
|
+
const tokenCount = literal.split(/\s+/).length;
|
|
229
|
+
if (tokenCount < 3) continue;
|
|
230
|
+
const entry = bucket.get(literal) ?? { files: new Set(), count: 0 };
|
|
231
|
+
entry.files.add(rel);
|
|
232
|
+
entry.count++;
|
|
233
|
+
bucket.set(literal, entry);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Proposal builder ────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
interface BuildProposalsArgs {
|
|
240
|
+
section: ExtractProposal["section"];
|
|
241
|
+
kind: ExtractKind;
|
|
242
|
+
keyer: (literal: string) => string;
|
|
243
|
+
valuer: (literal: string) => string;
|
|
244
|
+
seed: Map<string, { files: Set<string>; count: number }>;
|
|
245
|
+
minOccurrences: number;
|
|
246
|
+
existingKeys: Set<string>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildProposals(args: BuildProposalsArgs): ExtractProposal[] {
|
|
250
|
+
const out: ExtractProposal[] = [];
|
|
251
|
+
for (const [literal, info] of args.seed) {
|
|
252
|
+
if (info.count < args.minOccurrences) continue;
|
|
253
|
+
const key = args.keyer(literal).toLowerCase();
|
|
254
|
+
if (args.existingKeys.has(key)) continue;
|
|
255
|
+
out.push({
|
|
256
|
+
kind: args.kind,
|
|
257
|
+
section: args.section,
|
|
258
|
+
key: literal,
|
|
259
|
+
value: args.valuer(literal),
|
|
260
|
+
occurrences: info.count,
|
|
261
|
+
files: [...info.files].slice(0, 5),
|
|
262
|
+
confidence: Math.min(1, info.count / 10),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function collectExistingColorValues(spec: DesignSpec | undefined): Set<string> {
|
|
269
|
+
const out = new Set<string>();
|
|
270
|
+
if (!spec) return out;
|
|
271
|
+
for (const t of spec.sections["color-palette"].tokens) {
|
|
272
|
+
if (t.value) out.add(t.value.toLowerCase());
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function collectExistingFontFamilies(spec: DesignSpec | undefined): Set<string> {
|
|
278
|
+
const out = new Set<string>();
|
|
279
|
+
if (!spec) return out;
|
|
280
|
+
for (const t of spec.sections.typography.tokens) {
|
|
281
|
+
if (t.fontFamily) out.add(t.fontFamily.toLowerCase());
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
package/src/design/index.ts
CHANGED
|
@@ -34,6 +34,29 @@ export {
|
|
|
34
34
|
type ThemeMergeResult,
|
|
35
35
|
} from "./tailwind-theme";
|
|
36
36
|
|
|
37
|
+
export {
|
|
38
|
+
extractDesignTokens,
|
|
39
|
+
type ExtractKind,
|
|
40
|
+
type ExtractOptions,
|
|
41
|
+
type ExtractProposal,
|
|
42
|
+
type ExtractResult,
|
|
43
|
+
} from "./extract";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
patchDesignMd,
|
|
47
|
+
patchDesignMdBatch,
|
|
48
|
+
type PatchableSection,
|
|
49
|
+
type PatchOperation,
|
|
50
|
+
type PatchResult,
|
|
51
|
+
type PatchBatchResult,
|
|
52
|
+
} from "./patch";
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
diffDesignSpecs,
|
|
56
|
+
type DesignSpecDiff,
|
|
57
|
+
type DiffEntry,
|
|
58
|
+
} from "./diff";
|
|
59
|
+
|
|
37
60
|
export type {
|
|
38
61
|
AgentPrompt,
|
|
39
62
|
AgentPromptsSection,
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section-safe DESIGN.md patcher (Issue #245 M4 §3.5).
|
|
3
|
+
*
|
|
4
|
+
* The patcher rewrites only the *body* of a target H2 section, leaving:
|
|
5
|
+
* - the H1 title and other H2 sections untouched (verbatim)
|
|
6
|
+
* - the target heading line itself untouched
|
|
7
|
+
* - any free-form prose between the heading and the first structured
|
|
8
|
+
* row preserved
|
|
9
|
+
*
|
|
10
|
+
* Operations are scoped to one `(section, key)` pair at a time so an
|
|
11
|
+
* agent can stream multiple patches without re-loading the file. The
|
|
12
|
+
* `dryRun` flag returns the would-be next source without writing it,
|
|
13
|
+
* so MCP tools can show the user a diff before committing.
|
|
14
|
+
*
|
|
15
|
+
* Token row rules per section:
|
|
16
|
+
* - color-palette / shadows / layout / typography: bullet rows of
|
|
17
|
+
* the form `- <Name> — <value>` (extra columns preserved verbatim
|
|
18
|
+
* for `update`).
|
|
19
|
+
* - components: H3 sub-headings with optional bullet body.
|
|
20
|
+
*
|
|
21
|
+
* Unsupported sections return `{ applied: false, reason: ... }` —
|
|
22
|
+
* they're free-form by design. Callers decide whether to surface the
|
|
23
|
+
* limitation or fall back to a hand-edit.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { parseDesignMd } from "./parser";
|
|
27
|
+
|
|
28
|
+
export type PatchableSection =
|
|
29
|
+
| "color-palette"
|
|
30
|
+
| "typography"
|
|
31
|
+
| "layout"
|
|
32
|
+
| "shadows"
|
|
33
|
+
| "components";
|
|
34
|
+
|
|
35
|
+
export interface PatchOperation {
|
|
36
|
+
section: PatchableSection;
|
|
37
|
+
/** "add" creates the row; "update" replaces an existing matching row;
|
|
38
|
+
* "remove" deletes a matching row. */
|
|
39
|
+
operation: "add" | "update" | "remove";
|
|
40
|
+
/** Token name. Match is case-insensitive on the slug. */
|
|
41
|
+
key: string;
|
|
42
|
+
/** Required for `add` / `update`. Free-form value (`#FF8C42`,
|
|
43
|
+
* `Inter, sans-serif`, `0 1px 3px rgba(...)`). */
|
|
44
|
+
value?: string;
|
|
45
|
+
/** Optional functional role / usage hint (color/shadow). */
|
|
46
|
+
role?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PatchResult {
|
|
50
|
+
applied: boolean;
|
|
51
|
+
/** Reason the operation was a no-op or rejected. */
|
|
52
|
+
reason?: string;
|
|
53
|
+
/** Source after applying — same as input when not applied. */
|
|
54
|
+
next: string;
|
|
55
|
+
/** Old row (for `update` / `remove`). Undefined for clean adds. */
|
|
56
|
+
before?: string;
|
|
57
|
+
/** New row (for `add` / `update`). Undefined for `remove`. */
|
|
58
|
+
after?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const HEADING_BY_SECTION: Record<PatchableSection, RegExp> = {
|
|
62
|
+
"color-palette": /^##\s+.*?(color|palette).*$/im,
|
|
63
|
+
typography: /^##\s+.*?(typograph|typeface|font|type scale).*$/im,
|
|
64
|
+
layout: /^##\s+.*?(layout|spacing|grid).*$/im,
|
|
65
|
+
shadows: /^##\s+.*?(shadow|elevation|depth).*$/im,
|
|
66
|
+
components: /^##\s+.*?(component|button|card|input).*$/im,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply a single patch. Pure — does not touch the filesystem.
|
|
71
|
+
*
|
|
72
|
+
* The result's `applied` flag tells the caller whether the source
|
|
73
|
+
* actually changed (e.g. `remove` of a non-existent key returns
|
|
74
|
+
* `applied: false` with `reason`).
|
|
75
|
+
*/
|
|
76
|
+
export function patchDesignMd(source: string, op: PatchOperation): PatchResult {
|
|
77
|
+
const headingRx = HEADING_BY_SECTION[op.section];
|
|
78
|
+
const headingMatch = headingRx.exec(source);
|
|
79
|
+
if (!headingMatch) {
|
|
80
|
+
return {
|
|
81
|
+
applied: false,
|
|
82
|
+
reason: `Section "${op.section}" not found in DESIGN.md`,
|
|
83
|
+
next: source,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sectionStart = headingMatch.index + headingMatch[0].length;
|
|
88
|
+
const nextHeadingIdx = source.indexOf("\n## ", sectionStart);
|
|
89
|
+
const sectionEnd = nextHeadingIdx >= 0 ? nextHeadingIdx : source.length;
|
|
90
|
+
const sectionBody = source.slice(sectionStart, sectionEnd);
|
|
91
|
+
|
|
92
|
+
const updated = applyToBody(sectionBody, op);
|
|
93
|
+
if (!updated.applied) {
|
|
94
|
+
return { ...updated, next: source };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const next = source.slice(0, sectionStart) + updated.body + source.slice(sectionEnd);
|
|
98
|
+
return {
|
|
99
|
+
applied: true,
|
|
100
|
+
next,
|
|
101
|
+
before: updated.before,
|
|
102
|
+
after: updated.after,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface BodyApplyResult {
|
|
107
|
+
applied: boolean;
|
|
108
|
+
reason?: string;
|
|
109
|
+
body: string;
|
|
110
|
+
before?: string;
|
|
111
|
+
after?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applyToBody(body: string, op: PatchOperation): BodyApplyResult {
|
|
115
|
+
const lines = body.split(/\r?\n/);
|
|
116
|
+
const targetSlug = slug(op.key);
|
|
117
|
+
|
|
118
|
+
const matchIdx = lines.findIndex((line) => {
|
|
119
|
+
const row = parseTokenRow(line, op.section);
|
|
120
|
+
return row !== null && slug(row.name) === targetSlug;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (op.operation === "remove") {
|
|
124
|
+
if (matchIdx < 0) {
|
|
125
|
+
return { applied: false, reason: `No row with name "${op.key}"`, body };
|
|
126
|
+
}
|
|
127
|
+
const before = lines[matchIdx]!;
|
|
128
|
+
lines.splice(matchIdx, 1);
|
|
129
|
+
return { applied: true, body: lines.join("\n"), before };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (op.operation === "update") {
|
|
133
|
+
if (matchIdx < 0) {
|
|
134
|
+
return { applied: false, reason: `No row with name "${op.key}"`, body };
|
|
135
|
+
}
|
|
136
|
+
if (op.value === undefined) {
|
|
137
|
+
return { applied: false, reason: "`value` is required for update", body };
|
|
138
|
+
}
|
|
139
|
+
const before = lines[matchIdx]!;
|
|
140
|
+
const after = renderRow(op);
|
|
141
|
+
lines[matchIdx] = after;
|
|
142
|
+
return { applied: true, body: lines.join("\n"), before, after };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// add — value is required for token rows, optional for component H3
|
|
146
|
+
if (op.section !== "components" && op.value === undefined) {
|
|
147
|
+
return { applied: false, reason: "`value` is required for add", body };
|
|
148
|
+
}
|
|
149
|
+
if (matchIdx >= 0) {
|
|
150
|
+
return {
|
|
151
|
+
applied: false,
|
|
152
|
+
reason: `Row "${op.key}" already exists — use update or remove first`,
|
|
153
|
+
body,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const after = renderRow(op);
|
|
158
|
+
// Find the last existing token row to anchor the insertion. When
|
|
159
|
+
// none exist, insert at the bottom of the section before any
|
|
160
|
+
// trailing whitespace.
|
|
161
|
+
let insertAt = lines.length;
|
|
162
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
163
|
+
if (parseTokenRow(lines[i]!, op.section) !== null) {
|
|
164
|
+
insertAt = i + 1;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lines.splice(insertAt, 0, after);
|
|
169
|
+
// Ensure the section body keeps its trailing blank line before the
|
|
170
|
+
// next H2 (or EOF) so subsequent patches don't crowd headers.
|
|
171
|
+
let next = lines.join("\n");
|
|
172
|
+
if (!next.endsWith("\n")) next = `${next}\n`;
|
|
173
|
+
return { applied: true, body: next, after };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface ParsedTokenRow {
|
|
177
|
+
name: string;
|
|
178
|
+
rest: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseTokenRow(line: string, section: PatchableSection): ParsedTokenRow | null {
|
|
182
|
+
if (section === "components") {
|
|
183
|
+
const h3 = /^###\s+(.+?)\s*$/.exec(line);
|
|
184
|
+
if (h3) return { name: h3[1]!, rest: "" };
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const stripped = line.trim().replace(/^[-*+]\s*/, "");
|
|
188
|
+
if (!stripped || /^[#|]/.test(stripped)) return null;
|
|
189
|
+
const m = /^([^—:|]+?)\s*[—:–|]\s*(.+)$/.exec(stripped);
|
|
190
|
+
if (!m) return null;
|
|
191
|
+
return { name: m[1]!.replace(/[`*_]/g, "").trim(), rest: m[2]!.trim() };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderRow(op: PatchOperation): string {
|
|
195
|
+
if (op.section === "components") {
|
|
196
|
+
return `### ${op.key}`;
|
|
197
|
+
}
|
|
198
|
+
const value = op.value ?? "";
|
|
199
|
+
const role = op.role ? ` — ${op.role}` : "";
|
|
200
|
+
return `- ${op.key} — ${value}${role}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function slug(name: string): string {
|
|
204
|
+
return name
|
|
205
|
+
.normalize("NFKD")
|
|
206
|
+
.replace(/[^\w\s-]/g, "")
|
|
207
|
+
.trim()
|
|
208
|
+
.replace(/\s+/g, "-")
|
|
209
|
+
.toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Multi-op sugar ───────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export interface PatchBatchResult {
|
|
215
|
+
next: string;
|
|
216
|
+
results: PatchResult[];
|
|
217
|
+
appliedCount: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apply a list of operations in order. Each operation runs against
|
|
222
|
+
* the cumulative source — later ops see earlier ones. Failures are
|
|
223
|
+
* surfaced per-entry but never abort the batch (so a partial success
|
|
224
|
+
* is observable).
|
|
225
|
+
*/
|
|
226
|
+
export function patchDesignMdBatch(
|
|
227
|
+
source: string,
|
|
228
|
+
ops: readonly PatchOperation[],
|
|
229
|
+
): PatchBatchResult {
|
|
230
|
+
let current = source;
|
|
231
|
+
const results: PatchResult[] = [];
|
|
232
|
+
let appliedCount = 0;
|
|
233
|
+
for (const op of ops) {
|
|
234
|
+
const r = patchDesignMd(current, op);
|
|
235
|
+
results.push(r);
|
|
236
|
+
if (r.applied) {
|
|
237
|
+
appliedCount++;
|
|
238
|
+
current = r.next;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { next: current, results, appliedCount };
|
|
242
|
+
}
|