SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 71.5% CSS 14.1% Nunjucks 11.8% Shell 2.6%

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 2 days ago (Aug 10, 2026) parent acd5957

Showing 28 changed files with +2,985 and −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
@@ -0,0 +1,23 @@
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/theme-boot.js
8 + * Purpose : Apply the persisted theme before first paint (no FOUC)
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +(function () {
14 + var stored = null;
15 + try {
16 + stored = localStorage.getItem('spbgit-theme');
17 + } catch (e) { /* private mode */ }
18 + var theme = stored;
19 + if (theme !== 'dark' && theme !== 'light') {
20 + theme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
21 + }
22 + document.documentElement.setAttribute('data-theme', theme);
23 +})();
added src/web/routes.mjs +626 −0
@@ -0,0 +1,626 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/routes.mjs
8 + * Purpose : Server-rendered web UI — every HTML page of the forge
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import nunjucks from 'nunjucks';
14 +import { join, posix } from 'node:path';
15 +import { PROJECT_ROOT } from '../config.mjs';
16 +import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';
17 +import { search } from '../lib/search.mjs';
18 +import { contributionCalendar } from '../stats/activity.mjs';
19 +import { languageColor } from '../stats/languages.mjs';
20 +import { renderMarkdown } from '../render/markdown.mjs';
21 +import { highlight, highlightFile, langForPath } from '../render/highlight.mjs';
22 +import { renderMonogramPng } from '../render/og-image.mjs';
23 +import { sendArchive } from '../git/archive.mjs';
24 +import {
25 + relativeTime, formatBytes, escapeHtml, identiconSvg, isValidRepoName,
26 +} from '../lib/util.mjs';
27 +
28 +const BLOB_RENDER_LIMIT = 1024 * 1024; // 1 MB — beyond this, offer raw only
29 +const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif']);
30 +const RAW_MIME = {
31 + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
32 + '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.svg': 'image/svg+xml',
33 + '.pdf': 'application/pdf', '.json': 'application/json', '.zip': 'application/zip',
34 + '.gz': 'application/gzip', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wasm': 'application/wasm',
35 + '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf',
36 +};
37 +
38 +/** Build the Nunjucks environment with all filters/globals. */
39 +export function setupViews(config) {
40 + const env = new nunjucks.Environment(
41 + new nunjucks.FileSystemLoader(join(PROJECT_ROOT, 'src/web/views'), { noCache: config.isDev }),
42 + { autoescape: true },
43 + );
44 + env.addFilter('reltime', (value) => (value ? relativeTime(value) : ''));
45 + env.addFilter('bytes', (value) => formatBytes(Number(value) || 0));
46 + env.addFilter('num', (value) => new Intl.NumberFormat('en-US').format(Number(value) || 0));
47 + env.addFilter('langcolor', (name) => languageColor(name));
48 + env.addFilter('identicon', (email, size) => new nunjucks.runtime.SafeString(identiconSvg(String(email ?? ''), size ?? 20)));
49 + env.addFilter('datefull', (value) => {
50 + if (!value) return '';
51 + const d = new Date(value);
52 + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
53 + });
54 + env.addGlobal('owner', config.owner);
55 + env.addGlobal('publicUrl', config.publicUrl);
56 + return env;
57 +}
58 +
59 +/** Render a view with the standard page envelope. */
60 +function makeRender(env, config) {
61 + return function render(reply, template, data) {
62 + const html = env.render(template, {
63 + ...data,
64 + canonical: `${config.publicUrl}${data.canonicalPath ?? '/'}`,
65 + ogImage: `${config.publicUrl}${data.ogImagePath ?? '/og/site.png'}`,
66 + });
67 + reply.type('text/html; charset=utf-8');
68 + return reply.send(html);
69 + };
70 +}
71 +
72 +/** Shared header data for every repo page (tabs, refs, counts). */
73 +async function repoShell(ctx, name) {
74 + const overview = await repoOverview(ctx, name);
75 + if (!overview) return null;
76 + const branches = await ctx.repos.branches(name);
77 + const tags = await ctx.repos.tags(name);
78 + return { overview, branches, tags };
79 +}
80 +
81 +/** Breadcrumb segments for a tree path. */
82 +function breadcrumbs(repo, kind, ref, path) {
83 + const crumbs = [];
84 + if (!path) return crumbs;
85 + const parts = path.split('/');
86 + let acc = '';
87 + for (const part of parts) {
88 + acc = acc === '' ? part : `${acc}/${part}`;
89 + crumbs.push({ name: part, href: `/${repo}/${kind}/${ref}/${acc}` });
90 + }
91 + return crumbs;
92 +}
93 +
94 +/** Highlight the ordered lines of one diff hunk with the file's language. */
95 +async function highlightHunk(lines, lang) {
96 + const fallback = () => lines.map((l) => escapeHtml(l.text));
97 + if (!lang || lines.length > 500) return fallback();
98 + try {
99 + const html = await highlight(lines.map((l) => l.text).join('\n'), lang);
100 + const parts = html.split('<span class="line">').slice(1);
101 + if (parts.length !== lines.length) return fallback();
102 + return parts.map((part) =>
103 + part
104 + .replace(/<\/span>\s*<\/code><\/pre>\s*$/s, '')
105 + .replace(/<\/span>\n?$/s, ''),
106 + );
107 + } catch {
108 + return fallback();
109 + }
110 +}
111 +
112 +/** Prepare a parsed diff for the template (adds highlighted line HTML). */
113 +async function prepareDiff(files) {
114 + for (const file of files) {
115 + const lang = langForPath(file.newPath || file.oldPath);
116 + for (const hunk of file.hunks) {
117 + const content = hunk.lines.filter((l) => l.type !== 'meta');
118 + const highlighted = await highlightHunk(content, lang);
119 + let i = 0;
120 + for (const line of hunk.lines) {
121 + line.html = line.type === 'meta' ? escapeHtml(line.text) : highlighted[i++];
122 + }
123 + }
124 + }
125 + return files;
126 +}
127 +
128 +/**
129 + * Register all HTML routes + error pages.
130 + * @param {import('fastify').FastifyInstance} app
131 + * @param {object} ctx
132 + */
133 +export async function registerWeb(app, ctx) {
134 + const { config } = ctx;
135 + const env = setupViews(config);
136 + const render = makeRender(env, config);
137 + ctx.views = env;
138 +
139 + /** Resolve `:repo` param or render 404. Returns name or null. */
140 + function repoParam(request, reply) {
141 + const name = request.params.repo;
142 + if (!isValidRepoName(name) || !ctx.repos.exists(name)) {
143 + notFound(request, reply);
144 + return null;
145 + }
146 + return name;
147 + }
148 +
149 + function notFound(request, reply) {
150 + if (request.url.startsWith('/api/')) {
151 + return reply.code(404).send({ error: { code: 'not_found', message: 'no such endpoint' } });
152 + }
153 + reply.code(404);
154 + return render(reply, 'error.njk', {
155 + title: '404 · SPB Git',
156 + status: 404,
157 + message: 'This ref does not exist in any timeline.',
158 + canonicalPath: request.url,
159 + });
160 + }
161 +
162 + app.setNotFoundHandler((request, reply) => notFound(request, reply));
163 +
164 + app.setErrorHandler((error, request, reply) => {
165 + request.log.error({ err: error }, 'unhandled error');
166 + if (error.statusCode === 429) {
167 + return reply.code(429).send({ error: { code: 'rate_limited', message: 'Too many requests — slow down.' } });
168 + }
169 + if (request.url.startsWith('/api/')) {
170 + return reply.code(500).send({ error: { code: 'internal', message: 'internal server error' } });
171 + }
172 + reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 500);
173 + return render(reply, 'error.njk', {
174 + title: '500 · SPB Git',
175 + status: 500,
176 + message: 'Something went sideways in the reflog. The incident has been logged.',
177 + canonicalPath: request.url,
178 + });
179 + });
180 +
181 + // ───────────────────────── home ─────────────────────────
182 + app.get('/', async (request, reply) => {
183 + const [overviews, stats, heatmap] = await Promise.all([
184 + allOverviews(ctx),
185 + siteStats(ctx),
186 + contributionCalendar(ctx),
187 + ]);
188 + const pinned = overviews.filter((o) => o.pinned).slice(0, 6);
189 + const activity = ctx.activity.recent(15);
190 + const topics = [...new Set(overviews.flatMap((o) => o.topics))].sort();
191 + const languages = [...new Set(overviews.map((o) => o.topLanguage).filter(Boolean))].sort();
192 + return render(reply, 'home.njk', {
193 + title: 'SPB Git — Simon-Pierre Boucher',
194 + description: 'Personal git platform of Simon-Pierre Boucher — every repository, public and clonable.',
195 + overviews,
196 + pinned,
197 + stats,
198 + activity,
199 + topics,
200 + languages,
201 + heatmap,
202 + canonicalPath: '/',
203 + });
204 + });
205 +
206 + // ───────────────────────── search ─────────────────────────
207 + app.get('/search', async (request, reply) => {
208 + const q = String(request.query.q ?? '').slice(0, 120);
209 + const results = q ? await search(ctx, q) : { repos: [], readmes: [] };
210 + return render(reply, 'search.njk', {
211 + title: q ? `Search: ${q} · SPB Git` : 'Search · SPB Git',
212 + description: `Search results for ${q}`,
213 + q,
214 + results,
215 + canonicalPath: `/search`,
216 + });
217 + });
218 +
219 + // ───────────────────────── meta/polish routes ─────────────────────────
220 + app.get('/robots.txt', async (_request, reply) => {
221 + reply.type('text/plain');
222 + return [
223 + 'User-agent: *',
224 + 'Allow: /',
225 + 'Disallow: /archive/',
226 + 'Disallow: /internal/',
227 + `Sitemap: ${config.publicUrl}/sitemap.xml`,
228 + '',
229 + ].join('\n');
230 + });
231 +
232 + app.get('/sitemap.xml', async (_request, reply) => {
233 + const overviews = await allOverviews(ctx);
234 + const urls = [
235 + { loc: `${config.publicUrl}/`, priority: '1.0' },
236 + ...overviews.map((o) => ({
237 + loc: `${config.publicUrl}/${o.name}`,
238 + lastmod: o.lastPush ? new Date(o.lastPush).toISOString() : undefined,
239 + priority: '0.8',
240 + })),
241 + ];
242 + const body = urls
243 + .map((u) =>
244 + [
245 + ' <url>',
246 + ` <loc>${escapeHtml(u.loc)}</loc>`,
247 + u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>` : null,
248 + ` <priority>${u.priority}</priority>`,
249 + ' </url>',
250 + ].filter(Boolean).join('\n'),
251 + )
252 + .join('\n');
253 + reply.type('application/xml');
254 + return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
255 + });
256 +
257 + app.get('/feed.atom', async (_request, reply) => {
258 + const events = ctx.activity.recent(30);
259 + const updated = events[0]?.at ?? new Date().toISOString();
260 + const entries = events
261 + .map((e) => {
262 + const what = e.deleted
263 + ? `deleted ${e.refType} ${e.ref}`
264 + : `pushed ${e.commits} commit${e.commits === 1 ? '' : 's'} to ${e.ref}`;
265 + const id = `${config.publicUrl}/${e.repo}#${e.at}`;
266 + return [
267 + ' <entry>',
268 + ` <title>${escapeHtml(`${e.repo}: ${what}`)}</title>`,
269 + ` <link href="${escapeHtml(`${config.publicUrl}/${e.repo}`)}"/>`,
270 + ` <id>${escapeHtml(id)}</id>`,
271 + ` <updated>${escapeHtml(e.at)}</updated>`,
272 + ` <author><name>${escapeHtml(config.owner.name)}</name></author>`,
273 + ` <summary>${escapeHtml(`${config.owner.name} ${what} in ${e.repo}`)}</summary>`,
274 + ' </entry>',
275 + ].join('\n');
276 + })
277 + .join('\n');
278 + reply.type('application/atom+xml');
279 + return [
280 + '<?xml version="1.0" encoding="utf-8"?>',
281 + '<feed xmlns="http://www.w3.org/2005/Atom">',
282 + ` <title>SPB Git — activity</title>`,
283 + ` <link href="${config.publicUrl}/feed.atom" rel="self"/>`,
284 + ` <link href="${config.publicUrl}/"/>`,
285 + ` <id>${config.publicUrl}/feed.atom</id>`,
286 + ` <updated>${escapeHtml(updated)}</updated>`,
287 + ` <author><name>${escapeHtml(config.owner.name)}</name><email>${escapeHtml(config.owner.email)}</email></author>`,
288 + entries,
289 + '</feed>',
290 + '',
291 + ].join('\n');
292 + });
293 +
294 + // Favicons + OG images (all generated, cached).
295 + app.get('/favicon.png', async (_request, reply) => {
296 + const path = ctx.cache.path('global', 'favicon-32.png');
297 + let buf = ctx.cache.getBuffer(path);
298 + if (!buf) {
299 + buf = await renderMonogramPng(64);
300 + ctx.cache.set(path, buf);
301 + }
302 + reply.type('image/png').header('Cache-Control', 'public, max-age=604800');
303 + return reply.send(buf);
304 + });
305 + app.get('/apple-touch-icon.png', async (_request, reply) => {
306 + const path = ctx.cache.path('global', 'favicon-180.png');
307 + let buf = ctx.cache.getBuffer(path);
308 + if (!buf) {
309 + buf = await renderMonogramPng(180);
310 + ctx.cache.set(path, buf);
311 + }
312 + reply.type('image/png').header('Cache-Control', 'public, max-age=604800');
313 + return reply.send(buf);
314 + });
315 + app.get('/favicon.ico', async (_request, reply) => reply.redirect('/favicon.png', 301));
316 +
317 + app.get('/og/site.png', async (_request, reply) => {
318 + const buf = await ctx.ogImage(null);
319 + reply.type('image/png').header('Cache-Control', 'public, max-age=86400');
320 + return reply.send(buf);
321 + });
322 + app.get('/og/:file', async (request, reply) => {
323 + const file = String(request.params.file ?? '');
324 + if (!file.endsWith('.png')) return notFound(request, reply);
325 + const name = file.slice(0, -4);
326 + if (!isValidRepoName(name) || !ctx.repos.exists(name)) return notFound(request, reply);
327 + const buf = await ctx.ogImage(name);
328 + reply.type('image/png').header('Cache-Control', 'public, max-age=3600');
329 + return reply.send(buf);
330 + });
331 +
332 + // ───────────────────────── raw files ─────────────────────────
333 + app.get('/raw/:repo/*', async (request, reply) => {
334 + const name = repoParam(request, reply);
335 + if (!name) return reply;
336 + const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);
337 + if (!resolved || resolved.path === '') return notFound(request, reply);
338 + const blob = await ctx.repos.blob(name, resolved.sha, resolved.path);
339 + if (!blob) return notFound(request, reply);
340 + const ext = posix.extname(resolved.path).toLowerCase();
341 + let mime = RAW_MIME[ext];
342 + if (!mime) mime = blob.binary ? 'application/octet-stream' : 'text/plain; charset=utf-8';
343 + // Stored-XSS guard: never serve repo HTML as HTML.
344 + if (['.html', '.htm', '.xhtml'].includes(ext)) mime = 'text/plain; charset=utf-8';
345 + const immutable = /^[0-9a-f]{40}$/.test(resolved.ref) || /^[0-9a-f]{7,40}$/.test(resolved.ref);
346 + reply
347 + .type(mime)
348 + .header('X-Content-Type-Options', 'nosniff')
349 + .header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
350 + .header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=60');
351 + return reply.send(blob.content);
352 + });
353 +
354 + // ───────────────────────── archives ─────────────────────────
355 + app.get('/archive/:repo/*', async (request, reply) => {
356 + const name = repoParam(request, reply);
357 + if (!name) return reply;
358 + const splat = String(request.params['*'] ?? '');
359 + const match = /^(.+?)\.(zip|tar\.gz)$/.exec(splat);
360 + if (!match) return notFound(request, reply);
361 + const [, refPart, format] = match;
362 + const resolved = await ctx.repos.resolveRefAndPath(name, refPart);
363 + if (!resolved || resolved.path !== '') return notFound(request, reply);
364 + const safeRef = refPart.replaceAll('/', '-');
365 + return sendArchive(ctx, name, resolved.sha, format, reply, `${name}-${safeRef}`);
366 + });
367 +
368 + // ───────────────────────── repo home ─────────────────────────
369 + app.get('/:repo', async (request, reply) => {
370 + const name = repoParam(request, reply);
371 + if (!name) return reply;
372 + const shell = await repoShell(ctx, name);
373 + const { overview } = shell;
374 + let entries = [];
375 + let lastCommits = {};
376 + let readme = null;
377 + if (!overview.empty) {
378 + entries = (await ctx.repos.tree(name, overview.head, '')) ?? [];
379 + const lcCache = ctx.cache.repoPath(name, overview.head, 'lastcommits-root.json');
380 + lastCommits = await ctx.cache.remember(lcCache, () =>
381 + ctx.repos.lastCommits(name, overview.head, '', entries.map((e) => e.name)),
382 + );
383 + readme = await ctx.renderReadme(name, overview.head);
384 + }
385 + return render(reply, 'repo.njk', {
386 + title: `${name} · SPB Git`,
387 + description: overview.description || `${name} — a repository by ${config.owner.name}`,
388 + ...shell,
389 + tab: 'code',
390 + currentRef: overview.defaultBranch,
391 + entries,
392 + lastCommits,
393 + readme,
394 + canonicalPath: `/${name}`,
395 + ogImagePath: `/og/${name}.png`,
396 + });
397 + });
398 +
399 + // ───────────────────────── tree browsing ─────────────────────────
400 + app.get('/:repo/tree/*', async (request, reply) => {
401 + const name = repoParam(request, reply);
402 + if (!name) return reply;
403 + const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);
404 + if (!resolved) return notFound(request, reply);
405 + const entries = await ctx.repos.tree(name, resolved.sha, resolved.path);
406 + if (!entries) return notFound(request, reply);
407 + const shell = await repoShell(ctx, name);
408 + const lcKey = `lastcommits-${resolved.path.replaceAll('/', '_') || 'root'}.json`;
409 + const lastCommits = await ctx.cache.remember(ctx.cache.repoPath(name, resolved.sha, lcKey), () =>
410 + ctx.repos.lastCommits(name, resolved.sha, resolved.path, entries.map((e) => e.name)),
411 + );
412 + return render(reply, 'tree.njk', {
413 + title: `${resolved.path || name} at ${resolved.ref} · ${name} · SPB Git`,
414 + description: `Browse ${resolved.path || 'the root tree'} of ${name} at ${resolved.ref}`,
415 + ...shell,
416 + tab: 'code',
417 + currentRef: resolved.ref,
418 + path: resolved.path,
419 + parentPath: resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '',
420 + crumbs: breadcrumbs(name, 'tree', resolved.ref, resolved.path),
421 + entries,
422 + lastCommits,
423 + canonicalPath: `/${name}/tree/${resolved.ref}${resolved.path ? `/${resolved.path}` : ''}`,
424 + ogImagePath: `/og/${name}.png`,
425 + });
426 + });
427 +
428 + // ───────────────────────── blob view ─────────────────────────
429 + app.get('/:repo/blob/*', async (request, reply) => {
430 + const name = repoParam(request, reply);
431 + if (!name) return reply;
432 + const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);
433 + if (!resolved || resolved.path === '') return notFound(request, reply);
434 + const type = await ctx.repos.objectType(name, resolved.sha, resolved.path);
435 + if (type === 'tree') {
436 + return reply.redirect(`/${name}/tree/${resolved.ref}/${resolved.path}`);
437 + }
438 + const blob = await ctx.repos.blob(name, resolved.sha, resolved.path);
439 + if (!blob) return notFound(request, reply);
440 + const shell = await repoShell(ctx, name);
441 + const ext = posix.extname(resolved.path).toLowerCase();
442 + const rawUrl = `/raw/${name}/${resolved.ref}/${resolved.path}`;
443 +
444 + const view = {
445 + kind: 'code',
446 + html: null,
447 + lines: 0,
448 + lang: null,
449 + tooLarge: false,
450 + isImage: false,
451 + isMarkdown: false,
452 + notebookNote: false,
453 + };
454 +
455 + if (blob.binary || IMAGE_EXT.has(ext)) {
456 + view.kind = 'binary';
457 + view.isImage = IMAGE_EXT.has(ext);
458 + } else if (blob.size > BLOB_RENDER_LIMIT) {
459 + view.kind = 'toolarge';
460 + view.tooLarge = true;
461 + } else {
462 + const text = blob.content.toString('utf8');
463 + if (['.md', '.markdown'].includes(ext) && request.query.plain !== '1') {
464 + view.kind = 'markdown';
465 + view.isMarkdown = true;
466 + const basePath = resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '.';
467 + view.html = await renderMarkdown(text, {
468 + repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl,
469 + });
470 + } else {
471 + let source = text;
472 + if (ext === '.ipynb') {
473 + view.notebookNote = true;
474 + try {
475 + source = JSON.stringify(JSON.parse(text), null, 2);
476 + } catch {
477 + source = text;
478 + }
479 + }
480 + const result = await highlightFile(source, resolved.path);
481 + view.html = result.html;
482 + view.lines = result.lines;
483 + view.lang = result.lang;
484 + if (['.md', '.markdown'].includes(ext)) view.isMarkdown = true;
485 + }
486 + }
487 +
488 + return render(reply, 'blob.njk', {
489 + title: `${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,
490 + description: `${resolved.path} — ${name} at ${resolved.ref}`,
491 + ...shell,
492 + tab: 'code',
493 + currentRef: resolved.ref,
494 + refSha: resolved.sha,
495 + path: resolved.path,
496 + fileName: resolved.path.split('/').pop(),
497 + crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path),
498 + blobSize: blob.size,
499 + rawUrl,
500 + view,
501 + canonicalPath: `/${name}/blob/${resolved.ref}/${resolved.path}`,
502 + ogImagePath: `/og/${name}.png`,
503 + });
504 + });
505 +
506 + // ───────────────────────── commits list ─────────────────────────
507 + const commitsHandler = async (request, reply) => {
508 + const name = repoParam(request, reply);
509 + if (!name) return reply;
510 + const shell = await repoShell(ctx, name);
511 + const splat = request.params['*'] ?? '';
512 + const resolved = await ctx.repos.resolveRefAndPath(name, splat);
513 + if (!resolved) return notFound(request, reply);
514 + const page = Math.max(1, Number(request.query.page) || 1);
515 + const path = typeof request.query.path === 'string' ? request.query.path : undefined;
516 + const { commits, hasNext } = await ctx.repos.log(name, resolved.sha, { page, path });
517 + return render(reply, 'commits.njk', {
518 + title: `Commits on ${resolved.ref} · ${name} · SPB Git`,
519 + description: `Commit history of ${name} on ${resolved.ref}`,
520 + ...shell,
521 + tab: 'commits',
522 + currentRef: resolved.ref,
523 + commits,
524 + page,
525 + hasNext,
526 + filterPath: path ?? '',
527 + canonicalPath: `/${name}/commits/${resolved.ref}`,
528 + ogImagePath: `/og/${name}.png`,
529 + });
530 + };
531 + app.get('/:repo/commits', commitsHandler);
532 + app.get('/:repo/commits/*', commitsHandler);
533 +
534 + // ───────────────────────── single commit ─────────────────────────
535 + app.get('/:repo/commit/:sha', async (request, reply) => {
536 + const name = repoParam(request, reply);
537 + if (!name) return reply;
538 + const shaParam = String(request.params.sha ?? '');
539 + if (!/^[0-9a-f]{4,40}$/i.test(shaParam)) return notFound(request, reply);
540 + const commit = await ctx.repos.commit(name, shaParam);
541 + if (!commit) return notFound(request, reply);
542 + await prepareDiff(commit.files);
543 + const shell = await repoShell(ctx, name);
544 + return render(reply, 'commit.njk', {
545 + title: `${commit.subject} · ${commit.shortSha} · ${name} · SPB Git`,
546 + description: commit.subject,
547 + ...shell,
548 + tab: 'commits',
549 + currentRef: shell.overview.defaultBranch,
550 + commit,
551 + canonicalPath: `/${name}/commit/${commit.sha}`,
552 + ogImagePath: `/og/${name}.png`,
553 + });
554 + });
555 +
556 + // ───────────────────────── blame ─────────────────────────
557 + app.get('/:repo/blame/*', async (request, reply) => {
558 + const name = repoParam(request, reply);
559 + if (!name) return reply;
560 + const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']);
561 + if (!resolved || resolved.path === '') return notFound(request, reply);
562 + const blame = await ctx.repos.blame(name, resolved.sha, resolved.path);
563 + if (!blame) return notFound(request, reply);
564 + const shell = await repoShell(ctx, name);
565 + // Age-scale each hunk 0..9 (newer = brighter accent).
566 + const dates = blame.hunks.map((h) => new Date(h.date).getTime()).filter(Number.isFinite);
567 + const min = Math.min(...dates);
568 + const max = Math.max(...dates);
569 + for (const hunk of blame.hunks) {
570 + const t = new Date(hunk.date).getTime();
571 + hunk.age = max === min ? 0 : Math.round((1 - (t - min) / (max - min)) * 9);
572 + }
573 + return render(reply, 'blame.njk', {
574 + title: `Blame ${resolved.path} at ${resolved.ref} · ${name} · SPB Git`,
575 + description: `Blame view of ${resolved.path} in ${name}`,
576 + ...shell,
577 + tab: 'code',
578 + currentRef: resolved.ref,
579 + path: resolved.path,
580 + crumbs: breadcrumbs(name, 'blame', resolved.ref, resolved.path),
581 + blame,
582 + canonicalPath: `/${name}/blame/${resolved.ref}/${resolved.path}`,
583 + ogImagePath: `/og/${name}.png`,
584 + });
585 + });
586 +
587 + // ───────────────────────── branches / tags ─────────────────────────
588 + app.get('/:repo/branches', async (request, reply) => {
589 + const name = repoParam(request, reply);
590 + if (!name) return reply;
591 + const shell = await repoShell(ctx, name);
592 + const def = shell.overview.defaultBranch;
593 + const branches = [];
594 + for (const branch of shell.branches) {
595 + const counts = branch.isDefault
596 + ? { ahead: 0, behind: 0 }
597 + : await ctx.repos.aheadBehind(name, def, branch.name);
598 + branches.push({ ...branch, ...counts });
599 + }
600 + return render(reply, 'branches.njk', {
601 + title: `Branches · ${name} · SPB Git`,
602 + description: `Branches of ${name}`,
603 + ...shell,
604 + tab: 'branches',
605 + currentRef: def,
606 + branchesDetailed: branches,
607 + canonicalPath: `/${name}/branches`,
608 + ogImagePath: `/og/${name}.png`,
609 + });
610 + });
611 +
612 + app.get('/:repo/tags', async (request, reply) => {
613 + const name = repoParam(request, reply);
614 + if (!name) return reply;
615 + const shell = await repoShell(ctx, name);
616 + return render(reply, 'tags.njk', {
617 + title: `Tags · ${name} · SPB Git`,
618 + description: `Tags and releases of ${name}`,
619 + ...shell,
620 + tab: 'tags',
621 + currentRef: shell.overview.defaultBranch,
622 + canonicalPath: `/${name}/tags`,
623 + ogImagePath: `/og/${name}.png`,
624 + });
625 + });
626 +}
added src/web/views/blame.njk +52 −0
@@ -0,0 +1,52 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/blame.njk
8 + Purpose : Blame view — hunks grouped per commit, color-aged
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<div class="blob-card">
16 + <div class="blob-header">
17 + <nav class="path-breadcrumb" aria-label="Path">
18 + <a href="/{{ overview.name }}">{{ overview.name }}</a>
19 + {% for crumb in crumbs %}
20 + <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<span>{{ crumb.name }}</span>{% endif %}
21 + {% endfor %}
22 + <span class="chip">blame</span>
23 + </nav>
24 + <div class="blob-actions">
25 + <a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">Normal view</a>
26 + <a class="btn btn-sm" href="/raw/{{ overview.name }}/{{ currentRef }}/{{ path }}" rel="nofollow">Raw</a>
27 + </div>
28 + </div>
29 +
30 + <table class="blame-table">
31 + <tbody>
32 + {% for hunk in blame.hunks %}
33 + {% for line in hunk.lines %}
34 + <tr class="blame-line" data-age="{{ hunk.age }}">
35 + {% if loop.first %}
36 + <td class="blame-commit" rowspan="{{ hunk.lines.length }}">
37 + <div class="blame-commit-inner">
38 + <span class="commit-avatar">{{ hunk.authorEmail | identicon(20) }}</span>
39 + <a href="/{{ overview.name }}/commit/{{ hunk.sha }}" title="{{ hunk.subject }}">{{ hunk.subject | truncate(48) }}</a>
40 + <span class="muted blame-date">{{ hunk.date | reltime }}</span>
41 + </div>
42 + </td>
43 + {% endif %}
44 + <td class="blame-ln">{{ line.line }}</td>
45 + <td class="blame-code"><pre>{{ line.text }}</pre></td>
46 + </tr>
47 + {% endfor %}
48 + {% endfor %}
49 + </tbody>
50 + </table>
51 +</div>
52 +{% endblock %}
added src/web/views/blob.njk +69 −0
@@ -0,0 +1,69 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/blob.njk
8 + Purpose : File view — sticky header, shiki code, line anchors
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<div class="blob-card">
16 + <div class="blob-header">
17 + <nav class="path-breadcrumb" aria-label="Path">
18 + <a href="/{{ overview.name }}">{{ overview.name }}</a>
19 + {% for crumb in crumbs %}
20 + <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<a href="{{ crumb.href | replace('/blame/', '/tree/') | replace('/blob/', '/tree/') }}">{{ crumb.name }}</a>{% endif %}
21 + {% endfor %}
22 + </nav>
23 + <div class="blob-meta">
24 + <span class="muted">{{ blobSize | bytes }}</span>
25 + {% if view.lines %}<span class="muted">· {{ view.lines | num }} lines</span>{% endif %}
26 + {% if view.lang %}<span class="chip">{{ view.lang }}</span>{% endif %}
27 + </div>
28 + <div class="blob-actions">
29 + {% if view.isMarkdown %}
30 + {% if view.kind == 'markdown' %}
31 + <a class="btn btn-sm" href="?plain=1" rel="nofollow">View source</a>
32 + {% else %}
33 + <a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">Rendered</a>
34 + {% endif %}
35 + {% endif %}
36 + <a class="btn btn-sm" href="{{ rawUrl }}" rel="nofollow">Raw</a>
37 + <a class="btn btn-sm" href="/{{ overview.name }}/blame/{{ currentRef }}/{{ path }}" rel="nofollow">Blame</a>
38 + <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}?path={{ path | urlencode }}" rel="nofollow">History</a>
39 + <button class="btn btn-sm copy-btn" type="button"
40 + data-copy="{{ publicUrl }}/{{ overview.name }}/blob/{{ refSha }}/{{ path }}"
41 + title="Copy permalink (pinned to {{ refSha | truncate(7, true, '') }})">Permalink</button>
42 + </div>
43 + </div>
44 +
45 + {% if view.notebookNote %}
46 + <p class="notice">Jupyter notebook shown as formatted JSON. Download the <a href="{{ rawUrl }}">raw file</a> to open it in Jupyter.</p>
47 + {% endif %}
48 +
49 + {% if view.kind == 'binary' %}
50 + {% if view.isImage %}
51 + <div class="blob-image"><img src="{{ rawUrl }}" alt="{{ fileName }}"></div>
52 + {% else %}
53 + <div class="blob-binary">
54 + <p>Binary file — {{ blobSize | bytes }}.</p>
55 + <a class="btn btn-primary" href="{{ rawUrl }}" download>Download</a>
56 + </div>
57 + {% endif %}
58 + {% elif view.kind == 'toolarge' %}
59 + <div class="blob-binary">
60 + <p>This file is {{ blobSize | bytes }} — too large to display.</p>
61 + <a class="btn btn-primary" href="{{ rawUrl }}">View raw</a>
62 + </div>
63 + {% elif view.kind == 'markdown' %}
64 + <article class="markdown-body blob-markdown">{{ view.html | safe }}</article>
65 + {% else %}
66 + <div class="code-view" id="code-view">{{ view.html | safe }}</div>
67 + {% endif %}
68 +</div>
69 +{% endblock %}
added src/web/views/branches.njk +41 −0
@@ -0,0 +1,41 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/branches.njk
8 + Purpose : Branch list with ahead/behind vs default
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<table class="ref-table">
16 + <thead><tr><th>Branch</th><th>Last commit</th><th>Ahead / behind {{ overview.defaultBranch }}</th></tr></thead>
17 + <tbody>
18 + {% for branch in branchesDetailed %}
19 + <tr>
20 + <td>
21 + <a class="ref-name" href="/{{ overview.name }}/tree/{{ branch.name }}">{{ branch.name }}</a>
22 + {% if branch.isDefault %}<span class="chip chip-default">default</span>{% endif %}
23 + </td>
24 + <td class="muted">{{ branch.subject | truncate(60) }} · {{ branch.date | reltime }}</td>
25 + <td>
26 + {% if branch.isDefault %}
27 + <span class="muted">—</span>
28 + {% else %}
29 + <span class="ahead-behind">
30 + <span class="diff-add" title="{{ branch.ahead }} ahead">↑{{ branch.ahead }}</span>
31 + <span class="diff-del" title="{{ branch.behind }} behind">↓{{ branch.behind }}</span>
32 + </span>
33 + {% endif %}
34 + </td>
35 + </tr>
36 + {% else %}
37 + <tr><td colspan="3" class="muted">No branches yet.</td></tr>
38 + {% endfor %}
39 + </tbody>
40 +</table>
41 +{% endblock %}
added src/web/views/commit.njk +65 −0
@@ -0,0 +1,65 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/commit.njk
8 + Purpose : Single commit — full diff with per-file collapsible hunks
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<article class="commit-detail">
16 + <header class="commit-detail-header">
17 + <h2 class="commit-detail-subject">{{ commit.subject }}</h2>
18 + {% if commit.body %}<pre class="commit-body">{{ commit.body }}</pre>{% endif %}
19 + <div class="commit-detail-meta">
20 + <span class="commit-avatar">{{ commit.authorEmail | identicon(24) }}</span>
21 + <strong>{{ commit.authorName }}</strong>
22 + <span class="muted">committed {{ commit.date | reltime }} ({{ commit.date | datefull }})</span>
23 + <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA">{{ commit.shortSha }}</button>
24 + {% for parent in commit.parents %}
25 + <a class="muted" href="/{{ overview.name }}/commit/{{ parent }}">parent {{ parent | truncate(7, true, '') }}</a>
26 + {% endfor %}
27 + </div>
28 + <p class="commit-stats">
29 + Showing <strong>{{ commit.files.length }}</strong> changed file{{ 's' if commit.files.length != 1 }}
30 + with <span class="diff-add">+{{ commit.additions | num }}</span> and <span class="diff-del">−{{ commit.deletions | num }}</span>
31 + </p>
32 + </header>
33 +
34 + {% for file in commit.files %}
35 + <details class="diff-file" open>
36 + <summary class="diff-file-header">
37 + <span class="diff-status diff-status-{{ file.status }}">{{ file.status }}</span>
38 + <code class="diff-path">{% if file.status == 'renamed' %}{{ file.oldPath }}{% endif %}{{ file.newPath or file.oldPath }}</code>
39 + <span class="diff-counts"><span class="diff-add">+{{ file.additions }}</span> <span class="diff-del">−{{ file.deletions }}</span></span>
40 + </summary>
41 + {% if file.binary %}
42 + <p class="diff-binary muted">Binary file not shown.</p>
43 + {% else %}
44 + <div class="diff-table-wrap">
45 + <table class="diff-table">
46 + <tbody>
47 + {% for hunk in file.hunks %}
48 + <tr class="diff-hunk-header"><td class="diff-gutter" colspan="2"></td><td><code>{{ hunk.header }}</code></td></tr>
49 + {% for line in hunk.lines %}
50 + <tr class="diff-line diff-line-{{ line.type }}">
51 + <td class="diff-gutter">{{ line.old if line.old }}</td>
52 + <td class="diff-gutter">{{ line.new if line.new }}</td>
53 + <td class="diff-code"><span class="diff-sign">{{ '+' if line.type == 'add' else ('−' if line.type == 'del' else ' ') }}</span><span class="diff-content">{{ line.html | safe }}</span></td>
54 + </tr>
55 + {% endfor %}
56 + {% endfor %}
57 + </tbody>
58 + </table>
59 + </div>
60 + {% if file.truncated %}<p class="muted">Diff truncated — file too large.</p>{% endif %}
61 + {% endif %}
62 + </details>
63 + {% endfor %}
64 +</article>
65 +{% endblock %}
added src/web/views/commits.njk +66 −0
@@ -0,0 +1,66 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/commits.njk
8 + Purpose : Paginated commit history
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<div class="repo-toolbar">
16 + <div class="ref-select-wrap">
17 + <label class="sr-only" for="ref-select">Branch</label>
18 + <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="commits">
19 + {% for branch in branches %}
20 + <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option>
21 + {% endfor %}
22 + {% for tag in tags %}
23 + <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option>
24 + {% endfor %}
25 + </select>
26 + </div>
27 + {% if filterPath %}
28 + <p class="muted">History of <code>{{ filterPath }}</code> · <a href="/{{ overview.name }}/commits/{{ currentRef }}">clear filter</a></p>
29 + {% endif %}
30 +</div>
31 +
32 +<ol class="commit-list">
33 + {% for commit in commits %}
34 + <li class="commit-row">
35 + <span class="commit-avatar" title="{{ commit.authorName }}">{{ commit.authorEmail | identicon(28) }}</span>
36 + <div class="commit-main">
37 + <div class="commit-subject">
38 + <a href="/{{ overview.name }}/commit/{{ commit.sha }}">{{ commit.subject }}</a>
39 + {% if commit.body %}
40 + <details class="commit-body-toggle"><summary aria-label="Show commit body">…</summary><pre class="commit-body">{{ commit.body }}</pre></details>
41 + {% endif %}
42 + </div>
43 + <div class="commit-meta muted">
44 + {{ commit.authorName }} committed {{ commit.date | reltime }}
45 + <span title="{{ commit.date }}">({{ commit.date | datefull }})</span>
46 + · {{ commit.filesChanged | num }} file{{ 's' if commit.filesChanged != 1 }} changed
47 + {% if commit.additions %}<span class="diff-add">+{{ commit.additions | num }}</span>{% endif %}
48 + {% if commit.deletions %}<span class="diff-del">−{{ commit.deletions | num }}</span>{% endif %}
49 + </div>
50 + </div>
51 + <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA">{{ commit.shortSha }}</button>
52 + </li>
53 + {% else %}
54 + <li class="muted">No commits yet.</li>
55 + {% endfor %}
56 +</ol>
57 +
58 +<nav class="pagination" aria-label="Commit pages">
59 + {% if page > 1 %}
60 + <a class="btn" href="?page={{ page - 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">← Newer</a>
61 + {% endif %}
62 + {% if hasNext %}
63 + <a class="btn" href="?page={{ page + 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">Older →</a>
64 + {% endif %}
65 +</nav>
66 +{% endblock %}
added src/web/views/error.njk +20 −0
@@ -0,0 +1,20 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/error.njk
8 + Purpose : Custom 404 / 500 pages
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +<div class="error-page">
14 + <div class="error-code">{{ status }}</div>
15 + <p class="error-message">{{ message }}</p>
16 + <pre class="error-art"> git checkout --orphan reality
17 + fatal: reference is not a tree</pre>
18 + <a class="btn btn-primary" href="/">Back to the repositories</a>
19 +</div>
20 +{% endblock %}
added src/web/views/home.njk +108 −0
@@ -0,0 +1,108 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/home.njk
8 + Purpose : Home — hero, pinned repos, repo index, activity, heatmap
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +<section class="hero">
14 + <div class="hero-id">
15 + <div class="hero-avatar" aria-hidden="true">SPB</div>
16 + <div>
17 + <h1 class="hero-name">{{ owner.name }}</h1>
18 + <p class="hero-tagline">{{ owner.tagline }} · <a href="mailto:{{ owner.email }}">{{ owner.email }}</a></p>
19 + </div>
20 + </div>
21 + <div class="hero-badges">
22 + <span class="badge-stat"><strong>{{ stats.repos | num }}</strong> repos</span>
23 + <span class="badge-stat"><strong>{{ stats.commits | num }}</strong> commits</span>
24 + <span class="badge-stat"><strong>{{ stats.languages | num }}</strong> languages</span>
25 + </div>
26 +</section>
27 +
28 +{% if pinned.length %}
29 +<section aria-labelledby="pinned-h">
30 + <h2 id="pinned-h" class="section-title">Pinned</h2>
31 + <div class="pinned-grid">
32 + {% for repo in pinned %}{% include "partials/repo-card.njk" %}{% endfor %}
33 + </div>
34 +</section>
35 +{% endif %}
36 +
37 +<div class="home-columns">
38 + <section class="home-main" aria-labelledby="repos-h">
39 + <div class="repo-controls">
40 + <h2 id="repos-h" class="section-title">Repositories</h2>
41 + <div class="repo-filters">
42 + <input type="search" id="repo-filter" placeholder="Filter repositories…" aria-label="Filter repositories">
43 + <select id="repo-sort" aria-label="Sort repositories">
44 + <option value="pushed">Recently pushed</option>
45 + <option value="name">Name</option>
46 + <option value="created">Created</option>
47 + </select>
48 + <select id="repo-lang" aria-label="Filter by language">
49 + <option value="">All languages</option>
50 + {% for lang in languages %}<option value="{{ lang }}">{{ lang }}</option>{% endfor %}
51 + </select>
52 + <select id="repo-topic" aria-label="Filter by topic">
53 + <option value="">All topics</option>
54 + {% for topic in topics %}<option value="{{ topic }}">{{ topic }}</option>{% endfor %}
55 + </select>
56 + </div>
57 + </div>
58 + <div class="repo-list" id="repo-list">
59 + {% for repo in overviews %}{% include "partials/repo-card.njk" %}{% endfor %}
60 + {% if not overviews.length %}
61 + <div class="empty-state">
62 + <p>No repositories yet. Create the first one:</p>
63 + <pre class="clone-snippet">spbgit create hello-world --push</pre>
64 + </div>
65 + {% endif %}
66 + </div>
67 + <p class="muted" id="repo-list-empty" hidden>No repository matches the current filters.</p>
68 + </section>
69 +
70 + <aside class="home-side">
71 + <section aria-labelledby="activity-h">
72 + <h2 id="activity-h" class="section-title">Activity</h2>
73 + <ul class="activity-feed">
74 + {% for event in activity %}
75 + <li>
76 + <span class="activity-dot" aria-hidden="true"></span>
77 + <div>
78 + {% if event.deleted %}
79 + deleted {{ event.refType }} <code>{{ event.ref }}</code> in <a href="/{{ event.repo }}">{{ event.repo }}</a>
80 + {% else %}
81 + pushed {{ event.commits | num }} commit{{ 's' if event.commits != 1 }} to <a href="/{{ event.repo }}">{{ event.repo }}</a>
82 + {% if event.refType == 'tag' %}(tag <code>{{ event.ref }}</code>){% elif event.ref != overviewDefault %}<code class="ref-inline">{{ event.ref }}</code>{% endif %}
83 + {% endif %}
84 + <span class="muted">· {{ event.at | reltime }}</span>
85 + </div>
86 + </li>
87 + {% else %}
88 + <li class="muted">No pushes recorded yet.</li>
89 + {% endfor %}
90 + </ul>
91 + </section>
92 + </aside>
93 +</div>
94 +
95 +<section aria-labelledby="heatmap-h" class="heatmap-section">
96 + <h2 id="heatmap-h" class="section-title">{{ heatmap.total | num }} commits in the last year</h2>
97 + <div class="heatmap-scroll">{{ heatmap.svg | safe }}</div>
98 + <div class="heatmap-legend" aria-hidden="true">
99 + <span>Less</span>
100 + <span class="heatmap-cell-demo" data-level="0"></span>
101 + <span class="heatmap-cell-demo" data-level="1"></span>
102 + <span class="heatmap-cell-demo" data-level="2"></span>
103 + <span class="heatmap-cell-demo" data-level="3"></span>
104 + <span class="heatmap-cell-demo" data-level="4"></span>
105 + <span>More</span>
106 + </div>
107 +</section>
108 +{% endblock %}
added src/web/views/layout.njk +76 −0
@@ -0,0 +1,76 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/layout.njk
8 + Purpose : Base layout — nav, footer, meta, OG cards, theme
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}<!DOCTYPE html>
12 +<html lang="en" data-theme="dark">
13 +<head>
14 + <meta charset="utf-8">
15 + <meta name="viewport" content="width=device-width, initial-scale=1">
16 + <title>{{ title | default('SPB Git') }}</title>
17 + <meta name="description" content="{{ description | default('SPB Git — the personal git platform of Simon-Pierre Boucher.') }}">
18 + <link rel="canonical" href="{{ canonical }}">
19 + <meta name="author" content="{{ owner.name }}">
20 + <meta property="og:site_name" content="SPB Git">
21 + <meta property="og:type" content="website">
22 + <meta property="og:title" content="{{ title | default('SPB Git') }}">
23 + <meta property="og:description" content="{{ description | default('SPB Git — the personal git platform of Simon-Pierre Boucher.') }}">
24 + <meta property="og:url" content="{{ canonical }}">
25 + <meta property="og:image" content="{{ ogImage }}">
26 + <meta name="twitter:card" content="summary_large_image">
27 + <meta name="twitter:title" content="{{ title | default('SPB Git') }}">
28 + <meta name="twitter:description" content="{{ description | default('SPB Git — the personal git platform of Simon-Pierre Boucher.') }}">
29 + <meta name="twitter:image" content="{{ ogImage }}">
30 + <link rel="icon" type="image/svg+xml" href="/assets/img/logo.svg">
31 + <link rel="icon" type="image/png" href="/favicon.png">
32 + <link rel="apple-touch-icon" href="/apple-touch-icon.png">
33 + <link rel="alternate" type="application/atom+xml" title="SPB Git activity" href="/feed.atom">
34 + <link rel="preload" href="/assets/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
35 + <link rel="preload" href="/assets/fonts/inter-600.woff2" as="font" type="font/woff2" crossorigin>
36 + <link rel="preload" href="/assets/fonts/jetbrains-mono-400.woff2" as="font" type="font/woff2" crossorigin>
37 + <link rel="stylesheet" href="/assets/css/tokens.css">
38 + <link rel="stylesheet" href="/assets/css/app.css">
39 + <link rel="stylesheet" href="/assets/css/markdown.css">
40 + <script src="/assets/js/theme-boot.js"></script>
41 +</head>
42 +<body>
43 + <a class="skip-link" href="#main">Skip to content</a>
44 + <header class="topnav">
45 + <div class="container topnav-inner">
46 + <a class="brand" href="/" aria-label="SPB Git home">
47 + <svg class="brand-mark" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
48 + <rect width="28" height="28" rx="7" fill="var(--accent)"/>
49 + <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"/>
50 + </svg>
51 + <span class="brand-name">SPB Git</span>
52 + </a>
53 + <form class="global-search" action="/search" method="get" role="search">
54 + <input type="search" name="q" id="global-search" placeholder="Search repositories… ( / )"
55 + value="{{ q | default('') }}" autocomplete="off" aria-label="Search repositories">
56 + </form>
57 + <nav class="topnav-links" aria-label="Site">
58 + <button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle color theme" title="Toggle theme">
59 + <svg class="icon-sun" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1 0-1h1A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 0 1-.708-.708l.707-.707a.5.5 0 0 1 .708 0zm-9.9 9.9a.5.5 0 0 1 0 .707l-.707.707a.5.5 0 0 1-.707-.707l.707-.707a.5.5 0 0 1 .707 0zm9.9 1.414a.5.5 0 0 1-.707 0l-.708-.707a.5.5 0 0 1 .708-.707l.707.707a.5.5 0 0 1 0 .707zm-9.9-9.9a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l.708.707a.5.5 0 0 1 0 .707z"/></svg>
60 + <svg class="icon-moon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278z"/></svg>
61 + </button>
62 + <a class="topnav-ext" href="https://spboucher.ai" rel="noopener">spboucher.ai</a>
63 + </nav>
64 + </div>
65 + </header>
66 + <main id="main" class="container">
67 + {% block content %}{% endblock %}
68 + </main>
69 + <footer class="footer">
70 + <div class="container">
71 + © {{ owner.name }} · <a href="mailto:{{ owner.email }}">{{ owner.email }}</a> · Powered by SPB Git
72 + </div>
73 + </footer>
74 + <script src="/assets/js/app.js" defer></script>
75 +</body>
76 +</html>
added src/web/views/partials/file-table.njk +40 −0
@@ -0,0 +1,40 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/partials/file-table.njk
8 + Purpose : Tree file table — icon, name, last commit, relative date
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}<table class="file-table">
12 + <thead class="sr-only"><tr><th>Name</th><th>Last commit</th><th>Updated</th></tr></thead>
13 + <tbody>
14 + {% if path %}
15 + <tr class="file-row">
16 + <td class="file-name" colspan="3">
17 + <a href="{{ ('/' + overview.name + '/tree/' + currentRef + '/' + parentPath) if parentPath else ('/' + overview.name) }}" rel="nofollow">…</a>
18 + </td>
19 + </tr>
20 + {% endif %}
21 + {% for entry in entries %}
22 + <tr class="file-row">
23 + <td class="file-name">
24 + {% if entry.type == 'tree' %}
25 + <svg class="file-icon icon-dir" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 1z"/></svg>
26 + <a href="/{{ overview.name }}/tree/{{ currentRef }}/{{ entry.path }}">{{ entry.name }}</a>
27 + {% else %}
28 + <svg class="file-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/></svg>
29 + <a href="/{{ overview.name }}/blob/{{ currentRef }}/{{ entry.path }}">{{ entry.name }}</a>
30 + {% endif %}
31 + </td>
32 + <td class="file-commit">
33 + {% set lc = lastCommits[entry.name] %}
34 + {% if lc %}<a href="/{{ overview.name }}/commit/{{ lc.sha }}" class="muted">{{ lc.subject | truncate(70) }}</a>{% endif %}
35 + </td>
36 + <td class="file-date muted">{% if lc %}{{ lc.date | reltime }}{% endif %}</td>
37 + </tr>
38 + {% endfor %}
39 + </tbody>
40 +</table>
added src/web/views/partials/repo-card.njk +33 −0
@@ -0,0 +1,33 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/partials/repo-card.njk
8 + Purpose : Repo card used on home (pinned grid + list)
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}<article class="repo-card" data-name="{{ repo.name }}"
12 + data-description="{{ repo.description }}"
13 + data-language="{{ repo.topLanguage or '' }}"
14 + data-topics="{{ repo.topics | join(',') }}"
15 + data-pushed="{{ repo.lastPush or '' }}"
16 + data-created="{{ repo.created or '' }}">
17 + <h3 class="repo-card-name"><a href="/{{ repo.name }}">{{ repo.name }}</a>
18 + {% if repo.pinned %}<span class="chip chip-pin" title="Pinned">★</span>{% endif %}
19 + </h3>
20 + {% if repo.description %}<p class="repo-card-desc">{{ repo.description }}</p>{% endif %}
21 + {% if repo.topics.length %}
22 + <div class="chip-row">
23 + {% for topic in repo.topics %}<a class="chip chip-topic" href="/search?q={{ topic }}">{{ topic }}</a>{% endfor %}
24 + </div>
25 + {% endif %}
26 + <div class="repo-card-meta">
27 + {% if repo.topLanguage %}
28 + <span class="lang-dot-label"><span class="lang-dot" style="background:{{ repo.topLanguage | langcolor }}"></span>{{ repo.topLanguage }}</span>
29 + {% endif %}
30 + {% if repo.cloneCount %}<span title="Clone / fetch count">⇩ {{ repo.cloneCount | num }}</span>{% endif %}
31 + {% if repo.lastPush %}<span>pushed {{ repo.lastPush | reltime }}</span>{% else %}<span class="muted">empty</span>{% endif %}
32 + </div>
33 +</article>
added src/web/views/partials/repo-header.njk +47 −0
@@ -0,0 +1,47 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/partials/repo-header.njk
8 + Purpose : Repo page header — breadcrumb, meta, language bar, tabs
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}<header class="repo-header">
12 + <h1 class="repo-title">
13 + <a href="/" class="repo-owner">spb</a><span class="repo-sep">/</span><a href="/{{ overview.name }}" class="repo-name">{{ overview.name }}</a>
14 + <span class="chip chip-public">Public</span>
15 + {% if overview.license %}<span class="chip chip-license" title="License detected from {{ overview.licensePath }}">{{ overview.license }}</span>{% endif %}
16 + </h1>
17 + {% if overview.description %}<p class="repo-desc">{{ overview.description }}</p>{% endif %}
18 + <div class="repo-meta-row">
19 + {% if overview.homepage %}<a class="repo-homepage" href="{{ overview.homepage }}" rel="noopener noreferrer">🔗 {{ overview.homepage }}</a>{% endif %}
20 + {% if overview.topics.length %}
21 + <div class="chip-row">
22 + {% for topic in overview.topics %}<a class="chip chip-topic" href="/search?q={{ topic }}">{{ topic }}</a>{% endfor %}
23 + </div>
24 + {% endif %}
25 + </div>
26 + {% if overview.languages.length %}
27 + <div class="lang-bar" role="img" aria-label="Language composition">
28 + {% for lang in overview.languages %}
29 + <span class="lang-bar-seg" style="width:{{ lang.percent }}%;background:{{ lang.color }}" title="{{ lang.name }} {{ lang.percent }}%"></span>
30 + {% endfor %}
31 + </div>
32 + <div class="lang-legend">
33 + {% for lang in overview.languages %}
34 + {% if lang.percent >= 0.5 %}
35 + <span class="lang-dot-label"><span class="lang-dot" style="background:{{ lang.color }}"></span>{{ lang.name }} <span class="muted">{{ lang.percent }}%</span></span>
36 + {% endif %}
37 + {% endfor %}
38 + </div>
39 + {% endif %}
40 + <nav class="repo-tabs" aria-label="Repository sections">
41 + <a class="repo-tab {{ 'active' if tab == 'code' }}" href="/{{ overview.name }}">Code</a>
42 + <a class="repo-tab {{ 'active' if tab == 'commits' }}" href="/{{ overview.name }}/commits/{{ currentRef }}">Commits <span class="count">{{ overview.commitCount | num }}</span></a>
43 + <a class="repo-tab {{ 'active' if tab == 'branches' }}" href="/{{ overview.name }}/branches">Branches <span class="count">{{ overview.branchCount }}</span></a>
44 + <a class="repo-tab {{ 'active' if tab == 'tags' }}" href="/{{ overview.name }}/tags">Tags <span class="count">{{ overview.tagCount }}</span></a>
45 + <span class="repo-tab-meta muted">{{ overview.sizeBytes | bytes }}</span>
46 + </nav>
47 +</header>
added src/web/views/repo.njk +57 −0
@@ -0,0 +1,57 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/repo.njk
8 + Purpose : Repo home — clone box, file table, rendered README
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +{% if overview.empty %}
16 +<div class="empty-repo">
17 + <h2>This repository is empty</h2>
18 + <p>Push something to bring it to life:</p>
19 + <pre class="clone-snippet">git remote add origin {{ overview.cloneUrl }}
20 +git push -u origin {{ overview.defaultBranch }}</pre>
21 + <p class="muted">or, from anywhere: <code>spbgit clone {{ overview.name }}</code></p>
22 +</div>
23 +{% else %}
24 +
25 +<div class="repo-toolbar">
26 + <div class="ref-select-wrap">
27 + <label class="sr-only" for="ref-select">Branch</label>
28 + <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="tree">
29 + {% for branch in branches %}
30 + <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option>
31 + {% endfor %}
32 + {% for tag in tags %}
33 + <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option>
34 + {% endfor %}
35 + </select>
36 + </div>
37 + <div class="clone-box">
38 + <input class="clone-url" type="text" readonly value="{{ overview.cloneUrl }}" aria-label="Clone URL">
39 + <button class="btn copy-btn" type="button" data-copy="{{ overview.cloneUrl }}" title="Copy clone URL">Copy</button>
40 + <button class="btn copy-btn" type="button" data-copy="spbgit clone {{ overview.name }}" title="Copy spbgit command">spbgit</button>
41 + <a class="btn" href="/archive/{{ overview.name }}/{{ currentRef }}.zip" rel="nofollow">ZIP</a>
42 + </div>
43 +</div>
44 +
45 +{% include "partials/file-table.njk" %}
46 +
47 +{% if readme %}
48 +<section class="readme-section" aria-label="README">
49 + <div class="readme-header">
50 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574M8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25"/></svg>
51 + <code>{{ readme.path }}</code>
52 + </div>
53 + <article class="markdown-body">{{ readme.html | safe }}</article>
54 +</section>
55 +{% endif %}
56 +{% endif %}
57 +{% endblock %}
added src/web/views/search.njk +56 −0
@@ -0,0 +1,56 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/search.njk
8 + Purpose : Global search — grouped repo + README results
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +<h1 class="section-title">Search</h1>
14 +<form action="/search" method="get" class="search-page-form" role="search">
15 + <input type="search" name="q" value="{{ q }}" placeholder="Search repositories, topics, READMEs…" aria-label="Search query" autofocus>
16 + <button class="btn btn-primary" type="submit">Search</button>
17 +</form>
18 +
19 +{% if q %}
20 +<section aria-labelledby="repo-results-h">
21 + <h2 id="repo-results-h" class="section-title">Repositories <span class="count">{{ results.repos.length }}</span></h2>
22 + {% if results.repos.length %}
23 + <ul class="search-results">
24 + {% for hit in results.repos %}
25 + <li>
26 + <a class="search-hit-name" href="/{{ hit.name }}">{{ hit.name }}</a>
27 + {% if hit.topLanguage %}<span class="lang-dot-label"><span class="lang-dot" style="background:{{ hit.topLanguage | langcolor }}"></span>{{ hit.topLanguage }}</span>{% endif %}
28 + {% if hit.description %}<p class="muted">{{ hit.description }}</p>{% endif %}
29 + {% if hit.topics.length %}
30 + <div class="chip-row">{% for topic in hit.topics %}<span class="chip chip-topic">{{ topic }}</span>{% endfor %}</div>
31 + {% endif %}
32 + </li>
33 + {% endfor %}
34 + </ul>
35 + {% else %}
36 + <p class="muted">No repository matches “{{ q }}”.</p>
37 + {% endif %}
38 +</section>
39 +
40 +<section aria-labelledby="readme-results-h">
41 + <h2 id="readme-results-h" class="section-title">README content <span class="count">{{ results.readmes.length }}</span></h2>
42 + {% if results.readmes.length %}
43 + <ul class="search-results">
44 + {% for hit in results.readmes %}
45 + <li>
46 + <a class="search-hit-name" href="/{{ hit.name }}">{{ hit.name }}</a>
47 + <p class="search-snippet">{{ hit.snippet }}</p>
48 + </li>
49 + {% endfor %}
50 + </ul>
51 + {% else %}
52 + <p class="muted">No README mentions “{{ q }}”.</p>
53 + {% endif %}
54 +</section>
55 +{% endif %}
56 +{% endblock %}
added src/web/views/tags.njk +33 −0
@@ -0,0 +1,33 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/tags.njk
8 + Purpose : Tags list — releases-lite with archive downloads
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<table class="ref-table">
16 + <thead><tr><th>Tag</th><th>Message</th><th>Created</th><th>Download</th></tr></thead>
17 + <tbody>
18 + {% for tag in tags %}
19 + <tr>
20 + <td><a class="ref-name" href="/{{ overview.name }}/tree/{{ tag.name }}">{{ tag.name }}</a></td>
21 + <td class="muted">{{ tag.subject | truncate(60) }}</td>
22 + <td class="muted">{{ tag.date | reltime }}</td>
23 + <td class="archive-links">
24 + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.zip" rel="nofollow">zip</a>
25 + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.tar.gz" rel="nofollow">tar.gz</a>
26 + </td>
27 + </tr>
28 + {% else %}
29 + <tr><td colspan="4" class="muted">No tags yet. Tag a release with <code>git tag v1.0.0 && git push --tags</code>.</td></tr>
30 + {% endfor %}
31 + </tbody>
32 +</table>
33 +{% endblock %}
added src/web/views/tree.njk +36 −0
@@ -0,0 +1,36 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/tree.njk
8 + Purpose : Directory browsing — breadcrumb + file table
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}{% extends "layout.njk" %}
12 +{% block content %}
13 +{% include "partials/repo-header.njk" %}
14 +
15 +<div class="repo-toolbar">
16 + <div class="ref-select-wrap">
17 + <label class="sr-only" for="ref-select">Branch</label>
18 + <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="tree" data-path="{{ path }}">
19 + {% for branch in branches %}
20 + <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option>
21 + {% endfor %}
22 + {% for tag in tags %}
23 + <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option>
24 + {% endfor %}
25 + </select>
26 + </div>
27 + <nav class="path-breadcrumb" aria-label="Path">
28 + <a href="/{{ overview.name }}">{{ overview.name }}</a>
29 + {% for crumb in crumbs %}
30 + <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %}
31 + {% endfor %}
32 + </nav>
33 +</div>
34 +
35 +{% include "partials/file-table.njk" %}
36 +{% endblock %}
37