UNPKG

@docuify/engine

Version:

A flexible, pluggable engine for building and transforming documentation content from source files.

334 lines (325 loc) 8.92 kB
// lib/base/basePlugin.ts var BasePlugin = class { }; // lib/base/baseSource.ts var BaseSource = class { }; // lib/utils/build_tree.ts var idCounter = 0; function generateId() { return `node-${idCounter++}`; } function createFolderNode(name, fullPath, parentId) { return { id: generateId(), name, fullPath, type: "folder", parentId, children: [] }; } function createFileNode(file, name, fullPath, parentId) { const nodeId = generateId(); const transformQueue = []; const getRawContent = file.loadContent; async function transformContent(rawContent) { let result = rawContent; for (const transformFunction of transformQueue) { result = await transformFunction(result); } return result; } async function loadContent() { if (!getRawContent) { throw new Error(`No content getter for ${file.path}`); } const raw = await getRawContent(); return transformContent(raw); } return { id: nodeId, name, fullPath, type: "file", parentId, extension: file.extension, metadata: file.metadata ? { ...file.metadata } : void 0, // For inspection or debugging purposes — it's like peeking under the hood. _contentTransformQueue: transformQueue, // All the file's special powers are grouped here. Transform, load, mutate! actions: { useTransform: (fn) => { transformQueue.push(fn); }, transformContent, loadContent } }; } function buildTree(sourceFiles) { const root = { id: generateId(), name: "root", // Root node — the top of the tree. No parents. Just responsibilities. fullPath: ".", type: "folder", children: [] }; for (const file of sourceFiles) { const parts = file.path.split("/"); let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; if (!current.children) { current.children = []; } let found = current.children.find((child) => child.name === part); if (!found) { const fullPath = parts.slice(0, i + 1).join("/"); const newNode = isLast && file.type === "file" ? createFileNode(file, part, fullPath, current.id) : createFolderNode(part, fullPath, current.id); current.children.push(newNode); found = newNode; } current = found; } } return root; } // lib/utils/flatten_tree.ts function flattenTree(tree) { const flat = []; const walk = (node) => { flat.push(node); if (node.children) { for (const child of node.children) { walk(child); } } delete node.children; }; walk(tree); return flat; } // lib/utils/walk_tree_with_plugins.ts async function walkTreeWithPlugins(root, plugins) { const state = {}; let currentRoot = root; for (const plugin of plugins) { if (plugin.applyBefore) { const result = await plugin.applyBefore(currentRoot, state); if (result) currentRoot = result; } } async function walk(node, ancestors = [], index) { const context = { parent: ancestors[ancestors.length - 1], ancestors, index, visit: (child, ctx) => walk(child, ctx.ancestors, ctx.index), state }; for (const plugin of plugins) { if (plugin.onVisit) { await plugin.onVisit(node, context); } } if (node.children) { for (let i = 0; i < node.children.length; i++) { await walk(node.children[i], [...ancestors, node], i); } } } await walk(currentRoot); for (const plugin of plugins) { if (plugin.applyAfter) { const result = await plugin.applyAfter(currentRoot, state); if (result) currentRoot = result; } } return currentRoot; } // lib/core/query_context.ts import isMatch from "lodash/isMatch"; var QueryContext = class { flatNodes; constructor(flatNodes) { this.flatNodes = flatNodes; } /** * Filters nodes by a deep partial `where` clause. * Returns a new array of matching nodes (immutable). */ findMany(where) { if (!where) return this.flatNodes; return this.flatNodes.filter((node) => isMatch(node, where)); } /** * Finds the first node matching `where`. * Returns undefined if none found. */ findFirst(where) { if (!where) return this.flatNodes[0]; return this.flatNodes.find((node) => isMatch(node, where)); } /** * Loads content from a given node if possible. * Throws if the node has no loadContent action or is not a file. * Returns loaded content as string. */ async loadContent(node) { if (node.type !== "file") { throw new Error("loadContent only supported for file nodes"); } if (!node.actions?.loadContent) { throw new Error(`Node at ${node.fullPath} has no content loader`); } return await node.actions.loadContent(); } }; // lib/utils/walk_tree.ts function walkTree(node, visitor) { visitor(node); if (node.type === "folder" && node.children?.length) { for (const child of node.children) { walkTree(child, visitor); } } } // lib/utils/preload.ts async function preloadFiles(files, concurrency = 10, keepContent = false) { const fileList = files.filter( (f) => f.type === "file" && typeof f.actions?.loadContent === "function" ); let index = 0; const worker = async () => { while (index < fileList.length) { const currentIndex = index++; const file = fileList[currentIndex]; try { await file.actions?.loadContent?.(); } catch (err) { console.warn( `[docuify] Failed to preload content for: ${file.path}`, err ); } } }; const workers = Array.from({ length: concurrency }, () => worker()); await Promise.all(workers); } async function preloadFlatNodesContent(nodes, concurrency = 10, keepContent = false) { await preloadFiles(nodes, concurrency, keepContent); } async function preloadTreeContent(tree, concurrency = 10, keepContent = false) { const files = []; walkTree(tree, (node) => { if (node.type === "file") { files.push(node); } }); await preloadFiles(files, concurrency, keepContent); } // lib/core/engine.ts var DocuifyEngine = class { config; tree; constructor(config) { if (!config.source) { throw new Error("DocuifyEngine requires a source. It's the whole point."); } this.config = { ...config, plugins: config.plugins || [] // If no plugins are passed, we still dance alone. }; } get buildResult() { const pluginNames = this.config.plugins.map((p) => p.name); return { head: {}, // Reserved for future lore: commit hashes, timestamps, secrets... tree: this.tree, foot: { source: this.config.source.name, pluginNames } }; } /** * Builds the Docuify tree from the source. * Filters are applied here. Lazy loading is respected. */ async treeBuilder() { const rawFiles = await this.config.source.fetch(); const filteredFiles = this.config.filter ? rawFiles.filter(this.config.filter) : rawFiles; this.tree = buildTree(filteredFiles); return this.tree; } /** * Applies plugins to the tree. Plugins may mutate nodes, extract metadata, or summon demons. */ async applyPlugins() { this.tree = await walkTreeWithPlugins(this.tree, this.config.plugins); return this.tree; } /** * Returns the current Docuify tree. * Caution: this may return undefined if `buildTree` hasn’t run. */ getTree() { return this.tree; } /** * One-click build: fetches source, builds tree, runs plugins. * Think of it like `npm run build`, but for trees. */ async buildTree() { await this.treeBuilder(); await this.applyPlugins(); if (!this.config.disableContentPreload) { await preloadTreeContent(this.tree); } return this.buildResult; } /** * Flattens the tree and returns only file nodes. * Ideal for querying, indexing, or just skipping the forest to find your leaf. */ async buildFlat() { await this.treeBuilder(); await this.applyPlugins(); const flatNodes = flattenTree(this.tree).filter( (node) => node.type === "file" ); if (!this.config.disableContentPreload) { await preloadFlatNodesContent(flatNodes); } return { head: {}, nodes: flatNodes, foot: { source: this.config.source.name, pluginNames: this.config.plugins.map((p) => p.name) } }; } use(base) { if (base && base instanceof BasePlugin) { this.config.plugins.push(base); } if (base && base instanceof BaseSource) { this.config.source = base; } } async query() { const { nodes } = await this.buildFlat(); return new QueryContext(nodes); } }; export { DocuifyEngine, QueryContext };