/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/render/highlight.mjs * Purpose : Shiki syntax highlighting — dual theme, line anchors * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createHighlighter, bundledLanguages } from 'shiki'; import { escapeHtml } from '../lib/util.mjs'; const THEMES = { light: 'github-light', dark: 'github-dark' }; /** Languages loaded eagerly at boot — everything else lazy-loads. */ const COMMON_LANGS = [ 'javascript', 'typescript', 'jsx', 'tsx', 'python', 'go', 'rust', 'c', 'cpp', 'java', 'ruby', 'php', 'shellscript', 'sql', 'json', 'jsonc', 'yaml', 'toml', 'html', 'css', 'scss', 'markdown', 'diff', 'docker', 'make', 'xml', 'ini', 'lua', 'swift', 'kotlin', 'r', 'julia', ]; /** File extension → shiki language id (for the blob view). */ const EXT_TO_LANG = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'jsx', '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'tsx', '.py': 'python', '.pyw': 'python', '.pyi': 'python', '.go': 'go', '.rs': 'rust', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp', '.cs': 'csharp', '.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin', '.swift': 'swift', '.m': 'objective-c', '.mm': 'objective-cpp', '.rb': 'ruby', '.rake': 'ruby', '.gemspec': 'ruby', '.php': 'php', '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', '.fish': 'fish', '.ps1': 'powershell', '.pl': 'perl', '.pm': 'perl', '.lua': 'lua', '.r': 'r', '.jl': 'julia', '.scala': 'scala', '.hs': 'haskell', '.ex': 'elixir', '.exs': 'elixir', '.erl': 'erlang', '.clj': 'clojure', '.cljs': 'clojure', '.dart': 'dart', '.zig': 'zig', '.nim': 'nim', '.ml': 'ocaml', '.mli': 'ocaml', '.fs': 'fsharp', '.fsx': 'fsharp', '.sol': 'solidity', '.cu': 'cuda-cpp', '.vue': 'vue', '.svelte': 'svelte', '.astro': 'astro', '.html': 'html', '.htm': 'html', '.xhtml': 'html', '.xml': 'xml', '.svg': 'xml', '.plist': 'xml', '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less', '.json': 'json', '.jsonc': 'jsonc', '.ipynb': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml', '.ini': 'ini', '.cfg': 'ini', '.conf': 'ini', '.sql': 'sql', '.graphql': 'graphql', '.gql': 'graphql', '.proto': 'proto', '.md': 'markdown', '.markdown': 'markdown', '.mdx': 'mdx', '.rst': 'rst', '.tex': 'latex', '.sty': 'latex', '.bib': 'bibtex', '.dockerfile': 'docker', '.mk': 'make', '.cmake': 'cmake', '.nix': 'nix', '.tf': 'terraform', '.tfvars': 'terraform', '.groovy': 'groovy', '.gradle': 'groovy', '.vim': 'viml', '.njk': 'jinja', '.ejs': 'html', '.hbs': 'handlebars', '.env': 'dotenv', '.diff': 'diff', '.patch': 'diff', '.txt': 'text', '.log': 'text', '.csv': 'csv', '.service': 'ini', }; const FILENAME_TO_LANG = { Dockerfile: 'docker', Containerfile: 'docker', Makefile: 'make', GNUmakefile: 'make', makefile: 'make', 'CMakeLists.txt': 'cmake', '.gitignore': 'text', '.gitattributes': 'text', '.vimrc': 'viml', '.env': 'dotenv', '.env.example': 'dotenv', LICENSE: 'text', }; let highlighter = null; /** Boot-time initialization — must run before any highlight call. */ export async function initHighlighter() { if (highlighter) return highlighter; highlighter = await createHighlighter({ themes: Object.values(THEMES), langs: COMMON_LANGS, }); return highlighter; } /** * Normalize a user-supplied fence language to a loadable shiki id. * @param {string} lang * @returns {string|null} shiki id, or null when unknown */ export function resolveLang(lang) { if (!lang) return null; const id = String(lang).trim().toLowerCase(); const aliases = { sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript', shell: 'shellscript', console: 'shellscript', 'c++': 'cpp', 'c#': 'csharp', yml: 'yaml', dockerfile: 'docker', makefile: 'make', js: 'javascript', ts: 'typescript', golang: 'go', rb: 'ruby', py: 'python', kt: 'kotlin', plaintext: 'text', text: 'text', txt: 'text', }; const resolved = aliases[id] ?? id; if (resolved === 'text') return 'text'; return resolved in bundledLanguages ? resolved : null; } /** * Ensure a set of shiki languages are loaded (lazy, cached in the singleton). * @param {string[]} langs shiki ids */ export async function ensureLangs(langs) { if (!highlighter) await initHighlighter(); const loaded = new Set(highlighter.getLoadedLanguages()); for (const lang of langs) { if (!lang || lang === 'text' || loaded.has(lang)) continue; if (lang in bundledLanguages) { try { await highlighter.loadLanguage(lang); } catch { /* fall back to plain rendering */ } } } } /** * Synchronously highlight code (language must already be loaded). * @param {string} code * @param {string|null} lang shiki id * @returns {string} `
…` markup (plain fallback when needed)
 */
export function highlightSync(code, lang) {
  const plain = () =>
    `
${code
      .split('\n')
      .map((l) => `${escapeHtml(l)}`)
      .join('\n')}
`; if (!highlighter || !lang || lang === 'text') return plain(); if (!highlighter.getLoadedLanguages().includes(lang)) return plain(); try { return highlighter.codeToHtml(code, { lang, themes: THEMES, defaultColor: false, }); } catch { return plain(); } } /** * Async highlight — loads the language on demand first. * @param {string} code * @param {string|null} langInput raw language hint (fence info / extension) */ export async function highlight(code, langInput) { const lang = resolveLang(langInput); if (lang) await ensureLangs([lang]); return highlightSync(code, lang); } /** * Highlight a full file for the blob view — adds line ids + anchor links. * @param {string} code file content * @param {string} path file path (extension-based detection) * @returns {Promise<{html: string, lines: number, lang: string|null}>} */ export async function highlightFile(code, path) { const base = path.split('/').pop() ?? path; const dot = base.lastIndexOf('.'); const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''; const lang = FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null; const html = await highlight(code, lang); let line = 0; const withAnchors = html.replaceAll('', () => { line += 1; return `${line}`; }); const lines = code === '' ? 0 : code.split('\n').length; return { html: withAnchors, lines, lang }; } /** * Language hint for a path (used by the blob header badge). * @param {string} path * @returns {string|null} */ export function langForPath(path) { const base = path.split('/').pop() ?? path; const dot = base.lastIndexOf('.'); const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''; return FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null; }