From a965061791d9e780098580042c2a4b18409a511f Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Thu, 22 Oct 2026 14:51:09 +0100 Subject: [PATCH 1/4] fix(security): bound the regexes CodeQL flagged, and null-prototype DTCG groups The patterns that read a number (lengths, line heights, DESIGN.md dimensions, bare HSL and RGB channels) allowed a run of digits to be split between `\d+` and `\d*` in every way, so a failing match on a long value took quadratic time: 25 s for 100,000 digits. They now read the same strings with one way to match. The composite typography pattern and the var() fallback dropped a `\s*` that overlapped the part after it. toDtcg builds its groups with Object.create(null), so a token path with a `__proto__` part is an ordinary key and cannot reach Object.prototype. The site's resolveDocHref turns only the site's own URL, or a path under it, into a site path, not a host that merely starts with it. --- .changeset/long-token-values.md | 7 +++ site/lib/site.ts | 4 +- site/test/markdown.test.ts | 13 ++++ src/tokens/color.ts | 4 +- src/tokens/export.ts | 12 ++-- src/tokens/units.ts | 4 +- test/export.test.ts | 101 ++++++++++++++++++++++++++++++++ test/tokens.test.ts | 62 +++++++++++++++++++- 8 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 .changeset/long-token-values.md diff --git a/.changeset/long-token-values.md b/.changeset/long-token-values.md new file mode 100644 index 00000000..5db81e87 --- /dev/null +++ b/.changeset/long-token-values.md @@ -0,0 +1,7 @@ +--- +'onsystem': patch +--- + +A long length or color no longer slows onsystem down. A class with an arbitrary length thousands of digits long, such as `p-[999…9x]`, took a second to check at 20,000 characters and about 25 seconds at 100,000, in `check`, the MCP `check_ui` tool, the ESLint plugin and the GitHub Action: the pattern that reads a length tried every way to split the digits. It now takes a few milliseconds. The same goes for token values: a length, a `var()` with a fallback, or bare HSL or RGB channels in the design system's stylesheets or Tailwind config, and typography, spacing and radius values in `onsystem export`. + +`onsystem export --format dtcg` keeps a token whose path has a `__proto__` part, such as `--color-__proto__`, under a `"__proto__"` key of its group. It was left out, and a name such as `--__proto__.polluted` added a `polluted` property to every object in the process that ran the export. diff --git a/site/lib/site.ts b/site/lib/site.ts index 73c94ca1..1c42ff25 100644 --- a/site/lib/site.ts +++ b/site/lib/site.ts @@ -133,7 +133,9 @@ export function docsHref(section: string): string { * GitHub, and an anchor to the section on this page. */ export function resolveDocHref(href: string): string { - if (href.startsWith(SITE_URL)) return href.slice(SITE_URL.length) || '/'; + if (href === SITE_URL || href.startsWith(`${SITE_URL}/`)) { + return href.slice(SITE_URL.length) || '/'; + } if (/^https?:\/\//.test(href) || href.startsWith('/') || href.startsWith('#')) return href; const doc = /^(?:\.\/)?([\w-]+\.md)(#[\w-]*)?$/.exec(href); const page = doc?.[1] ? DOCS_FILES[doc[1]] : undefined; diff --git a/site/test/markdown.test.ts b/site/test/markdown.test.ts index 0e558046..2b3a731a 100644 --- a/site/test/markdown.test.ts +++ b/site/test/markdown.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { InlineMarkdown } from '@/components/markdown'; import { MarkdownBlocks, parseBlocks } from '@/components/markdown-blocks'; +import { resolveDocHref } from '@/lib/site'; const inline = (text: string) => renderToStaticMarkup(createElement(InlineMarkdown, { text })); const text = (html: string) => html.replace(/<[^>]+>/g, ''); @@ -98,3 +99,15 @@ describe('MarkdownBlocks', () => { ); }); }); + +describe('resolveDocHref', () => { + it("turns the site's own URLs into paths, and no other host's", () => { + expect(resolveDocHref('https://onsystem.vercel.app')).toBe('/'); + expect(resolveDocHref('https://onsystem.vercel.app/')).toBe('/'); + expect(resolveDocHref('https://onsystem.vercel.app/docs/ci')).toBe('/docs/ci'); + // A host that only starts with the site's stays a link to that host. + expect(resolveDocHref('https://onsystem.vercel.app.example.com/x')).toBe( + 'https://onsystem.vercel.app.example.com/x', + ); + }); +}); diff --git a/src/tokens/color.ts b/src/tokens/color.ts index f8d3349d..6f1d83e2 100644 --- a/src/tokens/color.ts +++ b/src/tokens/color.ts @@ -34,7 +34,7 @@ export type { Oklch }; * colors, to be used as `hsl(var(--border))`. */ const BARE_HSL = - /^(-?\d*\.?\d+)(deg)?(?:\s*,\s*|\s+)(\d*\.?\d+)%(?:\s*,\s*|\s+)(\d*\.?\d+)%(?:\s*\/\s*(\d*\.?\d+%?))?$/; + /^(-?(?:\d+(?:\.\d+)?|\.\d+))(deg)?(?:\s*,\s*|\s+)(\d+(?:\.\d+)?|\.\d+)%(?:\s*,\s*|\s+)(\d+(?:\.\d+)?|\.\d+)%(?:\s*\/\s*((?:\d+(?:\.\d+)?|\.\d+)%?))?$/; export function isBareHsl(value: string): boolean { return BARE_HSL.test(value.trim()); @@ -47,7 +47,7 @@ export function isBareHsl(value: string): boolean { * known to be a color: one the Tailwind config reads with `rgb()`. */ const BARE_RGB = - /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*(\d*\.?\d+%?))?$/; + /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*((?:\d+(?:\.\d+)?|\.\d+)%?))?$/; export function isBareRgb(value: string): boolean { const match = BARE_RGB.exec(value.trim()); diff --git a/src/tokens/export.ts b/src/tokens/export.ts index cd7c817e..47d5e878 100644 --- a/src/tokens/export.ts +++ b/src/tokens/export.ts @@ -43,7 +43,7 @@ export function toDtcg(tokens: readonly Token[]): Exported { const paths = assignPaths(tokens, warnings); const lookup = varLookup(tokens); const byName = new Map(tokens.map((t) => [t.name, t])); - const tree: JsonObject = {}; + const tree: JsonObject = Object.create(null) as JsonObject; let count = 0; for (const category of TOKEN_CATEGORIES) { for (const token of tokens) { @@ -149,7 +149,7 @@ function insert(tree: JsonObject, path: string[], node: JsonObject): void { if (isObject(next)) { group = next; } else { - const created: JsonObject = {}; + const created: JsonObject = Object.create(null) as JsonObject; group[key] = created; group = created; } @@ -285,7 +285,7 @@ function colorObject(css: string): JsonObject | undefined { return out; } -const PLAIN_LENGTH = /^(-?(?:\d+\.?\d*|\.\d+))(px|rem)$/; +const PLAIN_LENGTH = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(px|rem)$/; /** `0.5rem` → `{ value: 0.5, unit: "rem" }`; a `calc()` of tokens → its px value. */ function dimensionValue(value: string, lookup: Lookup): Typed | undefined { @@ -306,8 +306,8 @@ function plainDimension(value: string): JsonObject | undefined { type TypographyKind = 'fontFamily' | 'fontWeight' | 'fontSize' | 'lineHeight' | 'letterSpacing' | 'composite'; -const COMPOSITE = /^[\w-]+\s*:\s*[^;]+(?:;\s*[\w-]+\s*:\s*[^;]+)*;?$/; -const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)$/; +const COMPOSITE = /^[\w-]+\s*:[^;]+(?:;\s*[\w-]+\s*:[^;]+)*;?$/; +const NUMBER = /^-?(?:\d+(?:\.\d*)?|\.\d+)$/; /** What a typography token sets, from its name and Tailwind class (`font-sans`, `text-sm`). */ function typographyKind(token: Token): TypographyKind | undefined { @@ -573,7 +573,7 @@ function frontMatterValue( } /** A DESIGN.md dimension: px, em or rem. */ -const DESIGN_DIMENSION = /^-?(?:\d+\.?\d*|\.\d+)(?:px|rem|em)$/; +const DESIGN_DIMENSION = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em)$/; /** * A typography token as a DESIGN.md typography level: `{ fontFamily: "Inter" }` for diff --git a/src/tokens/units.ts b/src/tokens/units.ts index b7748817..8ea9b876 100644 --- a/src/tokens/units.ts +++ b/src/tokens/units.ts @@ -1,7 +1,7 @@ /** Root font size used to convert rem/em to px for comparisons. */ export const ROOT_FONT_SIZE = 16; -const LENGTH = /^(-?(?:\d+\.?\d*|\.\d+))(px|rem|em)?$/i; +const LENGTH = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(px|rem|em)?$/i; /** `"0.5rem"` → 8, `"12px"` → 12, `"4"` → 4. Returns undefined for anything else. */ export function lengthToPx(value: string | number): number | undefined { @@ -36,7 +36,7 @@ export function evaluateLength(value: string, resolve: Resolve, depth = 0): numb function expandVars(value: string, resolve: Resolve, depth: number): string | undefined { let out = ''; let last = 0; - for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g)) { + for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?:,([^)]+))?\)/g)) { const [whole, name = '', fallback] = match; const resolved = resolve(name) ?? fallback; const px = resolved === undefined ? undefined : evaluateLength(resolved, resolve, depth + 1); diff --git a/test/export.test.ts b/test/export.test.ts index 0b192ff6..f813bf50 100644 --- a/test/export.test.ts +++ b/test/export.test.ts @@ -344,6 +344,107 @@ describe('export --format design-md', () => { }); }); +const tok = (name: string, category: Token['category'], value: string): Token => ({ + name, + category, + value, + usage: [], + source: { file: 'a.css', line: 1 }, +}); + +describe('export of long and unusual values', () => { + it('types the numbers, lengths, composites and color channels it typed before', () => { + const tokens = [ + tok('font-size-sm', 'typography', '0.875rem'), + tok('line-height-tight', 'typography', '1.25'), + tok('line-height-snug', 'typography', '1.'), + tok('line-height-loose', 'typography', '24px'), + tok('tracking-tight', 'typography', '-.025em'), + tok('type-body', 'typography', 'fontFamily: Inter; fontSize: 1rem; lineHeight: 1.5;'), + tok('type-caption', 'typography', 'fontSize : .75rem;lineHeight: 16px'), + tok('space-2', 'spacing', '.5rem'), + tok('radius-sm', 'radius', '2.px'), + tok('border', 'color', '214.3 31.8% 91.4%'), + tok('ring', 'color', '221.2deg, 83.2%, 53.3% / .5'), + tok('success', 'color', '22 163 74 / 50%'), + ]; + const json = JSON.parse(toDtcg(tokens).text) as Tree; + expect(json.typography?.['font-size-sm']?.$value).toEqual({ value: 0.875, unit: 'rem' }); + expect(json.typography?.['line-height-tight']).toMatchObject({ $type: 'number', $value: 1.25 }); + expect(json.typography?.['line-height-snug']).toMatchObject({ $type: 'number', $value: 1 }); + expect(json.typography?.['line-height-loose']?.$value).toEqual({ value: 24, unit: 'px' }); + expect(json.typography?.['tracking-tight']?.$value).toBe('-.025em'); + expect(json.typography?.['type-body']).toMatchObject({ + $type: 'typography', + $value: { fontFamily: 'Inter', fontSize: { value: 1, unit: 'rem' }, lineHeight: 1.5 }, + }); + expect(json.typography?.['type-caption']?.$value).toEqual({ + fontSize: { value: 0.75, unit: 'rem' }, + lineHeight: { value: 16, unit: 'px' }, + }); + expect(json.spacing?.['space-2']?.$value).toEqual({ value: 0.5, unit: 'rem' }); + expect(json.radius?.sm?.$value).toEqual({ value: 2, unit: 'px' }); + expect(json.color?.ring?.$value).toEqual({ + colorSpace: 'hsl', + components: [221.2, 83.2, 53.3], + alpha: 0.5, + }); + expect(json.color?.success?.$extensions).toMatchObject({ + 'com.github.dgesteves.onsystem': { channels: 'rgb' }, + }); + + const md = toDesignMd(tokens, { name: 'Same' }).text; + expect(md).toContain( + 'colors:\n border: hsl(214.3 31.8% 91.4%)\n ring: hsl(221.2deg, 83.2%, 53.3% / .5)\n success: rgb(22 163 74 / 50%)\n', + ); + expect(md).toContain(' line-height-snug:\n lineHeight: 1\n'); + expect(md).toContain(' tracking-tight:\n letterSpacing: -.025em\n'); + expect(md).toContain(' type-caption:\n fontSize: .75rem\n lineHeight: 16px\n'); + expect(md).toContain('rounded:\n sm: 2.px\nspacing:\n space-2: .5rem\n'); + }); + + it('exports long values in time that grows with their length', () => { + const digits = `9${'99'.repeat(50_000)}!`; + const nines = '9'.repeat(100_000); + const tokens = [ + tok('font-size-huge', 'typography', digits), + tok('line-height-huge', 'typography', digits), + tok('space-huge', 'spacing', digits), + tok('radius-huge', 'radius', digits), + tok('type-n', 'typography', `lineHeight: ${digits}`), + tok('type-a', 'typography', `-:${' '.repeat(100_000)};!`), + // 126 characters, which took 3.6 s: each `-: :` could split two ways. + tok('type-b', 'typography', `-::;-:${' :;-:'.repeat(24)}`), + tok('color-hsl', 'color', nines), + tok('color-hsl-alpha', 'color', `9 9% 9%/${nines}!`), + tok('color-rgb-alpha', 'color', `9 9 9/${nines}!`), + ]; + const started = performance.now(); + const dtcg = toDtcg(tokens); + const md = toDesignMd(tokens, { name: 'Long' }); + // Seconds per token before; a few milliseconds for all of them now. + expect(performance.now() - started).toBeLessThan(200); + expect(dtcg.count).toBe(tokens.length); + expect(md.text).toContain('# Long'); + }); + + it('keeps a token named __proto__ in its group, and leaves Object.prototype alone', () => { + const { tokens } = parseCssTokens( + ':root { --__proto__.polluted: #ffffff; } @theme { --color-__proto__: #111111; }', + 'a.css', + ); + try { + const { text, count } = toDtcg(tokens); + expect(({} as Record).polluted).toBeUndefined(); + expect(count).toBe(2); + const group = (JSON.parse(text) as Tree).color?.['__proto__']; + expect(Object.keys(group ?? {})).toEqual(['$root', 'polluted']); + } finally { + delete (Object.prototype as Record).polluted; + } + }); +}); + describe('onsystem export', () => { it('prints the export on stdout', async () => { const { code, stdout } = await run(['export', '--format', 'design-md', '--no-cache']); diff --git a/test/tokens.test.ts b/test/tokens.test.ts index e46a257b..86a6ab05 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -3,7 +3,14 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { colorDistance, findColorLiterals, isTinted, parseColor } from '../src/tokens/color.js'; +import { + colorDistance, + findColorLiterals, + isBareHsl, + isBareRgb, + isTinted, + parseColor, +} from '../src/tokens/color.js'; import { loadTokens, mergeTokens, @@ -473,6 +480,59 @@ describe('units and colors', () => { expect(parseColor('214.3 31.8% 91.4%')).toBeUndefined(); }); + it('reads the lengths and channels it read before', () => { + for (const [value, px] of [ + ['1.', 1], + ['.5rem', 8], + ['-2px', -2], + ['1.5EM', 24], + ['10', 10], + ] as const) { + expect([value, lengthToPx(value)]).toEqual([value, px]); + } + for (const value of ['.', '1.2.3', '1 px', '-', 'px']) { + expect([value, lengthToPx(value)]).toEqual([value, undefined]); + } + const resolve = (name: string) => (name === '--gap' ? '0.5rem' : undefined); + expect(evaluateLength('var(--missing, 4px)', resolve)).toBe(4); + expect(evaluateLength('var( --missing , 0.5rem )', resolve)).toBe(8); + expect(evaluateLength('calc(var(--gap) + var(--missing,2px))', resolve)).toBe(10); + expect(evaluateLength('var(--missing, )', resolve)).toBeUndefined(); + + expect(isBareHsl('214.3 31.8% 91.4%')).toBe(true); + expect(isBareHsl('-30deg, 50%, .5% / .5')).toBe(true); + expect(isBareHsl('0 0% 100% / 50%')).toBe(true); + expect(isBareHsl('1. 2% 3%')).toBe(false); + expect(isBareHsl('1 2 3')).toBe(false); + expect(isBareRgb('22 163 74')).toBe(true); + expect(isBareRgb('22,163,74 / 0.5')).toBe(true); + expect(isBareRgb('255 255 255 / 50%')).toBe(true); + expect(isBareRgb('22 163 74 / 1.')).toBe(false); + expect(isBareRgb('256 0 0')).toBe(false); + expect(parseColor('22 163 74 / .5', { bareRgb: true })).toMatchObject({ alpha: 0.5 }); + }); + + it('reads a long length, var() or color channels in time that grows with its length', () => { + const fast = (read: () => T): T => { + const started = performance.now(); + const value = read(); + expect(performance.now() - started).toBeLessThan(100); + return value; + }; + const digits = '9'.repeat(100_000); + const spaces = ' '.repeat(100_000); + // Each took 14 to 25 s before: the regex tried every way to split the run of digits or + // spaces between two of its parts. + expect(fast(() => lengthToPx(`9${digits}x`))).toBeUndefined(); + expect(fast(() => evaluateLength(`var(---,${spaces}x`, () => undefined))).toBeUndefined(); + expect(fast(() => isBareHsl(digits))).toBe(false); + expect(fast(() => isBareHsl(`9 9% 9%/${digits}!`))).toBe(false); + expect(fast(() => isBareRgb(`9 9 9/${digits}!`))).toBe(false); + const bare = { bareHsl: true, bareRgb: true }; + expect(fast(() => parseColor(digits, bare))).toBeUndefined(); + expect(fast(() => parseColor(`9 9 9/${digits}!`, bare))).toBeUndefined(); + }); + it('finds color literals inside CSS values', () => { expect( findColorLiterals('1px solid #ccc, 0 0 0 2px rgb(0 0 0 / 0.5)').map((m) => m.text), From 0fbcdbc9278c0d7ab2c14d55a14179a9325d3414 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 21:09:43 +0100 Subject: [PATCH 2/4] fix(tokens): stop a var() fallback at a parenthesis, so unclosed runs stay linear CodeQL's new alert on this PR: with `[^)]+`, each `var(` in an unclosed run such as `var(---,var(---,(...` scanned to the end of the value for its `)`. 20,000 repeats took 7.4 s; with `[^()]+` they take 0.4 ms. A fallback that holds parentheses had no px value before either, and still has none. --- src/tokens/units.ts | 5 ++++- test/tokens.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tokens/units.ts b/src/tokens/units.ts index 8ea9b876..5bea6e8b 100644 --- a/src/tokens/units.ts +++ b/src/tokens/units.ts @@ -36,7 +36,10 @@ export function evaluateLength(value: string, resolve: Resolve, depth = 0): numb function expandVars(value: string, resolve: Resolve, depth: number): string | undefined { let out = ''; let last = 0; - for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?:,([^)]+))?\)/g)) { + // The fallback stops at a parenthesis: with `[^)]+`, each `var(` in an unclosed run like + // `var(--a,var(--a,(...` scanned to the end of the value, which is quadratic. A fallback that holds + // parentheses, such as `var(--a, calc(1px + 2px))`, had no px value before either. + for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?:,([^()]+))?\)/g)) { const [whole, name = '', fallback] = match; const resolved = resolve(name) ?? fallback; const px = resolved === undefined ? undefined : evaluateLength(resolved, resolve, depth + 1); diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 86a6ab05..26aa839f 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -498,6 +498,8 @@ describe('units and colors', () => { expect(evaluateLength('var( --missing , 0.5rem )', resolve)).toBe(8); expect(evaluateLength('calc(var(--gap) + var(--missing,2px))', resolve)).toBe(10); expect(evaluateLength('var(--missing, )', resolve)).toBeUndefined(); + expect(evaluateLength('var(--missing, calc(1px + 2px))', resolve)).toBeUndefined(); + expect(evaluateLength('var(--missing, var(--gap))', resolve)).toBeUndefined(); expect(isBareHsl('214.3 31.8% 91.4%')).toBe(true); expect(isBareHsl('-30deg, 50%, .5% / .5')).toBe(true); @@ -525,6 +527,10 @@ describe('units and colors', () => { // spaces between two of its parts. expect(fast(() => lengthToPx(`9${digits}x`))).toBeUndefined(); expect(fast(() => evaluateLength(`var(---,${spaces}x`, () => undefined))).toBeUndefined(); + // 7.4 s before at 20,000 repeats: each `var(` scanned to the end for its `)`. + expect( + fast(() => evaluateLength(`var(---,${'var(---,('.repeat(20_000)}`, () => undefined)), + ).toBeUndefined(); expect(fast(() => isBareHsl(digits))).toBe(false); expect(fast(() => isBareHsl(`9 9% 9%/${digits}!`))).toBe(false); expect(fast(() => isBareRgb(`9 9 9/${digits}!`))).toBe(false); From 7c52757c4bb93a40e65bc3d54fc30962ed6dbf31 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 22:12:05 +0100 Subject: [PATCH 3/4] fix(tokens): read var() to its matching parenthesis, not with a regex The previous commit's `[^()]+` fallback kept unclosed runs linear but lost values main read inside calc(): calc(2 * var(--a, var(--b))) exported as 16px on main and as the raw string with it. expandVars now scans each var() to its matching parenthesis, so a fallback may hold calc() or another var(), and an unclosed var() ends the scan, so no later `var(` is scanned to the end again. A differential fuzz against a CSS-correct reference (300,009 expressions) finds no value it gets wrong, none main got right that it loses, 54,619 it now reads that main did not, and 9,705 main read wrong that it now reads right. The attack input (`var(---,` + `var(---,(` x 20,000) takes 0.5 ms. --- .changeset/long-token-values.md | 2 +- src/tokens/units.ts | 28 +++++++++++++++++++++------- test/tokens.test.ts | 13 ++++++++++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.changeset/long-token-values.md b/.changeset/long-token-values.md index 5db81e87..c50e0598 100644 --- a/.changeset/long-token-values.md +++ b/.changeset/long-token-values.md @@ -2,6 +2,6 @@ 'onsystem': patch --- -A long length or color no longer slows onsystem down. A class with an arbitrary length thousands of digits long, such as `p-[999…9x]`, took a second to check at 20,000 characters and about 25 seconds at 100,000, in `check`, the MCP `check_ui` tool, the ESLint plugin and the GitHub Action: the pattern that reads a length tried every way to split the digits. It now takes a few milliseconds. The same goes for token values: a length, a `var()` with a fallback, or bare HSL or RGB channels in the design system's stylesheets or Tailwind config, and typography, spacing and radius values in `onsystem export`. +A long length or color no longer slows onsystem down. A class with an arbitrary length thousands of digits long, such as `p-[999…9x]`, took a second to check at 20,000 characters and about 25 seconds at 100,000, in `check`, the MCP `check_ui` tool, the ESLint plugin and the GitHub Action: the pattern that reads a length tried every way to split the digits. It now takes a few milliseconds. The same goes for token values: a length, a `var()` with a fallback, or bare HSL or RGB channels in the design system's stylesheets or Tailwind config, and typography, spacing and radius values in `onsystem export`. A `var()` whose fallback holds `calc()` or another `var()` now has the fallback's value, inside `calc()` too: `calc(2 * var(--gap, var(--space-2)))` is read as a length, and `calc(var(--gap, var(--space-2)) + 1px)` no longer drops the `+ 1px`. `onsystem export --format dtcg` keeps a token whose path has a `__proto__` part, such as `--color-__proto__`, under a `"__proto__"` key of its group. It was left out, and a name such as `--__proto__.polluted` added a `polluted` property to every object in the process that ran the export. diff --git a/src/tokens/units.ts b/src/tokens/units.ts index 5bea6e8b..1f9eb3b1 100644 --- a/src/tokens/units.ts +++ b/src/tokens/units.ts @@ -36,16 +36,30 @@ export function evaluateLength(value: string, resolve: Resolve, depth = 0): numb function expandVars(value: string, resolve: Resolve, depth: number): string | undefined { let out = ''; let last = 0; - // The fallback stops at a parenthesis: with `[^)]+`, each `var(` in an unclosed run like - // `var(--a,var(--a,(...` scanned to the end of the value, which is quadratic. A fallback that holds - // parentheses, such as `var(--a, calc(1px + 2px))`, had no px value before either. - for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*(?:,([^()]+))?\)/g)) { - const [whole, name = '', fallback] = match; + // Each var() runs to its matching parenthesis, so a fallback may hold calc() or another var(). A + // var() that is never closed ends the scan: the value has no px value, and no later `var(` is + // scanned to the end again, so an unclosed run like `var(--a,var(--a,(...` stays linear. + for (let at = value.indexOf('var(', last); at !== -1; at = value.indexOf('var(', last)) { + let level = 0; + let comma = -1; + let end = -1; + for (let i = at + 3; i < value.length; i++) { + const c = value[i]; + if (c === '(') level++; + else if (c === ')' && --level === 0) { + end = i; + break; + } else if (c === ',' && level === 1 && comma === -1) comma = i; + } + if (end === -1) return undefined; + const name = value.slice(at + 4, comma === -1 ? end : comma).trim(); + if (!/^--[\w-]+$/.test(name)) return undefined; + const fallback = comma === -1 ? undefined : value.slice(comma + 1, end); const resolved = resolve(name) ?? fallback; const px = resolved === undefined ? undefined : evaluateLength(resolved, resolve, depth + 1); if (px === undefined) return undefined; - out += `${value.slice(last, match.index)}${px}px`; - last = match.index + whole.length; + out += `${value.slice(last, at)}${px}px`; + last = end + 1; } return out + value.slice(last); } diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 26aa839f..6b06dad2 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -498,8 +498,13 @@ describe('units and colors', () => { expect(evaluateLength('var( --missing , 0.5rem )', resolve)).toBe(8); expect(evaluateLength('calc(var(--gap) + var(--missing,2px))', resolve)).toBe(10); expect(evaluateLength('var(--missing, )', resolve)).toBeUndefined(); - expect(evaluateLength('var(--missing, calc(1px + 2px))', resolve)).toBeUndefined(); - expect(evaluateLength('var(--missing, var(--gap))', resolve)).toBeUndefined(); + // A fallback may hold calc() or another var(), inside calc() too. + expect(evaluateLength('var(--missing, calc(1px + 2px))', resolve)).toBe(3); + expect(evaluateLength('var(--missing, var(--gap))', resolve)).toBe(8); + expect(evaluateLength('calc(2 * var(--missing, var(--gap)))', resolve)).toBe(16); + expect(evaluateLength('calc(var(--missing, var(--gap)) + 1px)', resolve)).toBe(9); + expect(evaluateLength('var(--missing, var(--gap)', resolve)).toBeUndefined(); + expect(evaluateLength('var(gap, 4px)', resolve)).toBeUndefined(); expect(isBareHsl('214.3 31.8% 91.4%')).toBe(true); expect(isBareHsl('-30deg, 50%, .5% / .5')).toBe(true); @@ -527,10 +532,12 @@ describe('units and colors', () => { // spaces between two of its parts. expect(fast(() => lengthToPx(`9${digits}x`))).toBeUndefined(); expect(fast(() => evaluateLength(`var(---,${spaces}x`, () => undefined))).toBeUndefined(); - // 7.4 s before at 20,000 repeats: each `var(` scanned to the end for its `)`. + // 7.4 s at 20,000 repeats with a fallback regex: each `var(` scanned to the end for its `)`. expect( fast(() => evaluateLength(`var(---,${'var(---,('.repeat(20_000)}`, () => undefined)), ).toBeUndefined(); + const nested = `${'var(--missing,'.repeat(20_000)}1px${')'.repeat(20_000)}`; + expect(fast(() => evaluateLength(nested, () => undefined))).toBeUndefined(); expect(fast(() => isBareHsl(digits))).toBe(false); expect(fast(() => isBareHsl(`9 9% 9%/${digits}!`))).toBe(false); expect(fast(() => isBareRgb(`9 9 9/${digits}!`))).toBe(false); From 9c6604a763b66ad86d17f78cd60e1cc1754fd8a8 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 22:20:46 +0100 Subject: [PATCH 4/4] test(tokens): a 2,000-deep nested var() is enough to show the depth limit holds --- test/tokens.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 6b06dad2..de8f22bc 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -536,7 +536,8 @@ describe('units and colors', () => { expect( fast(() => evaluateLength(`var(---,${'var(---,('.repeat(20_000)}`, () => undefined)), ).toBeUndefined(); - const nested = `${'var(--missing,'.repeat(20_000)}1px${')'.repeat(20_000)}`; + // Nested fallbacks are read again at each level, at most 16 deep, so this stays linear. + const nested = `${'var(--missing,'.repeat(2_000)}1px${')'.repeat(2_000)}`; expect(fast(() => evaluateLength(nested, () => undefined))).toBeUndefined(); expect(fast(() => isBareHsl(digits))).toBe(false); expect(fast(() => isBareHsl(`9 9% 9%/${digits}!`))).toBe(false);