diff --git a/package.json b/package.json index 768cfbb9..fca42b5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index e598c7c7..e8b57f31 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 1c8d0db2..95b17239 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.10' +export const version: string = '1.1.11' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index c2ab69a5..735f0a28 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 880de4ee..929b9d85 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index 6b8b55c7..3d175537 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 67825d17..e52f2738 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index f74dac74..ae454416 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index ac3e351c..595583e0 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index 06d12d28..6ad827e0 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 52ece9bd..d6649a40 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.10", + "version": "1.1.11", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 7f620434..42adb9be 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -370,6 +370,71 @@ Plugins apply in order: 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: { … } }`. +### Code highlighting + +Use `markdown.resolveHighlighter(lang)` to select a code-to-HTML callback for a language: + +```ts +import { definePlugin } from '@purestack/ts-ssg' +import { renderCustomCode } from './custom-code' + +export const customCodePlugin = definePlugin({ + name: 'custom-code', + markdown: { + resolveHighlighter: (lang) => lang === 'custom' ? renderCustomCode : null, + }, +}) +``` + +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`. 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. + +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 `
` block without syntax highlighting.
+
+#### Backend factories
+
+Use `markdown.createShiki(options)` or `markdown.createHighlightJs()` to create the default backend instance. Both factories can return an instance synchronously or asynchronously. Only the selected backend's factory runs, once when a build or dev server initializes its highlighter. If no factory is provided, PureStack uses its built-in factory. Factories are skipped when `mdx.disableHighlighter` is `true`. Two plugins providing a factory for the selected backend fail with both plugin names.
+
+The Shiki factory receives the native creation options containing PureStack's default languages and light/dark themes. Extend those options to add a TextMate grammar:
+
+```ts
+import { definePlugin } from '@purestack/ts-ssg'
+import { createHighlighter } from 'shiki'
+import grammar from './mylang.tmLanguage.json' with { type: 'json' }
+
+export const languagePlugin = definePlugin({
+  name: 'mylang',
+  markdown: {
+    createShiki: (options) => createHighlighter({
+      ...options,
+      langs: [...options.langs, grammar],
+    }),
+  },
+})
+```
+
+The highlight.js factory returns a configured `HLJSApi` instance. A fresh instance starts without languages, so register the languages your site needs:
+
+```ts
+import { definePlugin } from '@purestack/ts-ssg'
+import hljs from 'highlight.js'
+import typescript from 'highlight.js/lib/languages/typescript'
+import myLanguage from './mylang'
+
+export const languagePlugin = definePlugin({
+  name: 'mylang',
+  markdown: {
+    createHighlightJs: () => {
+      const instance = hljs.newInstance()
+      instance.registerLanguage('typescript', typescript)
+      instance.registerLanguage('mylang', myLanguage)
+      return instance
+    },
+  },
+})
+```
+
+These factories provide native backend instances for the default HTML adapters. `resolveHighlighter(lang)` still runs first for each code block; its returned callback takes precedence over the configured backend.
+
 ### Hooks
 
 Every page render, in a full build or a dev server re-render, runs the page hooks:
@@ -618,7 +683,7 @@ Types:
 - Templates: `PageTemplateMap`, `PageTemplate`, `PageTemplateInput`, `PageInfo`, `PageFrontmatter`
 - Config and components: `SiteConfig`, `SiteConfigInput`, `PageContext`
 - Dev server: `DevServerInput`, `DevServerOptions`, `DevServerHandle`
-- Highlighting: `MdxCodeHighlighter`, `MdxCodeLangs`, `MdxCodeThemes`
+- Highlighting: `MdxCodeHighlighter`, `MdxCodeLangs`, `MdxCodeThemes`, `MdxShikiFactory`
 
 Related packages: `@purestack/ts-html` builds template markup (`h`), `@purestack/ts-style` provides skins (`themeSkins`, `ThemeSkin`), and `@purestack/ts-components` provides the built-in components.
 
diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json
index 83a229f1..c21dd3e4 100644
--- a/packages/ts-ssg/package.json
+++ b/packages/ts-ssg/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@purestack/ts-ssg",
-  "version": "1.1.10",
+  "version": "1.1.11",
   "packageManager": "yarn@4.9.2",
   "module": "./dist/ts-ssg.mjs",
   "types": "./dist/ts-ssg.d.mts",
diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts
index d1c857af..fb4719ea 100644
--- a/packages/ts-ssg/src/build/incremental/index.ts
+++ b/packages/ts-ssg/src/build/incremental/index.ts
@@ -97,7 +97,7 @@ async function createIncrementalRuntime(
   const minifyScripts = publishOptions.enabled === true
   const failOnAssetError = publishOptions.enabled === true
   const mdx = {
-    ...(await resolveMdxBuildOptions(config.mdx)),
+    ...(await resolveMdxBuildOptions(config.mdx, plugins)),
     contentProcessor: resolvePluginContentProcessor(plugins),
   }
   themes.setOptions(config.style.theme)
diff --git a/packages/ts-ssg/src/build/incremental/support.ts b/packages/ts-ssg/src/build/incremental/support.ts
index 4b52198f..93d9ceeb 100644
--- a/packages/ts-ssg/src/build/incremental/support.ts
+++ b/packages/ts-ssg/src/build/incremental/support.ts
@@ -22,7 +22,11 @@ import {
 } from '../../mdx/highlight'
 import { createHljsHighlighter } from '../../mdx/highlightjs'
 import { generatePluginPages } from '../../plugins/generated-pages'
-import type { PureStackPlugin } from '../../plugins/plugin'
+import {
+  type PureStackPlugin,
+  resolvePluginHighlighter,
+  resolvePluginHighlighterFactory,
+} from '../../plugins/plugin'
 import {
   assertUniqueContentRoutes,
   resolveRouteInfo,
@@ -128,8 +132,12 @@ export class ManifestContentIndex {
 
 export async function resolveMdxBuildOptions(
   mdx: Partial | undefined,
+  plugins: readonly PureStackPlugin[] = [],
 ): Promise {
-  const highlighter = await resolveHighlighter(mdx)
+  const highlighter = resolvePluginHighlighter(
+    plugins,
+    await resolveHighlighter(mdx, plugins),
+  )
   return {
     highlighter,
     compileMdAsMdx: mdx?.compileMdAsMdx ?? true,
@@ -138,14 +146,17 @@ export async function resolveMdxBuildOptions(
 
 async function resolveHighlighter(
   mdx: Partial | undefined,
+  plugins: readonly PureStackPlugin[],
 ): Promise {
   if (mdx?.disableHighlighter) return undefined
   if (mdx?.highlighter === 'highlightjs') {
-    return createHljsHighlighter()
+    const create = resolvePluginHighlighterFactory(plugins, 'createHighlightJs')
+    return createHljsHighlighter(await create?.())
   }
   return await createMdxHighlighter(
     DEFAULT_MDX_CODE_THEMES,
     DEFAULT_MDX_CODE_LANGS,
+    resolvePluginHighlighterFactory(plugins, 'createShiki'),
   )
 }
 
diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts
index 01f1ef9f..a9acd10d 100644
--- a/packages/ts-ssg/src/config/head.ts
+++ b/packages/ts-ssg/src/config/head.ts
@@ -25,7 +25,7 @@ export function getHead(config?: BasicHeadConfig) {
   const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push(
     h('meta').attr({
       name: 'generator',
-      content: 'PureStack v1.1.10',
+      content: 'PureStack v1.1.11',
     }),
     h('meta').attr({
       name: 'color-scheme',
diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts
index 5454eb09..a77a81cf 100644
--- a/packages/ts-ssg/src/index.ts
+++ b/packages/ts-ssg/src/index.ts
@@ -44,6 +44,7 @@ export {
   type MdxCodeHighlighter,
   type MdxCodeLangs,
   type MdxCodeThemes,
+  type MdxShikiFactory,
 } from './mdx/highlight'
 export {
   buildNavigation,
diff --git a/packages/ts-ssg/src/mdx/codeHighlighting.test.ts b/packages/ts-ssg/src/mdx/codeHighlighting.test.ts
new file mode 100644
index 00000000..7ff6b8b0
--- /dev/null
+++ b/packages/ts-ssg/src/mdx/codeHighlighting.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest'
+import { createHljsHighlighter } from './highlightjs'
+import { compileMarkdown } from './md'
+import { compileMdx } from './mdx'
+
+describe('code highlighting', () => {
+  it.each([compileMarkdown, compileMdx])(
+    'awaits arbitrary block HTML through the existing hook',
+    async (compile) => {
+      const html =
+        '
{{ literal }}

Caption

' + const result = await compile('```diagram\nsource\n```', { + highlighter: { codeToHtml: async () => html }, + }) + expect(result.bodyHtml).toBe(html) + }, + ) + + it('awaits the same hook inside component markup', async () => { + const result = await compileMdx( + '\n```diagram\nsource\n```\n', + { + highlighter: { + codeToHtml: async (code) => `
${code}
`, + }, + }, + ) + expect(result.bodyHtml).toContain( + '\n
source
\n
', + ) + expect(result.bodyHtml).not.toContain(' { + const calls: string[] = [] + await compileMdx( + [ + '', + '```custom', + 'first', + '```', + '', + '', + 'Between components.', + '', + '', + '```custom', + 'second', + '```', + '', + ].join('\n'), + { + highlighter: { + codeToHtml: async (code) => { + calls.push(`start:${code}`) + await Promise.resolve() + calls.push(`end:${code}`) + return `
${code}
` + }, + }, + }, + ) + expect(calls).toEqual([ + 'start:first', + 'end:first', + 'start:second', + 'end:second', + ]) + }) + + it.each([compileMarkdown, compileMdx])( + 'honors empty custom HTML without restoring a code block', + async (compile) => { + const source = + compile === compileMdx + ? '\n```custom\nsource\n```\n' + : '```custom\nsource\n```' + const result = await compile(source, { + highlighter: { codeToHtml: () => '' }, + }) + expect(result.bodyHtml).not.toContain('source') + expect(result.bodyHtml).not.toContain(' { + const fallback = createHljsHighlighter() + const source = + '```ts\nconst answer = 42\n```\n\n`value`\n\n\n```ts\nconst nested = 1\n```\n`nested`\n' + const original = await compileMdx(source, { highlighter: fallback }) + const result = await compileMdx(source, { + highlighter: { + codeToHtml: async (code, lang) => fallback.codeToHtml(code, lang), + }, + }) + expect(result).toEqual(original) + }) + + it.each([compileMarkdown, compileMdx])( + 'propagates asynchronous highlighter errors', + async (compile) => { + await expect( + compile('```diagram\nbroken\n```', { + highlighter: { + codeToHtml: async () => { + throw new Error('invalid diagram') + }, + }, + }), + ).rejects.toThrow('invalid diagram') + }, + ) +}) diff --git a/packages/ts-ssg/src/mdx/shikiHighlighting.ts b/packages/ts-ssg/src/mdx/codeHighlighting.ts similarity index 76% rename from packages/ts-ssg/src/mdx/shikiHighlighting.ts rename to packages/ts-ssg/src/mdx/codeHighlighting.ts index b1d8cf9d..70c970d2 100644 --- a/packages/ts-ssg/src/mdx/shikiHighlighting.ts +++ b/packages/ts-ssg/src/mdx/codeHighlighting.ts @@ -1,44 +1,46 @@ import type { Element, Root, Text } from 'hast' import { fromHtml } from 'hast-util-from-html' import { toHtml } from 'hast-util-to-html' +import type { Raw } from 'mdast-util-to-hast' import type { MdxCodeHighlighter } from './highlight' -export function applyShikiHighlighting( +/** Applies the configured highlighter to code blocks and inline code. */ +export async function applyCodeHighlighting( root: Root, highlighter: MdxCodeHighlighter, ) { - const visit = (node: Root | Element) => { + const visit = async (node: Root | Element) => { const children = node.children ?? [] for (let index = 0; index < children.length; index += 1) { const child = children[index] if (child.type !== 'element') continue if (child.tagName === 'pre') { - const highlighted = highlightPre(child, highlighter) + const highlighted = await highlightCodeBlock(child, highlighter) if (highlighted) { children[index] = highlighted continue } } if (child.tagName === 'code' && !isPre(node)) { - const highlighted = highlightInlineCode(child, highlighter) + const highlighted = await highlightInlineCode(child, highlighter) if (highlighted) { children[index] = highlighted continue } } if (child.children && child.children.length > 0) { - visit(child) + await visit(child) } } } - visit(root) + await visit(root) } -function highlightPre( +async function highlightCodeBlock( pre: Element, highlighter: MdxCodeHighlighter, -): Element | undefined { +): Promise { const code = pre.children?.find( (child) => child.type === 'element' && child.tagName === 'code', ) as Element | undefined @@ -46,17 +48,17 @@ function highlightPre( const language = resolveLanguage(code) ?? resolveLanguage(pre) const text = collectText(code).trimEnd() if (text.length === 0) return undefined - return renderHighlightedPre(text, language, highlighter) + return renderHighlightedCodeBlock(text, language, highlighter) } -function highlightInlineCode( +async function highlightInlineCode( code: Element, highlighter: MdxCodeHighlighter, -): Element | undefined { +): Promise { const language = resolveLanguage(code) const text = collectText(code).trimEnd() if (text.length === 0) return undefined - const html = highlighter.codeToHtml(text, language) + const html = await highlighter.codeToHtml(text, language) const parsed = fromHtml(html, { fragment: true }) const replacement = parsed.children?.find( (child) => child.type === 'element' && child.tagName === 'pre', @@ -86,38 +88,49 @@ function highlightInlineCode( return code } -function renderHighlightedPre( +/** Preserves arbitrary block HTML; a single pre uses the site's code styling. */ +async function renderHighlightedCodeBlock( text: string, language: string | undefined, highlighter: MdxCodeHighlighter, -): Element | undefined { - const html = highlighter.codeToHtml(text, language) +): Promise { + const html = await highlighter.codeToHtml(text, language) const parsed = fromHtml(html, { fragment: true }) - const replacement = parsed.children?.find( - (child) => child.type === 'element' && child.tagName === 'pre', - ) as Element | undefined - if (!replacement) return undefined - normalizeShikiPre(replacement) + const replacement = parsed.children[0] + if ( + parsed.children.length !== 1 || + replacement?.type !== 'element' || + replacement.tagName !== 'pre' + ) { + return { type: 'raw', value: html } + } + normalizeHighlightedPre(replacement) return replacement } -export function renderHighlightedPreHtml( +/** Renders a code block inside component markup, keeping pre content literal. */ +export async function renderHighlightedCodeBlockHtml( text: string, language: string | undefined, highlighter: MdxCodeHighlighter, -): string | undefined { - const replacement = renderHighlightedPre(text, language, highlighter) - if (!replacement) return undefined +): Promise { + const replacement = await renderHighlightedCodeBlock( + text, + language, + highlighter, + ) + if (replacement.type === 'raw') return replacement.value replacement.properties['r-pre'] = '' return toHtml(replacement, { allowDangerousHtml: true }) } -export function renderHighlightedInlineCodeHtml( +/** Extracts inline code from the highlighter's pre/code output. */ +export async function renderHighlightedInlineCodeHtml( text: string, language: string | undefined, highlighter: MdxCodeHighlighter, -): string | undefined { - const html = highlighter.codeToHtml(text, language) +): Promise { + const html = await highlighter.codeToHtml(text, language) const parsed = fromHtml(html, { fragment: true }) const replacement = parsed.children?.find( (child) => child.type === 'element' && child.tagName === 'pre', @@ -184,7 +197,8 @@ function isPre(node: Root | Element) { return node.type === 'element' && node.tagName === 'pre' } -function normalizeShikiPre(pre: Element) { +/** The site's shared code styles use the shiki class for every highlighter. */ +function normalizeHighlightedPre(pre: Element) { if (!pre.properties) pre.properties = {} pre.properties.className = mergeClassNames(pre.properties.className, 'shiki') const style = diff --git a/packages/ts-ssg/src/mdx/compile.ts b/packages/ts-ssg/src/mdx/compile.ts index de2ea437..4f75304f 100644 --- a/packages/ts-ssg/src/mdx/compile.ts +++ b/packages/ts-ssg/src/mdx/compile.ts @@ -5,8 +5,8 @@ import type { Root as MdastRoot } from 'mdast' import rehypeStringify from 'rehype-stringify' import remarkRehype from 'remark-rehype' import { type PluggableList, unified } from 'unified' +import { applyCodeHighlighting } from './codeHighlighting' import type { MdxCodeHighlighter } from './highlight' -import { applyShikiHighlighting } from './shikiHighlighting' export interface MdxCompileResult { bodyHtml: string @@ -56,7 +56,7 @@ export async function compileAstToHtml( wrapTablesInScrollContainers(tree) const outline = collectOutline(tree) if (options.highlighter) { - applyShikiHighlighting(tree, options.highlighter) + await applyCodeHighlighting(tree, options.highlighter) } keepCodeLiteral(tree) const bodyHtml = String( diff --git a/packages/ts-ssg/src/mdx/highlight.ts b/packages/ts-ssg/src/mdx/highlight.ts index 6978bac5..adc1ea0b 100644 --- a/packages/ts-ssg/src/mdx/highlight.ts +++ b/packages/ts-ssg/src/mdx/highlight.ts @@ -1,9 +1,13 @@ import { createHighlighter, type Highlighter } from 'shiki' export interface MdxCodeHighlighter { - codeToHtml: (code: string, lang?: string) => string + codeToHtml: (code: string, lang?: string) => string | Promise } +export type MdxShikiFactory = ( + options: Parameters[0], +) => Highlighter | Promise + export interface MdxCodeThemes { light: string dark: string @@ -53,10 +57,11 @@ const TYPESCRIPT_WITH_TAGS_LANG = 'ts-tags' export async function createMdxHighlighter( themes: MdxCodeThemes = DEFAULT_MDX_CODE_THEMES, langs: MdxCodeLangs = DEFAULT_MDX_CODE_LANGS, + create: MdxShikiFactory = createHighlighter, ): Promise { const themeList = dedupe([themes.light, themes.dark]) const langList = resolveLoadedLanguages(langs) - const highlighter: Highlighter = await createHighlighter({ + const highlighter = await create({ themes: themeList, langs: langList, }) diff --git a/packages/ts-ssg/src/mdx/highlightjs.ts b/packages/ts-ssg/src/mdx/highlightjs.ts index c9a8fe03..9653ce0f 100644 --- a/packages/ts-ssg/src/mdx/highlightjs.ts +++ b/packages/ts-ssg/src/mdx/highlightjs.ts @@ -1,4 +1,4 @@ -import hljs from 'highlight.js' +import hljs, { type HLJSApi } from 'highlight.js' import type { MdxCodeHighlighter } from './highlight' @@ -11,12 +11,14 @@ const languageAliases: Record = { md: 'markdown', } -export function createHljsHighlighter(): MdxCodeHighlighter { +export function createHljsHighlighter( + highlighter: HLJSApi = hljs, +): MdxCodeHighlighter { return { codeToHtml: (code: string, lang?: string) => { const normalizedLang = normalizeLanguage(lang) - if (normalizedLang && hljs.getLanguage(normalizedLang)) { - const highlighted = hljs.highlight(code, { + if (normalizedLang && highlighter.getLanguage(normalizedLang)) { + const highlighted = highlighter.highlight(code, { language: normalizedLang, ignoreIllegals: true, }) diff --git a/packages/ts-ssg/src/mdx/mdx.test.ts b/packages/ts-ssg/src/mdx/mdx.test.ts index 07aa30ad..eff6c3dc 100644 --- a/packages/ts-ssg/src/mdx/mdx.test.ts +++ b/packages/ts-ssg/src/mdx/mdx.test.ts @@ -19,7 +19,7 @@ describe('createMdxHighlighter', () => { // biome-ignore lint/suspicious/noTemplateCurlyInString: Literal TypeScript source for the highlighter. 'const view = html`
Hello ${name}
`', ].join('\n') - const html = highlighter.codeToHtml(source, lang) + const html = await highlighter.codeToHtml(source, lang) const cleanup = ensureDomGlobals() try { const root = document.createElement('div') diff --git a/packages/ts-ssg/src/mdx/mdx.ts b/packages/ts-ssg/src/mdx/mdx.ts index edafe346..611938a3 100644 --- a/packages/ts-ssg/src/mdx/mdx.ts +++ b/packages/ts-ssg/src/mdx/mdx.ts @@ -20,11 +20,12 @@ export { type MdxCodeHighlighter, type MdxCodeLangs, type MdxCodeThemes, + type MdxShikiFactory, } from './highlight' export type { MdxCompileResult, MdxRenderOptions } -export function compileMdx( +export async function compileMdx( source: string, options: MdxRenderOptions = {}, ): Promise { @@ -35,7 +36,7 @@ export function compileMdx( const file = parser.parse(masked.source) restoreRegorMarkup( file, - renderRegorMarkupCodeFences(masked.segments, options.highlighter), + await renderRegorMarkupCodeFences(masked.segments, options.highlighter), ) normalizeMarkupParagraphs(file) return compileAstToHtml(file, options) diff --git a/packages/ts-ssg/src/mdx/regorMarkup.ts b/packages/ts-ssg/src/mdx/regorMarkup.ts index 15af3464..574fcd39 100644 --- a/packages/ts-ssg/src/mdx/regorMarkup.ts +++ b/packages/ts-ssg/src/mdx/regorMarkup.ts @@ -34,11 +34,11 @@ Internally, the interesting helpers are: So the short version is: `regorMarkup.ts` is the compatibility layer that lets this project support Regor component syntax inside `.mdx` files while still using a mostly markdown-first pipeline. */ -import type { MdxCodeHighlighter } from './highlight' import { + renderHighlightedCodeBlockHtml, renderHighlightedInlineCodeHtml, - renderHighlightedPreHtml, -} from './shikiHighlighting' +} from './codeHighlighting' +import type { MdxCodeHighlighter } from './highlight' type MarkdownNode = { type?: unknown @@ -191,15 +191,19 @@ export function restoreRegorMarkup(root: unknown, segments: MarkupSegment[]) { } } -export function renderRegorMarkupCodeFences( +export async function renderRegorMarkupCodeFences( segments: MarkupSegment[], highlighter?: MdxCodeHighlighter, ) { if (segments.length === 0) return segments - return segments.map((segment) => ({ - ...segment, - source: replaceMarkupCodeFences(segment.source, highlighter), - })) + const rendered: MarkupSegment[] = [] + for (const segment of segments) { + rendered.push({ + ...segment, + source: await replaceMarkupCodeFences(segment.source, highlighter), + }) + } + return rendered } export function normalizeMarkupParagraphs(root: unknown) { @@ -591,7 +595,7 @@ function isParagraphNode(node: unknown): node is ParagraphNode { ) } -function replaceMarkupCodeFences( +async function replaceMarkupCodeFences( source: string, highlighter?: MdxCodeHighlighter, ) { @@ -613,14 +617,14 @@ function replaceMarkupCodeFences( if (markupStart < index) { next.push( - replaceMarkupInlineCode( + await replaceMarkupInlineCode( lines.slice(markupStart, index).join('\n'), highlighter, ), ) } next.push( - renderCodeFenceBlock(body.join('\n'), fence.language, highlighter), + await renderCodeFenceBlock(body.join('\n'), fence.language, highlighter), ) index = closeIndex markupStart = closeIndex + 1 @@ -628,7 +632,10 @@ function replaceMarkupCodeFences( if (markupStart < lines.length) { next.push( - replaceMarkupInlineCode(lines.slice(markupStart).join('\n'), highlighter), + await replaceMarkupInlineCode( + lines.slice(markupStart).join('\n'), + highlighter, + ), ) } return next.join('\n') @@ -672,14 +679,13 @@ function stripFenceIndent(line: string, indent: string) { return indent && line.startsWith(indent) ? line.slice(indent.length) : line } -function renderCodeFenceBlock( +async function renderCodeFenceBlock( code: string, language: string | undefined, highlighter?: MdxCodeHighlighter, ) { if (highlighter) { - const highlighted = renderHighlightedPreHtml(code, language, highlighter) - if (highlighted) return highlighted + return renderHighlightedCodeBlockHtml(code, language, highlighter) } const languageClass = language @@ -688,7 +694,7 @@ function renderCodeFenceBlock( return `
${escapeHtml(code)}
` } -function replaceMarkupInlineCode( +async function replaceMarkupInlineCode( source: string, highlighter?: MdxCodeHighlighter, ) { @@ -696,12 +702,12 @@ function replaceMarkupInlineCode( for (let index = 0; index < parts.length; index += 1) { const part = parts[index] if (!part || looksLikeTag(part)) continue - parts[index] = replaceInlineCodeInText(part, highlighter) + parts[index] = await replaceInlineCodeInText(part, highlighter) } return parts.join('') } -function replaceInlineCodeInText( +async function replaceInlineCodeInText( value: string, highlighter?: MdxCodeHighlighter, ) { @@ -729,7 +735,7 @@ function replaceInlineCodeInText( } next += value.slice(index, start) - next += renderInlineCodeSpan(value.slice(start + 1, end), highlighter) + next += await renderInlineCodeSpan(value.slice(start + 1, end), highlighter) index = end + 1 } @@ -743,9 +749,12 @@ function findInlineCodeEnd(value: string, startIndex: number) { return -1 } -function renderInlineCodeSpan(code: string, highlighter?: MdxCodeHighlighter) { +async function renderInlineCodeSpan( + code: string, + highlighter?: MdxCodeHighlighter, +) { if (highlighter) { - const highlighted = renderHighlightedInlineCodeHtml( + const highlighted = await renderHighlightedInlineCodeHtml( code, undefined, highlighter, diff --git a/packages/ts-ssg/src/plugins/plugin.test.ts b/packages/ts-ssg/src/plugins/plugin.test.ts index 13bb67c2..f1f2ce13 100644 --- a/packages/ts-ssg/src/plugins/plugin.test.ts +++ b/packages/ts-ssg/src/plugins/plugin.test.ts @@ -3,9 +3,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import path from 'node:path' import { h } from '@purestack/ts-html' import { registerSkin, themeSkins } from '@purestack/ts-style' +import hljs from 'highlight.js' +import typescript from 'highlight.js/lib/languages/typescript' import { disableLogger, getLogger, type Logger } from 'logpot' import { defineComponent, html } from 'regor' +import { + createHighlighter, + type Highlighter, + type LanguageRegistration, +} from 'shiki' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { resolveMdxBuildOptions } from '../build/incremental/support' import { buildSite } from '../build/site' import { resolveSiteConfig } from '../config/config' import { makeRepoTempDir } from '../test/repoTempDir' @@ -16,6 +24,7 @@ import { definePlugin, type PureStackPlugin, resolvePluginComponents, + resolvePluginHighlighter, resolvePluginTemplates, withPluginSkins, } from './plugin' @@ -42,6 +51,310 @@ describe('plugins', () => { expect(definePlugin(plugin)).toBe(plugin) }) + it('awaits resolver initialization and supports sync and async rendering callbacks', async () => { + const fallback = { + codeToHtml: (code: string) => `
${code}
`, + } + expect(resolvePluginHighlighter([], fallback)).toBe(fallback) + const highlighter = resolvePluginHighlighter( + [ + { + name: 'custom', + markdown: { + resolveHighlighter: async (lang) => { + if (lang.startsWith('diagram-')) + return async (code) => `
${code}
` + if (lang === 'custom') return (code) => `
${code}
` + return null + }, + }, + }, + ], + fallback, + ) + if (!highlighter) throw new Error('Expected a composed highlighter.') + expect(await highlighter.codeToHtml('graph', ' DIAGRAM-FLOW ')).toBe( + '
graph
', + ) + expect(await highlighter.codeToHtml('source', 'custom')).toBe( + '
source
', + ) + expect(await highlighter.codeToHtml('source', 'ts')).toBe( + '
source
', + ) + }) + + it('keeps custom languages working when default highlighting is disabled and escapes unmatched source', async () => { + const options = await resolveMdxBuildOptions({ disableHighlighter: true }, [ + { + name: 'custom', + markdown: { + createShiki: () => { + throw new Error('Shiki creation is disabled.') + }, + createHighlightJs: () => { + throw new Error('Highlight.js creation is disabled.') + }, + resolveHighlighter: (lang) => + lang === 'diagram' ? async () => '
rendered
' : null, + }, + }, + ]) + const highlighter = options.highlighter + if (!highlighter) throw new Error('Expected custom language handling.') + expect(await highlighter.codeToHtml('graph', 'diagram')).toBe( + '
rendered
', + ) + expect( + await highlighter.codeToHtml('', 'html'), + ).toBe( + '
<script>source</script>
', + ) + expect( + (await resolveMdxBuildOptions({ disableHighlighter: true })).highlighter, + ).toBeUndefined() + }) + + it('tries sync and async resolvers in plugin order and stops at the first callback', async () => { + const calls: string[] = [] + const highlighter = resolvePluginHighlighter( + ['first', 'second', 'third'].map((name) => ({ + name, + markdown: { + resolveHighlighter: (lang) => { + calls.push(`${name}:${lang}`) + if (name === 'first') return Promise.resolve(null) + if (lang === 'diagram') return () => `
${name}
` + if (lang === 'hidden') return () => '' + return null + }, + }, + })), + { + codeToHtml: () => { + calls.push('default') + return '
default
' + }, + }, + ) + if (!highlighter) throw new Error('Expected a composed highlighter.') + expect(await highlighter.codeToHtml('graph', 'diagram')).toBe( + '
second
', + ) + expect(calls).toEqual(['first:diagram', 'second:diagram']) + calls.length = 0 + expect(await highlighter.codeToHtml('source', 'hidden')).toBe('') + expect(calls).toEqual(['first:hidden', 'second:hidden']) + calls.length = 0 + expect(await highlighter.codeToHtml('source')).toBe('
default
') + expect(calls).toEqual(['first:', 'second:', 'third:', 'default']) + }) + + it('propagates asynchronous resolver failures without falling back', async () => { + let fallbackCalled = false + const highlighter = resolvePluginHighlighter( + [ + { + name: 'failing', + markdown: { + resolveHighlighter: async () => { + throw new Error('Could not initialize highlighter.') + }, + }, + }, + ], + { + codeToHtml: () => { + fallbackCalled = true + return '
default
' + }, + }, + ) + if (!highlighter) throw new Error('Expected a composed highlighter.') + await expect(highlighter.codeToHtml('source', 'custom')).rejects.toThrow( + 'Could not initialize highlighter.', + ) + expect(fallbackCalled).toBe(false) + }) + + it.each(['resolveHighlighter', 'createShiki', 'createHighlightJs'])( + 'validates markdown.%s declarations', + (field) => { + for (const value of [[], {}, 42, null]) { + expect(() => + assertValidPlugins([ + { name: 'bad', markdown: { [field]: value } } as never, + ]), + ).toThrow(`Plugin "bad" needs \`markdown.${field}\` to be a function.`) + } + }, + ) + + describe('highlighter factories', () => { + it.each(['sync', 'async'])( + 'uses a %s Shiki factory with default options and custom grammars', + async (mode) => { + let created: Highlighter | undefined + let calls = 0 + const grammar = { + name: 'creator-test', + scopeName: 'source.creator-test', + aliases: ['creator-test-alias'], + repository: {}, + patterns: [ + { name: 'keyword.control.creator-test', match: '\\blaunch\\b' }, + ], + } satisfies LanguageRegistration + try { + // A pre-created instance may be returned synchronously or asynchronously. + if (mode === 'sync') { + created = await createHighlighter({ + themes: ['github-light', 'github-dark'], + langs: ['ts-tags', grammar], + }) + } + const options = await resolveMdxBuildOptions( + { highlighter: 'shiki' }, + [ + { + name: 'custom-shiki', + markdown: { + createShiki: (defaults) => { + calls += 1 + expect(defaults.langs).toContain('ts-tags') + expect(defaults.themes).toEqual([ + 'github-light', + 'github-dark', + ]) + if (created) return created + return createHighlighter({ + ...defaults, + langs: [...defaults.langs, grammar], + }).then((instance) => { + created = instance + return instance + }) + }, + createHighlightJs: () => { + throw new Error('Unused backend.') + }, + }, + }, + ], + ) + if (!options.highlighter) + throw new Error('Expected Shiki highlighting.') + const first = options.highlighter.codeToHtml( + 'launch', + 'creator-test-alias', + ) + expect(typeof first).toBe('string') + expect(first).toContain('launch') + expect(first).toContain('--shiki-light:') + expect( + options.highlighter.codeToHtml('launch again', 'creator-test'), + ).toContain('launch') + expect(calls).toBe(1) + expect( + options.highlighter.codeToHtml('const answer = 42', 'ts'), + ).toContain('answer') + } finally { + created?.dispose() + } + }, + ) + + it.each(['sync', 'async'])( + 'uses a %s highlight.js factory without changing the global registry', + async (mode) => { + let calls = 0 + const options = await resolveMdxBuildOptions( + { highlighter: 'highlightjs' }, + [ + { + name: 'custom-highlightjs', + markdown: { + createHighlightJs: () => { + calls += 1 + const instance = hljs.newInstance() + instance.registerLanguage('typescript', typescript) + instance.registerLanguage('creator-test', () => ({ + keywords: 'launch', + aliases: ['creator-test-alias'], + contains: [], + })) + instance.configure({ classPrefix: 'custom-' }) + return mode === 'async' ? Promise.resolve(instance) : instance + }, + createShiki: () => { + throw new Error('Unused backend.') + }, + }, + }, + ], + ) + if (!options.highlighter) + throw new Error('Expected highlight.js highlighting.') + const html = options.highlighter.codeToHtml( + 'launch', + 'creator-test-alias', + ) + expect(typeof html).toBe('string') + expect(html).toContain('launch') + expect( + options.highlighter.codeToHtml('const answer = 42', 'ts'), + ).toContain('custom-keyword') + expect(calls).toBe(1) + expect(hljs.getLanguage('creator-test')).toBeUndefined() + const defaults = await resolveMdxBuildOptions({ + highlighter: 'highlightjs', + }) + expect( + defaults.highlighter?.codeToHtml('const answer = 42', 'ts'), + ).toContain('hljs-keyword') + }, + ) + + it.each(['shiki', 'highlightjs'] as const)( + 'rejects competing %s factories before creating an instance', + async (backend) => { + const field = backend === 'shiki' ? 'createShiki' : 'createHighlightJs' + const plugins = ['first', 'second'].map((name) => ({ + name, + markdown: { + [field]: () => { + throw new Error('Must not create an instance.') + }, + }, + })) + await expect( + resolveMdxBuildOptions({ highlighter: backend }, plugins), + ).rejects.toThrow( + `Plugins "first" and "second" both define markdown.${field}.`, + ) + }, + ) + + it.each(['shiki', 'highlightjs'] as const)( + 'propagates %s factory failures', + async (backend) => { + const field = backend === 'shiki' ? 'createShiki' : 'createHighlightJs' + await expect( + resolveMdxBuildOptions({ highlighter: backend }, [ + { + name: 'failing', + markdown: { + [field]: async () => { + throw new Error('Creation failed.') + }, + }, + }, + ]), + ).rejects.toThrow('Creation failed.') + }, + ) + }) + describe('hooks', () => { it('runs every plugin handler in plugin order, one after another', async () => { const calls: string[] = [] @@ -309,7 +622,7 @@ describe('plugins', () => { ], [ { name: 'typo', markdown: { remark: [] } }, - 'Plugin "typo" has an unknown markdown field "remark". Markdown fields: remarkPlugins, rehypePlugins.', + 'Plugin "typo" has an unknown markdown field "remark". Markdown fields: remarkPlugins, rehypePlugins, resolveHighlighter, createShiki, createHighlightJs.', ], [ { name: 'bad', markdown: { rehypePlugins: () => {} } }, @@ -386,6 +699,20 @@ describe('plugins', () => { '', '', 'Page note.', + '', + '```diagram', + 'custom block', + '```', + '', + '', + '```diagram', + 'nested block', + '```', + '', + '', + '```ts', + 'const answer = 42', + '```', ].join('\n'), ) await fs.writeFile(path.join(contentDir, 'header.mdx'), 'Header note.') @@ -422,7 +749,13 @@ describe('plugins', () => { .raw(bodyHtml), ), }, - markdown: { rehypePlugins: [markParagraphs] }, + markdown: { + rehypePlugins: [markParagraphs], + resolveHighlighter: async (lang) => + lang === 'diagram' + ? async (code) => `
${code}
` + : null, + }, hooks: { onConfigResolved: () => { calls.push('config') @@ -449,6 +782,9 @@ describe('plugins', () => { ) expect(pageHtml).toContain('') expect(pageHtml).toContain('Hello Ada') + expect(pageHtml).toContain('
custom block
') + expect(pageHtml).toContain('
nested block
') + expect(pageHtml).toContain('language-typescript') // Content plugins reach pages and shared partials alike. expect(pageHtml).toContain('

Page note.

') expect(pageHtml).toContain('

Header note.

') diff --git a/packages/ts-ssg/src/plugins/plugin.ts b/packages/ts-ssg/src/plugins/plugin.ts index eb0d77c7..88120b46 100644 --- a/packages/ts-ssg/src/plugins/plugin.ts +++ b/packages/ts-ssg/src/plugins/plugin.ts @@ -1,11 +1,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { PageTemplateMap, SiteConfig } from '@purestack/ts-common' import { registerSkin, type ThemeSkin, themeSkins } from '@purestack/ts-style' -import { isPlainObject } from '@purestack/ts-util' +import { escapeHtml, isPlainObject } from '@purestack/ts-util' +import type { HLJSApi } from 'highlight.js' import type { PluggableList } from 'unified' import type { BuildHooks } from '../build/site' import type { ResolvedContentFile } from '../i18n/content' import { createContentProcessor } from '../mdx/compile' +import type { MdxCodeHighlighter, MdxShikiFactory } from '../mdx/highlight' /** * A named bundle of extensions for a PureStack site. Plugins compose in @@ -21,7 +23,7 @@ export interface PureStackPlugin { components?: (config: SiteConfig) => Record /** Page templates, selected by the `template` frontmatter field. */ templates?: PageTemplateMap - /** Remark and rehype plugins, run on every page and shared partial. */ + /** Markdown transforms and highlighter resolution for pages and shared partials. */ markdown?: PureStackMarkdown /** * Pages without files, such as tag or index pages. Runs whenever the @@ -65,6 +67,17 @@ export interface GeneratedPage { } export interface PureStackMarkdown { + /** Creates Shiki from PureStack's default creation options. Only used when Shiki is selected. */ + createShiki?: MdxShikiFactory + /** Creates the configured highlight.js instance. Only used when highlight.js is selected. */ + createHighlightJs?: () => HLJSApi | Promise + /** Resolves a lowercase language (empty when absent); null lets the next highlighter handle it. */ + resolveHighlighter?: ( + lang: string, + ) => + | MdxCodeHighlighter['codeToHtml'] + | null + | Promise /** Transform the Markdown tree, after Regor markup is restored. */ remarkPlugins?: PluggableList /** Transform the HTML tree, before the outline and code highlighting. */ @@ -85,7 +98,13 @@ const PLUGIN_FIELDS = [ 'devMiddleware', 'hooks', ] -const MARKDOWN_FIELDS = ['remarkPlugins', 'rehypePlugins'] +const MARKDOWN_FIELDS = [ + 'remarkPlugins', + 'rehypePlugins', + 'resolveHighlighter', + 'createShiki', + 'createHighlightJs', +] const HOOK_NAMES = [ 'onConfigResolved', @@ -164,8 +183,12 @@ function assertPluginFields(name: string, plugin: Record) { `has an unknown markdown field "${field}". Markdown fields: ${MARKDOWN_FIELDS.join(', ')}.`, ) } - if (list !== undefined && !Array.isArray(list)) { - fail(`needs \`markdown.${field}\` to be a list of plugins.`) + if (list === undefined) continue + if (field === 'remarkPlugins' || field === 'rehypePlugins') { + if (!Array.isArray(list)) + fail(`needs \`markdown.${field}\` to be a list of plugins.`) + } else if (typeof list !== 'function') { + fail(`needs \`markdown.${field}\` to be a function.`) } } } @@ -269,6 +292,46 @@ export function resolvePluginContentProcessor( ) } +/** A selected backend has one factory; choosing between competing factories would hide a plugin. */ +export function resolvePluginHighlighterFactory< + K extends 'createShiki' | 'createHighlightJs', +>(plugins: readonly PureStackPlugin[], field: K): PureStackMarkdown[K] { + let owner: PureStackPlugin | undefined + for (const plugin of plugins) { + if (plugin.markdown?.[field] === undefined) continue + if (owner) { + throw new Error( + `Plugins "${owner.name}" and "${plugin.name}" both define markdown.${field}.`, + ) + } + owner = plugin + } + return owner?.markdown?.[field] +} + +/** Tries plugin resolvers in order, then uses the default highlighter. */ +export function resolvePluginHighlighter( + plugins: readonly PureStackPlugin[], + fallback: MdxCodeHighlighter | undefined, +): MdxCodeHighlighter | undefined { + const resolvers = plugins.flatMap( + (plugin) => plugin.markdown?.resolveHighlighter ?? [], + ) + if (resolvers.length === 0) return fallback + return { + async codeToHtml(code, lang) { + const normalizedLang = lang?.trim().toLowerCase() ?? '' + for (const resolve of resolvers) { + const highlighter = await resolve(normalizedLang) + if (highlighter !== null) return highlighter(code, lang) + } + if (fallback) return fallback.codeToHtml(code, lang) + const language = lang ? ` class="language-${escapeHtml(lang, true)}"` : '' + return `
${escapeHtml(code)}
` + }, + } +} + /** * Runs each plugin's dev middleware in order until one responds. Returns * whether a plugin handled the request. diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index 4a8a2545..18db8f77 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 9970a7eb..1528904c 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index f74a7dd2..a141cdfe 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index e7853e81..34c2098f 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.10", + "version": "1.1.11", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts",