From 89dcfd24ed833124dad27f6c798211560c58edce Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:13:22 -0700 Subject: [PATCH 1/2] fix(oas:sync): reuse an existing tag folder regardless of hyphen/space spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag-derived category folders were only lowercased, not slugified, so a spec tag with spaces (e.g. "Shipping Labels") produced a folder named literally "shipping labels" instead of landing on the platform's own hyphenated "shipping-labels" folder — creating a duplicate category on every sync instead of reusing the existing one. Hyphen vs. space isn't a "one spelling is correct" situation: they're just two spellings of the same folder. Added `slugifyFolder()`, used both as an equivalence key (so "Shipping Labels" / "shipping labels" / "shipping-labels" are recognized as the same folder) and, when nothing exists under any spelling yet, as the spelling used to create a new folder, matching what a fresh platform OAS-upload would produce. Added `resolveFolder()`, memoized per OAS file per sync run, which looks for an existing folder whose slugified name matches before falling back to the hyphenated default — used both when placing new operation pages and when backfilling a missing category index.md, so whatever's already on disk wins. Co-Authored-By: Claude Sonnet 5 --- src/commands/oas-sync.js | 65 +++++++++++++++++++++--- test/oas-sync.test.js | 104 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 9 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index 9aea4b7..a2b97ec 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -307,6 +307,24 @@ function buildTagIndexContent(title, description) { return stringifyFrontmatter(frontmatter); } +/** + * Kebab-case a folder segment: lowercase, with any run of whitespace or other + * non-alphanumeric characters (a space, an underscore, ...) collapsed to a + * single hyphen. Two roles: (1) as an equivalence key, so "Shipping Labels", + * "shipping labels" and "shipping-labels" are recognized as the same + * folder no matter which spelling is already on disk (see `resolveFolder`); + * and (2) as the spelling used when *creating* a folder that doesn't exist + * under any spelling yet, matching what a fresh platform OAS-upload would + * produce. Neither spelling is the one "true" form — this just needs one + * fixed spelling to create with and to compare against. + */ +function slugifyFolder(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + /** * The category-folder grouping for an operation. A tagged operation groups * under its own tag, as before. An untagged operation groups under a folder @@ -316,8 +334,8 @@ function buildTagIndexContent(title, description) { * 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(); + if (op.tag) return { folder: slugifyFolder(safeSegment(op.tag, 'Other')) || 'other', title: op.tag }; + const folder = slugifyFolder(safeSegment(op.path.replace(/[/{}]/g, ''), 'operation')) || 'operation'; return { folder, title: op.path }; } @@ -410,6 +428,38 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { 'api', ); + // Hyphen vs. space in a group's folder name is not a meaningful difference + // — "shipping-labels" and "shipping labels" are the same folder to a + // human and to the platform, just spelled differently. Whatever spelling + // is already on disk (hand-authored, or from a version of this command + // that didn't hyphenate) is authoritative and must be reused; a hyphenated + // slug is only the spelling this command creates *new* folders with, since + // that's what a fresh platform OAS-upload would produce absent any + // existing folder to defer to. resolveFolder(slug) looks for a directory + // under the API's own folder whose kebab-cased name equals `slug` and + // returns its actual on-disk name if found, else `slug` itself (a folder + // to be created). Memoized per OAS file per run. + const apiDir = path.join(refDir, infoTitle); + const resolvedFolders = new Map(); + function resolveFolder(slug) { + if (resolvedFolders.has(slug)) return resolvedFolders.get(slug); + + let resolved = slug; + try { + for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) { + if (entry.isDirectory() && slugifyFolder(entry.name) === slug) { + resolved = entry.name; + break; + } + } + } catch { + // apiDir doesn't exist yet — nothing to reuse. + } + + resolvedFolders.set(slug, resolved); + return resolved; + } + const existingPages = collectExistingPages(refDir).filter( (p) => p.data.api.file === oasFilename, ); @@ -478,7 +528,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // declared tag. const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : []) .filter((t) => t && t.name) - .map((t) => safeSegment(t.name, 'Other').toLowerCase()); + .map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other'); const orderedFolders = [ ...declaredOrder.filter((folder) => groupsByFolder.has(folder)), ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)), @@ -486,7 +536,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { for (const folder of orderedFolders) { const { title, description } = groupsByFolder.get(folder); - const pageDir = path.join(refDir, infoTitle, folder); + const actualFolder = resolveFolder(folder); + const pageDir = path.join(refDir, infoTitle, actualFolder); if (!isWithin(refDir, pageDir)) continue; const indexPath = path.join(pageDir, 'index.md'); @@ -498,9 +549,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // 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); + takeSlug(takenSlugs, actualFolder); } - addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder); + addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder); addToOrder(path.join(refDir, '_order.yaml'), infoTitle); } @@ -511,7 +562,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { if (pagesByOpId.has(key)) continue; const { folder } = operationGroup(op); - const pageDir = path.join(refDir, infoTitle, folder); + const pageDir = path.join(refDir, infoTitle, resolveFolder(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 7d94144..bf76447 100644 --- a/test/oas-sync.test.js +++ b/test/oas-sync.test.js @@ -195,8 +195,11 @@ test('spec-derived names cannot escape the reference directory', () => { assert.equal(fs.existsSync(path.join(root, '..', 'escaped-title')), false); assert.equal(fs.existsSync(path.join(root, 'escaped-title')), false); - // The page is still generated, under sanitized single-segment names. - const page = path.join(refDir, '..-..-escaped-title', '..-..-escaped-tag', '..-escaped-op.md'); + // The page is still generated, under sanitized single-segment names. The + // tag folder is additionally slugified (dots and hyphens collapse to one + // hyphen, then trimmed), unlike the title folder which only has slashes + // swapped for hyphens. + const page = path.join(refDir, '..-..-escaped-title', 'escaped-tag', '..-escaped-op.md'); assert.ok(fs.existsSync(page), 'expected sanitized page inside reference/'); const { data } = matter(fs.readFileSync(page, 'utf-8')); assert.equal(data.api.operationId, '../escaped-op', 'frontmatter keeps the raw operationId'); @@ -536,6 +539,103 @@ test('a mixed-case tag gets a lowercased folder, but keeps its original case as } }); +test('a space-separated tag folder lands on the platform\'s existing hyphenated folder, not a duplicate', () => { + // The platform's own OAS-upload slugifies a tag like "Shipping Labels" to + // an "shipping-labels" folder. Before this, oas:sync only lowercased tag + // names for the folder, so a raw spec tag with spaces ("shipping labels") + // produced a second, duplicate "shipping labels" folder alongside the + // platform's "shipping-labels" one instead of reusing it. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'Shipping Labels' }], + paths: { + '/a': { get: { operationId: 'getA', tags: ['Shipping Labels'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + // Pre-existing folder as the platform itself would have named it. + 'reference/Pets/shipping-labels/index.md': '---\ntitle: Shipping Labels\n---\n', + }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + + assert.ok(fs.existsSync(path.join(refDir, 'shipping-labels/geta.md'))); + assert.equal(fs.existsSync(path.join(refDir, 'shipping labels')), false); + assert.equal(fs.readdirSync(refDir).includes('shipping labels'), false); + } finally { + rmRepo(root); + } +}); + +test('a pre-existing space-separated tag folder is reused as-is, not replaced by a hyphenated duplicate', () => { + // A reference tree can already have a tag folder spelled with spaces — + // hand-authored, or left over from before this folder-slug fix. Whatever + // spelling is already on disk wins: a new operation under that same tag + // must land in the existing folder, not spawn a second, hyphenated one + // next to it. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'Shipping Labels' }], + paths: { + '/a': { get: { operationId: 'getA', tags: ['Shipping Labels'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + 'reference/Pets/shipping labels/index.md': '---\ntitle: Shipping Labels\n---\n', + }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + + assert.ok(fs.existsSync(path.join(refDir, 'shipping labels/geta.md'))); + assert.equal(fs.existsSync(path.join(refDir, 'shipping-labels')), false); + assert.equal(fs.readdirSync(refDir).includes('shipping-labels'), false); + + const order = fs.readFileSync(path.join(refDir, '_order.yaml'), 'utf-8'); + assert.deepEqual(order.trim().split('\n'), ['- shipping labels']); + } finally { + rmRepo(root); + } +}); + +test('a tag folder missing its category index.md is backfilled in place, regardless of its spelling', () => { + // The category-page backfill pass (for a reference first synced by a CLI + // version that didn't generate index.md) must resolve the same existing + // folder as the operation-adding pass, even when that folder predates the + // hyphenated-slug convention. + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Pets' }, + tags: [{ name: 'Shipping Labels', description: 'Shipping stuff' }], + paths: { + '/a': { get: { operationId: 'getA', tags: ['Shipping Labels'] } }, + }, + }); + const root = makeRepo({ + 'reference/pets.json': spec, + // Pre-existing operation page (still current in the spec) under the + // space-separated folder, with no index.md yet. + 'reference/Pets/shipping labels/geta.md': matter.stringify('', { + api: { file: 'pets.json', operationId: 'getA' }, + }), + }); + try { + syncOas(root); + const refDir = path.join(root, 'reference/Pets'); + + assert.ok(fs.existsSync(path.join(refDir, 'shipping labels/index.md'))); + assert.ok(fs.existsSync(path.join(refDir, 'shipping labels/geta.md'))); + assert.equal(fs.existsSync(path.join(refDir, 'shipping-labels')), false); + } 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, From 62c9a1837a4e7c8359805f24307251596123d8dc Mon Sep 17 00:00:00 2001 From: Ross <168011594+rossrdme@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:52:11 -0700 Subject: [PATCH 2/2] refactor(oas:sync): resolve group folders with one readdir pass, not a nested closure Addresses review feedback from flinehan on #51: - resolveFolder() re-walked apiDir with fs.readdirSync on every distinct tag/group not yet memoized, which is O(tags x entries) for an API with many tags and a large existing reference tree. - resolveFolder() was declared as a closure nested inside syncOneOas, inconsistent with every other helper in this file being top-level. Replaces it with existingFoldersBySlug(apiDir), a top-level function that builds a slug -> actual-name Map with a single readdirSync call; folder resolution at each call site is now foldersBySlug.get(folder) || folder, an O(1) lookup. No behavior change. Co-Authored-By: Claude Sonnet 5 --- src/commands/oas-sync.js | 72 +++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/src/commands/oas-sync.js b/src/commands/oas-sync.js index a2b97ec..5e31b5d 100644 --- a/src/commands/oas-sync.js +++ b/src/commands/oas-sync.js @@ -312,11 +312,12 @@ function buildTagIndexContent(title, description) { * non-alphanumeric characters (a space, an underscore, ...) collapsed to a * single hyphen. Two roles: (1) as an equivalence key, so "Shipping Labels", * "shipping labels" and "shipping-labels" are recognized as the same - * folder no matter which spelling is already on disk (see `resolveFolder`); - * and (2) as the spelling used when *creating* a folder that doesn't exist - * under any spelling yet, matching what a fresh platform OAS-upload would - * produce. Neither spelling is the one "true" form — this just needs one - * fixed spelling to create with and to compare against. + * folder no matter which spelling is already on disk (see + * `existingFoldersBySlug`); and (2) as the spelling used when *creating* a + * folder that doesn't exist under any spelling yet, matching what a fresh + * platform OAS-upload would produce. Neither spelling is the one "true" + * form — this just needs one fixed spelling to create with and to compare + * against. */ function slugifyFolder(value) { return value @@ -339,6 +340,30 @@ function operationGroup(op) { return { folder, title: op.path }; } +/** + * Map every existing directory directly under `apiDir` to its slugified + * name (`slug -> actual on-disk name`), with a single directory read. Used + * to resolve a group's folder: whatever spelling is already on disk + * (hyphenated, space-separated, hand-authored, ...) is authoritative and + * must be reused rather than spawning a second, differently-spelled folder + * next to it. A slug with no entry here has nothing on disk yet, so the + * caller falls back to creating it under its hyphenated slug — the spelling + * a fresh platform OAS-upload would produce. + */ +function existingFoldersBySlug(apiDir) { + const bySlug = new Map(); + let entries; + try { + entries = fs.readdirSync(apiDir, { withFileTypes: true }); + } catch { + return bySlug; // apiDir doesn't exist yet — nothing to reuse. + } + for (const entry of entries) { + if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name); + } + return bySlug; +} + /** * Collect every slug already used across the entire reference/ tree, as a * lowercase-slug -> owner-count map. Reference page slugs share one flat @@ -430,35 +455,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { // Hyphen vs. space in a group's folder name is not a meaningful difference // — "shipping-labels" and "shipping labels" are the same folder to a - // human and to the platform, just spelled differently. Whatever spelling - // is already on disk (hand-authored, or from a version of this command - // that didn't hyphenate) is authoritative and must be reused; a hyphenated - // slug is only the spelling this command creates *new* folders with, since - // that's what a fresh platform OAS-upload would produce absent any - // existing folder to defer to. resolveFolder(slug) looks for a directory - // under the API's own folder whose kebab-cased name equals `slug` and - // returns its actual on-disk name if found, else `slug` itself (a folder - // to be created). Memoized per OAS file per run. + // human and to the platform, just spelled differently. One directory read + // up front (existingFoldersBySlug) is enough to resolve every group's + // folder for this API as an O(1) lookup, rather than walking apiDir again + // for each distinct tag/group. const apiDir = path.join(refDir, infoTitle); - const resolvedFolders = new Map(); - function resolveFolder(slug) { - if (resolvedFolders.has(slug)) return resolvedFolders.get(slug); - - let resolved = slug; - try { - for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) { - if (entry.isDirectory() && slugifyFolder(entry.name) === slug) { - resolved = entry.name; - break; - } - } - } catch { - // apiDir doesn't exist yet — nothing to reuse. - } - - resolvedFolders.set(slug, resolved); - return resolved; - } + const foldersBySlug = existingFoldersBySlug(apiDir); const existingPages = collectExistingPages(refDir).filter( (p) => p.data.api.file === oasFilename, @@ -536,7 +538,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { for (const folder of orderedFolders) { const { title, description } = groupsByFolder.get(folder); - const actualFolder = resolveFolder(folder); + const actualFolder = foldersBySlug.get(folder) || folder; const pageDir = path.join(refDir, infoTitle, actualFolder); if (!isWithin(refDir, pageDir)) continue; @@ -562,7 +564,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) { if (pagesByOpId.has(key)) continue; const { folder } = operationGroup(op); - const pageDir = path.join(refDir, infoTitle, resolveFolder(folder)); + const pageDir = path.join(refDir, infoTitle, foldersBySlug.get(folder) || 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.