@fullstackdatasolutions/articles 1.3.0 → 1.3.1
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/CHANGELOG.md +6 -0
- package/dist/nextjs.cjs +5 -20
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.js +5 -20
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +5 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +5 -3
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/server-articles.test.ts +21 -0
- package/src/server-articles.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.1] - 2026-08-23
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Markdown twins no longer declare themselves their own alternate.** `getArticleMarkdownResponse` spread `getArticleAiHeaders(article, config)` into the twin's response headers. That helper builds `Link: <{slug}.md>; rel="alternate"; type="text/markdown"`, which is correct on the *HTML* article page - it advertises that a markdown representation exists - but on the twin's own response it emitted a link from `/articles/x.md` to `/articles/x.md`, telling a client the alternate of the URL it just fetched was that same URL. A client following it round-trips to itself, and nothing in the response pointed back at the canonical HTML page the markdown represents, which is the one relationship an answer engine needs in order to attribute a citation to a real page. The twin now emits `Link: <{siteUrl}/articles/{slug}>; rel="canonical"` instead. The helper's other branch (`X-Robots-Tag: noai, noimageai`) was unreachable from this call site regardless, since `getArticleMarkdown` already returns `null` - and the route 404s - for anything not opted in, so dropping the spread loses no behavior. `getArticleAiHeaders` itself is unchanged and still correct for its documented use on HTML route handlers. Backward compatible: the header key is the same, only its target and relation change.
|
|
13
|
+
|
|
8
14
|
## [1.3.0] - 2026-08-22
|
|
9
15
|
|
|
10
16
|
### Added
|
package/dist/nextjs.cjs
CHANGED
|
@@ -1040,31 +1040,16 @@ function getArticleMarkdownResponse(slug, config, options) {
|
|
|
1040
1040
|
const article = yield getArticleMetadata(slug, config);
|
|
1041
1041
|
reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
|
|
1042
1042
|
const body = article && config.markdownTwinHeader !== false ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}` : markdown;
|
|
1043
|
+
const canonicalUrl = `${config.siteUrl.replace(/\/$/, "")}/articles/${slug}`;
|
|
1043
1044
|
return new Response(body, {
|
|
1044
|
-
headers:
|
|
1045
|
+
headers: {
|
|
1045
1046
|
"Content-Type": "text/markdown; charset=utf-8",
|
|
1046
|
-
"Cache-Control": "public, max-age=3600, s-maxage=3600"
|
|
1047
|
-
|
|
1047
|
+
"Cache-Control": "public, max-age=3600, s-maxage=3600",
|
|
1048
|
+
Link: `<${canonicalUrl}>; rel="canonical"`
|
|
1049
|
+
}
|
|
1048
1050
|
});
|
|
1049
1051
|
});
|
|
1050
1052
|
}
|
|
1051
|
-
function getArticleMarkdownUrl(article, config) {
|
|
1052
|
-
if (article.aiCrawl !== true) return void 0;
|
|
1053
|
-
const pathname = `/articles/${article.slug}.md`;
|
|
1054
|
-
if (!config) return pathname;
|
|
1055
|
-
return `${config.siteUrl.replace(/\/$/, "")}${pathname}`;
|
|
1056
|
-
}
|
|
1057
|
-
function getArticleAiHeaders(article, config) {
|
|
1058
|
-
const markdownUrl = getArticleMarkdownUrl(article, config);
|
|
1059
|
-
if (markdownUrl) {
|
|
1060
|
-
return {
|
|
1061
|
-
Link: `<${markdownUrl}>; rel="alternate"; type="text/markdown"`
|
|
1062
|
-
};
|
|
1063
|
-
}
|
|
1064
|
-
return {
|
|
1065
|
-
"X-Robots-Tag": "noai, noimageai"
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
1053
|
function categoryToSlug(category) {
|
|
1069
1054
|
return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
|
|
1070
1055
|
}
|
package/dist/nextjs.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/nextjs.ts","../src/server-articles.ts","../src/authorUtils.ts","../src/markdown.ts","../src/errorReporting.ts","../src/linkClassification.ts"],"sourcesContent":["// Next.js API route handler factory — Node.js runtime only\n// Import from '@fullstackdatasolutions/articles/nextjs'\nimport type { NextRequest } from 'next/server'\nimport { getMarkdownTwinResponse } from './server-articles'\nimport type { ArticlesConfig } from './articlesConfig'\n\n/**\n * Returns a Next.js App Router GET handler for serving markdown twins -\n * articles plus category/author/series listings, dispatched by slug prefix.\n * The request is passed through so `ArticlesConfig.onAiCrawl` can see the\n * user agent.\n *\n * Usage in app/api/articles-markdown/[...slug]/route.ts:\n * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'\n * import { siteConfig } from '@/config/articles'\n * export const { GET } = createArticleMarkdownHandler(siteConfig)\n */\nexport function createArticleMarkdownHandler(config: ArticlesConfig) {\n async function GET(\n request: NextRequest,\n context: { params: Promise<Record<string, string | string[]>> }\n ) {\n const params = await context.params\n const slugParts = params['slug']\n const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')\n const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug\n return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers })\n }\n return { GET }\n}\n","import { cache } from 'react'\nimport matter from 'gray-matter'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\nimport { getAuthorAvatar } from './authorUtils'\nimport { markdownToHtml, extractToc, deriveFaqFromHeadings } from './markdown'\nimport type {\n Article,\n AuthorProfile,\n CategoryInfo,\n CitationReference,\n EntityReference,\n FaqItem,\n HowToStep,\n PathDefinition,\n} from './articleTypes'\nimport type { ArticlesConfig } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\nconst articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')\n\nfunction getReadingStats(content: string): { readTime: string; wordCount: number } {\n const stats = readingTime(content)\n return { readTime: stats.text, wordCount: stats.words }\n}\n\nfunction findArticleImage(slug: string): string | null {\n try {\n const articleDir = path.join(articlesDirectory, slug)\n const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, { withFileTypes: true })\n const names = entries\n .map((entry) => {\n if (typeof entry === 'string') return entry\n if (entry && typeof entry.name === 'string') return entry.name\n return null\n })\n .filter((name): name is string => Boolean(name))\n const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']\n const imageFileName = names.find((name) =>\n imageExtensions.some((ext) => name.toLowerCase().endsWith(ext))\n )\n return imageFileName ? sanitizeImagePath(imageFileName, slug) : null\n } catch {\n return null\n }\n}\n\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') return null\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe article image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (cleanPath.includes('/')) {\n const normalizedPath = path.normalize(cleanPath)\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${cleanPath}`\n }\n return `/articles/${articleSlug}/${cleanPath}`\n}\n\nfunction findArticleFile(slug: string): { filePath: string; contentType: 'md' | 'mdx' } | null {\n const mdPath = path.join(articlesDirectory, slug, 'article.md')\n const mdxPath = path.join(articlesDirectory, slug, 'article.mdx')\n if (fs.existsSync(mdPath)) return { filePath: mdPath, contentType: 'md' }\n if (fs.existsSync(mdxPath)) return { filePath: mdxPath, contentType: 'mdx' }\n return null\n}\n\nexport function getAvailableArticleSlugs(): string[] {\n try {\n if (!fs.existsSync(articlesDirectory)) return []\n return walkArticleDir(articlesDirectory, '')\n } catch (error) {\n reportArticlesError({\n code: 'article-directory-read-failed',\n message: 'Unable to read articles directory.',\n error,\n context: { directory: articlesDirectory },\n })\n return []\n }\n}\n\nfunction walkArticleDir(dir: string, baseSlug: string): string[] {\n const slugs: string[] = []\n try {\n const items = fs.readdirSync(dir, { withFileTypes: true })\n for (const item of items) {\n if (!item.isDirectory()) continue\n const slug = baseSlug ? `${baseSlug}/${item.name}` : item.name\n if (findArticleFile(slug) !== null) {\n slugs.push(slug)\n }\n slugs.push(...walkArticleDir(path.join(dir, item.name), slug))\n }\n } catch {\n // ignore unreadable directories\n }\n return slugs\n}\n\nfunction parseDateField(rawDate: unknown): string | undefined {\n if (!rawDate) return undefined\n try {\n const parsed = new Date(rawDate as string)\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().split('T')[0]\n } catch {\n // ignore invalid dates\n }\n return undefined\n}\n\nfunction resolveFeaturedImage(rawImage: unknown, slug: string): string {\n const img = typeof rawImage === 'string' ? rawImage : ''\n if (img && !img.startsWith('http')) return sanitizeImagePath(img, slug) || '/placeholder-logo.png'\n if (!img) return findArticleImage(slug) || '/placeholder-logo.png'\n return img\n}\n\nfunction parseFaqItems(raw: unknown): FaqItem[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw.filter(\n (item): item is FaqItem =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as FaqItem).question === 'string' &&\n typeof (item as FaqItem).answer === 'string'\n )\n return items.length ? items : undefined\n}\n\nfunction parseHowToSteps(raw: unknown): HowToStep[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const steps = raw.filter(\n (item): item is HowToStep =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as HowToStep).name === 'string' &&\n typeof (item as HowToStep).text === 'string'\n )\n return steps.length ? steps : undefined\n}\n\n// Trims and drops empty strings - the same \"unset if blank\" rule applied to\n// every other optional string frontmatter field in this file\n// (canonicalUrl/articleType/series). Used for all five discovery overrides\n// (Phase 27F) so a stray `searchTitle: \"\"` in frontmatter behaves exactly\n// like omitting the key, rather than becoming an empty <title> override.\nfunction parseOptionalString(raw: unknown): string | undefined {\n return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined\n}\n\n// `aiCrawl` is the one frontmatter flag with a config-level default\n// (`ArticlesConfig.aiCrawlDefault`). Explicit `true`/`false` in frontmatter\n// always wins; only an omitted/non-boolean key falls through to the config.\n// Omitting both reproduces the original \"blocked unless opted in\" behavior.\n// Explicit `faq` frontmatter always wins - derivation only fills the gap\n// for articles that never declared one, and only when the site opted in.\nfunction deriveFaq(markdownContent: string, config?: ArticlesConfig): FaqItem[] | undefined {\n if (config?.deriveFaqFromHeadings !== true) return undefined\n const derived = deriveFaqFromHeadings(markdownContent)\n return derived.length ? derived : undefined\n}\n\n// Frontmatter `lastmod` always wins. Only `'fileMtime'` produces a value\n// here; `'published'` (the default) and `'none'` differ in what\n// `ArticleSEO` does with an absent `lastmod` - reuse `datePublished`, or\n// omit `dateModified` rather than repeat a stale date.\nfunction resolveLastmod(\n rawLastmod: unknown,\n rawDate: unknown,\n filePath: string,\n config?: ArticlesConfig\n): string | undefined {\n const declared = parseDateField(rawLastmod)\n if (declared) return declared\n if (config?.lastmodFallback !== 'fileMtime') return undefined\n try {\n return parseDateField(fs.statSync(filePath).mtime)\n } catch {\n return parseDateField(rawDate)\n }\n}\n\nfunction resolveAiCrawl(raw: unknown, config?: ArticlesConfig): boolean {\n if (typeof raw === 'boolean') return raw\n return config?.aiCrawlDefault === true\n}\n\n// A bare string resolves through `config.entities` first, so a corpus can\n// share one canonical name/`sameAs` pair per topic instead of each article\n// spelling it out (and spelling it differently). Unregistered strings still\n// work as plain names - `validateArticles` warns about them rather than\n// dropping the entry.\nfunction parseEntityReferences(\n raw: unknown,\n config?: ArticlesConfig\n): EntityReference[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw\n .map((item) => {\n if (typeof item === 'string') {\n const key = item.trim()\n if (!key) return null\n return config?.entities?.[key] ?? { name: key }\n }\n if (\n typeof item === 'object' &&\n item !== null &&\n typeof (item as EntityReference).name === 'string'\n ) {\n const entity = item as EntityReference\n const name = entity.name.trim()\n if (!name) return null\n return entity.sameAs ? { name, sameAs: entity.sameAs } : { name }\n }\n return null\n })\n .filter((item): item is EntityReference => item !== null)\n return items.length ? items : undefined\n}\n\nfunction parseCitations(raw: unknown): CitationReference[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw\n .map((item) => {\n if (typeof item === 'string') return item.trim() ? { name: item.trim() } : null\n if (\n typeof item === 'object' &&\n item !== null &&\n typeof (item as CitationReference).name === 'string'\n ) {\n const citation = item as CitationReference\n const name = citation.name.trim()\n if (!name) return null\n return citation.url ? { name, url: citation.url } : { name }\n }\n return null\n })\n .filter((item): item is CitationReference => item !== null)\n return items.length ? items : undefined\n}\n\nfunction parseSeriesOrder(raw: unknown): number | undefined {\n return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined\n}\n\nfunction parsePrimaryAction(raw: unknown): { actionId: string } | undefined {\n if (typeof raw === 'string') {\n const actionId = raw.trim()\n return actionId ? { actionId } : undefined\n }\n if (\n typeof raw === 'object' &&\n raw !== null &&\n typeof (raw as { actionId?: unknown }).actionId === 'string'\n ) {\n const actionId = (raw as { actionId: string }).actionId.trim()\n return actionId ? { actionId } : undefined\n }\n return undefined\n}\n\nfunction parseAuthors(raw: unknown): string[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const authors = raw.filter(\n (author): author is string => typeof author === 'string' && author.trim().length > 0\n )\n return authors\n}\n\nexport function getAuthorBySlug(slug: string, config: ArticlesConfig): AuthorProfile | null {\n const profile = config.authors?.[slug]\n if (!profile) return null\n return {\n ...profile,\n url: profile.url ?? `${config.siteUrl.replace(/\\/$/, '')}/articles/authors/${profile.slug}`,\n }\n}\n\nfunction getConfiguredAuthorByName(name: string, config: ArticlesConfig): AuthorProfile | null {\n const normalizedName = name.trim().toLowerCase()\n const profile = Object.values(config.authors ?? {}).find(\n (author) => author.name.toLowerCase() === normalizedName\n )\n return profile ? getAuthorBySlug(profile.slug, config) : null\n}\n\nfunction resolveArticleAuthorName(\n rawAuthor: unknown,\n rawAuthors: unknown,\n config?: ArticlesConfig\n): string {\n const authorArray = parseAuthors(rawAuthors)\n const firstAuthor = authorArray?.[0]\n const rawAuthorValue = typeof rawAuthor === 'string' && rawAuthor.trim() ? rawAuthor : undefined\n const author = firstAuthor ?? rawAuthorValue\n const resolved = author ?? config?.defaultAuthor\n if (!resolved) return ''\n if (!config) return resolved\n return (\n getAuthorBySlug(resolved, config)?.name ??\n getConfiguredAuthorByName(resolved, config)?.name ??\n resolved\n )\n}\n\nexport function getArticleAuthors(article: Article, config: ArticlesConfig): AuthorProfile[] {\n const fallbackAuthors = Array.from(\n new Set(\n [article.author, config.defaultAuthor].filter(\n (author): author is string => typeof author === 'string' && author.trim().length > 0\n )\n )\n )\n const authorValues = article.authors ?? fallbackAuthors\n if (authorValues.length === 0) return []\n const resolvedAuthors = authorValues\n .map((author) => getAuthorBySlug(author, config) ?? getConfiguredAuthorByName(author, config))\n .filter((author): author is AuthorProfile => author !== null)\n .filter((author, index, all) => all.findIndex((a) => a.slug === author.slug) === index)\n\n if (resolvedAuthors.length > 0) return resolvedAuthors\n\n return authorValues.map((fallbackName) => ({\n name: fallbackName,\n slug: categoryToSlug(fallbackName),\n bio: '',\n }))\n}\n\nexport function getAllAuthors(config: ArticlesConfig): AuthorProfile[] {\n return Object.keys(config.authors ?? {})\n .map((slug) => getAuthorBySlug(slug, config))\n .filter((author): author is AuthorProfile => author !== null)\n}\n\nasync function getArticleSummary(slug: string, config?: ArticlesConfig): Promise<Article | null> {\n try {\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { data, content: markdownContent } = matter(fileContent)\n const { readTime, wordCount } = getReadingStats(markdownContent)\n const allTags: string[] = Array.isArray(data.tags)\n ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())\n : []\n const categories: string[] =\n allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']\n const author = resolveArticleAuthorName(data.author, data.authors, config)\n const authors = parseAuthors(data.authors)\n // Resolve the primary author's profile (if configured) so cards can\n // render an avatar/link without needing `config` client-side. Reuses\n // getArticleAuthors' existing slug/name resolution chain instead of\n // duplicating it - only the two fields it reads (author/authors) exist\n // on this partial yet.\n const primaryAuthorProfile = config\n ? getArticleAuthors({ author, authors } as Article, config)[0]\n : undefined\n return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: parseDateField(data.date),\n lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),\n author,\n authors,\n authorSlug: primaryAuthorProfile?.slug,\n authorAvatar: primaryAuthorProfile ? getAuthorAvatar(primaryAuthorProfile) : undefined,\n category: categories[0],\n categories,\n readTime,\n wordCount,\n featuredImage: resolveFeaturedImage(data.featuredImage, slug),\n tags: data.tags || [],\n contentType: found.contentType,\n draft: data.draft === true,\n faq: parseFaqItems(data.faq) ?? deriveFaq(markdownContent, config),\n howTo: parseHowToSteps(data.howTo),\n answer: parseOptionalString(data.answer),\n about: parseEntityReferences(data.about, config),\n citation: parseCitations(data.citation),\n canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,\n articleType: typeof data.articleType === 'string' ? data.articleType : undefined,\n series: typeof data.series === 'string' ? data.series : undefined,\n seriesSlug: parseOptionalString(data.seriesSlug),\n seriesOrder: parseSeriesOrder(data.seriesOrder),\n aiCrawl: resolveAiCrawl(data.aiCrawl, config),\n searchTitle: parseOptionalString(data.searchTitle),\n searchDescription: parseOptionalString(data.searchDescription),\n socialTitle: parseOptionalString(data.socialTitle),\n socialDescription: parseOptionalString(data.socialDescription),\n socialImage: parseOptionalString(data.socialImage),\n primaryAction: parsePrimaryAction(data.primaryAction),\n }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article summary.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\nexport const getArticleMetadata = cache(\n async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {\n try {\n const summary = await getArticleSummary(slug, config)\n if (!summary) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n const toc = await extractToc(markdownContent)\n let htmlContent: string | undefined\n let mdxSource: string | undefined\n if (found.contentType === 'mdx') {\n mdxSource = markdownContent\n } else {\n htmlContent = await markdownToHtml(markdownContent, slug, config)\n }\n return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article metadata.',\n error,\n context: { slug },\n })\n return null\n }\n }\n)\n\nexport const getAllArticles = cache(async (config?: ArticlesConfig): Promise<Article[]> => {\n const slugs = getAvailableArticleSlugs()\n const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug, config)))\n const currentDate = new Date().toISOString().split('T')[0]\n return articles\n .filter((article): article is Article => article !== null)\n .filter((article) => !article.date || article.date <= currentDate)\n .filter((article) => !(article.draft && process.env.NODE_ENV === 'production'))\n .sort((a, b) => {\n if (!a.date && !b.date) return 0\n if (!a.date) return 1\n if (!b.date) return -1\n return new Date(b.date).getTime() - new Date(a.date).getTime()\n })\n})\n\nexport async function getAdjacentArticles(\n currentSlug: string\n): Promise<{ previous: Article | null; next: Article | null }> {\n const allArticles = await getAllArticles()\n const currentIndex = allArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n const previous = currentIndex < allArticles.length - 1 ? allArticles[currentIndex + 1] : null\n const next = currentIndex > 0 ? allArticles[currentIndex - 1] : null\n return { previous, next }\n}\n\nexport async function getArticleMarkdown(\n slug: string,\n config?: ArticlesConfig\n): Promise<string | null> {\n try {\n const summary = await getArticleSummary(slug, config)\n if (!summary?.aiCrawl) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n return markdownContent\n } catch (error) {\n reportArticlesError({\n code: 'article-markdown-load-failed',\n message: 'Unable to load article markdown.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\n/**\n * Crawlers that read content on behalf of an answer engine. Used both to\n * write robots.txt rules and to classify markdown-twin fetches for\n * `ArticlesConfig.onAiCrawl` - one list, so the two can never disagree about\n * what counts as an AI crawler.\n */\nexport const AI_CRAWLERS = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'CCBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Google-Extended',\n 'Applebot-Extended',\n 'Bytespider',\n 'Amazonbot',\n 'meta-externalagent',\n 'cohere-ai',\n 'DuckAssistBot',\n 'MistralAI-User',\n] as const\n\n/** Matches a `User-Agent` against `AI_CRAWLERS`, returning the crawler name or `null`. */\nexport function matchAiCrawler(userAgent: string): string | null {\n if (!userAgent) return null\n const normalized = userAgent.toLowerCase()\n return AI_CRAWLERS.find((crawler) => normalized.includes(crawler.toLowerCase())) ?? null\n}\n\n/**\n * Attribution header prepended to an article's markdown twin.\n *\n * The twin is what an AI crawler actually reads, and `matter()` strips every\n * frontmatter field before it is served - so without this the model gets an\n * anonymous body with no title, date, author, or link back to the canonical\n * page. Skips its own `# {title}` line when the body already opens with the\n * same H1, so the common \"body repeats the title\" layout doesn't end up with\n * two.\n */\nexport function buildMarkdownTwinHeader(\n article: Article,\n config: ArticlesConfig,\n body: string\n): string {\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const firstLine = body.trimStart().split('\\n', 1)[0]?.trim() ?? ''\n const bodyRepeatsTitle = firstLine.toLowerCase() === `# ${article.title}`.toLowerCase()\n\n const facts = [\n `Source: ${siteUrl}/articles/${article.slug}`,\n article.date ? `Published: ${article.date}` : '',\n article.lastmod ? `Updated: ${article.lastmod}` : '',\n config.showAuthor !== false && article.author ? `Author: ${article.author}` : '',\n `Site: ${config.siteName}`,\n ].filter(Boolean)\n\n const blocks = [\n bodyRepeatsTitle ? '' : `# ${article.title}`,\n article.excerpt ? `> ${article.excerpt}` : '',\n facts.join('\\n'),\n article.answer ? `**Short answer:** ${article.answer}` : '',\n '---',\n ].filter((block) => block !== '')\n\n return `${blocks.join('\\n\\n')}\\n\\n`\n}\n\n/**\n * Reports a markdown-twin fetch to `config.onAiCrawl`. Never lets a consumer\n * callback break the response - a telemetry handler throwing must not turn a\n * served article into a 500.\n */\nfunction reportAiCrawl(slug: string, config: ArticlesConfig, headers?: RequestHeaders): void {\n if (!config.onAiCrawl) return\n const userAgent = headers?.get('user-agent') ?? ''\n try {\n config.onAiCrawl({ slug, crawler: matchAiCrawler(userAgent) ?? 'unknown', userAgent })\n } catch (error) {\n reportArticlesError({\n code: 'ai-crawl-handler-failed',\n message: 'onAiCrawl handler threw.',\n error,\n context: { slug },\n })\n }\n}\n\n/** Minimal shape of a request's headers - avoids depending on `next/server` here. */\nexport type RequestHeaders = Readonly<{ get(name: string): string | null }>\n\n/**\n * Markdown twin for a listing surface - a category, author, or series.\n *\n * Article twins alone leave a model with a bag of pages and no map: the\n * listing surfaces are what answer \"what does this site cover, and who\n * writes it\". Only articles resolved to `aiCrawl: true` are listed, so a\n * blocked article stays invisible here too.\n */\nfunction buildListingMarkdown(\n heading: string,\n intro: string[],\n articles: readonly Article[],\n config: ArticlesConfig\n): string {\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const crawlable = articles.filter((article) => article.aiCrawl === true)\n const entries = crawlable.map((article) => {\n const summary = article.answer ?? article.excerpt\n const line = `- [${article.title}](${siteUrl}/articles/${article.slug}.md)`\n return summary ? `${line}: ${summary}` : line\n })\n\n return [\n `# ${heading}`,\n '',\n ...intro.flatMap((line) => [line, '']),\n `Source: ${siteUrl}`,\n `Site: ${config.siteName}`,\n '',\n '---',\n '',\n ...(entries.length > 0 ? entries : ['_No articles available._']),\n '',\n ].join('\\n')\n}\n\n/** Markdown twin for `/articles/category/[category]`. `null` when the category has no articles. */\nexport async function getCategoryMarkdown(\n categorySlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const articles = await getArticlesByCategory(categorySlug, config)\n if (articles.length === 0) return null\n const name =\n articles[0].categories.find((c) => categoryToSlug(c) === categorySlug) ?? categorySlug\n const description = resolveCategoryDescription(categorySlug, config)\n return buildListingMarkdown(name, description ? [description] : [], articles, config)\n}\n\nfunction resolveCategoryDescription(\n categorySlug: string,\n config: ArticlesConfig\n): string | undefined {\n const entry = config.categoryDescriptions?.[categorySlug]\n if (!entry) return undefined\n return typeof entry === 'string' ? entry : (entry.long ?? entry.short)\n}\n\n/**\n * Markdown twin for `/articles/authors/[author]`.\n *\n * Carries the author's bio, promise, principles, and sourced proof alongside\n * their article list - the \"who is this and why trust them\" context that\n * otherwise exists only inside React components, and the exact question asked\n * before anything they wrote gets cited. `credentials` are included as the\n * author's own stated claims; unlike JSON-LD, prose can attribute a claim\n * without asserting it as a verified fact.\n */\nexport async function getAuthorMarkdown(\n authorSlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const author = getAuthorBySlug(authorSlug, config)\n if (!author) return null\n const articles = await getArticlesByAuthor(authorSlug, config)\n\n const intro = [\n author.promise ?? '',\n author.bio ?? '',\n ...(author.servesWho?.length ? [`Writes for: ${author.servesWho.join(', ')}`] : []),\n ...(author.knowsAbout?.length ? [`Writes about: ${author.knowsAbout.join(', ')}`] : []),\n ...(author.credentials?.length\n ? ['## Stated experience', ...author.credentials.map((item) => `- ${item}`)]\n : []),\n ...(author.proof?.length\n ? [\n '## Proof points',\n ...author.proof.map((item) =>\n item.url ? `- [${item.claim}](${item.url})` : `- ${item.claim}`\n ),\n ]\n : []),\n ...(author.originStory?.length\n ? [\n '## Background',\n ...author.originStory.flatMap((section) => [\n ...(section.heading ? [`### ${section.heading}`] : []),\n ...section.paragraphs,\n ]),\n ]\n : []),\n ].filter((line) => line.trim() !== '')\n\n return buildListingMarkdown(author.name, intro, articles, config)\n}\n\n/** Markdown twin for `/articles/series/[series]`. `null` when the series has no articles. */\nexport async function getSeriesMarkdown(\n seriesSlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const articles = await getArticlesBySeries(seriesSlug, config)\n if (articles.length === 0) return null\n const name = articles[0].series ?? seriesSlug\n return buildListingMarkdown(name, [], articles, config)\n}\n\n/**\n * Serves the markdown twin for any `/articles/...` path - an article, or a\n * category/author/series listing.\n *\n * Dispatching on the slug prefix here rather than adding three more app\n * routes keeps the existing single rewrite (`/articles/:path*.md`) working\n * unchanged: without it, `/articles/category/campaigns.md` matches that\n * rewrite, reaches the article handler, and 404s.\n */\nexport async function getMarkdownTwinResponse(\n slug: string,\n config: ArticlesConfig,\n options?: Readonly<{ headers?: RequestHeaders }>\n): Promise<Response> {\n const listing = await resolveListingMarkdown(slug, config)\n if (listing !== undefined) {\n if (listing === null) return new Response('Not Found', { status: 404 })\n reportAiCrawl(slug, config, options?.headers)\n return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS })\n }\n return getArticleMarkdownResponse(slug, config, options)\n}\n\nconst LISTING_MARKDOWN_HEADERS = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Cache-Control': 'public, max-age=3600, s-maxage=3600',\n}\n\n// `undefined` means \"not a listing path\" (fall through to the article\n// handler); `null` means \"a listing path that resolved to nothing\" (404).\nasync function resolveListingMarkdown(\n slug: string,\n config: ArticlesConfig\n): Promise<string | null | undefined> {\n const [prefix, ...rest] = slug.split('/')\n const key = rest.join('/')\n if (!key) return undefined\n if (prefix === 'category') return getCategoryMarkdown(key, config)\n if (prefix === 'authors') return getAuthorMarkdown(key, config)\n if (prefix === 'series') return getSeriesMarkdown(key, config)\n return undefined\n}\n\nexport async function getArticleMarkdownResponse(\n slug: string,\n config: ArticlesConfig,\n options?: Readonly<{ headers?: RequestHeaders }>\n): Promise<Response> {\n const markdown = await getArticleMarkdown(slug, config)\n if (markdown === null) return new Response('Not Found', { status: 404 })\n const article = await getArticleMetadata(slug, config)\n reportAiCrawl(slug, config, options?.headers)\n const body =\n article && config.markdownTwinHeader !== false\n ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}`\n : markdown\n return new Response(body, {\n headers: {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Cache-Control': 'public, max-age=3600, s-maxage=3600',\n ...(article ? getArticleAiHeaders(article, config) : {}),\n },\n })\n}\n\nexport function getArticleMarkdownUrl(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): string | undefined {\n if (article.aiCrawl !== true) return undefined\n const pathname = `/articles/${article.slug}.md`\n if (!config) return pathname\n return `${config.siteUrl.replace(/\\/$/, '')}${pathname}`\n}\n\nexport function getArticleAiHeaders(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): Record<string, string> {\n const markdownUrl = getArticleMarkdownUrl(article, config)\n if (markdownUrl) {\n return {\n Link: `<${markdownUrl}>; rel=\"alternate\"; type=\"text/markdown\"`,\n }\n }\n return {\n 'X-Robots-Tag': 'noai, noimageai',\n }\n}\n\nexport async function getAiRobotsTxtRules(config?: ArticlesConfig): Promise<string> {\n const articles = await getAllArticles(config)\n const blockedArticles = articles.filter((article) => article.aiCrawl !== true)\n if (blockedArticles.length === 0) return ''\n\n const disallowRules = blockedArticles\n .map((article) => `Disallow: /articles/${article.slug}`)\n .join('\\n')\n\n return AI_CRAWLERS.map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\\n')).join(\n '\\n\\n'\n )\n}\n\nexport async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {\n if (!query?.trim()) return getAllArticles(config)\n const articles = await getAllArticles(config)\n const searchTerm = query.toLowerCase().trim()\n const includeAuthor = config?.showAuthor !== false\n return articles.filter((article) => {\n const matchesTitle = article.title.toLowerCase().includes(searchTerm)\n const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)\n const matchesAuthor = includeAuthor && article.author.toLowerCase().includes(searchTerm)\n const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))\n const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))\n return (\n matchesTitle || matchesExcerpt || matchesAuthor || matchesCategory || Boolean(matchesTags)\n )\n })\n}\n\nexport function categoryToSlug(category: string): string {\n return category\n .toLowerCase()\n .replaceAll(/\\s+/g, '-')\n .replaceAll(/[^a-z0-9-]/g, '')\n}\n\nexport async function getAllCategories(): Promise<CategoryInfo[]> {\n const articles = await getAllArticles()\n const categoryMap = new Map<string, { count: number; featuredImage: string }>()\n for (const article of articles) {\n for (const cat of article.categories) {\n if (!categoryMap.has(cat)) {\n categoryMap.set(cat, { count: 0, featuredImage: article.featuredImage })\n }\n categoryMap.get(cat)!.count++\n }\n }\n return Array.from(categoryMap.entries())\n .map(([name, { count, featuredImage }]) => ({\n name,\n slug: categoryToSlug(name),\n count,\n featuredImage,\n }))\n .sort((a, b) => b.count - a.count)\n}\n\nexport async function getArticlesByCategory(\n categorySlug: string,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles.filter((article) =>\n article.categories.some((cat) => categoryToSlug(cat) === categorySlug)\n )\n}\n\n// Built on getArticlesByCategory (same slug-matching filter, no duplicated\n// logic) rather than getAdjacentArticles' global date-order walk, so an\n// article detail page can link to other articles in the same category\n// instead of just the two chronologically-nearest articles overall.\nexport async function getRelatedArticlesByCategory(\n currentSlug: string,\n category: string,\n limit = 3,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getArticlesByCategory(categoryToSlug(category), config)\n return articles.filter((article) => article.slug !== currentSlug).slice(0, limit)\n}\n\nexport async function getArticlesByAuthor(\n authorSlug: string,\n config: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles.filter((article) =>\n getArticleAuthors(article, config).some((author) => author.slug === authorSlug)\n )\n}\n\n// Sorted by `seriesOrder` ascending (undefined pushed to the end); ties fall\n// back to the date-descending order `getAllArticles` already applies, since\n// `Array.prototype.sort` is stable - matching `getArticlesByCategory`'s\n// \"build on the existing filter, don't duplicate `getAllArticles`\" pattern.\n// The label-only `series` string field is untouched by this function.\nexport async function getArticlesBySeries(\n seriesSlug: string,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles\n .filter((article) => article.seriesSlug === seriesSlug)\n .sort((a, b) => {\n const orderA = a.seriesOrder ?? Number.POSITIVE_INFINITY\n const orderB = b.seriesOrder ?? Number.POSITIVE_INFINITY\n return orderA - orderB\n })\n}\n\n/**\n * Series-aware sibling of `getAdjacentArticles`: walks `seriesOrder` within\n * one series instead of global date order. `previous`/`next` follow series\n * order (ascending), not chronology.\n */\nexport async function getAdjacentArticlesInSeries(\n currentSlug: string,\n seriesSlug: string,\n config?: ArticlesConfig\n): Promise<{ previous: Article | null; next: Article | null }> {\n const seriesArticles = await getArticlesBySeries(seriesSlug, config)\n const currentIndex = seriesArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n return {\n previous: currentIndex > 0 ? seriesArticles[currentIndex - 1] : null,\n next: currentIndex < seriesArticles.length - 1 ? seriesArticles[currentIndex + 1] : null,\n }\n}\n\n/** Looks up one configured `PathDefinition` by its app-chosen key. */\nexport function getPath(pathKey: string, config: ArticlesConfig): PathDefinition | null {\n return config.paths?.[pathKey] ?? null\n}\n\n/** Resolves a path's ordered slugs against the real article set, dropping any that don't resolve (e.g. a draft filtered out of `getAllArticles` in production) rather than throwing - use `validateArticles` to catch broken references before publishing. */\nexport async function getPathArticles(pathKey: string, config: ArticlesConfig): Promise<Article[]> {\n const path = getPath(pathKey, config)\n if (!path) return []\n const articles = await getAllArticles(config)\n const bySlug = new Map(articles.map((article) => [article.slug, article]))\n return path.articles\n .map((slug) => bySlug.get(slug))\n .filter((article): article is Article => Boolean(article))\n}\n\nfunction findPathForArticle(\n slug: string,\n config: ArticlesConfig\n): { key: string; path: PathDefinition } | null {\n for (const [key, path] of Object.entries(config.paths ?? {})) {\n if (path.articles.includes(slug)) return { key, path }\n }\n return null\n}\n\nexport type RelatedContentSource = 'path' | 'series' | 'category'\n\nexport interface RelatedContentResult {\n source: RelatedContentSource\n /** Heading for a related-content UI - the path's `name`, the article's `series` label, or \"More in {category}\". */\n heading: string\n articles: Article[]\n /** Set only when `source === 'path'`. */\n pathKey?: string\n /** Set only when `source === 'path'` - the path's one configured next action. */\n nextAction?: { label: string; href: string }\n}\n\n/**\n * Reusable related-content selection (Phase 27F): prefers a configured\n * `Path` containing this article first, then the article's `seriesSlug`,\n * falling back to 27B's `getRelatedArticlesByCategory` (imported, not\n * reimplemented) when neither a path nor a series applies - the plain\n * chronological-within-category behavior stays the fallback, not a full\n * replacement.\n */\nexport async function getRelatedContent(\n article: Article,\n config: ArticlesConfig,\n limit = 3\n): Promise<RelatedContentResult> {\n const matchedPath = findPathForArticle(article.slug, config)\n if (matchedPath) {\n const pathArticles = await getPathArticles(matchedPath.key, config)\n return {\n source: 'path',\n heading: matchedPath.path.name,\n articles: pathArticles.filter((a) => a.slug !== article.slug),\n pathKey: matchedPath.key,\n nextAction: matchedPath.path.nextAction,\n }\n }\n if (article.seriesSlug) {\n const seriesArticles = await getArticlesBySeries(article.seriesSlug, config)\n return {\n source: 'series',\n heading: article.series ?? 'This series',\n articles: seriesArticles.filter((a) => a.slug !== article.slug),\n }\n }\n const categoryArticles = await getRelatedArticlesByCategory(\n article.slug,\n article.category,\n limit,\n config\n )\n return { source: 'category', heading: `More in ${article.category}`, articles: categoryArticles }\n}\n\nexport { sanitizeImagePath }\n","import type { ArticlesConfig } from './articlesConfig'\nimport type { AuthorProfile, AuthorSocial } from './articleTypes'\n\n/**\n * The canonical identity URL a Person `@id` derives from. Prefers the\n * explicit cross-site `identityUrl`, then `url`, then this site's own author\n * page - so an author who configures nothing keeps a per-site identity, and\n * one who sets `identityUrl` gets the same `@id` on every site.\n */\nexport function getAuthorIdentityUrl(\n author: AuthorProfile,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): string | undefined {\n if (author.identityUrl) return author.identityUrl\n if (author.url) return author.url\n if (!config) return undefined\n return `${config.siteUrl.replace(/\\/$/, '')}/articles/authors/${author.slug}`\n}\n\nexport function getAuthorUrl(author: AuthorProfile): string {\n return author.url ?? `/articles/authors/${author.slug}`\n}\n\nexport function getAuthorAvatar(\n author: AuthorProfile,\n config?: ArticlesConfig\n): string | undefined {\n if (!author.avatar) return undefined\n if (author.avatar.startsWith('http://') || author.avatar.startsWith('https://')) {\n return author.avatar\n }\n const path = `/articles/authors/${author.slug}/${author.avatar.replace(/^\\/+/, '')}`\n if (!config) return path\n return `${config.siteUrl.replace(/\\/$/, '')}${path}`\n}\n\nexport function getAuthorSameAs(author: AuthorProfile): string[] {\n const derived = getAuthorSocialLinks(author).map((link) => link.href)\n const explicit = author.sameAs?.filter((url) => url.trim() !== '') ?? []\n return [...new Set([...derived, ...explicit])]\n}\n\nexport interface AuthorSocialLink {\n label: string\n href: string\n}\n\nfunction normalizeHandle(value: string): string {\n return value.replace(/^@/, '')\n}\n\nfunction normalizeUrl(value: string, baseUrl?: string): string {\n if (value.startsWith('http://') || value.startsWith('https://')) return value\n if (!baseUrl) return value\n return `${baseUrl}${normalizeHandle(value)}`\n}\n\nfunction getConfiguredSocialLinks(social: AuthorSocial): AuthorSocialLink[] {\n return [\n { label: 'Website', href: social.website ?? '' },\n {\n label: 'Facebook',\n href: social.facebook ? normalizeUrl(social.facebook, 'https://www.facebook.com/') : '',\n },\n {\n label: 'Twitter',\n href: social.twitter ? normalizeUrl(social.twitter, 'https://twitter.com/') : '',\n },\n { label: 'X', href: social.x ? normalizeUrl(social.x, 'https://x.com/') : '' },\n {\n label: 'LinkedIn',\n href: social.linkedin ? normalizeUrl(social.linkedin, 'https://www.linkedin.com/in/') : '',\n },\n {\n label: 'Instagram',\n href: social.instagram ? normalizeUrl(social.instagram, 'https://www.instagram.com/') : '',\n },\n {\n label: 'YouTube',\n href: social.youtube ? normalizeUrl(social.youtube, 'https://www.youtube.com/') : '',\n },\n {\n label: 'TikTok',\n href: social.tiktok ? normalizeUrl(social.tiktok, 'https://www.tiktok.com/@') : '',\n },\n {\n label: 'GitHub',\n href: social.github ? normalizeUrl(social.github, 'https://github.com/') : '',\n },\n {\n label: 'Bluesky',\n href: social.bluesky ? normalizeUrl(social.bluesky, 'https://bsky.app/profile/') : '',\n },\n {\n label: 'Threads',\n href: social.threads ? normalizeUrl(social.threads, 'https://www.threads.net/@') : '',\n },\n { label: 'Mastodon', href: social.mastodon ? normalizeUrl(social.mastodon) : '' },\n {\n label: 'Medium',\n href: social.medium ? normalizeUrl(social.medium, 'https://medium.com/@') : '',\n },\n { label: 'Newsletter', href: social.newsletter ?? '' },\n ]\n}\n\nexport function getAuthorSocialLinks(author: AuthorProfile): AuthorSocialLink[] {\n const social = author.social\n if (!social) return []\n const configuredLinks = getConfiguredSocialLinks(social)\n const otherLinks = Object.entries(social.other ?? {}).map(([label, href]) => ({ label, href }))\n return [...configuredLinks, ...otherLinks].filter((link) => link.href.trim().length > 0)\n}\n","import type { Element, Root, ElementContent } from 'hast'\nimport rehypePrism from 'rehype-prism-plus'\nimport rehypeSanitize from 'rehype-sanitize'\nimport rehypeSlug from 'rehype-slug'\nimport rehypeStringify from 'rehype-stringify'\nimport { remark } from 'remark'\nimport remarkGfm from 'remark-gfm'\nimport remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'\nimport remarkParse from 'remark-parse'\nimport remarkRehype from 'remark-rehype'\nimport { Plugin } from 'unified'\nimport { visit } from 'unist-util-visit'\nimport type { FaqItem, TocItem } from './articleTypes'\nimport type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\nimport { isExternalHttpLink, isNonBrowserNavigationLink } from './linkClassification'\n\nexport { isExternalHttpLink, isNonBrowserNavigationLink }\n\ntype LinkTargetOptions = Readonly<{\n strategy?: LinkTargetStrategy\n siteUrl?: string\n}>\n\nconst DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'\n\nfunction shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {\n if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false\n\n const strategy = options.strategy ?? DEFAULT_LINK_TARGET_STRATEGY\n if (strategy === 'same-tab') return false\n if (strategy === 'all-new-tab') return true\n return isExternalHttpLink(href, options.siteUrl)\n}\n\nfunction applyLinkTarget(props: Record<string, unknown>, options?: LinkTargetOptions): void {\n const href = typeof props.href === 'string' ? props.href : ''\n if (shouldOpenInNewTab(href, options)) {\n props.target = '_blank'\n props.rel = 'noopener noreferrer'\n return\n }\n delete props.target\n delete props.rel\n}\n\nfunction getLinkTargetOptions(config?: ArticlesConfig): LinkTargetOptions {\n return {\n strategy: config?.linkTargetStrategy,\n siteUrl: config?.siteUrl,\n }\n}\n\n// Import the sanitizeImagePath function\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') {\n return null\n }\n\n // Remove any null bytes or control characters\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n\n // Check for absolute URLs (http/https)\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {\n // For external URLs, just return as-is (they're safe)\n return cleanPath\n }\n\n // For relative paths, ensure they don't contain dangerous patterns\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe markdown image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Construct safe path within articles directory\n if (cleanPath.includes('/')) {\n // Relative path, ensure it's within the article directory\n const normalizedPath = cleanPath.replaceAll('\\\\', '/')\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${normalizedPath}`\n } else {\n // Just a filename, construct full path\n return `/articles/${articleSlug}/${cleanPath}`\n }\n}\n\nfunction styleFootnoteLinks(nodes: ElementContent[]): void {\n nodes.forEach((n) => {\n if (n.type !== 'element') return\n const el = n as Element\n if (el.tagName === 'a') {\n const isBackRef =\n el.properties?.['dataFootnoteBackref'] !== undefined ||\n (el.children[0]?.type === 'text' && el.children[0].value === '↩')\n if (isBackRef) {\n el.properties.className = 'text-primary hover:underline ml-1'\n if (typeof el.properties.href === 'string') {\n el.properties.href = el.properties.href.replaceAll('#user-content-fnref-', '#ref-')\n }\n } else {\n el.properties.className = 'text-primary hover:underline break-all'\n }\n }\n if (el.children) styleFootnoteLinks(el.children)\n })\n}\n\nfunction processFootnoteRef(node: Element): void {\n if (\n node.tagName !== 'sup' ||\n node.children?.[0]?.type !== 'element' ||\n (node.children[0] as Element).tagName !== 'a'\n )\n return\n\n const link = node.children[0] as Element\n const href = link.properties?.href\n if (typeof href !== 'string' || !href.startsWith('#user-content-fn-')) return\n\n delete link.properties.target\n delete link.properties.rel\n link.properties.className = 'text-primary hover:underline'\n link.properties.href = href.replaceAll('#user-content-fn-', '#footnote-')\n\n if (typeof node.properties?.id === 'string') {\n link.properties.id = node.properties.id.replaceAll('user-content-fnref-', 'ref-')\n delete node.properties.id\n }\n\n if (link.children?.[0]?.type === 'text') {\n link.children[0].value = `[${link.children[0].value}]`\n }\n}\n\nfunction processFootnotesSection(node: Element): void {\n const cls = node.properties?.className\n const isFootnotes =\n node.tagName === 'section' &&\n (Array.isArray(cls) ? cls.includes('footnotes') : cls === 'footnotes')\n if (!isFootnotes) return\n\n const olCandidate = node.children.find(\n (child) => child.type === 'element' && (child as Element).tagName === 'ol'\n )\n if (olCandidate?.type !== 'element') return\n\n const ol = olCandidate as Element\n ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'\n\n ol.children.forEach((li) => {\n if (li.type !== 'element' || li.tagName !== 'li') return\n const liEl = li as Element\n\n if (liEl.properties) {\n liEl.properties.className = 'pl-2'\n if (typeof liEl.properties.id === 'string') {\n liEl.properties.id = liEl.properties.id.replaceAll('user-content-fn-', 'footnote-')\n }\n }\n\n const pIndex = liEl.children.findIndex(\n (child) => child.type === 'element' && (child as Element).tagName === 'p'\n )\n if (pIndex !== -1) {\n const p = liEl.children[pIndex] as Element\n liEl.children.splice(pIndex, 1, ...p.children)\n }\n\n styleFootnoteLinks(liEl.children)\n })\n\n const hr: Element = {\n type: 'element',\n tagName: 'hr',\n properties: { className: 'my-8 border-border' },\n children: [],\n }\n const h3: Element = {\n type: 'element',\n tagName: 'h3',\n properties: { className: 'text-lg font-semibold mb-4' },\n children: [{ type: 'text', value: 'References' }],\n }\n\n node.children = [hr, h3, ol]\n if (node.properties) node.properties.className = undefined\n}\n\nexport const customRenderer: Plugin<[LinkTargetOptions?], Root> = (linkTargetOptions = {}) => {\n return (tree: Root) => {\n // First pass: Apply general styles\n visit(tree, 'element', (node: Element) => {\n if (node.tagName) {\n const props = node.properties || {}\n\n switch (node.tagName) {\n case 'h1':\n props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'\n break\n case 'h2':\n props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'\n break\n case 'h3':\n props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'\n break\n case 'h4':\n props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'\n break\n case 'p':\n props.className = 'text-muted-foreground leading-relaxed mb-4'\n break\n case 'a': {\n props.className = 'text-primary hover:underline transition-colors duration-200'\n applyLinkTarget(props, linkTargetOptions)\n break\n }\n case 'ul':\n props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'\n break\n case 'ol':\n props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'\n break\n case 'li':\n props.className = 'mb-1'\n break\n case 'blockquote':\n props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'\n break\n case 'code':\n props.className =\n (props.className ? props.className + ' ' : '') +\n 'bg-muted px-1 py-0.5 rounded text-sm font-mono'\n break\n case 'pre':\n props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'\n break\n case 'img':\n props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'\n break\n case 'table':\n props.className = 'border-collapse border border-border my-4 w-full'\n break\n case 'th':\n props.className = 'border border-border px-2 py-1 bg-muted font-semibold'\n break\n case 'td':\n props.className = 'border border-border px-2 py-1'\n break\n case 'hr':\n props.className = 'my-8 border-border'\n break\n default:\n break\n }\n\n node.properties = props\n }\n })\n\n // Second pass: Fix footnotes and references\n visit(tree, 'element', (node: Element) => {\n processFootnoteRef(node)\n processFootnotesSection(node)\n })\n }\n}\n\n// Custom rehype plugin to process image URLs\nconst rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {\n return (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n if (node.tagName === 'img' && node.properties) {\n const src = node.properties.src\n if (src && typeof src === 'string' && options.articleSlug) {\n // Sanitize the image path\n const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)\n if (sanitizedSrc) {\n node.properties.src = sanitizedSrc\n } else {\n // If sanitization fails, use a placeholder\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Using placeholder for unsafe markdown image path.',\n context: { articleSlug: options.articleSlug, path: src },\n })\n node.properties.src = '/placeholder-logo.png'\n }\n }\n }\n })\n }\n}\n\nexport async function markdownToHtml(\n markdown: string,\n articleSlug?: string,\n config?: ArticlesConfig\n) {\n try {\n // Start building the remark processor\n let processor = remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkGithubBlockquoteAlert)\n .use(remarkRehype)\n .use(customRenderer, getLinkTargetOptions(config))\n .use(rehypeSlug)\n // @ts-ignore\n .use(rehypePrism)\n .use(rehypeSanitize, {\n attributes: {\n '*': ['className', 'class', 'id'],\n a: ['href', 'target', 'rel', 'id'],\n img: ['src', 'alt'],\n },\n })\n\n // Add image processing plugin if articleSlug is provided\n if (articleSlug) {\n processor = processor.use(rehypeProcessImages, { articleSlug })\n }\n\n const result = await processor\n .use(rehypeStringify)\n .process(stripInlineTagsFromHeadings(markdown))\n\n return result.toString()\n } catch (error) {\n reportArticlesError({\n code: 'markdown-conversion-failed',\n message: 'Unable to convert markdown to HTML.',\n error,\n context: { articleSlug },\n })\n // Return the original markdown as fallback\n return markdown\n }\n}\n\n/**\n * Strips inline HTML/JSX tags from heading lines only, keeping their inner\n * text, before the markdown reaches a plain (non-MDX) remark parse.\n *\n * Article headings commonly carry a reader-facing rating dot written as JSX,\n * e.g. `### Rage <span style={{ color: '#3b82f6' }}>●</span>`. `renderMdxSource`\n * (real MDX compilation via `@mdx-js/mdx`) parses that correctly and renders\n * a real `<span>` element. But `extractToc` and `markdownToHtml` both run\n * headings through plain `remark-parse` with no MDX support, and CommonMark's\n * raw-inline-HTML grammar does not accept a JSX object-literal attribute\n * expression like `style={{ ... }}` - remark's HTML tokenizer fails to match\n * it as a tag and falls back to treating the whole thing as literal text.\n * That garbled text then (a) becomes the visible TOC label, and (b) feeds\n * `rehype-slug`, producing a slug built from the raw markup instead of the\n * heading's real words - which does not match the id `renderMdxSource`'s\n * correctly-parsed pipeline assigns to the same heading in the live page, so\n * the TOC entry silently links to an id that does not exist in the DOM.\n *\n * Stripping tags (not their inner content) from heading lines before parsing\n * keeps the extracted text and generated slug consistent with what the real\n * MDX render puts in the page, for any heading-level inline markup - not\n * only the rating-dot convention that surfaced the bug.\n */\nfunction stripInlineTagsFromHeadings(markdown: string): string {\n return markdown.replace(/^(#{1,6}[ \\t].*)$/gm, (line) =>\n line.replace(/<\\/?[a-zA-Z][^<>\\n]*>/g, '')\n )\n}\n\n/**\n * Recursively extracts a node's text content, including text nested inside\n * child elements (for example a markdown link's `<a>Dwarf</a>` inside a\n * heading like `### [Dwarf](/link)`). A shallow, direct-children-only check\n * here previously dropped link text from every heading that used a link as\n * part of its heading text - a distinct bug from the JSX-in-heading garbling\n * `stripInlineTagsFromHeadings` fixes, but with a worse symptom: the heading\n * was silently omitted from the TOC entirely (both `id` and `text` came back\n * empty, so `extractHeadingItem` returned `null`) rather than merely garbled.\n */\nfunction nodeTextValue(c: ElementContent): string {\n if (c.type === 'text') return (c as { value: string }).value\n if (c.type === 'element' && 'children' in c) {\n return (c as Element).children.map(nodeTextValue).join('')\n }\n return ''\n}\n\nfunction extractHeadingItem(node: Element): TocItem | null {\n const match = /^h([1-6])$/.exec(node.tagName)\n if (!match) return null\n const id = typeof node.properties?.id === 'string' ? node.properties.id : ''\n const text = node.children.map(nodeTextValue).join('')\n if (!id || !text) return null\n return { id, depth: Number.parseInt(match[1], 10), text }\n}\n\nexport interface ContentSlotBoundaries {\n /** Character offset (into the raw markdown source) right after the first paragraph - the \"intro\" boundary. */\n introEnd: number\n /** Character offset right after the middle paragraph - the \"mid content\" boundary. */\n mid: number\n /** Total top-level paragraph count found. */\n paragraphCount: number\n}\n\n/**\n * Resolves deterministic `afterIntro`/`midContent` split points from the\n * raw markdown/MDX source's parsed AST (mdast paragraph node offsets) -\n * never from string-splitting rendered HTML, which is fragile by\n * construction (see Phase 27F plan notes). Returns `null` when the source\n * has no top-level paragraphs, or fails to parse (e.g. MDX with JSX syntax\n * remark-parse doesn't understand) - callers should treat `null` as \"only\n * `afterHero`/`afterContent` are available for this article\", not throw.\n */\ninterface MdastNode {\n type: string\n position?: { start: { offset?: number }; end: { offset?: number } }\n children?: MdastNode[]\n}\n\nexport function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null {\n try {\n const tree = remark().use(remarkParse).use(remarkGfm).parse(markdown) as unknown as MdastNode\n const paragraphs = (tree.children ?? []).filter(\n (node): node is MdastNode & { position: NonNullable<MdastNode['position']> } =>\n node.type === 'paragraph' && Boolean(node.position)\n )\n if (paragraphs.length === 0) return null\n const introEnd = paragraphs[0].position.end.offset ?? 0\n const midIndex = Math.floor(paragraphs.length / 2)\n const mid = paragraphs[midIndex].position.end.offset ?? introEnd\n return { introEnd, mid: Math.max(mid, introEnd), paragraphCount: paragraphs.length }\n } catch {\n return null\n }\n}\n\nexport async function extractToc(markdown: string): Promise<TocItem[]> {\n const headings: TocItem[] = []\n const collectHeadings: Plugin<[], Root> = () => (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n const item = extractHeadingItem(node)\n if (item) headings.push(item)\n })\n }\n await remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkRehype)\n .use(rehypeSlug)\n .use(collectHeadings)\n .use(rehypeStringify)\n .process(stripInlineTagsFromHeadings(markdown))\n return headings\n}\n\n// Interrogatives that open a genuine reader question. Deliberately a closed\n// list rather than \"any heading ending in ?\" - a rhetorical heading like\n// \"Sound familiar?\" ends in a question mark too, and promoting it to a\n// published Q&A pair would be worse than omitting it.\nconst QUESTION_OPENERS =\n /^(what|how|why|when|where|who|which|can|should|does|do|is|are|will|would|must)\\b/i\n\n/**\n * Derives `FaqItem`s from question-shaped `##` headings and the prose that\n * follows each one, for `ArticlesConfig.deriveFaqFromHeadings`.\n *\n * Line-based on purpose: it runs for every article at summary time, and the\n * shapes it must reject (fenced code, a heading with no prose under it) are\n * cheaper to detect by scanning than by walking a parsed AST.\n */\nexport function deriveFaqFromHeadings(markdown: string): FaqItem[] {\n const items: FaqItem[] = []\n const collector = createFaqCollector(items)\n let inFence = false\n\n for (const line of markdown.split('\\n')) {\n if (FENCE.test(line)) {\n inFence = !inFence\n continue\n }\n if (inFence) continue\n collector.consume(line)\n }\n collector.flush()\n\n return items\n}\n\nconst FENCE = /^\\s*(```|~~~)/\nconst ANY_HEADING = /^#{1,6}\\s/\n\nfunction isSpace(char: string | undefined): boolean {\n return char === ' ' || char === '\\t'\n}\n\n/** Drops closing-hash decoration (`## Heading ##`) without a `#+$` scan. */\nfunction trimTrailingHashes(value: string): string {\n let end = value.length\n while (end > 0 && value[end - 1] === '#') end--\n return value.slice(0, end).trimEnd()\n}\n\n/**\n * Extracts a question-shaped h2's text, or `null` for any other line.\n *\n * Deliberately string operations rather than a capture regex: every anchored\n * form of this (`(.*\\S)\\s*$`, `\\s+(.*)$`, `#+$`) backtracks super-linearly,\n * and this runs over every line of every article.\n */\nfunction readQuestionHeading(line: string): string | null {\n if (!line.startsWith('##') || line.startsWith('###')) return null\n if (!isSpace(line[2])) return null\n const text = trimTrailingHashes(line.slice(3).trim())\n if (!text.endsWith('?') || !QUESTION_OPENERS.test(text)) return null\n return text\n}\n\n/**\n * Line-at-a-time state machine pairing a question heading with the prose\n * under it. Split out of `deriveFaqFromHeadings` so the loop there stays a\n * flat fence check plus a delegation.\n */\nfunction createFaqCollector(items: FaqItem[]) {\n let pending: string | null = null\n let buffer: string[] = []\n\n const flush = (): void => {\n if (pending && buffer.length > 0) {\n items.push({ question: pending, answer: buffer.join(' ').trim() })\n }\n pending = null\n buffer = []\n }\n\n const consume = (line: string): void => {\n if (ANY_HEADING.test(line)) {\n flush()\n pending = readQuestionHeading(line)\n return\n }\n if (!pending) return\n // A blank line only ends an answer that has already started - the blank\n // between a heading and its first paragraph must not discard the pending\n // question.\n if (line.trim() === '') {\n if (buffer.length > 0) flush()\n return\n }\n buffer.push(line.trim())\n }\n\n return { consume, flush }\n}\n","export type ArticlesErrorCode =\n | 'article-directory-read-failed'\n | 'article-load-failed'\n | 'article-markdown-load-failed'\n | 'ai-crawl-handler-failed'\n | 'markdown-conversion-failed'\n | 'unsafe-image-path'\n\nexport type ArticlesErrorContext = Readonly<Record<string, string | number | boolean | undefined>>\n\nexport type ArticlesErrorReport = Readonly<{\n code: ArticlesErrorCode\n message: string\n error?: unknown\n context?: ArticlesErrorContext\n}>\n\nexport type ArticlesErrorHandler = (report: ArticlesErrorReport) => void\n\nfunction defaultArticlesErrorHandler(report: ArticlesErrorReport): void {\n if (process.env.NODE_ENV === 'production') return\n const context = report.context ? ` ${JSON.stringify(report.context)}` : ''\n const detail = report.error instanceof Error ? `: ${report.error.message}` : ''\n console.warn(`[articles:${report.code}] ${report.message}${context}${detail}`)\n}\n\nlet articlesErrorHandler: ArticlesErrorHandler = defaultArticlesErrorHandler\n\nexport function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void {\n articlesErrorHandler = handler ?? defaultArticlesErrorHandler\n}\n\nexport function reportArticlesError(report: ArticlesErrorReport): void {\n articlesErrorHandler(report)\n}\n","// Pure href classification helpers with no dependency on the rehype/hast\n// pipeline - kept separate from markdown.ts (which pulls in ESM-only\n// rehype/remark plugins Jest can't transform without extra config) so\n// consumers that only need \"is this link internal/external/navigable\"\n// (like renderMdx.tsx's next/link routing decision) don't have to import\n// that whole transitive dependency chain, in production or in tests.\n\nexport function isNonBrowserNavigationLink(href: string): boolean {\n return (\n /^[a-zA-Z][a-zA-Z\\d+.-]*:/.test(href) &&\n !href.startsWith('http://') &&\n !href.startsWith('https://')\n )\n}\n\nfunction getOrigin(url: string | undefined): string | null {\n if (!url) return null\n try {\n return new URL(url).origin\n } catch {\n return null\n }\n}\n\nexport function isExternalHttpLink(href: string, siteUrl?: string): boolean {\n if (!href.startsWith('http://') && !href.startsWith('https://')) return false\n const siteOrigin = getOrigin(siteUrl)\n if (!siteOrigin) return true\n return getOrigin(href) !== siteOrigin\n}\n"],"mappings":"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","names":["path","remarkParse","remarkGfm","remarkGithubBlockquoteAlert","remarkRehype","rehypeSlug","rehypePrism","rehypeSanitize","rehypeStringify","remarkParse","remarkGfm","remarkRehype","rehypeSlug","rehypeStringify","path","readingTime","fs","sanitizeImagePath","_a","fs","matter","fs","matter"]}
|
|
1
|
+
{"version":3,"sources":["../src/nextjs.ts","../src/server-articles.ts","../src/authorUtils.ts","../src/markdown.ts","../src/errorReporting.ts","../src/linkClassification.ts"],"sourcesContent":["// Next.js API route handler factory — Node.js runtime only\n// Import from '@fullstackdatasolutions/articles/nextjs'\nimport type { NextRequest } from 'next/server'\nimport { getMarkdownTwinResponse } from './server-articles'\nimport type { ArticlesConfig } from './articlesConfig'\n\n/**\n * Returns a Next.js App Router GET handler for serving markdown twins -\n * articles plus category/author/series listings, dispatched by slug prefix.\n * The request is passed through so `ArticlesConfig.onAiCrawl` can see the\n * user agent.\n *\n * Usage in app/api/articles-markdown/[...slug]/route.ts:\n * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'\n * import { siteConfig } from '@/config/articles'\n * export const { GET } = createArticleMarkdownHandler(siteConfig)\n */\nexport function createArticleMarkdownHandler(config: ArticlesConfig) {\n async function GET(\n request: NextRequest,\n context: { params: Promise<Record<string, string | string[]>> }\n ) {\n const params = await context.params\n const slugParts = params['slug']\n const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')\n const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug\n return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers })\n }\n return { GET }\n}\n","import { cache } from 'react'\nimport matter from 'gray-matter'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\nimport { getAuthorAvatar } from './authorUtils'\nimport { markdownToHtml, extractToc, deriveFaqFromHeadings } from './markdown'\nimport type {\n Article,\n AuthorProfile,\n CategoryInfo,\n CitationReference,\n EntityReference,\n FaqItem,\n HowToStep,\n PathDefinition,\n} from './articleTypes'\nimport type { ArticlesConfig } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\nconst articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')\n\nfunction getReadingStats(content: string): { readTime: string; wordCount: number } {\n const stats = readingTime(content)\n return { readTime: stats.text, wordCount: stats.words }\n}\n\nfunction findArticleImage(slug: string): string | null {\n try {\n const articleDir = path.join(articlesDirectory, slug)\n const entries: (fs.Dirent | string)[] = fs.readdirSync(articleDir, { withFileTypes: true })\n const names = entries\n .map((entry) => {\n if (typeof entry === 'string') return entry\n if (entry && typeof entry.name === 'string') return entry.name\n return null\n })\n .filter((name): name is string => Boolean(name))\n const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp']\n const imageFileName = names.find((name) =>\n imageExtensions.some((ext) => name.toLowerCase().endsWith(ext))\n )\n return imageFileName ? sanitizeImagePath(imageFileName, slug) : null\n } catch {\n return null\n }\n}\n\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') return null\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) return cleanPath\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe article image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n if (cleanPath.includes('/')) {\n const normalizedPath = path.normalize(cleanPath)\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected article image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${cleanPath}`\n }\n return `/articles/${articleSlug}/${cleanPath}`\n}\n\nfunction findArticleFile(slug: string): { filePath: string; contentType: 'md' | 'mdx' } | null {\n const mdPath = path.join(articlesDirectory, slug, 'article.md')\n const mdxPath = path.join(articlesDirectory, slug, 'article.mdx')\n if (fs.existsSync(mdPath)) return { filePath: mdPath, contentType: 'md' }\n if (fs.existsSync(mdxPath)) return { filePath: mdxPath, contentType: 'mdx' }\n return null\n}\n\nexport function getAvailableArticleSlugs(): string[] {\n try {\n if (!fs.existsSync(articlesDirectory)) return []\n return walkArticleDir(articlesDirectory, '')\n } catch (error) {\n reportArticlesError({\n code: 'article-directory-read-failed',\n message: 'Unable to read articles directory.',\n error,\n context: { directory: articlesDirectory },\n })\n return []\n }\n}\n\nfunction walkArticleDir(dir: string, baseSlug: string): string[] {\n const slugs: string[] = []\n try {\n const items = fs.readdirSync(dir, { withFileTypes: true })\n for (const item of items) {\n if (!item.isDirectory()) continue\n const slug = baseSlug ? `${baseSlug}/${item.name}` : item.name\n if (findArticleFile(slug) !== null) {\n slugs.push(slug)\n }\n slugs.push(...walkArticleDir(path.join(dir, item.name), slug))\n }\n } catch {\n // ignore unreadable directories\n }\n return slugs\n}\n\nfunction parseDateField(rawDate: unknown): string | undefined {\n if (!rawDate) return undefined\n try {\n const parsed = new Date(rawDate as string)\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().split('T')[0]\n } catch {\n // ignore invalid dates\n }\n return undefined\n}\n\nfunction resolveFeaturedImage(rawImage: unknown, slug: string): string {\n const img = typeof rawImage === 'string' ? rawImage : ''\n if (img && !img.startsWith('http')) return sanitizeImagePath(img, slug) || '/placeholder-logo.png'\n if (!img) return findArticleImage(slug) || '/placeholder-logo.png'\n return img\n}\n\nfunction parseFaqItems(raw: unknown): FaqItem[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw.filter(\n (item): item is FaqItem =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as FaqItem).question === 'string' &&\n typeof (item as FaqItem).answer === 'string'\n )\n return items.length ? items : undefined\n}\n\nfunction parseHowToSteps(raw: unknown): HowToStep[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const steps = raw.filter(\n (item): item is HowToStep =>\n typeof item === 'object' &&\n item !== null &&\n typeof (item as HowToStep).name === 'string' &&\n typeof (item as HowToStep).text === 'string'\n )\n return steps.length ? steps : undefined\n}\n\n// Trims and drops empty strings - the same \"unset if blank\" rule applied to\n// every other optional string frontmatter field in this file\n// (canonicalUrl/articleType/series). Used for all five discovery overrides\n// (Phase 27F) so a stray `searchTitle: \"\"` in frontmatter behaves exactly\n// like omitting the key, rather than becoming an empty <title> override.\nfunction parseOptionalString(raw: unknown): string | undefined {\n return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined\n}\n\n// `aiCrawl` is the one frontmatter flag with a config-level default\n// (`ArticlesConfig.aiCrawlDefault`). Explicit `true`/`false` in frontmatter\n// always wins; only an omitted/non-boolean key falls through to the config.\n// Omitting both reproduces the original \"blocked unless opted in\" behavior.\n// Explicit `faq` frontmatter always wins - derivation only fills the gap\n// for articles that never declared one, and only when the site opted in.\nfunction deriveFaq(markdownContent: string, config?: ArticlesConfig): FaqItem[] | undefined {\n if (config?.deriveFaqFromHeadings !== true) return undefined\n const derived = deriveFaqFromHeadings(markdownContent)\n return derived.length ? derived : undefined\n}\n\n// Frontmatter `lastmod` always wins. Only `'fileMtime'` produces a value\n// here; `'published'` (the default) and `'none'` differ in what\n// `ArticleSEO` does with an absent `lastmod` - reuse `datePublished`, or\n// omit `dateModified` rather than repeat a stale date.\nfunction resolveLastmod(\n rawLastmod: unknown,\n rawDate: unknown,\n filePath: string,\n config?: ArticlesConfig\n): string | undefined {\n const declared = parseDateField(rawLastmod)\n if (declared) return declared\n if (config?.lastmodFallback !== 'fileMtime') return undefined\n try {\n return parseDateField(fs.statSync(filePath).mtime)\n } catch {\n return parseDateField(rawDate)\n }\n}\n\nfunction resolveAiCrawl(raw: unknown, config?: ArticlesConfig): boolean {\n if (typeof raw === 'boolean') return raw\n return config?.aiCrawlDefault === true\n}\n\n// A bare string resolves through `config.entities` first, so a corpus can\n// share one canonical name/`sameAs` pair per topic instead of each article\n// spelling it out (and spelling it differently). Unregistered strings still\n// work as plain names - `validateArticles` warns about them rather than\n// dropping the entry.\nfunction parseEntityReferences(\n raw: unknown,\n config?: ArticlesConfig\n): EntityReference[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw\n .map((item) => {\n if (typeof item === 'string') {\n const key = item.trim()\n if (!key) return null\n return config?.entities?.[key] ?? { name: key }\n }\n if (\n typeof item === 'object' &&\n item !== null &&\n typeof (item as EntityReference).name === 'string'\n ) {\n const entity = item as EntityReference\n const name = entity.name.trim()\n if (!name) return null\n return entity.sameAs ? { name, sameAs: entity.sameAs } : { name }\n }\n return null\n })\n .filter((item): item is EntityReference => item !== null)\n return items.length ? items : undefined\n}\n\nfunction parseCitations(raw: unknown): CitationReference[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const items = raw\n .map((item) => {\n if (typeof item === 'string') return item.trim() ? { name: item.trim() } : null\n if (\n typeof item === 'object' &&\n item !== null &&\n typeof (item as CitationReference).name === 'string'\n ) {\n const citation = item as CitationReference\n const name = citation.name.trim()\n if (!name) return null\n return citation.url ? { name, url: citation.url } : { name }\n }\n return null\n })\n .filter((item): item is CitationReference => item !== null)\n return items.length ? items : undefined\n}\n\nfunction parseSeriesOrder(raw: unknown): number | undefined {\n return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined\n}\n\nfunction parsePrimaryAction(raw: unknown): { actionId: string } | undefined {\n if (typeof raw === 'string') {\n const actionId = raw.trim()\n return actionId ? { actionId } : undefined\n }\n if (\n typeof raw === 'object' &&\n raw !== null &&\n typeof (raw as { actionId?: unknown }).actionId === 'string'\n ) {\n const actionId = (raw as { actionId: string }).actionId.trim()\n return actionId ? { actionId } : undefined\n }\n return undefined\n}\n\nfunction parseAuthors(raw: unknown): string[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const authors = raw.filter(\n (author): author is string => typeof author === 'string' && author.trim().length > 0\n )\n return authors\n}\n\nexport function getAuthorBySlug(slug: string, config: ArticlesConfig): AuthorProfile | null {\n const profile = config.authors?.[slug]\n if (!profile) return null\n return {\n ...profile,\n url: profile.url ?? `${config.siteUrl.replace(/\\/$/, '')}/articles/authors/${profile.slug}`,\n }\n}\n\nfunction getConfiguredAuthorByName(name: string, config: ArticlesConfig): AuthorProfile | null {\n const normalizedName = name.trim().toLowerCase()\n const profile = Object.values(config.authors ?? {}).find(\n (author) => author.name.toLowerCase() === normalizedName\n )\n return profile ? getAuthorBySlug(profile.slug, config) : null\n}\n\nfunction resolveArticleAuthorName(\n rawAuthor: unknown,\n rawAuthors: unknown,\n config?: ArticlesConfig\n): string {\n const authorArray = parseAuthors(rawAuthors)\n const firstAuthor = authorArray?.[0]\n const rawAuthorValue = typeof rawAuthor === 'string' && rawAuthor.trim() ? rawAuthor : undefined\n const author = firstAuthor ?? rawAuthorValue\n const resolved = author ?? config?.defaultAuthor\n if (!resolved) return ''\n if (!config) return resolved\n return (\n getAuthorBySlug(resolved, config)?.name ??\n getConfiguredAuthorByName(resolved, config)?.name ??\n resolved\n )\n}\n\nexport function getArticleAuthors(article: Article, config: ArticlesConfig): AuthorProfile[] {\n const fallbackAuthors = Array.from(\n new Set(\n [article.author, config.defaultAuthor].filter(\n (author): author is string => typeof author === 'string' && author.trim().length > 0\n )\n )\n )\n const authorValues = article.authors ?? fallbackAuthors\n if (authorValues.length === 0) return []\n const resolvedAuthors = authorValues\n .map((author) => getAuthorBySlug(author, config) ?? getConfiguredAuthorByName(author, config))\n .filter((author): author is AuthorProfile => author !== null)\n .filter((author, index, all) => all.findIndex((a) => a.slug === author.slug) === index)\n\n if (resolvedAuthors.length > 0) return resolvedAuthors\n\n return authorValues.map((fallbackName) => ({\n name: fallbackName,\n slug: categoryToSlug(fallbackName),\n bio: '',\n }))\n}\n\nexport function getAllAuthors(config: ArticlesConfig): AuthorProfile[] {\n return Object.keys(config.authors ?? {})\n .map((slug) => getAuthorBySlug(slug, config))\n .filter((author): author is AuthorProfile => author !== null)\n}\n\nasync function getArticleSummary(slug: string, config?: ArticlesConfig): Promise<Article | null> {\n try {\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { data, content: markdownContent } = matter(fileContent)\n const { readTime, wordCount } = getReadingStats(markdownContent)\n const allTags: string[] = Array.isArray(data.tags)\n ? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())\n : []\n const categories: string[] =\n allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']\n const author = resolveArticleAuthorName(data.author, data.authors, config)\n const authors = parseAuthors(data.authors)\n // Resolve the primary author's profile (if configured) so cards can\n // render an avatar/link without needing `config` client-side. Reuses\n // getArticleAuthors' existing slug/name resolution chain instead of\n // duplicating it - only the two fields it reads (author/authors) exist\n // on this partial yet.\n const primaryAuthorProfile = config\n ? getArticleAuthors({ author, authors } as Article, config)[0]\n : undefined\n return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: parseDateField(data.date),\n lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),\n author,\n authors,\n authorSlug: primaryAuthorProfile?.slug,\n authorAvatar: primaryAuthorProfile ? getAuthorAvatar(primaryAuthorProfile) : undefined,\n category: categories[0],\n categories,\n readTime,\n wordCount,\n featuredImage: resolveFeaturedImage(data.featuredImage, slug),\n tags: data.tags || [],\n contentType: found.contentType,\n draft: data.draft === true,\n faq: parseFaqItems(data.faq) ?? deriveFaq(markdownContent, config),\n howTo: parseHowToSteps(data.howTo),\n answer: parseOptionalString(data.answer),\n about: parseEntityReferences(data.about, config),\n citation: parseCitations(data.citation),\n canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,\n articleType: typeof data.articleType === 'string' ? data.articleType : undefined,\n series: typeof data.series === 'string' ? data.series : undefined,\n seriesSlug: parseOptionalString(data.seriesSlug),\n seriesOrder: parseSeriesOrder(data.seriesOrder),\n aiCrawl: resolveAiCrawl(data.aiCrawl, config),\n searchTitle: parseOptionalString(data.searchTitle),\n searchDescription: parseOptionalString(data.searchDescription),\n socialTitle: parseOptionalString(data.socialTitle),\n socialDescription: parseOptionalString(data.socialDescription),\n socialImage: parseOptionalString(data.socialImage),\n primaryAction: parsePrimaryAction(data.primaryAction),\n }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article summary.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\nexport const getArticleMetadata = cache(\n async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {\n try {\n const summary = await getArticleSummary(slug, config)\n if (!summary) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n const toc = await extractToc(markdownContent)\n let htmlContent: string | undefined\n let mdxSource: string | undefined\n if (found.contentType === 'mdx') {\n mdxSource = markdownContent\n } else {\n htmlContent = await markdownToHtml(markdownContent, slug, config)\n }\n return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }\n } catch (error) {\n reportArticlesError({\n code: 'article-load-failed',\n message: 'Unable to load article metadata.',\n error,\n context: { slug },\n })\n return null\n }\n }\n)\n\nexport const getAllArticles = cache(async (config?: ArticlesConfig): Promise<Article[]> => {\n const slugs = getAvailableArticleSlugs()\n const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug, config)))\n const currentDate = new Date().toISOString().split('T')[0]\n return articles\n .filter((article): article is Article => article !== null)\n .filter((article) => !article.date || article.date <= currentDate)\n .filter((article) => !(article.draft && process.env.NODE_ENV === 'production'))\n .sort((a, b) => {\n if (!a.date && !b.date) return 0\n if (!a.date) return 1\n if (!b.date) return -1\n return new Date(b.date).getTime() - new Date(a.date).getTime()\n })\n})\n\nexport async function getAdjacentArticles(\n currentSlug: string\n): Promise<{ previous: Article | null; next: Article | null }> {\n const allArticles = await getAllArticles()\n const currentIndex = allArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n const previous = currentIndex < allArticles.length - 1 ? allArticles[currentIndex + 1] : null\n const next = currentIndex > 0 ? allArticles[currentIndex - 1] : null\n return { previous, next }\n}\n\nexport async function getArticleMarkdown(\n slug: string,\n config?: ArticlesConfig\n): Promise<string | null> {\n try {\n const summary = await getArticleSummary(slug, config)\n if (!summary?.aiCrawl) return null\n const found = findArticleFile(slug)\n if (!found) return null\n const fileContent = fs.readFileSync(found.filePath, 'utf8')\n const { content: markdownContent } = matter(fileContent)\n return markdownContent\n } catch (error) {\n reportArticlesError({\n code: 'article-markdown-load-failed',\n message: 'Unable to load article markdown.',\n error,\n context: { slug },\n })\n return null\n }\n}\n\n/**\n * Crawlers that read content on behalf of an answer engine. Used both to\n * write robots.txt rules and to classify markdown-twin fetches for\n * `ArticlesConfig.onAiCrawl` - one list, so the two can never disagree about\n * what counts as an AI crawler.\n */\nexport const AI_CRAWLERS = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'CCBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Google-Extended',\n 'Applebot-Extended',\n 'Bytespider',\n 'Amazonbot',\n 'meta-externalagent',\n 'cohere-ai',\n 'DuckAssistBot',\n 'MistralAI-User',\n] as const\n\n/** Matches a `User-Agent` against `AI_CRAWLERS`, returning the crawler name or `null`. */\nexport function matchAiCrawler(userAgent: string): string | null {\n if (!userAgent) return null\n const normalized = userAgent.toLowerCase()\n return AI_CRAWLERS.find((crawler) => normalized.includes(crawler.toLowerCase())) ?? null\n}\n\n/**\n * Attribution header prepended to an article's markdown twin.\n *\n * The twin is what an AI crawler actually reads, and `matter()` strips every\n * frontmatter field before it is served - so without this the model gets an\n * anonymous body with no title, date, author, or link back to the canonical\n * page. Skips its own `# {title}` line when the body already opens with the\n * same H1, so the common \"body repeats the title\" layout doesn't end up with\n * two.\n */\nexport function buildMarkdownTwinHeader(\n article: Article,\n config: ArticlesConfig,\n body: string\n): string {\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const firstLine = body.trimStart().split('\\n', 1)[0]?.trim() ?? ''\n const bodyRepeatsTitle = firstLine.toLowerCase() === `# ${article.title}`.toLowerCase()\n\n const facts = [\n `Source: ${siteUrl}/articles/${article.slug}`,\n article.date ? `Published: ${article.date}` : '',\n article.lastmod ? `Updated: ${article.lastmod}` : '',\n config.showAuthor !== false && article.author ? `Author: ${article.author}` : '',\n `Site: ${config.siteName}`,\n ].filter(Boolean)\n\n const blocks = [\n bodyRepeatsTitle ? '' : `# ${article.title}`,\n article.excerpt ? `> ${article.excerpt}` : '',\n facts.join('\\n'),\n article.answer ? `**Short answer:** ${article.answer}` : '',\n '---',\n ].filter((block) => block !== '')\n\n return `${blocks.join('\\n\\n')}\\n\\n`\n}\n\n/**\n * Reports a markdown-twin fetch to `config.onAiCrawl`. Never lets a consumer\n * callback break the response - a telemetry handler throwing must not turn a\n * served article into a 500.\n */\nfunction reportAiCrawl(slug: string, config: ArticlesConfig, headers?: RequestHeaders): void {\n if (!config.onAiCrawl) return\n const userAgent = headers?.get('user-agent') ?? ''\n try {\n config.onAiCrawl({ slug, crawler: matchAiCrawler(userAgent) ?? 'unknown', userAgent })\n } catch (error) {\n reportArticlesError({\n code: 'ai-crawl-handler-failed',\n message: 'onAiCrawl handler threw.',\n error,\n context: { slug },\n })\n }\n}\n\n/** Minimal shape of a request's headers - avoids depending on `next/server` here. */\nexport type RequestHeaders = Readonly<{ get(name: string): string | null }>\n\n/**\n * Markdown twin for a listing surface - a category, author, or series.\n *\n * Article twins alone leave a model with a bag of pages and no map: the\n * listing surfaces are what answer \"what does this site cover, and who\n * writes it\". Only articles resolved to `aiCrawl: true` are listed, so a\n * blocked article stays invisible here too.\n */\nfunction buildListingMarkdown(\n heading: string,\n intro: string[],\n articles: readonly Article[],\n config: ArticlesConfig\n): string {\n const siteUrl = config.siteUrl.replace(/\\/$/, '')\n const crawlable = articles.filter((article) => article.aiCrawl === true)\n const entries = crawlable.map((article) => {\n const summary = article.answer ?? article.excerpt\n const line = `- [${article.title}](${siteUrl}/articles/${article.slug}.md)`\n return summary ? `${line}: ${summary}` : line\n })\n\n return [\n `# ${heading}`,\n '',\n ...intro.flatMap((line) => [line, '']),\n `Source: ${siteUrl}`,\n `Site: ${config.siteName}`,\n '',\n '---',\n '',\n ...(entries.length > 0 ? entries : ['_No articles available._']),\n '',\n ].join('\\n')\n}\n\n/** Markdown twin for `/articles/category/[category]`. `null` when the category has no articles. */\nexport async function getCategoryMarkdown(\n categorySlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const articles = await getArticlesByCategory(categorySlug, config)\n if (articles.length === 0) return null\n const name =\n articles[0].categories.find((c) => categoryToSlug(c) === categorySlug) ?? categorySlug\n const description = resolveCategoryDescription(categorySlug, config)\n return buildListingMarkdown(name, description ? [description] : [], articles, config)\n}\n\nfunction resolveCategoryDescription(\n categorySlug: string,\n config: ArticlesConfig\n): string | undefined {\n const entry = config.categoryDescriptions?.[categorySlug]\n if (!entry) return undefined\n return typeof entry === 'string' ? entry : (entry.long ?? entry.short)\n}\n\n/**\n * Markdown twin for `/articles/authors/[author]`.\n *\n * Carries the author's bio, promise, principles, and sourced proof alongside\n * their article list - the \"who is this and why trust them\" context that\n * otherwise exists only inside React components, and the exact question asked\n * before anything they wrote gets cited. `credentials` are included as the\n * author's own stated claims; unlike JSON-LD, prose can attribute a claim\n * without asserting it as a verified fact.\n */\nexport async function getAuthorMarkdown(\n authorSlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const author = getAuthorBySlug(authorSlug, config)\n if (!author) return null\n const articles = await getArticlesByAuthor(authorSlug, config)\n\n const intro = [\n author.promise ?? '',\n author.bio ?? '',\n ...(author.servesWho?.length ? [`Writes for: ${author.servesWho.join(', ')}`] : []),\n ...(author.knowsAbout?.length ? [`Writes about: ${author.knowsAbout.join(', ')}`] : []),\n ...(author.credentials?.length\n ? ['## Stated experience', ...author.credentials.map((item) => `- ${item}`)]\n : []),\n ...(author.proof?.length\n ? [\n '## Proof points',\n ...author.proof.map((item) =>\n item.url ? `- [${item.claim}](${item.url})` : `- ${item.claim}`\n ),\n ]\n : []),\n ...(author.originStory?.length\n ? [\n '## Background',\n ...author.originStory.flatMap((section) => [\n ...(section.heading ? [`### ${section.heading}`] : []),\n ...section.paragraphs,\n ]),\n ]\n : []),\n ].filter((line) => line.trim() !== '')\n\n return buildListingMarkdown(author.name, intro, articles, config)\n}\n\n/** Markdown twin for `/articles/series/[series]`. `null` when the series has no articles. */\nexport async function getSeriesMarkdown(\n seriesSlug: string,\n config: ArticlesConfig\n): Promise<string | null> {\n const articles = await getArticlesBySeries(seriesSlug, config)\n if (articles.length === 0) return null\n const name = articles[0].series ?? seriesSlug\n return buildListingMarkdown(name, [], articles, config)\n}\n\n/**\n * Serves the markdown twin for any `/articles/...` path - an article, or a\n * category/author/series listing.\n *\n * Dispatching on the slug prefix here rather than adding three more app\n * routes keeps the existing single rewrite (`/articles/:path*.md`) working\n * unchanged: without it, `/articles/category/campaigns.md` matches that\n * rewrite, reaches the article handler, and 404s.\n */\nexport async function getMarkdownTwinResponse(\n slug: string,\n config: ArticlesConfig,\n options?: Readonly<{ headers?: RequestHeaders }>\n): Promise<Response> {\n const listing = await resolveListingMarkdown(slug, config)\n if (listing !== undefined) {\n if (listing === null) return new Response('Not Found', { status: 404 })\n reportAiCrawl(slug, config, options?.headers)\n return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS })\n }\n return getArticleMarkdownResponse(slug, config, options)\n}\n\nconst LISTING_MARKDOWN_HEADERS = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Cache-Control': 'public, max-age=3600, s-maxage=3600',\n}\n\n// `undefined` means \"not a listing path\" (fall through to the article\n// handler); `null` means \"a listing path that resolved to nothing\" (404).\nasync function resolveListingMarkdown(\n slug: string,\n config: ArticlesConfig\n): Promise<string | null | undefined> {\n const [prefix, ...rest] = slug.split('/')\n const key = rest.join('/')\n if (!key) return undefined\n if (prefix === 'category') return getCategoryMarkdown(key, config)\n if (prefix === 'authors') return getAuthorMarkdown(key, config)\n if (prefix === 'series') return getSeriesMarkdown(key, config)\n return undefined\n}\n\nexport async function getArticleMarkdownResponse(\n slug: string,\n config: ArticlesConfig,\n options?: Readonly<{ headers?: RequestHeaders }>\n): Promise<Response> {\n const markdown = await getArticleMarkdown(slug, config)\n if (markdown === null) return new Response('Not Found', { status: 404 })\n const article = await getArticleMetadata(slug, config)\n reportAiCrawl(slug, config, options?.headers)\n const body =\n article && config.markdownTwinHeader !== false\n ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}`\n : markdown\n // `getArticleAiHeaders` builds a `rel=\"alternate\"` link *to* the twin, which\n // is correct on the HTML page and wrong here - on the twin's own response it\n // pointed at the URL being requested, telling a client the alternate of\n // `/articles/x.md` is `/articles/x.md`. `getArticleMarkdown` already returned\n // null for anything not opted in, so that helper's other branch\n // (`X-Robots-Tag: noai`) was unreachable from this call site anyway. Point\n // back at the HTML article instead, which is the relationship a client\n // fetching the twin actually needs: this is a representation of that page.\n const canonicalUrl = `${config.siteUrl.replace(/\\/$/, '')}/articles/${slug}`\n return new Response(body, {\n headers: {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Cache-Control': 'public, max-age=3600, s-maxage=3600',\n Link: `<${canonicalUrl}>; rel=\"canonical\"`,\n },\n })\n}\n\nexport function getArticleMarkdownUrl(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): string | undefined {\n if (article.aiCrawl !== true) return undefined\n const pathname = `/articles/${article.slug}.md`\n if (!config) return pathname\n return `${config.siteUrl.replace(/\\/$/, '')}${pathname}`\n}\n\nexport function getArticleAiHeaders(\n article: Pick<Article, 'slug' | 'aiCrawl'>,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): Record<string, string> {\n const markdownUrl = getArticleMarkdownUrl(article, config)\n if (markdownUrl) {\n return {\n Link: `<${markdownUrl}>; rel=\"alternate\"; type=\"text/markdown\"`,\n }\n }\n return {\n 'X-Robots-Tag': 'noai, noimageai',\n }\n}\n\nexport async function getAiRobotsTxtRules(config?: ArticlesConfig): Promise<string> {\n const articles = await getAllArticles(config)\n const blockedArticles = articles.filter((article) => article.aiCrawl !== true)\n if (blockedArticles.length === 0) return ''\n\n const disallowRules = blockedArticles\n .map((article) => `Disallow: /articles/${article.slug}`)\n .join('\\n')\n\n return AI_CRAWLERS.map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\\n')).join(\n '\\n\\n'\n )\n}\n\nexport async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {\n if (!query?.trim()) return getAllArticles(config)\n const articles = await getAllArticles(config)\n const searchTerm = query.toLowerCase().trim()\n const includeAuthor = config?.showAuthor !== false\n return articles.filter((article) => {\n const matchesTitle = article.title.toLowerCase().includes(searchTerm)\n const matchesExcerpt = article.excerpt.toLowerCase().includes(searchTerm)\n const matchesAuthor = includeAuthor && article.author.toLowerCase().includes(searchTerm)\n const matchesCategory = article.categories.some((cat) => cat.toLowerCase().includes(searchTerm))\n const matchesTags = article.tags?.some((tag) => tag.toLowerCase().includes(searchTerm))\n return (\n matchesTitle || matchesExcerpt || matchesAuthor || matchesCategory || Boolean(matchesTags)\n )\n })\n}\n\nexport function categoryToSlug(category: string): string {\n return category\n .toLowerCase()\n .replaceAll(/\\s+/g, '-')\n .replaceAll(/[^a-z0-9-]/g, '')\n}\n\nexport async function getAllCategories(): Promise<CategoryInfo[]> {\n const articles = await getAllArticles()\n const categoryMap = new Map<string, { count: number; featuredImage: string }>()\n for (const article of articles) {\n for (const cat of article.categories) {\n if (!categoryMap.has(cat)) {\n categoryMap.set(cat, { count: 0, featuredImage: article.featuredImage })\n }\n categoryMap.get(cat)!.count++\n }\n }\n return Array.from(categoryMap.entries())\n .map(([name, { count, featuredImage }]) => ({\n name,\n slug: categoryToSlug(name),\n count,\n featuredImage,\n }))\n .sort((a, b) => b.count - a.count)\n}\n\nexport async function getArticlesByCategory(\n categorySlug: string,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles.filter((article) =>\n article.categories.some((cat) => categoryToSlug(cat) === categorySlug)\n )\n}\n\n// Built on getArticlesByCategory (same slug-matching filter, no duplicated\n// logic) rather than getAdjacentArticles' global date-order walk, so an\n// article detail page can link to other articles in the same category\n// instead of just the two chronologically-nearest articles overall.\nexport async function getRelatedArticlesByCategory(\n currentSlug: string,\n category: string,\n limit = 3,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getArticlesByCategory(categoryToSlug(category), config)\n return articles.filter((article) => article.slug !== currentSlug).slice(0, limit)\n}\n\nexport async function getArticlesByAuthor(\n authorSlug: string,\n config: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles.filter((article) =>\n getArticleAuthors(article, config).some((author) => author.slug === authorSlug)\n )\n}\n\n// Sorted by `seriesOrder` ascending (undefined pushed to the end); ties fall\n// back to the date-descending order `getAllArticles` already applies, since\n// `Array.prototype.sort` is stable - matching `getArticlesByCategory`'s\n// \"build on the existing filter, don't duplicate `getAllArticles`\" pattern.\n// The label-only `series` string field is untouched by this function.\nexport async function getArticlesBySeries(\n seriesSlug: string,\n config?: ArticlesConfig\n): Promise<Article[]> {\n const articles = await getAllArticles(config)\n return articles\n .filter((article) => article.seriesSlug === seriesSlug)\n .sort((a, b) => {\n const orderA = a.seriesOrder ?? Number.POSITIVE_INFINITY\n const orderB = b.seriesOrder ?? Number.POSITIVE_INFINITY\n return orderA - orderB\n })\n}\n\n/**\n * Series-aware sibling of `getAdjacentArticles`: walks `seriesOrder` within\n * one series instead of global date order. `previous`/`next` follow series\n * order (ascending), not chronology.\n */\nexport async function getAdjacentArticlesInSeries(\n currentSlug: string,\n seriesSlug: string,\n config?: ArticlesConfig\n): Promise<{ previous: Article | null; next: Article | null }> {\n const seriesArticles = await getArticlesBySeries(seriesSlug, config)\n const currentIndex = seriesArticles.findIndex((article) => article.slug === currentSlug)\n if (currentIndex === -1) return { previous: null, next: null }\n return {\n previous: currentIndex > 0 ? seriesArticles[currentIndex - 1] : null,\n next: currentIndex < seriesArticles.length - 1 ? seriesArticles[currentIndex + 1] : null,\n }\n}\n\n/** Looks up one configured `PathDefinition` by its app-chosen key. */\nexport function getPath(pathKey: string, config: ArticlesConfig): PathDefinition | null {\n return config.paths?.[pathKey] ?? null\n}\n\n/** Resolves a path's ordered slugs against the real article set, dropping any that don't resolve (e.g. a draft filtered out of `getAllArticles` in production) rather than throwing - use `validateArticles` to catch broken references before publishing. */\nexport async function getPathArticles(pathKey: string, config: ArticlesConfig): Promise<Article[]> {\n const path = getPath(pathKey, config)\n if (!path) return []\n const articles = await getAllArticles(config)\n const bySlug = new Map(articles.map((article) => [article.slug, article]))\n return path.articles\n .map((slug) => bySlug.get(slug))\n .filter((article): article is Article => Boolean(article))\n}\n\nfunction findPathForArticle(\n slug: string,\n config: ArticlesConfig\n): { key: string; path: PathDefinition } | null {\n for (const [key, path] of Object.entries(config.paths ?? {})) {\n if (path.articles.includes(slug)) return { key, path }\n }\n return null\n}\n\nexport type RelatedContentSource = 'path' | 'series' | 'category'\n\nexport interface RelatedContentResult {\n source: RelatedContentSource\n /** Heading for a related-content UI - the path's `name`, the article's `series` label, or \"More in {category}\". */\n heading: string\n articles: Article[]\n /** Set only when `source === 'path'`. */\n pathKey?: string\n /** Set only when `source === 'path'` - the path's one configured next action. */\n nextAction?: { label: string; href: string }\n}\n\n/**\n * Reusable related-content selection (Phase 27F): prefers a configured\n * `Path` containing this article first, then the article's `seriesSlug`,\n * falling back to 27B's `getRelatedArticlesByCategory` (imported, not\n * reimplemented) when neither a path nor a series applies - the plain\n * chronological-within-category behavior stays the fallback, not a full\n * replacement.\n */\nexport async function getRelatedContent(\n article: Article,\n config: ArticlesConfig,\n limit = 3\n): Promise<RelatedContentResult> {\n const matchedPath = findPathForArticle(article.slug, config)\n if (matchedPath) {\n const pathArticles = await getPathArticles(matchedPath.key, config)\n return {\n source: 'path',\n heading: matchedPath.path.name,\n articles: pathArticles.filter((a) => a.slug !== article.slug),\n pathKey: matchedPath.key,\n nextAction: matchedPath.path.nextAction,\n }\n }\n if (article.seriesSlug) {\n const seriesArticles = await getArticlesBySeries(article.seriesSlug, config)\n return {\n source: 'series',\n heading: article.series ?? 'This series',\n articles: seriesArticles.filter((a) => a.slug !== article.slug),\n }\n }\n const categoryArticles = await getRelatedArticlesByCategory(\n article.slug,\n article.category,\n limit,\n config\n )\n return { source: 'category', heading: `More in ${article.category}`, articles: categoryArticles }\n}\n\nexport { sanitizeImagePath }\n","import type { ArticlesConfig } from './articlesConfig'\nimport type { AuthorProfile, AuthorSocial } from './articleTypes'\n\n/**\n * The canonical identity URL a Person `@id` derives from. Prefers the\n * explicit cross-site `identityUrl`, then `url`, then this site's own author\n * page - so an author who configures nothing keeps a per-site identity, and\n * one who sets `identityUrl` gets the same `@id` on every site.\n */\nexport function getAuthorIdentityUrl(\n author: AuthorProfile,\n config?: Pick<ArticlesConfig, 'siteUrl'>\n): string | undefined {\n if (author.identityUrl) return author.identityUrl\n if (author.url) return author.url\n if (!config) return undefined\n return `${config.siteUrl.replace(/\\/$/, '')}/articles/authors/${author.slug}`\n}\n\nexport function getAuthorUrl(author: AuthorProfile): string {\n return author.url ?? `/articles/authors/${author.slug}`\n}\n\nexport function getAuthorAvatar(\n author: AuthorProfile,\n config?: ArticlesConfig\n): string | undefined {\n if (!author.avatar) return undefined\n if (author.avatar.startsWith('http://') || author.avatar.startsWith('https://')) {\n return author.avatar\n }\n const path = `/articles/authors/${author.slug}/${author.avatar.replace(/^\\/+/, '')}`\n if (!config) return path\n return `${config.siteUrl.replace(/\\/$/, '')}${path}`\n}\n\nexport function getAuthorSameAs(author: AuthorProfile): string[] {\n const derived = getAuthorSocialLinks(author).map((link) => link.href)\n const explicit = author.sameAs?.filter((url) => url.trim() !== '') ?? []\n return [...new Set([...derived, ...explicit])]\n}\n\nexport interface AuthorSocialLink {\n label: string\n href: string\n}\n\nfunction normalizeHandle(value: string): string {\n return value.replace(/^@/, '')\n}\n\nfunction normalizeUrl(value: string, baseUrl?: string): string {\n if (value.startsWith('http://') || value.startsWith('https://')) return value\n if (!baseUrl) return value\n return `${baseUrl}${normalizeHandle(value)}`\n}\n\nfunction getConfiguredSocialLinks(social: AuthorSocial): AuthorSocialLink[] {\n return [\n { label: 'Website', href: social.website ?? '' },\n {\n label: 'Facebook',\n href: social.facebook ? normalizeUrl(social.facebook, 'https://www.facebook.com/') : '',\n },\n {\n label: 'Twitter',\n href: social.twitter ? normalizeUrl(social.twitter, 'https://twitter.com/') : '',\n },\n { label: 'X', href: social.x ? normalizeUrl(social.x, 'https://x.com/') : '' },\n {\n label: 'LinkedIn',\n href: social.linkedin ? normalizeUrl(social.linkedin, 'https://www.linkedin.com/in/') : '',\n },\n {\n label: 'Instagram',\n href: social.instagram ? normalizeUrl(social.instagram, 'https://www.instagram.com/') : '',\n },\n {\n label: 'YouTube',\n href: social.youtube ? normalizeUrl(social.youtube, 'https://www.youtube.com/') : '',\n },\n {\n label: 'TikTok',\n href: social.tiktok ? normalizeUrl(social.tiktok, 'https://www.tiktok.com/@') : '',\n },\n {\n label: 'GitHub',\n href: social.github ? normalizeUrl(social.github, 'https://github.com/') : '',\n },\n {\n label: 'Bluesky',\n href: social.bluesky ? normalizeUrl(social.bluesky, 'https://bsky.app/profile/') : '',\n },\n {\n label: 'Threads',\n href: social.threads ? normalizeUrl(social.threads, 'https://www.threads.net/@') : '',\n },\n { label: 'Mastodon', href: social.mastodon ? normalizeUrl(social.mastodon) : '' },\n {\n label: 'Medium',\n href: social.medium ? normalizeUrl(social.medium, 'https://medium.com/@') : '',\n },\n { label: 'Newsletter', href: social.newsletter ?? '' },\n ]\n}\n\nexport function getAuthorSocialLinks(author: AuthorProfile): AuthorSocialLink[] {\n const social = author.social\n if (!social) return []\n const configuredLinks = getConfiguredSocialLinks(social)\n const otherLinks = Object.entries(social.other ?? {}).map(([label, href]) => ({ label, href }))\n return [...configuredLinks, ...otherLinks].filter((link) => link.href.trim().length > 0)\n}\n","import type { Element, Root, ElementContent } from 'hast'\nimport rehypePrism from 'rehype-prism-plus'\nimport rehypeSanitize from 'rehype-sanitize'\nimport rehypeSlug from 'rehype-slug'\nimport rehypeStringify from 'rehype-stringify'\nimport { remark } from 'remark'\nimport remarkGfm from 'remark-gfm'\nimport remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'\nimport remarkParse from 'remark-parse'\nimport remarkRehype from 'remark-rehype'\nimport { Plugin } from 'unified'\nimport { visit } from 'unist-util-visit'\nimport type { FaqItem, TocItem } from './articleTypes'\nimport type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\nimport { isExternalHttpLink, isNonBrowserNavigationLink } from './linkClassification'\n\nexport { isExternalHttpLink, isNonBrowserNavigationLink }\n\ntype LinkTargetOptions = Readonly<{\n strategy?: LinkTargetStrategy\n siteUrl?: string\n}>\n\nconst DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'\n\nfunction shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {\n if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false\n\n const strategy = options.strategy ?? DEFAULT_LINK_TARGET_STRATEGY\n if (strategy === 'same-tab') return false\n if (strategy === 'all-new-tab') return true\n return isExternalHttpLink(href, options.siteUrl)\n}\n\nfunction applyLinkTarget(props: Record<string, unknown>, options?: LinkTargetOptions): void {\n const href = typeof props.href === 'string' ? props.href : ''\n if (shouldOpenInNewTab(href, options)) {\n props.target = '_blank'\n props.rel = 'noopener noreferrer'\n return\n }\n delete props.target\n delete props.rel\n}\n\nfunction getLinkTargetOptions(config?: ArticlesConfig): LinkTargetOptions {\n return {\n strategy: config?.linkTargetStrategy,\n siteUrl: config?.siteUrl,\n }\n}\n\n// Import the sanitizeImagePath function\nfunction sanitizeImagePath(rawPath: string, articleSlug: string): string | null {\n if (!rawPath || typeof rawPath !== 'string') {\n return null\n }\n\n // Remove any null bytes or control characters\n const cleanPath = rawPath.replaceAll(/[\\x00-\\x1f\\x7f-\\x9f]/g, '')\n\n // Check for absolute URLs (http/https)\n if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {\n // For external URLs, just return as-is (they're safe)\n return cleanPath\n }\n\n // For relative paths, ensure they don't contain dangerous patterns\n if (cleanPath.includes('..') || cleanPath.includes('\\\\') || cleanPath.startsWith('/')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected unsafe markdown image path.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes\n if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path with invalid characters.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n\n // Construct safe path within articles directory\n if (cleanPath.includes('/')) {\n // Relative path, ensure it's within the article directory\n const normalizedPath = cleanPath.replaceAll('\\\\', '/')\n if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Rejected markdown image path traversal attempt.',\n context: { articleSlug, path: cleanPath },\n })\n return null\n }\n return `/articles/${articleSlug}/${normalizedPath}`\n } else {\n // Just a filename, construct full path\n return `/articles/${articleSlug}/${cleanPath}`\n }\n}\n\nfunction styleFootnoteLinks(nodes: ElementContent[]): void {\n nodes.forEach((n) => {\n if (n.type !== 'element') return\n const el = n as Element\n if (el.tagName === 'a') {\n const isBackRef =\n el.properties?.['dataFootnoteBackref'] !== undefined ||\n (el.children[0]?.type === 'text' && el.children[0].value === '↩')\n if (isBackRef) {\n el.properties.className = 'text-primary hover:underline ml-1'\n if (typeof el.properties.href === 'string') {\n el.properties.href = el.properties.href.replaceAll('#user-content-fnref-', '#ref-')\n }\n } else {\n el.properties.className = 'text-primary hover:underline break-all'\n }\n }\n if (el.children) styleFootnoteLinks(el.children)\n })\n}\n\nfunction processFootnoteRef(node: Element): void {\n if (\n node.tagName !== 'sup' ||\n node.children?.[0]?.type !== 'element' ||\n (node.children[0] as Element).tagName !== 'a'\n )\n return\n\n const link = node.children[0] as Element\n const href = link.properties?.href\n if (typeof href !== 'string' || !href.startsWith('#user-content-fn-')) return\n\n delete link.properties.target\n delete link.properties.rel\n link.properties.className = 'text-primary hover:underline'\n link.properties.href = href.replaceAll('#user-content-fn-', '#footnote-')\n\n if (typeof node.properties?.id === 'string') {\n link.properties.id = node.properties.id.replaceAll('user-content-fnref-', 'ref-')\n delete node.properties.id\n }\n\n if (link.children?.[0]?.type === 'text') {\n link.children[0].value = `[${link.children[0].value}]`\n }\n}\n\nfunction processFootnotesSection(node: Element): void {\n const cls = node.properties?.className\n const isFootnotes =\n node.tagName === 'section' &&\n (Array.isArray(cls) ? cls.includes('footnotes') : cls === 'footnotes')\n if (!isFootnotes) return\n\n const olCandidate = node.children.find(\n (child) => child.type === 'element' && (child as Element).tagName === 'ol'\n )\n if (olCandidate?.type !== 'element') return\n\n const ol = olCandidate as Element\n ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'\n\n ol.children.forEach((li) => {\n if (li.type !== 'element' || li.tagName !== 'li') return\n const liEl = li as Element\n\n if (liEl.properties) {\n liEl.properties.className = 'pl-2'\n if (typeof liEl.properties.id === 'string') {\n liEl.properties.id = liEl.properties.id.replaceAll('user-content-fn-', 'footnote-')\n }\n }\n\n const pIndex = liEl.children.findIndex(\n (child) => child.type === 'element' && (child as Element).tagName === 'p'\n )\n if (pIndex !== -1) {\n const p = liEl.children[pIndex] as Element\n liEl.children.splice(pIndex, 1, ...p.children)\n }\n\n styleFootnoteLinks(liEl.children)\n })\n\n const hr: Element = {\n type: 'element',\n tagName: 'hr',\n properties: { className: 'my-8 border-border' },\n children: [],\n }\n const h3: Element = {\n type: 'element',\n tagName: 'h3',\n properties: { className: 'text-lg font-semibold mb-4' },\n children: [{ type: 'text', value: 'References' }],\n }\n\n node.children = [hr, h3, ol]\n if (node.properties) node.properties.className = undefined\n}\n\nexport const customRenderer: Plugin<[LinkTargetOptions?], Root> = (linkTargetOptions = {}) => {\n return (tree: Root) => {\n // First pass: Apply general styles\n visit(tree, 'element', (node: Element) => {\n if (node.tagName) {\n const props = node.properties || {}\n\n switch (node.tagName) {\n case 'h1':\n props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'\n break\n case 'h2':\n props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'\n break\n case 'h3':\n props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'\n break\n case 'h4':\n props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'\n break\n case 'p':\n props.className = 'text-muted-foreground leading-relaxed mb-4'\n break\n case 'a': {\n props.className = 'text-primary hover:underline transition-colors duration-200'\n applyLinkTarget(props, linkTargetOptions)\n break\n }\n case 'ul':\n props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'\n break\n case 'ol':\n props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'\n break\n case 'li':\n props.className = 'mb-1'\n break\n case 'blockquote':\n props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'\n break\n case 'code':\n props.className =\n (props.className ? props.className + ' ' : '') +\n 'bg-muted px-1 py-0.5 rounded text-sm font-mono'\n break\n case 'pre':\n props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'\n break\n case 'img':\n props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'\n break\n case 'table':\n props.className = 'border-collapse border border-border my-4 w-full'\n break\n case 'th':\n props.className = 'border border-border px-2 py-1 bg-muted font-semibold'\n break\n case 'td':\n props.className = 'border border-border px-2 py-1'\n break\n case 'hr':\n props.className = 'my-8 border-border'\n break\n default:\n break\n }\n\n node.properties = props\n }\n })\n\n // Second pass: Fix footnotes and references\n visit(tree, 'element', (node: Element) => {\n processFootnoteRef(node)\n processFootnotesSection(node)\n })\n }\n}\n\n// Custom rehype plugin to process image URLs\nconst rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {\n return (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n if (node.tagName === 'img' && node.properties) {\n const src = node.properties.src\n if (src && typeof src === 'string' && options.articleSlug) {\n // Sanitize the image path\n const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)\n if (sanitizedSrc) {\n node.properties.src = sanitizedSrc\n } else {\n // If sanitization fails, use a placeholder\n reportArticlesError({\n code: 'unsafe-image-path',\n message: 'Using placeholder for unsafe markdown image path.',\n context: { articleSlug: options.articleSlug, path: src },\n })\n node.properties.src = '/placeholder-logo.png'\n }\n }\n }\n })\n }\n}\n\nexport async function markdownToHtml(\n markdown: string,\n articleSlug?: string,\n config?: ArticlesConfig\n) {\n try {\n // Start building the remark processor\n let processor = remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkGithubBlockquoteAlert)\n .use(remarkRehype)\n .use(customRenderer, getLinkTargetOptions(config))\n .use(rehypeSlug)\n // @ts-ignore\n .use(rehypePrism)\n .use(rehypeSanitize, {\n attributes: {\n '*': ['className', 'class', 'id'],\n a: ['href', 'target', 'rel', 'id'],\n img: ['src', 'alt'],\n },\n })\n\n // Add image processing plugin if articleSlug is provided\n if (articleSlug) {\n processor = processor.use(rehypeProcessImages, { articleSlug })\n }\n\n const result = await processor\n .use(rehypeStringify)\n .process(stripInlineTagsFromHeadings(markdown))\n\n return result.toString()\n } catch (error) {\n reportArticlesError({\n code: 'markdown-conversion-failed',\n message: 'Unable to convert markdown to HTML.',\n error,\n context: { articleSlug },\n })\n // Return the original markdown as fallback\n return markdown\n }\n}\n\n/**\n * Strips inline HTML/JSX tags from heading lines only, keeping their inner\n * text, before the markdown reaches a plain (non-MDX) remark parse.\n *\n * Article headings commonly carry a reader-facing rating dot written as JSX,\n * e.g. `### Rage <span style={{ color: '#3b82f6' }}>●</span>`. `renderMdxSource`\n * (real MDX compilation via `@mdx-js/mdx`) parses that correctly and renders\n * a real `<span>` element. But `extractToc` and `markdownToHtml` both run\n * headings through plain `remark-parse` with no MDX support, and CommonMark's\n * raw-inline-HTML grammar does not accept a JSX object-literal attribute\n * expression like `style={{ ... }}` - remark's HTML tokenizer fails to match\n * it as a tag and falls back to treating the whole thing as literal text.\n * That garbled text then (a) becomes the visible TOC label, and (b) feeds\n * `rehype-slug`, producing a slug built from the raw markup instead of the\n * heading's real words - which does not match the id `renderMdxSource`'s\n * correctly-parsed pipeline assigns to the same heading in the live page, so\n * the TOC entry silently links to an id that does not exist in the DOM.\n *\n * Stripping tags (not their inner content) from heading lines before parsing\n * keeps the extracted text and generated slug consistent with what the real\n * MDX render puts in the page, for any heading-level inline markup - not\n * only the rating-dot convention that surfaced the bug.\n */\nfunction stripInlineTagsFromHeadings(markdown: string): string {\n return markdown.replace(/^(#{1,6}[ \\t].*)$/gm, (line) =>\n line.replace(/<\\/?[a-zA-Z][^<>\\n]*>/g, '')\n )\n}\n\n/**\n * Recursively extracts a node's text content, including text nested inside\n * child elements (for example a markdown link's `<a>Dwarf</a>` inside a\n * heading like `### [Dwarf](/link)`). A shallow, direct-children-only check\n * here previously dropped link text from every heading that used a link as\n * part of its heading text - a distinct bug from the JSX-in-heading garbling\n * `stripInlineTagsFromHeadings` fixes, but with a worse symptom: the heading\n * was silently omitted from the TOC entirely (both `id` and `text` came back\n * empty, so `extractHeadingItem` returned `null`) rather than merely garbled.\n */\nfunction nodeTextValue(c: ElementContent): string {\n if (c.type === 'text') return (c as { value: string }).value\n if (c.type === 'element' && 'children' in c) {\n return (c as Element).children.map(nodeTextValue).join('')\n }\n return ''\n}\n\nfunction extractHeadingItem(node: Element): TocItem | null {\n const match = /^h([1-6])$/.exec(node.tagName)\n if (!match) return null\n const id = typeof node.properties?.id === 'string' ? node.properties.id : ''\n const text = node.children.map(nodeTextValue).join('')\n if (!id || !text) return null\n return { id, depth: Number.parseInt(match[1], 10), text }\n}\n\nexport interface ContentSlotBoundaries {\n /** Character offset (into the raw markdown source) right after the first paragraph - the \"intro\" boundary. */\n introEnd: number\n /** Character offset right after the middle paragraph - the \"mid content\" boundary. */\n mid: number\n /** Total top-level paragraph count found. */\n paragraphCount: number\n}\n\n/**\n * Resolves deterministic `afterIntro`/`midContent` split points from the\n * raw markdown/MDX source's parsed AST (mdast paragraph node offsets) -\n * never from string-splitting rendered HTML, which is fragile by\n * construction (see Phase 27F plan notes). Returns `null` when the source\n * has no top-level paragraphs, or fails to parse (e.g. MDX with JSX syntax\n * remark-parse doesn't understand) - callers should treat `null` as \"only\n * `afterHero`/`afterContent` are available for this article\", not throw.\n */\ninterface MdastNode {\n type: string\n position?: { start: { offset?: number }; end: { offset?: number } }\n children?: MdastNode[]\n}\n\nexport function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null {\n try {\n const tree = remark().use(remarkParse).use(remarkGfm).parse(markdown) as unknown as MdastNode\n const paragraphs = (tree.children ?? []).filter(\n (node): node is MdastNode & { position: NonNullable<MdastNode['position']> } =>\n node.type === 'paragraph' && Boolean(node.position)\n )\n if (paragraphs.length === 0) return null\n const introEnd = paragraphs[0].position.end.offset ?? 0\n const midIndex = Math.floor(paragraphs.length / 2)\n const mid = paragraphs[midIndex].position.end.offset ?? introEnd\n return { introEnd, mid: Math.max(mid, introEnd), paragraphCount: paragraphs.length }\n } catch {\n return null\n }\n}\n\nexport async function extractToc(markdown: string): Promise<TocItem[]> {\n const headings: TocItem[] = []\n const collectHeadings: Plugin<[], Root> = () => (tree: Root) => {\n visit(tree, 'element', (node: Element) => {\n const item = extractHeadingItem(node)\n if (item) headings.push(item)\n })\n }\n await remark()\n .use(remarkParse)\n .use(remarkGfm)\n .use(remarkRehype)\n .use(rehypeSlug)\n .use(collectHeadings)\n .use(rehypeStringify)\n .process(stripInlineTagsFromHeadings(markdown))\n return headings\n}\n\n// Interrogatives that open a genuine reader question. Deliberately a closed\n// list rather than \"any heading ending in ?\" - a rhetorical heading like\n// \"Sound familiar?\" ends in a question mark too, and promoting it to a\n// published Q&A pair would be worse than omitting it.\nconst QUESTION_OPENERS =\n /^(what|how|why|when|where|who|which|can|should|does|do|is|are|will|would|must)\\b/i\n\n/**\n * Derives `FaqItem`s from question-shaped `##` headings and the prose that\n * follows each one, for `ArticlesConfig.deriveFaqFromHeadings`.\n *\n * Line-based on purpose: it runs for every article at summary time, and the\n * shapes it must reject (fenced code, a heading with no prose under it) are\n * cheaper to detect by scanning than by walking a parsed AST.\n */\nexport function deriveFaqFromHeadings(markdown: string): FaqItem[] {\n const items: FaqItem[] = []\n const collector = createFaqCollector(items)\n let inFence = false\n\n for (const line of markdown.split('\\n')) {\n if (FENCE.test(line)) {\n inFence = !inFence\n continue\n }\n if (inFence) continue\n collector.consume(line)\n }\n collector.flush()\n\n return items\n}\n\nconst FENCE = /^\\s*(```|~~~)/\nconst ANY_HEADING = /^#{1,6}\\s/\n\nfunction isSpace(char: string | undefined): boolean {\n return char === ' ' || char === '\\t'\n}\n\n/** Drops closing-hash decoration (`## Heading ##`) without a `#+$` scan. */\nfunction trimTrailingHashes(value: string): string {\n let end = value.length\n while (end > 0 && value[end - 1] === '#') end--\n return value.slice(0, end).trimEnd()\n}\n\n/**\n * Extracts a question-shaped h2's text, or `null` for any other line.\n *\n * Deliberately string operations rather than a capture regex: every anchored\n * form of this (`(.*\\S)\\s*$`, `\\s+(.*)$`, `#+$`) backtracks super-linearly,\n * and this runs over every line of every article.\n */\nfunction readQuestionHeading(line: string): string | null {\n if (!line.startsWith('##') || line.startsWith('###')) return null\n if (!isSpace(line[2])) return null\n const text = trimTrailingHashes(line.slice(3).trim())\n if (!text.endsWith('?') || !QUESTION_OPENERS.test(text)) return null\n return text\n}\n\n/**\n * Line-at-a-time state machine pairing a question heading with the prose\n * under it. Split out of `deriveFaqFromHeadings` so the loop there stays a\n * flat fence check plus a delegation.\n */\nfunction createFaqCollector(items: FaqItem[]) {\n let pending: string | null = null\n let buffer: string[] = []\n\n const flush = (): void => {\n if (pending && buffer.length > 0) {\n items.push({ question: pending, answer: buffer.join(' ').trim() })\n }\n pending = null\n buffer = []\n }\n\n const consume = (line: string): void => {\n if (ANY_HEADING.test(line)) {\n flush()\n pending = readQuestionHeading(line)\n return\n }\n if (!pending) return\n // A blank line only ends an answer that has already started - the blank\n // between a heading and its first paragraph must not discard the pending\n // question.\n if (line.trim() === '') {\n if (buffer.length > 0) flush()\n return\n }\n buffer.push(line.trim())\n }\n\n return { consume, flush }\n}\n","export type ArticlesErrorCode =\n | 'article-directory-read-failed'\n | 'article-load-failed'\n | 'article-markdown-load-failed'\n | 'ai-crawl-handler-failed'\n | 'markdown-conversion-failed'\n | 'unsafe-image-path'\n\nexport type ArticlesErrorContext = Readonly<Record<string, string | number | boolean | undefined>>\n\nexport type ArticlesErrorReport = Readonly<{\n code: ArticlesErrorCode\n message: string\n error?: unknown\n context?: ArticlesErrorContext\n}>\n\nexport type ArticlesErrorHandler = (report: ArticlesErrorReport) => void\n\nfunction defaultArticlesErrorHandler(report: ArticlesErrorReport): void {\n if (process.env.NODE_ENV === 'production') return\n const context = report.context ? ` ${JSON.stringify(report.context)}` : ''\n const detail = report.error instanceof Error ? `: ${report.error.message}` : ''\n console.warn(`[articles:${report.code}] ${report.message}${context}${detail}`)\n}\n\nlet articlesErrorHandler: ArticlesErrorHandler = defaultArticlesErrorHandler\n\nexport function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void {\n articlesErrorHandler = handler ?? defaultArticlesErrorHandler\n}\n\nexport function reportArticlesError(report: ArticlesErrorReport): void {\n articlesErrorHandler(report)\n}\n","// Pure href classification helpers with no dependency on the rehype/hast\n// pipeline - kept separate from markdown.ts (which pulls in ESM-only\n// rehype/remark plugins Jest can't transform without extra config) so\n// consumers that only need \"is this link internal/external/navigable\"\n// (like renderMdx.tsx's next/link routing decision) don't have to import\n// that whole transitive dependency chain, in production or in tests.\n\nexport function isNonBrowserNavigationLink(href: string): boolean {\n return (\n /^[a-zA-Z][a-zA-Z\\d+.-]*:/.test(href) &&\n !href.startsWith('http://') &&\n !href.startsWith('https://')\n )\n}\n\nfunction getOrigin(url: string | undefined): string | null {\n if (!url) return null\n try {\n return new URL(url).origin\n } catch {\n return null\n }\n}\n\nexport function isExternalHttpLink(href: string, siteUrl?: string): boolean {\n if (!href.startsWith('http://') && !href.startsWith('https://')) return false\n const siteOrigin = getOrigin(siteUrl)\n if (!siteOrigin) return true\n return getOrigin(href) !== siteOrigin\n}\n"],"mappings":"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","names":["path","remarkParse","remarkGfm","remarkGithubBlockquoteAlert","remarkRehype","rehypeSlug","rehypePrism","rehypeSanitize","rehypeStringify","remarkParse","remarkGfm","remarkRehype","rehypeSlug","rehypeStringify","path","readingTime","fs","sanitizeImagePath","_a","fs","matter","fs","matter"]}
|
package/dist/nextjs.js
CHANGED
|
@@ -1007,31 +1007,16 @@ function getArticleMarkdownResponse(slug, config, options) {
|
|
|
1007
1007
|
const article = yield getArticleMetadata(slug, config);
|
|
1008
1008
|
reportAiCrawl(slug, config, options == null ? void 0 : options.headers);
|
|
1009
1009
|
const body = article && config.markdownTwinHeader !== false ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}` : markdown;
|
|
1010
|
+
const canonicalUrl = `${config.siteUrl.replace(/\/$/, "")}/articles/${slug}`;
|
|
1010
1011
|
return new Response(body, {
|
|
1011
|
-
headers:
|
|
1012
|
+
headers: {
|
|
1012
1013
|
"Content-Type": "text/markdown; charset=utf-8",
|
|
1013
|
-
"Cache-Control": "public, max-age=3600, s-maxage=3600"
|
|
1014
|
-
|
|
1014
|
+
"Cache-Control": "public, max-age=3600, s-maxage=3600",
|
|
1015
|
+
Link: `<${canonicalUrl}>; rel="canonical"`
|
|
1016
|
+
}
|
|
1015
1017
|
});
|
|
1016
1018
|
});
|
|
1017
1019
|
}
|
|
1018
|
-
function getArticleMarkdownUrl(article, config) {
|
|
1019
|
-
if (article.aiCrawl !== true) return void 0;
|
|
1020
|
-
const pathname = `/articles/${article.slug}.md`;
|
|
1021
|
-
if (!config) return pathname;
|
|
1022
|
-
return `${config.siteUrl.replace(/\/$/, "")}${pathname}`;
|
|
1023
|
-
}
|
|
1024
|
-
function getArticleAiHeaders(article, config) {
|
|
1025
|
-
const markdownUrl = getArticleMarkdownUrl(article, config);
|
|
1026
|
-
if (markdownUrl) {
|
|
1027
|
-
return {
|
|
1028
|
-
Link: `<${markdownUrl}>; rel="alternate"; type="text/markdown"`
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
return {
|
|
1032
|
-
"X-Robots-Tag": "noai, noimageai"
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
1020
|
function categoryToSlug(category) {
|
|
1036
1021
|
return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
|
|
1037
1022
|
}
|