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 `= $name ?>
', { 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: '= $name ?>
# 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 = '= $name ?>
'
+ expect(classes(code, '$name')).toEqual(['variable'])
+ expect(classes(code, '=')).toEqual(['meta'])
+ 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', () => {