@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bfra.me/doc-sync/types - Core type definitions for documentation synchronization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {Result} from '@bfra.me/es/result'
|
|
6
|
+
import type {z} from 'zod'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Metadata extracted from a package.json file
|
|
10
|
+
*/
|
|
11
|
+
export interface PackageInfo {
|
|
12
|
+
/** Package name from package.json */
|
|
13
|
+
readonly name: string
|
|
14
|
+
/** Package version from package.json */
|
|
15
|
+
readonly version: string
|
|
16
|
+
/** Package description from package.json */
|
|
17
|
+
readonly description?: string
|
|
18
|
+
/** Keywords for categorization */
|
|
19
|
+
readonly keywords?: readonly string[]
|
|
20
|
+
/** Path to the package directory */
|
|
21
|
+
readonly packagePath: string
|
|
22
|
+
/** Path to the package's source directory */
|
|
23
|
+
readonly srcPath: string
|
|
24
|
+
/** Path to the package's README file if it exists */
|
|
25
|
+
readonly readmePath?: string
|
|
26
|
+
/** Custom documentation config from package.json "docs" field */
|
|
27
|
+
readonly docsConfig?: DocConfigSource
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Source configuration for documentation, from package.json or docs.config.json
|
|
32
|
+
*/
|
|
33
|
+
export interface DocConfigSource {
|
|
34
|
+
/** Custom title override for the documentation page */
|
|
35
|
+
readonly title?: string
|
|
36
|
+
/** Custom description override */
|
|
37
|
+
readonly description?: string
|
|
38
|
+
/** Sidebar configuration */
|
|
39
|
+
readonly sidebar?: {
|
|
40
|
+
/** Label shown in sidebar navigation */
|
|
41
|
+
readonly label?: string
|
|
42
|
+
/** Sort order in sidebar */
|
|
43
|
+
readonly order?: number
|
|
44
|
+
/** Whether to hide from sidebar */
|
|
45
|
+
readonly hidden?: boolean
|
|
46
|
+
}
|
|
47
|
+
/** Sections to exclude from auto-generation */
|
|
48
|
+
readonly excludeSections?: readonly string[]
|
|
49
|
+
/** Custom frontmatter fields to include */
|
|
50
|
+
readonly frontmatter?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Configuration for documentation generation
|
|
55
|
+
*/
|
|
56
|
+
export interface DocConfig {
|
|
57
|
+
/** Root directory of the monorepo */
|
|
58
|
+
readonly rootDir: string
|
|
59
|
+
/** Output directory for generated documentation */
|
|
60
|
+
readonly outputDir: string
|
|
61
|
+
/** Glob patterns for packages to include */
|
|
62
|
+
readonly includePatterns: readonly string[]
|
|
63
|
+
/** Glob patterns for packages to exclude */
|
|
64
|
+
readonly excludePatterns?: readonly string[]
|
|
65
|
+
/** Whether to watch for changes */
|
|
66
|
+
readonly watch?: boolean
|
|
67
|
+
/** Debounce delay in milliseconds for watch mode */
|
|
68
|
+
readonly debounceMs?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Error types for parse operations
|
|
73
|
+
*/
|
|
74
|
+
export type ParseErrorCode =
|
|
75
|
+
| 'INVALID_SYNTAX'
|
|
76
|
+
| 'FILE_NOT_FOUND'
|
|
77
|
+
| 'READ_ERROR'
|
|
78
|
+
| 'MALFORMED_JSON'
|
|
79
|
+
| 'UNSUPPORTED_FORMAT'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Structured error for parse operations
|
|
83
|
+
*/
|
|
84
|
+
export interface ParseError {
|
|
85
|
+
/** Error classification code */
|
|
86
|
+
readonly code: ParseErrorCode
|
|
87
|
+
/** Human-readable error message */
|
|
88
|
+
readonly message: string
|
|
89
|
+
/** File path that caused the error */
|
|
90
|
+
readonly filePath?: string
|
|
91
|
+
/** Line number where error occurred (1-indexed) */
|
|
92
|
+
readonly line?: number
|
|
93
|
+
/** Column number where error occurred (1-indexed) */
|
|
94
|
+
readonly column?: number
|
|
95
|
+
/** Original error if wrapping */
|
|
96
|
+
readonly cause?: unknown
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Result type for parse operations
|
|
101
|
+
*/
|
|
102
|
+
export type ParseResult<T> = Result<T, ParseError>
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Error types for sync operations
|
|
106
|
+
*/
|
|
107
|
+
export type SyncErrorCode =
|
|
108
|
+
| 'WRITE_ERROR'
|
|
109
|
+
| 'VALIDATION_ERROR'
|
|
110
|
+
| 'GENERATION_ERROR'
|
|
111
|
+
| 'PACKAGE_NOT_FOUND'
|
|
112
|
+
| 'CONFIG_ERROR'
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Structured error for sync operations
|
|
116
|
+
*/
|
|
117
|
+
export interface SyncError {
|
|
118
|
+
/** Error classification code */
|
|
119
|
+
readonly code: SyncErrorCode
|
|
120
|
+
/** Human-readable error message */
|
|
121
|
+
readonly message: string
|
|
122
|
+
/** Package name that caused the error */
|
|
123
|
+
readonly packageName?: string
|
|
124
|
+
/** File path that caused the error */
|
|
125
|
+
readonly filePath?: string
|
|
126
|
+
/** Original error if wrapping */
|
|
127
|
+
readonly cause?: unknown
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Result type for sync operations
|
|
132
|
+
*/
|
|
133
|
+
export type SyncResult<T> = Result<T, SyncError>
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Information about a single sync operation
|
|
137
|
+
*/
|
|
138
|
+
export interface SyncInfo {
|
|
139
|
+
/** Package name that was synced */
|
|
140
|
+
readonly packageName: string
|
|
141
|
+
/** Output file path */
|
|
142
|
+
readonly outputPath: string
|
|
143
|
+
/** Whether the file was created or updated */
|
|
144
|
+
readonly action: 'created' | 'updated' | 'unchanged'
|
|
145
|
+
/** Timestamp of the sync operation */
|
|
146
|
+
readonly timestamp: Date
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Summary of a complete sync run
|
|
151
|
+
*/
|
|
152
|
+
export interface SyncSummary {
|
|
153
|
+
/** Total number of packages processed */
|
|
154
|
+
readonly totalPackages: number
|
|
155
|
+
/** Number of successful syncs */
|
|
156
|
+
readonly successCount: number
|
|
157
|
+
/** Number of failed syncs */
|
|
158
|
+
readonly failureCount: number
|
|
159
|
+
/** Number of unchanged packages */
|
|
160
|
+
readonly unchangedCount: number
|
|
161
|
+
/** Details of each sync operation */
|
|
162
|
+
readonly details: readonly SyncInfo[]
|
|
163
|
+
/** Errors encountered during sync */
|
|
164
|
+
readonly errors: readonly SyncError[]
|
|
165
|
+
/** Duration of the sync run in milliseconds */
|
|
166
|
+
readonly durationMs: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extracted JSDoc information
|
|
171
|
+
*/
|
|
172
|
+
export interface JSDocInfo {
|
|
173
|
+
/** Main description from JSDoc */
|
|
174
|
+
readonly description?: string
|
|
175
|
+
/** @param tags with name and description */
|
|
176
|
+
readonly params?: readonly JSDocParam[]
|
|
177
|
+
/** @returns description */
|
|
178
|
+
readonly returns?: string
|
|
179
|
+
/** @example code blocks */
|
|
180
|
+
readonly examples?: readonly string[]
|
|
181
|
+
/** @deprecated message if present */
|
|
182
|
+
readonly deprecated?: string
|
|
183
|
+
/** @since version if present */
|
|
184
|
+
readonly since?: string
|
|
185
|
+
/** @see references */
|
|
186
|
+
readonly see?: readonly string[]
|
|
187
|
+
/** Custom tags not in standard set */
|
|
188
|
+
readonly customTags?: readonly JSDocTag[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A single JSDoc @param entry
|
|
193
|
+
*/
|
|
194
|
+
export interface JSDocParam {
|
|
195
|
+
/** Parameter name */
|
|
196
|
+
readonly name: string
|
|
197
|
+
/** Parameter type (from JSDoc or inferred) */
|
|
198
|
+
readonly type?: string
|
|
199
|
+
/** Parameter description */
|
|
200
|
+
readonly description?: string
|
|
201
|
+
/** Whether the parameter is optional */
|
|
202
|
+
readonly optional?: boolean
|
|
203
|
+
/** Default value if specified */
|
|
204
|
+
readonly defaultValue?: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A custom JSDoc tag
|
|
209
|
+
*/
|
|
210
|
+
export interface JSDocTag {
|
|
211
|
+
/** Tag name without @ symbol */
|
|
212
|
+
readonly name: string
|
|
213
|
+
/** Tag value/content */
|
|
214
|
+
readonly value?: string
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Information about an exported function
|
|
219
|
+
*/
|
|
220
|
+
export interface ExportedFunction {
|
|
221
|
+
/** Function name */
|
|
222
|
+
readonly name: string
|
|
223
|
+
/** JSDoc documentation */
|
|
224
|
+
readonly jsdoc?: JSDocInfo
|
|
225
|
+
/** Function signature as string */
|
|
226
|
+
readonly signature: string
|
|
227
|
+
/** Whether it's async */
|
|
228
|
+
readonly isAsync: boolean
|
|
229
|
+
/** Whether it's a generator */
|
|
230
|
+
readonly isGenerator: boolean
|
|
231
|
+
/** Parameter information */
|
|
232
|
+
readonly parameters: readonly FunctionParameter[]
|
|
233
|
+
/** Return type as string */
|
|
234
|
+
readonly returnType: string
|
|
235
|
+
/** Whether it's the default export */
|
|
236
|
+
readonly isDefault: boolean
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Function parameter information
|
|
241
|
+
*/
|
|
242
|
+
export interface FunctionParameter {
|
|
243
|
+
/** Parameter name */
|
|
244
|
+
readonly name: string
|
|
245
|
+
/** TypeScript type */
|
|
246
|
+
readonly type: string
|
|
247
|
+
/** Whether optional */
|
|
248
|
+
readonly optional: boolean
|
|
249
|
+
/** Default value if provided */
|
|
250
|
+
readonly defaultValue?: string
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Information about an exported type or interface
|
|
255
|
+
*/
|
|
256
|
+
export interface ExportedType {
|
|
257
|
+
/** Type name */
|
|
258
|
+
readonly name: string
|
|
259
|
+
/** JSDoc documentation */
|
|
260
|
+
readonly jsdoc?: JSDocInfo
|
|
261
|
+
/** Full type definition as string */
|
|
262
|
+
readonly definition: string
|
|
263
|
+
/** Kind of type declaration */
|
|
264
|
+
readonly kind: 'interface' | 'type' | 'enum' | 'class'
|
|
265
|
+
/** Whether it's the default export */
|
|
266
|
+
readonly isDefault: boolean
|
|
267
|
+
/** Type parameters (generics) */
|
|
268
|
+
readonly typeParameters?: readonly string[]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Complete API surface extracted from a package
|
|
273
|
+
*/
|
|
274
|
+
export interface PackageAPI {
|
|
275
|
+
/** Exported functions */
|
|
276
|
+
readonly functions: readonly ExportedFunction[]
|
|
277
|
+
/** Exported types/interfaces */
|
|
278
|
+
readonly types: readonly ExportedType[]
|
|
279
|
+
/** Re-exported modules */
|
|
280
|
+
readonly reExports: readonly ReExport[]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Information about a re-export statement
|
|
285
|
+
*/
|
|
286
|
+
export interface ReExport {
|
|
287
|
+
/** Module path being re-exported from */
|
|
288
|
+
readonly from: string
|
|
289
|
+
/** Named exports being re-exported, or '*' for all */
|
|
290
|
+
readonly exports: readonly string[] | '*'
|
|
291
|
+
/** Alias if renamed during re-export */
|
|
292
|
+
readonly alias?: string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parsed README content with sections
|
|
297
|
+
*/
|
|
298
|
+
export interface ReadmeContent {
|
|
299
|
+
/** Main title (first H1) */
|
|
300
|
+
readonly title?: string
|
|
301
|
+
/** Content before first heading */
|
|
302
|
+
readonly preamble?: string
|
|
303
|
+
/** Structured sections by heading */
|
|
304
|
+
readonly sections: readonly ReadmeSection[]
|
|
305
|
+
/** Raw markdown content */
|
|
306
|
+
readonly raw: string
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A section in the README
|
|
311
|
+
*/
|
|
312
|
+
export interface ReadmeSection {
|
|
313
|
+
/** Section heading text */
|
|
314
|
+
readonly heading: string
|
|
315
|
+
/** Heading level (1-6) */
|
|
316
|
+
readonly level: number
|
|
317
|
+
/** Section content (markdown) */
|
|
318
|
+
readonly content: string
|
|
319
|
+
/** Nested subsections */
|
|
320
|
+
readonly children: readonly ReadmeSection[]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* MDX frontmatter for Starlight
|
|
325
|
+
*/
|
|
326
|
+
export interface MDXFrontmatter {
|
|
327
|
+
/** Page title */
|
|
328
|
+
readonly title: string
|
|
329
|
+
/** Page description for SEO */
|
|
330
|
+
readonly description?: string
|
|
331
|
+
/** Sidebar configuration */
|
|
332
|
+
readonly sidebar?: {
|
|
333
|
+
readonly label?: string
|
|
334
|
+
readonly order?: number
|
|
335
|
+
readonly hidden?: boolean
|
|
336
|
+
readonly badge?:
|
|
337
|
+
| string
|
|
338
|
+
| {
|
|
339
|
+
readonly text: string
|
|
340
|
+
readonly variant?: 'note' | 'tip' | 'caution' | 'danger' | 'success' | 'default'
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Table of contents configuration */
|
|
344
|
+
readonly tableOfContents?:
|
|
345
|
+
| boolean
|
|
346
|
+
| {
|
|
347
|
+
readonly minHeadingLevel?: number
|
|
348
|
+
readonly maxHeadingLevel?: number
|
|
349
|
+
}
|
|
350
|
+
/** Template to use */
|
|
351
|
+
readonly template?: 'doc' | 'splash'
|
|
352
|
+
/** Hero configuration for splash template */
|
|
353
|
+
readonly hero?: {
|
|
354
|
+
readonly title?: string
|
|
355
|
+
readonly tagline?: string
|
|
356
|
+
readonly image?: {readonly src: string; readonly alt: string}
|
|
357
|
+
readonly actions?: readonly {
|
|
358
|
+
readonly text: string
|
|
359
|
+
readonly link: string
|
|
360
|
+
readonly icon?: string
|
|
361
|
+
readonly variant?: 'primary' | 'secondary' | 'minimal'
|
|
362
|
+
}[]
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Generated MDX document
|
|
368
|
+
*/
|
|
369
|
+
export interface MDXDocument {
|
|
370
|
+
/** Frontmatter configuration */
|
|
371
|
+
readonly frontmatter: MDXFrontmatter
|
|
372
|
+
/** Document body content */
|
|
373
|
+
readonly content: string
|
|
374
|
+
/** Full rendered document */
|
|
375
|
+
readonly rendered: string
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Sentinel markers for content preservation
|
|
380
|
+
*/
|
|
381
|
+
export const SENTINEL_MARKERS = {
|
|
382
|
+
AUTO_START: '{/* AUTO-GENERATED-START */}',
|
|
383
|
+
AUTO_END: '{/* AUTO-GENERATED-END */}',
|
|
384
|
+
MANUAL_START: '{/* MANUAL-CONTENT-START */}',
|
|
385
|
+
MANUAL_END: '{/* MANUAL-CONTENT-END */}',
|
|
386
|
+
} as const
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* File change event from watcher
|
|
390
|
+
*/
|
|
391
|
+
export interface FileChangeEvent {
|
|
392
|
+
/** Type of change */
|
|
393
|
+
readonly type: 'add' | 'change' | 'unlink'
|
|
394
|
+
/** Absolute path to the changed file */
|
|
395
|
+
readonly path: string
|
|
396
|
+
/** Package name if determinable */
|
|
397
|
+
readonly packageName?: string
|
|
398
|
+
/** Timestamp of the change */
|
|
399
|
+
readonly timestamp: Date
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Options for the CLI
|
|
404
|
+
*/
|
|
405
|
+
export interface CLIOptions {
|
|
406
|
+
/** Root directory to operate from */
|
|
407
|
+
readonly root?: string
|
|
408
|
+
/** Specific packages to sync */
|
|
409
|
+
readonly packages?: readonly string[]
|
|
410
|
+
/** Run in watch mode */
|
|
411
|
+
readonly watch?: boolean
|
|
412
|
+
/** Dry run without writing files */
|
|
413
|
+
readonly dryRun?: boolean
|
|
414
|
+
/** Verbose logging */
|
|
415
|
+
readonly verbose?: boolean
|
|
416
|
+
/** Quiet mode (minimal output) */
|
|
417
|
+
readonly quiet?: boolean
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Zod schema type helper for runtime validation
|
|
422
|
+
*/
|
|
423
|
+
export type InferSchema<T extends z.ZodTypeAny> = z.infer<T>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bfra.me/doc-sync/utils - Security utilities for MDX generation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
createHeadingPattern,
|
|
7
|
+
extractCodeBlocks,
|
|
8
|
+
findEmptyMarkdownLinks,
|
|
9
|
+
hasComponent,
|
|
10
|
+
parseJSXTags,
|
|
11
|
+
} from './safe-patterns'
|
|
12
|
+
|
|
13
|
+
export {parseJSXAttributes, sanitizeAttribute, sanitizeForMDX, sanitizeJSXTag} from './sanitization'
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bfra.me/doc-sync/utils/safe-patterns - Safe regex patterns and utilities for MDX/HTML parsing
|
|
3
|
+
* All patterns are designed to prevent ReDoS attacks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import remarkParse from 'remark-parse'
|
|
7
|
+
import {unified} from 'unified'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create safe heading pattern for specific level
|
|
11
|
+
* Uses explicit character class instead of greedy `.+` to prevent ReDoS
|
|
12
|
+
*
|
|
13
|
+
* @param level - Heading level (1-6)
|
|
14
|
+
* @returns Safe regex pattern for the heading level
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const h2Pattern = createHeadingPattern(2)
|
|
19
|
+
* const matches = content.match(h2Pattern)
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function createHeadingPattern(level: number): RegExp {
|
|
23
|
+
if (level < 1 || level > 6) {
|
|
24
|
+
throw new Error('Heading level must be between 1 and 6')
|
|
25
|
+
}
|
|
26
|
+
const hashes = '#'.repeat(level)
|
|
27
|
+
// Use [^\r\n]+ instead of .+ to prevent ReDoS
|
|
28
|
+
return new RegExp(`^${hashes} ([^\r\n]+)$`, 'gm')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if content contains a specific JSX component
|
|
33
|
+
* Uses a safe pattern that avoids catastrophic backtracking
|
|
34
|
+
*
|
|
35
|
+
* @param content - The MDX/HTML content to search
|
|
36
|
+
* @param componentName - Name of the component to find (e.g., 'Card', 'Badge')
|
|
37
|
+
* @returns True if the component is found
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const hasCard = hasComponent(content, 'Card')
|
|
42
|
+
* const hasCardGrid = hasComponent(content, 'CardGrid')
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function hasComponent(content: string, componentName: string): boolean {
|
|
46
|
+
// Escape special regex characters in component name
|
|
47
|
+
const escaped = componentName.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
48
|
+
// Match opening tag or self-closing tag with word boundary
|
|
49
|
+
const pattern = new RegExp(String.raw`<${escaped}(?:\s[^>]*)?(?:>|\/>)`)
|
|
50
|
+
return pattern.test(content)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract code blocks from markdown content using unified/remark (safe, no regex)
|
|
55
|
+
* This approach uses AST parsing instead of regex to avoid ReDoS vulnerabilities
|
|
56
|
+
*
|
|
57
|
+
* @param content - The markdown content to parse
|
|
58
|
+
* @returns Array of code block strings with their language identifiers
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const blocks = extractCodeBlocks(content)
|
|
63
|
+
* for (const block of blocks) {
|
|
64
|
+
* console.log(block)
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function extractCodeBlocks(content: string): readonly string[] {
|
|
69
|
+
const processor = unified().use(remarkParse)
|
|
70
|
+
let tree
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
tree = processor.parse(content)
|
|
74
|
+
} catch {
|
|
75
|
+
// If parsing fails, return empty array instead of throwing
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const blocks: string[] = []
|
|
80
|
+
|
|
81
|
+
interface MarkdownNode {
|
|
82
|
+
type?: string
|
|
83
|
+
lang?: string | null
|
|
84
|
+
value?: string
|
|
85
|
+
children?: MarkdownNode[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function visit(node: MarkdownNode): void {
|
|
89
|
+
if (node.type === 'code') {
|
|
90
|
+
const lang = node.lang ?? ''
|
|
91
|
+
const value = node.value ?? ''
|
|
92
|
+
blocks.push(`\`\`\`${lang}\n${value}\n\`\`\``)
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(node.children)) {
|
|
95
|
+
for (const child of node.children) {
|
|
96
|
+
visit(child)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
visit(tree as MarkdownNode)
|
|
102
|
+
return blocks
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse JSX tags from content using a safe, non-backtracking approach.
|
|
107
|
+
* Uses a state machine instead of regex to prevent ReDoS.
|
|
108
|
+
*
|
|
109
|
+
* @param content - The MDX/HTML content to parse
|
|
110
|
+
* @returns Array of matched JSX tags with their positions
|
|
111
|
+
*/
|
|
112
|
+
export function parseJSXTags(
|
|
113
|
+
content: string,
|
|
114
|
+
): readonly {tag: string; index: number; isClosing: boolean; isSelfClosing: boolean}[] {
|
|
115
|
+
const results: {tag: string; index: number; isClosing: boolean; isSelfClosing: boolean}[] = []
|
|
116
|
+
let i = 0
|
|
117
|
+
|
|
118
|
+
while (i < content.length) {
|
|
119
|
+
if (content[i] !== '<') {
|
|
120
|
+
i++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const startIndex = i
|
|
125
|
+
i++
|
|
126
|
+
|
|
127
|
+
if (i >= content.length) break
|
|
128
|
+
|
|
129
|
+
const isClosing = content[i] === '/'
|
|
130
|
+
if (isClosing) {
|
|
131
|
+
i++
|
|
132
|
+
if (i >= content.length) break
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// JSX components must start with uppercase (React convention)
|
|
136
|
+
if (!/^[A-Z]/.test(content[i] ?? '')) {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let tagName = ''
|
|
141
|
+
while (i < content.length && /^[a-z0-9]/i.test(content[i] ?? '')) {
|
|
142
|
+
tagName += content[i]
|
|
143
|
+
i++
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (tagName.length === 0) {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isClosing) {
|
|
151
|
+
if (i < content.length && content[i] === '>') {
|
|
152
|
+
results.push({
|
|
153
|
+
tag: `</${tagName}>`,
|
|
154
|
+
index: startIndex,
|
|
155
|
+
isClosing: true,
|
|
156
|
+
isSelfClosing: false,
|
|
157
|
+
})
|
|
158
|
+
i++
|
|
159
|
+
}
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Skip attributes while tracking nested structures
|
|
164
|
+
let depth = 0
|
|
165
|
+
let foundEnd = false
|
|
166
|
+
let isSelfClosing = false
|
|
167
|
+
|
|
168
|
+
while (i < content.length && !foundEnd) {
|
|
169
|
+
const char = content[i]
|
|
170
|
+
|
|
171
|
+
// Skip quoted string contents to avoid misinterpreting > inside strings
|
|
172
|
+
if ((char === '"' || char === "'") && depth === 0) {
|
|
173
|
+
const quote = char
|
|
174
|
+
i++
|
|
175
|
+
while (i < content.length && content[i] !== quote) {
|
|
176
|
+
if (content[i] === '\\' && i + 1 < content.length) {
|
|
177
|
+
i += 2
|
|
178
|
+
} else {
|
|
179
|
+
i++
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (i < content.length) i++
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Track brace depth for JSX expressions like {value}
|
|
187
|
+
if (char === '{') {
|
|
188
|
+
depth++
|
|
189
|
+
i++
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (char === '}') {
|
|
193
|
+
depth = Math.max(0, depth - 1)
|
|
194
|
+
i++
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Only match tag end when not inside a JSX expression
|
|
199
|
+
if (depth === 0) {
|
|
200
|
+
if (char === '/' && i + 1 < content.length && content[i + 1] === '>') {
|
|
201
|
+
isSelfClosing = true
|
|
202
|
+
foundEnd = true
|
|
203
|
+
i += 2
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (char === '>') {
|
|
207
|
+
foundEnd = true
|
|
208
|
+
i++
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
i++
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (foundEnd) {
|
|
217
|
+
const fullTag = content.slice(startIndex, i)
|
|
218
|
+
results.push({tag: fullTag, index: startIndex, isClosing: false, isSelfClosing})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return results
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Find empty markdown links in content using safe parsing.
|
|
227
|
+
* Uses indexOf-based scanning instead of regex to prevent ReDoS.
|
|
228
|
+
*
|
|
229
|
+
* @param content - The markdown content to check
|
|
230
|
+
* @returns Array of positions where empty links were found
|
|
231
|
+
*/
|
|
232
|
+
export function findEmptyMarkdownLinks(content: string): readonly number[] {
|
|
233
|
+
const positions: number[] = []
|
|
234
|
+
let pos = 0
|
|
235
|
+
|
|
236
|
+
while (pos < content.length) {
|
|
237
|
+
const openBracket = content.indexOf('[', pos)
|
|
238
|
+
if (openBracket === -1) break
|
|
239
|
+
|
|
240
|
+
// Handle nested brackets
|
|
241
|
+
let bracketDepth = 1
|
|
242
|
+
let closeBracket = openBracket + 1
|
|
243
|
+
while (closeBracket < content.length && bracketDepth > 0) {
|
|
244
|
+
if (content[closeBracket] === '[') {
|
|
245
|
+
bracketDepth++
|
|
246
|
+
} else if (content[closeBracket] === ']') {
|
|
247
|
+
bracketDepth--
|
|
248
|
+
}
|
|
249
|
+
closeBracket++
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (bracketDepth !== 0) {
|
|
253
|
+
pos = openBracket + 1
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
closeBracket--
|
|
258
|
+
|
|
259
|
+
if (closeBracket + 1 < content.length && content[closeBracket + 1] === '(') {
|
|
260
|
+
let parenPos = closeBracket + 2
|
|
261
|
+
let isEmptyOrWhitespace = true
|
|
262
|
+
|
|
263
|
+
while (parenPos < content.length && content[parenPos] !== ')') {
|
|
264
|
+
if (!/^\s/.test(content[parenPos] ?? '')) {
|
|
265
|
+
isEmptyOrWhitespace = false
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
parenPos++
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (isEmptyOrWhitespace && parenPos < content.length && content[parenPos] === ')') {
|
|
272
|
+
positions.push(openBracket)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pos = closeBracket + 1
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return positions
|
|
280
|
+
}
|