diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index b551960..4dcc0d0 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, }); } } @@ -179,21 +180,152 @@ 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: { 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); + return stringifyFrontmatter(frontmatter); +} + +/** + * 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(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; + + return stringifyFrontmatter(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').toLowerCase(), 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, as a + * lowercase-slug -> owner-count map. 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. + * + * A count, not a Set, because two existing pages or folders can already share + * a slug (hand-authored content, or content that predates this uniqueness + * logic) — a Set would collapse them to one entry, and releasing one owner + * (see `releaseSlug`) would incorrectly free the slug while the other owner + * still holds it. + */ +function collectReferenceSlugs(refDir) { + const counts = new Map(); + + 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'); + takeSlug(counts, slug); + } + } + } + + walk(refDir); + return counts; +} + +function isSlugTaken(takenSlugs, slug) { + return (takenSlugs.get(slug.toLowerCase()) || 0) > 0; +} + +/** Record one more owner of `slug`. */ +function takeSlug(takenSlugs, slug) { + const key = slug.toLowerCase(); + takenSlugs.set(key, (takenSlugs.get(key) || 0) + 1); +} + +/** Record one fewer owner of `slug`; only fully frees it once every owner is gone. */ +function releaseSlug(takenSlugs, slug) { + const key = slug.toLowerCase(); + const remaining = (takenSlugs.get(key) || 0) - 1; + if (remaining > 0) takenSlugs.set(key, remaining); + else takenSlugs.delete(key); +} + +/** + * 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 gains an owner in `takenSlugs` so later operations see it. + */ +function reserveSlug(takenSlugs, base) { + let chosen = base; + if (base === 'index' || isSlugTaken(takenSlugs, base)) { + let n = 1; + while (isSlugTaken(takenSlugs, `${base}-${n}`)) n += 1; + chosen = `${base}-${n}`; + } + takeSlug(takenSlugs, 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,32 +343,102 @@ 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)) { fs.unlinkSync(page.filePath); const pageDir = path.dirname(page.filePath); - const slug = path.basename(page.filePath, '.md'); - removeFromOrder(path.join(pageDir, '_order.yaml'), slug); + // A legacy operation page can be literally named index.md (predating + // the "index is reserved for the category page" convention). Two + // different things need two different values here: pageSlug is what a + // pre-refactor tool would have actually written into pageDir's own + // _order.yaml ("index", the filename) — that's what removeFromOrder + // must remove. referenceSlug is what the reference-wide slug map + // reserved for it (its folder name, like any index.md — see + // collectReferenceSlugs) — that's what releaseSlug must free. + const isIndexPage = path.basename(page.filePath) === 'index.md'; + const pageSlug = path.basename(page.filePath, '.md'); + const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug; + removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug); + releaseSlug(takenSlugs, referenceSlug); 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 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 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 }); + } + } + + // 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').toLowerCase()); + 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; + + 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(title, description)); + changes.added.push(path.relative(refDir, indexPath)); + // The category page's slug is the folder name; reserve it so no operation + // takes it. Only when just-created — an existing index.md was already + // counted by collectReferenceSlugs's initial disk walk. + takeSlug(takenSlugs, folder); + } + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder); + 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); + 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. + 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 +449,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 +476,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..fd0b0e4 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -20,11 +20,15 @@ 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'); + // 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'); 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 +36,147 @@ 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, + // "Other" is lowercased to "other" for a tag-derived folder — matches + // where the operation's own generated page (tag: 'Other') actually goes. + '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 +205,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 +217,96 @@ 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; + // Tag "Other" is lowercased to the folder "other". + 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, + // 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 { - 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, 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 { + 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". Tag "Other" + // is lowercased to "other", matching where the operation's own page goes. + '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); } @@ -125,3 +332,246 @@ 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); + } +}); + +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); + } +}); + +test('deleting one of two existing owners of a shared slug does not free it for reuse', () => { + // "shared" is already claimed by two pre-existing things: a leaf page + // backing an operation that's about to be removed from the spec, and an + // unrelated hand-authored category folder that survives. Deleting the + // former must not make the slug look free again. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + // "goneOp" (which used to back reference/Pets/a/shared.md) no longer + // exists in the spec. "newOp" is a new operation that would also want + // the base slug "shared". + '/new': { get: { operationId: 'shared', tags: ['a'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/a/shared.md': '---\napi:\n file: pets.json\n operationId: goneOp\n---\n', + 'reference/Pets/b/shared/index.md': '---\ntitle: Shared Category\n---\n\nHand-authored, unrelated to any operation.\n', + }); + try { + syncOas(root); + + // The orphaned page is gone... + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/a/shared.md')), false); + // ...but the still-existing category folder still owns "shared", so the + // new operation is suffixed rather than colliding with it. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/a/shared-1.md'))); + const opId = matter( + fs.readFileSync(path.join(root, 'reference/Pets/a/shared-1.md'), 'utf-8'), + ).data.api.operationId; + assert.equal(opId, 'shared'); + + // The hand-authored survivor is untouched. + assert.match( + fs.readFileSync(path.join(root, 'reference/Pets/b/shared/index.md'), 'utf-8'), + /Hand-authored/, + ); + } finally { + rmRepo(root); + } +}); + +test('a mixed-case tag gets a lowercased folder, but keeps its original case as the category title', () => { + // Confirmed against a real platform upload: a tag declared "MixedCaseTag" + // in the spec produces an on-disk folder "mixedcasetag", but the category + // page's title frontmatter keeps the original casing. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'MixedCaseTag', description: 'Ops under a mixed-case tag' }], + paths: { + '/a': { get: { operationId: 'getA', tags: ['MixedCaseTag'] } }, + }, + }); + const root = makeRepo({ 'reference/pets.json': spec }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + assert.ok(fs.existsSync(path.join(refDir, 'mixedcasetag/geta.md'))); + // Check the actual on-disk directory name (not just existsSync, which + // some filesystems like macOS's default APFS resolve case-insensitively). + assert.ok(fs.readdirSync(refDir).includes('mixedcasetag')); + + const index = matter(fs.readFileSync(path.join(refDir, 'mixedcasetag/index.md'), 'utf-8')).data; + assert.equal(index.title, 'MixedCaseTag'); + + const order = fs.readFileSync(path.join(refDir, '_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- mixedcasetag']); + } finally { + rmRepo(root); + } +}); + +test('deleting a legacy operation page literally named index.md releases its folder-name slug, not "index"', () => { + // A legacy operation stored as index.md (predating the "index is reserved + // for the category page" convention) claims its folder's name as its slug, + // same as any index.md. The spec no longer has this operation, so it's + // deleted; a completely unrelated new operation elsewhere in the same sync + // run wants that exact same slug and must get it cleanly, not a suffix. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + paths: { + // Unrelated new operation whose desired slug is "sometag" — the same + // string as the deleted legacy page's folder name. + '/new': { get: { operationId: 'sometag', tags: ['other-tag'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/sometag/index.md': + '---\napi:\n file: pets.json\n operationId: legacyOp\n---\n', + // A pre-refactor tool would have written the literal filename "index" + // into this directory's own order file — not the folder name. + 'reference/Pets/sometag/_order.yaml': '- index\n', + }); + try { + const [result] = syncOas(root); + assert.ok(result.changes.deleted.some((p) => p.endsWith('sometag/index.md'))); + + // The base slug is free again — no unnecessary numeric suffix. + assert.ok(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag.md'))); + assert.equal(fs.existsSync(path.join(root, 'reference/Pets/other-tag/sometag-1.md')), false); + + // The dangling "- index" entry is removed from the folder's own order + // file (not the folder name — that was never what was listed there). + const orderPath = path.join(root, 'reference/Pets/sometag/_order.yaml'); + assert.equal(fs.existsSync(orderPath), false, 'expected the now-empty _order.yaml to be removed'); + } finally { + rmRepo(root); + } +});