@docsector/docsector-reader 1.5.0 → 1.7.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
@@ -31,6 +31,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
31
31
  - 🧭 **Content Signals** — Optional `Content-Signal` directive for declaring AI usage policy (`ai-train`, `search`, `ai-input`) in `robots.txt`
32
32
  - 🧩 **Agent Skills Discovery Index** — Optional `/.well-known/agent-skills/index.json` with RFC v0.2.0 schema and SHA-256 digests
33
33
  - 🪪 **MCP Server Card** — Optional `/.well-known/mcp/server-card.json` for MCP server discovery before connection
34
+ - 🌐 **WebMCP Browser Tools** — Optional registration of in-page tools via `navigator.modelContext` for browser agents
34
35
  - 🔗 **Homepage Link Headers** — Auto-generated `Link` response headers for agent discovery (`api-catalog`, `service-doc`, `service-desc`, `describedby`) per RFC 8288 / RFC 9727
35
36
  - 🔌 **MCP Server** — Auto-generated [MCP](https://modelcontextprotocol.io) server at `/mcp` for AI assistant integration (Claude Desktop, VS Code, etc.)
36
37
  - 📄 **llms.txt / llms-full.txt** — Auto-generated [llms.txt](https://llmstxt.org) index and full-content file for LLM discovery (requires `siteUrl` in config)
@@ -46,6 +47,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
46
47
  - 🌗 **Dark/Light Mode** — Automatic theme switching with Quasar Dark Plugin
47
48
  - 🔗 **Anchor Navigation** — Right-side Table of Contents tree with scroll tracking and auto-scroll to active section
48
49
  - 🔎 **Search** — Menu search across all documentation content and tags
50
+ - 🌐 **WebMCP Browser Tools** — Registers in-page tools for browser agents with `registerTool` and optional `provideContext` fallback
49
51
  - 📱 **Responsive** — Mobile-friendly with collapsible sidebar and drawers
50
52
  - 🏷️ **Status Badges** — Mark pages as `done`, `draft`, or `empty` with visual indicators
51
53
  - ✏️ **Edit on GitHub** — Direct links to edit pages on your repository
@@ -56,6 +58,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
56
58
  - 🔐 **Web Bot Auth** — Can publish a signed HTTP message signatures directory and includes helpers to sign outbound bot requests
57
59
  - 🧭 **Content Signals** — Injects `Content-Signal` policy in `robots.txt` with deterministic, idempotent build output
58
60
  - 🏠 **Markdown Home at Root** — Homepage is rendered from `src/pages/Homepage.{lang}.md` directly at `/`
61
+ - 🌍 **Remote README as Home** — Optional build-time remote README source for homepage with automatic local fallback
59
62
  - 🧭 **Quick Links Custom Element** — Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
60
63
  - 🗂️ **API Catalog Well-Known** — Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
61
64
  - ⚙️ **Single Config File** — Customize branding, links, and languages via `docsector.config.js`
@@ -205,6 +208,63 @@ Check `checks.discovery.mcpServerCard.status` equals `"pass"`.
205
208
 
206
209
  ---
207
210
 
211
+ ## 🌐 WebMCP Browser Tools
212
+
213
+ Docsector Reader can register browser-side tools for agents when
214
+ `navigator.modelContext` is available (secure context required).
215
+
216
+ Default tools:
217
+
218
+ - `docs.search_docs` (bridges to MCP `search_{toolSuffix}`)
219
+ - `docs.get_page` (bridges to MCP `get_page_{toolSuffix}`)
220
+ - `docs.navigate_to` (SPA navigation)
221
+ - `docs.copy_current_page` (current page markdown URL/content)
222
+
223
+ ### WebMCP Configure
224
+
225
+ ```javascript
226
+ export default {
227
+ // ...other config
228
+
229
+ mcp: {
230
+ serverName: 'my-docs',
231
+ toolSuffix: 'my_docs'
232
+ },
233
+
234
+ webMcp: {
235
+ enabled: true,
236
+ apiMode: 'dual', // 'registerTool' | 'dual'
237
+ toolPrefix: 'docs',
238
+ bridgeEndpoint: '/mcp',
239
+ bridgeToMcp: true,
240
+ tools: {
241
+ searchDocs: true,
242
+ getPage: true,
243
+ navigateTo: true,
244
+ copyCurrentPage: true
245
+ }
246
+ }
247
+ }
248
+ ```
249
+
250
+ Notes:
251
+
252
+ - `apiMode: 'registerTool'` uses only `navigator.modelContext.registerTool()`.
253
+ - `apiMode: 'dual'` also attempts `provideContext` fallback when available.
254
+ - Registration happens on page load and is automatically cleaned up on unmount.
255
+
256
+ ### WebMCP Validate
257
+
258
+ ```bash
259
+ curl -X POST https://isitagentready.com/api/scan \
260
+ -H 'Content-Type: application/json' \
261
+ -d '{"url":"https://YOUR-SITE.com"}'
262
+ ```
263
+
264
+ Check `checks.discovery.webMcp.status` equals `"pass"`.
265
+
266
+ ---
267
+
208
268
  ## � llms.txt (LLM Discovery)
209
269
 
210
270
  Docsector Reader automatically generates [llms.txt](https://llmstxt.org) files at build time when `siteUrl` is configured (same requirement as sitemap.xml).
@@ -274,6 +334,39 @@ Set any target to `null` or `false` to disable that relation.
274
334
 
275
335
  ---
276
336
 
337
+ ## 🏠 Remote README as Home
338
+
339
+ You can configure Docsector Reader to use a remote README as homepage content.
340
+
341
+ - Fetch happens at build-time.
342
+ - The same README content is used for all configured languages.
343
+ - If fetch fails, it falls back to local `src/pages/Homepage.{lang}.md` by default.
344
+
345
+ ### Configure
346
+
347
+ ```javascript
348
+ export default {
349
+ // ...other config
350
+
351
+ homePage: {
352
+ source: 'remote-readme',
353
+ remoteReadmeUrl: 'https://raw.githubusercontent.com/your-org/your-repo/main/README.md',
354
+ timeoutMs: 8000,
355
+ fallbackToLocal: true
356
+ }
357
+ }
358
+ ```
359
+
360
+ ### Validate
361
+
362
+ ```bash
363
+ npx docsector build
364
+ cat dist/spa/homepage.md
365
+ cat dist/spa/homepage.en-US.md
366
+ ```
367
+
368
+ ---
369
+
277
370
  ## 🔐 Web Bot Auth
278
371
 
279
372
  Docsector Reader can publish a signed Web Bot Auth directory at:
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.5.0'
26
+ const VERSION = '1.7.0'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
@@ -148,6 +148,33 @@ export default {
148
148
  // }
149
149
  // },
150
150
 
151
+ // @ WebMCP browser tools (optional)
152
+ // Registers tools in browser contexts that expose navigator.modelContext.
153
+ // Uses registerTool when available, with optional provideContext fallback.
154
+ // webMcp: {
155
+ // enabled: true,
156
+ // apiMode: 'dual', // 'registerTool' | 'dual'
157
+ // toolPrefix: 'docs',
158
+ // bridgeEndpoint: '/mcp',
159
+ // bridgeToMcp: true,
160
+ // tools: {
161
+ // searchDocs: true,
162
+ // getPage: true,
163
+ // navigateTo: true,
164
+ // copyCurrentPage: true
165
+ // }
166
+ // },
167
+
168
+ // @ Home page source (optional)
169
+ // Use a remote README.md as homepage content at build-time.
170
+ // Falls back to local src/pages/Homepage.{lang}.md on fetch failure by default.
171
+ // homePage: {
172
+ // source: 'remote-readme', // 'local' | 'remote-readme'
173
+ // remoteReadmeUrl: 'https://raw.githubusercontent.com/your-org/your-repo/main/README.md',
174
+ // timeoutMs: 8000,
175
+ // fallbackToLocal: true
176
+ // },
177
+
151
178
  // @ Homepage Link headers for agent discovery (optional)
152
179
  // linkHeaders: {
153
180
  // enabled: true,
@@ -43,6 +43,14 @@ export default {
43
43
  toolSuffix: 'docsector'
44
44
  },
45
45
 
46
+ // @ Home page source
47
+ homePage: {
48
+ source: 'remote-readme',
49
+ remoteReadmeUrl: 'https://raw.githubusercontent.com/docsector/docsector-reader/main/README.md',
50
+ timeoutMs: 8000,
51
+ fallbackToLocal: true
52
+ },
53
+
46
54
  // @ Languages
47
55
  languages: [
48
56
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "1.5.0",
3
+ "version": "1.7.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/App.vue CHANGED
@@ -114,20 +114,24 @@
114
114
  </template>
115
115
 
116
116
  <script setup>
117
- import { reactive, computed, onMounted } from 'vue'
117
+ import { reactive, computed, onMounted, onBeforeUnmount } from 'vue'
118
118
  import { useQuasar } from 'quasar'
119
119
  import { useStore } from 'vuex'
120
120
  import { useI18n } from 'vue-i18n'
121
- import { useRouter } from 'vue-router'
121
+ import { useRouter, useRoute } from 'vue-router'
122
122
 
123
123
  import docsectorConfig from 'docsector.config.js'
124
+ import { setupWebMcp } from './composables/useWebMcp'
124
125
 
125
126
  defineOptions({ name: 'App' })
126
127
 
127
128
  const $q = useQuasar()
128
129
  const store = useStore()
129
- const { locale } = useI18n()
130
+ const { locale, t } = useI18n()
130
131
  const router = useRouter()
132
+ const route = useRoute()
133
+
134
+ let cleanupWebMcp = null
131
135
 
132
136
  const settings = reactive({
133
137
  general: {
@@ -213,6 +217,21 @@ onMounted(() => {
213
217
  settings.appearance.background.default = dark
214
218
  }
215
219
  $q.dark.set(dark)
220
+
221
+ cleanupWebMcp = setupWebMcp({
222
+ router,
223
+ route,
224
+ store,
225
+ translate: t,
226
+ locale
227
+ })
228
+ })
229
+
230
+ onBeforeUnmount(() => {
231
+ if (typeof cleanupWebMcp === 'function') {
232
+ cleanupWebMcp()
233
+ cleanupWebMcp = null
234
+ }
216
235
  })
217
236
  </script>
218
237
 
@@ -71,6 +71,13 @@ const rawMarkdown = computed(() => {
71
71
 
72
72
  const markdownURL = computed(() => {
73
73
  if (store.state.page.base === 'home') {
74
+ const homePage = docsectorConfig.homePage || {}
75
+ const isRemoteHome = homePage.source === 'remote-readme' && typeof homePage.remoteReadmeUrl === 'string' && homePage.remoteReadmeUrl.length > 0
76
+
77
+ if (isRemoteHome) {
78
+ return homePage.remoteReadmeUrl
79
+ }
80
+
74
81
  return `/Homepage.${locale.value}.md`
75
82
  }
76
83
 
@@ -87,12 +94,27 @@ const fullMarkdownURL = computed(() => {
87
94
  return `${window.location.origin}${path}.md`
88
95
  })
89
96
 
97
+ const chatSourceURL = computed(() => {
98
+ if (store.state.page.base !== 'home') {
99
+ return fullMarkdownURL.value
100
+ }
101
+
102
+ const homePage = docsectorConfig.homePage || {}
103
+ const isRemoteHome = homePage.source === 'remote-readme' && typeof homePage.remoteReadmeUrl === 'string' && homePage.remoteReadmeUrl.length > 0
104
+
105
+ if (isRemoteHome) {
106
+ return `${window.location.origin}/`
107
+ }
108
+
109
+ return fullMarkdownURL.value
110
+ })
111
+
90
112
  const chatgptURL = computed(() => {
91
- const prompt = `Read ${fullMarkdownURL.value} and answer questions about the content.`
113
+ const prompt = `Read ${chatSourceURL.value} and answer questions about the content.`
92
114
  return `https://chat.openai.com/?q=${encodeURIComponent(prompt)}`
93
115
  })
94
116
  const claudeURL = computed(() => {
95
- const prompt = `Read ${fullMarkdownURL.value} and answer questions about the content.`
117
+ const prompt = `Read ${chatSourceURL.value} and answer questions about the content.`
96
118
  return `https://claude.ai/new?q=${encodeURIComponent(prompt)}`
97
119
  })
98
120
 
@@ -157,11 +157,19 @@ const next = computed(() => {
157
157
 
158
158
  return ''
159
159
  })
160
+
161
+ const hideRemoteHomeFooterMeta = computed(() => {
162
+ const homePage = docsectorConfig.homePage || {}
163
+ const isRemoteHome = homePage.source === 'remote-readme' && typeof homePage.remoteReadmeUrl === 'string' && homePage.remoteReadmeUrl.length > 0
164
+ const isHomePage = route.path === '/' || store.state.page.base === 'home'
165
+
166
+ return isRemoteHome && isHomePage
167
+ })
160
168
  </script>
161
169
 
162
170
  <template>
163
171
  <div id="d-page-meta">
164
- <div class="row justify-between q-mt-lg">
172
+ <div v-if="!hideRemoteHomeFooterMeta" class="row justify-between q-mt-lg">
165
173
  <div id="d-page-edit" class="col">
166
174
  <q-btn dense no-caps text-color="black" :color="color" @click="openURL(URL)" aria-label="Edit page on Github">
167
175
  <q-icon class="q-mr-xs" name="fab fa-github" size="20px" />
@@ -130,7 +130,10 @@ const tokenized = computed(() => {
130
130
 
131
131
  const { source: sourceWithQuickLinks, quickLinksMap } = extractQuickLinksBlocks(normalizedSource)
132
132
 
133
- const Markdown = new MarkdownIt()
133
+ const Markdown = new MarkdownIt({
134
+ // Home remote README may contain raw HTML blocks (badges, centered headers, etc.)
135
+ html: true
136
+ })
134
137
  Markdown.use(attrs, {
135
138
  leftDelimiter: ':',
136
139
  rightDelimiter: ';',
@@ -0,0 +1,398 @@
1
+ import docsectorConfig from 'docsector.config.js'
2
+
3
+ let activeCleanup = null
4
+
5
+ function toSafeToolPrefix (prefix) {
6
+ const value = String(prefix || 'docs')
7
+ .trim()
8
+ .replace(/[^A-Za-z0-9_.-]/g, '_')
9
+
10
+ if (!value) {
11
+ return 'docs'
12
+ }
13
+
14
+ return value.slice(0, 48)
15
+ }
16
+
17
+ function decodeMarkdownSource (source) {
18
+ return String(source || '')
19
+ .replace(/&#123;/g, '{')
20
+ .replace(/&#125;/g, '}')
21
+ .replace(/\{'([^']+)'\}/g, '$1')
22
+ .replace(/&amp;/g, '&')
23
+ }
24
+
25
+ function isSecurePageContext () {
26
+ if (typeof window === 'undefined') return false
27
+
28
+ if (window.isSecureContext) return true
29
+
30
+ const protocol = window.location?.protocol || ''
31
+ const host = window.location?.hostname || ''
32
+ return protocol === 'https:' || host === 'localhost' || host === '127.0.0.1'
33
+ }
34
+
35
+ function buildMcpEndpoint (bridgeEndpoint) {
36
+ if (typeof window === 'undefined') return '/mcp'
37
+
38
+ const endpoint = bridgeEndpoint || '/mcp'
39
+ try {
40
+ return new URL(endpoint, window.location.origin).toString()
41
+ } catch {
42
+ return `${window.location.origin}/mcp`
43
+ }
44
+ }
45
+
46
+ async function callMcpTool ({ endpoint, toolName, args }) {
47
+ const requestId = `${toolName}:${Date.now()}`
48
+ const response = await fetch(endpoint, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ jsonrpc: '2.0',
53
+ id: requestId,
54
+ method: 'tools/call',
55
+ params: {
56
+ name: toolName,
57
+ arguments: args || {}
58
+ }
59
+ })
60
+ })
61
+
62
+ if (!response.ok) {
63
+ throw new Error(`MCP endpoint responded with ${response.status}`)
64
+ }
65
+
66
+ const payload = await response.json()
67
+ if (payload.error) {
68
+ throw new Error(payload.error.message || 'MCP tool call failed')
69
+ }
70
+
71
+ return payload.result || null
72
+ }
73
+
74
+ function normalizePath (inputPath) {
75
+ const value = String(inputPath || '').trim()
76
+ if (!value) return '/'
77
+
78
+ if (value.startsWith('http://') || value.startsWith('https://')) {
79
+ try {
80
+ const url = new URL(value)
81
+ return `${url.pathname}${url.search}${url.hash}`
82
+ } catch {
83
+ return '/'
84
+ }
85
+ }
86
+
87
+ if (value.startsWith('/')) {
88
+ return value
89
+ }
90
+
91
+ return `/${value}`
92
+ }
93
+
94
+ function createToolDefinitions ({
95
+ prefix,
96
+ mcpToolSuffix,
97
+ bridgeEndpoint,
98
+ bridgeToMcp,
99
+ tools,
100
+ router,
101
+ getCurrentPath,
102
+ getCurrentHash,
103
+ getLocale,
104
+ getAbsoluteI18nPath,
105
+ translate
106
+ }) {
107
+ const endpoint = buildMcpEndpoint(bridgeEndpoint)
108
+
109
+ const maybeCallMcp = async (toolName, args) => {
110
+ if (!bridgeToMcp) {
111
+ throw new Error('MCP bridge disabled in webMcp configuration')
112
+ }
113
+
114
+ return callMcpTool({ endpoint, toolName, args })
115
+ }
116
+
117
+ const definitions = []
118
+
119
+ if (tools.searchDocs) {
120
+ definitions.push({
121
+ name: `${prefix}.search_docs`,
122
+ description: 'Search documentation pages by keyword and return top matches.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ query: {
127
+ type: 'string',
128
+ description: 'Search term or phrase.'
129
+ }
130
+ },
131
+ required: ['query']
132
+ },
133
+ annotations: { readOnlyHint: true },
134
+ execute: async (input) => {
135
+ const query = String(input?.query || '').trim()
136
+ if (!query) {
137
+ return {
138
+ ok: false,
139
+ error: 'Missing required field: query'
140
+ }
141
+ }
142
+
143
+ const toolName = `search_${mcpToolSuffix}`
144
+ const result = await maybeCallMcp(toolName, { query })
145
+
146
+ return {
147
+ ok: true,
148
+ query,
149
+ mcpTool: toolName,
150
+ endpoint,
151
+ result
152
+ }
153
+ }
154
+ })
155
+ }
156
+
157
+ if (tools.getPage) {
158
+ definitions.push({
159
+ name: `${prefix}.get_page`,
160
+ description: 'Fetch a documentation page in markdown format by its path.',
161
+ inputSchema: {
162
+ type: 'object',
163
+ properties: {
164
+ path: {
165
+ type: 'string',
166
+ description: 'Documentation page path without trailing .md.'
167
+ }
168
+ },
169
+ required: ['path']
170
+ },
171
+ annotations: { readOnlyHint: true },
172
+ execute: async (input) => {
173
+ const path = String(input?.path || '').trim()
174
+ if (!path) {
175
+ return {
176
+ ok: false,
177
+ error: 'Missing required field: path'
178
+ }
179
+ }
180
+
181
+ const toolName = `get_page_${mcpToolSuffix}`
182
+ const result = await maybeCallMcp(toolName, { path })
183
+
184
+ return {
185
+ ok: true,
186
+ path,
187
+ mcpTool: toolName,
188
+ endpoint,
189
+ result
190
+ }
191
+ }
192
+ })
193
+ }
194
+
195
+ if (tools.navigateTo) {
196
+ definitions.push({
197
+ name: `${prefix}.navigate_to`,
198
+ description: 'Navigate to a docs route and optional anchor hash in the current session.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ path: {
203
+ type: 'string',
204
+ description: 'Target path (absolute or relative).'
205
+ },
206
+ hash: {
207
+ type: 'string',
208
+ description: 'Optional hash fragment, with or without leading #.'
209
+ }
210
+ }
211
+ },
212
+ annotations: { readOnlyHint: false },
213
+ execute: async (input) => {
214
+ const path = normalizePath(input?.path || getCurrentPath())
215
+ const hashInput = String(input?.hash || '').trim()
216
+ const hash = hashInput ? (hashInput.startsWith('#') ? hashInput : `#${hashInput}`) : ''
217
+
218
+ await router.push({ path, hash })
219
+
220
+ return {
221
+ ok: true,
222
+ path,
223
+ hash,
224
+ currentPath: getCurrentPath(),
225
+ currentHash: getCurrentHash()
226
+ }
227
+ }
228
+ })
229
+ }
230
+
231
+ if (tools.copyCurrentPage) {
232
+ definitions.push({
233
+ name: `${prefix}.copy_current_page`,
234
+ description: 'Return markdown URL and markdown source for the current page context.',
235
+ inputSchema: {
236
+ type: 'object',
237
+ properties: {
238
+ includeContent: {
239
+ type: 'boolean',
240
+ description: 'When false, return metadata and URL only.'
241
+ }
242
+ }
243
+ },
244
+ annotations: { readOnlyHint: true },
245
+ execute: async (input) => {
246
+ const locale = getLocale() || 'en-US'
247
+ const currentPath = getCurrentPath()
248
+ const path = currentPath === '/' ? '/Homepage' : currentPath.replace(/\/+$/, '')
249
+ const markdownUrl = `${window.location.origin}${path}.md`
250
+
251
+ const includeContent = input?.includeContent !== false
252
+ const absolute = getAbsoluteI18nPath()
253
+
254
+ let content = ''
255
+ if (includeContent && absolute) {
256
+ const source = translate(`_.${absolute}.source`)
257
+ if (source) {
258
+ content = decodeMarkdownSource(source)
259
+ }
260
+ }
261
+
262
+ return {
263
+ ok: true,
264
+ locale,
265
+ path: currentPath,
266
+ markdownUrl,
267
+ content
268
+ }
269
+ }
270
+ })
271
+ }
272
+
273
+ return definitions
274
+ }
275
+
276
+ function registerWithProvideContext (modelContext, toolDefinitions) {
277
+ if (typeof modelContext.provideContext !== 'function') return null
278
+
279
+ const payload = {
280
+ tools: toolDefinitions.map((tool) => ({
281
+ name: tool.name,
282
+ description: tool.description,
283
+ inputSchema: tool.inputSchema,
284
+ execute: tool.execute
285
+ }))
286
+ }
287
+
288
+ const result = modelContext.provideContext(payload)
289
+
290
+ if (typeof result === 'function') {
291
+ return result
292
+ }
293
+
294
+ if (result && typeof result.dispose === 'function') {
295
+ return () => result.dispose()
296
+ }
297
+
298
+ if (result && typeof result.unregister === 'function') {
299
+ return () => result.unregister()
300
+ }
301
+
302
+ return null
303
+ }
304
+
305
+ export function setupWebMcp ({ router, route, store, translate, locale }) {
306
+ const webMcp = docsectorConfig.webMcp || {}
307
+ if (!webMcp.enabled) return () => {}
308
+
309
+ if (!isSecurePageContext()) return () => {}
310
+
311
+ const modelContext = navigator?.modelContext
312
+ if (!modelContext) return () => {}
313
+
314
+ if (activeCleanup) {
315
+ activeCleanup()
316
+ activeCleanup = null
317
+ }
318
+
319
+ const prefix = toSafeToolPrefix(webMcp.toolPrefix || 'docs')
320
+ const mcpToolSuffix = String(docsectorConfig.mcp?.toolSuffix || 'docs')
321
+ .replace(/[^A-Za-z0-9_]/g, '_')
322
+
323
+ const tools = {
324
+ searchDocs: webMcp.tools?.searchDocs !== false,
325
+ getPage: webMcp.tools?.getPage !== false,
326
+ navigateTo: webMcp.tools?.navigateTo !== false,
327
+ copyCurrentPage: webMcp.tools?.copyCurrentPage !== false
328
+ }
329
+
330
+ const definitions = createToolDefinitions({
331
+ prefix,
332
+ mcpToolSuffix,
333
+ bridgeEndpoint: webMcp.bridgeEndpoint || '/mcp',
334
+ bridgeToMcp: webMcp.bridgeToMcp !== false,
335
+ tools,
336
+ router,
337
+ getCurrentPath: () => route.path,
338
+ getCurrentHash: () => route.hash,
339
+ getLocale: () => locale.value,
340
+ getAbsoluteI18nPath: () => store?.state?.i18n?.absolute,
341
+ translate
342
+ })
343
+
344
+ if (definitions.length === 0) return () => {}
345
+
346
+ const supportsRegisterTool = typeof modelContext.registerTool === 'function'
347
+ const supportsProvideContext = typeof modelContext.provideContext === 'function'
348
+ const mode = webMcp.apiMode === 'registerTool' ? 'registerTool' : 'dual'
349
+
350
+ const abortController = new AbortController()
351
+ const cleanups = [() => abortController.abort()]
352
+
353
+ try {
354
+ if (supportsRegisterTool) {
355
+ for (const tool of definitions) {
356
+ modelContext.registerTool({
357
+ name: tool.name,
358
+ description: tool.description,
359
+ inputSchema: tool.inputSchema,
360
+ annotations: tool.annotations,
361
+ execute: tool.execute
362
+ }, { signal: abortController.signal })
363
+ }
364
+ } else if (mode === 'registerTool') {
365
+ return () => {}
366
+ }
367
+
368
+ if ((!supportsRegisterTool || mode === 'dual') && supportsProvideContext) {
369
+ const provideCleanup = registerWithProvideContext(modelContext, definitions)
370
+ if (provideCleanup) {
371
+ cleanups.push(provideCleanup)
372
+ }
373
+ }
374
+ } catch (error) {
375
+ if (import.meta.env?.DEV) {
376
+ console.warn('[docsector:webmcp] Failed to register tools', error)
377
+ }
378
+ return () => {}
379
+ }
380
+
381
+ const cleanup = () => {
382
+ while (cleanups.length > 0) {
383
+ const fn = cleanups.pop()
384
+ try {
385
+ fn()
386
+ } catch {
387
+ // Ignore cleanup errors
388
+ }
389
+ }
390
+ }
391
+
392
+ activeCleanup = cleanup
393
+ return cleanup
394
+ }
395
+
396
+ export default {
397
+ setupWebMcp
398
+ }
package/src/css/app.sass CHANGED
@@ -1,7 +1,7 @@
1
1
  /* --- Docsector Reader --- */
2
2
  @font-face
3
3
  font-family: "Fira Code Nerd Font"
4
- src: url('/fonts/FiraCodeNerdFont-Regular.ttf') format('truetype')
4
+ src: local('Fira Code Nerd Font'), local('FiraCode Nerd Font')
5
5
  font-weight: normal
6
6
  font-style: normal
7
7
  // * General
@@ -117,9 +117,10 @@ export function filter (source) {
117
117
  * @param {Object} options.pages - Page registry from pages/index.js
118
118
  * @param {Object} options.boot - Boot meta from pages/boot.js
119
119
  * @param {string[]} [options.langs] - Language codes to process (auto-detected from langModules if omitted)
120
+ * @param {Object<string,string>} [options.homePageOverride] - Optional per-language Home markdown override
120
121
  * @returns {Object} Complete i18n messages object keyed by locale
121
122
  */
122
- export function buildMessages ({ langModules, mdModules, pages, boot, langs }) {
123
+ export function buildMessages ({ langModules, mdModules, pages, boot, langs, homePageOverride = {} }) {
123
124
  // Auto-detect languages from HJSON files if not provided
124
125
  if (!langs) {
125
126
  langs = Object.keys(langModules).map(key => {
@@ -145,6 +146,11 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs }) {
145
146
  }
146
147
 
147
148
  function loadHomepage (lang) {
149
+ const override = homePageOverride?.[lang] ?? homePageOverride?.['en-US']
150
+ if (typeof override === 'string' && override.length > 0) {
151
+ return filter(override)
152
+ }
153
+
148
154
  const key = `../pages/Homepage.${lang}.md`
149
155
  const fallbackKey = '../pages/Homepage.en-US.md'
150
156
 
@@ -159,6 +165,23 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs }) {
159
165
  }
160
166
 
161
167
  function extractHeadingFromHomepage (lang) {
168
+ const override = homePageOverride?.[lang] ?? homePageOverride?.['en-US']
169
+ if (typeof override === 'string' && override.length > 0) {
170
+ const htmlHeadingMatch = override.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
171
+ if (htmlHeadingMatch) {
172
+ const htmlHeading = htmlHeadingMatch[1]
173
+ .replace(/<[^>]+>/g, ' ')
174
+ .replace(/\s+/g, ' ')
175
+ .trim()
176
+ if (htmlHeading) {
177
+ return htmlHeading
178
+ }
179
+ }
180
+
181
+ const overrideMatch = override.match(/^#\s+(.+)$/m)
182
+ return overrideMatch ? overrideMatch[1].trim() : ''
183
+ }
184
+
162
185
  const key = `../pages/Homepage.${lang}.md`
163
186
  const fallbackKey = '../pages/Homepage.en-US.md'
164
187
 
package/src/i18n/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ Import i18n message builder
2
2
  import { buildMessages } from './helpers'
3
+ import homePageOverride from 'virtual:docsector-homepage-override'
3
4
 
4
5
  // @ Import language HJSON files (Vite-compatible eager import)
5
6
  const langModules = import.meta.glob('./languages/*.hjson', { eager: true })
@@ -10,4 +11,4 @@ const mdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?r
10
11
  import boot from 'pages/boot'
11
12
  import pages from 'pages'
12
13
 
13
- export default buildMessages({ langModules, mdModules, pages, boot })
14
+ export default buildMessages({ langModules, mdModules, pages, boot, homePageOverride })
package/src/index.js CHANGED
@@ -87,6 +87,22 @@
87
87
  * @param {Object} [config.mcpServerCard.capabilities] - Optional capability overrides for tools/resources/prompts
88
88
  * @param {Array<Object>} [config.mcpServerCard.remotes=[]] - Optional additional remotes to include in the server card
89
89
  * @param {Object} [config.mcpServerCard.metadata] - Optional additional metadata merged into the server card payload
90
+ * @param {Object} [config.webMcp] - WebMCP browser tools settings
91
+ * @param {boolean} [config.webMcp.enabled=false] - Enables browser-side WebMCP tool registration on page load
92
+ * @param {'registerTool'|'dual'} [config.webMcp.apiMode='dual'] - Registration mode: registerTool only or registerTool + provideContext fallback
93
+ * @param {string} [config.webMcp.toolPrefix='docs'] - Prefix used to build WebMCP tool names (e.g. docs.search_docs)
94
+ * @param {string} [config.webMcp.bridgeEndpoint='/mcp'] - Relative endpoint used to bridge search/get_page to the MCP HTTP server
95
+ * @param {boolean} [config.webMcp.bridgeToMcp=true] - Uses MCP endpoint bridge for search/get_page tools when true
96
+ * @param {Object} [config.webMcp.tools] - Per-tool enable flags
97
+ * @param {boolean} [config.webMcp.tools.searchDocs=true] - Enables tool search_docs
98
+ * @param {boolean} [config.webMcp.tools.getPage=true] - Enables tool get_page
99
+ * @param {boolean} [config.webMcp.tools.navigateTo=true] - Enables tool navigate_to
100
+ * @param {boolean} [config.webMcp.tools.copyCurrentPage=true] - Enables tool copy_current_page
101
+ * @param {Object} [config.homePage] - Home page content source settings
102
+ * @param {'local'|'remote-readme'} [config.homePage.source='local'] - Source strategy for home page markdown
103
+ * @param {string|null} [config.homePage.remoteReadmeUrl=null] - Absolute URL of remote README markdown when source is remote-readme
104
+ * @param {number} [config.homePage.timeoutMs=8000] - Timeout in milliseconds for remote README fetch during build
105
+ * @param {boolean} [config.homePage.fallbackToLocal=true] - Fallback to local Homepage.{lang}.md when remote fetch fails
90
106
  * @returns {Object} Resolved Docsector configuration
91
107
  */
92
108
  export function createDocsector (config = {}) {
@@ -190,6 +206,30 @@ export function createDocsector (config = {}) {
190
206
  remotes: [],
191
207
  metadata: null,
192
208
  ...config.mcpServerCard
209
+ },
210
+
211
+ webMcp: {
212
+ enabled: false,
213
+ apiMode: 'dual',
214
+ toolPrefix: 'docs',
215
+ bridgeEndpoint: '/mcp',
216
+ bridgeToMcp: true,
217
+ ...config.webMcp,
218
+ tools: {
219
+ searchDocs: true,
220
+ getPage: true,
221
+ navigateTo: true,
222
+ copyCurrentPage: true,
223
+ ...(config.webMcp?.tools || {})
224
+ }
225
+ },
226
+
227
+ homePage: {
228
+ source: 'local',
229
+ remoteReadmeUrl: null,
230
+ timeoutMs: 8000,
231
+ fallbackToLocal: true,
232
+ ...config.homePage
193
233
  }
194
234
  }
195
235
  }
@@ -141,7 +141,6 @@ function createPrerenderMetaPlugin (projectRoot) {
141
141
  async closeBundle () {
142
142
  const distDir = resolve(projectRoot, 'dist', 'spa')
143
143
  const baseHtmlPath = resolve(distDir, 'index.html')
144
-
145
144
  if (!existsSync(baseHtmlPath)) return
146
145
 
147
146
  const baseHtml = readFileSync(baseHtmlPath, 'utf-8')
@@ -421,6 +420,148 @@ function createGitDatesPlugin (projectRoot) {
421
420
  }
422
421
  }
423
422
 
423
+ function getHomePageConfig (config = {}) {
424
+ const homePage = config.homePage || {}
425
+ return {
426
+ source: homePage.source || 'local',
427
+ remoteReadmeUrl: homePage.remoteReadmeUrl || null,
428
+ timeoutMs: Number.isFinite(homePage.timeoutMs)
429
+ ? Math.max(1000, Number(homePage.timeoutMs))
430
+ : 8000,
431
+ fallbackToLocal: homePage.fallbackToLocal !== false
432
+ }
433
+ }
434
+
435
+ function getConfiguredLanguages (config = {}) {
436
+ const defaultLang = config.defaultLanguage || config.languages?.[0]?.value || 'en-US'
437
+ const languageValues = config.languages?.map(language => language.value).filter(Boolean) || []
438
+ const langs = [...new Set([defaultLang, ...languageValues])]
439
+ return { defaultLang, langs }
440
+ }
441
+
442
+ async function fetchRemoteMarkdown (url, timeoutMs = 8000) {
443
+ const controller = new AbortController()
444
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
445
+
446
+ try {
447
+ const response = await fetch(url, {
448
+ headers: {
449
+ Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8'
450
+ },
451
+ signal: controller.signal
452
+ })
453
+
454
+ if (!response.ok) {
455
+ throw new Error(`Remote README request failed with status ${response.status}`)
456
+ }
457
+
458
+ return await response.text()
459
+ } finally {
460
+ clearTimeout(timeout)
461
+ }
462
+ }
463
+
464
+ async function resolveHomePageSources (projectRoot, config = {}, options = {}) {
465
+ const { logPrefix = '[docsector]' } = options
466
+ const pagesDir = resolve(projectRoot, 'src', 'pages')
467
+ const { defaultLang, langs } = getConfiguredLanguages(config)
468
+ const homePageConfig = getHomePageConfig(config)
469
+
470
+ const byLang = {}
471
+ let mode = 'local'
472
+
473
+ if (homePageConfig.source === 'remote-readme' && homePageConfig.remoteReadmeUrl) {
474
+ try {
475
+ const remote = await fetchRemoteMarkdown(homePageConfig.remoteReadmeUrl, homePageConfig.timeoutMs)
476
+ for (const lang of langs) {
477
+ byLang[lang] = remote
478
+ }
479
+ mode = 'remote-readme'
480
+ console.log(`\x1b[36m${logPrefix}\x1b[0m Loaded remote README for home page`)
481
+ return { mode, byLang, defaultLang, langs }
482
+ } catch (error) {
483
+ const reason = error?.message || String(error)
484
+ console.warn(`${logPrefix} Failed to load remote README for home page: ${reason}`)
485
+
486
+ if (!homePageConfig.fallbackToLocal) {
487
+ throw error
488
+ }
489
+ }
490
+ }
491
+
492
+ for (const lang of langs) {
493
+ const homepage = resolve(pagesDir, `Homepage.${lang}.md`)
494
+ if (existsSync(homepage)) {
495
+ byLang[lang] = readFileSync(homepage, 'utf-8')
496
+ continue
497
+ }
498
+
499
+ const fallback = resolve(pagesDir, `Homepage.${defaultLang}.md`)
500
+ if (existsSync(fallback)) {
501
+ byLang[lang] = readFileSync(fallback, 'utf-8')
502
+ }
503
+ }
504
+
505
+ return { mode, byLang, defaultLang, langs }
506
+ }
507
+
508
+ function createHomePageOverridePlugin (projectRoot) {
509
+ const virtualId = 'virtual:docsector-homepage-override'
510
+ const resolvedId = '\0' + virtualId
511
+ let byLang = null
512
+ let loadPromise = null
513
+
514
+ const ensureSources = async () => {
515
+ if (byLang) return byLang
516
+ if (!loadPromise) {
517
+ loadPromise = (async () => {
518
+ const configUrl = pathToFileURL(resolve(projectRoot, 'docsector.config.js')).href
519
+ const { default: config } = await import(configUrl)
520
+ const sources = await resolveHomePageSources(projectRoot, config, { logPrefix: '[docsector]' })
521
+ byLang = sources.byLang
522
+ return byLang
523
+ })().finally(() => {
524
+ loadPromise = null
525
+ })
526
+ }
527
+
528
+ return loadPromise
529
+ }
530
+
531
+ return {
532
+ name: 'docsector-homepage-override',
533
+ resolveId (id) {
534
+ if (id === virtualId) return resolvedId
535
+ },
536
+ async buildStart () {
537
+ await ensureSources()
538
+ },
539
+ configureServer () {
540
+ ensureSources().catch((error) => {
541
+ console.warn(`[docsector] Could not prepare home page override: ${error?.message || String(error)}`)
542
+ })
543
+ },
544
+ async load (id) {
545
+ if (id === resolvedId) {
546
+ await ensureSources()
547
+ return `export default ${JSON.stringify(byLang || {})}`
548
+ }
549
+
550
+ await ensureSources()
551
+ if (!byLang) return null
552
+
553
+ const match = id.match(/Homepage\.([A-Za-z0-9-]+)\.md\?raw(?:$|&)/)
554
+ if (!match) return null
555
+
556
+ const lang = match[1]
557
+ const content = byLang[lang]
558
+ if (typeof content !== 'string') return null
559
+
560
+ return `export default ${JSON.stringify(content)}`
561
+ }
562
+ }
563
+ }
564
+
424
565
  /**
425
566
  * Create a Vite plugin that serves raw Markdown content for `.md` suffixed URLs.
426
567
  *
@@ -496,32 +637,63 @@ function createMarkdownEndpointPlugin (projectRoot) {
496
637
  name: 'docsector-markdown-endpoint',
497
638
 
498
639
  configureServer (server) {
499
- // Read default language from config
500
640
  let defaultLang = 'en-US'
501
641
  let markdownNegotiationEnabled = true
502
642
  let markdownAgentFallback = true
503
- try {
504
- const configPath = resolve(projectRoot, 'docsector.config.js')
505
- if (existsSync(configPath)) {
506
- // Dynamic import in dev — we read it synchronously via a simple approach
507
- const configContent = readFileSync(configPath, 'utf-8')
508
- const match = configContent.match(/defaultLanguage\s*:\s*['"]([^'"]+)['"]/)
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'
643
+ let homepageByLang = null
644
+
645
+ const configReady = (async () => {
646
+ try {
647
+ const configUrl = pathToFileURL(resolve(projectRoot, 'docsector.config.js')).href
648
+ const { default: config } = await import(configUrl)
649
+
650
+ defaultLang = config.defaultLanguage || config.languages?.[0]?.value || 'en-US'
651
+ const markdownNegotiationConfig = config.markdownNegotiation || {}
652
+ markdownNegotiationEnabled = markdownNegotiationConfig.enabled !== false
653
+ markdownAgentFallback = markdownNegotiationConfig.agentFallback !== false
654
+
655
+ const sources = await resolveHomePageSources(projectRoot, config, { logPrefix: '[docsector]' })
656
+ homepageByLang = sources.byLang
657
+ } catch (error) {
658
+ console.warn(`[docsector] Could not load config for markdown endpoint: ${error?.message || String(error)}`)
516
659
  }
517
- } catch { /* use fallback */ }
660
+ })()
661
+
662
+ server.middlewares.use(async (req, res, next) => {
663
+ await configReady
518
664
 
519
- server.middlewares.use((req, res, next) => {
520
665
  const url = new URL(req.url, 'http://localhost')
521
666
  const accept = (req.headers.accept || '').toLowerCase()
522
667
  const wantsMarkdown = accept.includes('text/markdown')
523
668
  const lang = url.searchParams.get('lang') || defaultLang
524
669
 
670
+ const homepagePath = url.pathname === '/' || url.pathname === '/index.html'
671
+ const remoteHomepage = homepageByLang?.[lang] || homepageByLang?.[defaultLang] || null
672
+
673
+ const homepageMarkdownMatch = url.pathname.match(/^\/homepage(?:\.([A-Za-z0-9-]+))?\.md$/i)
674
+ if (homepageMarkdownMatch) {
675
+ const requestedLang = homepageMarkdownMatch[1] || lang
676
+ const homepageMarkdown = homepageByLang?.[requestedLang] || homepageByLang?.[defaultLang] || null
677
+
678
+ if (typeof homepageMarkdown === 'string' && homepageMarkdown.length > 0) {
679
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
680
+ res.setHeader('Vary', 'Accept')
681
+ res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(homepageMarkdown)))
682
+ res.end(homepageMarkdown)
683
+ return
684
+ }
685
+ }
686
+
687
+ if (homepagePath && typeof remoteHomepage === 'string' && remoteHomepage.length > 0) {
688
+ if ((markdownNegotiationEnabled && wantsMarkdown) || (markdownAgentFallback && LLM_BOT_PATTERN.test(req.headers['user-agent'] || ''))) {
689
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
690
+ res.setHeader('Vary', 'Accept')
691
+ res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(remoteHomepage)))
692
+ res.end(remoteHomepage)
693
+ return
694
+ }
695
+ }
696
+
525
697
  // Explicit .md request
526
698
  if (url.pathname.endsWith('.md')) {
527
699
  const file = resolveMarkdownFile(url.pathname, lang)
@@ -620,16 +792,14 @@ function createMarkdownBuildPlugin (projectRoot) {
620
792
  }
621
793
 
622
794
  // 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])]
795
+ const homepageSources = await resolveHomePageSources(projectRoot, config, { logPrefix: '[docsector]' })
625
796
  let homepageCount = 0
626
- for (const lang of allLangs) {
627
- const homepageSrc = resolve(pagesDir, `Homepage.${lang}.md`)
628
- if (!existsSync(homepageSrc)) continue
797
+ for (const lang of homepageSources.langs) {
798
+ const homepageContent = homepageSources.byLang?.[lang]
799
+ if (typeof homepageContent !== 'string' || homepageContent.length === 0) continue
629
800
 
630
- const homepageContent = readFileSync(homepageSrc, 'utf-8')
631
801
  writeFileSync(resolve(distDir, `homepage.${lang}.md`), homepageContent)
632
- if (lang === defaultLang) {
802
+ if (lang === homepageSources.defaultLang) {
633
803
  writeFileSync(resolve(distDir, 'homepage.md'), homepageContent)
634
804
  }
635
805
  homepageCount++
@@ -1705,6 +1875,7 @@ export function createQuasarConfig (options = {}) {
1705
1875
 
1706
1876
  vitePlugins: [
1707
1877
  createHjsonPlugin(),
1878
+ createHomePageOverridePlugin(projectRoot),
1708
1879
  createGitDatesPlugin(projectRoot),
1709
1880
  createMarkdownEndpointPlugin(projectRoot),
1710
1881
  createMarkdownBuildPlugin(projectRoot),