Skip to content
Open
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/powershell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/highlight": minor
---

Improve PowerShell highlighting for strings, variables, operators, parameters, and type literals.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Local browser bundles, minified with esbuild and compressed independently. KB us
| Core + TSX | 10.34 KB | 4.39 KB | 4.01 KB |
| Octane MDX + TypeScript | 14.09 KB | 5.72 KB | 5.23 KB |
| Nine-language docs set | 16.27 KB | 6.31 KB | 5.77 KB |
| All 79 languages | 82.09 KB | 27.75 KB | 24.61 KB |
| All 79 languages | 84.52 KB | 28.58 KB | 25.38 KB |

Ordinary HTML blocks skip the intermediate render tree and only escape text when needed. A local comparison of identical output measured 20% less time for the docs corpus in Node and 27% less in Bun, adding 73 gzip bytes to core and 67 to the TSX bundle. [Measurements and reproduction](reports/language-expansion/README.md).

Expand Down
1 change: 1 addition & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
{ "label": "SSR and Client Rendering", "to": "guides/ssr-and-client" },
{ "label": "React Integration", "to": "guides/react" },
{ "label": "Octane Integration", "to": "guides/octane" },
{ "label": "PowerShell Showcase", "to": "guides/powershell" },
{ "label": "Custom Languages", "to": "guides/custom-languages" },
{ "label": "Bundle Size and Performance", "to": "guides/performance" }
]
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ CI measures selective browser bundles and highlighting performance on real docum
| TSX | TSX | 4.39 KB | 4.45 KB |
| Octane | TypeScript plus Octane MDX adapter | 5.72 KB | 5.80 KB |
| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.31 KB | 6.40 KB |
| All | All 79 definitions | 27.75 KB | 28.00 KB |
| All | All 79 definitions | 28.58 KB | 28.80 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, the 40 additional language fixtures, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, Rust, and Swift 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, the 40 additional language fixtures, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, PowerShell, Ruby, Rust, and Swift 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.

The 40 added definitions each cost 2.8 to 3.8 KB gzip including core. Each stays below a 4 KB gzip budget. Every definition is tested through both its direct subpath and a named barrel import, and unrelated language code must be absent from the resulting bundle.

Expand Down
83 changes: 83 additions & 0 deletions docs/guides/powershell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: PowerShell Showcase
---

# PowerShell Showcase

Register PowerShell directly for report and automation examples. The `pwsh`, `ps1`, and `ps` aliases normalize to `powershell` when it is registered.

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

const highlighter = createHighlighter({ languages: [powershell] })
const result = highlighter.highlight('Get-Item $HOME', { lang: 'pwsh' })
```

## A report pipeline

An advanced function combines attributes, splatting, scoped variables, typed records, here-strings, null coalescing, and case-insensitive word operators. The API URL is a placeholder.

```powershell
#Requires -Version 7.4
<# Build typed records, then shape a pipeline for display.
Quotes and $variables in this comment stay quiet. #>
function Get-StationReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('coast', 'ridge')]
[string[]] $Station
)

begin {
$script:Endpoint = $env:STATION_API ?? 'https://example.com'
$headers = @{ Accept = 'application/json' }
$budget = 64MB
}
process {
foreach ($name in $Station) {
$request = @{
Uri = "$script:Endpoint/observations/$name"
Headers = $headers
ErrorAction = 'Stop'
}
try {
$reading = Invoke-RestMethod @request
[pscustomobject]@{
Station = $name
Celsius = [double] $reading.celsius
Online = $true
}
}
catch {
Write-Warning -Message "Station $name unavailable: $_"
}
}
}
end {
$banner = @"
Forecast ready
Budget: $budget
"@
$literal = @'
$HOME is literal; # this is text, not a comment.
'@
Write-Verbose -Message ($banner + $literal)
}
}

${report-title} = 'Today''s observatory'
Get-StationReport -Station coast, ridge |
Where-Object { $_.Online -and $_.Celsius -ge 0 } |
Sort-Object -Property Celsius -Descending |
Select-Object -First 5 -Property Station, Celsius
```

## Preview and scope

Run `pnpm run report:compare` and open `artifacts/shiki-comparison.html`. This showcase appears first in GitHub Light and Aurora X alongside Shiki's reference. Its canonical source is `test/showcases/Get-StationReport.ps1`.

Interpolation remains inside the string token. Full PowerShell command/argument disambiguation and symbol resolution are outside the lightweight tokenizer's scope.

Lexical rules follow PowerShell's [quoting rules](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules) and [language specification](https://learn.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-02).
2 changes: 1 addition & 1 deletion docs/language-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Every language is an isolated definition imported from `@tanstack/highlight/lang
| Perl | `perl` | `pl` | Sigil and special variables, quote-like operators with nested delimiters, regex vs division, heredocs, POD blocks |
| PHP | `php` | - | PHP tags, attributes, quoted strings, heredoc/nowdoc, optional HTML delegation |
| Plaintext | `plaintext` | `text`, `txt`, `-->` | Escaping only |
| PowerShell | `powershell` | `pwsh`, `ps1`, `ps` | Here-strings, variables, commands, block comments |
| PowerShell | `powershell` | `pwsh`, `ps1`, `ps` | Quoted and here-strings, nested expandable expressions, scoped/braced/splat variables, cmdlets, parameters, type literals, case-insensitive keywords and operators |
| Protocol Buffers | `protobuf` | `proto` | Message and service declarations, scalar types, comments |
| Python | `python` | `py` | Triple strings, prefixes, decorators, comments |
| R | `r` | None | Strings, backtick names, literals, comments |
Expand Down
4 changes: 2 additions & 2 deletions docs/test-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ The suite protects the package's actual product boundary: valid code commonly pu
| TSX | TSX | 4.45 KB |
| Octane | TypeScript plus Octane MDX adapter | 5.80 KB |
| Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.40 KB |
| All | All 79 definitions | 28.00 KB |
| All | All 79 definitions | 28.80 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 check every language through its direct export and a named barrel import after building. Each of the 40 new lexical definitions has its own size profile, representative sample, source reconstruction checks, and truncated-input checks.

## Throughput

`pnpm run bench` measures highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, long decorated blocks, the 40 additional language fixtures, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, Rust, and Swift 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 bench` measures highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, long decorated blocks, the 40 additional language fixtures, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, PowerShell, Ruby, Rust, and Swift 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 @@ -56,6 +56,12 @@ try {
outputBytes: htmlBytes,
targetBlocks: 10_000,
},
powershell: {
fixtures: [{ rawLang: 'powershell', code: fs.readFileSync('test/showcases/Get-StationReport.ps1', 'utf8') }],
run: (fixture) => highlight(fixture.code, { lang: fixture.rawLang }),
observe: (result) => result.html.length,
targetBlocks: 2_000,
},
cpp: {
fixtures: [{ rawLang: 'cpp', code: '#include <vector>\nconstexpr auto text = R"tag(// raw text)tag";\nint main() { std::vector<int> values{1, 2, 3}; return values.size(); }' }],
run: (fixture) => highlight(fixture.code, { lang: fixture.rawLang }),
Expand Down
19 changes: 17 additions & 2 deletions scripts/generate-visual-compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ function normalizeShikiLanguage(lang) {
'js-vue': 'javascript',
jsonc: 'json',
md: 'markdown',
pwsh: 'powershell',
ps1: 'powershell',
sh: 'bash',
shell: 'bash',
text: 'plaintext',
Expand All @@ -96,8 +98,14 @@ function normalizeShikiLanguage(lang) {
}

function selectFixtures(fixtures) {
const selected = []
const seen = new Set()
const selected = [{
lang: 'powershell',
rawLang: 'powershell',
file: 'test/showcases/Get-StationReport.ps1',
line: 1,
code: fs.readFileSync('test/showcases/Get-StationReport.ps1', 'utf8'),
}]
const seen = new Set(['powershell'])

for (const fixture of fixtures) {
if (seen.has(fixture.lang)) continue
Expand Down Expand Up @@ -208,6 +216,13 @@ function buildHtml(samples) {
background: #0d1117;
color: #e6edf3;
}
.dark .shiki, .dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
.missing-shiki {
border: 1px dashed var(--panel-border);
color: var(--muted);
Expand Down
3 changes: 3 additions & 0 deletions scripts/language-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const supportedLanguages = [
'perl',
'php',
'plaintext',
'powershell',
'python',
'ruby',
'rust',
Expand Down Expand Up @@ -54,6 +55,8 @@ const aliases = {
kt: 'kotlin',
kts: 'kotlin',
pl: 'perl',
pwsh: 'powershell',
ps1: 'powershell',
rb: 'ruby',
rs: 'rust',
'-->': 'plaintext',
Expand Down
2 changes: 1 addition & 1 deletion scripts/measure-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ const profiles = {
globalThis.highlighter = defaultHighlighter
`,
languages: 'all',
limits: { minified: 83_000, gzip: 28_000, brotli: 25_000 },
limits: { minified: 85_000, gzip: 28_800, brotli: 25_600 },
},
reactAdapter: {
source: `export * from './src/react.ts'`,
Expand Down
101 changes: 91 additions & 10 deletions src/languages/powershell.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,93 @@
// Vocabulary adapted from Highlight.js 11.11.1, see NOTICE.md.
import { simpleLanguage } from '../internal/simple-language.js'
import { defineLanguage, type TokenRange } from '../core.js'
import { patternTokenizer } from '../internal/patterns.js'

export const powershell = /* @__PURE__ */ simpleLanguage({
name: "powershell",
aliases: ["pwsh", "ps1", "ps"],
comments: /<#[\s\S]*?(?:#>|(?![\s\S]))|#[^\r\n]*/,
strings: /@"\r?\n[\s\S]*?^"@|@\'\r?\n[\s\S]*?^\'@|"(?:`[\s\S]|[^"`])*(?:"|(?![\s\S]))|\'(?:\'\'|[^\'])*(?:\'|(?![\s\S]))/,
keywords: "function class enum using configuration if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",
insensitive: true,
beforeWords: [{ className: 'command', regex: /\b[A-Za-z]+-[A-Za-z][\w-]*/g }, { className: 'literal', regex: /\$(?:true|false|null)\b/gi }, { className: 'variable', regex: /\$(?:\{[^}]*\}|[\w:?]+)/g }],
export const powershell = defineLanguage({
name: 'powershell',
aliases: ['pwsh', 'ps1', 'ps'],
tokenize: patternTokenizer([
{ collect: collectPowerShellLexicalRanges },
{ className: 'literal', regex: /\$(?:true|false|null)\b/gi },
{ className: 'variable', regex: /[$@](?:[\p{L}\p{N}_?]+:)?[\p{L}\p{N}_?]+|\$[$^]/gu },
{ className: 'type', regex: /\[(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:\[\])?\]/g },
{ className: 'keyword', regex: /(?<![\w.-])(?:begin|break|catch|class|clean|continue|data|do|dynamicparam|else|elseif|end|enum|exit|filter|finally|for|foreach|function|if|in|param|process|return|switch|throw|trap|try|until|using|while|workflow|parallel|sequence|inlinescript)(?![\w-])/gi },
{ className: 'function', regex: /\b(?:function|filter|class|enum)\s+([A-Za-z_][\w-]*)/gi, group: 1 },
{ className: 'command', regex: /(?<![\w-])[A-Za-z]+-[A-Za-z][\w-]*\b/g },
{ className: 'operator', regex: /-(?:(?:[ci])?(?:eq|ne|gt|ge|lt|le|like|notlike|match|notmatch|contains|notcontains|in|notin|replace|split)|and|or|xor|not|band|bor|bxor|bnot|shl|shr|join|isnot|is|as|f)\b/gi },
{ className: 'property', regex: /(?<![\w-])-[A-Za-z][\w-]*\b/g },
{ className: 'number', regex: /(?:(?<![\w.])|(?<=\.\.))(?:0[xX][\da-fA-F]+|0[bB][01]+|(?:\d+(?:\.(?!\.)\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:u?[ylsn]|ul|us|uy|d)?(?:kb|mb|gb|tb|pb)?(?!\w)(?!\.(?!\.))/gi },
{ className: 'function', regex: /\b[A-Za-z_]\w*(?=\s*\()/g },
{ className: 'property', regex: /\.([A-Za-z_]\w*)/g, group: 1 },
{ className: 'operator', regex: /\?\?=?|\?\.|::|\.\.|\+\+|--|&&|\|\||[+\-*/%=!<>|&]=?/g },
]),
})

function collectPowerShellLexicalRanges(code: string) {
const ranges: Array<TokenRange> = []
for (let index = 0; index < code.length;) {
const start = index
let end = index
let className: TokenRange['className'] = 'string'
if (code[index] === '`') end = Math.min(index + 2, code.length)
else if (code.startsWith('${', index)) {
end = index + 2
while (end < code.length && code[end] !== '}') {
end += code[end] === '`' ? 2 : 1
}
end = Math.min(end + 1, code.length)
className = 'variable'
} else if (code.startsWith('<#', index)) {
const close = code.indexOf('#>', index + 2)
end = close < 0 ? code.length : close + 2
className = 'comment'
} else if (code[index] === '#' && (index === 0 || /[\s(){}\[\];'"|&]/.test(code[index - 1]))) {
end = index + 1
while (end < code.length && !/[\r\n]/.test(code[end])) end++
className = 'comment'
} else if (code[index] === '@' && /['"]/.test(code[index + 1] || '') && /^[ \t]*(?:\r\n|\r|\n)/.test(code.slice(index + 2))) {
const quote = code[index + 1]
const close = new RegExp(`^${quote}@`, 'gm')
close.lastIndex = index + 2
const match = close.exec(code)
end = match ? match.index + 2 : code.length
} else if (code[index] === '"' || code[index] === "'") {
end = quotedEnd(code, index)
}
if (end > start) {
ranges.push({ start, end, className })
index = end
} else index++
}
return ranges
}

// Keep nested quoted strings in expandable-string subexpressions protected.
function quotedEnd(code: string, start: number, depth = 0): number {
const quote = code[start]
let index = start + 1
while (index < code.length) {
if (quote === '"' && code[index] === '`') index += 2
else if (code[index] === quote) {
index++
if (code[index] === quote) index++
else return index
} else if (quote === '"' && code.startsWith('$(', index) && depth < 24) {
let balance = 1
index += 2
while (index < code.length && balance) {
if (code.startsWith('<#', index)) {
const close = code.indexOf('#>', index + 2)
index = close < 0 ? code.length : close + 2
} else if (code[index] === '#' && /[\s(){}\[\];'"|&]/.test(code[index - 1])) {
while (index < code.length && !/[\r\n]/.test(code[index])) index++
} else if (code[index] === '`') index += 2
else if (code[index] === '"' || code[index] === "'") index = quotedEnd(code, index, depth + 1)
else {
if (code[index] === '(') balance++
if (code[index] === ')') balance--
index++
}
}
} else index++
}
return Math.min(index, code.length)
}
6 changes: 6 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import additionalFixtures from './generated/additional-language-fixtures.json'
import type { HighlightLanguage } from '../src/index'

Expand All @@ -9,6 +10,11 @@ export type LanguageFixture = {
}

export const languageFixtures: Array<LanguageFixture> = [
{
lang: 'pwsh', normalized: 'powershell',
code: readFileSync(new URL('./showcases/Get-StationReport.ps1', import.meta.url), 'utf8'),
expectedClasses: ['th-keyword', 'th-type', 'th-variable', 'th-string', 'th-comment', 'th-literal', 'th-command', 'th-number', 'th-function', 'th-operator', 'th-property'],
},
...additionalFixtures.map((fixture) => ({
lang: fixture.name,
normalized: fixture.name as HighlightLanguage,
Expand Down
Loading