diff --git a/src/css/docs.css b/src/css/docs.css new file mode 100644 index 0000000..46aa473 --- /dev/null +++ b/src/css/docs.css @@ -0,0 +1,48 @@ +body { + font-family: monospace, monospace; + background: #f9f9f9; + color: #222; + margin: 1rem; +} + +h1, +h2, +h3 { + margin-top: 1.5rem; +} + +pre { + background: #eee; + padding: 1rem; + overflow-x: auto; +} + +table { + border-collapse: collapse; + width: 100%; + margin-top: 1rem; +} + +th, +td { + border: 1px solid #ccc; + padding: 0.5rem; + text-align: left; + vertical-align: top; +} + +th { + background: #ddd; +} + +.section { + margin-bottom: 2rem; +} + +.key { + font-weight: bold; +} + +.list { + margin: 0.25rem 0 1rem 1.5rem; +} diff --git a/src/middleware/hbs.js b/src/middleware/hbs.js index 3d4660d..42f7ded 100644 --- a/src/middleware/hbs.js +++ b/src/middleware/hbs.js @@ -24,6 +24,9 @@ this._sections[name] += options.fn(this); return null; }, + json(context) { + return JSON.stringify(context, null, 2); + }, }, extname: EXTENSION, runtimeOptions: RUNTIME_OPTIONS, diff --git a/src/routes/docs.js b/src/routes/docs.js new file mode 100644 index 0000000..a9811d2 --- /dev/null +++ b/src/routes/docs.js @@ -0,0 +1,130 @@ +// src/routes/docs/index.js +const express = require("express"); +const path = require("path"); +const fs = require("fs/promises"); +const yaml = require("js-yaml"); + +const router = express.Router(); +const docsContext = require("../utils/docsContext"); +const HttpError = require("../utils/HttpError"); + +const docsDir = path.join(__dirname, "../../content/docs"); +let docsCache = {}; // { [path]: { modules: {}, crossCuttingSummary: {} } } + +async function loadDocFile(filePath) { + if (docsCache[filePath]) return docsCache[filePath]; + try { + const fullPath = path.join(docsDir, filePath + ".yaml"); + const fileContent = await fs.readFile(fullPath, "utf-8"); + const parsed = yaml.load(fileContent); + const crossCuttingSummary = parsed.crossCuttingSummary || null; + delete parsed.crossCuttingSummary; + docsCache[filePath] = { + modules: parsed, + crossCuttingSummary, + }; + return docsCache[filePath]; + } catch (e) { + console.error(e.stack); + return null; + } +} + +// /docs/summary - aggregate crossCuttingSummary from all cached docs +router.get("/summary", async (req, res) => { + const summaries = []; + const files = await fs.readdir(docsDir); + const yamlFiles = files + .filter((f) => f.endsWith(".yaml")) + .map((f) => f.slice(0, -5)); + for (const file of yamlFiles) { + const doc = await loadDocFile(file); + if (doc?.crossCuttingSummary) { + summaries.push({ path: file, summary: doc.crossCuttingSummary }); + } + } + + const context = await docsContext(req.isAuthenticated, { + layout: "docs", + docPath: "/docs/summary", + docModule: null, + }); + + res.render("docs/summary", { + ...context, + summaries, + }); +}); + +// /docs/:path - show all modules in a YAML file +router.get("/:path", async (req, res) => { + const { path: docPath } = req.params; + const doc = await loadDocFile(docPath); + + const context = await docsContext(req.isAuthenticated, { + layout: "docs", + docPath: "/docs" + docPath, + docModule: null, + }); + + // Precompute links + const modulesWithLinks = Object.entries(doc.modules).map(([key, value]) => ({ + name: key, + url: `${baseUrl}/docs/${docPath}/${key}`, + })); + + res.render("docs/path", { + ...context, + + path: docPath, + crossCuttingSummary: doc.crossCuttingSummary, + modules: modulesWithLinks, + }); +}); + +// /docs/:path/:module - show single module from YAML file +router.get("/:path/:module", async (req, res, next) => { + const { path: docPath, module } = req.params; + const doc = await loadDocFile(docPath); + if (!doc) return next(new HttpError("Documentation not found", 404)); + const moduleDoc = doc.modules[module]; + if (!moduleDoc) + return next(new HttpError("Module documentation not found", 404)); + + const context = await docsContext(req.isAuthenticated, { + layout: "docs", + docPath, + docModule: module, + }); + + res.render("docs/module", { + ...context, + moduleDoc, + }); +}); + +// /docs - list all doc files +router.get("/", async (req, res) => { + try { + const files = await fs.readdir(docsDir); + const yamlFiles = files + .filter((f) => f.endsWith(".yaml")) + .map((f) => f.slice(0, -5)); + + const context = await docsContext(req.isAuthenticated, { + layout: "docs", + docPath: null, + docModule: null, + }); + + res.render("docs/index", { + ...context, + docsPaths: yamlFiles, + }); + } catch (err) { + req.log.error(err.stack); + next(new HttpError("Failed to read docs directory", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index 346a771..253b719 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -15,6 +15,7 @@ const sitemap = require("./sitemap"); const post = require("./post"); const pages = require("./pages"); +const docs = require("./docs"); const rssFeed = require("./rssFeed"); const { qualifyLink } = require("../utils/qualifyLinks"); const HttpError = require("../utils/HttpError"); @@ -67,6 +68,7 @@ router.use(pages); router.use(rssFeed); router.use(tags); +router.use("/docs", docs); router.get("/blog/:year/:month/:name", post); diff --git a/src/utils/docsContext.js b/src/utils/docsContext.js new file mode 100644 index 0000000..afbe6f5 --- /dev/null +++ b/src/utils/docsContext.js @@ -0,0 +1,72 @@ +// src/utils/docsContext.js +const path = require("path"); +const fs = require("fs/promises"); +const yaml = require("js-yaml"); + +const { qualifyNavLinks } = require("./qualifyLinks"); +const { baseUrl } = require("./baseUrl"); +const generateDocsMenuModel = require("./generateDocsMenuModel"); +const navLinks = require(path.join(__dirname, "../../content/navLinks.json")); +const filterSecureLinks = require("../utils/filterSecureLinks"); + +const getSiteTitle = (owner) => `${owner}'s Software Blog`; + +const YAML_DOCS_DIR = path.join(__dirname, "../../content/docs"); + +/** + * Load and parse all YAML documentation files in content/docs/ + * Returns an object keyed by filename (without .yaml) with YAML content + */ +async function loadAllYamlDocs() { + const entries = {}; + const files = await fs.readdir(YAML_DOCS_DIR); + + for (const file of files) { + if (!file.endsWith(".yaml")) continue; + const fullPath = path.join(YAML_DOCS_DIR, file); + const content = await fs.readFile(fullPath, "utf8"); + const parsed = yaml.load(content); + const key = path.basename(file, ".yaml"); + entries[key] = parsed; + } + + return entries; +} + +/** + * Base context generator + */ +module.exports = async function getDocsContext( + isAuthenticated, + overrides = {} +) { + const filteredNavLinks = filterSecureLinks(navLinks, isAuthenticated); + const qualifiedNavLinks = qualifyNavLinks(filteredNavLinks); + const siteOwner = process.env.SITE_OWNER; + + const allYamlDocs = await loadAllYamlDocs(); + const currentPath = overrides.docPath || null; + console.debug(`overrides: ${JSON.stringify(overrides)}`); + console.debug(`current Path: ${currentPath}`); + const currentModule = overrides.docModule || null; + const docsMenu = generateDocsMenuModel( + allYamlDocs, + currentPath, + currentModule + ); + + const context = { + title: getSiteTitle(siteOwner), + siteOwner, + originCountry: process.env.COUNTRY, + navLinks: qualifiedNavLinks, + baseUrl, + docsMenu, + isAuthenticated, + showFooter: true, + showSidebar: true, + ...overrides, + }; + + return context; +}; diff --git a/src/utils/generateDocsMenuModel.js b/src/utils/generateDocsMenuModel.js new file mode 100644 index 0000000..a98dcf7 --- /dev/null +++ b/src/utils/generateDocsMenuModel.js @@ -0,0 +1,46 @@ +// generateDocsMenuModel.js + +/** + * Transforms loaded YAML docs data into a structured menu model for the docsMenu partial. + * @param {Object} allDocs - Parsed YAML content keyed by path (filename), values are module objects. + * @param {String} currentPath - Optional, path currently viewed (for UI state). + * @param {String} currentModule - Optional, module currently viewed (for UI state). + * @returns {Array} Array of path objects with modules array. + */ +function generateDocsMenuModel( + allDocs, + currentPath = null, + currentModule = null +) { + return Object.entries(allDocs).map(([path, modules]) => { + // modules is an object with module keys and values being their doc data + // filter out crossCuttingSummary key if present + const filteredModules = Object.entries(modules).filter( + ([modKey]) => modKey !== "crossCuttingSummary" + ); + + return { + name: path, + isActive: path === currentPath, + modules: filteredModules.map(([modKey, modData]) => ({ + name: modKey, + displayName: formatModuleName(modKey), + isActive: path === currentPath && modKey === currentModule, + })), + }; + }); +} + +function formatModuleName(moduleKey) { + // Convert camelCase or snake_case to spaced words with initial caps + // Example: newsletterService => Newsletter Service, posts_menu => Posts Menu + const withSpaces = moduleKey + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]/g, " "); + return withSpaces + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +module.exports = generateDocsMenuModel; diff --git a/src/views/docs/index.handlebars b/src/views/docs/index.handlebars new file mode 100644 index 0000000..c97da1b --- /dev/null +++ b/src/views/docs/index.handlebars @@ -0,0 +1,13 @@ +{{!-- views/docs/index.hbs --}} +{{#section "styles"}} + +{{/section}} +{{#section "scripts"}} + +{{/section}} +

Documentation Files

+ diff --git a/src/views/docs/module.handlebars b/src/views/docs/module.handlebars new file mode 100644 index 0000000..78b9fbf --- /dev/null +++ b/src/views/docs/module.handlebars @@ -0,0 +1,12 @@ +{{!-- views/docs/module.hbs --}} +{{#section "styles"}} + +{{/section}} +{{#section "scripts"}} + +{{/section}} +

Documentation: {{path}} / {{module}}

+ +
{{json moduleDoc}}
+ +{{!-- json helper needed to pretty print object --}} diff --git a/src/views/docs/path.handlebars b/src/views/docs/path.handlebars new file mode 100644 index 0000000..71bac02 --- /dev/null +++ b/src/views/docs/path.handlebars @@ -0,0 +1,34 @@ +{{!-- views/docs/path.hbs --}} +{{#section "styles"}} + +{{/section}} +{{#section "scripts"}} + +{{/section}} + +

Documentation: {{path}}

+ +{{#if crossCuttingSummary}} +
+

Cross Cutting Summary

+

Themes

+ +

System Recommendations

+ +
+{{/if}} + +

Modules

+ diff --git a/src/views/docs/summary.handlebars b/src/views/docs/summary.handlebars new file mode 100644 index 0000000..32ba766 --- /dev/null +++ b/src/views/docs/summary.handlebars @@ -0,0 +1,26 @@ +{{!-- views/docs/summary.hbs --}} +{{#section "styles"}} + +{{/section}} +{{#section "scripts"}} + +{{/section}} + +

Cross Cutting Summaries

+{{#each summaries}} +
+

{{path}}

+

Themes

+ +

System Recommendations

+ +
+{{/each}} diff --git a/src/views/layouts/docs.handlebars b/src/views/layouts/docs.handlebars new file mode 100644 index 0000000..b0f85e4 --- /dev/null +++ b/src/views/layouts/docs.handlebars @@ -0,0 +1,44 @@ + + + + + + + + + + {{{_sections.styles}}} + {{title}} + + + + + {{{_sections.headers}}} + + + + + {{> headers}} +
+ {{#if showSidebar}} + + {{/if}} +
+ {{{body}}} +
+
+ {{#if showFooter}} + + {{/if}} + {{{_sections.scripts}}} + + + diff --git a/src/views/partials/docsMenu.handlebars b/src/views/partials/docsMenu.handlebars new file mode 100644 index 0000000..41f256f --- /dev/null +++ b/src/views/partials/docsMenu.handlebars @@ -0,0 +1,19 @@ +{{!-- partials/docsMenu.handlebars --}} +