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..d75ba3a 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)
@@ -214,10 +215,12 @@ Local browser bundles, minified with esbuild and compressed independently. KB us
| Registration | Minified | Gzip | Brotli |
| --- | ---: | ---: | ---: |
| Core, no languages | 3.84 KB | 1.82 KB | 1.66 KB |
-| Core + TSX | 9.46 KB | 4.03 KB | 3.66 KB |
-| Octane MDX + TypeScript | 13.21 KB | 5.37 KB | 4.90 KB |
-| Nine-language docs set | 15.39 KB | 5.97 KB | 5.44 KB |
-| All 30 languages | 30.08 KB | 10.61 KB | 9.56 KB |
+| Core + TSX | 10.11 KB | 4.26 KB | 3.90 KB |
+| Octane MDX + TypeScript | 13.86 KB | 5.59 KB | 5.17 KB |
+| Nine-language docs set | 16.04 KB | 6.20 KB | 5.66 KB |
+| All 30 languages | 30.72 KB | 10.77 KB | 9.79 KB |
+
+The following comparison was measured before the 1.0 property-context correction. Re-run the comparison commands below for current timings.
On 80 real JavaScript/TypeScript/JSX/TSX TanStack docs fixtures repeated across 5,040 blocks, using the median of three runs after warmup:
diff --git a/docs/comparison.md b/docs/comparison.md
index baffc4b..81000d5 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -73,3 +73,5 @@ Choose another tool when you need:
- Semantic tokens from a language service
Run `pnpm run compare:sugar-high` and `pnpm run compare:shiki` to reproduce the repository's comparisons.
+
+These recorded comparison timings precede the 1.0 property-context correction. Run the comparison scripts for current measurements; see the performance guide for current bundle sizes.
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..e7dff1d
--- /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 and optional settings are minor releases. `HighlightTokenClass` is a closed public union, so adding or removing a token class requires a major release to preserve exhaustive TypeScript consumers. Changing an existing class's meaning also 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/guides/performance.md b/docs/guides/performance.md
index 14a3354..11df7d7 100644
--- a/docs/guides/performance.md
+++ b/docs/guides/performance.md
@@ -13,10 +13,10 @@ CI measures selective browser bundles and highlighting performance on real docum
| Profile | Registered languages | Current gzip | CI budget |
| --- | --- | ---: | ---: |
| Core | None | 1.82 KB | 2.0 KB |
-| TSX | TSX | 4.03 KB | 4.1 KB |
-| Octane | TypeScript plus Octane MDX adapter | 5.37 KB | 5.5 KB |
-| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 5.97 KB | 6.1 KB |
-| All | All 30 definitions | 10.61 KB | 10.8 KB |
+| TSX | TSX | 4.26 KB | 4.35 KB |
+| Octane | TypeScript plus Octane MDX adapter | 5.59 KB | 5.7 KB |
+| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.20 KB | 6.3 KB |
+| All | All 30 definitions | 10.77 KB | 10.9 KB |
KB uses 1,000 bytes. Core helpers imported from the root tree-shake to the same engine size. The standalone theme helper is 695 gzip bytes.
@@ -75,3 +75,5 @@ Context-aware fixes are welcome when they solve common docs code. A change shoul
- Generated HTML size when output structure changes
The correct response to a crossed budget is to inspect the behavior and architecture. Budgets can move when a measured quality improvement justifies the bytes, but the tradeoff must be explicit.
+
+The 1.0 property-context correction adds roughly 230 gzip bytes to the TSX profile without changing core. Local Node 26 gzip results differ slightly from CI compression: the CI docs profile is 6,217 bytes and all languages is 10,838 bytes. Their budgets are 6,300 and 10,900 bytes respectively, retaining a small explicit margin.
diff --git a/docs/test-strategy.md b/docs/test-strategy.md
index 3726731..06d4e1d 100644
--- a/docs/test-strategy.md
+++ b/docs/test-strategy.md
@@ -24,10 +24,10 @@ 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 |
-| All | All 30 definitions | 10.8 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.3 KB |
+| All | All 30 definitions | 10.9 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..016b84a 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_300, 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_900, 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('