diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index b551960..32a2bc7 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,15 +185,96 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, + // Mirror the platform's OAS-upload behavior: a newly added endpoint is + // always written `hidden: false`, even when its tag and siblings are + // `hidden: true`. The backend does not infer this from a missing field, so + // it must be written explicitly. + // + // @todo Honor the `x-internal` OpenAPI extension for page visibility, to + // match gitto#2095 (RM-4616 / CX-3303): resolve `hidden` from operation-level + // `x-internal`, falling back to root-level, else false; and hide a tag's + // index page when all of its operations are `x-internal: true`. Deferred to + // keep oas:sync create-only — the resync-side rules (re-applying x-internal + // to existing pages, parent hide-ratchet) would require mutating existing + // pages, which this command intentionally never does. + hidden: false, }; return matter.stringify('', frontmatter); } +/** + * Build the category landing page for a tag (mirrors what the ReadMe platform + * generates on OAS upload): title from the tag name, excerpt from the tag's + * description in the spec's top-level `tags` array. + */ +function buildTagIndexContent(tagName, description) { + const frontmatter = { title: tagName }; + if (description) frontmatter.excerpt = description; + // As with operation pages, upload always stamps hidden: false on new pages. + frontmatter.hidden = false; + + return matter.stringify('', frontmatter); +} + +/** + * Collect every slug already used across the entire reference/ tree. Reference + * page slugs share one flat namespace (docs/ is a separate namespace and is not + * consulted), so a generated operation slug must be unique against all of them. + * A page's slug is its filename without `.md`; a category page's slug (a folder + * containing `index.md`) is the folder name. Comparison is case-insensitive. + */ +function collectReferenceSlugs(refDir) { + const slugs = new Set(); + + function walk(dir) { + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) { + walk(full); + } else if (entry.endsWith('.md')) { + // A folder's index.md contributes the folder name as a slug; any other + // page contributes its own filename. + const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md'); + slugs.add(slug.toLowerCase()); + } + } + } + + walk(refDir); + return slugs; +} + +/** + * Reserve a unique reference slug. `index` is never usable by an operation (it's + * reserved for the tag category page), and any slug already present in the + * reference namespace gets a numeric suffix (`-1`, `-2`, ...) until it's free. + * The chosen slug is added to `takenSlugs` so later operations see it. + */ +function reserveSlug(takenSlugs, base) { + let chosen = base; + if (base === 'index' || takenSlugs.has(base)) { + let n = 1; + while (takenSlugs.has(`${base}-${n}`)) n += 1; + chosen = `${base}-${n}`; + } + takenSlugs.add(chosen); + return chosen; +} + /** * Run the sync for a single OAS file. Returns changes for that file. + * + * `takenSlugs` is the reference-wide set of slugs already in use; it is read and + * mutated so slugs stay unique across every spec processed in one sync run. */ -function syncOneOas(refDir, oasFilename, spec) { +function syncOneOas(refDir, oasFilename, spec, takenSlugs) { const specOps = extractOperations(spec); const infoTitle = safeSegment( spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), @@ -211,6 +292,14 @@ function syncOneOas(refDir, oasFilename, spec) { const changes = { added: [], deleted: [], skipped: [] }; + // Tag descriptions from the spec's top-level `tags` array, used for the + // per-tag category landing page (index.md). + const tagDescriptions = new Map( + (Array.isArray(spec.tags) ? spec.tags : []) + .filter((t) => t && t.name) + .map((t) => [t.name, t.description || null]), + ); + // Deletes: pages referencing operations that no longer exist. for (const [opId, page] of pagesByOpId) { if (!specOps.has(opId)) { @@ -219,24 +308,53 @@ function syncOneOas(refDir, oasFilename, spec) { const pageDir = path.dirname(page.filePath); const slug = path.basename(page.filePath, '.md'); removeFromOrder(path.join(pageDir, '_order.yaml'), slug); + takenSlugs.delete(slug.toLowerCase()); changes.deleted.push(page.relativePath); } } - // Adds: operations with no page yet. Title/excerpt are owned by the OAS spec - // at render time, so generated pages carry only the api reference. + // Ensure every tag present in the spec has its category landing page (index.md) + // and is ordered — independent of whether its operation pages are new. Doing + // this as its own pass (rather than only when creating a new op page) backfills + // category pages for references first synced by a CLI version that didn't + // generate them, and recreates one that was deleted. + const specTags = new Set([...specOps.values()].map((op) => op.tag || 'Other')); + for (const rawTag of specTags) { + const tag = safeSegment(rawTag, 'Other'); + const pageDir = path.join(refDir, infoTitle, tag); + if (!isWithin(refDir, pageDir)) continue; + + const indexPath = path.join(pageDir, 'index.md'); + if (!fs.existsSync(indexPath)) { + // Never overwrite an existing index.md — it may be a hand-written category. + fs.mkdirSync(pageDir, { recursive: true }); + fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); + changes.added.push(path.relative(refDir, indexPath)); + } + // The category page's slug is the tag folder name; reserve it so no operation + // takes it. Ordering entries are idempotent, so this is a no-op when present. + takenSlugs.add(tag.toLowerCase()); + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); + addToOrder(path.join(refDir, '_order.yaml'), infoTitle); + } + + // Adds: operation pages with no page yet. Title/excerpt are owned by the OAS + // spec at render time, so generated pages carry only the api reference. Slugs + // are lowercased to match the platform's OAS-upload output. for (const [opId, op] of specOps) { if (pagesByOpId.has(opId)) continue; const tag = safeSegment(op.tag || 'Other', 'Other'); - const slug = safeSegment(opId, 'operation'); const pageDir = path.join(refDir, infoTitle, tag); + // Reference slugs share one flat namespace, so uniquify against every slug + // already in reference/ — a collision (or the reserved `index` slug) gets a + // numeric suffix rather than being skipped. + const slug = reserveSlug(takenSlugs, safeSegment(opId, 'operation').toLowerCase()); const pagePath = path.join(pageDir, `${slug}.md`); - // Never overwrite an existing file: it belongs to a manual page, another - // spec, or a different operation whose sanitized name collides with this - // one. Skipping (rather than clobbering) keeps repeated syncs stable. + // Guard against a spec-crafted name escaping reference/, or a stale slug set + // vs. disk. reserveSlug already prevents slug collisions. if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) { changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: opId }); continue; @@ -247,7 +365,6 @@ function syncOneOas(refDir, oasFilename, spec) { fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); - addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); changes.added.push(path.relative(refDir, pagePath)); } @@ -275,11 +392,14 @@ export function syncOas(input) { const oasFiles = findOasFiles(refDir); if (oasFiles.length === 0) return null; + // Reference slugs share one flat namespace across every spec, so build the set + // of in-use slugs once and let each spec read/extend it. + const takenSlugs = collectReferenceSlugs(refDir); const allChanges = []; for (const { filename, spec } of oasFiles) { const ops = extractOperations(spec); - const changes = syncOneOas(refDir, filename, spec); + const changes = syncOneOas(refDir, filename, spec, takenSlugs); allChanges.push({ filename, spec, opCount: ops.size, changes }); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index b0b48cc..53c0461 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,11 +20,14 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const root = makeRepo({ 'reference/pets.json': SPEC }); try { syncOas(root); - const page = path.join(root, 'reference/Pets/Other/listPets.md'); + // Slugs are lowercased to match the platform's OAS-upload output. + const page = path.join(root, 'reference/Pets/Other/listpets.md'); assert.ok(fs.existsSync(page), 'expected generated page'); const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); + // Mirrors upload: new pages are always stamped hidden: false. + assert.equal(data.hidden, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -32,6 +35,145 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () } }); +test('sync generates a tag index.md with the tag description from the spec', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Sample API' }, + tags: [{ name: 'users', description: 'User management operations' }], + paths: { + '/users': { get: { operationId: 'listUsers', tags: ['users'] } }, + }, + }); + const root = makeRepo({ 'reference/sample.json': spec }); + try { + syncOas(root); + const indexPath = path.join(root, 'reference/Sample API/users/index.md'); + assert.ok(fs.existsSync(indexPath), 'expected tag index.md'); + const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); + assert.equal(data.title, 'users'); + assert.equal(data.excerpt, 'User management operations'); + assert.equal(data.hidden, false); + + // index must not be listed in the tag's _order.yaml. + const order = fs.readFileSync(path.join(root, 'reference/Sample API/users/_order.yaml'), 'utf-8'); + assert.equal(order.includes('index'), false); + assert.match(order, /- listusers/); + } finally { + rmRepo(root); + } +}); + +test('sync maintains the root reference/_order.yaml', () => { + const root = makeRepo({ 'reference/pets.json': SPEC }); + try { + syncOas(root); + const rootOrder = path.join(root, 'reference/_order.yaml'); + assert.ok(fs.existsSync(rootOrder), 'expected root _order.yaml'); + assert.match(fs.readFileSync(rootOrder, 'utf-8'), /- Pets/); + } finally { + rmRepo(root); + } +}); + +test('sync backfills a missing tag index.md even when all op pages already exist', () => { + // Simulates a reference synced by an older CLI: op pages exist, no index.md. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'API' }, + tags: [{ name: 'widgets', description: 'Widget operations' }], + paths: { + '/1': { get: { operationId: 'listWidgets', tags: ['widgets'] } }, + '/2': { get: { operationId: 'getWidget', tags: ['widgets'] } }, + }, + }); + const root = makeRepo({ + 'reference/api.json': spec, + 'reference/API/widgets/listwidgets.md': + '---\napi:\n file: api.json\n operationId: listWidgets\n---\n', + 'reference/API/widgets/getwidget.md': + '---\napi:\n file: api.json\n operationId: getWidget\n---\n', + }); + try { + const indexPath = path.join(root, 'reference/API/widgets/index.md'); + assert.equal(fs.existsSync(indexPath), false, 'precondition: no index.md yet'); + + syncOas(root); + + assert.ok(fs.existsSync(indexPath), 'expected the category index.md to be backfilled'); + const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); + assert.equal(data.title, 'widgets'); + assert.equal(data.excerpt, 'Widget operations'); + + // A second run is a no-op (index now present). + const [second] = syncOas(root); + assert.equal(second.changes.added.length, 0); + } finally { + rmRepo(root); + } +}); + +test('sync does not overwrite an existing tag index.md', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'Other', description: 'From the spec' }], + paths: { + '/pets': { get: { operationId: 'listPets', tags: ['Other'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/Other/index.md': '---\ntitle: Hand-written category\n---\n\nCustom intro.\n', + }); + try { + syncOas(root); + const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/index.md'), 'utf-8'); + assert.match(content, /Hand-written category/); + assert.match(content, /Custom intro/); + } finally { + rmRepo(root); + } +}); + +test('an operation named "index" does not clobber the tag index.md', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'pets', description: 'Pet ops' }], + paths: { + // Two operations that both normalize to the reserved slug "index". + '/a': { get: { operationId: 'index', tags: ['pets'] } }, + '/b': { get: { operationId: 'INDEX', tags: ['pets'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const dir = path.join(root, 'reference/Pets/pets'); + + // index.md is the category page, never an operation. + const indexData = matter(fs.readFileSync(path.join(dir, 'index.md'), 'utf-8')).data; + assert.equal(indexData.title, 'pets'); + assert.equal('api' in indexData, false); + + // Each colliding operation gets a distinct numeric slug. + assert.ok(fs.existsSync(path.join(dir, 'index-1.md')), 'expected index-1.md'); + assert.ok(fs.existsSync(path.join(dir, 'index-2.md')), 'expected index-2.md'); + const opIds = ['index-1', 'index-2'].map( + (s) => matter(fs.readFileSync(path.join(dir, `${s}.md`), 'utf-8')).data.api.operationId, + ); + assert.deepEqual([...opIds].sort(), ['INDEX', 'index']); + + // _order.yaml lists the operation slugs but not the reserved index page. + const order = fs.readFileSync(path.join(dir, '_order.yaml'), 'utf-8'); + assert.match(order, /- index-1/); + assert.match(order, /- index-2/); + assert.equal(/^- index$/m.test(order), false); + } finally { + rmRepo(root); + } +}); + test('spec-derived names cannot escape the reference directory', () => { const spec = JSON.stringify({ openapi: '3.0.0', @@ -60,7 +202,7 @@ test('spec-derived names cannot escape the reference directory', () => { } }); -test('operations whose sanitized names collide do not overwrite each other', () => { +test('operations whose sanitized names collide get distinct suffixed slugs', () => { const spec = JSON.stringify({ openapi: '3.0.0', info: { title: 'Pets' }, @@ -72,34 +214,92 @@ test('operations whose sanitized names collide do not overwrite each other', () const root = makeRepo({ 'reference/pets.json': spec }); try { const [first] = syncOas(root); - assert.equal(first.changes.added.length, 1); - assert.equal(first.changes.skipped.length, 1); + // Both operations get their own page; the second collides and is suffixed. + const opPages = first.changes.added.filter((p) => !p.endsWith('index.md')); + assert.equal(opPages.length, 2); + assert.equal(first.changes.skipped.length, 0); - const page = path.join(root, 'reference/Pets/Other/foo-bar.md'); - const opIdOnDisk = matter(fs.readFileSync(page, 'utf-8')).data.api.operationId; + const base = path.join(root, 'reference/Pets/Other/foo-bar.md'); + const suffixed = path.join(root, 'reference/Pets/Other/foo-bar-1.md'); + assert.ok(fs.existsSync(base) && fs.existsSync(suffixed), 'expected foo-bar.md and foo-bar-1.md'); + const ops = [base, suffixed].map((p) => matter(fs.readFileSync(p, 'utf-8')).data.api.operationId); + assert.deepEqual([...ops].sort(), ['foo/bar', 'foo\\bar']); - // Re-running must not flip the page to the other colliding operation. + // Re-running is stable: both pages already exist (matched by operationId). const [second] = syncOas(root); assert.equal(second.changes.added.length, 0); - assert.equal(second.changes.skipped.length, 1); - assert.equal(matter(fs.readFileSync(page, 'utf-8')).data.api.operationId, opIdOnDisk); + assert.equal(second.changes.skipped.length, 0); } finally { rmRepo(root); } }); -test('sync does not overwrite an existing page from another spec or author', () => { +test('sync gives an operation a unique slug rather than overwriting a hand-written page', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - 'reference/Pets/Other/listPets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', + // A hand-written page (no api frontmatter) already occupies the slug. + 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', + }); + try { + syncOas(root); + // The hand-written page is untouched... + const hand = fs.readFileSync(path.join(root, 'reference/Pets/Other/listpets.md'), 'utf-8'); + assert.match(hand, /Hand-written page/); + assert.match(hand, /Custom content/); + // ...and the operation gets its own suffixed page. + const opPage = path.join(root, 'reference/Pets/Other/listpets-1.md'); + assert.ok(fs.existsSync(opPage), 'expected listpets-1.md for the operation'); + assert.equal(matter(fs.readFileSync(opPage, 'utf-8')).data.api.operationId, 'listPets'); + } finally { + rmRepo(root); + } +}); + +test('reference slugs are unique across tags (flat namespace), not per-folder', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/a': { get: { operationId: 'thing', tags: ['alpha'] } }, + '/b': { get: { operationId: 'Thing', tags: ['beta'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + // Same base slug in two different tags: the second is suffixed even though + // it's in a different folder, because reference slugs share one namespace. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/alpha/thing.md'))); + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/beta/thing-1.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/beta/thing.md')), false); + } finally { + rmRepo(root); + } +}); + +test('a slug taken by a category folder (folder/index.md) is not reused by an operation', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/a': { get: { operationId: 'guides', tags: ['Other'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + // A category folder whose slug is its folder name: "guides". + 'reference/Pets/Other/guides/index.md': '---\ntitle: Guides\n---\n\nA sub-category.\n', }); try { - const [result] = syncOas(root); - assert.equal(result.changes.added.length, 0); - assert.equal(result.changes.skipped.length, 1); - const content = fs.readFileSync(path.join(root, 'reference/Pets/Other/listPets.md'), 'utf-8'); - assert.match(content, /Hand-written page/); - assert.match(content, /Custom content/); + syncOas(root); + // The operation slug "guides" is taken by the folder, so it is suffixed. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/Other/guides-1.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/Other/guides.md')), false); + // The category folder's index.md is untouched. + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/Other/guides/index.md'), 'utf-8'), + /A sub-category/, + ); } finally { rmRepo(root); }