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
7 changes: 7 additions & 0 deletions .changeset/decorator-highlighting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@vortexlabs/kirari": patch
---

Highlight JavaScript, TypeScript, and Python decorator markers as operators and bare decorator names as functions, while preserving qualified names and Python matrix multiplication.

Reduce bundle overhead by consolidating function lookahead, simplifying equivalent JavaScript regexes, and sharing HTML escape replacements.
42 changes: 42 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
inheritance: true
language: "en-US"
early_access: false
tone_instructions: "Review Kirari as a TypeScript syntax-highlighting library. Keep feedback concise and actionable, prioritizing correctness, streaming consistency, public API compatibility, and bundle size."
reviews:
profile: "assertive"
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
collapse_walkthrough: false
auto_review:
enabled: true
drafts: false
base_branches: ["main"]
auto_pause_after_reviewed_commits: 0
path_filters:
- "!**/.zed/**"
path_instructions:
- path: "packages/kirari/src/**/*.ts"
instructions: |
Review Kirari's tokenizer, grammars, renderer, and streaming API. Ensure that:
- Preserve exact source text, contiguous UTF-16 token offsets, custom token categories, and HTML escaping.
- Keep streamed patches equivalent to full tokenization across chunk boundaries, incomplete source, and reset/end calls.
- Preserve safe resume checkpoints and lookahead caches; check adversarial inputs for repeated rescanning.
- Preserve public exports, language coverage, tree shaking, and dependency-free browser/server support.
- Verify bundle-size claims with `bun run size` and keep existing size budgets.
- path: "packages/react/src/**/*.{ts,tsx}"
instructions: |
Review the React rendering and streaming integration. Ensure that:
- Keep static Code rendering compatible with Server Components and hooks in the client entry point.
- Reuse core tokenization and decoration logic, and render source as React text children.
- Preserve token-patch application, language-change resets, and protection against stale stream callbacks.
- path: "packages/rehype/src/**/*.ts"
instructions: |
Review the Rehype integration and code-block metadata handling. Ensure that:
- Preserve source text and existing pre/code properties when transforming HAST nodes.
- Keep code content as text nodes and attributes as HAST properties for safe serialization.
- Preserve language aliases, unknown-language fallback, metadata parsing, and line decorations.
chat:
auto_reply: true
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ TypeScript apps. It turns source code into escaped HTML or a token array on the
server, at build time, or in the browser, without a DOM. It is a lexical
highlighter, not a parser.

- **Small:** about 4.3 KB gzipped with JavaScript, about 23 KB with all 48 languages.
- **Small:** about 4.2 KB gzipped with JavaScript, about 23 KB with all 48 languages.
- **Streaming:** emits token patches as code arrives, such as LLM output.
- **Themeable:** CSS-variable themes, with 21 built in.
- **Integrations:** a React `<Code>` component that works in Server Components,
Expand Down
8 changes: 4 additions & 4 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@ bun packages/benchmarks/compare-size.ts "$comparison_dir"
bun run size
```

Measured on 2026-10-21:
Competitors measured on 2026-10-21; Kirari working tree measured on 2026-10-22:

| Setup | Gzip bytes |
| --- | ---: |
| Kirari working tree, `highlight()` wrapper | 4,190 |
| Kirari working tree, `highlight()` wrapper | 4,146 |
| Shiki 4.5.0, synchronous core, JavaScript regex engine, JavaScript grammar, GitHub Dark | 73,530 |
| Prism 1.30.0, core, clike dependency, JavaScript grammar | 5,278 |
| highlight.js 11.12.0, core, JavaScript grammar | 11,056 |

The script validates that each bundled function produces highlighted HTML and
preserves an escaped source sample. It does not measure speed or grammar accuracy.

Kirari's full JavaScript entry is 4,292 bytes gzip; its full set of 48 language
definitions with core is 22,900 bytes. These come from `bun run size`. The wrapper
Kirari's full JavaScript entry is 4,247 bytes gzip; its full set of 48 language
definitions with core is 22,960 bytes. These come from `bun run size`. The wrapper
above is smaller because unused exports can be removed. Kirari measurements use
the current source, including unreleased changes; competitors use the named npm
versions. Rerun both commands when the source or bundler changes.
Expand Down
10 changes: 5 additions & 5 deletions packages/kirari/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A small, dependency-free syntax highlighter. Kirari turns source code into
escaped HTML or a token array on the server, at build time, or in the browser,
without a DOM. The default bundle is about 4.3 KB gzipped; 48 language
without a DOM. The default bundle is about 4.2 KB gzipped; 48 language
definitions and 21 CSS-variable themes are available as separate imports.
It is a lexical highlighter, not a parser.

Expand Down Expand Up @@ -329,13 +329,13 @@ The core fixture includes an exported API object; the all-language fixture also
retains a language registry so that no language gets removed as unused. Theme
CSS is excluded. These are bundle sizes, not throughput benchmarks.

Measured on 2026-10-17 using Bun 1.3.11:
Measured on 2026-10-22 using Bun 1.3.4:

| Entry | Minified bytes | Gzip bytes |
| --- | ---: | ---: |
| Core API | 3,439 | 1,717 |
| JavaScript default | 4,648 | 2,240 |
| All 48 languages | 51,964 | 16,044 |
| Core API | 8,505 | 3,728 |
| JavaScript default | 9,678 | 4,247 |
| All 48 languages | 68,465 | 22,960 |

## Development

Expand Down
5 changes: 4 additions & 1 deletion packages/kirari/src/grammars/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,10 @@ export const classifyDefinitions = (tokens: Token[], language: string, open: rea
const qualified = followsDeclaration && /^(\.|:)$/.test(value(qualifierEnd + 1))
if (qualified) qualifiers.add(qualifierEnd)
const declared = followsDeclaration && !qualified
let isFunction = declared
// Decorators start a logical line outside brackets; matrix multiplication does not.
const decorated = language === 'python' && value(index - 1) === '@' && value(index + 1) !== '.' && !lineBreakBefore(index)
&& !parents.has(index - 1) && (index === 1 || lineBreakBefore(index - 1))
let isFunction = declared || decorated
const member = /^(\.|\?\.|->|\?->|::)$/.test(value(index - 1))
let next = index + 1
if (!declared && token.type !== 'variable' && token.type !== 'property') continue
Expand Down
137 changes: 72 additions & 65 deletions packages/kirari/src/grammars/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ export interface FunctionLookupCache {
lastLine?: number
}
export interface LookupOptions {
/** The name follows `const`, `let`, or `var` in TypeScript, so `:` starts a type annotation. */
readonly annotation: boolean
/** A colon after the name may introduce a property value or a typed binding. */
readonly colon: boolean
/** The name may take type parameters, as after `function` or in a method position. */
readonly generic: boolean
/** The name is an object or class member. */
readonly property: boolean
/** The name is a bare `async` that may be an arrow-function modifier. */
readonly asyncModifier: boolean
/** The name follows `@`; a member-access suffix instead makes it a namespace. */
readonly decorator: boolean
}

const closers: Record<string, string> = { '(': ')', '[': ']', '{': '}', '${': '}', '<': '>' }
Expand All @@ -32,16 +32,15 @@ const closers: Record<string, string> = { '(': ')', '[': ']', '{': '}', '${': '}
export const createFunctionLookup = (source: string, typed: boolean, scan: (start: number) => Generator<Token, void, number | undefined>, cache: FunctionLookupCache = {}) => {
// Reuse nested groups, including unfinished ones, instead of rescanning each signature suffix.
const groups = new Map<number, Group>()
return (start: number, { annotation, generic, property, asyncModifier }: LookupOptions): Result => {
return (start: number, { colon: acceptsColon, generic, asyncModifier, decorator }: LookupOptions): Result => {
let reader = scan(start)
let token: Token | undefined
let limit = source.length
let pending = false
// Wraps an answer, marking it pending when it relied on reaching the end of the source.
const result = (value: boolean): Result => ({ function: value, pending: pending || limit === source.length && (!token || token.end === limit && /^[=/]$/.test(token.value)) })
const is = (value: string): boolean => token?.value === value
// Advances `token` to the next significant token before `limit`.
const next = (): void => {
// Advances `token`, optionally restarting at a source offset.
const next = (position?: number): void => {
if (position !== undefined) reader = scan(position)
do {
const value = reader.next()
token = value.done || value.value.start >= limit ? undefined : value.value
Expand All @@ -52,13 +51,11 @@ export const createFunctionLookup = (source: string, typed: boolean, scan: (star
}
} while (token && !isSignificant(token.type))
}
// Restarts scanning at `position`.
const jump = (position: number): void => { reader = scan(position); next() }
// Consumes the bracket group opening at `token`, including `<…>` and `>>` splits; results are cached.
const group = (): Group => {
const opening = token!
const cached = groups.get(opening.start)
if (cached) { jump(cached.end); return cached }
if (cached) { next(cached.end); return cached }
const angles = opening.value === '<'
const stack: { token: Token; close: string }[] = []
while (token) {
Expand All @@ -76,7 +73,7 @@ export const createFunctionLookup = (source: string, typed: boolean, scan: (star
}
const closed = { end: token.start + index + 1, complete: true }
groups.set(frame.token.start, closed)
if (!stack.length) { jump(closed.end); return closed }
if (!stack.length) { next(closed.end); return closed }
}
}
next()
Expand Down Expand Up @@ -114,64 +111,74 @@ export const createFunctionLookup = (source: string, typed: boolean, scan: (star
return false
}

next()
// Optional or definite members: `name?()`, `name!: T`.
if (typed && generic && token && /^[?!]$/.test(token.value)) next()
// `name(` is a call or definition; for `async (x) => …` the name is a modifier, not a function.
if (is('(')) {
if (!asyncModifier) return result(true)
if (!group().complete) return result(false)
return result(!is('=>') && !(typed && is(':')))
}
if (typed && is('<') && (generic || token!.start === start)) {
if (!group().complete) return result(false)
return result(is('('))
}
// Otherwise the name must be bound: `name = …`, or `name: …` for properties and annotations.
if (!is('=') && !is(':')) return result(false)
const colon = is(':')
if (colon && !annotation && !property) return result(false)
next()
if (typed && (annotation || property) && colon && token && !/^(\(|<|async|function)$/.test(token.value)) {
if (!until('=')) return result(false)
const inspect = (): boolean => {
next()
}

// Inspect the bound value; parenthesized expressions are re-entered with a narrower limit.
while (token) {
if (is('async')) {
next()
if (is('=>')) return result(true)
if (decorator) {
// A trailing `?` or one/two dots may become a different operator in the next chunk.
pending ||= !!token && token.start >= source.length - 2 && (is('.') || is('?'))
return !is('.') && !is('?.')
}
if (is('function')) {
next()
if (is('*')) next()
if (token && /^(variable|keyword|function)$/.test(token.type)) next()
if (typed && is('<') && !group().complete) return result(true)
if (!is('(') || !group().complete) return result(true)
if (typed && is(':')) { next(); if (!body()) return result(true) }
if (!is('{') || !group().complete) return result(true)
return result(continuation())
}
if (typed && is('<') && !group().complete) return result(false)
// Optional or definite members: `name?()`, `name!: T`.
if (typed && generic && token && /^[?!]$/.test(token.value)) next()
// `name(` is a call or definition; for `async (x) => …` the name is a modifier, not a function.
if (is('(')) {
const opening = token
const closed = group()
if (!closed.complete) return result(false)
if (is('=>')) return result(true)
if (typed && is(':')) { next(); return result(until('=>')) }
if (!continuation()) return result(false)
pending ||= !token && limit === source.length
limit = closed.end - 1
jump(opening.end)
continue
if (!asyncModifier) return true
if (!group().complete) return false
return !is('=>') && !(typed && is(':'))
}
if (typed && is('<') && (generic || token!.start === start)) {
if (!group().complete) return false
return is('(')
}
if (token && /^(variable|keyword|function)$/.test(token.type)) {
// Otherwise the name must be bound: `name = …`, or `name: …` for properties and annotations.
if (!is('=') && !is(':')) return false
const colon = is(':')
if (colon && !acceptsColon) return false
next()
if (typed && acceptsColon && colon && token && !/^(\(|<|async|function)$/.test(token.value)) {
if (!until('=')) return false
next()
return result(is('=>'))
}
return result(false)

// Inspect the bound value; parenthesized expressions are re-entered with a narrower limit.
while (token) {
if (is('async')) {
next()
if (is('=>')) return true
}
if (is('function')) {
next()
if (is('*')) next()
if (token && /^(variable|keyword|function)$/.test(token.type)) next()
if (typed && is('<') && !group().complete) return true
if (!is('(') || !group().complete) return true
if (typed && is(':')) { next(); if (!body()) return true }
if (!is('{') || !group().complete) return true
return continuation()
}
if (typed && is('<') && !group().complete) return false
if (is('(')) {
const opening = token
const closed = group()
if (!closed.complete) return false
if (is('=>')) return true
if (typed && is(':')) { next(); return until('=>') }
if (!continuation()) return false
pending ||= !token && limit === source.length
limit = closed.end - 1
next(opening.end)
continue
}
if (token && /^(variable|keyword|function)$/.test(token.type)) {
next()
return is('=>')
}
return false
}
return false
}
return result(false)
const found = inspect()
// EOF-dependent answers remain provisional until more source arrives.
return { function: found, pending: pending || limit === source.length && (!token || token.end === limit && /^[=/]$/.test(token.value)) }
}
}
6 changes: 3 additions & 3 deletions packages/kirari/src/languages/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export const javascript: Language = {
{ type: 'comment', pattern: /^#![^\r\n\u2028\u2029]*/ },
{ type: 'comment', pattern: /\/\/[^\r\n\u2028\u2029]*/ },
{ type: 'comment', pattern: /\/\*[\s\S]*?(?:\*\/|$)/ },
{ type: 'string', pattern: /"(?:\\(?:\r\n|[\s\S]|$)|[^"\\\r\n])*(?:"|(?=[\r\n])|$)|'(?:\\(?:\r\n|[\s\S]|$)|[^'\\\r\n])*(?:'|(?=[\r\n])|$)/ },
{ type: 'number', pattern: /(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[bB][01](?:_?[01])*|0[oO][0-7](?:_?[0-7])*|\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?(?:[eE][+-]?\d(?:_?\d)*)?|\.\d+(?:[eE][+-]?\d+)?)(?:n)?/ },
{ type: 'operator', pattern: /\?\.|=>|\*\*=?|===?|!==?|>>>?=?|<<=?|&&=?|\|\|=?|\?\?=?|\+\+|--|\.\.\.|[+*/%&|^!=<>-]=?|[?~]/ },
{ type: 'string', pattern: /"(?:\\(?:\r\n|[\s\S]|$)|[^"\\\r\n])*"?|'(?:\\(?:\r\n|[\s\S]|$)|[^'\\\r\n])*'?/ },
{ type: 'number', pattern: /(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[bB][01](?:_?[01])*|0[oO][0-7](?:_?[0-7])*|\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?(?:[eE][+-]?\d(?:_?\d)*)?|\.\d+(?:[eE][+-]?\d+)?)n?/ },
{ type: 'operator', pattern: /\?\.|=>|\*\*=?|===?|!==?|>>>?=?|<<=?|&&=?|\|\|=?|\?\?=?|\+\+|--|\.\.\.|[+*/%&|^!=<>-]=?|[?~@]/ },
{ type: 'punctuation', pattern: /[{}()[\],;:.]/ },
],
}
7 changes: 5 additions & 2 deletions packages/kirari/src/languages/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const scanLanguage = (language: Language): Language => {
return builtIn
}

// Resume points are whitespace tokens containing a newline, outside f-strings, with more source after them.
// Resume points are whitespace at unescaped line breaks, outside f-strings, with more source after them.
const scanPython: ResumableScan = (source, language, from, checkpoint) => {
const tokens: Token[] = []
const frames: PythonFrame[] = [{ kind: 'expression', depth: 0, root: true }]
Expand Down Expand Up @@ -105,7 +105,10 @@ const scanPython: ResumableScan = (source, language, from, checkpoint) => {
if (checkpoint && frames.length === 1 && token.type === 'text' && /[\r\n]/.test(token.value) && token.end < source.length) {
const last = tokens[tokens.length - 1]
const merged = last?.type === 'text'
checkpoint({ offset: merged ? last.start : position, previous }, merged ? tokens.length - 1 : tokens.length)
// A backslash in source text continues the line; one inside a comment does not.
if (!merged || !last.value.endsWith('\\')) {
checkpoint({ offset: merged ? last.start : position, previous }, merged ? tokens.length - 1 : tokens.length)
}
}
const opening = token.type === fstringOpening.type && /^(f|fr|rf)("""|'''|"|')$/i.exec(token.value)
if (opening) {
Expand Down
14 changes: 4 additions & 10 deletions packages/kirari/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import type { RenderOptions, Token } from './types.js'

export const escape = (value: string): string => {
return value.replace(/[&<>"']/g, char => {
switch (char) {
case '&': return '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
default: return '&#39;'
}
})
const entities: Readonly<Record<string, string>> = {
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}

export const escape = (value: string): string => value.replace(/[&<>"']/g, char => entities[char]!)

/** Extra classes for a token from the `classes` map and the `className` callback. */
export const tokenClasses = (token: Token, options: RenderOptions): string => {
const mapped = options.classes && Object.hasOwn(options.classes, token.type)
Expand Down
4 changes: 2 additions & 2 deletions packages/kirari/src/tokenize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,10 @@ export function* scan(source: string, language: Language, start = 0, previous?:
// `pending` means the answer depends on source that has not arrived yet (streaming).
if (lookup && !member && (type === 'variable' || type === 'property' || context.functionName || method || /^(async|get|set|static)$/.test(name))) {
const found = lookup(end, {
annotation: typed && variableKeyword.test(previous?.value ?? ''),
colon: type === 'property' || method || typed && variableKeyword.test(previous?.value ?? ''),
generic: !!context.functionName || method,
property: type === 'property' || method,
asyncModifier: name === 'async' && !context.functionName && !method,
decorator: previous?.value === '@',
})
if (found.function) type = 'function'
if (found.pending) context.functionPending = true
Expand Down
Loading
Loading