import { all, q } from 'vanilla-jui' import './icons.ts' import { initAccordion } from '../components/accordion.ts' import { initOffcanvas } from '../components/offcanvas.ts' import { initTabs } from '../components/tabs.ts' import { initTip } from '../components/tip.ts' import { initTree } from '../components/tree.ts' import type { RuntimeConfig, LanguagesConfig, NavItem, RuntimePage, RuntimeSidebar, SearchSource, } from '../types.ts' import { isLlmsEnabled, isSearchEnabled, isTocEnabled, } from '../utilities/features.ts' import { initDocChrome } from './chrome.ts' import { initCodeBlock } from './code-block.ts' import { initEditorSize } from './editor-size.ts' import { initLlms } from './llms.ts' import { initMobileSecondary } from './menu.ts' import { initSearch } from './search.ts' import { initToc } from './toc.ts' type ComponentInit = (root: Document | Element, config?: RuntimeConfig) => void interface ComponentRegistryEntry { init: ComponentInit dependsOn: string[] } export interface RuntimeCustomComponent { name: string init?: ComponentInit dependsOn?: string[] } export interface DocPageOptions { config?: RuntimeConfig menu?: NavItem[] sidebar?: RuntimeSidebar languages?: LanguagesConfig page?: RuntimePage search?: SearchSource components?: unknown customComponents?: RuntimeCustomComponent[] } const builtinComponentRegistry: Record = { tabs: { init: initTabs, dependsOn: [], }, accordion: { init: initAccordion, dependsOn: ['tabs'], }, offcanvas: { init: initOffcanvas, dependsOn: ['tabs'], }, tree: { init: initTree, dependsOn: [], }, tip: { init: initTip, dependsOn: [], }, 'code-block': { init: initCodeBlock, dependsOn: [], }, } function isComponentName( value: unknown, registry: Record ): value is string { return typeof value === 'string' && value in registry } function createComponentRegistry( customComponents: RuntimeCustomComponent[] = [] ): Record { const registry: Record = { ...builtinComponentRegistry, } for (const component of customComponents) { const name = String(component?.name || '').trim() if (!name || typeof component.init !== 'function') continue registry[name] = { init: component.init, dependsOn: Array.isArray(component.dependsOn) ? component.dependsOn.filter( (item): item is string => typeof item === 'string' ) : [], } } return registry } function expandWithDependencies( names: string[], registry: Record ): string[] { const result = new Set() const stack = [...names] while (stack.length) { const name = stack.pop() if (!name) continue const entry = registry[name] if (!entry) continue if (result.has(name)) continue result.add(name) for (const dependency of entry.dependsOn) { if (!result.has(dependency)) stack.push(dependency) } } return Array.from(result) } function normalizeComponents( value: unknown, registry: Record ): string[] { const names = Array.isArray(value) ? value : Object.keys(registry) const seen = new Set() const result: string[] = [] for (const name of names) { if (!isComponentName(name, registry) || seen.has(name)) continue seen.add(name) result.push(name) } return result } function resolveExecutionOrder( names: string[], registry: Record ): string[] { const selected = new Set(expandWithDependencies(names, registry)) const visiting = new Set() const visited = new Set() const order: string[] = [] const visit = (name: string): void => { if (!selected.has(name) || visited.has(name)) return if (visiting.has(name)) return visiting.add(name) for (const dependency of registry[name]?.dependsOn || []) { visit(dependency) } visiting.delete(name) visited.add(name) order.push(name) } for (const name of selected) { visit(name) } return order } function countPending(root: Document | Element, name: string): number { return all(`[data-vp-component="${name}"]:not([data-vp-ready="true"])`, root) .length } function hasPending(root: Document | Element, names: string[]): boolean { return names.some((name) => countPending(root, name) > 0) } function nodeContainsComponents(node: Node): boolean { if (!(node instanceof Element)) return false if (node.hasAttribute('data-vp-component')) return true return Boolean(q('[data-vp-component]', node)) } function initComponents( root: Document | Element, names: string[], registry: Record, config: RuntimeConfig = {}, maxPasses = 5 ): void { const ordered = resolveExecutionOrder(names, registry) for (let pass = 0; pass < maxPasses; pass += 1) { let progressed = false for (const name of ordered) { const init = registry[name]?.init if (!init) continue const before = countPending(root, name) if (!before) continue init(root, config) const after = countPending(root, name) if (after < before) progressed = true } if (!progressed) break } } function watchDynamicComponents( names: string[], registry: Record, config: RuntimeConfig = {} ): void { if (typeof MutationObserver === 'undefined') return if (!document.body) return let scheduled = false const rerun = () => { scheduled = false if (!hasPending(document, names)) return initComponents(document, names, registry, config) } const observer = new MutationObserver((records) => { const found = records.some((record) => Array.from(record.addedNodes).some((node) => nodeContainsComponents(node)) ) if (!found || scheduled) return scheduled = true queueMicrotask(rerun) }) observer.observe(document.body, { childList: true, subtree: true }) } export function initDocPage(options: DocPageOptions = {}): void { const componentRegistry = createComponentRegistry(options.customComponents) const components = normalizeComponents(options.components, componentRegistry) const chrome = initDocChrome( options.config, options.menu, [], options.languages, options.page ) if (chrome?.redirected) return if (isSearchEnabled(options.config)) { initSearch( options.config, options.search, options.page, chrome.i18n, chrome.locale ) } initEditorSize(options.config) if (isLlmsEnabled(options.config)) { initLlms() } initComponents(document, components, componentRegistry, options.config) watchDynamicComponents(components, componentRegistry, options.config) initMobileSecondary(chrome.i18n, options.config) if (isTocEnabled(options.config)) { initToc(options.config) } }