Vectrum
Sign In
122 lines · 4.4 KB · TypeScript
import { bundledLanguages, codeToHtml, type BundledLanguage, type ThemeRegistration } from "shiki";

const SPECIAL_NAMES: Record<string, string> = {
  dockerfile: "docker",
  makefile: "make",
  gemfile: "ruby",
  rakefile: "ruby",
  ".gitignore": "shellscript",
  ".env": "dotenv",
};

export function languageFor(filename: string): BundledLanguage | null {
  const lower = filename.toLowerCase();
  const candidate = SPECIAL_NAMES[lower] ?? (lower.includes(".") ? lower.split(".").pop()! : "");
  return candidate in bundledLanguages ? (candidate as BundledLanguage) : null;
}

const MAX_HIGHLIGHT_BYTES = 512 * 1024;

const syn = (token: string) => `var(--mu-syn-${token})`;

/**
 * MetalUI's code tints (the code card's: keywords, types, members, strings, numbers, comments,
 * punctuation) as a Shiki theme. The colours are MetalUI's `--mu-syn-*` variables, which follow the
 * colorway, so one theme serves bone and graphite (and matches the editor's, see editor-theme.ts).
 */
const metalSyntax: ThemeRegistration = {
  name: "metalui",
  type: "light",
  colors: { "editor.background": "transparent", "editor.foreground": syn("text") },
  tokenColors: [
    { settings: { foreground: syn("text") } },
    { scope: ["comment", "punctuation.definition.comment", "string.comment", "markup.quote"], settings: { foreground: syn("comment") } },
    {
      scope: [
        "keyword",
        "storage",
        "storage.type",
        "storage.modifier",
        "keyword.operator.new",
        "keyword.operator.expression",
        "keyword.operator.logical.python",
        "variable.language",
        "entity.name.tag",
        "markup.heading",
        "markup.heading entity.name",
        "punctuation.definition.heading",
      ],
      settings: { foreground: syn("keyword") },
    },
    {
      scope: [
        "entity.name.type",
        "entity.name.class",
        "entity.name.namespace",
        "entity.other.inherited-class",
        "support.type",
        "support.class",
        "storage.type.primitive",
        "storage.type.built-in",
        "entity.name.type.module",
        "entity.other.attribute-name.class.css",
        "entity.name.tag.css",
      ],
      settings: { foreground: syn("type") },
    },
    {
      scope: [
        "entity.name.function",
        "support.function",
        "meta.function-call entity.name.function",
        "variable.other.property",
        "variable.other.object.property",
        "support.variable.property",
        "meta.object-literal.key",
        "entity.other.attribute-name",
        "support.type.property-name",
        "meta.property-name",
        "markup.underline.link",
        "string.other.link",
      ],
      settings: { foreground: syn("member") },
    },
    {
      scope: ["string", "string.template", "string.regexp", "punctuation.definition.string", "markup.inline.raw", "markup.fenced_code", "markup.raw"],
      settings: { foreground: syn("string") },
    },
    {
      scope: ["constant.numeric", "constant.language", "constant.character", "constant.other", "support.constant", "variable.other.constant", "keyword.other.unit"],
      settings: { foreground: syn("number") },
    },
    {
      scope: [
        "punctuation",
        "keyword.operator",
        "meta.brace",
        "punctuation.definition.tag",
        "punctuation.separator",
        "punctuation.terminator",
        "punctuation.accessor",
        "meta.embedded punctuation.section",
      ],
      settings: { foreground: syn("punct") },
    },
    { scope: ["punctuation.definition.template-expression", "punctuation.section.embedded"], settings: { foreground: syn("keyword") } },
    { scope: ["markup.inserted"], settings: { foreground: syn("string") } },
    { scope: ["markup.deleted"], settings: { foreground: "var(--mu-red)" } },
  ],
};

export async function CodeView({ code, filename }: { code: string; filename: string }) {
  const lang = languageFor(filename);
  // Very large files are shown without highlighting to keep rendering fast.
  const html = await codeToHtml(code.replace(/\n$/, ""), {
    lang: lang && code.length <= MAX_HIGHLIGHT_BYTES ? lang : "text",
    // Both slots get the same theme: its colours are colorway variables, and the app's graphite rule
    // (globals.css) reads Shiki's dark slot.
    themes: { light: metalSyntax, dark: { ...metalSyntax, name: "metalui-dark", type: "dark" } },
    defaultColor: "light",
  });
  return <div className="code-view font-mono" dangerouslySetInnerHTML={{ __html: html }} />;
}