spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
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 const 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 const lines = code === '' ? 0 : code.split('\n').length;167 return { html: withAnchors, lines, lang };168}169170/**171 * Language hint for a path (used by the blob header badge).172 * @param {string} path173 * @returns {string|null}174 */175export function langForPath(path) {176 const base = path.split('/').pop() ?? path;177 const dot = base.lastIndexOf('.');178 const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';179 return FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null;180}181