cobble-lang 0.6.2

A modern, Python-like language for creating Minecraft Data Packs
Documentation
import type { ReactNode } from "react";

export type HighlightLanguage = "cobble" | "mcfunction" | "json" | "text";

const keywords = new Set([
  "and",
  "as",
  "break",
  "class",
  "continue",
  "def",
  "elif",
  "else",
  "false",
  "for",
  "from",
  "if",
  "import",
  "in",
  "none",
  "not",
  "or",
  "pass",
  "return",
  "true",
  "while"
]);

const helpers = new Set([
  "datapack",
  "event",
  "range",
  "stdlib",
  "text"
]);

const minecraftCommands = new Set([
  "advancement",
  "attribute",
  "bossbar",
  "clear",
  "clone",
  "damage",
  "data",
  "datapack",
  "dialog",
  "effect",
  "enchant",
  "execute",
  "experience",
  "fetchprofile",
  "fill",
  "function",
  "gamemode",
  "gamerule",
  "give",
  "item",
  "loot",
  "particle",
  "playsound",
  "recipe",
  "return",
  "say",
  "scoreboard",
  "setblock",
  "summon",
  "tag",
  "team",
  "tellraw",
  "title",
  "tp",
  "version",
  "waypoint"
]);

const tokenPattern =
  /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|@[a-z](?:\[[^\]\n]*\])?|\/?[A-Za-z_][A-Za-z0-9_.:-]*|\b\d+(?:\.\d+)?|\.\.|[{}\[\]():,=+\-*<>/])/g;

type CodeBlockProps = {
  code: string;
  language: HighlightLanguage;
  className?: string;
};

export function CodeBlock({ code, language, className }: CodeBlockProps) {
  return (
    <pre className={["syntax-code", className].filter(Boolean).join(" ")}>
      <code>
        <HighlightedCode code={code} language={language} />
      </code>
    </pre>
  );
}

export function HighlightedCode({
  code,
  language
}: {
  code: string;
  language: HighlightLanguage;
}) {
  return <>{highlight(code, language)}</>;
}

function highlight(code: string, language: HighlightLanguage) {
  if (language === "text") {
    return [code];
  }

  const nodes: ReactNode[] = [];
  let cursor = 0;

  for (const match of code.matchAll(tokenPattern)) {
    const token = match[0];
    const index = match.index ?? 0;

    if (index > cursor) {
      nodes.push(code.slice(cursor, index));
    }

    const className = classifyToken(code, token, index, index + token.length, language);
    nodes.push(
      className ? (
        <span className={`tok ${className}`} key={`${index}-${token}`}>
          {token}
        </span>
      ) : (
        token
      )
    );
    cursor = index + token.length;
  }

  if (cursor < code.length) {
    nodes.push(code.slice(cursor));
  }

  return nodes;
}

function classifyToken(
  code: string,
  token: string,
  start: number,
  end: number,
  language: HighlightLanguage
) {
  const lower = token.toLowerCase();

  if (token.startsWith("#")) {
    return "tok-comment";
  }

  if (isQuoted(token)) {
    return language === "json" && isJsonKey(code, end) ? "tok-key" : "tok-string";
  }

  if (token.startsWith("@")) {
    return "tok-selector";
  }

  if (/^\d/.test(token)) {
    return "tok-number";
  }

  if (lower === "true" || lower === "false" || lower === "null" || lower === "none") {
    return "tok-constant";
  }

  if (token.startsWith("/") && minecraftCommands.has(lower.slice(1))) {
    return "tok-command";
  }

  if (language === "mcfunction" && isLineCommand(code, start) && minecraftCommands.has(lower)) {
    return "tok-command";
  }

  if (minecraftCommands.has(lower) && language !== "json") {
    return "tok-command";
  }

  if (isResourceId(token)) {
    return "tok-resource";
  }

  if (keywords.has(lower)) {
    return "tok-keyword";
  }

  if (helpers.has(token.split(".")[0])) {
    return "tok-helper";
  }

  if (code[end] === "(" && language !== "json") {
    return "tok-call";
  }

  if (/^[{}\[\]():,=+\-*<>/]|\.{2}$/.test(token)) {
    return "tok-punctuation";
  }

  return "";
}

function isQuoted(token: string) {
  return (
    (token.startsWith("\"") && token.endsWith("\"")) ||
    (token.startsWith("'") && token.endsWith("'"))
  );
}

function isJsonKey(code: string, end: number) {
  return /^\s*:/.test(code.slice(end));
}

function isLineCommand(code: string, start: number) {
  const lineStart = code.lastIndexOf("\n", start - 1) + 1;
  return code.slice(lineStart, start).trim().length === 0;
}

function isResourceId(token: string) {
  return /^[A-Za-z0-9_.-]+:[A-Za-z0-9_./-]+$/.test(token.replace(/^\//, ""));
}