feat: web UI — design system, all pages, GitHub-grade README pipeline, OG cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | ||