@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
@@ -14,6 +14,23 @@ export interface HowToStep {
14
14
  text: string
15
15
  }
16
16
 
17
+ /**
18
+ * A real-world thing an article is about, emitted as schema.org `about`.
19
+ * `sameAs` should point at an authoritative identifier for the entity
20
+ * (Wikipedia, Wikidata, an official site) - that URL is what lets a consumer
21
+ * resolve "Pathfinder" the game system rather than guessing from the string.
22
+ */
23
+ export interface EntityReference {
24
+ name: string
25
+ sameAs?: string
26
+ }
27
+
28
+ /** An outbound source an article cites, emitted as schema.org `citation`. */
29
+ export interface CitationReference {
30
+ name: string
31
+ url?: string
32
+ }
33
+
17
34
  export interface Article {
18
35
  slug: string
19
36
  title: string
@@ -44,6 +61,18 @@ export interface Article {
44
61
  toc?: TocItem[]
45
62
  faq?: FaqItem[]
46
63
  howTo?: HowToStep[]
64
+ /**
65
+ * A direct, self-contained answer to the article's core question, 40-60
66
+ * words. Rendered by `ArticleAnswer`, emitted as the Article schema's
67
+ * `abstract`, used as the `acceptedAnswer` when `articleType` is
68
+ * `'QAPage'`, and placed at the top of the article's markdown twin - the
69
+ * one passage most likely to be lifted verbatim by an answer engine.
70
+ */
71
+ answer?: string
72
+ /** Entities this article is about. Emitted as schema.org `about`. */
73
+ about?: EntityReference[]
74
+ /** Outbound sources. Emitted as schema.org `citation`. */
75
+ citation?: CitationReference[]
47
76
  canonicalUrl?: string
48
77
  articleType?: string
49
78
  series?: string
@@ -165,6 +194,13 @@ export interface AuthorProfile {
165
194
  promise?: string
166
195
  /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
167
196
  originStory?: RichText
197
+ /**
198
+ * Topics this author writes about, emitted as Person `knowsAbout`. Omit to
199
+ * derive it from the categories of their own published articles - unlike
200
+ * `credentials`/`proof`, subject matter is verifiable from the corpus
201
+ * itself, so it does belong in structured data.
202
+ */
203
+ knowsAbout?: string[]
168
204
  /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
169
205
  servesWho?: string[]
170
206
  /** Core beliefs/approach statements. */
@@ -177,6 +213,27 @@ export interface AuthorProfile {
177
213
  credentials?: string[]
178
214
  /** Concrete, sourceable proof points. */
179
215
  proof?: ProofItem[]
216
+ /**
217
+ * The one canonical URL identifying this person across every site they
218
+ * publish on. Used *only* to derive the Person `@id`, so the same author
219
+ * resolves to a single entity everywhere instead of one entity per site.
220
+ *
221
+ * Deliberately separate from `url`: that field also drives byline link
222
+ * targets, the author page's Open Graph URL, and its `CollectionPage` URL,
223
+ * so pointing it at another domain would send readers off-site and hand
224
+ * this site's author page a canonical belonging to a different one.
225
+ * `identityUrl` changes nothing a reader sees.
226
+ *
227
+ * Set the same value on every site. Falls back to `url`, then to this
228
+ * site's own author page.
229
+ */
230
+ identityUrl?: string
231
+ /**
232
+ * Additional profile URLs identifying this same person elsewhere, merged
233
+ * into the Person schema's `sameAs` alongside the derived social links.
234
+ * List the author's pages on the other sites here.
235
+ */
236
+ sameAs?: string[]
180
237
  /** Primary call-to-action rendered on the author's page. */
181
238
  primaryCta?: {
182
239
  label: string
@@ -1,5 +1,5 @@
1
1
  import type { ComponentType } from 'react'
2
- import type { AuthorProfile, PathDefinition } from './articleTypes'
2
+ import type { AuthorProfile, EntityReference, PathDefinition } from './articleTypes'
3
3
  import type { ArticleEventHandler } from './events'
4
4
 
5
5
  /** Keys for each renderable section of the articles listing page. */
@@ -141,6 +141,56 @@ export interface BreadcrumbsConfig {
141
141
  labels?: BreadcrumbLabels
142
142
  }
143
143
 
144
+ /** Payload passed to `ArticlesConfig.onAiCrawl`. */
145
+ export interface AiCrawlEvent {
146
+ /** Article slug whose markdown twin was fetched. */
147
+ slug: string
148
+ /** Matched crawler name (e.g. `'GPTBot'`), or `'unknown'` when the agent is not recognized. */
149
+ crawler: string
150
+ /** Raw `User-Agent` header, or an empty string when absent. */
151
+ userAgent: string
152
+ }
153
+
154
+ /**
155
+ * The publishing entity behind the site. Drives `OrganizationSchema`,
156
+ * `WebSiteSchema`, and the `publisher` reference on every article - so all
157
+ * three point at one `@id` instead of repeating an inline, unlinked
158
+ * `Organization` stub per page.
159
+ */
160
+ export interface OrganizationConfig {
161
+ /** schema.org type. Use `'Person'` for a personal brand. Default: `'Organization'`. */
162
+ type?: 'Organization' | 'Person'
163
+ /** Entity name. Falls back to `siteName`. */
164
+ name?: string
165
+ /** Entity homepage. Falls back to `siteUrl`. */
166
+ url?: string
167
+ /** Logo URL. Site-relative paths are resolved against `siteUrl`. */
168
+ logo?: string
169
+ /** Entity description. Falls back to `ArticlesConfig.description`. */
170
+ description?: string
171
+ /** Profile URLs that identify the same entity elsewhere (social, Crunchbase, Wikidata). */
172
+ sameAs?: string[]
173
+ /**
174
+ * The umbrella entity this site belongs to, for a network of sites run by
175
+ * one publisher. Emitted as `parentOrganization` - the link that lets
176
+ * authority earned by the network attach to each site in it, instead of
177
+ * each site standing alone.
178
+ */
179
+ parentOrganization?: {
180
+ name: string
181
+ url: string
182
+ }
183
+ /**
184
+ * Search URL template for the `WebSite` `SearchAction`, e.g.
185
+ * `'/search?q={search_term_string}'`. Omitted by default - the library's
186
+ * own search is client-side with no crawlable results URL, so declaring one
187
+ * that does not exist would be a false claim. Only set this if the app
188
+ * actually serves search results at that URL. Must contain the literal
189
+ * `{search_term_string}` placeholder.
190
+ */
191
+ searchUrlTemplate?: string
192
+ }
193
+
144
194
  /** Top-level configuration object. Pass one instance to every library component. */
145
195
  export interface ArticlesConfig {
146
196
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -203,6 +253,94 @@ export interface ArticlesConfig {
203
253
  * step rather than a build-time failure otherwise.
204
254
  */
205
255
  paths?: Record<string, PathDefinition>
256
+ /**
257
+ * Template for the `<title>` tag on article, category, series, and author
258
+ * pages. Supports `{title}` and `{siteName}` placeholders.
259
+ * Default: `'{title} | {siteName}'`.
260
+ *
261
+ * Google truncates a result title around 60 characters, and a site name
262
+ * suffix spends that budget on every page. Set `'{title}'` to drop it when
263
+ * your titles are already long and your brand draws little search volume -
264
+ * the suffix is only earning its characters if people search for the brand.
265
+ */
266
+ titleTemplate?: string
267
+ /**
268
+ * BCP 47 language tag for the site's content. Emitted as the Article
269
+ * schema's `inLanguage` and the RSS channel `<language>`. Default: `'en'`.
270
+ */
271
+ language?: string
272
+ /**
273
+ * Set to `false` when articles sit behind a paywall or registration wall.
274
+ * Default: `true`, emitted as the Article schema's `isAccessibleForFree` -
275
+ * an explicit "this is readable" signal, since consumers that cannot tell
276
+ * tend to skip suspected-paywalled sources.
277
+ */
278
+ isAccessibleForFree?: boolean
279
+ /**
280
+ * CSS selectors marking the parts of an article suitable for text-to-speech,
281
+ * emitted as the Article schema's `speakable`. Omitted by default - the
282
+ * correct selectors depend on the consuming app's own markup, and guessing
283
+ * them would point at elements that may not exist.
284
+ */
285
+ speakableSelectors?: string[]
286
+ /**
287
+ * Set to `true` to derive `FAQPage` entries from question-shaped `##`
288
+ * headings and the paragraph that follows each one. Default: `false` -
289
+ * turning prose into structured data without the author's intent can
290
+ * promote a rhetorical heading into a published Q&A pair, so this is
291
+ * opt-in. Explicit `faq` frontmatter always wins over derived entries.
292
+ */
293
+ deriveFaqFromHeadings?: boolean
294
+ /**
295
+ * Shared entity vocabulary, keyed by an app-chosen slug. Article `about`
296
+ * entries may reference a key here instead of repeating a name/`sameAs`
297
+ * pair - the point of `about` is that a consumer can resolve one entity
298
+ * across a corpus, which free-text names spelled three different ways
299
+ * defeat. `validateArticles` warns on `about` keys with no registry entry.
300
+ */
301
+ entities?: Record<string, EntityReference>
302
+ /**
303
+ * Where `lastmod` comes from when frontmatter omits it.
304
+ * - `'published'` (default): reuse the publish date, as before 1.3.0.
305
+ * - `'none'`: leave `lastmod` unset, so `dateModified` is omitted rather
306
+ * than repeating a stale publish date.
307
+ * - `'fileMtime'`: read the article file's modification time. Accurate
308
+ * locally; on a CI runner that clones fresh, every file's mtime is the
309
+ * checkout time, which would report the whole corpus as updated today.
310
+ * Only use it where the build preserves mtimes.
311
+ */
312
+ lastmodFallback?: 'published' | 'none' | 'fileMtime'
313
+ /**
314
+ * Called when an AI crawler fetches an article's markdown twin. The one
315
+ * choke point where those requests land, so it is the only place a site
316
+ * can measure whether any of its AI-readable content is being read, and
317
+ * by which bot. No PII: the payload carries the slug, the matched crawler
318
+ * name, and the raw user agent string only.
319
+ */
320
+ onAiCrawl?: (event: AiCrawlEvent) => void
321
+ /**
322
+ * The publishing entity behind the site. Omit to keep the pre-1.3.0 inline
323
+ * `{'@type':'Organization', name: siteName}` publisher stub on articles.
324
+ * Set it to emit `OrganizationSchema`/`WebSiteSchema` in the root layout and
325
+ * have every article, author, and collection page reference the same `@id`.
326
+ */
327
+ organization?: OrganizationConfig
328
+ /**
329
+ * Default `aiCrawl` value for articles whose frontmatter omits the key.
330
+ * Default: `false` (every article stays opted out unless it sets
331
+ * `aiCrawl: true`). Set to `true` on a site whose goal is being cited by
332
+ * answer engines to opt the whole corpus in at once; per-article
333
+ * `aiCrawl: false` still wins and keeps that article blocked.
334
+ */
335
+ aiCrawlDefault?: boolean
336
+ /**
337
+ * Set to `false` to serve article markdown twins as the bare body, with no
338
+ * attribution header. Default: `true` - the twin is prefixed with the
339
+ * title, excerpt, canonical source URL, dates, author, and site name so a
340
+ * model reading `/articles/[slug].md` can attribute it. Only applies when
341
+ * a config is available (i.e. via `getArticleMarkdownResponse`).
342
+ */
343
+ markdownTwinHeader?: boolean
206
344
  /**
207
345
  * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
208
346
  * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
@@ -231,3 +369,40 @@ export function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig
231
369
  if (config.breadcrumbs === false) return {}
232
370
  return config.breadcrumbs ?? {}
233
371
  }
372
+
373
+ /** Stable `@id` for the site's publishing entity. */
374
+ export function getOrganizationId(config: Pick<ArticlesConfig, 'siteUrl'>): string {
375
+ return `${config.siteUrl.replace(/\/$/, '')}/#organization`
376
+ }
377
+
378
+ /** Stable `@id` for the site itself. */
379
+ export function getWebSiteId(config: Pick<ArticlesConfig, 'siteUrl'>): string {
380
+ return `${config.siteUrl.replace(/\/$/, '')}/#website`
381
+ }
382
+
383
+ /** Stable `@id` for an author, so every article by them resolves to one Person. */
384
+ export function getPersonId(authorUrl: string): string {
385
+ return `${authorUrl.replace(/\/$/, '')}#person`
386
+ }
387
+
388
+ /** Resolves a possibly site-relative asset path against `siteUrl`. */
389
+ export function resolveEntityUrl(value: string, config: Pick<ArticlesConfig, 'siteUrl'>): string {
390
+ if (/^https?:\/\//.test(value)) return value
391
+ return `${config.siteUrl.replace(/\/$/, '')}/${value.replace(/^\/+/, '')}`
392
+ }
393
+
394
+ export const DEFAULT_TITLE_TEMPLATE = '{title} | {siteName}'
395
+
396
+ /**
397
+ * Applies `ArticlesConfig.titleTemplate` to a page title. Kept here rather
398
+ * than inlined at each call site so article, category, series, and author
399
+ * pages can never drift apart on how the site name is appended.
400
+ */
401
+ export function formatPageTitle(
402
+ title: string,
403
+ config: Pick<ArticlesConfig, 'siteName' | 'titleTemplate'>
404
+ ): string {
405
+ return (config.titleTemplate ?? DEFAULT_TITLE_TEMPLATE)
406
+ .replaceAll('{title}', title)
407
+ .replaceAll('{siteName}', config.siteName)
408
+ }
@@ -1,6 +1,22 @@
1
1
  import type { ArticlesConfig } from './articlesConfig'
2
2
  import type { AuthorProfile, AuthorSocial } from './articleTypes'
3
3
 
4
+ /**
5
+ * The canonical identity URL a Person `@id` derives from. Prefers the
6
+ * explicit cross-site `identityUrl`, then `url`, then this site's own author
7
+ * page - so an author who configures nothing keeps a per-site identity, and
8
+ * one who sets `identityUrl` gets the same `@id` on every site.
9
+ */
10
+ export function getAuthorIdentityUrl(
11
+ author: AuthorProfile,
12
+ config?: Pick<ArticlesConfig, 'siteUrl'>
13
+ ): string | undefined {
14
+ if (author.identityUrl) return author.identityUrl
15
+ if (author.url) return author.url
16
+ if (!config) return undefined
17
+ return `${config.siteUrl.replace(/\/$/, '')}/articles/authors/${author.slug}`
18
+ }
19
+
4
20
  export function getAuthorUrl(author: AuthorProfile): string {
5
21
  return author.url ?? `/articles/authors/${author.slug}`
6
22
  }
@@ -19,7 +35,9 @@ export function getAuthorAvatar(
19
35
  }
20
36
 
21
37
  export function getAuthorSameAs(author: AuthorProfile): string[] {
22
- return getAuthorSocialLinks(author).map((link) => link.href)
38
+ const derived = getAuthorSocialLinks(author).map((link) => link.href)
39
+ const explicit = author.sameAs?.filter((url) => url.trim() !== '') ?? []
40
+ return [...new Set([...derived, ...explicit])]
23
41
  }
24
42
 
25
43
  export interface AuthorSocialLink {
@@ -2,6 +2,7 @@ export type ArticlesErrorCode =
2
2
  | 'article-directory-read-failed'
3
3
  | 'article-load-failed'
4
4
  | 'article-markdown-load-failed'
5
+ | 'ai-crawl-handler-failed'
5
6
  | 'markdown-conversion-failed'
6
7
  | 'unsafe-image-path'
7
8
 
package/src/index.ts CHANGED
@@ -16,13 +16,20 @@ export { AuthorArticlesPage } from './AuthorArticlesPage'
16
16
  export { AuthorCard, AuthorSocialLinks } from './AuthorCard'
17
17
  export { AuthorDetailHero } from './AuthorDetailHero'
18
18
  export { Breadcrumb, BreadcrumbSchema } from './Breadcrumb'
19
- export { ArticleSEO, CollectionPageSchema, FAQPageSchema } from './ArticleSchemas'
19
+ export {
20
+ ArticleSEO,
21
+ CollectionPageSchema,
22
+ FAQPageSchema,
23
+ OrganizationSchema,
24
+ WebSiteSchema,
25
+ } from './ArticleSchemas'
20
26
 
21
27
  export { ArticleSocialShare } from './ArticleSocialShare'
22
28
  export { ArticleNavigation } from './ArticleNavigation'
23
29
  export { RelatedArticlesSection } from './RelatedArticlesSection'
24
30
  export { ArticleBackLink } from './ArticleBackLink'
25
31
  export { ArticleTOC } from './ArticleTOC'
32
+ export { ArticleAnswer } from './ArticleAnswer'
26
33
  export { ScrollToTop } from './ScrollToTop'
27
34
  export { PaginationNav } from './PaginationNav'
28
35
  export { ArticleViewTracker, CtaViewTracker } from './eventTracking'
@@ -56,13 +63,20 @@ export type {
56
63
  CategoryBreadcrumbEntry,
57
64
  AuthorBreadcrumbEntry,
58
65
  CustomBreadcrumbItem,
66
+ OrganizationConfig,
67
+ AiCrawlEvent,
59
68
  } from './articlesConfig'
60
69
  export {
61
70
  DEFAULT_LAYOUT,
62
71
  DEFAULT_PAGE_SIZE,
63
72
  DEFAULT_CATEGORIES_PAGE_SIZE,
73
+ DEFAULT_TITLE_TEMPLATE,
64
74
  breadcrumbsAreEnabled,
75
+ formatPageTitle,
65
76
  getBreadcrumbsConfig,
77
+ getOrganizationId,
78
+ getPersonId,
79
+ getWebSiteId,
66
80
  } from './articlesConfig'
67
81
  export type {
68
82
  Article,
@@ -70,6 +84,8 @@ export type {
70
84
  AuthorSocial,
71
85
  BreadcrumbItem,
72
86
  CategoryInfo,
87
+ CitationReference,
88
+ EntityReference,
73
89
  FaqItem,
74
90
  HowToStep,
75
91
  PathDefinition,
package/src/markdown.ts CHANGED
@@ -10,7 +10,7 @@ import remarkParse from 'remark-parse'
10
10
  import remarkRehype from 'remark-rehype'
11
11
  import { Plugin } from 'unified'
12
12
  import { visit } from 'unist-util-visit'
13
- import type { TocItem } from './articleTypes'
13
+ import type { FaqItem, TocItem } from './articleTypes'
14
14
  import type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
15
15
  import { reportArticlesError } from './errorReporting'
16
16
  import { isExternalHttpLink, isNonBrowserNavigationLink } from './linkClassification'
@@ -473,3 +473,102 @@ export async function extractToc(markdown: string): Promise<TocItem[]> {
473
473
  .process(stripInlineTagsFromHeadings(markdown))
474
474
  return headings
475
475
  }
476
+
477
+ // Interrogatives that open a genuine reader question. Deliberately a closed
478
+ // list rather than "any heading ending in ?" - a rhetorical heading like
479
+ // "Sound familiar?" ends in a question mark too, and promoting it to a
480
+ // published Q&A pair would be worse than omitting it.
481
+ const QUESTION_OPENERS =
482
+ /^(what|how|why|when|where|who|which|can|should|does|do|is|are|will|would|must)\b/i
483
+
484
+ /**
485
+ * Derives `FaqItem`s from question-shaped `##` headings and the prose that
486
+ * follows each one, for `ArticlesConfig.deriveFaqFromHeadings`.
487
+ *
488
+ * Line-based on purpose: it runs for every article at summary time, and the
489
+ * shapes it must reject (fenced code, a heading with no prose under it) are
490
+ * cheaper to detect by scanning than by walking a parsed AST.
491
+ */
492
+ export function deriveFaqFromHeadings(markdown: string): FaqItem[] {
493
+ const items: FaqItem[] = []
494
+ const collector = createFaqCollector(items)
495
+ let inFence = false
496
+
497
+ for (const line of markdown.split('\n')) {
498
+ if (FENCE.test(line)) {
499
+ inFence = !inFence
500
+ continue
501
+ }
502
+ if (inFence) continue
503
+ collector.consume(line)
504
+ }
505
+ collector.flush()
506
+
507
+ return items
508
+ }
509
+
510
+ const FENCE = /^\s*(```|~~~)/
511
+ const ANY_HEADING = /^#{1,6}\s/
512
+
513
+ function isSpace(char: string | undefined): boolean {
514
+ return char === ' ' || char === '\t'
515
+ }
516
+
517
+ /** Drops closing-hash decoration (`## Heading ##`) without a `#+$` scan. */
518
+ function trimTrailingHashes(value: string): string {
519
+ let end = value.length
520
+ while (end > 0 && value[end - 1] === '#') end--
521
+ return value.slice(0, end).trimEnd()
522
+ }
523
+
524
+ /**
525
+ * Extracts a question-shaped h2's text, or `null` for any other line.
526
+ *
527
+ * Deliberately string operations rather than a capture regex: every anchored
528
+ * form of this (`(.*\S)\s*$`, `\s+(.*)$`, `#+$`) backtracks super-linearly,
529
+ * and this runs over every line of every article.
530
+ */
531
+ function readQuestionHeading(line: string): string | null {
532
+ if (!line.startsWith('##') || line.startsWith('###')) return null
533
+ if (!isSpace(line[2])) return null
534
+ const text = trimTrailingHashes(line.slice(3).trim())
535
+ if (!text.endsWith('?') || !QUESTION_OPENERS.test(text)) return null
536
+ return text
537
+ }
538
+
539
+ /**
540
+ * Line-at-a-time state machine pairing a question heading with the prose
541
+ * under it. Split out of `deriveFaqFromHeadings` so the loop there stays a
542
+ * flat fence check plus a delegation.
543
+ */
544
+ function createFaqCollector(items: FaqItem[]) {
545
+ let pending: string | null = null
546
+ let buffer: string[] = []
547
+
548
+ const flush = (): void => {
549
+ if (pending && buffer.length > 0) {
550
+ items.push({ question: pending, answer: buffer.join(' ').trim() })
551
+ }
552
+ pending = null
553
+ buffer = []
554
+ }
555
+
556
+ const consume = (line: string): void => {
557
+ if (ANY_HEADING.test(line)) {
558
+ flush()
559
+ pending = readQuestionHeading(line)
560
+ return
561
+ }
562
+ if (!pending) return
563
+ // A blank line only ends an answer that has already started - the blank
564
+ // between a heading and its first paragraph must not discard the pending
565
+ // question.
566
+ if (line.trim() === '') {
567
+ if (buffer.length > 0) flush()
568
+ return
569
+ }
570
+ buffer.push(line.trim())
571
+ }
572
+
573
+ return { consume, flush }
574
+ }
package/src/nextjs.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  // Next.js API route handler factory — Node.js runtime only
2
2
  // Import from '@fullstackdatasolutions/articles/nextjs'
3
3
  import type { NextRequest } from 'next/server'
4
- import { getArticleMarkdownResponse } from './server-articles'
4
+ import { getMarkdownTwinResponse } from './server-articles'
5
5
  import type { ArticlesConfig } from './articlesConfig'
6
6
 
7
7
  /**
8
- * Returns a Next.js App Router GET handler for serving article markdown.
8
+ * Returns a Next.js App Router GET handler for serving markdown twins -
9
+ * articles plus category/author/series listings, dispatched by slug prefix.
10
+ * The request is passed through so `ArticlesConfig.onAiCrawl` can see the
11
+ * user agent.
9
12
  *
10
13
  * Usage in app/api/articles-markdown/[...slug]/route.ts:
11
14
  * import { createArticleMarkdownHandler } from '@fullstackdatasolutions/articles/nextjs'
@@ -14,14 +17,14 @@ import type { ArticlesConfig } from './articlesConfig'
14
17
  */
15
18
  export function createArticleMarkdownHandler(config: ArticlesConfig) {
16
19
  async function GET(
17
- _request: NextRequest,
20
+ request: NextRequest,
18
21
  context: { params: Promise<Record<string, string | string[]>> }
19
22
  ) {
20
23
  const params = await context.params
21
24
  const slugParts = params['slug']
22
25
  const slug = Array.isArray(slugParts) ? slugParts.join('/') : (slugParts ?? '')
23
26
  const cleanSlug = slug.endsWith('.md') ? slug.slice(0, -3) : slug
24
- return getArticleMarkdownResponse(cleanSlug, config)
27
+ return getMarkdownTwinResponse(cleanSlug, config, { headers: request.headers })
25
28
  }
26
29
  return { GET }
27
30
  }