~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
253 lines · 7.3 KB Raw History
// Syntax colouring for file bodies and diffs.
//
// The whole app is one inlined document, so the grammars are chosen rather
// than imported wholesale: highlight.js ships over a megabyte of them and
// this list is what a repository usually holds. A file whose extension is
// not here renders as plain text, which is the same thing the page does for
// a file it cannot colour.

import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import css from 'highlight.js/lib/languages/css';
import diff from 'highlight.js/lib/languages/diff';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import groovy from 'highlight.js/lib/languages/groovy';
import ini from 'highlight.js/lib/languages/ini';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import kotlin from 'highlight.js/lib/languages/kotlin';
import markdown from 'highlight.js/lib/languages/markdown';
import objectivec from 'highlight.js/lib/languages/objectivec';
import plaintext from 'highlight.js/lib/languages/plaintext';
import properties from 'highlight.js/lib/languages/properties';
import python from 'highlight.js/lib/languages/python';
import ruby from 'highlight.js/lib/languages/ruby';
import rust from 'highlight.js/lib/languages/rust';
import sql from 'highlight.js/lib/languages/sql';
import swift from 'highlight.js/lib/languages/swift';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';

for (const [name, grammar] of Object.entries({
  bash,
  css,
  diff,
  dockerfile,
  go,
  groovy,
  ini,
  java,
  javascript,
  json,
  kotlin,
  markdown,
  objectivec,
  plaintext,
  properties,
  python,
  ruby,
  rust,
  sql,
  swift,
  typescript,
  xml,
  yaml,
})) {
  hljs.registerLanguage(name, grammar);
}

/** File extension to grammar. Anything absent renders plain. */
/** @type {Record<string, string>} */
const BY_EXTENSION = {
  bash: 'bash',
  cjs: 'javascript',
  css: 'css',
  diff: 'diff',
  entitlements: 'xml',
  go: 'go',
  gradle: 'groovy',
  groovy: 'groovy',
  h: 'objectivec',
  htm: 'xml',
  html: 'xml',
  ini: 'ini',
  java: 'java',
  js: 'javascript',
  json: 'json',
  jsonc: 'json',
  jsx: 'javascript',
  kt: 'kotlin',
  kts: 'kotlin',
  m: 'objectivec',
  markdown: 'markdown',
  md: 'markdown',
  mjs: 'javascript',
  mm: 'objectivec',
  patch: 'diff',
  plist: 'xml',
  properties: 'properties',
  py: 'python',
  rb: 'ruby',
  rs: 'rust',
  sh: 'bash',
  sql: 'sql',
  storyboard: 'xml',
  // highlight.js has no Svelte grammar. The xml grammar colours the markup,
  // and its <script> and <style> sub-languages colour the two blocks.
  svelte: 'xml',
  svg: 'xml',
  swift: 'swift',
  toml: 'ini',
  ts: 'typescript',
  tsx: 'typescript',
  xcscheme: 'xml',
  xib: 'xml',
  xml: 'xml',
  yaml: 'yaml',
  yml: 'yaml',
  zsh: 'bash',
};

/** Files with no extension that still have a known grammar. */
/** @type {Record<string, string>} */
const BY_NAME = {
  dockerfile: 'dockerfile',
  fastfile: 'ruby',
  gemfile: 'ruby',
  justfile: 'bash',
  makefile: 'plaintext',
  podfile: 'ruby',
  rakefile: 'ruby',
};

/**
 * Colouring a very large file costs more than it is worth, and the page
 * already refuses to inline one past its own preview cap.
 */
const MAX_BYTES = 200 * 1024;

/**
 * The grammar for a path, or '' when the page should render it plain.
 * @param {string} path
 * @returns {string}
 */
export function languageFor(path) {
  const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase();
  const dot = name.lastIndexOf('.');
  if (dot > 0) {
    const language = BY_EXTENSION[name.slice(dot + 1)];
    if (language) return language;
  }
  return BY_NAME[name] ?? '';
}

/** @param {string} text */
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

/**
 * Colour a whole text. The result is HTML, and highlight.js escapes the
 * source it is given, so nothing in the file can become markup.
 * @param {string} text
 * @param {string} language - from languageFor, '' to render plain
 * @returns {string} HTML
 */
export function highlightBlock(text, language) {
  if (!language || text.length > MAX_BYTES) return escapeHtml(text);
  try {
    return hljs.highlight(text, { language, ignoreIllegals: true }).value;
  } catch {
    // An unregistered grammar or a pathological input: the text still has to
    // reach the page.
    return escapeHtml(text);
  }
}

/**
 * Colour a text and cut it into one HTML string per line.
 *
 * A span opened by highlight.js can cross a newline, and a diff prints each
 * line in its own row, so every span still open at a line break is closed
 * and reopened on the next line.
 * @param {string} text
 * @param {string} language
 * @returns {string[]} one HTML string per line
 */
export function highlightToLines(text, language) {
  const html = highlightBlock(text, language);
  /** @type {string[]} */
  const lines = [];
  /** @type {string[]} */
  const open = [];
  let current = '';

  const addText = (/** @type {string} */ chunk) => {
    const parts = chunk.split('\n');
    for (let i = 0; i < parts.length; i++) {
      if (i > 0) {
        current += '</span>'.repeat(open.length);
        lines.push(current);
        current = open.map((cls) => `<span class="${cls}">`).join('');
      }
      current += parts[i];
    }
  };

  const tag = /<span class="([^"]*)">|<\/span>/g;
  let at = 0;
  let match = tag.exec(html);
  while (match) {
    addText(html.slice(at, match.index));
    at = tag.lastIndex;
    if (match[1] === undefined) open.pop();
    else open.push(match[1]);
    current += match[0];
    match = tag.exec(html);
  }
  addText(html.slice(at));
  lines.push(current);
  return lines;
}

/**
 * @typedef {Object} DiffRow
 * @property {'context'|'add'|'delete'} type
 * @property {string} text
 * @property {number|null} oldLine - 1-based line number before the change
 * @property {number|null} newLine - 1-based line number after the change
 */

/**
 * Colour a changed file, a diff row at a time.
 *
 * Both whole texts are coloured, and each row reads the line it came from.
 * A hunk on its own is not enough to colour: it is a fragment, and a grammar
 * that reads ahead decides by what it can see. highlight.js takes
 * `<Component prop={x}>` for JSX only when the matching close tag is in the
 * same text, and prints the tag plain when it is not. A block comment or a
 * template literal cut by a hunk boundary reads the same way.
 *
 * @param {string} beforeText - the whole file before the change
 * @param {string} afterText - the whole file after it
 * @param {string} language - from languageFor, '' to render plain
 * @returns {(row: DiffRow) => string} HTML for one row
 */
export function diffHighlighter(beforeText, afterText, language) {
  const before = highlightToLines(beforeText, language);
  const after = highlightToLines(afterText, language);
  return (row) => {
    // A context line reads the same on both sides, so it takes the newer
    // colouring with the added lines.
    const deleted = row.type === 'delete';
    const side = deleted ? before : after;
    const number = deleted ? row.oldLine : row.newLine;
    return side[(number ?? 0) - 1] ?? escapeHtml(row.text);
  };
}