@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 +29 -1
- package/bin/docsector.js +15 -1
- package/package.json +1 -1
- package/src/index.js +22 -0
- package/src/quasar.factory.js +317 -19
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.
|
|
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.
|
|
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
|
}
|
package/src/quasar.factory.js
CHANGED
|
@@ -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
|
|
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
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1095
|
+
console.log(`\x1b[36m[docsector]\x1b[0m Updated _routes.json for functions runtime`)
|
|
798
1096
|
}
|
|
799
1097
|
}
|
|
800
1098
|
}
|