diff --git a/README.md b/README.md index 472bce0..e9969ee 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ A lightweight command-line tool that converts markdown files to styled HTML and opens them in your default browser. Supports GitHub Flavored Markdown, Mermaid diagrams, embedded images, and automatic theme detection. +> **This fork adds corporate theming tools** (see [`contrib/`](contrib/README.md)): +> **Style Inspector**, a Chrome extension that extracts any website's design +> tokens (logo, colors, fonts, paddings) and exports them as a theme file, plus +> **mdview-themed**, an offline bash viewer that renders markdown in that +> style — your company's colors, fonts and logo on every document, mermaid +> diagrams re-colored to match. Themes are plain JSON files you can save, +> share and install with one command. + ## Features - **GitHub Flavored Markdown** - Full support for tables, task lists, strikethrough, and more diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000..bb0bb8f --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,55 @@ +# contrib: Style Inspector + themed viewer + +Companion tools that let `mdview` users render markdown in the visual style of +any website — extract a site's design tokens with a Chrome extension, save them +as a theme file, and apply that theme to a markdown viewer. + +## What's inside + +| Path | What it is | +|------|------------| +| `chrome-extension/` | **Style Inspector** — a Chrome extension (Manifest V3) that extracts a site's logo, colors, fonts and paddings, and exports them as JSON | +| `mdview-themed` | A self-contained bash markdown viewer (marked + mermaid, fully offline) that renders `.md` files using an installable theme | +| `install-mdview-themed.sh` | Installer: copies the script to `~/.local/bin` and fetches marked/mermaid | +| `mdview-theme.example.json` | Example theme file | + +## Workflow + +1. Install the extension: `chrome://extensions` → Developer mode → *Load unpacked* → `contrib/chrome-extension`. +2. Open any website, click the extension icon, press **Inject into mdview** — + the extension derives a theme from the page (background, text color, the two + most saturated colors as accents, font stack, logo) and downloads + `mdview-theme.json`. +3. Apply it: + + ```sh + ./contrib/install-mdview-themed.sh + mdview-themed --install-theme ~/Downloads/mdview-theme.json + mdview-themed README.md + ``` + +Every markdown file now opens styled like the site: its colors, its fonts, its +logo in the header. Mermaid diagrams are re-colored to match. + +`--reset-theme` restores the built-in dark theme, `--show-theme` prints the +active one. + +## Theme file format + +```json +{ + "bg": "#FFFFFF", // page background + "text": "#1A1A1A", // main text color + "accent": "#7C88FC", // links, table headers, rules + "accent2": "#FF8562", // hover, blockquote border + "surface": "#F5F5F7", // code blocks, quotes, even table rows + "line": "#E6E6E6", // borders + "fonts": ["Manrope", "Roboto", "Arial"], + "logo": "https://…/logo.svg", // URL or data URI; downloaded on install + "site": "example.com" // logo links here +} +``` + +Only `bg` and `text` are required. The extension's **Export style** / **Import +style** buttons additionally let you save and share the full extracted token +set between machines and teammates. diff --git a/contrib/chrome-extension/README.md b/contrib/chrome-extension/README.md new file mode 100644 index 0000000..8816d7c --- /dev/null +++ b/contrib/chrome-extension/README.md @@ -0,0 +1,91 @@ +# Style Inspector — Chrome Extension + +A lightweight Chrome extension that instantly extracts the design system of any website: fonts, colors, paddings, and logo URL — all in one click. + +## Demo + +[![Style Inspector Demo](https://img.youtube.com/vi/NnzUSkLEZ3M/0.jpg)](https://www.youtube.com/watch?v=NnzUSkLEZ3M) + +## What it does + +Open any website, click the extension icon — and you'll see: + +- **Logo** — image preview with a direct link (falls back to favicon if no logo found) +- **Background colors** — HEX chips, click any chip to copy the code +- **Text colors** — same +- **Fonts** — list with a live preview (`Aa Бб`) in the actual font +- **Paddings** — all unique padding values used on the page + +At the bottom there's a **ready-to-copy text summary**, e.g.: + +``` +Site: business-pad.com +Logo: https://business-pad.com/assets/logo.svg +Background colors: #FFFFFF, #F5F7FA, #1A1A2E +Text colors: #333333, #666666, #0056D2 +Fonts: Inter, Roboto +Paddings: 8px, 16px, 24px, 32px, 48px +``` + +Paste it into a brief, a design chat, or a Notion doc — done. + +## Export / Import / mdview + +Three buttons in the popup toolbar: + +- **Export style** — saves everything the inspector collected as a JSON file + (`style-.json`). Share it with a teammate or archive it. +- **Import style** — loads a previously exported JSON file and renders it in the + popup, even on pages the extension can't access (e.g. `chrome://` pages). +- **Inject into mdview** — derives a ready-to-use theme (`mdview-theme.json`) + from the page's palette: background, text, the two most saturated colors as + accents, surface/border tones, and the font stack. Apply it to the + [mdview](https://github.com/neo37/mdview) markdown viewer: + + ``` + mdview --install-theme ~/Downloads/mdview-theme.json + ``` + + From then on every markdown file you open with `mdview` is rendered in the + site's corporate style, logo included. `mdview --reset-theme` restores the + default theme. + +## Installation (Developer mode) + +1. Download or clone this repository +2. Open Chrome → `chrome://extensions/` +3. Enable **Developer mode** (top right toggle) +4. Click **"Load unpacked"** +5. Select the `style-inspector` folder + +No build step required — it's pure HTML + JS. + +## How it works + +When you click the popup, the extension injects `content.js` into the active tab using the Chrome Scripting API. The script scans computed styles of key elements (`header`, `nav`, `h1–h4`, `p`, `button`, `a`, etc.), deduplicates values by frequency, and returns the top results to the popup. + +Logo detection checks: +- `` with "logo" in `src`, `alt`, `class`, or `id` +- First `` inside `header` or `nav` +- Inline SVG elements with a logo-related class +- `` as a fallback + +## Files + +| File | Purpose | +|------|---------| +| `manifest.json` | Extension config (Manifest V3) | +| `content.js` | Page analysis — extracts styles and logo | +| `popup.html` | Extension popup UI | +| `popup.js` | Renders results, handles copy | + +## Permissions + +- `activeTab` — access the current tab when the popup is opened +- `scripting` — inject the analysis script into the page + +No data is sent anywhere. Everything runs locally. + +## License + +MIT diff --git a/contrib/chrome-extension/content.js b/contrib/chrome-extension/content.js new file mode 100644 index 0000000..4bd0b7e --- /dev/null +++ b/contrib/chrome-extension/content.js @@ -0,0 +1,111 @@ +function rgbToHex(rgb) { + const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!m) return null; + return '#' + [m[1], m[2], m[3]] + .map(n => parseInt(n).toString(16).padStart(2, '0')) + .join('').toUpperCase(); +} + +function getLuminance(hex) { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +function analyzeStyles() { + // --- Logo --- + let logo = null; + + const logoImgSelectors = [ + 'a[href="/"] img', 'header img', 'nav img', + 'img[src*="logo" i]', 'img[alt*="logo" i]', + 'img[class*="logo" i]', 'img[id*="logo" i]', + '[class*="logo" i] img', '[id*="logo" i] img', + '.navbar-brand img', '.brand img', + ]; + for (const sel of logoImgSelectors) { + const el = document.querySelector(sel); + if (el && el.src && !el.src.startsWith('data:')) { logo = el.src; break; } + } + + // SVG logo + if (!logo) { + const svgEl = document.querySelector( + '[class*="logo" i] svg, [id*="logo" i] svg, header svg, nav svg, .navbar-brand svg' + ); + if (svgEl) logo = '__SVG_INLINE__'; + } + + // Favicon fallback + const favicon = document.querySelector('link[rel~="icon"]'); + const faviconUrl = favicon ? favicon.href : null; + + // og:image + const ogImage = document.querySelector('meta[property="og:image"]'); + const ogUrl = ogImage ? ogImage.content : null; + + // --- Colors & Fonts & Paddings --- + const bgColorMap = new Map(); // hex -> count + const textColorMap = new Map(); // hex -> count + const fontMap = new Map(); // family -> count + const paddingSet = new Set(); + + const els = document.querySelectorAll( + 'body, header, nav, main, footer, section, article, aside, ' + + 'h1, h2, h3, h4, p, a, button, span, div, li, input, label, ' + + '[class*="btn"], [class*="card"], [class*="hero"], [class*="banner"]' + ); + + els.forEach(el => { + const s = window.getComputedStyle(el); + + // background-color + const bg = s.backgroundColor; + if (bg && bg !== 'rgba(0, 0, 0, 0)') { + const hex = rgbToHex(bg); + if (hex && hex !== '#000000' || bg.includes('0, 0, 0')) { + bgColorMap.set(hex, (bgColorMap.get(hex) || 0) + 1); + } + } + + // color + const col = s.color; + if (col) { + const hex = rgbToHex(col); + if (hex) textColorMap.set(hex, (textColorMap.get(hex) || 0) + 1); + } + + // font-family (first family only) + const rawFont = s.fontFamily; + if (rawFont) { + const family = rawFont.split(',')[0].trim().replace(/['"]/g, ''); + if (family) fontMap.set(family, (fontMap.get(family) || 0) + 1); + } + + // paddings + ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'].forEach(prop => { + const val = parseFloat(s[prop]); + if (val > 0 && val < 200) paddingSet.add(val); + }); + }); + + // Sort by frequency, dedupe, limit + const sortByCount = (map) => + [...map.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k); + + const bgColors = sortByCount(bgColorMap).slice(0, 8); + const textColors = sortByCount(textColorMap).slice(0, 8); + + // Unique sorted paddings + const paddings = [...paddingSet] + .sort((a, b) => a - b) + .map(v => v + 'px') + .slice(0, 12); + + const fonts = sortByCount(fontMap).slice(0, 6); + + return { logo, faviconUrl, ogUrl, bgColors, textColors, fonts, paddings }; +} + +analyzeStyles(); diff --git a/contrib/chrome-extension/manifest.json b/contrib/chrome-extension/manifest.json new file mode 100644 index 0000000..e6d7b58 --- /dev/null +++ b/contrib/chrome-extension/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "Style Inspector", + "version": "1.1", + "description": "Извлекает шрифты, цвета, отступы и логотип с любого сайта. Экспорт/импорт стиля и тема для mdview", + "permissions": ["activeTab", "scripting"], + "action": { + "default_popup": "popup.html", + "default_title": "Style Inspector" + } +} diff --git a/contrib/chrome-extension/popup.html b/contrib/chrome-extension/popup.html new file mode 100644 index 0000000..51d86f5 --- /dev/null +++ b/contrib/chrome-extension/popup.html @@ -0,0 +1,199 @@ + + + + + + + +
+

Style Inspector

+ +
+
Анализирую страницу...
+
+ + + + +
+
+ + + + diff --git a/contrib/chrome-extension/popup.js b/contrib/chrome-extension/popup.js new file mode 100644 index 0000000..f8f19b1 --- /dev/null +++ b/contrib/chrome-extension/popup.js @@ -0,0 +1,304 @@ +let currentData = null; +let currentHost = ''; + +function colorChip(hex) { + const chip = document.createElement('div'); + chip.className = 'color-chip'; + chip.title = 'Нажми чтобы скопировать'; + + const swatch = document.createElement('div'); + swatch.className = 'color-swatch'; + swatch.style.background = hex; + + const label = document.createElement('span'); + label.className = 'color-hex'; + label.textContent = hex; + + chip.appendChild(swatch); + chip.appendChild(label); + chip.addEventListener('click', () => { + navigator.clipboard.writeText(hex); + label.textContent = 'скопировано!'; + setTimeout(() => { label.textContent = hex; }, 1200); + }); + return chip; +} + +function renderColors(containerId, colors) { + const el = document.getElementById(containerId); + el.innerHTML = ''; + if (!colors.length) { + el.innerHTML = 'не найдено'; + return; + } + colors.forEach(hex => el.appendChild(colorChip(hex))); +} + +function buildCopyText(data, url) { + const lines = [`Сайт: ${url}`, '']; + + if (data.logo && data.logo !== '__SVG_INLINE__') { + lines.push(`Логотип: ${data.logo}`); + } else if (data.logo === '__SVG_INLINE__') { + lines.push('Логотип: SVG (встроенный)'); + } else if (data.faviconUrl) { + lines.push(`Фавиконка: ${data.faviconUrl}`); + } + + if (data.bgColors.length) { + lines.push(`Цвет фона: ${data.bgColors.slice(0, 4).join(', ')}`); + } + if (data.textColors.length) { + lines.push(`Цвет текста: ${data.textColors.slice(0, 4).join(', ')}`); + } + if (data.fonts.length) { + lines.push(`Шрифты: ${data.fonts.join(', ')}`); + } + if (data.paddings.length) { + lines.push(`Отступы: ${data.paddings.join(', ')}`); + } + + return lines.join('\n'); +} + +// ---------- вывод mdview-темы из собранного стиля ---------- + +function hexToRgb(hex) { + return [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + ]; +} + +function luminance(hex) { + const [r, g, b] = hexToRgb(hex).map(v => v / 255); + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +function saturation(hex) { + const [r, g, b] = hexToRgb(hex).map(v => v / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + return max === 0 ? 0 : (max - min) / max; +} + +function blend(hexA, hexB, t) { + const a = hexToRgb(hexA), b = hexToRgb(hexB); + return '#' + a.map((v, i) => Math.round(v + (b[i] - v) * t) + .toString(16).padStart(2, '0')).join('').toUpperCase(); +} + +function deriveMdviewTheme(data, host) { + const bgColors = data.bgColors || []; + const textColors = data.textColors || []; + const all = [...bgColors, ...textColors]; + + const bg = bgColors[0] || '#FFFFFF'; + const text = textColors[0] || (luminance(bg) > 0.5 ? '#1A1A1A' : '#FFFFFF'); + + // акценты — самые насыщенные цвета палитры + const accents = [...new Set(all)] + .filter(h => saturation(h) > 0.35) + .sort((a, b) => saturation(b) - saturation(a)); + const accent = accents[0] || '#7C88FC'; + const accent2 = accents.find(h => h !== accent) || '#FF8562'; + + // поверхность — фоновый цвет, близкий по светлоте к основному, иначе лёгкий сдвиг к тексту + const surface = bgColors.slice(1).find(c => + Math.abs(luminance(c) - luminance(bg)) < 0.3) || blend(bg, text, 0.06); + const line = blend(bg, text, 0.18); + + return { + format: 'mdview-theme', + version: 1, + name: host || 'imported style', + site: host || '', + logo: (data.logo && data.logo !== '__SVG_INLINE__') ? data.logo : (data.faviconUrl || ''), + bg, text, accent, accent2, surface, line, + fonts: data.fonts || [], + }; +} + +// ---------- скачивание / импорт файлов ---------- + +function downloadJson(filename, obj) { + const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +} + +function showHint(text) { + const el = document.getElementById('toolbar-hint'); + el.textContent = text; + el.style.display = 'block'; +} + +// ---------- рендер ---------- + +function render(data, host, imported) { + currentData = data; + currentHost = host; + + document.getElementById('domain').innerHTML = imported + ? `${host}импорт` + : host; + document.getElementById('status').style.display = 'none'; + document.getElementById('content').style.display = 'block'; + + // Logo + const logoEl = document.getElementById('logo-content'); + logoEl.innerHTML = ''; + if (data.logo && data.logo !== '__SVG_INLINE__') { + const img = document.createElement('img'); + img.className = 'logo-preview'; + img.src = data.logo; + img.onerror = () => img.remove(); + + const link = document.createElement('a'); + link.className = 'logo-url'; + link.href = data.logo; + link.target = '_blank'; + link.textContent = data.logo; + + const row = document.createElement('div'); + row.className = 'logo-row'; + row.appendChild(img); + row.appendChild(link); + logoEl.appendChild(row); + } else if (data.logo === '__SVG_INLINE__') { + logoEl.innerHTML = 'SVG логотип (встроен в HTML)'; + if (data.faviconUrl) { + logoEl.innerHTML += `
${data.faviconUrl}`; + } + } else if (data.faviconUrl) { + const link = document.createElement('a'); + link.className = 'logo-url'; + link.href = data.faviconUrl; + link.target = '_blank'; + link.textContent = data.faviconUrl; + const note = document.createElement('span'); + note.style.cssText = 'font-size:11px;color:#555;display:block;margin-bottom:4px'; + note.textContent = 'Логотип не найден, фавиконка:'; + logoEl.appendChild(note); + logoEl.appendChild(link); + } else { + logoEl.innerHTML = 'логотип не найден'; + } + + renderColors('bg-colors', data.bgColors); + renderColors('text-colors', data.textColors); + + // Fonts + const fontsEl = document.getElementById('fonts'); + fontsEl.innerHTML = ''; + if (!data.fonts.length) { + fontsEl.innerHTML = 'не найдено'; + } else { + data.fonts.forEach(font => { + const item = document.createElement('div'); + item.className = 'font-item'; + item.innerHTML = `${font}Aa Бб`; + fontsEl.appendChild(item); + }); + } + + // Paddings + const padEl = document.getElementById('paddings'); + padEl.innerHTML = ''; + if (!data.paddings.length) { + padEl.innerHTML = 'не найдено'; + } else { + data.paddings.forEach(p => { + const chip = document.createElement('span'); + chip.className = 'pad-chip'; + chip.textContent = p; + padEl.appendChild(chip); + }); + } + + document.getElementById('copy-text').value = buildCopyText(data, host); +} + +// ---------- кнопки ---------- + +document.getElementById('copy-btn').addEventListener('click', () => { + navigator.clipboard.writeText(document.getElementById('copy-text').value); + const btn = document.getElementById('copy-btn'); + btn.textContent = 'Скопировано!'; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = 'Скопировать'; + btn.classList.remove('copied'); + }, 1500); +}); + +document.getElementById('export-btn').addEventListener('click', () => { + if (!currentData) { showHint('Нет данных для экспорта — открой обычную страницу.'); return; } + const safeHost = (currentHost || 'style').replace(/[^\w.-]/g, '_'); + downloadJson(`style-${safeHost}.json`, { + format: 'style-inspector', + version: 1, + site: currentHost, + exportedAt: new Date().toISOString(), + data: currentData, + }); +}); + +document.getElementById('import-btn').addEventListener('click', () => { + document.getElementById('import-file').click(); +}); + +document.getElementById('import-file').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const parsed = JSON.parse(await file.text()); + // принимаем и обёртку экспорта, и «голые» данные + const data = parsed.data || parsed; + if (!Array.isArray(data.bgColors) || !Array.isArray(data.textColors)) { + throw new Error('bad format'); + } + data.fonts = data.fonts || []; + data.paddings = data.paddings || []; + render(data, parsed.site || file.name, true); + showHint(`Импортирован стиль: ${file.name}`); + } catch { + showHint('Не удалось прочитать файл — это не экспорт Style Inspector.'); + } + e.target.value = ''; +}); + +document.getElementById('mdview-btn').addEventListener('click', () => { + if (!currentData) { showHint('Нет данных — открой обычную страницу или импортируй стиль.'); return; } + const theme = deriveMdviewTheme(currentData, currentHost); + downloadJson('mdview-theme.json', theme); + showHint('Тема скачана. Внедрить: mdview --install-theme ~/Загрузки/mdview-theme.json'); +}); + +// ---------- запуск ---------- + +async function run() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const host = tab.url ? new URL(tab.url).hostname : ''; + document.getElementById('domain').textContent = host; + + let data; + try { + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['content.js'], + }); + data = results[0].result; + } catch (e) { + document.getElementById('status').textContent = + 'Нет доступа к этой странице (системная страница Chrome). Можно импортировать сохранённый стиль.'; + return; + } + + render(data, host, false); +} + +run(); diff --git a/contrib/install-mdview-themed.sh b/contrib/install-mdview-themed.sh new file mode 100755 index 0000000..de0b5af --- /dev/null +++ b/contrib/install-mdview-themed.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Installs mdview-themed: copies the script to ~/.local/bin and downloads +# marked + mermaid into ~/.local/share/mdview-themed for offline rendering. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +BIN="$HOME/.local/bin" +LIB="$HOME/.local/share/mdview-themed" + +mkdir -p "$BIN" "$LIB" +install -m 755 "$HERE/mdview-themed" "$BIN/mdview-themed" + +[ -f "$LIB/marked.min.js" ] || curl -fsSL -o "$LIB/marked.min.js" "https://cdn.jsdelivr.net/npm/marked/marked.min.js" +[ -f "$LIB/mermaid.min.js" ] || curl -fsSL -o "$LIB/mermaid.min.js" "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js" + +echo "installed: $BIN/mdview-themed" +echo "try: mdview-themed README.md" +echo "theme: mdview-themed --install-theme mdview-theme.json" diff --git a/contrib/mdview-theme.example.json b/contrib/mdview-theme.example.json new file mode 100644 index 0000000..9c9a0cc --- /dev/null +++ b/contrib/mdview-theme.example.json @@ -0,0 +1,14 @@ +{ + "format": "mdview-theme", + "version": 1, + "name": "example corporate theme", + "site": "example.com", + "logo": "https://example.com/logo.svg", + "bg": "#FFFFFF", + "text": "#1A1A1A", + "accent": "#7C88FC", + "accent2": "#FF8562", + "surface": "#F5F5F7", + "line": "#E6E6E6", + "fonts": ["Manrope", "Roboto", "Arial"] +} diff --git a/contrib/mdview-themed b/contrib/mdview-themed new file mode 100755 index 0000000..7228b09 --- /dev/null +++ b/contrib/mdview-themed @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# mdview-themed — render markdown (with mermaid diagrams) in the browser, offline, +# using a corporate theme installed from a JSON file. +# +# Themes: +# mdview-themed --install-theme install a theme (export one with the +# Style Inspector Chrome extension, see +# contrib/chrome-extension) +# mdview-themed --reset-theme restore the built-in dark theme +# mdview-themed --show-theme print the active theme +# theme.json format: +# {"bg","text","accent","accent2","surface","line","fonts":[],"logo","site","name"} +set -euo pipefail +LIB="$HOME/.local/share/mdview-themed" +CONF="$HOME/.config/mdview-themed" +THEME_JSON="$CONF/theme.json" + +usage(){ echo "usage: mdview-themed | --install-theme | --reset-theme | --show-theme"; exit 1; } +[ $# -lt 1 ] && usage + +case "$1" in + --install-theme) + [ $# -lt 2 ] && usage + SRC_THEME="$2" + [ ! -f "$SRC_THEME" ] && { echo "no such file: $SRC_THEME"; exit 1; } + python3 - "$SRC_THEME" <<'PY' || { echo "error: not an mdview theme (bg/text keys required)"; exit 1; } +import json,sys +t=json.load(open(sys.argv[1])) +assert isinstance(t,dict) and t.get("bg") and t.get("text") +PY + mkdir -p "$CONF" + cp "$SRC_THEME" "$THEME_JSON" + rm -f "$CONF"/logo.* + LOGO_URL="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("logo") or "")' "$THEME_JSON")" + if [ -n "$LOGO_URL" ]; then + case "$LOGO_URL" in + data:image/svg*) python3 - "$LOGO_URL" > "$CONF/logo.svg" <<'PY' +import sys,base64,urllib.parse +u=sys.argv[1]; meta,payload=u.split(",",1) +data=base64.b64decode(payload) if ";base64" in meta else urllib.parse.unquote(payload).encode() +sys.stdout.buffer.write(data) +PY + ;; + data:image/png*) python3 -c 'import sys,base64;sys.stdout.buffer.write(base64.b64decode(sys.argv[1].split(",",1)[1]))' "$LOGO_URL" > "$CONF/logo.png" ;; + http*) + EXT="${LOGO_URL##*.}"; EXT="${EXT%%\?*}" + case "$EXT" in svg|png|jpg|jpeg|webp|gif|ico) ;; *) EXT=png ;; esac + curl -fsSL --max-time 15 -o "$CONF/logo.$EXT" "$LOGO_URL" \ + || echo "warning: could not download logo ($LOGO_URL), rendering without it" + ;; + esac + fi + echo "theme installed: $THEME_JSON" + python3 -c 'import json,sys;t=json.load(open(sys.argv[1]));print(" site:",t.get("site","-"));print(" bg:",t["bg"],"text:",t["text"],"accent:",t.get("accent","-"))' "$THEME_JSON" + exit 0 ;; + --reset-theme) + rm -f "$THEME_JSON" "$CONF"/logo.* + echo "built-in dark theme restored" + exit 0 ;; + --show-theme) + if [ -f "$THEME_JSON" ]; then cat "$THEME_JSON"; else echo "built-in dark theme is active"; fi + exit 0 ;; + -*) usage ;; +esac + +SRC="$1" +[ ! -f "$SRC" ] && { echo "no such file: $SRC"; exit 1; } + +# theme tokens (defaults: neutral dark) +T_BG="#1A1A1A"; T_TEXT="#FFFFFF"; T_ACCENT="#7C88FC"; T_ACCENT2="#FF8562" +T_SURFACE="#000000"; T_LINE="#3A3A3A" +T_FONTS="system-ui,Arial,sans-serif" +T_SITE="" +LOGO_FILE=""; LOGO_MIME="image/svg+xml" + +if [ -f "$THEME_JSON" ]; then + eval "$(python3 - "$THEME_JSON" <<'PY' +import json,sys,shlex +t=json.load(open(sys.argv[1])) +d={"bg":"#1A1A1A","text":"#FFFFFF","accent":"#7C88FC","accent2":"#FF8562", + "surface":"#000000","line":"#3A3A3A"} +def g(k): return t.get(k) or d[k] +fonts=t.get("fonts") or [] +stack=",".join(f"'{f}'" for f in fonts[:3]) + ("," if fonts else "") + "Arial,sans-serif" +site=t.get("site") or "" +if site and not site.startswith("http"): site="https://"+site +print(f'T_BG={shlex.quote(g("bg"))}') +print(f'T_TEXT={shlex.quote(g("text"))}') +print(f'T_ACCENT={shlex.quote(g("accent"))}') +print(f'T_ACCENT2={shlex.quote(g("accent2"))}') +print(f'T_SURFACE={shlex.quote(g("surface"))}') +print(f'T_LINE={shlex.quote(g("line"))}') +print(f'T_FONTS={shlex.quote(stack)}') +print(f'T_SITE={shlex.quote(site)}') +PY +)" + CUSTOM_LOGO="$(ls "$CONF"/logo.* 2>/dev/null | head -1 || true)" + if [ -n "$CUSTOM_LOGO" ]; then + LOGO_FILE="$CUSTOM_LOGO" + case "$CUSTOM_LOGO" in + *.svg) LOGO_MIME="image/svg+xml" ;; + *.png) LOGO_MIME="image/png" ;; + *.jpg|*.jpeg) LOGO_MIME="image/jpeg" ;; + *.webp) LOGO_MIME="image/webp" ;; + *.gif) LOGO_MIME="image/gif" ;; + *.ico) LOGO_MIME="image/x-icon" ;; + esac + fi +fi + +OUT="$(mktemp --suffix=.html)" +TITLE="$(basename "$SRC")" + +# markdown -> base64 so special characters survive HTML embedding +MD_B64="$(base64 -w0 "$SRC")" + +LOGO_HTML="" +if [ -n "$LOGO_FILE" ] && [ -f "$LOGO_FILE" ]; then + LOGO_B64="$(base64 -w0 "$LOGO_FILE")" + LOGO_HTML="\"logo\"" +fi + +# mermaid needs to know whether the background is dark +IS_DARK="$(python3 -c ' +import sys +h=sys.argv[1].lstrip("#") +r,g,b=(int(h[i:i+2],16) for i in (0,2,4)) +print(1 if (0.299*r+0.587*g+0.114*b)/255<0.5 else 0)' "$T_BG")" + +{ +cat < +${TITLE} + + + + +
+ ${LOGO_HTML} + ${TITLE} +
+
+ + +HTML +} > "$OUT" + +if command -v xdg-open >/dev/null 2>&1; then + (xdg-open "$OUT" >/dev/null 2>&1 &) +elif command -v open >/dev/null 2>&1; then + (open "$OUT" >/dev/null 2>&1 &) +fi +echo "opened: $OUT"