@docuify/engine
Version:
A flexible, pluggable engine for building and transforming documentation content from source files.
333 lines (324 loc) • 8.91 kB
JavaScript
// 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
};