@opentf/web-docs 0.2.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.
@@ -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 RSS
47
- * feed generator at build time). Returns `[]` when the folder is absent.
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 2.0 feed generation for the blog. A pure, build-time counterpart to the
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, it renders an `rss.xml` string. The web-cli build writes it to
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] RSS channel title (default `"<docs.title> Blog"`).
46
- * @property {string} [description] RSS channel description (default the title). The
47
- * feed (`<dir>/rss.xml`) is generated by `otfw build` when
48
- * `site.url` is set; a `public/<dir>/rss.xml` override wins.
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 SEO / sitemap).
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.2.0",
3
+ "version": "0.3.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.6.0",
56
- "@opentf/web-test": "1.1.0"
55
+ "@opentf/web": "0.7.0",
56
+ "@opentf/web-test": "1.2.0"
57
57
  }
58
58
  }
@@ -6,4 +6,5 @@
6
6
  // each page, from git or a frontmatter override. This file is loaded only when the
7
7
  // plugin is NOT active, in which case the map is empty and the UI omits the line.
8
8
 
9
+ export const editPaths = {};
9
10
  export default {};