@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,313 @@
1
+ /**
2
+ * @bfra.me/doc-sync/generators/code-example-formatter - Syntax-highlighted code blocks from JSDoc examples
3
+ */
4
+
5
+ import type {ExportedFunction, ExportedType, PackageAPI} from '../types'
6
+
7
+ /**
8
+ * Options for code example formatting
9
+ */
10
+ export interface CodeExampleOptions {
11
+ /** Default language for code blocks */
12
+ readonly defaultLanguage?: string
13
+ /** Whether to add function name as title */
14
+ readonly includeTitle?: boolean
15
+ /** Maximum number of examples per function */
16
+ readonly maxExamplesPerFunction?: number
17
+ /** Whether to wrap examples in collapsible sections */
18
+ readonly collapsible?: boolean
19
+ }
20
+
21
+ /**
22
+ * Default options for code example formatting
23
+ */
24
+ const DEFAULT_OPTIONS: Required<CodeExampleOptions> = {
25
+ defaultLanguage: 'typescript',
26
+ includeTitle: true,
27
+ maxExamplesPerFunction: 3,
28
+ collapsible: false,
29
+ }
30
+
31
+ export function formatCodeExamples(api: PackageAPI, options: CodeExampleOptions = {}): string {
32
+ const mergedOptions = {...DEFAULT_OPTIONS, ...options}
33
+ const examples: string[] = []
34
+
35
+ for (const fn of api.functions) {
36
+ const fnExamples = formatFunctionExamples(fn, mergedOptions)
37
+ if (fnExamples.length > 0) {
38
+ examples.push(fnExamples)
39
+ }
40
+ }
41
+
42
+ for (const type of api.types) {
43
+ const typeExamples = formatTypeExamples(type, mergedOptions)
44
+ if (typeExamples.length > 0) {
45
+ examples.push(typeExamples)
46
+ }
47
+ }
48
+
49
+ return examples.join('\n\n')
50
+ }
51
+
52
+ export function formatFunctionExamples(
53
+ fn: ExportedFunction,
54
+ options: CodeExampleOptions = {},
55
+ ): string {
56
+ const mergedOptions = {...DEFAULT_OPTIONS, ...options}
57
+
58
+ if (fn.jsdoc?.examples === undefined || fn.jsdoc.examples.length === 0) {
59
+ return ''
60
+ }
61
+
62
+ const exampleCount = Math.min(fn.jsdoc.examples.length, mergedOptions.maxExamplesPerFunction)
63
+ const examples = fn.jsdoc.examples.slice(0, exampleCount)
64
+
65
+ const sections: string[] = []
66
+
67
+ if (mergedOptions.includeTitle) {
68
+ sections.push(`### ${fn.name}`)
69
+ sections.push('')
70
+ }
71
+
72
+ for (const [index, example] of examples.entries()) {
73
+ const formattedExample = formatCodeBlock(example, mergedOptions.defaultLanguage)
74
+
75
+ if (mergedOptions.collapsible && examples.length > 1) {
76
+ sections.push(`<details>`)
77
+ sections.push(`<summary>Example ${index + 1}</summary>`)
78
+ sections.push('')
79
+ sections.push(formattedExample)
80
+ sections.push('')
81
+ sections.push(`</details>`)
82
+ } else {
83
+ sections.push(formattedExample)
84
+ }
85
+
86
+ if (index < examples.length - 1) {
87
+ sections.push('')
88
+ }
89
+ }
90
+
91
+ return sections.join('\n')
92
+ }
93
+
94
+ export function formatTypeExamples(type: ExportedType, options: CodeExampleOptions = {}): string {
95
+ const mergedOptions = {...DEFAULT_OPTIONS, ...options}
96
+
97
+ if (type.jsdoc?.examples === undefined || type.jsdoc.examples.length === 0) {
98
+ return ''
99
+ }
100
+
101
+ const exampleCount = Math.min(type.jsdoc.examples.length, mergedOptions.maxExamplesPerFunction)
102
+ const examples = type.jsdoc.examples.slice(0, exampleCount)
103
+
104
+ const sections: string[] = []
105
+
106
+ if (mergedOptions.includeTitle) {
107
+ sections.push(`### ${type.name}`)
108
+ sections.push('')
109
+ }
110
+
111
+ for (const [index, example] of examples.entries()) {
112
+ const formattedExample = formatCodeBlock(example, mergedOptions.defaultLanguage)
113
+
114
+ if (mergedOptions.collapsible && examples.length > 1) {
115
+ sections.push(`<details>`)
116
+ sections.push(`<summary>Example ${index + 1}</summary>`)
117
+ sections.push('')
118
+ sections.push(formattedExample)
119
+ sections.push('')
120
+ sections.push(`</details>`)
121
+ } else {
122
+ sections.push(formattedExample)
123
+ }
124
+
125
+ if (index < examples.length - 1) {
126
+ sections.push('')
127
+ }
128
+ }
129
+
130
+ return sections.join('\n')
131
+ }
132
+
133
+ export function formatCodeBlock(code: string, language = 'typescript'): string {
134
+ const cleanedCode = cleanCodeExample(code)
135
+ const detectedLanguage = detectLanguage(cleanedCode) ?? language
136
+
137
+ return `\`\`\`${detectedLanguage}\n${cleanedCode}\n\`\`\``
138
+ }
139
+
140
+ export function cleanCodeExample(code: string): string {
141
+ let cleaned = code.trim()
142
+
143
+ if (cleaned.startsWith('```')) {
144
+ const lines = cleaned.split('\n')
145
+ lines.shift()
146
+ if (lines.at(-1)?.trim() === '```') {
147
+ lines.pop()
148
+ }
149
+ cleaned = lines.join('\n')
150
+ }
151
+
152
+ cleaned = cleaned
153
+ .split('\n')
154
+ .map(line => {
155
+ if (line.startsWith(' * ')) {
156
+ return line.slice(3)
157
+ }
158
+ if (line.startsWith('* ')) {
159
+ return line.slice(2)
160
+ }
161
+ return line
162
+ })
163
+ .join('\n')
164
+
165
+ const lines = cleaned.split('\n')
166
+ const nonEmptyLines = lines.filter(line => line.trim().length > 0)
167
+
168
+ if (nonEmptyLines.length === 0) {
169
+ return cleaned
170
+ }
171
+
172
+ const minIndent = Math.min(
173
+ ...nonEmptyLines.map(line => {
174
+ const match = /^(\s*)/.exec(line)
175
+ return match?.[1]?.length ?? 0
176
+ }),
177
+ )
178
+
179
+ if (minIndent > 0) {
180
+ cleaned = lines.map(line => line.slice(minIndent)).join('\n')
181
+ }
182
+
183
+ return cleaned.trim()
184
+ }
185
+
186
+ export function detectLanguage(code: string): string | undefined {
187
+ if (code.includes('import ') || code.includes('export ') || code.includes(': ')) {
188
+ if (code.includes('<') && code.includes('/>')) {
189
+ return 'tsx'
190
+ }
191
+ return 'typescript'
192
+ }
193
+
194
+ if (code.includes('const ') || code.includes('let ') || code.includes('function ')) {
195
+ if (code.includes('<') && code.includes('/>')) {
196
+ return 'jsx'
197
+ }
198
+ return 'javascript'
199
+ }
200
+
201
+ if (code.startsWith('{') || code.startsWith('[')) {
202
+ return 'json'
203
+ }
204
+
205
+ if (code.includes('$ ') || code.includes('npm ') || code.includes('pnpm ')) {
206
+ return 'bash'
207
+ }
208
+
209
+ return undefined
210
+ }
211
+
212
+ export function formatUsageExample(fn: ExportedFunction): string {
213
+ const params = fn.parameters.map(p => {
214
+ if (p.optional && p.defaultValue !== undefined) {
215
+ return `${p.name} = ${p.defaultValue}`
216
+ }
217
+ if (p.optional) {
218
+ return `${p.name}?`
219
+ }
220
+ return p.name
221
+ })
222
+
223
+ const call = `${fn.name}(${params.join(', ')})`
224
+
225
+ if (fn.isAsync) {
226
+ return `const result = await ${call}`
227
+ }
228
+
229
+ if (fn.returnType !== 'void') {
230
+ return `const result = ${call}`
231
+ }
232
+
233
+ return call
234
+ }
235
+
236
+ export function groupExamplesByCategory(
237
+ api: PackageAPI,
238
+ ): Map<string, {functions: ExportedFunction[]; types: ExportedType[]}> {
239
+ const categories = new Map<string, {functions: ExportedFunction[]; types: ExportedType[]}>()
240
+
241
+ for (const fn of api.functions) {
242
+ if (fn.jsdoc?.examples === undefined || fn.jsdoc.examples.length === 0) {
243
+ continue
244
+ }
245
+
246
+ const category = inferCategory(fn.name)
247
+ const existing = categories.get(category) ?? {functions: [], types: []}
248
+ existing.functions.push(fn)
249
+ categories.set(category, existing)
250
+ }
251
+
252
+ for (const type of api.types) {
253
+ if (type.jsdoc?.examples === undefined || type.jsdoc.examples.length === 0) {
254
+ continue
255
+ }
256
+
257
+ const category = inferCategory(type.name)
258
+ const existing = categories.get(category) ?? {functions: [], types: []}
259
+ existing.types.push(type)
260
+ categories.set(category, existing)
261
+ }
262
+
263
+ return categories
264
+ }
265
+
266
+ function inferCategory(name: string): string {
267
+ const prefixes = ['create', 'get', 'set', 'is', 'has', 'parse', 'format', 'validate', 'build']
268
+
269
+ for (const prefix of prefixes) {
270
+ if (name.toLowerCase().startsWith(prefix)) {
271
+ return capitalizeFirst(prefix)
272
+ }
273
+ }
274
+
275
+ return 'General'
276
+ }
277
+
278
+ function capitalizeFirst(str: string): string {
279
+ if (str.length === 0) return str
280
+ return str.charAt(0).toUpperCase() + str.slice(1)
281
+ }
282
+
283
+ export function formatGroupedExamples(api: PackageAPI, options: CodeExampleOptions = {}): string {
284
+ const categories = groupExamplesByCategory(api)
285
+ const sections: string[] = []
286
+
287
+ for (const [category, items] of categories) {
288
+ sections.push(`### ${category}`)
289
+ sections.push('')
290
+
291
+ for (const fn of items.functions) {
292
+ const examples = formatFunctionExamples(fn, {...options, includeTitle: false})
293
+ if (examples.length > 0) {
294
+ sections.push(`#### \`${fn.name}\``)
295
+ sections.push('')
296
+ sections.push(examples)
297
+ sections.push('')
298
+ }
299
+ }
300
+
301
+ for (const type of items.types) {
302
+ const examples = formatTypeExamples(type, {...options, includeTitle: false})
303
+ if (examples.length > 0) {
304
+ sections.push(`#### \`${type.name}\``)
305
+ sections.push('')
306
+ sections.push(examples)
307
+ sections.push('')
308
+ }
309
+ }
310
+ }
311
+
312
+ return sections.join('\n').trim()
313
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * @bfra.me/doc-sync/generators/component-mapper - Map content sections to Starlight components
3
+ */
4
+
5
+ import type {PackageInfo, ReadmeContent, ReadmeSection} from '../types'
6
+
7
+ import {sanitizeAttribute} from '../utils/sanitization'
8
+
9
+ /**
10
+ * Configuration for Starlight component mapping
11
+ */
12
+ export interface ComponentMapperConfig {
13
+ /** Section titles that should use CardGrid for features */
14
+ readonly featureSections?: readonly string[]
15
+ /** Section titles that should use Tabs for installation */
16
+ readonly tabSections?: readonly string[]
17
+ /** Section titles to exclude from output */
18
+ readonly excludeSections?: readonly string[]
19
+ /** Custom component mappings by section title */
20
+ readonly customMappings?: Record<string, SectionMapper>
21
+ }
22
+
23
+ /**
24
+ * A function that maps section content to MDX output
25
+ */
26
+ export type SectionMapper = (section: ReadmeSection, info: PackageInfo) => string
27
+
28
+ /**
29
+ * Default configuration for component mapping
30
+ */
31
+ const DEFAULT_CONFIG: Required<Omit<ComponentMapperConfig, 'customMappings'>> = {
32
+ featureSections: ['features', 'highlights', 'key features'],
33
+ tabSections: ['installation', 'getting started', 'setup'],
34
+ excludeSections: ['license', 'contributing', 'contributors', 'changelog'],
35
+ }
36
+
37
+ export function mapToStarlightComponents(
38
+ readme: ReadmeContent,
39
+ packageInfo: PackageInfo,
40
+ config: ComponentMapperConfig = {},
41
+ ): string {
42
+ const mergedConfig = {...DEFAULT_CONFIG, ...config}
43
+ const sections: string[] = []
44
+
45
+ if (readme.preamble !== undefined && readme.preamble.trim().length > 0) {
46
+ sections.push(readme.preamble)
47
+ sections.push('')
48
+ }
49
+
50
+ for (const section of readme.sections) {
51
+ const output = mapSection(section, packageInfo, mergedConfig)
52
+ if (output.length > 0) {
53
+ sections.push(output)
54
+ sections.push('')
55
+ }
56
+ }
57
+
58
+ return sections.join('\n').trim()
59
+ }
60
+
61
+ function mapSection(
62
+ section: ReadmeSection,
63
+ packageInfo: PackageInfo,
64
+ config: Required<Omit<ComponentMapperConfig, 'customMappings'>> &
65
+ Pick<ComponentMapperConfig, 'customMappings'>,
66
+ ): string {
67
+ const normalizedHeading = section.heading.toLowerCase().trim()
68
+
69
+ if (isExcludedSection(normalizedHeading, config.excludeSections, packageInfo)) {
70
+ return ''
71
+ }
72
+
73
+ if (config.customMappings?.[normalizedHeading] !== undefined) {
74
+ return config.customMappings[normalizedHeading](section, packageInfo)
75
+ }
76
+
77
+ if (isFeatureSection(normalizedHeading, config.featureSections)) {
78
+ return mapFeatureSection(section)
79
+ }
80
+
81
+ if (isInstallationSection(normalizedHeading, config.tabSections)) {
82
+ return mapInstallationSection(section, packageInfo)
83
+ }
84
+
85
+ return mapDefaultSection(section, packageInfo, config)
86
+ }
87
+
88
+ function isExcludedSection(
89
+ heading: string,
90
+ excludeSections: readonly string[],
91
+ packageInfo: PackageInfo,
92
+ ): boolean {
93
+ if (excludeSections.some(excluded => heading.includes(excluded.toLowerCase()))) {
94
+ return true
95
+ }
96
+
97
+ if (packageInfo.docsConfig?.excludeSections !== undefined) {
98
+ return packageInfo.docsConfig.excludeSections.some(excluded =>
99
+ heading.includes(excluded.toLowerCase()),
100
+ )
101
+ }
102
+
103
+ return false
104
+ }
105
+
106
+ function isFeatureSection(heading: string, featureSections: readonly string[]): boolean {
107
+ return featureSections.some(feature => heading.includes(feature.toLowerCase()))
108
+ }
109
+
110
+ function isInstallationSection(heading: string, tabSections: readonly string[]): boolean {
111
+ return tabSections.some(tab => heading.includes(tab.toLowerCase()))
112
+ }
113
+
114
+ function mapFeatureSection(section: ReadmeSection): string {
115
+ const lines: string[] = []
116
+
117
+ lines.push(`## ${section.heading}`)
118
+ lines.push('')
119
+
120
+ const features = extractFeatureItems(section.content)
121
+
122
+ if (features.length > 0) {
123
+ lines.push('<CardGrid>')
124
+ for (const feature of features) {
125
+ const icon = inferFeatureIcon(feature.title, feature.emoji)
126
+ lines.push(` <Card title="${sanitizeAttribute(feature.title)}" icon="${icon}">`)
127
+ lines.push(` ${feature.description}`)
128
+ lines.push(' </Card>')
129
+ }
130
+ lines.push('</CardGrid>')
131
+ } else {
132
+ lines.push(section.content)
133
+ }
134
+
135
+ return lines.join('\n')
136
+ }
137
+
138
+ interface FeatureItem {
139
+ readonly title: string
140
+ readonly description: string
141
+ readonly emoji?: string
142
+ }
143
+
144
+ const EMOJI_TO_ICON_MAP: Record<string, string> = {
145
+ '📝': 'document',
146
+ '📖': 'document',
147
+ '🔄': 'seti:refresh',
148
+ '👁️': 'eye-open',
149
+ '✨': 'star',
150
+ '🛡️': 'shield',
151
+ '🎨': 'seti:settings',
152
+ '🔒': 'lock',
153
+ } as const
154
+
155
+ function extractFeatureItems(content: string): FeatureItem[] {
156
+ const features: FeatureItem[] = []
157
+
158
+ // Uses specific character classes to prevent catastrophic backtracking
159
+ // Pattern breakdown:
160
+ // ^[-*] - list marker at start of line
161
+ // ([^*\r\n]*) - prefix (no asterisks or newlines)
162
+ // \*\*([^*\r\n]+)\*\* - bold text (must contain non-asterisk chars)
163
+ // [ ]?[:—–-][ ]? - separator with optional spaces
164
+ // ([^\r\n]+) - description (rest of line, no newlines)
165
+ const boldListPattern = /^[-*] ([^*\r\n]*)\*\*([^*\r\n]+)\*\* ?[:—–-] ?([^\r\n]+)$/gm
166
+
167
+ for (const match of content.matchAll(boldListPattern)) {
168
+ if (match[2] !== undefined && match[3] !== undefined) {
169
+ const prefix = match[1] ?? ''
170
+ const emoji = extractEmoji(prefix)
171
+
172
+ features.push({
173
+ title: match[2].trim(),
174
+ description: match[3].trim(),
175
+ emoji,
176
+ })
177
+ }
178
+ }
179
+
180
+ if (features.length === 0) {
181
+ const dashSeparatedPattern = /^[-*] ([^—–\n]+)[—–] (.+)$/gm
182
+
183
+ for (const match of content.matchAll(dashSeparatedPattern)) {
184
+ if (match[1] !== undefined && match[2] !== undefined) {
185
+ const rawTitle = match[1].trim()
186
+ const description = match[2].trim()
187
+ const emoji = extractEmoji(rawTitle)
188
+ const title = rawTitle.replace(
189
+ /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/u,
190
+ '',
191
+ )
192
+
193
+ if (title.length > 0 && description.length > 0 && description.length < 300) {
194
+ features.push({title, description, emoji})
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ // Tertiary fallback pattern: any bold text followed by description
201
+ if (features.length === 0) {
202
+ const boldPattern = /\*\*([^*]+)\*\*/g
203
+ const boldMatches = [...content.matchAll(boldPattern)]
204
+
205
+ for (const match of boldMatches) {
206
+ if (match[1] !== undefined) {
207
+ const title = match[1].trim()
208
+ const afterMatch = content.slice((match.index ?? 0) + match[0].length)
209
+ const description = afterMatch.split(/\n\n|\*\*/)[0]?.trim() ?? ''
210
+
211
+ if (description.length > 0 && description.length < 200) {
212
+ features.push({title, description})
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ return features
219
+ }
220
+
221
+ function extractEmoji(text: string): string | undefined {
222
+ const emojiMatch = text.match(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u)
223
+ return emojiMatch?.[0]
224
+ }
225
+
226
+ function inferFeatureIcon(title: string, emoji?: string): string {
227
+ if (emoji !== undefined && EMOJI_TO_ICON_MAP[emoji] !== undefined) {
228
+ return EMOJI_TO_ICON_MAP[emoji]
229
+ }
230
+
231
+ const lowerTitle = title.toLowerCase()
232
+
233
+ const keywordIconMap: Record<string, string> = {
234
+ typescript: 'approve-check',
235
+ type: 'approve-check',
236
+ test: 'approve-check',
237
+ fast: 'rocket',
238
+ speed: 'rocket',
239
+ performance: 'rocket',
240
+ tree: 'seti:plan',
241
+ modular: 'puzzle',
242
+ plugin: 'puzzle',
243
+ extensible: 'puzzle',
244
+ document: 'document',
245
+ doc: 'document',
246
+ config: 'setting',
247
+ setting: 'setting',
248
+ star: 'star',
249
+ feature: 'star',
250
+ safe: 'approve-check',
251
+ secure: 'approve-check',
252
+ error: 'warning',
253
+ async: 'rocket',
254
+ result: 'approve-check',
255
+ function: 'puzzle',
256
+ watch: 'eye-open',
257
+ incremental: 'seti:refresh',
258
+ preservation: 'shield',
259
+ mdx: 'document',
260
+ parsing: 'document',
261
+ }
262
+
263
+ for (const [keyword, icon] of Object.entries(keywordIconMap)) {
264
+ if (lowerTitle.includes(keyword)) {
265
+ return icon
266
+ }
267
+ }
268
+
269
+ return 'star'
270
+ }
271
+
272
+ function mapInstallationSection(section: ReadmeSection, packageInfo: PackageInfo): string {
273
+ const lines: string[] = []
274
+
275
+ lines.push(`## ${section.heading}`)
276
+ lines.push('')
277
+
278
+ // Use word boundaries to avoid matching 'npm' within 'pnpm'
279
+ const hasPnpm = /\bpnpm\b/.test(section.content)
280
+ const hasNpm = /\bnpm\b/.test(section.content)
281
+ const hasYarn = /\byarn\b/.test(section.content)
282
+ const packageManagerCount = [hasPnpm, hasNpm, hasYarn].filter(Boolean).length
283
+
284
+ const hasExistingTabs = section.content.includes('```bash')
285
+ const hasMultiplePackageManagers = packageManagerCount >= 2
286
+
287
+ if (hasExistingTabs && hasMultiplePackageManagers) {
288
+ lines.push(section.content)
289
+ } else {
290
+ lines.push(generateInstallTabs(packageInfo.name))
291
+
292
+ const contentWithoutInstall = removeInstallCommand(section.content)
293
+ if (contentWithoutInstall.trim().length > 0) {
294
+ lines.push('')
295
+ lines.push(contentWithoutInstall)
296
+ }
297
+ }
298
+
299
+ return lines.join('\n')
300
+ }
301
+
302
+ export function generateInstallTabs(packageName: string): string {
303
+ return `<Tabs>
304
+ <TabItem label="pnpm">
305
+ \`\`\`bash
306
+ pnpm add ${packageName}
307
+ \`\`\`
308
+ </TabItem>
309
+ <TabItem label="npm">
310
+ \`\`\`bash
311
+ npm install ${packageName}
312
+ \`\`\`
313
+ </TabItem>
314
+ <TabItem label="yarn">
315
+ \`\`\`bash
316
+ yarn add ${packageName}
317
+ \`\`\`
318
+ </TabItem>
319
+ </Tabs>`
320
+ }
321
+
322
+ function removeInstallCommand(content: string): string {
323
+ return content
324
+ .replaceAll(/```(?:bash|sh)\n(?:npm|pnpm|yarn)\s+(?:install|add|i)\s+\S+\n```/g, '')
325
+ .trim()
326
+ }
327
+
328
+ function mapDefaultSection(
329
+ section: ReadmeSection,
330
+ packageInfo: PackageInfo,
331
+ config: Required<Omit<ComponentMapperConfig, 'customMappings'>> &
332
+ Pick<ComponentMapperConfig, 'customMappings'>,
333
+ ): string {
334
+ const lines: string[] = []
335
+
336
+ const headingLevel = '#'.repeat(Math.min(section.level + 1, 6))
337
+ lines.push(`${headingLevel} ${section.heading}`)
338
+ lines.push('')
339
+ lines.push(section.content)
340
+
341
+ for (const child of section.children) {
342
+ const childOutput = mapSection(child, packageInfo, config)
343
+ if (childOutput.length > 0) {
344
+ lines.push('')
345
+ lines.push(childOutput)
346
+ }
347
+ }
348
+
349
+ return lines.join('\n')
350
+ }
351
+
352
+ export function createBadge(
353
+ text: string,
354
+ variant: 'note' | 'tip' | 'caution' | 'danger' | 'success' | 'default' = 'note',
355
+ ): string {
356
+ return `<Badge text="${sanitizeAttribute(text)}" variant="${variant}" />`
357
+ }
358
+
359
+ export function createCard(title: string, content: string, icon?: string): string {
360
+ const iconAttr = icon === undefined ? '' : ` icon="${icon}"`
361
+ return `<Card title="${sanitizeAttribute(title)}"${iconAttr}>
362
+ ${content}
363
+ </Card>`
364
+ }
365
+
366
+ export function createCardGrid(cards: {title: string; content: string; icon?: string}[]): string {
367
+ const cardElements = cards.map(card => createCard(card.title, card.content, card.icon))
368
+ return `<CardGrid>
369
+ ${cardElements.map(c => ` ${c}`).join('\n')}
370
+ </CardGrid>`
371
+ }
372
+
373
+ export function createTabs(items: {label: string; content: string}[]): string {
374
+ const tabItems = items.map(
375
+ item => ` <TabItem label="${sanitizeAttribute(item.label)}">
376
+ ${item.content}
377
+ </TabItem>`,
378
+ )
379
+
380
+ return `<Tabs>
381
+ ${tabItems.join('\n')}
382
+ </Tabs>`
383
+ }