@readme/cli 0.0.31 → 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 CHANGED
@@ -24,6 +24,16 @@ Syncs your `reference/` directory with your OpenAPI spec. It'll create, update,
24
24
  npx @readme/cli oas:sync
25
25
  ```
26
26
 
27
+ ### OpenAPI Bundle
28
+
29
+ Combines a spec that's split across multiple files (external `$ref`s to partials) into a single self-contained file. Handy when your spec is assembled at build time and you want to drop the result into `reference/` from CI.
30
+
31
+ ```bash
32
+ npx @readme/cli oas:bundle path/to/openapi.yaml --out reference/api.json
33
+ ```
34
+
35
+ Without `--out`, the bundled spec is printed to stdout. The output format follows the `--out` extension (`.yaml`/`.yml` for YAML, JSON otherwise). This command works from any directory, so you can run it in your API's source repo.
36
+
27
37
  ### Import
28
38
 
29
39
  Imports content from an external folder and converts it into ReadMe's format using Claude. Great for migrating existing docs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readme/cli",
3
- "version": "0.0.31",
3
+ "version": "0.1.0",
4
4
  "description": "The ReadMe CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
46
46
  "@readme/markdown": "^13.3.0",
47
+ "@readme/openapi-parser": "^5.5.0",
47
48
  "@readmeio/git-format": "git+https://github.com/readmeio/git-format.git",
48
49
  "ajv": "^8.18.0",
49
50
  "chalk": "^5.4.1",
@@ -30,6 +30,13 @@ export const skipBootstrap = true
30
30
 
31
31
  export const DEFAULT_MODEL = 'claude-sonnet-5'
32
32
 
33
+ export function getClaudeReasoningOptions(model) {
34
+ if (model === 'sonnet' || model === DEFAULT_MODEL || model?.startsWith('claude-sonnet-5-')) {
35
+ return { thinking: { type: 'adaptive' }, effort: 'medium' }
36
+ }
37
+ return {}
38
+ }
39
+
33
40
  export function args(cmd) {
34
41
  cmd.requiredOption(
35
42
  '--source <url-or-file...>',
@@ -39,10 +46,6 @@ export function args(cmd) {
39
46
  cmd.addOption(new Option('-m, --model <name>', 'Claude model alias: haiku, sonnet, opus').choices(['haiku', 'sonnet', 'opus']).default(DEFAULT_MODEL))
40
47
  cmd.option('--firecrawl-key <key>', 'Firecrawl API key (or set FIRECRAWL_API_KEY env var) — enables JS-rendered sidebar scraping')
41
48
  cmd.option('--skip-api-reference', 'Drop pages routed to the API Reference / reference dir. Use when uploading the OAS spec separately.')
42
- cmd.option(
43
- '--separate-changelog',
44
- 'Move changelog / release-notes pages into a top-level changelog/ directory instead of docs/Changelog/. Note: changelog/ is outside the git-format schema, so it will not lint or sync to ReadMe.',
45
- )
46
49
  // Internal dev-only flag: skip the zip, keep staging, and boot the dev server
47
50
  // against it for quick visual previews. Hidden from --help.
48
51
  cmd.addOption(new Option('--test').hideHelp())
@@ -96,7 +99,6 @@ const OAS_SOURCE_CONFIDENCE = ['llms', 'mintlify', 'probe', 'html', 'sitemap', '
96
99
  * @param {string} [options.model] Claude model alias: 'haiku' | 'sonnet' | 'opus'. Defaults to 'sonnet'.
97
100
  * @param {string} [options.firecrawlKey] Firecrawl API key (falls back to FIRECRAWL_API_KEY env var).
98
101
  * @param {boolean} [options.skipApiReference] Drop pages routed to the API Reference dir.
99
- * @param {boolean} [options.separateChangelog] Move changelog pages into a top-level `changelog/` dir instead of `docs/Changelog/`.
100
102
  * @param {boolean} [options.test] Skip the zip, keep staging, and boot the dev server.
101
103
  * @param {boolean} [options.debug] Dump intermediate pipeline artifacts to a tmp dir.
102
104
  * @returns {Promise<{ source: 'url' | 'oas', outputZip?: string, stagingDir?: string, fileCount: number, duration: number, phases: Array<{ label: string, ms: number }> }>}
@@ -211,17 +213,11 @@ export async function importDocs(options) {
211
213
  stageOrganized(organized, stagingDir, { skipApiReference: !!options.skipApiReference, llmsPaths }),
212
214
  )
213
215
 
214
- // Changelog pages stage into docs/Changelog/ (a schema-valid docs
215
- // category). With --separate-changelog, relocate them afterwards into a
216
- // top-level changelog/ dir — done as a post-staging move so the staged
217
- // tree is a valid git-format layout up to this point.
218
- if (options.separateChangelog) {
219
- const movedChangelog = relocateChangelogDir(stagingDir)
220
- if (movedChangelog > 0) {
221
- styles.info(
222
- `Moved ${styles.bold(String(movedChangelog))} changelog page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('changelog/')} (--separate-changelog).`,
223
- )
224
- }
216
+ const movedChangelog = finalizeChangelogs(stagingDir)
217
+ if (movedChangelog > 0) {
218
+ styles.info(
219
+ `Staged ${styles.bold(String(movedChangelog))} changelog page${movedChangelog === 1 ? '' : 's'} in ${styles.bold('changelogs/')}.`,
220
+ )
225
221
  }
226
222
 
227
223
  const landingTitle =
@@ -442,9 +438,8 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
442
438
 
443
439
  // Pre-extract changelog pages so AI / URL clustering / section-direct paths
444
440
  // never see them as candidates for organization. We re-attach them as a
445
- // dedicated Changelog category once `organized` is finalized below — that
446
- // guarantees they route to docs/Changelog/ (or to changelog/ with
447
- // --separate-changelog) regardless of which organize path ran. Done here,
441
+ // dedicated Changelog category once `organized` is finalized below. Done
442
+ // here,
448
443
  // before knownUrls is derived, so every downstream consumer operates on the
449
444
  // already-changelog-free view.
450
445
  let extractedChangelog = []
@@ -657,8 +652,7 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
657
652
  }
658
653
 
659
654
  // Same idea for changelog / release-notes pages — pull any whose URL
660
- // carries a changelog-style segment into one "Changelog" category. It
661
- // routes to docs/Changelog/ unless --separate-changelog relocates it.
655
+ // carries a changelog-style segment into one "Changelog" category.
662
656
  const movedChangelog = reclassifyChangelogPages(scraped)
663
657
  if (movedChangelog > 0) {
664
658
  styles.info(`Moved ${styles.bold(String(movedChangelog))} page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('Changelog')} based on URL path.`)
@@ -1210,6 +1204,7 @@ export async function runAgent({ userPrompt, systemPrompt, cwd, model }) {
1210
1204
  canUseTool: makeStagingGuard(cwd),
1211
1205
  ...(systemPrompt ? { systemPrompt } : {}),
1212
1206
  ...(model ? { model } : {}),
1207
+ ...getClaudeReasoningOptions(model),
1213
1208
  },
1214
1209
  })) {
1215
1210
  if (message.type === 'assistant' && message.message?.content) {
@@ -1222,7 +1217,8 @@ export async function runAgent({ userPrompt, systemPrompt, cwd, model }) {
1222
1217
  }
1223
1218
  } else if (message.type === 'result') {
1224
1219
  if (message.subtype && message.subtype !== 'success') {
1225
- const err = new Error(`Agent result subtype=${message.subtype}${message.error?.message ? ': ' + message.error.message : ''}`)
1220
+ const details = claudeResultDetails(message)
1221
+ const err = new Error(`Agent result subtype=${message.subtype}${details ? ` — ${details}` : ''}`)
1226
1222
  err.subtype = message.subtype
1227
1223
  err.result = message
1228
1224
  throw err
@@ -2741,9 +2737,8 @@ function injectChangelogCategory(organized, items) {
2741
2737
  /**
2742
2738
  * Sweep `/changelog/*`, `/release-notes/*` and `/releases/*` pages into one
2743
2739
  * "Changelog" category, wherever the site's sidebar happened to place them.
2744
- * That category routes to `docs/Changelog/`; `--separate-changelog` later
2745
- * relocates it to a top-level `changelog/` dir. Returns the number of pages
2746
- * relocated.
2740
+ * It is finalized as canonical top-level `changelogs/` output. Returns the
2741
+ * number of pages relocated.
2747
2742
  */
2748
2743
  function reclassifyChangelogPages(scraped) {
2749
2744
  return reclassifyPagesByUrlSegment(scraped, {
@@ -3701,6 +3696,34 @@ async function organizeFromScratch(parsed, model) {
3701
3696
  return { title: raw.title, categories: expandedCategories }
3702
3697
  }
3703
3698
 
3699
+ function claudeResultDetails(message) {
3700
+ const details = []
3701
+ if (message.api_error_status != null) details.push(`API status ${message.api_error_status}`)
3702
+ if (Array.isArray(message.errors)) details.push(...message.errors.filter((error) => typeof error === 'string' && error.trim()))
3703
+ if (typeof message.result === 'string' && message.result.trim()) {
3704
+ const result = message.result.replace(/\s+/g, ' ').trim()
3705
+ details.push(result.length > 1000 ? `${result.slice(0, 1000)}…` : result)
3706
+ }
3707
+ return details.join('; ')
3708
+ }
3709
+
3710
+ export function getStructuredOutput(message) {
3711
+ const details = claudeResultDetails(message)
3712
+ if (message.subtype === 'error_max_structured_output_retries') {
3713
+ throw new Error(
3714
+ 'Claude could not produce output matching the schema after retries. ' +
3715
+ `Likely hit the model's output limit — try --model sonnet.${details ? ` ${details}` : ''}`,
3716
+ )
3717
+ }
3718
+ if (message.subtype && message.subtype !== 'success') {
3719
+ throw new Error(`Claude failed: ${message.subtype}${details ? ` — ${details}` : ''}`)
3720
+ }
3721
+ if (!message.structured_output || typeof message.structured_output !== 'object') {
3722
+ throw new Error(`Claude returned no structured output${details ? ` — ${details}` : ''}`)
3723
+ }
3724
+ return message.structured_output
3725
+ }
3726
+
3704
3727
  /**
3705
3728
  * Shared Claude call for "send a prompt, get schema-validated JSON back".
3706
3729
  * Logs the prompts so we can see what went in, and runs a heartbeat so silent
@@ -3731,19 +3754,11 @@ async function runJsonQuery({ systemPrompt, userPrompt, model, schema }) {
3731
3754
  allowedTools: [],
3732
3755
  outputFormat: { type: 'json_schema', schema },
3733
3756
  ...(model ? { model } : {}),
3757
+ ...getClaudeReasoningOptions(model),
3734
3758
  },
3735
3759
  })) {
3736
3760
  if (message.type === 'result') {
3737
- if (message.subtype === 'error_max_structured_output_retries') {
3738
- throw new Error(
3739
- 'Claude could not produce output matching the schema after retries. ' +
3740
- 'Likely hit the model\'s output limit — try --model sonnet.',
3741
- )
3742
- }
3743
- if (message.subtype && message.subtype !== 'success') {
3744
- throw new Error(`Claude failed: ${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
3745
- }
3746
- structured = message.structured_output
3761
+ structured = getStructuredOutput(message)
3747
3762
  break
3748
3763
  }
3749
3764
  }
@@ -4777,7 +4792,8 @@ function stageOrganized(organized, stagingDir, opts = {}) {
4777
4792
  for (const cat of eligibleCategories) collapseRedundantLayers(cat)
4778
4793
 
4779
4794
  // Slug names must be unique
4780
- const slugFor = ensureUniqueSlugs(eligibleCategories)
4795
+ const slugFor = opts.slugFor || ensureUniqueSlugs(eligibleCategories)
4796
+ disambiguateChangelogSiblingSlugs(eligibleCategories, slugFor)
4781
4797
 
4782
4798
  const labelFor = (p) => {
4783
4799
  if (p.url) return p.url
@@ -4945,31 +4961,80 @@ function countPagesDeep(pages) {
4945
4961
  }
4946
4962
 
4947
4963
  /**
4948
- * Post-staging relocation for `--separate-changelog`: move the staged
4949
- * `docs/Changelog/` category up to a top-level `changelog/` directory.
4964
+ * Make changelog sibling slugs safe to write on case-insensitive filesystems.
4965
+ * Guides retain their directory hierarchy, while changelogs are later flattened
4966
+ * into one directory; the final flattening pass handles cross-level collisions.
4950
4967
  *
4951
- * Done as a filesystem move *after* staging (rather than routing there
4952
- * directly) so the staged tree is a valid git-format layout up to this point,
4953
- * and the one schema-divergent step lives in a single, clearly-named place.
4954
- * Note `changelog/` is not part of the git-format schema — it won't lint or
4955
- * sync to ReadMe until git-format adds first-class changelog support.
4968
+ * @param {Array<{ title?: string, pages?: object[] }>} categories Organized import categories.
4969
+ * @param {Map<object, string>} slugFor Slugs keyed by page; mutated in place.
4970
+ * @returns {void}
4971
+ */
4972
+ function disambiguateChangelogSiblingSlugs(categories, slugFor) {
4973
+ const changelogPages = []
4974
+ for (const category of categories) {
4975
+ const route = routeCategory(category.title)
4976
+ if (route.topDir !== 'docs' || route.subDir !== 'Changelog') continue
4977
+ changelogPages.push(...(category.pages || []))
4978
+ }
4979
+
4980
+ const planSiblings = (pages) => {
4981
+ const plannedPages = pages.filter((page) => slugFor.has(page))
4982
+ const plannedSlugs = allocateChangelogFilenames(
4983
+ plannedPages.map((page) => ({ ancestors: [], slug: slugFor.get(page) })),
4984
+ )
4985
+ for (const [index, page] of plannedPages.entries()) slugFor.set(page, plannedSlugs[index])
4986
+ for (const page of pages) planSiblings(page.pages || [])
4987
+ }
4988
+ planSiblings(changelogPages)
4989
+ }
4990
+
4991
+ /**
4992
+ * Allocate case-insensitively unique filenames for a flat changelog directory.
4956
4993
  *
4957
- * Returns the number of changelog pages moved (0 if there were none).
4994
+ * @param {Array<{ ancestors: string[], slug: string }>} pages Changelog paths to flatten.
4995
+ * @returns {string[]} Filenames in the same order as `pages`, without extensions.
4958
4996
  */
4959
- function relocateChangelogDir(stagingDir) {
4997
+ function allocateChangelogFilenames(pages) {
4998
+ const baseSlugs = pages.map((page) => [...page.ancestors, page.slug].join('-'))
4999
+ const baseNames = baseSlugs.map((slug) => slug.toLowerCase())
5000
+ const counts = new Map()
5001
+ for (const name of baseNames) counts.set(name, (counts.get(name) || 0) + 1)
5002
+
5003
+ const reserved = new Set(baseNames)
5004
+ const allocated = new Set()
5005
+ return baseSlugs.map((baseSlug, index) => {
5006
+ const baseName = baseNames[index]
5007
+ if (counts.get(baseName) === 1 || !allocated.has(baseName)) {
5008
+ allocated.add(baseName)
5009
+ return baseSlug
5010
+ }
5011
+
5012
+ for (let suffix = 2; ; suffix++) {
5013
+ const filename = `${baseSlug}-${suffix}`
5014
+ const name = filename.toLowerCase()
5015
+ if (reserved.has(name) || allocated.has(name)) continue
5016
+ allocated.add(name)
5017
+ return filename
5018
+ }
5019
+ })
5020
+ }
5021
+
5022
+ /**
5023
+ * Convert the staged changelog category into the runner's canonical, flat
5024
+ * `changelogs/` skeleton directory. This happens for every import so neither
5025
+ * command-line nor programmatic callers can produce the legacy layout.
5026
+ */
5027
+ function finalizeChangelogs(stagingDir) {
4960
5028
  const srcDir = path.join(stagingDir, 'docs', 'Changelog')
4961
5029
  if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0
4962
5030
 
4963
- const dstDir = path.join(stagingDir, 'changelog')
5031
+ const dstDir = path.join(stagingDir, 'changelogs')
4964
5032
  fs.renameSync(srcDir, dstDir)
4965
5033
 
4966
- // Collect every real x-import page from the (possibly nested) tree.
4967
- // Synthetic group-only stubs (no x-import — they exist only to render an
4968
- // empty parent in the sidebar) are dropped: the flat layout has no concept
4969
- // of containers, so they'd just be dead frontmatter.
4970
5034
  const pages = []
4971
5035
  const walk = (dir, ancestors) => {
4972
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
5036
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
5037
+ for (const entry of entries) {
4973
5038
  const full = path.join(dir, entry.name)
4974
5039
  if (entry.isDirectory()) {
4975
5040
  walk(full, [...ancestors, entry.name])
@@ -4977,44 +5042,32 @@ function relocateChangelogDir(stagingDir) {
4977
5042
  }
4978
5043
  if (!entry.name.endsWith('.md')) continue
4979
5044
  const parsed = matter(fs.readFileSync(full, 'utf-8'))
4980
- if (!parsed.data || !parsed.data['x-import']) continue
5045
+ if (!parsed.data?.['x-import']) continue
4981
5046
  if (entry.name === 'index.md' && ancestors.length > 0) {
4982
5047
  pages.push({ ancestors: ancestors.slice(0, -1), slug: ancestors.at(-1), content: parsed.content, data: parsed.data })
4983
5048
  continue
4984
5049
  }
4985
- const slug = entry.name.replace(/\.md$/, '')
4986
- pages.push({ ancestors, slug, content: parsed.content, data: parsed.data })
5050
+ pages.push({ ancestors, slug: entry.name.replace(/\.md$/, ''), content: parsed.content, data: parsed.data })
4987
5051
  }
4988
5052
  }
4989
5053
  walk(dstDir, [])
4990
5054
 
4991
- // Wipe the nested tree and rewrite it flat. Filename concatenates the
4992
- // ancestor folder slugs with the leaf slug so siblings share a prefix —
4993
- // alphabetical order then naturally matches authored reading order, and
4994
- // names are unique across the (formerly nested) tree.
4995
5055
  fs.rmSync(dstDir, { recursive: true, force: true })
4996
5056
  fs.mkdirSync(dstDir, { recursive: true })
4997
5057
 
4998
- const flatSlugs = []
4999
- for (const p of pages) {
5000
- const flatSlug = [...p.ancestors, p.slug].join('-')
5001
- const frontmatter = { ...p.data }
5058
+ for (const [index, filename] of allocateChangelogFilenames(pages).entries()) {
5059
+ const page = pages[index]
5060
+ const frontmatter = { ...page.data }
5002
5061
  delete frontmatter.icon
5003
- fs.writeFileSync(path.join(dstDir, `${flatSlug}.md`), matter.stringify(p.content, frontmatter))
5004
- flatSlugs.push(flatSlug)
5062
+ fs.writeFileSync(path.join(dstDir, `${filename}.md`), matter.stringify(page.content, frontmatter))
5005
5063
  }
5006
5064
 
5007
- flatSlugs.sort()
5008
- fs.writeFileSync(path.join(dstDir, '_order.yaml'), flatSlugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
5009
-
5010
- // docs/_order.yaml lists the docs category subfolders — drop the moved one
5011
- // so the docs sidebar doesn't point at a folder that's no longer there.
5012
5065
  const docsOrderPath = path.join(stagingDir, 'docs', '_order.yaml')
5013
5066
  if (fs.existsSync(docsOrderPath)) {
5014
5067
  const entries = yamlRequire().load(fs.readFileSync(docsOrderPath, 'utf-8'))
5015
- const filtered = Array.isArray(entries) ? entries.filter((e) => e !== 'Changelog') : []
5068
+ const filtered = Array.isArray(entries) ? entries.filter((entry) => entry !== 'Changelog') : []
5016
5069
  if (filtered.length > 0) {
5017
- fs.writeFileSync(docsOrderPath, filtered.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
5070
+ fs.writeFileSync(docsOrderPath, filtered.map((entry) => `- ${yamlSafeSlug(entry)}`).join('\n') + '\n')
5018
5071
  } else {
5019
5072
  fs.rmSync(docsOrderPath)
5020
5073
  }
@@ -5030,8 +5083,8 @@ function relocateChangelogDir(stagingDir) {
5030
5083
  function routeCategory(title) {
5031
5084
  const t = (title || '').trim()
5032
5085
  if (/^(api[ -]?reference|reference|api|endpoints?)$/i.test(t)) return { topDir: 'reference', subDir: null }
5033
- // Changelog-ish categories normalize to one docs/Changelog/ folder so the
5034
- // --separate-changelog relocation has a single, predictable source dir.
5086
+ // Changelog categories initially share a staging folder before they are
5087
+ // finalized as canonical top-level changelogs/ output.
5035
5088
  if (/^(changelog|change[ -]?log|release[ -]?notes?|releases?|what'?s[ -]?new)$/i.test(t)) return { topDir: 'docs', subDir: 'Changelog' }
5036
5089
  if (/^(recipes?|cookbook|tutorials?|how[ -]?tos?)$/i.test(t)) return { topDir: 'recipes', subDir: null }
5037
5090
  if (/^(custom[ -]?pages?|landing( page)?s?)$/i.test(t)) return { topDir: 'custom_pages', subDir: null }
@@ -5327,6 +5380,8 @@ export const __test__ = {
5327
5380
  isDocsUnderApiPath,
5328
5381
  filterUrlPagesTree,
5329
5382
  stageOrganized,
5383
+ finalizeChangelogs,
5384
+ allocateChangelogFilenames,
5330
5385
  urlIsApiReference,
5331
5386
  urlIsChangelog,
5332
5387
  reclassifyReferencePages,
@@ -4,9 +4,10 @@ import fs from 'node:fs'
4
4
  import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import matter from 'gray-matter'
7
- import { __test__ } from './import.js'
7
+ import { Command } from 'commander'
8
+ import { args, __test__ } from './import.js'
8
9
 
9
- const { stageOrganized } = __test__
10
+ const { stageOrganized, finalizeChangelogs, allocateChangelogFilenames } = __test__
10
11
 
11
12
  const originalLog = console.log
12
13
  afterEach(() => {
@@ -134,6 +135,77 @@ test('stageOrganized namespaces nested category folders that would otherwise sha
134
135
  })
135
136
  })
136
137
 
138
+ test('finalizeChangelogs emits flat canonical changelogs without an order file and suffixes case-insensitive collisions', () => {
139
+ withStaging((stagingDir) => {
140
+ const changelogDir = path.join(stagingDir, 'docs', 'Changelog')
141
+ fs.mkdirSync(path.join(changelogDir, 'foo'), { recursive: true })
142
+ fs.writeFileSync(
143
+ path.join(changelogDir, 'foo', 'Bar.md'),
144
+ matter.stringify('', { title: 'Nested', icon: 'fa-book', 'x-import': 'https://example.com/nested' }),
145
+ )
146
+ fs.writeFileSync(
147
+ path.join(changelogDir, 'foo-bar.md'),
148
+ matter.stringify('', { title: 'Root', icon: 'fa-book', 'x-import': 'https://example.com/root' }),
149
+ )
150
+ fs.writeFileSync(
151
+ path.join(changelogDir, 'unchanged.md'),
152
+ matter.stringify('', { title: 'Unchanged', icon: 'fa-book', 'x-import': 'https://example.com/unchanged' }),
153
+ )
154
+ fs.writeFileSync(path.join(stagingDir, 'docs', '_order.yaml'), '- Changelog\n')
155
+
156
+ assert.equal(finalizeChangelogs(stagingDir), 3)
157
+
158
+ const outputDir = path.join(stagingDir, 'changelogs')
159
+ assert.deepEqual(fs.readdirSync(outputDir).sort(), ['foo-Bar.md', 'foo-bar-2.md', 'unchanged.md'])
160
+ assert.equal(fs.existsSync(path.join(outputDir, '_order.yaml')), false)
161
+ assert.equal(fs.existsSync(path.join(stagingDir, 'changelog')), false)
162
+ assert.equal(fs.existsSync(path.join(stagingDir, 'docs', 'Changelog')), false)
163
+ assert.equal(fs.existsSync(path.join(stagingDir, 'docs', '_order.yaml')), false)
164
+ assert.equal(readFm(path.join(outputDir, 'foo-Bar.md')).icon, undefined)
165
+ assert.equal(readFm(path.join(outputDir, 'foo-Bar.md'))['x-import'], 'https://example.com/nested')
166
+ assert.equal(readFm(path.join(outputDir, 'foo-bar-2.md'))['x-import'], 'https://example.com/root')
167
+ assert.equal(readFm(path.join(outputDir, 'unchanged.md'))['x-import'], 'https://example.com/unchanged')
168
+ })
169
+ })
170
+
171
+ test('stageOrganized retains case-only changelog names before finalization', () => {
172
+ withStaging((stagingDir) => {
173
+ const upper = { title: 'Upper A', url: 'https://example.com/A' }
174
+ const lower = { title: 'Lower a', url: 'https://example.com/a' }
175
+ const independent = { title: 'Independent a-2', url: 'https://example.com/a-2' }
176
+ stageOrganized(
177
+ { categories: [{ title: 'Changelog', pages: [upper, lower, independent] }] },
178
+ stagingDir,
179
+ { slugFor: new Map([[upper, 'A'], [lower, 'a'], [independent, 'a-2']]) },
180
+ )
181
+ assert.equal(finalizeChangelogs(stagingDir), 3)
182
+
183
+ const outputDir = path.join(stagingDir, 'changelogs')
184
+ assert.deepEqual(fs.readdirSync(outputDir).sort(), ['A.md', 'a-2.md', 'a-3.md'])
185
+ assert.equal(readFm(path.join(outputDir, 'A.md')).title, 'Upper A')
186
+ assert.equal(readFm(path.join(outputDir, 'a-3.md')).title, 'Lower a')
187
+ assert.equal(readFm(path.join(outputDir, 'a-2.md')).title, 'Independent a-2')
188
+ })
189
+ })
190
+
191
+ test('allocateChangelogFilenames preserves an independent numeric-suffix filename', () => {
192
+ assert.deepEqual(
193
+ allocateChangelogFilenames([
194
+ { ancestors: [], slug: 'A' },
195
+ { ancestors: [], slug: 'a' },
196
+ { ancestors: [], slug: 'a-2' },
197
+ ]),
198
+ ['A', 'a-3', 'a-2'],
199
+ )
200
+ })
201
+
202
+ test('import command has no conditional changelog layout option', () => {
203
+ const cmd = new Command()
204
+ args(cmd)
205
+
206
+ assert.equal(cmd.options.some((option) => option.long === '--separate-changelog'), false)
207
+ })
208
+
137
209
  test('stageOrganized quotes YAML-unsafe slugs in _order.yaml', () => {
138
210
  withStaging((stagingDir) => {
139
211
  stageOrganized(
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { bundle } from '@readme/openapi-parser';
5
+ import * as styles from '../utils/styles.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const yaml = require('js-yaml');
9
+
10
+ export const command = 'oas:bundle';
11
+ export const order = 4;
12
+ export const category = 'OAS Tooling';
13
+ export const description = 'Bundle a multi-file OpenAPI spec into a single file';
14
+ // The input spec usually lives outside a ReadMe docs repo (e.g. the API's
15
+ // source repo in CI), so don't require a version branch or docs/ dir.
16
+ export const skipBootstrap = true;
17
+
18
+ export function args(cmd) {
19
+ cmd
20
+ .argument('<spec>', 'Path to the root OpenAPI spec file (JSON or YAML)')
21
+ .option('-o, --out <path>', 'Write the bundled spec to this file instead of stdout');
22
+ }
23
+
24
+ /** Resolve external $refs into one document with only internal (#/...) pointers. */
25
+ export async function bundleOas({ file, cwd } = {}) {
26
+ const specPath = path.resolve(cwd || process.cwd(), file);
27
+ if (!fs.existsSync(specPath)) {
28
+ throw new Error(`Spec file not found: ${specPath}`);
29
+ }
30
+ // Pass the path, not contents: openapi-parser then resolves relative $refs
31
+ // against the file's directory. oas-normalize reads first and loses that.
32
+ return bundle(specPath);
33
+ }
34
+
35
+ export function formatForPath(outPath) {
36
+ return /\.ya?ml$/i.test(outPath) ? 'yaml' : 'json';
37
+ }
38
+
39
+ export function serializeSpec(spec, format) {
40
+ if (format === 'yaml') return yaml.dump(spec, { noRefs: true, lineWidth: -1 });
41
+ return `${JSON.stringify(spec, null, 2)}\n`;
42
+ }
43
+
44
+ /** Bundle `file` and write it to `out` (format follows the extension). */
45
+ export async function writeBundle({ file, out, cwd } = {}) {
46
+ const root = cwd || process.cwd();
47
+ const spec = await bundleOas({ file, cwd: root });
48
+ const outPath = path.resolve(root, out);
49
+ const format = formatForPath(outPath);
50
+
51
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
52
+ fs.writeFileSync(outPath, serializeSpec(spec, format));
53
+
54
+ return { spec, outPath, format };
55
+ }
56
+
57
+ export async function run(specArg, options) {
58
+ try {
59
+ if (options.out) {
60
+ const { spec, outPath } = await writeBundle({ file: specArg, out: options.out });
61
+ const opCount = countOperations(spec);
62
+ const rel = path.relative(process.cwd(), outPath);
63
+ const shown = rel && !rel.startsWith('..') ? rel : outPath;
64
+ styles.ok(`Bundled ${styles.bold(spec.info?.title || specArg)} (${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'}) → ${shown}`);
65
+ return;
66
+ }
67
+
68
+ const spec = await bundleOas({ file: specArg });
69
+ process.stdout.write(serializeSpec(spec, formatForPath(specArg)));
70
+ } catch (err) {
71
+ styles.error(`Bundle failed: ${err.message || String(err)}`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
77
+
78
+ function countOperations(spec) {
79
+ let n = 0;
80
+ for (const item of Object.values(spec.paths || {})) {
81
+ if (!item || typeof item !== 'object') continue;
82
+ for (const key of Object.keys(item)) if (HTTP_METHODS.has(key)) n += 1;
83
+ }
84
+ return n;
85
+ }