SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%
4.6 KB · 105 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/preview/code.mjs8 *  Purpose : Shiki syntax highlighting + markdown-it GFM rendering9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { createHighlighter } from 'shiki';14import MarkdownIt from 'markdown-it';15import anchor from 'markdown-it-anchor';16import taskLists from 'markdown-it-task-lists';1718/** Extension → shiki language id. Anything unknown highlights as plaintext. */19export const EXT_LANG = {20  js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',21  ts: 'typescript', tsx: 'tsx', py: 'python', rb: 'ruby', go: 'go', rs: 'rust',22  c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp', java: 'java',23  kt: 'kotlin', swift: 'swift', m: 'objective-c', php: 'php', sh: 'shellscript',24  bash: 'shellscript', zsh: 'shellscript', fish: 'fish', ps1: 'powershell',25  sql: 'sql', html: 'html', htm: 'html', xml: 'xml', css: 'css', scss: 'scss',26  less: 'less', json: 'json', jsonc: 'jsonc', yaml: 'yaml', yml: 'yaml',27  toml: 'toml', ini: 'ini', conf: 'ini', env: 'ini', dockerfile: 'docker',28  makefile: 'make', mk: 'make', cmake: 'cmake', gradle: 'groovy', groovy: 'groovy',29  lua: 'lua', r: 'r', jl: 'julia', scala: 'scala', pl: 'perl', ex: 'elixir',30  exs: 'elixir', erl: 'erlang', hs: 'haskell', ml: 'ocaml', clj: 'clojure',31  vue: 'vue', svelte: 'svelte', astro: 'astro', graphql: 'graphql', gql: 'graphql',32  proto: 'proto', tf: 'hcl', hcl: 'hcl', nix: 'nix', zig: 'zig', dart: 'dart',33  sol: 'solidity', asm: 'asm', s: 'asm', vim: 'viml', diff: 'diff', patch: 'diff',34  tex: 'latex', bib: 'latex', md: 'markdown', njk: 'html', csv: 'csv', tsv: 'tsv',35  txt: 'plaintext', log: 'log', bat: 'bat', cmd: 'bat', vb: 'vb', fs: 'fsharp',36  nim: 'nim', cr: 'crystal', d: 'd', pas: 'pascal', f90: 'fortran-free-form',37  cob: 'cobol', ada: 'ada', prisma: 'prisma', cue: 'cue', http: 'http',38};3940let highlighterPromise = null;4142async function getHighlighter() {43  if (!highlighterPromise) {44    highlighterPromise = createHighlighter({45      themes: ['dark-plus', 'light-plus'],46      langs: ['javascript', 'typescript', 'python', 'json', 'yaml', 'markdown', 'shellscript', 'html', 'css', 'sql'],47    });48  }49  return highlighterPromise;50}5152/** Resolve a shiki language for a filename (plaintext fallback). */53export function langForFile(name) {54  const base = name.toLowerCase();55  if (base === 'dockerfile') return 'docker';56  if (base === 'makefile') return 'make';57  const ext = base.includes('.') ? base.split('.').pop() : base;58  return EXT_LANG[ext] ?? 'plaintext';59}6061/**62 * Highlight source text → themed HTML (dual theme via CSS variables).63 * Unknown or unloadable languages degrade to plaintext, never throw.64 */65export async function highlightCode(text, name, { maxBytes = 2_000_000 } = {}) {66  const clipped = text.length > maxBytes;67  const source = clipped ? text.slice(0, maxBytes) : text;68  const hl = await getHighlighter();69  let lang = langForFile(name);70  if (!hl.getLoadedLanguages().includes(lang)) {71    try {72      await hl.loadLanguage(lang);73    } catch {74      lang = 'plaintext';75    }76  }77  const html = hl.codeToHtml(source, {78    lang,79    themes: { dark: 'dark-plus', light: 'light-plus' },80    defaultColor: false,81  });82  return { html, lang, clipped };83}8485const md = new MarkdownIt({ html: false, linkify: true, typographer: true })86  .use(anchor, { permalink: anchor.permalink.headerLink({ safariReaderFix: true }) })87  .use(taskLists, { enabled: false });8889// Fenced code inside markdown gets a plain <pre> with a language class;90// mermaid fences are tagged for client-side rendering.91md.renderer.rules.fence = (tokens, idx) => {92  const token = tokens[idx];93  const info = (token.info || '').trim().split(/\s+/)[0];94  const escaped = md.utils.escapeHtml(token.content);95  if (info === 'mermaid') {96    return `<pre class="mermaid-block" data-mermaid>${escaped}</pre>\n`;97  }98  return `<pre class="code-fence"><code class="language-${md.utils.escapeHtml(info || 'text')}">${escaped}</code></pre>\n`;99};100101/** Render GFM markdown → sanitized HTML (raw HTML disabled). */102export function renderMarkdown(text) {103  return md.render(text ?? '');104}105