@docsector/docsector-reader 1.0.0 β†’ 1.2.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.
package/README.md CHANGED
@@ -22,11 +22,12 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
22
22
 
23
23
  - πŸ“‹ **Copy Page** β€” One-click button copies the current page as raw Markdown, ready to paste into LLMs
24
24
  - πŸ“„ **View as Markdown** β€” Open any page as plain text by appending `.md` to the URL, with locale support (`?lang=`)
25
+ - 🧠 **Markdown Negotiation** β€” Requests with `Accept: text/markdown` receive markdown responses, while browsers keep HTML by default
25
26
  - πŸ€– **Open in ChatGPT / Claude** β€” One-click links to open the current page directly in ChatGPT or Claude for Q&A
26
27
  - πŸ€– **LLM Bot Detection** β€” Automatically serves raw Markdown to known AI crawlers (GPTBot, ClaudeBot, PerplexityBot, GrokBot, and others)
27
28
  - πŸ—ΊοΈ **Sitemap Generation** β€” Automatic `sitemap.xml` generation at build time with all page URLs (requires `siteUrl` in config)
28
29
  - πŸ€– **AI-Friendly robots.txt** β€” Scaffold includes a `robots.txt` explicitly allowing 23 AI crawlers (GPTBot, ClaudeBot, PerplexityBot, GrokBot, etc.)
29
- - πŸ”— **Homepage Link Headers** β€” Auto-generated `Link` response headers for agent discovery (`service-doc`, `service-desc`, `describedby`) per RFC 8288 / RFC 9727
30
+ - πŸ”— **Homepage Link Headers** β€” Auto-generated `Link` response headers for agent discovery (`api-catalog`, `service-doc`, `service-desc`, `describedby`) per RFC 8288 / RFC 9727
30
31
  - πŸ”Œ **MCP Server** β€” Auto-generated [MCP](https://modelcontextprotocol.io) server at `/mcp` for AI assistant integration (Claude Desktop, VS Code, etc.)
31
32
  - πŸ“„ **llms.txt / llms-full.txt** β€” Auto-generated [llms.txt](https://llmstxt.org) index and full-content file for LLM discovery (requires `siteUrl` in config)
32
33
 
@@ -46,8 +47,10 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
46
47
  - ✏️ **Edit on GitHub** β€” Direct links to edit pages on your repository
47
48
  - πŸ“… **Last Updated Date** β€” Automatic per-page "last updated" date from git commit history, locale-formatted
48
49
  - πŸ“Š **Translation Progress** β€” Automatic translation percentage based on header coverage
50
+ - 🧠 **Markdown Negotiation** β€” Responds with Markdown when clients send `Accept: text/markdown`, while keeping HTML as browser default
49
51
  - 🏠 **Markdown Home at Root** β€” Homepage is rendered from `src/pages/Homepage.{lang}.md` directly at `/`
50
52
  - 🧭 **Quick Links Custom Element** β€” Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
53
+ - πŸ—‚οΈ **API Catalog Well-Known** β€” Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
51
54
  - βš™οΈ **Single Config File** β€” Customize branding, links, and languages via `docsector.config.js`
52
55
 
53
56
  ---
@@ -162,6 +165,7 @@ Docsector Reader adds homepage `Link` response headers at build time for agent d
162
165
 
163
166
  Default relations emitted on homepage (`/` and `/index.html`):
164
167
 
168
+ - `rel="api-catalog"` β†’ `</.well-known/api-catalog>`
165
169
  - `rel="service-doc"` β†’ `</>`
166
170
  - `rel="service-desc"` β†’ `</mcp>` (only when `mcp` is enabled)
167
171
  - `rel="describedby"` β†’ `</llms.txt>` (only when `siteUrl` is configured, i.e. `llms.txt` is generated)
@@ -169,6 +173,7 @@ Default relations emitted on homepage (`/` and `/index.html`):
169
173
  Generated in:
170
174
 
171
175
  - `dist/spa/_headers`
176
+ - `dist/spa/.well-known/api-catalog` (Linkset JSON)
172
177
 
173
178
  ### Optional configuration
174
179
 
@@ -178,9 +183,19 @@ export default {
178
183
 
179
184
  linkHeaders: {
180
185
  enabled: true,
186
+ apiCatalog: '/.well-known/api-catalog',
181
187
  serviceDoc: '/',
182
188
  serviceDesc: '/mcp',
183
189
  describedBy: '/llms.txt'
190
+ },
191
+
192
+ apiCatalog: {
193
+ enabled: true,
194
+ path: '/.well-known/api-catalog',
195
+ items: [
196
+ '/mcp',
197
+ 'https://api.example.com/openapi.json'
198
+ ]
184
199
  }
185
200
  }
186
201
  ```
@@ -192,6 +207,7 @@ Set any target to `null` or `false` to disable that relation.
192
207
  ```bash
193
208
  npx docsector build
194
209
  cat dist/spa/_headers
210
+ cat dist/spa/.well-known/api-catalog
195
211
  ```
196
212
 
197
213
  Or scan discoverability:
@@ -343,11 +359,23 @@ export default {
343
359
 
344
360
  linkHeaders: {
345
361
  enabled: true,
362
+ apiCatalog: '/.well-known/api-catalog',
346
363
  serviceDoc: '/',
347
364
  serviceDesc: '/mcp',
348
365
  describedBy: '/llms.txt'
349
366
  },
350
367
 
368
+ apiCatalog: {
369
+ enabled: true,
370
+ path: '/.well-known/api-catalog',
371
+ items: []
372
+ },
373
+
374
+ markdownNegotiation: {
375
+ enabled: true,
376
+ agentFallback: true
377
+ },
378
+
351
379
  languages: [
352
380
  { image: '/images/flags/united-states-of-america.png', label: 'English (US)', value: 'en-US' },
353
381
  { image: '/images/flags/brazil.png', label: 'PortuguΓͺs (BR)', value: 'pt-BR' }
package/bin/docsector.js CHANGED
@@ -23,7 +23,7 @@ const packageRoot = resolve(__dirname, '..')
23
23
  const args = process.argv.slice(2)
24
24
  const command = args[0]
25
25
 
26
- const VERSION = '1.0.0'
26
+ const VERSION = '1.2.0'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
@@ -136,11 +136,25 @@ export default {
136
136
  // @ Homepage Link headers for agent discovery (optional)
137
137
  // linkHeaders: {
138
138
  // enabled: true,
139
+ // apiCatalog: '/.well-known/api-catalog',
139
140
  // serviceDoc: '/',
140
141
  // serviceDesc: '/mcp',
141
142
  // describedBy: '/llms.txt'
142
143
  // },
143
144
 
145
+ // @ API catalog artifact (RFC 9727) (optional)
146
+ // apiCatalog: {
147
+ // enabled: true,
148
+ // path: '/.well-known/api-catalog',
149
+ // items: ['/mcp']
150
+ // },
151
+
152
+ // @ Markdown negotiation for agents (optional)
153
+ // markdownNegotiation: {
154
+ // enabled: true,
155
+ // agentFallback: true
156
+ // },
157
+
144
158
  // @ Languages
145
159
  languages: [
146
160
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A documentation rendering engine built with Vue 3, Quasar v2 and Vite. Transform Markdown into beautiful, navigable documentation sites.",
5
5
  "productName": "Docsector Reader",
6
6
  "author": "Rodrigo de Araujo Vieira",
package/src/index.js CHANGED
@@ -47,8 +47,16 @@
47
47
  * @param {Object} [config.linkHeaders] - Homepage Link headers for agent discovery
48
48
  * @param {boolean} [config.linkHeaders.enabled=true] - Enables homepage Link headers generation
49
49
  * @param {string|null|false} [config.linkHeaders.serviceDoc='/'] - Target URI for rel="service-doc"
50
+ * @param {string|null|false} [config.linkHeaders.apiCatalog='/.well-known/api-catalog'] - Target URI for rel="api-catalog"
50
51
  * @param {string|null|false} [config.linkHeaders.serviceDesc='/mcp'] - Target URI for rel="service-desc" (only emitted when MCP is enabled)
51
52
  * @param {string|null|false} [config.linkHeaders.describedBy='/llms.txt'] - Target URI for rel="describedby" (only emitted when llms.txt is generated)
53
+ * @param {Object} [config.apiCatalog] - API catalog generation settings
54
+ * @param {boolean} [config.apiCatalog.enabled=true] - Enables generation of API catalog artifact
55
+ * @param {string} [config.apiCatalog.path='/.well-known/api-catalog'] - Output URI path for API catalog artifact
56
+ * @param {Array<string|{href: string}>} [config.apiCatalog.items=[]] - Additional API endpoint links to include as item relations
57
+ * @param {Object} [config.markdownNegotiation] - Markdown content negotiation settings for agents
58
+ * @param {boolean} [config.markdownNegotiation.enabled=true] - Enables markdown negotiation by Accept header in production runtime
59
+ * @param {boolean} [config.markdownNegotiation.agentFallback=true] - Enables markdown fallback for known AI bot user agents when Accept is absent
52
60
  * @returns {Object} Resolved Docsector configuration
53
61
  */
54
62
  export function createDocsector (config = {}) {
@@ -93,9 +101,23 @@ export function createDocsector (config = {}) {
93
101
  linkHeaders: {
94
102
  enabled: true,
95
103
  serviceDoc: '/',
104
+ apiCatalog: '/.well-known/api-catalog',
96
105
  serviceDesc: '/mcp',
97
106
  describedBy: '/llms.txt',
98
107
  ...config.linkHeaders
108
+ },
109
+
110
+ apiCatalog: {
111
+ enabled: true,
112
+ path: '/.well-known/api-catalog',
113
+ items: [],
114
+ ...config.apiCatalog
115
+ },
116
+
117
+ markdownNegotiation: {
118
+ enabled: true,
119
+ agentFallback: true,
120
+ ...config.markdownNegotiation
99
121
  }
100
122
  }
101
123
  }
@@ -457,6 +457,38 @@ function createMarkdownEndpointPlugin (projectRoot) {
457
457
  return null
458
458
  }
459
459
 
460
+ function resolveNegotiatedFile (urlPath, lang) {
461
+ const pathname = (urlPath || '').split('?')[0]
462
+
463
+ if (pathname === '/' || pathname === '/index.html') {
464
+ const homepage = resolve(pagesDir, `Homepage.${lang}.md`)
465
+ return existsSync(homepage) ? homepage : null
466
+ }
467
+
468
+ if (pathname.endsWith('.md')) {
469
+ return resolveMarkdownFile(pathname, lang)
470
+ }
471
+
472
+ let clean = pathname
473
+ if (clean.endsWith('/index.html')) clean = clean.slice(0, -11)
474
+ if (clean.endsWith('/')) clean = clean.slice(0, -1)
475
+
476
+ if (!clean) {
477
+ const homepage = resolve(pagesDir, `Homepage.${lang}.md`)
478
+ return existsSync(homepage) ? homepage : null
479
+ }
480
+
481
+ if (clean.endsWith('/overview') || clean.endsWith('/showcase') || clean.endsWith('/vs')) {
482
+ return resolveMarkdownFile(`${clean}.md`, lang)
483
+ }
484
+
485
+ return resolveMarkdownFile(`${clean}/overview.md`, lang)
486
+ }
487
+
488
+ function estimateMarkdownTokens (markdown = '') {
489
+ return Math.max(1, Math.ceil(markdown.length / 4))
490
+ }
491
+
460
492
  // LLM bot user-agent patterns
461
493
  const LLM_BOT_PATTERN = /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|Google-Extended|Gemini-Deep-Research|PerplexityBot|Perplexity-User|Bytespider|CCBot|Meta-ExternalAgent|FacebookBot|Amazonbot|Applebot-Extended|cohere-ai|DuckAssistBot|GrokBot|AI2Bot|YouBot|PetalBot/i
462
494
 
@@ -466,6 +498,8 @@ function createMarkdownEndpointPlugin (projectRoot) {
466
498
  configureServer (server) {
467
499
  // Read default language from config
468
500
  let defaultLang = 'en-US'
501
+ let markdownNegotiationEnabled = true
502
+ let markdownAgentFallback = true
469
503
  try {
470
504
  const configPath = resolve(projectRoot, 'docsector.config.js')
471
505
  if (existsSync(configPath)) {
@@ -473,34 +507,56 @@ function createMarkdownEndpointPlugin (projectRoot) {
473
507
  const configContent = readFileSync(configPath, 'utf-8')
474
508
  const match = configContent.match(/defaultLanguage\s*:\s*['"]([^'"]+)['"]/)
475
509
  if (match) defaultLang = match[1]
510
+
511
+ const enabledMatch = configContent.match(/markdownNegotiation\s*:\s*\{[\s\S]*?enabled\s*:\s*(true|false)/)
512
+ if (enabledMatch) markdownNegotiationEnabled = enabledMatch[1] === 'true'
513
+
514
+ const fallbackMatch = configContent.match(/markdownNegotiation\s*:\s*\{[\s\S]*?agentFallback\s*:\s*(true|false)/)
515
+ if (fallbackMatch) markdownAgentFallback = fallbackMatch[1] === 'true'
476
516
  }
477
517
  } catch { /* use fallback */ }
478
518
 
479
519
  server.middlewares.use((req, res, next) => {
480
520
  const url = new URL(req.url, 'http://localhost')
521
+ const accept = (req.headers.accept || '').toLowerCase()
522
+ const wantsMarkdown = accept.includes('text/markdown')
523
+ const lang = url.searchParams.get('lang') || defaultLang
481
524
 
482
525
  // Explicit .md request
483
526
  if (url.pathname.endsWith('.md')) {
484
- const lang = url.searchParams.get('lang') || defaultLang
485
527
  const file = resolveMarkdownFile(url.pathname, lang)
486
528
  if (!file) return next()
487
529
 
488
530
  const content = readFileSync(file, 'utf-8')
489
531
  res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
532
+ res.setHeader('Vary', 'Accept')
533
+ res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(content)))
490
534
  res.end(content)
491
535
  return
492
536
  }
493
537
 
538
+ // Content negotiation for agents requesting markdown explicitly
539
+ if (markdownNegotiationEnabled && wantsMarkdown) {
540
+ const file = resolveNegotiatedFile(url.pathname, lang)
541
+ if (file) {
542
+ const content = readFileSync(file, 'utf-8')
543
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
544
+ res.setHeader('Vary', 'Accept')
545
+ res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(content)))
546
+ res.end(content)
547
+ return
548
+ }
549
+ }
550
+
494
551
  // Auto-serve markdown to LLM bot crawlers
495
552
  const ua = req.headers['user-agent'] || ''
496
- if (LLM_BOT_PATTERN.test(ua)) {
497
- const lang = url.searchParams.get('lang') || defaultLang
498
- // Try appending /overview as the default subpage
499
- const mdPath = url.pathname.replace(/\/$/, '') + '/overview.md'
500
- const file = resolveMarkdownFile(mdPath, lang)
553
+ if (markdownAgentFallback && LLM_BOT_PATTERN.test(ua)) {
554
+ const file = resolveNegotiatedFile(url.pathname, lang)
501
555
  if (file) {
502
556
  const content = readFileSync(file, 'utf-8')
503
557
  res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
558
+ res.setHeader('Vary', 'Accept')
559
+ res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(content)))
504
560
  res.end(content)
505
561
  return
506
562
  }
@@ -563,6 +619,25 @@ function createMarkdownBuildPlugin (projectRoot) {
563
619
  }
564
620
  }
565
621
 
622
+ // Generate homepage markdown files so root content can be negotiated in production
623
+ const languageValues = config.languages?.map(language => language.value).filter(Boolean) || []
624
+ const allLangs = [...new Set([defaultLang, ...languageValues])]
625
+ let homepageCount = 0
626
+ for (const lang of allLangs) {
627
+ const homepageSrc = resolve(pagesDir, `Homepage.${lang}.md`)
628
+ if (!existsSync(homepageSrc)) continue
629
+
630
+ const homepageContent = readFileSync(homepageSrc, 'utf-8')
631
+ writeFileSync(resolve(distDir, `homepage.${lang}.md`), homepageContent)
632
+ if (lang === defaultLang) {
633
+ writeFileSync(resolve(distDir, 'homepage.md'), homepageContent)
634
+ }
635
+ homepageCount++
636
+ }
637
+ if (homepageCount > 0) {
638
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated ${homepageCount} homepage markdown file(s)`)
639
+ }
640
+
566
641
  console.log(`\x1b[36m[docsector]\x1b[0m Generated ${count} static .md files`)
567
642
 
568
643
  // Generate sitemap.xml if siteUrl is configured
@@ -663,6 +738,103 @@ function createMarkdownBuildPlugin (projectRoot) {
663
738
  } else {
664
739
  writeFileSync(headersPath, headersRule)
665
740
  }
741
+
742
+ const markdownNegotiationConfig = config.markdownNegotiation || {}
743
+ const markdownNegotiationEnabled = markdownNegotiationConfig.enabled !== false
744
+ const markdownAgentFallback = markdownNegotiationConfig.agentFallback !== false
745
+
746
+ if (markdownNegotiationEnabled) {
747
+ const functionsDir = resolve(projectRoot, 'functions')
748
+ mkdirSync(functionsDir, { recursive: true })
749
+
750
+ const middlewareCode = `const LLM_BOT_PATTERN = /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|Google-Extended|Gemini-Deep-Research|PerplexityBot|Perplexity-User|Bytespider|CCBot|Meta-ExternalAgent|FacebookBot|Amazonbot|Applebot-Extended|cohere-ai|DuckAssistBot|GrokBot|AI2Bot|YouBot|PetalBot/i
751
+
752
+ const DEFAULT_LANG = ${JSON.stringify(defaultLang)}
753
+ const AGENT_FALLBACK = ${markdownAgentFallback ? 'true' : 'false'}
754
+
755
+ function wantsMarkdown (request) {
756
+ const accept = (request.headers.get('accept') || '').toLowerCase()
757
+ return accept.includes('text/markdown')
758
+ }
759
+
760
+ function estimateTokens (markdown = '') {
761
+ return Math.max(1, Math.ceil(markdown.length / 4))
762
+ }
763
+
764
+ function shouldBypass (pathname) {
765
+ if (pathname === '/mcp' || pathname.startsWith('/mcp/')) return true
766
+ if (pathname.startsWith('/.well-known/')) return true
767
+ return /\\.(js|css|map|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|xml|json|txt)$/i.test(pathname)
768
+ }
769
+
770
+ function resolveMarkdownPath (pathname, lang) {
771
+ if (!pathname) return null
772
+ if (pathname.endsWith('.md')) return pathname
773
+ if (pathname === '/' || pathname === '/index.html') return '/homepage.' + lang + '.md'
774
+
775
+ let clean = pathname
776
+ if (clean.endsWith('/index.html')) clean = clean.slice(0, -11)
777
+ if (clean.endsWith('/')) clean = clean.slice(0, -1)
778
+
779
+ if (!clean) return '/homepage.' + lang + '.md'
780
+ if (clean.endsWith('/overview') || clean.endsWith('/showcase') || clean.endsWith('/vs')) {
781
+ return clean + '.md'
782
+ }
783
+
784
+ return clean + '/overview.md'
785
+ }
786
+
787
+ export async function onRequest (context) {
788
+ const { request, env, next } = context
789
+
790
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
791
+ return next()
792
+ }
793
+
794
+ const url = new URL(request.url)
795
+ if (shouldBypass(url.pathname)) {
796
+ return next()
797
+ }
798
+
799
+ const ua = request.headers.get('user-agent') || ''
800
+ const acceptMarkdown = wantsMarkdown(request)
801
+ const fallbackMarkdown = AGENT_FALLBACK && LLM_BOT_PATTERN.test(ua)
802
+ if (!acceptMarkdown && !fallbackMarkdown) {
803
+ return next()
804
+ }
805
+
806
+ const lang = url.searchParams.get('lang') || DEFAULT_LANG
807
+ const markdownPath = resolveMarkdownPath(url.pathname, lang)
808
+ if (!markdownPath) {
809
+ return next()
810
+ }
811
+
812
+ const markdownUrl = new URL(url.toString())
813
+ markdownUrl.pathname = markdownPath
814
+ const markdownRequest = new Request(markdownUrl.toString(), request)
815
+ const markdownResponse = await env.ASSETS.fetch(markdownRequest)
816
+ if (!markdownResponse.ok) {
817
+ return next()
818
+ }
819
+
820
+ const markdown = await markdownResponse.text()
821
+ const headers = new Headers(markdownResponse.headers)
822
+ headers.set('Content-Type', 'text/markdown; charset=utf-8')
823
+ const vary = headers.get('Vary')
824
+ headers.set('Vary', vary ? vary + ', Accept' : 'Accept')
825
+ headers.set('x-markdown-tokens', String(estimateTokens(markdown)))
826
+
827
+ if (request.method === 'HEAD') {
828
+ return new Response(null, { status: markdownResponse.status, headers })
829
+ }
830
+
831
+ return new Response(markdown, { status: markdownResponse.status, headers })
832
+ }
833
+ `
834
+
835
+ writeFileSync(resolve(functionsDir, '_middleware.js'), middlewareCode)
836
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated markdown negotiation middleware at functions/_middleware.js`)
837
+ }
666
838
  console.log(`\x1b[36m[docsector]\x1b[0m Added _headers rule for .md files`)
667
839
 
668
840
  // Add homepage Link headers for agent discovery (RFC 8288 / RFC 9727)
@@ -679,6 +851,13 @@ function createMarkdownBuildPlugin (projectRoot) {
679
851
  homepageLinks.push({ rel: 'service-doc', href: serviceDocHref })
680
852
  }
681
853
 
854
+ const apiCatalogHref = linkHeadersConfig.apiCatalog === undefined
855
+ ? '/.well-known/api-catalog'
856
+ : linkHeadersConfig.apiCatalog
857
+ if (apiCatalogHref) {
858
+ homepageLinks.push({ rel: 'api-catalog', href: apiCatalogHref })
859
+ }
860
+
682
861
  const serviceDescHref = linkHeadersConfig.serviceDesc === undefined
683
862
  ? '/mcp'
684
863
  : linkHeadersConfig.serviceDesc
@@ -694,19 +873,106 @@ function createMarkdownBuildPlugin (projectRoot) {
694
873
  }
695
874
 
696
875
  if (homepageLinks.length > 0) {
697
- const linkLines = homepageLinks.map(({ rel, href }) => ` Link: <${href}>; rel="${rel}"`).join('\n')
698
- const homepageRule = ['/','/index.html']
699
- .map(path => `${path}\n${linkLines}`)
700
- .join('\n\n') + '\n'
701
-
702
876
  const currentHeaders = readFileSync(headersPath, 'utf-8')
703
- const hasAgentLinks = currentHeaders.includes('rel="service-doc"')
704
- || currentHeaders.includes('rel="service-desc"')
705
- || currentHeaders.includes('rel="describedby"')
877
+ const missingHomepageLinks = homepageLinks.filter(({ rel }) => !currentHeaders.includes(`rel="${rel}"`))
878
+
879
+ if (missingHomepageLinks.length > 0) {
880
+ const linkLines = missingHomepageLinks
881
+ .map(({ rel, href }) => ` Link: <${href}>; rel="${rel}"`)
882
+ .join('\n')
883
+ const homepageRule = ['/', '/index.html']
884
+ .map(path => `${path}\n${linkLines}`)
885
+ .join('\n\n') + '\n'
706
886
 
707
- if (!hasAgentLinks) {
708
887
  writeFileSync(headersPath, currentHeaders.trimEnd() + '\n\n' + homepageRule)
709
- console.log(`\x1b[36m[docsector]\x1b[0m Added homepage Link headers for agent discovery`)
888
+ console.log(`\x1b[36m[docsector]\x1b[0m Added homepage Link headers for agent discovery (${missingHomepageLinks.length} relation(s))`)
889
+ }
890
+ }
891
+
892
+ // Generate /.well-known/api-catalog Linkset document (RFC 9727)
893
+ const apiCatalogConfig = config.apiCatalog || {}
894
+ const apiCatalogEnabled = apiCatalogConfig.enabled !== false
895
+ const apiCatalogPath = (apiCatalogConfig.path || apiCatalogHref || '/.well-known/api-catalog')
896
+
897
+ const toUrl = (href) => {
898
+ if (!href) return null
899
+ if (/^https?:\/\//i.test(href)) return href
900
+ const normalizedHref = href.startsWith('/') ? href : `/${href}`
901
+ return siteUrl ? `${siteUrl}${normalizedHref}` : normalizedHref
902
+ }
903
+
904
+ const normalizeLocalPath = (href) => {
905
+ if (!href || /^https?:\/\//i.test(href)) return null
906
+ const path = href.startsWith('/') ? href.slice(1) : href
907
+ return path || null
908
+ }
909
+
910
+ if (apiCatalogEnabled && apiCatalogPath) {
911
+ const catalogDistPath = normalizeLocalPath(apiCatalogPath)
912
+
913
+ if (catalogDistPath) {
914
+ const catalogHref = apiCatalogPath.startsWith('/') ? apiCatalogPath : `/${apiCatalogPath}`
915
+ const catalogEntry = {
916
+ anchor: toUrl(catalogHref)
917
+ }
918
+
919
+ const catalogServiceDoc = toUrl(serviceDocHref)
920
+ if (catalogServiceDoc) {
921
+ catalogEntry['service-doc'] = [{ href: catalogServiceDoc }]
922
+ }
923
+
924
+ const catalogServiceDesc = config.mcp ? toUrl(serviceDescHref) : null
925
+ if (catalogServiceDesc) {
926
+ catalogEntry['service-desc'] = [{ href: catalogServiceDesc }]
927
+ }
928
+
929
+ const catalogDescribedBy = siteUrl ? toUrl(describedByHref) : null
930
+ if (catalogDescribedBy) {
931
+ catalogEntry.describedby = [{ href: catalogDescribedBy }]
932
+ }
933
+
934
+ const customItems = Array.isArray(apiCatalogConfig.items)
935
+ ? apiCatalogConfig.items
936
+ : []
937
+ const itemHrefs = new Set()
938
+
939
+ if (catalogServiceDesc) {
940
+ itemHrefs.add(catalogServiceDesc)
941
+ }
942
+
943
+ for (const item of customItems) {
944
+ if (typeof item === 'string') {
945
+ const href = toUrl(item)
946
+ if (href) itemHrefs.add(href)
947
+ continue
948
+ }
949
+
950
+ if (item && typeof item === 'object' && typeof item.href === 'string') {
951
+ const href = toUrl(item.href)
952
+ if (href) itemHrefs.add(href)
953
+ }
954
+ }
955
+
956
+ if (itemHrefs.size > 0) {
957
+ catalogEntry.item = [...itemHrefs].map(href => ({ href }))
958
+ }
959
+
960
+ const catalogDir = resolve(distDir, catalogDistPath, '..')
961
+ mkdirSync(catalogDir, { recursive: true })
962
+ writeFileSync(
963
+ resolve(distDir, catalogDistPath),
964
+ JSON.stringify({ linkset: [catalogEntry] }, null, 2) + '\n'
965
+ )
966
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated ${catalogHref}`)
967
+
968
+ const headersWithLinks = readFileSync(headersPath, 'utf-8')
969
+ if (!headersWithLinks.includes(catalogHref)) {
970
+ const apiCatalogHeaders = `${catalogHref}\n Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"\n`
971
+ writeFileSync(headersPath, headersWithLinks.trimEnd() + '\n\n' + apiCatalogHeaders)
972
+ console.log(`\x1b[36m[docsector]\x1b[0m Added _headers rule for ${catalogHref}`)
973
+ }
974
+ } else {
975
+ console.warn(`\x1b[33m[docsector]\x1b[0m Skipped API catalog generation: path must be a local URI path, got "${apiCatalogPath}"`)
710
976
  }
711
977
  }
712
978
  }
@@ -780,7 +1046,10 @@ function createMarkdownBuildPlugin (projectRoot) {
780
1046
  writeFileSync(headersPath, currentHeaders.trimEnd() + '\n\n' + mcpHeaders)
781
1047
  }
782
1048
 
783
- // Generate or merge _routes.json for Cloudflare Pages
1049
+ }
1050
+
1051
+ // Generate or merge _routes.json for Cloudflare Pages functions
1052
+ if (config.mcp || markdownNegotiationEnabled) {
784
1053
  const routesPath = resolve(distDir, '_routes.json')
785
1054
  let routes = { version: 1, include: [], exclude: [] }
786
1055
  if (existsSync(routesPath)) {
@@ -790,11 +1059,40 @@ function createMarkdownBuildPlugin (projectRoot) {
790
1059
  // empty
791
1060
  }
792
1061
  }
793
- if (!routes.include.includes('/mcp')) {
1062
+
1063
+ if (config.mcp && !routes.include.includes('/mcp')) {
794
1064
  routes.include.push('/mcp')
795
1065
  }
1066
+
1067
+ if (markdownNegotiationEnabled && !routes.include.includes('/*')) {
1068
+ routes.include.push('/*')
1069
+ }
1070
+
1071
+ const markdownExcludes = [
1072
+ '/assets/*',
1073
+ '/*.js',
1074
+ '/*.css',
1075
+ '/*.png',
1076
+ '/*.jpg',
1077
+ '/*.jpeg',
1078
+ '/*.gif',
1079
+ '/*.webp',
1080
+ '/*.svg',
1081
+ '/*.ico',
1082
+ '/*.woff',
1083
+ '/*.woff2',
1084
+ '/*.ttf',
1085
+ '/*.map'
1086
+ ]
1087
+
1088
+ for (const excludePath of markdownExcludes) {
1089
+ if (!routes.exclude.includes(excludePath)) {
1090
+ routes.exclude.push(excludePath)
1091
+ }
1092
+ }
1093
+
796
1094
  writeFileSync(routesPath, JSON.stringify(routes, null, 2))
797
- console.log(`\x1b[36m[docsector]\x1b[0m Added /mcp to _routes.json`)
1095
+ console.log(`\x1b[36m[docsector]\x1b[0m Updated _routes.json for functions runtime`)
798
1096
  }
799
1097
  }
800
1098
  }