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"; /** * TipTap code block with Shiki-powered syntax highlighting. * * Architecture mirrors `@tiptap/extension-code-block-lowlight`: * 1. Extends the base `CodeBlock` node (input rules, keymap, etc.). * 2. Registers a ProseMirror plugin that produces a `DecorationSet` from * a Shiki `codeToHast()` call for every `codeBlock` in the document. * 3. Token coloring uses `Decoration.inline` with `style`/`class` (rendered * by PM as wrapping spans). Line numbers use `Decoration.widget`: one * `` inserted at each source line start. * * Why widgets for numbers (not CSS counters): PM merges overlapping inline * decorations into a single flat span per text range, so an outer `.line` * decoration would NOT produce a nesting parent usable by `counter-increment`. * A widget at line start is a standalone DOM node, which the same CSS can * style identically in the editor and in the published output (the publisher * transformer injects the exact same `` markup). * * The language label (e.g. "python") is rendered by pure CSS using * `pre::after { content: attr(data-lang) }` - no extra DOM. * * Because Shiki is async (WASM engine + grammars), decorations stay empty * until the shared highlighter resolves. A view-level hook then dispatches a * refresh transaction, causing the whole doc to re-decorate at once. */ /** * Walks Shiki's token tree inside a single `` and pushes * one `Decoration.inline` per leaf text node, inheriting `style`/`class` * from ancestors. The `line` class itself is stripped from class propagation * because it only marks Shiki's line wrapper, not the tokens. */ /** * Read the class list off a hast element. Shiki emits a plain `class` string * (not the JSX-style `className` array), so we inspect both to be robust * against upstream changes. */ 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 = {}; 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; } /** * Extract the `` node's children from a Shiki hast root, skipping the * outer `
` and `` wrappers (their styles would leak onto tokens).
 */
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;

    // Always emit line-number widgets, even before the highlighter resolves,
    // so the gutter appears immediately on page load instead of flashing in
    // ~100ms later when Shiki is ready. Each widget captures its own line
    // number via a block-scoped const to avoid the classic let-in-closure
    // pitfall (all widgets would otherwise render the final line number).
    {
      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 /* \n */) {
        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;

    // Auto-detect when no explicit language is set, so language-less blocks
    // get highlighted in the editor just like in the published output.
    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("shiki");

function createShikiPlugin(name: string, defaultLanguage: string | null): Plugin {
  let highlighter: ShikiHighlighter | null = null;

  const build = (doc: ProsemirrorNode) =>
    buildDecorations({ doc, name, highlighter, defaultLanguage });

  return new Plugin({
    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({
  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) || "";
        // Mirror the decoration logic: show the auto-detected language label
        // (and `language-*` class) when the author didn't pick one.
        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;
        },
      };
    };
  },
});