Skip to content

Commit 815c19c

Browse files
committed
Add plugin config for custom code highlighting.
1 parent 06676f0 commit 815c19c

12 files changed

Lines changed: 419 additions & 64 deletions

File tree

‎packages/ts-ssg/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,26 @@ Plugins apply in order:
370370

371371
Each plugin is checked when the build starts. An unknown field or hook name, such as `onPageRender`, a value of the wrong type, two plugins sharing a name, or two plugins defining the same skin, component, template, or generated page fails the build with the plugin's name. For a single hook, pass an inline plugin: `{ name: 'site', hooks: { … } }`.
372372

373+
### Code highlighting
374+
375+
Use `markdown.resolveHighlighter(lang)` to select a code-to-HTML callback for a language:
376+
377+
```ts
378+
import { definePlugin } from '@purestack/ts-ssg'
379+
import { renderCustomCode } from './custom-code'
380+
381+
export const customCodePlugin = definePlugin({
382+
name: 'custom-code',
383+
markdown: {
384+
resolveHighlighter: (lang) => lang === 'custom' ? renderCustomCode : null,
385+
},
386+
})
387+
```
388+
389+
The resolver can be synchronous or asynchronous and receives the trimmed, lowercase language, or `''` when absent. Its callback accepts `(code, lang?)` and returns the HTML as `string | Promise<string>`. Resolvers are awaited in plugin order; the first callback returned handles the block. If all return `null`, the configured default highlighter handles it, including its existing behavior for unknown languages.
390+
391+
Custom callbacks also work when `mdx.disableHighlighter` is `true`. In that case, if all resolvers return `null`, the source is HTML-escaped and displayed in a plain `<pre><code>` block without syntax highlighting.
392+
373393
### Hooks
374394

375395
Every page render, in a full build or a dev server re-render, runs the page hooks:

‎packages/ts-ssg/src/build/incremental/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async function createIncrementalRuntime(
9797
const minifyScripts = publishOptions.enabled === true
9898
const failOnAssetError = publishOptions.enabled === true
9999
const mdx = {
100-
...(await resolveMdxBuildOptions(config.mdx)),
100+
...(await resolveMdxBuildOptions(config.mdx, plugins)),
101101
contentProcessor: resolvePluginContentProcessor(plugins),
102102
}
103103
themes.setOptions(config.style.theme)

‎packages/ts-ssg/src/build/incremental/support.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {
2222
} from '../../mdx/highlight'
2323
import { createHljsHighlighter } from '../../mdx/highlightjs'
2424
import { generatePluginPages } from '../../plugins/generated-pages'
25-
import type { PureStackPlugin } from '../../plugins/plugin'
25+
import {
26+
type PureStackPlugin,
27+
resolvePluginHighlighter,
28+
} from '../../plugins/plugin'
2629
import {
2730
assertUniqueContentRoutes,
2831
resolveRouteInfo,
@@ -128,8 +131,12 @@ export class ManifestContentIndex {
128131

129132
export async function resolveMdxBuildOptions(
130133
mdx: Partial<SiteMdxConfig> | undefined,
134+
plugins: readonly PureStackPlugin[] = [],
131135
): Promise<MdxRenderOptions> {
132-
const highlighter = await resolveHighlighter(mdx)
136+
const highlighter = resolvePluginHighlighter(
137+
plugins,
138+
await resolveHighlighter(mdx),
139+
)
133140
return {
134141
highlighter,
135142
compileMdAsMdx: mdx?.compileMdAsMdx ?? true,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createHljsHighlighter } from './highlightjs'
3+
import { compileMarkdown } from './md'
4+
import { compileMdx } from './mdx'
5+
6+
describe('code highlighting', () => {
7+
it.each([compileMarkdown, compileMdx])(
8+
'awaits arbitrary block HTML through the existing hook',
9+
async (compile) => {
10+
const html =
11+
'<figure r-pre><svg viewBox="0 0 10 10"><title>{{ literal }}</title></svg></figure><p>Caption</p>'
12+
const result = await compile('```diagram\nsource\n```', {
13+
highlighter: { codeToHtml: async () => html },
14+
})
15+
expect(result.bodyHtml).toBe(html)
16+
},
17+
)
18+
19+
it('awaits the same hook inside component markup', async () => {
20+
const result = await compileMdx(
21+
'<Panel>\n```diagram\nsource\n```\n</Panel>',
22+
{
23+
highlighter: {
24+
codeToHtml: async (code) => `<figure r-pre>${code}</figure>`,
25+
},
26+
},
27+
)
28+
expect(result.bodyHtml).toContain(
29+
'<Panel>\n<figure r-pre>source</figure>\n</Panel>',
30+
)
31+
expect(result.bodyHtml).not.toContain('<pre')
32+
})
33+
34+
it('preserves callback order across separate component markup segments', async () => {
35+
const calls: string[] = []
36+
await compileMdx(
37+
[
38+
'<Panel>',
39+
'```custom',
40+
'first',
41+
'```',
42+
'</Panel>',
43+
'',
44+
'Between components.',
45+
'',
46+
'<Panel>',
47+
'```custom',
48+
'second',
49+
'```',
50+
'</Panel>',
51+
].join('\n'),
52+
{
53+
highlighter: {
54+
codeToHtml: async (code) => {
55+
calls.push(`start:${code}`)
56+
await Promise.resolve()
57+
calls.push(`end:${code}`)
58+
return `<figure>${code}</figure>`
59+
},
60+
},
61+
},
62+
)
63+
expect(calls).toEqual([
64+
'start:first',
65+
'end:first',
66+
'start:second',
67+
'end:second',
68+
])
69+
})
70+
71+
it.each([compileMarkdown, compileMdx])(
72+
'honors empty custom HTML without restoring a code block',
73+
async (compile) => {
74+
const source =
75+
compile === compileMdx
76+
? '<Panel>\n```custom\nsource\n```\n</Panel>'
77+
: '```custom\nsource\n```'
78+
const result = await compile(source, {
79+
highlighter: { codeToHtml: () => '' },
80+
})
81+
expect(result.bodyHtml).not.toContain('source')
82+
expect(result.bodyHtml).not.toContain('<pre')
83+
},
84+
)
85+
86+
it('preserves built-in block and inline highlighting with an asynchronous wrapper', async () => {
87+
const fallback = createHljsHighlighter()
88+
const source =
89+
'```ts\nconst answer = 42\n```\n\n`value`\n\n<Panel>\n```ts\nconst nested = 1\n```\n`nested`\n</Panel>'
90+
const original = await compileMdx(source, { highlighter: fallback })
91+
const result = await compileMdx(source, {
92+
highlighter: {
93+
codeToHtml: async (code, lang) => fallback.codeToHtml(code, lang),
94+
},
95+
})
96+
expect(result).toEqual(original)
97+
})
98+
99+
it.each([compileMarkdown, compileMdx])(
100+
'propagates asynchronous highlighter errors',
101+
async (compile) => {
102+
await expect(
103+
compile('```diagram\nbroken\n```', {
104+
highlighter: {
105+
codeToHtml: async () => {
106+
throw new Error('invalid diagram')
107+
},
108+
},
109+
}),
110+
).rejects.toThrow('invalid diagram')
111+
},
112+
)
113+
})

packages/ts-ssg/src/mdx/shikiHighlighting.ts renamed to packages/ts-ssg/src/mdx/codeHighlighting.ts

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,64 @@
11
import type { Element, Root, Text } from 'hast'
22
import { fromHtml } from 'hast-util-from-html'
33
import { toHtml } from 'hast-util-to-html'
4+
import type { Raw } from 'mdast-util-to-hast'
45

56
import type { MdxCodeHighlighter } from './highlight'
67

7-
export function applyShikiHighlighting(
8+
/** Applies the configured highlighter to code blocks and inline code. */
9+
export async function applyCodeHighlighting(
810
root: Root,
911
highlighter: MdxCodeHighlighter,
1012
) {
11-
const visit = (node: Root | Element) => {
13+
const visit = async (node: Root | Element) => {
1214
const children = node.children ?? []
1315
for (let index = 0; index < children.length; index += 1) {
1416
const child = children[index]
1517
if (child.type !== 'element') continue
1618
if (child.tagName === 'pre') {
17-
const highlighted = highlightPre(child, highlighter)
19+
const highlighted = await highlightCodeBlock(child, highlighter)
1820
if (highlighted) {
1921
children[index] = highlighted
2022
continue
2123
}
2224
}
2325
if (child.tagName === 'code' && !isPre(node)) {
24-
const highlighted = highlightInlineCode(child, highlighter)
26+
const highlighted = await highlightInlineCode(child, highlighter)
2527
if (highlighted) {
2628
children[index] = highlighted
2729
continue
2830
}
2931
}
3032
if (child.children && child.children.length > 0) {
31-
visit(child)
33+
await visit(child)
3234
}
3335
}
3436
}
35-
visit(root)
37+
await visit(root)
3638
}
3739

38-
function highlightPre(
40+
async function highlightCodeBlock(
3941
pre: Element,
4042
highlighter: MdxCodeHighlighter,
41-
): Element | undefined {
43+
): Promise<Element | Raw | undefined> {
4244
const code = pre.children?.find(
4345
(child) => child.type === 'element' && child.tagName === 'code',
4446
) as Element | undefined
4547
if (!code) return undefined
4648
const language = resolveLanguage(code) ?? resolveLanguage(pre)
4749
const text = collectText(code).trimEnd()
4850
if (text.length === 0) return undefined
49-
return renderHighlightedPre(text, language, highlighter)
51+
return renderHighlightedCodeBlock(text, language, highlighter)
5052
}
5153

52-
function highlightInlineCode(
54+
async function highlightInlineCode(
5355
code: Element,
5456
highlighter: MdxCodeHighlighter,
55-
): Element | undefined {
57+
): Promise<Element | undefined> {
5658
const language = resolveLanguage(code)
5759
const text = collectText(code).trimEnd()
5860
if (text.length === 0) return undefined
59-
const html = highlighter.codeToHtml(text, language)
61+
const html = await highlighter.codeToHtml(text, language)
6062
const parsed = fromHtml(html, { fragment: true })
6163
const replacement = parsed.children?.find(
6264
(child) => child.type === 'element' && child.tagName === 'pre',
@@ -86,38 +88,49 @@ function highlightInlineCode(
8688
return code
8789
}
8890

89-
function renderHighlightedPre(
91+
/** Preserves arbitrary block HTML; a single pre uses the site's code styling. */
92+
async function renderHighlightedCodeBlock(
9093
text: string,
9194
language: string | undefined,
9295
highlighter: MdxCodeHighlighter,
93-
): Element | undefined {
94-
const html = highlighter.codeToHtml(text, language)
96+
): Promise<Element | Raw> {
97+
const html = await highlighter.codeToHtml(text, language)
9598
const parsed = fromHtml(html, { fragment: true })
96-
const replacement = parsed.children?.find(
97-
(child) => child.type === 'element' && child.tagName === 'pre',
98-
) as Element | undefined
99-
if (!replacement) return undefined
100-
normalizeShikiPre(replacement)
99+
const replacement = parsed.children[0]
100+
if (
101+
parsed.children.length !== 1 ||
102+
replacement?.type !== 'element' ||
103+
replacement.tagName !== 'pre'
104+
) {
105+
return { type: 'raw', value: html }
106+
}
107+
normalizeHighlightedPre(replacement)
101108
return replacement
102109
}
103110

104-
export function renderHighlightedPreHtml(
111+
/** Renders a code block inside component markup, keeping pre content literal. */
112+
export async function renderHighlightedCodeBlockHtml(
105113
text: string,
106114
language: string | undefined,
107115
highlighter: MdxCodeHighlighter,
108-
): string | undefined {
109-
const replacement = renderHighlightedPre(text, language, highlighter)
110-
if (!replacement) return undefined
116+
): Promise<string> {
117+
const replacement = await renderHighlightedCodeBlock(
118+
text,
119+
language,
120+
highlighter,
121+
)
122+
if (replacement.type === 'raw') return replacement.value
111123
replacement.properties['r-pre'] = ''
112124
return toHtml(replacement, { allowDangerousHtml: true })
113125
}
114126

115-
export function renderHighlightedInlineCodeHtml(
127+
/** Extracts inline code from the highlighter's pre/code output. */
128+
export async function renderHighlightedInlineCodeHtml(
116129
text: string,
117130
language: string | undefined,
118131
highlighter: MdxCodeHighlighter,
119-
): string | undefined {
120-
const html = highlighter.codeToHtml(text, language)
132+
): Promise<string | undefined> {
133+
const html = await highlighter.codeToHtml(text, language)
121134
const parsed = fromHtml(html, { fragment: true })
122135
const replacement = parsed.children?.find(
123136
(child) => child.type === 'element' && child.tagName === 'pre',
@@ -184,7 +197,8 @@ function isPre(node: Root | Element) {
184197
return node.type === 'element' && node.tagName === 'pre'
185198
}
186199

187-
function normalizeShikiPre(pre: Element) {
200+
/** The site's shared code styles use the shiki class for every highlighter. */
201+
function normalizeHighlightedPre(pre: Element) {
188202
if (!pre.properties) pre.properties = {}
189203
pre.properties.className = mergeClassNames(pre.properties.className, 'shiki')
190204
const style =

‎packages/ts-ssg/src/mdx/compile.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { Root as MdastRoot } from 'mdast'
55
import rehypeStringify from 'rehype-stringify'
66
import remarkRehype from 'remark-rehype'
77
import { type PluggableList, unified } from 'unified'
8+
import { applyCodeHighlighting } from './codeHighlighting'
89
import type { MdxCodeHighlighter } from './highlight'
9-
import { applyShikiHighlighting } from './shikiHighlighting'
1010

1111
export interface MdxCompileResult {
1212
bodyHtml: string
@@ -56,7 +56,7 @@ export async function compileAstToHtml(
5656
wrapTablesInScrollContainers(tree)
5757
const outline = collectOutline(tree)
5858
if (options.highlighter) {
59-
applyShikiHighlighting(tree, options.highlighter)
59+
await applyCodeHighlighting(tree, options.highlighter)
6060
}
6161
keepCodeLiteral(tree)
6262
const bodyHtml = String(

‎packages/ts-ssg/src/mdx/highlight.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHighlighter, type Highlighter } from 'shiki'
22

33
export interface MdxCodeHighlighter {
4-
codeToHtml: (code: string, lang?: string) => string
4+
codeToHtml: (code: string, lang?: string) => string | Promise<string>
55
}
66

77
export interface MdxCodeThemes {

‎packages/ts-ssg/src/mdx/mdx.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('createMdxHighlighter', () => {
1919
// biome-ignore lint/suspicious/noTemplateCurlyInString: Literal TypeScript source for the highlighter.
2020
'const view = html`<div class="greeting">Hello ${name}</div>`',
2121
].join('\n')
22-
const html = highlighter.codeToHtml(source, lang)
22+
const html = await highlighter.codeToHtml(source, lang)
2323
const cleanup = ensureDomGlobals()
2424
try {
2525
const root = document.createElement('div')

‎packages/ts-ssg/src/mdx/mdx.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export {
2424

2525
export type { MdxCompileResult, MdxRenderOptions }
2626

27-
export function compileMdx(
27+
export async function compileMdx(
2828
source: string,
2929
options: MdxRenderOptions = {},
3030
): Promise<MdxCompileResult> {
@@ -35,7 +35,7 @@ export function compileMdx(
3535
const file = parser.parse(masked.source)
3636
restoreRegorMarkup(
3737
file,
38-
renderRegorMarkupCodeFences(masked.segments, options.highlighter),
38+
await renderRegorMarkupCodeFences(masked.segments, options.highlighter),
3939
)
4040
normalizeMarkupParagraphs(file)
4141
return compileAstToHtml(file, options)

0 commit comments

Comments
 (0)