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