Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/REFRESH-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,41 @@ and this performance follow-up. Version 6.1.5 is the CSS parser, not Router.
An inverse-patch check against main passed with a temporary index; no rollback
was executed. Avoid rolling the whole site back to an older data bundle.

## Local Typography parser backport candidate — 2026-10-23 UTC

This local candidate scopes an npm override to `@tailwindcss/typography` only:
its parser changes from 6.0.10 to **6.1.5**. Typography stays at 0.5.19; no other
package or dependency edge changes. npm hoists the replacement from the nested
Typography directory to `node_modules/postcss-selector-parser`. This is a local
review candidate, not a production publication receipt.

The [maintainer advisory](https://github.com/postcss/postcss-selector-parser/security/advisories/GHSA-rj75-hqrm-r3gf)
and [6.1.5 release](https://github.com/postcss/postcss-selector-parser/releases/tag/6.1.5)
identify the security backport for the 6.x line. The separate
[reviewed advisory range](https://github.com/advisories/GHSA-rj75-hqrm-r3gf)
still says `<7.1.6`, so npm may keep reporting the two moderate package entries.
Keep those findings visible: no advisory exception, audit threshold change,
forced downgrade, or unrelated 7.x migration is part of this candidate.

The inspected consumer is the CSS build plugin in `src/index.css`, parsing
repository sources. No browser/API import or visitor-controlled selector
parsing endpoint was found. That limited review does not establish zero risk
for future consumers or other dependencies.

`npm run test:css-parser` resolves the parser through Typography itself, checks
the patched version and pseudo-element transformations, and parses a flat
400KB class/id selector in a heap-limited worker with a five-second deadline.
The old parser fails that bounded control; the backport completes it. The test
stops only its own worker if parsing regresses, without leaving a blocked test
process. It is also part of the full `npm test` gate.

For review or a future parent upgrade, perform a clean `npm ci`, build and full
test suite; compare generated CSS against the same source/data input before
the override. Recheck the actual Typography resolution and advisory metadata.
Remove this override only when the parent itself supplies a patched compatible
parser, then repeat the output and route checks. Preserve the existing JS/CSS
budgets and the dated historical review below.

## Bounded dependency review — 2026-10-22 UTC

Official npm audit reports **eight moderate package entries**, zero high or
Expand Down
26 changes: 14 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes",
"test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:css-parser && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes",
"test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs scripts/test-crossing-snapshot.mjs",
"test:ads": "node --test scripts/test-ad-slot-diagnostics.mjs && node scripts/check-google-ads.mjs",
"test:publish": "node --test scripts/test-publish-data.mjs scripts/test-public-crossings.mjs",
Expand Down Expand Up @@ -37,6 +37,7 @@
"test:audit": "node scripts/check-audit.mjs",
"test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs scripts/test-historical-pattern.mjs scripts/test-router-navigation.mjs",
"test:mdx": "node --test scripts/test-mdx-frontmatter.mjs",
"test:css-parser": "node --test scripts/test-typography-parser.mjs",
"test:writer": "node --test scripts/test-generated-writer.mjs",
"test:refresh": "node --test scripts/test-data-refresh.mjs"
},
Expand Down Expand Up @@ -113,5 +114,10 @@
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2",
"vite": "^6.4.4"
},
"overrides": {
"@tailwindcss/typography": {
"postcss-selector-parser": "6.1.5"
}
}
}
70 changes: 70 additions & 0 deletions scripts/test-typography-parser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { Worker } from 'node:worker_threads';

// Resolve the parser used by the CSS plugin, including a nested dependency.
const require = createRequire(import.meta.url);
const typographyRequire = createRequire(require.resolve('@tailwindcss/typography/package.json'));
const parserPath = typographyRequire.resolve('postcss-selector-parser');
const version = typographyRequire('postcss-selector-parser/package.json').version;
const utils = typographyRequire('./src/utils.js');

test('Typography resolves a version patched for GHSA-rj75-hqrm-r3gf', () => {
assert.match(version, /^\d+\.\d+\.\d+$/);
const [major, minor, patch] = version.split('.').map(Number);
const patched = (major === 6 && (minor > 1 || (minor === 1 && patch >= 5)))
|| major > 7 || (major === 7 && (minor > 1 || (minor === 1 && patch >= 6)));
assert.ok(patched, 'Typography actually resolves vulnerable parser ' + version);
});

test('Typography preserves pseudo-element and selector-list transformations', () => {
const cases = [
['.alpha::before', ['::before', '.alpha']],
['.alpha::before, .beta::before', ['::before', '.alpha, .beta']],
['.alpha:hover', [null, '.alpha:hover']],
['.alpha:not(.beta)::after', ['::after', '.alpha:not(.beta)']],
['[data-state="open"] > a::before', ['::before', '[data-state="open"] > a']],
['section > p::before, section > a::after', [null, 'section > p::before, section > a::after']],
];
for (const [selector, expected] of cases) {
assert.deepEqual(utils.commonTrailingPseudos(selector), expected);
}
});

test('flat class/id selectors parse within a bounded worker deadline', { timeout: 8_000 }, async t => {
// The advisory uses a flat 400KB selector: nesting guards cannot protect it.
// Bound both time and heap; a regression stops only this test's own worker.
const source = [
"const { parentPort, workerData } = require('node:worker_threads');",
"const { performance } = require('node:perf_hooks');",
'const parser = require(workerData.parserPath);',
"const selector = '.a#b'.repeat(100_000);",
'const started = performance.now();',
'const ast = parser().astSync(selector);',
'parentPort.postMessage({ nodes: ast.first.nodes.length,',
' roundTrip: ast.toString() === selector, elapsedMs: performance.now() - started });',
].join('\n');
const worker = new Worker(source, {
eval: true,
workerData: { parserPath },
resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 32 },
});
let deadline;
try {
const result = await new Promise((resolve, reject) => {
deadline = setTimeout(() => reject(new Error('Flat selector parsing exceeded the 5-second safety deadline')), 5_000);
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', code => {
if (code !== 0) reject(new Error('Parser worker exited with code ' + code));
});
});
assert.equal(result.nodes, 200_000);
assert.equal(result.roundTrip, true);
t.diagnostic('Parser ' + version + ': 200000 selector nodes in ' + Math.round(result.elapsedMs) + 'ms');
} finally {
clearTimeout(deadline);
await worker.terminate();
}
});
Loading