From e125561816c32f2eefc5912f5dba951ba4b1f07b Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 30 Sep 2026 16:28:42 -0600 Subject: [PATCH 1/5] Prepare Highlight 1.0 contracts and runtime validation --- .changeset/highlight-stable-contract.md | 5 +++ .github/workflows/ci.yml | 34 ++++++++++++++ README.md | 1 + docs/config.json | 1 + docs/guides/migrating-to-v1.md | 27 +++++++++++ docs/test-strategy.md | 8 ++-- package.json | 5 ++- scripts/measure-size.mjs | 10 ++--- scripts/test-packed.mjs | 15 +++++++ scripts/test-runtime.mjs | 56 +++++++++++++++++++++++ src/internal/script.ts | 59 ++++++++++++++++++++++--- test/regressions.test.ts | 39 ++++++++++++++++ 12 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 .changeset/highlight-stable-contract.md create mode 100644 docs/guides/migrating-to-v1.md create mode 100644 scripts/test-packed.mjs create mode 100644 scripts/test-runtime.mjs diff --git a/.changeset/highlight-stable-contract.md b/.changeset/highlight-stable-contract.md new file mode 100644 index 0000000..7eceedc --- /dev/null +++ b/.changeset/highlight-stable-contract.md @@ -0,0 +1,5 @@ +--- +"@tanstack/highlight": major +--- + +Release the stable 1.0 highlighting and adapter contracts. Correct JavaScript and TypeScript keyword property names while preserving switch defaults, accessors, strings, and comments. Validate installed package exports and bounded malformed input across supported Node runtimes, and document migration and compatibility guarantees. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 625de3a..d107810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,37 @@ jobs: uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Verify run: pnpm run verify + + - name: Pack runtime artifact + run: mkdir -p runtime-package && npm pack --ignore-scripts --pack-destination runtime-package + - name: Upload runtime artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: highlight-runtime + path: runtime-package/*.tgz + + runtime: + needs: verify + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [18, 20, 22, 24, 26] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ matrix.node }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: highlight-runtime + path: runtime-package + - name: Install packed package and verify runtime + run: | + mkdir consumer + cd consumer + npm init -y + npm install --ignore-scripts --no-audit --no-fund ../runtime-package/*.tgz + node ../scripts/test-runtime.mjs diff --git a/README.md b/README.md index 1ea7290..b42c557 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ This is not an editor parser or a TextMate engine. It is a deliberately small do ## Documentation - [Overview](docs/overview.md) +- [Migrating to v1 and compatibility policy](docs/guides/migrating-to-v1.md) - [Installation](docs/installation.md) - [Quick Start](docs/quick-start.md) - [Comparison](docs/comparison.md) diff --git a/docs/config.json b/docs/config.json index 889b1e3..86099b9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -12,6 +12,7 @@ { "label": "Overview", "to": "overview" }, { "label": "Installation", "to": "installation" }, { "label": "Quick Start", "to": "quick-start" }, + { "label": "Migrating to v1", "to": "guides/migrating-to-v1" }, { "label": "Comparison", "to": "comparison" } ] }, diff --git a/docs/guides/migrating-to-v1.md b/docs/guides/migrating-to-v1.md new file mode 100644 index 0000000..ef3e8d3 --- /dev/null +++ b/docs/guides/migrating-to-v1.md @@ -0,0 +1,27 @@ +--- +title: Migrating to v1 +--- + +# Migrating to v1 + +Install `@tanstack/highlight@^1.0.0`. Existing 0.x version ranges do not select 1.x automatically. Update your lockfile and use the same Highlight version and language registrations for server and client rendering. + +The 1.0 release preserves the 0.1 public entry points and synchronous APIs. No import or option changes are required. JavaScript and TypeScript property names that are also keywords now receive property styling. This can change token snapshots and colors without changing the source text. + +If upgrading from an older 0.0 release, review the changelog for parser corrections, additional languages and adapters. Check rendered code blocks, copy text, line annotations and your theme selectors. `highlight()` preserves input; the view helpers `renderCodeBlockData()` and `renderCodeFence()` intentionally trim trailing whitespace before rendering and copying. + +## Compatibility policy + +Within 1.x, exported entry points, documented argument and return shapes, the meaning of existing `th-*` classes, and documented HTML/HAST wrapper structure remain compatible. Removal or incompatible changes require a major release. This includes language aliases and fallback behavior, UTF-16 decoration offsets, and the structured Markdown adapters. + +Parser corrections are patch releases. They may change which existing semantic class is assigned to a token, or split and join adjacent token spans. Exact token boundaries, complete HTML snapshots and syntax coloring are not frozen. Source preservation and escaped output remain required. + +New languages, themes, optional settings and token classes are minor releases. Consumers of `HighlightTokenClass` should tolerate additional semantic classes, for example with a default branch when choosing presentation. Removing a class or changing an existing class's meaning requires a major release. + +Custom language definitions and theme selectors are trusted application configuration. The renderer escapes code and decoration attributes; it does not sanitize arbitrary CSS supplied to theme helpers. Synchronous highlighting is intended for documentation-sized blocks. Applications accepting unbounded input should enforce their own size limit or isolate work off the UI thread. + +## Runtime support + +The published ESM package supports Node.js 18 and newer and modern browsers. Build tooling can require a newer Node version than the published runtime package. CI imports every installed public entry point and runs representative and bounded malformed inputs on Node 18, 20, 22, 24 and 26. Browser and SSR users should share a registry and avoid re-highlighting server-rendered blocks during hydration. + +Highlight is a documentation highlighter, not an incremental editor parser or a compiler grammar. These remain outside the 1.0 contract. diff --git a/docs/test-strategy.md b/docs/test-strategy.md index 3726731..7ee1c0c 100644 --- a/docs/test-strategy.md +++ b/docs/test-strategy.md @@ -24,9 +24,9 @@ The suite protects the package's actual product boundary: valid code commonly pu | Profile | Languages | Gzip budget | | --- | --- | ---: | | Core | None | 2.0 KB | -| TSX | TSX | 4.1 KB | -| Octane | TypeScript plus Octane MDX adapter | 5.5 KB | -| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.1 KB | +| TSX | TSX | 4.35 KB | +| Octane | TypeScript plus Octane MDX adapter | 5.7 KB | +| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.2 KB | | All | All 30 definitions | 10.8 KB | The selective profiles are the primary product metric. The all-language profile protects the convenience entry from unbounded growth. Bundle graphs reject unexpected language or theme code. Package tests repeat isolation checks through public exports after building. @@ -39,4 +39,4 @@ The selective profiles are the primary product metric. The all-language profile ## Deliberate Omissions -The package does not maintain compiler conformance suites, malformed-input fuzzing, ReDoS corpora, editor state tests, or exact parity snapshots against another highlighter. Those would optimize for a broader parser product than this library intends to become. +Installed-package checks include bounded malformed strings, repeated delimiters, Unicode and escaping under a process timeout. They are a regression guard, not a proof of arbitrary-input complexity. The package does not maintain compiler conformance suites, unbounded fuzzing, a comprehensive ReDoS corpus, editor state tests, or exact parity snapshots against another highlighter. Those would optimize for a broader parser product than this library intends to become. diff --git a/package.json b/package.json index 112d834..b3feff8 100644 --- a/package.json +++ b/package.json @@ -118,8 +118,9 @@ "skills:check-version": "node scripts/sync-skill-version.mjs --check", "skills:sync-version": "node scripts/sync-skill-version.mjs", "typecheck": "tsc --noEmit", - "verify": "pnpm run typecheck && pnpm run build && pnpm run test:docs && pnpm run test:skills && pnpm run lint:package && pnpm run test:package && pnpm run test && pnpm run size && pnpm run bench", - "version": "pnpm run skills:sync-version" + "verify": "pnpm run typecheck && pnpm run build && pnpm run test:docs && pnpm run test:skills && pnpm run lint:package && pnpm run test:package && pnpm run test:packed && pnpm run test && pnpm run size && pnpm run bench", + "version": "pnpm run skills:sync-version", + "test:packed": "node scripts/test-packed.mjs" }, "devDependencies": { "@changesets/cli": "2.31.1", diff --git a/scripts/measure-size.mjs b/scripts/measure-size.mjs index fa63e87..6eae2f1 100644 --- a/scripts/measure-size.mjs +++ b/scripts/measure-size.mjs @@ -29,7 +29,7 @@ const profiles = { globalThis.highlighter = createHighlighter({ languages: [tsx] }) `, languages: ['tsx'], - limits: { minified: 9_800, gzip: 4_100, brotli: 3_750 }, + limits: { minified: 10_200, gzip: 4_350, brotli: 4_000 }, }, tsxBarrel: { source: ` @@ -38,7 +38,7 @@ const profiles = { globalThis.highlighter = createHighlighter({ languages: [tsx] }) `, languages: ['tsx'], - limits: { minified: 9_800, gzip: 4_100, brotli: 3_750 }, + limits: { minified: 10_200, gzip: 4_350, brotli: 4_000 }, }, octane: { source: ` @@ -50,7 +50,7 @@ const profiles = { globalThis.octaneHighlight = createOctaneMdxHighlight({ highlighter }) `, languages: ['ts'], - limits: { minified: 13_500, gzip: 5_500, brotli: 5_000 }, + limits: { minified: 14_000, gzip: 5_700, brotli: 5_250 }, }, docs: { source: ` @@ -69,7 +69,7 @@ const profiles = { }) `, languages: ['css', 'html', 'js', 'json', 'jsx', 'markdown', 'shell', 'ts', 'tsx'], - limits: { minified: 16_000, gzip: 6_100, brotli: 5_550 }, + limits: { minified: 16_100, gzip: 6_200, brotli: 5_700 }, }, php: { source: ` @@ -104,7 +104,7 @@ const profiles = { globalThis.highlighter = defaultHighlighter `, languages: 'all', - limits: { minified: 30_500, gzip: 10_800, brotli: 9_800 }, + limits: { minified: 30_800, gzip: 10_800, brotli: 9_800 }, }, reactAdapter: { source: `export * from './src/react.ts'`, diff --git a/scripts/test-packed.mjs b/scripts/test-packed.mjs new file mode 100644 index 0000000..aa15819 --- /dev/null +++ b/scripts/test-packed.mjs @@ -0,0 +1,15 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { execFileSync } from 'node:child_process' + +const consumer = mkdtempSync(join(tmpdir(), 'highlight-consumer-')) +const env = { ...process.env, npm_config_cache: join(consumer, 'cache') } +try { + const packed = JSON.parse(execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', consumer], { encoding: 'utf8', env }))[0] + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })) + execFileSync('npm', ['install', '--ignore-scripts', '--offline', '--no-audit', '--no-fund', '--package-lock=false', join(consumer, packed.filename)], { cwd: consumer, stdio: 'inherit', env }) + execFileSync(process.execPath, [resolve('scripts/test-runtime.mjs')], { cwd: consumer, stdio: 'inherit', env }) +} finally { + rmSync(consumer, { recursive: true, force: true }) +} diff --git a/scripts/test-runtime.mjs b/scripts/test-runtime.mjs new file mode 100644 index 0000000..e983eb1 --- /dev/null +++ b/scripts/test-runtime.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { spawnSync } from 'node:child_process' + +// Run from an installed package consumer, not the source checkout. +const require = createRequire(`${process.cwd()}/consumer.cjs`) +const entry = require.resolve('@tanstack/highlight') +const root = new URL('../', `file://${entry}`) +const pkg = JSON.parse(readFileSync(new URL('package.json', root), 'utf8')) +const entries = Object.keys(pkg.exports).flatMap(key => { + if (!key.includes('*')) return [key === '.' ? pkg.name : pkg.name + key.slice(1)] + const directory = key.slice(2, key.indexOf('*')) + return readdirSync(new URL(`dist/${directory}`, root)) + .filter(file => file.endsWith('.js')) + .map(file => `${pkg.name}/${directory}${file.slice(0, -3)}`) +}) +// Imports resolve relative to this generated consumer module. +const moduleSource = ` +import assert from 'node:assert/strict'; +const modules = await Promise.all(${JSON.stringify(entries)}.map(entry => import(entry))); +const { highlight, listLanguages } = modules[0]; +const inputs = [ + ' & \\"', + '/*' + '*'.repeat(20000), + '\\x60' + '\\x24{'.repeat(100), + '<'.repeat(10000), + '('.repeat(10000), + 'a'.repeat(20000) + ':', + '😀\\r\\nconst x = { default: true }\\n', +]; +for (const lang of listLanguages()) { + for (const code of inputs) { + const result = highlight(code, { lang }); + assert.equal(result.tokens.map(token => token.value).join(''), code); + assert.ok(!result.html.includes('