@docsector/docsector-reader 1.1.0 β†’ 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ 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)
@@ -46,6 +47,7 @@ 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
51
53
  - πŸ—‚οΈ **API Catalog Well-Known** β€” Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
@@ -369,6 +371,11 @@ export default {
369
371
  items: []
370
372
  },
371
373
 
374
+ markdownNegotiation: {
375
+ enabled: true,
376
+ agentFallback: true
377
+ },
378
+
372
379
  languages: [
373
380
  { image: '/images/flags/united-states-of-america.png', label: 'English (US)', value: 'en-US' },
374
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.1.0'
26
+ const VERSION = '1.2.1'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
@@ -149,6 +149,12 @@ export default {
149
149
  // items: ['/mcp']
150
150
  // },
151
151
 
152
+ // @ Markdown negotiation for agents (optional)
153
+ // markdownNegotiation: {
154
+ // enabled: true,
155
+ // agentFallback: true
156
+ // },
157
+
152
158
  // @ Languages
153
159
  languages: [
154
160
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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
@@ -54,6 +54,9 @@
54
54
  * @param {boolean} [config.apiCatalog.enabled=true] - Enables generation of API catalog artifact
55
55
  * @param {string} [config.apiCatalog.path='/.well-known/api-catalog'] - Output URI path for API catalog artifact
56
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
57
60
  * @returns {Object} Resolved Docsector configuration
58
61
  */
59
62
  export function createDocsector (config = {}) {
@@ -109,6 +112,12 @@ export function createDocsector (config = {}) {
109
112
  path: '/.well-known/api-catalog',
110
113
  items: [],
111
114
  ...config.apiCatalog
115
+ },
116
+
117
+ markdownNegotiation: {
118
+ enabled: true,
119
+ agentFallback: true,
120
+ ...config.markdownNegotiation
112
121
  }
113
122
  }
114
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)
@@ -795,12 +967,12 @@ function createMarkdownBuildPlugin (projectRoot) {
795
967
 
796
968
  const headersWithLinks = readFileSync(headersPath, 'utf-8')
797
969
  if (!headersWithLinks.includes(catalogHref)) {
798
- const apiCatalogHeaders = `${catalogHref}\n Content-Type: application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"\n`
970
+ const apiCatalogHeaders = `${catalogHref}\n Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"\n`
799
971
  writeFileSync(headersPath, headersWithLinks.trimEnd() + '\n\n' + apiCatalogHeaders)
800
972
  console.log(`\x1b[36m[docsector]\x1b[0m Added _headers rule for ${catalogHref}`)
801
973
  }
802
974
  } else {
803
- console.warn(`\x1b[33m[docsector]\x1b[0m Skipped API catalog generation: path must be a local URI path, got \"${apiCatalogPath}\"`)
975
+ console.warn(`\x1b[33m[docsector]\x1b[0m Skipped API catalog generation: path must be a local URI path, got "${apiCatalogPath}"`)
804
976
  }
805
977
  }
806
978
  }
@@ -874,7 +1046,10 @@ function createMarkdownBuildPlugin (projectRoot) {
874
1046
  writeFileSync(headersPath, currentHeaders.trimEnd() + '\n\n' + mcpHeaders)
875
1047
  }
876
1048
 
877
- // 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) {
878
1053
  const routesPath = resolve(distDir, '_routes.json')
879
1054
  let routes = { version: 1, include: [], exclude: [] }
880
1055
  if (existsSync(routesPath)) {
@@ -884,11 +1059,46 @@ function createMarkdownBuildPlugin (projectRoot) {
884
1059
  // empty
885
1060
  }
886
1061
  }
887
- if (!routes.include.includes('/mcp')) {
1062
+
1063
+ if (markdownNegotiationEnabled && !routes.include.includes('/*')) {
1064
+ routes.include.push('/*')
1065
+ }
1066
+
1067
+ if (config.mcp && !markdownNegotiationEnabled && !routes.include.includes('/mcp')) {
888
1068
  routes.include.push('/mcp')
889
1069
  }
1070
+
1071
+ // Cloudflare Pages rejects overlapping include rules (e.g. "/mcp" with "/*").
1072
+ // Keep only the catch-all when markdown negotiation is enabled.
1073
+ if (routes.include.includes('/*')) {
1074
+ routes.include = routes.include.filter((route) => route !== '/mcp')
1075
+ }
1076
+
1077
+ const markdownExcludes = [
1078
+ '/assets/*',
1079
+ '/*.js',
1080
+ '/*.css',
1081
+ '/*.png',
1082
+ '/*.jpg',
1083
+ '/*.jpeg',
1084
+ '/*.gif',
1085
+ '/*.webp',
1086
+ '/*.svg',
1087
+ '/*.ico',
1088
+ '/*.woff',
1089
+ '/*.woff2',
1090
+ '/*.ttf',
1091
+ '/*.map'
1092
+ ]
1093
+
1094
+ for (const excludePath of markdownExcludes) {
1095
+ if (!routes.exclude.includes(excludePath)) {
1096
+ routes.exclude.push(excludePath)
1097
+ }
1098
+ }
1099
+
890
1100
  writeFileSync(routesPath, JSON.stringify(routes, null, 2))
891
- console.log(`\x1b[36m[docsector]\x1b[0m Added /mcp to _routes.json`)
1101
+ console.log(`\x1b[36m[docsector]\x1b[0m Updated _routes.json for functions runtime`)
892
1102
  }
893
1103
  }
894
1104
  }