diff --git a/.changeset/silent-facts-wonder.md b/.changeset/silent-facts-wonder.md
new file mode 100644
index 0000000000..37b894b704
--- /dev/null
+++ b/.changeset/silent-facts-wonder.md
@@ -0,0 +1,7 @@
+---
+"@redocly/cli": patch
+---
+
+Fixed the `split` command to properly handle root-level paths.
+Previously, the root path `/` was converted to an empty string as a filename, leading to incorrect file structure and broken links.
+Now, it correctly maps to the specified path separator.
diff --git a/docs/@v2/commands/split.md b/docs/@v2/commands/split.md
index 0e34cf47c4..442dba3634 100644
--- a/docs/@v2/commands/split.md
+++ b/docs/@v2/commands/split.md
@@ -24,15 +24,15 @@ redocly split --version
## Options
-| Option | Type | Description |
-| ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| api | string | **REQUIRED.** Path to the API description file that you want to split into a multi-file structure. |
-| --config | string | Specify path to the [configuration file](../configuration/index.md). |
-| --help | boolean | Show help. |
-| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
-| --outDir | string | **REQUIRED.** Path to the directory where you want to save the split files. If the specified directory doesn't exist, it is created automatically. |
-| --separator | string | File path separator used while splitting. The default value is `_`. This controls the file names generated in the `paths` folder (e.g. `/users/create` path becomes `user_create.yaml`). |
-| --version | boolean | Show version number. |
+| Option | Type | Description |
+| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| api | string | **REQUIRED.** Path to the API description file that you want to split into a multi-file structure. |
+| --config | string | Specify path to the [configuration file](../configuration/index.md). |
+| --help | boolean | Show help. |
+| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
+| --outDir | string | **REQUIRED.** Path to the directory where you want to save the split files. If the specified directory doesn't exist, it is created automatically. |
+| --separator | string | File path separator used while splitting. The default value is `_`. This controls the file names generated in the `paths` folder (e.g. `/users/create` path becomes `user_create.yaml`, root level path `/` becomes `_.yaml`, and so on). |
+| --version | boolean | Show version number. |
## Examples
diff --git a/package-lock.json b/package-lock.json
index e523b321bc..30d046c633 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12593,24 +12593,6 @@
}
}
},
- "node_modules/vite-node/node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/vitest": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz",
@@ -12811,24 +12793,6 @@
}
}
},
- "node_modules/vitest/node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts
index 25bee21aec..6f1ff1abc0 100644
--- a/packages/cli/src/utils/miscellaneous.ts
+++ b/packages/cli/src/utils/miscellaneous.ts
@@ -121,11 +121,15 @@ export function printExecutionTime(commandName: string, startedAt: number, api:
}
export function pathToFilename(path: string, pathSeparator: string) {
+ if (path === '/') {
+ return pathSeparator;
+ }
+
return path
- .replace(/~1/g, '/')
- .replace(/~0/g, '~')
+ .replaceAll('~1', '/')
+ .replaceAll('~0', '~')
.replace(/^\//, '')
- .replace(/\//g, pathSeparator);
+ .replaceAll('/', pathSeparator);
}
export function escapeLanguageName(lang: string) {
diff --git a/tests/e2e/commands.test.ts b/tests/e2e/commands.test.ts
index bba15e30f4..b0d76367f7 100644
--- a/tests/e2e/commands.test.ts
+++ b/tests/e2e/commands.test.ts
@@ -375,6 +375,45 @@ describe('E2E', () => {
const result = getCommandOutput(args, { testPath });
await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
});
+
+ test('root endpoint correctly split', async () => {
+ const testPath = join(__dirname, `split/root-endpoint`);
+
+ const args = getParams(indexEntryPoint, ['split', 'openapi.yaml', '--outDir=output']);
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('root endpoint split and bundle again to the content', async () => {
+ const testPath = join(__dirname, `split/root-endpoint`);
+
+ let args = getParams(indexEntryPoint, ['split', 'openapi.yaml', '--outDir=output']);
+ spawnSync('node', args, {
+ cwd: testPath,
+ env: {
+ ...process.env,
+ NODE_ENV: 'production',
+ NO_COLOR: 'TRUE',
+ },
+ });
+
+ args = getParams(indexEntryPoint, [
+ 'bundle',
+ 'output/openapi.yaml',
+ '-o=output/bundled.yaml',
+ ]);
+ getCommandOutput(args, { testPath });
+
+ const expected = readFileSync(join(testPath, 'openapi.yaml'), 'utf8');
+ const actual = readFileSync(join(testPath, 'output/bundled.yaml'), 'utf8');
+
+ // Clean up output folder after splitting so the produced files do not interfere with other tests
+ spawnSync('rm', ['-rf', 'output'], {
+ cwd: testPath,
+ });
+
+ expect(actual).toEqual(expected);
+ });
});
describe('join', () => {
diff --git a/tests/e2e/split/root-endpoint/openapi.yaml b/tests/e2e/split/root-endpoint/openapi.yaml
new file mode 100644
index 0000000000..54ca42cd4c
--- /dev/null
+++ b/tests/e2e/split/root-endpoint/openapi.yaml
@@ -0,0 +1,111 @@
+openapi: 3.0.0
+info:
+ version: 1.0.0
+ title: Test
+ license:
+ name: MIT
+servers:
+ - url: http://localhost/v1
+paths:
+ /:
+ get:
+ summary: List all pets
+ operationId: listPets
+ tags:
+ - pets
+ parameters:
+ - name: limit
+ in: query
+ description: How many items to return at one time (max 100)
+ required: false
+ schema:
+ type: integer
+ format: int32
+ responses:
+ '200':
+ description: A paged array of pets
+ headers:
+ x-next:
+ description: A link to the next page of responses
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pets'
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ post:
+ summary: Create a pet
+ operationId: createPets
+ tags:
+ - pets
+ responses:
+ '201':
+ description: Null response
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ /pets/{petId}:
+ get:
+ summary: Info for a specific pet
+ operationId: showPetById
+ tags:
+ - pets
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ description: The id of the pet to retrieve
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Expected response to a valid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+components:
+ schemas:
+ Error:
+ type: object
+ required:
+ - code
+ - message
+ properties:
+ code:
+ type: integer
+ format: int32
+ message:
+ type: string
+ Pet:
+ type: object
+ required:
+ - id
+ - name
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ tag:
+ type: string
+ Pets:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pet'
diff --git a/tests/e2e/split/root-endpoint/snapshot.txt b/tests/e2e/split/root-endpoint/snapshot.txt
new file mode 100644
index 0000000000..3e6a8a0bc5
--- /dev/null
+++ b/tests/e2e/split/root-endpoint/snapshot.txt
@@ -0,0 +1,114 @@
+
+type: object
+required:
+ - code
+ - message
+properties:
+ code:
+ type: integer
+ format: int32
+ message:
+ type: string
+type: object
+required:
+ - id
+ - name
+properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ tag:
+ type: string
+type: array
+items:
+ $ref: ./Pet.yaml
+get:
+ summary: List all pets
+ operationId: listPets
+ tags:
+ - pets
+ parameters:
+ - name: limit
+ in: query
+ description: How many items to return at one time (max 100)
+ required: false
+ schema:
+ type: integer
+ format: int32
+ responses:
+ '200':
+ description: A paged array of pets
+ headers:
+ x-next:
+ description: A link to the next page of responses
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pets'
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+post:
+ summary: Create a pet
+ operationId: createPets
+ tags:
+ - pets
+ responses:
+ '201':
+ description: Null response
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+get:
+ summary: Info for a specific pet
+ operationId: showPetById
+ tags:
+ - pets
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ description: The id of the pet to retrieve
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Expected response to a valid request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ default:
+ description: unexpected error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+openapi: 3.0.0
+info:
+ version: 1.0.0
+ title: Test
+ license:
+ name: MIT
+servers:
+ - url: http://localhost/v1
+paths:
+ /:
+ $ref: paths/_.yaml
+ /pets/{petId}:
+ $ref: paths/pets_{petId}.yaml
+🪓 Document: openapi.yaml is successfully split
+ and all related files are saved to the directory: output
+
+openapi.yaml: split processed in ms
+