@ingram-tech/nk-blog 0.1.5 → 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.
- package/package.json +7 -4
- package/src/blog.ts +170 -0
- package/src/contract.ts +97 -0
- package/src/date.ts +22 -0
- package/src/http.ts +16 -0
- package/src/index.ts +40 -0
- package/src/keys.ts +12 -0
- package/src/limited-mdx.ts +267 -0
- package/src/reading-time.ts +21 -0
- package/src/render.tsx +94 -0
- package/src/rss.ts +71 -0
- package/src/schema.ts +68 -0
- package/src/seo.ts +69 -0
- package/src/server.ts +22 -0
- package/src/sources/fs.ts +59 -0
- package/src/sources/github.ts +112 -0
- package/src/sources/publish.ts +103 -0
- package/src/types.ts +34 -0
- package/src/unstyled/callout.tsx +18 -0
- package/src/unstyled/figure.tsx +30 -0
- package/src/unstyled/index.ts +24 -0
- package/src/unstyled/newsletter-subscribe.tsx +23 -0
- package/src/unstyled/tweet.tsx +12 -0
- package/src/unstyled/youtube.tsx +24 -0
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("&", "&")
|
|
22
|
+
.replaceAll("<", "<")
|
|
23
|
+
.replaceAll(">", ">")
|
|
24
|
+
.replaceAll('"', """);
|
|
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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { BlogSource, RawPostFile } from "../blog.js";
|
|
3
|
+
import { assertResponseOk } from "../http.js";
|
|
4
|
+
import { keys } from "../keys.js";
|
|
5
|
+
|
|
6
|
+
export interface GitHubRepoConfig {
|
|
7
|
+
owner: string;
|
|
8
|
+
repo: string;
|
|
9
|
+
/** Branch, tag, or commit; defaults to the repo's default branch. */
|
|
10
|
+
ref?: string;
|
|
11
|
+
/** Content directory inside the repo, e.g. "content/blog". */
|
|
12
|
+
dir: string;
|
|
13
|
+
/** Falls back to the GITHUB_TOKEN env var. */
|
|
14
|
+
token?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const API = "https://api.github.com";
|
|
18
|
+
|
|
19
|
+
export function githubHeaders(token: string | undefined): Record<string, string> {
|
|
20
|
+
const resolved = token ?? keys().GITHUB_TOKEN;
|
|
21
|
+
return {
|
|
22
|
+
accept: "application/vnd.github+json",
|
|
23
|
+
"x-github-api-version": "2022-11-28",
|
|
24
|
+
"user-agent": "nk-blog",
|
|
25
|
+
...(resolved ? { authorization: `Bearer ${resolved}` } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const treeSchema = z.object({
|
|
30
|
+
truncated: z.boolean(),
|
|
31
|
+
tree: z.array(
|
|
32
|
+
z.object({
|
|
33
|
+
path: z.string(),
|
|
34
|
+
type: z.string(),
|
|
35
|
+
sha: z.string(),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const blobSchema = z.object({
|
|
41
|
+
content: z.string(),
|
|
42
|
+
encoding: z.literal("base64"),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a repo's posts over the GitHub API — the admin publisher's read path
|
|
47
|
+
* (list a target's real posts; reconcile instead of blind-append). One Trees
|
|
48
|
+
* call for the listing, then batched blob fetches: never per-file `contents`
|
|
49
|
+
* round-trips.
|
|
50
|
+
*/
|
|
51
|
+
export function githubSource(config: GitHubRepoConfig): BlogSource {
|
|
52
|
+
return {
|
|
53
|
+
async load(): Promise<RawPostFile[]> {
|
|
54
|
+
const headers = githubHeaders(config.token);
|
|
55
|
+
const base = `${API}/repos/${config.owner}/${config.repo}`;
|
|
56
|
+
const ref = config.ref ?? "HEAD";
|
|
57
|
+
|
|
58
|
+
const treeResponse = await fetch(
|
|
59
|
+
`${base}/git/trees/${encodeURIComponent(ref)}?recursive=1`,
|
|
60
|
+
{ headers },
|
|
61
|
+
);
|
|
62
|
+
await assertResponseOk(
|
|
63
|
+
treeResponse,
|
|
64
|
+
`nk-blog: listing ${config.owner}/${config.repo}@${ref}`,
|
|
65
|
+
);
|
|
66
|
+
const { tree, truncated } = treeSchema.parse(await treeResponse.json());
|
|
67
|
+
if (truncated) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`nk-blog: git tree for ${config.owner}/${config.repo} is truncated; narrow \`dir\``,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const prefix = `${config.dir.replace(/\/$/, "")}/`;
|
|
74
|
+
const files = tree.filter(
|
|
75
|
+
(entry) =>
|
|
76
|
+
entry.type === "blob" &&
|
|
77
|
+
entry.path.startsWith(prefix) &&
|
|
78
|
+
/\.mdx?$/.test(entry.path),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Bounded concurrency: an unbounded Promise.all over every post blob
|
|
82
|
+
// hammers the API and trips secondary rate limits on large blogs.
|
|
83
|
+
const CONCURRENCY = 8;
|
|
84
|
+
const results: { name: string; content: string }[] = [];
|
|
85
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
86
|
+
const chunk = await Promise.all(
|
|
87
|
+
files.slice(i, i + CONCURRENCY).map(async (entry) => {
|
|
88
|
+
const blobResponse = await fetch(
|
|
89
|
+
`${base}/git/blobs/${entry.sha}`,
|
|
90
|
+
{
|
|
91
|
+
headers,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
await assertResponseOk(
|
|
95
|
+
blobResponse,
|
|
96
|
+
`nk-blog: reading ${entry.path}`,
|
|
97
|
+
);
|
|
98
|
+
const blob = blobSchema.parse(await blobResponse.json());
|
|
99
|
+
return {
|
|
100
|
+
name: entry.path.slice(prefix.length),
|
|
101
|
+
content: Buffer.from(blob.content, "base64").toString(
|
|
102
|
+
"utf8",
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
results.push(...chunk);
|
|
108
|
+
}
|
|
109
|
+
return results;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
blogFrontmatterSchema,
|
|
5
|
+
type BlogFrontmatterInput,
|
|
6
|
+
slugPattern,
|
|
7
|
+
} from "../schema.js";
|
|
8
|
+
import type { PostFormat } from "../types.js";
|
|
9
|
+
import { assertResponseOk } from "../http.js";
|
|
10
|
+
import { githubHeaders, type GitHubRepoConfig } from "./github.js";
|
|
11
|
+
|
|
12
|
+
const API = "https://api.github.com";
|
|
13
|
+
|
|
14
|
+
/** Canonical on-disk form of a post: YAML frontmatter + body, one trailing \n. */
|
|
15
|
+
export function serializePost(frontmatter: BlogFrontmatterInput, body: string): string {
|
|
16
|
+
// Validate before serializing so admin can never commit a file the sites'
|
|
17
|
+
// readers would reject at build time.
|
|
18
|
+
blogFrontmatterSchema.parse(frontmatter);
|
|
19
|
+
return `${matter.stringify(`\n${body.trim()}\n`, frontmatter).trim()}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublishPostInput {
|
|
23
|
+
slug: string;
|
|
24
|
+
frontmatter: BlogFrontmatterInput;
|
|
25
|
+
body: string;
|
|
26
|
+
/** Admin's automated path emits "md" (data, never code) — the default. */
|
|
27
|
+
format?: PostFormat;
|
|
28
|
+
message?: string;
|
|
29
|
+
/** Commit to this branch (e.g. for a draft/preview PR) instead of the ref. */
|
|
30
|
+
branch?: string;
|
|
31
|
+
/** Update an existing post instead of failing the slug-collision check. */
|
|
32
|
+
overwrite?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const putResponseSchema = z.object({
|
|
36
|
+
content: z.object({ path: z.string(), sha: z.string() }),
|
|
37
|
+
commit: z.object({ sha: z.string(), html_url: z.string().optional() }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type PublishedPost = z.infer<typeof putResponseSchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Publish a post by committing one content file to the target repo — the
|
|
44
|
+
* entire replacement for admin's buildPostsJson/buildMdx machinery.
|
|
45
|
+
*/
|
|
46
|
+
export async function publishPost(
|
|
47
|
+
target: GitHubRepoConfig,
|
|
48
|
+
input: PublishPostInput,
|
|
49
|
+
): Promise<PublishedPost> {
|
|
50
|
+
if (!slugPattern.test(input.slug)) {
|
|
51
|
+
// The slug lands in a repo file path — anything looser is a traversal
|
|
52
|
+
// vector (`../.github/workflows/x`) and breaks routes/RSS URLs anyway.
|
|
53
|
+
throw new Error(`nk-blog: invalid slug "${input.slug}"`);
|
|
54
|
+
}
|
|
55
|
+
const frontmatterSlug = input.frontmatter.slug;
|
|
56
|
+
if (frontmatterSlug !== undefined && frontmatterSlug !== input.slug) {
|
|
57
|
+
// The reader routes by the frontmatter override, so a mismatch defeats
|
|
58
|
+
// the filename-based collision check below and can break the target
|
|
59
|
+
// site's build with a duplicate-slug error.
|
|
60
|
+
throw new Error(
|
|
61
|
+
`nk-blog: frontmatter slug "${frontmatterSlug}" does not match publish slug "${input.slug}"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const headers = githubHeaders(target.token);
|
|
65
|
+
const base = `${API}/repos/${target.owner}/${target.repo}`;
|
|
66
|
+
const filePath = `${target.dir.replace(/\/$/, "")}/${input.slug}.${
|
|
67
|
+
input.format ?? "md"
|
|
68
|
+
}`;
|
|
69
|
+
const branch = input.branch ?? target.ref;
|
|
70
|
+
const refQuery = branch ? `?ref=${encodeURIComponent(branch)}` : "";
|
|
71
|
+
|
|
72
|
+
// Slug-collision check (and the sha needed when overwriting).
|
|
73
|
+
const existing = await fetch(`${base}/contents/${filePath}${refQuery}`, {
|
|
74
|
+
headers,
|
|
75
|
+
});
|
|
76
|
+
let existingSha: string | undefined;
|
|
77
|
+
if (existing.ok) {
|
|
78
|
+
if (!input.overwrite) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`nk-blog: ${filePath} already exists in ${target.owner}/${target.repo}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
existingSha = z.object({ sha: z.string() }).parse(await existing.json()).sha;
|
|
84
|
+
} else if (existing.status !== 404) {
|
|
85
|
+
await assertResponseOk(existing, `nk-blog: checking ${filePath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const response = await fetch(`${base}/contents/${filePath}`, {
|
|
89
|
+
method: "PUT",
|
|
90
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
message: input.message ?? `content: publish ${input.slug}`,
|
|
93
|
+
content: Buffer.from(
|
|
94
|
+
serializePost(input.frontmatter, input.body),
|
|
95
|
+
"utf8",
|
|
96
|
+
).toString("base64"),
|
|
97
|
+
...(branch ? { branch } : {}),
|
|
98
|
+
...(existingSha ? { sha: existingSha } : {}),
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
await assertResponseOk(response, `nk-blog: publishing ${filePath}`);
|
|
102
|
+
return putResponseSchema.parse(await response.json());
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type PostFormat = "md" | "mdx";
|
|
2
|
+
|
|
3
|
+
/** Everything a listing page needs — no body. */
|
|
4
|
+
export interface BlogPostPreview {
|
|
5
|
+
slug: string;
|
|
6
|
+
title: string;
|
|
7
|
+
seoTitle?: string;
|
|
8
|
+
description: string;
|
|
9
|
+
/** ISO 8601. */
|
|
10
|
+
date: string;
|
|
11
|
+
/** ISO 8601; set when the post declares an `updated` date. */
|
|
12
|
+
updated?: string;
|
|
13
|
+
authors: string[];
|
|
14
|
+
/** First author — convenience for single-byline layouts. */
|
|
15
|
+
author: string;
|
|
16
|
+
category?: string;
|
|
17
|
+
tags: string[];
|
|
18
|
+
image?: string;
|
|
19
|
+
draft: boolean;
|
|
20
|
+
featured: boolean;
|
|
21
|
+
/** Reserved for i18n'd blogs; not interpreted by the reader yet. */
|
|
22
|
+
lang?: string;
|
|
23
|
+
/** Reserved canonical-URL override for syndicated posts. */
|
|
24
|
+
canonical?: string;
|
|
25
|
+
format: PostFormat;
|
|
26
|
+
readingTimeMinutes: number;
|
|
27
|
+
/** e.g. "5 min read" — derived from the body, never authored. */
|
|
28
|
+
readTime: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A full post: the preview fields plus the body (frontmatter stripped). */
|
|
32
|
+
export interface BlogPost extends BlogPostPreview {
|
|
33
|
+
content: string;
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CalloutProps } from "../contract.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unstyled default. Semantic structure only — style it from the site via the
|
|
5
|
+
* data attributes, or replace it wholesale. No visual props beyond className,
|
|
6
|
+
* ever (the bright line that keeps "unstyled" from eroding into a theme).
|
|
7
|
+
*/
|
|
8
|
+
export const Callout: React.FC<CalloutProps> = ({
|
|
9
|
+
variant = "note",
|
|
10
|
+
title,
|
|
11
|
+
className,
|
|
12
|
+
children,
|
|
13
|
+
}) => (
|
|
14
|
+
<aside data-callout={variant} className={className}>
|
|
15
|
+
{title ? <p data-callout-title>{title}</p> : null}
|
|
16
|
+
<div data-callout-body>{children}</div>
|
|
17
|
+
</aside>
|
|
18
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FigureProps } from "../contract.js";
|
|
2
|
+
|
|
3
|
+
const toDimension = (value: number | string | undefined): number | undefined =>
|
|
4
|
+
typeof value === "string" ? Number(value) : value;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unstyled captioned image: correct semantics (figure/figcaption), lazy
|
|
8
|
+
* loading, and explicit dimensions when given (CLS). Sites wanting
|
|
9
|
+
* next/image replace this wholesale.
|
|
10
|
+
*/
|
|
11
|
+
export const Figure: React.FC<FigureProps> = ({
|
|
12
|
+
src,
|
|
13
|
+
alt,
|
|
14
|
+
caption,
|
|
15
|
+
width,
|
|
16
|
+
height,
|
|
17
|
+
className,
|
|
18
|
+
}) => (
|
|
19
|
+
<figure className={className}>
|
|
20
|
+
<img
|
|
21
|
+
src={src}
|
|
22
|
+
alt={alt}
|
|
23
|
+
width={toDimension(width)}
|
|
24
|
+
height={toDimension(height)}
|
|
25
|
+
loading="lazy"
|
|
26
|
+
decoding="async"
|
|
27
|
+
/>
|
|
28
|
+
{caption ? <figcaption>{caption}</figcaption> : null}
|
|
29
|
+
</figure>
|
|
30
|
+
);
|