@readme/cli 0.0.27 → 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/package.json +3 -3
- package/src/cli.js +1 -1
- package/src/commands/import.js +2134 -430
- package/src/commands/import.slug-planner.test.js +86 -0
- package/src/commands/import.test.js +136 -0
- package/src/commands/oas-sync.js +60 -75
- package/src/prompts/index.js +101 -13
- package/src/utils/llms.js +224 -0
- package/src/utils/llms.test.js +257 -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 -134
|
@@ -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/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
|
}
|
package/src/prompts/index.js
CHANGED
|
@@ -19,16 +19,33 @@
|
|
|
19
19
|
|
|
20
20
|
export const slotOrphansSystemPrompt = [
|
|
21
21
|
'You assign orphan documentation pages to existing categories.',
|
|
22
|
-
'
|
|
22
|
+
'Respond with a JSON object of the form {"assignments": [...]} — one integer per orphan page, in order.',
|
|
23
23
|
'Each integer is the category index (0-based) the orphan belongs in, or -1 if none fit.',
|
|
24
24
|
'',
|
|
25
|
-
'Example output: [0, 2, 0, 1, -1, 3]',
|
|
25
|
+
'Example output: {"assignments": [0, 2, 0, 1, -1, 3]}',
|
|
26
26
|
'',
|
|
27
27
|
'Guidance:',
|
|
28
28
|
'- Choose the best semantic fit based on the orphan\'s title and URL path.',
|
|
29
29
|
'- Only use -1 when no existing category is plausible.',
|
|
30
30
|
].join('\n');
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Structured-output schema for slotOrphans. Root must be an object (top-level
|
|
34
|
+
* arrays aren't allowed), so the index array is wrapped in `assignments`.
|
|
35
|
+
*/
|
|
36
|
+
export const slotOrphansOutputSchema = {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
required: ['assignments'],
|
|
40
|
+
properties: {
|
|
41
|
+
assignments: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'integer' },
|
|
44
|
+
description: 'One entry per orphan page, in input order: the 0-based category index, or -1 if none fit.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
32
49
|
/**
|
|
33
50
|
* @param {object} input
|
|
34
51
|
* @param {Array<{ title: string, pages?: Array<{ title: string }> }>} input.categories
|
|
@@ -54,7 +71,7 @@ export function buildSlotOrphansUserPrompt({ categories, orphans }) {
|
|
|
54
71
|
`${orphans.length} orphan pages to slot:`,
|
|
55
72
|
...orphanList,
|
|
56
73
|
'',
|
|
57
|
-
`Output
|
|
74
|
+
`Output {"assignments": [...]} with ${orphans.length} integers, one per orphan in order (category index 0..${categories.length - 1}, or -1 for none).`,
|
|
58
75
|
].join('\n');
|
|
59
76
|
}
|
|
60
77
|
|
|
@@ -69,12 +86,29 @@ export function slotOrphansPrompt(input) {
|
|
|
69
86
|
|
|
70
87
|
export const iconizeNavSystemPrompt = [
|
|
71
88
|
'You assign one FontAwesome Free Solid icon to each documentation category.',
|
|
72
|
-
'
|
|
73
|
-
'Use the icon name only (no "fa-" prefix
|
|
89
|
+
'Respond with a JSON object of the form {"icons": [...]} — one icon name string per input category, in order.',
|
|
90
|
+
'Use the icon name only (no "fa-" prefix).',
|
|
74
91
|
'',
|
|
75
|
-
'Example output: ["rocket", "book", "code", "gear"]',
|
|
92
|
+
'Example output: {"icons": ["rocket", "book", "code", "gear"]}',
|
|
76
93
|
].join('\n');
|
|
77
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Structured-output schema for iconizeNav. Root must be an object, so the
|
|
97
|
+
* icon-name array is wrapped in `icons`.
|
|
98
|
+
*/
|
|
99
|
+
export const iconizeNavOutputSchema = {
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
required: ['icons'],
|
|
103
|
+
properties: {
|
|
104
|
+
icons: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
items: { type: 'string' },
|
|
107
|
+
description: 'One FontAwesome Free Solid icon name per input category, in order (no "fa-" prefix).',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
78
112
|
/**
|
|
79
113
|
* @param {object} input
|
|
80
114
|
* @param {Array<{ title: string }>} input.categories
|
|
@@ -86,7 +120,7 @@ export function buildIconizeNavUserPrompt({ categories }) {
|
|
|
86
120
|
'',
|
|
87
121
|
...categories.map((c, i) => `${i}. ${c.title}`),
|
|
88
122
|
'',
|
|
89
|
-
'Return
|
|
123
|
+
'Return {"icons": [...]} with one FontAwesome icon name per category, in order.',
|
|
90
124
|
].join('\n');
|
|
91
125
|
}
|
|
92
126
|
|
|
@@ -101,13 +135,15 @@ export function iconizeNavPrompt(input) {
|
|
|
101
135
|
|
|
102
136
|
export const organizeFromSectionsSystemPrompt = [
|
|
103
137
|
'You assign a FontAwesome Free Solid icon to each documentation section, and lightly polish the section title.',
|
|
104
|
-
'
|
|
138
|
+
'Respond with a JSON object matching the schema below.',
|
|
105
139
|
'',
|
|
106
140
|
'Schema:',
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
110
|
-
'
|
|
141
|
+
'{',
|
|
142
|
+
' "sections": [',
|
|
143
|
+
' { "title": "<lightly polished Title Case, 1-4 words>", "icon": "<fontawesome icon name, no fa- prefix>" },',
|
|
144
|
+
' ...',
|
|
145
|
+
' ]',
|
|
146
|
+
'}',
|
|
111
147
|
'',
|
|
112
148
|
'Rules:',
|
|
113
149
|
'- Return exactly one entry per input section, in the same order.',
|
|
@@ -115,6 +151,31 @@ export const organizeFromSectionsSystemPrompt = [
|
|
|
115
151
|
'- Pick an icon that semantically fits the section (e.g. rocket for Getting Started, code for API, plug for Integrations).',
|
|
116
152
|
].join('\n');
|
|
117
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Structured-output schema for organizeFromSections. Root must be an object,
|
|
156
|
+
* so the per-section entries are wrapped in `sections`.
|
|
157
|
+
*/
|
|
158
|
+
export const organizeFromSectionsOutputSchema = {
|
|
159
|
+
type: 'object',
|
|
160
|
+
additionalProperties: false,
|
|
161
|
+
required: ['sections'],
|
|
162
|
+
properties: {
|
|
163
|
+
sections: {
|
|
164
|
+
type: 'array',
|
|
165
|
+
description: 'Exactly one entry per input section, in the same order.',
|
|
166
|
+
items: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
additionalProperties: false,
|
|
169
|
+
required: ['title', 'icon'],
|
|
170
|
+
properties: {
|
|
171
|
+
title: { type: 'string', description: 'Lightly polished Title Case section title, 1-4 words.' },
|
|
172
|
+
icon: { type: 'string', description: 'FontAwesome Free Solid icon name, no "fa-" prefix.' },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
118
179
|
/**
|
|
119
180
|
* @param {object} input
|
|
120
181
|
* @param {string|null|undefined} input.siteTitle
|
|
@@ -128,7 +189,7 @@ export function buildOrganizeFromSectionsUserPrompt({ siteTitle, sections }) {
|
|
|
128
189
|
'',
|
|
129
190
|
...sections.map((s, i) => `${i}. ${s.title} (${s.items.length} pages)`),
|
|
130
191
|
'',
|
|
131
|
-
'Output the JSON
|
|
192
|
+
'Output the JSON object now.',
|
|
132
193
|
].join('\n');
|
|
133
194
|
}
|
|
134
195
|
|
|
@@ -166,6 +227,33 @@ export const organizeFromScratchSystemPrompt = [
|
|
|
166
227
|
'- Keep category titles human-readable, Title Case, 1-4 words.',
|
|
167
228
|
].join('\n');
|
|
168
229
|
|
|
230
|
+
/** Structured-output schema for organizeFromScratch. */
|
|
231
|
+
export const organizeFromScratchOutputSchema = {
|
|
232
|
+
type: 'object',
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
required: ['title', 'categories'],
|
|
235
|
+
properties: {
|
|
236
|
+
title: { type: 'string', description: 'Short site or doc title.' },
|
|
237
|
+
categories: {
|
|
238
|
+
type: 'array',
|
|
239
|
+
items: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
required: ['title', 'icon', 'pageIds'],
|
|
243
|
+
properties: {
|
|
244
|
+
title: { type: 'string', description: 'Title Case category name, 1-4 words.' },
|
|
245
|
+
icon: { type: 'string', description: 'FontAwesome Free Solid icon name, no "fa-" prefix.' },
|
|
246
|
+
pageIds: {
|
|
247
|
+
type: 'array',
|
|
248
|
+
items: { type: 'integer' },
|
|
249
|
+
description: 'Integer ids from the input page list. Every input id appears in exactly one category.',
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
169
257
|
/**
|
|
170
258
|
* @param {object} input
|
|
171
259
|
* @param {string|null|undefined} input.siteTitle
|