SPB Git

spb/spbgit

Public MIT Pinned

SPB Git — the platform hosting itself

feat: web UI — design system, all pages, GitHub-grade README pipeline, OG cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 10, 2026) parent acd5957 Browse files

28 changed files +2,985 −0

added src/render/highlight.mjs +180 −0
@@ -0,0 +1,180 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/render/highlight.mjs
8 + * Purpose : Shiki syntax highlighting — dual theme, line anchors
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { createHighlighter, bundledLanguages } from 'shiki';
14 +import { escapeHtml } from '../lib/util.mjs';
15 +
16 +const THEMES = { light: 'github-light', dark: 'github-dark' };
17 +
18 +/** Languages loaded eagerly at boot — everything else lazy-loads. */
19 +const 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 +];
25 +
26 +/** File extension → shiki language id (for the blob view). */
27 +const 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 +};
56 +
57 +const 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 +};
62 +
63 +let highlighter = null;
64 +
65 +/** Boot-time initialization — must run before any highlight call. */
66 +export 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 +}
74 +
75 +/**
76 + * Normalize a user-supplied fence language to a loadable shiki id.
77 + * @param {string} lang
78 + * @returns {string|null} shiki id, or null when unknown
79 + */
80 +export 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 +}
93 +
94 +/**
95 + * Ensure a set of shiki languages are loaded (lazy, cached in the singleton).
96 + * @param {string[]} langs shiki ids
97 + */
98 +export 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 +}
112 +
113 +/**
114 + * Synchronously highlight code (language must already be loaded).
115 + * @param {string} code
116 + * @param {string|null} lang shiki id
117 + * @returns {string} `<pre class="shiki">…` markup (plain fallback when needed)
118 + */
119 +export function highlightSync(code, lang) {
120 + const plain = () =>
121 + `<pre class="shiki shiki-plain"><code>${code
122 + .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 +}
137 +
138 +/**
139 + * Async highlight — loads the language on demand first.
140 + * @param {string} code
141 + * @param {string|null} langInput raw language hint (fence info / extension)
142 + */
143 +export async function highlight(code, langInput) {
144 + const lang = resolveLang(langInput);
145 + if (lang) await ensureLangs([lang]);
146 + return highlightSync(code, lang);
147 +}
148 +
149 +/**
150 + * Highlight a full file for the blob view — adds line ids + anchor links.
151 + * @param {string} code file content
152 + * @param {string} path file path (extension-based detection)
153 + * @returns {Promise<{html: string, lines: number, lang: string|null}>}
154 + */
155 +export 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 +}
169 +
170 +/**
171 + * Language hint for a path (used by the blob header badge).
172 + * @param {string} path
173 + * @returns {string|null}
174 + */
175 +export 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 +}
added src/render/markdown.mjs +201 −0
@@ -0,0 +1,201 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/render/markdown.mjs
8 + * Purpose : GitHub-grade Markdown pipeline — GFM, badges, mermaid, sanitized
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import MarkdownIt from 'markdown-it';
14 +import anchor from 'markdown-it-anchor';
15 +import taskLists from 'markdown-it-task-lists';
16 +import footnote from 'markdown-it-footnote';
17 +import { full as emoji } from 'markdown-it-emoji';
18 +import sanitizeHtml from 'sanitize-html';
19 +import { posix } from 'node:path';
20 +import { escapeHtml } from '../lib/util.mjs';
21 +import { highlightSync, resolveLang, ensureLangs } from './highlight.mjs';
22 +
23 +/**
24 + * GitHub's heading slug algorithm (lowercase, strip punctuation, dashes).
25 + * @param {string} text
26 + * @returns {string}
27 + */
28 +export function githubSlug(text) {
29 + return String(text)
30 + .trim()
31 + .toLowerCase()
32 + .replace(/<[^>]+>/g, '')
33 + .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')
34 + .replace(/\s/g, '-');
35 +}
36 +
37 +const FENCE_PLACEHOLDER_ATTR = 'data-spbgit-fence';
38 +
39 +/**
40 + * Build the markdown-it instance. Fences render as placeholders and are
41 + * swapped back in after sanitization so shiki markup survives untouched.
42 + * @param {{fences: string[]}} state collector for rendered fence HTML
43 + */
44 +function buildParser(state) {
45 + const md = new MarkdownIt({
46 + html: true,
47 + linkify: true,
48 + breaks: false,
49 + });
50 + md.use(anchor, {
51 + slugify: githubSlug,
52 + permalink: anchor.permalink.linkInsideHeader({
53 + symbol: '<span class="anchor-icon" aria-hidden="true">#</span>',
54 + placement: 'before',
55 + class: 'heading-anchor',
56 + ariaHidden: true,
57 + }),
58 + });
59 + md.use(taskLists, { enabled: false, label: true });
60 + md.use(footnote);
61 + md.use(emoji);
62 +
63 + md.renderer.rules.fence = (tokens, idx) => {
64 + const token = tokens[idx];
65 + const info = (token.info ?? '').trim().split(/\s+/)[0];
66 + let html;
67 + if (info === 'mermaid') {
68 + html = `<div class="mermaid-block"><pre class="mermaid-src">${escapeHtml(token.content)}</pre><div class="mermaid-target" aria-live="polite"></div></div>`;
69 + } else {
70 + const lang = resolveLang(info);
71 + const highlighted = highlightSync(token.content.replace(/\n$/, ''), lang);
72 + html = `<div class="code-block"${info ? ` data-lang="${escapeHtml(info)}"` : ''}>${highlighted}</div>`;
73 + }
74 + const index = state.fences.push(html) - 1;
75 + return `<pre ${FENCE_PLACEHOLDER_ATTR}="${index}"></pre>`;
76 + };
77 + return md;
78 +}
79 +
80 +/**
81 + * Resolve a relative README/blob URL against the repo raw/blob endpoints.
82 + * @param {string} url as written in the document
83 + * @param {{repo: string, ref: string, basePath: string}} ctx basePath = dir of the rendered file
84 + * @param {'raw'|'blob'} mode images go to raw, links go to blob
85 + * @returns {string}
86 + */
87 +export function resolveRelativeUrl(url, ctx, mode) {
88 + if (!url) return url;
89 + const trimmed = url.trim();
90 + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(trimmed)) return trimmed;
91 + const [pathPart, suffix = ''] = splitUrlSuffix(trimmed);
92 + const joined = posix.normalize(posix.join(ctx.basePath || '.', pathPart));
93 + if (joined.startsWith('..')) return trimmed;
94 + const clean = joined.replace(/^\.\//, '').replace(/^\//, '');
95 + const encoded = clean.split('/').map(encodeURIComponent).join('/');
96 + const refEnc = ctx.ref.split('/').map(encodeURIComponent).join('/');
97 + return mode === 'raw'
98 + ? `/raw/${encodeURIComponent(ctx.repo)}/${refEnc}/${encoded}${suffix}`
99 + : `/${encodeURIComponent(ctx.repo)}/blob/${refEnc}/${encoded}${suffix}`;
100 +}
101 +
102 +/** Split `path?query#hash` into [path, suffix]. */
103 +function splitUrlSuffix(url) {
104 + const m = /^([^?#]*)([?#].*)?$/.exec(url);
105 + return [m[1], m[2] ?? ''];
106 +}
107 +
108 +/** Sanitizer allowlist — badges, details, kbd, align HTML all survive. */
109 +function sanitizeOptions(ctx) {
110 + return {
111 + allowedTags: [
112 + 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'dd', 'del',
113 + 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',
114 + 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'mark', 'ol',
115 + 'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small',
116 + 'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody',
117 + 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
118 + ],
119 + allowedAttributes: {
120 + '*': ['align', 'id', 'class', 'dir', 'lang'],
121 + a: ['href', 'title', 'rel', 'name'],
122 + img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
123 + source: ['src', 'srcset', 'type', 'media'],
124 + input: ['type', 'checked', 'disabled'],
125 + td: ['colspan', 'rowspan', 'align', 'valign'],
126 + th: ['colspan', 'rowspan', 'align', 'valign', 'scope'],
127 + details: ['open'],
128 + pre: [FENCE_PLACEHOLDER_ATTR],
129 + div: ['data-lang'],
130 + li: ['value'],
131 + ol: ['start', 'type'],
132 + abbr: ['title'],
133 + },
134 + allowedSchemes: ['http', 'https', 'mailto'],
135 + allowedSchemesByTag: { img: ['http', 'https', 'data'] },
136 + allowProtocolRelative: false,
137 + disallowedTagsMode: 'discard',
138 + transformTags: {
139 + img: (tagName, attribs) => ({
140 + tagName,
141 + attribs: {
142 + ...attribs,
143 + src: resolveRelativeUrl(attribs.src ?? '', ctx, 'raw'),
144 + loading: 'lazy',
145 + },
146 + }),
147 + a: (tagName, attribs) => {
148 + const href = resolveRelativeUrl(attribs.href ?? '', ctx, 'blob');
149 + const external = /^https?:\/\//i.test(href) && !href.startsWith(ctx.publicUrl);
150 + return {
151 + tagName,
152 + attribs: {
153 + ...attribs,
154 + href,
155 + ...(external ? { rel: 'noopener noreferrer' } : {}),
156 + },
157 + };
158 + },
159 + input: (tagName, attribs) => {
160 + if ((attribs.type ?? '').toLowerCase() !== 'checkbox') {
161 + return { tagName: 'span', attribs: {} };
162 + }
163 + return { tagName, attribs: { ...attribs, disabled: 'disabled' } };
164 + },
165 + },
166 + };
167 +}
168 +
169 +/**
170 + * Render a Markdown document to sanitized, GitHub-grade HTML.
171 + * @param {string} source
172 + * @param {{repo: string, ref: string, basePath?: string, publicUrl?: string}} ctx
173 + * @returns {Promise<string>} HTML for insertion inside `<article class="markdown-body">`
174 + */
175 +export async function renderMarkdown(source, ctx) {
176 + const fullCtx = { basePath: '.', publicUrl: '', ...ctx };
177 + // Preload every fence language so fence rendering can stay synchronous.
178 + const fenceLangs = [...source.matchAll(/^\s{0,3}(?:```+|~~~+)\s*([\w#+.-]+)/gm)]
179 + .map((m) => resolveLang(m[1]))
180 + .filter(Boolean);
181 + await ensureLangs(fenceLangs);
182 +
183 + const state = { fences: [] };
184 + const md = buildParser(state);
185 + const rawHtml = md.render(source);
186 + let clean = sanitizeHtml(rawHtml, sanitizeOptions(fullCtx));
187 + clean = clean.replace(
188 + new RegExp(`<pre ${FENCE_PLACEHOLDER_ATTR}="(\\d+)"></pre>`, 'g'),
189 + (_m, index) => state.fences[Number(index)] ?? '',
190 + );
191 + return clean;
192 +}
193 +
194 +/**
195 + * Plain-text fallback rendering (README.txt / readme.rst).
196 + * @param {string} source
197 + * @returns {string}
198 + */
199 +export function renderPlain(source) {
200 + return `<pre class="plain-readme">${escapeHtml(source)}</pre>`;
201 +}
added src/render/og-image.mjs +229 −0
@@ -0,0 +1,229 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/render/og-image.mjs
8 + * Purpose : Dynamic Open Graph cards per repo (satori → resvg → PNG)
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { readFileSync, existsSync } from 'node:fs';
14 +import { join } from 'node:path';
15 +import satori from 'satori';
16 +import { Resvg } from '@resvg/resvg-js';
17 +import { PROJECT_ROOT, OWNER } from '../config.mjs';
18 +
19 +const FONT_DIR = join(PROJECT_ROOT, 'src/web/assets/fonts/og');
20 +const WIDTH = 1200;
21 +const HEIGHT = 630;
22 +
23 +const PALETTE = {
24 + bg: '#0b0e14',
25 + surface: '#11151c',
26 + border: '#1f2530',
27 + text: '#e6e9ef',
28 + muted: '#8b93a3',
29 + accent: '#4f8cff',
30 + accent2: '#22d3aa',
31 +};
32 +
33 +let fontsCache = null;
34 +
35 +/** @returns {Array<{name: string, data: Buffer, weight: number, style: 'normal'}>|null} */
36 +function loadFonts() {
37 + if (fontsCache) return fontsCache;
38 + const specs = [
39 + ['inter-400.woff', 'Inter', 400],
40 + ['inter-700.woff', 'Inter', 700],
41 + ['jetbrains-mono-400.woff', 'JetBrains Mono', 400],
42 + ];
43 + const fonts = [];
44 + for (const [file, name, weight] of specs) {
45 + const path = join(FONT_DIR, file);
46 + if (!existsSync(path)) return null;
47 + fonts.push({ name, data: readFileSync(path), weight, style: 'normal' });
48 + }
49 + fontsCache = fonts;
50 + return fonts;
51 +}
52 +
53 +/** Shorthand for satori element objects. */
54 +function h(type, style, ...children) {
55 + const props = { style };
56 + if (children.length > 0) props.children = children.length === 1 ? children[0] : children;
57 + return { type, props };
58 +}
59 +
60 +/**
61 + * Compose the repo card element tree.
62 + * @param {{name: string, description: string, languages: Array<{name: string, percent: number, color: string}>}} data
63 + */
64 +function repoCardTree(data) {
65 + const description = data.description || 'A repository by Simon-Pierre Boucher';
66 + const langs = (data.languages ?? []).slice(0, 5);
67 + const bar = h(
68 + 'div',
69 + { display: 'flex', width: '100%', height: 14, borderRadius: 7, overflow: 'hidden', backgroundColor: PALETTE.border },
70 + ...(langs.length > 0
71 + ? langs.map((l) => h('div', { width: `${Math.max(2, l.percent)}%`, height: '100%', backgroundColor: l.color }))
72 + : [h('div', { width: '100%', height: '100%', backgroundColor: PALETTE.border })]),
73 + );
74 + const legend = h(
75 + 'div',
76 + { display: 'flex', gap: 24, marginTop: 18, fontSize: 22, color: PALETTE.muted, fontFamily: 'JetBrains Mono' },
77 + ...langs.map((l) =>
78 + h(
79 + 'div',
80 + { display: 'flex', alignItems: 'center', gap: 8 },
81 + h('div', { width: 14, height: 14, borderRadius: 7, backgroundColor: l.color }),
82 + h('div', {}, `${l.name} ${l.percent}%`),
83 + ),
84 + ),
85 + );
86 + return h(
87 + 'div',
88 + {
89 + width: '100%',
90 + height: '100%',
91 + display: 'flex',
92 + flexDirection: 'column',
93 + justifyContent: 'space-between',
94 + backgroundColor: PALETTE.bg,
95 + padding: 64,
96 + fontFamily: 'Inter',
97 + borderBottom: `14px solid ${PALETTE.accent}`,
98 + },
99 + h(
100 + 'div',
101 + { display: 'flex', alignItems: 'center', gap: 20 },
102 + h(
103 + 'div',
104 + {
105 + width: 64,
106 + height: 64,
107 + borderRadius: 16,
108 + backgroundColor: PALETTE.accent,
109 + color: '#ffffff',
110 + display: 'flex',
111 + alignItems: 'center',
112 + justifyContent: 'center',
113 + fontSize: 28,
114 + fontWeight: 700,
115 + },
116 + 'SPB',
117 + ),
118 + h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
119 + h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'),
120 + ),
121 + h(
122 + 'div',
123 + { display: 'flex', flexDirection: 'column', gap: 22 },
124 + h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)),
125 + h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)),
126 + ),
127 + h('div', { display: 'flex', flexDirection: 'column' }, bar, legend),
128 + );
129 +}
130 +
131 +/** Home-page / fallback card. */
132 +function siteCardTree() {
133 + return h(
134 + 'div',
135 + {
136 + width: '100%',
137 + height: '100%',
138 + display: 'flex',
139 + flexDirection: 'column',
140 + justifyContent: 'center',
141 + alignItems: 'center',
142 + gap: 26,
143 + backgroundColor: PALETTE.bg,
144 + fontFamily: 'Inter',
145 + borderBottom: `14px solid ${PALETTE.accent}`,
146 + },
147 + h(
148 + 'div',
149 + {
150 + width: 110,
151 + height: 110,
152 + borderRadius: 28,
153 + backgroundColor: PALETTE.accent,
154 + color: '#ffffff',
155 + display: 'flex',
156 + alignItems: 'center',
157 + justifyContent: 'center',
158 + fontSize: 44,
159 + fontWeight: 700,
160 + },
161 + 'SPB',
162 + ),
163 + h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
164 + h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`),
165 + h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'),
166 + );
167 +}
168 +
169 +function truncate(text, max) {
170 + const s = String(text ?? '');
171 + return s.length > max ? `${s.slice(0, max - 1)}…` : s;
172 +}
173 +
174 +/** Minimal static SVG used when fonts were never downloaded. */
175 +function fallbackSvg(title) {
176 + const safe = String(title).replace(/[<>&"]/g, '');
177 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="${PALETTE.bg}"/><rect y="${HEIGHT - 14}" width="${WIDTH}" height="14" fill="${PALETTE.accent}"/><text x="64" y="330" font-family="sans-serif" font-size="72" font-weight="bold" fill="${PALETTE.text}">${safe}</text><text x="64" y="400" font-family="sans-serif" font-size="30" fill="${PALETTE.muted}">git.spboucher.ai</text></svg>`;
178 +}
179 +
180 +/**
181 + * Render an OG card PNG.
182 + * @param {{name: string, description?: string, languages?: object[]}|null} repoData null → site card
183 + * @returns {Promise<{buffer: Buffer, type: string}>}
184 + */
185 +export async function renderOgImage(repoData) {
186 + const fonts = loadFonts();
187 + let svg;
188 + if (fonts) {
189 + const tree = repoData ? repoCardTree(repoData) : siteCardTree();
190 + svg = await satori(tree, { width: WIDTH, height: HEIGHT, fonts });
191 + } else {
192 + svg = fallbackSvg(repoData?.name ?? 'SPB Git');
193 + }
194 + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } });
195 + return { buffer: resvg.render().asPng(), type: 'image/png' };
196 +}
197 +
198 +/**
199 + * Render the SPB monogram as a PNG favicon/app icon.
200 + * @param {number} size
201 + * @returns {Promise<Buffer>}
202 + */
203 +export async function renderMonogramPng(size) {
204 + const fonts = loadFonts();
205 + const svg = fonts
206 + ? await satori(
207 + h(
208 + 'div',
209 + {
210 + width: '100%',
211 + height: '100%',
212 + display: 'flex',
213 + alignItems: 'center',
214 + justifyContent: 'center',
215 + backgroundColor: PALETTE.accent,
216 + borderRadius: Math.round(size * 0.22),
217 + color: '#ffffff',
218 + fontFamily: 'Inter',
219 + fontWeight: 700,
220 + fontSize: Math.round(size * 0.42),
221 + },
222 + 'SPB',
223 + ),
224 + { width: size, height: size, fonts },
225 + )
226 + : `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="${PALETTE.accent}"/></svg>`;
227 + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: size } });
228 + return resvg.render().asPng();
229 +}
added src/web/assets/css/app.css +421 −0
@@ -0,0 +1,421 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/css/app.css
8 + * Purpose : Application styles — layout, nav, cards, tables, diffs
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +*, *::before, *::after { box-sizing: border-box; }
14 +
15 +html { -webkit-text-size-adjust: 100%; }
16 +
17 +body {
18 + margin: 0;
19 + background: var(--bg);
20 + color: var(--text);
21 + font-family: var(--font-ui);
22 + font-size: 15px;
23 + line-height: 1.55;
24 + min-height: 100vh;
25 + display: flex;
26 + flex-direction: column;
27 +}
28 +
29 +a { color: var(--accent); text-decoration: none; }
30 +a:hover { text-decoration: underline; }
31 +code, pre, kbd { font-family: var(--font-mono); }
32 +button { font-family: inherit; }
33 +
34 +.container { max-width: 1200px; margin: 0 auto; padding: 0 20px; width: 100%; }
35 +main.container { flex: 1; padding-top: 24px; padding-bottom: 48px; }
36 +
37 +.sr-only, .skip-link {
38 + position: absolute; width: 1px; height: 1px; overflow: hidden;
39 + clip: rect(0 0 0 0); white-space: nowrap;
40 +}
41 +.skip-link:focus {
42 + position: fixed; top: 8px; left: 8px; width: auto; height: auto; clip: auto;
43 + background: var(--surface); border: 1px solid var(--accent); padding: 8px 12px;
44 + border-radius: var(--radius); z-index: 100;
45 +}
46 +.muted { color: var(--muted); }
47 +
48 +/* ── Top nav ─────────────────────────────── */
49 +.topnav {
50 + position: sticky; top: 0; z-index: 50;
51 + background: color-mix(in srgb, var(--bg) 88%, transparent);
52 + backdrop-filter: blur(10px);
53 + border-bottom: 1px solid var(--border);
54 +}
55 +.topnav-inner { display: flex; align-items: center; gap: 16px; height: 56px; }
56 +.brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-weight: 700; }
57 +.brand:hover { text-decoration: none; }
58 +.brand-mark { flex: none; }
59 +.global-search { flex: 1; max-width: 420px; }
60 +.global-search input, .repo-filters input, .search-page-form input {
61 + width: 100%; background: var(--surface); color: var(--text);
62 + border: 1px solid var(--border); border-radius: var(--radius);
63 + padding: 7px 12px; font-size: 14px; outline: none;
64 +}
65 +.global-search input:focus, .repo-filters input:focus, .search-page-form input:focus {
66 + border-color: var(--accent);
67 +}
68 +.topnav-links { margin-left: auto; display: flex; align-items: center; gap: 12px; }
69 +.topnav-ext { color: var(--muted); font-size: 14px; }
70 +.icon-btn {
71 + background: var(--surface); color: var(--muted); border: 1px solid var(--border);
72 + border-radius: var(--radius); padding: 7px 9px; cursor: pointer; line-height: 0;
73 +}
74 +.icon-btn:hover { border-color: var(--border-strong); color: var(--text); }
75 +html[data-theme='dark'] .icon-moon { display: none; }
76 +html[data-theme='light'] .icon-sun { display: none; }
77 +
78 +/* ── Buttons & chips ─────────────────────── */
79 +.btn {
80 + display: inline-flex; align-items: center; gap: 6px;
81 + background: var(--surface); color: var(--text);
82 + border: 1px solid var(--border); border-radius: var(--radius);
83 + padding: 6px 14px; font-size: 14px; cursor: pointer;
84 +}
85 +.btn:hover { border-color: var(--border-strong); text-decoration: none; }
86 +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
87 +.btn-primary:hover { filter: brightness(1.08); }
88 +.btn-sm { padding: 4px 10px; font-size: 13px; }
89 +.copy-btn.copied { border-color: var(--accent-2); color: var(--accent-2); }
90 +
91 +.chip {
92 + display: inline-flex; align-items: center;
93 + border-radius: 999px; padding: 1px 10px; font-size: 12px;
94 + background: var(--chip-bg); color: var(--accent); border: 1px solid transparent;
95 +}
96 +.chip-topic:hover { border-color: var(--accent); text-decoration: none; }
97 +.chip-public { color: var(--muted); background: transparent; border-color: var(--border-strong); }
98 +.chip-license { color: var(--accent-2); background: rgba(34, 211, 170, 0.1); }
99 +.chip-default { color: var(--accent-2); background: rgba(34, 211, 170, 0.1); }
100 +.chip-pin { color: var(--accent-2); background: transparent; }
101 +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
102 +.count {
103 + background: var(--surface-2); border-radius: 999px; padding: 0 8px;
104 + font-size: 12px; color: var(--muted);
105 +}
106 +
107 +/* ── Hero ────────────────────────────────── */
108 +.hero {
109 + display: flex; align-items: center; justify-content: space-between;
110 + flex-wrap: wrap; gap: 20px; padding: 28px 0 20px;
111 + border-bottom: 1px solid var(--border); margin-bottom: 28px;
112 +}
113 +.hero-id { display: flex; align-items: center; gap: 18px; }
114 +.hero-avatar {
115 + width: 64px; height: 64px; border-radius: 18px; background: var(--accent);
116 + color: #fff; font-weight: 700; font-size: 20px;
117 + display: flex; align-items: center; justify-content: center; flex: none;
118 +}
119 +.hero-name { margin: 0; font-size: 24px; }
120 +.hero-tagline { margin: 2px 0 0; color: var(--muted); }
121 +.hero-badges { display: flex; gap: 10px; flex-wrap: wrap; }
122 +.badge-stat {
123 + background: var(--surface); border: 1px solid var(--border);
124 + border-radius: var(--radius); padding: 8px 14px; font-size: 14px; color: var(--muted);
125 +}
126 +.badge-stat strong { color: var(--text); margin-right: 4px; }
127 +
128 +.section-title { font-size: 17px; margin: 0 0 14px; display: flex; align-items: center; gap: 8px; }
129 +
130 +/* ── Repo cards / home ───────────────────── */
131 +.pinned-grid {
132 + display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
133 + gap: 14px; margin-bottom: 32px;
134 +}
135 +.home-columns { display: grid; grid-template-columns: 1fr 300px; gap: 32px; align-items: start; }
136 +.repo-list { display: flex; flex-direction: column; gap: 12px; }
137 +.repo-card {
138 + background: var(--surface); border: 1px solid var(--border);
139 + border-radius: var(--radius); padding: 16px 18px;
140 + transition: border-color 0.15s ease;
141 +}
142 +.repo-card:hover { border-color: var(--border-strong); }
143 +.repo-card-name { margin: 0 0 4px; font-size: 16px; display: flex; align-items: center; gap: 8px; }
144 +.repo-card-desc { margin: 0 0 10px; color: var(--muted); font-size: 14px; }
145 +.repo-card .chip-row { margin-bottom: 10px; }
146 +.repo-card-meta { display: flex; gap: 16px; color: var(--muted); font-size: 13px; flex-wrap: wrap; }
147 +.lang-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
148 +.lang-dot-label { display: inline-flex; align-items: center; gap: 6px; }
149 +
150 +.repo-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
151 +.repo-filters { display: flex; gap: 8px; flex-wrap: wrap; }
152 +.repo-filters input { max-width: 200px; }
153 +.repo-filters select, .ref-select {
154 + background: var(--surface); color: var(--text); border: 1px solid var(--border);
155 + border-radius: var(--radius); padding: 7px 10px; font-size: 14px;
156 +}
157 +.empty-state { text-align: center; padding: 40px 0; color: var(--muted); }
158 +
159 +/* ── Activity feed ───────────────────────── */
160 +.activity-feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; font-size: 14px; }
161 +.activity-feed li { display: flex; gap: 10px; align-items: baseline; }
162 +.activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-2); flex: none; position: relative; top: -1px; }
163 +.ref-inline { font-size: 12px; color: var(--muted); }
164 +
165 +/* ── Heatmap ─────────────────────────────── */
166 +.heatmap-section { margin-top: 40px; }
167 +.heatmap-scroll { overflow-x: auto; padding-bottom: 6px; }
168 +.heatmap-label { fill: var(--muted); font-size: 9px; font-family: var(--font-ui); }
169 +.heatmap-cell[data-level='0'] { fill: var(--heat-0); }
170 +.heatmap-cell[data-level='1'] { fill: var(--heat-1); }
171 +.heatmap-cell[data-level='2'] { fill: var(--heat-2); }
172 +.heatmap-cell[data-level='3'] { fill: var(--heat-3); }
173 +.heatmap-cell[data-level='4'] { fill: var(--heat-4); }
174 +.heatmap-legend { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; margin-top: 6px; }
175 +.heatmap-cell-demo { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
176 +.heatmap-cell-demo[data-level='0'] { background: var(--heat-0); }
177 +.heatmap-cell-demo[data-level='1'] { background: var(--heat-1); }
178 +.heatmap-cell-demo[data-level='2'] { background: var(--heat-2); }
179 +.heatmap-cell-demo[data-level='3'] { background: var(--heat-3); }
180 +.heatmap-cell-demo[data-level='4'] { background: var(--heat-4); }
181 +
182 +/* ── Repo header ─────────────────────────── */
183 +.repo-header { padding-bottom: 0; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
184 +.repo-title { font-size: 20px; margin: 4px 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 500; }
185 +.repo-owner { color: var(--muted); }
186 +.repo-sep { color: var(--muted); }
187 +.repo-name { font-weight: 700; }
188 +.repo-desc { margin: 0 0 8px; color: var(--muted); max-width: 780px; }
189 +.repo-meta-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-bottom: 10px; }
190 +.repo-homepage { font-size: 14px; }
191 +.lang-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--surface-2); margin: 6px 0; }
192 +.lang-bar-seg { display: block; height: 100%; min-width: 2px; }
193 +.lang-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; margin-bottom: 12px; }
194 +.repo-tabs { display: flex; gap: 4px; align-items: center; margin-top: 8px; overflow-x: auto; }
195 +.repo-tab {
196 + color: var(--muted); padding: 8px 14px; border-radius: var(--radius) var(--radius) 0 0;
197 + border-bottom: 2px solid transparent; display: inline-flex; gap: 7px; align-items: center;
198 + white-space: nowrap; font-size: 14px;
199 +}
200 +.repo-tab:hover { color: var(--text); text-decoration: none; }
201 +.repo-tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
202 +.repo-tab-meta { margin-left: auto; font-size: 13px; white-space: nowrap; }
203 +
204 +/* ── Repo toolbar / clone box ────────────── */
205 +.repo-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 14px; }
206 +.clone-box { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
207 +.clone-url {
208 + background: var(--surface); border: 1px solid var(--border); color: var(--muted);
209 + border-radius: var(--radius); padding: 6px 10px; font-family: var(--font-mono);
210 + font-size: 13px; width: 300px; max-width: 60vw;
211 +}
212 +.clone-snippet {
213 + background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius);
214 + padding: 14px 16px; font-size: 13px; overflow-x: auto; display: inline-block; text-align: left;
215 +}
216 +.path-breadcrumb { font-family: var(--font-mono); font-size: 14px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
217 +.crumb-sep { color: var(--muted); }
218 +
219 +/* ── File table ──────────────────────────── */
220 +.file-table {
221 + width: 100%; border-collapse: collapse; border: 1px solid var(--border);
222 + border-radius: var(--radius); overflow: hidden; background: var(--surface);
223 + font-size: 14px; margin-bottom: 24px;
224 +}
225 +.file-table td { padding: 8px 14px; border-top: 1px solid var(--border); }
226 +.file-table tr:first-child td { border-top: 0; }
227 +.file-row:hover { background: var(--surface-2); }
228 +.file-name { white-space: nowrap; width: 32%; }
229 +.file-name a { color: var(--text); }
230 +.file-name a:hover { color: var(--accent); }
231 +.file-icon { vertical-align: -3px; margin-right: 7px; color: var(--muted); }
232 +.icon-dir { color: var(--accent); }
233 +.file-commit { overflow: hidden; text-overflow: ellipsis; max-width: 400px; white-space: nowrap; }
234 +.file-commit a:hover { color: var(--accent); }
235 +.file-date { text-align: right; white-space: nowrap; font-size: 13px; }
236 +
237 +/* ── README / markdown section ───────────── */
238 +.readme-section {
239 + border: 1px solid var(--border); border-radius: var(--radius);
240 + background: var(--surface); margin-bottom: 32px;
241 +}
242 +.readme-header {
243 + display: flex; align-items: center; gap: 8px; padding: 10px 20px;
244 + border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px;
245 + position: sticky; top: 56px; background: var(--surface); border-radius: var(--radius) var(--radius) 0 0;
246 +}
247 +.readme-section .markdown-body { padding: 24px 32px 32px; }
248 +.empty-repo { text-align: center; padding: 48px 20px; }
249 +.notice {
250 + background: var(--accent-soft); border: 1px solid var(--accent);
251 + border-radius: var(--radius); padding: 8px 14px; font-size: 13px; margin: 12px;
252 +}
253 +
254 +/* ── Blob view ───────────────────────────── */
255 +.blob-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
256 +.blob-header {
257 + display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
258 + padding: 10px 16px; border-bottom: 1px solid var(--border);
259 + position: sticky; top: 56px; background: var(--surface); z-index: 10;
260 +}
261 +.blob-meta { display: flex; gap: 8px; align-items: center; font-size: 13px; }
262 +.blob-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
263 +.blob-image { text-align: center; padding: 32px; }
264 +.blob-image img { max-width: 100%; border-radius: var(--radius); }
265 +.blob-binary { text-align: center; padding: 48px 20px; color: var(--muted); }
266 +.blob-markdown { padding: 24px 32px 32px; }
267 +
268 +.code-view { overflow-x: auto; }
269 +.code-view pre.shiki {
270 + margin: 0; padding: 12px 0; background: transparent !important;
271 + font-size: 13px; line-height: 1.6; min-width: max-content;
272 +}
273 +.code-view .line { display: block; padding: 0 16px 0 0; min-height: 1.6em; }
274 +.code-view .line:target { background: var(--accent-soft); }
275 +.code-view .line.range-hl { background: var(--accent-soft); }
276 +.code-view .ln {
277 + display: inline-block; width: 52px; padding-right: 16px; text-align: right;
278 + color: var(--muted); user-select: none; font-size: 12px; text-decoration: none;
279 + position: sticky; left: 0; background: var(--surface);
280 +}
281 +.code-view .ln:hover { color: var(--accent); }
282 +.plain-readme { padding: 20px; overflow-x: auto; font-size: 13px; }
283 +
284 +/* ── Commits ─────────────────────────────── */
285 +.commit-list { list-style: none; margin: 0 0 20px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
286 +.commit-row { display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; border-top: 1px solid var(--border); }
287 +.commit-row:first-child { border-top: 0; }
288 +.commit-avatar { flex: none; border-radius: 6px; overflow: hidden; background: var(--surface-2); line-height: 0; padding: 2px; border: 1px solid var(--border); }
289 +.commit-main { flex: 1; min-width: 0; }
290 +.commit-subject { font-weight: 600; display: flex; gap: 8px; align-items: baseline; }
291 +.commit-subject a { color: var(--text); }
292 +.commit-subject a:hover { color: var(--accent); }
293 +.commit-body-toggle summary { cursor: pointer; color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 0 8px; display: inline-block; }
294 +.commit-body { font-size: 13px; color: var(--muted); white-space: pre-wrap; margin: 8px 0 0; }
295 +.commit-meta { font-size: 13px; margin-top: 3px; display: flex; flex-wrap: wrap; gap: 6px; }
296 +.sha-chip {
297 + font-family: var(--font-mono); font-size: 12px; background: var(--surface-2);
298 + color: var(--accent); border: 1px solid var(--border); border-radius: 6px;
299 + padding: 3px 8px; cursor: pointer; flex: none;
300 +}
301 +.sha-chip:hover { border-color: var(--accent); }
302 +.pagination { display: flex; gap: 10px; justify-content: center; margin: 20px 0; }
303 +
304 +/* ── Commit detail / diffs ───────────────── */
305 +.commit-detail-header {
306 + border: 1px solid var(--border); border-radius: var(--radius);
307 + background: var(--surface); padding: 16px 20px; margin-bottom: 18px;
308 +}
309 +.commit-detail-subject { margin: 0 0 8px; font-size: 18px; }
310 +.commit-detail-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 14px; }
311 +.commit-stats { margin: 12px 0 0; font-size: 14px; color: var(--muted); }
312 +.diff-add { color: var(--add-text); font-weight: 600; }
313 +.diff-del { color: var(--del-text); font-weight: 600; }
314 +
315 +.diff-file { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; overflow: hidden; }
316 +.diff-file-header {
317 + display: flex; gap: 10px; align-items: center; padding: 9px 14px;
318 + cursor: pointer; background: var(--surface-2); font-size: 13px; list-style: none;
319 + position: sticky; top: 56px; z-index: 5;
320 +}
321 +.diff-file-header::-webkit-details-marker { display: none; }
322 +.diff-file-header::before { content: '▾'; color: var(--muted); }
323 +.diff-file:not([open]) .diff-file-header::before { content: '▸'; }
324 +.diff-path { font-size: 13px; word-break: break-all; }
325 +.diff-counts { margin-left: auto; white-space: nowrap; }
326 +.diff-status { border-radius: 4px; padding: 1px 7px; font-size: 11px; text-transform: uppercase; font-weight: 700; }
327 +.diff-status-added { background: var(--add-bg); color: var(--add-text); }
328 +.diff-status-deleted { background: var(--del-bg); color: var(--del-text); }
329 +.diff-status-modified { background: var(--accent-soft); color: var(--accent); }
330 +.diff-status-renamed { background: var(--chip-bg); color: var(--accent-2); }
331 +
332 +.diff-table-wrap { overflow-x: auto; }
333 +.diff-table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; }
334 +.diff-gutter {
335 + width: 44px; min-width: 44px; text-align: right; padding: 0 8px;
336 + color: var(--muted); user-select: none; border-right: 1px solid var(--border);
337 + font-size: 11.5px; vertical-align: top;
338 +}
339 +.diff-code { padding: 0 10px; white-space: pre; }
340 +.diff-sign { display: inline-block; width: 14px; user-select: none; color: var(--muted); }
341 +.diff-line-add { background: var(--add-bg); }
342 +.diff-line-add .diff-sign { color: var(--add-text); }
343 +.diff-line-del { background: var(--del-bg); }
344 +.diff-line-del .diff-sign { color: var(--del-text); }
345 +.diff-hunk-header td { background: var(--hunk-bg); color: var(--muted); padding: 3px 10px; font-size: 12px; }
346 +.diff-binary { padding: 16px; }
347 +
348 +/* ── Blame ───────────────────────────────── */
349 +.blame-table { border-collapse: collapse; width: 100%; font-size: 13px; }
350 +.blame-commit {
351 + border-top: 1px solid var(--border); border-right: 1px solid var(--border);
352 + padding: 4px 10px; width: 320px; min-width: 240px; vertical-align: top;
353 + background: var(--surface-2);
354 +}
355 +.blame-commit-inner { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
356 +.blame-date { font-size: 12px; width: 100%; padding-left: 30px; }
357 +.blame-ln { width: 44px; text-align: right; color: var(--muted); padding: 0 8px; user-select: none; font-family: var(--font-mono); font-size: 12px; }
358 +.blame-code pre { margin: 0; font-size: 12.5px; line-height: 1.55; white-space: pre; }
359 +.blame-line[data-age='0'] .blame-commit { box-shadow: inset 3px 0 0 var(--accent); }
360 +.blame-line[data-age='1'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 85%, transparent); }
361 +.blame-line[data-age='2'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 70%, transparent); }
362 +.blame-line[data-age='3'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 58%, transparent); }
363 +.blame-line[data-age='4'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 47%, transparent); }
364 +.blame-line[data-age='5'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 38%, transparent); }
365 +.blame-line[data-age='6'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 30%, transparent); }
366 +.blame-line[data-age='7'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 22%, transparent); }
367 +.blame-line[data-age='8'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 15%, transparent); }
368 +.blame-line[data-age='9'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 8%, transparent); }
369 +
370 +/* ── Branch/tag tables ───────────────────── */
371 +.ref-table {
372 + width: 100%; border-collapse: collapse; background: var(--surface);
373 + border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
374 + font-size: 14px;
375 +}
376 +.ref-table th { text-align: left; padding: 10px 14px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--border); }
377 +.ref-table td { padding: 10px 14px; border-top: 1px solid var(--border); }
378 +.ref-name { font-family: var(--font-mono); font-size: 13px; }
379 +.ahead-behind { display: inline-flex; gap: 10px; font-family: var(--font-mono); font-size: 13px; }
380 +.archive-links { display: flex; gap: 6px; }
381 +
382 +/* ── Search page ─────────────────────────── */
383 +.search-page-form { display: flex; gap: 8px; margin-bottom: 28px; max-width: 560px; }
384 +.search-results { list-style: none; margin: 0 0 32px; padding: 0; display: flex; flex-direction: column; gap: 16px; }
385 +.search-results li { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
386 +.search-hit-name { font-weight: 600; font-size: 15px; }
387 +.search-snippet { color: var(--muted); font-size: 13px; margin: 6px 0 0; }
388 +
389 +/* ── Error pages ─────────────────────────── */
390 +.error-page { text-align: center; padding: 72px 20px; }
391 +.error-code { font-size: 96px; font-weight: 700; color: var(--accent); font-family: var(--font-mono); line-height: 1; }
392 +.error-message { font-size: 18px; color: var(--muted); margin: 16px 0 8px; }
393 +.error-art { display: inline-block; text-align: left; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; font-size: 13px; margin: 16px 0 24px; }
394 +
395 +/* ── Mermaid blocks ──────────────────────── */
396 +.mermaid-block { margin: 16px 0; }
397 +.mermaid-src { display: none; }
398 +.mermaid-block[data-rendered='0'] .mermaid-src { display: block; }
399 +.mermaid-target svg { max-width: 100%; }
400 +
401 +/* ── Footer ──────────────────────────────── */
402 +.footer {
403 + border-top: 1px solid var(--border); color: var(--muted);
404 + padding: 20px 0; font-size: 13px; text-align: center;
405 +}
406 +
407 +/* ── Responsive ──────────────────────────── */
408 +@media (max-width: 960px) {
409 + .home-columns { grid-template-columns: 1fr; }
410 + .file-commit { display: none; }
411 + .blame-commit { width: 200px; min-width: 150px; }
412 +}
413 +@media (max-width: 640px) {
414 + .global-search { display: none; }
415 + .hero { flex-direction: column; align-items: flex-start; }
416 + .pinned-grid { grid-template-columns: 1fr; }
417 + .clone-url { width: 100%; max-width: none; }
418 + .repo-toolbar { flex-direction: column; align-items: stretch; }
419 + .file-date { display: none; }
420 + .readme-section .markdown-body, .blob-markdown { padding: 16px; }
421 +}
added src/web/assets/css/markdown.css +188 −0
@@ -0,0 +1,188 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/css/markdown.css
8 + * Purpose : GitHub-grade .markdown-body typography (badges included)
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +.markdown-body {
14 + font-size: 16px;
15 + line-height: 1.6;
16 + word-wrap: break-word;
17 +}
18 +
19 +.markdown-body > *:first-child { margin-top: 0 !important; }
20 +.markdown-body > *:last-child { margin-bottom: 0 !important; }
21 +
22 +.markdown-body h1, .markdown-body h2, .markdown-body h3,
23 +.markdown-body h4, .markdown-body h5, .markdown-body h6 {
24 + margin-top: 24px;
25 + margin-bottom: 16px;
26 + font-weight: 600;
27 + line-height: 1.25;
28 + position: relative;
29 +}
30 +.markdown-body h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); }
31 +.markdown-body h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); }
32 +.markdown-body h3 { font-size: 1.25em; }
33 +.markdown-body h4 { font-size: 1em; }
34 +.markdown-body h5 { font-size: 0.875em; }
35 +.markdown-body h6 { font-size: 0.85em; color: var(--muted); }
36 +
37 +.heading-anchor {
38 + position: absolute;
39 + left: -20px;
40 + top: 50%;
41 + transform: translateY(-50%);
42 + opacity: 0;
43 + text-decoration: none;
44 + color: var(--muted);
45 + font-size: 0.8em;
46 +}
47 +.markdown-body h1:hover .heading-anchor, .markdown-body h2:hover .heading-anchor,
48 +.markdown-body h3:hover .heading-anchor, .markdown-body h4:hover .heading-anchor,
49 +.markdown-body h5:hover .heading-anchor, .markdown-body h6:hover .heading-anchor { opacity: 1; }
50 +
51 +.markdown-body p, .markdown-body blockquote, .markdown-body ul, .markdown-body ol,
52 +.markdown-body dl, .markdown-body table, .markdown-body pre, .markdown-body details {
53 + margin-top: 0;
54 + margin-bottom: 16px;
55 +}
56 +
57 +.markdown-body blockquote {
58 + padding: 0 1em;
59 + color: var(--muted);
60 + border-left: 0.25em solid var(--accent-2);
61 + margin-left: 0;
62 + margin-right: 0;
63 +}
64 +
65 +.markdown-body ul, .markdown-body ol { padding-left: 2em; }
66 +.markdown-body li + li { margin-top: 0.25em; }
67 +.markdown-body li.task-list-item { list-style: none; margin-left: -1.4em; }
68 +.markdown-body li.task-list-item input[type='checkbox'] {
69 + margin-right: 0.5em;
70 + vertical-align: -1px;
71 + accent-color: var(--accent);
72 +}
73 +
74 +.markdown-body code {
75 + background: var(--surface-2);
76 + border-radius: 6px;
77 + padding: 0.2em 0.4em;
78 + font-size: 85%;
79 + font-family: var(--font-mono);
80 +}
81 +.markdown-body pre code { background: transparent; padding: 0; font-size: 100%; }
82 +
83 +.markdown-body .code-block {
84 + background: var(--code-bg);
85 + border: 1px solid var(--border);
86 + border-radius: var(--radius);
87 + overflow-x: auto;
88 + margin-bottom: 16px;
89 + position: relative;
90 +}
91 +.markdown-body .code-block pre.shiki {
92 + margin: 0;
93 + padding: 14px 16px;
94 + background: transparent !important;
95 + font-size: 85%;
96 + line-height: 1.55;
97 +}
98 +.markdown-body pre:not(.shiki) {
99 + background: var(--code-bg);
100 + border: 1px solid var(--border);
101 + border-radius: var(--radius);
102 + padding: 14px 16px;
103 + overflow-x: auto;
104 + font-size: 85%;
105 + line-height: 1.55;
106 +}
107 +
108 +.markdown-body table {
109 + border-collapse: collapse;
110 + border-spacing: 0;
111 + display: block;
112 + max-width: 100%;
113 + overflow-x: auto;
114 + width: max-content;
115 +}
116 +.markdown-body table th, .markdown-body table td {
117 + border: 1px solid var(--border-strong);
118 + padding: 6px 13px;
119 +}
120 +.markdown-body table th { font-weight: 600; background: var(--surface-2); }
121 +.markdown-body table tr:nth-child(2n) { background: var(--surface-2); }
122 +
123 +.markdown-body hr {
124 + height: 1px;
125 + border: 0;
126 + background: var(--border);
127 + margin: 24px 0;
128 +}
129 +
130 +.markdown-body img {
131 + max-width: 100%;
132 + box-sizing: content-box;
133 + border-radius: 4px;
134 +}
135 +
136 +/* Badges: shields.io & friends stay inline, centered, unwrapped. */
137 +.markdown-body img[src*='shields.io'],
138 +.markdown-body img[src*='badge'],
139 +.markdown-body img[src*='badgen.net'],
140 +.markdown-body img[src*='github.io/badges'],
141 +.markdown-body img[height] {
142 + display: inline-block;
143 + vertical-align: middle;
144 + border-radius: 0;
145 + margin: 0 2px 2px 0;
146 +}
147 +.markdown-body p > a > img { display: inline-block; vertical-align: middle; }
148 +
149 +.markdown-body kbd {
150 + display: inline-block;
151 + padding: 3px 5px;
152 + font-size: 11px;
153 + line-height: 10px;
154 + color: var(--text);
155 + vertical-align: middle;
156 + background: var(--surface-2);
157 + border: 1px solid var(--border-strong);
158 + border-bottom-width: 2px;
159 + border-radius: 6px;
160 +}
161 +
162 +.markdown-body details {
163 + border: 1px solid var(--border);
164 + border-radius: var(--radius);
165 + padding: 10px 14px;
166 +}
167 +.markdown-body details summary {
168 + cursor: pointer;
169 + font-weight: 600;
170 + margin: -10px -14px;
171 + padding: 10px 14px;
172 +}
173 +.markdown-body details[open] summary { border-bottom: 1px solid var(--border); margin-bottom: 10px; }
174 +
175 +.markdown-body [align='center'] { text-align: center; }
176 +.markdown-body [align='right'] { text-align: right; }
177 +.markdown-body [align='left'] { text-align: left; }
178 +
179 +.markdown-body .footnotes {
180 + font-size: 13px;
181 + color: var(--muted);
182 + border-top: 1px solid var(--border);
183 + margin-top: 32px;
184 + padding-top: 16px;
185 +}
186 +.markdown-body sup a { text-decoration: none; }
187 +
188 +.markdown-body .anchor-icon { font-weight: 400; }
added src/web/assets/css/tokens.css +127 −0
@@ -0,0 +1,127 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/css/tokens.css
8 + * Purpose : Design tokens — colors, radius, typography, themes
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +@font-face {
14 + font-family: 'Inter';
15 + src: url('/assets/fonts/inter-400.woff2') format('woff2');
16 + font-weight: 400;
17 + font-style: normal;
18 + font-display: swap;
19 +}
20 +@font-face {
21 + font-family: 'Inter';
22 + src: url('/assets/fonts/inter-500.woff2') format('woff2');
23 + font-weight: 500;
24 + font-style: normal;
25 + font-display: swap;
26 +}
27 +@font-face {
28 + font-family: 'Inter';
29 + src: url('/assets/fonts/inter-600.woff2') format('woff2');
30 + font-weight: 600;
31 + font-style: normal;
32 + font-display: swap;
33 +}
34 +@font-face {
35 + font-family: 'Inter';
36 + src: url('/assets/fonts/inter-700.woff2') format('woff2');
37 + font-weight: 700;
38 + font-style: normal;
39 + font-display: swap;
40 +}
41 +@font-face {
42 + font-family: 'JetBrains Mono';
43 + src: url('/assets/fonts/jetbrains-mono-400.woff2') format('woff2');
44 + font-weight: 400;
45 + font-style: normal;
46 + font-display: swap;
47 +}
48 +@font-face {
49 + font-family: 'JetBrains Mono';
50 + src: url('/assets/fonts/jetbrains-mono-600.woff2') format('woff2');
51 + font-weight: 600;
52 + font-style: normal;
53 + font-display: swap;
54 +}
55 +
56 +:root,
57 +html[data-theme='dark'] {
58 + --bg: #0b0e14;
59 + --surface: #11151c;
60 + --surface-2: #161b24;
61 + --border: #1f2530;
62 + --border-strong: #2a3242;
63 + --text: #e6e9ef;
64 + --muted: #8b93a3;
65 + --accent: #4f8cff;
66 + --accent-soft: rgba(79, 140, 255, 0.14);
67 + --accent-2: #22d3aa;
68 + --danger: #ff5d5d;
69 + --success: #3fb950;
70 + --add-bg: rgba(63, 185, 80, 0.15);
71 + --add-text: #56d364;
72 + --del-bg: rgba(248, 81, 73, 0.15);
73 + --del-text: #ff7b72;
74 + --hunk-bg: rgba(79, 140, 255, 0.08);
75 + --code-bg: #0d1117;
76 + --chip-bg: rgba(79, 140, 255, 0.12);
77 + --heat-0: #161b24;
78 + --heat-1: #0e4429;
79 + --heat-2: #006d32;
80 + --heat-3: #26a641;
81 + --heat-4: #39d353;
82 + --radius: 10px;
83 + --font-ui: 'Inter', -apple-system, 'Segoe UI', sans-serif;
84 + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
85 + --shadow-pop: 0 0 0 1px var(--border);
86 + color-scheme: dark;
87 +}
88 +
89 +html[data-theme='light'] {
90 + --bg: #f7f8fa;
91 + --surface: #ffffff;
92 + --surface-2: #f0f2f5;
93 + --border: #d9dee6;
94 + --border-strong: #c2cad6;
95 + --text: #1c2330;
96 + --muted: #57606e;
97 + --accent: #2f6fe0;
98 + --accent-soft: rgba(47, 111, 224, 0.1);
99 + --accent-2: #0d9e80;
100 + --danger: #d33636;
101 + --success: #1a7f37;
102 + --add-bg: rgba(26, 127, 55, 0.12);
103 + --add-text: #1a7f37;
104 + --del-bg: rgba(207, 34, 46, 0.12);
105 + --del-text: #cf222e;
106 + --hunk-bg: rgba(47, 111, 224, 0.07);
107 + --code-bg: #f6f8fa;
108 + --chip-bg: rgba(47, 111, 224, 0.1);
109 + --heat-0: #ebedf0;
110 + --heat-1: #9be9a8;
111 + --heat-2: #40c463;
112 + --heat-3: #30a14e;
113 + --heat-4: #216e39;
114 + color-scheme: light;
115 +}
116 +
117 +/* Shiki dual-theme switch */
118 +html[data-theme='dark'] .shiki,
119 +html[data-theme='dark'] .shiki span {
120 + color: var(--shiki-dark) !important;
121 + background-color: transparent !important;
122 +}
123 +html[data-theme='light'] .shiki,
124 +html[data-theme='light'] .shiki span {
125 + color: var(--shiki-light) !important;
126 + background-color: transparent !important;
127 +}
added src/web/assets/fonts/og/inter-400.woff +0 −0

Binary file not shown.

added src/web/assets/fonts/og/inter-700.woff +0 −0

Binary file not shown.

added src/web/assets/fonts/og/jetbrains-mono-400.woff +0 −0

Binary file not shown.

added src/web/assets/img/logo.svg +15 −0
@@ -0,0 +1,15 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/assets/img/logo.svg
8 + Purpose : SPB monogram favicon / logo
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
13 + <rect width="28" height="28" rx="7" fill="#4f8cff"/>
14 + <path d="M8 18.5c1 1.4 2.7 2.2 4.6 2.2 2.6 0 4.4-1.3 4.4-3.3 0-1.8-1.2-2.7-3.6-3.3l-1.6-.4c-1.4-.4-2-.9-2-1.8 0-1.1 1-1.8 2.5-1.8 1.4 0 2.5.6 3.2 1.7l1.9-1.3c-1-1.6-2.8-2.5-5-2.5C10 8 8.2 9.4 8.2 11.4c0 1.9 1.2 2.8 3.4 3.3l1.7.4c1.5.4 2.1.9 2.1 1.8 0 1.1-1.1 1.8-2.7 1.8-1.6 0-2.9-.7-3.7-2z" fill="#fff"/>
15 +</svg>
added src/web/assets/js/app.js +176 −0
@@ -0,0 +1,176 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/app.js
8 + * Purpose : Vanilla JS layer — theme, copy, filters, anchors, mermaid
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +(function () {
14 + 'use strict';
15 +
16 + /* ── Theme toggle ─────────────────────────── */
17 + var toggle = document.getElementById('theme-toggle');
18 + if (toggle) {
19 + toggle.addEventListener('click', function () {
20 + var html = document.documentElement;
21 + var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
22 + html.setAttribute('data-theme', next);
23 + try { localStorage.setItem('spbgit-theme', next); } catch (e) { /* ignore */ }
24 + });
25 + }
26 +
27 + /* ── "/" focuses the global search ────────── */
28 + document.addEventListener('keydown', function (event) {
29 + if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return;
30 + var target = event.target;
31 + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
32 + var input = document.getElementById('global-search');
33 + if (input) {
34 + event.preventDefault();
35 + input.focus();
36 + input.select();
37 + }
38 + });
39 +
40 + /* ── Copy buttons ─────────────────────────── */
41 + document.addEventListener('click', function (event) {
42 + var btn = event.target.closest('.copy-btn');
43 + if (!btn) return;
44 + var text = btn.getAttribute('data-copy');
45 + if (!text) return;
46 + navigator.clipboard.writeText(text).then(function () {
47 + btn.classList.add('copied');
48 + var prev = btn.textContent;
49 + btn.textContent = '✓';
50 + setTimeout(function () {
51 + btn.classList.remove('copied');
52 + btn.textContent = prev;
53 + }, 1200);
54 + });
55 + });
56 +
57 + /* ── Home: filter + sort repo list ────────── */
58 + var list = document.getElementById('repo-list');
59 + if (list) {
60 + var filterInput = document.getElementById('repo-filter');
61 + var sortSelect = document.getElementById('repo-sort');
62 + var langSelect = document.getElementById('repo-lang');
63 + var topicSelect = document.getElementById('repo-topic');
64 + var emptyNote = document.getElementById('repo-list-empty');
65 +
66 + var apply = function () {
67 + var q = (filterInput.value || '').toLowerCase();
68 + var lang = langSelect.value;
69 + var topic = topicSelect.value;
70 + var cards = Array.prototype.slice.call(list.querySelectorAll('.repo-card'));
71 + var visible = 0;
72 + cards.forEach(function (card) {
73 + var name = card.getAttribute('data-name') || '';
74 + var desc = (card.getAttribute('data-description') || '').toLowerCase();
75 + var cardLang = card.getAttribute('data-language') || '';
76 + var topics = (card.getAttribute('data-topics') || '').split(',');
77 + var show =
78 + (!q || name.toLowerCase().indexOf(q) !== -1 || desc.indexOf(q) !== -1) &&
79 + (!lang || cardLang === lang) &&
80 + (!topic || topics.indexOf(topic) !== -1);
81 + card.hidden = !show;
82 + if (show) visible += 1;
83 + });
84 + var key = sortSelect.value;
85 + var attr = key === 'name' ? 'data-name' : key === 'created' ? 'data-created' : 'data-pushed';
86 + cards
87 + .sort(function (a, b) {
88 + var av = a.getAttribute(attr) || '';
89 + var bv = b.getAttribute(attr) || '';
90 + return key === 'name' ? av.localeCompare(bv) : bv.localeCompare(av);
91 + })
92 + .forEach(function (card) { list.appendChild(card); });
93 + if (emptyNote) emptyNote.hidden = visible > 0;
94 + };
95 + [filterInput, sortSelect, langSelect, topicSelect].forEach(function (el) {
96 + if (!el) return;
97 + el.addEventListener('input', apply);
98 + el.addEventListener('change', apply);
99 + });
100 + }
101 +
102 + /* ── Ref selector navigation ──────────────── */
103 + var refSelect = document.getElementById('ref-select');
104 + if (refSelect) {
105 + refSelect.addEventListener('change', function () {
106 + var repo = refSelect.getAttribute('data-repo');
107 + var kind = refSelect.getAttribute('data-kind') || 'tree';
108 + var path = refSelect.getAttribute('data-path') || '';
109 + var ref = refSelect.value;
110 + var url = '/' + repo + '/' + kind + '/' + ref + (path ? '/' + path : '');
111 + location.href = url;
112 + });
113 + }
114 +
115 + /* ── Line range highlighting (#L10-L20) ───── */
116 + var codeView = document.getElementById('code-view');
117 + if (codeView) {
118 + var applyRange = function () {
119 + codeView.querySelectorAll('.line.range-hl').forEach(function (el) {
120 + el.classList.remove('range-hl');
121 + });
122 + var match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);
123 + if (!match) return;
124 + var start = parseInt(match[1], 10);
125 + var end = match[2] ? parseInt(match[2], 10) : start;
126 + if (end < start) { var t = start; start = end; end = t; }
127 + for (var i = start; i <= end; i += 1) {
128 + var line = document.getElementById('L' + i);
129 + if (line) line.classList.add('range-hl');
130 + }
131 + var first = document.getElementById('L' + start);
132 + if (first && match[2]) first.scrollIntoView({ block: 'center' });
133 + };
134 + applyRange();
135 + window.addEventListener('hashchange', applyRange);
136 + // Shift-click a line number to extend the selection into a range.
137 + codeView.addEventListener('click', function (event) {
138 + var anchor = event.target.closest('a.ln');
139 + if (!anchor || !event.shiftKey) return;
140 + event.preventDefault();
141 + var current = /^#L(\d+)/.exec(location.hash);
142 + var clicked = /^#?L?(\d+)/.exec(anchor.getAttribute('href').slice(1));
143 + if (current && clicked) {
144 + history.replaceState(null, '', '#L' + current[1] + '-L' + clicked[1]);
145 + applyRange();
146 + }
147 + });
148 + }
149 +
150 + /* ── Mermaid: lazy-load only when present ─── */
151 + var mermaidBlocks = document.querySelectorAll('.mermaid-block');
152 + if (mermaidBlocks.length > 0) {
153 + var script = document.createElement('script');
154 + script.src = '/assets/vendor/mermaid.min.js';
155 + script.onload = function () {
156 + if (!window.mermaid) return;
157 + var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
158 + window.mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict' });
159 + mermaidBlocks.forEach(function (block, index) {
160 + var src = block.querySelector('.mermaid-src');
161 + var target = block.querySelector('.mermaid-target');
162 + if (!src || !target) return;
163 + window.mermaid
164 + .render('mermaid-svg-' + index, src.textContent)
165 + .then(function (result) {
166 + target.innerHTML = result.svg; // mermaid output, securityLevel strict
167 + block.setAttribute('data-rendered', '1');
168 + })
169 + .catch(function () {
170 + block.setAttribute('data-rendered', '0');
171 + });
172 + });
173 + };
174 + document.body.appendChild(script);
175 + }
176 +})();
added src/web/assets/js/theme-boot.js +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added src/web/routes.mjs +626 −0
Large diff (626 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/blame.njk +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/blob.njk +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/branches.njk +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/commit.njk +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/commits.njk +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/error.njk +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/home.njk +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/layout.njk +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/file-table.njk +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/repo-card.njk +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/repo-header.njk +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/repo.njk +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/search.njk +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/tags.njk +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/tree.njk +36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme