@readme/cli 0.0.30 → 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 +37 -0
- package/package.json +7 -2
- package/src/commands/import.js +128 -73
- package/src/commands/import.staging.test.js +74 -2
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-bundle.js +85 -0
- package/src/commands/oas-sync.js +501 -48
- package/src/commands/oas-validate.js +23 -0
- package/src/gha-pre.js +29 -0
- package/src/gha.js +104 -0
- package/src/utils/gha-output.js +32 -0
- package/src/utils/lint.js +30 -15
- package/src/validators/oas-reference.js +24 -3
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.
|
|
@@ -50,6 +60,33 @@ Starts a local dev server so you can preview your docs as you write them. Watche
|
|
|
50
60
|
npx @readme/cli dev
|
|
51
61
|
```
|
|
52
62
|
|
|
63
|
+
## GitHub Action
|
|
64
|
+
|
|
65
|
+
This repo also publishes itself as a GitHub Action, so a workflow can run `lint`, `oas:validate`, or `oas:sync` directly, pinned to a version, without an `npx` install step on every run:
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
- uses: readmeio/cli@v1
|
|
69
|
+
with:
|
|
70
|
+
readme: oas:sync
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `readme` input is the same command you'd run on the CLI, e.g. `lint`, `oas:validate --dereference`, or `oas:sync`. Each command exits non-zero on a real problem, same as running it locally, and publishes its results as step outputs — `has-errors`, `skipped-count`, `total-errors`, and more (see [`action.yml`](./action.yml) for the full list) — so a later step can check them directly instead of parsing log output:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
- name: Sync reference pages from OpenAPI specs
|
|
77
|
+
id: sync
|
|
78
|
+
uses: readmeio/cli@v1
|
|
79
|
+
with:
|
|
80
|
+
readme: oas:sync
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
- if: steps.sync.outputs.skipped-count != '0'
|
|
85
|
+
run: echo "::error::oas:sync skipped a page — see the sync step above."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Note: the first run of a workflow using this Action installs one extra dependency (`@readme/markdown`) behind the scenes before your command runs — this is expected, and only adds a few seconds.
|
|
89
|
+
|
|
53
90
|
## Feedback
|
|
54
91
|
|
|
55
92
|
If something doesn't work right, or if you have suggestions, please [open an issue](https://github.com/readmeio/cli/issues). We're actively building this and want to hear from you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readme/cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The ReadMe CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node bin/readme.js",
|
|
28
|
-
"test": "node --test src/**/*.test.js test/*.test.js"
|
|
28
|
+
"test": "node --test src/**/*.test.js test/*.test.js",
|
|
29
|
+
"build:gha": "ncc build src/gha.js -o dist-gha --external @readme/markdown"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=22"
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@anthropic-ai/claude-agent-sdk": "^0.3.207",
|
|
45
46
|
"@readme/markdown": "^13.3.0",
|
|
47
|
+
"@readme/openapi-parser": "^5.5.0",
|
|
46
48
|
"@readmeio/git-format": "git+https://github.com/readmeio/git-format.git",
|
|
47
49
|
"ajv": "^8.18.0",
|
|
48
50
|
"chalk": "^5.4.1",
|
|
@@ -56,5 +58,8 @@
|
|
|
56
58
|
"react": "^18.3.1",
|
|
57
59
|
"react-dom": "^18.3.1",
|
|
58
60
|
"update-notifier": "^7.3.1"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@vercel/ncc": "^0.45.0"
|
|
59
64
|
}
|
|
60
65
|
}
|
package/src/commands/import.js
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
446
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
2745
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
4949
|
-
*
|
|
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
|
-
*
|
|
4952
|
-
*
|
|
4953
|
-
*
|
|
4954
|
-
|
|
4955
|
-
|
|
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
|
-
*
|
|
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
|
|
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, '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4999
|
-
|
|
5000
|
-
const
|
|
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, `${
|
|
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((
|
|
5068
|
+
const filtered = Array.isArray(entries) ? entries.filter((entry) => entry !== 'Changelog') : []
|
|
5016
5069
|
if (filtered.length > 0) {
|
|
5017
|
-
fs.writeFileSync(docsOrderPath, filtered.map((
|
|
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
|
|
5034
|
-
//
|
|
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 {
|
|
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(
|
package/src/commands/lint.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { createRequire } from 'node:module'
|
|
2
1
|
import { collectFiles, runValidators } from '../utils/lint.js'
|
|
3
2
|
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
|
|
4
3
|
import { printHeader, isAgenticCli } from '../utils/eyes.js'
|
|
5
4
|
import * as styles from '../utils/styles.js'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const pkg = require('../../package.json')
|
|
5
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js'
|
|
6
|
+
import pkg from '../../package.json' with { type: 'json' }
|
|
9
7
|
|
|
10
8
|
export const command = 'lint'
|
|
11
9
|
export const order = 1
|
|
@@ -59,6 +57,8 @@ export async function run(options, _cmd, ctx) {
|
|
|
59
57
|
|
|
60
58
|
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
|
|
61
59
|
|
|
60
|
+
writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
|
|
61
|
+
|
|
62
62
|
if (hasErrors) {
|
|
63
63
|
// Wait for stdout to flush (important when piped to a file), then exit
|
|
64
64
|
await new Promise((resolve) => process.stdout.write('', resolve))
|
|
@@ -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
|
+
}
|