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()', () => {