Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/php-language.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Gruvbox Dark, Gr

## Languages

`apache`, `cmake`, `cpp`, `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`, `cmake`, `cpp`, `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/<name>`. 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.

Expand All @@ -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 29 languages | 26.78 KB | 9.62 KB | 8.67 KB |
| All 30 languages | 30.08 KB | 10.61 KB | 9.56 KB |

On 80 real JavaScript/TypeScript/JSX/TSX TanStack docs fixtures repeated across 5,040 blocks, using the median of three runs after warmup:

Expand Down
15 changes: 15 additions & 0 deletions docs/guides/embedded-languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ EJS regions delegate to JavaScript when available:
<% } %>
```

## PHP

Register `php` to highlight PHP code, including tagless excerpts. Inputs containing `<?php` or `<?=` are treated as documents, with text outside the PHP tags delegated to `html` only when it is registered. Legacy `<?` short tags aren't recognized.

```ts
import { createHighlighter } from '@tanstack/highlight/core'
import { html } from '@tanstack/highlight/languages/html'
import { php } from '@tanstack/highlight/languages/php'

const highlighter = createHighlighter({ languages: [php, html] })
const result = highlighter.highlight('<p><?= $name ?></p>', { 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:
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ CI measures selective browser bundles and highlighting performance on real docum

## Bundle profiles

`pnpm run size` builds sixteen 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 seventeen 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 |
| --- | --- | ---: | ---: |
| 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 29 definitions | 9.62 KB | 9.8 KB |
| All | All 30 definitions | 10.61 KB | 10.8 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.

Expand All @@ -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, long decorated blocks, and dedicated C++ and CMake 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.
`pnpm run bench` measures tokenization, HTML, Markdown, HAST, line numbers, long decorated blocks, and dedicated C++, CMake, and 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:

Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const highlighter = createHighlighter({
})
```

The root entry is useful for prototypes, server-only scripts, or sites where the roughly 10 KB gzip all-language build is acceptable:
The root entry is useful for prototypes, server-only scripts, or sites where the roughly 11 KB gzip all-language build is acceptable:

```ts
import { highlight } from '@tanstack/highlight'
Expand Down
1 change: 1 addition & 0 deletions docs/language-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/default-entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Returns the canonical registered language name for a name or alias. Names are tr
function listLanguages(): Array<HighlightLanguage>
```

Returns the 29 canonical language names registered in `defaultHighlighter`.
Returns the 30 canonical language names registered in `defaultHighlighter`.

### `tokenize`

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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` |
Expand All @@ -51,6 +52,6 @@ const highlighter = createHighlighter({
| `vue` | `@tanstack/highlight/languages/vue` | None |
| `yaml` | `@tanstack/highlight/languages/yaml` | `yml` |

`@tanstack/highlight/languages` re-exports `apache`, `cmake`, `cpp`, `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`, `cmake`, `cpp`, `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.
8 changes: 4 additions & 4 deletions docs/test-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The suite protects the package's actual product boundary: valid code commonly pu

## Contracts

- Every supported language has committed code from real TanStack Markdown or MDX files.
- Every supported language has representative fixtures. Languages available in TanStack Markdown or MDX files also have committed samples from those docs.
- The corpus contains up to twenty samples per language and currently totals 334 blocks.
- Every token stream reconstructs its source byte for byte.
- Focused regressions cover context-sensitive failures such as TSX generics, nested template interpolation, regular expressions, Python triple strings, shell heredocs, YAML fragments and block scalars, and markup embeddings.
Expand All @@ -19,21 +19,21 @@ The suite protects the package's actual product boundary: valid code commonly pu

## Size Profiles

`pnpm run size` checks sixteen 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 seventeen independent browser profiles, including root helpers, language barrel imports, adapters, and themes. Each has minified, gzip, and Brotli budgets. The main highlighter profiles are:

| 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 29 definitions | 9.8 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.

## Throughput

`pnpm run bench` measures seven profiles: highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, and long decorated blocks. Timings use the median of three samples after two warmup passes. Each profile has a 1.2 second CI budget; the main highlighting profile processes at least 10,000 blocks.
`pnpm run bench` measures highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, long decorated blocks, and dedicated C++, CMake, and PHP samples. Timings use the median of three samples after two warmup passes. Each profile has a 1.2 second CI budget; the main highlighting profile processes at least 10,000 blocks.

`pnpm run compare:sugar-high` compares the overlapping JS/TS/JSX/TSX use case. `pnpm run compare:shiki` compares all supported fixtures. These are directional measurements, not claims of equivalent grammar depth.

Expand Down
6 changes: 6 additions & 0 deletions scripts/bench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ try {
observe: (result) => result.children[0].children.length,
targetBlocks: 5_000,
},
php: {
fixtures: [{ rawLang: 'php', code: '<p><?= $name ?></p><?php #[Route("/hello")] function greet(string $name): string { return "Hi {$name}"; }\n$text = <<<END\nraw ?> # 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 }),
Expand Down
1 change: 1 addition & 0 deletions scripts/language-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const supportedLanguages = [
'markdown',
'mermaid',
'nginx',
'php',
'plaintext',
'python',
'scheme',
Expand Down
11 changes: 10 additions & 1 deletion scripts/measure-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ 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 },
},
cpp: {
source: `
import { createHighlighter } from './src/core.ts'
Expand All @@ -95,7 +104,7 @@ const profiles = {
globalThis.highlighter = defaultHighlighter
`,
languages: 'all',
limits: { minified: 27_000, gzip: 9_800, brotli: 8_900 },
limits: { minified: 30_500, gzip: 10_800, brotli: 9_800 },
},
reactAdapter: {
source: `export * from './src/react.ts'`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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` |
Expand All @@ -42,6 +43,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.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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'
Expand Down Expand Up @@ -53,6 +54,7 @@ export type HighlightLanguage =
| 'markdown'
| 'mermaid'
| 'nginx'
| 'php'
| 'plaintext'
| 'python'
| 'scheme'
Expand Down Expand Up @@ -124,6 +126,7 @@ export const allLanguages = [
markdown,
mermaid,
nginx,
php,
plaintext,
python,
scheme,
Expand Down
1 change: 1 addition & 0 deletions src/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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'
Expand Down
125 changes: 125 additions & 0 deletions src/languages/php.ts
Original file line number Diff line number Diff line change
@@ -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<typeof collectPatternRanges>[1]

export const php = defineLanguage({
name: 'php',
tokenize(code, context) {
const ranges: Array<TokenRange> = []
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<TokenRange> = []
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
}
Loading
Loading