@bfra.me/doc-sync 0.1.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.
Files changed (50) hide show
  1. package/README.md +288 -0
  2. package/lib/chunk-6NKAJT2M.js +1233 -0
  3. package/lib/chunk-DR6UG237.js +1027 -0
  4. package/lib/chunk-G5KKGJYO.js +1560 -0
  5. package/lib/chunk-ROLA7SBB.js +12 -0
  6. package/lib/cli/index.d.ts +1 -0
  7. package/lib/cli/index.js +397 -0
  8. package/lib/generators/index.d.ts +170 -0
  9. package/lib/generators/index.js +76 -0
  10. package/lib/index.d.ts +141 -0
  11. package/lib/index.js +118 -0
  12. package/lib/parsers/index.d.ts +264 -0
  13. package/lib/parsers/index.js +113 -0
  14. package/lib/types.d.ts +388 -0
  15. package/lib/types.js +7 -0
  16. package/package.json +99 -0
  17. package/src/cli/commands/index.ts +3 -0
  18. package/src/cli/commands/sync.ts +146 -0
  19. package/src/cli/commands/validate.ts +151 -0
  20. package/src/cli/commands/watch.ts +74 -0
  21. package/src/cli/index.ts +71 -0
  22. package/src/cli/types.ts +19 -0
  23. package/src/cli/ui.ts +123 -0
  24. package/src/generators/api-reference-generator.ts +268 -0
  25. package/src/generators/code-example-formatter.ts +313 -0
  26. package/src/generators/component-mapper.ts +383 -0
  27. package/src/generators/content-merger.ts +295 -0
  28. package/src/generators/frontmatter-generator.ts +277 -0
  29. package/src/generators/index.ts +56 -0
  30. package/src/generators/mdx-generator.ts +289 -0
  31. package/src/index.ts +131 -0
  32. package/src/orchestrator/index.ts +21 -0
  33. package/src/orchestrator/package-scanner.ts +276 -0
  34. package/src/orchestrator/sync-orchestrator.ts +382 -0
  35. package/src/orchestrator/validation-pipeline.ts +328 -0
  36. package/src/parsers/export-analyzer.ts +335 -0
  37. package/src/parsers/guards.ts +350 -0
  38. package/src/parsers/index.ts +82 -0
  39. package/src/parsers/jsdoc-extractor.ts +313 -0
  40. package/src/parsers/package-info.ts +267 -0
  41. package/src/parsers/readme-parser.ts +334 -0
  42. package/src/parsers/typescript-parser.ts +299 -0
  43. package/src/types.ts +423 -0
  44. package/src/utils/index.ts +13 -0
  45. package/src/utils/safe-patterns.ts +280 -0
  46. package/src/utils/sanitization.ts +164 -0
  47. package/src/watcher/change-detector.ts +138 -0
  48. package/src/watcher/debouncer.ts +168 -0
  49. package/src/watcher/file-watcher.ts +164 -0
  50. package/src/watcher/index.ts +27 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @bfra.me/doc-sync/generators/mdx-generator - MDX document structure generation for Starlight
3
+ */
4
+
5
+ import type {Result} from '@bfra.me/es/result'
6
+ import type {
7
+ MDXDocument,
8
+ MDXFrontmatter,
9
+ PackageAPI,
10
+ PackageInfo,
11
+ ReadmeContent,
12
+ SyncError,
13
+ } from '../types'
14
+
15
+ import {err, ok} from '@bfra.me/es/result'
16
+ import {SENTINEL_MARKERS} from '../types'
17
+
18
+ import {extractCodeBlocks, parseJSXTags} from '../utils/safe-patterns'
19
+ import {sanitizeAttribute, sanitizeForMDX, sanitizeJSXTag} from '../utils/sanitization'
20
+ import {generateAPIReference} from './api-reference-generator'
21
+ import {formatCodeExamples} from './code-example-formatter'
22
+ import {mapToStarlightComponents} from './component-mapper'
23
+ import {generateFrontmatter, stringifyFrontmatter} from './frontmatter-generator'
24
+
25
+ /**
26
+ * Options for MDX generation
27
+ */
28
+ export interface MDXGeneratorOptions {
29
+ /** Include API reference section */
30
+ readonly includeAPI?: boolean
31
+ /** Include examples from JSDoc */
32
+ readonly includeExamples?: boolean
33
+ /** Custom frontmatter overrides */
34
+ readonly frontmatterOverrides?: Partial<MDXFrontmatter>
35
+ /** Preserve manual content sections */
36
+ readonly preserveManualContent?: boolean
37
+ /** Existing MDX content for merging */
38
+ readonly existingContent?: string
39
+ }
40
+
41
+ /**
42
+ * Default options for MDX generation
43
+ */
44
+ const DEFAULT_OPTIONS: Required<
45
+ Omit<MDXGeneratorOptions, 'frontmatterOverrides' | 'existingContent'>
46
+ > = {
47
+ includeAPI: true,
48
+ includeExamples: true,
49
+ preserveManualContent: true,
50
+ }
51
+
52
+ /**
53
+ * Starlight component imports required for generated documentation
54
+ */
55
+ const STARLIGHT_IMPORTS = `import { Badge, Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';`
56
+
57
+ export function generateMDXDocument(
58
+ packageInfo: PackageInfo,
59
+ readme: ReadmeContent | undefined,
60
+ api: PackageAPI | undefined,
61
+ options: MDXGeneratorOptions = {},
62
+ ): Result<MDXDocument, SyncError> {
63
+ const mergedOptions = {...DEFAULT_OPTIONS, ...options}
64
+
65
+ try {
66
+ const frontmatter = generateFrontmatter(packageInfo, readme, options.frontmatterOverrides)
67
+ const contentSections = buildContentSections(packageInfo, readme, api, mergedOptions)
68
+ const content = contentSections.join('\n\n')
69
+ const rendered = renderMDXDocument(frontmatter, content)
70
+
71
+ return ok({
72
+ frontmatter,
73
+ content,
74
+ rendered,
75
+ })
76
+ } catch (error) {
77
+ return err({
78
+ code: 'GENERATION_ERROR',
79
+ message: `Failed to generate MDX for ${packageInfo.name}: ${error instanceof Error ? error.message : String(error)}`,
80
+ packageName: packageInfo.name,
81
+ cause: error,
82
+ })
83
+ }
84
+ }
85
+
86
+ function buildContentSections(
87
+ packageInfo: PackageInfo,
88
+ readme: ReadmeContent | undefined,
89
+ api: PackageAPI | undefined,
90
+ options: Required<Omit<MDXGeneratorOptions, 'frontmatterOverrides' | 'existingContent'>>,
91
+ ): string[] {
92
+ const sections: string[] = []
93
+
94
+ sections.push(STARLIGHT_IMPORTS)
95
+ sections.push('')
96
+ sections.push(generatePackageHeader(packageInfo))
97
+
98
+ if (readme !== undefined) {
99
+ const mappedContent = mapToStarlightComponents(readme, packageInfo)
100
+ sections.push(mappedContent)
101
+ }
102
+
103
+ if (options.includeAPI && api !== undefined && hasAPIContent(api)) {
104
+ sections.push(SENTINEL_MARKERS.AUTO_START)
105
+ sections.push('')
106
+ sections.push('## API Reference')
107
+ sections.push('')
108
+ sections.push(generateAPIReference(api))
109
+
110
+ if (options.includeExamples) {
111
+ const examples = formatCodeExamples(api)
112
+ if (examples.length > 0) {
113
+ sections.push('')
114
+ sections.push('## Examples')
115
+ sections.push('')
116
+ sections.push(examples)
117
+ }
118
+ }
119
+
120
+ sections.push('')
121
+ sections.push(SENTINEL_MARKERS.AUTO_END)
122
+ }
123
+
124
+ return sections
125
+ }
126
+
127
+ function generatePackageHeader(packageInfo: PackageInfo): string {
128
+ const lines: string[] = []
129
+
130
+ lines.push(`# ${packageInfo.name}`)
131
+ lines.push('')
132
+
133
+ const badges: string[] = []
134
+
135
+ if (packageInfo.keywords?.includes('cli')) {
136
+ badges.push(`<Badge text="CLI Tool" variant="tip" />`)
137
+ } else if (packageInfo.keywords?.includes('library')) {
138
+ badges.push(`<Badge text="Library" variant="tip" />`)
139
+ } else if (packageInfo.keywords?.includes('config')) {
140
+ badges.push(`<Badge text="Config" variant="tip" />`)
141
+ }
142
+
143
+ badges.push(`<Badge text="${sanitizeAttribute(`v${packageInfo.version}`)}" variant="note" />`)
144
+
145
+ if (badges.length > 0) {
146
+ lines.push(badges.join('\n'))
147
+ lines.push('')
148
+ }
149
+
150
+ if (packageInfo.description !== undefined) {
151
+ lines.push(packageInfo.description)
152
+ lines.push('')
153
+ }
154
+
155
+ return lines.join('\n')
156
+ }
157
+
158
+ function hasAPIContent(api: PackageAPI): boolean {
159
+ return api.functions.length > 0 || api.types.length > 0
160
+ }
161
+
162
+ function renderMDXDocument(frontmatter: MDXFrontmatter, content: string): string {
163
+ const frontmatterYaml = stringifyFrontmatter(frontmatter)
164
+ return `---\n${frontmatterYaml}\n---\n\n${content}\n`
165
+ }
166
+
167
+ /**
168
+ * Sanitizes content for safe MDX rendering
169
+ * Prevents XSS by escaping potentially dangerous content
170
+ */
171
+ export function sanitizeContent(content: string): string {
172
+ // Use comprehensive sanitization from utils
173
+ return sanitizeForMDX(content)
174
+ }
175
+
176
+ /**
177
+ * Sanitizes content within MDX while preserving JSX components
178
+ * Only escapes content that appears to be user-provided text
179
+ * Now includes sanitization of JSX component attributes to prevent XSS
180
+ * Uses safe, non-backtracking parsing to prevent ReDoS
181
+ */
182
+ export function sanitizeTextContent(content: string): string {
183
+ const jsxTags = parseJSXTags(content)
184
+ const parts: string[] = []
185
+ let lastIndex = 0
186
+
187
+ for (const {tag, index, isClosing} of jsxTags) {
188
+ if (index > lastIndex) {
189
+ parts.push(sanitizeContent(content.slice(lastIndex, index)))
190
+ }
191
+
192
+ if (isClosing) {
193
+ parts.push(tag)
194
+ } else {
195
+ parts.push(sanitizeJSXTag(tag))
196
+ }
197
+
198
+ lastIndex = index + tag.length
199
+ }
200
+
201
+ if (lastIndex < content.length) {
202
+ parts.push(sanitizeContent(content.slice(lastIndex)))
203
+ }
204
+
205
+ return parts.join('')
206
+ }
207
+
208
+ export function validateMDXSyntax(mdx: string): Result<true, SyncError> {
209
+ const unclosedTags = checkForUnclosedTags(mdx)
210
+ if (unclosedTags.length > 0) {
211
+ return err({
212
+ code: 'VALIDATION_ERROR',
213
+ message: `Unclosed MDX tags: ${unclosedTags.join(', ')}`,
214
+ })
215
+ }
216
+
217
+ const invalidFrontmatter = checkFrontmatter(mdx)
218
+ if (invalidFrontmatter !== undefined) {
219
+ return err({
220
+ code: 'VALIDATION_ERROR',
221
+ message: invalidFrontmatter,
222
+ })
223
+ }
224
+
225
+ return ok(true)
226
+ }
227
+
228
+ function checkForUnclosedTags(mdx: string): string[] {
229
+ const unclosed: string[] = []
230
+ const tagStack: string[] = []
231
+
232
+ // Remove code blocks from content before checking for JSX tags
233
+ // This prevents TypeScript generics like Result<T, E> from being
234
+ // misinterpreted as unclosed JSX tags
235
+ const codeBlocks = extractCodeBlocks(mdx)
236
+ let contentWithoutCodeBlocks = mdx
237
+ for (const block of codeBlocks) {
238
+ // Replace code block with empty lines to preserve line numbers
239
+ const lineCount = block.split('\n').length
240
+ const placeholder = '\n'.repeat(lineCount)
241
+ contentWithoutCodeBlocks = contentWithoutCodeBlocks.replace(block, placeholder)
242
+ }
243
+
244
+ const jsxTags = parseJSXTags(contentWithoutCodeBlocks)
245
+
246
+ for (const {tag, isClosing, isSelfClosing} of jsxTags) {
247
+ const tagNameMatch = isClosing
248
+ ? tag.match(/^<\/([A-Z][a-zA-Z0-9]*)>$/)
249
+ : tag.match(/^<([A-Z][a-zA-Z0-9]*)/)
250
+ const tagName = tagNameMatch?.[1]
251
+
252
+ if (tagName === undefined) continue
253
+
254
+ if (isClosing) {
255
+ const lastOpenTag = tagStack.pop()
256
+ if (lastOpenTag !== tagName && lastOpenTag !== undefined) {
257
+ unclosed.push(lastOpenTag)
258
+ }
259
+ } else if (!isSelfClosing) {
260
+ tagStack.push(tagName)
261
+ }
262
+ }
263
+
264
+ unclosed.push(...tagStack)
265
+
266
+ return unclosed
267
+ }
268
+
269
+ function checkFrontmatter(mdx: string): string | undefined {
270
+ if (!mdx.startsWith('---')) {
271
+ return 'MDX document must start with frontmatter'
272
+ }
273
+
274
+ const secondDashIndex = mdx.indexOf('---', 3)
275
+ if (secondDashIndex === -1) {
276
+ return 'Frontmatter is not properly closed'
277
+ }
278
+
279
+ const frontmatterContent = mdx.slice(3, secondDashIndex).trim()
280
+ if (frontmatterContent.length === 0) {
281
+ return 'Frontmatter cannot be empty'
282
+ }
283
+
284
+ if (!frontmatterContent.includes('title:')) {
285
+ return 'Frontmatter must include a title'
286
+ }
287
+
288
+ return undefined
289
+ }
package/src/index.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Re-export generators
2
+ export {
3
+ cleanCodeExample,
4
+ createBadge,
5
+ createCard,
6
+ createCardGrid,
7
+ createDiffSummary,
8
+ createTabs,
9
+ detectLanguage,
10
+ extractAutoSections,
11
+ extractManualSections,
12
+ formatCodeBlock,
13
+ formatCodeExamples,
14
+ formatFunctionExamples,
15
+ formatGroupedExamples,
16
+ formatTypeExamples,
17
+ formatUsageExample,
18
+ generateAPICompact,
19
+ generateAPIReference,
20
+ generateCategoryReference,
21
+ generateFrontmatter,
22
+ generateInstallTabs,
23
+ generateMDXDocument,
24
+ groupExamplesByCategory,
25
+ hasAutoContent,
26
+ hasManualContent,
27
+ mapToStarlightComponents,
28
+ mergeContent,
29
+ parseFrontmatter,
30
+ sanitizeContent,
31
+ sanitizeTextContent,
32
+ stringifyFrontmatter,
33
+ stripSentinelMarkers,
34
+ validateMarkerPairing,
35
+ validateMDXSyntax,
36
+ wrapAutoSection,
37
+ wrapManualSection,
38
+ } from './generators'
39
+
40
+ export type {
41
+ CodeExampleOptions,
42
+ ComponentMapperConfig,
43
+ ContentSection,
44
+ MDXGeneratorOptions,
45
+ MergeOptions,
46
+ MergeResult,
47
+ SectionMapper,
48
+ } from './generators'
49
+
50
+ // Re-export orchestrator
51
+ export {
52
+ createPackageScanner,
53
+ createSyncOrchestrator,
54
+ createValidationPipeline,
55
+ filterPackagesByPattern,
56
+ groupPackagesByScope,
57
+ isValidFilePath,
58
+ validateContentString,
59
+ validateDocument,
60
+ } from './orchestrator'
61
+
62
+ export type {
63
+ PackageScannerOptions,
64
+ ScannedPackage,
65
+ ScanResult,
66
+ SyncOrchestrator,
67
+ SyncOrchestratorOptions,
68
+ ValidationError,
69
+ ValidationPipelineOptions,
70
+ ValidationResult,
71
+ ValidationWarning,
72
+ } from './orchestrator'
73
+
74
+ export type {
75
+ CLIOptions,
76
+ DocConfig,
77
+ DocConfigSource,
78
+ ExportedFunction,
79
+ ExportedType,
80
+ FileChangeEvent,
81
+ FunctionParameter,
82
+ InferSchema,
83
+ JSDocInfo,
84
+ JSDocParam,
85
+ JSDocTag,
86
+ MDXDocument,
87
+ MDXFrontmatter,
88
+ PackageAPI,
89
+ PackageInfo,
90
+ ParseError,
91
+ ParseErrorCode,
92
+ ParseResult,
93
+ ReadmeContent,
94
+ ReadmeSection,
95
+ ReExport,
96
+ SyncError,
97
+ SyncErrorCode,
98
+ SyncInfo,
99
+ SyncResult,
100
+ SyncSummary,
101
+ } from './types'
102
+
103
+ export {SENTINEL_MARKERS} from './types'
104
+
105
+ // Re-export watcher
106
+ export {
107
+ categorizeFile,
108
+ consolidateEvents,
109
+ createDocChangeDetector,
110
+ createDocDebouncer,
111
+ createDocWatcher,
112
+ deduplicateEvents,
113
+ determineRegenerationScope,
114
+ filterDocumentationChanges,
115
+ groupChangesByPackage,
116
+ hasAnyFileChanged,
117
+ } from './watcher'
118
+
119
+ export type {
120
+ BatchChangeHandler,
121
+ DocChangeDetector,
122
+ DocChangeDetectorOptions,
123
+ DocChangeHandler,
124
+ DocDebouncer,
125
+ DocDebouncerOptions,
126
+ DocFileWatcher,
127
+ DocWatcherOptions,
128
+ FileCategory,
129
+ PackageChangeAnalysis,
130
+ RegenerationScope,
131
+ } from './watcher'
@@ -0,0 +1,21 @@
1
+ export {
2
+ createPackageScanner,
3
+ filterPackagesByPattern,
4
+ groupPackagesByScope,
5
+ } from './package-scanner'
6
+ export type {PackageScannerOptions, ScannedPackage, ScanResult} from './package-scanner'
7
+
8
+ export {createSyncOrchestrator, isValidFilePath} from './sync-orchestrator'
9
+ export type {SyncOrchestrator, SyncOrchestratorOptions} from './sync-orchestrator'
10
+
11
+ export {
12
+ createValidationPipeline,
13
+ validateContentString,
14
+ validateDocument,
15
+ } from './validation-pipeline'
16
+ export type {
17
+ ValidationError,
18
+ ValidationPipelineOptions,
19
+ ValidationResult,
20
+ ValidationWarning,
21
+ } from './validation-pipeline'
@@ -0,0 +1,276 @@
1
+ import type {Result} from '@bfra.me/es/result'
2
+ import type {PackageAPI, PackageInfo, ReadmeContent, SyncError} from '../types'
3
+
4
+ import fs from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ import {err, ok} from '@bfra.me/es/result'
8
+
9
+ import {analyzePublicAPI, parsePackageComplete, parseReadmeFile} from '../parsers'
10
+
11
+ export interface PackageScannerOptions {
12
+ readonly rootDir: string
13
+ readonly includePatterns?: readonly string[]
14
+ readonly excludePackages?: readonly string[]
15
+ readonly parseSourceFiles?: boolean
16
+ readonly parseReadme?: boolean
17
+ }
18
+
19
+ export interface ScannedPackage {
20
+ readonly info: PackageInfo
21
+ readonly readme?: ReadmeContent
22
+ readonly api?: PackageAPI
23
+ readonly sourceFiles: readonly string[]
24
+ readonly needsDocumentation: boolean
25
+ readonly existingDocPath?: string
26
+ }
27
+
28
+ export interface ScanResult {
29
+ readonly packages: readonly ScannedPackage[]
30
+ readonly packagesNeedingDocs: readonly ScannedPackage[]
31
+ readonly errors: readonly SyncError[]
32
+ readonly durationMs: number
33
+ }
34
+
35
+ const DEFAULT_OPTIONS: Required<Omit<PackageScannerOptions, 'rootDir' | 'excludePackages'>> = {
36
+ includePatterns: ['packages/*'],
37
+ parseSourceFiles: true,
38
+ parseReadme: true,
39
+ }
40
+
41
+ export function createPackageScanner(options: PackageScannerOptions): {
42
+ readonly scan: () => Promise<ScanResult>
43
+ readonly scanPackage: (packagePath: string) => Promise<Result<ScannedPackage, SyncError>>
44
+ } {
45
+ const {
46
+ rootDir,
47
+ includePatterns = DEFAULT_OPTIONS.includePatterns,
48
+ excludePackages = [],
49
+ parseSourceFiles = DEFAULT_OPTIONS.parseSourceFiles,
50
+ parseReadme = DEFAULT_OPTIONS.parseReadme,
51
+ } = options
52
+
53
+ const docsOutputDir = path.join(rootDir, 'docs', 'src', 'content', 'docs', 'packages')
54
+
55
+ async function discoverPackages(): Promise<string[]> {
56
+ const packagePaths: string[] = []
57
+
58
+ for (const pattern of includePatterns) {
59
+ const baseDir = path.join(rootDir, pattern.replace('/*', ''))
60
+
61
+ try {
62
+ const entries = await fs.readdir(baseDir, {withFileTypes: true})
63
+
64
+ for (const entry of entries) {
65
+ if (!entry.isDirectory()) {
66
+ continue
67
+ }
68
+
69
+ const packagePath = path.join(baseDir, entry.name)
70
+ const packageJsonPath = path.join(packagePath, 'package.json')
71
+
72
+ try {
73
+ await fs.access(packageJsonPath)
74
+ packagePaths.push(packagePath)
75
+ } catch {
76
+ // No package.json, skip this directory
77
+ }
78
+ }
79
+ } catch {
80
+ // Pattern directory doesn't exist, skip
81
+ }
82
+ }
83
+
84
+ return packagePaths
85
+ }
86
+
87
+ async function findSourceFiles(srcDir: string): Promise<string[]> {
88
+ const sourceFiles: string[] = []
89
+
90
+ try {
91
+ await collectSourceFiles(srcDir, sourceFiles)
92
+ } catch {
93
+ // Source directory doesn't exist
94
+ }
95
+
96
+ return sourceFiles
97
+ }
98
+
99
+ async function collectSourceFiles(dir: string, files: string[]): Promise<void> {
100
+ const entries = await fs.readdir(dir, {withFileTypes: true})
101
+
102
+ for (const entry of entries) {
103
+ const fullPath = path.join(dir, entry.name)
104
+
105
+ if (entry.isDirectory()) {
106
+ // Skip test directories
107
+ if (entry.name === '__tests__' || entry.name === '__mocks__') {
108
+ continue
109
+ }
110
+ await collectSourceFiles(fullPath, files)
111
+ } else if (entry.isFile()) {
112
+ const ext = path.extname(entry.name).toLowerCase()
113
+ if (
114
+ (ext === '.ts' || ext === '.tsx') &&
115
+ !entry.name.includes('.test.') &&
116
+ !entry.name.includes('.spec.')
117
+ ) {
118
+ files.push(fullPath)
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ async function scanPackage(packagePath: string): Promise<Result<ScannedPackage, SyncError>> {
125
+ const packageResult = await parsePackageComplete(packagePath)
126
+
127
+ if (!packageResult.success) {
128
+ return err({
129
+ code: 'PACKAGE_NOT_FOUND',
130
+ message: `Failed to parse package at ${packagePath}: ${packageResult.error.message}`,
131
+ filePath: packagePath,
132
+ cause: packageResult.error,
133
+ })
134
+ }
135
+
136
+ const info = packageResult.data
137
+ const sourceFiles = await findSourceFiles(info.srcPath)
138
+
139
+ let readme: ReadmeContent | undefined
140
+ if (parseReadme && info.readmePath !== undefined) {
141
+ const readmeResult = await parseReadmeFile(info.readmePath)
142
+ if (readmeResult.success) {
143
+ readme = readmeResult.data
144
+ }
145
+ }
146
+
147
+ let api: PackageAPI | undefined
148
+ if (parseSourceFiles && sourceFiles.length > 0) {
149
+ const entryFile = findEntryFile(sourceFiles, info.srcPath)
150
+ if (entryFile !== undefined) {
151
+ const analysisResult = analyzePublicAPI(entryFile)
152
+ if (analysisResult.success) {
153
+ api = analysisResult.data.api
154
+ }
155
+ }
156
+ }
157
+
158
+ const docSlug = buildDocSlug(info.name)
159
+ const existingDocPath = path.join(docsOutputDir, `${docSlug}.mdx`)
160
+ let hasExistingDoc = false
161
+
162
+ try {
163
+ await fs.access(existingDocPath)
164
+ hasExistingDoc = true
165
+ } catch {
166
+ // Doc doesn't exist yet
167
+ }
168
+
169
+ return ok({
170
+ info,
171
+ readme,
172
+ api,
173
+ sourceFiles,
174
+ needsDocumentation: true,
175
+ existingDocPath: hasExistingDoc ? existingDocPath : undefined,
176
+ })
177
+ }
178
+
179
+ return {
180
+ async scan(): Promise<ScanResult> {
181
+ const startTime = Date.now()
182
+ const packagePaths = await discoverPackages()
183
+ const packages: ScannedPackage[] = []
184
+ const errors: SyncError[] = []
185
+
186
+ for (const packagePath of packagePaths) {
187
+ const result = await scanPackage(packagePath)
188
+
189
+ if (result.success) {
190
+ const scanned = result.data
191
+
192
+ // Check if this package should be excluded
193
+ if (excludePackages.includes(scanned.info.name)) {
194
+ continue
195
+ }
196
+
197
+ packages.push(scanned)
198
+ } else {
199
+ errors.push(result.error)
200
+ }
201
+ }
202
+
203
+ const packagesNeedingDocs = packages.filter(pkg => pkg.needsDocumentation)
204
+
205
+ return {
206
+ packages,
207
+ packagesNeedingDocs,
208
+ errors,
209
+ durationMs: Date.now() - startTime,
210
+ }
211
+ },
212
+
213
+ scanPackage,
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Finds the entry file (index.ts) from a list of source files
219
+ */
220
+ function findEntryFile(sourceFiles: readonly string[], srcDir: string): string | undefined {
221
+ const indexPath = path.join(srcDir, 'index.ts')
222
+ return sourceFiles.find(file => file === indexPath) ?? sourceFiles[0]
223
+ }
224
+
225
+ function buildDocSlug(packageName: string): string {
226
+ return getUnscopedName(packageName)
227
+ .toLowerCase()
228
+ .replaceAll(/[^a-z0-9-]/g, '-')
229
+ }
230
+
231
+ function getUnscopedName(packageName: string): string {
232
+ if (packageName.startsWith('@')) {
233
+ const slashIndex = packageName.indexOf('/')
234
+ if (slashIndex > 0) {
235
+ return packageName.slice(slashIndex + 1)
236
+ }
237
+ }
238
+ return packageName
239
+ }
240
+
241
+ export function filterPackagesByPattern(
242
+ packages: readonly ScannedPackage[],
243
+ pattern: string,
244
+ ): ScannedPackage[] {
245
+ const regex = new RegExp(pattern.replaceAll('*', '.*'), 'i')
246
+ return packages.filter(pkg => regex.test(pkg.info.name))
247
+ }
248
+
249
+ export function groupPackagesByScope(
250
+ packages: readonly ScannedPackage[],
251
+ ): Map<string, ScannedPackage[]> {
252
+ const grouped = new Map<string, ScannedPackage[]>()
253
+
254
+ for (const pkg of packages) {
255
+ const scope = getPackageScope(pkg.info.name) ?? '__unscoped__'
256
+ const existing = grouped.get(scope)
257
+
258
+ if (existing === undefined) {
259
+ grouped.set(scope, [pkg])
260
+ } else {
261
+ existing.push(pkg)
262
+ }
263
+ }
264
+
265
+ return grouped
266
+ }
267
+
268
+ function getPackageScope(packageName: string): string | undefined {
269
+ if (packageName.startsWith('@')) {
270
+ const slashIndex = packageName.indexOf('/')
271
+ if (slashIndex > 0) {
272
+ return packageName.slice(0, slashIndex)
273
+ }
274
+ }
275
+ return undefined
276
+ }