@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,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
+ }