@opentf/web-docs 0.1.0 → 0.3.0

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.
Files changed (44) hide show
  1. package/build/blog-posts-plugin.js +127 -0
  2. package/build/docs-nav-plugin.js +17 -10
  3. package/build/feed.js +136 -0
  4. package/build/index.js +5 -0
  5. package/build/last-updated-plugin.js +98 -0
  6. package/build/last-updated.js +45 -0
  7. package/build/llms.js +156 -0
  8. package/build/pagefind.js +58 -0
  9. package/build/reading-time.js +16 -0
  10. package/components/BlogLayout.jsx +77 -0
  11. package/components/Breadcrumbs.jsx +30 -27
  12. package/components/Callout.jsx +7 -7
  13. package/components/Card.jsx +21 -0
  14. package/components/Cards.jsx +11 -0
  15. package/components/CodeBlock.jsx +39 -0
  16. package/components/DocsLayout.jsx +66 -6
  17. package/components/LastUpdated.jsx +22 -0
  18. package/components/NavIcon.jsx +34 -0
  19. package/components/Navbar.jsx +34 -22
  20. package/components/NavbarLink.jsx +33 -0
  21. package/components/Pagination.jsx +37 -33
  22. package/components/PostBanner.jsx +25 -0
  23. package/components/PostCard.jsx +19 -0
  24. package/components/PostList.jsx +17 -0
  25. package/components/PostMeta.jsx +28 -0
  26. package/components/ReadingTime.jsx +6 -0
  27. package/components/Search.jsx +211 -0
  28. package/components/SearchTrigger.jsx +14 -3
  29. package/components/Sidebar.jsx +111 -9
  30. package/components/SidebarNode.jsx +1 -0
  31. package/components/SidebarToggle.jsx +47 -0
  32. package/components/Steps.jsx +14 -0
  33. package/components/Tabs.jsx +4 -2
  34. package/components/ThemeToggle.jsx +150 -25
  35. package/components/Tooltip.jsx +19 -0
  36. package/components/format.js +11 -0
  37. package/config.js +28 -2
  38. package/index.js +29 -2
  39. package/nav-virtual.js +6 -4
  40. package/package.json +15 -1
  41. package/posts-virtual.js +17 -0
  42. package/theme/index.css +1034 -58
  43. package/updated-virtual.js +10 -0
  44. package/components/CodeGroup.jsx +0 -28
@@ -0,0 +1,127 @@
1
+ // Build-time blog post index generator (a Rolldown plugin), the blog counterpart of
2
+ // docs-nav-plugin.js. It scans `app/<dir>` for post folders (`<slug>/page.{mdx,md}`),
3
+ // reads each post's frontmatter + computes its reading time, and resolves the virtual
4
+ // module `@opentf/web-docs/posts` to the ordered post list. A pure build-time
5
+ // consumer of the file tree — no compiler stage, no runtime cost.
6
+ //
7
+ // Post shape: `{ slug, path, title, description?, date?, author?, tags?, readingTime,
8
+ // order? }`. Sorted newest-first by `date`; a numeric `order` (frontmatter) overrides.
9
+
10
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { readFrontmatter } from "./frontmatter.js";
14
+ import { readingTime } from "./reading-time.js";
15
+
16
+ const VIRTUAL_ID = "@opentf/web-docs/posts";
17
+ const RESOLVED_ID = "\0otfw-blog-posts";
18
+ const PAGE_RE = /^page\.(mdx|md)$/;
19
+
20
+ /**
21
+ * @param {Object} opts
22
+ * @param {string} opts.appDir Absolute path to the project's `app/` directory.
23
+ * @param {string} [opts.contentDir] Blog content folder under app/ (default "blog").
24
+ * @param {Set<string>} [opts.exclude] Folder names to skip.
25
+ */
26
+ export function blogPostsPlugin({ appDir, contentDir = "blog", exclude = new Set() } = {}) {
27
+ const root = join(appDir, contentDir);
28
+ const base = "/" + contentDir;
29
+ return {
30
+ name: "otfw-blog-posts",
31
+ resolveId(source) {
32
+ return source === VIRTUAL_ID ? RESOLVED_ID : null;
33
+ },
34
+ load(id) {
35
+ if (id !== RESOLVED_ID) return null;
36
+ const watch = [];
37
+ const posts = existsSync(root) ? collectPosts(root, base, exclude, watch) : [];
38
+ for (const f of watch) this.addWatchFile?.(f);
39
+ return `export const posts = ${JSON.stringify(posts)};\nexport default posts;\n`;
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Scan the blog content folder and return the ordered post list — the same array the
46
+ * `blogPostsPlugin` virtual module exposes, but callable directly (used by the feed
47
+ * generator at build time). Returns `[]` when the folder is absent.
48
+ *
49
+ * @param {Object} opts
50
+ * @param {string} opts.appDir
51
+ * @param {string} [opts.contentDir]
52
+ * @param {Set<string>} [opts.exclude]
53
+ */
54
+ export function loadPosts({ appDir, contentDir = "blog", exclude = new Set() } = {}) {
55
+ const root = join(appDir, contentDir);
56
+ if (!existsSync(root)) return [];
57
+ return collectPosts(root, "/" + contentDir, exclude, []);
58
+ }
59
+
60
+ function collectPosts(root, base, exclude, watch) {
61
+ const posts = [];
62
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
63
+ if (
64
+ !entry.isDirectory() ||
65
+ entry.name.startsWith(".") ||
66
+ entry.name.startsWith("_") ||
67
+ exclude.has(entry.name)
68
+ ) {
69
+ continue;
70
+ }
71
+ const dir = join(root, entry.name);
72
+ const page = readdirSync(dir).find((n) => PAGE_RE.test(n));
73
+ if (!page) continue;
74
+
75
+ const file = join(dir, page);
76
+ watch.push(file);
77
+ const source = readFileSync(file, "utf8");
78
+ const fm = readFrontmatter(file);
79
+ posts.push(
80
+ clean({
81
+ slug: entry.name,
82
+ path: `${base}/${entry.name}`,
83
+ title: fm.title ?? humanize(entry.name),
84
+ description: fm.description,
85
+ date: fm.date != null ? String(fm.date) : undefined,
86
+ // Cover image (frontmatter `cover`) — shown on the post banner and the card.
87
+ cover: fm.cover,
88
+ author: fm.author,
89
+ authorAvatar: fm.author_avatar,
90
+ authorRole: fm.author_role,
91
+ // Frontmatter is flat scalars, so `tags: a, b` arrives as a string; normalize
92
+ // to a trimmed array. Components can render chips from it.
93
+ tags: typeof fm.tags === "string" ? splitTags(fm.tags) : undefined,
94
+ readingTime: readingTime(source),
95
+ order: typeof fm.order === "number" ? fm.order : undefined,
96
+ }),
97
+ );
98
+ }
99
+ return sortPosts(posts);
100
+ }
101
+
102
+ /** Newest-first by `date`; a numeric `order` wins when present; title breaks ties. */
103
+ function sortPosts(posts) {
104
+ return posts.sort((a, b) => {
105
+ if (a.order != null || b.order != null) {
106
+ return (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER);
107
+ }
108
+ if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1;
109
+ if (a.date && !b.date) return -1;
110
+ if (!a.date && b.date) return 1;
111
+ return a.title.localeCompare(b.title);
112
+ });
113
+ }
114
+
115
+ function splitTags(s) {
116
+ const tags = s.split(",").map((t) => t.trim()).filter(Boolean);
117
+ return tags.length ? tags : undefined;
118
+ }
119
+
120
+ function humanize(seg) {
121
+ return seg.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
122
+ }
123
+
124
+ function clean(node) {
125
+ for (const k of Object.keys(node)) if (node[k] === undefined) delete node[k];
126
+ return node;
127
+ }
@@ -22,17 +22,16 @@ const PAGE_RE = /^page\.(mdx|md|[jt]sx)$/;
22
22
  const MD_RE = /\.(mdx|md)$/;
23
23
 
24
24
  /**
25
+ * Generates `@opentf/web-docs/nav` as a **section map** — `{ "/<dir>": tree }`, one entry
26
+ * per top-level folder under `app/`. Each folder is a potential `DocsLayout` section; the
27
+ * layout selects its own subtree by the current route, so any number of sections (`/docs`,
28
+ * `/api`, …) "just work" with no config — drop a folder in and give it a `DocsLayout`.
29
+ *
25
30
  * @param {Object} opts
26
- * @param {string} opts.appDir Absolute path to the project's `app/` directory.
27
- * @param {string} [opts.contentDir] Docs content folder under app/ (default "docs").
31
+ * @param {string} opts.appDir Absolute path to the project's `app/` directory.
28
32
  * @param {Set<string>} [opts.exclude] Folder names to skip (mirrors route exclusions).
29
33
  */
30
- export function docsNavPlugin({ appDir, contentDir = "docs", exclude = new Set() } = {}) {
31
- // `"."`/`""` means the docs live at the app root (routes at "/"); otherwise they
32
- // live in app/<contentDir> (routes under "/<contentDir>").
33
- const atRoot = contentDir === "." || contentDir === "";
34
- const root = atRoot ? appDir : join(appDir, contentDir);
35
- const base = atRoot ? "" : "/" + contentDir;
34
+ export function docsNavPlugin({ appDir, exclude = new Set() } = {}) {
36
35
  return {
37
36
  name: "otfw-docs-nav",
38
37
  resolveId(source) {
@@ -42,10 +41,18 @@ export function docsNavPlugin({ appDir, contentDir = "docs", exclude = new Set()
42
41
  async load(id) {
43
42
  if (id !== RESOLVED_ID) return null;
44
43
  const watch = [];
45
- const tree = existsSync(root) ? await buildSection(root, base, watch, true, exclude) : [];
44
+ const out = {};
45
+ for (const entry of readdirSync(appDir, { withFileTypes: true })) {
46
+ if (!entry.isDirectory()) continue;
47
+ if (entry.name.startsWith(".") || entry.name.startsWith("_") || exclude.has(entry.name)) {
48
+ continue;
49
+ }
50
+ const base = "/" + entry.name;
51
+ out[base] = await buildSection(join(appDir, entry.name), base, watch, true, exclude);
52
+ }
46
53
  // Rebuild on changes to meta/page files during `otfw dev`.
47
54
  for (const f of watch) this.addWatchFile?.(f);
48
- return `export default ${JSON.stringify(tree)};\n`;
55
+ return `export default ${JSON.stringify(out)};\n`;
49
56
  },
50
57
  };
51
58
  }
package/build/feed.js ADDED
@@ -0,0 +1,136 @@
1
+ // RSS/Atom feed generation for the blog. Pure, build-time counterparts to the
2
+ // sitemap: given the post list (from `loadPosts`) plus the site origin and channel
3
+ // metadata, render feed XML strings. The web-cli build writes them to
4
+ // `dist/<blogDir>/rss.xml` and `dist/<blogDir>/atom.xml` when a base URL is configured.
5
+ //
6
+ // RSS 2.0 (https://www.rssboard.org/rss-specification) is the lowest-common-
7
+ // denominator feed format: every reader supports it, and `<pubDate>` must be RFC-822.
8
+
9
+ /** XML text-escape (the five predefined entities). */
10
+ function esc(s) {
11
+ return String(s).replace(/[&<>'"]/g, (c) =>
12
+ c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "'" ? "&apos;" : "&quot;",
13
+ );
14
+ }
15
+
16
+ /** Absolute URL from a (trailing-slash-trimmed) origin + an absolute path. */
17
+ function abs(baseUrl, path) {
18
+ return baseUrl.replace(/\/+$/, "") + (path.startsWith("/") ? path : "/" + path);
19
+ }
20
+
21
+ /** `YYYY-MM-DD` (or any Date-parseable string) → RFC-822, e.g. for `<pubDate>`. */
22
+ function rfc822(date) {
23
+ if (!date) return null;
24
+ const d = new Date(date);
25
+ return Number.isNaN(d.getTime()) ? null : d.toUTCString();
26
+ }
27
+
28
+ /** `YYYY-MM-DD` (or any Date-parseable string) -> ISO-8601 for Atom timestamps. */
29
+ function iso8601(date) {
30
+ if (!date) return null;
31
+ const d = new Date(date);
32
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
33
+ }
34
+
35
+ /**
36
+ * Render an RSS 2.0 feed.
37
+ *
38
+ * @param {Object} opts
39
+ * @param {Array} opts.posts Post list (from `loadPosts`).
40
+ * @param {string} opts.baseUrl Canonical site origin (required; absolute links).
41
+ * @param {string} [opts.feedPath] Path of the feed itself (for `<atom:link rel=self>`).
42
+ * @param {Object} [opts.channel] `{ title, description, link, language }`.
43
+ * @returns {string} The `rss.xml` document.
44
+ */
45
+ export function renderBlogFeed({ posts = [], baseUrl, feedPath = "/blog/rss.xml", channel = {} } = {}) {
46
+ const title = channel.title || "Blog";
47
+ const description = channel.description || title;
48
+ const link = channel.link ? abs(baseUrl, channel.link) : abs(baseUrl, "/blog");
49
+ const language = channel.language || "en";
50
+ const self = abs(baseUrl, feedPath);
51
+ const lastBuild = rfc822(posts.find((p) => p.date)?.date) || new Date().toUTCString();
52
+
53
+ const items = posts
54
+ .map((p) => {
55
+ const url = abs(baseUrl, p.path);
56
+ const pubDate = rfc822(p.date);
57
+ const parts = [
58
+ ` <title>${esc(p.title)}</title>`,
59
+ ` <link>${esc(url)}</link>`,
60
+ ` <guid isPermaLink="true">${esc(url)}</guid>`,
61
+ ];
62
+ if (pubDate) parts.push(` <pubDate>${pubDate}</pubDate>`);
63
+ if (p.author) parts.push(` <dc:creator>${esc(p.author)}</dc:creator>`);
64
+ if (p.description) parts.push(` <description>${esc(p.description)}</description>`);
65
+ for (const tag of p.tags || []) parts.push(` <category>${esc(tag)}</category>`);
66
+ return ` <item>\n${parts.join("\n")}\n </item>`;
67
+ })
68
+ .join("\n");
69
+
70
+ return (
71
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
72
+ `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">\n` +
73
+ ` <channel>\n` +
74
+ ` <title>${esc(title)}</title>\n` +
75
+ ` <link>${esc(link)}</link>\n` +
76
+ ` <atom:link href="${esc(self)}" rel="self" type="application/rss+xml"/>\n` +
77
+ ` <description>${esc(description)}</description>\n` +
78
+ ` <language>${esc(language)}</language>\n` +
79
+ ` <lastBuildDate>${lastBuild}</lastBuildDate>\n` +
80
+ ` <generator>@opentf/web-docs</generator>\n` +
81
+ (items ? items + "\n" : "") +
82
+ ` </channel>\n` +
83
+ `</rss>\n`
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Render an Atom 1.0 feed.
89
+ *
90
+ * @param {Object} opts
91
+ * @param {Array} opts.posts Post list (from `loadPosts`).
92
+ * @param {string} opts.baseUrl Canonical site origin (required; absolute links).
93
+ * @param {string} [opts.feedPath] Path of the feed itself (for `<link rel=self>`).
94
+ * @param {Object} [opts.channel] `{ title, description, link, author }`.
95
+ * @returns {string} The `atom.xml` document.
96
+ */
97
+ export function renderAtomFeed({ posts = [], baseUrl, feedPath = "/blog/atom.xml", channel = {} } = {}) {
98
+ const title = channel.title || "Blog";
99
+ const description = channel.description || title;
100
+ const link = channel.link ? abs(baseUrl, channel.link) : abs(baseUrl, "/blog");
101
+ const self = abs(baseUrl, feedPath);
102
+ const updated = iso8601(posts.find((p) => p.date)?.date) || new Date().toISOString();
103
+ const fallbackAuthor = channel.author || title;
104
+
105
+ const entries = posts
106
+ .map((p) => {
107
+ const url = abs(baseUrl, p.path);
108
+ const published = iso8601(p.date);
109
+ const parts = [
110
+ ` <title>${esc(p.title)}</title>`,
111
+ ` <link href="${esc(url)}"/>`,
112
+ ` <id>${esc(url)}</id>`,
113
+ ` <updated>${published || updated}</updated>`,
114
+ ];
115
+ if (published) parts.push(` <published>${published}</published>`);
116
+ parts.push(` <author><name>${esc(p.author || fallbackAuthor)}</name></author>`);
117
+ if (p.description) parts.push(` <summary>${esc(p.description)}</summary>`);
118
+ for (const tag of p.tags || []) parts.push(` <category term="${esc(tag)}"/>`);
119
+ return ` <entry>\n${parts.join("\n")}\n </entry>`;
120
+ })
121
+ .join("\n");
122
+
123
+ return (
124
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
125
+ `<feed xmlns="http://www.w3.org/2005/Atom">\n` +
126
+ ` <title>${esc(title)}</title>\n` +
127
+ ` <subtitle>${esc(description)}</subtitle>\n` +
128
+ ` <link href="${esc(link)}"/>\n` +
129
+ ` <link href="${esc(self)}" rel="self" type="application/atom+xml"/>\n` +
130
+ ` <id>${esc(link)}</id>\n` +
131
+ ` <updated>${updated}</updated>\n` +
132
+ ` <generator>@opentf/web-docs</generator>\n` +
133
+ (entries ? entries + "\n" : "") +
134
+ `</feed>\n`
135
+ );
136
+ }
package/build/index.js CHANGED
@@ -5,3 +5,8 @@
5
5
  // from web-cli keeps all docs-specific build logic owned by this package.
6
6
 
7
7
  export { docsNavPlugin } from "./docs-nav-plugin.js";
8
+ export { blogPostsPlugin, loadPosts } from "./blog-posts-plugin.js";
9
+ export { lastUpdatedPlugin, loadLastUpdated } from "./last-updated-plugin.js";
10
+ export { renderAtomFeed, renderBlogFeed } from "./feed.js";
11
+ export { renderLlmsFullTxt, renderLlmsTxt } from "./llms.js";
12
+ export { indexWithPagefind } from "./pagefind.js";
@@ -0,0 +1,98 @@
1
+ // Build-time "last updated" map generator (a Rolldown plugin), sibling of the nav and
2
+ // blog-posts plugins. It walks every page under `app/`, resolves each one's last-updated
3
+ // time (git commit or frontmatter override — see last-updated.js), and resolves the
4
+ // virtual module `@opentf/web-docs/updated` to a `{ [routePath]: ISO }` map. Layouts look
5
+ // the current route up in that map to render a "Last updated" line — so every section
6
+ // gets it from one switch, no per-section config.
7
+ //
8
+ // The same map is needed by the SSG step for the `article:modified_time` SEO tag, so
9
+ // the scan is exposed as a callable (`loadLastUpdated`) too.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { readdirSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
+
15
+ import { readFrontmatter } from "./frontmatter.js";
16
+ import { resolveLastUpdated } from "./last-updated.js";
17
+
18
+ const VIRTUAL_ID = "@opentf/web-docs/updated";
19
+ const RESOLVED_ID = "\0otfw-last-updated";
20
+ const PAGE_RE = /^page\.(mdx|md|[jt]sx)$/;
21
+ const MD_RE = /\.(mdx|md)$/;
22
+
23
+ /**
24
+ * @param {Object} opts
25
+ * @param {string} opts.appDir
26
+ * @param {Set<string>} [opts.exclude] Folder names to skip (mirrors route exclusions).
27
+ */
28
+ export function lastUpdatedPlugin({ appDir, exclude = new Set() } = {}) {
29
+ return {
30
+ name: "otfw-last-updated",
31
+ resolveId(source) {
32
+ return source === VIRTUAL_ID ? RESOLVED_ID : null;
33
+ },
34
+ load(id) {
35
+ if (id !== RESOLVED_ID) return null;
36
+ const watch = [];
37
+ const { updated, editPaths } = collect(appDir, exclude, watch);
38
+ for (const f of watch) this.addWatchFile?.(f);
39
+ // Default export: the `{ route: ISO }` last-updated map. Named `editPaths`:
40
+ // `{ route: repo-relative-file }`, for building "Edit this page" links.
41
+ return (
42
+ `export default ${JSON.stringify(updated)};\n` +
43
+ `export const editPaths = ${JSON.stringify(editPaths)};\n`
44
+ );
45
+ },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * The `{ [routePath]: ISO }` last-updated map, callable directly (used by the SSG SEO
51
+ * step). Mirrors what the virtual module exposes.
52
+ */
53
+ export function loadLastUpdated({ appDir, exclude = new Set() } = {}) {
54
+ return collect(appDir, exclude, []).updated;
55
+ }
56
+
57
+ /** Git repo root containing `dir`, or null (so edit links degrade gracefully). */
58
+ function gitRoot(dir) {
59
+ try {
60
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
61
+ cwd: dir,
62
+ encoding: "utf8",
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ }).trim() || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function collect(appDir, exclude, watch) {
71
+ const updated = {};
72
+ const editPaths = {};
73
+ const root0 = gitRoot(appDir);
74
+ // One walk over every page under app/ — covers all sections (and the home page).
75
+ walk(appDir, "", exclude, (file, route) => {
76
+ watch.push(file);
77
+ const fm = MD_RE.test(file) ? readFrontmatter(file) : {};
78
+ const iso = resolveLastUpdated(file, fm.lastUpdated);
79
+ if (iso) updated[route] = iso;
80
+ // Repo-relative path (POSIX separators) for the "Edit this page" link.
81
+ if (root0) editPaths[route] = relative(root0, file).split(/[\\/]/).join("/");
82
+ });
83
+ return { updated, editPaths };
84
+ }
85
+
86
+ /** Recursively find every `page.*` under `root`, mapping it to its route path. */
87
+ function walk(dir, route, exclude, onPage) {
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ if (entry.isDirectory()) {
90
+ if (entry.name.startsWith(".") || entry.name.startsWith("_") || exclude.has(entry.name)) {
91
+ continue;
92
+ }
93
+ walk(join(dir, entry.name), `${route}/${entry.name}`, exclude, onPage);
94
+ } else if (PAGE_RE.test(entry.name)) {
95
+ onPage(join(dir, entry.name), route || "/");
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,45 @@
1
+ // "Last updated" resolution for a page file. Two sources only (build time):
2
+ //
3
+ // 1. A frontmatter override — `lastUpdated: 2026-06-25` (any Date-parseable value)
4
+ // pins the date; `lastUpdated: false` hides it for that page.
5
+ // 2. The file's last git commit (committer date, `git log -1 --format=%cI`).
6
+ //
7
+ // No file-mtime fallback: mtime is noise (a checkout/copy bumps it), so when the file
8
+ // isn't in git history the value is simply null and the UI omits it. This runs in dev
9
+ // and build alike — the displayed time is always the real last content change.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { dirname } from "node:path";
13
+
14
+ /** Last git commit (committer ISO-8601) that touched `file`, or null. */
15
+ export function gitLastUpdated(file) {
16
+ try {
17
+ const out = execFileSync("git", ["log", "-1", "--format=%cI", "--", file], {
18
+ cwd: dirname(file),
19
+ encoding: "utf8",
20
+ stdio: ["ignore", "pipe", "ignore"],
21
+ }).trim();
22
+ return out || null; // empty = untracked / no history (e.g. a shallow CI clone)
23
+ } catch {
24
+ return null; // not a git repo / git unavailable
25
+ }
26
+ }
27
+
28
+ /** Normalize a frontmatter override to ISO-8601, or keep the raw string if unparseable. */
29
+ function normalize(value) {
30
+ const d = new Date(value);
31
+ return Number.isNaN(d.getTime()) ? String(value) : d.toISOString();
32
+ }
33
+
34
+ /**
35
+ * Resolve a page's last-updated timestamp.
36
+ *
37
+ * @param {string} file Absolute path to the page file.
38
+ * @param {*} [frontmatter] The frontmatter `lastUpdated` value, if any.
39
+ * @returns {string|null} ISO-8601 (or a raw override string), or null to omit.
40
+ */
41
+ export function resolveLastUpdated(file, frontmatter) {
42
+ if (frontmatter === false) return null; // explicit opt-out for this page
43
+ if (frontmatter != null && frontmatter !== true) return normalize(frontmatter);
44
+ return gitLastUpdated(file);
45
+ }
package/build/llms.js ADDED
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, relative, sep } from "node:path";
3
+
4
+ import { readFrontmatter } from "./frontmatter.js";
5
+
6
+ const PAGE_RE = /(^|[\\/])page\.(mdx|md|[jt]sx)$/;
7
+ const MD_RE = /\.(mdx|md)$/;
8
+
9
+ function abs(baseUrl, path) {
10
+ return baseUrl.replace(/\/+$/, "") + (path === "/" ? "/" : path);
11
+ }
12
+
13
+ function routePath(appDir, file) {
14
+ const rel = relative(appDir, file).split(sep).join("/");
15
+ if (!PAGE_RE.test(rel)) return null;
16
+ const dir = dirname(rel).split(sep).join("/");
17
+ if (dir === ".") return "/";
18
+ const segments = dir.split("/");
19
+ if (segments.some((seg) => seg.startsWith("_") || seg.startsWith(".") || seg.includes("["))) {
20
+ return null;
21
+ }
22
+ return "/" + segments.join("/");
23
+ }
24
+
25
+ function humanize(seg) {
26
+ return seg.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
27
+ }
28
+
29
+ function titleFor(file, path) {
30
+ const fm = MD_RE.test(file) ? readFrontmatter(file) : {};
31
+ if (fm.title) return String(fm.title);
32
+ if (path === "/") return "Home";
33
+ return humanize(path.split("/").filter(Boolean).at(-1) ?? "Home");
34
+ }
35
+
36
+ function descriptionFor(file) {
37
+ const fm = MD_RE.test(file) ? readFrontmatter(file) : {};
38
+ return fm.description ? String(fm.description) : "";
39
+ }
40
+
41
+ function stripFrontmatter(source) {
42
+ return source.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
43
+ }
44
+
45
+ function stripMdxBoilerplate(source) {
46
+ const lines = stripFrontmatter(source).split(/\r?\n/);
47
+ const out = [];
48
+ let inFence = false;
49
+ for (const line of lines) {
50
+ if (/^```/.test(line.trim())) inFence = !inFence;
51
+ if (!inFence && /^\s*import\s/.test(line)) continue;
52
+ if (!inFence && /^\s*export\s+(const|let|var|default)\s/.test(line)) continue;
53
+ out.push(line);
54
+ }
55
+ return out.join("\n").trim();
56
+ }
57
+
58
+ function sectionFor(path) {
59
+ if (path === "/") return "Start Here";
60
+ if (path.startsWith("/docs")) return "Documentation";
61
+ if (path.startsWith("/api")) return "API Reference";
62
+ if (path.startsWith("/blog")) return "Blog";
63
+ return "Other Routes";
64
+ }
65
+
66
+ function pageRecords({ appDir, pages = [], baseUrl }) {
67
+ return pages
68
+ .filter((file) => PAGE_RE.test(file))
69
+ .map((file) => {
70
+ const path = routePath(appDir, file);
71
+ if (!path) return null;
72
+ return {
73
+ file,
74
+ path,
75
+ url: abs(baseUrl, path),
76
+ title: titleFor(file, path),
77
+ description: descriptionFor(file),
78
+ markdown: MD_RE.test(file) && existsSync(file) ? stripMdxBoilerplate(readFileSync(file, "utf8")) : "",
79
+ };
80
+ })
81
+ .filter(Boolean)
82
+ .sort((a, b) => {
83
+ if (a.path === "/") return -1;
84
+ if (b.path === "/") return 1;
85
+ return a.path.localeCompare(b.path);
86
+ });
87
+ }
88
+
89
+ function grouped(records) {
90
+ const out = new Map();
91
+ for (const record of records) {
92
+ const section = sectionFor(record.path);
93
+ if (!out.has(section)) out.set(section, []);
94
+ out.get(section).push(record);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ const SECTION_ORDER = ["Start Here", "Documentation", "API Reference", "Blog", "Other Routes"];
100
+
101
+ export function renderLlmsTxt({ appDir, pages = [], baseUrl, config = {} } = {}) {
102
+ const title = config?.docs?.title || "OTF Web";
103
+ const records = pageRecords({ appDir, pages, baseUrl });
104
+ const description =
105
+ config?.docs?.description ||
106
+ "Documentation, API reference, and blog content for the OTF Web framework.";
107
+ const lines = [
108
+ `# ${title}`,
109
+ "",
110
+ `> ${description}`,
111
+ "",
112
+ `This file is a curated entry point for language models. For full page content, see [llms-full.txt](${abs(baseUrl, "/llms-full.txt")}).`,
113
+ "",
114
+ ];
115
+
116
+ const groups = grouped(records);
117
+ for (const section of SECTION_ORDER) {
118
+ const items = groups.get(section);
119
+ if (!items?.length) continue;
120
+ lines.push(`## ${section}`, "");
121
+ for (const item of items) {
122
+ const note = item.description ? `: ${item.description}` : "";
123
+ lines.push(`- [${item.title}](${item.url})${note}`);
124
+ }
125
+ lines.push("");
126
+ }
127
+
128
+ if (config?.blog) {
129
+ lines.push("## Optional", "");
130
+ lines.push(`- [RSS feed](${abs(baseUrl, `/${config.blog.dir ?? "blog"}/rss.xml`)}): Blog feed in RSS 2.0 format.`);
131
+ lines.push(`- [Atom feed](${abs(baseUrl, `/${config.blog.dir ?? "blog"}/atom.xml`)}): Blog feed in Atom 1.0 format.`);
132
+ }
133
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
134
+ }
135
+
136
+ export function renderLlmsFullTxt({ appDir, pages = [], baseUrl, config = {} } = {}) {
137
+ const title = config?.docs?.title || "OTF Web";
138
+ const records = pageRecords({ appDir, pages, baseUrl }).filter((record) => record.markdown);
139
+ const lines = [
140
+ `# ${title} Full Documentation`,
141
+ "",
142
+ `Generated from filesystem routes for ${baseUrl.replace(/\/+$/, "")}.`,
143
+ "",
144
+ ];
145
+
146
+ for (const record of records) {
147
+ lines.push(`## ${record.title}`, "");
148
+ lines.push(`URL: ${record.url}`);
149
+ if (record.description) lines.push(`Description: ${record.description}`);
150
+ lines.push("");
151
+ lines.push(record.markdown);
152
+ lines.push("");
153
+ }
154
+
155
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
156
+ }
@@ -0,0 +1,58 @@
1
+ // Post-build Pagefind indexing for the docs site (Phase 2 search).
2
+ //
3
+ // Runs after the SSG pre-render, over the built HTML in `dist/`. Pagefind reads the
4
+ // `data-pagefind-body` region of each page (the docs `<main id="otfw-content">`) and
5
+ // writes a static, fragmented search index to `dist/pagefind/`. The runtime `<Search>`
6
+ // modal loads `/pagefind/pagefind.js` on demand and queries that index — no server.
7
+ //
8
+ // `@opentf/web-cli` calls this from `otfw build --ssg` when the project's docs config
9
+ // has `search.provider === "pagefind"`, keeping all docs build logic owned by web-docs.
10
+
11
+ import { readdir, readFile } from "node:fs/promises";
12
+ import { join, relative } from "node:path";
13
+
14
+ /** Recursively collect every `*.html` file under `dir` (absolute paths). */
15
+ async function htmlFiles(dir) {
16
+ const out = [];
17
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
18
+ const path = join(dir, entry.name);
19
+ if (entry.isDirectory()) out.push(...(await htmlFiles(path)));
20
+ else if (entry.name.endsWith(".html")) out.push(path);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /**
26
+ * Index a built site directory with Pagefind, file by file so callers can show
27
+ * progress. Only pages that opted into search (`data-pagefind-body`, the docs shell)
28
+ * are added — matching Pagefind's site-wide body-exclusion rule deterministically.
29
+ *
30
+ * @param {{ siteDir: string, onProgress?: (done: number, total: number) => void }} opts
31
+ * @returns {Promise<{ pages: number, errors: string[] }>}
32
+ */
33
+ export async function indexWithPagefind({ siteDir, onProgress }) {
34
+ // Lazy import so the (native) Pagefind binary is only loaded when search is enabled.
35
+ const pagefind = await import("pagefind");
36
+ const { index } = await pagefind.createIndex();
37
+
38
+ // Pick the searchable pages up front so progress has a real total.
39
+ const pages = [];
40
+ for (const path of await htmlFiles(siteDir)) {
41
+ const content = await readFile(path, "utf8");
42
+ if (content.includes("data-pagefind-body")) pages.push({ path, content });
43
+ }
44
+
45
+ const total = pages.length;
46
+ const errors = [];
47
+ let done = 0;
48
+ onProgress?.(0, total);
49
+ for (const { path, content } of pages) {
50
+ const res = await index.addHTMLFile({ sourcePath: relative(siteDir, path), content });
51
+ if (res.errors && res.errors.length) errors.push(...res.errors);
52
+ onProgress?.(++done, total);
53
+ }
54
+
55
+ await index.writeFiles({ outputPath: join(siteDir, "pagefind") });
56
+ await pagefind.close();
57
+ return { pages: total, errors };
58
+ }