@fullstackdatasolutions/articles 0.12.0 → 1.1.0

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +149 -0
  12. package/dist/nextjs.d.ts +149 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +357 -3
  18. package/dist/server.d.ts +357 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +56 -5
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +58 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +131 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +45 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. package/src/validateArticles.ts +260 -0
package/dist/nextjs.d.ts CHANGED
@@ -1,6 +1,27 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import { ComponentType } from 'react';
3
3
 
4
+ /**
5
+ * A "start here" curated reader journey that can cross series/categories -
6
+ * a distinct primitive from the label-only `series` field/`seriesSlug`
7
+ * pair (Phase 27F). Configured via `ArticlesConfig.paths`, keyed by an
8
+ * app-chosen path key. `articles` is an ordered list of slugs; every
9
+ * referenced slug must exist and not be `draft: true` - enforced by
10
+ * `validateArticles`, not silently at render time.
11
+ */
12
+ interface PathDefinition {
13
+ /** Display name, e.g. "New GM Starter Path". */
14
+ name: string;
15
+ /** One-sentence value proposition shown on the path's landing/step UI. */
16
+ promise: string;
17
+ /** Ordered article slugs making up the journey. */
18
+ articles: string[];
19
+ /** The one next action offered once a reader completes the path. */
20
+ nextAction: {
21
+ label: string;
22
+ href: string;
23
+ };
24
+ }
4
25
  interface AuthorSocial {
5
26
  website?: string;
6
27
  facebook?: string;
@@ -18,6 +39,23 @@ interface AuthorSocial {
18
39
  newsletter?: string;
19
40
  other?: Record<string, string>;
20
41
  }
42
+ /**
43
+ * One headed block of structured long-form content (used by
44
+ * `AuthorProfile.originStory`). An array of these, not a single HTML blob,
45
+ * so consuming apps can render/style each block themselves rather than
46
+ * `dangerouslySetInnerHTML`-ing raw markup.
47
+ */
48
+ interface RichTextSection {
49
+ heading?: string;
50
+ paragraphs: string[];
51
+ }
52
+ type RichText = RichTextSection[];
53
+ /** A single sourceable claim used by `AuthorProfile.proof`. */
54
+ interface ProofItem {
55
+ claim: string;
56
+ source?: string;
57
+ url?: string;
58
+ }
21
59
  interface AuthorProfile {
22
60
  name: string;
23
61
  slug: string;
@@ -25,8 +63,77 @@ interface AuthorProfile {
25
63
  avatar?: string;
26
64
  url?: string;
27
65
  social?: AuthorSocial;
66
+ /** Short one-line audience promise, e.g. "Helping new GMs run confident first sessions." Optional, additive - omitted fields never change existing rendering. */
67
+ promise?: string;
68
+ /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
69
+ originStory?: RichText;
70
+ /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
71
+ servesWho?: string[];
72
+ /** Core beliefs/approach statements. */
73
+ principles?: string[];
74
+ /**
75
+ * Experience/credential claims (e.g. "10+ years running published campaigns").
76
+ * Intentionally never included in Person JSON-LD - unverifiable claims don't
77
+ * belong in structured data (see `getPersonSchema`/`getPersonSchemas`).
78
+ */
79
+ credentials?: string[];
80
+ /** Concrete, sourceable proof points. */
81
+ proof?: ProofItem[];
82
+ /** Primary call-to-action rendered on the author's page. */
83
+ primaryCta?: {
84
+ label: string;
85
+ href: string;
86
+ };
28
87
  }
29
88
 
89
+ type ArticleEventName = 'article_viewed' | 'meaningful_read' | 'author_clicked' | 'cta_viewed' | 'cta_clicked' | 'shared' | 'related_article_clicked' | 'path_step_advanced';
90
+ interface ArticleEventBase<Name extends ArticleEventName> {
91
+ name: Name;
92
+ /** `Date.now()` at emit time. */
93
+ timestamp: number;
94
+ }
95
+ interface ArticleViewedEvent extends ArticleEventBase<'article_viewed'> {
96
+ articleSlug: string;
97
+ category?: string;
98
+ seriesSlug?: string;
99
+ }
100
+ /** Fired once per view after the reader has spent roughly half the article's estimated read time on the page (see `ArticleViewTracker`). */
101
+ interface MeaningfulReadEvent extends ArticleEventBase<'meaningful_read'> {
102
+ articleSlug: string;
103
+ }
104
+ interface AuthorClickedEvent extends ArticleEventBase<'author_clicked'> {
105
+ articleSlug: string;
106
+ authorSlug: string;
107
+ }
108
+ /** `ctaId` is `primaryAction.actionId`, an `AuthorProfile.primaryCta` slug, or a `PathDefinition` key - always an app-chosen ID, never label text. */
109
+ interface CtaViewedEvent extends ArticleEventBase<'cta_viewed'> {
110
+ ctaId: string;
111
+ articleSlug?: string;
112
+ }
113
+ interface CtaClickedEvent extends ArticleEventBase<'cta_clicked'> {
114
+ ctaId: string;
115
+ articleSlug?: string;
116
+ }
117
+ interface SharedEvent extends ArticleEventBase<'shared'> {
118
+ articleSlug: string;
119
+ /** Share channel key, e.g. `'linkedin'`, `'copy-link'` - never the shared URL/message text. */
120
+ channel: string;
121
+ }
122
+ interface RelatedArticleClickedEvent extends ArticleEventBase<'related_article_clicked'> {
123
+ fromSlug: string;
124
+ toSlug: string;
125
+ source: 'path' | 'series' | 'category';
126
+ }
127
+ interface PathStepAdvancedEvent extends ArticleEventBase<'path_step_advanced'> {
128
+ pathKey: string;
129
+ fromSlug: string;
130
+ toSlug: string;
131
+ direction: 'previous' | 'next';
132
+ }
133
+ type ArticleEvent = ArticleViewedEvent | MeaningfulReadEvent | AuthorClickedEvent | CtaViewedEvent | CtaClickedEvent | SharedEvent | RelatedArticleClickedEvent | PathStepAdvancedEvent;
134
+ /** Register this on `ArticlesConfig.onEvent` to receive every emitted event and translate it to your own analytics stack. */
135
+ type ArticleEventHandler = (event: ArticleEvent) => void;
136
+
30
137
  /** Keys for each renderable section of the articles listing page. */
31
138
  type ArticlesSection = 'hero' | 'search' | 'featured' | 'latest' | 'categories' | 'newsletter';
32
139
  /**
@@ -36,6 +143,14 @@ type ArticlesSection = 'hero' | 'search' | 'featured' | 'latest' | 'categories'
36
143
  interface ArticlesTheme {
37
144
  /** Font family for the articles section. Example: `"'Inter', sans-serif"` */
38
145
  fontFamily?: string;
146
+ /**
147
+ * Font family for headings only (article title, card titles, section
148
+ * headings) - falls back to `fontFamily` when omitted. Lets a site use a
149
+ * distinct display face for headings (e.g. a serif) while keeping a
150
+ * separate body font, without hardcoding either into the package.
151
+ * Example: `"'Cinzel', serif"`
152
+ */
153
+ headerFontFamily?: string;
39
154
  /** Color for article card titles and section headings. Example: `'#111827'` */
40
155
  headerColor?: string;
41
156
  /** Color for body and excerpt text. Example: `'#6b7280'` */
@@ -87,6 +202,18 @@ interface HeroConfig {
87
202
  }
88
203
  /** Controls how article body links set target/rel attributes. */
89
204
  type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab';
205
+ /**
206
+ * Controls how listing pages (the articles index, category pages, author
207
+ * pages) surface articles beyond the first `pageSize`.
208
+ * - `'load-more'` (default): client-only "Load more" button, no URL change.
209
+ * Byte-for-byte identical to pre-27D behavior.
210
+ * - `'pages'`: real, directly-navigable paginated routes (`/articles/page/2`,
211
+ * `/articles/category/[category]/page/2`, `/articles/authors/[author]/page/2`)
212
+ * with SSR content, prev/next links, and per-page canonical metadata. The
213
+ * route *files* live in the consuming app - see the pagination primitives
214
+ * exported from `./server` and the `PaginationNav` component.
215
+ */
216
+ type ListingPagination = 'load-more' | 'pages';
90
217
  /** React components that article MDX bodies can reference by JSX tag name. */
91
218
  type MdxComponents = Record<string, ComponentType<never>>;
92
219
  type ArticleBreadcrumbToken = 'home' | 'articles' | 'primaryCategory' | 'folderPath' | 'articleTitle';
@@ -169,6 +296,28 @@ interface ArticlesConfig {
169
296
  linkTargetStrategy?: LinkTargetStrategy;
170
297
  /** Extra components exposed to article MDX bodies by JSX tag name. */
171
298
  mdxComponents?: MdxComponents;
299
+ /**
300
+ * Chooses how listing pages surface articles beyond the first `pageSize`.
301
+ * Default: `'load-more'` (unchanged pre-27D behavior). Set to `'pages'` to
302
+ * opt into real, crawlable paginated routes instead.
303
+ */
304
+ listingPagination?: ListingPagination;
305
+ /**
306
+ * "Start here" curated reader journeys, keyed by an app-chosen path key.
307
+ * Distinct from the label-only `series` field/`seriesSlug` pair - a path
308
+ * can cross series and categories. Every `PathDefinition.articles` slug
309
+ * must exist and not be `draft: true`; validate with `validateArticles`
310
+ * before publishing, since a broken reference produces a dead journey
311
+ * step rather than a build-time failure otherwise.
312
+ */
313
+ paths?: Record<string, PathDefinition>;
314
+ /**
315
+ * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
316
+ * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
317
+ * No PII in any payload. The package never talks to an analytics/email
318
+ * vendor directly - translate events to PostHog/etc. in this callback.
319
+ */
320
+ onEvent?: ArticleEventHandler;
172
321
  }
173
322
 
174
323
  /**
package/dist/nextjs.js CHANGED
@@ -45,6 +45,17 @@ import fs from "node:fs";
45
45
  import path from "node:path";
46
46
  import readingTime from "reading-time";
47
47
 
48
+ // src/authorUtils.ts
49
+ function getAuthorAvatar(author, config) {
50
+ if (!author.avatar) return void 0;
51
+ if (author.avatar.startsWith("http://") || author.avatar.startsWith("https://")) {
52
+ return author.avatar;
53
+ }
54
+ const path2 = `/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, "")}`;
55
+ if (!config) return path2;
56
+ return `${config.siteUrl.replace(/\/$/, "")}${path2}`;
57
+ }
58
+
48
59
  // src/markdown.ts
49
60
  import rehypePrism from "rehype-prism-plus";
50
61
  import rehypeSanitize from "rehype-sanitize";
@@ -384,8 +395,9 @@ var articlesDirectory = path.join(
384
395
  process.cwd(),
385
396
  "public/articles"
386
397
  );
387
- function getReadingTime(content) {
388
- return readingTime(content).text;
398
+ function getReadingStats(content) {
399
+ const stats = readingTime(content);
400
+ return { readTime: stats.text, wordCount: stats.words };
389
401
  }
390
402
  function findArticleImage(slug) {
391
403
  try {
@@ -505,6 +517,23 @@ function parseHowToSteps(raw) {
505
517
  );
506
518
  return steps.length ? steps : void 0;
507
519
  }
520
+ function parseOptionalString(raw) {
521
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
522
+ }
523
+ function parseSeriesOrder(raw) {
524
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
525
+ }
526
+ function parsePrimaryAction(raw) {
527
+ if (typeof raw === "string") {
528
+ const actionId = raw.trim();
529
+ return actionId ? { actionId } : void 0;
530
+ }
531
+ if (typeof raw === "object" && raw !== null && typeof raw.actionId === "string") {
532
+ const actionId = raw.actionId.trim();
533
+ return actionId ? { actionId } : void 0;
534
+ }
535
+ return void 0;
536
+ }
508
537
  function parseAuthors(raw) {
509
538
  if (!Array.isArray(raw)) return void 0;
510
539
  const authors = raw.filter(
@@ -539,6 +568,28 @@ function resolveArticleAuthorName(rawAuthor, rawAuthors, config) {
539
568
  if (!config) return resolved;
540
569
  return (_d = (_c = (_a = getAuthorBySlug(resolved, config)) == null ? void 0 : _a.name) != null ? _c : (_b = getConfiguredAuthorByName(resolved, config)) == null ? void 0 : _b.name) != null ? _d : resolved;
541
570
  }
571
+ function getArticleAuthors(article, config) {
572
+ var _a;
573
+ const fallbackAuthors = Array.from(
574
+ new Set(
575
+ [article.author, config.defaultAuthor].filter(
576
+ (author) => typeof author === "string" && author.trim().length > 0
577
+ )
578
+ )
579
+ );
580
+ const authorValues = (_a = article.authors) != null ? _a : fallbackAuthors;
581
+ if (authorValues.length === 0) return [];
582
+ const resolvedAuthors = authorValues.map((author) => {
583
+ var _a2;
584
+ return (_a2 = getAuthorBySlug(author, config)) != null ? _a2 : getConfiguredAuthorByName(author, config);
585
+ }).filter((author) => author !== null).filter((author, index, all) => all.findIndex((a) => a.slug === author.slug) === index);
586
+ if (resolvedAuthors.length > 0) return resolvedAuthors;
587
+ return authorValues.map((fallbackName) => ({
588
+ name: fallbackName,
589
+ slug: categoryToSlug(fallbackName),
590
+ bio: ""
591
+ }));
592
+ }
542
593
  function getArticleSummary(slug, config) {
543
594
  return __async(this, null, function* () {
544
595
  try {
@@ -546,20 +597,26 @@ function getArticleSummary(slug, config) {
546
597
  if (!found) return null;
547
598
  const fileContent = fs.readFileSync(found.filePath, "utf8");
548
599
  const { data, content: markdownContent } = matter(fileContent);
549
- const readTime = getReadingTime(markdownContent);
600
+ const { readTime, wordCount } = getReadingStats(markdownContent);
550
601
  const allTags = Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === "string" && String(t).trim()) : [];
551
602
  const categories = allTags.length > 0 ? allTags.map((t) => t.replaceAll("-", " ").trim()) : ["Campaigns"];
603
+ const author = resolveArticleAuthorName(data.author, data.authors, config);
604
+ const authors = parseAuthors(data.authors);
605
+ const primaryAuthorProfile = config ? getArticleAuthors({ author, authors }, config)[0] : void 0;
552
606
  return {
553
607
  slug,
554
608
  title: data.title || slug.replaceAll("-", " "),
555
609
  excerpt: data.excerpt || "",
556
610
  date: parseDateField(data.date),
557
611
  lastmod: parseDateField(data.lastmod),
558
- author: resolveArticleAuthorName(data.author, data.authors, config),
559
- authors: parseAuthors(data.authors),
612
+ author,
613
+ authors,
614
+ authorSlug: primaryAuthorProfile == null ? void 0 : primaryAuthorProfile.slug,
615
+ authorAvatar: primaryAuthorProfile ? getAuthorAvatar(primaryAuthorProfile, config) : void 0,
560
616
  category: categories[0],
561
617
  categories,
562
618
  readTime,
619
+ wordCount,
563
620
  featuredImage: resolveFeaturedImage(data.featuredImage, slug),
564
621
  tags: data.tags || [],
565
622
  contentType: found.contentType,
@@ -569,7 +626,15 @@ function getArticleSummary(slug, config) {
569
626
  canonicalUrl: typeof data.canonicalUrl === "string" ? data.canonicalUrl : void 0,
570
627
  articleType: typeof data.articleType === "string" ? data.articleType : void 0,
571
628
  series: typeof data.series === "string" ? data.series : void 0,
572
- aiCrawl: data.aiCrawl === true
629
+ seriesSlug: parseOptionalString(data.seriesSlug),
630
+ seriesOrder: parseSeriesOrder(data.seriesOrder),
631
+ aiCrawl: data.aiCrawl === true,
632
+ searchTitle: parseOptionalString(data.searchTitle),
633
+ searchDescription: parseOptionalString(data.searchDescription),
634
+ socialTitle: parseOptionalString(data.socialTitle),
635
+ socialDescription: parseOptionalString(data.socialDescription),
636
+ socialImage: parseOptionalString(data.socialImage),
637
+ primaryAction: parsePrimaryAction(data.primaryAction)
573
638
  };
574
639
  } catch (error) {
575
640
  reportArticlesError({
@@ -673,6 +738,9 @@ function getArticleAiHeaders(article, config) {
673
738
  "X-Robots-Tag": "noai, noimageai"
674
739
  };
675
740
  }
741
+ function categoryToSlug(category) {
742
+ return category.toLowerCase().replaceAll(/\s+/g, "-").replaceAll(/[^a-z0-9-]/g, "");
743
+ }
676
744
 
677
745
  // src/nextjs.ts
678
746
  function createArticleMarkdownHandler(config) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server-articles.ts","../src/markdown.ts","../src/errorReporting.ts","../src/nextjs.ts"],"sourcesContent":["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 { markdownToHtml, extractToc } from './markdown'\nimport type { Article, AuthorProfile, CategoryInfo, FaqItem, HowToStep } from './articleTypes'\nimport type { ArticlesConfig } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\nconst articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')\n\nfunction getReadingTime(content: string): string {\n return readingTime(content).text\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\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 = getReadingTime(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 return {\n slug,\n title: data.title || slug.replaceAll('-', ' '),\n excerpt: data.excerpt || '',\n date: parseDateField(data.date),\n lastmod: parseDateField(data.lastmod),\n author: resolveArticleAuthorName(data.author, data.authors, config),\n authors: parseAuthors(data.authors),\n category: categories[0],\n categories,\n readTime,\n featuredImage: resolveFeaturedImage(data.featuredImage, slug),\n tags: data.tags || [],\n contentType: found.contentType,\n draft: data.draft === true,\n faq: parseFaqItems(data.faq),\n howTo: parseHowToSteps(data.howTo),\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 aiCrawl: data.aiCrawl === true,\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(slug: string): Promise<string | null> {\n try {\n const summary = await getArticleSummary(slug)\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\nexport async function getArticleMarkdownResponse(\n slug: string,\n config: ArticlesConfig\n): Promise<Response> {\n const markdown = await getArticleMarkdown(slug)\n if (markdown === null) return new Response('Not Found', { status: 404 })\n const article = await getArticleMetadata(slug)\n return new Response(markdown, {\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(): Promise<string> {\n const articles = await getAllArticles()\n const blockedArticles = articles.filter((article) => article.aiCrawl !== true)\n if (blockedArticles.length === 0) return ''\n\n const aiCrawlers = [\n 'GPTBot',\n 'ChatGPT-User',\n 'CCBot',\n 'ClaudeBot',\n 'Claude-User',\n 'PerplexityBot',\n 'Google-Extended',\n ]\n const disallowRules = blockedArticles\n .map((article) => `Disallow: /articles/${article.slug}`)\n .join('\\n')\n\n return aiCrawlers\n .map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\\n'))\n .join('\\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\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\nexport { sanitizeImagePath }\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 { TocItem } from './articleTypes'\nimport type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\ntype LinkTargetOptions = Readonly<{\n strategy?: LinkTargetStrategy\n siteUrl?: string\n}>\n\nconst DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'\n\nfunction 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\nfunction 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\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.use(rehypeStringify).process(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\nfunction nodeTextValue(c: ElementContent): string {\n return c.type === 'text' ? (c as { value: string }).value : ''\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 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(markdown)\n return headings\n}\n","export type ArticlesErrorCode =\n | 'article-directory-read-failed'\n | 'article-load-failed'\n | 'article-markdown-load-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","// Next.js API route handler factory — Node.js runtime only\n// Import from '@fullstackdatasolutions/articles/nextjs'\nimport type { NextRequest } from 'next/server'\nimport { getArticleMarkdownResponse } from './server-articles'\nimport type { ArticlesConfig } from './articlesConfig'\n\n/**\n * Returns a Next.js App Router GET handler for serving article markdown.\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 getArticleMarkdownResponse(cleanSlug, config)\n }\n return { GET }\n}\n"],"mappings":"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","names":["sanitizeImagePath"]}
1
+ {"version":3,"sources":["../src/server-articles.ts","../src/authorUtils.ts","../src/markdown.ts","../src/errorReporting.ts","../src/nextjs.ts"],"sourcesContent":["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 } from './markdown'\nimport type {\n Article,\n AuthorProfile,\n CategoryInfo,\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\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: parseDateField(data.lastmod),\n author,\n authors,\n authorSlug: primaryAuthorProfile?.slug,\n authorAvatar: primaryAuthorProfile\n ? getAuthorAvatar(primaryAuthorProfile, config)\n : 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),\n howTo: parseHowToSteps(data.howTo),\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: data.aiCrawl === true,\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(slug: string): Promise<string | null> {\n try {\n const summary = await getArticleSummary(slug)\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\nexport async function getArticleMarkdownResponse(\n slug: string,\n config: ArticlesConfig\n): Promise<Response> {\n const markdown = await getArticleMarkdown(slug)\n if (markdown === null) return new Response('Not Found', { status: 404 })\n const article = await getArticleMetadata(slug)\n return new Response(markdown, {\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(): Promise<string> {\n const articles = await getAllArticles()\n const blockedArticles = articles.filter((article) => article.aiCrawl !== true)\n if (blockedArticles.length === 0) return ''\n\n const aiCrawlers = [\n 'GPTBot',\n 'ChatGPT-User',\n 'CCBot',\n 'ClaudeBot',\n 'Claude-User',\n 'PerplexityBot',\n 'Google-Extended',\n ]\n const disallowRules = blockedArticles\n .map((article) => `Disallow: /articles/${article.slug}`)\n .join('\\n')\n\n return aiCrawlers\n .map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\\n'))\n .join('\\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\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 return getAuthorSocialLinks(author).map((link) => link.href)\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 { TocItem } from './articleTypes'\nimport type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'\nimport { reportArticlesError } from './errorReporting'\n\ntype LinkTargetOptions = Readonly<{\n strategy?: LinkTargetStrategy\n siteUrl?: string\n}>\n\nconst DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'\n\nfunction 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\nfunction 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\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.use(rehypeStringify).process(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\nfunction nodeTextValue(c: ElementContent): string {\n return c.type === 'text' ? (c as { value: string }).value : ''\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(markdown)\n return headings\n}\n","export type ArticlesErrorCode =\n | 'article-directory-read-failed'\n | 'article-load-failed'\n | 'article-markdown-load-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","// Next.js API route handler factory — Node.js runtime only\n// Import from '@fullstackdatasolutions/articles/nextjs'\nimport type { NextRequest } from 'next/server'\nimport { getArticleMarkdownResponse } from './server-articles'\nimport type { ArticlesConfig } from './articlesConfig'\n\n/**\n * Returns a Next.js App Router GET handler for serving article markdown.\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 getArticleMarkdownResponse(cleanSlug, config)\n }\n return { GET }\n}\n"],"mappings":"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","names":["path","sanitizeImagePath","_a"]}