@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.
- package/README.md +288 -0
- package/lib/chunk-6NKAJT2M.js +1233 -0
- package/lib/chunk-DR6UG237.js +1027 -0
- package/lib/chunk-G5KKGJYO.js +1560 -0
- package/lib/chunk-ROLA7SBB.js +12 -0
- package/lib/cli/index.d.ts +1 -0
- package/lib/cli/index.js +397 -0
- package/lib/generators/index.d.ts +170 -0
- package/lib/generators/index.js +76 -0
- package/lib/index.d.ts +141 -0
- package/lib/index.js +118 -0
- package/lib/parsers/index.d.ts +264 -0
- package/lib/parsers/index.js +113 -0
- package/lib/types.d.ts +388 -0
- package/lib/types.js +7 -0
- package/package.json +99 -0
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/sync.ts +146 -0
- package/src/cli/commands/validate.ts +151 -0
- package/src/cli/commands/watch.ts +74 -0
- package/src/cli/index.ts +71 -0
- package/src/cli/types.ts +19 -0
- package/src/cli/ui.ts +123 -0
- package/src/generators/api-reference-generator.ts +268 -0
- package/src/generators/code-example-formatter.ts +313 -0
- package/src/generators/component-mapper.ts +383 -0
- package/src/generators/content-merger.ts +295 -0
- package/src/generators/frontmatter-generator.ts +277 -0
- package/src/generators/index.ts +56 -0
- package/src/generators/mdx-generator.ts +289 -0
- package/src/index.ts +131 -0
- package/src/orchestrator/index.ts +21 -0
- package/src/orchestrator/package-scanner.ts +276 -0
- package/src/orchestrator/sync-orchestrator.ts +382 -0
- package/src/orchestrator/validation-pipeline.ts +328 -0
- package/src/parsers/export-analyzer.ts +335 -0
- package/src/parsers/guards.ts +350 -0
- package/src/parsers/index.ts +82 -0
- package/src/parsers/jsdoc-extractor.ts +313 -0
- package/src/parsers/package-info.ts +267 -0
- package/src/parsers/readme-parser.ts +334 -0
- package/src/parsers/typescript-parser.ts +299 -0
- package/src/types.ts +423 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/safe-patterns.ts +280 -0
- package/src/utils/sanitization.ts +164 -0
- package/src/watcher/change-detector.ts +138 -0
- package/src/watcher/debouncer.ts +168 -0
- package/src/watcher/file-watcher.ts +164 -0
- 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
|
+
}
|