@opentf/web-docs 0.1.0 → 0.2.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.
- package/build/blog-posts-plugin.js +127 -0
- package/build/docs-nav-plugin.js +17 -10
- package/build/feed.js +78 -0
- package/build/index.js +4 -0
- package/build/last-updated-plugin.js +98 -0
- package/build/last-updated.js +45 -0
- package/build/pagefind.js +58 -0
- package/build/reading-time.js +16 -0
- package/components/BlogLayout.jsx +77 -0
- package/components/Breadcrumbs.jsx +30 -27
- package/components/Callout.jsx +7 -7
- package/components/Card.jsx +21 -0
- package/components/Cards.jsx +11 -0
- package/components/CodeBlock.jsx +39 -0
- package/components/DocsLayout.jsx +66 -6
- package/components/LastUpdated.jsx +22 -0
- package/components/NavIcon.jsx +34 -0
- package/components/Navbar.jsx +34 -22
- package/components/NavbarLink.jsx +33 -0
- package/components/Pagination.jsx +37 -33
- package/components/PostBanner.jsx +25 -0
- package/components/PostCard.jsx +19 -0
- package/components/PostList.jsx +17 -0
- package/components/PostMeta.jsx +28 -0
- package/components/ReadingTime.jsx +6 -0
- package/components/Search.jsx +211 -0
- package/components/SearchTrigger.jsx +14 -3
- package/components/Sidebar.jsx +111 -9
- package/components/SidebarNode.jsx +1 -0
- package/components/SidebarToggle.jsx +47 -0
- package/components/Steps.jsx +14 -0
- package/components/Tabs.jsx +4 -2
- package/components/ThemeToggle.jsx +150 -25
- package/components/Tooltip.jsx +19 -0
- package/components/format.js +11 -0
- package/config.js +25 -1
- package/index.js +29 -2
- package/nav-virtual.js +6 -4
- package/package.json +15 -1
- package/posts-virtual.js +17 -0
- package/theme/index.css +1034 -58
- package/updated-virtual.js +9 -0
- 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 RSS
|
|
47
|
+
* feed 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
|
+
}
|
package/build/docs-nav-plugin.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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(
|
|
55
|
+
return `export default ${JSON.stringify(out)};\n`;
|
|
49
56
|
},
|
|
50
57
|
};
|
|
51
58
|
}
|
package/build/feed.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// RSS 2.0 feed generation for the blog. A pure, build-time counterpart to the
|
|
2
|
+
// sitemap: given the post list (from `loadPosts`) plus the site origin and channel
|
|
3
|
+
// metadata, it renders an `rss.xml` string. The web-cli build writes it to
|
|
4
|
+
// `dist/<blogDir>/rss.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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === "'" ? "'" : """,
|
|
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
|
+
/**
|
|
29
|
+
* Render an RSS 2.0 feed.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} opts
|
|
32
|
+
* @param {Array} opts.posts Post list (from `loadPosts`).
|
|
33
|
+
* @param {string} opts.baseUrl Canonical site origin (required; absolute links).
|
|
34
|
+
* @param {string} [opts.feedPath] Path of the feed itself (for `<atom:link rel=self>`).
|
|
35
|
+
* @param {Object} [opts.channel] `{ title, description, link, language }`.
|
|
36
|
+
* @returns {string} The `rss.xml` document.
|
|
37
|
+
*/
|
|
38
|
+
export function renderBlogFeed({ posts = [], baseUrl, feedPath = "/blog/rss.xml", channel = {} } = {}) {
|
|
39
|
+
const title = channel.title || "Blog";
|
|
40
|
+
const description = channel.description || title;
|
|
41
|
+
const link = channel.link ? abs(baseUrl, channel.link) : abs(baseUrl, "/blog");
|
|
42
|
+
const language = channel.language || "en";
|
|
43
|
+
const self = abs(baseUrl, feedPath);
|
|
44
|
+
const lastBuild = rfc822(posts.find((p) => p.date)?.date) || new Date().toUTCString();
|
|
45
|
+
|
|
46
|
+
const items = posts
|
|
47
|
+
.map((p) => {
|
|
48
|
+
const url = abs(baseUrl, p.path);
|
|
49
|
+
const pubDate = rfc822(p.date);
|
|
50
|
+
const parts = [
|
|
51
|
+
` <title>${esc(p.title)}</title>`,
|
|
52
|
+
` <link>${esc(url)}</link>`,
|
|
53
|
+
` <guid isPermaLink="true">${esc(url)}</guid>`,
|
|
54
|
+
];
|
|
55
|
+
if (pubDate) parts.push(` <pubDate>${pubDate}</pubDate>`);
|
|
56
|
+
if (p.author) parts.push(` <dc:creator>${esc(p.author)}</dc:creator>`);
|
|
57
|
+
if (p.description) parts.push(` <description>${esc(p.description)}</description>`);
|
|
58
|
+
for (const tag of p.tags || []) parts.push(` <category>${esc(tag)}</category>`);
|
|
59
|
+
return ` <item>\n${parts.join("\n")}\n </item>`;
|
|
60
|
+
})
|
|
61
|
+
.join("\n");
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
65
|
+
`<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">\n` +
|
|
66
|
+
` <channel>\n` +
|
|
67
|
+
` <title>${esc(title)}</title>\n` +
|
|
68
|
+
` <link>${esc(link)}</link>\n` +
|
|
69
|
+
` <atom:link href="${esc(self)}" rel="self" type="application/rss+xml"/>\n` +
|
|
70
|
+
` <description>${esc(description)}</description>\n` +
|
|
71
|
+
` <language>${esc(language)}</language>\n` +
|
|
72
|
+
` <lastBuildDate>${lastBuild}</lastBuildDate>\n` +
|
|
73
|
+
` <generator>@opentf/web-docs</generator>\n` +
|
|
74
|
+
(items ? items + "\n" : "") +
|
|
75
|
+
` </channel>\n` +
|
|
76
|
+
`</rss>\n`
|
|
77
|
+
);
|
|
78
|
+
}
|
package/build/index.js
CHANGED
|
@@ -5,3 +5,7 @@
|
|
|
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 { renderBlogFeed } from "./feed.js";
|
|
11
|
+
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Estimate a post's reading time (minutes) from its Markdown source. Strips
|
|
2
|
+
// frontmatter, code, HTML/JSX tags, and Markdown punctuation before counting words,
|
|
3
|
+
// then divides by `wpm` (200 words/min ≈ average adult prose reading). Build-time
|
|
4
|
+
// only — the result ships as data on each post (see blog-posts-plugin.js).
|
|
5
|
+
export function readingTime(source, wpm = 200) {
|
|
6
|
+
const body = String(source || "")
|
|
7
|
+
.replace(/^?---\r?\n[\s\S]*?\r?\n---/, "") // frontmatter
|
|
8
|
+
.replace(/```[\s\S]*?```/g, " ") // fenced code
|
|
9
|
+
.replace(/`[^`]*`/g, " ") // inline code
|
|
10
|
+
.replace(/<[^>]+>/g, " ") // html / jsx tags
|
|
11
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
|
|
12
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → their text
|
|
13
|
+
.replace(/[#>*_~`\-]+/g, " "); // residual md punctuation
|
|
14
|
+
const words = body.split(/\s+/).filter(Boolean).length;
|
|
15
|
+
return Math.max(1, Math.round(words / wpm));
|
|
16
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Blog section layout. Wraps both the index (`/blog`) and individual posts
|
|
2
|
+
// (`/blog/<slug>`). It identifies the current page by matching `router.pathname`
|
|
3
|
+
// against the post list (from `@opentf/web-docs/posts`):
|
|
4
|
+
//
|
|
5
|
+
// import BlogLayout from "@opentf/web-docs"; // BlogLayout
|
|
6
|
+
// import { posts } from "@opentf/web-docs/posts";
|
|
7
|
+
// import config from "../../otfw.config.js";
|
|
8
|
+
// export default function (props) {
|
|
9
|
+
// return <BlogLayout config={config.docs} posts={posts} frame={false}>{props.children}</BlogLayout>;
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// On a post it renders the PostBanner (from the post's frontmatter) above the prose
|
|
13
|
+
// body, plus a reusable "On this page" TOC; on the index it just renders the children
|
|
14
|
+
// (typically a <PostList/>). `frame` (default true) adds the navbar/footer chrome;
|
|
15
|
+
// pass `frame={false}` when nesting inside a site layout that already provides them.
|
|
16
|
+
//
|
|
17
|
+
// The layout factory re-runs on every navigation (the router rebuilds the route +
|
|
18
|
+
// layout chain), so reading `router.pathname` resolves to the page being shown.
|
|
19
|
+
import { Link, router } from "@opentf/web";
|
|
20
|
+
import updated from "@opentf/web-docs/updated";
|
|
21
|
+
|
|
22
|
+
import Navbar from "./Navbar.jsx";
|
|
23
|
+
import Footer from "./Footer.jsx";
|
|
24
|
+
import Toc from "./Toc.jsx";
|
|
25
|
+
import PostBanner from "./PostBanner.jsx";
|
|
26
|
+
import LastUpdated from "./LastUpdated.jsx";
|
|
27
|
+
|
|
28
|
+
// True when the post was edited after it was published (different calendar day), so a
|
|
29
|
+
// "Last updated" line adds information rather than echoing the publish date.
|
|
30
|
+
function editedAfterPublish(iso, published) {
|
|
31
|
+
if (!iso) return false;
|
|
32
|
+
if (!published) return true;
|
|
33
|
+
return new Date(iso).toDateString() !== new Date(published).toDateString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function BlogLayout(props) {
|
|
37
|
+
const config = props.config || {};
|
|
38
|
+
const posts = props.posts || [];
|
|
39
|
+
const frame = props.frame !== false;
|
|
40
|
+
const indexPath = props.indexPath || "/blog";
|
|
41
|
+
|
|
42
|
+
const post = posts.find((p) => p.path === router.pathname);
|
|
43
|
+
const editedIso = post && editedAfterPublish(updated[post.path], post.date) ? updated[post.path] : null;
|
|
44
|
+
|
|
45
|
+
const body = post ? (
|
|
46
|
+
<div class="otfw-blog otfw-blog-post">
|
|
47
|
+
{/* `otfw-content` so the shared Toc can read this article's headings. */}
|
|
48
|
+
<main id="otfw-content" class="otfw-blog-main" data-pagefind-body>
|
|
49
|
+
{/* Give blog posts a search breadcrumb too (docs get theirs from <Breadcrumbs>),
|
|
50
|
+
so a result looks the same whether it's a doc or a post. Inline `key:value`
|
|
51
|
+
syntax sets the meta literally, independent of visible text. */}
|
|
52
|
+
<span data-pagefind-meta="breadcrumb:Blog" hidden></span>
|
|
53
|
+
<Link href={indexPath} class="otfw-blog-back" data-pagefind-ignore>
|
|
54
|
+
← {config.title ? `${config.title} Blog` : "Blog"}
|
|
55
|
+
</Link>
|
|
56
|
+
<PostBanner post={post} />
|
|
57
|
+
<article class="otfw-prose">{props.children}</article>
|
|
58
|
+
{editedIso ? <LastUpdated date={editedIso} /> : null}
|
|
59
|
+
</main>
|
|
60
|
+
<Toc />
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<div class="otfw-blog otfw-blog-index">
|
|
64
|
+
<main class="otfw-blog-main">{props.children}</main>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return frame ? (
|
|
69
|
+
<div class="otfw-shell">
|
|
70
|
+
<Navbar config={config} />
|
|
71
|
+
<div class="otfw-shell-body">{body}</div>
|
|
72
|
+
<Footer config={config} />
|
|
73
|
+
</div>
|
|
74
|
+
) : (
|
|
75
|
+
body
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -5,34 +5,37 @@ import { Link, router } from "@opentf/web";
|
|
|
5
5
|
export default function Breadcrumbs(props) {
|
|
6
6
|
const nav = props.nav || [];
|
|
7
7
|
|
|
8
|
+
const findTrail = (items, path, trail) => {
|
|
9
|
+
for (const it of items) {
|
|
10
|
+
const next = trail.concat(it);
|
|
11
|
+
if (it.path === path) return next;
|
|
12
|
+
if (it.items) {
|
|
13
|
+
const found = findTrail(it.items, path, next);
|
|
14
|
+
if (found) return found;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Direct-child `.map` (not wrapped in a `{() => …}` thunk) so the compiler
|
|
21
|
+
// lowers it to a reactive list whose item renderer receives `it`/`i`. The
|
|
22
|
+
// source re-runs on `router.pathname`, so the trail tracks navigation.
|
|
23
|
+
// `data-pagefind-meta="breadcrumb"` exposes this trail to the search index, so each
|
|
24
|
+
// result can show which page (and section) it belongs to.
|
|
8
25
|
return (
|
|
9
|
-
<nav class="otfw-breadcrumbs" aria-label="Breadcrumb">
|
|
10
|
-
{() =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const trail = findTrail(nav, router.pathname, []) || [];
|
|
23
|
-
return trail.map((it, i) => (
|
|
24
|
-
<span class="otfw-crumb">
|
|
25
|
-
{i > 0 ? <span class="otfw-crumb-sep">/</span> : null}
|
|
26
|
-
{it.path ? (
|
|
27
|
-
<Link href={it.path} class="otfw-crumb-link">
|
|
28
|
-
{it.title}
|
|
29
|
-
</Link>
|
|
30
|
-
) : (
|
|
31
|
-
<span class="otfw-crumb-text">{it.title}</span>
|
|
32
|
-
)}
|
|
33
|
-
</span>
|
|
34
|
-
));
|
|
35
|
-
}}
|
|
26
|
+
<nav class="otfw-breadcrumbs" aria-label="Breadcrumb" data-pagefind-meta="breadcrumb">
|
|
27
|
+
{(findTrail(nav, router.pathname, []) || []).map((it, i) => (
|
|
28
|
+
<span class="otfw-crumb">
|
|
29
|
+
{i > 0 ? <span class="otfw-crumb-sep">/</span> : null}
|
|
30
|
+
{it.path ? (
|
|
31
|
+
<Link href={it.path} class="otfw-crumb-link">
|
|
32
|
+
{it.title}
|
|
33
|
+
</Link>
|
|
34
|
+
) : (
|
|
35
|
+
<span class="otfw-crumb-text">{it.title}</span>
|
|
36
|
+
)}
|
|
37
|
+
</span>
|
|
38
|
+
))}
|
|
36
39
|
</nav>
|
|
37
40
|
);
|
|
38
41
|
}
|
package/components/Callout.jsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const ICONS = {
|
|
6
6
|
note: "✎",
|
|
7
7
|
tip: "💡",
|
|
8
|
-
info: "
|
|
8
|
+
info: "🛈",
|
|
9
9
|
warning: "⚠",
|
|
10
10
|
danger: "⛔",
|
|
11
11
|
};
|
|
@@ -17,13 +17,13 @@ export default function Callout(props) {
|
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<div class={"otfw-callout otfw-callout-" + type}>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{title}
|
|
20
|
+
<span class="otfw-callout-icon" aria-hidden="true">
|
|
21
|
+
{icon}
|
|
22
|
+
</span>
|
|
23
|
+
<div class="otfw-callout-content">
|
|
24
|
+
<div class="otfw-callout-title">{title}</div>
|
|
25
|
+
<div class="otfw-callout-body">{props.children}</div>
|
|
25
26
|
</div>
|
|
26
|
-
<div class="otfw-callout-body">{props.children}</div>
|
|
27
27
|
</div>
|
|
28
28
|
);
|
|
29
29
|
}
|