mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
fix(design-md): markdown edge cases: rule-opened legacy files, spaced fences, ~~~ blocks, duplicate headings, YAML 1.2 numerics
- insertMarker keyed on "starts with ---", so a legacy file opening with a horizontal rule got a `# gstack:` line rendered as a heading that the parser then never read back (the conversion question re-asked every run). It keys on parsed front matter. - A closing front-matter fence with trailing spaces (`--- `) made a valid spec file `unknown`; the closer is any whole `---` line. - `~~~` fences hid nothing, so a `## ` inside one was a section boundary and a splice corrupted the fence; both fence kinds are tracked and only the same kind closes an opener. - convertLegacy silently kept the first of two `## Layout` bodies (and one of `## Color` / `## Colors`); it refuses with DESIGN_MD_CONVERT_REFUSED and the bin leaves the file and writes no backup. - needsQuotes covers 0x / 0o / .inf / .nan (YAML 1.2 numerics that changed type on round-trip); emitYamlBlock throws on an object inside an array instead of writing "[object Object]". - The design binary coerces the model's extraction JSON at the parse boundary (null names, missing arrays) so the paid call's result survives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3994ddfdd7
commit
69b0856d42
+27
-12
@@ -128,15 +128,16 @@ export function parseDesignMd(text: string): DesignMdDoc {
|
||||
rest = rest.slice(legacyMarker[0].length);
|
||||
}
|
||||
if (rest.startsWith('---\n')) {
|
||||
const end = rest.indexOf('\n---', 4);
|
||||
if (end !== -1 && (rest[end + 4] === '\n' || end + 4 === rest.length)) {
|
||||
frontmatterText = rest.slice(4, end + 1);
|
||||
// The closing fence is a whole line of `---` (trailing spaces allowed, an editor artifact); a value line like `---x` is not one.
|
||||
const close = /^---[ \t]*$/m.exec(rest.slice(4));
|
||||
if (close) {
|
||||
frontmatterText = rest.slice(4, 4 + close.index);
|
||||
const m = frontmatterText.match(YAML_MARKER_RE);
|
||||
if (m) marker = m[1] as FormatChoice;
|
||||
const parsed = parseYaml(frontmatterText);
|
||||
frontmatter = parsed.value;
|
||||
frontmatterError = parsed.error;
|
||||
rest = rest.slice(end + 5);
|
||||
rest = rest.slice(4 + close.index + close[0].length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,14 +161,16 @@ export function parseDesignMd(text: string): DesignMdDoc {
|
||||
*/
|
||||
function headingLines(lines: string[]): { heads: Array<{ index: number; heading: string }>; unclosedFence: boolean } {
|
||||
const heads: Array<{ index: number; heading: string }> = [];
|
||||
let inFence = false;
|
||||
let fence: string | null = null; // the opener's characters (``` or ~~~); only the same kind closes it
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^```/.test(lines[i])) { inFence = !inFence; continue; }
|
||||
if (inFence) continue;
|
||||
const f = lines[i].match(/^(```|~~~)/);
|
||||
if (f && fence === null) { fence = f[1]; continue; }
|
||||
if (f && fence === f[1]) { fence = null; continue; }
|
||||
if (fence !== null) continue;
|
||||
const h = lines[i].match(/^## (.+?)\s*$/);
|
||||
if (h) heads.push({ index: i, heading: h[1] });
|
||||
}
|
||||
return { heads, unclosedFence: inFence };
|
||||
return { heads, unclosedFence: fence !== null };
|
||||
}
|
||||
|
||||
/** Thrown by the text-level editors when the file cannot be edited safely (an unclosed code fence). The bins print it as DESIGN_MD_EDIT_REFUSED and leave the file unchanged. */
|
||||
@@ -220,8 +223,9 @@ export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat;
|
||||
function needsQuotes(s: string): boolean {
|
||||
// Control characters (an LLM-extracted font family with an embedded newline) must
|
||||
// go through the double-quoted form: a bare multi-line scalar does not parse back.
|
||||
// `\s#` too: a plain scalar ending in ` #F59E0B` would parse back as a comment.
|
||||
return s === '' || /[\x00-\x1f\x7f]/.test(s) || /^[\s#&*!|>'"%@`{[\]},:?-]|[:#]\s|\s#|\s$|^(true|false|null|yes|no|on|off|~)$|^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/i.test(s);
|
||||
// `\s#` too: a plain scalar ending in ` #F59E0B` would parse back as a comment. YAML 1.2 also
|
||||
// reads 0x1F / 0o17 / .inf / .nan as numbers, so those shapes are quoted as well.
|
||||
return s === '' || /[\x00-\x1f\x7f]/.test(s) || /^[\s#&*!|>'"%@`{[\]},:?-]|[:#]\s|\s#|\s$|^(true|false|null|yes|no|on|off|~)$|^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$|^0[xob][0-9a-f_]+$|^[-+]?\.(inf|nan)$/i.test(s);
|
||||
}
|
||||
|
||||
function yamlScalar(v: unknown): string {
|
||||
@@ -242,7 +246,10 @@ export function emitYamlBlock(obj: Record<string, unknown>, indent = 0): string
|
||||
out.push(emitYamlBlock(v as Record<string, unknown>, indent + 2));
|
||||
} else if (Array.isArray(v)) {
|
||||
out.push(`${pad}${key}:`);
|
||||
for (const item of v) out.push(`${pad} - ${yamlScalar(item)}`);
|
||||
for (const item of v) {
|
||||
if (item !== null && typeof item === 'object') throw new TypeError('emitYamlBlock: array items must be scalars (a nested object would be written as "[object Object]")');
|
||||
out.push(`${pad} - ${yamlScalar(item)}`);
|
||||
}
|
||||
} else {
|
||||
out.push(`${pad}${key}: ${yamlScalar(v)}`);
|
||||
}
|
||||
@@ -329,7 +336,8 @@ export function insertMarker(text: string, choice: FormatChoice): string {
|
||||
const src = text.slice(bom.length).replace(/\r\n/g, '\n');
|
||||
const stripped = src.replace(LEGACY_MARKER_RE, '');
|
||||
let out: string;
|
||||
if (stripped.startsWith('---\n')) {
|
||||
// Front matter, not "starts with ---": a legacy file opening with a horizontal rule gets the HTML comment.
|
||||
if (parseDesignMd(stripped).frontmatterText !== null) {
|
||||
const withoutOld = stripped.replace(FRONTMATTER_OPEN_WITH_MARKER_RE, '---\n');
|
||||
out = withoutOld.replace(/^---\n/, `---\n# ${FORMAT_MARKER_PREFIX}${choice}\n`);
|
||||
} else {
|
||||
@@ -431,6 +439,13 @@ function firstFontName(value: string): string | undefined {
|
||||
* its original order. Idempotent: converting the render again changes nothing.
|
||||
*/
|
||||
export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): DesignMdDoc {
|
||||
// A heading the conversion consumes must be unique, or a second body would be silently dropped.
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of doc.sections) counts.set(s.heading.trim().toLowerCase(), (counts.get(s.heading.trim().toLowerCase()) ?? 0) + 1);
|
||||
for (const h of [...LEGACY_HEADINGS, 'Typography', 'Layout', 'Colors']) {
|
||||
if ((counts.get(h.toLowerCase()) ?? 0) > 1) throw new DesignMdEditRefused(`legacy heading "## ${h}" appears more than once`);
|
||||
}
|
||||
if (counts.has('color') && counts.has('colors')) throw new DesignMdEditRefused('both "## Color" and "## Colors" are present');
|
||||
const title = doc.preamble.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||||
const name = opts.name ?? (title ? title.replace(/^Design System\s*[—–-]\s*/i, '').trim() : 'Design System');
|
||||
const fm: Record<string, unknown> = { name };
|
||||
|
||||
Reference in New Issue
Block a user