feat: web UI — design system, all pages, GitHub-grade README pipeline, OG cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
28 changed files +2,985 −0
added
src/render/highlight.mjs
+180 −0
@@ -0,0 +1,180 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/render/highlight.mjs | |
| 8 | + * Purpose : Shiki syntax highlighting — dual theme, line anchors | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { createHighlighter, bundledLanguages } from 'shiki'; | |
| 14 | +import { escapeHtml } from '../lib/util.mjs'; | |
| 15 | + | |
| 16 | +const THEMES = { light: 'github-light', dark: 'github-dark' }; | |
| 17 | + | |
| 18 | +/** Languages loaded eagerly at boot — everything else lazy-loads. */ | |
| 19 | +const COMMON_LANGS = [ | |
| 20 | + 'javascript', 'typescript', 'jsx', 'tsx', 'python', 'go', 'rust', 'c', 'cpp', | |
| 21 | + 'java', 'ruby', 'php', 'shellscript', 'sql', 'json', 'jsonc', 'yaml', 'toml', 'html', | |
| 22 | + 'css', 'scss', 'markdown', 'diff', 'docker', 'make', 'xml', 'ini', 'lua', 'swift', | |
| 23 | + 'kotlin', 'r', 'julia', | |
| 24 | +]; | |
| 25 | + | |
| 26 | +/** File extension → shiki language id (for the blob view). */ | |
| 27 | +const EXT_TO_LANG = { | |
| 28 | + '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'jsx', | |
| 29 | + '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'tsx', | |
| 30 | + '.py': 'python', '.pyw': 'python', '.pyi': 'python', | |
| 31 | + '.go': 'go', '.rs': 'rust', | |
| 32 | + '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp', | |
| 33 | + '.cs': 'csharp', '.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin', '.swift': 'swift', | |
| 34 | + '.m': 'objective-c', '.mm': 'objective-cpp', | |
| 35 | + '.rb': 'ruby', '.rake': 'ruby', '.gemspec': 'ruby', '.php': 'php', | |
| 36 | + '.sh': 'shellscript', '.bash': 'shellscript', '.zsh': 'shellscript', '.fish': 'fish', | |
| 37 | + '.ps1': 'powershell', '.pl': 'perl', '.pm': 'perl', '.lua': 'lua', | |
| 38 | + '.r': 'r', '.jl': 'julia', '.scala': 'scala', '.hs': 'haskell', | |
| 39 | + '.ex': 'elixir', '.exs': 'elixir', '.erl': 'erlang', '.clj': 'clojure', '.cljs': 'clojure', | |
| 40 | + '.dart': 'dart', '.zig': 'zig', '.nim': 'nim', '.ml': 'ocaml', '.mli': 'ocaml', | |
| 41 | + '.fs': 'fsharp', '.fsx': 'fsharp', '.sol': 'solidity', '.cu': 'cuda-cpp', | |
| 42 | + '.vue': 'vue', '.svelte': 'svelte', '.astro': 'astro', | |
| 43 | + '.html': 'html', '.htm': 'html', '.xhtml': 'html', '.xml': 'xml', '.svg': 'xml', '.plist': 'xml', | |
| 44 | + '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less', | |
| 45 | + '.json': 'json', '.jsonc': 'jsonc', '.ipynb': 'json', | |
| 46 | + '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml', '.ini': 'ini', '.cfg': 'ini', '.conf': 'ini', | |
| 47 | + '.sql': 'sql', '.graphql': 'graphql', '.gql': 'graphql', '.proto': 'proto', | |
| 48 | + '.md': 'markdown', '.markdown': 'markdown', '.mdx': 'mdx', '.rst': 'rst', | |
| 49 | + '.tex': 'latex', '.sty': 'latex', '.bib': 'bibtex', | |
| 50 | + '.dockerfile': 'docker', '.mk': 'make', '.cmake': 'cmake', | |
| 51 | + '.nix': 'nix', '.tf': 'terraform', '.tfvars': 'terraform', | |
| 52 | + '.groovy': 'groovy', '.gradle': 'groovy', '.vim': 'viml', '.njk': 'jinja', | |
| 53 | + '.ejs': 'html', '.hbs': 'handlebars', '.env': 'dotenv', '.diff': 'diff', '.patch': 'diff', | |
| 54 | + '.txt': 'text', '.log': 'text', '.csv': 'csv', '.service': 'ini', | |
| 55 | +}; | |
| 56 | + | |
| 57 | +const FILENAME_TO_LANG = { | |
| 58 | + Dockerfile: 'docker', Containerfile: 'docker', Makefile: 'make', GNUmakefile: 'make', | |
| 59 | + makefile: 'make', 'CMakeLists.txt': 'cmake', '.gitignore': 'text', '.gitattributes': 'text', | |
| 60 | + '.vimrc': 'viml', '.env': 'dotenv', '.env.example': 'dotenv', LICENSE: 'text', | |
| 61 | +}; | |
| 62 | + | |
| 63 | +let highlighter = null; | |
| 64 | + | |
| 65 | +/** Boot-time initialization — must run before any highlight call. */ | |
| 66 | +export async function initHighlighter() { | |
| 67 | + if (highlighter) return highlighter; | |
| 68 | + highlighter = await createHighlighter({ | |
| 69 | + themes: Object.values(THEMES), | |
| 70 | + langs: COMMON_LANGS, | |
| 71 | + }); | |
| 72 | + return highlighter; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * Normalize a user-supplied fence language to a loadable shiki id. | |
| 77 | + * @param {string} lang | |
| 78 | + * @returns {string|null} shiki id, or null when unknown | |
| 79 | + */ | |
| 80 | +export function resolveLang(lang) { | |
| 81 | + if (!lang) return null; | |
| 82 | + const id = String(lang).trim().toLowerCase(); | |
| 83 | + const aliases = { | |
| 84 | + sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript', shell: 'shellscript', | |
| 85 | + console: 'shellscript', 'c++': 'cpp', 'c#': 'csharp', yml: 'yaml', dockerfile: 'docker', | |
| 86 | + makefile: 'make', js: 'javascript', ts: 'typescript', golang: 'go', rb: 'ruby', | |
| 87 | + py: 'python', kt: 'kotlin', plaintext: 'text', text: 'text', txt: 'text', | |
| 88 | + }; | |
| 89 | + const resolved = aliases[id] ?? id; | |
| 90 | + if (resolved === 'text') return 'text'; | |
| 91 | + return resolved in bundledLanguages ? resolved : null; | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** | |
| 95 | + * Ensure a set of shiki languages are loaded (lazy, cached in the singleton). | |
| 96 | + * @param {string[]} langs shiki ids | |
| 97 | + */ | |
| 98 | +export async function ensureLangs(langs) { | |
| 99 | + if (!highlighter) await initHighlighter(); | |
| 100 | + const loaded = new Set(highlighter.getLoadedLanguages()); | |
| 101 | + for (const lang of langs) { | |
| 102 | + if (!lang || lang === 'text' || loaded.has(lang)) continue; | |
| 103 | + if (lang in bundledLanguages) { | |
| 104 | + try { | |
| 105 | + await highlighter.loadLanguage(lang); | |
| 106 | + } catch { | |
| 107 | + /* fall back to plain rendering */ | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** | |
| 114 | + * Synchronously highlight code (language must already be loaded). | |
| 115 | + * @param {string} code | |
| 116 | + * @param {string|null} lang shiki id | |
| 117 | + * @returns {string} `<pre class="shiki">…` markup (plain fallback when needed) | |
| 118 | + */ | |
| 119 | +export function highlightSync(code, lang) { | |
| 120 | + const plain = () => | |
| 121 | + `<pre class="shiki shiki-plain"><code>${code | |
| 122 | + .split('\n') | |
| 123 | + .map((l) => `<span class="line">${escapeHtml(l)}</span>`) | |
| 124 | + .join('\n')}</code></pre>`; | |
| 125 | + if (!highlighter || !lang || lang === 'text') return plain(); | |
| 126 | + if (!highlighter.getLoadedLanguages().includes(lang)) return plain(); | |
| 127 | + try { | |
| 128 | + return highlighter.codeToHtml(code, { | |
| 129 | + lang, | |
| 130 | + themes: THEMES, | |
| 131 | + defaultColor: false, | |
| 132 | + }); | |
| 133 | + } catch { | |
| 134 | + return plain(); | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** | |
| 139 | + * Async highlight — loads the language on demand first. | |
| 140 | + * @param {string} code | |
| 141 | + * @param {string|null} langInput raw language hint (fence info / extension) | |
| 142 | + */ | |
| 143 | +export async function highlight(code, langInput) { | |
| 144 | + const lang = resolveLang(langInput); | |
| 145 | + if (lang) await ensureLangs([lang]); | |
| 146 | + return highlightSync(code, lang); | |
| 147 | +} | |
| 148 | + | |
| 149 | +/** | |
| 150 | + * Highlight a full file for the blob view — adds line ids + anchor links. | |
| 151 | + * @param {string} code file content | |
| 152 | + * @param {string} path file path (extension-based detection) | |
| 153 | + * @returns {Promise<{html: string, lines: number, lang: string|null}>} | |
| 154 | + */ | |
| 155 | +export async function highlightFile(code, path) { | |
| 156 | + const base = path.split('/').pop() ?? path; | |
| 157 | + const dot = base.lastIndexOf('.'); | |
| 158 | + const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''; | |
| 159 | + const lang = FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null; | |
| 160 | + const html = await highlight(code, lang); | |
| 161 | + let line = 0; | |
| 162 | + const withAnchors = html.replaceAll('<span class="line">', () => { | |
| 163 | + line += 1; | |
| 164 | + return `<span class="line" id="L${line}"><a class="ln" href="#L${line}" aria-label="Line ${line}">${line}</a>`; | |
| 165 | + }); | |
| 166 | + const lines = code === '' ? 0 : code.split('\n').length; | |
| 167 | + return { html: withAnchors, lines, lang }; | |
| 168 | +} | |
| 169 | + | |
| 170 | +/** | |
| 171 | + * Language hint for a path (used by the blob header badge). | |
| 172 | + * @param {string} path | |
| 173 | + * @returns {string|null} | |
| 174 | + */ | |
| 175 | +export function langForPath(path) { | |
| 176 | + const base = path.split('/').pop() ?? path; | |
| 177 | + const dot = base.lastIndexOf('.'); | |
| 178 | + const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''; | |
| 179 | + return FILENAME_TO_LANG[base] ?? EXT_TO_LANG[ext] ?? null; | |
| 180 | +} | |
added
src/render/markdown.mjs
+201 −0
@@ -0,0 +1,201 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/render/markdown.mjs | |
| 8 | + * Purpose : GitHub-grade Markdown pipeline — GFM, badges, mermaid, sanitized | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import MarkdownIt from 'markdown-it'; | |
| 14 | +import anchor from 'markdown-it-anchor'; | |
| 15 | +import taskLists from 'markdown-it-task-lists'; | |
| 16 | +import footnote from 'markdown-it-footnote'; | |
| 17 | +import { full as emoji } from 'markdown-it-emoji'; | |
| 18 | +import sanitizeHtml from 'sanitize-html'; | |
| 19 | +import { posix } from 'node:path'; | |
| 20 | +import { escapeHtml } from '../lib/util.mjs'; | |
| 21 | +import { highlightSync, resolveLang, ensureLangs } from './highlight.mjs'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * GitHub's heading slug algorithm (lowercase, strip punctuation, dashes). | |
| 25 | + * @param {string} text | |
| 26 | + * @returns {string} | |
| 27 | + */ | |
| 28 | +export function githubSlug(text) { | |
| 29 | + return String(text) | |
| 30 | + .trim() | |
| 31 | + .toLowerCase() | |
| 32 | + .replace(/<[^>]+>/g, '') | |
| 33 | + .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '') | |
| 34 | + .replace(/\s/g, '-'); | |
| 35 | +} | |
| 36 | + | |
| 37 | +const FENCE_PLACEHOLDER_ATTR = 'data-spbgit-fence'; | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * Build the markdown-it instance. Fences render as placeholders and are | |
| 41 | + * swapped back in after sanitization so shiki markup survives untouched. | |
| 42 | + * @param {{fences: string[]}} state collector for rendered fence HTML | |
| 43 | + */ | |
| 44 | +function buildParser(state) { | |
| 45 | + const md = new MarkdownIt({ | |
| 46 | + html: true, | |
| 47 | + linkify: true, | |
| 48 | + breaks: false, | |
| 49 | + }); | |
| 50 | + md.use(anchor, { | |
| 51 | + slugify: githubSlug, | |
| 52 | + permalink: anchor.permalink.linkInsideHeader({ | |
| 53 | + symbol: '<span class="anchor-icon" aria-hidden="true">#</span>', | |
| 54 | + placement: 'before', | |
| 55 | + class: 'heading-anchor', | |
| 56 | + ariaHidden: true, | |
| 57 | + }), | |
| 58 | + }); | |
| 59 | + md.use(taskLists, { enabled: false, label: true }); | |
| 60 | + md.use(footnote); | |
| 61 | + md.use(emoji); | |
| 62 | + | |
| 63 | + md.renderer.rules.fence = (tokens, idx) => { | |
| 64 | + const token = tokens[idx]; | |
| 65 | + const info = (token.info ?? '').trim().split(/\s+/)[0]; | |
| 66 | + let html; | |
| 67 | + if (info === 'mermaid') { | |
| 68 | + html = `<div class="mermaid-block"><pre class="mermaid-src">${escapeHtml(token.content)}</pre><div class="mermaid-target" aria-live="polite"></div></div>`; | |
| 69 | + } else { | |
| 70 | + const lang = resolveLang(info); | |
| 71 | + const highlighted = highlightSync(token.content.replace(/\n$/, ''), lang); | |
| 72 | + html = `<div class="code-block"${info ? ` data-lang="${escapeHtml(info)}"` : ''}>${highlighted}</div>`; | |
| 73 | + } | |
| 74 | + const index = state.fences.push(html) - 1; | |
| 75 | + return `<pre ${FENCE_PLACEHOLDER_ATTR}="${index}"></pre>`; | |
| 76 | + }; | |
| 77 | + return md; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * Resolve a relative README/blob URL against the repo raw/blob endpoints. | |
| 82 | + * @param {string} url as written in the document | |
| 83 | + * @param {{repo: string, ref: string, basePath: string}} ctx basePath = dir of the rendered file | |
| 84 | + * @param {'raw'|'blob'} mode images go to raw, links go to blob | |
| 85 | + * @returns {string} | |
| 86 | + */ | |
| 87 | +export function resolveRelativeUrl(url, ctx, mode) { | |
| 88 | + if (!url) return url; | |
| 89 | + const trimmed = url.trim(); | |
| 90 | + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(trimmed)) return trimmed; | |
| 91 | + const [pathPart, suffix = ''] = splitUrlSuffix(trimmed); | |
| 92 | + const joined = posix.normalize(posix.join(ctx.basePath || '.', pathPart)); | |
| 93 | + if (joined.startsWith('..')) return trimmed; | |
| 94 | + const clean = joined.replace(/^\.\//, '').replace(/^\//, ''); | |
| 95 | + const encoded = clean.split('/').map(encodeURIComponent).join('/'); | |
| 96 | + const refEnc = ctx.ref.split('/').map(encodeURIComponent).join('/'); | |
| 97 | + return mode === 'raw' | |
| 98 | + ? `/raw/${encodeURIComponent(ctx.repo)}/${refEnc}/${encoded}${suffix}` | |
| 99 | + : `/${encodeURIComponent(ctx.repo)}/blob/${refEnc}/${encoded}${suffix}`; | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** Split `path?query#hash` into [path, suffix]. */ | |
| 103 | +function splitUrlSuffix(url) { | |
| 104 | + const m = /^([^?#]*)([?#].*)?$/.exec(url); | |
| 105 | + return [m[1], m[2] ?? '']; | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** Sanitizer allowlist — badges, details, kbd, align HTML all survive. */ | |
| 109 | +function sanitizeOptions(ctx) { | |
| 110 | + return { | |
| 111 | + allowedTags: [ | |
| 112 | + 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'dd', 'del', | |
| 113 | + 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', | |
| 114 | + 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'mark', 'ol', | |
| 115 | + 'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small', | |
| 116 | + 'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', | |
| 117 | + 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', | |
| 118 | + ], | |
| 119 | + allowedAttributes: { | |
| 120 | + '*': ['align', 'id', 'class', 'dir', 'lang'], | |
| 121 | + a: ['href', 'title', 'rel', 'name'], | |
| 122 | + img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'], | |
| 123 | + source: ['src', 'srcset', 'type', 'media'], | |
| 124 | + input: ['type', 'checked', 'disabled'], | |
| 125 | + td: ['colspan', 'rowspan', 'align', 'valign'], | |
| 126 | + th: ['colspan', 'rowspan', 'align', 'valign', 'scope'], | |
| 127 | + details: ['open'], | |
| 128 | + pre: [FENCE_PLACEHOLDER_ATTR], | |
| 129 | + div: ['data-lang'], | |
| 130 | + li: ['value'], | |
| 131 | + ol: ['start', 'type'], | |
| 132 | + abbr: ['title'], | |
| 133 | + }, | |
| 134 | + allowedSchemes: ['http', 'https', 'mailto'], | |
| 135 | + allowedSchemesByTag: { img: ['http', 'https', 'data'] }, | |
| 136 | + allowProtocolRelative: false, | |
| 137 | + disallowedTagsMode: 'discard', | |
| 138 | + transformTags: { | |
| 139 | + img: (tagName, attribs) => ({ | |
| 140 | + tagName, | |
| 141 | + attribs: { | |
| 142 | + ...attribs, | |
| 143 | + src: resolveRelativeUrl(attribs.src ?? '', ctx, 'raw'), | |
| 144 | + loading: 'lazy', | |
| 145 | + }, | |
| 146 | + }), | |
| 147 | + a: (tagName, attribs) => { | |
| 148 | + const href = resolveRelativeUrl(attribs.href ?? '', ctx, 'blob'); | |
| 149 | + const external = /^https?:\/\//i.test(href) && !href.startsWith(ctx.publicUrl); | |
| 150 | + return { | |
| 151 | + tagName, | |
| 152 | + attribs: { | |
| 153 | + ...attribs, | |
| 154 | + href, | |
| 155 | + ...(external ? { rel: 'noopener noreferrer' } : {}), | |
| 156 | + }, | |
| 157 | + }; | |
| 158 | + }, | |
| 159 | + input: (tagName, attribs) => { | |
| 160 | + if ((attribs.type ?? '').toLowerCase() !== 'checkbox') { | |
| 161 | + return { tagName: 'span', attribs: {} }; | |
| 162 | + } | |
| 163 | + return { tagName, attribs: { ...attribs, disabled: 'disabled' } }; | |
| 164 | + }, | |
| 165 | + }, | |
| 166 | + }; | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** | |
| 170 | + * Render a Markdown document to sanitized, GitHub-grade HTML. | |
| 171 | + * @param {string} source | |
| 172 | + * @param {{repo: string, ref: string, basePath?: string, publicUrl?: string}} ctx | |
| 173 | + * @returns {Promise<string>} HTML for insertion inside `<article class="markdown-body">` | |
| 174 | + */ | |
| 175 | +export async function renderMarkdown(source, ctx) { | |
| 176 | + const fullCtx = { basePath: '.', publicUrl: '', ...ctx }; | |
| 177 | + // Preload every fence language so fence rendering can stay synchronous. | |
| 178 | + const fenceLangs = [...source.matchAll(/^\s{0,3}(?:```+|~~~+)\s*([\w#+.-]+)/gm)] | |
| 179 | + .map((m) => resolveLang(m[1])) | |
| 180 | + .filter(Boolean); | |
| 181 | + await ensureLangs(fenceLangs); | |
| 182 | + | |
| 183 | + const state = { fences: [] }; | |
| 184 | + const md = buildParser(state); | |
| 185 | + const rawHtml = md.render(source); | |
| 186 | + let clean = sanitizeHtml(rawHtml, sanitizeOptions(fullCtx)); | |
| 187 | + clean = clean.replace( | |
| 188 | + new RegExp(`<pre ${FENCE_PLACEHOLDER_ATTR}="(\\d+)"></pre>`, 'g'), | |
| 189 | + (_m, index) => state.fences[Number(index)] ?? '', | |
| 190 | + ); | |
| 191 | + return clean; | |
| 192 | +} | |
| 193 | + | |
| 194 | +/** | |
| 195 | + * Plain-text fallback rendering (README.txt / readme.rst). | |
| 196 | + * @param {string} source | |
| 197 | + * @returns {string} | |
| 198 | + */ | |
| 199 | +export function renderPlain(source) { | |
| 200 | + return `<pre class="plain-readme">${escapeHtml(source)}</pre>`; | |
| 201 | +} | |
added
src/render/og-image.mjs
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/render/og-image.mjs | |
| 8 | + * Purpose : Dynamic Open Graph cards per repo (satori → resvg → PNG) | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { readFileSync, existsSync } from 'node:fs'; | |
| 14 | +import { join } from 'node:path'; | |
| 15 | +import satori from 'satori'; | |
| 16 | +import { Resvg } from '@resvg/resvg-js'; | |
| 17 | +import { PROJECT_ROOT, OWNER } from '../config.mjs'; | |
| 18 | + | |
| 19 | +const FONT_DIR = join(PROJECT_ROOT, 'src/web/assets/fonts/og'); | |
| 20 | +const WIDTH = 1200; | |
| 21 | +const HEIGHT = 630; | |
| 22 | + | |
| 23 | +const PALETTE = { | |
| 24 | + bg: '#0b0e14', | |
| 25 | + surface: '#11151c', | |
| 26 | + border: '#1f2530', | |
| 27 | + text: '#e6e9ef', | |
| 28 | + muted: '#8b93a3', | |
| 29 | + accent: '#4f8cff', | |
| 30 | + accent2: '#22d3aa', | |
| 31 | +}; | |
| 32 | + | |
| 33 | +let fontsCache = null; | |
| 34 | + | |
| 35 | +/** @returns {Array<{name: string, data: Buffer, weight: number, style: 'normal'}>|null} */ | |
| 36 | +function loadFonts() { | |
| 37 | + if (fontsCache) return fontsCache; | |
| 38 | + const specs = [ | |
| 39 | + ['inter-400.woff', 'Inter', 400], | |
| 40 | + ['inter-700.woff', 'Inter', 700], | |
| 41 | + ['jetbrains-mono-400.woff', 'JetBrains Mono', 400], | |
| 42 | + ]; | |
| 43 | + const fonts = []; | |
| 44 | + for (const [file, name, weight] of specs) { | |
| 45 | + const path = join(FONT_DIR, file); | |
| 46 | + if (!existsSync(path)) return null; | |
| 47 | + fonts.push({ name, data: readFileSync(path), weight, style: 'normal' }); | |
| 48 | + } | |
| 49 | + fontsCache = fonts; | |
| 50 | + return fonts; | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Shorthand for satori element objects. */ | |
| 54 | +function h(type, style, ...children) { | |
| 55 | + const props = { style }; | |
| 56 | + if (children.length > 0) props.children = children.length === 1 ? children[0] : children; | |
| 57 | + return { type, props }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * Compose the repo card element tree. | |
| 62 | + * @param {{name: string, description: string, languages: Array<{name: string, percent: number, color: string}>}} data | |
| 63 | + */ | |
| 64 | +function repoCardTree(data) { | |
| 65 | + const description = data.description || 'A repository by Simon-Pierre Boucher'; | |
| 66 | + const langs = (data.languages ?? []).slice(0, 5); | |
| 67 | + const bar = h( | |
| 68 | + 'div', | |
| 69 | + { display: 'flex', width: '100%', height: 14, borderRadius: 7, overflow: 'hidden', backgroundColor: PALETTE.border }, | |
| 70 | + ...(langs.length > 0 | |
| 71 | + ? langs.map((l) => h('div', { width: `${Math.max(2, l.percent)}%`, height: '100%', backgroundColor: l.color })) | |
| 72 | + : [h('div', { width: '100%', height: '100%', backgroundColor: PALETTE.border })]), | |
| 73 | + ); | |
| 74 | + const legend = h( | |
| 75 | + 'div', | |
| 76 | + { display: 'flex', gap: 24, marginTop: 18, fontSize: 22, color: PALETTE.muted, fontFamily: 'JetBrains Mono' }, | |
| 77 | + ...langs.map((l) => | |
| 78 | + h( | |
| 79 | + 'div', | |
| 80 | + { display: 'flex', alignItems: 'center', gap: 8 }, | |
| 81 | + h('div', { width: 14, height: 14, borderRadius: 7, backgroundColor: l.color }), | |
| 82 | + h('div', {}, `${l.name} ${l.percent}%`), | |
| 83 | + ), | |
| 84 | + ), | |
| 85 | + ); | |
| 86 | + return h( | |
| 87 | + 'div', | |
| 88 | + { | |
| 89 | + width: '100%', | |
| 90 | + height: '100%', | |
| 91 | + display: 'flex', | |
| 92 | + flexDirection: 'column', | |
| 93 | + justifyContent: 'space-between', | |
| 94 | + backgroundColor: PALETTE.bg, | |
| 95 | + padding: 64, | |
| 96 | + fontFamily: 'Inter', | |
| 97 | + borderBottom: `14px solid ${PALETTE.accent}`, | |
| 98 | + }, | |
| 99 | + h( | |
| 100 | + 'div', | |
| 101 | + { display: 'flex', alignItems: 'center', gap: 20 }, | |
| 102 | + h( | |
| 103 | + 'div', | |
| 104 | + { | |
| 105 | + width: 64, | |
| 106 | + height: 64, | |
| 107 | + borderRadius: 16, | |
| 108 | + backgroundColor: PALETTE.accent, | |
| 109 | + color: '#ffffff', | |
| 110 | + display: 'flex', | |
| 111 | + alignItems: 'center', | |
| 112 | + justifyContent: 'center', | |
| 113 | + fontSize: 28, | |
| 114 | + fontWeight: 700, | |
| 115 | + }, | |
| 116 | + 'SPB', | |
| 117 | + ), | |
| 118 | + h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'), | |
| 119 | + h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'), | |
| 120 | + ), | |
| 121 | + h( | |
| 122 | + 'div', | |
| 123 | + { display: 'flex', flexDirection: 'column', gap: 22 }, | |
| 124 | + h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)), | |
| 125 | + h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)), | |
| 126 | + ), | |
| 127 | + h('div', { display: 'flex', flexDirection: 'column' }, bar, legend), | |
| 128 | + ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** Home-page / fallback card. */ | |
| 132 | +function siteCardTree() { | |
| 133 | + return h( | |
| 134 | + 'div', | |
| 135 | + { | |
| 136 | + width: '100%', | |
| 137 | + height: '100%', | |
| 138 | + display: 'flex', | |
| 139 | + flexDirection: 'column', | |
| 140 | + justifyContent: 'center', | |
| 141 | + alignItems: 'center', | |
| 142 | + gap: 26, | |
| 143 | + backgroundColor: PALETTE.bg, | |
| 144 | + fontFamily: 'Inter', | |
| 145 | + borderBottom: `14px solid ${PALETTE.accent}`, | |
| 146 | + }, | |
| 147 | + h( | |
| 148 | + 'div', | |
| 149 | + { | |
| 150 | + width: 110, | |
| 151 | + height: 110, | |
| 152 | + borderRadius: 28, | |
| 153 | + backgroundColor: PALETTE.accent, | |
| 154 | + color: '#ffffff', | |
| 155 | + display: 'flex', | |
| 156 | + alignItems: 'center', | |
| 157 | + justifyContent: 'center', | |
| 158 | + fontSize: 44, | |
| 159 | + fontWeight: 700, | |
| 160 | + }, | |
| 161 | + 'SPB', | |
| 162 | + ), | |
| 163 | + h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'), | |
| 164 | + h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`), | |
| 165 | + h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'), | |
| 166 | + ); | |
| 167 | +} | |
| 168 | + | |
| 169 | +function truncate(text, max) { | |
| 170 | + const s = String(text ?? ''); | |
| 171 | + return s.length > max ? `${s.slice(0, max - 1)}…` : s; | |
| 172 | +} | |
| 173 | + | |
| 174 | +/** Minimal static SVG used when fonts were never downloaded. */ | |
| 175 | +function fallbackSvg(title) { | |
| 176 | + const safe = String(title).replace(/[<>&"]/g, ''); | |
| 177 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="${PALETTE.bg}"/><rect y="${HEIGHT - 14}" width="${WIDTH}" height="14" fill="${PALETTE.accent}"/><text x="64" y="330" font-family="sans-serif" font-size="72" font-weight="bold" fill="${PALETTE.text}">${safe}</text><text x="64" y="400" font-family="sans-serif" font-size="30" fill="${PALETTE.muted}">git.spboucher.ai</text></svg>`; | |
| 178 | +} | |
| 179 | + | |
| 180 | +/** | |
| 181 | + * Render an OG card PNG. | |
| 182 | + * @param {{name: string, description?: string, languages?: object[]}|null} repoData null → site card | |
| 183 | + * @returns {Promise<{buffer: Buffer, type: string}>} | |
| 184 | + */ | |
| 185 | +export async function renderOgImage(repoData) { | |
| 186 | + const fonts = loadFonts(); | |
| 187 | + let svg; | |
| 188 | + if (fonts) { | |
| 189 | + const tree = repoData ? repoCardTree(repoData) : siteCardTree(); | |
| 190 | + svg = await satori(tree, { width: WIDTH, height: HEIGHT, fonts }); | |
| 191 | + } else { | |
| 192 | + svg = fallbackSvg(repoData?.name ?? 'SPB Git'); | |
| 193 | + } | |
| 194 | + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }); | |
| 195 | + return { buffer: resvg.render().asPng(), type: 'image/png' }; | |
| 196 | +} | |
| 197 | + | |
| 198 | +/** | |
| 199 | + * Render the SPB monogram as a PNG favicon/app icon. | |
| 200 | + * @param {number} size | |
| 201 | + * @returns {Promise<Buffer>} | |
| 202 | + */ | |
| 203 | +export async function renderMonogramPng(size) { | |
| 204 | + const fonts = loadFonts(); | |
| 205 | + const svg = fonts | |
| 206 | + ? await satori( | |
| 207 | + h( | |
| 208 | + 'div', | |
| 209 | + { | |
| 210 | + width: '100%', | |
| 211 | + height: '100%', | |
| 212 | + display: 'flex', | |
| 213 | + alignItems: 'center', | |
| 214 | + justifyContent: 'center', | |
| 215 | + backgroundColor: PALETTE.accent, | |
| 216 | + borderRadius: Math.round(size * 0.22), | |
| 217 | + color: '#ffffff', | |
| 218 | + fontFamily: 'Inter', | |
| 219 | + fontWeight: 700, | |
| 220 | + fontSize: Math.round(size * 0.42), | |
| 221 | + }, | |
| 222 | + 'SPB', | |
| 223 | + ), | |
| 224 | + { width: size, height: size, fonts }, | |
| 225 | + ) | |
| 226 | + : `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="${PALETTE.accent}"/></svg>`; | |
| 227 | + const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: size } }); | |
| 228 | + return resvg.render().asPng(); | |
| 229 | +} | |
added
src/web/assets/css/app.css
+421 −0
@@ -0,0 +1,421 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/web/assets/css/app.css | |
| 8 | + * Purpose : Application styles — layout, nav, cards, tables, diffs | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +*, *::before, *::after { box-sizing: border-box; } | |
| 14 | + | |
| 15 | +html { -webkit-text-size-adjust: 100%; } | |
| 16 | + | |
| 17 | +body { | |
| 18 | + margin: 0; | |
| 19 | + background: var(--bg); | |
| 20 | + color: var(--text); | |
| 21 | + font-family: var(--font-ui); | |
| 22 | + font-size: 15px; | |
| 23 | + line-height: 1.55; | |
| 24 | + min-height: 100vh; | |
| 25 | + display: flex; | |
| 26 | + flex-direction: column; | |
| 27 | +} | |
| 28 | + | |
| 29 | +a { color: var(--accent); text-decoration: none; } | |
| 30 | +a:hover { text-decoration: underline; } | |
| 31 | +code, pre, kbd { font-family: var(--font-mono); } | |
| 32 | +button { font-family: inherit; } | |
| 33 | + | |
| 34 | +.container { max-width: 1200px; margin: 0 auto; padding: 0 20px; width: 100%; } | |
| 35 | +main.container { flex: 1; padding-top: 24px; padding-bottom: 48px; } | |
| 36 | + | |
| 37 | +.sr-only, .skip-link { | |
| 38 | + position: absolute; width: 1px; height: 1px; overflow: hidden; | |
| 39 | + clip: rect(0 0 0 0); white-space: nowrap; | |
| 40 | +} | |
| 41 | +.skip-link:focus { | |
| 42 | + position: fixed; top: 8px; left: 8px; width: auto; height: auto; clip: auto; | |
| 43 | + background: var(--surface); border: 1px solid var(--accent); padding: 8px 12px; | |
| 44 | + border-radius: var(--radius); z-index: 100; | |
| 45 | +} | |
| 46 | +.muted { color: var(--muted); } | |
| 47 | + | |
| 48 | +/* ── Top nav ─────────────────────────────── */ | |
| 49 | +.topnav { | |
| 50 | + position: sticky; top: 0; z-index: 50; | |
| 51 | + background: color-mix(in srgb, var(--bg) 88%, transparent); | |
| 52 | + backdrop-filter: blur(10px); | |
| 53 | + border-bottom: 1px solid var(--border); | |
| 54 | +} | |
| 55 | +.topnav-inner { display: flex; align-items: center; gap: 16px; height: 56px; } | |
| 56 | +.brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-weight: 700; } | |
| 57 | +.brand:hover { text-decoration: none; } | |
| 58 | +.brand-mark { flex: none; } | |
| 59 | +.global-search { flex: 1; max-width: 420px; } | |
| 60 | +.global-search input, .repo-filters input, .search-page-form input { | |
| 61 | + width: 100%; background: var(--surface); color: var(--text); | |
| 62 | + border: 1px solid var(--border); border-radius: var(--radius); | |
| 63 | + padding: 7px 12px; font-size: 14px; outline: none; | |
| 64 | +} | |
| 65 | +.global-search input:focus, .repo-filters input:focus, .search-page-form input:focus { | |
| 66 | + border-color: var(--accent); | |
| 67 | +} | |
| 68 | +.topnav-links { margin-left: auto; display: flex; align-items: center; gap: 12px; } | |
| 69 | +.topnav-ext { color: var(--muted); font-size: 14px; } | |
| 70 | +.icon-btn { | |
| 71 | + background: var(--surface); color: var(--muted); border: 1px solid var(--border); | |
| 72 | + border-radius: var(--radius); padding: 7px 9px; cursor: pointer; line-height: 0; | |
| 73 | +} | |
| 74 | +.icon-btn:hover { border-color: var(--border-strong); color: var(--text); } | |
| 75 | +html[data-theme='dark'] .icon-moon { display: none; } | |
| 76 | +html[data-theme='light'] .icon-sun { display: none; } | |
| 77 | + | |
| 78 | +/* ── Buttons & chips ─────────────────────── */ | |
| 79 | +.btn { | |
| 80 | + display: inline-flex; align-items: center; gap: 6px; | |
| 81 | + background: var(--surface); color: var(--text); | |
| 82 | + border: 1px solid var(--border); border-radius: var(--radius); | |
| 83 | + padding: 6px 14px; font-size: 14px; cursor: pointer; | |
| 84 | +} | |
| 85 | +.btn:hover { border-color: var(--border-strong); text-decoration: none; } | |
| 86 | +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 87 | +.btn-primary:hover { filter: brightness(1.08); } | |
| 88 | +.btn-sm { padding: 4px 10px; font-size: 13px; } | |
| 89 | +.copy-btn.copied { border-color: var(--accent-2); color: var(--accent-2); } | |
| 90 | + | |
| 91 | +.chip { | |
| 92 | + display: inline-flex; align-items: center; | |
| 93 | + border-radius: 999px; padding: 1px 10px; font-size: 12px; | |
| 94 | + background: var(--chip-bg); color: var(--accent); border: 1px solid transparent; | |
| 95 | +} | |
| 96 | +.chip-topic:hover { border-color: var(--accent); text-decoration: none; } | |
| 97 | +.chip-public { color: var(--muted); background: transparent; border-color: var(--border-strong); } | |
| 98 | +.chip-license { color: var(--accent-2); background: rgba(34, 211, 170, 0.1); } | |
| 99 | +.chip-default { color: var(--accent-2); background: rgba(34, 211, 170, 0.1); } | |
| 100 | +.chip-pin { color: var(--accent-2); background: transparent; } | |
| 101 | +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 102 | +.count { | |
| 103 | + background: var(--surface-2); border-radius: 999px; padding: 0 8px; | |
| 104 | + font-size: 12px; color: var(--muted); | |
| 105 | +} | |
| 106 | + | |
| 107 | +/* ── Hero ────────────────────────────────── */ | |
| 108 | +.hero { | |
| 109 | + display: flex; align-items: center; justify-content: space-between; | |
| 110 | + flex-wrap: wrap; gap: 20px; padding: 28px 0 20px; | |
| 111 | + border-bottom: 1px solid var(--border); margin-bottom: 28px; | |
| 112 | +} | |
| 113 | +.hero-id { display: flex; align-items: center; gap: 18px; } | |
| 114 | +.hero-avatar { | |
| 115 | + width: 64px; height: 64px; border-radius: 18px; background: var(--accent); | |
| 116 | + color: #fff; font-weight: 700; font-size: 20px; | |
| 117 | + display: flex; align-items: center; justify-content: center; flex: none; | |
| 118 | +} | |
| 119 | +.hero-name { margin: 0; font-size: 24px; } | |
| 120 | +.hero-tagline { margin: 2px 0 0; color: var(--muted); } | |
| 121 | +.hero-badges { display: flex; gap: 10px; flex-wrap: wrap; } | |
| 122 | +.badge-stat { | |
| 123 | + background: var(--surface); border: 1px solid var(--border); | |
| 124 | + border-radius: var(--radius); padding: 8px 14px; font-size: 14px; color: var(--muted); | |
| 125 | +} | |
| 126 | +.badge-stat strong { color: var(--text); margin-right: 4px; } | |
| 127 | + | |
| 128 | +.section-title { font-size: 17px; margin: 0 0 14px; display: flex; align-items: center; gap: 8px; } | |
| 129 | + | |
| 130 | +/* ── Repo cards / home ───────────────────── */ | |
| 131 | +.pinned-grid { | |
| 132 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); | |
| 133 | + gap: 14px; margin-bottom: 32px; | |
| 134 | +} | |
| 135 | +.home-columns { display: grid; grid-template-columns: 1fr 300px; gap: 32px; align-items: start; } | |
| 136 | +.repo-list { display: flex; flex-direction: column; gap: 12px; } | |
| 137 | +.repo-card { | |
| 138 | + background: var(--surface); border: 1px solid var(--border); | |
| 139 | + border-radius: var(--radius); padding: 16px 18px; | |
| 140 | + transition: border-color 0.15s ease; | |
| 141 | +} | |
| 142 | +.repo-card:hover { border-color: var(--border-strong); } | |
| 143 | +.repo-card-name { margin: 0 0 4px; font-size: 16px; display: flex; align-items: center; gap: 8px; } | |
| 144 | +.repo-card-desc { margin: 0 0 10px; color: var(--muted); font-size: 14px; } | |
| 145 | +.repo-card .chip-row { margin-bottom: 10px; } | |
| 146 | +.repo-card-meta { display: flex; gap: 16px; color: var(--muted); font-size: 13px; flex-wrap: wrap; } | |
| 147 | +.lang-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; } | |
| 148 | +.lang-dot-label { display: inline-flex; align-items: center; gap: 6px; } | |
| 149 | + | |
| 150 | +.repo-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } | |
| 151 | +.repo-filters { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 152 | +.repo-filters input { max-width: 200px; } | |
| 153 | +.repo-filters select, .ref-select { | |
| 154 | + background: var(--surface); color: var(--text); border: 1px solid var(--border); | |
| 155 | + border-radius: var(--radius); padding: 7px 10px; font-size: 14px; | |
| 156 | +} | |
| 157 | +.empty-state { text-align: center; padding: 40px 0; color: var(--muted); } | |
| 158 | + | |
| 159 | +/* ── Activity feed ───────────────────────── */ | |
| 160 | +.activity-feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; font-size: 14px; } | |
| 161 | +.activity-feed li { display: flex; gap: 10px; align-items: baseline; } | |
| 162 | +.activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-2); flex: none; position: relative; top: -1px; } | |
| 163 | +.ref-inline { font-size: 12px; color: var(--muted); } | |
| 164 | + | |
| 165 | +/* ── Heatmap ─────────────────────────────── */ | |
| 166 | +.heatmap-section { margin-top: 40px; } | |
| 167 | +.heatmap-scroll { overflow-x: auto; padding-bottom: 6px; } | |
| 168 | +.heatmap-label { fill: var(--muted); font-size: 9px; font-family: var(--font-ui); } | |
| 169 | +.heatmap-cell[data-level='0'] { fill: var(--heat-0); } | |
| 170 | +.heatmap-cell[data-level='1'] { fill: var(--heat-1); } | |
| 171 | +.heatmap-cell[data-level='2'] { fill: var(--heat-2); } | |
| 172 | +.heatmap-cell[data-level='3'] { fill: var(--heat-3); } | |
| 173 | +.heatmap-cell[data-level='4'] { fill: var(--heat-4); } | |
| 174 | +.heatmap-legend { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; margin-top: 6px; } | |
| 175 | +.heatmap-cell-demo { width: 11px; height: 11px; border-radius: 2px; display: inline-block; } | |
| 176 | +.heatmap-cell-demo[data-level='0'] { background: var(--heat-0); } | |
| 177 | +.heatmap-cell-demo[data-level='1'] { background: var(--heat-1); } | |
| 178 | +.heatmap-cell-demo[data-level='2'] { background: var(--heat-2); } | |
| 179 | +.heatmap-cell-demo[data-level='3'] { background: var(--heat-3); } | |
| 180 | +.heatmap-cell-demo[data-level='4'] { background: var(--heat-4); } | |
| 181 | + | |
| 182 | +/* ── Repo header ─────────────────────────── */ | |
| 183 | +.repo-header { padding-bottom: 0; margin-bottom: 20px; border-bottom: 1px solid var(--border); } | |
| 184 | +.repo-title { font-size: 20px; margin: 4px 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 500; } | |
| 185 | +.repo-owner { color: var(--muted); } | |
| 186 | +.repo-sep { color: var(--muted); } | |
| 187 | +.repo-name { font-weight: 700; } | |
| 188 | +.repo-desc { margin: 0 0 8px; color: var(--muted); max-width: 780px; } | |
| 189 | +.repo-meta-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-bottom: 10px; } | |
| 190 | +.repo-homepage { font-size: 14px; } | |
| 191 | +.lang-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--surface-2); margin: 6px 0; } | |
| 192 | +.lang-bar-seg { display: block; height: 100%; min-width: 2px; } | |
| 193 | +.lang-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; margin-bottom: 12px; } | |
| 194 | +.repo-tabs { display: flex; gap: 4px; align-items: center; margin-top: 8px; overflow-x: auto; } | |
| 195 | +.repo-tab { | |
| 196 | + color: var(--muted); padding: 8px 14px; border-radius: var(--radius) var(--radius) 0 0; | |
| 197 | + border-bottom: 2px solid transparent; display: inline-flex; gap: 7px; align-items: center; | |
| 198 | + white-space: nowrap; font-size: 14px; | |
| 199 | +} | |
| 200 | +.repo-tab:hover { color: var(--text); text-decoration: none; } | |
| 201 | +.repo-tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } | |
| 202 | +.repo-tab-meta { margin-left: auto; font-size: 13px; white-space: nowrap; } | |
| 203 | + | |
| 204 | +/* ── Repo toolbar / clone box ────────────── */ | |
| 205 | +.repo-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 14px; } | |
| 206 | +.clone-box { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } | |
| 207 | +.clone-url { | |
| 208 | + background: var(--surface); border: 1px solid var(--border); color: var(--muted); | |
| 209 | + border-radius: var(--radius); padding: 6px 10px; font-family: var(--font-mono); | |
| 210 | + font-size: 13px; width: 300px; max-width: 60vw; | |
| 211 | +} | |
| 212 | +.clone-snippet { | |
| 213 | + background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); | |
| 214 | + padding: 14px 16px; font-size: 13px; overflow-x: auto; display: inline-block; text-align: left; | |
| 215 | +} | |
| 216 | +.path-breadcrumb { font-family: var(--font-mono); font-size: 14px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } | |
| 217 | +.crumb-sep { color: var(--muted); } | |
| 218 | + | |
| 219 | +/* ── File table ──────────────────────────── */ | |
| 220 | +.file-table { | |
| 221 | + width: 100%; border-collapse: collapse; border: 1px solid var(--border); | |
| 222 | + border-radius: var(--radius); overflow: hidden; background: var(--surface); | |
| 223 | + font-size: 14px; margin-bottom: 24px; | |
| 224 | +} | |
| 225 | +.file-table td { padding: 8px 14px; border-top: 1px solid var(--border); } | |
| 226 | +.file-table tr:first-child td { border-top: 0; } | |
| 227 | +.file-row:hover { background: var(--surface-2); } | |
| 228 | +.file-name { white-space: nowrap; width: 32%; } | |
| 229 | +.file-name a { color: var(--text); } | |
| 230 | +.file-name a:hover { color: var(--accent); } | |
| 231 | +.file-icon { vertical-align: -3px; margin-right: 7px; color: var(--muted); } | |
| 232 | +.icon-dir { color: var(--accent); } | |
| 233 | +.file-commit { overflow: hidden; text-overflow: ellipsis; max-width: 400px; white-space: nowrap; } | |
| 234 | +.file-commit a:hover { color: var(--accent); } | |
| 235 | +.file-date { text-align: right; white-space: nowrap; font-size: 13px; } | |
| 236 | + | |
| 237 | +/* ── README / markdown section ───────────── */ | |
| 238 | +.readme-section { | |
| 239 | + border: 1px solid var(--border); border-radius: var(--radius); | |
| 240 | + background: var(--surface); margin-bottom: 32px; | |
| 241 | +} | |
| 242 | +.readme-header { | |
| 243 | + display: flex; align-items: center; gap: 8px; padding: 10px 20px; | |
| 244 | + border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; | |
| 245 | + position: sticky; top: 56px; background: var(--surface); border-radius: var(--radius) var(--radius) 0 0; | |
| 246 | +} | |
| 247 | +.readme-section .markdown-body { padding: 24px 32px 32px; } | |
| 248 | +.empty-repo { text-align: center; padding: 48px 20px; } | |
| 249 | +.notice { | |
| 250 | + background: var(--accent-soft); border: 1px solid var(--accent); | |
| 251 | + border-radius: var(--radius); padding: 8px 14px; font-size: 13px; margin: 12px; | |
| 252 | +} | |
| 253 | + | |
| 254 | +/* ── Blob view ───────────────────────────── */ | |
| 255 | +.blob-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; } | |
| 256 | +.blob-header { | |
| 257 | + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; | |
| 258 | + padding: 10px 16px; border-bottom: 1px solid var(--border); | |
| 259 | + position: sticky; top: 56px; background: var(--surface); z-index: 10; | |
| 260 | +} | |
| 261 | +.blob-meta { display: flex; gap: 8px; align-items: center; font-size: 13px; } | |
| 262 | +.blob-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; } | |
| 263 | +.blob-image { text-align: center; padding: 32px; } | |
| 264 | +.blob-image img { max-width: 100%; border-radius: var(--radius); } | |
| 265 | +.blob-binary { text-align: center; padding: 48px 20px; color: var(--muted); } | |
| 266 | +.blob-markdown { padding: 24px 32px 32px; } | |
| 267 | + | |
| 268 | +.code-view { overflow-x: auto; } | |
| 269 | +.code-view pre.shiki { | |
| 270 | + margin: 0; padding: 12px 0; background: transparent !important; | |
| 271 | + font-size: 13px; line-height: 1.6; min-width: max-content; | |
| 272 | +} | |
| 273 | +.code-view .line { display: block; padding: 0 16px 0 0; min-height: 1.6em; } | |
| 274 | +.code-view .line:target { background: var(--accent-soft); } | |
| 275 | +.code-view .line.range-hl { background: var(--accent-soft); } | |
| 276 | +.code-view .ln { | |
| 277 | + display: inline-block; width: 52px; padding-right: 16px; text-align: right; | |
| 278 | + color: var(--muted); user-select: none; font-size: 12px; text-decoration: none; | |
| 279 | + position: sticky; left: 0; background: var(--surface); | |
| 280 | +} | |
| 281 | +.code-view .ln:hover { color: var(--accent); } | |
| 282 | +.plain-readme { padding: 20px; overflow-x: auto; font-size: 13px; } | |
| 283 | + | |
| 284 | +/* ── Commits ─────────────────────────────── */ | |
| 285 | +.commit-list { list-style: none; margin: 0 0 20px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } | |
| 286 | +.commit-row { display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; border-top: 1px solid var(--border); } | |
| 287 | +.commit-row:first-child { border-top: 0; } | |
| 288 | +.commit-avatar { flex: none; border-radius: 6px; overflow: hidden; background: var(--surface-2); line-height: 0; padding: 2px; border: 1px solid var(--border); } | |
| 289 | +.commit-main { flex: 1; min-width: 0; } | |
| 290 | +.commit-subject { font-weight: 600; display: flex; gap: 8px; align-items: baseline; } | |
| 291 | +.commit-subject a { color: var(--text); } | |
| 292 | +.commit-subject a:hover { color: var(--accent); } | |
| 293 | +.commit-body-toggle summary { cursor: pointer; color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 0 8px; display: inline-block; } | |
| 294 | +.commit-body { font-size: 13px; color: var(--muted); white-space: pre-wrap; margin: 8px 0 0; } | |
| 295 | +.commit-meta { font-size: 13px; margin-top: 3px; display: flex; flex-wrap: wrap; gap: 6px; } | |
| 296 | +.sha-chip { | |
| 297 | + font-family: var(--font-mono); font-size: 12px; background: var(--surface-2); | |
| 298 | + color: var(--accent); border: 1px solid var(--border); border-radius: 6px; | |
| 299 | + padding: 3px 8px; cursor: pointer; flex: none; | |
| 300 | +} | |
| 301 | +.sha-chip:hover { border-color: var(--accent); } | |
| 302 | +.pagination { display: flex; gap: 10px; justify-content: center; margin: 20px 0; } | |
| 303 | + | |
| 304 | +/* ── Commit detail / diffs ───────────────── */ | |
| 305 | +.commit-detail-header { | |
| 306 | + border: 1px solid var(--border); border-radius: var(--radius); | |
| 307 | + background: var(--surface); padding: 16px 20px; margin-bottom: 18px; | |
| 308 | +} | |
| 309 | +.commit-detail-subject { margin: 0 0 8px; font-size: 18px; } | |
| 310 | +.commit-detail-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 14px; } | |
| 311 | +.commit-stats { margin: 12px 0 0; font-size: 14px; color: var(--muted); } | |
| 312 | +.diff-add { color: var(--add-text); font-weight: 600; } | |
| 313 | +.diff-del { color: var(--del-text); font-weight: 600; } | |
| 314 | + | |
| 315 | +.diff-file { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; overflow: hidden; } | |
| 316 | +.diff-file-header { | |
| 317 | + display: flex; gap: 10px; align-items: center; padding: 9px 14px; | |
| 318 | + cursor: pointer; background: var(--surface-2); font-size: 13px; list-style: none; | |
| 319 | + position: sticky; top: 56px; z-index: 5; | |
| 320 | +} | |
| 321 | +.diff-file-header::-webkit-details-marker { display: none; } | |
| 322 | +.diff-file-header::before { content: '▾'; color: var(--muted); } | |
| 323 | +.diff-file:not([open]) .diff-file-header::before { content: '▸'; } | |
| 324 | +.diff-path { font-size: 13px; word-break: break-all; } | |
| 325 | +.diff-counts { margin-left: auto; white-space: nowrap; } | |
| 326 | +.diff-status { border-radius: 4px; padding: 1px 7px; font-size: 11px; text-transform: uppercase; font-weight: 700; } | |
| 327 | +.diff-status-added { background: var(--add-bg); color: var(--add-text); } | |
| 328 | +.diff-status-deleted { background: var(--del-bg); color: var(--del-text); } | |
| 329 | +.diff-status-modified { background: var(--accent-soft); color: var(--accent); } | |
| 330 | +.diff-status-renamed { background: var(--chip-bg); color: var(--accent-2); } | |
| 331 | + | |
| 332 | +.diff-table-wrap { overflow-x: auto; } | |
| 333 | +.diff-table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; } | |
| 334 | +.diff-gutter { | |
| 335 | + width: 44px; min-width: 44px; text-align: right; padding: 0 8px; | |
| 336 | + color: var(--muted); user-select: none; border-right: 1px solid var(--border); | |
| 337 | + font-size: 11.5px; vertical-align: top; | |
| 338 | +} | |
| 339 | +.diff-code { padding: 0 10px; white-space: pre; } | |
| 340 | +.diff-sign { display: inline-block; width: 14px; user-select: none; color: var(--muted); } | |
| 341 | +.diff-line-add { background: var(--add-bg); } | |
| 342 | +.diff-line-add .diff-sign { color: var(--add-text); } | |
| 343 | +.diff-line-del { background: var(--del-bg); } | |
| 344 | +.diff-line-del .diff-sign { color: var(--del-text); } | |
| 345 | +.diff-hunk-header td { background: var(--hunk-bg); color: var(--muted); padding: 3px 10px; font-size: 12px; } | |
| 346 | +.diff-binary { padding: 16px; } | |
| 347 | + | |
| 348 | +/* ── Blame ───────────────────────────────── */ | |
| 349 | +.blame-table { border-collapse: collapse; width: 100%; font-size: 13px; } | |
| 350 | +.blame-commit { | |
| 351 | + border-top: 1px solid var(--border); border-right: 1px solid var(--border); | |
| 352 | + padding: 4px 10px; width: 320px; min-width: 240px; vertical-align: top; | |
| 353 | + background: var(--surface-2); | |
| 354 | +} | |
| 355 | +.blame-commit-inner { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| 356 | +.blame-date { font-size: 12px; width: 100%; padding-left: 30px; } | |
| 357 | +.blame-ln { width: 44px; text-align: right; color: var(--muted); padding: 0 8px; user-select: none; font-family: var(--font-mono); font-size: 12px; } | |
| 358 | +.blame-code pre { margin: 0; font-size: 12.5px; line-height: 1.55; white-space: pre; } | |
| 359 | +.blame-line[data-age='0'] .blame-commit { box-shadow: inset 3px 0 0 var(--accent); } | |
| 360 | +.blame-line[data-age='1'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 85%, transparent); } | |
| 361 | +.blame-line[data-age='2'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 70%, transparent); } | |
| 362 | +.blame-line[data-age='3'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 58%, transparent); } | |
| 363 | +.blame-line[data-age='4'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 47%, transparent); } | |
| 364 | +.blame-line[data-age='5'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 38%, transparent); } | |
| 365 | +.blame-line[data-age='6'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 30%, transparent); } | |
| 366 | +.blame-line[data-age='7'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 22%, transparent); } | |
| 367 | +.blame-line[data-age='8'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 15%, transparent); } | |
| 368 | +.blame-line[data-age='9'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 8%, transparent); } | |
| 369 | + | |
| 370 | +/* ── Branch/tag tables ───────────────────── */ | |
| 371 | +.ref-table { | |
| 372 | + width: 100%; border-collapse: collapse; background: var(--surface); | |
| 373 | + border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; | |
| 374 | + font-size: 14px; | |
| 375 | +} | |
| 376 | +.ref-table th { text-align: left; padding: 10px 14px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--border); } | |
| 377 | +.ref-table td { padding: 10px 14px; border-top: 1px solid var(--border); } | |
| 378 | +.ref-name { font-family: var(--font-mono); font-size: 13px; } | |
| 379 | +.ahead-behind { display: inline-flex; gap: 10px; font-family: var(--font-mono); font-size: 13px; } | |
| 380 | +.archive-links { display: flex; gap: 6px; } | |
| 381 | + | |
| 382 | +/* ── Search page ─────────────────────────── */ | |
| 383 | +.search-page-form { display: flex; gap: 8px; margin-bottom: 28px; max-width: 560px; } | |
| 384 | +.search-results { list-style: none; margin: 0 0 32px; padding: 0; display: flex; flex-direction: column; gap: 16px; } | |
| 385 | +.search-results li { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; } | |
| 386 | +.search-hit-name { font-weight: 600; font-size: 15px; } | |
| 387 | +.search-snippet { color: var(--muted); font-size: 13px; margin: 6px 0 0; } | |
| 388 | + | |
| 389 | +/* ── Error pages ─────────────────────────── */ | |
| 390 | +.error-page { text-align: center; padding: 72px 20px; } | |
| 391 | +.error-code { font-size: 96px; font-weight: 700; color: var(--accent); font-family: var(--font-mono); line-height: 1; } | |
| 392 | +.error-message { font-size: 18px; color: var(--muted); margin: 16px 0 8px; } | |
| 393 | +.error-art { display: inline-block; text-align: left; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; font-size: 13px; margin: 16px 0 24px; } | |
| 394 | + | |
| 395 | +/* ── Mermaid blocks ──────────────────────── */ | |
| 396 | +.mermaid-block { margin: 16px 0; } | |
| 397 | +.mermaid-src { display: none; } | |
| 398 | +.mermaid-block[data-rendered='0'] .mermaid-src { display: block; } | |
| 399 | +.mermaid-target svg { max-width: 100%; } | |
| 400 | + | |
| 401 | +/* ── Footer ──────────────────────────────── */ | |
| 402 | +.footer { | |
| 403 | + border-top: 1px solid var(--border); color: var(--muted); | |
| 404 | + padding: 20px 0; font-size: 13px; text-align: center; | |
| 405 | +} | |
| 406 | + | |
| 407 | +/* ── Responsive ──────────────────────────── */ | |
| 408 | +@media (max-width: 960px) { | |
| 409 | + .home-columns { grid-template-columns: 1fr; } | |
| 410 | + .file-commit { display: none; } | |
| 411 | + .blame-commit { width: 200px; min-width: 150px; } | |
| 412 | +} | |
| 413 | +@media (max-width: 640px) { | |
| 414 | + .global-search { display: none; } | |
| 415 | + .hero { flex-direction: column; align-items: flex-start; } | |
| 416 | + .pinned-grid { grid-template-columns: 1fr; } | |
| 417 | + .clone-url { width: 100%; max-width: none; } | |
| 418 | + .repo-toolbar { flex-direction: column; align-items: stretch; } | |
| 419 | + .file-date { display: none; } | |
| 420 | + .readme-section .markdown-body, .blob-markdown { padding: 16px; } | |
| 421 | +} | |
added
src/web/assets/css/markdown.css
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/web/assets/css/markdown.css | |
| 8 | + * Purpose : GitHub-grade .markdown-body typography (badges included) | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +.markdown-body { | |
| 14 | + font-size: 16px; | |
| 15 | + line-height: 1.6; | |
| 16 | + word-wrap: break-word; | |
| 17 | +} | |
| 18 | + | |
| 19 | +.markdown-body > *:first-child { margin-top: 0 !important; } | |
| 20 | +.markdown-body > *:last-child { margin-bottom: 0 !important; } | |
| 21 | + | |
| 22 | +.markdown-body h1, .markdown-body h2, .markdown-body h3, | |
| 23 | +.markdown-body h4, .markdown-body h5, .markdown-body h6 { | |
| 24 | + margin-top: 24px; | |
| 25 | + margin-bottom: 16px; | |
| 26 | + font-weight: 600; | |
| 27 | + line-height: 1.25; | |
| 28 | + position: relative; | |
| 29 | +} | |
| 30 | +.markdown-body h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); } | |
| 31 | +.markdown-body h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); } | |
| 32 | +.markdown-body h3 { font-size: 1.25em; } | |
| 33 | +.markdown-body h4 { font-size: 1em; } | |
| 34 | +.markdown-body h5 { font-size: 0.875em; } | |
| 35 | +.markdown-body h6 { font-size: 0.85em; color: var(--muted); } | |
| 36 | + | |
| 37 | +.heading-anchor { | |
| 38 | + position: absolute; | |
| 39 | + left: -20px; | |
| 40 | + top: 50%; | |
| 41 | + transform: translateY(-50%); | |
| 42 | + opacity: 0; | |
| 43 | + text-decoration: none; | |
| 44 | + color: var(--muted); | |
| 45 | + font-size: 0.8em; | |
| 46 | +} | |
| 47 | +.markdown-body h1:hover .heading-anchor, .markdown-body h2:hover .heading-anchor, | |
| 48 | +.markdown-body h3:hover .heading-anchor, .markdown-body h4:hover .heading-anchor, | |
| 49 | +.markdown-body h5:hover .heading-anchor, .markdown-body h6:hover .heading-anchor { opacity: 1; } | |
| 50 | + | |
| 51 | +.markdown-body p, .markdown-body blockquote, .markdown-body ul, .markdown-body ol, | |
| 52 | +.markdown-body dl, .markdown-body table, .markdown-body pre, .markdown-body details { | |
| 53 | + margin-top: 0; | |
| 54 | + margin-bottom: 16px; | |
| 55 | +} | |
| 56 | + | |
| 57 | +.markdown-body blockquote { | |
| 58 | + padding: 0 1em; | |
| 59 | + color: var(--muted); | |
| 60 | + border-left: 0.25em solid var(--accent-2); | |
| 61 | + margin-left: 0; | |
| 62 | + margin-right: 0; | |
| 63 | +} | |
| 64 | + | |
| 65 | +.markdown-body ul, .markdown-body ol { padding-left: 2em; } | |
| 66 | +.markdown-body li + li { margin-top: 0.25em; } | |
| 67 | +.markdown-body li.task-list-item { list-style: none; margin-left: -1.4em; } | |
| 68 | +.markdown-body li.task-list-item input[type='checkbox'] { | |
| 69 | + margin-right: 0.5em; | |
| 70 | + vertical-align: -1px; | |
| 71 | + accent-color: var(--accent); | |
| 72 | +} | |
| 73 | + | |
| 74 | +.markdown-body code { | |
| 75 | + background: var(--surface-2); | |
| 76 | + border-radius: 6px; | |
| 77 | + padding: 0.2em 0.4em; | |
| 78 | + font-size: 85%; | |
| 79 | + font-family: var(--font-mono); | |
| 80 | +} | |
| 81 | +.markdown-body pre code { background: transparent; padding: 0; font-size: 100%; } | |
| 82 | + | |
| 83 | +.markdown-body .code-block { | |
| 84 | + background: var(--code-bg); | |
| 85 | + border: 1px solid var(--border); | |
| 86 | + border-radius: var(--radius); | |
| 87 | + overflow-x: auto; | |
| 88 | + margin-bottom: 16px; | |
| 89 | + position: relative; | |
| 90 | +} | |
| 91 | +.markdown-body .code-block pre.shiki { | |
| 92 | + margin: 0; | |
| 93 | + padding: 14px 16px; | |
| 94 | + background: transparent !important; | |
| 95 | + font-size: 85%; | |
| 96 | + line-height: 1.55; | |
| 97 | +} | |
| 98 | +.markdown-body pre:not(.shiki) { | |
| 99 | + background: var(--code-bg); | |
| 100 | + border: 1px solid var(--border); | |
| 101 | + border-radius: var(--radius); | |
| 102 | + padding: 14px 16px; | |
| 103 | + overflow-x: auto; | |
| 104 | + font-size: 85%; | |
| 105 | + line-height: 1.55; | |
| 106 | +} | |
| 107 | + | |
| 108 | +.markdown-body table { | |
| 109 | + border-collapse: collapse; | |
| 110 | + border-spacing: 0; | |
| 111 | + display: block; | |
| 112 | + max-width: 100%; | |
| 113 | + overflow-x: auto; | |
| 114 | + width: max-content; | |
| 115 | +} | |
| 116 | +.markdown-body table th, .markdown-body table td { | |
| 117 | + border: 1px solid var(--border-strong); | |
| 118 | + padding: 6px 13px; | |
| 119 | +} | |
| 120 | +.markdown-body table th { font-weight: 600; background: var(--surface-2); } | |
| 121 | +.markdown-body table tr:nth-child(2n) { background: var(--surface-2); } | |
| 122 | + | |
| 123 | +.markdown-body hr { | |
| 124 | + height: 1px; | |
| 125 | + border: 0; | |
| 126 | + background: var(--border); | |
| 127 | + margin: 24px 0; | |
| 128 | +} | |
| 129 | + | |
| 130 | +.markdown-body img { | |
| 131 | + max-width: 100%; | |
| 132 | + box-sizing: content-box; | |
| 133 | + border-radius: 4px; | |
| 134 | +} | |
| 135 | + | |
| 136 | +/* Badges: shields.io & friends stay inline, centered, unwrapped. */ | |
| 137 | +.markdown-body img[src*='shields.io'], | |
| 138 | +.markdown-body img[src*='badge'], | |
| 139 | +.markdown-body img[src*='badgen.net'], | |
| 140 | +.markdown-body img[src*='github.io/badges'], | |
| 141 | +.markdown-body img[height] { | |
| 142 | + display: inline-block; | |
| 143 | + vertical-align: middle; | |
| 144 | + border-radius: 0; | |
| 145 | + margin: 0 2px 2px 0; | |
| 146 | +} | |
| 147 | +.markdown-body p > a > img { display: inline-block; vertical-align: middle; } | |
| 148 | + | |
| 149 | +.markdown-body kbd { | |
| 150 | + display: inline-block; | |
| 151 | + padding: 3px 5px; | |
| 152 | + font-size: 11px; | |
| 153 | + line-height: 10px; | |
| 154 | + color: var(--text); | |
| 155 | + vertical-align: middle; | |
| 156 | + background: var(--surface-2); | |
| 157 | + border: 1px solid var(--border-strong); | |
| 158 | + border-bottom-width: 2px; | |
| 159 | + border-radius: 6px; | |
| 160 | +} | |
| 161 | + | |
| 162 | +.markdown-body details { | |
| 163 | + border: 1px solid var(--border); | |
| 164 | + border-radius: var(--radius); | |
| 165 | + padding: 10px 14px; | |
| 166 | +} | |
| 167 | +.markdown-body details summary { | |
| 168 | + cursor: pointer; | |
| 169 | + font-weight: 600; | |
| 170 | + margin: -10px -14px; | |
| 171 | + padding: 10px 14px; | |
| 172 | +} | |
| 173 | +.markdown-body details[open] summary { border-bottom: 1px solid var(--border); margin-bottom: 10px; } | |
| 174 | + | |
| 175 | +.markdown-body [align='center'] { text-align: center; } | |
| 176 | +.markdown-body [align='right'] { text-align: right; } | |
| 177 | +.markdown-body [align='left'] { text-align: left; } | |
| 178 | + | |
| 179 | +.markdown-body .footnotes { | |
| 180 | + font-size: 13px; | |
| 181 | + color: var(--muted); | |
| 182 | + border-top: 1px solid var(--border); | |
| 183 | + margin-top: 32px; | |
| 184 | + padding-top: 16px; | |
| 185 | +} | |
| 186 | +.markdown-body sup a { text-decoration: none; } | |
| 187 | + | |
| 188 | +.markdown-body .anchor-icon { font-weight: 400; } | |
added
src/web/assets/css/tokens.css
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/web/assets/css/tokens.css | |
| 8 | + * Purpose : Design tokens — colors, radius, typography, themes | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +@font-face { | |
| 14 | + font-family: 'Inter'; | |
| 15 | + src: url('/assets/fonts/inter-400.woff2') format('woff2'); | |
| 16 | + font-weight: 400; | |
| 17 | + font-style: normal; | |
| 18 | + font-display: swap; | |
| 19 | +} | |
| 20 | +@font-face { | |
| 21 | + font-family: 'Inter'; | |
| 22 | + src: url('/assets/fonts/inter-500.woff2') format('woff2'); | |
| 23 | + font-weight: 500; | |
| 24 | + font-style: normal; | |
| 25 | + font-display: swap; | |
| 26 | +} | |
| 27 | +@font-face { | |
| 28 | + font-family: 'Inter'; | |
| 29 | + src: url('/assets/fonts/inter-600.woff2') format('woff2'); | |
| 30 | + font-weight: 600; | |
| 31 | + font-style: normal; | |
| 32 | + font-display: swap; | |
| 33 | +} | |
| 34 | +@font-face { | |
| 35 | + font-family: 'Inter'; | |
| 36 | + src: url('/assets/fonts/inter-700.woff2') format('woff2'); | |
| 37 | + font-weight: 700; | |
| 38 | + font-style: normal; | |
| 39 | + font-display: swap; | |
| 40 | +} | |
| 41 | +@font-face { | |
| 42 | + font-family: 'JetBrains Mono'; | |
| 43 | + src: url('/assets/fonts/jetbrains-mono-400.woff2') format('woff2'); | |
| 44 | + font-weight: 400; | |
| 45 | + font-style: normal; | |
| 46 | + font-display: swap; | |
| 47 | +} | |
| 48 | +@font-face { | |
| 49 | + font-family: 'JetBrains Mono'; | |
| 50 | + src: url('/assets/fonts/jetbrains-mono-600.woff2') format('woff2'); | |
| 51 | + font-weight: 600; | |
| 52 | + font-style: normal; | |
| 53 | + font-display: swap; | |
| 54 | +} | |
| 55 | + | |
| 56 | +:root, | |
| 57 | +html[data-theme='dark'] { | |
| 58 | + --bg: #0b0e14; | |
| 59 | + --surface: #11151c; | |
| 60 | + --surface-2: #161b24; | |
| 61 | + --border: #1f2530; | |
| 62 | + --border-strong: #2a3242; | |
| 63 | + --text: #e6e9ef; | |
| 64 | + --muted: #8b93a3; | |
| 65 | + --accent: #4f8cff; | |
| 66 | + --accent-soft: rgba(79, 140, 255, 0.14); | |
| 67 | + --accent-2: #22d3aa; | |
| 68 | + --danger: #ff5d5d; | |
| 69 | + --success: #3fb950; | |
| 70 | + --add-bg: rgba(63, 185, 80, 0.15); | |
| 71 | + --add-text: #56d364; | |
| 72 | + --del-bg: rgba(248, 81, 73, 0.15); | |
| 73 | + --del-text: #ff7b72; | |
| 74 | + --hunk-bg: rgba(79, 140, 255, 0.08); | |
| 75 | + --code-bg: #0d1117; | |
| 76 | + --chip-bg: rgba(79, 140, 255, 0.12); | |
| 77 | + --heat-0: #161b24; | |
| 78 | + --heat-1: #0e4429; | |
| 79 | + --heat-2: #006d32; | |
| 80 | + --heat-3: #26a641; | |
| 81 | + --heat-4: #39d353; | |
| 82 | + --radius: 10px; | |
| 83 | + --font-ui: 'Inter', -apple-system, 'Segoe UI', sans-serif; | |
| 84 | + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; | |
| 85 | + --shadow-pop: 0 0 0 1px var(--border); | |
| 86 | + color-scheme: dark; | |
| 87 | +} | |
| 88 | + | |
| 89 | +html[data-theme='light'] { | |
| 90 | + --bg: #f7f8fa; | |
| 91 | + --surface: #ffffff; | |
| 92 | + --surface-2: #f0f2f5; | |
| 93 | + --border: #d9dee6; | |
| 94 | + --border-strong: #c2cad6; | |
| 95 | + --text: #1c2330; | |
| 96 | + --muted: #57606e; | |
| 97 | + --accent: #2f6fe0; | |
| 98 | + --accent-soft: rgba(47, 111, 224, 0.1); | |
| 99 | + --accent-2: #0d9e80; | |
| 100 | + --danger: #d33636; | |
| 101 | + --success: #1a7f37; | |
| 102 | + --add-bg: rgba(26, 127, 55, 0.12); | |
| 103 | + --add-text: #1a7f37; | |
| 104 | + --del-bg: rgba(207, 34, 46, 0.12); | |
| 105 | + --del-text: #cf222e; | |
| 106 | + --hunk-bg: rgba(47, 111, 224, 0.07); | |
| 107 | + --code-bg: #f6f8fa; | |
| 108 | + --chip-bg: rgba(47, 111, 224, 0.1); | |
| 109 | + --heat-0: #ebedf0; | |
| 110 | + --heat-1: #9be9a8; | |
| 111 | + --heat-2: #40c463; | |
| 112 | + --heat-3: #30a14e; | |
| 113 | + --heat-4: #216e39; | |
| 114 | + color-scheme: light; | |
| 115 | +} | |
| 116 | + | |
| 117 | +/* Shiki dual-theme switch */ | |
| 118 | +html[data-theme='dark'] .shiki, | |
| 119 | +html[data-theme='dark'] .shiki span { | |
| 120 | + color: var(--shiki-dark) !important; | |
| 121 | + background-color: transparent !important; | |
| 122 | +} | |
| 123 | +html[data-theme='light'] .shiki, | |
| 124 | +html[data-theme='light'] .shiki span { | |
| 125 | + color: var(--shiki-light) !important; | |
| 126 | + background-color: transparent !important; | |
| 127 | +} | |
added
src/web/assets/fonts/og/inter-400.woff
+0 −0
Binary file not shown.
added
src/web/assets/fonts/og/inter-700.woff
+0 −0
Binary file not shown.
added
src/web/assets/fonts/og/jetbrains-mono-400.woff
+0 −0
Binary file not shown.
added
src/web/assets/img/logo.svg
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +<!-- | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + SPB Git — Personal Git Platform | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : src/web/assets/img/logo.svg | |
| 8 | + Purpose : SPB monogram favicon / logo | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +--> | |
| 12 | +<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28"> | |
| 13 | + <rect width="28" height="28" rx="7" fill="#4f8cff"/> | |
| 14 | + <path d="M8 18.5c1 1.4 2.7 2.2 4.6 2.2 2.6 0 4.4-1.3 4.4-3.3 0-1.8-1.2-2.7-3.6-3.3l-1.6-.4c-1.4-.4-2-.9-2-1.8 0-1.1 1-1.8 2.5-1.8 1.4 0 2.5.6 3.2 1.7l1.9-1.3c-1-1.6-2.8-2.5-5-2.5C10 8 8.2 9.4 8.2 11.4c0 1.9 1.2 2.8 3.4 3.3l1.7.4c1.5.4 2.1.9 2.1 1.8 0 1.1-1.1 1.8-2.7 1.8-1.6 0-2.9-.7-3.7-2z" fill="#fff"/> | |
| 15 | +</svg> | |
added
src/web/assets/js/app.js
+176 −0
@@ -0,0 +1,176 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/web/assets/js/app.js | |
| 8 | + * Purpose : Vanilla JS layer — theme, copy, filters, anchors, mermaid | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +(function () { | |
| 14 | + 'use strict'; | |
| 15 | + | |
| 16 | + /* ── Theme toggle ─────────────────────────── */ | |
| 17 | + var toggle = document.getElementById('theme-toggle'); | |
| 18 | + if (toggle) { | |
| 19 | + toggle.addEventListener('click', function () { | |
| 20 | + var html = document.documentElement; | |
| 21 | + var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; | |
| 22 | + html.setAttribute('data-theme', next); | |
| 23 | + try { localStorage.setItem('spbgit-theme', next); } catch (e) { /* ignore */ } | |
| 24 | + }); | |
| 25 | + } | |
| 26 | + | |
| 27 | + /* ── "/" focuses the global search ────────── */ | |
| 28 | + document.addEventListener('keydown', function (event) { | |
| 29 | + if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return; | |
| 30 | + var target = event.target; | |
| 31 | + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; | |
| 32 | + var input = document.getElementById('global-search'); | |
| 33 | + if (input) { | |
| 34 | + event.preventDefault(); | |
| 35 | + input.focus(); | |
| 36 | + input.select(); | |
| 37 | + } | |
| 38 | + }); | |
| 39 | + | |
| 40 | + /* ── Copy buttons ─────────────────────────── */ | |
| 41 | + document.addEventListener('click', function (event) { | |
| 42 | + var btn = event.target.closest('.copy-btn'); | |
| 43 | + if (!btn) return; | |
| 44 | + var text = btn.getAttribute('data-copy'); | |
| 45 | + if (!text) return; | |
| 46 | + navigator.clipboard.writeText(text).then(function () { | |
| 47 | + btn.classList.add('copied'); | |
| 48 | + var prev = btn.textContent; | |
| 49 | + btn.textContent = '✓'; | |
| 50 | + setTimeout(function () { | |
| 51 | + btn.classList.remove('copied'); | |
| 52 | + btn.textContent = prev; | |
| 53 | + }, 1200); | |
| 54 | + }); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + /* ── Home: filter + sort repo list ────────── */ | |
| 58 | + var list = document.getElementById('repo-list'); | |
| 59 | + if (list) { | |
| 60 | + var filterInput = document.getElementById('repo-filter'); | |
| 61 | + var sortSelect = document.getElementById('repo-sort'); | |
| 62 | + var langSelect = document.getElementById('repo-lang'); | |
| 63 | + var topicSelect = document.getElementById('repo-topic'); | |
| 64 | + var emptyNote = document.getElementById('repo-list-empty'); | |
| 65 | + | |
| 66 | + var apply = function () { | |
| 67 | + var q = (filterInput.value || '').toLowerCase(); | |
| 68 | + var lang = langSelect.value; | |
| 69 | + var topic = topicSelect.value; | |
| 70 | + var cards = Array.prototype.slice.call(list.querySelectorAll('.repo-card')); | |
| 71 | + var visible = 0; | |
| 72 | + cards.forEach(function (card) { | |
| 73 | + var name = card.getAttribute('data-name') || ''; | |
| 74 | + var desc = (card.getAttribute('data-description') || '').toLowerCase(); | |
| 75 | + var cardLang = card.getAttribute('data-language') || ''; | |
| 76 | + var topics = (card.getAttribute('data-topics') || '').split(','); | |
| 77 | + var show = | |
| 78 | + (!q || name.toLowerCase().indexOf(q) !== -1 || desc.indexOf(q) !== -1) && | |
| 79 | + (!lang || cardLang === lang) && | |
| 80 | + (!topic || topics.indexOf(topic) !== -1); | |
| 81 | + card.hidden = !show; | |
| 82 | + if (show) visible += 1; | |
| 83 | + }); | |
| 84 | + var key = sortSelect.value; | |
| 85 | + var attr = key === 'name' ? 'data-name' : key === 'created' ? 'data-created' : 'data-pushed'; | |
| 86 | + cards | |
| 87 | + .sort(function (a, b) { | |
| 88 | + var av = a.getAttribute(attr) || ''; | |
| 89 | + var bv = b.getAttribute(attr) || ''; | |
| 90 | + return key === 'name' ? av.localeCompare(bv) : bv.localeCompare(av); | |
| 91 | + }) | |
| 92 | + .forEach(function (card) { list.appendChild(card); }); | |
| 93 | + if (emptyNote) emptyNote.hidden = visible > 0; | |
| 94 | + }; | |
| 95 | + [filterInput, sortSelect, langSelect, topicSelect].forEach(function (el) { | |
| 96 | + if (!el) return; | |
| 97 | + el.addEventListener('input', apply); | |
| 98 | + el.addEventListener('change', apply); | |
| 99 | + }); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /* ── Ref selector navigation ──────────────── */ | |
| 103 | + var refSelect = document.getElementById('ref-select'); | |
| 104 | + if (refSelect) { | |
| 105 | + refSelect.addEventListener('change', function () { | |
| 106 | + var repo = refSelect.getAttribute('data-repo'); | |
| 107 | + var kind = refSelect.getAttribute('data-kind') || 'tree'; | |
| 108 | + var path = refSelect.getAttribute('data-path') || ''; | |
| 109 | + var ref = refSelect.value; | |
| 110 | + var url = '/' + repo + '/' + kind + '/' + ref + (path ? '/' + path : ''); | |
| 111 | + location.href = url; | |
| 112 | + }); | |
| 113 | + } | |
| 114 | + | |
| 115 | + /* ── Line range highlighting (#L10-L20) ───── */ | |
| 116 | + var codeView = document.getElementById('code-view'); | |
| 117 | + if (codeView) { | |
| 118 | + var applyRange = function () { | |
| 119 | + codeView.querySelectorAll('.line.range-hl').forEach(function (el) { | |
| 120 | + el.classList.remove('range-hl'); | |
| 121 | + }); | |
| 122 | + var match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash); | |
| 123 | + if (!match) return; | |
| 124 | + var start = parseInt(match[1], 10); | |
| 125 | + var end = match[2] ? parseInt(match[2], 10) : start; | |
| 126 | + if (end < start) { var t = start; start = end; end = t; } | |
| 127 | + for (var i = start; i <= end; i += 1) { | |
| 128 | + var line = document.getElementById('L' + i); | |
| 129 | + if (line) line.classList.add('range-hl'); | |
| 130 | + } | |
| 131 | + var first = document.getElementById('L' + start); | |
| 132 | + if (first && match[2]) first.scrollIntoView({ block: 'center' }); | |
| 133 | + }; | |
| 134 | + applyRange(); | |
| 135 | + window.addEventListener('hashchange', applyRange); | |
| 136 | + // Shift-click a line number to extend the selection into a range. | |
| 137 | + codeView.addEventListener('click', function (event) { | |
| 138 | + var anchor = event.target.closest('a.ln'); | |
| 139 | + if (!anchor || !event.shiftKey) return; | |
| 140 | + event.preventDefault(); | |
| 141 | + var current = /^#L(\d+)/.exec(location.hash); | |
| 142 | + var clicked = /^#?L?(\d+)/.exec(anchor.getAttribute('href').slice(1)); | |
| 143 | + if (current && clicked) { | |
| 144 | + history.replaceState(null, '', '#L' + current[1] + '-L' + clicked[1]); | |
| 145 | + applyRange(); | |
| 146 | + } | |
| 147 | + }); | |
| 148 | + } | |
| 149 | + | |
| 150 | + /* ── Mermaid: lazy-load only when present ─── */ | |
| 151 | + var mermaidBlocks = document.querySelectorAll('.mermaid-block'); | |
| 152 | + if (mermaidBlocks.length > 0) { | |
| 153 | + var script = document.createElement('script'); | |
| 154 | + script.src = '/assets/vendor/mermaid.min.js'; | |
| 155 | + script.onload = function () { | |
| 156 | + if (!window.mermaid) return; | |
| 157 | + var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| 158 | + window.mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict' }); | |
| 159 | + mermaidBlocks.forEach(function (block, index) { | |
| 160 | + var src = block.querySelector('.mermaid-src'); | |
| 161 | + var target = block.querySelector('.mermaid-target'); | |
| 162 | + if (!src || !target) return; | |
| 163 | + window.mermaid | |
| 164 | + .render('mermaid-svg-' + index, src.textContent) | |
| 165 | + .then(function (result) { | |
| 166 | + target.innerHTML = result.svg; // mermaid output, securityLevel strict | |
| 167 | + block.setAttribute('data-rendered', '1'); | |
| 168 | + }) | |
| 169 | + .catch(function () { | |
| 170 | + block.setAttribute('data-rendered', '0'); | |
| 171 | + }); | |
| 172 | + }); | |
| 173 | + }; | |
| 174 | + document.body.appendChild(script); | |
| 175 | + } | |
| 176 | +})(); | |
added
src/web/assets/js/theme-boot.js
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/routes.mjs
+626 −0
Large diff (626 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/blame.njk
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/blob.njk
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/branches.njk
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/commit.njk
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/commits.njk
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/error.njk
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/home.njk
+108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/layout.njk
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/partials/file-table.njk
+40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/partials/repo-card.njk
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/partials/repo-header.njk
+47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/repo.njk
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/search.njk
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/tags.njk
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
src/web/views/tree.njk
+36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff