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}` +
`` +
`
` +
`
` +
`
`;
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();
}
}