From 9ffa69e1856420882b12918d3788271f361a31f8 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 15:25:54 -0600 Subject: [PATCH] Add isolated PHP highlighting with optional HTML delegation --- .changeset/php-language.md | 5 + README.md | 4 +- docs/guides/embedded-languages.md | 15 +++ docs/guides/performance.md | 6 +- docs/installation.md | 2 +- docs/language-support.md | 1 + docs/reference/default-entry.md | 2 +- docs/reference/languages.md | 3 +- docs/test-strategy.md | 4 +- scripts/bench.mjs | 6 + scripts/language-utils.mjs | 1 + scripts/measure-size.mjs | 11 +- .../references/languages.md | 2 + src/index.ts | 3 + src/languages/index.ts | 1 + src/languages/php.ts | 125 ++++++++++++++++++ test/fixtures.ts | 6 + test/php.test.ts | 118 +++++++++++++++++ test/real-doc-fixtures.test.ts | 2 +- 19 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 .changeset/php-language.md create mode 100644 src/languages/php.ts create mode 100644 test/php.test.ts diff --git a/.changeset/php-language.md b/.changeset/php-language.md new file mode 100644 index 0000000..9032975 --- /dev/null +++ b/.changeset/php-language.md @@ -0,0 +1,5 @@ +--- +"@tanstack/highlight": minor +--- + +Add an isolated PHP language definition with PHP tags, attributes, strings, heredoc/nowdoc, and optional HTML delegation. Core and unrelated selective bundles are unchanged. diff --git a/README.md b/README.md index b5b10f9..2189bf9 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Gruvbox Dark, Gr ## Languages -`apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. +`apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `php`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. Each language is available from `@tanstack/highlight/languages/`. The aggregate `@tanstack/highlight/languages` entry can tree-shake, while direct subpaths make isolation explicit. Importing only core helpers from the root entry also removes unused language registrations in a compatible bundler. @@ -217,7 +217,7 @@ Local browser bundles, minified with esbuild and compressed independently. KB us | 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 27 languages | 23.93 KB | 8.66 KB | 7.78 KB | +| All 28 languages | 27.23 KB | 9.68 KB | 8.72 KB | 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/guides/embedded-languages.md b/docs/guides/embedded-languages.md index b5eaa33..01afa25 100644 --- a/docs/guides/embedded-languages.md +++ b/docs/guides/embedded-languages.md @@ -158,6 +158,21 @@ EJS regions delegate to JavaScript when available: <% } %> ``` +## PHP + +Register `php` to highlight PHP code, including tagless excerpts. Inputs containing `

', { lang: 'php' }) +``` + +PHP does not import HTML. Register `js`, `ts`, and `css` too when the surrounding HTML contains script or style blocks. Quoted strings, including interpolation, and heredoc/nowdoc bodies stay string tokens; Highlight doesn't parse the expressions inside them. + ## Markdown fences The Markdown definition reads the fence info string and delegates the body: diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 29b9710..4fa4a09 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -8,7 +8,7 @@ CI measures selective browser bundles and highlighting performance on real docum ## Bundle profiles -`pnpm run size` builds fourteen browser profiles with esbuild and measures minified, gzip, and Brotli bytes independently. It also checks that helper, adapter, and selective language imports retain only the requested modules. +`pnpm run size` builds fifteen browser profiles with esbuild and measures minified, gzip, and Brotli bytes independently. It also checks that helper, adapter, and selective language imports retain only the requested modules. | Profile | Registered languages | Current gzip | CI budget | | --- | --- | ---: | ---: | @@ -16,7 +16,7 @@ CI measures selective browser bundles and highlighting performance on real docum | 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 27 definitions | 8.66 KB | 8.9 KB | +| All | All 28 definitions | 9.68 KB | 9.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. @@ -26,7 +26,7 @@ Selective profiles are the primary metric. The all-language profile exists to pr The committed corpus contains 334 real code fences sampled from TanStack documentation, with up to twenty samples per normalized language. -`pnpm run bench` measures tokenization, HTML, Markdown, HAST, line numbers, and long decorated blocks. Each profile reports the median of three samples after two warmup passes, with a 1.2 second CI budget. The main highlighting profile processes at least 10,000 blocks. +`pnpm run bench` measures tokenization, HTML, Markdown, HAST, line numbers, long decorated blocks, and dedicated PHP samples. Each profile reports the median of three samples after two warmup passes, with a 1.2 second CI budget. The main highlighting profile processes at least 10,000 blocks. A local before-and-after review used the same minified bundle settings, fixtures, and benchmark harness on macOS arm64 with Node 24.15.0: diff --git a/docs/installation.md b/docs/installation.md index 41922a5..4c8c765 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -60,7 +60,7 @@ export const highlighter = createHighlighter({ }) ``` -The root entry is useful for prototypes, server-only scripts, or sites where the roughly 8 KB gzip all-language build is acceptable: +The root entry is useful for prototypes, server-only scripts, or sites where the roughly 10 KB gzip all-language build is acceptable: ```ts import { highlight } from '@tanstack/highlight' diff --git a/docs/language-support.md b/docs/language-support.md index 35b2b29..80abf72 100644 --- a/docs/language-support.md +++ b/docs/language-support.md @@ -25,6 +25,7 @@ Every language is an isolated definition imported from `@tanstack/highlight/lang | Markdown | `markdown` | `md` | Optional fenced-language delegation | | Mermaid | `mermaid` | - | Common diagram declarations and arrows | | Nginx | `nginx` | - | Directives, variables, URLs, comments | +| PHP | `php` | - | PHP tags, attributes, quoted strings, heredoc/nowdoc, optional HTML delegation | | Plaintext | `plaintext` | `text`, `txt`, `-->` | Escaping only | | Python | `python` | `py` | Triple strings, prefixes, decorators, comments | | Scheme | `scheme` | `scm`, `racket` | Comments, strings, forms, literals | diff --git a/docs/reference/default-entry.md b/docs/reference/default-entry.md index 6f6f756..c991efb 100644 --- a/docs/reference/default-entry.md +++ b/docs/reference/default-entry.md @@ -43,7 +43,7 @@ Returns the canonical registered language name for a name or alias. Names are tr function listLanguages(): Array ``` -Returns the 27 canonical language names registered in `defaultHighlighter`. +Returns the 28 canonical language names registered in `defaultHighlighter`. ### `tokenize` diff --git a/docs/reference/languages.md b/docs/reference/languages.md index 3089e6b..7eec37c 100644 --- a/docs/reference/languages.md +++ b/docs/reference/languages.md @@ -36,6 +36,7 @@ const highlighter = createHighlighter({ | `markdown` | `@tanstack/highlight/languages/markdown` | `md` | | `mermaid` | `@tanstack/highlight/languages/mermaid` | None | | `nginx` | `@tanstack/highlight/languages/nginx` | None | +| `php` | `@tanstack/highlight/languages/php` | None | | `plaintext` | `@tanstack/highlight/languages/plaintext` | `text`, `txt`, `-->` | | `python` | `@tanstack/highlight/languages/python` | `py` | | `scheme` | `@tanstack/highlight/languages/scheme` | `scm`, `racket` | @@ -49,6 +50,6 @@ const highlighter = createHighlighter({ | `vue` | `@tanstack/highlight/languages/vue` | None | | `yaml` | `@tanstack/highlight/languages/yaml` | `yml` | -`@tanstack/highlight/languages` re-exports `apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. +`@tanstack/highlight/languages` re-exports `apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `php`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. See the [language support matrix](../language-support) for the context-aware behavior and current scope of each registration. diff --git a/docs/test-strategy.md b/docs/test-strategy.md index 9ebfb69..684d031 100644 --- a/docs/test-strategy.md +++ b/docs/test-strategy.md @@ -19,7 +19,7 @@ The suite protects the package's actual product boundary: valid code commonly pu ## Size Profiles -`pnpm run size` checks fourteen independent browser profiles, including root helpers, language barrel imports, adapters, and themes. Each has minified, gzip, and Brotli budgets. The five highlighter profiles are: +`pnpm run size` checks fifteen independent browser profiles, including root helpers, language barrel imports, adapters, and themes. Each has minified, gzip, and Brotli budgets. The five highlighter profiles are: | Profile | Languages | Gzip budget | | --- | --- | ---: | @@ -27,7 +27,7 @@ The suite protects the package's actual product boundary: valid code commonly pu | 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 27 definitions | 8.9 KB | +| All | All 28 definitions | 9.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. diff --git a/scripts/bench.mjs b/scripts/bench.mjs index 39f98af..5404880 100644 --- a/scripts/bench.mjs +++ b/scripts/bench.mjs @@ -74,6 +74,12 @@ try { observe: (result) => result.children[0].children.length, targetBlocks: 5_000, }, + php: { + fixtures: [{ rawLang: 'php', code: '

# text\nEND;' }], + run: (fixture) => highlight(fixture.code, { lang: fixture.rawLang }), + observe: (result) => result.html.length, + targetBlocks: 10_000, + }, lineNumbers: { fixtures, run: (fixture) => highlight(fixture.code, { lang: fixture.rawLang, lineNumbers: true }), diff --git a/scripts/language-utils.mjs b/scripts/language-utils.mjs index 451eb1e..09c0e67 100644 --- a/scripts/language-utils.mjs +++ b/scripts/language-utils.mjs @@ -17,6 +17,7 @@ export const supportedLanguages = [ 'markdown', 'mermaid', 'nginx', + 'php', 'plaintext', 'python', 'scheme', diff --git a/scripts/measure-size.mjs b/scripts/measure-size.mjs index 57738c8..741b857 100644 --- a/scripts/measure-size.mjs +++ b/scripts/measure-size.mjs @@ -71,13 +71,22 @@ const profiles = { languages: ['css', 'html', 'js', 'json', 'jsx', 'markdown', 'shell', 'ts', 'tsx'], limits: { minified: 16_000, gzip: 6_100, brotli: 5_550 }, }, + php: { + source: ` + import { createHighlighter } from './src/core.ts' + import { php } from './src/languages/php.ts' + globalThis.highlighter = createHighlighter({ languages: [php] }) + `, + languages: ['php'], + limits: { minified: 8_000, gzip: 3_700, brotli: 3_400 }, + }, all: { source: ` import { defaultHighlighter } from './src/index.ts' globalThis.highlighter = defaultHighlighter `, languages: 'all', - limits: { minified: 24_500, gzip: 8_900, brotli: 8_000 }, + limits: { minified: 27_500, gzip: 9_900, brotli: 9_000 }, }, reactAdapter: { source: `export * from './src/react.ts'`, diff --git a/skills/configure-selective-highlighting/references/languages.md b/skills/configure-selective-highlighting/references/languages.md index 769972c..1194e54 100644 --- a/skills/configure-selective-highlighting/references/languages.md +++ b/skills/configure-selective-highlighting/references/languages.md @@ -19,6 +19,7 @@ Import only the definitions the application registers. | Markdown | `markdown` | `@tanstack/highlight/languages/markdown` | `md` | | Mermaid | `mermaid` | `@tanstack/highlight/languages/mermaid` | - | | Nginx | `nginx` | `@tanstack/highlight/languages/nginx` | - | +| PHP | `php` | `@tanstack/highlight/languages/php` | - | | Plaintext | `plaintext` | `@tanstack/highlight/languages/plaintext` | `text`, `txt`, `-->` | | Python | `python` | `@tanstack/highlight/languages/python` | `py` | | Scheme | `scheme` | `@tanstack/highlight/languages/scheme` | `scm`, `racket` | @@ -40,6 +41,7 @@ Import only the definitions the application registers. | Vue | `js`, `ts`, `css` | | Svelte | `js`, `ts`, `css` | | EJS | `js` | +| PHP | `html` (and its optional script/style languages) | | Markdown | The language named by each fence | The aggregate `@tanstack/highlight/languages` entry can tree-shake in a compatible bundler. Direct subpaths make isolation explicit and are the default for size-sensitive clients. diff --git a/src/index.ts b/src/index.ts index 9de48a2..375cd39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { jsx } from './languages/jsx.js' import { markdown } from './languages/markdown.js' import { mermaid } from './languages/mermaid.js' import { nginx } from './languages/nginx.js' +import { php } from './languages/php.js' import { plaintext } from './languages/plaintext.js' import { python } from './languages/python.js' import { scheme } from './languages/scheme.js' @@ -49,6 +50,7 @@ export type HighlightLanguage = | 'markdown' | 'mermaid' | 'nginx' + | 'php' | 'plaintext' | 'python' | 'scheme' @@ -118,6 +120,7 @@ export const allLanguages = [ markdown, mermaid, nginx, + php, plaintext, python, scheme, diff --git a/src/languages/index.ts b/src/languages/index.ts index 50c2791..f7ca2f0 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -13,6 +13,7 @@ export { jsx } from './jsx.js' export { markdown } from './markdown.js' export { mermaid } from './mermaid.js' export { nginx } from './nginx.js' +export { php } from './php.js' export { plaintext } from './plaintext.js' export { python } from './python.js' export { scheme } from './scheme.js' diff --git a/src/languages/php.ts b/src/languages/php.ts new file mode 100644 index 0000000..0015068 --- /dev/null +++ b/src/languages/php.ts @@ -0,0 +1,125 @@ +import { defineLanguage, type TokenRange } from '../core.js' +import { collectPatternRanges, offsetRanges } from '../internal/patterns.js' + +const patterns = [ + { className: 'variable', regex: /\$[A-Za-z_\x80-\uffff][\w\x80-\uffff]*/g }, + { className: 'attr', regex: /#\[\s*([\\A-Za-z_][\\\w]*)/g, group: 1 }, + { className: 'function', regex: /(?:\?->|->|::)\s*([A-Za-z_]\w*)(?=\s*\()/g, group: 1 }, + { className: 'property', regex: /(?:\?->|->|::)\s*([A-Za-z_][\w]*)/g, group: 1 }, + { className: 'keyword', regex: /\b(?:abstract|and|as|break|callable|case|catch|class|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|new|or|print|private|protected|public|readonly|require|require_once|return|static|switch|throw|trait|try|unset|use|var|while|xor|yield|from)\b/gi }, + { className: 'literal', regex: /\b(?:true|false|null|__CLASS__|__DIR__|__FILE__|__FUNCTION__|__LINE__|__METHOD__|__NAMESPACE__|__TRAIT__)\b/gi }, + { className: 'type', regex: /\b(?:array|bool|float|int|iterable|mixed|never|object|parent|self|string|void)\b/gi }, + { className: 'type', regex: /\b(?:class|enum|interface|trait|new|extends|implements|instanceof)\s+([\\A-Za-z_][\\\w]*)/gi, group: 1 }, + { className: 'function', regex: /\b[A-Za-z_][\w]*(?=\s*\()/g }, + { className: 'number', regex: /(?:^|[^\w.])((?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[bB][01](?:_?[01])*|0[oO][0-7](?:_?[0-7])*|(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)(?:[eE][+-]?\d(?:_?\d)*)?))(?![\w.])/g, group: 1 }, + { className: 'operator', regex: /\?->|\?\?=?|<=>|===|!==|=>|->|::|\*\*=?|<<=?|>>=?|&&|\|\||\+\+|--|[+\-*/%.&|^!<>=]=?|[~?:@]/g }, +] satisfies Parameters[1] + +export const php = defineLanguage({ + name: 'php', + tokenize(code, context) { + const ranges: Array = [] + const opening = /<\?(?:php(?=\s|$)|=)/gi + let tag = opening.exec(code) + let start = 0 + // Inputs with opening tags are documents; otherwise accept a PHP excerpt. + while (start < code.length) { + if (tag) { + if (tag.index > start && context.hasLanguage('html')) { + ranges.push(...offsetRanges(context.tokenize(code.slice(start, tag.index), 'html'), start)) + } + start = tag.index + tag[0].length + ranges.push({ start: tag.index, end: start, className: 'meta' }) + } + const scanned = scanPhp(code, start) + ranges.push(...offsetRanges(collectPatternRanges( + code.slice(start, scanned.end), + patterns, + offsetRanges(scanned.ranges, -start), + ), start)) + start = scanned.end + if (start === code.length) break + ranges.push({ start, end: start + 2, className: 'meta' }) + start += 2 + opening.lastIndex = start + tag = opening.exec(code) + if (!tag) { + if (context.hasLanguage('html')) { + ranges.push(...offsetRanges(context.tokenize(code.slice(start), 'html'), start)) + } + break + } + } + return ranges + }, +}) + +function scanPhp(code: string, start: number) { + const ranges: Array = [] + const lexical = /\/\*|\/\/|#(?!\[)|['"`]|<<<|\?>/g + lexical.lastIndex = start + let match: RegExpExecArray | null + while ((match = lexical.exec(code))) { + const token = match[0] + const from = match.index + if (token === '?>') return { end: from, ranges } + let end: number + let className: 'comment' | 'string' = 'string' + if (token === '/*') { + const close = code.indexOf('*/', lexical.lastIndex) + end = close < 0 ? code.length : close + 2 + className = 'comment' + } else if (token === '//' || token === '#') { + end = lexical.lastIndex + while (end < code.length && code[end] !== '\n' && !code.startsWith('?>', end)) end++ + className = 'comment' + } else if (token === '<<<') { + const header = /<<<[ \t]*(?:'([A-Za-z_\x80-\uffff][\w\x80-\uffff]*)'|"([A-Za-z_\x80-\uffff][\w\x80-\uffff]*)"|([A-Za-z_\x80-\uffff][\w\x80-\uffff]*))[ \t]*\r?\n/y + header.lastIndex = from + const heredoc = header.exec(code) + if (!heredoc) continue + const label = heredoc[1] || heredoc[2] || heredoc[3] + const closing = new RegExp(`^[\\t ]*${label}(?![\\w\\x80-\\uffff])`, 'gm') + closing.lastIndex = header.lastIndex + const close = closing.exec(code) + end = close ? close.index + close[0].length : code.length + } else { + end = stringEnd(code, from, token) + } + ranges.push({ start: from, end, className }) + lexical.lastIndex = end + } + return { end: code.length, ranges } +} + +function stringEnd(code: string, start: number, quote: string): number { + let index = start + 1 + let depth = 0 + while (index < code.length) { + const char = code[index] + if (char === '\\') { + index += 2 + continue + } + if (quote !== "'" && (char === '{' && (depth > 0 || code[index + 1] === '$') || char === '$' && code[index + 1] === '{')) { + depth++ + index += char === '$' ? 2 : 1 + continue + } + if (depth) { + if (char === '"' || char === "'") { + // Quoted keys inside interpolation belong to this string. + const keyQuote = char + index++ + while (index < code.length) { + if (code[index] === '\\') index += 2 + else if (code[index++] === keyQuote) break + } + continue + } + if (char === '}') depth-- + } else if (char === quote) return index + 1 + index++ + } + return code.length +} diff --git a/test/fixtures.ts b/test/fixtures.ts index a3ead0d..df49790 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -8,6 +8,12 @@ export type LanguageFixture = { } export const languageFixtures: Array = [ + { + lang: 'php', + normalized: 'php', + code: ' token.value).join('')).toBe(code) + const start = code.indexOf(text) + expect(start).toBeGreaterThanOrEqual(0) + let offset = 0 + return result.tokens.flatMap((token) => { + const from = offset + offset += token.value.length + return from < start + text.length && offset > start ? [token.className] : [] + }) +} + +describe('PHP documentation syntax', () => { + it('highlights ordinary PHP excerpts without tags', () => { + const code = '$answer = 42; function greet(string $name): string { return "Hi " . $name; }' + expect(classes(code, '$answer')).toEqual(['variable']) + expect(classes(code, '42')).toEqual(['number']) + expect(classes(code, 'greet')).toEqual(['function']) + expect(classes(code, 'string')).toEqual(['type']) + }) + + it('delegates HTML only when it is registered', () => { + const code = '

' + expect(classes(code, '$name')).toEqual(['variable']) + expect(classes(code, ' token.value).join('')).toBe(code) + }) + + it('keeps closing tags inside quoted strings and block comments', () => { + const code = '"; /* ?> */ echo true; ?>' + expect(classes(code, '"https://example.com/?>"')).toEqual(['string']) + expect(classes(code, '/* ?> */')).toEqual(['comment']) + expect(classes(code, 'echo')).toEqual(['keyword']) + expect(classes(code, 'true')).toEqual(['literal']) + }) + + it('ends line comments at a closing tag', () => { + for (const comment of ['// comment', '# comment']) { + const code = `

Visible

` + expect(classes(code, comment)).toEqual(['comment']) + expect(classes(code, '?>')).toEqual(['meta']) + expect(classes(code, 'Visible')).not.toContain('comment') + expect(highlighter.highlight(code, { lang: 'php' }).html).toContain('th-tag') + } + }) + + it('distinguishes attributes from hash comments', () => { + const code = ' { + const strings = ['"Hello {$user["name"]}!"', '"Hello {$user[\'name\']}!"', '"escaped \\${value} and $name"', '"nested {$values[0][\'key\']}"'] + for (const string of strings) { + const code = ` { + for (const header of ['<< # text $name\r\n END` + const code = ` { + const code = ' { + for (const number of ['1_000', '0xFF', '0b1010', '0o755', '.5', '1.5e-2', '1.']) { + expect(classes(`$n = ${number};`, number)).toEqual(['number']) + } + const code = '$value = $user?->name ?? NULL; RETURN $value;' + expect(classes(code, '?->')).toEqual(['operator']) + expect(classes(code, 'name')).toEqual(['property']) + expect(classes(code, 'NULL')).toEqual(['literal']) + expect(classes(code, 'RETURN')).toEqual(['keyword']) + }) + + it('keeps keyword-named members out of keyword matching', () => { + const code = '$object->match(); $object->readonly; Service::list();' + expect(classes(code, 'match')).toEqual(['function']) + expect(classes(code, 'readonly')).toEqual(['property']) + expect(classes(code, 'list')).toEqual(['function']) + }) + + it('does not need a closing tag and leaves an unclosed string intact', () => { + expect(classes(' -const languagesWithoutRealDocFixtures = new Set(['go']) +const languagesWithoutRealDocFixtures = new Set(['go', 'php']) describe('real TanStack docs fixtures', () => { it('covers normalized language targets available in TanStack docs', () => {