From 2c738b2587ba778a141af92e32bde36ccf07c5c9 Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:03:34 -0500 Subject: [PATCH 1/3] fix(oas:sync): match the platform's OAS-upload output oas:sync generated a different tree than uploading the same spec to ReadMe, so a repo initialized by upload drifted after a local sync: - Tag category pages (/index.md) were never generated, silently dropping the tag's description from the spec's top-level tags array. - The root reference/_order.yaml was never written, losing top-level ordering. - Slugs kept the operationId casing (getUserById.md) where the platform lowercases (getuserbyid.md), diverging URLs. - Generated pages omitted hidden: false. Generate the tag index.md (title from tag name, excerpt from tag description, never overwriting an existing one), maintain the root _order.yaml, lowercase slugs, and include hidden: false. Co-Authored-By: Claude Fable 5 --- src/commands/oas-sync.js | 39 +++++++++++++++++++-- test/oas-sync.test.js | 76 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index b551960..100627f 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,11 +185,25 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, + 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; + frontmatter.hidden = false; + + return matter.stringify('', frontmatter); +} + /** * Run the sync for a single OAS file. Returns changes for that file. */ @@ -211,6 +225,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)) { @@ -225,12 +247,14 @@ function syncOneOas(refDir, oasFilename, spec) { } // 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. + // 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 rawTag = op.tag || 'Other'; + const tag = safeSegment(rawTag, 'Other'); + const slug = safeSegment(opId, 'operation').toLowerCase(); const pageDir = path.join(refDir, infoTitle, tag); const pagePath = path.join(pageDir, `${slug}.md`); @@ -243,11 +267,20 @@ function syncOneOas(refDir, oasFilename, spec) { } fs.mkdirSync(pageDir, { recursive: true }); + // The tag's category landing page (index.md), like the platform generates + // on upload. Never overwrite one that already exists. + const indexPath = path.join(pageDir, 'index.md'); + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); + changes.added.push(path.relative(refDir, indexPath)); + } + const content = buildPageContent({ oasFilename, operationId: opId }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); + addToOrder(path.join(refDir, '_order.yaml'), infoTitle); changes.added.push(path.relative(refDir, pagePath)); } diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index b0b48cc..156a97c 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,11 +20,13 @@ 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'); + assert.equal(data.hidden, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -32,6 +34,69 @@ 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 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('spec-derived names cannot escape the reference directory', () => { const spec = JSON.stringify({ openapi: '3.0.0', @@ -72,7 +137,9 @@ 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); + // added = the op page plus the tag's generated index.md. + const addedPages = first.changes.added.filter((p) => !p.endsWith('index.md')); + assert.equal(addedPages.length, 1); assert.equal(first.changes.skipped.length, 1); const page = path.join(root, 'reference/Pets/Other/foo-bar.md'); @@ -91,13 +158,14 @@ test('operations whose sanitized names collide do not overwrite each other', () test('sync does not overwrite an existing page from another spec or author', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - 'reference/Pets/Other/listPets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', + // The sync targets the lowercased slug. + 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\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'); + 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/); } finally { From 86bd1b46a3f627168f8915afb717dda78fa631a5 Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:02:44 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(oas:sync):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20reserve=20index.md,=20drop=20explicit=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #35: - Reserve /index.md exclusively for the tag category page. An operation whose slug normalizes to "index" now gets the first free numeric slug (index-1, index-2, ...) instead of clobbering the category page. Uses numeric suffixes rather than a fixed "index-operation" suffix, which could itself collide with a real operation. - Drop the explicit `hidden: false` from generated op and tag index pages; rely on the backend default instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/oas-sync.js | 17 +++++++++++++--- test/oas-sync.test.js | 44 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 100627f..5702525 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,7 +185,6 @@ function buildPageContent({ oasFilename, operationId }) { file: oasFilename, operationId, }, - hidden: false, }; return matter.stringify('', frontmatter); @@ -199,11 +198,23 @@ function buildPageContent({ oasFilename, operationId }) { function buildTagIndexContent(tagName, description) { const frontmatter = { title: tagName }; if (description) frontmatter.excerpt = description; - frontmatter.hidden = false; return matter.stringify('', frontmatter); } +/** + * `index.md` in a tag directory is reserved for the tag's category landing + * page, so an operation whose slug is `index` can't use it. Fall back to the + * first free `index-N` so it collides with neither the landing page nor another + * operation (including a second operation that also normalizes to `index`). + */ +function reserveOperationSlug(pageDir, slug) { + if (slug !== 'index') return slug; + let n = 1; + while (fs.existsSync(path.join(pageDir, `index-${n}.md`))) n += 1; + return `index-${n}`; +} + /** * Run the sync for a single OAS file. Returns changes for that file. */ @@ -254,8 +265,8 @@ function syncOneOas(refDir, oasFilename, spec) { const rawTag = op.tag || 'Other'; const tag = safeSegment(rawTag, 'Other'); - const slug = safeSegment(opId, 'operation').toLowerCase(); const pageDir = path.join(refDir, infoTitle, tag); + const slug = reserveOperationSlug(pageDir, safeSegment(opId, 'operation').toLowerCase()); const pagePath = path.join(pageDir, `${slug}.md`); // Never overwrite an existing file: it belongs to a manual page, another diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 156a97c..65f33c1 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -26,7 +26,8 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); - assert.equal(data.hidden, false); + // hidden is left to the backend default rather than written explicitly. + assert.equal('hidden' in data, false); assert.equal('title' in data, false); assert.equal('excerpt' in data, false); } finally { @@ -51,7 +52,7 @@ test('sync generates a tag index.md with the tag description from the spec', () 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); + assert.equal('hidden' in data, 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'); @@ -97,6 +98,45 @@ test('sync does not overwrite an existing tag index.md', () => { } }); +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', From 363d3deb6a6f61dc0bef4408aff18fe5107c792e Mon Sep 17 00:00:00 2001 From: rossrdme <168011594+rossrdme@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:33:40 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(oas:sync):=20rework=20per=20review=20?= =?UTF-8?q?=E2=80=94=20reference-wide=20slugs,=20backfill=20index,=20keep?= =?UTF-8?q?=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #35 review: - hidden: keep `hidden: false` on generated pages (operation + tag index). Testing the UI upload shows it always stamps hidden and forces false on a new endpoint even when the tag/siblings are hidden, so the backend default can't be relied on. Reverts the drop in 86bd1b4. Added an @todo to honor the x-internal extension (gitto#2095) once available. - index-slug collisions: replace the tag-local check with reference-wide, folder-aware slug reservation. Reference page slugs are one flat namespace (docs/ is separate), so uniquify against every slug in reference/ — files and category folders (folder/index.md) alike — appending -1, -2, ... The reserved `index` slug always yields a numbered operation page. - backfill: generate a tag's category index.md in its own pass over every tag in the spec, so references first synced by an older CLI (op pages, no index) get their category pages on the next sync. Never overwrites an existing index; idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/oas-sync.js | 136 ++++++++++++++++++++++++++++++--------- test/oas-sync.test.js | 134 ++++++++++++++++++++++++++++++++------ 2 files changed, 219 insertions(+), 51 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 5702525..32a2bc7 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -185,6 +185,19 @@ 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); @@ -198,27 +211,70 @@ function buildPageContent({ oasFilename, operationId }) { 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); } /** - * `index.md` in a tag directory is reserved for the tag's category landing - * page, so an operation whose slug is `index` can't use it. Fall back to the - * first free `index-N` so it collides with neither the landing page nor another - * operation (including a second operation that also normalizes to `index`). + * 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 reserveOperationSlug(pageDir, slug) { - if (slug !== 'index') return slug; - let n = 1; - while (fs.existsSync(path.join(pageDir, `index-${n}.md`))) n += 1; - return `index-${n}`; +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)), @@ -252,46 +308,63 @@ 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. Slugs are - // lowercased to match the platform's OAS-upload output. + // 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 rawTag = op.tag || 'Other'; - const tag = safeSegment(rawTag, 'Other'); + const tag = safeSegment(op.tag || 'Other', 'Other'); const pageDir = path.join(refDir, infoTitle, tag); - const slug = reserveOperationSlug(pageDir, safeSegment(opId, 'operation').toLowerCase()); + // 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; } fs.mkdirSync(pageDir, { recursive: true }); - // The tag's category landing page (index.md), like the platform generates - // on upload. Never overwrite one that already exists. - const indexPath = path.join(pageDir, 'index.md'); - if (!fs.existsSync(indexPath)) { - fs.writeFileSync(indexPath, buildTagIndexContent(rawTag, tagDescriptions.get(rawTag))); - changes.added.push(path.relative(refDir, indexPath)); - } - const content = buildPageContent({ oasFilename, operationId: opId }); fs.writeFileSync(pagePath, content); addToOrder(path.join(pageDir, '_order.yaml'), slug); - addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag); - addToOrder(path.join(refDir, '_order.yaml'), infoTitle); changes.added.push(path.relative(refDir, pagePath)); } @@ -319,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 65f33c1..53c0461 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -26,8 +26,8 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.file, 'pets.json'); assert.equal(data.api.operationId, 'listPets'); - // hidden is left to the backend default rather than written explicitly. - assert.equal('hidden' in data, false); + // 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 { @@ -52,7 +52,7 @@ test('sync generates a tag index.md with the tag description from the spec', () const { data } = matter(fs.readFileSync(indexPath, 'utf-8')); assert.equal(data.title, 'users'); assert.equal(data.excerpt, 'User management operations'); - assert.equal('hidden' in data, false); + 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'); @@ -75,6 +75,43 @@ test('sync maintains the root reference/_order.yaml', () => { } }); +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', @@ -165,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' }, @@ -177,37 +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); - // added = the op page plus the tag's generated index.md. - const addedPages = first.changes.added.filter((p) => !p.endsWith('index.md')); - assert.equal(addedPages.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, - // The sync targets the lowercased slug. + // 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 { - 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 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 { + 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); }