chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
/**
* 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);
};
}