diff --git a/.vscode/settings.json b/.vscode/settings.json
index 795f401e1..b1360f3d8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -36,5 +36,6 @@
"json",
"jsonc",
"yaml"
- ]
+ ],
+ "references.preferredLocation": "peek"
}
diff --git a/README.md b/README.md
index d8fbe68da..0810ae3a2 100644
--- a/README.md
+++ b/README.md
@@ -432,21 +432,23 @@ console.log(root)
Since `shikiji` uses `hast` internally, you can use the `transforms` option to customize the generated HTML by manipulating the hast tree. You can pass custom functions to modify the tree for different types of nodes. For example:
```js
+import { addClassToHast, codeToHtml } from 'shikiji'
+
const code = await codeToHtml('foo\bar', {
lang: 'js',
theme: 'vitesse-light',
transformers: [
{
code(node) {
- node.properties.class = 'language-js'
+ addClassToHast(node, 'language-js')
},
line(node, line) {
node.properties['data-line'] = line
if ([1, 3, 4].includes(line))
- node.properties.class += ' highlight'
+ addClassToHast(node, 'highlight')
},
token(node, line, col) {
- node.properties.class = `token:${line}:${col}`
+ node.properties['data-token'] = `token:${line}:${col}`
},
},
]
diff --git a/eslint.config.js b/eslint.config.js
index 13a76bb5f..6d32da619 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -4,11 +4,13 @@ export default antfu(
{
ignores: [
'packages/shikiji/src/assets/*.ts',
+ '**/fixtures/**',
],
},
{
rules: {
'no-restricted-syntax': 'off',
+ 'ts/no-invalid-this': 'off',
},
},
)
diff --git a/packages/markdown-it-shikiji/src/index.ts b/packages/markdown-it-shikiji/src/index.ts
index 33cc0fb47..47dbf471b 100644
--- a/packages/markdown-it-shikiji/src/index.ts
+++ b/packages/markdown-it-shikiji/src/index.ts
@@ -1,5 +1,5 @@
import type MarkdownIt from 'markdown-it'
-import { bundledLanguages, getHighlighter } from 'shikiji'
+import { addClassToHast, bundledLanguages, getHighlighter } from 'shikiji'
import type { BuiltinLanguage, BuiltinTheme, CodeOptionsThemes, CodeToHastOptions, Highlighter, LanguageInput } from 'shikiji'
import { parseHighlightLines } from '../../shared/line-highlight'
@@ -45,7 +45,7 @@ function setup(markdownit: MarkdownIt, highlighter: Highlighter, options: Markdo
name: 'markdown-it-shikiji:line-class',
line(node, line) {
if (lines.includes(line))
- node.properties.class += ` ${className}`
+ addClassToHast(node, className)
return node
},
})
diff --git a/packages/rehype-shikiji/src/index.ts b/packages/rehype-shikiji/src/index.ts
index 914e400fb..8873ad7f9 100644
--- a/packages/rehype-shikiji/src/index.ts
+++ b/packages/rehype-shikiji/src/index.ts
@@ -1,5 +1,5 @@
import type { BuiltinLanguage, BuiltinTheme, CodeOptionsThemes, CodeToHastOptions, LanguageInput } from 'shikiji'
-import { bundledLanguages, getHighlighter } from 'shikiji'
+import { addClassToHast, bundledLanguages, getHighlighter } from 'shikiji'
import { toString } from 'hast-util-to-string'
import { visit } from 'unist-util-visit'
import type { Plugin } from 'unified'
@@ -105,7 +105,7 @@ const rehypeShikiji: Plugin<[RehypeShikijiOptions], Root> = function (options =
name: 'rehype-shikiji:line-class',
line(node, line) {
if (lines.includes(line))
- node.properties.class += ` ${className}`
+ addClassToHast(node, className)
return node
},
})
diff --git a/packages/shikiji-compat/package.json b/packages/shikiji-compat/package.json
index 71df297d6..01a496e03 100644
--- a/packages/shikiji-compat/package.json
+++ b/packages/shikiji-compat/package.json
@@ -37,6 +37,7 @@
"prepublishOnly": "nr build"
},
"dependencies": {
- "shikiji": "workspace:*"
+ "shikiji": "workspace:*",
+ "shikiji-transformers": "workspace:*"
}
}
diff --git a/packages/shikiji-compat/src/index.ts b/packages/shikiji-compat/src/index.ts
index c6c477229..2b37a733f 100644
--- a/packages/shikiji-compat/src/index.ts
+++ b/packages/shikiji-compat/src/index.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import fsp from 'node:fs/promises'
import type { BuiltinLanguage, BuiltinTheme, CodeToThemedTokensOptions, MaybeGetter, StringLiteralUnion, ThemeInput, ThemeRegistration, ThemedToken } from 'shikiji'
import { bundledLanguages, bundledThemes, getHighlighter as getShikiji, toShikiTheme } from 'shikiji'
+import { transformerCompactLineOptions } from 'shikiji-transformers'
import type { AnsiToHtmlOptions, CodeToHtmlOptions, CodeToHtmlOptionsExtra, HighlighterOptions } from './types'
export const BUNDLED_LANGUAGES = bundledLanguages
@@ -66,17 +67,7 @@ export async function getHighlighter(options: HighlighterOptions = {}) {
if (options.lineOptions) {
options.transformers ||= []
- options.transformers.push({
- name: 'shikiji-compat:line-class',
- line(node, line) {
- const lineOption = options.lineOptions?.find(o => o.line === line)
- if (lineOption?.classes) {
- node.properties ??= {}
- node.properties.class = [node.properties.class, ...lineOption.classes].filter(Boolean).join(' ')
- }
- return node
- },
- })
+ options.transformers.push(transformerCompactLineOptions(options.lineOptions))
}
return shikiji.codeToHtml(code, options as any)
diff --git a/packages/shikiji-transformers/README.md b/packages/shikiji-transformers/README.md
new file mode 100644
index 000000000..6950329aa
--- /dev/null
+++ b/packages/shikiji-transformers/README.md
@@ -0,0 +1,98 @@
+# shikiji-transformers
+
+Collective of common transformers for [shikiji](https://github.com/antfu/shikiji), inspired by [shiki-processor](https://github.com/innocenzi/shiki-processor).
+
+## Install
+
+```bash
+npm i -D shikiji-transformers
+```
+
+```ts
+import {
+ codeToHtml,
+} from 'shikiji'
+import {
+ transformerNotationDiff,
+ // ...
+} from 'shikiji-transformers'
+
+const html = codeToHtml(code, {
+ lang: 'ts',
+ transformers: [
+ transformerNotationDiff(),
+ // ...
+ ],
+})
+```
+
+## Transformers
+
+### `transformerNotationDiff`
+
+Use `[!code ++]` and `[!code --]` to mark added and removed lines.
+
+For example, the following code
+
+```ts
+export function foo() {
+ console.log('hewwo') // [!code --]
+ console.log('hello') // [!code ++]
+}
+```
+
+will be transformed to
+
+```html
+
+
+
+
+ function(){
+
+ console.log('hewwo')
+
+
+ console.log('hello')
+
+ }
+
+
+
+```
+
+With some CSS, you can make it look like this:
+
+
+
+### `transformerNotationHighlight`
+
+Use `[!code highlight]` to highlight a line (adding `highlighted` class).
+
+### `transformerNotationFocus`
+
+Use `[!code focus]` to focus a line (adding `focused` class).
+
+### `transformerNotationErrorLevel`
+
+Use `[!code error]`, `[!code warning]`, to mark a line with an error level (adding `highlighted error`, `highlighted warning` class).
+
+### `transformerRenderWhitespace`
+
+Render whitespaces (tabs and spaces) as individual spans, with classes `tab` and `space`.
+
+With some CSS, you can make it look like this:
+
+
+
+### `transformerCompactLineOptions`
+
+Support for `shiki`'s `lineOptions` that is removed in `shikiji`.
+
+### `transformerRemoveLineBreak`
+
+Remove line breaks between ``. Useful when you set `display: block` to `.line` in CSS.
+
+## License
+
+MIT
diff --git a/packages/shikiji-transformers/build.config.ts b/packages/shikiji-transformers/build.config.ts
new file mode 100644
index 000000000..2656e4688
--- /dev/null
+++ b/packages/shikiji-transformers/build.config.ts
@@ -0,0 +1,14 @@
+import { defineBuildConfig } from 'unbuild'
+
+export default defineBuildConfig({
+ entries: [
+ 'src/index.ts',
+ ],
+ declaration: true,
+ rollup: {
+ emitCJS: false,
+ },
+ externals: [
+ 'hast',
+ ],
+})
diff --git a/packages/shikiji-transformers/package.json b/packages/shikiji-transformers/package.json
new file mode 100644
index 000000000..4b6b0fb7e
--- /dev/null
+++ b/packages/shikiji-transformers/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "shikiji-transformers",
+ "type": "module",
+ "version": "0.0.0",
+ "description": "Collective of common transformers transformers for Shikiji",
+ "author": "Anthony Fu ",
+ "license": "MIT",
+ "homepage": "https://github.com/antfu/shikiji#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/antfu/shikiji.git",
+ "directory": "packages/shikiji-transformers"
+ },
+ "bugs": "https://github.com/antfu/shikiji/issues",
+ "keywords": [
+ "shiki",
+ "shikiji-transformers"
+ ],
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "unbuild",
+ "dev": "unbuild --stub",
+ "prepublishOnly": "nr build"
+ },
+ "dependencies": {
+ "shikiji": "workspace:*"
+ }
+}
diff --git a/packages/shikiji-transformers/src/index.ts b/packages/shikiji-transformers/src/index.ts
new file mode 100644
index 000000000..49c7103b8
--- /dev/null
+++ b/packages/shikiji-transformers/src/index.ts
@@ -0,0 +1,8 @@
+export * from './transformers/render-whitespace'
+export * from './transformers/remove-line-breaks'
+export * from './transformers/compact-line-options'
+export * from './transformers/notation-focus'
+export * from './transformers/notation-highlight'
+export * from './transformers/notation-diff'
+export * from './transformers/notation-error-level'
+export * from './utils'
diff --git a/packages/shikiji-transformers/src/transformers/compact-line-options.ts b/packages/shikiji-transformers/src/transformers/compact-line-options.ts
new file mode 100644
index 000000000..3e19aabb5
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/compact-line-options.ts
@@ -0,0 +1,27 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+
+export interface TransformerCompactLineOption {
+ /**
+ * 1-based line number.
+ */
+ line: number
+ classes?: string[]
+}
+
+/**
+ * Transformer for `shiki`'s legacy `lineOptions`
+ */
+export function transformerCompactLineOptions(
+ lineOptions: TransformerCompactLineOption[] = [],
+): ShikijiTransformer {
+ return {
+ name: 'shikiji-transformers:compact-line-options',
+ line(node, line) {
+ const lineOption = lineOptions.find(o => o.line === line)
+ if (lineOption?.classes)
+ addClassToHast(node, lineOption.classes)
+ return node
+ },
+ }
+}
diff --git a/packages/shikiji-transformers/src/transformers/notation-diff.ts b/packages/shikiji-transformers/src/transformers/notation-diff.ts
new file mode 100644
index 000000000..9d1716b88
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/notation-diff.ts
@@ -0,0 +1,45 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+import { createCommentNotationTransformer } from '../utils'
+
+export interface TransformerNotationDiffOptions {
+ /**
+ * Class for added lines
+ */
+ classAdded?: string
+ /**
+ * Class for removed lines
+ */
+ classRemoved?: string
+ /**
+ * Class added to the root element when the current code has diff
+ */
+ classRootActive?: string
+}
+
+/**
+ * Use `[!code ++]` and `[!code --]` to mark added and removed lines.
+ */
+export function transformerNotationDiff(
+ options: TransformerNotationDiffOptions = {},
+): ShikijiTransformer {
+ const {
+ classAdded = 'diff add',
+ classRemoved = 'diff remove',
+ classRootActive = 'has-diff',
+ } = options
+
+ return createCommentNotationTransformer(
+ 'shikiji-transformers:notation-diff',
+ /\[!code (\-\-|\+\+)\]/,
+ function ([_, match], line) {
+ const className = match === '--'
+ ? classRemoved
+ : classAdded
+ addClassToHast(line, className)
+ if (classRootActive)
+ addClassToHast(this.pre, classRootActive)
+ return true
+ },
+ )
+}
diff --git a/packages/shikiji-transformers/src/transformers/notation-error-level.ts b/packages/shikiji-transformers/src/transformers/notation-error-level.ts
new file mode 100644
index 000000000..f76d272f9
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/notation-error-level.ts
@@ -0,0 +1,30 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+import { createCommentNotationTransformer } from '../utils'
+
+export interface TransformerNotationErrorLevelOptions {
+ classMap?: Record
+}
+
+/**
+ * Allow using `[!code error]` `[!code warning]` notation in code to mark highlighted lines.
+ */
+export function transformerNotationErrorLevel(
+ options: TransformerNotationErrorLevelOptions = {},
+): ShikijiTransformer {
+ const {
+ classMap = {
+ error: ['highlighted', 'error'],
+ warning: ['highlighted', 'warning'],
+ },
+ } = options
+
+ return createCommentNotationTransformer(
+ 'shikiji-transformers:notation-error-level',
+ new RegExp(`\\[!code (${Object.keys(classMap).join('|')})\\]`),
+ ([_, match], line) => {
+ addClassToHast(line, classMap[match])
+ return true
+ },
+ )
+}
diff --git a/packages/shikiji-transformers/src/transformers/notation-focus.ts b/packages/shikiji-transformers/src/transformers/notation-focus.ts
new file mode 100644
index 000000000..fcb4b09b3
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/notation-focus.ts
@@ -0,0 +1,37 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+import { createCommentNotationTransformer } from '../utils'
+
+export interface TransformerNotationFocusOptions {
+ /**
+ * Class for focused lines
+ */
+ classFocused?: string
+ /**
+ * Class added to the root element when the code has focused lines
+ */
+ classRootActive?: string
+}
+
+/**
+ * Allow using `[!code focus]` notation in code to mark focused lines.
+ */
+export function transformerNotationFocus(
+ options: TransformerNotationFocusOptions = {},
+): ShikijiTransformer {
+ const {
+ classFocused = 'focused',
+ classRootActive = 'has-focused',
+ } = options
+
+ return createCommentNotationTransformer(
+ 'shikiji-transformers:notation-focus',
+ /\[!code focus\]/,
+ function (_, line) {
+ addClassToHast(line, classFocused)
+ if (classRootActive)
+ addClassToHast(this.pre, classRootActive)
+ return true
+ },
+ )
+}
diff --git a/packages/shikiji-transformers/src/transformers/notation-highlight.ts b/packages/shikiji-transformers/src/transformers/notation-highlight.ts
new file mode 100644
index 000000000..948242202
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/notation-highlight.ts
@@ -0,0 +1,37 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+import { createCommentNotationTransformer } from '../utils'
+
+export interface TransformerNotationHighlightOptions {
+ /**
+ * Class for highlighted lines
+ */
+ classHighlight?: string
+ /**
+ * Class added to the root element when the code has highlighted lines
+ */
+ classRootActive?: string
+}
+
+/**
+ * Allow using `[!code highlight]` notation in code to mark highlighted lines.
+ */
+export function transformerNotationHighlight(
+ options: TransformerNotationHighlightOptions = {},
+): ShikijiTransformer {
+ const {
+ classHighlight = 'highlighted',
+ classRootActive = 'has-highlighted',
+ } = options
+
+ return createCommentNotationTransformer(
+ 'shikiji-transformers:notation-highlight',
+ /\[!code (hl|highlight)\]/,
+ function (_, line) {
+ addClassToHast(line, classHighlight)
+ if (classRootActive)
+ addClassToHast(this.pre, classRootActive)
+ return true
+ },
+ )
+}
diff --git a/packages/shikiji-transformers/src/transformers/remove-line-breaks.ts b/packages/shikiji-transformers/src/transformers/remove-line-breaks.ts
new file mode 100644
index 000000000..5a208e3fe
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/remove-line-breaks.ts
@@ -0,0 +1,14 @@
+import type { ShikijiTransformer } from 'shikiji'
+
+/**
+ * Remove line breaks between lines.
+ * Useful when you override `display: block` to `.line` in CSS.
+ */
+export function transformerRemoveLineBreak(): ShikijiTransformer {
+ return {
+ name: 'shikiji-transformers:remove-line-break',
+ code(code) {
+ code.children = code.children.filter(line => !(line.type === 'text' && line.value === '\n'))
+ },
+ }
+}
diff --git a/packages/shikiji-transformers/src/transformers/render-whitespace.ts b/packages/shikiji-transformers/src/transformers/render-whitespace.ts
new file mode 100644
index 000000000..877552ccc
--- /dev/null
+++ b/packages/shikiji-transformers/src/transformers/render-whitespace.ts
@@ -0,0 +1,79 @@
+import type { ShikijiTransformer } from 'shikiji'
+import { addClassToHast } from 'shikiji'
+
+export interface TransformerRenderWhitespaceOptions {
+ /**
+ * Class for tab
+ *
+ * @default 'tab'
+ */
+ classTab?: string
+ /**
+ * Class for space
+ *
+ * @default 'space'
+ */
+ classSpace?: string
+
+ /**
+ * Position of rendered whitespace
+ * @default all positions
+ */
+ positions?: {
+ startOfLine?: boolean
+ endOfLine?: boolean
+ inline?: boolean
+ }
+}
+
+/**
+ * Render whitespaces as separate tokens.
+ * Apply with CSS, it can be used to render tabs and spaces visually.
+ */
+export function transformerRenderWhitespace(
+ options: TransformerRenderWhitespaceOptions = {},
+): ShikijiTransformer {
+ const classMap: Record = {
+ ' ': options.classSpace ?? 'space',
+ '\t': options.classTab ?? 'tab',
+ }
+
+ // TODO: support `positions`
+
+ return {
+ name: 'shikiji-transformers:render-whitespace',
+ line(node) {
+ const first = node.children[0]
+ if (!first || first.type !== 'element')
+ return
+ const textNode = first.children[0]
+ if (!textNode || textNode.type !== 'text')
+ return
+ node.children = node.children.flatMap((child) => {
+ if (child.type !== 'element')
+ return child
+ const node = child.children[0]
+ if (node.type !== 'text' || !node.value)
+ return child
+
+ // Split by whitespaces
+ const parts = node.value.split(/([ \t])/).filter(i => i.length)
+ if (parts.length <= 1)
+ return child
+
+ return parts.map((part) => {
+ const clone = {
+ ...child,
+ properties: { ...child.properties },
+ }
+ clone.children = [{ type: 'text', value: part }]
+ if (part in classMap) {
+ addClassToHast(clone, classMap[part])
+ delete clone.properties.style
+ }
+ return clone
+ })
+ })
+ },
+ }
+}
diff --git a/packages/shikiji-transformers/src/utils.ts b/packages/shikiji-transformers/src/utils.ts
new file mode 100644
index 000000000..01ee39329
--- /dev/null
+++ b/packages/shikiji-transformers/src/utils.ts
@@ -0,0 +1,50 @@
+import type { Element } from 'hast'
+import type { ShikijiTransformer, ShikijiTransformerContext } from 'shikiji'
+
+/**
+ * Check if a node is comment-like,
+ * e.g. ``, `/* comment ..`, `// comment`
+ */
+export function isCommentLike(node: Element, line: Element) {
+ if (node.children?.[0].type !== 'text')
+ return false
+ const text = node.children[0].value.trim()
+ if (text.startsWith(''))
+ return true
+ if (text.startsWith('/*') && text.endsWith('*/'))
+ return true
+ if (text.startsWith('//') && line.children.indexOf(node) === line.children.length - 1)
+ return true
+ return false
+}
+
+export function createCommentNotationTransformer(
+ name: string,
+ regex: RegExp,
+ onMatch: (this: ShikijiTransformerContext, match: RegExpMatchArray, line: Element, commentNode: Element) => boolean,
+): ShikijiTransformer {
+ return {
+ name,
+ line(line) {
+ let nodeToRemove: Element | undefined
+ for (const child of line.children) {
+ if (child.type !== 'element')
+ continue
+ if (!isCommentLike(child, line))
+ continue
+ const text = child.children[0]
+ if (text.type !== 'text')
+ continue
+ const match = text.value.match(regex)
+ if (!match)
+ continue
+ if (onMatch.call(this, match, line, child)) {
+ nodeToRemove = child
+ break
+ }
+ }
+ if (nodeToRemove)
+ line.children.splice(line.children.indexOf(nodeToRemove), 1)
+ },
+ }
+}
diff --git a/packages/shikiji-transformers/test/fixtures.test.ts b/packages/shikiji-transformers/test/fixtures.test.ts
new file mode 100644
index 000000000..fdb7edcd5
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures.test.ts
@@ -0,0 +1,116 @@
+///
+
+import { describe, expect, it } from 'vitest'
+import type { ShikijiTransformer } from 'shikiji'
+import { codeToHtml } from 'shikiji'
+import {
+ transformerNotationDiff,
+ transformerNotationErrorLevel,
+ transformerNotationFocus,
+ transformerNotationHighlight,
+ transformerRemoveLineBreak,
+ transformerRenderWhitespace,
+} from '../src'
+
+function suite(
+ name: string,
+ files: Record,
+ transformers: ShikijiTransformer[],
+ replace?: (code: string) => string,
+) {
+ describe(name, () => {
+ for (const path of Object.keys(files)) {
+ if (path.endsWith('.output.html'))
+ continue
+
+ it(path, async () => {
+ const ext = path.split('.').pop()!
+
+ let code = await codeToHtml(files[path], {
+ lang: ext,
+ theme: 'github-dark',
+ transformers,
+ })
+
+ if (replace)
+ code = replace(code)
+
+ expect(code)
+ .toMatchFileSnapshot(`${path}.output.html`)
+ })
+ }
+ })
+}
+
+suite(
+ 'diff',
+ import.meta.glob('./fixtures/diff/*.*', { as: 'raw', eager: true }),
+ [transformerNotationDiff(), transformerRemoveLineBreak()],
+ code => `${code}
+`,
+)
+
+suite(
+ 'focus',
+ import.meta.glob('./fixtures/focus/*.*', { as: 'raw', eager: true }),
+ [transformerNotationFocus(), transformerRemoveLineBreak()],
+ code => `${code}
+`,
+)
+
+suite(
+ 'highlight',
+ import.meta.glob('./fixtures/highlight/*.*', { as: 'raw', eager: true }),
+ [transformerNotationHighlight(), transformerRemoveLineBreak()],
+ code => `${code}
+`,
+)
+
+suite(
+ 'error-level',
+ import.meta.glob('./fixtures/error-level/*.*', { as: 'raw', eager: true }),
+ [transformerNotationErrorLevel(), transformerRemoveLineBreak()],
+ code => `${code}
+`,
+)
+
+suite(
+ 'whitespace',
+ import.meta.glob('./fixtures/whitespace/*.*', { as: 'raw', eager: true }),
+ [transformerRenderWhitespace()],
+ code => `${code}
+`,
+)
diff --git a/packages/shikiji-transformers/test/fixtures/diff/a.js b/packages/shikiji-transformers/test/fixtures/diff/a.js
new file mode 100644
index 000000000..d84dc5177
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/diff/a.js
@@ -0,0 +1,4 @@
+export function foo() {
+ console.log('hewwo') // [!code --]
+ console.log('hello') // [!code ++]
+}
diff --git a/packages/shikiji-transformers/test/fixtures/diff/a.js.output.html b/packages/shikiji-transformers/test/fixtures/diff/a.js.output.html
new file mode 100644
index 000000000..5c7745259
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/diff/a.js.output.html
@@ -0,0 +1,12 @@
+export function foo() { console.log('hewwo') console.log('hello') }
+
\ No newline at end of file
diff --git a/packages/shikiji-transformers/test/fixtures/error-level/a.js b/packages/shikiji-transformers/test/fixtures/error-level/a.js
new file mode 100644
index 000000000..6408a3543
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/error-level/a.js
@@ -0,0 +1,4 @@
+export function foo() {
+ console.log('error') // [!code error]
+ console.log('warn') // [!code warning]
+}
diff --git a/packages/shikiji-transformers/test/fixtures/error-level/a.js.output.html b/packages/shikiji-transformers/test/fixtures/error-level/a.js.output.html
new file mode 100644
index 000000000..3a4e280fc
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/error-level/a.js.output.html
@@ -0,0 +1,8 @@
+export function foo() { console.log('error') console.log('warn') }
+
\ No newline at end of file
diff --git a/packages/shikiji-transformers/test/fixtures/focus/a.js b/packages/shikiji-transformers/test/fixtures/focus/a.js
new file mode 100644
index 000000000..a0441c2ce
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/focus/a.js
@@ -0,0 +1,6 @@
+export function foo() {
+ console.log('focus') // [!code focus]
+
+ // should not be transformed:
+ console.log('[!code focus]')
+}
diff --git a/packages/shikiji-transformers/test/fixtures/focus/a.js.output.html b/packages/shikiji-transformers/test/fixtures/focus/a.js.output.html
new file mode 100644
index 000000000..63e4dcfd1
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/focus/a.js.output.html
@@ -0,0 +1,7 @@
+export function foo() { console.log('focus') // should not be transformed: console.log('[!code focus]')}
+
\ No newline at end of file
diff --git a/packages/shikiji-transformers/test/fixtures/highlight/a.js b/packages/shikiji-transformers/test/fixtures/highlight/a.js
new file mode 100644
index 000000000..26c3d0bb6
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/highlight/a.js
@@ -0,0 +1,7 @@
+export function foo() {
+ console.log('highlight') // [!code highlight]
+ console.log('hl') // [!code hl]
+
+ // should not be transformed:
+ console.log('[!code highlight]')
+}
diff --git a/packages/shikiji-transformers/test/fixtures/highlight/a.js.output.html b/packages/shikiji-transformers/test/fixtures/highlight/a.js.output.html
new file mode 100644
index 000000000..a130211b9
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/highlight/a.js.output.html
@@ -0,0 +1,7 @@
+export function foo() { console.log('highlight') console.log('hl') // should not be transformed: console.log('[!code highlight]')}
+
\ No newline at end of file
diff --git a/packages/shikiji-transformers/test/fixtures/whitespace/a.js b/packages/shikiji-transformers/test/fixtures/whitespace/a.js
new file mode 100644
index 000000000..6a67ee38f
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/whitespace/a.js
@@ -0,0 +1,4 @@
+function block( ) {
+ space()
+ table()
+}
diff --git a/packages/shikiji-transformers/test/fixtures/whitespace/a.js.output.html b/packages/shikiji-transformers/test/fixtures/whitespace/a.js.output.html
new file mode 100644
index 000000000..83c968c98
--- /dev/null
+++ b/packages/shikiji-transformers/test/fixtures/whitespace/a.js.output.html
@@ -0,0 +1,13 @@
+function block( ) {
+ space()
+ table()
+}
+
+
\ No newline at end of file
diff --git a/packages/shikiji/src/core/bundle-factory.ts b/packages/shikiji/src/core/bundle-factory.ts
index c6b7a809b..ec34722d8 100644
--- a/packages/shikiji/src/core/bundle-factory.ts
+++ b/packages/shikiji/src/core/bundle-factory.ts
@@ -69,21 +69,21 @@ export function createSingletonShorthands(g
let _shiki: ReturnType
async function _getHighlighter(options: {
- theme: MaybeArray
- lang: MaybeArray
- }) {
+ theme?: MaybeArray
+ lang?: MaybeArray
+ } = {}) {
if (!_shiki) {
_shiki = getHighlighter({
- themes: toArray(options.theme),
- langs: toArray(options.lang),
+ themes: toArray(options.theme || []),
+ langs: toArray(options.lang || []),
})
return _shiki
}
else {
const s = await _shiki
await Promise.all([
- s.loadTheme(...toArray(options.theme)),
- s.loadLanguage(...toArray(options.lang)),
+ s.loadTheme(...toArray(options.theme || [])),
+ s.loadLanguage(...toArray(options.lang || [])),
])
return s
}
@@ -143,6 +143,7 @@ export function createSingletonShorthands(g
}
return {
+ getSingletonHighlighter: () => _getHighlighter(),
codeToHtml,
codeToHast,
codeToThemedTokens,
diff --git a/packages/shikiji/src/core/index.ts b/packages/shikiji/src/core/index.ts
index 2e6936b0c..13f00f9ad 100644
--- a/packages/shikiji/src/core/index.ts
+++ b/packages/shikiji/src/core/index.ts
@@ -3,5 +3,6 @@ export { loadWasm } from '../oniguruma'
export * from './context'
export * from './highlighter'
export * from './bundle-factory'
+export * from './utils'
export { toShikiTheme } from './normalize'
diff --git a/packages/shikiji/src/core/renderer-hast.ts b/packages/shikiji/src/core/renderer-hast.ts
index 53fac6e71..9d05d915d 100644
--- a/packages/shikiji/src/core/renderer-hast.ts
+++ b/packages/shikiji/src/core/renderer-hast.ts
@@ -1,5 +1,5 @@
import type { Element, Root, Text } from 'hast'
-import type { CodeToHastOptions, HtmlRendererOptions, ShikiContext, ThemedToken } from '../types'
+import type { CodeToHastOptions, HtmlRendererOptions, ShikiContext, ShikijiTransformerContext, ThemedToken } from '../types'
import { codeToThemedTokens } from './tokenizer'
import { FontStyle } from './stackElementMetadata'
import { codeToTokensWithThemes } from './renderer-html-themes'
@@ -151,6 +151,24 @@ export function tokensToHast(
children: lines,
}
+ const context: ShikijiTransformerContext = {
+ get tokens() {
+ return tokens
+ },
+ get options() {
+ return options
+ },
+ get root() {
+ return tree
+ },
+ get pre() {
+ return preNode
+ },
+ get code() {
+ return codeNode
+ },
+ }
+
tokens.forEach((line, idx) => {
if (idx)
lines.push({ type: 'text', value: '\n' })
@@ -177,30 +195,30 @@ export function tokensToHast(
tokenNode.properties.style = style
for (const transformer of transformers)
- tokenNode = transformer?.token?.(tokenNode, idx + 1, col, lineNode) || tokenNode
+ tokenNode = transformer?.token?.call(context, tokenNode, idx + 1, col, lineNode) || tokenNode
lineNode.children.push(tokenNode)
col += token.content.length
}
for (const transformer of transformers)
- lineNode = transformer?.line?.(lineNode, idx + 1) || lineNode
+ lineNode = transformer?.line?.call(context, lineNode, idx + 1) || lineNode
lines.push(lineNode)
})
for (const transformer of transformers)
- codeNode = transformer?.code?.(codeNode) || codeNode
+ codeNode = transformer?.code?.call(context, codeNode) || codeNode
preNode.children.push(codeNode)
for (const transformer of transformers)
- preNode = transformer?.pre?.(preNode) || preNode
+ preNode = transformer?.pre?.call(context, preNode) || preNode
tree.children.push(preNode)
let result = tree
for (const transformer of transformers)
- result = transformer?.root?.(result) || result
+ result = transformer?.root?.call(context, result) || result
return result
}
diff --git a/packages/shikiji/src/core/utils.ts b/packages/shikiji/src/core/utils.ts
index 2e0ae8050..cd849d34c 100644
--- a/packages/shikiji/src/core/utils.ts
+++ b/packages/shikiji/src/core/utils.ts
@@ -1,3 +1,4 @@
+import type { Element } from 'hast'
import type { MaybeArray } from '../types'
export function isPlaintext(lang: string | null | undefined) {
@@ -11,3 +12,18 @@ export function toArray(x: MaybeArray): T[] {
export function isSpecialLang(lang: string) {
return lang === 'ansi' || isPlaintext(lang)
}
+
+export function addClassToHast(node: Element, className: string | string[]) {
+ node.properties ||= {}
+ node.properties.class ||= []
+ if (typeof node.properties.class === 'string')
+ node.properties.class = node.properties.class.split(/\s+/g)
+ if (!Array.isArray(node.properties.class))
+ node.properties.class = []
+
+ const targets = Array.isArray(className) ? className : className.split(/\s+/g)
+ for (const c of targets) {
+ if (c && !node.properties.class.includes(c))
+ node.properties.class.push(c)
+ }
+}
diff --git a/packages/shikiji/src/index.ts b/packages/shikiji/src/index.ts
index 4571e07bf..4e026969f 100644
--- a/packages/shikiji/src/index.ts
+++ b/packages/shikiji/src/index.ts
@@ -25,6 +25,7 @@ export const {
codeToHast,
codeToThemedTokens,
codeToTokensWithThemes,
+ getSingletonHighlighter,
} = /* @__PURE__ */ createSingletonShorthands<
BuiltinLanguage,
BuiltinTheme
diff --git a/packages/shikiji/src/types.ts b/packages/shikiji/src/types.ts
index 114459d98..e0a1a7f2e 100644
--- a/packages/shikiji/src/types.ts
+++ b/packages/shikiji/src/types.ts
@@ -271,6 +271,14 @@ export interface ThemeRegistration extends ThemeRegistrationRaw {
colors?: Record
}
+export interface ShikijiTransformerContext {
+ readonly tokens: ThemedToken[][]
+ readonly options: CodeToHastOptions
+ readonly root: Root
+ readonly pre: Element
+ readonly code: Element
+}
+
export interface ShikijiTransformer {
/**
* Name of the transformer
@@ -278,20 +286,27 @@ export interface ShikijiTransformer {
name?: string
/**
* Transform the entire generated HAST tree. Return a new Node will replace the original one.
- *
- * @param hast
*/
- root?: (hast: Root) => Root | void
- pre?: (hast: Element) => Element | void
- code?: (hast: Element) => Element | void
+ root?(this: ShikijiTransformerContext, hast: Root): Root | void
+ /**
+ * Transform the `` element. Return a new Node will replace the original one.
+ */
+ pre?(this: ShikijiTransformerContext, hast: Element): Element | void
+ /**
+ * Transform the `` element. Return a new Node will replace the original one.
+ */
+ code?(this: ShikijiTransformerContext, hast: Element): Element | void
/**
* Transform each line element.
*
* @param hast
* @param line 1-based line number
*/
- line?: (hast: Element, line: number) => Element | void
- token?: (hast: Element, line: number, col: number, lineElement: Element) => Element | void
+ line?(this: ShikijiTransformerContext, hast: Element, line: number): Element | void
+ /**
+ * Transform each token element.
+ */
+ token?(this: ShikijiTransformerContext, hast: Element, line: number, col: number, lineElement: Element): Element | void
}
export interface HtmlRendererOptionsCommon {
diff --git a/packages/shikiji/test/cf.ts b/packages/shikiji/test/cf.ts
index 9e68d9c12..5fb08c2e5 100644
--- a/packages/shikiji/test/cf.ts
+++ b/packages/shikiji/test/cf.ts
@@ -1,4 +1,5 @@
import { getHighlighterCore, loadWasm } from 'shikiji/core'
+import type { LanguageRegistration } from 'shikiji'
import nord from 'shikiji/themes/nord.mjs'
import js from 'shikiji/langs/javascript.mjs'
@@ -12,7 +13,7 @@ export default {
async fetch() {
const highlighter = await getHighlighterCore({
themes: [nord],
- langs: [js],
+ langs: [js as LanguageRegistration[]],
})
return new Response(
diff --git a/packages/shikiji/test/hast.test.ts b/packages/shikiji/test/hast.test.ts
index ca23af9bb..14de3a794 100644
--- a/packages/shikiji/test/hast.test.ts
+++ b/packages/shikiji/test/hast.test.ts
@@ -92,52 +92,17 @@ it('render whitespace', async () => {
'\t\ttab()',
].join('\n')
- const classMap: Record = {
- ' ': 'space',
- '\t': 'tab',
- }
-
const code = await codeToHtml(snippet, {
lang: 'js',
theme: 'vitesse-light',
transformers: [
- {
- line(node) {
- const first = node.children[0]
- if (!first || first.type !== 'element')
- return
- const textNode = first.children[0]
- if (!textNode || textNode.type !== 'text')
- return
- node.children = node.children.flatMap((child) => {
- if (child.type !== 'element')
- return child
- const node = child.children[0]
- if (node.type !== 'text' || !node.value)
- return child
- const parts = node.value.split(/([ \t])/).filter(i => i.length)
- if (parts.length <= 1)
- return child
- return parts.map((part) => {
- const clone = {
- ...child,
- properties: { ...child.properties },
- }
- clone.children = [{ type: 'text', value: part }]
- if (part in classMap)
- clone.properties.class = [clone.properties.class, classMap[part]].filter(Boolean).join(' ')
- return clone
- })
- })
- },
- },
],
})
expect(code)
.toMatchInlineSnapshot(`
- " space()
- tab()
"
+ " space()
+ tab()
"
`)
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b17c432c..ba8e13da0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,6 +188,15 @@ importers:
shikiji:
specifier: workspace:*
version: link:../shikiji
+ shikiji-transformers:
+ specifier: workspace:*
+ version: link:../shikiji-transformers
+
+ packages/shikiji-transformers:
+ dependencies:
+ shikiji:
+ specifier: workspace:*
+ version: link:../shikiji
packages:
diff --git a/tsconfig.json b/tsconfig.json
index 35bf652f5..9fc37e08d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,12 +1,14 @@
{
"compilerOptions": {
- "target": "es2020",
+ "target": "esnext",
"lib": ["esnext", "DOM"],
"rootDir": ".",
"module": "esnext",
"moduleResolution": "Bundler",
"paths": {
- "shikiji": ["./packages/shikiji/src/index.ts"]
+ "shikiji": ["./packages/shikiji/src/index.ts"],
+ "shikiji/core": ["./packages/shikiji/src/core/index.ts"],
+ "shikiji-transformers": ["./packages/shikiji-transformers/src/index.ts"]
},
"resolveJsonModule": true,
"strict": true,
@@ -15,5 +17,12 @@
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
- "exclude": ["node_modules", "dist"]
+ "include": [
+ "**/*.ts"
+ ],
+ "exclude": [
+ "**/node_modules/**",
+ "**/dist/**",
+ "**/fixtures/**"
+ ]
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 9837d4eb2..3f8a9b1d9 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,7 +1,7 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
-// @ts-expect-error no types
+// @ts-expect-error - no types
import { wasmPlugin } from './packages/shikiji/rollup.config.mjs'
export default defineConfig({
@@ -10,7 +10,9 @@ export default defineConfig({
],
resolve: {
alias: {
- shikiji: fileURLToPath(new URL('./packages/shikiji/src/index.ts', import.meta.url)),
+ 'shikiji': fileURLToPath(new URL('./packages/shikiji/src/index.ts', import.meta.url)),
+ 'shikiji/core': fileURLToPath(new URL('./packages/shikiji/src/core/index.ts', import.meta.url)),
+ 'shikiji-transformers': fileURLToPath(new URL('./packages/shikiji-transformers/src/index.ts', import.meta.url)),
},
},
test: {
@@ -22,6 +24,7 @@ export default defineConfig({
},
},
coverage: {
+ provider: 'v8',
exclude: [
'**/src/oniguruma/**',
'**/src/assets/**',