import { API, type WikiTreeNode, type WikiPageResponse } from '../utils/api.js'; import { DebugLogger } from '../utils/debug-logger.js'; import { createResizeHandle } from '../utils/dom.js'; import { reportPageContext } from '../utils/ui-commands.js'; declare const marked: { parse(md: string): string }; declare const DOMPurify: { sanitize(html: string): string }; const logger = new DebugLogger('Wiki'); // ── Mobile helpers ────────────────────────────────────────────────────────── const MOBILE_BREAKPOINT = 768; function isMobile(): boolean { return window.innerWidth < MOBILE_BREAKPOINT; } const backBtnStyle = 'display:flex;align-items:center;gap:6px;' + 'padding:8px 12px;margin-bottom:8px;' + 'font-size:13px;color:#6B6560;cursor:pointer;' + 'border:none;background:none;'; function wikilinkToHtml(md: string): string { return md.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, path: string, display?: string) => { const label = display || path.split('/').pop() || path; const href = path.replace(/\.md$/, ''); return `${label}`; } ); } function renderMarkdown(raw: string): string { const stripped = raw.replace(/^---\n[\s\S]*?\n---\n?/, ''); const withLinks = wikilinkToHtml(stripped); try { const html = marked.parse(withLinks); return DOMPurify.sanitize(html); } catch { return DOMPurify.sanitize(withLinks.replace(/\n/g, '
')); } } function renderTreeNode(node: WikiTreeNode, depth: number = 0): string { const indent = depth * 12; if (node.type === 'directory') { const storageKey = `wiki-dir-${node.path || node.name}-d${depth}`; const storedState = localStorage.getItem(storageKey); const isOpen = storedState !== null ? storedState === 'true' : true; const children = (node.children || []).map((c) => renderTreeNode(c, depth + 1)).join(''); const arrow = isOpen ? '\u25BC' : '\u25B6'; const childDisplay = isOpen ? '' : 'display:none;'; return ( `
` + `
` + `${arrow}` + `\u{1F4C1}${escapeHtml(node.name)}
` + `
${children}
` ); } return ( `
` + `${escapeHtml(node.name.replace(/\.md$/, ''))}
` ); } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export class WikiModule { private container: HTMLElement | null = null; private currentPath: string | null = null; private resizeHandler: (() => void) | null = null; private mobileShowingPage = false; private initialized = false; private treeLoadPromise: Promise | null = null; init(): void { if (this.initialized) { return; } this.container = document.getElementById('wiki-content'); if (!this.container) { return; } this.initialized = true; this.resizeHandler = () => this.handleResize(); window.addEventListener('resize', this.resizeHandler); this.treeLoadPromise = this.loadTree(); } private publishListContext(): void { reportPageContext('wiki', { pageType: 'wiki-list', path: null, activePath: this.currentPath, }); } private publishPageContext(page: WikiPageResponse): void { const title = String(page.frontmatter.title ?? '').trim() || page.path.split('/').pop()?.replace(/\.md$/, '') || page.path; reportPageContext( 'wiki', { pageType: 'wiki-page', path: page.path, title, frontmatter: page.frontmatter, content_preview: page.raw.slice(0, 400), }, { type: 'wiki-page', id: page.path } ); } private async loadTree(): Promise { if (!this.container) return; try { const { tree } = await API.getWikiTree(); this.renderLayout(tree); } catch (err) { logger.error('Failed to load wiki tree', err); this.container.innerHTML = '
' + 'Wiki not configured. Enable wiki in config.yaml.
'; } } private renderLayout(tree: WikiTreeNode[]): void { if (!this.container) return; const treeHtml = tree.map((n) => renderTreeNode(n)).join(''); const mobile = isMobile(); const treeStyle = mobile ? 'width:100%;overflow-y:auto;padding-right:0' : 'width:200px;min-width:200px;overflow-y:auto;border-right:1px solid #EDE9E1;padding-right:12px'; const pageDisplay = mobile ? 'display:none;' : ''; const pageStyle = mobile ? `flex:1;overflow-y:auto;${pageDisplay}width:100%` : `flex:1;overflow-y:auto;${pageDisplay}`; this.container.innerHTML = `
` + `
` + `
` + `

Wiki

` + `` + `
` + treeHtml + `
` + `
` + (mobile ? '' : `
Select a page to view.
`) + `
` + '
'; // Attach resize handle to wiki tree panel (desktop only) if (!mobile) { const treePanel = document.getElementById('wiki-tree'); if (treePanel) { createResizeHandle(treePanel, { storageKey: 'wiki-tree-width', minWidth: 120, maxWidth: 500, }); } } // Bind collapsible directory toggles this.container.querySelectorAll('.wiki-tree-dir').forEach((el) => { el.addEventListener('click', () => { const dirEl = el as HTMLElement; const key = dirEl.dataset.storageKey; const arrow = dirEl.querySelector('.wiki-dir-arrow') as HTMLElement; const children = dirEl.parentElement?.querySelector('.wiki-dir-children') as HTMLElement; if (!arrow || !children) return; const isOpen = children.style.display !== 'none'; children.style.display = isOpen ? 'none' : ''; arrow.textContent = isOpen ? '\u25B6' : '\u25BC'; if (key) localStorage.setItem(key, String(!isOpen)); }); }); this.container.querySelectorAll('.wiki-tree-file').forEach((el) => { el.addEventListener('click', () => { const path = (el as HTMLElement).dataset.path; if (path) this.openPage(path); }); }); document.getElementById('wiki-new-btn')?.addEventListener('click', () => this.promptNewPage()); // Auto-open index page only on initial load (no page selected yet) if (!this.currentPath) { const indexNode = tree.find((n) => n.name === 'index.md'); if (!mobile && indexNode) { this.openPage(indexNode.path); } else { this.publishListContext(); } } } private async openPage(path: string): Promise { this.currentPath = path; const pageEl = document.getElementById('wiki-page'); if (!pageEl) return; // Mobile: hide tree, show page if (isMobile()) { this.mobileShowingPage = true; const treeEl = document.getElementById('wiki-tree'); if (treeEl) treeEl.style.display = 'none'; pageEl.style.display = ''; pageEl.style.width = '100%'; } try { const page = await API.getWikiPage(path); this.renderPageView(pageEl, page); this.publishPageContext(page); } catch { pageEl.innerHTML = `
Failed to load ${path}
`; } this.container?.querySelectorAll('.wiki-tree-file').forEach((el) => { const isActive = (el as HTMLElement).dataset.path === path; (el as HTMLElement).style.background = isActive ? '#F5F3EF' : 'transparent'; (el as HTMLElement).style.fontWeight = isActive ? '600' : '400'; }); } private buildBreadcrumb(path: string): string { // e.g. "projects/MyPage.md" -> "Wiki / projects / MyPage" const parts = path.replace(/\.md$/, '').split('/').filter(Boolean); return ['Wiki', ...parts].join(' / '); } private showMobileTree(): void { this.mobileShowingPage = false; this.currentPath = null; const treeEl = document.getElementById('wiki-tree'); const pageEl = document.getElementById('wiki-page'); if (treeEl) treeEl.style.display = ''; if (pageEl) pageEl.style.display = 'none'; this.publishListContext(); } private handleResize(): void { const treeEl = document.getElementById('wiki-tree'); const pageEl = document.getElementById('wiki-page'); if (!treeEl || !pageEl) return; if (isMobile()) { // Mobile: show one panel at a time treeEl.style.width = '100%'; treeEl.style.minWidth = ''; treeEl.style.borderRight = 'none'; treeEl.style.paddingRight = '0'; pageEl.style.width = '100%'; if (this.mobileShowingPage) { treeEl.style.display = 'none'; pageEl.style.display = ''; } else { treeEl.style.display = ''; pageEl.style.display = 'none'; } } else { // Desktop: side-by-side — only set default width if no persisted width treeEl.style.display = ''; const savedWidth = localStorage.getItem('wiki-tree-width'); if (!savedWidth) { treeEl.style.width = '200px'; treeEl.style.minWidth = '200px'; } treeEl.style.borderRight = '1px solid #EDE9E1'; treeEl.style.paddingRight = '12px'; pageEl.style.display = ''; pageEl.style.width = ''; this.mobileShowingPage = false; } } private renderPageView(el: HTMLElement, page: WikiPageResponse): void { const type = (page.frontmatter.type as string) || ''; const confidence = (page.frontmatter.confidence as string) || ''; const meta = [type, confidence].filter(Boolean).join(' · '); const html = renderMarkdown(page.raw); // Mobile back button with breadcrumb const mobileBackHtml = isMobile() && this.currentPath ? `` : ''; el.innerHTML = mobileBackHtml + `
` + `
` + `${meta}` + `` + `
` + `
` + `` + `
${html}
` + `
`; document.getElementById('wiki-back-btn')?.addEventListener('click', () => { this.showMobileTree(); }); document.getElementById('wiki-edit-btn')?.addEventListener('click', () => { this.renderPageEdit(el, page); }); el.querySelectorAll('.wiki-link').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); const wikiPath = (link as HTMLElement).dataset.wikiPath; if (wikiPath) this.openPage(wikiPath); }); }); } private renderPageEdit(el: HTMLElement, page: WikiPageResponse): void { // Obsidian-style: single pane editor replaces the rendered view // Mobile back button preserved const mobileBackHtml = isMobile() && this.currentPath ? `` : ''; const mobile = isMobile(); // Mobile: use calc(100vh - offset) so editor fills the screen // Desktop: flex within parent container const containerStyle = mobile ? 'display:flex;flex-direction:column;width:100%;padding:0 4px' : 'display:flex;flex-direction:column;height:100%;max-width:720px'; const textareaHeight = mobile ? 'height:calc(100vh - 160px);min-height:300px' : 'flex:1;min-height:400px'; // Mobile toolbar: wrap buttons for small screens const toolbarStyle = mobile ? 'display:flex;align-items:center;gap:6px;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #EDE9E1;flex-wrap:wrap' : 'display:flex;align-items:center;gap:8px;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #EDE9E1'; el.innerHTML = mobileBackHtml + `
` + `
` + `Editing` + `${escapeHtml(page.path)}` + `` + `` + `
` + `` + `
`; const editor = document.getElementById('wiki-editor') as HTMLTextAreaElement; if (editor) editor.focus(); document.getElementById('wiki-back-btn')?.addEventListener('click', () => { this.showMobileTree(); }); document.getElementById('wiki-save-btn')?.addEventListener('click', async () => { if (!this.currentPath || !editor) return; try { await API.saveWikiPage(this.currentPath, editor.value); const updated = await API.getWikiPage(this.currentPath); this.renderPageView(el, updated); } catch (err) { logger.error('Save failed', err); } }); document.getElementById('wiki-cancel-btn')?.addEventListener('click', () => { this.renderPageView(el, page); }); // Ctrl+S / Cmd+S to save editor?.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); document.getElementById('wiki-save-btn')?.click(); } // Escape to cancel if (e.key === 'Escape') { this.renderPageView(el, page); } }); } private async promptNewPage(): Promise { const path = prompt('Page path (e.g. projects/NewProject.md):'); if (!path) return; const normalized = path.endsWith('.md') ? path : `${path}.md`; try { await API.createWikiPage(normalized); await this.loadTree(); this.openPage(normalized); } catch (err) { logger.error('Create page failed', err); } } destroy(): void { this.currentPath = null; this.mobileShowingPage = false; if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); this.resizeHandler = null; } } async navigateTo(path?: string): Promise { if (!this.initialized) { this.init(); } if (this.treeLoadPromise) { await this.treeLoadPromise; } if (path) { await this.openPage(path); return; } if (this.currentPath) { try { const page = await API.getWikiPage(this.currentPath); this.publishPageContext(page); return; } catch { /* fall through to list context */ } } this.publishListContext(); } }