@ingram-tech/nk-blog 0.1.5 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-blog",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.8.0",
46
+ "@ingram-tech/nk-seo": "^0.9.0",
44
47
  "@mdx-js/mdx": "^3.1.1",
45
48
  "gray-matter": "^4.0.3",
46
49
  "react-markdown": "^10.1.0",
@@ -61,7 +64,7 @@
61
64
  }
62
65
  },
63
66
  "devDependencies": {
64
- "@ingram-tech/nk-dev": "0.11.1",
67
+ "@ingram-tech/nk-dev": "0.13.0",
65
68
  "@types/mdast": "^4.0.4",
66
69
  "@types/node": "^26.2.0",
67
70
  "@types/react": "^19.2.18",
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);
@@ -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
+ }