import type * as Mdast from 'mdast' import type { IThemeRegistration, Lang, ILanguageRegistration } from 'shiki' import { createHash } from 'crypto' import { visit } from 'unist-util-visit' import { toHtml } from 'hast-util-to-html' import * as shiki from 'shiki' // @ts-ignore import { escape } from 'html-escaper' import { unified } from 'unified' import rehypeParse from 'rehype-parse' import rangeParser from 'parse-numeric-range' import { cx } from '$lib/twind' // js title="..." showLineNumbers {1-3,4} /needle/3-5 // diff-js title focus=1-3,5 // // [(diff-)?lang]? [title|title="..."]? [showLineNumbers|line-numbers]? [highlightLines]* [highlightTerm]* [focusLines]* // highlightLines: {1-3,5} or highlight=1-3,5 // highlightTerm: /needle/ or /needle/3-5 (Highlight only the third to fifth instances) // focusLines: [1-3,5] or focus=1-3,5 // line-numbers or showLineNumber // TODO: mark=2[16:26] // TODO: link=2[16:26] https://github.com/code-hike/codehike // TODO: copy diff include +/- // TODO: replace style="..." with class? // TODO: highlight inline code: https://rehype-pretty-code.netlify.app // TODO: https://github.com/kevin940726/remark-codesandbox interface ClassNames { figure: string figcaption: string toolbar: string lang: string copy: string pre: string code: string 'inline-code': string line: string 'line-highlight': string 'line-focus': string 'line-not-focus': string 'line-inserted': string 'line-removed': string 'line-unchanged': string 'term-highlight': string } export default function attacher({ themes, langs, classNames, tokenMap, }: { themes: { light: IThemeRegistration; dark: IThemeRegistration } classNames?: Partial /** * A list of languages to load upfront. */ langs?: ILanguageRegistration[] tokenMap?: Record }): import('unified').Transformer { return async function transformer(tree) { const [lightHighligher, darkHighlighter] = await Promise.all([ shiki.getHighlighter({ theme: themes.light, langs: [...(langs || []), ...shiki.BUNDLED_LANGUAGES], }), shiki.getHighlighter({ theme: themes.dark, langs: [...(langs || []), ...shiki.BUNDLED_LANGUAGES], }), ]) const loadedLanguages = lightHighligher.getLoadedLanguages() const light = { colors: {}, ...lightHighligher.getTheme() } const dark = { colors: {}, ...darkHighlighter.getTheme() } const resolvedClassNames: ClassNames = { figure: cx`relative group text-([${light.fg}] dark:[${dark.fg}]) bg-([${light.bg}] dark:[${dark.bg}]) border-([${light.colors['editorRuler.foreground']}] dark:[${dark.colors['editorRuler.foreground']}]) hover:border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-md shadow [data-line-numbers]:[counterReset:line]`, figcaption: cx`px-4 py-2 text-([${light.colors['tab.activeForeground']}] dark:[${dark.colors['tab.activeForeground']}]) bg-([${light.colors['tab.activeBackground']}] dark:[${dark.colors['tab.activeBackground']}]) border-b border-b-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-t-md`, toolbar: cx`flex mb-2 relative text-xs`, lang: cx`ml-4 px-3 py-1 text-([${light.colors['tab.unfocusedActiveForeground']}] dark:[${dark.colors['tab.unfocusedActiveForeground']}]) bg-([${light.colors['tab.inactiveBackground']}] dark:[${dark.colors['tab.inactiveBackground']}]) border-r border-b border-l border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-bl-md rounded-br-md shadow-sm uppercase`, copy: cx`absolute top-0 right-0 flex items-center justify-center w-8 h-7 border border-transparent rounded-md transition-all origin-bottom-left text-([${light.colors['tab.unfocusedActiveForeground']}] dark:[${dark.colors['tab.unfocusedActiveForeground']}]) group-hover:(bg-([${light.colors['tab.activeBackground']}]/75 dark:[${dark.colors['tab.activeBackground']}]/75) border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) shadow-sm scale-125) &&:hocus:(text-([${light.colors['button.foreground']}] dark:[${dark.colors['button.foreground']}]) bg-([${light.colors['button.hoverBackground']}] dark:[${dark.colors['button.hoverBackground']}]) border-([${light.colors['tab.unfocusedActiveBorder']}] dark:[${dark.colors['tab.unfocusedActiveBorder']}]) scale-125) &&:focus-visible:(border-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) ring-2 ring-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) outline-none) [data-clipboard-copy='success']:!text-([${light.colors['terminal.ansiBrightGreen']} dark:[${dark.colors['terminal.ansiBrightGreen']}]]) [data-clipboard-copy='error']:!text-([${light.colors['terminal.ansiBrightRed']}] dark:[${dark.colors['terminal.ansiBrightRed']}])`, pre: cx`m-0 p-0 pb-2 text-([${light.colors['editor.foreground']}] dark:[${dark.colors['editor.foreground']}]) bg-([${light.colors['editor.background']}] dark:[${dark.colors['editor.background']}]) border-none rounded-t-none`, code: cx`grid`, 'inline-code': cx`font-normal text-([${light.colors['editor.foreground']}] dark:[${dark.colors['editor.foreground']}]) bg-([${light.colors['editor.background']}] dark:[${dark.colors['editor.background']}]) dark:(mx-1 px-1 ring-1 ring-[${dark.colors['editor.background']}]/100 rounded-sm before:content-[''] after:content-[''])`, // TODO: with-line-numbers as own class line: cx`~(px-4 border-l-2 border-l-transparent empty:h-5 not-only-child:hover:bg-([${light.colors['editor.selectionBackground']}] dark:[${dark.colors['editor.selectionBackground']}]) [data-line-numbers]_&:not-only-child:hover:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}]) [data-line-numbers]_&:pl-2 [data-line-numbers]_&:before:(inline-block w-4 mr-4 text-right text-([${light.colors['editorLineNumber.foreground']}] dark:[${dark.colors['editorLineNumber.foreground']}]) content-[counter(line)] counter-increment[line]))`, 'line-highlight': cx`bg-([${light.colors['editor.lineHighlightBackground']}] dark:[${dark.colors['editor.lineHighlightBackground']}]) border-l-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) [data-line-numbers]_&:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}])`, 'line-focus': cx`opacity-100 [data-line-numbers]_&:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}])`, 'line-not-focus': cx`opacity-50`, 'line-inserted': cx`pl-2 bg-([${light.colors['diffEditor.insertedTextBackground']}] dark:[${dark.colors['diffEditor.insertedTextBackground']}]) before:(inline-block w-2 mr-1 content-['+'])`, 'line-removed': cx`pl-2 bg-([${light.colors['diffEditor.removedTextBackground']}] dark:[${dark.colors['diffEditor.removedTextBackground']}]) before:(inline-block w-2 mr-1 content-['-'])`, 'line-unchanged': cx`pl-2 opacity-50 before:(inline-block w-2 mr-1 content-[' '])`, 'term-highlight': cx`bg-([${light.colors['editor.selectionBackground']}] dark:[${dark.colors['editor.selectionBackground']}]) rounded-sm ring-2 ring-([${light.colors['editor.selectionBackground']}]/100 dark:[${dark.colors['editor.selectionBackground']}]/100)`, ...classNames, } const resolvedTokenMap: Record = { import: 'constant.other.symbol', module: 'constant.other.symbol', package: 'entity.name.module.js', fn: 'entity.name.function', function: 'entity.name.function', arg: 'variable.parameter', param: 'variable.parameter', parameter: 'variable.parameter', let: 'support.other.variable', const: 'support.other.variable', var: 'support.other.variable', variable: 'support.other.variable', nil: 'constant.language.undefined', undefined: 'constant.language.undefined', null: 'constant.language.null', ...tokenMap, } function highlight( code: string, lang: string | undefined, lineOptions?: { line: number; classes: string[] }[], ) { if (lang && !loadedLanguages.includes(lang as Lang)) { console.warn(`Unrecognised language: ${lang}`) lang = undefined } const darkCode = unified() .use(rehypeParse, { fragment: true }) .parse(darkHighlighter.codeToHtml(code, { lang: lang as Lang, lineOptions })) const darkClasses: string[][] = [] visit(darkCode, 'element', (node) => { if (node.properties) { darkClasses.push(styleToClassNames((node.properties.style as string) || '')) } }) const lightCode = unified() .use(rehypeParse, { fragment: true }) .parse(lightHighligher.codeToHtml(code, { lang: lang as Lang, lineOptions })) visit(lightCode, 'element', (node) => { if (node.properties) { const darkClassNames = darkClasses.shift() as string[] const lightClassNames = styleToClassNames((node.properties.style as string) || '') const classNames = mergeClassNames( lightClassNames, darkClassNames, node.properties.className as string[], ) node.properties.style = undefined node.properties.className = classNames.length ? classNames : undefined } }) return lightCode } visit(tree, 'inlineCode', function visitor(node) { const { value } = node if (!value) { return } // TODO: allow escape characters to break out of highlighting let meta = '' const code = value.replace(/{:([a-z.-]+)}$/i, (_, $1) => { meta = $1 return '' }) const isLang = !meta.startsWith('.') let html: any if (isLang) { html = (highlight(code, meta).children[0] as any).children[0] } else { const lightToken = light.settings.find( ({ scope }) => meta && scope?.includes(resolvedTokenMap[meta.slice(1)] || meta.slice(1)), )?.settings const darkToken = dark.settings.find( ({ scope }) => meta && scope?.includes(resolvedTokenMap[meta.slice(1)] || meta.slice(1)), )?.settings const lightClassNames = [ `text-[${lightToken?.foreground || light.fg}]`, lightToken?.background && `bg-[${lightToken.background}]`, // "bold underline italic" /\bbold\b/.test(lightToken?.fontStyle as string) && `font-bold`, /\bunderline\b/.test(lightToken?.fontStyle as string) && `underline`, /\bitalic\b/.test(lightToken?.fontStyle as string) && `italic`, ].filter(Boolean) as string[] const darkClassNames = [ `text-[${darkToken?.foreground || dark.fg}]`, darkToken?.background && `bg-[${darkToken.background}]`, // "bold underline italic" /\bbold\b/.test(darkToken?.fontStyle as string) && `font-bold`, /\bunderline\b/.test(darkToken?.fontStyle as string) && `underline`, /\bitalic\b/.test(darkToken?.fontStyle as string) && `italic`, ].filter(Boolean) as string[] const classNames = mergeClassNames(lightClassNames, darkClassNames) html = unified() .use(rehypeParse, { fragment: true }) .parse( `${escape( code, )}`, ) } visit(html, 'element', function (node) { if (node.tagName === 'code') { node.properties = { ...node.properties, 'data-inline-code': true, className: resolvedClassNames['inline-code'].split(' '), } if (meta && isLang) { node.properties['data-lang'] = meta } node.children = node.children[0].children } }) ;(node as any).type = 'html' node.value = toHtml(html) }) visit(tree, 'code', function visitor(node) { const id = ':code:' + createHash('sha1') .update(JSON.stringify(node)) .digest() .toString('hex') .replace(/[=/]/g, '') .slice(0, 8) const { code, lang, title, isDiff, showLineNumbers, terms, lineOptions } = parse( node, resolvedClassNames, ) const html = highlight(code, lang, lineOptions) if (terms.length) { highlightTerms(html, terms, resolvedClassNames['term-highlight'] || 'term-highlight') } visit(html, 'element', function (node) { if (node.tagName === 'pre') { node.properties = { ...node.properties, className: (resolvedClassNames['pre'] || '').split(' '), } } else if (node.tagName === 'code') { node.properties = { ...node.properties, id, className: (resolvedClassNames['code'] || '').split(' '), } } else if ( node.tagName === 'span' && (node.properties?.className as undefined | string[])?.includes('line') ) { const classNames = new Set([ ...(node.properties?.className as string[]).filter((className) => className !== 'line'), ...(resolvedClassNames['line'] || 'line').split(' '), ]) node.properties = { ...node.properties, className: [...classNames], } } }) const toolbar = [ lang && `${escape(lang)}`, ``, ] .filter(Boolean) .join('') const attributes = [ 'data-code', lang && `data-lang="${escape(lang)}"`, isDiff && 'data-diff', showLineNumbers && 'data-line-numbers', `class="${resolvedClassNames['figure']}"`, ] .filter(Boolean) .join(' ') const body = [ title && `
${escape(title)}
`, `
${toolbar}
`, toHtml(html), ] .filter(Boolean) .join('') ;(node as any).type = 'html' node.value = `
${body}
` }) } } function parse(node: Mdast.Code, classNames: ClassNames) { let { meta, lang, value: code } = node meta = (meta || '').trim() const lines = code.split('\n') /** @type {Map>} */ const lineOptions = new Map() /** * @param {number} line * @param {string} className */ const addLineClass = (line: number, className: keyof ClassNames) => { let lineClassNames = lineOptions.get(line) if (!lineClassNames) { lineOptions.set(line, (lineClassNames = new Set())) } lineClassNames.add(classNames[className]) } // [(diff-)?lang]? [title|title="..."]? [showLineNumbers|line-numbers]? [highlightLines]* [highlightTerm]* [focusLines]* // title=asas or title="..." or title='...' or title=`...` let title: string | undefined meta = meta.replace(/ *title=(?:([^"'`]\S+)|(["'`])(.+?)\2) */, (_, unqoted, quote, quoted) => { title = unqoted || quoted // remove the match return '' }) // showLineNumber or line-numbers let showLineNumbers = false meta = meta.replace(/ *(showLineNumbers|line-numbers) */, () => { showLineNumbers = true // remove the match return '' }) // highlightLines: {1-3,5} or highlight=1-3,5 meta = meta.replace(/ *(?:{([\d.,-]+)}|highlight=([\d.,-]+)) */g, (_, unkeyed, keyed) => { rangeParser(unkeyed || keyed).map((line) => addLineClass(line, 'line-highlight')) // remove the match return '' }) // focusLines: [1-3,5] or focus=1-3,5 meta = meta.replace(/ *(?:\[([\d.,-]+)\]|focus=([\d.,-]+)) */, (_, unkeyed, keyed) => { const lineNumbers = rangeParser(unkeyed || keyed) lineNumbers.forEach((line) => addLineClass(line, 'line-focus')) Array.from({ length: lines.length }, (_, index) => index + 1) .filter((line) => !lineNumbers.includes(line)) .map((line) => addLineClass(line, 'line-not-focus')) // remove the match return '' }) const terms: { value: string; instances: number[] | null | undefined; count: number }[] = [] // highlightTerm: /needle/ or /needle/3-5 (Highlight only the third to fifth instances) meta = meta.replace( / *\/((?:\\\/|'[^']+'|"[^"]+"|`[^`]+`|[^/])+)\/([\d.,-]+)? */g, (_, value, instances) => { terms.push({ value: value.replace(/\\\//g, '/'), instances: instances && rangeParser(instances), count: 0, }) // remove the match return '' }, ) // the remaining may be the title if (!title && meta) { title = meta } // lang=diff-js -> replace `+` and `-` by classes const isDiff = lang?.startsWith('diff-') if (isDiff) { lang = lang!.slice(5) code = lines .map((line, index) => { addLineClass( index + 1, line[0] == '+' ? 'line-inserted' : line[0] == '-' ? 'line-removed' : 'line-unchanged', ) return line.slice(1) }) .join('\n') } return { title, code, lang: lang || undefined, isDiff, showLineNumbers, terms, lineOptions: Array.from(lineOptions, ([line, classes]) => ({ line, classes: [...classes].filter(Boolean), })).sort((a, b) => a.line - b.line), } } function highlightTerms(html: any, terms: any, className: string) { const cloneNode = (node: any, value: string, highlight = false) => { const classNames = highlight ? [...new Set([...(node.properties?.className || []), ...className.split(' ')])].filter( Boolean, ) : node.properties?.className return { ...node, properties: { ...node.properties, className: classNames }, children: [{ ...node.children[0], value, position: undefined }], position: undefined, } } visit(html, 'element', function (node) { if ( node.tagName === 'span' && (node.properties?.className as undefined | string | string[])?.includes('line') ) { // [ ..., ..., ...] for (const term of terms) { const needle = term.value let lineContent = '' for (let i = 0; i < node.children.length; i++) { const span = node.children[i] const textContent = span.children[0].value as string if (!textContent) { lineContent = '' continue } lineContent += textContent // 1. includes: prefix[needle]suffix const index = textContent.indexOf(needle) if (~index) { // prefix.needle.suffix // => prefix.needle.suffix term.count += 1 if (!term.instances || term.instances.includes(term.count)) { const prefix = textContent.slice(0, index) const suffix = textContent.slice(index + needle.length) // console.log({ textContent, needle, prefix, suffix }) const newNodes = [ prefix && cloneNode(span, prefix), cloneNode(span, needle, true), suffix && cloneNode(span, suffix), ].filter(Boolean) node.children.splice(i, 1, ...newNodes) if (prefix) { i += 1 } lineContent = '' } } else { // prefix.needle.suffix // => prefix.needle.suffix // walk backwords through the nodes // prefix.nee|dl|e.suffix // const startIndex = lineContent.indexOf(needle) if (~startIndex) { term.count += 1 if (!term.instances || term.instances.includes(term.count)) { let position = lineContent.length const endIndex = startIndex + needle.length let suffixNode const wrappedNodes = [] for (let j = i; j >= 0; j--) { const span = node.children[j] const textContent = span.children[0].value as string position -= textContent.length const index = position <= startIndex ? Math.max(position, startIndex) : Math.min(lineContent.length, endIndex) const prefix = lineContent.slice(position, index) const suffix = lineContent.slice(index) lineContent = lineContent.slice(0, position) if (j === i) { // last node suffixNode = suffix && cloneNode(span, suffix) } if (position <= startIndex) { // first node if (suffix) { wrappedNodes.unshift(cloneNode(span, suffix)) } } else if (prefix) { wrappedNodes.unshift(cloneNode(span, prefix)) } if (position <= startIndex) { const newNodes = [ prefix && cloneNode(span, prefix), { type: 'element', tagName: 'span', properties: { className: className.split(' ') }, children: wrappedNodes, position: undefined, }, suffixNode, ].filter(Boolean) node.children.splice(j, i - j + 1, ...newNodes) i = j + newNodes.length - 1 break } } } lineContent = '' } } } } } }) } function styleToClassNames(style: string): string[] { const classNames: string[] = [] for (const [, property, value] of style.matchAll(/\s*([a-z-]+):\s*([^;]+)\s*;?\s*/g)) { switch (property) { case 'background-color': { classNames.push(`bg-[${value.toLowerCase()}]`) break } case 'color': { classNames.push(`text-[${value.toLowerCase()}]`) break } case 'font-weight': { classNames.push(`font-${value}`) break } case 'font-style': case 'text-decoration': { classNames.push(value) break } default: { console.warn(`Could not convert "${property}:${value}" to class name.`) classNames.push(`[${property}:${value}]`) } } } return classNames } function mergeClassNames( lightClassNames: string[], darkClassNames: string[], initialClassNames?: string[], ): string[] { const classNames = new Set(initialClassNames || []) // reset non color styles for dark mode if (lightClassNames.includes('font-bold') && !darkClassNames.includes('font-bold')) { darkClassNames.push('font-normal') } if (lightClassNames.includes('italic') && !darkClassNames.includes('italic')) { darkClassNames.push('not-italic') } if (lightClassNames.includes('underline') && !darkClassNames.includes('underline')) { darkClassNames.push('no-underline') } lightClassNames.forEach((className) => classNames.add(className)) darkClassNames.forEach((className) => classNames.add(`dark:${className}`)) return [...classNames] }