import { existsSync, promises as fs } from 'fs'; import path from 'path'; import { readEnv } from '../app-config'; /** * In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with * the app — the same content that CI mirrors to the public GitHub wiki. Reading * from disk keeps the help pages pinned to the running version (a v1.2 install * shows v1.2 docs, not whatever `main` says) and works offline. * * If that directory can't be resolved — an unusual layout, an image built without * it — we fall back to fetching from the GitHub wiki over the network and caching * hourly, so help degrades instead of disappearing. The client never talks to * GitHub directly either way; images are proxied through /api/help/asset. */ const REPO = 'liketrek/TREK'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`; const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** * `server/{src,dist}/services` both sit three levels under the repo root, so this * one anchor resolves in dev, a built source install, vitest, and Docker (where * the Dockerfile copies `wiki/` to /app/wiki). `process.cwd()` would not — Docker * runs the server from /app/server. */ const WIKI_DIR = readEnv().paths.wikiDir ?? path.join(__dirname, '..', '..', '..', 'wiki'); /** * Probe for the sidebar rather than the bare directory: an empty or half-copied * `wiki/` should fall back to GitHub, not serve an empty table of contents. */ const useLocalWiki = existsSync(path.join(WIKI_DIR, '_Sidebar.md')); if (!useLocalWiki) { console.warn( `[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`, ); } export class WikiNotFound extends Error { status = 404; } interface TextEntry { data: string; ts: number; } const textCache = new Map(); const assetCache = new Map(); const fresh = (ts: number): boolean => Date.now() - ts < TTL_MS; /** Resolve a path inside the wiki dir, refusing anything that escapes it. */ function resolveInWiki(rel: string): string { const root = path.resolve(WIKI_DIR); const full = path.resolve(root, rel); if (full !== root && !full.startsWith(root + path.sep)) throw new WikiNotFound(rel); return full; } /** Fetch a wiki text file: local disk, or GitHub with cache → stale-cache fallback. */ async function fetchText(file: string): Promise { if (useLocalWiki) { try { return await fs.readFile(resolveInWiki(file), 'utf8'); } catch (err) { if (err instanceof WikiNotFound) throw err; if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(file); throw err; } } const cached = textCache.get(file); if (cached && fresh(cached.ts)) return cached.data; try { const res = await fetch(`${RAW_BASE}/${encodeURIComponent(file)}`, { headers: { 'User-Agent': 'TREK-help', Accept: 'text/plain' }, }); if (res.ok) { const text = await res.text(); textCache.set(file, { data: text, ts: Date.now() }); return text; } if (res.status === 404) throw new WikiNotFound(file); } catch (err) { if (err instanceof WikiNotFound) throw err; // network/parse error — fall through to stale cache } if (cached) return cached.data; // serve stale rather than fail throw new WikiNotFound(file); } export interface WikiNavItem { title: string; slug: string; } export interface WikiNavSection { title: string; pages: WikiNavItem[]; } /** Parse the wiki `_Sidebar.md` into ordered sections of `[[Title|Slug]]` links. */ function parseSidebar(md: string): WikiNavSection[] { const sections: WikiNavSection[] = []; let current: WikiNavSection | null = null; for (const raw of md.split('\n')) { const heading = raw.match(/^#{1,4}\s+(.+?)\s*$/); if (heading) { current = { title: heading[1].replace(/[*_`]/g, '').trim(), pages: [] }; sections.push(current); continue; } const link = raw.match(/^\s*[-*]\s*\[\[([^\]]+)\]\]/); if (link) { if (!current) { current = { title: '', pages: [] }; sections.push(current); } const inner = link[1]; const [title, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner]; const slug = slugRaw.trim().replace(/\s+/g, '-'); if (SLUG_RE.test(slug)) current.pages.push({ title: title.trim(), slug }); } } return sections.filter((s) => s.pages.length > 0); } /** Rewrite GitHub-wiki `[[..]]` links to /help routes and proxy relative images. */ function processMarkdown(md: string): string { // Strip HTML comments (e.g. `` placeholders) — the // markdown renderer would otherwise surface them as raw text. let out = md.replace(//g, ''); out = out.replace(/\[\[([^\]]+)\]\]/g, (_m, inner: string) => { const [titleRaw, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner]; const slug = slugRaw.trim().replace(/\s+/g, '-'); // `[[Plugin Development#talking-to-plugins|Plugin-Development]]` must not // render its anchor as visible link text. const [title, anchor] = titleRaw.includes('#') ? titleRaw.split('#') : [titleRaw, '']; const hash = anchor ? `#${anchor.trim()}` : ''; return `[${title.trim()}](/help/${slug}${hash})`; }); out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, (m, alt: string, url: string) => { if (/^https?:\/\//i.test(url) || url.startsWith('/api/help/asset/')) return m; const clean = url.replace(/^\.?\//, '').replace(/^wiki\//, ''); return `![${alt}](/api/help/asset/${clean})`; }); // Bare relative links — `[Currencies](Currencies)`, the native GitHub-wiki // spelling and by far the most common in these pages (455 of them across 81 // files, against 114 `[[..]]` links). GitHub resolves them against the wiki // root; in-app they used to fall through to HelpPage's external-link branch // and open a dead tab. Rewriting them here fixes every page at once and keeps // the source GitHub-compatible, so contributors can keep writing either form. // // Runs last: `[[..]]` links and images have already become absolute paths by // this point, so the leading-slash guard skips them. out = outsideCode(out, (segment) => segment.replace(/(^|[^!])\[([^\]]+)\]\(([^)\s]+)\)/g, (m, prefix: string, text: string, url: string) => { if (/^(https?:|mailto:|tel:|#|\/)/i.test(url)) return m; const [pageRaw, anchor] = url.includes('#') ? url.split('#') : [url, '']; const page = pageRaw.replace(/^\.?\//, '').replace(/\.md$/i, '').trim(); if (!page || !SLUG_RE.test(page)) return m; return `${prefix}[${text}](/help/${page}${anchor ? `#${anchor}` : ''})`; }), ); return out; } /** * Apply `fn` to the parts of the markdown that are NOT code, leaving fenced * blocks and inline spans untouched. * * Without this the link rewriter corrupts code samples: Plugin-Development.md * documents `actions[key](ctx)`, which reads as a markdown link and would be * rewritten to `actions[key](/help/ctx)` inside what is supposed to be a * verbatim snippet. */ function outsideCode(md: string, fn: (segment: string) => string): string { // Alternation order matters: fenced blocks first, so a ``` fence containing // backticks is consumed whole rather than being split by the inline rule. const CODE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g; const parts = md.split(CODE); // split() with a capturing group yields [text, code, text, code, …]. return parts.map((part, i) => (i % 2 === 1 ? part : fn(part))).join(''); } function extractTitle(md: string, fallback: string): string { const h1 = md.match(/^#\s+(.+?)\s*$/m); return h1 ? h1[1].replace(/[*_`]/g, '').trim() : fallback.replace(/-/g, ' '); } export interface WikiPage { slug: string; title: string; markdown: string; } /** True when help is served from the bundled wiki rather than fetched from GitHub. */ export const isLocalWiki = (): boolean => useLocalWiki; export async function getWikiIndex(): Promise<{ sections: WikiNavSection[] }> { const md = await fetchText('_Sidebar.md'); return { sections: parseSidebar(md) }; } export async function getWikiPage(slug: string): Promise { if (!SLUG_RE.test(slug)) throw new WikiNotFound(slug); const md = await fetchText(`${slug}.md`); return { slug, title: extractTitle(md, slug), markdown: processMarkdown(md) }; } const ASSET_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', }; /** Read a wiki image from disk, or proxy it from GitHub so the browser never calls it directly. */ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; type: string }> { // Defend against traversal; allow nested image folders. if (assetPath.includes('..') || !/^[A-Za-z0-9/._-]+$/.test(assetPath)) throw new WikiNotFound(assetPath); const ext = path.extname(assetPath).toLowerCase(); const type = ASSET_TYPES[ext]; if (!type) throw new WikiNotFound(assetPath); if (useLocalWiki) { try { // resolveInWiki re-checks containment: the regex above is a filter, this is the boundary. const buf = await fs.readFile(resolveInWiki(assetPath)); return { buf, type }; } catch (err) { if (err instanceof WikiNotFound) throw err; if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(assetPath); throw err; } } const cached = assetCache.get(assetPath); if (cached && fresh(cached.ts)) return { buf: cached.buf, type: cached.type }; try { const res = await fetch(`${RAW_BASE}/${assetPath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'TREK-help' }, }); if (res.ok) { const buf = Buffer.from(await res.arrayBuffer()); assetCache.set(assetPath, { buf, type, ts: Date.now() }); return { buf, type }; } } catch { /* fall through */ } if (cached) return { buf: cached.buf, type: cached.type }; throw new WikiNotFound(assetPath); }