@agentskit/doc-bridge 1.0.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CONTRIBUTING.md +8 -0
  3. package/README.md +114 -17
  4. package/SECURITY.md +1 -1
  5. package/action.yml +23 -26
  6. package/dist/cli/program.js +1241 -309
  7. package/dist/cli/program.js.map +1 -1
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +338 -13
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/{index-CPUJbTbg.d.ts → index-DGI9TBLE.d.ts} +906 -11
  12. package/dist/index.d.ts +65 -11
  13. package/dist/index.js +1084 -171
  14. package/dist/index.js.map +1 -1
  15. package/docs/DOGFOOD-ROUND2.md +5 -0
  16. package/docs/DOGFOOD-ROUND3.md +5 -0
  17. package/docs/DOGFOOD-V1.md +5 -0
  18. package/docs/DOGFOOD.md +5 -0
  19. package/docs/MARKETPLACE-ECOSYSTEM-PLAN.md +16 -0
  20. package/docs/MARKETPLACE.md +39 -0
  21. package/docs/POSITIONING.md +5 -0
  22. package/docs/RELEASE.md +26 -19
  23. package/docs/agent-corpus/INDEX.md +10 -0
  24. package/docs/agent-corpus/OVERVIEW.md +9 -0
  25. package/docs/agent-corpus/chat.md +10 -0
  26. package/docs/agent-corpus/cli.md +10 -0
  27. package/docs/agent-corpus/conformance.md +10 -0
  28. package/docs/agent-corpus/doc-bridge.md +10 -0
  29. package/docs/agent-corpus/doctor.md +10 -0
  30. package/docs/agent-corpus/gates.md +10 -0
  31. package/docs/agent-corpus/mcp.md +10 -0
  32. package/docs/agent-corpus/memory.md +10 -0
  33. package/docs/agent-corpus/query.md +10 -0
  34. package/docs/chat-and-rag.md +34 -0
  35. package/docs/examples.md +5 -0
  36. package/docs/for-agents.md +31 -0
  37. package/docs/getting-started.md +32 -2
  38. package/docs/index.md +23 -0
  39. package/docs/landing/assets/doc-bridge-hero.webp +0 -0
  40. package/docs/landing/assets/doc-bridge-surfaces.webp +0 -0
  41. package/docs/landing/assets/doc-bridge-two-way.webp +0 -0
  42. package/docs/landing/index.html +70 -10
  43. package/docs/mcp.md +5 -0
  44. package/docs/meta.json +20 -0
  45. package/docs/ollama-demo.md +6 -1
  46. package/docs/playbook/doc-bridge-pattern.md +4 -2
  47. package/docs/query.md +34 -0
  48. package/docs/recipes/index-pipeline.md +7 -2
  49. package/docs/schemas/agent-handoff-v1.md +5 -0
  50. package/docs/schemas/doc-bridge-index-v1.md +5 -0
  51. package/docs/schemas/memory-candidate-v1.md +15 -1
  52. package/docs/skills/doc-bridge.md +6 -1
  53. package/docs/spec/cli.md +6 -0
  54. package/docs/spec/config-v1.md +56 -6
  55. package/docs/spec/documentation-standard-v1.md +136 -0
  56. package/docs/spec/playbook-feedback.md +5 -0
  57. package/docs/spec/registry-agents.md +5 -0
  58. package/ecosystem-claims.json +187 -0
  59. package/ecosystem-upstream.json +9 -0
  60. package/ecosystem.json +235 -0
  61. package/examples/verify-handoff.mjs +5 -0
  62. package/package.json +46 -4
  63. package/scripts/check-ecosystem-upstream.mjs +50 -0
  64. package/src/cli/program.ts +36 -3
  65. package/src/config/index.ts +7 -1
  66. package/src/config/load-config.ts +4 -14
  67. package/src/config/schema.ts +91 -0
  68. package/src/conformance/documentation-standard-v1.ts +502 -0
  69. package/src/conformance/ecosystem-contract.ts +175 -0
  70. package/src/gates/run-gates.ts +33 -4
  71. package/src/index-builder/human-adapters/core.ts +12 -5
  72. package/src/index-builder/human-adapters/docusaurus.ts +29 -44
  73. package/src/index-builder/human-adapters/index.ts +15 -3
  74. package/src/index-builder/scan-corpus.ts +6 -6
  75. package/src/index.ts +17 -0
  76. package/src/lib/bounded-text.ts +25 -0
  77. package/src/lib/paths.ts +20 -2
  78. package/src/lib/static-js-literal.ts +261 -0
  79. package/src/lib/walk.ts +23 -4
  80. package/src/version.ts +1 -1
@@ -1,14 +1,23 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
3
  import type { DocBridgeConfigV1 } from '../config/schema.js'
4
+ import {
5
+ runDocumentationStandardV1,
6
+ type DocumentationConformanceReportV1,
7
+ } from '../conformance/documentation-standard-v1.js'
4
8
  import { buildDocBridgeIndex } from '../index-builder/build-index.js'
5
9
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
6
10
  import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
7
11
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
8
12
 
9
- export type GateId = 'index-freshness' | 'human-guide-links' | 'okf-type' | 'docs-style'
13
+ export type GateId =
14
+ | 'index-freshness'
15
+ | 'human-guide-links'
16
+ | 'okf-type'
17
+ | 'docs-style'
18
+ | 'documentation-standard-v1'
10
19
 
11
- const SUPPORTED_GATES = ['index-freshness', 'human-guide-links', 'okf-type', 'docs-style'] as const
20
+ const RESERVED_GATE_IDS = new Set(['link-rot', 'routing-currency', 'bootstrap-size'])
12
21
 
13
22
  export type GateResult = {
14
23
  readonly id: GateId
@@ -16,6 +25,7 @@ export type GateResult = {
16
25
  readonly message: string
17
26
  readonly expected?: string
18
27
  readonly actual?: string
28
+ readonly details?: DocumentationConformanceReportV1
19
29
  }
20
30
 
21
31
  export type GateRunResult = {
@@ -28,6 +38,19 @@ export const runGate = (
28
38
  config: DocBridgeConfigV1,
29
39
  id: GateId,
30
40
  ): GateResult => {
41
+ if (id === 'documentation-standard-v1') {
42
+ const details = runDocumentationStandardV1(root, config)
43
+ return {
44
+ id,
45
+ ok: details.ok,
46
+ message: details.ok
47
+ ? 'Documentation Standard v1 required rules pass'
48
+ : `${details.summary.required.failed} Documentation Standard v1 required rule(s) failed`,
49
+ expected: 'all required rules pass or have approved exceptions',
50
+ actual: `${details.summary.required.passed} passed, ${details.summary.required.failed} failed, ${details.summary.required.excepted} excepted`,
51
+ details,
52
+ }
53
+ }
31
54
  if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config)
32
55
  if (id === 'okf-type') return runOkfTypeGate(root, config)
33
56
  if (id === 'docs-style') return runDocsStyleGate(root, config)
@@ -264,10 +287,16 @@ export const resolveGateIds = (config: DocBridgeConfigV1): GateId[] => {
264
287
  )
265
288
 
266
289
  for (const id of config.gates?.include ?? []) {
267
- if (SUPPORTED_GATES.includes(id as GateId)) ids.add(id as GateId)
290
+ if (RESERVED_GATE_IDS.has(id)) {
291
+ process.emitWarning(`Gate "${id}" is reserved and has no runtime implementation; it was not executed.`, {
292
+ code: 'AK_DOCS_RESERVED_GATE',
293
+ })
294
+ continue
295
+ }
296
+ ids.add(id as GateId)
268
297
  }
269
298
  for (const id of config.gates?.exclude ?? []) {
270
- if (SUPPORTED_GATES.includes(id as GateId)) ids.delete(id as GateId)
299
+ if (!RESERVED_GATE_IDS.has(id)) ids.delete(id as GateId)
271
300
  }
272
301
 
273
302
  return [...ids]
@@ -1,9 +1,10 @@
1
- import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  import type { HumanCorpusConfig } from '../../config/schema.js'
5
5
  import { slugFromPath } from '../../lib/markdown.js'
6
- import { toPosix } from '../../lib/paths.js'
6
+ import { readBoundedText } from '../../lib/bounded-text.js'
7
+ import { containedProjectPath, toPosix } from '../../lib/paths.js'
7
8
  import { walkFiles } from '../../lib/walk.js'
8
9
 
9
10
  export type HumanDocRecord = {
@@ -79,11 +80,17 @@ export const scanMarkdownDocs = (
79
80
  },
80
81
  ): HumanDocRecord[] => {
81
82
  const out: HumanDocRecord[] = []
82
- const absRoot = join(root, humanRoot)
83
+ const projectRoot = realpathSync.native(resolve(root))
84
+ const absRoot = containedProjectPath(root, humanRoot)
85
+ if (!absRoot) return out
86
+ const budget = { used: 0 }
83
87
 
84
88
  for (const abs of walkFiles(absRoot, { extensions: ['.md', '.mdx'] })) {
89
+ const canonical = realpathSync.native(abs)
90
+ const fileRelative = relative(projectRoot, canonical)
91
+ if (isAbsolute(fileRelative) || fileRelative === '..' || fileRelative.startsWith(`..${sep}`)) continue
85
92
  const relToHumanRoot = toPosix(abs.replace(`${toPosix(absRoot)}/`, ''))
86
- const raw = readFileSync(abs, 'utf8')
93
+ const raw = readBoundedText(abs, budget)
87
94
  if (options?.includeRelPath && !options.includeRelPath(relToHumanRoot, raw)) continue
88
95
  out.push({
89
96
  id: options?.idForDoc?.(relToHumanRoot, raw) ?? docId(relToHumanRoot, raw),
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import vm from 'node:vm'
1
+ import { existsSync } from 'node:fs'
4
2
 
3
+ import { readBoundedText } from '../../lib/bounded-text.js'
4
+ import { containedProjectPath } from '../../lib/paths.js'
5
+ import { parseStaticJsObject } from '../../lib/static-js-literal.js'
5
6
  import {
6
7
  optionString,
7
8
  parseFrontmatter,
@@ -43,48 +44,31 @@ const docusaurusRecordId = (relPath: string, raw: string): string => {
43
44
  return frontmatter.package ?? frontmatter.module ?? docusaurusSidebarId(relPath, raw)
44
45
  }
45
46
 
46
- const sidebarsValue = (file: string): unknown => {
47
- if (!existsSync(file)) return undefined
48
- const raw = readFileSync(file, 'utf8')
49
- .replace(/import\s+type\s+[\s\S]*?;?\n/g, '')
50
- .replace(/:\s*[A-Za-z0-9_.$<>{}\[\],\s]+(?=\s*=)/g, '')
51
- .replace(/\s+satisfies\s+[A-Za-z0-9_.$<>{}\[\],\s]+(?=\s*(?:;|\n|$))/g, '')
52
- .replace(/export\s+default/, 'module.exports =')
53
- const sandbox = { module: { exports: {} as unknown }, exports: {} }
54
- vm.runInNewContext(raw, sandbox, { timeout: 250 })
55
- return sandbox.module.exports
56
- }
57
-
58
- const visitSidebar = (value: unknown, filter: { ids: Set<string>; autogenDirs: string[] }): void => {
59
- if (typeof value === 'string') {
60
- filter.ids.add(value)
61
- return
62
- }
63
- if (Array.isArray(value)) {
64
- for (const item of value) visitSidebar(item, filter)
65
- return
66
- }
67
- if (!value || typeof value !== 'object') return
68
-
69
- const item = value as Record<string, unknown>
70
- if ((item.type === 'doc' || item.type === 'ref') && typeof item.id === 'string') {
71
- filter.ids.add(item.id)
72
- }
73
- if (item.type === 'autogenerated' && typeof item.dirName === 'string') {
74
- filter.autogenDirs.push(item.dirName)
75
- }
76
- if (Array.isArray(item.items)) visitSidebar(item.items, filter)
77
- if (item.link && typeof item.link === 'object') visitSidebar(item.link, filter)
78
- for (const child of Object.values(item)) {
79
- if (Array.isArray(child)) visitSidebar(child, filter)
80
- }
81
- }
82
-
83
- const readSidebars = (root: string, sidebarsFile: string | undefined): SidebarDocFilter => {
47
+ const readSidebars = (sidebarsFile: string | undefined): SidebarDocFilter => {
84
48
  if (!sidebarsFile) return { enabled: false, ids: new Set(), autogenDirs: [] }
85
- const value = sidebarsValue(join(root, sidebarsFile))
49
+ if (!existsSync(sidebarsFile)) return { enabled: false, ids: new Set(), autogenDirs: [] }
50
+ const raw = readBoundedText(sidebarsFile, { used: 0 }, { maxFileBytes: 1_048_576, maxCorpusBytes: 1_048_576 })
86
51
  const filter = { ids: new Set<string>(), autogenDirs: [] as string[] }
87
- visitSidebar(value, filter)
52
+ const visit = (value: unknown): void => {
53
+ if (typeof value === 'string') {
54
+ filter.ids.add(value)
55
+ return
56
+ }
57
+ if (Array.isArray(value)) {
58
+ for (const item of value) visit(item)
59
+ return
60
+ }
61
+ if (!value || typeof value !== 'object') return
62
+ const item = value as Record<string, unknown>
63
+ if ((item.type === 'doc' || item.type === 'ref') && typeof item.id === 'string') filter.ids.add(item.id)
64
+ if (item.type === 'autogenerated' && typeof item.dirName === 'string') filter.autogenDirs.push(item.dirName)
65
+ if (Array.isArray(item.items)) visit(item.items)
66
+ if (item.link && typeof item.link === 'object') visit(item.link)
67
+ for (const child of Object.values(item)) {
68
+ if (Array.isArray(child)) visit(child)
69
+ }
70
+ }
71
+ visit(parseStaticJsObject(raw))
88
72
  return { enabled: true, ...filter }
89
73
  }
90
74
 
@@ -102,7 +86,8 @@ export const docusaurusAdapter: HumanAdapter = {
102
86
  scan: ({ root, config }) => {
103
87
  const docsDir = optionString(config.options, ['docsDir', 'root'])
104
88
  if (!docsDir) return []
105
- const sidebarFilter = readSidebars(root, optionString(config.options, ['sidebarsFile']))
89
+ const sidebarsFile = optionString(config.options, ['sidebarsFile'])
90
+ const sidebarFilter = readSidebars(sidebarsFile ? containedProjectPath(root, sidebarsFile) : undefined)
106
91
  return scanMarkdownDocs(root, docsDir, {
107
92
  includeRelPath: (relPath, raw) => isIncludedBySidebar(sidebarFilter, docusaurusSidebarId(relPath, raw)),
108
93
  idForDoc: docusaurusRecordId,
@@ -1,3 +1,6 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { resolve, sep } from 'node:path'
3
+
1
4
  import type { DocBridgeConfigV1, HumanCorpusConfig } from '../../config/schema.js'
2
5
  import { docusaurusAdapter } from './docusaurus.js'
3
6
  import { fumadocsAdapter } from './fumadocs.js'
@@ -18,22 +21,31 @@ const humanConfigs = (config: DocBridgeConfigV1): HumanCorpusConfig[] => {
18
21
  return Array.isArray(human) ? human : [human]
19
22
  }
20
23
 
24
+ const canonicalPath = (path: string): string => {
25
+ try {
26
+ return realpathSync.native(path)
27
+ } catch {
28
+ return resolve(path)
29
+ }
30
+ }
31
+
21
32
  export const scanHumanDocRecords = (
22
33
  root: string,
23
34
  config: DocBridgeConfigV1,
24
35
  ): HumanDocRecord[] => {
25
36
  const out: HumanDocRecord[] = []
26
37
  const seen = new Set<string>()
27
- const agentRoot = config.corpus.agent.root.replace(/\/$/, '')
38
+ const agentRoot = canonicalPath(resolve(root, config.corpus.agent.root))
28
39
 
29
40
  for (const human of humanConfigs(config)) {
30
41
  const adapter = ADAPTERS.find((candidate) => candidate.plugin === human.plugin)
31
42
  if (!adapter) continue
32
43
  for (const record of adapter.scan({ root, config: human })) {
33
44
  // Never treat agent-corpus files as human docs (nested for-agents, etc.)
45
+ const recordPath = canonicalPath(record.path)
34
46
  if (
35
- record.path === agentRoot ||
36
- record.path.startsWith(`${agentRoot}/`) ||
47
+ recordPath === agentRoot ||
48
+ recordPath.startsWith(`${agentRoot}${sep}`) ||
37
49
  record.path.includes('/for-agents/') ||
38
50
  record.path.endsWith('/for-agents')
39
51
  ) {
@@ -1,7 +1,5 @@
1
- import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
-
4
1
  import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { readBoundedText } from '../lib/bounded-text.js'
5
3
  import {
6
4
  extractSearchBody,
7
5
  firstHeading,
@@ -12,7 +10,7 @@ import {
12
10
  slugFromPath,
13
11
  type FrontmatterData,
14
12
  } from '../lib/markdown.js'
15
- import { toPosix } from '../lib/paths.js'
13
+ import { containedProjectPath, toPosix } from '../lib/paths.js'
16
14
  import { walkFiles } from '../lib/walk.js'
17
15
  import type { KnowledgeEntry } from '../schemas/doc-bridge-index.js'
18
16
 
@@ -34,14 +32,16 @@ export type OwnershipSeed = {
34
32
  const relFromRoot = (root: string, abs: string): string => toPosix(abs.replace(`${toPosix(root)}/`, ''))
35
33
 
36
34
  export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): CorpusDoc[] => {
37
- const agentRoot = join(root, config.corpus.agent.root)
35
+ const agentRoot = containedProjectPath(root, config.corpus.agent.root)
36
+ if (!agentRoot) throw new Error('Agent corpus root escapes the project root.')
38
37
  const files = walkFiles(agentRoot, { extensions: ['.md', '.mdx'] })
39
38
  const corpusRelRoot = toPosix(config.corpus.agent.root)
40
39
 
40
+ const budget = { used: 0 }
41
41
  return files.map((abs) => {
42
42
  const relToCorpus = toPosix(abs.replace(`${toPosix(agentRoot)}/`, ''))
43
43
  const relPath = `${corpusRelRoot}/${relToCorpus}`
44
- const raw = readFileSync(abs, 'utf8')
44
+ const raw = readBoundedText(abs, budget)
45
45
  const { data: frontmatter } = parseFrontmatter(raw)
46
46
  const id =
47
47
  frontmatterString(frontmatter, 'id') ??
package/src/index.ts CHANGED
@@ -9,8 +9,12 @@ export {
9
9
  } from './config/load-config.js'
10
10
  export {
11
11
  DocBridgeConfigV1Schema,
12
+ DocumentationStandardRuleIdSchema,
13
+ DocumentationStandardV1ConfigSchema,
14
+ EcosystemContractEvidenceSchema,
12
15
  type DocBridgeConfigV1,
13
16
  type AgentCorpusConfig,
17
+ type DocumentationStandardV1Config,
14
18
  } from './config/schema.js'
15
19
 
16
20
  export {
@@ -75,6 +79,19 @@ export {
75
79
  type GateResult,
76
80
  type GateRunResult,
77
81
  } from './gates/run-gates.js'
82
+ export {
83
+ DOCUMENTATION_STANDARD_V1_ID,
84
+ DOCUMENTATION_STANDARD_V1_STATUS,
85
+ formatDocumentationStandardText,
86
+ runDocumentationStandardV1,
87
+ type DocumentationConformanceReportV1,
88
+ type DocumentationStandardEvidence,
89
+ type DocumentationStandardRemediation,
90
+ type DocumentationStandardRuleId,
91
+ type DocumentationStandardRuleLevel,
92
+ type DocumentationStandardRuleResult,
93
+ type DocumentationStandardRuleStatus,
94
+ } from './conformance/documentation-standard-v1.js'
78
95
  export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
79
96
  export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
80
97
  export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
@@ -0,0 +1,25 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+
3
+ export const MAX_DOCUMENT_BYTES = 4 * 1_024 * 1_024
4
+ export const MAX_CORPUS_BYTES = 64 * 1_024 * 1_024
5
+
6
+ export type TextReadBudget = { used: number }
7
+
8
+ export const readBoundedText = (
9
+ path: string,
10
+ budget: TextReadBudget,
11
+ limits?: { readonly maxFileBytes?: number; readonly maxCorpusBytes?: number },
12
+ ): string => {
13
+ const maxFileBytes = limits?.maxFileBytes ?? MAX_DOCUMENT_BYTES
14
+ const maxCorpusBytes = limits?.maxCorpusBytes ?? MAX_CORPUS_BYTES
15
+ const stat = statSync(path)
16
+ if (!stat.isFile()) throw new Error(`Documentation path is not a regular file: ${path}`)
17
+ if (stat.size > maxFileBytes) {
18
+ throw new Error(`Documentation file exceeds the ${maxFileBytes} byte limit: ${path}`)
19
+ }
20
+ if (budget.used + stat.size > maxCorpusBytes) {
21
+ throw new Error(`Documentation corpus exceeds the ${maxCorpusBytes} byte read budget.`)
22
+ }
23
+ budget.used += stat.size
24
+ return readFileSync(path, 'utf8')
25
+ }
package/src/lib/paths.ts CHANGED
@@ -1,6 +1,24 @@
1
- import { resolve } from 'node:path'
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
3
 
3
4
  export const toPosix = (value: string): string => value.split('\\').join('/')
4
5
 
5
6
  export const resolveFromRoot = (root: string, rel: string): string =>
6
- resolve(root, rel)
7
+ resolve(root, rel)
8
+
9
+ export const containedProjectPath = (root: string, path: string): string | undefined => {
10
+ const projectRoot = realpathSync.native(resolve(root))
11
+ const unresolved = resolve(projectRoot, path)
12
+ const unresolvedRelative = relative(projectRoot, unresolved)
13
+ if (
14
+ isAbsolute(unresolvedRelative) ||
15
+ unresolvedRelative === '..' ||
16
+ unresolvedRelative.startsWith(`..${sep}`)
17
+ ) return undefined
18
+
19
+ const canonical = existsSync(unresolved) ? realpathSync.native(unresolved) : unresolved
20
+ const canonicalRelative = relative(projectRoot, canonical)
21
+ return isAbsolute(canonicalRelative) || canonicalRelative === '..' || canonicalRelative.startsWith(`..${sep}`)
22
+ ? undefined
23
+ : canonical
24
+ }
@@ -0,0 +1,261 @@
1
+ const stripComments = (source: string): string => {
2
+ let out = ''
3
+ let index = 0
4
+ let quote: string | undefined
5
+ while (index < source.length) {
6
+ const char = source[index] ?? ''
7
+ const next = source[index + 1] ?? ''
8
+ if (quote) {
9
+ out += char
10
+ if (char === '\\') {
11
+ out += next
12
+ index += 2
13
+ continue
14
+ }
15
+ if (char === quote) quote = undefined
16
+ index += 1
17
+ continue
18
+ }
19
+ if (char === '"' || char === "'" || char === '`') {
20
+ quote = char
21
+ out += char
22
+ index += 1
23
+ continue
24
+ }
25
+ if (char === '/' && next === '/') {
26
+ while (index < source.length && source[index] !== '\n') index += 1
27
+ out += '\n'
28
+ index += 1
29
+ continue
30
+ }
31
+ if (char === '/' && next === '*') {
32
+ index += 2
33
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1
34
+ index += 2
35
+ out += ' '
36
+ continue
37
+ }
38
+ out += char
39
+ index += 1
40
+ }
41
+ if (quote) throw new Error('Unterminated string in static JavaScript literal.')
42
+ return out
43
+ }
44
+
45
+ class LiteralParser {
46
+ private index = 0
47
+
48
+ constructor(private readonly source: string) {}
49
+
50
+ parseAt(index: number): unknown {
51
+ this.index = index
52
+ const value = this.value()
53
+ this.space()
54
+ return value
55
+ }
56
+
57
+ private space(): void {
58
+ while (/\s/.test(this.source[this.index] ?? '')) this.index += 1
59
+ }
60
+
61
+ private value(): unknown {
62
+ this.space()
63
+ const char = this.source[this.index]
64
+ if (char === '{') return this.object()
65
+ if (char === '[') return this.array()
66
+ if (char === '"' || char === "'") return this.string()
67
+ const word = this.word()
68
+ if (word === 'true') return true
69
+ if (word === 'false') return false
70
+ if (word === 'null') return null
71
+ if (/^-?\d+(?:\.\d+)?$/.test(word)) return Number(word)
72
+ throw new Error(`Unsupported dynamic sidebar expression near "${word || char || 'EOF'}".`)
73
+ }
74
+
75
+ private object(): Record<string, unknown> {
76
+ const result: Record<string, unknown> = {}
77
+ this.index += 1
78
+ this.space()
79
+ while (this.source[this.index] !== '}') {
80
+ const key = this.source[this.index] === '"' || this.source[this.index] === "'" ? this.string() : this.word()
81
+ if (!key) throw new Error('Static sidebar object key is required.')
82
+ this.space()
83
+ if (this.source[this.index] !== ':') throw new Error(`Static sidebar key "${key}" requires a literal value.`)
84
+ this.index += 1
85
+ result[key] = this.value()
86
+ this.space()
87
+ if (this.source[this.index] === ',') {
88
+ this.index += 1
89
+ this.space()
90
+ continue
91
+ }
92
+ if (this.source[this.index] !== '}') throw new Error('Expected comma or closing brace in static sidebar.')
93
+ }
94
+ this.index += 1
95
+ return result
96
+ }
97
+
98
+ private array(): unknown[] {
99
+ const result: unknown[] = []
100
+ this.index += 1
101
+ this.space()
102
+ while (this.source[this.index] !== ']') {
103
+ result.push(this.value())
104
+ this.space()
105
+ if (this.source[this.index] === ',') {
106
+ this.index += 1
107
+ this.space()
108
+ continue
109
+ }
110
+ if (this.source[this.index] !== ']') throw new Error('Expected comma or closing bracket in static sidebar.')
111
+ }
112
+ this.index += 1
113
+ return result
114
+ }
115
+
116
+ private string(): string {
117
+ const quote = this.source[this.index] ?? ''
118
+ this.index += 1
119
+ let result = ''
120
+ while (this.index < this.source.length) {
121
+ const char = this.source[this.index] ?? ''
122
+ if (char === quote) {
123
+ this.index += 1
124
+ return result
125
+ }
126
+ if (char === '\\') {
127
+ const escaped = this.source[this.index + 1]
128
+ if (escaped === undefined) break
129
+ const escapes: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
130
+ result += escapes[escaped] ?? escaped
131
+ this.index += 2
132
+ continue
133
+ }
134
+ result += char
135
+ this.index += 1
136
+ }
137
+ throw new Error('Unterminated string in static sidebar.')
138
+ }
139
+
140
+ private word(): string {
141
+ this.space()
142
+ const start = this.index
143
+ while (/[A-Za-z0-9_$.-]/.test(this.source[this.index] ?? '')) this.index += 1
144
+ return this.source.slice(start, this.index)
145
+ }
146
+ }
147
+
148
+ const maskStrings = (source: string): string => {
149
+ const chars = [...source]
150
+ let quote: string | undefined
151
+ let previousToken: string | undefined
152
+ const regexPrefixKeywords = new Set([
153
+ 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new',
154
+ 'of', 'return', 'throw', 'typeof', 'void', 'yield',
155
+ ])
156
+ const canStartRegex = (): boolean => previousToken === undefined
157
+ || '=(:,[!&|?{};'.includes(previousToken)
158
+ || previousToken === '=>'
159
+ || regexPrefixKeywords.has(previousToken)
160
+ for (let index = 0; index < chars.length; index += 1) {
161
+ const char = chars[index] ?? ''
162
+ if (quote) {
163
+ chars[index] = ' '
164
+ if (char === '\\') {
165
+ if (index + 1 < chars.length) chars[index + 1] = ' '
166
+ index += 1
167
+ } else if (char === quote) {
168
+ quote = undefined
169
+ previousToken = 'literal'
170
+ }
171
+ continue
172
+ }
173
+ if (char === '"' || char === "'" || char === '`') {
174
+ quote = char
175
+ chars[index] = ' '
176
+ continue
177
+ }
178
+ if (char === '/' && canStartRegex()) {
179
+ chars[index] = ' '
180
+ let inClass = false
181
+ for (index += 1; index < chars.length; index += 1) {
182
+ const regexChar = chars[index] ?? ''
183
+ chars[index] = ' '
184
+ if (regexChar === '\\') {
185
+ if (index + 1 < chars.length) chars[index + 1] = ' '
186
+ index += 1
187
+ } else if (regexChar === '[') {
188
+ inClass = true
189
+ } else if (regexChar === ']') {
190
+ inClass = false
191
+ } else if (regexChar === '/' && !inClass) {
192
+ while (/[A-Za-z]/.test(chars[index + 1] ?? '')) {
193
+ index += 1
194
+ chars[index] = ' '
195
+ }
196
+ break
197
+ }
198
+ }
199
+ previousToken = 'literal'
200
+ continue
201
+ }
202
+ if (/[A-Za-z_$]/.test(char)) {
203
+ const start = index
204
+ while (/[A-Za-z0-9_$]/.test(chars[index + 1] ?? '')) index += 1
205
+ previousToken = chars.slice(start, index + 1).join('')
206
+ continue
207
+ }
208
+ if (char === '=' && chars[index + 1] === '>') {
209
+ previousToken = '=>'
210
+ index += 1
211
+ continue
212
+ }
213
+ if (!/\s/.test(char)) previousToken = char
214
+ }
215
+ return chars.join('')
216
+ }
217
+
218
+ const nonSpace = (source: string, start: number): number => {
219
+ let index = start
220
+ while (/\s/.test(source[index] ?? '')) index += 1
221
+ return index
222
+ }
223
+
224
+ const escapedRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
225
+
226
+ export const parseStaticJsObject = (source: string): unknown => {
227
+ const clean = stripComments(source)
228
+ const mask = maskStrings(clean)
229
+ const parseBinding = (identifier: string): unknown => {
230
+ const declaration = new RegExp(`\\b(?:const|let|var)\\s+${escapedRegex(identifier)}(?:\\s*:[^=;]+)?\\s*=`).exec(mask)
231
+ if (!declaration) throw new Error(`Exported binding "${identifier}" must reference a static object declaration.`)
232
+ const start = nonSpace(mask, declaration.index + declaration[0].length)
233
+ if (mask[start] !== '{') throw new Error(`Exported binding "${identifier}" must be a static object literal.`)
234
+ return new LiteralParser(clean).parseAt(start)
235
+ }
236
+ const commonJs = /\bmodule\.exports\s*=/.exec(mask)
237
+ if (commonJs) {
238
+ const start = nonSpace(mask, commonJs.index + commonJs[0].length)
239
+ if (mask[start] === '{') return new LiteralParser(clean).parseAt(start)
240
+ const identifier = /^[A-Za-z_$][\w$]*/.exec(mask.slice(start))?.[0]
241
+ if (identifier) return parseBinding(identifier)
242
+ throw new Error('module.exports must be assigned a static object literal or binding.')
243
+ }
244
+
245
+ const exported = /\bexport\s+default\b/.exec(mask)
246
+ if (exported) {
247
+ let start = nonSpace(mask, exported.index + exported[0].length)
248
+ if (mask.slice(start).startsWith('defineConfig')) {
249
+ start = nonSpace(mask, start + 'defineConfig'.length)
250
+ if (mask[start] !== '(') throw new Error('defineConfig must be called with a static object literal.')
251
+ start = nonSpace(mask, start + 1)
252
+ if (mask[start] !== '{') throw new Error('defineConfig must be called with a static object literal.')
253
+ return new LiteralParser(clean).parseAt(start)
254
+ }
255
+ if (mask[start] === '{') return new LiteralParser(clean).parseAt(start)
256
+
257
+ const identifier = /^[A-Za-z_$][\w$]*/.exec(mask.slice(start))?.[0]
258
+ if (identifier) return parseBinding(identifier)
259
+ }
260
+ throw new Error('Docusaurus sidebar must export a static object or assign one to a const.')
261
+ }