diff --git a/package.json b/package.json index 710d73d7..bf56a03e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "js-yaml": "^4.1.1", "minimatch": "^10.1.1", "nuxt-component-meta": "^0.15.0", + "shiki": "^3.19.0", "unstorage": "1.17.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f5380e2..d33b8b75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: version: 1.2.73 '@nuxtjs/mdc': specifier: ^0.18.3 - version: 0.18.3 + version: 0.18.3(magicast@0.5.1) '@vueuse/core': specifier: ^13.9.0 version: 13.9.0(vue@3.5.24(typescript@5.9.3)) @@ -36,6 +36,9 @@ importers: nuxt-component-meta: specifier: ^0.15.0 version: 0.15.0(magicast@0.5.1) + shiki: + specifier: ^3.19.0 + version: 3.19.0 unstorage: specifier: 1.17.1 version: 1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2) @@ -144,7 +147,7 @@ importers: version: 12.4.1 docus: specifier: ^5.2.1 - version: 5.2.1(11f0878163c5ad20c88674cad1ad542a) + version: 5.2.1(61f5cd6ace809d8b701fd5614108e082) nuxt: specifier: ^4.2.1 version: 4.2.1(@parcel/watcher@2.5.1)(@types/node@24.10.1)(@vue/compiler-sfc@3.5.24)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.1)(meow@13.2.0)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.1)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue-tsc@3.1.3(typescript@5.9.3))(yaml@2.8.1) @@ -4767,10 +4770,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -8362,7 +8361,7 @@ snapshots: '@nuxt/content@3.8.0(better-sqlite3@12.4.1)': dependencies: '@nuxt/kit': 4.2.1(magicast@0.5.1) - '@nuxtjs/mdc': 0.18.2(magicast@0.5.1) + '@nuxtjs/mdc': 0.18.2 '@shikijs/langs': 3.15.0 '@sqlite.org/sqlite-wasm': 3.50.4-build1 '@standard-schema/spec': 1.0.0 @@ -8664,7 +8663,7 @@ snapshots: defu: 6.1.4 h3: 1.15.4 image-meta: 0.2.2 - knitwork: 1.2.0 + knitwork: 1.3.0 ohash: 2.0.11 pathe: 2.0.3 std-env: 3.10.0 @@ -8707,7 +8706,7 @@ snapshots: ignore: 7.0.5 jiti: 2.6.1 klona: 2.0.6 - knitwork: 1.3.0 + knitwork: 1.2.0 mlly: 1.8.0 ohash: 2.0.11 pathe: 2.0.3 @@ -9153,7 +9152,7 @@ snapshots: defu: 6.1.4 devalue: 5.4.2 h3: 1.15.4 - knitwork: 1.2.0 + knitwork: 1.3.0 magic-string: 0.30.21 mlly: 1.8.0 nuxt-define: 1.0.0 @@ -9164,7 +9163,7 @@ snapshots: pathe: 2.0.3 typescript: 5.9.3 ufo: 1.6.1 - unplugin: 2.3.10 + unplugin: 2.3.11 unplugin-vue-router: 0.16.1(@vue/compiler-sfc@3.5.24)(typescript@5.9.3)(vue-router@4.6.3(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) unstorage: 1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2) vue-i18n: 11.1.12(vue@3.5.24(typescript@5.9.3)) @@ -9197,7 +9196,7 @@ snapshots: - uploadthing - vue - '@nuxtjs/mdc@0.18.2(magicast@0.5.1)': + '@nuxtjs/mdc@0.18.2': dependencies: '@nuxt/kit': 4.2.1(magicast@0.5.1) '@shikijs/core': 3.15.0 @@ -9246,7 +9245,7 @@ snapshots: - magicast - supports-color - '@nuxtjs/mdc@0.18.3': + '@nuxtjs/mdc@0.18.3(magicast@0.5.1)': dependencies: '@nuxt/kit': 4.2.1(magicast@0.5.1) '@shikijs/core': 3.15.0 @@ -9284,7 +9283,7 @@ snapshots: remark-rehype: 11.1.2 remark-stringify: 11.0.0 scule: 1.3.0 - shiki: 3.15.0 + shiki: 3.19.0 ufo: 1.6.1 unified: 11.0.5 unist-builder: 4.0.0 @@ -9881,7 +9880,7 @@ snapshots: '@rollup/plugin-yaml@4.1.2(rollup@4.53.2)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.2) - js-yaml: 4.1.0 + js-yaml: 4.1.1 tosource: 2.0.0-alpha.3 optionalDependencies: rollup: 4.53.2 @@ -11804,7 +11803,7 @@ snapshots: diff@8.0.2: {} - docus@5.2.1(11f0878163c5ad20c88674cad1ad542a): + docus@5.2.1(61f5cd6ace809d8b701fd5614108e082): dependencies: '@iconify-json/lucide': 1.2.73 '@iconify-json/simple-icons': 1.2.58 @@ -11814,7 +11813,7 @@ snapshots: '@nuxt/kit': 4.2.1(magicast@0.5.1) '@nuxt/ui': https://pkg.pr.new/@nuxt/ui@049b182(7dc40839a48769e5440cac311ba7e0b2) '@nuxtjs/i18n': 10.2.0(@vue/compiler-dom@3.5.24)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.8.2)(magicast@0.5.1)(rollup@4.53.2)(vue@3.5.24(typescript@5.9.3)) - '@nuxtjs/mdc': 0.18.2(magicast@0.5.1) + '@nuxtjs/mdc': 0.18.3(magicast@0.5.1) '@nuxtjs/robots': 5.5.6(h3@1.15.4)(magicast@0.5.1)(vue@3.5.24(typescript@5.9.3)) '@vueuse/core': 13.9.0(vue@3.5.24(typescript@5.9.3)) better-sqlite3: 12.4.1 @@ -11825,7 +11824,7 @@ snapshots: motion-v: 1.7.4(@vueuse/core@13.9.0(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) nuxt: 4.2.1(@parcel/watcher@2.5.1)(@types/node@24.10.1)(@vue/compiler-sfc@3.5.24)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.1(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.1)(meow@13.2.0)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.1)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue-tsc@3.1.3(typescript@5.9.3))(yaml@2.8.1) nuxt-llms: 0.1.3(magicast@0.5.1) - nuxt-og-image: 5.1.12(@unhead/vue@2.0.19(vue@3.5.24(typescript@5.9.3)))(h3@1.15.4)(magicast@0.5.1)(unstorage@1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2))(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) + nuxt-og-image: 5.1.12(@unhead/vue@2.0.19(vue@3.5.24(typescript@5.9.3)))(h3@1.15.4)(magicast@0.5.1)(unstorage@1.17.2(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2))(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) pkg-types: 2.3.0 scule: 1.3.0 tailwindcss: 4.1.17 @@ -13074,10 +13073,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -13867,7 +13862,7 @@ snapshots: ioredis: 5.8.2 jiti: 2.6.1 klona: 2.0.6 - knitwork: 1.3.0 + knitwork: 1.2.0 listhen: 1.9.0 magic-string: 0.30.21 magicast: 0.5.1 @@ -14011,7 +14006,7 @@ snapshots: transitivePeerDependencies: - magicast - nuxt-og-image@5.1.12(@unhead/vue@2.0.19(vue@3.5.24(typescript@5.9.3)))(h3@1.15.4)(magicast@0.5.1)(unstorage@1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2))(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)): + nuxt-og-image@5.1.12(@unhead/vue@2.0.19(vue@3.5.24(typescript@5.9.3)))(h3@1.15.4)(magicast@0.5.1)(unstorage@1.17.2(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2))(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)): dependencies: '@nuxt/devtools-kit': 2.7.0(magicast@0.5.1)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.1)) '@nuxt/kit': 4.2.1(magicast@0.5.1) @@ -14041,8 +14036,8 @@ snapshots: std-env: 3.10.0 strip-literal: 3.1.0 ufo: 1.6.1 - unplugin: 2.3.10 - unstorage: 1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2) + unplugin: 2.3.11 + unstorage: 1.17.2(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2) unwasm: 0.3.11 yoga-wasm-web: 0.3.3 transitivePeerDependencies: @@ -16101,12 +16096,12 @@ snapshots: unwasm@0.3.11: dependencies: - knitwork: 1.3.0 + knitwork: 1.2.0 magic-string: 0.30.21 mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 - unplugin: 2.3.11 + unplugin: 2.3.10 unwasm@0.4.2: dependencies: diff --git a/src/app/src/components/content/ContentEditorTipTap.vue b/src/app/src/components/content/ContentEditorTipTap.vue index b7680963..769979e1 100644 --- a/src/app/src/components/content/ContentEditorTipTap.vue +++ b/src/app/src/components/content/ContentEditorTipTap.vue @@ -73,7 +73,9 @@ watch(() => `${document.value?.id}-${props.draftItem.version}-${props.draftItem. watch(tiptapJSON, async (json) => { const cleanedTiptap = removeLastEmptyParagraph(json!) - const { body, data } = await tiptapToMDC(cleanedTiptap) + const { body, data } = await tiptapToMDC(cleanedTiptap, { + highlightTheme: host.meta.getHighlightTheme(), + }) const compressedBody: MarkdownRoot = compressTree(body) const toc: Toc = generateToc(body, { searchDepth: 2, depth: 2 } as Toc) diff --git a/src/app/src/types/content.ts b/src/app/src/types/content.ts index 6c78866a..bc43f982 100644 --- a/src/app/src/types/content.ts +++ b/src/app/src/types/content.ts @@ -1,3 +1,9 @@ export interface MarkdownParsingOptions { compress?: boolean } + +export interface SyntaxHighlightTheme { + default: string + dark?: string + light?: string +} diff --git a/src/app/src/types/index.ts b/src/app/src/types/index.ts index 75336895..12df1ff0 100644 --- a/src/app/src/types/index.ts +++ b/src/app/src/types/index.ts @@ -4,7 +4,7 @@ import type { RouteLocationNormalized } from 'vue-router' import type { MediaItem } from './media' import type { Repository } from './git' import type { ComponentMeta } from './component' -import type { MarkdownParsingOptions } from './content' +import type { MarkdownParsingOptions, SyntaxHighlightTheme } from './content' export * from './file' export * from './item' @@ -24,6 +24,7 @@ export interface StudioHost { dev: boolean getComponents: () => ComponentMeta[] defaultLocale: string + getHighlightTheme: () => SyntaxHighlightTheme } on: { routeChange: (fn: (to: RouteLocationNormalized, from: RouteLocationNormalized) => void) => void diff --git a/src/app/src/utils/tiptap/tiptapToMdc.ts b/src/app/src/utils/tiptap/tiptapToMdc.ts index 34bcfc5c..aa4ee533 100644 --- a/src/app/src/utils/tiptap/tiptapToMdc.ts +++ b/src/app/src/utils/tiptap/tiptapToMdc.ts @@ -1,18 +1,18 @@ -// import type { MarkdownNode, MarkdownRoot, Toc } from '@nuxt/content' import type { JSONContent } from '@tiptap/vue-3' import Slugger from 'github-slugger' -// import rehypeShiki from '@nuxtjs/mdc/dist/runtime/highlighter/rehype' -// import { createShikiHighlighter } from '@nuxtjs/mdc/runtime/highlighter/shiki' -// import { bundledThemes, bundledLanguages as bundledLangs, createJavaScriptRegexEngine } from 'shiki' -// import { visit } from 'unist-util-visit' -import type { MDCElement, MDCNode, MDCRoot, MDCText } from '@nuxtjs/mdc' -// import type { RehypeMarkdownNode } from '../types' - -let slugs = new Slugger() -// let shikiHighlighter: Highlighter +import type { Highlighter, Element, MDCElement, MDCNode, MDCRoot, MDCText } from '@nuxtjs/mdc' +import rehypeShiki from '@nuxtjs/mdc/dist/runtime/highlighter/rehype' +import { createShikiHighlighter } from '@nuxtjs/mdc/runtime/highlighter/shiki' +import { bundledThemes, bundledLanguages as bundledLangs, createJavaScriptRegexEngine } from 'shiki' +import { visit } from 'unist-util-visit' +import type { SyntaxHighlightTheme } from '../../types/content' type TiptapToMDCMap = Record MDCRoot | MDCNode | MDCNode[]> +interface TiptapToMDCOptions { + highlightTheme?: SyntaxHighlightTheme +} + const markToTag: Record = { bold: 'strong', italic: 'em', @@ -49,8 +49,16 @@ const tiptapToMDCMap: TiptapToMDCMap = { 'br': (node: JSONContent) => createElement(node, 'br'), } -/* Parsing methods */ -export async function tiptapToMDC(node: JSONContent): Promise<{ body: MDCRoot, data: Record }> { +let slugs = new Slugger() +let shikiHighlighter: Highlighter | undefined + +/* + *************************************************************** + ******************** Parsing methods ************************** + *************************************************************** + */ + +export async function tiptapToMDC(node: JSONContent, options?: TiptapToMDCOptions): Promise<{ body: MDCRoot, data: Record }> { // re-create slugs slugs = new Slugger() @@ -81,7 +89,7 @@ export async function tiptapToMDC(node: JSONContent): Promise<{ body: MDCRoot, d mdc.body = tiptapNodeToMDC(nodeCopy) as MDCRoot - // await applyShikiSyntaxHighlighting(mdc.body) + await applyShikiSyntaxHighlighting(mdc.body, options?.highlightTheme) return mdc } @@ -115,82 +123,9 @@ export function tiptapNodeToMDC(node: JSONContent): MDCRoot | MDCNode | MDCNode[ } } -// async function applyShikiSyntaxHighlighting(mdc: MarkdownRoot) { -// // convert tag to tagName and props to properties to be compatible with rehype -// // TODO: we may refactor tiptapToMDC to use tagName and properties instead of tag and props to avoid this step -// visit( -// mdc, -// (n: MarkdownNode) => n.tag !== undefined, -// (n: MarkdownNode) => { Object.assign(n, { tagName: n.tag, properties: n.props }) }, -// ) - -// if (typeof useProjects !== 'undefined') { -// const { project } = useProjects() -// if (project.value) { -// const { meta } = useProjectMeta(project.value) -// const theme = meta.value?.content?.highlight?.theme || { default: 'github-light', dark: 'github-dark' } - -// if (!shikiHighlighter) { -// shikiHighlighter = createShikiHighlighter({ bundledThemes, bundledLangs, engine: createJavaScriptRegexEngine({ forgiving: true }) }) -// } -// const shikit = rehypeShiki({ theme, highlighter: shikiHighlighter }) -// // highlight code blocks -// await shikit(mdc as never) -// } -// } - -// // convert back tagName to tag and properties to props to be compatible with MDC -// visit( -// mdc, -// (n: MarkdownNode) => (n as RehypeMarkdownNode).tagName !== undefined, -// (n: MarkdownNode) => { Object.assign(n, { tag: (n as RehypeMarkdownNode).tagName, props: (n as RehypeMarkdownNode).properties, tagName: undefined, properties: undefined }) }, -// ) - -// // remove empty newline text nodes -// visit( -// mdc, -// (n: MarkdownNode) => n.tag === 'pre', -// (n: MarkdownNode) => { -// n.children[0].children = n.children[0].children.filter((child: MarkdownNode) => child.type !== 'text' || child.value.trim()) -// }, -// ) -// } - -/* Create element methods */ - -/** - * Unwrap a single child if it matches the specified type - */ -function unwrapParagraph(content: JSONContent[]): JSONContent[] { - if (content.length === 1 && content[0]?.type === 'paragraph') { - return content[0].content || [] - } - return content -} - -/** - * Unwrap a default slot's content directly to parent level - */ -function unwrapDefaultSlot(content: JSONContent[]): JSONContent[] { - if (content.length === 1 && content[0]?.type === 'slot' && content[0].attrs?.name === 'default') { - return content[0].content || [] - } - return content -} - -/** - * Process and normalize element props, converting className to class - */ -function normalizeProps(nodeProps: Record, extraProps: object): Array<[string, string]> { - return Object.entries({ ...nodeProps, ...extraProps }) - .map(([key, value]) => { - if (key === 'className') { - return ['class', typeof value === 'string' ? value : (value as Array).join(' ')] as [string, string] - } - return [key.trim(), String(value).trim()] as [string, string] - }) - .filter(([key]) => Boolean(String(key).trim())) -} +/*************************************************************** + *********************** Create element methods **************** + ***************************************************************/ function createElement(node: JSONContent, tag?: string, extra: unknown = {}): MDCElement { const { props = {}, ...rest } = extra as { props: object } @@ -390,7 +325,77 @@ function createListItemElement(node: JSONContent) { return createElement(node, 'li') } -// Merge adjacent children with the same tag if separated by a single space text node +/*************************************************************** + ******************** Utility methods ************************** + ***************************************************************/ + +async function applyShikiSyntaxHighlighting(mdc: MDCRoot, theme: SyntaxHighlightTheme = { default: 'github-light', dark: 'github-dark' }) { + // @ts-expect-error MDCNode is not compatible with the type of the visitor + // Convert tag to tagName and props to properties to be compatible with rehype + visit(mdc, (n: MDCNode) => n.tag !== undefined, (n: MDCNode) => Object.assign(n, { tagName: n.tag, properties: n.props })) + + if (!shikiHighlighter) { + shikiHighlighter = createShikiHighlighter({ bundledThemes, bundledLangs, engine: createJavaScriptRegexEngine({ forgiving: true }) }) + } + + // Highlight code blocks + const shikit = rehypeShiki({ theme: theme as never, highlighter: shikiHighlighter }) + await shikit(mdc as never) + + // Convert back tagName to tag and properties to props to be compatible with MDC + visit( + mdc, + (n: unknown) => (n as Element).tagName !== undefined, + (n: unknown) => { Object.assign(n as MDCNode, { tag: (n as Element).tagName, props: (n as Element).properties, tagName: undefined, properties: undefined }) }, + ) + + // Remove empty newline text nodes + visit( + mdc, + (n: unknown) => (n as MDCElement).tag === 'pre', + (n: unknown) => { + ((n as MDCElement).children[0] as MDCElement).children = ((n as MDCElement).children[0] as MDCElement).children.filter((child: MDCNode) => child.type !== 'text' || child.value.trim()) + }, + ) +} + +/** + * Unwrap a single child if it matches the specified type + */ +function unwrapParagraph(content: JSONContent[]): JSONContent[] { + if (content.length === 1 && content[0]?.type === 'paragraph') { + return content[0].content || [] + } + return content +} + +/** + * Unwrap a default slot's content directly to parent level + */ +function unwrapDefaultSlot(content: JSONContent[]): JSONContent[] { + if (content.length === 1 && content[0]?.type === 'slot' && content[0].attrs?.name === 'default') { + return content[0].content || [] + } + return content +} + +/** + * Process and normalize element props, converting className to class + */ +function normalizeProps(nodeProps: Record, extraProps: object): Array<[string, string]> { + return Object.entries({ ...nodeProps, ...extraProps }) + .map(([key, value]) => { + if (key === 'className') { + return ['class', typeof value === 'string' ? value : (value as Array).join(' ')] as [string, string] + } + return [key.trim(), String(value).trim()] as [string, string] + }) + .filter(([key]) => Boolean(String(key).trim())) +} + +/** + * Merge adjacent children with the same tag if separated by a single space text node + */ function mergeSiblingsWithSameTag(children: MDCNode[], allowedTags: string[]): MDCNode[] { if (!Array.isArray(children)) return children const merged: MDCNode[] = [] diff --git a/src/app/test/unit/utils/tiptap.test.ts b/src/app/test/unit/utils/tiptap.test.ts index 53e11063..ec9cac32 100644 --- a/src/app/test/unit/utils/tiptap.test.ts +++ b/src/app/test/unit/utils/tiptap.test.ts @@ -809,3 +809,55 @@ Hello expect(outputContent).toBe(`${inputContent}\n`) }) }) + +describe('code block', () => { + test('simple code block highlighting', async () => { + const mdcInput: MDCRoot = { + type: 'root', + children: [ + { + type: 'element', + tag: 'pre', + props: { + language: 'javascript', + }, + children: [ + { + type: 'element', + tag: 'code', + props: {}, + children: [{ type: 'text', value: 'console.log("Hello, world!");' }], + }, + ], + }, + ], + } + + const tiptapJSON = await mdcToTiptap(mdcInput, {}) + + const generatedMdcJSON = await tiptapToMDC(tiptapJSON, { highlightTheme: { default: 'github-light', dark: 'github-dark' } }) + const pre = generatedMdcJSON.body.children[0] as JSONContent + + // Tags: pre -> code -> line -> span -> text + expect(pre.tag).toBe('pre') + expect(pre.children.length).toBe(1) + expect(pre.children[0].tag).toBe('code') + expect(pre.props.language).toBe('javascript') + expect(pre.props.code).toBe('console.log("Hello, world!");') + expect(pre.props.className).toBe('shiki shiki-themes github-light github-dark') + + const code = pre.children[0] as JSONContent + const line0 = code.children[0] as JSONContent + + // Make sure the line is parsed correctly + expect(line0.children.length).toBe(5) // console. -- log -- ( -- "Hello, world!" -- ); + for (const child of line0.children) { + expect(child.tag).toBe('span') + expect(child.children.length).toBe(1) + expect(child.children[0].type).toBe('text') + expect(child.props.style.includes('--shiki-default:')).toBeTruthy() + } + + // Note we don't check the styles and colors because they are generated by Shiki and we don't want to test Shiki here + }) +}) diff --git a/src/app/vite.config.ts b/src/app/vite.config.ts index 6c184601..ac46f953 100644 --- a/src/app/vite.config.ts +++ b/src/app/vite.config.ts @@ -77,5 +77,11 @@ export default defineConfig({ formats: ['es'], }, sourcemap: false, + minify: 'terser', + terserOptions: { + mangle: { + reserved: ['h'], // Reserve 'h' to avoid conflicts + }, + }, }, }) diff --git a/src/module/src/runtime/composables/useMeta.ts b/src/module/src/runtime/composables/useMeta.ts index 6d90dfc4..cc447208 100644 --- a/src/module/src/runtime/composables/useMeta.ts +++ b/src/module/src/runtime/composables/useMeta.ts @@ -3,14 +3,27 @@ import type { ComponentMeta } from 'nuxt-studio/app' import { shallowRef } from 'vue' import { kebabCase } from 'scule' +interface Meta { + components: ComponentMeta[] + highlightTheme: { default: string, dark?: string, light?: string } +} + +const defaultMeta: Meta = { + components: [], + highlightTheme: { default: 'github-light', dark: 'github-dark' }, +} + export const useHostMeta = createSharedComposable(() => { const components = shallowRef([]) + const highlightTheme = shallowRef() async function fetch() { // TODO: look into this approach and consider possible refactors - const data = await $fetch<{ components: ComponentMeta[] }>('/__nuxt_studio/meta', { + const data = await $fetch('/__nuxt_studio/meta', { headers: { 'content-type': 'application/json' }, - }).catch(() => ({ components: [] })) + }).catch(() => defaultMeta) + + highlightTheme.value = data.highlightTheme // Markdown elements to exclude (in kebab-case) const markdownElements = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'p', 'li', 'ul', 'ol', 'blockquote', 'code', 'code-block', 'image', 'video', 'link', 'hr', 'img', 'pre', 'em', 'bold', 'italic', 'strike', 'strong', 'tr', 'thead', 'tbody', 'tfoot', 'th', 'td']) @@ -78,5 +91,6 @@ export const useHostMeta = createSharedComposable(() => { return { fetch, components, + highlightTheme, } }) diff --git a/src/module/src/runtime/host.ts b/src/module/src/runtime/host.ts index 0525ca4c..7543e298 100644 --- a/src/module/src/runtime/host.ts +++ b/src/module/src/runtime/host.ts @@ -108,6 +108,7 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH dev: false, getComponents: () => meta.components.value, defaultLocale: useRuntimeConfig().public.studio.i18n?.defaultLocale || 'en', + getHighlightTheme: () => meta.highlightTheme.value!, }, on: { routeChange: (fn: (to: RouteLocationNormalized, from: RouteLocationNormalized) => void) => { diff --git a/src/module/src/runtime/server/routes/meta.ts b/src/module/src/runtime/server/routes/meta.ts index d66be578..4110aaec 100644 --- a/src/module/src/runtime/server/routes/meta.ts +++ b/src/module/src/runtime/server/routes/meta.ts @@ -3,6 +3,8 @@ import { eventHandler, useSession } from 'h3' import { useRuntimeConfig, createError } from '#imports' // @ts-expect-error import does exist import components from '#nuxt-component-meta/nitro' +// @ts-expect-error import does exist +import { highlight } from '#mdc-imports' interface NuxtComponentMeta { pascalName: string @@ -40,6 +42,7 @@ export default eventHandler(async (event) => { }) return { + highlightTheme: highlight?.theme || { default: 'github-light', dark: 'github-dark', light: 'github-light' }, components: mappedComponents, } }) diff --git a/src/module/src/runtime/utils/document.ts b/src/module/src/runtime/utils/document.ts index a3e7146c..b6f925df 100644 --- a/src/module/src/runtime/utils/document.ts +++ b/src/module/src/runtime/utils/document.ts @@ -16,6 +16,7 @@ import type { MinimarkNode, MinimarkTree } from 'minimark' // import type { ParsedContentFile } from '@nuxt/content' import { stringifyMarkdown } from '@nuxtjs/mdc/runtime' import type { Node } from 'unist' +import { useHostMeta } from '../composables/useMeta' const reservedKeys = ['id', 'fsPath', 'stem', 'extension', '__hash__', 'path', 'body', 'meta', 'rawbody'] @@ -328,6 +329,9 @@ export async function generateDocumentFromJSONContent(id: string, content: strin export async function generateDocumentFromMarkdownContent(id: string, content: string, options: MarkdownParsingOptions = { compress: true }): Promise { const document = await parseMarkdown(content, { + highlight: { + theme: useHostMeta().highlightTheme.value, + }, remark: { plugins: { 'remark-mdc': {