@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,382 @@
|
|
|
1
|
+
import type {Result} from '@bfra.me/es/result'
|
|
2
|
+
import type {DocConfig, FileChangeEvent, SyncError, SyncInfo, SyncSummary} 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 {generateMDXDocument, mergeContent} from '../generators'
|
|
10
|
+
import {
|
|
11
|
+
createDocChangeDetector,
|
|
12
|
+
createDocDebouncer,
|
|
13
|
+
createDocWatcher,
|
|
14
|
+
determineRegenerationScope,
|
|
15
|
+
groupChangesByPackage,
|
|
16
|
+
type DocDebouncer,
|
|
17
|
+
type DocFileWatcher,
|
|
18
|
+
} from '../watcher'
|
|
19
|
+
|
|
20
|
+
import {createPackageScanner, type ScannedPackage} from './package-scanner'
|
|
21
|
+
import {createValidationPipeline} from './validation-pipeline'
|
|
22
|
+
|
|
23
|
+
export interface SyncOrchestratorOptions {
|
|
24
|
+
readonly config: DocConfig
|
|
25
|
+
readonly dryRun?: boolean
|
|
26
|
+
readonly verbose?: boolean
|
|
27
|
+
readonly onProgress?: (message: string) => void
|
|
28
|
+
readonly onError?: (error: SyncError) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncOrchestrator {
|
|
32
|
+
readonly syncAll: () => Promise<SyncSummary>
|
|
33
|
+
readonly syncPackages: (packageNames: readonly string[]) => Promise<SyncSummary>
|
|
34
|
+
readonly handleChanges: (events: readonly FileChangeEvent[]) => Promise<SyncSummary>
|
|
35
|
+
readonly startWatching: () => Promise<void>
|
|
36
|
+
readonly stopWatching: () => Promise<void>
|
|
37
|
+
readonly isWatching: () => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSyncOrchestrator(options: SyncOrchestratorOptions): SyncOrchestrator {
|
|
41
|
+
const {config, dryRun = false, verbose = false, onProgress, onError} = options
|
|
42
|
+
|
|
43
|
+
const scanner = createPackageScanner({
|
|
44
|
+
rootDir: config.rootDir,
|
|
45
|
+
includePatterns: config.includePatterns,
|
|
46
|
+
excludePackages: config.excludePatterns as string[] | undefined,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const validationPipeline = createValidationPipeline()
|
|
50
|
+
|
|
51
|
+
let watcher: DocFileWatcher | undefined
|
|
52
|
+
let debouncer: DocDebouncer | undefined
|
|
53
|
+
let watching = false
|
|
54
|
+
|
|
55
|
+
function log(message: string): void {
|
|
56
|
+
if (verbose && onProgress != null) {
|
|
57
|
+
onProgress(message)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reportError(error: SyncError): void {
|
|
62
|
+
if (onError != null) {
|
|
63
|
+
onError(error)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function syncPackage(pkg: ScannedPackage): Promise<Result<SyncInfo, SyncError>> {
|
|
68
|
+
log(`Syncing documentation for ${pkg.info.name}...`)
|
|
69
|
+
|
|
70
|
+
const docResult = generateMDXDocument(pkg.info, pkg.readme, pkg.api)
|
|
71
|
+
|
|
72
|
+
if (!docResult.success) {
|
|
73
|
+
return docResult
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const doc = docResult.data
|
|
77
|
+
const validationResult = validationPipeline.validate(doc)
|
|
78
|
+
|
|
79
|
+
if (!validationResult.valid) {
|
|
80
|
+
const errorMessages = validationResult.errors.map(e => e.message).join('; ')
|
|
81
|
+
return err({
|
|
82
|
+
code: 'VALIDATION_ERROR',
|
|
83
|
+
message: `Validation failed for ${pkg.info.name}: ${errorMessages}`,
|
|
84
|
+
packageName: pkg.info.name,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const outputPath = getOutputPath(pkg.info.name, config.outputDir)
|
|
89
|
+
let action: SyncInfo['action'] = 'created'
|
|
90
|
+
let contentToWrite = doc.rendered
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (pkg.existingDocPath == null) {
|
|
94
|
+
if (!dryRun) {
|
|
95
|
+
await writeFile(outputPath, contentToWrite)
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
const existingContent = await fs.readFile(pkg.existingDocPath, 'utf-8')
|
|
100
|
+
const mergedResult = mergeContent(existingContent, doc.rendered)
|
|
101
|
+
|
|
102
|
+
if (!mergedResult.success) {
|
|
103
|
+
if (!dryRun) {
|
|
104
|
+
await writeFile(outputPath, contentToWrite)
|
|
105
|
+
}
|
|
106
|
+
action = 'updated'
|
|
107
|
+
} else if (mergedResult.data.content === existingContent) {
|
|
108
|
+
return ok({
|
|
109
|
+
packageName: pkg.info.name,
|
|
110
|
+
outputPath,
|
|
111
|
+
action: 'unchanged',
|
|
112
|
+
timestamp: new Date(),
|
|
113
|
+
})
|
|
114
|
+
} else {
|
|
115
|
+
contentToWrite = mergedResult.data.content
|
|
116
|
+
action = 'updated'
|
|
117
|
+
|
|
118
|
+
if (!dryRun) {
|
|
119
|
+
await writeFile(outputPath, contentToWrite)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
if (!dryRun) {
|
|
124
|
+
await writeFile(outputPath, contentToWrite)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return err(createWriteError(error, outputPath, pkg.info.name))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log(`${dryRun ? '[DRY RUN] Would write' : 'Wrote'} ${outputPath}`)
|
|
133
|
+
|
|
134
|
+
return ok({
|
|
135
|
+
packageName: pkg.info.name,
|
|
136
|
+
outputPath,
|
|
137
|
+
action,
|
|
138
|
+
timestamp: new Date(),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function syncAll(): Promise<SyncSummary> {
|
|
143
|
+
const startTime = Date.now()
|
|
144
|
+
log('Starting full documentation sync...')
|
|
145
|
+
|
|
146
|
+
const scanResult = await scanner.scan()
|
|
147
|
+
const details: SyncInfo[] = []
|
|
148
|
+
const errors: SyncError[] = [...scanResult.errors]
|
|
149
|
+
|
|
150
|
+
for (const pkg of scanResult.packagesNeedingDocs) {
|
|
151
|
+
const result = await syncPackage(pkg)
|
|
152
|
+
|
|
153
|
+
if (result.success) {
|
|
154
|
+
details.push(result.data)
|
|
155
|
+
} else {
|
|
156
|
+
errors.push(result.error)
|
|
157
|
+
reportError(result.error)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const successCount = details.filter(d => d.action !== 'unchanged').length
|
|
162
|
+
const unchangedCount = details.filter(d => d.action === 'unchanged').length
|
|
163
|
+
|
|
164
|
+
log(
|
|
165
|
+
`Sync complete: ${successCount} updated, ${unchangedCount} unchanged, ${errors.length} errors`,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
totalPackages: scanResult.packages.length,
|
|
170
|
+
successCount,
|
|
171
|
+
failureCount: errors.length,
|
|
172
|
+
unchangedCount,
|
|
173
|
+
details,
|
|
174
|
+
errors,
|
|
175
|
+
durationMs: Date.now() - startTime,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function syncPackages(packageNames: readonly string[]): Promise<SyncSummary> {
|
|
180
|
+
const startTime = Date.now()
|
|
181
|
+
log(`Syncing specific packages: ${packageNames.join(', ')}...`)
|
|
182
|
+
|
|
183
|
+
const scanResult = await scanner.scan()
|
|
184
|
+
const packagesToSync = scanResult.packages.filter(pkg => packageNames.includes(pkg.info.name))
|
|
185
|
+
|
|
186
|
+
const details: SyncInfo[] = []
|
|
187
|
+
const errors: SyncError[] = []
|
|
188
|
+
|
|
189
|
+
for (const pkg of packagesToSync) {
|
|
190
|
+
const result = await syncPackage(pkg)
|
|
191
|
+
|
|
192
|
+
if (result.success) {
|
|
193
|
+
details.push(result.data)
|
|
194
|
+
} else {
|
|
195
|
+
errors.push(result.error)
|
|
196
|
+
reportError(result.error)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const successCount = details.filter(d => d.action !== 'unchanged').length
|
|
201
|
+
const unchangedCount = details.filter(d => d.action === 'unchanged').length
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
totalPackages: packagesToSync.length,
|
|
205
|
+
successCount,
|
|
206
|
+
failureCount: errors.length,
|
|
207
|
+
unchangedCount,
|
|
208
|
+
details,
|
|
209
|
+
errors,
|
|
210
|
+
durationMs: Date.now() - startTime,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleChanges(events: readonly FileChangeEvent[]): Promise<SyncSummary> {
|
|
215
|
+
const startTime = Date.now()
|
|
216
|
+
const groupedChanges = groupChangesByPackage(events)
|
|
217
|
+
const packageNames: string[] = []
|
|
218
|
+
|
|
219
|
+
for (const [packageName, packageEvents] of groupedChanges) {
|
|
220
|
+
if (packageName === '__unknown__') {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const categories = packageEvents.map(e => categorizeFileChange(e.path))
|
|
225
|
+
|
|
226
|
+
const scope = determineRegenerationScope(categories)
|
|
227
|
+
if (scope !== 'none') {
|
|
228
|
+
packageNames.push(packageName)
|
|
229
|
+
log(`Package ${packageName} needs ${scope} regeneration`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (packageNames.length === 0) {
|
|
234
|
+
return {
|
|
235
|
+
totalPackages: 0,
|
|
236
|
+
successCount: 0,
|
|
237
|
+
failureCount: 0,
|
|
238
|
+
unchangedCount: 0,
|
|
239
|
+
details: [],
|
|
240
|
+
errors: [],
|
|
241
|
+
durationMs: Date.now() - startTime,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return syncPackages(packageNames)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function startWatching(): Promise<void> {
|
|
249
|
+
if (watching) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
log('Starting watch mode...')
|
|
254
|
+
|
|
255
|
+
watcher = createDocWatcher({
|
|
256
|
+
rootDir: config.rootDir,
|
|
257
|
+
debounceMs: config.debounceMs ?? 300,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
debouncer = createDocDebouncer(
|
|
261
|
+
async events => {
|
|
262
|
+
const result = await handleChanges(events)
|
|
263
|
+
log(`Watch mode sync: ${result.successCount} updated, ${result.failureCount} errors`)
|
|
264
|
+
},
|
|
265
|
+
{debounceMs: config.debounceMs ?? 300},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
watcher.onChanges(events => {
|
|
269
|
+
for (const event of events) {
|
|
270
|
+
debouncer?.add(event)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await watcher.start()
|
|
275
|
+
watching = true
|
|
276
|
+
log('Watch mode started')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function stopWatching(): Promise<void> {
|
|
280
|
+
if (!watching) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
log('Stopping watch mode...')
|
|
285
|
+
|
|
286
|
+
debouncer?.cancel()
|
|
287
|
+
await watcher?.close()
|
|
288
|
+
|
|
289
|
+
watcher = undefined
|
|
290
|
+
debouncer = undefined
|
|
291
|
+
watching = false
|
|
292
|
+
|
|
293
|
+
log('Watch mode stopped')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
syncAll,
|
|
298
|
+
syncPackages,
|
|
299
|
+
handleChanges,
|
|
300
|
+
startWatching,
|
|
301
|
+
stopWatching,
|
|
302
|
+
isWatching: () => watching,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function getOutputPath(packageName: string, outputDir: string): string {
|
|
307
|
+
const slug = createSlug(getUnscopedName(packageName))
|
|
308
|
+
return path.join(outputDir, `${slug}.mdx`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function createSlug(name: string): string {
|
|
312
|
+
return name.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getUnscopedName(packageName: string): string {
|
|
316
|
+
if (packageName.startsWith('@')) {
|
|
317
|
+
const slashIndex = packageName.indexOf('/')
|
|
318
|
+
if (slashIndex > 0) {
|
|
319
|
+
return packageName.slice(slashIndex + 1)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return packageName
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function writeFile(filePath: string, content: string): Promise<void> {
|
|
326
|
+
const dir = path.dirname(filePath)
|
|
327
|
+
await fs.mkdir(dir, {recursive: true})
|
|
328
|
+
await fs.writeFile(filePath, content, 'utf-8')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function createWriteError(error: unknown, filePath: string, packageName: string): SyncError {
|
|
332
|
+
const message = formatWriteErrorMessage(error, filePath)
|
|
333
|
+
return {
|
|
334
|
+
code: 'WRITE_ERROR',
|
|
335
|
+
message,
|
|
336
|
+
packageName,
|
|
337
|
+
filePath,
|
|
338
|
+
cause: error,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function formatWriteErrorMessage(error: unknown, filePath: string): string {
|
|
343
|
+
const errorCode = isNodeError(error) ? error.code : undefined
|
|
344
|
+
|
|
345
|
+
switch (errorCode) {
|
|
346
|
+
case 'EACCES':
|
|
347
|
+
return `Permission denied writing to ${filePath}`
|
|
348
|
+
case 'ENOENT':
|
|
349
|
+
return `Directory not found for ${filePath}`
|
|
350
|
+
case 'EISDIR':
|
|
351
|
+
return `Cannot write to directory ${filePath}`
|
|
352
|
+
case undefined:
|
|
353
|
+
default: {
|
|
354
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
355
|
+
return `Failed to write ${filePath}: ${errorMessage}`
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
361
|
+
return error instanceof Error && 'code' in error
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function categorizeFileChange(filePath: string): 'readme' | 'package-json' | 'source' | 'unknown' {
|
|
365
|
+
const basename = path.basename(filePath).toLowerCase()
|
|
366
|
+
|
|
367
|
+
if (basename === 'readme.md' || basename === 'readme') return 'readme'
|
|
368
|
+
if (basename === 'package.json') return 'package-json'
|
|
369
|
+
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'source'
|
|
370
|
+
return 'unknown'
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Prevents directory traversal attacks (SEC-002) */
|
|
374
|
+
export function isValidFilePath(filePath: string, rootDir: string): boolean {
|
|
375
|
+
const resolvedPath = path.resolve(filePath)
|
|
376
|
+
const resolvedRoot = path.resolve(rootDir)
|
|
377
|
+
|
|
378
|
+
return resolvedPath.startsWith(resolvedRoot)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Export changeDetector factory for external use
|
|
382
|
+
export {createDocChangeDetector}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type {Result} from '@bfra.me/es/result'
|
|
2
|
+
import type {MDXDocument, SyncError} from '../types'
|
|
3
|
+
|
|
4
|
+
import {err, ok} from '@bfra.me/es/result'
|
|
5
|
+
|
|
6
|
+
import {validateMDXSyntax} from '../generators'
|
|
7
|
+
import {
|
|
8
|
+
createHeadingPattern,
|
|
9
|
+
extractCodeBlocks,
|
|
10
|
+
findEmptyMarkdownLinks,
|
|
11
|
+
hasComponent,
|
|
12
|
+
} from '../utils/safe-patterns'
|
|
13
|
+
|
|
14
|
+
export interface ValidationResult {
|
|
15
|
+
readonly valid: boolean
|
|
16
|
+
readonly errors: readonly ValidationError[]
|
|
17
|
+
readonly warnings: readonly ValidationWarning[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ValidationError {
|
|
21
|
+
readonly type: 'syntax' | 'frontmatter' | 'component' | 'content'
|
|
22
|
+
readonly message: string
|
|
23
|
+
readonly line?: number
|
|
24
|
+
readonly column?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ValidationWarning {
|
|
28
|
+
readonly type: 'deprecation' | 'recommendation' | 'compatibility'
|
|
29
|
+
readonly message: string
|
|
30
|
+
readonly line?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ValidationPipelineOptions {
|
|
34
|
+
readonly validateFrontmatter?: boolean
|
|
35
|
+
readonly validateComponents?: boolean
|
|
36
|
+
readonly validateContent?: boolean
|
|
37
|
+
readonly strict?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_OPTIONS: Required<ValidationPipelineOptions> = {
|
|
41
|
+
validateFrontmatter: true,
|
|
42
|
+
validateComponents: true,
|
|
43
|
+
validateContent: true,
|
|
44
|
+
strict: false,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createValidationPipeline(options: ValidationPipelineOptions = {}): {
|
|
48
|
+
readonly validate: (doc: MDXDocument) => ValidationResult
|
|
49
|
+
readonly validateContent: (content: string) => ValidationResult
|
|
50
|
+
readonly validateMultiple: (
|
|
51
|
+
docs: readonly MDXDocument[],
|
|
52
|
+
) => Result<Map<string, ValidationResult>, SyncError>
|
|
53
|
+
} {
|
|
54
|
+
const mergedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
55
|
+
|
|
56
|
+
function validate(doc: MDXDocument): ValidationResult {
|
|
57
|
+
const errors: ValidationError[] = []
|
|
58
|
+
const warnings: ValidationWarning[] = []
|
|
59
|
+
|
|
60
|
+
if (mergedOptions.validateFrontmatter) {
|
|
61
|
+
const frontmatterResult = validateFrontmatter(doc.frontmatter)
|
|
62
|
+
errors.push(...frontmatterResult.errors)
|
|
63
|
+
warnings.push(...frontmatterResult.warnings)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const syntaxResult = validateSyntax(doc.rendered)
|
|
67
|
+
errors.push(...syntaxResult.errors)
|
|
68
|
+
warnings.push(...syntaxResult.warnings)
|
|
69
|
+
|
|
70
|
+
if (mergedOptions.validateComponents) {
|
|
71
|
+
const componentResult = validateStarlightComponents(doc.rendered)
|
|
72
|
+
errors.push(...componentResult.errors)
|
|
73
|
+
warnings.push(...componentResult.warnings)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mergedOptions.validateContent) {
|
|
77
|
+
const contentResult = validateContentQuality(doc.rendered)
|
|
78
|
+
errors.push(...contentResult.errors)
|
|
79
|
+
warnings.push(...contentResult.warnings)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const valid = mergedOptions.strict
|
|
83
|
+
? errors.length === 0 && warnings.length === 0
|
|
84
|
+
: errors.length === 0
|
|
85
|
+
|
|
86
|
+
return {valid, errors, warnings}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateContent(content: string): ValidationResult {
|
|
90
|
+
const errors: ValidationError[] = []
|
|
91
|
+
const warnings: ValidationWarning[] = []
|
|
92
|
+
|
|
93
|
+
const syntaxResult = validateSyntax(content)
|
|
94
|
+
errors.push(...syntaxResult.errors)
|
|
95
|
+
warnings.push(...syntaxResult.warnings)
|
|
96
|
+
|
|
97
|
+
if (mergedOptions.validateComponents) {
|
|
98
|
+
const componentResult = validateStarlightComponents(content)
|
|
99
|
+
errors.push(...componentResult.errors)
|
|
100
|
+
warnings.push(...componentResult.warnings)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const valid = mergedOptions.strict
|
|
104
|
+
? errors.length === 0 && warnings.length === 0
|
|
105
|
+
: errors.length === 0
|
|
106
|
+
|
|
107
|
+
return {valid, errors, warnings}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateMultiple(
|
|
111
|
+
docs: readonly MDXDocument[],
|
|
112
|
+
): Result<Map<string, ValidationResult>, SyncError> {
|
|
113
|
+
const results = new Map<string, ValidationResult>()
|
|
114
|
+
const seen = new Set<string>()
|
|
115
|
+
|
|
116
|
+
for (const doc of docs) {
|
|
117
|
+
const key = doc.frontmatter.title
|
|
118
|
+
|
|
119
|
+
// Fixed: Return error on duplicate titles instead of silent overwrite
|
|
120
|
+
if (seen.has(key)) {
|
|
121
|
+
return err({
|
|
122
|
+
code: 'VALIDATION_ERROR',
|
|
123
|
+
message: `Duplicate document title detected: "${key}". Each document must have a unique title.`,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
seen.add(key)
|
|
128
|
+
results.set(key, validate(doc))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return ok(results)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {validate, validateContent, validateMultiple}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateFrontmatter(frontmatter: MDXDocument['frontmatter']): {
|
|
138
|
+
errors: ValidationError[]
|
|
139
|
+
warnings: ValidationWarning[]
|
|
140
|
+
} {
|
|
141
|
+
const errors: ValidationError[] = []
|
|
142
|
+
const warnings: ValidationWarning[] = []
|
|
143
|
+
|
|
144
|
+
if (frontmatter.title.trim().length === 0) {
|
|
145
|
+
errors.push({
|
|
146
|
+
type: 'frontmatter',
|
|
147
|
+
message: 'Frontmatter title is required and cannot be empty',
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (frontmatter.title.length > 100) {
|
|
152
|
+
warnings.push({
|
|
153
|
+
type: 'recommendation',
|
|
154
|
+
message: `Title is ${frontmatter.title.length} characters, consider keeping under 100 for better readability`,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (frontmatter.description !== undefined && frontmatter.description.length > 200) {
|
|
159
|
+
warnings.push({
|
|
160
|
+
type: 'recommendation',
|
|
161
|
+
message: 'Description is quite long, consider keeping under 200 characters for SEO',
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {errors, warnings}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateSyntax(content: string): {
|
|
169
|
+
errors: ValidationError[]
|
|
170
|
+
warnings: ValidationWarning[]
|
|
171
|
+
} {
|
|
172
|
+
const errors: ValidationError[] = []
|
|
173
|
+
const warnings: ValidationWarning[] = []
|
|
174
|
+
|
|
175
|
+
const result = validateMDXSyntax(content)
|
|
176
|
+
|
|
177
|
+
if (!result.success) {
|
|
178
|
+
errors.push({
|
|
179
|
+
type: 'syntax',
|
|
180
|
+
message: result.error.message,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {errors, warnings}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const STARLIGHT_COMPONENTS = ['Badge', 'Card', 'CardGrid', 'Tabs', 'TabItem'] as const
|
|
188
|
+
|
|
189
|
+
function validateStarlightComponents(content: string): {
|
|
190
|
+
errors: ValidationError[]
|
|
191
|
+
warnings: ValidationWarning[]
|
|
192
|
+
} {
|
|
193
|
+
const errors: ValidationError[] = []
|
|
194
|
+
const warnings: ValidationWarning[] = []
|
|
195
|
+
|
|
196
|
+
for (const component of STARLIGHT_COMPONENTS) {
|
|
197
|
+
const openPattern = new RegExp(String.raw`<${component}(?:\s[^>]*)?>`, 'g')
|
|
198
|
+
const closePattern = new RegExp(`</${component}>`, 'g')
|
|
199
|
+
const selfClosePattern = new RegExp(String.raw`<${component}(?:\s[^>]*)?/>`, 'g')
|
|
200
|
+
|
|
201
|
+
const opens = content.match(openPattern)?.length ?? 0
|
|
202
|
+
const closes = content.match(closePattern)?.length ?? 0
|
|
203
|
+
const selfCloses = content.match(selfClosePattern)?.length ?? 0
|
|
204
|
+
|
|
205
|
+
const nonSelfClosingOpens = opens - selfCloses
|
|
206
|
+
if (nonSelfClosingOpens > closes) {
|
|
207
|
+
errors.push({
|
|
208
|
+
type: 'component',
|
|
209
|
+
message: `Unclosed <${component}> tag detected (${nonSelfClosingOpens} opens, ${closes} closes)`,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const hasTabItem = content.includes('<TabItem')
|
|
215
|
+
const hasTabs = content.includes('<Tabs')
|
|
216
|
+
if (hasTabItem && !hasTabs) {
|
|
217
|
+
errors.push({
|
|
218
|
+
type: 'component',
|
|
219
|
+
message: '<TabItem> must be used inside <Tabs>',
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Ensures hasCard detects 'Card' but not 'CardGrid'
|
|
224
|
+
const hasCard = hasComponent(content, 'Card')
|
|
225
|
+
const hasCardGrid = hasComponent(content, 'CardGrid')
|
|
226
|
+
if (hasCard && !hasCardGrid) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
type: 'recommendation',
|
|
229
|
+
message: 'Consider wrapping <Card> components in <CardGrid> for better layout',
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {errors, warnings}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateContentQuality(content: string): {
|
|
237
|
+
errors: ValidationError[]
|
|
238
|
+
warnings: ValidationWarning[]
|
|
239
|
+
} {
|
|
240
|
+
const errors: ValidationError[] = []
|
|
241
|
+
const warnings: ValidationWarning[] = []
|
|
242
|
+
|
|
243
|
+
const emptyLinkPositions = findEmptyMarkdownLinks(content)
|
|
244
|
+
if (emptyLinkPositions.length > 0) {
|
|
245
|
+
errors.push({
|
|
246
|
+
type: 'content',
|
|
247
|
+
message: `Found ${emptyLinkPositions.length} empty link(s)`,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const codeBlockMarkers = content.match(/```/g)?.length ?? 0
|
|
252
|
+
if (codeBlockMarkers % 2 !== 0) {
|
|
253
|
+
errors.push({
|
|
254
|
+
type: 'content',
|
|
255
|
+
message: 'Unclosed code block detected (odd number of ``` markers)',
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Duplicate headings can confuse readers and break anchor links
|
|
260
|
+
const h2Pattern = createHeadingPattern(2)
|
|
261
|
+
const h2Headings: string[] = []
|
|
262
|
+
const h2Matches = content.matchAll(h2Pattern)
|
|
263
|
+
for (const match of h2Matches) {
|
|
264
|
+
const heading = match[1]
|
|
265
|
+
if (heading === undefined) {
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (h2Headings.includes(heading)) {
|
|
269
|
+
warnings.push({
|
|
270
|
+
type: 'recommendation',
|
|
271
|
+
message: `Duplicate H2 heading: "${heading}"`,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
h2Headings.push(heading)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const codeBlocks = extractCodeBlocks(content)
|
|
278
|
+
for (const block of codeBlocks) {
|
|
279
|
+
const lines = block.split('\n')
|
|
280
|
+
for (const line of lines) {
|
|
281
|
+
if (line.length > 120 && !line.startsWith('```')) {
|
|
282
|
+
warnings.push({
|
|
283
|
+
type: 'recommendation',
|
|
284
|
+
message: `Code line exceeds 120 characters, may require horizontal scrolling`,
|
|
285
|
+
})
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {errors, warnings}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function validateDocument(
|
|
295
|
+
doc: MDXDocument,
|
|
296
|
+
options?: ValidationPipelineOptions,
|
|
297
|
+
): Result<MDXDocument, SyncError> {
|
|
298
|
+
const pipeline = createValidationPipeline(options)
|
|
299
|
+
const result = pipeline.validate(doc)
|
|
300
|
+
|
|
301
|
+
if (!result.valid) {
|
|
302
|
+
const errorMessages = result.errors.map(e => e.message).join('; ')
|
|
303
|
+
return err({
|
|
304
|
+
code: 'VALIDATION_ERROR',
|
|
305
|
+
message: `MDX validation failed: ${errorMessages}`,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return ok(doc)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function validateContentString(
|
|
313
|
+
content: string,
|
|
314
|
+
options?: ValidationPipelineOptions,
|
|
315
|
+
): Result<string, SyncError> {
|
|
316
|
+
const pipeline = createValidationPipeline(options)
|
|
317
|
+
const result = pipeline.validateContent(content)
|
|
318
|
+
|
|
319
|
+
if (!result.valid) {
|
|
320
|
+
const errorMessages = result.errors.map(e => e.message).join('; ')
|
|
321
|
+
return err({
|
|
322
|
+
code: 'VALIDATION_ERROR',
|
|
323
|
+
message: `MDX validation failed: ${errorMessages}`,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return ok(content)
|
|
328
|
+
}
|