@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.
package/dist/render.d.ts CHANGED
@@ -35,4 +35,16 @@ export declare function PostBody(props: {
35
35
  elements?: BlogElements;
36
36
  bespoke?: BespokeComponents;
37
37
  }): Promise<ReactElement>;
38
+ /**
39
+ * The hardened JSON-LD script tag, re-exported from nk-seo so it sits with the
40
+ * node builders that feed it (`blogPostArticle`, `blogPostBreadcrumbs`).
41
+ *
42
+ * nk-blog hands sites schema *nodes* and left rendering to them, which meant
43
+ * every site hand-rolled `<script type="application/ld+json"
44
+ * dangerouslySetInnerHTML={{ __html: JSON.stringify(nodes) }} />` — and a post
45
+ * title or FAQ answer containing `</script>` then closes the tag and injects
46
+ * markup into the page. `JsonLd` escapes `<`, so the safe path is now the one
47
+ * already in reach.
48
+ */
49
+ export { JsonLd, serializeJsonLd } from "@ingram-tech/nk-seo/components";
38
50
  //# sourceMappingURL=render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAEzD,OAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAG3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACxE,YAAY,EACX,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,GACnB,MAAM,kBAAkB,CAAC;AAE1B;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC;AAGtC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;AAErE,0EAA0E;AAC1E,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAIA,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,OAAO,CAAC,KAAK,EAAE;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,OAAO,CAAC,YAAY,CAAC,CAcxB;AAED,wEAAwE;AACxE,wBAAsB,QAAQ,CAAC,KAAK,EAAE;IACrC,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;CAC5B,GAAG,OAAO,CAAC,YAAY,CAAC,CAUxB"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAEzD,OAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAG3D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACxE,YAAY,EACX,iBAAiB,EACjB,gBAAgB,EAChB,mBAAmB,GACnB,MAAM,kBAAkB,CAAC;AAE1B;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC;AAGtC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;AAErE,0EAA0E;AAC1E,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,YAAY,CAAC;CACxB,CAIA,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,OAAO,CAAC,KAAK,EAAE;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,OAAO,CAAC,YAAY,CAAC,CAcxB;AAED,wEAAwE;AACxE,wBAAsB,QAAQ,CAAC,KAAK,EAAE;IACrC,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,OAAO,CAAC,EAAE,iBAAiB,CAAC;CAC5B,GAAG,OAAO,CAAC,YAAY,CAAC,CAUxB;AAED;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC"}
package/dist/render.js CHANGED
@@ -39,4 +39,16 @@ export async function PostBody(props) {
39
39
  bespoke: props.bespoke,
40
40
  });
41
41
  }
42
+ /**
43
+ * The hardened JSON-LD script tag, re-exported from nk-seo so it sits with the
44
+ * node builders that feed it (`blogPostArticle`, `blogPostBreadcrumbs`).
45
+ *
46
+ * nk-blog hands sites schema *nodes* and left rendering to them, which meant
47
+ * every site hand-rolled `<script type="application/ld+json"
48
+ * dangerouslySetInnerHTML={{ __html: JSON.stringify(nodes) }} />` — and a post
49
+ * title or FAQ answer containing `</script>` then closes the tag and injects
50
+ * markup into the page. `JsonLd` escapes `<`, so the safe path is now the one
51
+ * already in reach.
52
+ */
53
+ export { JsonLd, serializeJsonLd } from "@ingram-tech/nk-seo/components";
42
54
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,OAAO,KAAK,OAAO,MAAM,mBAAmB,CAAC;AAC7C,OAAO,QAA6B,MAAM,gBAAgB,CAAC;AAC3D,OAAO,SAAS,MAAM,YAAY,CAAC;AAGnC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAiBxE,0EAA0E;AAC1E,MAAM,CAAC,MAAM,YAAY,GAGpB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAC/B,KAAC,QAAQ,IAAC,aAAa,EAAE,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,QAAQ,YACxD,OAAO,GACE,CACX,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,KAM7B;IACA,0EAA0E;IAC1E,0EAA0E;IAC1E,gCAAgC;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;IAC/E,MAAM,KAAK,GAAG,EAAE,GAAG,KAAK,CAAC,UAAU,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;IACxD,MAAM,aAAa,GAAkB,OAAO;QAC3C,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,gBAAgB,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAChE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACf,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE;QAC7D,GAAG,OAAO;QACV,aAAa;KACb,CAAC,CAAC;IACH,OAAO,KAAC,UAAU,IAAC,UAAU,EAAE,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAI,CAAC;AACpE,CAAC;AAED,wEAAwE;AACxE,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAK9B;IACA,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,IAAI,EAAE,CAAC;QAChC,OAAO,KAAC,YAAY,IAAC,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,GAAI,CAAC;IAChF,CAAC;IACD,OAAO,OAAO,CAAC;QACd,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO;QAC3B,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,KAAK,CAAC,OAAO;KACtB,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,OAAO,KAAK,OAAO,MAAM,mBAAmB,CAAC;AAC7C,OAAO,QAA6B,MAAM,gBAAgB,CAAC;AAC3D,OAAO,SAAS,MAAM,YAAY,CAAC;AAGnC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAiBxE,0EAA0E;AAC1E,MAAM,CAAC,MAAM,YAAY,GAGpB,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAC/B,KAAC,QAAQ,IAAC,aAAa,EAAE,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,QAAQ,YACxD,OAAO,GACE,CACX,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,KAM7B;IACA,0EAA0E;IAC1E,0EAA0E;IAC1E,gCAAgC;IAChC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;IAC/E,MAAM,KAAK,GAAG,EAAE,GAAG,KAAK,CAAC,UAAU,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;IACxD,MAAM,aAAa,GAAkB,OAAO;QAC3C,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,gBAAgB,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAChE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACf,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE;QAC7D,GAAG,OAAO;QACV,aAAa;KACb,CAAC,CAAC;IACH,OAAO,KAAC,UAAU,IAAC,UAAU,EAAE,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAI,CAAC;AACpE,CAAC;AAED,wEAAwE;AACxE,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAK9B;IACA,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,IAAI,EAAE,CAAC;QAChC,OAAO,KAAC,YAAY,IAAC,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,GAAI,CAAC;IAChF,CAAC;IACD,OAAO,OAAO,CAAC;QACd,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO;QAC3B,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,KAAK,CAAC,OAAO;KACtB,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-blog",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "File-based blog foundation for Next.js sites: frontmatter schema, build-time reader, limited-MDX rendering, a typed component contract with unstyled defaults, GitHub read/publish, and RSS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,10 @@
14
14
  },
15
15
  "sideEffects": false,
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/**/*.test.tsx"
18
21
  ],
19
22
  "exports": {
20
23
  ".": {
@@ -40,7 +43,7 @@
40
43
  "test": "vitest run"
41
44
  },
42
45
  "dependencies": {
43
- "@ingram-tech/nk-seo": "^0.7.0",
46
+ "@ingram-tech/nk-seo": "^0.8.1",
44
47
  "@mdx-js/mdx": "^3.1.1",
45
48
  "gray-matter": "^4.0.3",
46
49
  "react-markdown": "^10.1.0",
@@ -61,13 +64,13 @@
61
64
  }
62
65
  },
63
66
  "devDependencies": {
64
- "@ingram-tech/nk-dev": "0.10.0",
67
+ "@ingram-tech/nk-dev": "0.12.0",
65
68
  "@types/mdast": "^4.0.4",
66
- "@types/node": "^26.1.2",
69
+ "@types/node": "^26.2.0",
67
70
  "@types/react": "^19.2.18",
68
71
  "@types/react-dom": "^19.2.4",
69
72
  "mdast-util-mdx-jsx": "^3.2.0",
70
- "next": "^16.2.12",
73
+ "next": "^16.3.1",
71
74
  "react": "^19.2.8",
72
75
  "react-dom": "^19.2.8",
73
76
  "typescript": "^7.0.2",
package/src/blog.ts ADDED
@@ -0,0 +1,170 @@
1
+ import matter from "gray-matter";
2
+ import { blogFrontmatterSchema } from "./schema.js";
3
+ import { DEFAULT_WORDS_PER_MINUTE, readingTime } from "./reading-time.js";
4
+ import type { BlogPost, BlogPostPreview, PostFormat } from "./types.js";
5
+
6
+ /** A content file, named relative to the content dir ("slug.md", "slug/index.mdx"). */
7
+ export interface RawPostFile {
8
+ name: string;
9
+ content: string;
10
+ }
11
+
12
+ export interface BlogSource {
13
+ load(): Promise<RawPostFile[]>;
14
+ }
15
+
16
+ export interface BlogConfig {
17
+ source: BlogSource;
18
+ /** Byline when a post declares none (each site sets its own). */
19
+ defaultAuthor?: string;
20
+ defaultCategory?: string;
21
+ wordsPerMinute?: number;
22
+ /** Include drafts (listing pages typically pass `NODE_ENV !== "production"`). */
23
+ drafts?: boolean;
24
+ /**
25
+ * Site-specific image fallback (e.g. "look in /public/images/posts/<slug>").
26
+ * This is config precisely so per-site divergence never forks the reader.
27
+ */
28
+ resolveImage?: (post: { slug: string; image?: string }) => string | undefined;
29
+ /**
30
+ * "throw" (default) fails the build on malformed frontmatter — a real post
31
+ * silently missing from a site is worse than a red build. Remote sources
32
+ * (admin listing over GitHub) prefer "skip" to keep one bad file from
33
+ * hiding a whole target.
34
+ */
35
+ onInvalid?: "throw" | "skip";
36
+ }
37
+
38
+ export interface Blog {
39
+ /** All non-draft posts, newest first, with bodies. */
40
+ posts(): Promise<BlogPost[]>;
41
+ /** All non-draft posts, newest first, without bodies. */
42
+ previews(): Promise<BlogPostPreview[]>;
43
+ post(slug: string): Promise<BlogPost | null>;
44
+ slugs(): Promise<string[]>;
45
+ /** The pinned (`featured: true`) post, else the newest. */
46
+ featured(): Promise<BlogPost | null>;
47
+ }
48
+
49
+ const POST_FILE = /^(?:(?<flat>[^/]+)|(?<dir>[^/]+)\/index)\.(?<ext>mdx?)$/;
50
+
51
+ interface ParsedName {
52
+ slug: string;
53
+ format: PostFormat;
54
+ draftByName: boolean;
55
+ }
56
+
57
+ /** "slug.md", "slug.mdx", "slug/index.md(x)" → slug + format; else null. */
58
+ export function parsePostFileName(name: string): ParsedName | null {
59
+ const match = POST_FILE.exec(name);
60
+ const groups = match?.groups;
61
+ if (!groups) return null;
62
+ const base = groups.flat ?? groups.dir;
63
+ if (!base) return null;
64
+ return {
65
+ slug: base.replace(/^_/, ""),
66
+ format: groups.ext === "mdx" ? "mdx" : "md",
67
+ // Legacy `_draft.md` convention, honored as an alias of `draft: true`.
68
+ draftByName: base.startsWith("_"),
69
+ };
70
+ }
71
+
72
+ export function parsePost(file: RawPostFile, config: BlogConfig): BlogPost | null {
73
+ const named = parsePostFileName(file.name);
74
+ if (!named) return null;
75
+
76
+ const { data, content } = matter(file.content);
77
+ const result = blogFrontmatterSchema.safeParse(data);
78
+ if (!result.success) {
79
+ if ((config.onInvalid ?? "throw") === "skip") {
80
+ console.warn(
81
+ `nk-blog: skipping ${file.name}: ${result.error.issues
82
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
83
+ .join("; ")}`,
84
+ );
85
+ return null;
86
+ }
87
+ throw new Error(
88
+ `nk-blog: invalid frontmatter in ${file.name}: ${result.error.issues
89
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
90
+ .join("; ")}`,
91
+ );
92
+ }
93
+
94
+ const frontmatter = result.data;
95
+ const slug = frontmatter.slug ?? named.slug;
96
+ const body = content.trim();
97
+ const authors = frontmatter.authors.length
98
+ ? frontmatter.authors
99
+ : config.defaultAuthor
100
+ ? [config.defaultAuthor]
101
+ : [];
102
+ const time = readingTime(body, config.wordsPerMinute ?? DEFAULT_WORDS_PER_MINUTE);
103
+ const image =
104
+ config.resolveImage?.({ slug, image: frontmatter.image }) ?? frontmatter.image;
105
+
106
+ return {
107
+ slug,
108
+ title: frontmatter.title,
109
+ seoTitle: frontmatter.seoTitle,
110
+ description: frontmatter.description,
111
+ date: frontmatter.date,
112
+ updated: frontmatter.updated,
113
+ authors,
114
+ author: authors[0] ?? "",
115
+ category: frontmatter.category ?? config.defaultCategory,
116
+ tags: frontmatter.tags,
117
+ image,
118
+ draft: frontmatter.draft || named.draftByName,
119
+ featured: frontmatter.featured,
120
+ lang: frontmatter.lang,
121
+ canonical: frontmatter.canonical,
122
+ format: named.format,
123
+ readingTimeMinutes: time.minutes,
124
+ readTime: time.text,
125
+ content: body,
126
+ };
127
+ }
128
+
129
+ export function createBlog(config: BlogConfig): Blog {
130
+ const posts = async (): Promise<BlogPost[]> => {
131
+ const files = await config.source.load();
132
+ const parsed = files
133
+ .map((file) => parsePost(file, config))
134
+ .filter((post): post is BlogPost => post !== null);
135
+
136
+ // Collision check BEFORE the draft filter: a draft colliding with a live
137
+ // post must fail the production build too, not only draft-enabled
138
+ // previews. Two files resolving to one slug is a routing conflict —
139
+ // always a loud build failure, never a quiet last-one-wins.
140
+ const seen = new Map<string, BlogPost>();
141
+ for (const post of parsed) {
142
+ if (seen.has(post.slug)) {
143
+ throw new Error(`nk-blog: duplicate slug "${post.slug}"`);
144
+ }
145
+ seen.set(post.slug, post);
146
+ }
147
+
148
+ return [...seen.values()]
149
+ .filter((post) => config.drafts === true || !post.draft)
150
+ .sort(
151
+ (a, b) =>
152
+ new Date(b.date).getTime() - new Date(a.date).getTime() ||
153
+ // Same-day posts: deterministic order between builds.
154
+ a.slug.localeCompare(b.slug),
155
+ );
156
+ };
157
+
158
+ return {
159
+ posts,
160
+ previews: async () =>
161
+ (await posts()).map(({ content: _content, ...preview }) => preview),
162
+ post: async (slug) =>
163
+ (await posts()).find((candidate) => candidate.slug === slug) ?? null,
164
+ slugs: async () => (await posts()).map((post) => post.slug),
165
+ featured: async () => {
166
+ const all = await posts();
167
+ return all.find((post) => post.featured) ?? all[0] ?? null;
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,97 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * The vocabulary manifest — the cross-site component contract.
6
+ *
7
+ * A Tier-1 post may reference exactly these components by bare name. The
8
+ * package owns the names and prop schemas; each site owns the pixels (see
9
+ * `@ingram-tech/nk-blog/unstyled` for the behavior-correct defaults). The
10
+ * admin publisher validates a post against this manifest (at the version the
11
+ * target site pins) before committing it.
12
+ */
13
+ export const VOCABULARY = [
14
+ "Callout",
15
+ "Figure",
16
+ "YouTube",
17
+ "Tweet",
18
+ "NewsletterSubscribe",
19
+ ] as const;
20
+
21
+ export type VocabularyName = (typeof VOCABULARY)[number];
22
+
23
+ // MDX literal attributes arrive as strings (`width="1200"`), so dimension-ish
24
+ // props coerce rather than demand numbers.
25
+ const dimension = z.union([z.number(), z.string().regex(/^\d+$/)]);
26
+
27
+ export const calloutProps = z.object({
28
+ variant: z.enum(["note", "tip", "warning", "important"]).default("note"),
29
+ title: z.string().optional(),
30
+ });
31
+
32
+ export const figureProps = z.object({
33
+ src: z.string().min(1),
34
+ alt: z.string(),
35
+ caption: z.string().optional(),
36
+ width: dimension.optional(),
37
+ height: dimension.optional(),
38
+ });
39
+
40
+ export const youTubeProps = z.object({
41
+ id: z.string().min(1),
42
+ title: z.string().optional(),
43
+ start: dimension.optional(),
44
+ });
45
+
46
+ export const tweetProps = z.object({
47
+ id: z.string().min(1),
48
+ });
49
+
50
+ export const newsletterSubscribeProps = z.object({
51
+ action: z.string().min(1),
52
+ placeholder: z.string().optional(),
53
+ buttonLabel: z.string().optional(),
54
+ });
55
+
56
+ /** Machine-checkable side of the contract, keyed by component name. */
57
+ export const vocabularyProps = {
58
+ Callout: calloutProps,
59
+ Figure: figureProps,
60
+ YouTube: youTubeProps,
61
+ Tweet: tweetProps,
62
+ NewsletterSubscribe: newsletterSubscribeProps,
63
+ } satisfies Record<VocabularyName, z.ZodType>;
64
+
65
+ // React-facing prop types: the schema's input shape plus children/className,
66
+ // which Zod does not model.
67
+ type WithReactExtras<T> = T & { children?: ReactNode; className?: string };
68
+
69
+ export type CalloutProps = WithReactExtras<z.input<typeof calloutProps>>;
70
+ export type FigureProps = WithReactExtras<z.input<typeof figureProps>>;
71
+ export type YouTubeProps = WithReactExtras<z.input<typeof youTubeProps>>;
72
+ export type TweetProps = WithReactExtras<z.input<typeof tweetProps>>;
73
+ export type NewsletterSubscribeProps = WithReactExtras<
74
+ z.input<typeof newsletterSubscribeProps>
75
+ >;
76
+
77
+ export interface VocabularyPropsMap {
78
+ Callout: CalloutProps;
79
+ Figure: FigureProps;
80
+ YouTube: YouTubeProps;
81
+ Tweet: TweetProps;
82
+ NewsletterSubscribe: NewsletterSubscribeProps;
83
+ }
84
+
85
+ /**
86
+ * The exhaustive registry a site must provide. Exhaustiveness is the point:
87
+ * a site missing a vocabulary component fails `tsc`, so an admin-published
88
+ * post can never reference something a site cannot render.
89
+ */
90
+ export type BlogComponents = {
91
+ [K in VocabularyName]: ComponentType<VocabularyPropsMap[K]>;
92
+ };
93
+
94
+ /** Identity helper — exists purely for inference and error locality. */
95
+ export function defineBlogComponents(components: BlogComponents): BlogComponents {
96
+ return components;
97
+ }
package/src/date.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one post-date formatter (one site shipped two of these). Defaults to the
3
+ * long-form English style every site currently renders: "April 30, 2026".
4
+ *
5
+ * Formats in UTC by default: post dates are normalized to UTC midnight, so a
6
+ * local-zone format would render the previous day anywhere west of UTC (and
7
+ * hydration-mismatch per viewer when used client-side). Pass `timeZone` in
8
+ * `options` to override.
9
+ */
10
+ export function formatPostDate(
11
+ isoDate: string,
12
+ locale = "en",
13
+ options: Intl.DateTimeFormatOptions = {
14
+ year: "numeric",
15
+ month: "long",
16
+ day: "numeric",
17
+ },
18
+ ): string {
19
+ return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...options }).format(
20
+ new Date(isoDate),
21
+ );
22
+ }
package/src/http.ts ADDED
@@ -0,0 +1,16 @@
1
+ export function toErrorMessage(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
4
+
5
+ export async function assertResponseOk(
6
+ response: Response,
7
+ message: string,
8
+ ): Promise<void> {
9
+ if (response.ok) return;
10
+ const body = await response.text().catch(() => "");
11
+ throw new Error(
12
+ `${message}: ${response.status} ${response.statusText}${
13
+ body ? ` — ${body.slice(0, 300)}` : ""
14
+ }`,
15
+ );
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Root entry: the contract — schema, types, vocabulary manifest — plus pure
2
+ // helpers. Importable anywhere (client, admin, route handlers); no fs, no
3
+ // components, no MDX compiler. The reader/sources live at "./server", the
4
+ // renderers at "./render", the default components at "./unstyled".
5
+ export {
6
+ blogFrontmatterSchema,
7
+ type BlogFrontmatter,
8
+ type BlogFrontmatterInput,
9
+ } from "./schema.js";
10
+ export type { BlogPost, BlogPostPreview, PostFormat } from "./types.js";
11
+ export {
12
+ VOCABULARY,
13
+ vocabularyProps,
14
+ defineBlogComponents,
15
+ calloutProps,
16
+ figureProps,
17
+ youTubeProps,
18
+ tweetProps,
19
+ newsletterSubscribeProps,
20
+ type BlogComponents,
21
+ type VocabularyName,
22
+ type VocabularyPropsMap,
23
+ type CalloutProps,
24
+ type FigureProps,
25
+ type YouTubeProps,
26
+ type TweetProps,
27
+ type NewsletterSubscribeProps,
28
+ } from "./contract.js";
29
+ export {
30
+ readingTime,
31
+ DEFAULT_WORDS_PER_MINUTE,
32
+ type ReadingTime,
33
+ } from "./reading-time.js";
34
+ export { formatPostDate } from "./date.js";
35
+ export {
36
+ blogPostArticle,
37
+ blogPostBreadcrumbs,
38
+ postUrl,
39
+ type BlogSeoConfig,
40
+ } from "./seo.js";
package/src/keys.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Env contract. Only the GitHub source/publisher needs anything, and callers
5
+ * may inject the token directly instead — so everything here is optional.
6
+ */
7
+ export const keys = () =>
8
+ z
9
+ .object({
10
+ GITHUB_TOKEN: z.string().min(1).optional(),
11
+ })
12
+ .parse(process.env);