From a385a7b312af7c51de916a769aacbacc94f6b38a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:44:46 -0400 Subject: [PATCH] Add plugin system --- README.md | 40 +++++++++++++++++++++++++++++++++++++++ plugins/analytics.js | 7 +++++++ src/config/loadConfig.js | 1 + src/config/loadPlugins.js | 24 +++++++++++++++++++++++ src/generator/index.js | 26 ++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 plugins/analytics.js create mode 100644 src/config/loadPlugins.js diff --git a/README.md b/README.md index a3a494e..1ec4dad 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ features: provider: "plausible" id: "my-site.io" +pluginsDir: "plugins" # Folder for custom plugins plugins: - "analytics" # Load from plugins/ ``` @@ -223,4 +224,43 @@ To make DocForge truly user-friendly, include these instructions in the starter - **Future**: PDF export plugin, AI-assisted search suggestions. - **Community**: MIT license; GitHub for issues. +## Creating Custom Plugins + +Plugins are plain Node.js modules placed in the folder defined by `pluginsDir`. +Each module can export hook functions which DocForge calls during the build: + +```js +module.exports = { + // Modify markdown before frontmatter is parsed + async onParseMarkdown({ file, content }) { + return { content }; + }, + // Modify the final HTML of each page + async onPageRendered({ file, html }) { + return { html }; + } +}; +``` + +Example `plugins/analytics.js`: + +```js +module.exports = { + onPageRendered: async ({ html, file }) => { + const snippet = `\n`; + return { html: html.replace('', `${snippet}`) }; + } +}; +``` + +Enable it in `config.yaml`: + +```yaml +pluginsDir: "plugins" +plugins: + - "analytics" +``` + +Running `npm run build` will load the plugin and execute its hooks. + This spec is complete and ready for implementation. Prototype core parsing first, then add features iteratively. Total est. dev time: 2-4 weeks for MVP. diff --git a/plugins/analytics.js b/plugins/analytics.js new file mode 100644 index 0000000..6fca129 --- /dev/null +++ b/plugins/analytics.js @@ -0,0 +1,7 @@ +module.exports = { + onPageRendered: async ({ html, file }) => { + // Example: inject analytics script into each page + const snippet = '\n'; + return html.replace('', `${snippet}`); + } +}; diff --git a/src/config/loadConfig.js b/src/config/loadConfig.js index 9ee969f..8097c83 100644 --- a/src/config/loadConfig.js +++ b/src/config/loadConfig.js @@ -44,6 +44,7 @@ function loadConfig(configPath = path.join(process.cwd(), 'config.yaml')) { darkMode: false }, features: {}, + pluginsDir: 'plugins', plugins: [] }; diff --git a/src/config/loadPlugins.js b/src/config/loadPlugins.js new file mode 100644 index 0000000..57d6b3d --- /dev/null +++ b/src/config/loadPlugins.js @@ -0,0 +1,24 @@ +const path = require('path'); +const fs = require('fs'); + +function loadPlugins(config) { + const dir = path.resolve(process.cwd(), config.pluginsDir || 'plugins'); + const names = Array.isArray(config.plugins) ? config.plugins : []; + const plugins = []; + for (const name of names) { + const file = path.join(dir, name.endsWith('.js') ? name : `${name}.js`); + if (fs.existsSync(file)) { + try { + const mod = require(file); + plugins.push(mod); + } catch (e) { + console.error(`Failed to load plugin ${name}:`, e); + } + } else { + console.warn(`Plugin not found: ${file}`); + } + } + return plugins; +} + +module.exports = loadPlugins; diff --git a/src/generator/index.js b/src/generator/index.js index aa73480..2fff800 100644 --- a/src/generator/index.js +++ b/src/generator/index.js @@ -6,6 +6,7 @@ const Eleventy = require('@11ty/eleventy'); const lunr = require('lunr'); const { lexer } = require('marked'); const loadConfig = require('../config/loadConfig'); +const loadPlugins = require('../config/loadPlugins'); async function readDirRecursive(dir) { const entries = await fs.promises.readdir(dir, { withFileTypes: true }); @@ -55,6 +56,17 @@ function buildNav(pages) { async function generate({ contentDir = 'content', outputDir = '_site', configPath } = {}) { const config = loadConfig(configPath); + const plugins = loadPlugins(config); + + async function runHook(name, data) { + for (const plugin of plugins) { + if (typeof plugin[name] === 'function') { + const res = await plugin[name](data); + if (res !== undefined) data = res; + } + } + return data; + } if (!fs.existsSync(contentDir)) { console.error(`Content directory not found: ${contentDir}`); return; @@ -76,7 +88,9 @@ async function generate({ contentDir = 'content', outputDir = '_site', configPat continue; // skip unchanged } } - const raw = await fs.promises.readFile(file, 'utf8'); + let raw = await fs.promises.readFile(file, 'utf8'); + const mdObj = await runHook('onParseMarkdown', { file: rel, content: raw }); + if (mdObj && mdObj.content) raw = mdObj.content; const parsed = matter(raw); const title = parsed.data.title || path.basename(rel, '.md'); const tokens = lexer(parsed.content || ''); @@ -124,6 +138,16 @@ async function generate({ contentDir = 'content', outputDir = '_site', configPat }; await elev.write(); + for (const page of pages) { + const outPath = path.join(outputDir, page.file.replace(/\.md$/, '.html')); + if (fs.existsSync(outPath)) { + let html = await fs.promises.readFile(outPath, 'utf8'); + const result = await runHook('onPageRendered', { file: page.file, html }); + if (result && result.html) html = result.html; + await fs.promises.writeFile(outPath, html); + } + } + for (const asset of assets) { const srcPath = path.join(contentDir, asset); const destPath = path.join(outputDir, asset);