Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 60 additions & 7 deletions src/commands/oas-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,25 @@ 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
* `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
.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
Expand All @@ -316,11 +335,35 @@ 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 };
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const folder = slugifyFolder(safeSegment(op.path.replace(/[/{}]/g, ''), 'operation')) || 'operation';
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
Expand Down Expand Up @@ -410,6 +453,15 @@ 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. 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 foldersBySlug = existingFoldersBySlug(apiDir);

const existingPages = collectExistingPages(refDir).filter(
(p) => p.data.api.file === oasFilename,
);
Expand Down Expand Up @@ -478,15 +530,16 @@ 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)),
];

for (const folder of orderedFolders) {
const { title, description } = groupsByFolder.get(folder);
const pageDir = path.join(refDir, infoTitle, folder);
const actualFolder = foldersBySlug.get(folder) || folder;
const pageDir = path.join(refDir, infoTitle, actualFolder);
if (!isWithin(refDir, pageDir)) continue;

const indexPath = path.join(pageDir, 'index.md');
Expand All @@ -498,9 +551,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);
}

Expand All @@ -511,7 +564,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, 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.
Expand Down
104 changes: 102 additions & 2 deletions test/oas-sync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
Loading