@fullstackdatasolutions/articles 1.2.3 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +313 -1
  3. package/dist/index.cjs +308 -79
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +267 -16
  6. package/dist/index.d.ts +267 -16
  7. package/dist/index.js +300 -79
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +325 -31
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +179 -2
  12. package/dist/nextjs.d.ts +179 -2
  13. package/dist/nextjs.js +325 -31
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +660 -50
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +333 -12
  18. package/dist/server.d.ts +333 -12
  19. package/dist/server.js +645 -50
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleAnswer.tsx +35 -0
  23. package/src/ArticleSchemas.tsx +263 -23
  24. package/src/AuthorArticlesPage.tsx +38 -8
  25. package/src/__tests__/ArticleAnswer.test.tsx +25 -0
  26. package/src/__tests__/ArticleSchemas.test.tsx +516 -0
  27. package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
  28. package/src/__tests__/authorUtils.test.ts +50 -0
  29. package/src/__tests__/markdown.test.ts +77 -1
  30. package/src/__tests__/nextjs.test.ts +31 -15
  31. package/src/__tests__/seoUtils.test.ts +279 -0
  32. package/src/__tests__/server-articles.test.ts +434 -1
  33. package/src/__tests__/validateArticles.test.ts +167 -6
  34. package/src/articleTypes.ts +57 -0
  35. package/src/articlesConfig.ts +176 -1
  36. package/src/authorUtils.ts +19 -1
  37. package/src/errorReporting.ts +1 -0
  38. package/src/index.ts +17 -1
  39. package/src/markdown.ts +100 -1
  40. package/src/nextjs.ts +7 -4
  41. package/src/seoUtils.ts +247 -26
  42. package/src/server-articles.ts +385 -25
  43. package/src/server.ts +35 -4
  44. package/src/validateArticles.ts +157 -12
package/dist/nextjs.d.cts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import { ComponentType } from 'react';
3
3
 
4
+ /**
5
+ * A real-world thing an article is about, emitted as schema.org `about`.
6
+ * `sameAs` should point at an authoritative identifier for the entity
7
+ * (Wikipedia, Wikidata, an official site) - that URL is what lets a consumer
8
+ * resolve "Pathfinder" the game system rather than guessing from the string.
9
+ */
10
+ interface EntityReference {
11
+ name: string;
12
+ sameAs?: string;
13
+ }
4
14
  /**
5
15
  * A "start here" curated reader journey that can cross series/categories -
6
16
  * a distinct primitive from the label-only `series` field/`seriesSlug`
@@ -67,6 +77,13 @@ interface AuthorProfile {
67
77
  promise?: string;
68
78
  /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
69
79
  originStory?: RichText;
80
+ /**
81
+ * Topics this author writes about, emitted as Person `knowsAbout`. Omit to
82
+ * derive it from the categories of their own published articles - unlike
83
+ * `credentials`/`proof`, subject matter is verifiable from the corpus
84
+ * itself, so it does belong in structured data.
85
+ */
86
+ knowsAbout?: string[];
70
87
  /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
71
88
  servesWho?: string[];
72
89
  /** Core beliefs/approach statements. */
@@ -79,6 +96,27 @@ interface AuthorProfile {
79
96
  credentials?: string[];
80
97
  /** Concrete, sourceable proof points. */
81
98
  proof?: ProofItem[];
99
+ /**
100
+ * The one canonical URL identifying this person across every site they
101
+ * publish on. Used *only* to derive the Person `@id`, so the same author
102
+ * resolves to a single entity everywhere instead of one entity per site.
103
+ *
104
+ * Deliberately separate from `url`: that field also drives byline link
105
+ * targets, the author page's Open Graph URL, and its `CollectionPage` URL,
106
+ * so pointing it at another domain would send readers off-site and hand
107
+ * this site's author page a canonical belonging to a different one.
108
+ * `identityUrl` changes nothing a reader sees.
109
+ *
110
+ * Set the same value on every site. Falls back to `url`, then to this
111
+ * site's own author page.
112
+ */
113
+ identityUrl?: string;
114
+ /**
115
+ * Additional profile URLs identifying this same person elsewhere, merged
116
+ * into the Person schema's `sameAs` alongside the derived social links.
117
+ * List the author's pages on the other sites here.
118
+ */
119
+ sameAs?: string[];
82
120
  /** Primary call-to-action rendered on the author's page. */
83
121
  primaryCta?: {
84
122
  label: string;
@@ -249,6 +287,54 @@ interface BreadcrumbsConfig {
249
287
  /** Optional label overrides for built-in breadcrumb items. */
250
288
  labels?: BreadcrumbLabels;
251
289
  }
290
+ /** Payload passed to `ArticlesConfig.onAiCrawl`. */
291
+ interface AiCrawlEvent {
292
+ /** Article slug whose markdown twin was fetched. */
293
+ slug: string;
294
+ /** Matched crawler name (e.g. `'GPTBot'`), or `'unknown'` when the agent is not recognized. */
295
+ crawler: string;
296
+ /** Raw `User-Agent` header, or an empty string when absent. */
297
+ userAgent: string;
298
+ }
299
+ /**
300
+ * The publishing entity behind the site. Drives `OrganizationSchema`,
301
+ * `WebSiteSchema`, and the `publisher` reference on every article - so all
302
+ * three point at one `@id` instead of repeating an inline, unlinked
303
+ * `Organization` stub per page.
304
+ */
305
+ interface OrganizationConfig {
306
+ /** schema.org type. Use `'Person'` for a personal brand. Default: `'Organization'`. */
307
+ type?: 'Organization' | 'Person';
308
+ /** Entity name. Falls back to `siteName`. */
309
+ name?: string;
310
+ /** Entity homepage. Falls back to `siteUrl`. */
311
+ url?: string;
312
+ /** Logo URL. Site-relative paths are resolved against `siteUrl`. */
313
+ logo?: string;
314
+ /** Entity description. Falls back to `ArticlesConfig.description`. */
315
+ description?: string;
316
+ /** Profile URLs that identify the same entity elsewhere (social, Crunchbase, Wikidata). */
317
+ sameAs?: string[];
318
+ /**
319
+ * The umbrella entity this site belongs to, for a network of sites run by
320
+ * one publisher. Emitted as `parentOrganization` - the link that lets
321
+ * authority earned by the network attach to each site in it, instead of
322
+ * each site standing alone.
323
+ */
324
+ parentOrganization?: {
325
+ name: string;
326
+ url: string;
327
+ };
328
+ /**
329
+ * Search URL template for the `WebSite` `SearchAction`, e.g.
330
+ * `'/search?q={search_term_string}'`. Omitted by default - the library's
331
+ * own search is client-side with no crawlable results URL, so declaring one
332
+ * that does not exist would be a false claim. Only set this if the app
333
+ * actually serves search results at that URL. Must contain the literal
334
+ * `{search_term_string}` placeholder.
335
+ */
336
+ searchUrlTemplate?: string;
337
+ }
252
338
  /** Top-level configuration object. Pass one instance to every library component. */
253
339
  interface ArticlesConfig {
254
340
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -311,6 +397,94 @@ interface ArticlesConfig {
311
397
  * step rather than a build-time failure otherwise.
312
398
  */
313
399
  paths?: Record<string, PathDefinition>;
400
+ /**
401
+ * Template for the `<title>` tag on article, category, series, and author
402
+ * pages. Supports `{title}` and `{siteName}` placeholders.
403
+ * Default: `'{title} | {siteName}'`.
404
+ *
405
+ * Google truncates a result title around 60 characters, and a site name
406
+ * suffix spends that budget on every page. Set `'{title}'` to drop it when
407
+ * your titles are already long and your brand draws little search volume -
408
+ * the suffix is only earning its characters if people search for the brand.
409
+ */
410
+ titleTemplate?: string;
411
+ /**
412
+ * BCP 47 language tag for the site's content. Emitted as the Article
413
+ * schema's `inLanguage` and the RSS channel `<language>`. Default: `'en'`.
414
+ */
415
+ language?: string;
416
+ /**
417
+ * Set to `false` when articles sit behind a paywall or registration wall.
418
+ * Default: `true`, emitted as the Article schema's `isAccessibleForFree` -
419
+ * an explicit "this is readable" signal, since consumers that cannot tell
420
+ * tend to skip suspected-paywalled sources.
421
+ */
422
+ isAccessibleForFree?: boolean;
423
+ /**
424
+ * CSS selectors marking the parts of an article suitable for text-to-speech,
425
+ * emitted as the Article schema's `speakable`. Omitted by default - the
426
+ * correct selectors depend on the consuming app's own markup, and guessing
427
+ * them would point at elements that may not exist.
428
+ */
429
+ speakableSelectors?: string[];
430
+ /**
431
+ * Set to `true` to derive `FAQPage` entries from question-shaped `##`
432
+ * headings and the paragraph that follows each one. Default: `false` -
433
+ * turning prose into structured data without the author's intent can
434
+ * promote a rhetorical heading into a published Q&A pair, so this is
435
+ * opt-in. Explicit `faq` frontmatter always wins over derived entries.
436
+ */
437
+ deriveFaqFromHeadings?: boolean;
438
+ /**
439
+ * Shared entity vocabulary, keyed by an app-chosen slug. Article `about`
440
+ * entries may reference a key here instead of repeating a name/`sameAs`
441
+ * pair - the point of `about` is that a consumer can resolve one entity
442
+ * across a corpus, which free-text names spelled three different ways
443
+ * defeat. `validateArticles` warns on `about` keys with no registry entry.
444
+ */
445
+ entities?: Record<string, EntityReference>;
446
+ /**
447
+ * Where `lastmod` comes from when frontmatter omits it.
448
+ * - `'published'` (default): reuse the publish date, as before 1.3.0.
449
+ * - `'none'`: leave `lastmod` unset, so `dateModified` is omitted rather
450
+ * than repeating a stale publish date.
451
+ * - `'fileMtime'`: read the article file's modification time. Accurate
452
+ * locally; on a CI runner that clones fresh, every file's mtime is the
453
+ * checkout time, which would report the whole corpus as updated today.
454
+ * Only use it where the build preserves mtimes.
455
+ */
456
+ lastmodFallback?: 'published' | 'none' | 'fileMtime';
457
+ /**
458
+ * Called when an AI crawler fetches an article's markdown twin. The one
459
+ * choke point where those requests land, so it is the only place a site
460
+ * can measure whether any of its AI-readable content is being read, and
461
+ * by which bot. No PII: the payload carries the slug, the matched crawler
462
+ * name, and the raw user agent string only.
463
+ */
464
+ onAiCrawl?: (event: AiCrawlEvent) => void;
465
+ /**
466
+ * The publishing entity behind the site. Omit to keep the pre-1.3.0 inline
467
+ * `{'@type':'Organization', name: siteName}` publisher stub on articles.
468
+ * Set it to emit `OrganizationSchema`/`WebSiteSchema` in the root layout and
469
+ * have every article, author, and collection page reference the same `@id`.
470
+ */
471
+ organization?: OrganizationConfig;
472
+ /**
473
+ * Default `aiCrawl` value for articles whose frontmatter omits the key.
474
+ * Default: `false` (every article stays opted out unless it sets
475
+ * `aiCrawl: true`). Set to `true` on a site whose goal is being cited by
476
+ * answer engines to opt the whole corpus in at once; per-article
477
+ * `aiCrawl: false` still wins and keeps that article blocked.
478
+ */
479
+ aiCrawlDefault?: boolean;
480
+ /**
481
+ * Set to `false` to serve article markdown twins as the bare body, with no
482
+ * attribution header. Default: `true` - the twin is prefixed with the
483
+ * title, excerpt, canonical source URL, dates, author, and site name so a
484
+ * model reading `/articles/[slug].md` can attribute it. Only applies when
485
+ * a config is available (i.e. via `getArticleMarkdownResponse`).
486
+ */
487
+ markdownTwinHeader?: boolean;
314
488
  /**
315
489
  * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
316
490
  * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
@@ -321,7 +495,10 @@ interface ArticlesConfig {
321
495
  }
322
496
 
323
497
  /**
324
- * Returns a Next.js App Router GET handler for serving article markdown.
498
+ * Returns a Next.js App Router GET handler for serving markdown twins -
499
+ * articles plus category/author/series listings, dispatched by slug prefix.
500
+ * The request is passed through so `ArticlesConfig.onAiCrawl` can see the
501
+ * user agent.
325
502
  *
326
503
  * Usage in app/api/articles-markdown/[...slug]/route.ts:
327
504
  * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
@@ -329,7 +506,7 @@ interface ArticlesConfig {
329
506
  * export const { GET } = createArticleMarkdownHandler(siteConfig)
330
507
  */
331
508
  declare function createArticleMarkdownHandler(config: ArticlesConfig): {
332
- GET: (_request: NextRequest, context: {
509
+ GET: (request: NextRequest, context: {
333
510
  params: Promise<Record<string, string | string[]>>;
334
511
  }) => Promise<Response>;
335
512
  };
package/dist/nextjs.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import { ComponentType } from 'react';
3
3
 
4
+ /**
5
+ * A real-world thing an article is about, emitted as schema.org `about`.
6
+ * `sameAs` should point at an authoritative identifier for the entity
7
+ * (Wikipedia, Wikidata, an official site) - that URL is what lets a consumer
8
+ * resolve "Pathfinder" the game system rather than guessing from the string.
9
+ */
10
+ interface EntityReference {
11
+ name: string;
12
+ sameAs?: string;
13
+ }
4
14
  /**
5
15
  * A "start here" curated reader journey that can cross series/categories -
6
16
  * a distinct primitive from the label-only `series` field/`seriesSlug`
@@ -67,6 +77,13 @@ interface AuthorProfile {
67
77
  promise?: string;
68
78
  /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
69
79
  originStory?: RichText;
80
+ /**
81
+ * Topics this author writes about, emitted as Person `knowsAbout`. Omit to
82
+ * derive it from the categories of their own published articles - unlike
83
+ * `credentials`/`proof`, subject matter is verifiable from the corpus
84
+ * itself, so it does belong in structured data.
85
+ */
86
+ knowsAbout?: string[];
70
87
  /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
71
88
  servesWho?: string[];
72
89
  /** Core beliefs/approach statements. */
@@ -79,6 +96,27 @@ interface AuthorProfile {
79
96
  credentials?: string[];
80
97
  /** Concrete, sourceable proof points. */
81
98
  proof?: ProofItem[];
99
+ /**
100
+ * The one canonical URL identifying this person across every site they
101
+ * publish on. Used *only* to derive the Person `@id`, so the same author
102
+ * resolves to a single entity everywhere instead of one entity per site.
103
+ *
104
+ * Deliberately separate from `url`: that field also drives byline link
105
+ * targets, the author page's Open Graph URL, and its `CollectionPage` URL,
106
+ * so pointing it at another domain would send readers off-site and hand
107
+ * this site's author page a canonical belonging to a different one.
108
+ * `identityUrl` changes nothing a reader sees.
109
+ *
110
+ * Set the same value on every site. Falls back to `url`, then to this
111
+ * site's own author page.
112
+ */
113
+ identityUrl?: string;
114
+ /**
115
+ * Additional profile URLs identifying this same person elsewhere, merged
116
+ * into the Person schema's `sameAs` alongside the derived social links.
117
+ * List the author's pages on the other sites here.
118
+ */
119
+ sameAs?: string[];
82
120
  /** Primary call-to-action rendered on the author's page. */
83
121
  primaryCta?: {
84
122
  label: string;
@@ -249,6 +287,54 @@ interface BreadcrumbsConfig {
249
287
  /** Optional label overrides for built-in breadcrumb items. */
250
288
  labels?: BreadcrumbLabels;
251
289
  }
290
+ /** Payload passed to `ArticlesConfig.onAiCrawl`. */
291
+ interface AiCrawlEvent {
292
+ /** Article slug whose markdown twin was fetched. */
293
+ slug: string;
294
+ /** Matched crawler name (e.g. `'GPTBot'`), or `'unknown'` when the agent is not recognized. */
295
+ crawler: string;
296
+ /** Raw `User-Agent` header, or an empty string when absent. */
297
+ userAgent: string;
298
+ }
299
+ /**
300
+ * The publishing entity behind the site. Drives `OrganizationSchema`,
301
+ * `WebSiteSchema`, and the `publisher` reference on every article - so all
302
+ * three point at one `@id` instead of repeating an inline, unlinked
303
+ * `Organization` stub per page.
304
+ */
305
+ interface OrganizationConfig {
306
+ /** schema.org type. Use `'Person'` for a personal brand. Default: `'Organization'`. */
307
+ type?: 'Organization' | 'Person';
308
+ /** Entity name. Falls back to `siteName`. */
309
+ name?: string;
310
+ /** Entity homepage. Falls back to `siteUrl`. */
311
+ url?: string;
312
+ /** Logo URL. Site-relative paths are resolved against `siteUrl`. */
313
+ logo?: string;
314
+ /** Entity description. Falls back to `ArticlesConfig.description`. */
315
+ description?: string;
316
+ /** Profile URLs that identify the same entity elsewhere (social, Crunchbase, Wikidata). */
317
+ sameAs?: string[];
318
+ /**
319
+ * The umbrella entity this site belongs to, for a network of sites run by
320
+ * one publisher. Emitted as `parentOrganization` - the link that lets
321
+ * authority earned by the network attach to each site in it, instead of
322
+ * each site standing alone.
323
+ */
324
+ parentOrganization?: {
325
+ name: string;
326
+ url: string;
327
+ };
328
+ /**
329
+ * Search URL template for the `WebSite` `SearchAction`, e.g.
330
+ * `'/search?q={search_term_string}'`. Omitted by default - the library's
331
+ * own search is client-side with no crawlable results URL, so declaring one
332
+ * that does not exist would be a false claim. Only set this if the app
333
+ * actually serves search results at that URL. Must contain the literal
334
+ * `{search_term_string}` placeholder.
335
+ */
336
+ searchUrlTemplate?: string;
337
+ }
252
338
  /** Top-level configuration object. Pass one instance to every library component. */
253
339
  interface ArticlesConfig {
254
340
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -311,6 +397,94 @@ interface ArticlesConfig {
311
397
  * step rather than a build-time failure otherwise.
312
398
  */
313
399
  paths?: Record<string, PathDefinition>;
400
+ /**
401
+ * Template for the `<title>` tag on article, category, series, and author
402
+ * pages. Supports `{title}` and `{siteName}` placeholders.
403
+ * Default: `'{title} | {siteName}'`.
404
+ *
405
+ * Google truncates a result title around 60 characters, and a site name
406
+ * suffix spends that budget on every page. Set `'{title}'` to drop it when
407
+ * your titles are already long and your brand draws little search volume -
408
+ * the suffix is only earning its characters if people search for the brand.
409
+ */
410
+ titleTemplate?: string;
411
+ /**
412
+ * BCP 47 language tag for the site's content. Emitted as the Article
413
+ * schema's `inLanguage` and the RSS channel `<language>`. Default: `'en'`.
414
+ */
415
+ language?: string;
416
+ /**
417
+ * Set to `false` when articles sit behind a paywall or registration wall.
418
+ * Default: `true`, emitted as the Article schema's `isAccessibleForFree` -
419
+ * an explicit "this is readable" signal, since consumers that cannot tell
420
+ * tend to skip suspected-paywalled sources.
421
+ */
422
+ isAccessibleForFree?: boolean;
423
+ /**
424
+ * CSS selectors marking the parts of an article suitable for text-to-speech,
425
+ * emitted as the Article schema's `speakable`. Omitted by default - the
426
+ * correct selectors depend on the consuming app's own markup, and guessing
427
+ * them would point at elements that may not exist.
428
+ */
429
+ speakableSelectors?: string[];
430
+ /**
431
+ * Set to `true` to derive `FAQPage` entries from question-shaped `##`
432
+ * headings and the paragraph that follows each one. Default: `false` -
433
+ * turning prose into structured data without the author's intent can
434
+ * promote a rhetorical heading into a published Q&A pair, so this is
435
+ * opt-in. Explicit `faq` frontmatter always wins over derived entries.
436
+ */
437
+ deriveFaqFromHeadings?: boolean;
438
+ /**
439
+ * Shared entity vocabulary, keyed by an app-chosen slug. Article `about`
440
+ * entries may reference a key here instead of repeating a name/`sameAs`
441
+ * pair - the point of `about` is that a consumer can resolve one entity
442
+ * across a corpus, which free-text names spelled three different ways
443
+ * defeat. `validateArticles` warns on `about` keys with no registry entry.
444
+ */
445
+ entities?: Record<string, EntityReference>;
446
+ /**
447
+ * Where `lastmod` comes from when frontmatter omits it.
448
+ * - `'published'` (default): reuse the publish date, as before 1.3.0.
449
+ * - `'none'`: leave `lastmod` unset, so `dateModified` is omitted rather
450
+ * than repeating a stale publish date.
451
+ * - `'fileMtime'`: read the article file's modification time. Accurate
452
+ * locally; on a CI runner that clones fresh, every file's mtime is the
453
+ * checkout time, which would report the whole corpus as updated today.
454
+ * Only use it where the build preserves mtimes.
455
+ */
456
+ lastmodFallback?: 'published' | 'none' | 'fileMtime';
457
+ /**
458
+ * Called when an AI crawler fetches an article's markdown twin. The one
459
+ * choke point where those requests land, so it is the only place a site
460
+ * can measure whether any of its AI-readable content is being read, and
461
+ * by which bot. No PII: the payload carries the slug, the matched crawler
462
+ * name, and the raw user agent string only.
463
+ */
464
+ onAiCrawl?: (event: AiCrawlEvent) => void;
465
+ /**
466
+ * The publishing entity behind the site. Omit to keep the pre-1.3.0 inline
467
+ * `{'@type':'Organization', name: siteName}` publisher stub on articles.
468
+ * Set it to emit `OrganizationSchema`/`WebSiteSchema` in the root layout and
469
+ * have every article, author, and collection page reference the same `@id`.
470
+ */
471
+ organization?: OrganizationConfig;
472
+ /**
473
+ * Default `aiCrawl` value for articles whose frontmatter omits the key.
474
+ * Default: `false` (every article stays opted out unless it sets
475
+ * `aiCrawl: true`). Set to `true` on a site whose goal is being cited by
476
+ * answer engines to opt the whole corpus in at once; per-article
477
+ * `aiCrawl: false` still wins and keeps that article blocked.
478
+ */
479
+ aiCrawlDefault?: boolean;
480
+ /**
481
+ * Set to `false` to serve article markdown twins as the bare body, with no
482
+ * attribution header. Default: `true` - the twin is prefixed with the
483
+ * title, excerpt, canonical source URL, dates, author, and site name so a
484
+ * model reading `/articles/[slug].md` can attribute it. Only applies when
485
+ * a config is available (i.e. via `getArticleMarkdownResponse`).
486
+ */
487
+ markdownTwinHeader?: boolean;
314
488
  /**
315
489
  * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
316
490
  * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
@@ -321,7 +495,10 @@ interface ArticlesConfig {
321
495
  }
322
496
 
323
497
  /**
324
- * Returns a Next.js App Router GET handler for serving article markdown.
498
+ * Returns a Next.js App Router GET handler for serving markdown twins -
499
+ * articles plus category/author/series listings, dispatched by slug prefix.
500
+ * The request is passed through so `ArticlesConfig.onAiCrawl` can see the
501
+ * user agent.
325
502
  *
326
503
  * Usage in app/api/articles-markdown/[...slug]/route.ts:
327
504
  * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
@@ -329,7 +506,7 @@ interface ArticlesConfig {
329
506
  * export const { GET } = createArticleMarkdownHandler(siteConfig)
330
507
  */
331
508
  declare function createArticleMarkdownHandler(config: ArticlesConfig): {
332
- GET: (_request: NextRequest, context: {
509
+ GET: (request: NextRequest, context: {
333
510
  params: Promise<Record<string, string | string[]>>;
334
511
  }) => Promise<Response>;
335
512
  };