@opentf/web-docs 0.2.0 → 0.4.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 +2 -2
- package/build/feed.js +61 -3
- package/build/index.js +2 -1
- package/build/llms.js +156 -0
- package/config.js +7 -5
- package/package.json +3 -3
- package/updated-virtual.js +1 -0
|
@@ -43,8 +43,8 @@ export function blogPostsPlugin({ appDir, contentDir = "blog", exclude = new Set
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
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
|
|
47
|
-
*
|
|
46
|
+
* `blogPostsPlugin` virtual module exposes, but callable directly (used by the feed
|
|
47
|
+
* generator at build time). Returns `[]` when the folder is absent.
|
|
48
48
|
*
|
|
49
49
|
* @param {Object} opts
|
|
50
50
|
* @param {string} opts.appDir
|
package/build/feed.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// RSS
|
|
1
|
+
// RSS/Atom feed generation for the blog. Pure, build-time counterparts to the
|
|
2
2
|
// sitemap: given the post list (from `loadPosts`) plus the site origin and channel
|
|
3
|
-
// metadata,
|
|
4
|
-
// `dist/<blogDir>/rss.xml` when a base URL is configured.
|
|
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
5
|
//
|
|
6
6
|
// RSS 2.0 (https://www.rssboard.org/rss-specification) is the lowest-common-
|
|
7
7
|
// denominator feed format: every reader supports it, and `<pubDate>` must be RFC-822.
|
|
@@ -25,6 +25,13 @@ function rfc822(date) {
|
|
|
25
25
|
return Number.isNaN(d.getTime()) ? null : d.toUTCString();
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
35
|
/**
|
|
29
36
|
* Render an RSS 2.0 feed.
|
|
30
37
|
*
|
|
@@ -76,3 +83,54 @@ export function renderBlogFeed({ posts = [], baseUrl, feedPath = "/blog/rss.xml"
|
|
|
76
83
|
`</rss>\n`
|
|
77
84
|
);
|
|
78
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
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
export { docsNavPlugin } from "./docs-nav-plugin.js";
|
|
8
8
|
export { blogPostsPlugin, loadPosts } from "./blog-posts-plugin.js";
|
|
9
9
|
export { lastUpdatedPlugin, loadLastUpdated } from "./last-updated-plugin.js";
|
|
10
|
-
export { renderBlogFeed } from "./feed.js";
|
|
10
|
+
export { renderAtomFeed, renderBlogFeed } from "./feed.js";
|
|
11
|
+
export { renderLlmsFullTxt, renderLlmsTxt } from "./llms.js";
|
|
11
12
|
export { indexWithPagefind } from "./pagefind.js";
|
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
|
+
}
|
package/config.js
CHANGED
|
@@ -42,15 +42,17 @@
|
|
|
42
42
|
* `<dir>/<slug>/page.mdx` is a post; its frontmatter
|
|
43
43
|
* (title, description, date, author, tags) feeds the
|
|
44
44
|
* generated `@opentf/web-docs/posts` list.
|
|
45
|
-
* @property {string} [title]
|
|
46
|
-
* @property {string} [description]
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
45
|
+
* @property {string} [title] Feed title (default `"<docs.title> Blog"`).
|
|
46
|
+
* @property {string} [description] Feed description (default the title). RSS and Atom
|
|
47
|
+
* feeds (`<dir>/rss.xml`, `<dir>/atom.xml`) are generated
|
|
48
|
+
* by `otfw build`; production docs/blog builds require
|
|
49
|
+
* `site.url` or `--base-url` so feed URLs are absolute.
|
|
49
50
|
* @property {boolean} [lastUpdated] Show a "Last updated" line on a post when it was
|
|
50
51
|
* edited after its publish date (from git / frontmatter).
|
|
51
52
|
*
|
|
52
53
|
* @typedef {Object} SiteConfig
|
|
53
|
-
* @property {{ url?: string }} [site] Canonical site origin (for
|
|
54
|
+
* @property {{ url?: string }} [site] Canonical site origin (required for production
|
|
55
|
+
* docs/blog builds unless `--base-url` is passed).
|
|
54
56
|
* @property {DocsConfig} [docs] Documentation generator config.
|
|
55
57
|
* @property {BlogConfig} [blog] Blog generator config.
|
|
56
58
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web-docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Documentation-site generator for OTF Web — themed shell components (sidebar, TOC, callouts, tabs), a build-time nav generator, and a default light/dark theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"pagefind": "^1.5.2"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@opentf/web": "0.
|
|
56
|
-
"@opentf/web-test": "1.
|
|
55
|
+
"@opentf/web": "0.8.0",
|
|
56
|
+
"@opentf/web-test": "1.3.0"
|
|
57
57
|
}
|
|
58
58
|
}
|