| import { findChildren } from "@tiptap/core"; |
| import { CodeBlock, type CodeBlockOptions } from "@tiptap/extension-code-block"; |
| import { Plugin, PluginKey } from "@tiptap/pm/state"; |
| import { Decoration, DecorationSet } from "@tiptap/pm/view"; |
| import type { Node as ProsemirrorNode } from "@tiptap/pm/model"; |
| import type { EditorView } from "@tiptap/pm/view"; |
| import type { Element as HastElement, ElementContent, Root as HastRoot } from "hast"; |
| import { |
| getSharedHighlighter, |
| isSupportedLang, |
| normalizeLang, |
| SHIKI_THEMES, |
| type ShikiHighlighter, |
| } from "#shared/shiki-config"; |
| import { detectShikiLang } from "#shared/detect-lang"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function readHastClasses(node: HastElement): string[] { |
| const props = node.properties ?? {}; |
| const raw = props.class ?? props.className; |
| if (Array.isArray(raw)) return raw.map(String).filter(Boolean); |
| if (typeof raw === "string") return raw.split(/\s+/).filter(Boolean); |
| return []; |
| } |
|
|
| function walkLineTokens( |
| nodes: readonly ElementContent[], |
| offset: number, |
| out: Decoration[], |
| styleStack: readonly string[] = [], |
| classStack: readonly string[] = [], |
| ): number { |
| for (const node of nodes) { |
| if (node.type === "text") { |
| const len = node.value.length; |
| if (len > 0 && (styleStack.length > 0 || classStack.length > 0)) { |
| const attrs: Record<string, string> = {}; |
| if (styleStack.length > 0) attrs.style = styleStack.join(";"); |
| if (classStack.length > 0) attrs.class = classStack.join(" "); |
| out.push(Decoration.inline(offset, offset + len, attrs)); |
| } |
| offset += len; |
| continue; |
| } |
| if (node.type === "element") { |
| const rawStyle = node.properties?.style; |
| const style = typeof rawStyle === "string" ? rawStyle : ""; |
| const classes = readHastClasses(node).filter((c) => c !== "line"); |
| const nextStyleStack = style ? [...styleStack, style] : styleStack; |
| const nextClassStack = classes.length ? [...classStack, ...classes] : classStack; |
| offset = walkLineTokens(node.children, offset, out, nextStyleStack, nextClassStack); |
| } |
| } |
| return offset; |
| } |
|
|
| |
| |
| |
| |
| function getCodeChildren(hast: HastRoot): readonly ElementContent[] { |
| const pre = hast.children.find((c): c is HastElement => c.type === "element" && c.tagName === "pre"); |
| if (!pre) return []; |
| const code = pre.children.find((c): c is HastElement => c.type === "element" && c.tagName === "code"); |
| return code ? code.children : []; |
| } |
|
|
| function isLineElement(node: ElementContent): node is HastElement { |
| if (node.type !== "element") return false; |
| return readHastClasses(node).includes("line"); |
| } |
|
|
| function buildLineNumberWidget(n: number): HTMLSpanElement { |
| const span = document.createElement("span"); |
| span.className = "code-line-num"; |
| span.textContent = String(n); |
| span.setAttribute("contenteditable", "false"); |
| span.setAttribute("aria-hidden", "true"); |
| return span; |
| } |
|
|
| interface DecorationBuildCtx { |
| doc: ProsemirrorNode; |
| name: string; |
| highlighter: ShikiHighlighter | null; |
| defaultLanguage: string | null; |
| } |
|
|
| function buildDecorations({ doc, name, highlighter, defaultLanguage }: DecorationBuildCtx): DecorationSet { |
| const decorations: Decoration[] = []; |
| const blocks = findChildren(doc, (node) => node.type.name === name); |
|
|
| for (const block of blocks) { |
| const text = block.node.textContent; |
|
|
| |
| |
| |
| |
| |
| { |
| const n = 1; |
| const pos = block.pos + 1; |
| decorations.push( |
| Decoration.widget(pos, () => buildLineNumberWidget(n), { |
| side: -1, |
| key: `cln-${block.pos}-${n}`, |
| ignoreSelection: true, |
| }), |
| ); |
| } |
| let lineNo = 1; |
| for (let i = 0; i < text.length; i++) { |
| if (text.charCodeAt(i) === 10 ) { |
| lineNo += 1; |
| const n = lineNo; |
| const pos = block.pos + 1 + i + 1; |
| decorations.push( |
| Decoration.widget(pos, () => buildLineNumberWidget(n), { |
| side: -1, |
| key: `cln-${block.pos}-${n}`, |
| ignoreSelection: true, |
| }), |
| ); |
| } |
| } |
|
|
| if (!highlighter || !text) continue; |
|
|
| |
| |
| const raw = block.node.attrs.language || defaultLanguage || ""; |
| const lang = normalizeLang(raw) || detectShikiLang(text); |
|
|
| let hast: HastRoot; |
| try { |
| hast = highlighter.codeToHast(text, { |
| lang: isSupportedLang(lang) ? lang : "text", |
| themes: SHIKI_THEMES, |
| defaultColor: false, |
| }) as HastRoot; |
| } catch { |
| continue; |
| } |
|
|
| const children = getCodeChildren(hast); |
| let offset = block.pos + 1; |
| for (const child of children) { |
| if (child.type === "text") { |
| offset += child.value.length; |
| continue; |
| } |
| if (!isLineElement(child)) continue; |
| offset = walkLineTokens(child.children, offset, decorations); |
| } |
| } |
|
|
| return DecorationSet.create(doc, decorations); |
| } |
|
|
| const shikiPluginKey = new PluginKey<DecorationSet>("shiki"); |
|
|
| function createShikiPlugin(name: string, defaultLanguage: string | null): Plugin<DecorationSet> { |
| let highlighter: ShikiHighlighter | null = null; |
|
|
| const build = (doc: ProsemirrorNode) => |
| buildDecorations({ doc, name, highlighter, defaultLanguage }); |
|
|
| return new Plugin<DecorationSet>({ |
| key: shikiPluginKey, |
| state: { |
| init: (_cfg, { doc }) => build(doc), |
| apply(tr, decorationSet, oldState, newState) { |
| if (tr.getMeta(shikiPluginKey)?.refresh) return build(tr.doc); |
|
|
| const oldNodeName = oldState.selection.$head.parent.type.name; |
| const newNodeName = newState.selection.$head.parent.type.name; |
| const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name); |
| const newNodes = findChildren(newState.doc, (node) => node.type.name === name); |
|
|
| const needsRebuild = |
| tr.docChanged && |
| ([oldNodeName, newNodeName].includes(name) || |
| newNodes.length !== oldNodes.length || |
| tr.steps.some((step) => { |
| const s = step as unknown as { from?: number; to?: number }; |
| return ( |
| s.from !== undefined && |
| s.to !== undefined && |
| oldNodes.some((n) => n.pos >= (s.from as number) && n.pos + n.node.nodeSize <= (s.to as number)) |
| ); |
| })); |
|
|
| if (needsRebuild) return build(tr.doc); |
| return decorationSet.map(tr.mapping, tr.doc); |
| }, |
| }, |
| props: { |
| decorations(state) { |
| return shikiPluginKey.getState(state); |
| }, |
| }, |
| view(view: EditorView) { |
| let cancelled = false; |
| getSharedHighlighter().then((h) => { |
| if (cancelled) return; |
| highlighter = h; |
| if (!view.isDestroyed) { |
| view.dispatch(view.state.tr.setMeta(shikiPluginKey, { refresh: true })); |
| } |
| }); |
| return { |
| destroy() { |
| cancelled = true; |
| }, |
| }; |
| }, |
| }); |
| } |
|
|
| export const CodeBlockShiki = CodeBlock.extend<CodeBlockOptions>({ |
| addProseMirrorPlugins() { |
| const parentPlugins = this.parent?.() || []; |
| return [ |
| ...parentPlugins, |
| createShikiPlugin(this.name, this.options.defaultLanguage ?? null), |
| ]; |
| }, |
| addNodeView() { |
| return ({ node, HTMLAttributes }) => { |
| const pre = document.createElement("pre"); |
| pre.classList.add("shiki"); |
| for (const [key, value] of Object.entries(HTMLAttributes)) { |
| if (value !== undefined && value !== null && value !== false) { |
| pre.setAttribute(key, String(value)); |
| } |
| } |
|
|
| const code = document.createElement("code"); |
| pre.append(code); |
|
|
| const applyLang = (n: ProsemirrorNode) => { |
| const raw = (n.attrs.language as string | null | undefined) || ""; |
| |
| |
| const normalized = raw ? normalizeLang(raw) : detectShikiLang(n.textContent || ""); |
| if (normalized) { |
| pre.dataset.lang = normalized; |
| code.className = `language-${normalized}`; |
| } else { |
| delete pre.dataset.lang; |
| code.className = ""; |
| } |
| }; |
|
|
| applyLang(node); |
|
|
| return { |
| dom: pre, |
| contentDOM: code, |
| update(updatedNode) { |
| if (updatedNode.type.name !== "codeBlock") return false; |
| applyLang(updatedNode); |
| return true; |
| }, |
| }; |
| }; |
| }, |
| }); |
|
|