@forwardimpact/libdoc
Advanced tools
| import { urlPathFromMdFile } from "./transforms.js"; | ||
| /** | ||
| * @typedef {{ filePath: string, urlPath: string, title: string, description: string }} PageMeta | ||
| * @typedef {Map<string, PageMeta>} PageTree | ||
| */ | ||
| /** | ||
| * Walk pagesDir recursively and build a PageTree map | ||
| * @param {string} pagesDir - Root directory to scan | ||
| * @param {{ fs: object, path: object, matter: Function }} deps | ||
| * @returns {PageTree} | ||
| */ | ||
| export function scanPages(pagesDir, { fs, path, matter }) { | ||
| /** @type {PageTree} */ | ||
| const pageTree = new Map(); | ||
| walk(pagesDir, pagesDir, pageTree, { fs, path, matter }); | ||
| return pageTree; | ||
| } | ||
| function collectPage(fullPath, baseDir, pageTree, { fs, matter }) { | ||
| const filePath = fullPath.slice(baseDir.length + 1); | ||
| const content = fs.readFileSync(fullPath, "utf-8"); | ||
| const { data } = matter(content); | ||
| if (!data.title) return; | ||
| const urlPath = urlPathFromMdFile(filePath); | ||
| pageTree.set(urlPath, { | ||
| filePath, | ||
| urlPath, | ||
| title: data.title, | ||
| description: data.description || "", | ||
| }); | ||
| } | ||
| const SKIP_ENTRIES = new Set(["assets", "public", "CLAUDE.md", "SKILL.md"]); | ||
| function isDirectory(fs, fullPath) { | ||
| try { | ||
| const stat = fs.statSync(fullPath); | ||
| return stat.isDirectory && stat.isDirectory(); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function walk(dir, baseDir, pageTree, deps) { | ||
| const { fs, path } = deps; | ||
| const entries = fs.readdirSync(dir); | ||
| for (const entryName of entries) { | ||
| if (SKIP_ENTRIES.has(entryName)) continue; | ||
| const fullPath = path.join(dir, entryName); | ||
| if (isDirectory(fs, fullPath)) { | ||
| walk(fullPath, baseDir, pageTree, deps); | ||
| } else if (entryName.endsWith(".md")) { | ||
| try { | ||
| collectPage(fullPath, baseDir, pageTree, deps); | ||
| } catch { | ||
| // Skip files that can't be read | ||
| } | ||
| } | ||
| } | ||
| } |
| import { urlPathFromMdFile } from "./transforms.js"; | ||
| export const defaultRegistry = { | ||
| card: (meta, href) => | ||
| `<a href="${href}">\n<h3>${meta.title}</h3>\n<p>${meta.description}</p>\n</a>`, | ||
| link: (meta, href) => `<a href="${href}">${meta.title}</a>`, | ||
| }; | ||
| const PARTIAL_RE = /<!--\s*part:(\w+):([\w./-]+)\s*-->/g; | ||
| /** | ||
| * Replace <!-- part:type:path --> markers with HTML from the registry | ||
| * @param {string} markdown - Markdown content | ||
| * @param {import("./page-tree.js").PageTree} pageTree | ||
| * @param {string} currentPageDir - Directory of the current page (relative to pagesDir) | ||
| * @param {Record<string, (meta: import("./page-tree.js").PageMeta, href: string) => string>} registry | ||
| * @param {{ path: object }} deps | ||
| * @param {string} [sourceFile] - Source file path for error messages | ||
| * @returns {string} | ||
| */ | ||
| export function resolvePartials( | ||
| markdown, | ||
| pageTree, | ||
| currentPageDir, | ||
| registry, | ||
| { path }, | ||
| sourceFile, | ||
| ) { | ||
| const src = sourceFile || currentPageDir + "/index.md"; | ||
| return markdown.replace(PARTIAL_RE, (_match, type, partialPath) => { | ||
| if (!registry[type]) { | ||
| throw new Error(`Unknown partial type "${type}" in ${src}`); | ||
| } | ||
| const resolved = path.normalize(path.join(currentPageDir, partialPath)); | ||
| const urlPath = urlPathFromMdFile(resolved + "/index.md"); | ||
| const meta = pageTree.get(urlPath); | ||
| if (!meta) { | ||
| throw new Error( | ||
| `Partial target "${partialPath}" not found in page tree (referenced from ${src})`, | ||
| ); | ||
| } | ||
| const currentUrlPath = urlPathFromMdFile( | ||
| currentPageDir === "." ? "index.md" : currentPageDir + "/index.md", | ||
| ); | ||
| const fromDir = currentUrlPath.replace(/\/$/, "") || "/"; | ||
| const toDir = urlPath.replace(/\/$/, "") || "/"; | ||
| let href = path.relative(fromDir, toDir); | ||
| if (!href.endsWith("/")) href += "/"; | ||
| return registry[type](meta, href); | ||
| }); | ||
| } |
| /** | ||
| * Decide whether a link points outside the site being built. | ||
| * Relative links and absolute links matching baseUrl's host are internal. | ||
| * @param {string} url - Link target | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {boolean} | ||
| */ | ||
| export function isExternalLink(url, baseUrl) { | ||
| if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) return false; | ||
| if (!baseUrl) return true; | ||
| try { | ||
| return new URL(url).host !== new URL(baseUrl).host; | ||
| } catch { | ||
| return true; | ||
| } | ||
| } | ||
| /** | ||
| * Rewrite a .md path to its directory-style equivalent. | ||
| * - index -> ./ | ||
| * - foo/index -> foo/ | ||
| * - foo -> foo/ | ||
| * @param {string} path - Path without the .md extension | ||
| * @param {string} fragment - Optional URL fragment (e.g. "#section") | ||
| * @returns {string} | ||
| */ | ||
| export function rewriteMarkdownPath(path, fragment) { | ||
| if (path === "index" || path === "./index") return `./${fragment}`; | ||
| if (path.endsWith("/index")) return `${path.slice(0, -5)}${fragment}`; | ||
| return `${path}/${fragment}`; | ||
| } | ||
| /** | ||
| * Transform internal .md links to match the HTML output structure. | ||
| * External links (different host than baseUrl) are left untouched. | ||
| * @param {string} html - HTML content to transform | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {string} HTML with transformed links | ||
| */ | ||
| export function transformMarkdownLinks(html, baseUrl) { | ||
| return html.replace(/href="([^"]*?)\.md(#[^"]*)?"/g, (match, path, hash) => { | ||
| if (isExternalLink(`${path}.md`, baseUrl)) return match; | ||
| return `href="${rewriteMarkdownPath(path, hash || "")}"`; | ||
| }); | ||
| } | ||
| /** | ||
| * Transform internal markdown-syntax links from .md references to directory-style URLs. | ||
| * External links (different host than baseUrl) are left untouched. | ||
| * @param {string} markdown - Markdown content to transform | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {string} Markdown with transformed links | ||
| */ | ||
| export function transformMarkdownBodyLinks(markdown, baseUrl) { | ||
| return markdown.replace( | ||
| /\[([^\]]*)\]\(([^)]*?)\.md(#[^)]*)?\)/g, | ||
| (match, text, path, hash) => { | ||
| if (isExternalLink(`${path}.md`, baseUrl)) return match; | ||
| return `[${text}](${rewriteMarkdownPath(path, hash || "")})`; | ||
| }, | ||
| ); | ||
| } | ||
| /** | ||
| * Generate table of contents from h2 headings | ||
| * @param {string} html - HTML content to extract headings from | ||
| * @returns {string} HTML list of ToC links | ||
| */ | ||
| export function generateToc(html) { | ||
| const headings = Array.from( | ||
| html.matchAll(/<h2 id="([^"]+)">([^<]+)<\/h2>/g), | ||
| (m) => `<li><a href="#${m[1]}">${m[2]}</a></li>`, | ||
| ); | ||
| return headings.length ? `<ul>${headings.join("\n")}</ul>` : ""; | ||
| } | ||
| /** | ||
| * Compute URL path from a markdown file's relative path. | ||
| * Strips a trailing `index.md` segment, then folds the rest into `/path/`. | ||
| * @param {string} mdFile - Relative path to markdown file | ||
| * @returns {string} URL path (e.g. "/docs/pathway/") | ||
| */ | ||
| export function urlPathFromMdFile(mdFile) { | ||
| const stripped = mdFile.replace(/(?:^|\/)index\.md$|\.md$/, ""); | ||
| return stripped ? `/${stripped}/` : "/"; | ||
| } | ||
| /** | ||
| * Build breadcrumb HTML for pages two or more levels deep | ||
| * @param {string} urlPath - URL path of the current page | ||
| * @param {Map<string, {title: string}>} pageTree - Map of URL paths to page metadata | ||
| * @returns {string} Breadcrumb HTML or empty string | ||
| */ | ||
| export function buildBreadcrumbs(urlPath, pageTree) { | ||
| const segments = urlPath.split("/").filter(Boolean); | ||
| if (segments.length < 2) return ""; | ||
| const breadcrumbLabel = (title) => { | ||
| const colonIdx = title.indexOf(": "); | ||
| return colonIdx !== -1 ? title.slice(colonIdx + 2) : title; | ||
| }; | ||
| const parts = []; | ||
| for (let i = 0; i < segments.length - 1; i++) { | ||
| const ancestorPath = "/" + segments.slice(0, i + 1).join("/") + "/"; | ||
| const title = pageTree.get(ancestorPath)?.title || segments[i]; | ||
| parts.push(`<a href="${ancestorPath}">${breadcrumbLabel(title)}</a>`); | ||
| } | ||
| const currentTitle = | ||
| pageTree.get(urlPath)?.title || segments[segments.length - 1]; | ||
| parts.push(`<span>${breadcrumbLabel(currentTitle)}</span>`); | ||
| return parts.join(" / "); | ||
| } | ||
| /** | ||
| * Classify pages into Products / Documentation / Optional buckets | ||
| * @param {Array<{urlPath: string}>} pages - Page inventory | ||
| * @returns {Object<string, Array>} | ||
| */ | ||
| export function classifyPagesIntoSections(pages) { | ||
| const sections = { Products: [], Documentation: [], Optional: [] }; | ||
| const productSlugs = new Set([ | ||
| "map", | ||
| "pathway", | ||
| "outpost", | ||
| "guide", | ||
| "landmark", | ||
| "summit", | ||
| "gear", | ||
| ]); | ||
| for (const page of pages) { | ||
| const topSegment = page.urlPath.split("/").filter(Boolean)[0]; | ||
| if (page.urlPath.startsWith("/docs/")) { | ||
| sections.Documentation.push(page); | ||
| } else if (topSegment && productSlugs.has(topSegment)) { | ||
| sections.Products.push(page); | ||
| } else { | ||
| sections.Optional.push(page); | ||
| } | ||
| } | ||
| return sections; | ||
| } | ||
| /** | ||
| * Insert page links after matching H2 headings | ||
| * @param {string[]} lines - Original llms.txt lines | ||
| * @param {Object<string, Array>} sections - Classified page buckets | ||
| * @param {Function} linkLine - Formats a page as a markdown link line | ||
| * @returns {string[]} Augmented lines | ||
| */ | ||
| export function insertSectionLinks(lines, sections, linkLine) { | ||
| const output = []; | ||
| for (const line of lines) { | ||
| output.push(line); | ||
| const h2Match = line.match(/^## (.+)$/); | ||
| if (h2Match) { | ||
| const pageList = sections[h2Match[1].trim()]; | ||
| if (pageList?.length) { | ||
| output.push(""); | ||
| for (const page of pageList) { | ||
| output.push(linkLine(page)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return output; | ||
| } | ||
| /** | ||
| * Build hero section template variables from front matter | ||
| * @param {object} frontMatter - Parsed front matter | ||
| * @returns {object} Hero-related template variables | ||
| */ | ||
| export function buildHeroVars(frontMatter) { | ||
| const hero = frontMatter.hero; | ||
| const heroCta = | ||
| hero?.cta?.map((item) => ({ | ||
| ...item, | ||
| btnClass: item.secondary ? "btn-secondary" : "btn-primary", | ||
| })) || []; | ||
| return { | ||
| hasHero: !!hero, | ||
| heroImage: hero?.image || "", | ||
| heroAlt: hero?.alt || "", | ||
| heroTitle: hero?.title || frontMatter.title, | ||
| heroSubtitle: hero?.subtitle || frontMatter.description || "", | ||
| heroCta, | ||
| hasHeroCta: heroCta.length > 0, | ||
| }; | ||
| } |
+25
-25
@@ -14,3 +14,3 @@ #!/usr/bin/env node | ||
| import { createLogger } from "@forwardimpact/libtelemetry"; | ||
| import { DocsBuilder, DocsServer } from "../src/index.js"; | ||
| import { PagesBuilder, PagesServer } from "../src/index.js"; | ||
| import { parseFrontMatter } from "../src/frontmatter.js"; | ||
@@ -80,9 +80,9 @@ | ||
| /** | ||
| * @param {import("../builder.js").DocsBuilder} builder | ||
| * @param {string} docsDir | ||
| * @param {import("../builder.js").PagesBuilder} builder | ||
| * @param {string} pagesDir | ||
| * @param {string} distDir | ||
| * @param {string} [baseUrl] | ||
| */ | ||
| function runPreBuildHook(docsDir) { | ||
| const justfilePath = path.join(docsDir, "justfile"); | ||
| function runPreBuildHook(pagesDir) { | ||
| const justfilePath = path.join(pagesDir, "justfile"); | ||
| if (!fs.existsSync(justfilePath)) return; | ||
@@ -93,3 +93,3 @@ | ||
| try { | ||
| execFileSync("just", ["build"], { cwd: docsDir, stdio: "pipe" }); | ||
| execFileSync("just", ["build"], { cwd: pagesDir, stdio: "pipe" }); | ||
| logger.info(" ✓ pre-build hook complete"); | ||
@@ -108,30 +108,30 @@ } catch (err) { | ||
| async function runBuild(builder, docsDir, distDir, baseUrl) { | ||
| if (!fs.existsSync(docsDir)) { | ||
| cli.error(`source directory not found: ${docsDir}`); | ||
| async function runBuild(builder, pagesDir, distDir, baseUrl) { | ||
| if (!fs.existsSync(pagesDir)) { | ||
| cli.error(`source directory not found: ${pagesDir}`); | ||
| process.exit(1); | ||
| } | ||
| runPreBuildHook(docsDir); | ||
| await builder.build(docsDir, distDir, baseUrl); | ||
| runPreBuildHook(pagesDir); | ||
| await builder.build(pagesDir, distDir, baseUrl); | ||
| } | ||
| /** | ||
| * @param {import("../builder.js").DocsBuilder} builder | ||
| * @param {import("../server.js").DocsServer} server | ||
| * @param {string} docsDir | ||
| * @param {import("../builder.js").PagesBuilder} builder | ||
| * @param {import("../server.js").PagesServer} server | ||
| * @param {string} pagesDir | ||
| * @param {string} distDir | ||
| * @param {{ port: number, watch: boolean, baseUrl: string }} options | ||
| */ | ||
| async function runServe(builder, server, docsDir, distDir, options) { | ||
| if (!fs.existsSync(docsDir)) { | ||
| cli.error(`source directory not found: ${docsDir}`); | ||
| async function runServe(builder, server, pagesDir, distDir, options) { | ||
| if (!fs.existsSync(pagesDir)) { | ||
| cli.error(`source directory not found: ${pagesDir}`); | ||
| process.exit(1); | ||
| } | ||
| runPreBuildHook(docsDir); | ||
| await builder.build(docsDir, distDir, options.baseUrl); | ||
| runPreBuildHook(pagesDir); | ||
| await builder.build(pagesDir, distDir, options.baseUrl); | ||
| if (options.watch) { | ||
| server.watch(docsDir, distDir); | ||
| server.watch(pagesDir, distDir); | ||
| } | ||
@@ -161,7 +161,7 @@ | ||
| const workingDir = process.env.INIT_CWD || process.cwd(); | ||
| const docsDir = path.resolve(workingDir, values.src); | ||
| const pagesDir = path.resolve(workingDir, values.src); | ||
| const distDir = path.resolve(workingDir, values.out); | ||
| const baseUrl = values["base-url"]; | ||
| const builder = new DocsBuilder( | ||
| const builder = new PagesBuilder( | ||
| fs, | ||
@@ -177,6 +177,6 @@ path, | ||
| if (command === "build") { | ||
| await runBuild(builder, docsDir, distDir, baseUrl); | ||
| await runBuild(builder, pagesDir, distDir, baseUrl); | ||
| } else { | ||
| const server = new DocsServer(fs, Hono, serve, builder); | ||
| await runServe(builder, server, docsDir, distDir, { | ||
| const server = new PagesServer(fs, Hono, serve, builder); | ||
| await runServe(builder, server, pagesDir, distDir, { | ||
| port: parseInt(values.port || "3000", 10), | ||
@@ -183,0 +183,0 @@ watch: values.watch, |
+5
-9
| { | ||
| "name": "@forwardimpact/libdoc", | ||
| "version": "0.2.24", | ||
| "description": "Static documentation sites from markdown folders with front matter and navigation.", | ||
| "version": "0.2.25", | ||
| "description": "Static documentation sites from markdown — publish docs without a framework.", | ||
| "keywords": [ | ||
@@ -19,8 +19,2 @@ "docs", | ||
| "author": "D. Olsson <hi@senzilla.io>", | ||
| "forwardimpact": { | ||
| "capability": "agent-capability", | ||
| "needs": [ | ||
| "Build a static documentation site" | ||
| ] | ||
| }, | ||
| "type": "module", | ||
@@ -32,3 +26,5 @@ "main": "./src/index.js", | ||
| "./server": "./src/server.js", | ||
| "./frontmatter": "./src/frontmatter.js" | ||
| "./frontmatter": "./src/frontmatter.js", | ||
| "./page-tree": "./src/page-tree.js", | ||
| "./partials": "./src/partials.js" | ||
| }, | ||
@@ -35,0 +31,0 @@ "bin": { |
+5
-1
| # libdoc | ||
| Documentation build and serve tools for static site generation from Markdown. | ||
| <!-- BEGIN:description — Do not edit. Generated from package.json. --> | ||
| Static documentation sites from markdown — publish docs without a framework. | ||
| <!-- END:description --> | ||
| ## Getting Started | ||
@@ -6,0 +10,0 @@ |
+75
-332
| import { gfmHeadingId } from "marked-gfm-heading-id"; | ||
| import { markedHighlight } from "marked-highlight"; | ||
| import { createLogger } from "@forwardimpact/libtelemetry"; | ||
| import { | ||
| buildBreadcrumbs, | ||
| buildHeroVars, | ||
| classifyPagesIntoSections, | ||
| generateToc, | ||
| insertSectionLinks, | ||
| transformMarkdownBodyLinks, | ||
| transformMarkdownLinks, | ||
| urlPathFromMdFile, | ||
| } from "./transforms.js"; | ||
| import { scanPages } from "./page-tree.js"; | ||
| import { resolvePartials, defaultRegistry } from "./partials.js"; | ||
@@ -8,5 +20,5 @@ const logger = createLogger("libdoc"); | ||
| /** | ||
| * Documentation builder for converting Markdown files to HTML | ||
| * Pages builder for converting Markdown files to HTML | ||
| */ | ||
| export class DocsBuilder { | ||
| export class PagesBuilder { | ||
| #fs; | ||
@@ -20,3 +32,3 @@ #path; | ||
| /** | ||
| * Creates a new DocsBuilder instance | ||
| * Creates a new PagesBuilder instance | ||
| * @param {object} fs - File system module | ||
@@ -47,3 +59,3 @@ * @param {object} path - Path module | ||
| gfmHeadingId({ | ||
| prefix: "", // No prefix for heading IDs | ||
| prefix: "", | ||
| }), | ||
@@ -54,6 +66,5 @@ ); | ||
| markedHighlight({ | ||
| langPrefix: "language-", // Adds 'language-' prefix to code block classes | ||
| langPrefix: "language-", | ||
| // Prism.js handles highlighting on the client side | ||
| highlight(code, _lang) { | ||
| // Return the code as-is with proper language class | ||
| // Prism.js will handle highlighting on the client side | ||
| return code; | ||
@@ -66,81 +77,2 @@ }, | ||
| /** | ||
| * Decide whether a link points outside the site being built. | ||
| * Relative links and absolute links matching baseUrl's host are internal. | ||
| * @param {string} url - Link target | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {boolean} | ||
| */ | ||
| #isExternalLink(url, baseUrl) { | ||
| if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) return false; | ||
| if (!baseUrl) return true; | ||
| try { | ||
| return new URL(url).host !== new URL(baseUrl).host; | ||
| } catch { | ||
| return true; | ||
| } | ||
| } | ||
| /** | ||
| * Rewrite a .md path to its directory-style equivalent. | ||
| * - index -> ./ | ||
| * - foo/index -> foo/ | ||
| * - foo -> foo/ | ||
| * @param {string} path - Path without the .md extension | ||
| * @param {string} fragment - Optional URL fragment (e.g. "#section") | ||
| * @returns {string} | ||
| */ | ||
| #rewriteMarkdownPath(path, fragment) { | ||
| if (path === "index" || path === "./index") return `./${fragment}`; | ||
| if (path.endsWith("/index")) return `${path.slice(0, -5)}${fragment}`; | ||
| return `${path}/${fragment}`; | ||
| } | ||
| /** | ||
| * Transform internal .md links to match the HTML output structure. | ||
| * External links (different host than baseUrl) are left untouched. | ||
| * @param {string} html - HTML content to transform | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {string} HTML with transformed links | ||
| */ | ||
| #transformMarkdownLinks(html, baseUrl) { | ||
| return html.replace( | ||
| /href="([^"]*?)\.md(#[^"]*)?"/g, | ||
| (match, path, hash) => { | ||
| if (this.#isExternalLink(`${path}.md`, baseUrl)) return match; | ||
| return `href="${this.#rewriteMarkdownPath(path, hash || "")}"`; | ||
| }, | ||
| ); | ||
| } | ||
| /** | ||
| * Transform internal markdown-syntax links from .md references to directory-style URLs. | ||
| * External links (different host than baseUrl) are left untouched. | ||
| * @param {string} markdown - Markdown content to transform | ||
| * @param {string|undefined} baseUrl - Base URL of the site | ||
| * @returns {string} Markdown with transformed links | ||
| */ | ||
| #transformMarkdownBodyLinks(markdown, baseUrl) { | ||
| return markdown.replace( | ||
| /\[([^\]]*)\]\(([^)]*?)\.md(#[^)]*)?\)/g, | ||
| (match, text, path, hash) => { | ||
| if (this.#isExternalLink(`${path}.md`, baseUrl)) return match; | ||
| return `[${text}](${this.#rewriteMarkdownPath(path, hash || "")})`; | ||
| }, | ||
| ); | ||
| } | ||
| /** | ||
| * Generate table of contents from h2 headings | ||
| * @param {string} html - HTML content to extract headings from | ||
| * @returns {string} HTML list of ToC links | ||
| */ | ||
| #generateToc(html) { | ||
| const headings = Array.from( | ||
| html.matchAll(/<h2 id="([^"]+)">([^<]+)<\/h2>/g), | ||
| (m) => `<li><a href="#${m[1]}">${m[2]}</a></li>`, | ||
| ); | ||
| return headings.length ? `<ul>${headings.join("\n")}</ul>` : ""; | ||
| } | ||
| /** | ||
| * Copy directory recursively | ||
@@ -169,10 +101,9 @@ * @param {string} src - Source directory | ||
| * Copy static assets to distribution directory | ||
| * @param {string} docsDir - Source docs directory | ||
| * @param {string} pagesDir - Source pages directory | ||
| * @param {string} distDir - Destination distribution directory | ||
| */ | ||
| #copyStaticAssets(docsDir, distDir) { | ||
| // Copy assets directory (CSS, JS, images) | ||
| #copyStaticAssets(pagesDir, distDir) { | ||
| if ( | ||
| this.#copyDir( | ||
| this.#path.join(docsDir, "assets"), | ||
| this.#path.join(pagesDir, "assets"), | ||
| this.#path.join(distDir, "assets"), | ||
@@ -184,6 +115,5 @@ ) | ||
| // Copy root-level static files (robots.txt, llms.txt, etc.) | ||
| const skipFiles = new Set(["index.template.html", "CNAME"]); | ||
| this.#fs | ||
| .readdirSync(docsDir, { withFileTypes: true }) | ||
| .readdirSync(pagesDir, { withFileTypes: true }) | ||
| .filter( | ||
@@ -197,3 +127,3 @@ (entry) => | ||
| this.#fs.copyFileSync( | ||
| this.#path.join(docsDir, entry.name), | ||
| this.#path.join(pagesDir, entry.name), | ||
| this.#path.join(distDir, entry.name), | ||
@@ -206,84 +136,2 @@ ); | ||
| /** | ||
| * Compute URL path from a markdown file's relative path. | ||
| * Strips a trailing `index.md` segment, then folds the rest into `/path/`. | ||
| * @param {string} mdFile - Relative path to markdown file | ||
| * @returns {string} URL path (e.g. "/docs/pathway/") | ||
| */ | ||
| #urlPathFromMdFile(mdFile) { | ||
| const stripped = mdFile.replace(/(?:^|\/)index\.md$|\.md$/, ""); | ||
| return stripped ? `/${stripped}/` : "/"; | ||
| } | ||
| /** | ||
| * Build breadcrumb HTML for pages two or more levels deep | ||
| * @param {string} urlPath - URL path of the current page | ||
| * @param {Map<string, string>} pageTitles - Map of URL paths to page titles | ||
| * @returns {string} Breadcrumb HTML or empty string | ||
| */ | ||
| #buildBreadcrumbs(urlPath, pageTitles) { | ||
| const segments = urlPath.split("/").filter(Boolean); | ||
| if (segments.length < 2) return ""; | ||
| const breadcrumbLabel = (title) => { | ||
| const colonIdx = title.indexOf(": "); | ||
| return colonIdx !== -1 ? title.slice(colonIdx + 2) : title; | ||
| }; | ||
| const parts = []; | ||
| for (let i = 0; i < segments.length - 1; i++) { | ||
| const ancestorPath = "/" + segments.slice(0, i + 1).join("/") + "/"; | ||
| const title = pageTitles.get(ancestorPath) || segments[i]; | ||
| parts.push(`<a href="${ancestorPath}">${breadcrumbLabel(title)}</a>`); | ||
| } | ||
| const currentTitle = | ||
| pageTitles.get(urlPath) || segments[segments.length - 1]; | ||
| parts.push(`<span>${breadcrumbLabel(currentTitle)}</span>`); | ||
| return parts.join(" / "); | ||
| } | ||
| /** | ||
| * Recursively find all Markdown files in a directory | ||
| * @param {string} dir - Directory to search | ||
| * @param {string} baseDir - Base directory for relative paths | ||
| * @returns {string[]} Array of relative paths to Markdown files | ||
| */ | ||
| #findMarkdownFiles(dir, baseDir = dir) { | ||
| const results = []; | ||
| const entries = this.#fs.readdirSync(dir); | ||
| for (const entryName of entries) { | ||
| if (["assets", "public"].includes(entryName)) continue; | ||
| if (["CLAUDE.md", "SKILL.md"].includes(entryName)) continue; | ||
| const fullPath = this.#path.join(dir, entryName); | ||
| this.#collectMarkdownEntry(fullPath, entryName, baseDir, results); | ||
| } | ||
| return results; | ||
| } | ||
| /** | ||
| * Classify a single directory entry and collect it (or recurse) into results | ||
| * @param {string} fullPath - Absolute path to the entry | ||
| * @param {string} entryName - Basename of the entry | ||
| * @param {string} baseDir - Root directory for relative path computation | ||
| * @param {string[]} results - Accumulator for relative markdown paths | ||
| */ | ||
| #collectMarkdownEntry(fullPath, entryName, baseDir, results) { | ||
| try { | ||
| const stat = this.#fs.statSync(fullPath); | ||
| if (stat.isDirectory && stat.isDirectory()) { | ||
| results.push(...this.#findMarkdownFiles(fullPath, baseDir)); | ||
| } else if (entryName.endsWith(".md")) { | ||
| results.push(fullPath.slice(baseDir.length + 1)); | ||
| } | ||
| } catch { | ||
| // Skip files that can't be stat'd (e.g., template files) | ||
| if (entryName.endsWith(".md")) { | ||
| results.push(fullPath.slice(baseDir.length + 1)); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Generate sitemap.xml from page inventory | ||
@@ -314,57 +162,2 @@ * @param {Array<{urlPath: string}>} pages - Sorted page inventory | ||
| /** | ||
| * Classify pages into Products / Documentation / Optional buckets | ||
| * @param {Array<{urlPath: string}>} pages - Page inventory | ||
| * @returns {Object<string, Array>} | ||
| */ | ||
| #classifyPagesIntoSections(pages) { | ||
| const sections = { Products: [], Documentation: [], Optional: [] }; | ||
| const productSlugs = new Set([ | ||
| "map", | ||
| "pathway", | ||
| "outpost", | ||
| "guide", | ||
| "landmark", | ||
| "summit", | ||
| "gear", | ||
| ]); | ||
| for (const page of pages) { | ||
| const topSegment = page.urlPath.split("/").filter(Boolean)[0]; | ||
| if (page.urlPath.startsWith("/docs/")) { | ||
| sections.Documentation.push(page); | ||
| } else if (topSegment && productSlugs.has(topSegment)) { | ||
| sections.Products.push(page); | ||
| } else { | ||
| sections.Optional.push(page); | ||
| } | ||
| } | ||
| return sections; | ||
| } | ||
| /** | ||
| * Insert page links after matching H2 headings | ||
| * @param {string[]} lines - Original llms.txt lines | ||
| * @param {Object<string, Array>} sections - Classified page buckets | ||
| * @param {Function} linkLine - Formats a page as a markdown link line | ||
| * @returns {string[]} Augmented lines | ||
| */ | ||
| #insertSectionLinks(lines, sections, linkLine) { | ||
| const output = []; | ||
| for (const line of lines) { | ||
| output.push(line); | ||
| const h2Match = line.match(/^## (.+)$/); | ||
| if (h2Match) { | ||
| const pageList = sections[h2Match[1].trim()]; | ||
| if (pageList?.length) { | ||
| output.push(""); | ||
| for (const page of pageList) { | ||
| output.push(linkLine(page)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return output; | ||
| } | ||
| /** | ||
| * Augment llms.txt with auto-generated page links under each H2 section | ||
@@ -380,3 +173,3 @@ * @param {Array<{urlPath: string, title: string, description: string}>} pages - Sorted page inventory | ||
| const content = this.#fs.readFileSync(llmsPath, "utf-8"); | ||
| const sections = this.#classifyPagesIntoSections(pages); | ||
| const sections = classifyPagesIntoSections(pages); | ||
@@ -392,7 +185,3 @@ const linkLine = (page) => { | ||
| const output = this.#insertSectionLinks( | ||
| content.split("\n"), | ||
| sections, | ||
| linkLine, | ||
| ); | ||
| const output = insertSectionLinks(content.split("\n"), sections, linkLine); | ||
@@ -406,8 +195,8 @@ this.#fs.writeFileSync(llmsPath, output.join("\n"), "utf-8"); | ||
| * @param {string|undefined} baseUrl - Explicit base URL | ||
| * @param {string} docsDir - Source docs directory | ||
| * @param {string} pagesDir - Source pages directory | ||
| * @returns {string|undefined} | ||
| */ | ||
| #resolveBaseUrl(baseUrl, docsDir) { | ||
| #resolveBaseUrl(baseUrl, pagesDir) { | ||
| if (baseUrl) return baseUrl; | ||
| const cnamePath = this.#path.join(docsDir, "CNAME"); | ||
| const cnamePath = this.#path.join(pagesDir, "CNAME"); | ||
| if (this.#fs.existsSync(cnamePath)) { | ||
@@ -421,45 +210,2 @@ const hostname = this.#fs.readFileSync(cnamePath, "utf-8").trim(); | ||
| /** | ||
| * Collect page titles from all markdown files (first pass) | ||
| * @param {string[]} mdFiles - Relative paths to markdown files | ||
| * @param {string} docsDir - Source docs directory | ||
| * @returns {Map<string, string>} | ||
| */ | ||
| #collectPageTitles(mdFiles, docsDir) { | ||
| const pageTitles = new Map(); | ||
| for (const mdFile of mdFiles) { | ||
| const { data } = this.#matter( | ||
| this.#fs.readFileSync(this.#path.join(docsDir, mdFile), "utf-8"), | ||
| ); | ||
| if (data.title) { | ||
| pageTitles.set(this.#urlPathFromMdFile(mdFile), data.title); | ||
| } | ||
| } | ||
| return pageTitles; | ||
| } | ||
| /** | ||
| * Build hero section template variables from front matter | ||
| * @param {object} frontMatter - Parsed front matter | ||
| * @returns {object} Hero-related template variables | ||
| */ | ||
| #buildHeroVars(frontMatter) { | ||
| const hero = frontMatter.hero; | ||
| const heroCta = | ||
| hero?.cta?.map((item) => ({ | ||
| ...item, | ||
| btnClass: item.secondary ? "btn-secondary" : "btn-primary", | ||
| })) || []; | ||
| return { | ||
| hasHero: !!hero, | ||
| heroImage: hero?.image || "", | ||
| heroAlt: hero?.alt || "", | ||
| heroTitle: hero?.title || frontMatter.title, | ||
| heroSubtitle: hero?.subtitle || frontMatter.description || "", | ||
| heroCta, | ||
| hasHeroCta: heroCta.length > 0, | ||
| }; | ||
| } | ||
| /** | ||
| * Build template variables from front matter and rendered HTML | ||
@@ -469,9 +215,9 @@ * @param {object} frontMatter - Parsed front matter | ||
| * @param {string} urlPath - URL path for this page | ||
| * @param {Map<string, string>} pageTitles - Map of URL paths to page titles | ||
| * @param {import("./page-tree.js").PageTree} pageTree - Map of URL paths to page metadata | ||
| * @param {string|undefined} baseUrl - Base URL for canonical links | ||
| * @returns {object} Mustache template variables | ||
| */ | ||
| #buildTemplateVars(frontMatter, html, urlPath, pageTitles, baseUrl) { | ||
| const toc = frontMatter.toc !== false ? this.#generateToc(html) : ""; | ||
| const breadcrumbs = this.#buildBreadcrumbs(urlPath, pageTitles); | ||
| #buildTemplateVars(frontMatter, html, urlPath, pageTree, baseUrl) { | ||
| const toc = frontMatter.toc !== false ? generateToc(html) : ""; | ||
| const breadcrumbs = buildBreadcrumbs(urlPath, pageTree); | ||
@@ -485,3 +231,3 @@ return { | ||
| layout: frontMatter.layout || "", | ||
| ...this.#buildHeroVars(frontMatter), | ||
| ...buildHeroVars(frontMatter), | ||
| hasBreadcrumbs: !!breadcrumbs, | ||
@@ -525,22 +271,26 @@ breadcrumbs, | ||
| * @param {string} mdFile - Relative path to the markdown file | ||
| * @param {string} docsDir - Source docs directory | ||
| * @param {string} pagesDir - Source pages directory | ||
| * @param {string} distDir - Destination distribution directory | ||
| * @param {string} template - HTML template string | ||
| * @param {Map<string, string>} pageTitles - Map of URL paths to page titles | ||
| * @param {import("./page-tree.js").PageTree} pageTree - Map of URL paths to page metadata | ||
| * @param {string|undefined} baseUrl - Base URL for canonical links | ||
| * @returns {Promise<{mdFile: string, urlPath: string, title: string, description: string}|null>} | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async #renderPage(mdFile, docsDir, distDir, template, pageTitles, baseUrl) { | ||
| async #renderPage(mdFile, pagesDir, distDir, template, pageTree, baseUrl) { | ||
| const { data: frontMatter, content: markdown } = this.#matter( | ||
| this.#fs.readFileSync(this.#path.join(docsDir, mdFile), "utf-8"), | ||
| this.#fs.readFileSync(this.#path.join(pagesDir, mdFile), "utf-8"), | ||
| ); | ||
| if (!frontMatter.title) { | ||
| console.error(`Error: Missing 'title' in front matter of ${mdFile}`); | ||
| return null; | ||
| } | ||
| const rawHtml = this.#marked(markdown); | ||
| const html = this.#transformMarkdownLinks(rawHtml, baseUrl); | ||
| const urlPath = this.#urlPathFromMdFile(mdFile); | ||
| const pageDir = this.#path.dirname(mdFile); | ||
| const resolved = resolvePartials( | ||
| markdown, | ||
| pageTree, | ||
| pageDir, | ||
| defaultRegistry, | ||
| { path: this.#path }, | ||
| mdFile, | ||
| ); | ||
| const rawHtml = this.#marked(resolved); | ||
| const html = transformMarkdownLinks(rawHtml, baseUrl); | ||
| const urlPath = urlPathFromMdFile(mdFile); | ||
| const vars = this.#buildTemplateVars( | ||
@@ -550,3 +300,3 @@ frontMatter, | ||
| urlPath, | ||
| pageTitles, | ||
| pageTree, | ||
| baseUrl, | ||
@@ -556,12 +306,5 @@ ); | ||
| const finalHtml = await this.#formatAndPostProcess(outputHtml); | ||
| const companionContent = `# ${frontMatter.title}\n\n${this.#transformMarkdownBodyLinks(markdown, baseUrl)}`; | ||
| const companionContent = `# ${frontMatter.title}\n\n${transformMarkdownBodyLinks(resolved, baseUrl)}`; | ||
| this.#writePageFiles(mdFile, distDir, finalHtml, companionContent); | ||
| return { | ||
| mdFile, | ||
| urlPath, | ||
| title: frontMatter.title, | ||
| description: frontMatter.description || "", | ||
| }; | ||
| } | ||
@@ -603,3 +346,3 @@ | ||
| * Build documentation from Markdown files | ||
| * @param {string} docsDir - Source documentation directory | ||
| * @param {string} pagesDir - Source pages directory | ||
| * @param {string} distDir - Destination distribution directory | ||
@@ -609,8 +352,7 @@ * @param {string} [baseUrl] - Base URL for sitemap, canonical links, and llms.txt | ||
| */ | ||
| async build(docsDir, distDir, baseUrl) { | ||
| async build(pagesDir, distDir, baseUrl) { | ||
| logger.info("Building documentation..."); | ||
| baseUrl = this.#resolveBaseUrl(baseUrl, docsDir); | ||
| baseUrl = this.#resolveBaseUrl(baseUrl, pagesDir); | ||
| // Clean and create dist directory | ||
| if (this.#fs.existsSync(distDir)) { | ||
@@ -621,37 +363,38 @@ this.#fs.rmSync(distDir, { recursive: true }); | ||
| // Read and validate template | ||
| const templatePath = this.#path.join(docsDir, "index.template.html"); | ||
| const templatePath = this.#path.join(pagesDir, "index.template.html"); | ||
| if (!this.#fs.existsSync(templatePath)) { | ||
| throw new Error(`index.template.html not found in ${docsDir}`); | ||
| throw new Error(`index.template.html not found in ${pagesDir}`); | ||
| } | ||
| const template = this.#fs.readFileSync(templatePath, "utf-8"); | ||
| const mdFiles = this.#findMarkdownFiles(docsDir); | ||
| const pageTree = scanPages(pagesDir, { | ||
| fs: this.#fs, | ||
| path: this.#path, | ||
| matter: this.#matter, | ||
| }); | ||
| if (mdFiles.length === 0) { | ||
| console.warn(`Warning: No Markdown files found in ${docsDir}`); | ||
| if (pageTree.size === 0) { | ||
| console.warn(`Warning: No Markdown files found in ${pagesDir}`); | ||
| } | ||
| const pageTitles = this.#collectPageTitles(mdFiles, docsDir); | ||
| const pages = []; | ||
| for (const mdFile of mdFiles) { | ||
| const page = await this.#renderPage( | ||
| mdFile, | ||
| docsDir, | ||
| for (const entry of pageTree.values()) { | ||
| await this.#renderPage( | ||
| entry.filePath, | ||
| pagesDir, | ||
| distDir, | ||
| template, | ||
| pageTitles, | ||
| pageTree, | ||
| baseUrl, | ||
| ); | ||
| if (page) pages.push(page); | ||
| } | ||
| pages.sort((a, b) => a.urlPath.localeCompare(b.urlPath)); | ||
| const sortedPages = [...pageTree.values()].sort((a, b) => | ||
| a.urlPath.localeCompare(b.urlPath), | ||
| ); | ||
| this.#copyStaticAssets(docsDir, distDir); | ||
| this.#copyStaticAssets(pagesDir, distDir); | ||
| if (baseUrl) { | ||
| this.#generateSitemap(pages, baseUrl, distDir); | ||
| this.#augmentLlmsTxt(pages, baseUrl, distDir); | ||
| this.#generateSitemap(sortedPages, baseUrl, distDir); | ||
| this.#augmentLlmsTxt(sortedPages, baseUrl, distDir); | ||
| } | ||
@@ -658,0 +401,0 @@ |
+4
-2
@@ -1,3 +0,5 @@ | ||
| export { DocsBuilder } from "./builder.js"; | ||
| export { DocsServer } from "./server.js"; | ||
| export { PagesBuilder } from "./builder.js"; | ||
| export { PagesServer } from "./server.js"; | ||
| export { parseFrontMatter } from "./frontmatter.js"; | ||
| export { scanPages } from "./page-tree.js"; | ||
| export { resolvePartials, defaultRegistry } from "./partials.js"; |
+8
-8
@@ -8,3 +8,3 @@ import { createLogger } from "@forwardimpact/libtelemetry"; | ||
| */ | ||
| export class DocsServer { | ||
| export class PagesServer { | ||
| #fs; | ||
@@ -17,7 +17,7 @@ #Hono; | ||
| /** | ||
| * Creates a new DocsServer instance | ||
| * Creates a new PagesServer instance | ||
| * @param {object} fs - File system module | ||
| * @param {Function} HonoConstructor - Hono constructor (optional, required for serve()) | ||
| * @param {Function} serveFn - Hono serve function from @hono/node-server (optional, required for serve()) | ||
| * @param {import("./builder.js").DocsBuilder} builder - DocsBuilder instance | ||
| * @param {import("./builder.js").PagesBuilder} builder - PagesBuilder instance | ||
| */ | ||
@@ -37,11 +37,11 @@ constructor(fs, HonoConstructor, serveFn, builder) { | ||
| * Start watching for changes and rebuild | ||
| * @param {string} docsDir - Documentation directory to watch | ||
| * @param {string} pagesDir - Documentation directory to watch | ||
| * @param {string} distDir - Distribution directory for output | ||
| * @returns {void} | ||
| */ | ||
| watch(docsDir, distDir) { | ||
| logger.info(`Watching for changes in ${docsDir}...`); | ||
| watch(pagesDir, distDir) { | ||
| logger.info(`Watching for changes in ${pagesDir}...`); | ||
| this.#watcher = this.#fs.watch( | ||
| docsDir, | ||
| pagesDir, | ||
| { recursive: true }, | ||
@@ -56,3 +56,3 @@ (eventType, filename) => { | ||
| logger.info(`\nRebuilding due to change in ${filename}...`); | ||
| this.#builder.build(docsDir, distDir).catch((error) => { | ||
| this.#builder.build(pagesDir, distDir).catch((error) => { | ||
| console.error("Build error:", error); | ||
@@ -59,0 +59,0 @@ }); |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
46121
2.8%11
37.5%959
5.62%14
40%