@ingram-tech/nk-blog 0.1.4 → 0.1.6

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.
@@ -0,0 +1,267 @@
1
+ import matter from "gray-matter";
2
+ import type { Root } from "mdast";
3
+ import type { MdxJsxFlowElement, MdxJsxTextElement } from "mdast-util-mdx-jsx";
4
+ import { visit } from "unist-util-visit";
5
+ import type { VFile } from "vfile";
6
+ import { toErrorMessage } from "./http.js";
7
+
8
+ /**
9
+ * The "limited MDX" trust boundary, enforced in the AST — not a convention.
10
+ *
11
+ * MDX is a programming language: `{process.env.X}`, `export const x = …`, and
12
+ * ESM imports all execute server-side with zero components involved. A Tier-1
13
+ * post (the only thing the automated publisher may emit as .mdx) is therefore
14
+ * restricted to prose + whitelisted JSX elements with literal attributes;
15
+ * every expression/ESM construct is rejected at compile time.
16
+ */
17
+ export interface LimitedMdxOptions {
18
+ /** Component names the post may reference (the site's vocabulary). */
19
+ allow: readonly string[];
20
+ }
21
+
22
+ // Lowercase JSX that isn't one of these is almost certainly a miscased
23
+ // vocabulary reference (<callout>), which MDX would render as an inert,
24
+ // invisible HTML element — the one silent failure mode, so reject it.
25
+ const HTML_TAGS = new Set([
26
+ "a",
27
+ "abbr",
28
+ "aside",
29
+ "b",
30
+ "blockquote",
31
+ "br",
32
+ "caption",
33
+ "cite",
34
+ "code",
35
+ "dd",
36
+ "del",
37
+ "details",
38
+ "div",
39
+ "dl",
40
+ "dt",
41
+ "em",
42
+ "figcaption",
43
+ "figure",
44
+ "h1",
45
+ "h2",
46
+ "h3",
47
+ "h4",
48
+ "h5",
49
+ "h6",
50
+ "hr",
51
+ "i",
52
+ "img",
53
+ "ins",
54
+ "kbd",
55
+ "li",
56
+ "main",
57
+ "mark",
58
+ "nav",
59
+ "ol",
60
+ "p",
61
+ "pre",
62
+ "q",
63
+ "s",
64
+ "samp",
65
+ "section",
66
+ "small",
67
+ "span",
68
+ "strong",
69
+ "sub",
70
+ "summary",
71
+ "sup",
72
+ "table",
73
+ "tbody",
74
+ "td",
75
+ "tfoot",
76
+ "th",
77
+ "thead",
78
+ "tr",
79
+ "u",
80
+ "ul",
81
+ "var",
82
+ "video",
83
+ "wbr",
84
+ ]);
85
+
86
+ type JsxElement = MdxJsxFlowElement | MdxJsxTextElement;
87
+
88
+ // Attributes that carry a URL the browser will navigate/load — the XSS surface
89
+ // of otherwise-inert HTML (`<a href="javascript:…">`).
90
+ const URL_ATTRIBUTES = new Set([
91
+ "href",
92
+ "src",
93
+ "poster",
94
+ "cite",
95
+ "action",
96
+ "formaction",
97
+ "data",
98
+ ]);
99
+
100
+ /**
101
+ * Accept relative paths/anchors and http(s)/mailto/tel absolute URLs; reject
102
+ * every other scheme (`javascript:`, `data:`, `vbscript:`, …). Control chars
103
+ * and whitespace are stripped before scheme detection because browsers strip
104
+ * them when parsing, so `java\tscript:` would otherwise sneak through.
105
+ */
106
+ function isSafeUrl(raw: string): boolean {
107
+ // oxlint-disable-next-line no-control-regex -- stripping what browsers strip is the point
108
+ const cleaned = raw.replace(/[\u0000-\u0020]/g, "");
109
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
110
+ if (!scheme) return true;
111
+ return /^(?:https?|mailto|tel)$/i.test(scheme[1] ?? "");
112
+ }
113
+
114
+ function checkElement(node: JsxElement, allow: readonly string[], file: VFile): void {
115
+ const name = node.name;
116
+ // Nameless = fragment (<>…</>), which is inert prose grouping.
117
+ if (name === null) return;
118
+
119
+ if (/^[a-z]/.test(name)) {
120
+ if (!HTML_TAGS.has(name)) {
121
+ file.fail(
122
+ `Unknown HTML tag <${name}> — a miscased component? Vocabulary names are capitalized.`,
123
+ node,
124
+ );
125
+ }
126
+ } else if (!allow.includes(name)) {
127
+ file.fail(
128
+ `<${name}> is not in this blog's component vocabulary (${allow.join(", ")}).`,
129
+ node,
130
+ );
131
+ }
132
+
133
+ for (const attribute of node.attributes) {
134
+ if (attribute.type !== "mdxJsxAttribute") {
135
+ file.fail(
136
+ `<${name}> uses a spread attribute; only literal props are allowed.`,
137
+ node,
138
+ );
139
+ continue;
140
+ }
141
+ const value = attribute.value;
142
+ // null/undefined = bare boolean prop; string = literal. Anything else is
143
+ // an attribute-value expression — executable, so allow only simple
144
+ // literals.
145
+ if (value === null || value === undefined || typeof value === "string") {
146
+ if (
147
+ typeof value === "string" &&
148
+ URL_ATTRIBUTES.has(attribute.name.toLowerCase()) &&
149
+ !isSafeUrl(value)
150
+ ) {
151
+ file.fail(
152
+ `<${name} ${attribute.name}="…"> uses a URL scheme that is not allowed in limited MDX (http/https/mailto/tel or a relative path).`,
153
+ node,
154
+ );
155
+ }
156
+ continue;
157
+ }
158
+ const statement = value.data?.estree?.body[0];
159
+ const expression =
160
+ statement?.type === "ExpressionStatement"
161
+ ? statement.expression
162
+ : undefined;
163
+ if (expression?.type !== "Literal") {
164
+ file.fail(
165
+ `<${name} ${attribute.name}={…}> — attribute expressions are not allowed in limited MDX; use a literal.`,
166
+ node,
167
+ );
168
+ continue;
169
+ }
170
+ // A braced string literal is still a URL the browser will navigate
171
+ // (`href={"javascript:…"}`), so it must clear the same scheme guard as the
172
+ // plain-string branch above — the `Literal` check alone would wave it through.
173
+ if (
174
+ typeof expression.value === "string" &&
175
+ URL_ATTRIBUTES.has(attribute.name.toLowerCase()) &&
176
+ !isSafeUrl(expression.value)
177
+ ) {
178
+ file.fail(
179
+ `<${name} ${attribute.name}={…}> uses a URL scheme that is not allowed in limited MDX (http/https/mailto/tel or a relative path).`,
180
+ node,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Remark plugin implementing the boundary. Throws (via `file.fail`) on violation. */
187
+ export function remarkLimitedMdx(options: LimitedMdxOptions) {
188
+ return (tree: Root, file: VFile): void => {
189
+ visit(tree, (node) => {
190
+ switch (node.type) {
191
+ case "mdxjsEsm":
192
+ file.fail(
193
+ "import/export statements are not allowed in limited MDX.",
194
+ node,
195
+ );
196
+ break;
197
+ case "mdxFlowExpression":
198
+ case "mdxTextExpression":
199
+ file.fail("{…} expressions are not allowed in limited MDX.", node);
200
+ break;
201
+ case "mdxJsxFlowElement":
202
+ case "mdxJsxTextElement":
203
+ checkElement(node, options.allow, file);
204
+ break;
205
+ // Plain markdown links/images compile to real anchors too — unlike
206
+ // react-markdown (which sanitizes by default), the MDX pipeline
207
+ // applies no urlTransform, so `[x](javascript:…)` must die here.
208
+ case "link":
209
+ case "image":
210
+ case "definition":
211
+ if (!isSafeUrl(node.url)) {
212
+ file.fail(
213
+ `Link/image URL "${node.url}" uses a scheme that is not allowed in limited MDX.`,
214
+ node,
215
+ );
216
+ }
217
+ break;
218
+ default:
219
+ break;
220
+ }
221
+ });
222
+ };
223
+ }
224
+
225
+ export interface LimitedMdxViolation {
226
+ message: string;
227
+ line?: number;
228
+ column?: number;
229
+ }
230
+
231
+ export interface LimitedMdxResult {
232
+ ok: boolean;
233
+ errors: LimitedMdxViolation[];
234
+ }
235
+
236
+ /**
237
+ * Admin's pre-publish lint: compile (without evaluating) a post source —
238
+ * frontmatter included — against a vocabulary. Also surfaces MDX syntax
239
+ * errors, so an LLM-authored post can never break a target site's build.
240
+ */
241
+ export async function validateLimitedMdx(
242
+ source: string,
243
+ options: LimitedMdxOptions,
244
+ ): Promise<LimitedMdxResult> {
245
+ const { compile } = await import("@mdx-js/mdx");
246
+ const { default: remarkGfm } = await import("remark-gfm");
247
+ const { content } = matter(source);
248
+ try {
249
+ await compile(content, {
250
+ remarkPlugins: [remarkGfm, [remarkLimitedMdx, options]],
251
+ });
252
+ return { ok: true, errors: [] };
253
+ } catch (error) {
254
+ const place =
255
+ error !== null &&
256
+ typeof error === "object" &&
257
+ "line" in error &&
258
+ "column" in error
259
+ ? {
260
+ line: typeof error.line === "number" ? error.line : undefined,
261
+ column:
262
+ typeof error.column === "number" ? error.column : undefined,
263
+ }
264
+ : {};
265
+ return { ok: false, errors: [{ message: toErrorMessage(error), ...place }] };
266
+ }
267
+ }
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_WORDS_PER_MINUTE = 220;
2
+
3
+ export interface ReadingTime {
4
+ words: number;
5
+ minutes: number;
6
+ /** e.g. "5 min read". */
7
+ text: string;
8
+ }
9
+
10
+ /** Real reading time from the body — replaces the fleet's faked "5 min read". */
11
+ export function readingTime(
12
+ content: string,
13
+ wordsPerMinute: number = DEFAULT_WORDS_PER_MINUTE,
14
+ ): ReadingTime {
15
+ const words = content
16
+ .trim()
17
+ .split(/\s+/)
18
+ .filter((token) => token.length > 0).length;
19
+ const minutes = Math.max(1, Math.ceil(words / wordsPerMinute));
20
+ return { words, minutes, text: `${minutes} min read` };
21
+ }
package/src/render.tsx ADDED
@@ -0,0 +1,94 @@
1
+ import { evaluate } from "@mdx-js/mdx";
2
+ import type { ComponentType, ReactElement } from "react";
3
+ import * as runtime from "react/jsx-runtime";
4
+ import Markdown, { type Components } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import type { PluggableList } from "unified";
7
+ import type { BlogComponents } from "./contract.js";
8
+ import { remarkLimitedMdx } from "./limited-mdx.js";
9
+ import type { BlogPost } from "./types.js";
10
+
11
+ export { remarkLimitedMdx, validateLimitedMdx } from "./limited-mdx.js";
12
+ export type {
13
+ LimitedMdxOptions,
14
+ LimitedMdxResult,
15
+ LimitedMdxViolation,
16
+ } from "./limited-mdx.js";
17
+
18
+ /**
19
+ * The element map a site passes to style prose (h1–h4, p, code, tables, …).
20
+ * One key set shared by both pipelines — react-markdown's `Components` is the
21
+ * canonical shape. The map itself never ships from nk-blog: it is the brand.
22
+ */
23
+ export type BlogElements = Components;
24
+
25
+ // Per-post (Tier-2) components, merged over the site vocabulary in MDX scope.
26
+ export type BespokeComponents = Record<string, ComponentType<never>>;
27
+
28
+ /** Renders a `.md` body. Pure data — the automated publisher's format. */
29
+ export const MarkdownBody: React.FC<{
30
+ content: string;
31
+ elements?: BlogElements;
32
+ }> = ({ content, elements }) => (
33
+ <Markdown remarkPlugins={[remarkGfm]} components={elements}>
34
+ {content}
35
+ </Markdown>
36
+ );
37
+
38
+ /**
39
+ * Renders an `.mdx` body (async server component). `limited` (default true)
40
+ * enforces the vocabulary-only trust boundary; Tier-2 posts pass `bespoke`
41
+ * components, which turns enforcement off — those are human-reviewed code.
42
+ */
43
+ export async function MdxBody(props: {
44
+ content: string;
45
+ components: BlogComponents;
46
+ elements?: BlogElements;
47
+ bespoke?: BespokeComponents;
48
+ limited?: boolean;
49
+ }): Promise<ReactElement> {
50
+ // An *empty* bespoke map (a site conditionally spreading a possibly-empty
51
+ // registry) must not silently switch off the trust boundary — only actual
52
+ // human-reviewed components do.
53
+ const limited = props.limited ?? Object.keys(props.bespoke ?? {}).length === 0;
54
+ const scope = { ...props.components, ...props.bespoke };
55
+ const remarkPlugins: PluggableList = limited
56
+ ? [remarkGfm, [remarkLimitedMdx, { allow: Object.keys(scope) }]]
57
+ : [remarkGfm];
58
+ const { default: MDXContent } = await evaluate(props.content, {
59
+ ...runtime,
60
+ remarkPlugins,
61
+ });
62
+ return <MDXContent components={{ ...props.elements, ...scope }} />;
63
+ }
64
+
65
+ /** Format-dispatching renderer: the one component a post page needs. */
66
+ export async function PostBody(props: {
67
+ post: BlogPost;
68
+ components: BlogComponents;
69
+ elements?: BlogElements;
70
+ bespoke?: BespokeComponents;
71
+ }): Promise<ReactElement> {
72
+ if (props.post.format === "md") {
73
+ return <MarkdownBody content={props.post.content} elements={props.elements} />;
74
+ }
75
+ return MdxBody({
76
+ content: props.post.content,
77
+ components: props.components,
78
+ elements: props.elements,
79
+ bespoke: props.bespoke,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * The hardened JSON-LD script tag, re-exported from nk-seo so it sits with the
85
+ * node builders that feed it (`blogPostArticle`, `blogPostBreadcrumbs`).
86
+ *
87
+ * nk-blog hands sites schema *nodes* and left rendering to them, which meant
88
+ * every site hand-rolled `<script type="application/ld+json"
89
+ * dangerouslySetInnerHTML={{ __html: JSON.stringify(nodes) }} />` — and a post
90
+ * title or FAQ answer containing `</script>` then closes the tag and injects
91
+ * markup into the page. `JsonLd` escapes `<`, so the safe path is now the one
92
+ * already in reach.
93
+ */
94
+ export { JsonLd, serializeJsonLd } from "@ingram-tech/nk-seo/components";
package/src/rss.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { BlogPostPreview } from "./types.js";
2
+
3
+ export interface RssConfig {
4
+ title: string;
5
+ description?: string;
6
+ /** Absolute site origin, e.g. "https://example.com". */
7
+ siteUrl: string;
8
+ /** Path prefix of the blog, e.g. "/posts". */
9
+ basePath: string;
10
+ /** Absolute URL the feed is served from, e.g. `${siteUrl}/rss.xml`. */
11
+ feedUrl: string;
12
+ language?: string;
13
+ }
14
+
15
+ const escapeXml = (value: string): string =>
16
+ value
17
+ // XML 1.0 forbids most C0 controls even escaped — a stray \x08 pasted
18
+ // into a title would make strict parsers reject the whole feed.
19
+ // oxlint-disable-next-line no-control-regex -- stripping XML-invalid chars is the point
20
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "")
21
+ .replaceAll("&", "&amp;")
22
+ .replaceAll("<", "&lt;")
23
+ .replaceAll(">", "&gt;")
24
+ .replaceAll('"', "&quot;");
25
+
26
+ /**
27
+ * RSS 2.0 feed for a post list. Serve from a route handler (`rss.xml/route.ts`
28
+ * with `export const dynamic = "force-static"`) so it renders at build.
29
+ */
30
+ export function generateRss(config: RssConfig, posts: BlogPostPreview[]): string {
31
+ const items = posts
32
+ .map((post) => {
33
+ const url = `${config.siteUrl}${config.basePath}/${post.slug}`;
34
+ return [
35
+ "\t\t<item>",
36
+ `\t\t\t<title>${escapeXml(post.title)}</title>`,
37
+ `\t\t\t<link>${escapeXml(url)}</link>`,
38
+ `\t\t\t<guid isPermaLink="true">${escapeXml(url)}</guid>`,
39
+ `\t\t\t<pubDate>${new Date(post.date).toUTCString()}</pubDate>`,
40
+ `\t\t\t<description>${escapeXml(post.description)}</description>`,
41
+ ...post.authors.map(
42
+ (author) => `\t\t\t<dc:creator>${escapeXml(author)}</dc:creator>`,
43
+ ),
44
+ ...(post.category
45
+ ? [`\t\t\t<category>${escapeXml(post.category)}</category>`]
46
+ : []),
47
+ "\t\t</item>",
48
+ ].join("\n");
49
+ })
50
+ .join("\n");
51
+
52
+ return [
53
+ '<?xml version="1.0" encoding="UTF-8"?>',
54
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
55
+ "\t<channel>",
56
+ `\t\t<title>${escapeXml(config.title)}</title>`,
57
+ `\t\t<link>${escapeXml(config.siteUrl + config.basePath)}</link>`,
58
+ `\t\t<description>${escapeXml(config.description ?? config.title)}</description>`,
59
+ `\t\t<language>${escapeXml(config.language ?? "en")}</language>`,
60
+ `\t\t<atom:link href="${escapeXml(config.feedUrl)}" rel="self" type="application/rss+xml"/>`,
61
+ ...(posts[0]
62
+ ? [
63
+ `\t\t<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>`,
64
+ ]
65
+ : []),
66
+ items,
67
+ "\t</channel>",
68
+ "</rss>",
69
+ "",
70
+ ].join("\n");
71
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+
3
+ // YAML (via gray-matter) parses bare dates like `2026-04-30` into Date objects
4
+ // and quoted ones into strings — accept both, emit ISO 8601.
5
+ const isoDate = z.union([z.string(), z.date()]).transform((value, ctx) => {
6
+ const parsed = value instanceof Date ? value : new Date(value);
7
+ if (Number.isNaN(parsed.getTime())) {
8
+ ctx.addIssue({
9
+ code: "custom",
10
+ message: `Invalid date "${String(value)}". Use ISO format (YYYY-MM-DD).`,
11
+ });
12
+ return z.NEVER;
13
+ }
14
+ return parsed.toISOString();
15
+ });
16
+
17
+ const nonEmpty = z.string().trim().min(1);
18
+
19
+ /**
20
+ * Slug shape shared by the frontmatter contract and the publisher: alnum runs
21
+ * separated by single `-`/`_`/`.`. Rules out everything that breaks routes,
22
+ * RSS/JSON-LD URLs, or — via the publisher's `${dir}/${slug}.md` commit path —
23
+ * traverses into arbitrary repo locations (`../.github/workflows/x`).
24
+ */
25
+ export const slugPattern = /^[a-z0-9]+(?:[-._][a-z0-9]+)*$/;
26
+
27
+ const slugSchema = z
28
+ .string()
29
+ .regex(
30
+ slugPattern,
31
+ "Slug must be lowercase alphanumerics separated by single '-', '_' or '.'",
32
+ );
33
+
34
+ /**
35
+ * The shared frontmatter contract. Every post on every site validates against
36
+ * this; unknown keys are preserved-ignored (sites may carry extras locally).
37
+ *
38
+ * Normalizations: `coverImage` is accepted as an alias of `image`; `author`
39
+ * (string or list) and `authors` collapse into a single `authors: string[]`.
40
+ */
41
+ export const blogFrontmatterSchema = z
42
+ .object({
43
+ title: nonEmpty,
44
+ seoTitle: nonEmpty.optional(),
45
+ description: nonEmpty,
46
+ date: isoDate,
47
+ updated: isoDate.optional(),
48
+ author: z.union([nonEmpty, z.array(nonEmpty)]).optional(),
49
+ authors: z.array(nonEmpty).optional(),
50
+ category: nonEmpty.optional(),
51
+ tags: z.array(nonEmpty).default([]),
52
+ image: nonEmpty.optional(),
53
+ coverImage: nonEmpty.optional(),
54
+ slug: slugSchema.optional(),
55
+ draft: z.boolean().default(false),
56
+ featured: z.boolean().default(false),
57
+ // Reserved fields — validated now so adding behavior later isn't breaking.
58
+ lang: nonEmpty.optional(),
59
+ canonical: nonEmpty.optional(),
60
+ })
61
+ .transform(({ author, authors, coverImage, image, ...rest }) => ({
62
+ ...rest,
63
+ image: image ?? coverImage,
64
+ authors: authors ?? (Array.isArray(author) ? author : author ? [author] : []),
65
+ }));
66
+
67
+ export type BlogFrontmatter = z.infer<typeof blogFrontmatterSchema>;
68
+ export type BlogFrontmatterInput = z.input<typeof blogFrontmatterSchema>;
package/src/seo.ts ADDED
@@ -0,0 +1,69 @@
1
+ import {
2
+ article,
3
+ type ArticleInput,
4
+ type ArticleNode,
5
+ breadcrumbList,
6
+ type BreadcrumbListNode,
7
+ type OrganizationInput,
8
+ type WithContext,
9
+ } from "@ingram-tech/nk-seo";
10
+ import type { BlogPostPreview } from "./types.js";
11
+
12
+ export interface BlogSeoConfig {
13
+ /** Absolute site origin, e.g. "https://example.com". */
14
+ baseUrl: string;
15
+ /** Path prefix of the blog, e.g. "/posts" or "/blog". */
16
+ basePath: string;
17
+ /** Injected as the article publisher when provided. */
18
+ publisher?: OrganizationInput;
19
+ /** Crumb label for the blog index; defaults to "Blog". */
20
+ blogName?: string;
21
+ }
22
+
23
+ export function postUrl(post: BlogPostPreview, config: BlogSeoConfig): string {
24
+ return `${config.baseUrl}${config.basePath}/${post.slug}`;
25
+ }
26
+
27
+ // JSON-LD consumers don't resolve relative URLs, so anything without a scheme
28
+ // (leading-`/` or bare `img/x.png`) is absolutized against the site. Unlike
29
+ // nk-seo's `absoluteUrl`, an already-absolute value passes through untouched
30
+ // rather than being origin-checked: a post's image can live on a CDN and its
31
+ // `canonical` can be a cross-origin syndication override, both legitimately
32
+ // off-origin — so this must not throw the way the canonical-link resolver does.
33
+ const toAbsoluteUrl = (url: string, baseUrl: string): string =>
34
+ /^https?:\/\//.test(url)
35
+ ? url
36
+ : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
37
+
38
+ /** BlogPosting JSON-LD for a post — the nk-seo bridge. */
39
+ export function blogPostArticle(
40
+ post: BlogPostPreview,
41
+ config: BlogSeoConfig,
42
+ overrides: Partial<ArticleInput> = {},
43
+ ): WithContext<ArticleNode> {
44
+ return article({
45
+ type: "BlogPosting",
46
+ headline: post.title,
47
+ description: post.description,
48
+ url: toAbsoluteUrl(post.canonical ?? postUrl(post, config), config.baseUrl),
49
+ datePublished: post.date,
50
+ dateModified: post.updated,
51
+ authors: post.authors.map((name) => ({ name })),
52
+ image: post.image ? toAbsoluteUrl(post.image, config.baseUrl) : undefined,
53
+ keywords: post.tags.length ? post.tags : undefined,
54
+ publisher: config.publisher,
55
+ ...overrides,
56
+ });
57
+ }
58
+
59
+ /** Home → Blog → Post breadcrumb JSON-LD. */
60
+ export function blogPostBreadcrumbs(
61
+ post: BlogPostPreview,
62
+ config: BlogSeoConfig,
63
+ ): WithContext<BreadcrumbListNode> {
64
+ return breadcrumbList([
65
+ { name: "Home", url: config.baseUrl },
66
+ { name: config.blogName ?? "Blog", url: `${config.baseUrl}${config.basePath}` },
67
+ { name: post.title, url: postUrl(post, config) },
68
+ ]);
69
+ }
package/src/server.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Server entry: everything that touches the filesystem, the GitHub API, or
2
+ // env. Import from pages/route handlers/scripts only — never from client
3
+ // components (the root "." export carries the types you need there).
4
+ export {
5
+ createBlog,
6
+ parsePost,
7
+ parsePostFileName,
8
+ type Blog,
9
+ type BlogConfig,
10
+ type BlogSource,
11
+ type RawPostFile,
12
+ } from "./blog.js";
13
+ export { fsSource } from "./sources/fs.js";
14
+ export { githubSource, type GitHubRepoConfig } from "./sources/github.js";
15
+ export {
16
+ publishPost,
17
+ serializePost,
18
+ type PublishedPost,
19
+ type PublishPostInput,
20
+ } from "./sources/publish.js";
21
+ export { generateRss, type RssConfig } from "./rss.js";
22
+ export { keys } from "./keys.js";
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { BlogSource, RawPostFile } from "../blog.js";
4
+
5
+ /**
6
+ * Build-time filesystem source. The blog contract is full SSG: call this from
7
+ * `generateStaticParams`-driven pages (with `dynamicParams = false`), sitemap
8
+ * and RSS route handlers — all of which run at build. It must NOT be relied on
9
+ * at request time on serverless hosts: output file tracing does not follow
10
+ * `fs` reads made inside a published package, so the content directory may be
11
+ * absent from the deployed function (`outputFileTracingIncludes` is the escape
12
+ * hatch if you truly need runtime reads).
13
+ */
14
+ export function fsSource(dir: string): BlogSource {
15
+ const root = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
16
+
17
+ return {
18
+ async load(): Promise<RawPostFile[]> {
19
+ let entries;
20
+ try {
21
+ entries = await fs.readdir(root, { withFileTypes: true });
22
+ } catch (error) {
23
+ if (
24
+ error instanceof Error &&
25
+ "code" in error &&
26
+ error.code === "ENOENT"
27
+ ) {
28
+ return [];
29
+ }
30
+ throw error;
31
+ }
32
+
33
+ const names: string[] = [];
34
+ for (const entry of entries) {
35
+ if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
36
+ names.push(entry.name);
37
+ } else if (entry.isDirectory()) {
38
+ // Folder post: <slug>/index.md(x) with colocated assets/code.
39
+ for (const index of ["index.mdx", "index.md"]) {
40
+ try {
41
+ await fs.access(path.join(root, entry.name, index));
42
+ names.push(`${entry.name}/${index}`);
43
+ break;
44
+ } catch {
45
+ // keep looking
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ return Promise.all(
52
+ names.map(async (name) => ({
53
+ name,
54
+ content: await fs.readFile(path.join(root, name), "utf8"),
55
+ })),
56
+ );
57
+ },
58
+ };
59
+ }