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/5] 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/5] =?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/5] =?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); } From 4328f22a6eb634c139238d5ab96e7036177846f6 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:12:12 -0700 Subject: [PATCH 4/5] fix(oas:sync): group untagged operations by path, not a shared "Other" folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a repo synced by the real ReadMe platform (OAS upload): an untagged operation's category folder is derived from its path (e.g. `/pets/{petId}` -> `petspetid/`, with the category page's title set to the raw path), one folder per unique path — never a single shared "Other" bucket. oas:sync instead lumped every untagged operation into one `Other/` folder, which the earlier commits in this PR's backfill pass made worse: it started generating an `Other/index.md` category page even for specs whose untagged operations already live in per-path folders on disk, producing an orphaned index page with no member operations. Add `path` to the operations extractOperations returns, and derive an operation's category grouping (folder + index.md title) from its tag when present, or its sanitized path otherwise, via the new operationGroup helper. Tagged operations are unaffected. --- src/commands/oas-sync.js | 56 ++++++++++++++++++--------- test/oas-sync.test.js | 82 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 32a2bc7..6ab2019 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -57,7 +57,7 @@ function generateOperationId(method, pathStr) { /** * Extract operations from an OAS spec. - * Returns a Map of operationId -> { summary, description, tag, operationId }. + * Returns a Map of operationId -> { summary, description, tag, path, operationId }. * For operations without an operationId, a synthetic one is generated from the method and path. */ export function extractOperations(spec) { @@ -75,6 +75,7 @@ export function extractOperations(spec) { summary: operation.summary || null, description: operation.description || null, tag: (operation.tags && operation.tags[0]) || null, + path: pathStr, }); } } @@ -204,12 +205,13 @@ function buildPageContent({ oasFilename, operationId }) { } /** - * 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. + * Build a category landing page (mirrors what the ReadMe platform generates on + * OAS upload): `title` is the tag name for a tagged group, or the raw path for + * an untagged path-derived group (see `operationGroup`); `excerpt`, when given, + * is the tag's description from the spec's top-level `tags` array. */ -function buildTagIndexContent(tagName, description) { - const frontmatter = { title: tagName }; +function buildTagIndexContent(title, description) { + const frontmatter = { title }; if (description) frontmatter.excerpt = description; // As with operation pages, upload always stamps hidden: false on new pages. frontmatter.hidden = false; @@ -217,6 +219,20 @@ function buildTagIndexContent(tagName, description) { return matter.stringify('', frontmatter); } +/** + * The category-folder grouping for an operation. A tagged operation groups + * under its own tag, as before. An untagged operation groups under a folder + * derived from its path, with the raw path as the category page's title — one + * folder per unique path, not a single shared bucket. This mirrors the + * platform's own OAS-upload output: untagged operations are never lumped into + * one "Other" folder. + */ +function operationGroup(op) { + if (op.tag) return { folder: safeSegment(op.tag, 'Other'), title: op.tag }; + const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation').toLowerCase(); + return { folder, title: op.path }; +} + /** * 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 @@ -314,28 +330,34 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { } } - // Ensure every tag present in the spec has its category landing page (index.md) + // Ensure every group (a tag, or a path-derived bucket for untagged + // operations) 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); + const groupsByFolder = new Map(); + for (const op of specOps.values()) { + const { folder, title } = operationGroup(op); + if (!groupsByFolder.has(folder)) { + groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null }); + } + } + for (const [folder, { title, description }] of groupsByFolder) { + const pageDir = path.join(refDir, infoTitle, folder); 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))); + fs.writeFileSync(indexPath, buildTagIndexContent(title, description)); changes.added.push(path.relative(refDir, indexPath)); } - // The category page's slug is the tag folder name; reserve it so no operation + // The category page's slug is the 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); + takenSlugs.add(folder.toLowerCase()); + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder); addToOrder(path.join(refDir, '_order.yaml'), infoTitle); } @@ -345,8 +367,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { for (const [opId, op] of specOps) { if (pagesByOpId.has(opId)) continue; - const tag = safeSegment(op.tag || 'Other', 'Other'); - const pageDir = path.join(refDir, infoTitle, tag); + const { folder } = operationGroup(op); + const pageDir = path.join(refDir, infoTitle, folder); // 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. diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 53c0461..8ec4a74 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,8 +20,9 @@ test('generated reference page has only api frontmatter (no title/excerpt)', () const root = makeRepo({ 'reference/pets.json': SPEC }); try { syncOas(root); - // Slugs are lowercased to match the platform's OAS-upload output. - const page = path.join(root, 'reference/Pets/Other/listpets.md'); + // Untagged operations group by path ("/pets" -> "pets"), not a shared + // "Other" folder. Slugs are lowercased to match the platform's OAS-upload output. + const page = path.join(root, 'reference/Pets/pets/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'); @@ -237,7 +238,8 @@ test('operations whose sanitized names collide get distinct suffixed slugs', () test('sync gives an operation a unique slug rather than overwriting a hand-written page', () => { const root = makeRepo({ 'reference/pets.json': SPEC, - // A hand-written page (no api frontmatter) already occupies the slug. + // A hand-written page (no api frontmatter) already occupies the slug, + // parked in an unrelated folder — slugs are reserved reference-wide. 'reference/Pets/Other/listpets.md': '---\ntitle: Hand-written page\n---\n\nCustom content.\n', }); try { @@ -246,8 +248,9 @@ test('sync gives an operation a unique slug rather than overwriting a hand-writt 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'); + // ...and the operation gets its own suffixed page, under its path-derived + // group folder ("/pets" -> "pets"), since "listpets" is already taken. + const opPage = path.join(root, 'reference/Pets/pets/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 { @@ -325,3 +328,72 @@ test('existing reference page title is not overwritten by sync', () => { rmRepo(root); } }); + +test('untagged operations group by path, one folder per unique path, not a shared "Other" bucket', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + '/pets': { + get: { operationId: 'listPets' }, + post: { operationId: 'createPet' }, + }, + '/pets/{petId}': { + get: { operationId: 'getPet' }, + }, + '/search': { + get: { operationId: 'search' }, + }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + + // No shared "Other" folder — every unique path gets its own group. + assert.equal(fs.existsSync(path.join(refDir, 'Other')), false); + + // Operations sharing a path share a folder. + assert.ok(fs.existsSync(path.join(refDir, 'pets/listpets.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'pets/createpet.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'petspetid/getpet.md'))); + // The "search" folder itself reserves the slug "search" (it's the category + // page's slug), so the operationId "search" collides with its own folder + // name and is suffixed — matches real platform-upload output. + assert.ok(fs.existsSync(path.join(refDir, 'search/search-1.md'))); + assert.equal(fs.existsSync(path.join(refDir, 'search/search.md')), false); + + // The category page's title is the raw path, not the sanitized folder name. + const petsIndex = matter(fs.readFileSync(path.join(refDir, 'pets/index.md'), 'utf-8')).data; + assert.equal(petsIndex.title, '/pets'); + assert.equal('excerpt' in petsIndex, false); + + const petIdIndex = matter(fs.readFileSync(path.join(refDir, 'petspetid/index.md'), 'utf-8')).data; + assert.equal(petIdIndex.title, '/pets/{petId}'); + } finally { + rmRepo(root); + } +}); + +test('an operation with a real tag still groups under that tag, not its path', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'pets', description: 'Pet operations' }], + paths: { + '/pets': { get: { operationId: 'listPets', tags: ['pets'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + assert.ok(fs.existsSync(path.join(refDir, 'pets/listpets.md'))); + const index = matter(fs.readFileSync(path.join(refDir, 'pets/index.md'), 'utf-8')).data; + assert.equal(index.title, 'pets'); + assert.equal(index.excerpt, 'Pet operations'); + } finally { + rmRepo(root); + } +}); From c90584929f7fe11efc85f5c14b9889e3393cf0a7 Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:32:23 -0700 Subject: [PATCH 5/5] fix(oas:sync): trim trailing blank line, order tags by the spec's own tags array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more fidelity gaps found while diffing CLI output against a repo synced by the real platform (byte-for-byte, after wiping and regenerating from the raw specs): - matter.stringify('', frontmatter) always appends a blank body after the closing fence, even when there's no body. The platform's generated pages end immediately after the fence with no trailing newline. Trim it. - Category order previously followed the order operations happen to appear in `paths`. The platform instead keeps a declared tag in the position it holds in the spec's own top-level `tags` array (confirmed via a spec whose `tags` order doesn't match its `paths` order). A group with no declared position — an untagged path-derived group, or a tag used by an operation but never listed in `tags` — keeps its natural encounter order, appended after every declared tag. Verified against the real synced repo: regenerating all four specs from scratch and diffing against the original tree, remaining differences are now only the two open, out-of-scope items (untagged-group ordering has no declared position to anchor to, YAML quoting of `{`-containing titles) plus two pre-existing, unrelated artifacts (a hand-authored page relocation, and OAS `webhooks` operations, which extractOperations doesn't read). --- src/commands/oas-sync.js | 32 +++++++++++++++++++-- test/oas-sync.test.js | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 6ab2019..405b238 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -180,6 +180,16 @@ function isWithin(baseDir, target) { ); } +/** + * Render a frontmatter-only page. `matter.stringify` always appends a blank + * body after the closing fence (even for an empty body); the platform's own + * generated pages end immediately after the fence with no trailing newline, + * so trim it to match. + */ +function stringifyFrontmatter(frontmatter) { + return matter.stringify('', frontmatter).replace(/\n+$/, ''); +} + function buildPageContent({ oasFilename, operationId }) { const frontmatter = { api: { @@ -201,7 +211,7 @@ function buildPageContent({ oasFilename, operationId }) { hidden: false, }; - return matter.stringify('', frontmatter); + return stringifyFrontmatter(frontmatter); } /** @@ -216,7 +226,7 @@ function buildTagIndexContent(title, description) { // As with operation pages, upload always stamps hidden: false on new pages. frontmatter.hidden = false; - return matter.stringify('', frontmatter); + return stringifyFrontmatter(frontmatter); } /** @@ -343,7 +353,23 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null }); } } - for (const [folder, { title, description }] of groupsByFolder) { + + // Order groups the way the platform does: a tag keeps the position it's + // declared in the spec's own top-level `tags` array, not the order its + // operations happen to appear in `paths`. A group with no declared position + // (an untagged path-derived group, or a tag used by an operation but never + // listed in `tags`) keeps its natural encounter order, appended after every + // declared tag. + const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : []) + .filter((t) => t && t.name) + .map((t) => safeSegment(t.name, 'Other')); + const orderedFolders = [ + ...declaredOrder.filter((folder) => groupsByFolder.has(folder)), + ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), + ]; + + for (const folder of orderedFolders) { + const { title, description } = groupsByFolder.get(folder); const pageDir = path.join(refDir, infoTitle, folder); if (!isWithin(refDir, pageDir)) continue; diff --git a/test/oas-sync.test.js b/test/oas-sync.test.js index 8ec4a74..7540d0f 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -397,3 +397,63 @@ test('an operation with a real tag still groups under that tag, not its path', ( rmRepo(root); } }); + +test('generated pages end at the closing fence with no trailing blank line', () => { + const root = makeRepo({ 'reference/pets.json': SPEC }); + try { + syncOas(root); + // Matches platform-generated pages, which end immediately after "---" + // with no trailing newline. + const opContent = fs.readFileSync(path.join(root, 'reference/Pets/pets/listpets.md'), 'utf-8'); + assert.ok(opContent.endsWith('---'), `expected no trailing newline, got: ${JSON.stringify(opContent.slice(-5))}`); + + const indexContent = fs.readFileSync(path.join(root, 'reference/Pets/pets/index.md'), 'utf-8'); + assert.ok(indexContent.endsWith('---'), `expected no trailing newline, got: ${JSON.stringify(indexContent.slice(-5))}`); + } finally { + rmRepo(root); + } +}); + +test('tag order follows the spec\'s own `tags` array, not the order operations appear in `paths`', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + // Declared in "beta, alpha" order... + tags: [{ name: 'beta' }, { name: 'alpha' }], + paths: { + // ...even though "alpha"'s operation is declared first in paths. + '/a': { get: { operationId: 'aOp', tags: ['alpha'] } }, + '/b': { get: { operationId: 'bOp', tags: ['beta'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const order = fs.readFileSync(path.join(root, 'reference/Pets/_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- beta', '- alpha']); + } finally { + rmRepo(root); + } +}); + +test('a tag used by an operation but not declared in `tags` is ordered after every declared tag', () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'alpha' }], + paths: { + // "undeclared" is never listed in the spec's top-level tags array, and + // its operation appears before alpha's in paths. + '/a': { get: { operationId: 'aOp', tags: ['undeclared'] } }, + '/b': { get: { operationId: 'bOp', tags: ['alpha'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const order = fs.readFileSync(path.join(root, 'reference/Pets/_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- alpha', '- undeclared']); + } finally { + rmRepo(root); + } +});