@readme/cli 0.0.26 → 0.0.29
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 +2 -2
- package/package.json +5 -3
- package/src/cli.js +6 -1
- package/src/commands/components.js +288 -0
- package/src/commands/import.js +2456 -376
- package/src/commands/import.slug-planner.test.js +86 -0
- package/src/commands/import.test.js +136 -0
- package/src/commands/lint.js +32 -35
- package/src/commands/oas-sync.js +60 -75
- package/src/commands/pretty.js +314 -77
- package/src/prompts/index.js +187 -24
- package/src/utils/lint.js +33 -33
- package/src/utils/llms.js +224 -0
- package/src/utils/llms.test.js +257 -0
- package/src/utils/marketplace.js +208 -0
- package/src/utils/multi-spinner.js +71 -0
- package/src/utils/promise-pool.js +17 -0
- package/src/utils/url-segs.js +124 -0
- package/src/utils/url-segs.test.js +239 -0
- package/src/validators/components.js +1 -0
- package/src/validators/duplicates.js +6 -4
- package/src/validators/frontmatter.js +11 -0
- package/src/validators/oas-reference.js +1 -31
- package/src/validators/ordering.js +169 -83
- package/vendor/TOOLS.md +1 -1
- package/src/dev/.next/app-build-manifest.json +0 -20
- package/src/dev/.next/build-manifest.json +0 -31
- package/src/dev/.next/cache/.rscinfo +0 -1
- package/src/dev/.next/cache/next-devtools-config.json +0 -1
- package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/package.json +0 -1
- package/src/dev/.next/prerender-manifest.json +0 -11
- package/src/dev/.next/react-loadable-manifest.json +0 -1
- package/src/dev/.next/routes-manifest.json +0 -1
- package/src/dev/.next/server/app/[...slug]/page.js +0 -360
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app/page.js +0 -349
- package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app-paths-manifest.json +0 -3
- package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
- package/src/dev/.next/server/middleware-build-manifest.js +0 -33
- package/src/dev/.next/server/middleware-manifest.json +0 -32
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/src/dev/.next/server/middleware.js +0 -1113
- package/src/dev/.next/server/next-font-manifest.js +0 -1
- package/src/dev/.next/server/next-font-manifest.json +0 -1
- package/src/dev/.next/server/pages-manifest.json +0 -5
- package/src/dev/.next/server/server-reference-manifest.js +0 -1
- package/src/dev/.next/server/server-reference-manifest.json +0 -5
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
- package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
- package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
- package/src/dev/.next/server/webpack-runtime.js +0 -209
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
- package/src/dev/.next/static/chunks/app/layout.js +0 -171
- package/src/dev/.next/static/chunks/app/page.js +0 -28
- package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
- package/src/dev/.next/static/chunks/main-app.js +0 -1882
- package/src/dev/.next/static/chunks/polyfills.js +0 -1
- package/src/dev/.next/static/chunks/webpack.js +0 -1393
- package/src/dev/.next/static/css/app/layout.css +0 -559
- package/src/dev/.next/static/development/_buildManifest.js +0 -1
- package/src/dev/.next/static/development/_ssgManifest.js +0 -1
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
- package/src/dev/.next/trace +0 -21
- package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
- package/src/dev/.next/types/app/layout.ts +0 -84
- package/src/dev/.next/types/app/page.ts +0 -84
- package/src/dev/.next/types/cache-life.d.ts +0 -141
- package/src/dev/.next/types/package.json +0 -1
- package/src/dev/.next/types/routes.d.ts +0 -55
- package/src/validators/numbering.js +0 -136
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { ensureUniqueSlugs } from './import.js'
|
|
4
|
+
|
|
5
|
+
function assignedSlug(categories, page) {
|
|
6
|
+
return ensureUniqueSlugs(categories).get(page)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test('ensureUniqueSlugs: generic docs terminal paths fall back to introduction', () => {
|
|
10
|
+
for (const segment of ['doc', 'docs', 'documentation', 'documentations']) {
|
|
11
|
+
const page = { title: segment, url: `https://example.com/${segment}` }
|
|
12
|
+
assert.equal(
|
|
13
|
+
assignedSlug([{ title: 'Documentation', pages: [page] }], page),
|
|
14
|
+
'introduction',
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('ensureUniqueSlugs: terminal generic docs paths use introduction under non-generic categories', () => {
|
|
20
|
+
const page = { title: 'Docs', url: 'https://example.com/docs' }
|
|
21
|
+
|
|
22
|
+
assert.equal(
|
|
23
|
+
assignedSlug([{ title: 'Guides', pages: [page] }], page),
|
|
24
|
+
'introduction',
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('ensureUniqueSlugs: category segment disambiguates introduction collisions', () => {
|
|
29
|
+
const guidesPage = { title: 'Docs', url: 'https://example.com/docs' }
|
|
30
|
+
const apiPage = { title: 'Docs', url: 'https://example.com/docs' }
|
|
31
|
+
const slugs = ensureUniqueSlugs([
|
|
32
|
+
{ title: 'Guides', pages: [guidesPage] },
|
|
33
|
+
{ title: 'API', pages: [apiPage] },
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
assert.equal(slugs.get(guidesPage), 'guides-introduction')
|
|
37
|
+
assert.equal(slugs.get(apiPage), 'api-introduction')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('ensureUniqueSlugs: generic docs category does not prefix an available page slug', () => {
|
|
41
|
+
const page = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
|
|
42
|
+
|
|
43
|
+
assert.equal(
|
|
44
|
+
assignedSlug([{ title: 'Docs', pages: [page] }], page),
|
|
45
|
+
'getting-started',
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('ensureUniqueSlugs: generic docs categories use existing numeric suffix fallback for irreducible collisions', () => {
|
|
50
|
+
const first = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
|
|
51
|
+
const second = { title: 'Getting Started', url: 'https://example.com/documentation/getting-started' }
|
|
52
|
+
const slugs = ensureUniqueSlugs([
|
|
53
|
+
{ title: 'Docs', pages: [first] },
|
|
54
|
+
{ title: 'Documentation', pages: [second] },
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
assert.equal(slugs.get(first), 'getting-started')
|
|
58
|
+
assert.equal(slugs.get(second), 'getting-started-2')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('ensureUniqueSlugs: generic docs category is assigned before non-generic categories', () => {
|
|
62
|
+
const docsPage = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
|
|
63
|
+
const aiPage = { title: 'Getting Started', url: 'https://example.com/ai/getting-started' }
|
|
64
|
+
const slugs = ensureUniqueSlugs([
|
|
65
|
+
{ title: 'AI', pages: [aiPage] },
|
|
66
|
+
{ title: 'Docs', pages: [docsPage] },
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
assert.equal(slugs.get(docsPage), 'getting-started')
|
|
70
|
+
assert.equal(slugs.get(aiPage), 'ai-getting-started')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('ensureUniqueSlugs: non-generic docs paths keep current base slug behavior', () => {
|
|
74
|
+
const api = { title: 'API', url: 'https://example.com/docs/api' }
|
|
75
|
+
const gettingStarted = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
|
|
76
|
+
const docsApi = { title: 'Docs API', url: 'https://example.com/docs-api' }
|
|
77
|
+
const documentationSettings = { title: 'Documentation Settings', url: 'https://example.com/documentation-settings' }
|
|
78
|
+
const slugs = ensureUniqueSlugs([
|
|
79
|
+
{ title: 'Guides', pages: [api, gettingStarted, docsApi, documentationSettings] },
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
assert.equal(slugs.get(api), 'api')
|
|
83
|
+
assert.equal(slugs.get(gettingStarted), 'getting-started')
|
|
84
|
+
assert.equal(slugs.get(docsApi), 'docs-api')
|
|
85
|
+
assert.equal(slugs.get(documentationSettings), 'documentation-settings')
|
|
86
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { __test__ } from './import.js'
|
|
4
|
+
|
|
5
|
+
const originalFetch = globalThis.fetch
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
globalThis.fetch = originalFetch
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function mockLlmsFetch(files) {
|
|
12
|
+
const seen = []
|
|
13
|
+
globalThis.fetch = async (url) => {
|
|
14
|
+
const href = String(url)
|
|
15
|
+
seen.push(href)
|
|
16
|
+
if (!Object.hasOwn(files, href)) return new Response('', { status: 404 })
|
|
17
|
+
return new Response(files[href], {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { 'content-type': 'text/plain' },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
return seen
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mockRedirectFetch(files) {
|
|
26
|
+
globalThis.fetch = async (url) => {
|
|
27
|
+
const entry = files[String(url)]
|
|
28
|
+
if (!entry) return { ok: false, status: 404, url: String(url), text: async () => '' }
|
|
29
|
+
return { ok: true, status: 200, url: entry.finalUrl || String(url), text: async () => entry.body || '' }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mergedItems(merged) {
|
|
34
|
+
return merged.parsed.sections.flatMap((section) => section.items.map((item) => ({ text: item.text, url: item.url })))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('llms discovery and merge resolve nested files, dedupe canonical pages, and preserve distinct page states', async () => {
|
|
38
|
+
const seen = mockLlmsFetch({
|
|
39
|
+
'https://example.com/docs/llms.txt': `# Docs\n\n## Index\n- [Guide llms](guide/llms.txt)\n- [API llms](https://example.com/docs/api/llms.txt)\n`,
|
|
40
|
+
'https://example.com/docs/guide/llms.txt': `# Guide\n\n## Guides\n- [Rendered redirect](/getting-started)\n- [Rendered slash](/getting-started/)\n- [Rendered index](/getting-started/index.html)\n- [Markdown source](/getting-started.md)\n- [Search API](/search?product=api)\n- [Search SDK](/search?product=sdk)\n- [Search API Anchor](/search?product=api#overview)\n- [Hash only](#local)\n- [Mail](mailto:test@example.com)\n- [JS](javascript:alert(1))\n- [Malformed](http://%zz)\n`,
|
|
41
|
+
'https://example.com/docs/api/llms.txt': `# API\n\n## Reference\n- [Reference](/reference.md)\n- [OpenAPI](/openapi.yaml)\n`,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com/docs/'))
|
|
45
|
+
const merged = __test__.mergeValidHits(discovery.hits)
|
|
46
|
+
const items = mergedItems(merged)
|
|
47
|
+
const urls = items.map((item) => item.url)
|
|
48
|
+
|
|
49
|
+
assert(seen.includes('https://example.com/docs/guide/llms.txt'))
|
|
50
|
+
assert(seen.includes('https://example.com/docs/api/llms.txt'))
|
|
51
|
+
|
|
52
|
+
assert.equal(urls.filter((url) => url.includes('/docs/guide/getting-started')).length, 1)
|
|
53
|
+
assert(urls.includes('https://example.com/docs/guide/getting-started.md'))
|
|
54
|
+
|
|
55
|
+
assert(urls.includes('https://example.com/docs/guide/search?product=api'))
|
|
56
|
+
assert(urls.includes('https://example.com/docs/guide/search?product=sdk'))
|
|
57
|
+
assert.equal(urls.filter((url) => url.includes('/docs/guide/search?product=api')).length, 1)
|
|
58
|
+
|
|
59
|
+
assert(urls.includes('https://example.com/docs/api/reference.md'))
|
|
60
|
+
assert(urls.includes('https://example.com/openapi.yaml'))
|
|
61
|
+
|
|
62
|
+
assert(!urls.some((url) => url.includes('%zz')))
|
|
63
|
+
assert(!urls.some((url) => url.startsWith('mailto:') || url.startsWith('javascript:')))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('llms.txt fetched through a cross-origin redirect resolves links against the final URL', async () => {
|
|
67
|
+
mockRedirectFetch({
|
|
68
|
+
'https://docs.example.com/llms.txt': {
|
|
69
|
+
finalUrl: 'https://example.com/docs/llms.txt',
|
|
70
|
+
body: `# Docs\n\n## Guides\n- [Sample APIs](/docs/sample-apis.md)\n- [Policies](/policies.md)\n`,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const discovery = await __test__.discoverLlmsTxt(new URL('https://docs.example.com'))
|
|
75
|
+
const merged = __test__.mergeValidHits(discovery.hits)
|
|
76
|
+
const urls = mergedItems(merged).map((item) => item.url)
|
|
77
|
+
|
|
78
|
+
assert(urls.includes('https://example.com/docs/sample-apis.md'))
|
|
79
|
+
assert(urls.includes('https://example.com/docs/policies.md'))
|
|
80
|
+
assert(!urls.some((url) => url.startsWith('https://docs.example.com/')))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('nested llms.txt with its own redirect resolves links on its final path', async () => {
|
|
84
|
+
mockRedirectFetch({
|
|
85
|
+
'https://example.com/llms.txt': {
|
|
86
|
+
body: `# Root\n\n## Index\n- [Guide llms](/guide/llms.txt)\n`,
|
|
87
|
+
},
|
|
88
|
+
'https://example.com/guide/llms.txt': {
|
|
89
|
+
finalUrl: 'https://example.com/docs/guide/llms.txt',
|
|
90
|
+
body: `# Guide\n\n## Guides\n- [Intro](intro.md)\n- [Setup](setup.md)\n`,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com'))
|
|
95
|
+
const merged = __test__.mergeValidHits(discovery.hits)
|
|
96
|
+
const urls = mergedItems(merged).map((item) => item.url)
|
|
97
|
+
|
|
98
|
+
assert(urls.includes('https://example.com/docs/guide/intro.md'))
|
|
99
|
+
assert(urls.includes('https://example.com/docs/guide/setup.md'))
|
|
100
|
+
assert(!urls.includes('https://example.com/guide/intro.md'))
|
|
101
|
+
assert(!urls.includes('https://example.com/guide/setup.md'))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('resolveRedirectedSourceUrl adopts cross-origin path redirects', async () => {
|
|
105
|
+
mockRedirectFetch({
|
|
106
|
+
'https://docs.example.com/': { finalUrl: 'https://example.com/docs' },
|
|
107
|
+
})
|
|
108
|
+
const final = await __test__.resolveRedirectedSourceUrl(new URL('https://docs.example.com/'))
|
|
109
|
+
assert.equal(final?.href, 'https://example.com/docs')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('resolveRedirectedSourceUrl ignores trailing-slash redirects', async () => {
|
|
113
|
+
mockRedirectFetch({
|
|
114
|
+
'https://example.com/docs': { finalUrl: 'https://example.com/docs/' },
|
|
115
|
+
})
|
|
116
|
+
assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('resolveRedirectedSourceUrl ignores query and hash-only redirects', async () => {
|
|
120
|
+
mockRedirectFetch({
|
|
121
|
+
'https://example.com/docs': { finalUrl: 'https://example.com/docs?lang=en#top' },
|
|
122
|
+
})
|
|
123
|
+
assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('resolveRedirectedSourceUrl returns null on non-ok responses', async () => {
|
|
127
|
+
globalThis.fetch = async (url) => ({ ok: false, status: 500, url: String(url), text: async () => '' })
|
|
128
|
+
assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('resolveRedirectedSourceUrl returns null when fetch throws', async () => {
|
|
132
|
+
globalThis.fetch = async () => {
|
|
133
|
+
throw new Error('network down')
|
|
134
|
+
}
|
|
135
|
+
assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
|
|
136
|
+
})
|
package/src/commands/lint.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { createRequire } from 'node:module'
|
|
2
|
-
import { collectFiles, runValidators } from '../utils/lint.js'
|
|
3
|
-
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
|
|
4
|
-
import { printHeader, isAgenticCli } from '../utils/eyes.js'
|
|
5
|
-
import * as styles from '../utils/styles.js'
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { collectFiles, runValidators } from '../utils/lint.js'
|
|
3
|
+
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
|
|
4
|
+
import { printHeader, isAgenticCli } from '../utils/eyes.js'
|
|
5
|
+
import * as styles from '../utils/styles.js'
|
|
6
6
|
|
|
7
|
-
const require = createRequire(import.meta.url)
|
|
8
|
-
const pkg = require('../../package.json')
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
const pkg = require('../../package.json')
|
|
9
9
|
|
|
10
|
-
export const command = 'lint'
|
|
11
|
-
export const order = 1
|
|
12
|
-
export const aliases = ['validate']
|
|
13
|
-
export const category = 'Linting'
|
|
14
|
-
export const description = 'Lint and validate your ReadMe docs'
|
|
10
|
+
export const command = 'lint'
|
|
11
|
+
export const order = 1
|
|
12
|
+
export const aliases = ['validate']
|
|
13
|
+
export const category = 'Linting'
|
|
14
|
+
export const description = 'Lint and validate your ReadMe docs'
|
|
15
15
|
|
|
16
16
|
export function args(cmd) {
|
|
17
|
-
cmd.option('--json', 'Output results as JSON (for CI and automation)')
|
|
18
|
-
cmd.option('--github', 'Output a GitHub PR comment body as markdown')
|
|
19
|
-
cmd.option('--fix', 'Automatically fix issues where possible')
|
|
17
|
+
cmd.option('--json', 'Output results as JSON (for CI and automation)')
|
|
18
|
+
cmd.option('--github', 'Output a GitHub PR comment body as markdown')
|
|
19
|
+
cmd.option('--fix', 'Automatically fix issues where possible')
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -25,46 +25,43 @@ export function args(cmd) {
|
|
|
25
25
|
* exit on their own terms.
|
|
26
26
|
*
|
|
27
27
|
* @param {object} [opts]
|
|
28
|
-
* @param {string} [opts.cwd]
|
|
29
|
-
* @param {boolean} [opts.fix]
|
|
28
|
+
* @param {string} [opts.cwd] Repo root to lint (defaults to process.cwd()).
|
|
29
|
+
* @param {boolean} [opts.fix] Apply autofixes where validators support it.
|
|
30
|
+
* @param {boolean} [opts.nonInteractive] Skip any interactive prompts (e.g. rename confirmation). Fixes that would normally prompt are not applied.
|
|
30
31
|
* @param {(file: string) => void} [opts.onFile] Called per file before validation.
|
|
31
32
|
* @param {() => void} [opts.onBeforeCrossFile] Called once before cross-file checks run.
|
|
32
33
|
* @returns {Promise<{ gitRoot: string, files: string[], results: object[], hasErrors: boolean }>}
|
|
33
34
|
*/
|
|
34
|
-
export async function lint({ cwd, fix = false, onFile, onBeforeCrossFile } = {}) {
|
|
35
|
-
const gitRoot = cwd || process.cwd()
|
|
36
|
-
const files = collectFiles(gitRoot)
|
|
37
|
-
const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix })
|
|
38
|
-
const hasErrors = results.some((r) => r.severity !== 'warning')
|
|
39
|
-
return { gitRoot, files, results, hasErrors }
|
|
35
|
+
export async function lint({ cwd, fix = false, nonInteractive = false, onFile, onBeforeCrossFile } = {}) {
|
|
36
|
+
const gitRoot = cwd || process.cwd()
|
|
37
|
+
const files = collectFiles(gitRoot)
|
|
38
|
+
const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive })
|
|
39
|
+
const hasErrors = results.some((r) => r.severity !== 'warning')
|
|
40
|
+
return { gitRoot, files, results, hasErrors }
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export async function run(options, _cmd, ctx) {
|
|
43
|
-
const { gitRoot } = ctx
|
|
44
|
+
const { gitRoot } = ctx
|
|
44
45
|
|
|
45
46
|
if (!options.json && !options.github && !isAgenticCli()) {
|
|
46
|
-
printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
|
|
47
|
-
console.log()
|
|
47
|
+
printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
|
|
48
|
+
console.log()
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
const reporter = options.github
|
|
51
|
-
? createGithubReporter()
|
|
52
|
-
: options.json
|
|
53
|
-
? createJsonReporter()
|
|
54
|
-
: createHumanReporter();
|
|
51
|
+
const reporter = options.github ? createGithubReporter() : options.json ? createJsonReporter() : createHumanReporter()
|
|
55
52
|
|
|
56
53
|
const { files, results, hasErrors } = await lint({
|
|
57
54
|
cwd: gitRoot,
|
|
58
55
|
fix: options.fix,
|
|
59
56
|
onFile: (f) => reporter.onFile(f),
|
|
60
57
|
onBeforeCrossFile: () => reporter.pause(),
|
|
61
|
-
})
|
|
58
|
+
})
|
|
62
59
|
|
|
63
|
-
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
|
|
60
|
+
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
|
|
64
61
|
|
|
65
62
|
if (hasErrors) {
|
|
66
63
|
// Wait for stdout to flush (important when piped to a file), then exit
|
|
67
|
-
await new Promise((resolve) => process.stdout.write('', resolve))
|
|
68
|
-
process.exit(1)
|
|
64
|
+
await new Promise((resolve) => process.stdout.write('', resolve))
|
|
65
|
+
process.exit(1)
|
|
69
66
|
}
|
|
70
67
|
}
|
package/src/commands/oas-sync.js
CHANGED
|
@@ -14,12 +14,6 @@ export const description = 'Sync reference pages with OpenAPI specs';
|
|
|
14
14
|
|
|
15
15
|
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Check if this is a ReadMeConfig spec (internal ReadMe pages — skip title/excerpt updates).
|
|
19
|
-
*/
|
|
20
|
-
function isReadMeConfig(spec) {
|
|
21
|
-
return spec.info?.title === 'ReadMeConfig';
|
|
22
|
-
}
|
|
23
17
|
|
|
24
18
|
/**
|
|
25
19
|
* Find OAS files at the root of reference/ (JSON or YAML).
|
|
@@ -168,19 +162,31 @@ function removeFromOrder(orderPath, slug) {
|
|
|
168
162
|
}
|
|
169
163
|
}
|
|
170
164
|
|
|
171
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Spec-derived values (info.title, tags, operationIds) become directory and
|
|
167
|
+
* file names. Collapse path separators and dot-only names into a single safe
|
|
168
|
+
* segment so a crafted spec can't write outside reference/.
|
|
169
|
+
*/
|
|
170
|
+
function safeSegment(value, fallback) {
|
|
171
|
+
const segment = String(value).replace(/[/\\]/g, '-').trim();
|
|
172
|
+
return !segment || segment === '.' || segment === '..' ? fallback : segment;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isWithin(baseDir, target) {
|
|
176
|
+
const rel = path.relative(baseDir, target);
|
|
177
|
+
return (
|
|
178
|
+
rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildPageContent({ oasFilename, operationId }) {
|
|
172
183
|
const frontmatter = {
|
|
173
|
-
title: summary || operationId,
|
|
174
184
|
api: {
|
|
175
185
|
file: oasFilename,
|
|
176
186
|
operationId,
|
|
177
187
|
},
|
|
178
188
|
};
|
|
179
189
|
|
|
180
|
-
if (description) {
|
|
181
|
-
frontmatter.excerpt = description;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
190
|
return matter.stringify('', frontmatter);
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -189,8 +195,10 @@ function buildPageContent({ oasFilename, operationId, summary, description }) {
|
|
|
189
195
|
*/
|
|
190
196
|
function syncOneOas(refDir, oasFilename, spec) {
|
|
191
197
|
const specOps = extractOperations(spec);
|
|
192
|
-
const infoTitle =
|
|
193
|
-
|
|
198
|
+
const infoTitle = safeSegment(
|
|
199
|
+
spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
|
|
200
|
+
'api',
|
|
201
|
+
);
|
|
194
202
|
|
|
195
203
|
const existingPages = collectExistingPages(refDir).filter(
|
|
196
204
|
(p) => p.data.api.file === oasFilename,
|
|
@@ -201,7 +209,7 @@ function syncOneOas(refDir, oasFilename, spec) {
|
|
|
201
209
|
pagesByOpId.set(page.data.api.operationId, page);
|
|
202
210
|
}
|
|
203
211
|
|
|
204
|
-
const changes = { added: [], deleted: [],
|
|
212
|
+
const changes = { added: [], deleted: [], skipped: [] };
|
|
205
213
|
|
|
206
214
|
// Deletes: pages referencing operations that no longer exist.
|
|
207
215
|
for (const [opId, page] of pagesByOpId) {
|
|
@@ -216,61 +224,32 @@ function syncOneOas(refDir, oasFilename, spec) {
|
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
|
|
219
|
-
// Adds
|
|
227
|
+
// Adds: operations with no page yet. Title/excerpt are owned by the OAS spec
|
|
228
|
+
// at render time, so generated pages carry only the api reference.
|
|
220
229
|
for (const [opId, op] of specOps) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
addToOrder(path.join(pageDir, '_order.yaml'), opId);
|
|
238
|
-
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
|
|
239
|
-
|
|
240
|
-
changes.added.push(path.relative(refDir, pagePath));
|
|
241
|
-
} else if (!skipUpdates) {
|
|
242
|
-
const expectedTitle = op.summary || opId;
|
|
243
|
-
const expectedExcerpt = op.description || null;
|
|
244
|
-
const currentTitle = existing.data.title;
|
|
245
|
-
const currentExcerpt = existing.data.excerpt || null;
|
|
246
|
-
|
|
247
|
-
const titleChanged = currentTitle !== expectedTitle;
|
|
248
|
-
const excerptChanged = currentExcerpt !== expectedExcerpt;
|
|
249
|
-
|
|
250
|
-
if (titleChanged || excerptChanged) {
|
|
251
|
-
const updated = { ...existing.data };
|
|
252
|
-
const updateDetails = [];
|
|
230
|
+
if (pagesByOpId.has(opId)) continue;
|
|
231
|
+
|
|
232
|
+
const tag = safeSegment(op.tag || 'Other', 'Other');
|
|
233
|
+
const slug = safeSegment(opId, 'operation');
|
|
234
|
+
const pageDir = path.join(refDir, infoTitle, tag);
|
|
235
|
+
const pagePath = path.join(pageDir, `${slug}.md`);
|
|
236
|
+
|
|
237
|
+
// Never overwrite an existing file: it belongs to a manual page, another
|
|
238
|
+
// spec, or a different operation whose sanitized name collides with this
|
|
239
|
+
// one. Skipping (rather than clobbering) keeps repeated syncs stable.
|
|
240
|
+
if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
|
|
241
|
+
changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: opId });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
fs.mkdirSync(pageDir, { recursive: true });
|
|
253
245
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
updateDetails.push('title');
|
|
257
|
-
}
|
|
258
|
-
if (excerptChanged) {
|
|
259
|
-
if (expectedExcerpt) {
|
|
260
|
-
updated.excerpt = expectedExcerpt;
|
|
261
|
-
} else {
|
|
262
|
-
delete updated.excerpt;
|
|
263
|
-
}
|
|
264
|
-
updateDetails.push('excerpt');
|
|
265
|
-
}
|
|
246
|
+
const content = buildPageContent({ oasFilename, operationId: opId });
|
|
247
|
+
fs.writeFileSync(pagePath, content);
|
|
266
248
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
fs.writeFileSync(existing.filePath, newContent);
|
|
249
|
+
addToOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
250
|
+
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
|
|
270
251
|
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
}
|
|
252
|
+
changes.added.push(path.relative(refDir, pagePath));
|
|
274
253
|
}
|
|
275
254
|
|
|
276
255
|
return changes;
|
|
@@ -285,7 +264,7 @@ function syncOneOas(refDir, oasFilename, spec) {
|
|
|
285
264
|
*
|
|
286
265
|
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
287
266
|
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
288
|
-
* changes: { added: string[], deleted: string[]
|
|
267
|
+
* changes: { added: string[], deleted: string[] } }>}
|
|
289
268
|
* Returns null if there's no reference/ dir or no specs.
|
|
290
269
|
*/
|
|
291
270
|
export function syncOas(input) {
|
|
@@ -325,11 +304,12 @@ export async function run(_options, _cmd, ctx) {
|
|
|
325
304
|
|
|
326
305
|
let totalAdded = 0;
|
|
327
306
|
let totalDeleted = 0;
|
|
328
|
-
let
|
|
307
|
+
let totalSkipped = 0;
|
|
329
308
|
|
|
330
309
|
for (const { filename, spec, opCount, changes } of results) {
|
|
331
310
|
const title = spec.info?.title || filename;
|
|
332
|
-
const hasChanges =
|
|
311
|
+
const hasChanges =
|
|
312
|
+
changes.added.length + changes.deleted.length + changes.skipped.length > 0;
|
|
333
313
|
|
|
334
314
|
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
335
315
|
console.log();
|
|
@@ -345,20 +325,25 @@ export async function run(_options, _cmd, ctx) {
|
|
|
345
325
|
for (const file of changes.deleted) {
|
|
346
326
|
console.log(` ${styles.err('−')} Deleted ${file}`);
|
|
347
327
|
}
|
|
348
|
-
for (const file of changes.
|
|
349
|
-
console.log(
|
|
328
|
+
for (const { path: file, operationId } of changes.skipped) {
|
|
329
|
+
console.log(
|
|
330
|
+
` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
|
|
331
|
+
);
|
|
350
332
|
}
|
|
351
333
|
|
|
352
334
|
totalAdded += changes.added.length;
|
|
353
335
|
totalDeleted += changes.deleted.length;
|
|
354
|
-
|
|
336
|
+
totalSkipped += changes.skipped.length;
|
|
355
337
|
}
|
|
356
338
|
|
|
357
339
|
console.log();
|
|
358
|
-
const total = totalAdded + totalDeleted
|
|
359
|
-
|
|
340
|
+
const total = totalAdded + totalDeleted;
|
|
341
|
+
const skippedNote = totalSkipped > 0 ? `, ${totalSkipped} skipped` : '';
|
|
342
|
+
if (total === 0 && totalSkipped === 0) {
|
|
360
343
|
styles.ok('Reference pages are already in sync.');
|
|
344
|
+
} else if (total === 0) {
|
|
345
|
+
styles.warning(`No pages synced; ${totalSkipped} skipped (destination already exists).`);
|
|
361
346
|
} else {
|
|
362
|
-
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted
|
|
347
|
+
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${skippedNote}.`);
|
|
363
348
|
}
|
|
364
349
|
}
|