SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
7.6 KB · 186 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/render/highlight.mjs8 *  Purpose : Shiki syntax highlighting — dual theme, line anchors9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { createHighlighter, bundledLanguages } from 'shiki';14import { escapeHtml } from '../lib/util.mjs';1516const THEMES = { light: 'github-light', dark: 'github-dark' };1718/** Languages loaded eagerly at boot — everything else lazy-loads. */19const COMMON_LANGS = [20  'javascript', 'typescript', 'jsx', 'tsx', 'python', 'go', 'rust', 'c', 'cpp',21  'java', 'ruby', 'php', 'shellscript', 'sql', 'json', 'jsonc', 'yaml', 'toml', 'html',22  'css', 'scss', 'markdown', 'diff', 'docker', 'make', 'xml', 'ini', 'lua', 'swift',23  'kotlin', 'r', 'julia',24];2526/** File extension → shiki language id (for the blob view). */27const EXT_TO_LANG = {28  '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'jsx',29  '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'tsx',30  '.py': 'python', '.pyw': 'python', '.pyi': 'python',31  '.go': 'go', '.rs': 'rust',32  '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp',33  '.cs': 'csharp', '.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin', '.swift': 'swift',34  '.m': 'objective-c', '.mm': 'objective-cpp',35  '.rb': 'ruby', '.rake': 'ruby', '.gemspec': 'ruby', '.php': 'php',36  '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', '.fish': 'fish',37  '.ps1': 'powershell', '.pl': 'perl', '.pm': 'perl', '.lua': 'lua',38  '.r': 'r', '.jl': 'julia', '.scala': 'scala', '.hs': 'haskell',39  '.ex': 'elixir', '.exs': 'elixir', '.erl': 'erlang', '.clj': 'clojure', '.cljs': 'clojure',40  '.dart': 'dart', '.zig': 'zig', '.nim': 'nim', '.ml': 'ocaml', '.mli': 'ocaml',41  '.fs': 'fsharp', '.fsx': 'fsharp', '.sol': 'solidity', '.cu': 'cuda-cpp',42  '.vue': 'vue', '.svelte': 'svelte', '.astro': 'astro',43  '.html': 'html', '.htm': 'html', '.xhtml': 'html', '.xml': 'xml', '.svg': 'xml', '.plist': 'xml',44  '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less',45  '.json': 'json', '.jsonc': 'jsonc', '.ipynb': 'json',46  '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml', '.ini': 'ini', '.cfg': 'ini', '.conf': 'ini',47  '.sql': 'sql', '.graphql': 'graphql', '.gql': 'graphql', '.proto': 'proto',48  '.md': 'markdown', '.markdown': 'markdown', '.mdx': 'mdx', '.rst': 'rst',49  '.tex': 'latex', '.sty': 'latex', '.bib': 'bibtex',50  '.dockerfile': 'docker', '.mk': 'make', '.cmake': 'cmake',51  '.nix': 'nix', '.tf': 'terraform', '.tfvars': 'terraform',52  '.groovy': 'groovy', '.gradle': 'groovy', '.vim': 'viml', '.njk': 'jinja',53  '.ejs': 'html', '.hbs': 'handlebars', '.env': 'dotenv', '.diff': 'diff', '.patch': 'diff',54  '.txt': 'text', '.log': 'text', '.csv': 'csv', '.service': 'ini',55};5657const FILENAME_TO_LANG = {58  Dockerfile: 'docker', Containerfile: 'docker', Makefile: 'make', GNUmakefile: 'make',59  makefile: 'make', 'CMakeLists.txt': 'cmake', '.gitignore': 'text', '.gitattributes': 'text',60  '.vimrc': 'viml', '.env': 'dotenv', '.env.example': 'dotenv', LICENSE: 'text',61};6263let highlighter = null;6465/** Boot-time initialization — must run before any highlight call. */66export async function initHighlighter() {67  if (highlighter) return highlighter;68  highlighter = await createHighlighter({69    themes: Object.values(THEMES),70    langs: COMMON_LANGS,71  });72  return highlighter;73}7475/**76 * Normalize a user-supplied fence language to a loadable shiki id.77 * @param {string} lang78 * @returns {string|null} shiki id, or null when unknown79 */80export function resolveLang(lang) {81  if (!lang) return null;82  const id = String(lang).trim().toLowerCase();83  const aliases = {84    sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript', shell: 'shellscript',85    console: 'shellscript', 'c++': 'cpp', 'c#': 'csharp', yml: 'yaml', dockerfile: 'docker',86    makefile: 'make', js: 'javascript', ts: 'typescript', golang: 'go', rb: 'ruby',87    py: 'python', kt: 'kotlin', plaintext: 'text', text: 'text', txt: 'text',88  };89  const resolved = aliases[id] ?? id;90  if (resolved === 'text') return 'text';91  return resolved in bundledLanguages ? resolved : null;92}9394/**95 * Ensure a set of shiki languages are loaded (lazy, cached in the singleton).96 * @param {string[]} langs shiki ids97 */98export async function ensureLangs(langs) {99  if (!highlighter) await initHighlighter();100  const loaded = new Set(highlighter.getLoadedLanguages());101  for (const lang of langs) {102    if (!lang || lang === 'text' || loaded.has(lang)) continue;103    if (lang in bundledLanguages) {104      try {105        await highlighter.loadLanguage(lang);106      } catch {107        /* fall back to plain rendering */108      }109    }110  }111}112113/**114 * Synchronously highlight code (language must already be loaded).115 * @param {string} code116 * @param {string|null} lang shiki id117 * @returns {string} `<pre class="shiki">…` markup (plain fallback when needed)118 */119export function highlightSync(code, lang) {120  const plain = () =>121    `<pre class="shiki shiki-plain"><code>${code122      .split('\n')123      .map((l) => `<span class="line">${escapeHtml(l)}</span>`)124      .join('\n')}</code></pre>`;125  if (!highlighter || !lang || lang === 'text') return plain();126  if (!highlighter.getLoadedLanguages().includes(lang)) return plain();127  try {128    return highlighter.codeToHtml(code, {129      lang,130      themes: THEMES,131      defaultColor: false,132    });133  } catch {134    return plain();135  }136}137138/**139 * Async highlight — loads the language on demand first.140 * @param {string} code141 * @param {string|null} langInput raw language hint (fence info / extension)142 */143export async function highlight(code, langInput) {144  const lang = resolveLang(langInput);145  if (lang) await ensureLangs([lang]);146  return highlightSync(code, lang);147}148149/**150 * Highlight a full file for the blob view — adds line ids + anchor links.151 * @param {string} code file content152 * @param {string} path file path (extension-based detection)153 * @returns {Promise<{html: string, lines: number, lang: string|null}>}154 */155export async function highlightFile(code, path) {156  const base = path.split('/').pop() ?? path;157  const dot = base.lastIndexOf('.');158  const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';159  const lang = FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null;160  const html = await highlight(code, lang);161  let line = 0;162  let withAnchors = html.replaceAll('<span class="line">', () => {163    line += 1;164    return `<span class="line" id="L${line}"><a class="ln" href="#L${line}" aria-label="Line ${line}">${line}</a>`;165  });166  // Lines render as display:block — the \n shiki leaves between spans would167  // otherwise add a phantom empty line inside the <pre> (doubled spacing).168  withAnchors = withAnchors169    .replace(/\n(?=<span class="line")/g, '')170    .replace(/\n(?=<\/code>)/g, '');171  const lines = code === '' ? 0 : code.split('\n').length;172  return { html: withAnchors, lines, lang };173}174175/**176 * Language hint for a path (used by the blob header badge).177 * @param {string} path178 * @returns {string|null}179 */180export function langForPath(path) {181  const base = path.split('/').pop() ?? path;182  const dot = base.lastIndexOf('.');183  const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';184  return FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null;185}186