@docsector/docsector-reader 4.3.3 → 4.4.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.
@@ -0,0 +1,69 @@
1
+ ## Visão Geral
2
+
3
+ O AI Assistant adiciona um painel opcional de chat às páginas do Docsector. Ele foi desenhado para fluxos de RAG semântico na Cloudflare, mantendo a integração do navegador simples: usuários abrem um drawer pelo header global, e a Cloudflare Pages Function conversa com o provedor de IA configurado.
4
+
5
+ O primeiro provedor é o Cloudflare AI Search. Ele pode rastrear o sitemap Markdown gerado pelo Docsector, recuperar trechos relevantes com busca híbrida e transmitir uma resposta com trechos de origem que o painel mostra como citações.
6
+
7
+ ## O Que Ele Adiciona
8
+
9
+ - Um drawer lateral direito no desktop.
10
+ - Um diálogo em tela cheia no mobile.
11
+ - Prompts sugeridos, contexto da página atual, respostas em streaming e links de fontes.
12
+ - Artefatos de build para AI Search quando `siteUrl` está configurado.
13
+ - Um endpoint interno same-origin para manter credenciais no servidor.
14
+
15
+ ## Habilitar
16
+
17
+ ```javascript
18
+ export default {
19
+ siteUrl: 'https://docs.example.com',
20
+
21
+ aiAssistant: {
22
+ enabled: true,
23
+ provider: 'aiSearch',
24
+ endpoint: '/assistant',
25
+ aiSearch: {
26
+ binding: 'AI_SEARCH',
27
+ instanceNameEnv: 'AI_SEARCH_INSTANCE_NAME',
28
+ accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
29
+ apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
30
+ retrievalType: 'hybrid',
31
+ maxResults: 6,
32
+ stream: true
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Defina `AI_SEARCH_INSTANCE_NAME` nas variáveis de ambiente do Cloudflare Pages em deploy, ou em `.dev.vars` quando usar `wrangler pages dev` localmente.
39
+
40
+ ## Cloudflare AI Search
41
+
42
+ Crie uma instância do AI Search e configure uma fonte de dados Website. O Docsector sempre publica `/sitemap.xml` no build e anuncia esse arquivo em `robots.txt`, então o crawler da Cloudflare consegue descobrir o site automaticamente.
43
+
44
+ Para uma recuperação mais limpa, aponte a configuração de sitemap específico para:
45
+
46
+ ```text
47
+ https://docs.example.com/ai-search-sitemap.xml
48
+ ```
49
+
50
+ O sitemap do AI Search aponta para URLs Markdown, que são mais limpas para recuperação do que HTML renderizado pela SPA. O manifest em `/.well-known/ai-search/manifest.json` lista títulos, rotas, locales, books, versões e subpáginas do mesmo conjunto de fontes.
51
+
52
+ ## Endpoint Runtime
53
+
54
+ A Pages Function gerada aceita mensagens de chat, metadados da rota atual, locale e texto selecionado opcional. Ela encaminha a solicitação ao AI Search por binding quando disponível, ou por REST usando variáveis de ambiente criptografadas da Cloudflare. O endpoint é uma API interna do drawer, não uma página para o usuário navegar.
55
+
56
+ O navegador nunca precisa de um token da API Cloudflare.
57
+
58
+ ## Validar
59
+
60
+ ```bash
61
+ npx docsector build
62
+ cat dist/spa/sitemap.xml
63
+ cat dist/spa/robots.txt
64
+ cat dist/spa/ai-search-sitemap.xml
65
+ cat dist/spa/.well-known/ai-search/manifest.json
66
+ npx wrangler pages dev dist/spa
67
+ ```
68
+
69
+ Bindings remotos de AI Search e Workers AI podem gerar uso cobrado pela Cloudflare durante o desenvolvimento local.
@@ -265,6 +265,35 @@ export default {
265
265
  }
266
266
  },
267
267
 
268
+ '/basic/ai-assistant': {
269
+ config: {
270
+ icon: 'auto_awesome',
271
+ status: 'new',
272
+ version: 'v4.4.0',
273
+ meta: {
274
+ description: {
275
+ 'en-US': 'AI Assistant — Documentation of Docsector Reader',
276
+ 'pt-BR': 'Assistente de IA — Documentacao do Docsector Reader'
277
+ }
278
+ },
279
+ book: 'manual',
280
+ menu: {},
281
+ subpages: {
282
+ showcase: false
283
+ }
284
+ },
285
+ data: {
286
+ 'en-US': { title: 'AI Assistant' },
287
+ 'pt-BR': { title: 'Assistente de IA' }
288
+ },
289
+ metadata: {
290
+ tags: {
291
+ 'en-US': 'ai assistant cloudflare ai search rag drawer semantic search citations streaming',
292
+ 'pt-BR': 'ia assistente cloudflare ai search rag drawer busca semântica citações streaming'
293
+ }
294
+ }
295
+ },
296
+
268
297
  '/basic/agent-skills': {
269
298
  config: {
270
299
  icon: 'psychology',
@@ -30,6 +30,11 @@ import { resolve } from 'path'
30
30
  import { pathToFileURL } from 'url'
31
31
  import HJSON from 'hjson'
32
32
 
33
+ import { normalizeAiAssistantConfig } from './ai-assistant/config.js'
34
+ import { createAiSearchIndexArtifacts } from './ai-assistant/indexing.js'
35
+ import { MARKDOWN_AGENT_USER_AGENT_SOURCE, matchesMarkdownAgentUserAgent } from './markdown-agent.js'
36
+ import { appendSitemapsToRobots, createSitemap } from './sitemap.js'
37
+
33
38
  /**
34
39
  * No-op configure wrapper.
35
40
  * Quasar's `configure` from `quasar/wrappers` is a TypeScript identity function.
@@ -1751,9 +1756,6 @@ function createMarkdownEndpointPlugin (projectRoot) {
1751
1756
  return Math.max(1, Math.ceil(markdown.length / 4))
1752
1757
  }
1753
1758
 
1754
- // LLM bot user-agent patterns
1755
- 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
1756
-
1757
1759
  return {
1758
1760
  name: 'docsector-markdown-endpoint',
1759
1761
 
@@ -1806,7 +1808,7 @@ function createMarkdownEndpointPlugin (projectRoot) {
1806
1808
  }
1807
1809
 
1808
1810
  if (homepagePath && typeof remoteHomepage === 'string' && remoteHomepage.length > 0) {
1809
- if ((markdownNegotiationEnabled && wantsMarkdown) || (markdownAgentFallback && LLM_BOT_PATTERN.test(req.headers['user-agent'] || ''))) {
1811
+ if ((markdownNegotiationEnabled && wantsMarkdown) || (markdownAgentFallback && matchesMarkdownAgentUserAgent(req.headers['user-agent'] || ''))) {
1810
1812
  res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
1811
1813
  res.setHeader('Vary', 'Accept')
1812
1814
  res.setHeader('x-markdown-tokens', String(estimateMarkdownTokens(remoteHomepage)))
@@ -1843,7 +1845,7 @@ function createMarkdownEndpointPlugin (projectRoot) {
1843
1845
 
1844
1846
  // Auto-serve markdown to LLM bot crawlers
1845
1847
  const ua = req.headers['user-agent'] || ''
1846
- if (markdownAgentFallback && LLM_BOT_PATTERN.test(ua)) {
1848
+ if (markdownAgentFallback && matchesMarkdownAgentUserAgent(ua)) {
1847
1849
  const file = resolveNegotiatedFile(url.pathname, lang)
1848
1850
  if (file) {
1849
1851
  const content = readFileSync(file, 'utf-8')
@@ -1863,6 +1865,74 @@ function createMarkdownEndpointPlugin (projectRoot) {
1863
1865
  }
1864
1866
  }
1865
1867
 
1868
+ function collectAiSearchIndexEntries ({ pagesDir, pageEntries = [], defaultLang = 'en-US' } = {}) {
1869
+ const entries = []
1870
+
1871
+ for (const entry of pageEntries) {
1872
+ const { book, pagePath, page } = entry
1873
+ if (page?.config === null) continue
1874
+ if (page?.config?.status === 'empty') continue
1875
+
1876
+ const title = page?.data?.['*']?.title
1877
+ || page?.data?.[defaultLang]?.title
1878
+ || page?.data?.['en-US']?.title
1879
+ || pagePath?.split('/').pop()
1880
+ || pagePath
1881
+
1882
+ const subpages = ['overview']
1883
+ if (page?.config?.subpages?.showcase) subpages.push('showcase')
1884
+ if (page?.config?.subpages?.vs) subpages.push('vs')
1885
+
1886
+ for (const subpage of subpages) {
1887
+ const srcFile = resolveMarkdownSourceFile(pagesDir, entry, subpage, defaultLang)
1888
+ if (!existsSync(srcFile)) continue
1889
+
1890
+ const routePath = buildPageRoutePath(entry, subpage)
1891
+ entries.push({
1892
+ title,
1893
+ path: routePath,
1894
+ markdownPath: `${routePath}.md`,
1895
+ locale: defaultLang,
1896
+ book,
1897
+ version: entry.version,
1898
+ subpage
1899
+ })
1900
+ }
1901
+ }
1902
+
1903
+ return entries
1904
+ }
1905
+
1906
+ function collectStandardSitemapEntries ({ pagesDir, pageEntries = [], defaultLang = 'en-US' } = {}) {
1907
+ const entries = [
1908
+ { path: '/', priority: '1.0' }
1909
+ ]
1910
+ const seenPaths = new Set(['/'])
1911
+
1912
+ for (const entry of pageEntries) {
1913
+ const { page } = entry
1914
+ if (page?.config === null) continue
1915
+ if (page?.config?.status === 'empty') continue
1916
+
1917
+ const subpages = ['overview']
1918
+ if (page?.config?.subpages?.showcase) subpages.push('showcase')
1919
+ if (page?.config?.subpages?.vs) subpages.push('vs')
1920
+
1921
+ for (const subpage of subpages) {
1922
+ const srcFile = resolveMarkdownSourceFile(pagesDir, entry, subpage, defaultLang)
1923
+ if (!existsSync(srcFile)) continue
1924
+
1925
+ const routePath = buildPageRoutePath(entry, subpage, { leadingSlash: true })
1926
+ if (seenPaths.has(routePath)) continue
1927
+
1928
+ seenPaths.add(routePath)
1929
+ entries.push({ path: routePath })
1930
+ }
1931
+ }
1932
+
1933
+ return entries
1934
+ }
1935
+
1866
1936
  /**
1867
1937
  * Create a Vite plugin that generates static `.md` files at build time.
1868
1938
  *
@@ -1883,6 +1953,7 @@ function createMarkdownBuildPlugin (projectRoot) {
1883
1953
 
1884
1954
  const { default: config } = await import(configUrl)
1885
1955
  const { pageEntries } = await loadBooksRegistry(projectRoot)
1956
+ const assistantConfig = normalizeAiAssistantConfig(config)
1886
1957
 
1887
1958
  const defaultLang = config.defaultLanguage || config.languages?.[0]?.value || 'en-US'
1888
1959
  let count = 0
@@ -1929,34 +2000,23 @@ function createMarkdownBuildPlugin (projectRoot) {
1929
2000
 
1930
2001
  console.log(`\x1b[36m[docsector]\x1b[0m Generated ${count} static .md files`)
1931
2002
 
1932
- // Generate sitemap.xml if siteUrl is configured
1933
2003
  const siteUrl = (config.siteUrl || '').replace(/\/+$/, '')
1934
- if (siteUrl) {
1935
- const today = new Date().toISOString().split('T')[0]
1936
- let urls = ''
1937
-
1938
- for (const entry of pageEntries) {
1939
- const { page } = entry
1940
- if (page.config === null) continue
1941
- if (page.config.status === 'empty') continue
1942
-
1943
- const subpages = ['overview']
1944
- if (page.config.subpages?.showcase) subpages.push('showcase')
1945
- if (page.config.subpages?.vs) subpages.push('vs')
1946
-
1947
- for (const subpage of subpages) {
1948
- const srcFile = resolveMarkdownSourceFile(pagesDir, entry, subpage, defaultLang)
1949
- if (!existsSync(srcFile)) continue
1950
-
1951
- const routePath = buildPageRoutePath(entry, subpage, { leadingSlash: true })
1952
- urls += ` <url>\n <loc>${siteUrl}${routePath}</loc>\n <lastmod>${today}</lastmod>\n </url>\n`
1953
- }
1954
- }
1955
-
1956
- const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}</urlset>\n`
2004
+ const generatedAt = new Date().toISOString()
2005
+ const sitemapConfig = config.sitemap || {}
2006
+ const sitemapEnabled = sitemapConfig.enabled !== false
2007
+
2008
+ if (sitemapEnabled) {
2009
+ const sitemapEntries = collectStandardSitemapEntries({ pagesDir, pageEntries, defaultLang })
2010
+ const sitemap = createSitemap({
2011
+ entries: sitemapEntries,
2012
+ generatedAt,
2013
+ siteUrl
2014
+ })
1957
2015
  writeFileSync(resolve(distDir, 'sitemap.xml'), sitemap)
1958
- console.log(`\x1b[36m[docsector]\x1b[0m Generated sitemap.xml`)
2016
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated sitemap.xml (${sitemapEntries.length} URLs)`)
2017
+ }
1959
2018
 
2019
+ if (siteUrl) {
1960
2020
  // Generate llms.txt and llms-full.txt (LLM-friendly page index and full content)
1961
2021
  const brandingName = config.branding?.name || 'Documentation'
1962
2022
  const brandingVersion = config.branding?.version || ''
@@ -2047,6 +2107,8 @@ function createMarkdownBuildPlugin (projectRoot) {
2047
2107
  const agentSkillsEnabled = agentSkillsConfig.enabled === true
2048
2108
  const mcpServerCardConfig = config.mcpServerCard || {}
2049
2109
  const mcpServerCardEnabled = mcpServerCardConfig.enabled === true
2110
+ const aiAssistantEnabled = assistantConfig.enabled === true
2111
+ let aiSearchSitemapGenerated = false
2050
2112
 
2051
2113
  const toUrl = (href) => {
2052
2114
  if (!href) return null
@@ -2061,11 +2123,57 @@ function createMarkdownBuildPlugin (projectRoot) {
2061
2123
  return path || null
2062
2124
  }
2063
2125
 
2126
+ if (aiAssistantEnabled && assistantConfig.provider === 'aiSearch') {
2127
+ const aiSearchEntries = collectAiSearchIndexEntries({
2128
+ pagesDir,
2129
+ pageEntries,
2130
+ defaultLang
2131
+ })
2132
+ const artifacts = createAiSearchIndexArtifacts({
2133
+ siteUrl,
2134
+ entries: aiSearchEntries
2135
+ })
2136
+ const manifestPath = '.well-known/ai-search/manifest.json'
2137
+ const sitemapPath = 'ai-search-sitemap.xml'
2138
+
2139
+ mkdirSync(resolve(distDir, '.well-known', 'ai-search'), { recursive: true })
2140
+ writeFileSync(resolve(distDir, manifestPath), JSON.stringify(artifacts.manifest, null, 2) + '\n')
2141
+ if (artifacts.sitemap) {
2142
+ writeFileSync(resolve(distDir, sitemapPath), artifacts.sitemap)
2143
+ aiSearchSitemapGenerated = true
2144
+ }
2145
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated AI Search index artifacts (${aiSearchEntries.length} pages)`)
2146
+
2147
+ const headersWithAiSearch = readFileSync(headersPath, 'utf-8')
2148
+ const aiSearchHeaders = [
2149
+ '/.well-known/ai-search/manifest.json\n Content-Type: application/json; charset=utf-8',
2150
+ '/ai-search-sitemap.xml\n Content-Type: application/xml; charset=utf-8'
2151
+ ].filter(rule => !headersWithAiSearch.includes(rule.split('\n')[0])).join('\n\n')
2152
+ if (aiSearchHeaders) {
2153
+ writeFileSync(headersPath, headersWithAiSearch.trimEnd() + '\n\n' + aiSearchHeaders + '\n')
2154
+ }
2155
+ }
2156
+
2157
+ if (aiAssistantEnabled) {
2158
+ const functionsDir = resolve(projectRoot, 'functions')
2159
+ const packageRoot = getPackageRoot(projectRoot)
2160
+ const templatePath = resolve(packageRoot, 'src', 'ai-assistant', 'server.js')
2161
+ mkdirSync(functionsDir, { recursive: true })
2162
+
2163
+ if (existsSync(templatePath)) {
2164
+ const serverCode = readFileSync(templatePath, 'utf-8')
2165
+ .replaceAll('__AI_ASSISTANT_CONFIG__', JSON.stringify(assistantConfig, null, 2))
2166
+
2167
+ writeFileSync(resolve(functionsDir, 'assistant.js'), serverCode)
2168
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated AI Assistant endpoint at functions/assistant.js`)
2169
+ }
2170
+ }
2171
+
2064
2172
  if (markdownNegotiationEnabled || webBotAuthEnabled) {
2065
2173
  const functionsDir = resolve(projectRoot, 'functions')
2066
2174
  mkdirSync(functionsDir, { recursive: true })
2067
2175
 
2068
- 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
2176
+ const middlewareCode = `const LLM_BOT_PATTERN = new RegExp(${JSON.stringify(MARKDOWN_AGENT_USER_AGENT_SOURCE)}, 'i')
2069
2177
 
2070
2178
  const DEFAULT_LANG = ${JSON.stringify(defaultLang)}
2071
2179
  const MARKDOWN_ENABLED = ${markdownNegotiationEnabled ? 'true' : 'false'}
@@ -2227,6 +2335,7 @@ function estimateTokens (markdown = '') {
2227
2335
 
2228
2336
  function shouldBypass (pathname) {
2229
2337
  if (pathname === '/mcp' || pathname.startsWith('/mcp/')) return true
2338
+ if (pathname === '/assistant' || pathname.startsWith('/assistant/')) return true
2230
2339
  if (pathname.startsWith('/.well-known/')) return true
2231
2340
  return /\\.(js|css|map|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|xml|json|txt)$/i.test(pathname)
2232
2341
  }
@@ -2327,6 +2436,26 @@ export async function onRequest (context) {
2327
2436
  }
2328
2437
  }
2329
2438
 
2439
+ const robotsSitemapPaths = []
2440
+ if (sitemapEnabled) robotsSitemapPaths.push('/sitemap.xml')
2441
+ if (aiSearchSitemapGenerated) robotsSitemapPaths.push('/ai-search-sitemap.xml')
2442
+
2443
+ if (robotsSitemapPaths.length > 0) {
2444
+ const robotsPath = resolve(distDir, 'robots.txt')
2445
+ const existingRobots = existsSync(robotsPath)
2446
+ ? readFileSync(robotsPath, 'utf-8')
2447
+ : 'User-agent: *\nAllow: /\n'
2448
+ const patchedRobots = appendSitemapsToRobots(existingRobots, {
2449
+ sitemaps: robotsSitemapPaths,
2450
+ siteUrl
2451
+ })
2452
+
2453
+ if (patchedRobots !== existingRobots) {
2454
+ writeFileSync(robotsPath, patchedRobots)
2455
+ console.log(`\x1b[36m[docsector]\x1b[0m Updated robots.txt with sitemap discovery`)
2456
+ }
2457
+ }
2458
+
2330
2459
  if (agentSkillsEnabled) {
2331
2460
  const agentSkillsPath = agentSkillsConfig.path || '/.well-known/agent-skills/index.json'
2332
2461
  const agentSkillsSchema = agentSkillsConfig.schema || 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
@@ -2653,7 +2782,7 @@ export async function onRequest (context) {
2653
2782
  }
2654
2783
 
2655
2784
  // Generate or merge _routes.json for Cloudflare Pages functions
2656
- if (config.mcp || markdownNegotiationEnabled || webBotAuthEnabled) {
2785
+ if (config.mcp || aiAssistantEnabled || markdownNegotiationEnabled || webBotAuthEnabled) {
2657
2786
  const routesPath = resolve(distDir, '_routes.json')
2658
2787
  let routes = { version: 1, include: [], exclude: [] }
2659
2788
  if (existsSync(routesPath)) {
@@ -2672,6 +2801,10 @@ export async function onRequest (context) {
2672
2801
  routes.include.push('/mcp')
2673
2802
  }
2674
2803
 
2804
+ if (aiAssistantEnabled && !markdownNegotiationEnabled && !routes.include.includes('/assistant')) {
2805
+ routes.include.push('/assistant')
2806
+ }
2807
+
2675
2808
  if (webBotAuthEnabled && !markdownNegotiationEnabled && !routes.include.includes(webBotAuthDirectoryPath)) {
2676
2809
  routes.include.push(webBotAuthDirectoryPath)
2677
2810
  }
@@ -3144,7 +3277,7 @@ export function createQuasarConfig (options = {}) {
3144
3277
  config: {},
3145
3278
  lang: 'en-US',
3146
3279
  plugins: [
3147
- 'Meta', 'LocalStorage', 'SessionStorage'
3280
+ 'Meta', 'Notify', 'LocalStorage', 'SessionStorage'
3148
3281
  ]
3149
3282
  },
3150
3283
 
package/src/sitemap.js ADDED
@@ -0,0 +1,103 @@
1
+ function escapeXml (value) {
2
+ return String(value || '')
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&apos;')
8
+ }
9
+
10
+ function normalizeBaseUrl (siteUrl) {
11
+ return String(siteUrl || '').replace(/\/+$/g, '')
12
+ }
13
+
14
+ function normalizeLocalUrl (value) {
15
+ const path = String(value || '').trim()
16
+ if (!path || path === '/') return '/'
17
+ if (/^https?:\/\//i.test(path)) return path
18
+ return `/${path.replace(/^\/+/, '')}`
19
+ }
20
+
21
+ function resolveSitemapUrl (path, siteUrl) {
22
+ const localUrl = normalizeLocalUrl(path)
23
+ if (/^https?:\/\//i.test(localUrl)) return localUrl
24
+
25
+ const baseUrl = normalizeBaseUrl(siteUrl)
26
+ return baseUrl ? `${baseUrl}${localUrl}` : localUrl
27
+ }
28
+
29
+ function normalizeSitemapIdentity (value) {
30
+ const normalized = normalizeLocalUrl(String(value || '').trim())
31
+ if (!/^https?:\/\//i.test(normalized)) return normalized.toLowerCase()
32
+
33
+ try {
34
+ const url = new URL(normalized)
35
+ return `${url.pathname}${url.search}`.toLowerCase()
36
+ } catch {
37
+ return normalized.toLowerCase()
38
+ }
39
+ }
40
+
41
+ function normalizeSitemapEntry (entry) {
42
+ if (typeof entry === 'string') return { path: entry }
43
+ return entry && typeof entry === 'object' ? entry : null
44
+ }
45
+
46
+ export function createSitemap ({ entries = [], siteUrl = '', generatedAt = new Date().toISOString() } = {}) {
47
+ const lastmod = String(generatedAt || new Date().toISOString()).slice(0, 10)
48
+ const urls = (Array.isArray(entries) ? entries : [])
49
+ .map(normalizeSitemapEntry)
50
+ .filter(entry => entry?.path)
51
+ .map(entry => {
52
+ const lines = [
53
+ ' <url>',
54
+ ` <loc>${escapeXml(resolveSitemapUrl(entry.path, siteUrl))}</loc>`,
55
+ ` <lastmod>${escapeXml(entry.lastmod || lastmod)}</lastmod>`
56
+ ]
57
+
58
+ if (entry.changefreq) {
59
+ lines.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`)
60
+ }
61
+
62
+ if (entry.priority !== undefined && entry.priority !== null) {
63
+ lines.push(` <priority>${escapeXml(entry.priority)}</priority>`)
64
+ }
65
+
66
+ lines.push(' </url>')
67
+ return lines.join('\n')
68
+ })
69
+ .join('\n')
70
+
71
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls ? `${urls}\n` : ''}</urlset>\n`
72
+ }
73
+
74
+ export function appendSitemapsToRobots (robotsContent, { sitemaps = [], siteUrl = '' } = {}) {
75
+ const input = typeof robotsContent === 'string' && robotsContent.trim()
76
+ ? robotsContent
77
+ : 'User-agent: *\nAllow: /\n'
78
+
79
+ const existingIdentities = new Set(
80
+ input
81
+ .replace(/\r\n/g, '\n')
82
+ .split('\n')
83
+ .map(line => line.match(/^\s*Sitemap\s*:\s*(.+?)\s*$/i)?.[1])
84
+ .filter(Boolean)
85
+ .map(normalizeSitemapIdentity)
86
+ )
87
+
88
+ const addedIdentities = new Set()
89
+ const sitemapLines = (Array.isArray(sitemaps) ? sitemaps : [sitemaps])
90
+ .filter(Boolean)
91
+ .map(sitemap => resolveSitemapUrl(sitemap, siteUrl))
92
+ .filter(sitemap => {
93
+ const identity = normalizeSitemapIdentity(sitemap)
94
+ if (existingIdentities.has(identity) || addedIdentities.has(identity)) return false
95
+ addedIdentities.add(identity)
96
+ return true
97
+ })
98
+ .map(sitemap => `Sitemap: ${sitemap}`)
99
+
100
+ if (sitemapLines.length === 0) return input
101
+
102
+ return `${input.replace(/\s+$/g, '')}\n${sitemapLines.join('\n')}\n`
103
+ }
@@ -29,7 +29,9 @@ export default {
29
29
  // Main
30
30
  scrolling: true,
31
31
  meta: true,
32
- metaToggle: false
32
+ metaToggle: false,
33
+ assistant: false,
34
+ assistantWidth: 380
33
35
  },
34
36
  getters: {
35
37
  view (state) {
@@ -118,6 +120,12 @@ export default {
118
120
  },
119
121
  setMetaToggle (state, val) {
120
122
  state.metaToggle = val
123
+ },
124
+ setAssistant (state, val) {
125
+ state.assistant = val
126
+ },
127
+ setAssistantWidth (state, val) {
128
+ state.assistantWidth = val
121
129
  }
122
130
  },
123
131
  actions: {}