Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@forwardimpact/libdoc

Package Overview
Dependencies
Maintainers
1
Versions
30
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@forwardimpact/libdoc - npm Package Compare versions

Comparing version
0.2.24
to
0.2.25
+65
src/page-tree.js
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,

{
"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": {

# 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 @@

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 @@

@@ -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,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 @@ });