From b33feacbe9fad662a07b985ebea3d10fac09d55f Mon Sep 17 00:00:00 2001 From: Abdul Wahab <32850166+abdulwahabone@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:53:36 +0500 Subject: [PATCH] Fix: unescape YAML quote escapes in DESIGN.md frontmatter scalars (#473) * Fix: unescape YAML quote escapes in DESIGN.md frontmatter scalars (#428) parseScalar() stripped a double-quoted scalar's outer quotes without processing the backslash escapes inside, so a font stack that quotes a multi-word family the CSS way, e.g. fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" reached allowedFonts as '\"ibm plex sans' and design-system-font flagged fonts DESIGN.md declares. Also collapses the doubled-quote escape in single-quoted scalars and keeps a lone quote literal instead of slicing it to an empty string. Applied to both copies of the parser (cli/engine/design-system.mjs and skill/scripts/lib/design-parser.mjs). Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed by a maintainer) * Decode YAML hex and Unicode escapes in double-quoted scalars Review follow-up: the escape scanner only handled the simple set, so \xNN, \uNNNN, and \UNNNNNNNN sequences stayed encoded and an escaped token like "\x23b8422e" never matched #b8422e in CSS. Decode validated hex escapes in both parser copies; malformed or out-of-range sequences stay literal. Regression coverage for all three forms. Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed by a maintainer) * Complete the YAML 1.2 double-quote escape set Review follow-up: the escape map omitted the escaped space (\ ) and non-breaking space (\_) forms, so fonts declared with them kept a literal backslash in allowedFonts and their CSS declarations were reported as undeclared. Map the full spec 5.7 set (\a \b \v \f \e \N \L \P included) in both parser copies instead of chasing one escape at a time. Regression coverage for both named forms. Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed by a maintainer) --- cli/engine/design-system.mjs | 67 ++++++++++++++++++++++++++++- skill/scripts/lib/design-parser.mjs | 65 +++++++++++++++++++++++++++- tests/design-parser.test.mjs | 59 +++++++++++++++++++++++++ tests/design-system.test.mjs | 55 +++++++++++++++++++++++ 4 files changed, 242 insertions(+), 4 deletions(-) diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index c4d31eade..b9d9f3f69 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -142,10 +142,73 @@ function stripInlineYamlComment(s) { return s; } +// YAML double-quoted scalars process backslash escapes. Stripping the outer +// quotes without unescaping leaves them in place, so a nested font family like +// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" +// reaches allowedFonts as '\"ibm plex sans' and never matches the same family +// declared in CSS. Scanner instead of a regex: the escape set is small and the +// backslash handling stays readable. +// The full YAML 1.2 double-quote escape set (spec section 5.7). +const YAML_SIMPLE_ESCAPES = { + '0': '\0', + a: '\x07', + b: '\b', + t: '\t', + n: '\n', + v: '\v', + f: '\f', + r: '\r', + e: '\x1b', + ' ': ' ', + '"': '"', + '/': '/', + '\\': '\\', + N: '\u0085', + _: '\u00a0', + L: '\u2028', + P: '\u2029', +}; +const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 }; + +function unescapeYamlDoubleQuoted(body) { + let out = ''; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch !== '\\' || i === body.length - 1) { + out += ch; + continue; + } + const next = body[i + 1]; + if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) { + out += YAML_SIMPLE_ESCAPES[next]; + i++; + continue; + } + // \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay + // literal rather than corrupting the rest of the scalar. + const hexLen = YAML_HEX_ESCAPE_LENGTHS[next]; + if (hexLen) { + const hex = body.slice(i + 2, i + 2 + hexLen); + const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1; + if (codePoint >= 0 && codePoint <= 0x10ffff) { + out += String.fromCodePoint(codePoint); + i += 1 + hexLen; + continue; + } + } + out += ch; + } + return out; +} + function parseScalar(raw) { const s = raw.trim(); - if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { - return s.slice(1, -1); + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + return unescapeYamlDoubleQuoted(s.slice(1, -1)); + } + // Single-quoted YAML escapes only the quote itself, by doubling it. + if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) { + return s.slice(1, -1).split("''").join("'"); } if (s === 'true') return true; if (s === 'false') return false; diff --git a/skill/scripts/lib/design-parser.mjs b/skill/scripts/lib/design-parser.mjs index 5e2f2c286..f82370bb2 100644 --- a/skill/scripts/lib/design-parser.mjs +++ b/skill/scripts/lib/design-parser.mjs @@ -115,10 +115,71 @@ function stripInlineYamlComment(s) { return s; } +// YAML double-quoted scalars process backslash escapes. Stripping the outer +// quotes without unescaping leaves them in place, so a nested font family like +// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" +// keeps its literal backslashes and never matches the same family in CSS. +// The full YAML 1.2 double-quote escape set (spec section 5.7). +const YAML_SIMPLE_ESCAPES = { + '0': '\0', + a: '\x07', + b: '\b', + t: '\t', + n: '\n', + v: '\v', + f: '\f', + r: '\r', + e: '\x1b', + ' ': ' ', + '"': '"', + '/': '/', + '\\': '\\', + N: '\u0085', + _: '\u00a0', + L: '\u2028', + P: '\u2029', +}; +const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 }; + +function unescapeYamlDoubleQuoted(body) { + let out = ''; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch !== '\\' || i === body.length - 1) { + out += ch; + continue; + } + const next = body[i + 1]; + if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) { + out += YAML_SIMPLE_ESCAPES[next]; + i++; + continue; + } + // \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay + // literal rather than corrupting the rest of the scalar. + const hexLen = YAML_HEX_ESCAPE_LENGTHS[next]; + if (hexLen) { + const hex = body.slice(i + 2, i + 2 + hexLen); + const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1; + if (codePoint >= 0 && codePoint <= 0x10ffff) { + out += String.fromCodePoint(codePoint); + i += 1 + hexLen; + continue; + } + } + out += ch; + } + return out; +} + function parseScalar(raw) { const s = raw.trim(); - if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { - return s.slice(1, -1); + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + return unescapeYamlDoubleQuoted(s.slice(1, -1)); + } + // Single-quoted YAML escapes only the quote itself, by doubling it. + if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) { + return s.slice(1, -1).split("''").join("'"); } if (s === 'true') return true; if (s === 'false') return false; diff --git a/tests/design-parser.test.mjs b/tests/design-parser.test.mjs index 053f8dd48..7987841fd 100644 --- a/tests/design-parser.test.mjs +++ b/tests/design-parser.test.mjs @@ -142,6 +142,65 @@ Prose. assert.equal(model.frontmatter.colors['brand-gold'], '#d9a531'); assert.equal(model.frontmatter.rounded['"2xl"'], undefined); }); + + it('unescapes quote escapes inside quoted scalars (issue #428)', () => { + const md = `--- +typography: + body: + fontFamily: "\\"IBM Plex Sans\\", system-ui, sans-serif" +name: 'It''s quiet' +empty: " +--- + +# Design System: Escaped + +## 1. Overview + +Prose. +`; + const model = parseDesignMd(md); + // YAML double-quoted scalars process backslash escapes. + assert.equal(model.frontmatter.typography.body.fontFamily, '"IBM Plex Sans", system-ui, sans-serif'); + // Single-quoted scalars escape the quote by doubling it. + assert.equal(model.frontmatter.name, "It's quiet"); + // A lone quote satisfies startsWith and endsWith at once; keep it literal + // instead of slicing it into an empty string. + assert.equal(model.frontmatter.empty, '"'); + }); + + it('decodes hex, Unicode, and whitespace escapes in double-quoted scalars', () => { + const md = `--- +colors: + accent: "\\x23b8422e" +typography: + accent: + fontFamily: "S\\u00f6hne, sans-serif" + label: + fontFamily: "IBM\\ Plex\\ Serif, serif" + mono: + fontFamily: "Space\\_Grotesk, sans-serif" +emoji: "\\U0001F44D" +bad-hex: "\\xZZ nope" +bad-range: "\\UFFFFFFFF nope" +--- + +# Design System: Hex Escapes + +## 1. Overview + +Prose. +`; + const model = parseDesignMd(md); + assert.equal(model.frontmatter.colors.accent, '#b8422e'); + assert.equal(model.frontmatter.typography.accent.fontFamily, 'Söhne, sans-serif'); + // \ (escaped space) and \_ (non-breaking space) are valid YAML escapes. + assert.equal(model.frontmatter.typography.label.fontFamily, 'IBM Plex Serif, serif'); + assert.equal(model.frontmatter.typography.mono.fontFamily, 'Space\u00a0Grotesk, sans-serif'); + assert.equal(model.frontmatter.emoji, '\u{1F44D}'); + // Malformed or out-of-range sequences stay literal. + assert.equal(model.frontmatter['bad-hex'], '\\xZZ nope'); + assert.equal(model.frontmatter['bad-range'], '\\UFFFFFFFF nope'); + }); }); describe('parseDesignMd overview branch', () => { diff --git a/tests/design-system.test.mjs b/tests/design-system.test.mjs index f10a3765f..7999df940 100644 --- a/tests/design-system.test.mjs +++ b/tests/design-system.test.mjs @@ -297,6 +297,61 @@ rounded: assert.equal(isAllowedRadiusRaw('80px', loaded), true); assert.equal(isAllowedRadiusRaw('24px', loaded), true); }); + + it('unescapes YAML-escaped quotes around multi-word font families (issue #428)', () => { + // A YAML double-quoted scalar processes backslash escapes, so a stack that + // quotes a multi-word family the CSS way arrives as + // fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" + // Before the fix the family reached allowedFonts as '\"ibm plex sans' and + // the rule flagged fonts DESIGN.md declares. + const cwd = mkTmp(); + fs.writeFileSync(path.join(cwd, 'DESIGN.md'), `--- +typography: + display: + fontFamily: "Archivo, system-ui, sans-serif" + body: + fontFamily: "\\"IBM Plex Sans\\", system-ui, sans-serif" + data: + fontFamily: '"IBM Plex Mono", ui-monospace, monospace' + accent: + fontFamily: "S\\u00f6hne, sans-serif" + label: + fontFamily: "IBM\\ Plex\\ Serif, serif" + mono: + fontFamily: "Space\\_Grotesk, sans-serif" +colors: + accent: "\\x23b8422e" +--- + +# Design System +`); + + const loaded = loadDesignSystemForCwd(cwd); + assert.deepEqual( + [...loaded.allowedFonts].sort(), + ['archivo', 'ibm plex mono', 'ibm plex sans', 'ibm plex serif', 'space grotesk', 'söhne'], + ); + assert.equal(isAllowedFont('ibm plex sans', loaded), true); + assert.equal(isAllowedFont('ibm plex mono', loaded), true); + // Escaped space (\ ) and non-breaking space (\_) forms; NBSP collapses to + // a plain space in normalizeFontName, so the CSS declaration matches. + assert.equal(isAllowedFont('ibm plex serif', loaded), true); + assert.equal(isAllowedFont('space grotesk', loaded), true); + assert.equal(isAllowedFont('comic sans ms', loaded), false); + // \x escapes decode too: "\x23b8422e" is #b8422e. + assert.equal(isAllowedColorRaw('#b8422e', loaded), true); + assert.equal(isAllowedColorRaw('#ff00aa', loaded), false); + + const findings = checkSourceDesignSystem(` +body { font-family: "IBM Plex Sans", system-ui, sans-serif; } +code { font-family: "IBM Plex Mono", ui-monospace, monospace; } +h1 { font-family: Archivo, system-ui, sans-serif; } +em { font-family: "Söhne", sans-serif; color: #b8422e; } +small { font-family: "IBM Plex Serif", serif; } +pre { font-family: "Space Grotesk", sans-serif; } +`, '/tmp/escaped-fonts.css', { designSystem: loaded }); + assert.deepEqual(findings, []); + }); }); describe('checkSourceDesignSystem()', () => {