@forwardimpact/libdoc 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fit-doc.js CHANGED
@@ -22,8 +22,9 @@ Options:
22
22
  -h, --help Show this help message
23
23
 
24
24
  Build options:
25
- --src=<dir> Source directory (default: website)
26
- --out=<dir> Output directory (default: dist)
25
+ --src=<dir> Source directory (default: website)
26
+ --out=<dir> Output directory (default: dist)
27
+ --base-url=<url> Base URL for sitemap, canonical links, and llms.txt
27
28
 
28
29
  Serve options:
29
30
  --src=<dir> Source directory (default: website)
@@ -44,13 +45,14 @@ function error(message) {
44
45
  * @param {import("../builder.js").DocsBuilder} builder
45
46
  * @param {string} docsDir
46
47
  * @param {string} distDir
48
+ * @param {string} [baseUrl]
47
49
  */
48
- async function runBuild(builder, docsDir, distDir) {
50
+ async function runBuild(builder, docsDir, distDir, baseUrl) {
49
51
  if (!fs.existsSync(docsDir)) {
50
52
  error(`Source directory not found: ${docsDir}`);
51
53
  }
52
54
 
53
- await builder.build(docsDir, distDir);
55
+ await builder.build(docsDir, distDir, baseUrl);
54
56
  }
55
57
 
56
58
  /**
@@ -65,7 +67,7 @@ async function runServe(builder, server, docsDir, distDir, options) {
65
67
  error(`Source directory not found: ${docsDir}`);
66
68
  }
67
69
 
68
- await builder.build(docsDir, distDir);
70
+ await builder.build(docsDir, distDir, options.baseUrl);
69
71
 
70
72
  if (options.watch) {
71
73
  server.watch(docsDir, distDir);
@@ -95,6 +97,7 @@ async function main() {
95
97
  options: {
96
98
  src: { type: "string", default: "website" },
97
99
  out: { type: "string", default: "dist" },
100
+ "base-url": { type: "string" },
98
101
  port: { type: "string", short: "p", default: "3000" },
99
102
  watch: { type: "boolean", short: "w", default: false },
100
103
  help: { type: "boolean", short: "h", default: false },
@@ -110,6 +113,7 @@ async function main() {
110
113
  const workingDir = process.env.INIT_CWD || process.cwd();
111
114
  const docsDir = path.join(workingDir, values.src);
112
115
  const distDir = path.join(workingDir, values.out);
116
+ const baseUrl = values["base-url"];
113
117
 
114
118
  const builder = new DocsBuilder(
115
119
  fs,
@@ -122,12 +126,13 @@ async function main() {
122
126
 
123
127
  try {
124
128
  if (command === "build") {
125
- await runBuild(builder, docsDir, distDir);
129
+ await runBuild(builder, docsDir, distDir, baseUrl);
126
130
  } else {
127
131
  const server = new DocsServer(fs, Hono, serve, builder);
128
132
  await runServe(builder, server, docsDir, distDir, {
129
133
  port: parseInt(values.port, 10),
130
134
  watch: values.watch,
135
+ baseUrl,
131
136
  });
132
137
  }
133
138
  } catch (err) {
package/builder.js CHANGED
@@ -82,6 +82,28 @@ export class DocsBuilder {
82
82
  );
83
83
  }
84
84
 
85
+ /**
86
+ * Transform markdown-syntax links from .md references to directory-style URLs
87
+ * Same rules as #transformMarkdownLinks but for [text](path) syntax
88
+ * @param {string} markdown - Markdown content to transform
89
+ * @returns {string} Markdown with transformed links
90
+ */
91
+ #transformMarkdownBodyLinks(markdown) {
92
+ return markdown.replace(
93
+ /\[([^\]]*)\]\(([^)]*?)\.md(#[^)]*)?\)/g,
94
+ (_match, text, path, hash) => {
95
+ const fragment = hash || "";
96
+ if (path === "index" || path === "./index") {
97
+ return `[${text}](./${fragment})`;
98
+ }
99
+ if (path.endsWith("/index")) {
100
+ return `[${text}](${path.slice(0, -5)}${fragment})`;
101
+ }
102
+ return `[${text}](${path}/${fragment})`;
103
+ },
104
+ );
105
+ }
106
+
85
107
  /**
86
108
  * Generate table of contents from h2 headings
87
109
  * @param {string} html - HTML content to extract headings from
@@ -133,16 +155,19 @@ export class DocsBuilder {
133
155
  console.log(" ✓ assets/");
134
156
  }
135
157
 
136
- // Copy public files (favicon, etc.)
137
- const publicDir = this.#path.join(docsDir, "public");
138
- if (!this.#fs.existsSync(publicDir)) return;
139
-
158
+ // Copy root-level static files (robots.txt, llms.txt, etc.)
159
+ const skipFiles = new Set(["index.template.html", "CNAME"]);
140
160
  this.#fs
141
- .readdirSync(publicDir, { withFileTypes: true })
142
- .filter((entry) => entry.isFile())
161
+ .readdirSync(docsDir, { withFileTypes: true })
162
+ .filter(
163
+ (entry) =>
164
+ entry.isFile() &&
165
+ !entry.name.endsWith(".md") &&
166
+ !skipFiles.has(entry.name),
167
+ )
143
168
  .forEach((entry) => {
144
169
  this.#fs.copyFileSync(
145
- this.#path.join(publicDir, entry.name),
170
+ this.#path.join(docsDir, entry.name),
146
171
  this.#path.join(distDir, entry.name),
147
172
  );
148
173
  console.log(` ✓ ${entry.name}`);
@@ -225,15 +250,115 @@ export class DocsBuilder {
225
250
  return results;
226
251
  }
227
252
 
253
+ /**
254
+ * Generate sitemap.xml from page inventory
255
+ * @param {Array<{urlPath: string}>} pages - Sorted page inventory
256
+ * @param {string} baseUrl - Base URL for the site
257
+ * @param {string} distDir - Destination distribution directory
258
+ */
259
+ #generateSitemap(pages, baseUrl, distDir) {
260
+ const urls = pages
261
+ .map((p) => ` <url>\n <loc>${baseUrl}${p.urlPath}</loc>\n </url>`)
262
+ .join("\n");
263
+ const xml = [
264
+ '<?xml version="1.0" encoding="UTF-8"?>',
265
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
266
+ urls,
267
+ "</urlset>",
268
+ "",
269
+ ].join("\n");
270
+ this.#fs.writeFileSync(
271
+ this.#path.join(distDir, "sitemap.xml"),
272
+ xml,
273
+ "utf-8",
274
+ );
275
+ console.log(" ✓ sitemap.xml");
276
+ }
277
+
278
+ /**
279
+ * Augment llms.txt with auto-generated page links under each H2 section
280
+ * @param {Array<{urlPath: string, title: string, description: string}>} pages - Sorted page inventory
281
+ * @param {string} baseUrl - Base URL for the site
282
+ * @param {string} distDir - Destination distribution directory
283
+ */
284
+ #augmentLlmsTxt(pages, baseUrl, distDir) {
285
+ const llmsPath = this.#path.join(distDir, "llms.txt");
286
+ if (!this.#fs.existsSync(llmsPath)) return;
287
+
288
+ const content = this.#fs.readFileSync(llmsPath, "utf-8");
289
+ const lines = content.split("\n");
290
+
291
+ // Map pages to sections
292
+ const sections = { Products: [], Documentation: [], Optional: [] };
293
+ const productSlugs = new Set([
294
+ "map",
295
+ "pathway",
296
+ "basecamp",
297
+ "guide",
298
+ "landmark",
299
+ "summit",
300
+ ]);
301
+
302
+ for (const page of pages) {
303
+ const topSegment = page.urlPath.split("/").filter(Boolean)[0];
304
+ if (page.urlPath.startsWith("/docs/")) {
305
+ sections.Documentation.push(page);
306
+ } else if (topSegment && productSlugs.has(topSegment)) {
307
+ sections.Products.push(page);
308
+ } else {
309
+ sections.Optional.push(page);
310
+ }
311
+ }
312
+
313
+ const linkLine = (page) => {
314
+ const mdUrl =
315
+ page.urlPath === "/"
316
+ ? `${baseUrl}/index.md`
317
+ : `${baseUrl}${page.urlPath}index.md`;
318
+ const desc = page.description ? `: ${page.description}` : "";
319
+ return `- [${page.title}](${mdUrl})${desc}`;
320
+ };
321
+
322
+ // Reassemble: insert links after each H2
323
+ const output = [];
324
+ for (const line of lines) {
325
+ output.push(line);
326
+ const h2Match = line.match(/^## (.+)$/);
327
+ if (h2Match) {
328
+ const sectionName = h2Match[1].trim();
329
+ const pageList = sections[sectionName];
330
+ if (pageList?.length) {
331
+ output.push("");
332
+ for (const page of pageList) {
333
+ output.push(linkLine(page));
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ this.#fs.writeFileSync(llmsPath, output.join("\n"), "utf-8");
340
+ console.log(" ✓ llms.txt (augmented)");
341
+ }
342
+
228
343
  /**
229
344
  * Build documentation from Markdown files
230
345
  * @param {string} docsDir - Source documentation directory
231
346
  * @param {string} distDir - Destination distribution directory
347
+ * @param {string} [baseUrl] - Base URL for sitemap, canonical links, and llms.txt
232
348
  * @returns {Promise<void>}
233
349
  */
234
- async build(docsDir, distDir) {
350
+ async build(docsDir, distDir, baseUrl) {
235
351
  console.log("Building documentation...");
236
352
 
353
+ // Resolve base URL: explicit flag > CNAME fallback > undefined
354
+ if (!baseUrl) {
355
+ const cnamePath = this.#path.join(docsDir, "CNAME");
356
+ if (this.#fs.existsSync(cnamePath)) {
357
+ const hostname = this.#fs.readFileSync(cnamePath, "utf-8").trim();
358
+ baseUrl = `https://${hostname}`;
359
+ }
360
+ }
361
+
237
362
  // Clean and create dist directory
238
363
  if (this.#fs.existsSync(distDir)) {
239
364
  this.#fs.rmSync(distDir, { recursive: true });
@@ -265,6 +390,9 @@ export class DocsBuilder {
265
390
  }
266
391
  }
267
392
 
393
+ // Page inventory for sitemap and llms.txt generation
394
+ const pages = [];
395
+
268
396
  for (const mdFile of mdFiles) {
269
397
  const { data: frontMatter, content: markdown } = this.#matter(
270
398
  this.#fs.readFileSync(this.#path.join(docsDir, mdFile), "utf-8"),
@@ -292,6 +420,14 @@ export class DocsBuilder {
292
420
  const urlPath = this.#urlPathFromMdFile(mdFile);
293
421
  const breadcrumbs = this.#buildBreadcrumbs(urlPath, pageTitles);
294
422
 
423
+ // Collect page inventory for sitemap and llms.txt
424
+ pages.push({
425
+ mdFile,
426
+ urlPath,
427
+ title: frontMatter.title,
428
+ description: frontMatter.description || "",
429
+ });
430
+
295
431
  // Render template with context
296
432
  const outputHtml = this.#mustacheRender(template, {
297
433
  title: frontMatter.title,
@@ -309,6 +445,8 @@ export class DocsBuilder {
309
445
  hasHeroCta: heroCta.length > 0,
310
446
  hasBreadcrumbs: !!breadcrumbs,
311
447
  breadcrumbs,
448
+ markdownUrl: "index.md",
449
+ canonicalUrl: baseUrl ? baseUrl + urlPath : "",
312
450
  });
313
451
 
314
452
  // Format HTML with prettier
@@ -361,9 +499,26 @@ export class DocsBuilder {
361
499
  "utf-8",
362
500
  );
363
501
  console.log(` ✓ ${outputPath}.html`);
502
+
503
+ // Write markdown companion (index.md alongside index.html)
504
+ const companionContent = `# ${frontMatter.title}\n\n${this.#transformMarkdownBodyLinks(markdown)}`;
505
+ this.#fs.writeFileSync(
506
+ this.#path.join(distDir, outputPath + ".md"),
507
+ companionContent,
508
+ "utf-8",
509
+ );
364
510
  }
365
511
 
512
+ // Sort page inventory for deterministic output
513
+ pages.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
514
+
366
515
  this.#copyStaticAssets(docsDir, distDir);
516
+
517
+ if (baseUrl) {
518
+ this.#generateSitemap(pages, baseUrl, distDir);
519
+ this.#augmentLlmsTxt(pages, baseUrl, distDir);
520
+ }
521
+
367
522
  console.log("Documentation build complete!");
368
523
  }
369
524
  }
package/frontmatter.js CHANGED
@@ -1,4 +1,4 @@
1
- import yaml from "js-yaml";
1
+ import { parse } from "yaml";
2
2
 
3
3
  /**
4
4
  * Parse simple front matter from markdown content
@@ -20,7 +20,7 @@ export function parseFrontMatter(content) {
20
20
  }
21
21
 
22
22
  const [, frontMatter, remainingContent] = match;
23
- const parsedData = yaml.load(frontMatter);
23
+ const parsedData = parse(frontMatter);
24
24
 
25
25
  return {
26
26
  data: parsedData || {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libdoc",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Documentation build and serve tools for static site generation from Markdown",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,14 +28,14 @@
28
28
  "test": "node --test test/*.test.js"
29
29
  },
30
30
  "dependencies": {
31
- "@hono/node-server": "^1.19.9",
32
- "hono": "^4.11.4",
33
- "js-yaml": "^4.1.1",
34
- "marked": "^14.1.4",
31
+ "@hono/node-server": "^1.19.11",
32
+ "hono": "^4.12.9",
33
+ "marked": "^17.0.5",
35
34
  "marked-gfm-heading-id": "^4.1.3",
36
35
  "marked-highlight": "^2.2.3",
37
36
  "mustache": "^4.2.0",
38
- "prettier": "^3.8.0"
37
+ "prettier": "^3.8.1",
38
+ "yaml": "^2.8.3"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18.0.0"
package/server.js CHANGED
@@ -132,6 +132,8 @@ export class DocsServer {
132
132
  css: "text/css",
133
133
  js: "application/javascript",
134
134
  json: "application/json",
135
+ md: "text/markdown",
136
+ xml: "application/xml",
135
137
  png: "image/png",
136
138
  jpg: "image/jpeg",
137
139
  jpeg: "image/jpeg",