From 69f9c7e0ae213a91dfb7b0d360cb4d8097156ff5 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Mon, 3 Feb 2025 19:23:33 -0600 Subject: [PATCH] feat: `rdme docs upload` (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | πŸš₯ Resolves RM-11796, RM-11797 | | :------------------- | ## 🧰 Changes ### 🚧 outstanding work - [x] core command + helper logic - [x] tests - [x] loading states - [x] complete error messages - [x] docs - [x] migration guide (see https://github.com/readmeio/rdme/pull/1161) - [x] callouts in `documentation/rdme.md` (see https://github.com/readmeio/rdme/pull/1163) ### πŸ”œ to be addressed in follow-up PRs - [ ] safeguards for users that use bidirectional syncing (ticketed in RM-11883 and RM-11901) - [ ] updating the v9 migration guide + deprecation warnings (ticketed in RM-11902) ### things to call out - on a high level, we're mostly matching the functionality of v9's `rdme docs` in that we're just taking markdown files and sending its contents as a body payload to the ReadMe API. inferring page hierarchy from directory/file structure is out of scope for this work. - one big change from `rdme@9` is that we now validate page front matter against the schemas from our OAS before making any API requests and prompt the user to migrate any frontmatter that we recognize as legacy APIv1 attributes. i'm open to reevaluating this paradigm, but i think it'll be helpful for folks migrating. as an interim escape hatch, this command has a hidden `--skip-validation` flag, just in case. - our API expects category and parent page URIs to be a full URI (e.g., `/versions/stable/categories/guides/some-uri`), but that's a bit arduous for `rdme` users since most of the URI can be inferred from the `rdme` command context. as a result, `rdme` will automatically expand any category/parent URIs that don't match that pattern (i.e., a category URI front matter value of `some-uri` will be changed to `/versions/stable/categories/guides/some-uri` before we make the `POST`/`PATCH` request) - the vast majority of this diff is tests + fixtures + our OAS. the three files of note are: - `src/lib/syncPagePath.ts` - `src/lib/frontmatter.ts` - `src/commands/docs/upload.ts` - i've tried to build this in a testable and robust way where we can easily add support for reference pages, custom pages, etc. in the future - `PATCH` requests to our API (i.e., for making updates to guide pages) that include `slug` in the body payload will always rename the page. so if you have a slug called `some-page` and your body payload contains `some-page`, the API will change your slug to `some-page-1`. this is most likely unexpected behavior for users of `rdme`, so i'm **always** removing the slug from the body payload in favor of using the URL instead. the trade-off is that page renaming isn't really a thing via `rdme`, but that's why we support bidirectional syncing, right? ## 🧬 QA & Testing we're at nearly 100% test coverage with this work πŸ“ˆ to QA it yourself, check out this branch and run the following: ```sh # install + set up npm ci npm run build ``` ```sh # grab an API key from a readme-refactored-enabled project and pop it at the end of this command: bin/run.js docs upload __tests__/__fixtures__/docs/mixed-docs --key ``` --- README.md | 1 + .../docs/existing-docs/subdir/another-doc.md | 2 + .../docs/mixed-docs/doc-sans-attributes.md | 1 + .../docs/mixed-docs/invalid-attributes.md | 8 + .../docs/mixed-docs/legacy-category.md | 6 + .../docs/mixed-docs/new-doc-slug.md | 8 + .../docs/mixed-docs/not-a-markdown-file | 0 .../docs/mixed-docs/simple-doc.md | 5 + .../__fixtures__/docs/multiple-docs/child.md | 5 +- .../__fixtures__/docs/multiple-docs/friend.md | 2 +- .../docs/multiple-docs/grandparent.md | 2 +- .../__fixtures__/docs/multiple-docs/parent.md | 5 +- .../__fixtures__/docs/new-docs/new-doc.md | 3 +- .../docs/slug-docs/new-doc-slug.md | 5 +- .../docs/__snapshots__/upload.test.ts.snap | 525 ++ __tests__/commands/docs/upload.test.ts | 488 ++ __tests__/helpers/get-api-mock.ts | 6 + __tests__/lib/fetch.test.ts | 32 +- __tests__/lib/frontmatter.test.ts | 311 + documentation/commands/docs.md | 59 + documentation/migration-guide.md | 18 +- package-lock.json | 44 +- package.json | 6 +- src/commands/docs/upload.ts | 65 + src/index.ts | 3 + src/lib/apiError.ts | 4 +- src/lib/frontmatter.ts | 157 + src/lib/{readDoc.ts => readPage.ts} | 3 +- src/lib/readmeAPIFetch.ts | 64 + src/lib/syncDocsPath.legacy.ts | 4 +- src/lib/syncPagePath.ts | 418 ++ src/lib/types.ts | 5716 +++++++++++++++++ 32 files changed, 7953 insertions(+), 23 deletions(-) create mode 100644 __tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md create mode 100644 __tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md create mode 100644 __tests__/__fixtures__/docs/mixed-docs/legacy-category.md create mode 100644 __tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md create mode 100644 __tests__/__fixtures__/docs/mixed-docs/not-a-markdown-file create mode 100644 __tests__/__fixtures__/docs/mixed-docs/simple-doc.md create mode 100644 __tests__/commands/docs/__snapshots__/upload.test.ts.snap create mode 100644 __tests__/commands/docs/upload.test.ts create mode 100644 __tests__/lib/frontmatter.test.ts create mode 100644 documentation/commands/docs.md create mode 100644 src/commands/docs/upload.ts create mode 100644 src/lib/frontmatter.ts rename src/lib/{readDoc.ts => readPage.ts} (94%) create mode 100644 src/lib/syncPagePath.ts create mode 100644 src/lib/types.ts diff --git a/README.md b/README.md index e0b650ef1..0134c772d 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ npm run build && npm run build:docs * [`rdme autocomplete`](documentation/commands/autocomplete.md) - Display autocomplete installation instructions. * [`rdme changelogs`](documentation/commands/changelogs.md) - Upload Markdown files to your ReadMe project as Changelog posts. +* [`rdme docs`](documentation/commands/docs.md) - Upload Markdown files to the Guides section of your ReadMe project. * [`rdme help`](documentation/commands/help.md) - Display help for rdme. * [`rdme login`](documentation/commands/login.md) - Login to a ReadMe project. * [`rdme logout`](documentation/commands/logout.md) - Logs the currently authenticated user out of ReadMe. diff --git a/__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md b/__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md index 0b74ea3cc..378b2258b 100644 --- a/__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md +++ b/__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md @@ -1,5 +1,7 @@ --- title: This is another document title +category: + uri: /versions/stable/categories/guides/category-slug --- Another body diff --git a/__tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md b/__tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md new file mode 100644 index 000000000..e8eba3dc1 --- /dev/null +++ b/__tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md @@ -0,0 +1 @@ +Body diff --git a/__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md b/__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md new file mode 100644 index 000000000..1354f5c9a --- /dev/null +++ b/__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md @@ -0,0 +1,8 @@ +--- +category: + uri: some-category-uri + is-this-a-valid-property: nope +title: This is the document title +--- + +Body diff --git a/__tests__/__fixtures__/docs/mixed-docs/legacy-category.md b/__tests__/__fixtures__/docs/mixed-docs/legacy-category.md new file mode 100644 index 000000000..bd970fbec --- /dev/null +++ b/__tests__/__fixtures__/docs/mixed-docs/legacy-category.md @@ -0,0 +1,6 @@ +--- +category: 5ae122e10fdf4e39bb34db6f +title: This is the document title +--- + +Body diff --git a/__tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md b/__tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md new file mode 100644 index 000000000..b7efa04d0 --- /dev/null +++ b/__tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md @@ -0,0 +1,8 @@ +--- +category: + uri: some-category-uri +title: This is the document title +slug: some-slug +--- + +Body diff --git a/__tests__/__fixtures__/docs/mixed-docs/not-a-markdown-file b/__tests__/__fixtures__/docs/mixed-docs/not-a-markdown-file new file mode 100644 index 000000000..e69de29bb diff --git a/__tests__/__fixtures__/docs/mixed-docs/simple-doc.md b/__tests__/__fixtures__/docs/mixed-docs/simple-doc.md new file mode 100644 index 000000000..c4cae2b23 --- /dev/null +++ b/__tests__/__fixtures__/docs/mixed-docs/simple-doc.md @@ -0,0 +1,5 @@ +--- +title: This is the document title +--- + +Body diff --git a/__tests__/__fixtures__/docs/multiple-docs/child.md b/__tests__/__fixtures__/docs/multiple-docs/child.md index 216aead7e..e9358135e 100644 --- a/__tests__/__fixtures__/docs/multiple-docs/child.md +++ b/__tests__/__fixtures__/docs/multiple-docs/child.md @@ -1,6 +1,7 @@ --- title: Child -parentDocSlug: parent +parent: + uri: parent --- -# Child Body +Body diff --git a/__tests__/__fixtures__/docs/multiple-docs/friend.md b/__tests__/__fixtures__/docs/multiple-docs/friend.md index f406e500e..2d073d9ee 100644 --- a/__tests__/__fixtures__/docs/multiple-docs/friend.md +++ b/__tests__/__fixtures__/docs/multiple-docs/friend.md @@ -2,4 +2,4 @@ title: Friend --- -# Friend Body +Body diff --git a/__tests__/__fixtures__/docs/multiple-docs/grandparent.md b/__tests__/__fixtures__/docs/multiple-docs/grandparent.md index 5c7664f23..c11d1980b 100644 --- a/__tests__/__fixtures__/docs/multiple-docs/grandparent.md +++ b/__tests__/__fixtures__/docs/multiple-docs/grandparent.md @@ -2,4 +2,4 @@ title: Grandparent --- -# Grandparent Body +Body diff --git a/__tests__/__fixtures__/docs/multiple-docs/parent.md b/__tests__/__fixtures__/docs/multiple-docs/parent.md index 0efe16cfb..9a24962d8 100644 --- a/__tests__/__fixtures__/docs/multiple-docs/parent.md +++ b/__tests__/__fixtures__/docs/multiple-docs/parent.md @@ -1,6 +1,7 @@ --- title: Parent -parentDocSlug: grandparent +parent: + uri: grandparent --- -# Parent Body +Body diff --git a/__tests__/__fixtures__/docs/new-docs/new-doc.md b/__tests__/__fixtures__/docs/new-docs/new-doc.md index bd970fbec..102ae4788 100644 --- a/__tests__/__fixtures__/docs/new-docs/new-doc.md +++ b/__tests__/__fixtures__/docs/new-docs/new-doc.md @@ -1,5 +1,6 @@ --- -category: 5ae122e10fdf4e39bb34db6f +category: + uri: category-slug title: This is the document title --- diff --git a/__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md b/__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md index 336e0eaaf..b7efa04d0 100644 --- a/__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md +++ b/__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md @@ -1,7 +1,8 @@ --- -category: CATEGORY_ID +category: + uri: some-category-uri title: This is the document title -slug: marc-actually-wrote-a-test +slug: some-slug --- Body diff --git a/__tests__/commands/docs/__snapshots__/upload.test.ts.snap b/__tests__/commands/docs/__snapshots__/upload.test.ts.snap new file mode 100644 index 000000000..361e49797 --- /dev/null +++ b/__tests__/commands/docs/__snapshots__/upload.test.ts.snap @@ -0,0 +1,525 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rdme docs upload > given that the file path is a directory > given that the directory contains parent/child docs > should upload parents before children 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/multiple-docs/friend.md", + "response": {}, + "result": "created", + "slug": "friend", + }, + { + "filePath": "__tests__/__fixtures__/docs/multiple-docs/grandparent.md", + "response": {}, + "result": "created", + "slug": "grandparent", + }, + { + "filePath": "__tests__/__fixtures__/docs/multiple-docs/parent.md", + "response": {}, + "result": "created", + "slug": "parent", + }, + { + "filePath": "__tests__/__fixtures__/docs/multiple-docs/child.md", + "response": {}, + "result": "created", + "slug": "child", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/multiple-docs... +βœ” πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/multiple-docs... 4 file(s) found! +- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 4 page(s) in ReadMe: + - friend (__tests__/__fixtures__/docs/multiple-docs/friend.md) + - grandparent (__tests__/__fixtures__/docs/multiple-docs/grandparent.md) + - parent (__tests__/__fixtures__/docs/multiple-docs/parent.md) + - child (__tests__/__fixtures__/docs/multiple-docs/child.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a directory > should create a guides page in ReadMe for each file in the directory and its subdirectories 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/existing-docs/simple-doc.md", + "response": {}, + "result": "created", + "slug": "simple-doc", + }, + { + "filePath": "__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md", + "response": {}, + "result": "created", + "slug": "another-doc", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/existing-docs... +βœ” πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/existing-docs... 2 file(s) found! +- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 2 page(s) in ReadMe: + - simple-doc (__tests__/__fixtures__/docs/existing-docs/simple-doc.md) + - another-doc (__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a directory > should error out if the directory does not contain any Markdown files 1`] = ` +{ + "error": [Error: The directory you provided (__tests__/__fixtures__/ref-oas) doesn't contain any of the following file extensions: .markdown, .md.], + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/ref-oas... +βœ– πŸ” Looking for Markdown files in __tests__/__fixtures__/ref-oas... no files found. +", + "stdout": "", +} +`; + +exports[`rdme docs upload > given that the file path is a directory > should handle a mix of creates and updates and failures and skipped files (dry run) 1`] = ` +{ + "error": [AggregateError: Multiple dry runs failed. To see more detailed errors for a page, run \`rdme docs upload \` --dry-run.], + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/mixed-docs... +βœ” πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/mixed-docs... 5 file(s) found! +- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 2 file(s). + β€Ί Warning: 1 file(s) have issues that cannot be fixed automatically. The + β€Ί upload will proceed but we recommend addressing these issues. Please get + β€Ί in touch with us at support@readme.io if you need a hand. +- 🎭 Uploading files to ReadMe (but not really because it's a dry run)... +βœ– 🎭 Uploading files to ReadMe (but not really because it's a dry run)... 2 file(s) failed. +", + "stdout": "🌱 The following 1 page(s) do not currently exist in ReadMe and will be created: + - invalid-attributes (__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md) +πŸ”„ The following 1 page(s) already exist in ReadMe and will be updated: + - legacy-category (__tests__/__fixtures__/docs/mixed-docs/legacy-category.md) +⏭️ The following 1 page(s) will be skipped due to no frontmatter data: + - doc-sans-attributes (__tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md) +🚨 Unable to fetch data about the following 2 page(s): + - __tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. + - __tests__/__fixtures__/docs/mixed-docs/simple-doc.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. +", +} +`; + +exports[`rdme docs upload > given that the file path is a directory > should handle a mix of creates and updates and failures and skipped files 1`] = ` +{ + "error": [AggregateError: Multiple page uploads failed. To see more detailed errors for a page, run \`rdme docs upload \`.], + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/mixed-docs... +βœ” πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/mixed-docs... 5 file(s) found! +- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 2 file(s). + β€Ί Warning: 1 file(s) have issues that cannot be fixed automatically. The + β€Ί upload will proceed but we recommend addressing these issues. Please get + β€Ί in touch with us at support@readme.io if you need a hand. +- πŸš€ Uploading files to ReadMe... +βœ– πŸš€ Uploading files to ReadMe... 2 file(s) failed. +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - invalid-attributes (__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md) +πŸ”„ Successfully updated 1 page(s) in ReadMe: + - legacy-category (__tests__/__fixtures__/docs/mixed-docs/legacy-category.md) +⏭️ Skipped 1 page(s) in ReadMe due to no frontmatter data: + - doc-sans-attributes (__tests__/__fixtures__/docs/mixed-docs/doc-sans-attributes.md) +🚨 Received errors when attempting to upload 2 page(s): + - __tests__/__fixtures__/docs/mixed-docs/new-doc-slug.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. + - __tests__/__fixtures__/docs/mixed-docs/simple-doc.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. +", +} +`; + +exports[`rdme docs upload > given that the file path is a directory > should update existing guides pages in ReadMe for each file in the directory and its subdirectories 1`] = ` +{ + "result": { + "created": [], + "failed": [], + "skipped": [], + "updated": [ + { + "filePath": "__tests__/__fixtures__/docs/existing-docs/simple-doc.md", + "response": {}, + "result": "updated", + "slug": "simple-doc", + }, + { + "filePath": "__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md", + "response": {}, + "result": "updated", + "slug": "another-doc", + }, + ], + }, + "stderr": "- πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/existing-docs... +βœ” πŸ” Looking for Markdown files in __tests__/__fixtures__/docs/existing-docs... 2 file(s) found! +- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "πŸ”„ Successfully updated 2 page(s) in ReadMe: + - simple-doc (__tests__/__fixtures__/docs/existing-docs/simple-doc.md) + - another-doc (__tests__/__fixtures__/docs/existing-docs/subdir/another-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > and the command is being run in a CI environment > should create a guides page in ReadMe and include \`x-readme-source-url\` source header 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/new-docs/new-doc.md", + "response": {}, + "result": "created", + "slug": "new-doc", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - new-doc (__tests__/__fixtures__/docs/new-docs/new-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > and the command is being run in a CI environment > should error out if the file has validation errors 1`] = ` +{ + "error": [Error: 1 file(s) have issues that should be fixed before uploading to ReadMe. Please run \`rdme docs upload __tests__/__fixtures__/docs/mixed-docs/legacy-category.md --dry-run\` in a non-CI environment to fix them.], + "stderr": "- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 1 file(s). +", + "stdout": "", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the --dry-run flag is passed > should error out if a non-404 error is returned from the HEAD request 1`] = ` +{ + "error": [APIv2Error: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io.], + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- 🎭 Uploading files to ReadMe (but not really because it's a dry run)... +βœ– 🎭 Uploading files to ReadMe (but not really because it's a dry run)... 1 file(s) failed. +", + "stdout": "🚨 Unable to fetch data about the following 1 page(s): + - __tests__/__fixtures__/docs/slug-docs/new-doc-slug.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the --dry-run flag is passed > should not create anything in ReadMe 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/new-docs/new-doc.md", + "response": null, + "result": "created", + "slug": "new-doc", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- 🎭 Uploading files to ReadMe (but not really because it's a dry run)... +βœ” 🎭 Uploading files to ReadMe (but not really because it's a dry run)... done! +", + "stdout": "🌱 The following 1 page(s) do not currently exist in ReadMe and will be created: + - new-doc (__tests__/__fixtures__/docs/new-docs/new-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the --dry-run flag is passed > should not update anything in ReadMe 1`] = ` +{ + "result": { + "created": [], + "failed": [], + "skipped": [], + "updated": [ + { + "filePath": "__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md", + "response": null, + "result": "updated", + "slug": "some-slug", + }, + ], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- 🎭 Uploading files to ReadMe (but not really because it's a dry run)... +βœ” 🎭 Uploading files to ReadMe (but not really because it's a dry run)... done! +", + "stdout": "πŸ”„ The following 1 page(s) already exist in ReadMe and will be updated: + - some-slug (__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the file has frontmatter issues > should exit if the user declines to fix the issues 1`] = ` +{ + "error": [Error: Aborting upload due to user input.], + "stderr": "- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 1 file(s). +", + "stdout": "", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the file has frontmatter issues > should fix the frontmatter issues in the file and create the corrected file in ReadMe 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/mixed-docs/legacy-category.md", + "response": {}, + "result": "created", + "slug": "legacy-category", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 1 file(s). +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - legacy-category (__tests__/__fixtures__/docs/mixed-docs/legacy-category.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the file has frontmatter issues > should fix the frontmatter issues in the file and insert the proper category mapping 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/mixed-docs/legacy-category.md", + "response": {}, + "result": "created", + "slug": "legacy-category", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 1 file(s). +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - legacy-category (__tests__/__fixtures__/docs/mixed-docs/legacy-category.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the file has frontmatter issues > should skip client-side validation if the --skip-validation flag is passed 1`] = ` +{ + "error": [APIv2Error: ReadMe API error: bad request + +your category is whack], + "stderr": " β€Ί Warning: Skipping pre-upload validation of the Markdown file(s). This is + β€Ί not recommended. +- πŸš€ Uploading files to ReadMe... +βœ– πŸš€ Uploading files to ReadMe... 1 file(s) failed. +", + "stdout": "🚨 Received errors when attempting to upload 1 page(s): + - __tests__/__fixtures__/docs/mixed-docs/legacy-category.md: bad request +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the file has frontmatter issues > should warn user if the file has no autofixable issues 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md", + "response": {}, + "result": "created", + "slug": "invalid-attributes", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +⚠ πŸ”¬ Validating frontmatter data... issues found in 1 file(s). + β€Ί Warning: 1 file(s) have issues that cannot be fixed automatically. The + β€Ί upload will proceed but we recommend addressing these issues. Please get + β€Ί in touch with us at support@readme.io if you need a hand. +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - invalid-attributes (__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the slug is passed in the frontmatter > should use that slug to create a page in ReadMe 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md", + "response": {}, + "result": "created", + "slug": "some-slug", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - some-slug (__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > given that the slug is passed in the frontmatter > should use that slug update an existing guides page in ReadMe 1`] = ` +{ + "result": { + "created": [], + "failed": [], + "skipped": [], + "updated": [ + { + "filePath": "__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md", + "response": {}, + "result": "updated", + "slug": "some-slug", + }, + ], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "πŸ”„ Successfully updated 1 page(s) in ReadMe: + - some-slug (__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > should allow for user to specify version via --version flag 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/new-docs/new-doc.md", + "response": {}, + "result": "created", + "slug": "new-doc", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - new-doc (__tests__/__fixtures__/docs/new-docs/new-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > should create a guides page in ReadMe 1`] = ` +{ + "result": { + "created": [ + { + "filePath": "__tests__/__fixtures__/docs/new-docs/new-doc.md", + "response": {}, + "result": "created", + "slug": "new-doc", + }, + ], + "failed": [], + "skipped": [], + "updated": [], + }, + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ” πŸš€ Uploading files to ReadMe... done! +", + "stdout": "🌱 Successfully created 1 page(s) in ReadMe: + - new-doc (__tests__/__fixtures__/docs/new-docs/new-doc.md) +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > should error out if a non-404 error is returned from the HEAD request 1`] = ` +{ + "error": [APIv2Error: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io.], + "stderr": "- πŸ”¬ Validating frontmatter data... +βœ” πŸ”¬ Validating frontmatter data... no issues found! +- πŸš€ Uploading files to ReadMe... +βœ– πŸš€ Uploading files to ReadMe... 1 file(s) failed. +", + "stdout": "🚨 Received errors when attempting to upload 1 page(s): + - __tests__/__fixtures__/docs/new-docs/new-doc.md: The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io. +", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > should error out if the file does not exist 1`] = ` +{ + "error": [Error: Oops! We couldn't locate a file or directory at the path you provided.], + "stderr": "", + "stdout": "", +} +`; + +exports[`rdme docs upload > given that the file path is a single file > should error out if the file has an invalid file extension 1`] = ` +{ + "error": [Error: Invalid file extension (.json). Must be one of the following: .markdown, .md], + "stderr": "", + "stdout": "", +} +`; diff --git a/__tests__/commands/docs/upload.test.ts b/__tests__/commands/docs/upload.test.ts new file mode 100644 index 000000000..6ddeef162 --- /dev/null +++ b/__tests__/commands/docs/upload.test.ts @@ -0,0 +1,488 @@ +import fs from 'node:fs'; + +import nock from 'nock'; +import prompts from 'prompts'; +import { describe, afterEach, it, expect, beforeAll, beforeEach, vi } from 'vitest'; + +import Command from '../../../src/commands/docs/upload.js'; +import { getAPIv1Mock, getAPIv2Mock, getAPIv2MockForGHA } from '../../helpers/get-api-mock.js'; +import { runCommand, type OclifOutput } from '../../helpers/oclif.js'; +import { after, before } from '../../helpers/setup-gha-env.js'; + +const key = 'rdme_123'; +const authorization = `Bearer ${key}`; + +describe('rdme docs upload', () => { + let run: (args?: string[]) => OclifOutput; + + beforeAll(() => { + run = runCommand(Command); + }); + + beforeEach(() => { + getAPIv1Mock().get('/api/v1/migration').basicAuth({ user: key }).reply(201, { + categories: {}, + parentPages: {}, + }); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('given that the file path is a single file', () => { + it('should create a guides page in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/new-doc') + .reply(404) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/category-slug' }, + slug: 'new-doc', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/new-docs/new-doc.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should allow for user to specify version via --version flag', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/1.2.3/guides/new-doc') + .reply(404) + .post('/versions/1.2.3/guides', { + category: { uri: '/versions/1.2.3/categories/guides/category-slug' }, + slug: 'new-doc', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/new-docs/new-doc.md', '--key', key, '--version', '1.2.3']); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + describe('given that the --dry-run flag is passed', () => { + it('should not create anything in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }).head('/versions/stable/guides/new-doc').reply(404); + + const result = await run(['__tests__/__fixtures__/docs/new-docs/new-doc.md', '--key', key, '--dry-run']); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should not update anything in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }).head('/versions/stable/guides/some-slug').reply(200); + + const result = await run(['__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md', '--key', key, '--dry-run']); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should error out if a non-404 error is returned from the HEAD request', async () => { + const mock = getAPIv2Mock({ authorization }).head('/versions/stable/guides/some-slug').reply(500); + + const result = await run(['__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md', '--key', key, '--dry-run']); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + }); + + describe('given that the slug is passed in the frontmatter', () => { + it('should use that slug to create a page in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/some-slug') + .reply(404) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/some-category-uri' }, + title: 'This is the document title', + content: { body: '\nBody\n' }, + slug: 'some-slug', + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should use that slug update an existing guides page in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/some-slug') + .reply(200) + .patch('/versions/stable/guides/some-slug', { + category: { uri: '/versions/stable/categories/guides/some-category-uri' }, + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/slug-docs/new-doc-slug.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + }); + + describe('given that the file has frontmatter issues', () => { + it('should fix the frontmatter issues in the file and create the corrected file in ReadMe', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/legacy-category') + .reply(404) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/uri-that-does-not-map-to-5ae122e10fdf4e39bb34db6f' }, + slug: 'legacy-category', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + prompts.inject([true]); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', + expect.stringContaining('uri: uri-that-does-not-map-to-5ae122e10fdf4e39bb34db6f'), + { encoding: 'utf-8' }, + ); + + mock.done(); + }); + + it('should fix the frontmatter issues in the file and insert the proper category mapping', async () => { + nock.cleanAll(); + const mappingsMock = getAPIv1Mock() + .get('/api/v1/migration') + .basicAuth({ user: key }) + .reply(201, { + categories: { '5ae122e10fdf4e39bb34db6f': 'mapped-uri' }, + }); + + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/legacy-category') + .reply(404) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/mapped-uri' }, + slug: 'legacy-category', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + prompts.inject([true]); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', + expect.stringContaining('uri: mapped-uri'), + { encoding: 'utf-8' }, + ); + + mappingsMock.done(); + mock.done(); + }); + + it('should exit if the user declines to fix the issues', async () => { + prompts.inject([false]); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should skip client-side validation if the --skip-validation flag is passed', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/legacy-category') + .reply(404) + .post('/versions/stable/guides', { + category: '5ae122e10fdf4e39bb34db6f', + slug: 'legacy-category', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(400, { title: 'bad request', detail: 'your category is whack' }); + + const result = await run([ + '__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', + '--key', + key, + '--skip-validation', + ]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should warn user if the file has no autofixable issues', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/invalid-attributes') + .reply(404) + .post('/versions/stable/guides', { + category: { + uri: '/versions/stable/categories/guides/some-category-uri', + 'is-this-a-valid-property': 'nope', + }, + slug: 'invalid-attributes', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs/invalid-attributes.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + }); + + describe('and the command is being run in a CI environment', () => { + beforeEach(before); + + afterEach(after); + + it('should create a guides page in ReadMe and include `x-readme-source-url` source header', async () => { + const headMock = getAPIv2MockForGHA({ authorization }).head('/versions/stable/guides/new-doc').reply(404); + + const postMock = getAPIv2MockForGHA({ + authorization, + 'x-readme-source-url': + 'https://github.com/octocat/Hello-World/blob/ffac537e6cbbf934b08745a378932722df287a53/__tests__/__fixtures__/docs/new-docs/new-doc.md', + }) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/category-slug' }, + slug: 'new-doc', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/new-docs/new-doc.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + headMock.done(); + postMock.done(); + }); + + it('should error out if the file has validation errors', async () => { + const result = await run(['__tests__/__fixtures__/docs/mixed-docs/legacy-category.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + it('should error out if a non-404 error is returned from the HEAD request', async () => { + const mock = getAPIv2Mock({ authorization }).head('/versions/stable/guides/new-doc').reply(500); + + const result = await run(['__tests__/__fixtures__/docs/new-docs/new-doc.md', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should error out if the file does not exist', async () => { + const result = await run(['non-existent-file', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should error out if the file has an invalid file extension', async () => { + const result = await run(['package.json', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('given that the file path is a directory', () => { + it('should create a guides page in ReadMe for each file in the directory and its subdirectories', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/simple-doc') + .reply(404) + .post('/versions/stable/guides', { + slug: 'simple-doc', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/another-doc') + .reply(404) + .post('/versions/stable/guides', { + slug: 'another-doc', + title: 'This is another document title', + content: { body: '\nAnother body\n' }, + category: { uri: '/versions/stable/categories/guides/category-slug' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/existing-docs', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + it('should update existing guides pages in ReadMe for each file in the directory and its subdirectories', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/simple-doc') + .reply(200) + .patch('/versions/stable/guides/simple-doc', { + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/another-doc') + .reply(200) + .patch('/versions/stable/guides/another-doc', { + title: 'This is another document title', + category: { uri: '/versions/stable/categories/guides/category-slug' }, + content: { body: '\nAnother body\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/existing-docs', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + + describe('given that the directory contains parent/child docs', () => { + it('should upload parents before children', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/child') + .reply(404) + .post('/versions/stable/guides', { + slug: 'child', + title: 'Child', + parent: { uri: '/versions/stable/guides/parent' }, + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/friend') + .reply(404) + .post('/versions/stable/guides', { + slug: 'friend', + title: 'Friend', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/parent') + .reply(404) + .post('/versions/stable/guides', { + slug: 'parent', + title: 'Parent', + parent: { uri: '/versions/stable/guides/grandparent' }, + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/grandparent') + .reply(404) + .post('/versions/stable/guides', { + slug: 'grandparent', + title: 'Grandparent', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run(['__tests__/__fixtures__/docs/multiple-docs', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + + mock.done(); + }); + }); + + it('should error out if the directory does not contain any Markdown files', async () => { + const result = await run(['__tests__/__fixtures__/ref-oas', '--key', key]); + expect(result).toMatchSnapshot(); + }); + + it('should handle a mix of creates and updates and failures and skipped files', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/invalid-attributes') + .reply(404) + .post('/versions/stable/guides', { + category: { uri: '/versions/stable/categories/guides/some-category-uri', 'is-this-a-valid-property': 'nope' }, + slug: 'invalid-attributes', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/legacy-category') + .reply(200) + .patch('/versions/stable/guides/legacy-category', { + category: { uri: '/versions/stable/categories/guides/uri-that-does-not-map-to-5ae122e10fdf4e39bb34db6f' }, + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .head('/versions/stable/guides/some-slug') + .reply(404) + .post('/versions/stable/guides', { + slug: 'some-slug', + title: 'This is the document title', + category: { uri: '/versions/stable/categories/guides/some-category-uri' }, + content: { body: '\nBody\n' }, + }) + .reply(500, {}) + .head('/versions/stable/guides/simple-doc') + .reply(404) + .post('/versions/stable/guides', { + slug: 'simple-doc', + title: 'This is the document title', + content: { body: '\nBody\n' }, + }) + .reply(500, {}); + + prompts.inject([true]); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs', '--key', key]); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(5); + + mock.done(); + }); + + it('should handle a mix of creates and updates and failures and skipped files (dry run)', async () => { + const mock = getAPIv2Mock({ authorization }) + .head('/versions/stable/guides/invalid-attributes') + .reply(404) + .head('/versions/stable/guides/legacy-category') + .reply(200) + .head('/versions/stable/guides/some-slug') + .reply(500) + .head('/versions/stable/guides/simple-doc') + .reply(500); + + prompts.inject([true]); + + const result = await run(['__tests__/__fixtures__/docs/mixed-docs', '--key', key, '--dry-run']); + expect(result).toMatchSnapshot(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(5); + + mock.done(); + }); + }); +}); diff --git a/__tests__/helpers/get-api-mock.ts b/__tests__/helpers/get-api-mock.ts index 687ddabe5..725e984c1 100644 --- a/__tests__/helpers/get-api-mock.ts +++ b/__tests__/helpers/get-api-mock.ts @@ -2,6 +2,7 @@ import nock from 'nock'; import config from '../../src/lib/config.js'; import { getUserAgent } from '../../src/lib/readmeAPIFetch.js'; +import { readmeAPIv2Oas } from '../../src/lib/types.js'; import { mockVersion } from './oclif.js'; @@ -42,3 +43,8 @@ export function getAPIv2MockForGHA(reqHeaders: nock.Options['reqheaders'] = {}) ...reqHeaders, }); } + +/** + * Mocks the fetch call to ReadMe API v2's OpenAPI spec. + */ +export const oasFetchMock = (status = 200) => getAPIv2Mock().get('/openapi.json').reply(status, readmeAPIv2Oas); diff --git a/__tests__/lib/fetch.test.ts b/__tests__/lib/fetch.test.ts index 0c0e32ee5..97ed38fc8 100644 --- a/__tests__/lib/fetch.test.ts +++ b/__tests__/lib/fetch.test.ts @@ -2,8 +2,10 @@ import { describe, beforeEach, afterEach, it, expect, vi, type MockInstance } from 'vitest'; import pkg from '../../package.json' with { type: 'json' }; -import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from '../../src/lib/readmeAPIFetch.js'; -import { getAPIv1Mock } from '../helpers/get-api-mock.js'; +import DocsUploadCommand from '../../src/commands/docs/upload.js'; +import { cleanAPIv1Headers, fetchSchema, handleAPIv1Res, readmeAPIv1Fetch } from '../../src/lib/readmeAPIFetch.js'; +import { getAPIv1Mock, oasFetchMock } from '../helpers/get-api-mock.js'; +import { setupOclifConfig } from '../helpers/oclif.js'; import { after, before } from '../helpers/setup-gha-env.js'; describe('#readmeAPIv1Fetch()', () => { @@ -361,3 +363,29 @@ describe('#cleanAPIv1Headers()', () => { ]); }); }); + +describe('#fetchSchema', () => { + it('should fetch the schema', async () => { + const mock = oasFetchMock(); + + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + const schema = await fetchSchema.call(command); + + expect(schema.type).toBe('object'); + + mock.done(); + }); + + it('should have a fallback value in case fetch fails', async () => { + const mock = oasFetchMock(500); + + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + const schema = await fetchSchema.call(command); + + expect(schema.type).toBe('object'); + + mock.done(); + }); +}); diff --git a/__tests__/lib/frontmatter.test.ts b/__tests__/lib/frontmatter.test.ts new file mode 100644 index 000000000..1595aa562 --- /dev/null +++ b/__tests__/lib/frontmatter.test.ts @@ -0,0 +1,311 @@ +import type nock from 'nock'; +import type { SchemaObject } from 'oas/types'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import DocsUploadCommand from '../../src/commands/docs/upload.js'; +import { fix } from '../../src/lib/frontmatter.js'; +import { emptyMappings, fetchSchema } from '../../src/lib/readmeAPIFetch.js'; +import { oasFetchMock } from '../helpers/get-api-mock.js'; +import { setupOclifConfig } from '../helpers/oclif.js'; + +describe('#fix', () => { + let command: DocsUploadCommand; + let mock: nock.Scope; + let schema: SchemaObject; + + beforeEach(async () => { + const oclifConfig = await setupOclifConfig(); + command = new DocsUploadCommand([], oclifConfig); + mock = oasFetchMock(); + schema = await fetchSchema.call(command); + }); + + afterEach(() => { + mock.done(); + }); + + it('should do nothing for an empty object', () => { + const data = {}; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(false); + expect(result.updatedData).toStrictEqual(data); + }); + + it('should do nothing for valid frontmatter', () => { + const data = { + title: 'Hello, world!', + category: { uri: '/versions/stable/categories/guides/sup' }, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(false); + expect(result.updatedData).toStrictEqual(data); + }); + + it('should do nothing for valid frontmatter (with invalid category uri)', () => { + const data = { + title: 'Hello, world!', + category: { uri: 'sup' }, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(false); + expect(result.updatedData).toStrictEqual(data); + }); + + it('should do nothing for valid frontmatter (with invalid parent uri)', () => { + const data = { + title: 'Hello, world!', + parent: { uri: 'sup' }, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(false); + expect(result.updatedData).toStrictEqual(data); + }); + + it('should fix legacy category id and use mappings', () => { + const data = { + title: 'Hello, world!', + category: '5f92cbf10cf217478ba93561', + }; + + const result = fix.call(command, data, schema, { + categories: { '5f92cbf10cf217478ba93561': 'some-slug' }, + parentPages: {}, + }); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "category": { + "uri": "some-slug", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix legacy category id and use fallback mapping', () => { + const data = { + title: 'Hello, world!', + category: '5f92cbf10cf217478ba93561', + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "category": { + "uri": "uri-that-does-not-map-to-5f92cbf10cf217478ba93561", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix legacy category slug', () => { + const data = { + title: 'Hello, world!', + categorySlug: 'some-slug', + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "category": { + "uri": "some-slug", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix legacy parent page id and use mappings', () => { + const data = { + title: 'Hello, world!', + parentDoc: '5f92cbf10cf217478ba93561', + }; + + const result = fix.call(command, data, schema, { + categories: {}, + parentPages: { '5f92cbf10cf217478ba93561': 'some-slug' }, + }); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "parent": { + "uri": "some-slug", + }, + "title": "Hello, world!", + } + `); + }); + + it('should delete legacy parent page id if no mapping is available', () => { + const data = { + title: 'Hello, world!', + parentDoc: '5f92cbf10cf217478ba93561', + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "title": "Hello, world!", + } + `); + }); + + it('should fix legacy parent page slug', () => { + const data = { + title: 'Hello, world!', + parentDocSlug: 'some-slug', + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "parent": { + "uri": "some-slug", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix excerpt', () => { + const data = { + title: 'Hello, world!', + excerpt: 'This is an excerpt', + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "content": { + "excerpt": "This is an excerpt", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix position', () => { + const data = { + title: 'Hello, world!', + order: 5, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "position": 5, + "title": "Hello, world!", + } + `); + }); + + it('should fix privacy (public)', () => { + const data = { + title: 'Hello, world!', + hidden: false, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "privacy": { + "view": "public", + }, + "title": "Hello, world!", + } + `); + }); + + it('should fix privacy (anyone_with_link)', () => { + const data = { + title: 'Hello, world!', + hidden: true, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "privacy": { + "view": "anyone_with_link", + }, + "title": "Hello, world!", + } + `); + }); + + it.todo('should fix metadata object'); + + it.todo('should fix content.link object'); + + it.todo('should fix content.next object'); + + it('should fix multiple issues', () => { + const data = { + title: 'Hello, world!', + category: '5f92cbf10cf217478ba93561', + parentDocSlug: 'some-parent-slug', + excerpt: 'This is an excerpt', + content: { + body: 'This is the body', + }, + order: 7, + hidden: true, + slug: 'some-slug', + }; + + const result = fix.call(command, data, schema, { + categories: { '5f92cbf10cf217478ba93561': 'some-category-slug' }, + parentPages: { '5f92cbf10cf217478ba93561': 'some-parent-slug' }, + }); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).toMatchInlineSnapshot(` + { + "category": { + "uri": "some-category-slug", + }, + "content": { + "body": "This is the body", + "excerpt": "This is an excerpt", + }, + "parent": { + "uri": "some-parent-slug", + }, + "position": 7, + "privacy": { + "view": "anyone_with_link", + }, + "slug": "some-slug", + "title": "Hello, world!", + } + `); + }); +}); diff --git a/documentation/commands/docs.md b/documentation/commands/docs.md new file mode 100644 index 000000000..2d521c940 --- /dev/null +++ b/documentation/commands/docs.md @@ -0,0 +1,59 @@ +`rdme docs` +=========== + +Upload Markdown files to the Guides section of your ReadMe project. + +* [`rdme docs upload PATH`](#rdme-docs-upload-path) + +## `rdme docs upload PATH` + +Upload Markdown files to the Guides section of your ReadMe project. + +``` +USAGE + $ rdme docs upload PATH --key [--github] [--dry-run] [--version ] + +ARGUMENTS + PATH Path to a local Markdown file or folder of Markdown files. + +FLAGS + --dry-run Runs the command without creating nor updating any Guides in ReadMe. Useful for debugging. + --github Create a new GitHub Actions workflow for this command. + --key= (required) ReadMe project API key + --version= [default: stable] ReadMe project version + +DESCRIPTION + Upload Markdown files to the Guides section of your ReadMe project. + + The path can either be a directory or a single Markdown file. The Markdown files will require YAML frontmatter with + certain ReadMe documentation attributes. Check out our docs for more info on setting up your frontmatter: + https://docs.readme.com/main/docs/rdme#markdown-file-setup + +EXAMPLES + The path input can be a directory. This will also upload any Markdown files that are located in subdirectories: + + $ rdme docs upload documentation/ --version={project-version} + + The path input can also be individual Markdown files: + + $ rdme docs upload documentation/about.md --version={project-version} + + You can omit the `--version` flag to default to the `stable` version of your project: + + $ rdme docs upload [path] + + This command also has a dry run mode, which can be useful for initial setup and debugging. You can read more about + dry run mode in our docs: https://docs.readme.com/main/docs/rdme#dry-run-mode + + $ rdme docs upload [path] --dry-run + +FLAG DESCRIPTIONS + --key= ReadMe project API key + + An API key for your ReadMe project. Note that API authentication is required despite being omitted from the example + usage. See our docs for more information: https://github.com/readmeio/rdme/tree/v10#authentication + + --version= ReadMe project version + + Defaults to `stable` (i.e., your main project version). +``` diff --git a/documentation/migration-guide.md b/documentation/migration-guide.md index 647373f96..ef80d8da4 100644 --- a/documentation/migration-guide.md +++ b/documentation/migration-guide.md @@ -74,7 +74,7 @@ If you're using the `rdme` GitHub Action, update your GitHub Actions workflow fi - Replace: `openapi` β†’ `openapi upload` (see more in step 3 below) - Replace: `categories` β†’ use [Git-based workflow](https://docs.readme.com/main/docs/bi-directional-sync) - Replace: `custompages` β†’ use [Git-based workflow](https://docs.readme.com/main/docs/bi-directional-sync) - - Replace: `docs` (and its `guides` alias) β†’ use [Git-based workflow](https://docs.readme.com/main/docs/bi-directional-sync) + - Replace: `docs` (and its `guides` alias) β†’ `docs upload` (see more in step 4 below) - Replace: `versions` β†’ use [Git-based workflow](https://docs.readme.com/main/docs/bi-directional-sync) - Remove: `open` @@ -84,9 +84,21 @@ If you're using the `rdme` GitHub Action, update your GitHub Actions workflow fi - There is no prompt to select your ReadMe project version if you omit the `--version` flag. It now defaults to `stable` (i.e., your main ReadMe project version). - - Previously with `openapi`, the `--id` flag was an ObjectID that required an initial upload to ReadMe, which made it difficult to upsert API definitions and manage many at scale. With `openapi upload`, the `--id` flag has been renamed to `--slug` and is now optional. The slug (i.e., the unique identifier for your API definition resource in ReadMe) is inferred from the file path or URL to your API definition. + - Previously with `openapi`, the `--id` flag was an ObjectId that required an initial upload to ReadMe, which made it difficult to upsert API definitions and manage many at scale. With `openapi upload`, the `--id` flag has been renamed to `--slug` and is now optional. The slug (i.e., the unique identifier for your API definition resource in ReadMe) is inferred from the file path or URL to your API definition. - Read more in [the `openapi upload` command docs](https://github.com/readmeio/rdme/tree/v10/documentation/commands/openapi.md#rdme-openapi-upload-spec). + Read more in [the `openapi upload` command docs](https://github.com/readmeio/rdme/tree/v10/documentation/commands/openapi.md#rdme-openapi-upload-spec) and in [the ReadMe API migration guide](https://docs.readme.com/main/reference/api-migration-guide). + +4. **`docs` has been replaced by `docs upload`** + + If you previously uploaded Markdown files to your Guides section via `rdme docs`, the command is now `rdme docs upload`. The command semantics are largely the same, but with a few small changes: + + - The `--dryRun` flag has been deprecated in favor of `--dry-run`. + + - Like `openapi upload` above, there is no prompt to select your ReadMe project version if you omit the `--version` flag. It now defaults to `stable` (i.e., your main ReadMe project version). + + - `rdme docs upload` will now automatically validate your frontmatter and flag any issues prior to syncing. This is particularly helpful if you're coming from `rdme@9` or earlier, since the shape of certain frontmatter attributes (e.g., `category`, `parent`) have slightly changed. If you run this command in a non-CI environment, any outdated frontmatter will be detected and you'll have the ability to update it automatically. + + Read more in [the `docs upload` command docs](https://github.com/readmeio/rdme/tree/v10/documentation/commands/docs.md#rdme-docs-upload-path) and in [the ReadMe API migration guide](https://docs.readme.com/main/reference/api-migration-guide). ## Migrating to `rdme@9` diff --git a/package-lock.json b/package-lock.json index 87d3bbc89..897056fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "@oclif/plugin-help": "^6.2.15", "@oclif/plugin-not-found": "^3.2.28", "@oclif/plugin-warn-if-update-available": "^3.1.19", + "@readme/better-ajv-errors": "^2.0.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "ci-info": "^4.0.0", "configstore": "^7.0.0", @@ -43,7 +46,6 @@ "@commitlint/cli": "^19.0.3", "@commitlint/config-conventional": "^19.0.3", "@oclif/test": "^4.1.0", - "@readme/better-ajv-errors": "^2.0.0", "@readme/eslint-config": "^14.0.0", "@readme/oas-examples": "^5.10.0", "@rollup/plugin-commonjs": "^28.0.0", @@ -61,11 +63,11 @@ "@types/validator": "^13.7.6", "@vitest/coverage-v8": "^3.0.0", "@vitest/expect": "^3.0.0", - "ajv": "^8.11.0", "alex": "^11.0.0", "eslint": "^8.47.0", "husky": "^9.0.10", "js-yaml": "^4.1.0", + "json-schema-to-ts": "^3.1.1", "knip": "^5.0.2", "nock": "^14.0.0", "oclif": "^4.15.12", @@ -5074,6 +5076,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/alex": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/alex/-/alex-11.0.1.tgz", @@ -11216,6 +11235,20 @@ "node": ">=12.0.0" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -17129,6 +17162,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", diff --git a/package.json b/package.json index 2cb34017e..44526f8c7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@oclif/plugin-help": "^6.2.15", "@oclif/plugin-not-found": "^3.2.28", "@oclif/plugin-warn-if-update-available": "^3.1.19", + "@readme/better-ajv-errors": "^2.0.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "ci-info": "^4.0.0", "configstore": "^7.0.0", @@ -72,7 +75,6 @@ "@commitlint/cli": "^19.0.3", "@commitlint/config-conventional": "^19.0.3", "@oclif/test": "^4.1.0", - "@readme/better-ajv-errors": "^2.0.0", "@readme/eslint-config": "^14.0.0", "@readme/oas-examples": "^5.10.0", "@rollup/plugin-commonjs": "^28.0.0", @@ -90,11 +92,11 @@ "@types/validator": "^13.7.6", "@vitest/coverage-v8": "^3.0.0", "@vitest/expect": "^3.0.0", - "ajv": "^8.11.0", "alex": "^11.0.0", "eslint": "^8.47.0", "husky": "^9.0.10", "js-yaml": "^4.1.0", + "json-schema-to-ts": "^3.1.1", "knip": "^5.0.2", "nock": "^14.0.0", "oclif": "^4.15.12", diff --git a/src/commands/docs/upload.ts b/src/commands/docs/upload.ts new file mode 100644 index 000000000..dd0c1e25e --- /dev/null +++ b/src/commands/docs/upload.ts @@ -0,0 +1,65 @@ +import { Args, Flags } from '@oclif/core'; + +import BaseCommand from '../../lib/baseCommand.js'; +import { githubFlag, keyFlag } from '../../lib/flags.js'; +import syncPagePath from '../../lib/syncPagePath.js'; + +export default class DocsUploadCommand extends BaseCommand { + id = 'docs upload' as const; + + route = 'guides' as const; + + static summary = 'Upload Markdown files to the Guides section of your ReadMe project.'; + + static description = + 'The path can either be a directory or a single Markdown file. The Markdown files will require YAML frontmatter with certain ReadMe documentation attributes. Check out our docs for more info on setting up your frontmatter: https://docs.readme.com/main/docs/rdme#markdown-file-setup'; + + static args = { + path: Args.string({ description: 'Path to a local Markdown file or folder of Markdown files.', required: true }), + }; + + static examples = [ + { + description: + 'The path input can be a directory. This will also upload any Markdown files that are located in subdirectories:', + command: '<%= config.bin %> <%= command.id %> documentation/ --version={project-version}', + }, + { + description: 'The path input can also be individual Markdown files:', + command: '<%= config.bin %> <%= command.id %> documentation/about.md --version={project-version}', + }, + { + description: 'You can omit the `--version` flag to default to the `stable` version of your project:', + command: '<%= config.bin %> <%= command.id %> [path]', + }, + { + description: + 'This command also has a dry run mode, which can be useful for initial setup and debugging. You can read more about dry run mode in our docs: https://docs.readme.com/main/docs/rdme#dry-run-mode', + command: '<%= config.bin %> <%= command.id %> [path] --dry-run', + }, + ]; + + static flags = { + github: githubFlag, + key: keyFlag, + 'dry-run': Flags.boolean({ + description: 'Runs the command without creating nor updating any Guides in ReadMe. Useful for debugging.', + aliases: ['dryRun'], + deprecateAliases: true, + }), + 'skip-validation': Flags.boolean({ + description: + 'Skips the pre-upload validation of the Markdown files. This flag can be a useful escape hatch but its usage is not recommended.', + hidden: true, + }), + version: Flags.string({ + summary: 'ReadMe project version', + description: 'Defaults to `stable` (i.e., your main project version).', + default: 'stable', + }), + }; + + async run() { + return syncPagePath.call(this); + } +} diff --git a/src/index.ts b/src/index.ts index a0a63f7b0..b568bfd61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import type { ValueOf } from 'type-fest'; import ChangelogsCommand from './commands/changelogs.js'; +import DocsUploadCommand from './commands/docs/upload.js'; import LoginCommand from './commands/login.js'; import LogoutCommand from './commands/logout.js'; import OpenAPIConvertCommand from './commands/openapi/convert.js'; @@ -26,6 +27,8 @@ export { default as prerun } from './lib/hooks/prerun.js'; export const COMMANDS = { changelogs: ChangelogsCommand, + 'docs:upload': DocsUploadCommand, + login: LoginCommand, logout: LogoutCommand, diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts index 0d72c4c45..22cb9b091 100644 --- a/src/lib/apiError.ts +++ b/src/lib/apiError.ts @@ -87,7 +87,7 @@ export class APIv2Error extends Error { 'The ReadMe API responded with an unexpected error. Please try again and if this issue persists, get in touch with us at support@readme.io.'; if (res.title) { - stringified = `The ReadMe API returned the following error:\n\n${chalk.bold(res.title)}`; + stringified = `ReadMe API error: ${chalk.bold(res.title)}`; } if (res.detail) { @@ -95,7 +95,7 @@ export class APIv2Error extends Error { } if (res.errors?.length) { - stringified += `\n\n${res.errors.map((e, i) => `${i + 1}. ${chalk.bold(e.key)}: ${e.message}`).join('\n')}`; + stringified += `\n\n${res.errors.map((e, i) => `${i + 1}. ${chalk.underline(e.key)}: ${e.message}`).join('\n')}`; } super(stringified); diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts new file mode 100644 index 000000000..0011ebbbc --- /dev/null +++ b/src/lib/frontmatter.ts @@ -0,0 +1,157 @@ +import type { Mappings } from './readmeAPIFetch.js'; +import type { PageMetadata } from './readPage.js'; +import type { CommandClass } from '../index.js'; +import type { ErrorObject } from 'ajv'; +import type { SchemaObject } from 'oas/types'; + +import fs from 'node:fs'; + +import { Ajv } from 'ajv'; +import _addFormats from 'ajv-formats'; +import grayMatter from 'gray-matter'; + +// workaround from here: https://github.com/ajv-validator/ajv-formats/issues/85#issuecomment-2262652443 +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +/** + * Validates the frontmatter data, fixes any issues, and returns the results. + */ +export function fix( + this: CommandClass['prototype'], + /** frontmatter data to be validated */ + data: PageMetadata['data'], + /** schema to validate against */ + schema: SchemaObject, + /** + * mappings of object IDs to slugs + * (e.g., category IDs to category URIs) + */ + mappings: Mappings, +): { + changeCount: number; + errors: ErrorObject[]; + hasIssues: boolean; + unfixableErrors: ErrorObject[]; + updatedData: PageMetadata['data']; +} { + if (!Object.keys(data).length) { + this.debug('no frontmatter attributes found, skipping validation'); + return { changeCount: 0, errors: [], hasIssues: false, unfixableErrors: [], updatedData: data }; + } + + const ajv = new Ajv({ allErrors: true, strictTypes: false, strictTuples: false }); + + addFormats(ajv); + + const ajvValidate = ajv.compile(schema); + + ajvValidate(data); + + let errors: ErrorObject[] | undefined; + + if (ajvValidate?.errors) { + errors = ajvValidate.errors; + this.debug(`${errors.length} errors found: ${JSON.stringify(errors)}`); + + // if one of the errors is that the category uri doesn't match the pattern, + // we can ignore it since we normalize it later + const categoryUriPatternErrorIndex = ajvValidate.errors.findIndex( + error => error.instancePath === '/category/uri' && error.keyword === 'pattern', + ); + + if (categoryUriPatternErrorIndex >= 0) { + this.debug('removing category uri pattern error'); + errors.splice(categoryUriPatternErrorIndex, 1); + } + + // also do the same for the parent uri + const parentUriPatternErrorIndex = ajvValidate.errors.findIndex( + error => error.instancePath === '/parent/uri' && error.keyword === 'pattern', + ); + + if (parentUriPatternErrorIndex >= 0) { + this.debug('removing category uri pattern error'); + errors.splice(parentUriPatternErrorIndex, 1); + } + } + + let changeCount = 0; + const unfixableErrors: ErrorObject[] = []; + const updatedData = structuredClone(data); + + if (typeof errors === 'undefined' || !errors.length) { + return { changeCount, errors: [], hasIssues: false, unfixableErrors, updatedData }; + } + + errors.forEach(error => { + if (error.instancePath === '/category' && error.keyword === 'type') { + const uri = mappings.categories[data.category as string]; + updatedData.category = { + uri: uri || `uri-that-does-not-map-to-${data.category}`, + }; + changeCount += 1; + } else if (error.keyword === 'additionalProperties') { + const badKey = error.params.additionalProperty as string; + const extractedValue = data[badKey]; + if (error.schemaPath === '#/additionalProperties') { + // if the bad property is at the root level, delete it + delete updatedData[badKey]; + changeCount += 1; + if (badKey === 'excerpt') { + // if the `content` object exists, add to it. otherwise, create it + if (typeof updatedData.content === 'object' && updatedData.content) { + (updatedData.content as Record).excerpt = extractedValue; + } else { + updatedData.content = { + excerpt: extractedValue, + }; + } + } else if (badKey === 'categorySlug') { + updatedData.category = { + uri: extractedValue, + }; + } else if (badKey === 'parentDoc') { + const uri = mappings.parentPages[extractedValue as string]; + if (uri) { + updatedData.parent = { + uri, + }; + } + } else if (badKey === 'parentDocSlug') { + updatedData.parent = { + uri: extractedValue, + }; + } else if (badKey === 'hidden') { + updatedData.privacy = { view: extractedValue ? 'anyone_with_link' : 'public' }; + } else if (badKey === 'order') { + updatedData.position = extractedValue; + } + } else { + unfixableErrors.push(error); + } + } else { + unfixableErrors.push(error); + } + }); + + return { errors, changeCount, hasIssues: true, unfixableErrors, updatedData }; +} + +export function writeFixes( + this: CommandClass['prototype'], + /** all metadata for the page that will be written to */ + metadata: PageMetadata, + /** frontmatter changes to be applied */ + updatedData: PageMetadata['data'], +) { + this.debug(`writing fixes to ${metadata.filePath}`); + const result = grayMatter.stringify(metadata.content, updatedData); + fs.writeFileSync(metadata.filePath, result, { encoding: 'utf-8' }); + + const updatedMetadata = { + ...metadata, + data: updatedData, + }; + + return updatedMetadata; +} diff --git a/src/lib/readDoc.ts b/src/lib/readPage.ts similarity index 94% rename from src/lib/readDoc.ts rename to src/lib/readPage.ts index f54b9bb82..3d52d36cb 100644 --- a/src/lib/readDoc.ts +++ b/src/lib/readPage.ts @@ -1,4 +1,5 @@ import type ChangelogsCommand from '../commands/changelogs.js'; +import type DocsUploadCommand from '../commands/docs/upload.js'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -35,7 +36,7 @@ export interface PageMetadata> { * Returns the content, matter and slug of the specified Markdown or HTML file */ export default function readPage( - this: ChangelogsCommand, + this: ChangelogsCommand | DocsUploadCommand, /** * path to the HTML/Markdown file * (file extension must end in `.html`, `.md`., or `.markdown`) diff --git a/src/lib/readmeAPIFetch.ts b/src/lib/readmeAPIFetch.ts index a1ad1afa6..2f245fb0f 100644 --- a/src/lib/readmeAPIFetch.ts +++ b/src/lib/readmeAPIFetch.ts @@ -1,5 +1,7 @@ import type { SpecFileType } from './prepareOas.js'; import type { CommandClass } from '../index.js'; +import type { CommandsThatSyncMarkdown } from './syncPagePath.js'; +import type { SchemaObject } from 'oas/types'; import path from 'node:path'; @@ -12,6 +14,7 @@ import { git } from './createGHA/index.js'; import { getPkgVersion } from './getPkg.js'; import isCI, { ciName, isGHA } from './isCI.js'; import { debug, warn } from './logger.js'; +import { readmeAPIv2Oas } from './types.js'; const SUCCESS_NO_CONTENT = 204; @@ -48,6 +51,13 @@ function stripQuotes(s: string) { return s.replace(/(^"|[",]*$)/g, ''); } +export interface Mappings { + categories: Record; + parentPages: Record; +} + +export const emptyMappings: Mappings = { categories: {}, parentPages: {} }; + /** * Parses Warning header into an array of warning header objects * @@ -436,3 +446,57 @@ export function cleanAPIv1Headers( return filterOutFalsyHeaders(headers); } + +/** + * Fetches the category and parent page mappings from the ReadMe API. + * Used for migrating frontmatter in Guides pages to the new API v2 format. + */ +export async function fetchMappings(this: CommandClass['prototype']): Promise { + const mappings = await readmeAPIv1Fetch('/api/v1/migration', { + method: 'get', + headers: cleanAPIv1Headers(this.flags.key, undefined, new Headers({ Accept: 'application/json' })), + }) + .then(res => { + if (!res.ok) { + this.debug(`received the following error when attempting to fetch mappings: ${res.status}`); + return emptyMappings; + } + this.debug('received a successful response when fetching mappings'); + return res.json() as Promise; + }) + .catch(e => { + this.debug(`error fetching mappings: ${e}`); + return emptyMappings; + }); + + return mappings; +} + +/** + * Fetches the schema for the current route from the OpenAPI description for ReadMe API v2. + */ +export async function fetchSchema(this: CommandsThatSyncMarkdown): Promise { + const oas = await this.readmeAPIFetch('/openapi.json') + .then(res => { + if (!res.ok) { + this.debug(`received the following error when attempting to fetch the readme OAS: ${res.status}`); + return readmeAPIv2Oas; + } + this.debug('received a successful response when fetching schema'); + return res.json() as Promise; + }) + .catch(e => { + this.debug(`error fetching readme OAS: ${e}`); + return readmeAPIv2Oas; + }); + + const requestBody = oas.paths?.[`/versions/{version}/${this.route}/{slug}`]?.patch?.requestBody; + + if (requestBody && 'content' in requestBody) { + return requestBody.content['application/json'].schema as SchemaObject; + } + + this.debug(`unable to find parse out schema for ${JSON.stringify(oas)}`); + return readmeAPIv2Oas.paths[`/versions/{version}/${this.route}/{slug}`].patch.requestBody.content['application/json'] + .schema satisfies SchemaObject; +} diff --git a/src/lib/syncDocsPath.legacy.ts b/src/lib/syncDocsPath.legacy.ts index 843e84bc6..b55deb34c 100644 --- a/src/lib/syncDocsPath.legacy.ts +++ b/src/lib/syncDocsPath.legacy.ts @@ -1,4 +1,4 @@ -import type { PageMetadata } from './readDoc.js'; +import type { PageMetadata } from './readPage.js'; import type ChangelogsCommand from '../commands/changelogs.js'; import fs from 'node:fs/promises'; @@ -9,8 +9,8 @@ import toposort from 'toposort'; import { APIv1Error } from './apiError.js'; import readdirRecursive from './readdirRecursive.js'; -import readPage from './readDoc.js'; import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from './readmeAPIFetch.js'; +import readPage from './readPage.js'; /** API path within ReadMe to update (e.g. `docs`, `changelogs`, etc.) */ type PageType = 'changelogs' | 'custompages' | 'docs'; diff --git a/src/lib/syncPagePath.ts b/src/lib/syncPagePath.ts new file mode 100644 index 000000000..042e47ab0 --- /dev/null +++ b/src/lib/syncPagePath.ts @@ -0,0 +1,418 @@ +import type { PageMetadata } from './readPage.js'; +import type { GuidesRequestRepresentation } from './types.js'; +import type DocsUploadCommand from '../commands/docs/upload.js'; + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import chalk from 'chalk'; +import ora from 'ora'; +import toposort from 'toposort'; + +import { APIv2Error } from './apiError.js'; +import { fix, writeFixes } from './frontmatter.js'; +import isCI from './isCI.js'; +import { oraOptions } from './logger.js'; +import promptTerminal from './promptWrapper.js'; +import readdirRecursive from './readdirRecursive.js'; +import { fetchMappings, fetchSchema } from './readmeAPIFetch.js'; +import readPage from './readPage.js'; +import { categoryUriRegexPattern, parentUriRegexPattern } from './types.js'; + +/** + * Commands that use this file for syncing Markdown via APIv2. + * + * Note that the `changelogs` command is not included here + * because it is backed by APIv1. + */ +export type CommandsThatSyncMarkdown = DocsUploadCommand; + +type PageRepresentation = GuidesRequestRepresentation; + +interface FailedPushResult { + error: APIv2Error | Error; + filePath: string; + result: 'failed'; + slug: string; +} + +type PushResult = + | FailedPushResult + | { + filePath: string; + response: PageRepresentation | null; + result: 'created' | 'skipped' | 'updated'; + slug: string; + }; + +/** + * Reads the contents of the specified Markdown or HTML file + * and creates/updates the corresponding page in ReadMe + */ +async function pushPage( + this: CommandsThatSyncMarkdown, + /** the file data */ + fileData: PageMetadata, +): Promise { + const { key, 'dry-run': dryRun, version } = this.flags; + const { content, filePath, slug } = fileData; + const data = fileData.data; + let route = `/${this.route}`; + if (version) { + route = `/versions/${version}${route}`; + } + + const headers = new Headers({ authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }); + + if (!Object.keys(data).length) { + this.debug(`No frontmatter attributes found for ${filePath}, not syncing`); + return { response: null, filePath, result: 'skipped', slug }; + } + + const payload: PageRepresentation = { + ...data, + content: { + ...(typeof data.content === 'object' ? data.content : {}), + body: content, + }, + slug, + }; + + try { + // normalize the category uri + if (payload.category?.uri) { + const regex = new RegExp(categoryUriRegexPattern); + if (!regex.test(payload.category.uri)) { + let uri = payload.category.uri; + this.debug(`normalizing category uri ${uri} for ${filePath}`); + // remove leading and trailing slashes + uri = uri.replace(/^\/|\/$/g, ''); + payload.category.uri = `/versions/${version}/categories/${this.route}/${uri}`; + } + } + + // normalize the parent uri + if (payload.parent?.uri) { + const regex = new RegExp(parentUriRegexPattern); + if (!regex.test(payload.parent.uri)) { + let uri = payload.parent.uri; + this.debug(`normalizing parent uri ${uri} for ${filePath}`); + // remove leading and trailing slashes + uri = uri.replace(/^\/|\/$/g, ''); + payload.parent.uri = `/versions/${version}/${this.route}/${uri}`; + } + } + + const createPage = (): Promise | PushResult => { + if (dryRun) { + return { filePath, result: 'created', response: null, slug }; + } + + return this.readmeAPIFetch( + route, + { method: 'POST', headers, body: JSON.stringify(payload) }, + { filePath, fileType: 'path' }, + ) + .then(res => this.handleAPIRes(res)) + .then(res => { + return { filePath, result: 'created', response: res, slug }; + }); + }; + + const updatePage = (): Promise | PushResult => { + if (dryRun) { + return { filePath, result: 'updated', response: null, slug }; + } + + // omits the slug from the PATCH payload since this would lead to unexpected behavior + delete payload.slug; + + return this.readmeAPIFetch( + `${route}/${slug}`, + { + method: 'PATCH', + headers, + body: JSON.stringify(payload), + }, + { filePath, fileType: 'path' }, + ) + .then(res => this.handleAPIRes(res)) + .then(res => { + return { filePath, result: 'updated', response: res, slug }; + }); + }; + + return this.readmeAPIFetch(`${route}/${slug}`, { + method: 'HEAD', + headers, + }).then(async res => { + await this.handleAPIRes(res, true); + if (!res.ok) { + if (res.status !== 404) throw new APIv2Error(res); + this.debug(`error retrieving data for ${slug}, creating page`); + return createPage(); + } + this.debug(`data received for ${slug}, updating page`); + return updatePage(); + }); + } catch (e) { + return { error: e, filePath, result: 'failed', slug }; + } +} + +const byParentPage = (left: PageMetadata, right: PageMetadata) => { + return (right.data.parent?.uri ? 1 : 0) - (left.data.parent?.uri ? 1 : 0); +}; + +/** + * Sorts files based on their `parent.uri` attribute. If a file has a `parent.uri` attribute, + * it will be sorted after the file it references. + * + * @see {@link https://github.com/readmeio/rdme/pull/973} + * @returns An array of sorted PageMetadata objects + */ +function sortFiles(files: PageMetadata[]): PageMetadata[] { + const filesBySlug = files.reduce>>((bySlug, obj) => { + // eslint-disable-next-line no-param-reassign + bySlug[obj.slug] = obj; + return bySlug; + }, {}); + const dependencies = Object.values(filesBySlug).reduce< + [PageMetadata, PageMetadata][] + >((edges, obj) => { + if (obj.data.parent?.uri && filesBySlug[obj.data.parent.uri]) { + edges.push([filesBySlug[obj.data.parent.uri], filesBySlug[obj.slug]]); + } + + return edges; + }, []); + + return toposort.array(files, dependencies); +} + +/** + * Takes a path (either to a directory of files or to a single file) + * and syncs those (either via POST or PATCH) to ReadMe. + * @returns An array of objects with the results + */ +export default async function syncPagePath(this: CommandsThatSyncMarkdown) { + const { path: pathInput }: { path: string } = this.args; + const { 'dry-run': dryRun, 'skip-validation': skipValidation } = this.flags; + + const allowedFileExtensions = ['.markdown', '.md']; + + const stat = await fs.stat(pathInput).catch(err => { + if (err.code === 'ENOENT') { + throw new Error("Oops! We couldn't locate a file or directory at the path you provided."); + } + throw err; + }); + + if (skipValidation) { + this.warn('Skipping pre-upload validation of the Markdown file(s). This is not recommended.'); + } + + let files: string[]; + + if (stat.isDirectory()) { + const fileScanningSpinner = ora({ ...oraOptions() }).start( + `πŸ” Looking for Markdown files in ${chalk.underline(pathInput)}...`, + ); + // Filter out any files that don't match allowedFileExtensions + files = readdirRecursive(pathInput).filter(file => + allowedFileExtensions.includes(path.extname(file).toLowerCase()), + ); + + if (!files.length) { + fileScanningSpinner.fail(`${fileScanningSpinner.text} no files found.`); + throw new Error( + `The directory you provided (${pathInput}) doesn't contain any of the following file extensions: ${allowedFileExtensions.join( + ', ', + )}.`, + ); + } + + fileScanningSpinner.succeed(`${fileScanningSpinner.text} ${files.length} file(s) found!`); + } else { + const fileExtension = path.extname(pathInput).toLowerCase(); + if (!allowedFileExtensions.includes(fileExtension)) { + throw new Error( + `Invalid file extension (${fileExtension}). Must be one of the following: ${allowedFileExtensions.join(', ')}`, + ); + } + + files = [pathInput]; + } + + this.debug(`number of files: ${files.length}`); + + let unsortedFiles = files.map(file => readPage.call(this, file)); + + if (!skipValidation) { + const validationSpinner = ora({ ...oraOptions() }).start('πŸ”¬ Validating frontmatter data...'); + + const schema = await fetchSchema.call(this); + const mappings = await fetchMappings.call(this); + + // validate the files, prompt user to fix if necessary + const validationResults = unsortedFiles.map(file => { + this.debug(`validating frontmatter for ${file.filePath}`); + return fix.call(this, file.data, schema, mappings); + }); + + const filesWithIssues = validationResults.filter(result => result.hasIssues); + const filesWithFixableIssues = filesWithIssues.filter(result => result.changeCount); + const filesWithUnfixableIssues = filesWithIssues.filter(result => result.unfixableErrors.length); + + if (filesWithIssues.length) { + validationSpinner.warn(`${validationSpinner.text} issues found in ${filesWithIssues.length} file(s).`); + if (filesWithFixableIssues.length) { + if (isCI()) { + throw new Error( + `${filesWithIssues.length} file(s) have issues that should be fixed before uploading to ReadMe. Please run \`${this.config.bin} ${this.id} ${pathInput} --dry-run\` in a non-CI environment to fix them.`, + ); + } + + const { confirm } = await promptTerminal([ + { + type: 'confirm', + name: 'confirm', + message: `${filesWithFixableIssues.length} file(s) have issues that can be fixed automatically. Would you like to make these changes and continue with the upload to ReadMe?`, + }, + ]); + + if (!confirm) { + throw new Error('Aborting upload due to user input.'); + } + + const updatedFiles = unsortedFiles.map((file, index) => { + return writeFixes.call(this, file, validationResults[index].updatedData); + }); + + unsortedFiles = updatedFiles; + } + + // also inform the user if there are files with issues that can't be fixed + if (filesWithUnfixableIssues.length) { + this.warn( + `${filesWithUnfixableIssues.length} file(s) have issues that cannot be fixed automatically. The upload will proceed but we recommend addressing these issues. Please get in touch with us at support@readme.io if you need a hand.`, + ); + } + } else { + validationSpinner.succeed(`${validationSpinner.text} no issues found!`); + } + } + + const uploadSpinner = ora({ ...oraOptions() }).start( + dryRun + ? "🎭 Uploading files to ReadMe (but not really because it's a dry run)..." + : 'πŸš€ Uploading files to ReadMe...', + ); + + // topological sort the files + const sortedFiles = sortFiles((unsortedFiles as PageMetadata[]).sort(byParentPage)); + + // push the files to ReadMe + const rawResults = await Promise.allSettled(sortedFiles.map(async fileData => pushPage.call(this, fileData))); + + const results = rawResults.reduce<{ + created: PushResult[]; + failed: FailedPushResult[]; + skipped: PushResult[]; + updated: PushResult[]; + }>( + (acc, result, index) => { + if (result.status === 'fulfilled') { + const pushResult = result.value; + if (pushResult.result === 'created') { + acc.created.push(pushResult); + } else if (pushResult.result === 'updated') { + acc.updated.push(pushResult); + } else if (pushResult.result === 'failed') { + acc.failed.push(pushResult); + } else { + acc.skipped.push(pushResult); + } + } else { + // we're ignoring these ones for now since errors are handled in the catch block + acc.failed.push({ + error: result.reason, + filePath: sortedFiles[index].filePath, + result: 'failed', + slug: sortedFiles[index].slug, + }); + } + + return acc; + }, + { created: [], updated: [], skipped: [], failed: [] }, + ); + + if (results.failed.length) { + uploadSpinner.fail(`${uploadSpinner.text} ${results.failed.length} file(s) failed.`); + } else { + uploadSpinner.succeed(`${uploadSpinner.text} done!`); + } + + if (results.created.length) { + this.log( + dryRun + ? `🌱 The following ${results.created.length} page(s) do not currently exist in ReadMe and will be created:` + : `🌱 Successfully created ${results.created.length} page(s) in ReadMe:`, + ); + results.created.forEach(({ filePath, slug }) => { + this.log(` - ${slug} (${chalk.underline(filePath)})`); + }); + } + + if (results.updated.length) { + this.log( + dryRun + ? `πŸ”„ The following ${results.updated.length} page(s) already exist in ReadMe and will be updated:` + : `πŸ”„ Successfully updated ${results.updated.length} page(s) in ReadMe:`, + ); + results.updated.forEach(({ filePath, slug }) => { + this.log(` - ${slug} (${chalk.underline(filePath)})`); + }); + } + + if (results.skipped.length) { + this.log( + dryRun + ? `⏭️ The following ${results.skipped.length} page(s) will be skipped due to no frontmatter data:` + : `⏭️ Skipped ${results.skipped.length} page(s) in ReadMe due to no frontmatter data:`, + ); + results.skipped.forEach(({ filePath, slug }) => { + this.log(` - ${slug} (${chalk.underline(filePath)})`); + }); + } + + if (results.failed.length) { + this.log( + dryRun + ? `🚨 Unable to fetch data about the following ${results.failed.length} page(s):` + : `🚨 Received errors when attempting to upload ${results.failed.length} page(s):`, + ); + results.failed.forEach(({ error, filePath }) => { + let errorMessage = error.message || 'unknown error'; + if (error instanceof APIv2Error && error.response.title) { + errorMessage = error.response.title; + } + + this.log(` - ${chalk.underline(filePath)}: ${errorMessage}`); + }); + if (results.failed.length === 1) { + throw results.failed[0].error; + } else { + const errors = results.failed.map(({ error }) => error); + throw new AggregateError( + errors, + dryRun + ? `Multiple dry runs failed. To see more detailed errors for a page, run \`${this.config.bin} ${this.id} \` --dry-run.` + : `Multiple page uploads failed. To see more detailed errors for a page, run \`${this.config.bin} ${this.id} \`.`, + ); + } + } + + return results; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 000000000..23fb65733 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,5716 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import type { OASDocument } from 'oas/types'; + +export const readmeAPIv2Oas = { + openapi: '3.1.0', + info: { + description: 'Create beautiful product and API documentation with our developer friendly platform.', + version: '2.0.0-beta', + title: 'ReadMe API v2 πŸ¦‰ (BETA)', + // @ts-expect-error custom extension + 'x-readme-deploy': '5.271.0', + termsOfService: 'https://readme.com/tos', + contact: { + name: 'API Support', + url: 'https://docs.readme.com/main/docs/need-more-support', + email: 'support@readme.io', + }, + }, + components: { + securitySchemes: { + bearer: { + type: 'http', + scheme: 'bearer', + description: 'A bearer token that will be supplied within an `Authentication` header as `bearer `.', + }, + }, + schemas: {}, + }, + paths: { + '/versions/{version}/apis': { + get: { + operationId: 'getAPIs', + summary: 'Retrieve all API schemas that a project has.', + tags: ['APIs'], + description: + "Retrieves all of the API schemas that are set up for your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + total: { type: 'number' }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + created_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the API definition was created.', + }, + filename: { + type: 'string', + description: 'This is the unique identifier, its filename, for the API definition.', + }, + source: { + type: 'object', + properties: { + current: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + }, + original: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + }, + }, + required: ['current', 'original'], + additionalProperties: false, + description: 'The sources by which this API definition was ingested.', + }, + type: { + type: 'string', + enum: ['openapi', 'postman', 'swagger', 'unknown'], + description: + 'The type of API definition. This will be `unknown` if the API definition has either not yet been processed or failed with validation errors.', + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the API definition was last updated.', + }, + upload: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'failed', 'done', 'pending_update', 'failed_update'], + description: 'The status of the API definition upload.', + }, + reason: { + type: 'string', + nullable: true, + description: 'The reason for the upload failure (if it failed).', + }, + }, + required: ['status', 'reason'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + description: 'A URI to the API definition resource.', + }, + }, + required: ['created_at', 'filename', 'source', 'type', 'updated_at', 'upload', 'uri'], + additionalProperties: false, + }, + }, + }, + required: ['total', 'data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + post: { + operationId: 'createAPI', + summary: 'Create an API', + tags: ['APIs'], + description: + "Creates an API in the reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + schema: { description: 'The API definition.' }, + upload_source: { + default: 'form', + description: 'The source that the API definition is being uploaded through.', + }, + url: { description: 'The URL where the API definition is hosted.' }, + }, + additionalProperties: false, + }, + }, + }, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '202': { + description: 'Accepted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + upload: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'failed', 'done', 'pending_update', 'failed_update'], + description: 'The status of the API definition upload.', + }, + }, + required: ['status'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + description: 'A URI to the API definition resource.', + }, + }, + required: ['upload', 'uri'], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/apis/{filename}': { + get: { + operationId: 'getAPI', + summary: 'Retrieves information about a specific API schema.', + tags: ['APIs'], + description: + "Retrieves information about a specific API schema on your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: '(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml))' }, + in: 'path', + name: 'filename', + required: true, + description: 'The filename of the API definition to retrieve.', + }, + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + created_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the API definition was created.', + }, + filename: { + type: 'string', + description: 'This is the unique identifier, its filename, for the API definition.', + }, + source: { + type: 'object', + properties: { + current: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + }, + original: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + }, + }, + required: ['current', 'original'], + additionalProperties: false, + description: 'The sources by which this API definition was ingested.', + }, + type: { + type: 'string', + enum: ['openapi', 'postman', 'swagger', 'unknown'], + description: + 'The type of API definition. This will be `unknown` if the API definition has either not yet been processed or failed with validation errors.', + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the API definition was last updated.', + }, + upload: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'failed', 'done', 'pending_update', 'failed_update'], + description: 'The status of the API definition upload.', + }, + reason: { + type: 'string', + nullable: true, + description: 'The reason for the upload failure (if it failed).', + }, + }, + required: ['status', 'reason'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + description: 'A URI to the API definition resource.', + }, + schema: { type: 'object', additionalProperties: {}, description: 'The API schema.' }, + }, + required: ['created_at', 'filename', 'source', 'type', 'updated_at', 'upload', 'uri', 'schema'], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + put: { + operationId: 'updateAPI', + summary: 'Update an API', + tags: ['APIs'], + description: + "Updates an API in the reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + schema: { description: 'The API definition.' }, + upload_source: { + default: 'form', + description: 'The source that the API definition is being uploaded through.', + }, + url: { description: 'The URL where the API definition is hosted.' }, + }, + additionalProperties: false, + }, + }, + }, + }, + parameters: [ + { + schema: { type: 'string', pattern: '(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml))' }, + in: 'path', + name: 'filename', + required: true, + description: 'The filename of the API definition to retrieve.', + }, + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '202': { + description: 'Accepted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + upload: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['pending', 'failed', 'done', 'pending_update', 'failed_update'], + description: 'The status of the API definition upload.', + }, + }, + required: ['status'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + description: 'A URI to the API definition resource.', + }, + }, + required: ['upload', 'uri'], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/changelogs': { + get: { + operationId: 'getChangelogs', + summary: 'Get changelogs', + tags: ['Changelog'], + description: + "Get a collection of changelogs.\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'number', minimum: 1, default: 1 }, + in: 'query', + name: 'page', + required: false, + description: 'Used to specify further pages (starts at 1).', + }, + { + schema: { type: 'number', minimum: 1, maximum: 100, default: 10 }, + in: 'query', + name: 'per_page', + required: false, + description: 'Number of items to include in pagination (up to 100, defaults to 10).', + }, + { + schema: { type: 'string', enum: ['public', 'anyone_with_link', 'all'], default: 'all' }, + in: 'query', + name: 'visibility', + required: false, + description: + 'The visibility setting (`privacy.view`) for the changelog entries you wish to retrieve. Defaults to `all`.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + total: { type: 'number' }, + page: { type: 'number' }, + per_page: { type: 'number' }, + paging: { + type: 'object', + properties: { + next: { type: 'string', nullable: true }, + previous: { type: 'string', nullable: true }, + first: { type: 'string', nullable: true }, + last: { type: 'string', nullable: true }, + }, + required: ['next', 'previous', 'first', 'last'], + additionalProperties: false, + }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + author: { + type: 'object', + properties: { + id: { type: 'string', nullable: true, description: 'User ID of the changelog author.' }, + name: { + type: 'string', + nullable: true, + description: 'Full name of the user who created the changelog.', + }, + }, + required: ['id', 'name'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { body: { type: 'string', nullable: true } }, + required: ['body'], + additionalProperties: false, + }, + created_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the changelog was created.', + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: + 'A comma-separated list of keywords to place into your changelog metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this changelog.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string' }, + title: { type: 'string' }, + type: { + type: 'string', + enum: ['none', 'added', 'fixed', 'improved', 'deprecated', 'removed'], + default: 'none', + description: 'The type of changelog that this is.', + }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this changelog belongs to.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the changelog was updated.', + }, + uri: { + type: 'string', + pattern: '\\/changelogs\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'author', + 'content', + 'created_at', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + }, + required: ['total', 'page', 'per_page', 'paging', 'data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/changelogs/{identifier}': { + get: { + operationId: 'getChangelog', + summary: 'Get changelog', + tags: ['Changelog'], + description: + "Returns the changelog.\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { + anyOf: [ + { type: 'string', pattern: '[a-f\\d]{24}', description: 'A unique identifier for the resource.' }, + { + type: 'string', + pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+', + description: 'A URL-safe representation of the resource.', + }, + ], + }, + in: 'path', + name: 'identifier', + required: true, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + author: { + type: 'object', + properties: { + id: { type: 'string', nullable: true, description: 'User ID of the changelog author.' }, + name: { + type: 'string', + nullable: true, + description: 'Full name of the user who created the changelog.', + }, + }, + required: ['id', 'name'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { body: { type: 'string', nullable: true } }, + required: ['body'], + additionalProperties: false, + }, + created_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the changelog was created.', + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your changelog metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this changelog.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string' }, + title: { type: 'string' }, + type: { + type: 'string', + enum: ['none', 'added', 'fixed', 'improved', 'deprecated', 'removed'], + default: 'none', + description: 'The type of changelog that this is.', + }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this changelog belongs to.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the changelog was updated.', + }, + uri: { + type: 'string', + pattern: '\\/changelogs\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'author', + 'content', + 'created_at', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/custom_pages': { + post: { + operationId: 'createCustomPage', + summary: 'Create a custom page', + tags: ['Custom Pages'], + description: + "Create a new custom page. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + additionalProperties: false, + }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + }, + required: ['title'], + additionalProperties: false, + }, + }, + }, + required: true, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '201': { + description: 'Created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + required: ['body'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: + 'A comma-separated list of keywords to place into your custom page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project resource.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the custom page was updated.', + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/custom_pages\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'appearance', + 'content', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + get: { + operationId: 'getCustomPages', + summary: 'Get custom pages', + tags: ['Custom Pages'], + description: + "Get a collection of custom pages. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + total: { type: 'number' }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + required: ['body'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: + 'A comma-separated list of keywords to place into your custom page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project resource.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the custom page was updated.', + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/custom_pages\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'appearance', + 'content', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + }, + required: ['total', 'data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/custom_pages/{slug}': { + get: { + operationId: 'getCustomPage', + summary: 'Retrieve a custom page', + tags: ['Custom Pages'], + description: + "Retrieves a custom page from the custom page section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + required: ['body'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: + 'A comma-separated list of keywords to place into your custom page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project resource.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the custom page was updated.', + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/custom_pages\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'appearance', + 'content', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + delete: { + operationId: 'deleteCustomPage', + summary: 'Delete a custom page', + tags: ['Custom Pages'], + description: + "Deletes a custom page from the custom page section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { '204': { description: 'No Content' } }, + }, + patch: { + operationId: 'updateCustomPage', + summary: 'Update a custom page', + tags: ['Custom Pages'], + description: + "Updates a custom page in the custompage section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + additionalProperties: false, + }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + }, + }, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + fullscreen: { + type: 'boolean', + default: false, + description: 'Whether a html custom page is fullscreen or not.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + type: { + type: 'string', + enum: ['markdown', 'html'], + default: 'markdown', + description: 'The type of content contained in this custom page.', + }, + }, + required: ['body'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: + 'A comma-separated list of keywords to place into your custom page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'anyone_with_link'], + default: 'anyone_with_link', + description: 'The visibility of this custom page.', + }, + }, + additionalProperties: false, + }, + slug: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + title: { type: 'string', nullable: true }, + links: { + type: 'object', + properties: { + project: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project resource.', + }, + }, + required: ['project'], + additionalProperties: false, + }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the custom page was updated.', + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/custom_pages\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + }, + }, + required: [ + 'appearance', + 'content', + 'metadata', + 'privacy', + 'slug', + 'title', + 'links', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/guides': { + post: { + operationId: 'createGuide', + summary: 'Create a guide', + tags: ['Guides'], + description: + "Creates a page in the guides section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { type: 'boolean', nullable: true }, + }, + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { type: 'string', format: 'uri', description: 'A URL to this page in your ReadMe Dash.' }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true } }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true }, + message: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + position: { type: 'number' }, + }, + required: ['category', 'title'], + additionalProperties: false, + }, + }, + }, + required: true, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '201': { + description: 'Created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/guides/{slug}': { + get: { + operationId: 'getGuide', + summary: 'Retrieve a guide', + tags: ['Guides'], + description: + "Retrieves a page from the guides section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + delete: { + operationId: 'deleteGuide', + summary: 'Delete a guide', + tags: ['Guides'], + description: + "Deletes a page from the guides section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { '204': { description: 'No Content' } }, + }, + patch: { + operationId: 'updateGuide', + summary: 'Update a guide', + tags: ['Guides'], + description: + "Updates a page in the guides section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { type: 'boolean', nullable: true }, + }, + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { type: 'string', format: 'uri', description: 'A URL to this page in your ReadMe Dash.' }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true } }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true }, + message: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + position: { type: 'number' }, + }, + additionalProperties: false, + }, + }, + }, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/reference': { + post: { + operationId: 'createReference', + summary: 'Create a reference', + tags: ['API Reference'], + description: + "Creates a reference page in the API Reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { type: 'boolean', nullable: true }, + }, + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { type: 'string', format: 'uri', description: 'A URL to this page in your ReadMe Dash.' }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true } }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true }, + message: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + connections: { + type: 'object', + properties: { + recipes: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/recipes\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: + 'URI of the recipe that this API reference is connected to. The recipe and API reference must exist within the same version.', + }, + }, + additionalProperties: false, + }, + nullable: true, + }, + }, + additionalProperties: false, + }, + position: { type: 'number' }, + api_config: { type: 'string', enum: ['authentication', 'getting-started', 'my-requests'] }, + api: { + type: 'object', + properties: { + method: { + type: 'string', + enum: ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'], + description: 'The endpoint HTTP method.', + }, + path: { type: 'string', description: 'The endpoint path.' }, + schema: { nullable: true }, + stats: { + type: 'object', + properties: { + additional_properties: { + type: 'boolean', + default: false, + description: + 'This API operation uses `additionalProperties` for handling extra schema properties.', + }, + callbacks: { + type: 'boolean', + default: false, + description: 'This API operation has `callbacks` documented.', + }, + circular_references: { + type: 'boolean', + default: false, + description: 'This API operation contains `$ref` schema pointers that resolve to itself.', + }, + common_parameters: { + type: 'boolean', + default: false, + description: 'This API operation utilizes common parameters set at the path level.', + }, + discriminators: { + type: 'boolean', + default: false, + description: + 'This API operation utilizes `discriminator` for discriminating between different parts in a polymorphic schema.', + }, + links: { + type: 'boolean', + default: false, + description: 'This API operation has `links` documented.', + }, + polymorphism: { + type: 'boolean', + default: false, + description: 'This API operation contains polymorphic schemas.', + }, + server_variables: { + type: 'boolean', + default: false, + description: + 'This API operation has composable variables configured for its server definition.', + }, + style: { + type: 'boolean', + default: false, + description: 'This API operation has parameters that have specific `style` serializations.', + }, + webhooks: { + type: 'boolean', + default: false, + description: 'This API definition has `webhooks` documented.', + }, + xml: { + type: 'boolean', + default: false, + description: 'This API operation has parameters or schemas that serialize to XML.', + }, + references: { + type: 'boolean', + description: + 'This API operation, after being dereferenced, has `x-readme-ref-name` entries defining what the original `$ref` schema pointers were named.', + }, + }, + additionalProperties: false, + description: 'OpenAPI features that are utilized within this API operation.', + }, + source: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + nullable: true, + }, + }, + additionalProperties: false, + }, + }, + required: ['category', 'title'], + additionalProperties: false, + }, + }, + }, + required: true, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + ], + responses: { + '201': { + description: 'Created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + api_config: { + type: 'string', + enum: ['authentication', 'getting-started', 'my-requests'], + nullable: true, + }, + api: { + type: 'object', + properties: { + method: { + type: 'string', + enum: ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'], + description: 'The endpoint HTTP method.', + }, + path: { type: 'string', description: 'The endpoint path.' }, + schema: { + nullable: true, + description: + 'The API schema for this reference endpoint. This schema is a reduced version of the full API definition and only contains the necessary information for this endpoint.', + }, + stats: { + type: 'object', + properties: { + additional_properties: { + type: 'boolean', + default: false, + description: + 'This API operation uses `additionalProperties` for handling extra schema properties.', + }, + callbacks: { + type: 'boolean', + default: false, + description: 'This API operation has `callbacks` documented.', + }, + circular_references: { + type: 'boolean', + default: false, + description: + 'This API operation contains `$ref` schema pointers that resolve to itself.', + }, + common_parameters: { + type: 'boolean', + default: false, + description: 'This API operation utilizes common parameters set at the path level.', + }, + discriminators: { + type: 'boolean', + default: false, + description: + 'This API operation utilizes `discriminator` for discriminating between different parts in a polymorphic schema.', + }, + links: { + type: 'boolean', + default: false, + description: 'This API operation has `links` documented.', + }, + polymorphism: { + type: 'boolean', + default: false, + description: 'This API operation contains polymorphic schemas.', + }, + server_variables: { + type: 'boolean', + default: false, + description: + 'This API operation has composable variables configured for its server definition.', + }, + style: { + type: 'boolean', + default: false, + description: + 'This API operation has parameters that have specific `style` serializations.', + }, + webhooks: { + type: 'boolean', + default: false, + description: 'This API definition has `webhooks` documented.', + }, + xml: { + type: 'boolean', + default: false, + description: 'This API operation has parameters or schemas that serialize to XML.', + }, + references: { + type: 'boolean', + description: + 'This API operation, after being dereferenced, has `x-readme-ref-name` entries defining what the original `$ref` schema pointers were named.', + }, + }, + required: ['references'], + additionalProperties: false, + description: 'OpenAPI features that are utilized within this API operation.', + }, + source: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + nullable: true, + description: 'The source by which this API definition was ingested.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + nullable: true, + description: 'A URI to the API resource.', + }, + }, + required: ['method', 'path', 'stats', 'source', 'uri'], + additionalProperties: false, + description: 'Information about the API that this reference page is attached to.', + }, + connections: { + type: 'object', + properties: { + recipes: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/recipes\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: + 'URI of the recipe that this API reference is connected to. The recipe and API reference must exist within the same version.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + nullable: true, + description: 'A collection of recipes that are displayed on this API reference.', + }, + }, + required: ['recipes'], + additionalProperties: false, + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + 'api_config', + 'api', + 'connections', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/versions/{version}/reference/{slug}': { + get: { + operationId: 'getReference', + summary: 'Retrieve a reference', + tags: ['API Reference'], + description: + "Retrieves a page from the API reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + api_config: { + type: 'string', + enum: ['authentication', 'getting-started', 'my-requests'], + nullable: true, + }, + api: { + type: 'object', + properties: { + method: { + type: 'string', + enum: ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'], + description: 'The endpoint HTTP method.', + }, + path: { type: 'string', description: 'The endpoint path.' }, + schema: { + nullable: true, + description: + 'The API schema for this reference endpoint. This schema is a reduced version of the full API definition and only contains the necessary information for this endpoint.', + }, + stats: { + type: 'object', + properties: { + additional_properties: { + type: 'boolean', + default: false, + description: + 'This API operation uses `additionalProperties` for handling extra schema properties.', + }, + callbacks: { + type: 'boolean', + default: false, + description: 'This API operation has `callbacks` documented.', + }, + circular_references: { + type: 'boolean', + default: false, + description: + 'This API operation contains `$ref` schema pointers that resolve to itself.', + }, + common_parameters: { + type: 'boolean', + default: false, + description: 'This API operation utilizes common parameters set at the path level.', + }, + discriminators: { + type: 'boolean', + default: false, + description: + 'This API operation utilizes `discriminator` for discriminating between different parts in a polymorphic schema.', + }, + links: { + type: 'boolean', + default: false, + description: 'This API operation has `links` documented.', + }, + polymorphism: { + type: 'boolean', + default: false, + description: 'This API operation contains polymorphic schemas.', + }, + server_variables: { + type: 'boolean', + default: false, + description: + 'This API operation has composable variables configured for its server definition.', + }, + style: { + type: 'boolean', + default: false, + description: + 'This API operation has parameters that have specific `style` serializations.', + }, + webhooks: { + type: 'boolean', + default: false, + description: 'This API definition has `webhooks` documented.', + }, + xml: { + type: 'boolean', + default: false, + description: 'This API operation has parameters or schemas that serialize to XML.', + }, + references: { + type: 'boolean', + description: + 'This API operation, after being dereferenced, has `x-readme-ref-name` entries defining what the original `$ref` schema pointers were named.', + }, + }, + required: ['references'], + additionalProperties: false, + description: 'OpenAPI features that are utilized within this API operation.', + }, + source: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + nullable: true, + description: 'The source by which this API definition was ingested.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + nullable: true, + description: 'A URI to the API resource.', + }, + }, + required: ['method', 'path', 'stats', 'source', 'uri'], + additionalProperties: false, + description: 'Information about the API that this reference page is attached to.', + }, + connections: { + type: 'object', + properties: { + recipes: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/recipes\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: + 'URI of the recipe that this API reference is connected to. The recipe and API reference must exist within the same version.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + nullable: true, + description: 'A collection of recipes that are displayed on this API reference.', + }, + }, + required: ['recipes'], + additionalProperties: false, + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + 'api_config', + 'api', + 'connections', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + delete: { + operationId: 'deleteReference', + summary: 'Delete a reference', + tags: ['API Reference'], + description: + "Deletes a page from the API reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { '204': { description: 'No Content' } }, + }, + patch: { + operationId: 'updateReference', + summary: 'Update a reference', + tags: ['API Reference'], + description: + "Updates a page in the API reference section of your developer hub. \n\n>πŸ“˜\n> This route is only available to projects that are using [ReadMe Refactored](https://docs.readme.com/main/docs/welcome-to-readme-refactored).\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { type: 'boolean', nullable: true }, + }, + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { type: 'string', format: 'uri', description: 'A URL to this page in your ReadMe Dash.' }, + }, + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + keywords: { type: 'string', nullable: true }, + title: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { uri: { type: 'string', pattern: '\\/images\\/([a-f\\d]{24})', nullable: true } }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + }, + }, + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true }, + message: { type: 'string', nullable: true }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + connections: { + type: 'object', + properties: { + recipes: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/recipes\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: + 'URI of the recipe that this API reference is connected to. The recipe and API reference must exist within the same version.', + }, + }, + additionalProperties: false, + }, + nullable: true, + }, + }, + additionalProperties: false, + }, + api: { + type: 'object', + properties: { + method: { + type: 'string', + enum: ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'], + description: 'The endpoint HTTP method.', + }, + path: { type: 'string', description: 'The endpoint path.' }, + schema: { nullable: true }, + stats: { + type: 'object', + properties: { + additional_properties: { + type: 'boolean', + default: false, + description: + 'This API operation uses `additionalProperties` for handling extra schema properties.', + }, + callbacks: { + type: 'boolean', + default: false, + description: 'This API operation has `callbacks` documented.', + }, + circular_references: { + type: 'boolean', + default: false, + description: 'This API operation contains `$ref` schema pointers that resolve to itself.', + }, + common_parameters: { + type: 'boolean', + default: false, + description: 'This API operation utilizes common parameters set at the path level.', + }, + discriminators: { + type: 'boolean', + default: false, + description: + 'This API operation utilizes `discriminator` for discriminating between different parts in a polymorphic schema.', + }, + links: { + type: 'boolean', + default: false, + description: 'This API operation has `links` documented.', + }, + polymorphism: { + type: 'boolean', + default: false, + description: 'This API operation contains polymorphic schemas.', + }, + server_variables: { + type: 'boolean', + default: false, + description: + 'This API operation has composable variables configured for its server definition.', + }, + style: { + type: 'boolean', + default: false, + description: 'This API operation has parameters that have specific `style` serializations.', + }, + webhooks: { + type: 'boolean', + default: false, + description: 'This API definition has `webhooks` documented.', + }, + xml: { + type: 'boolean', + default: false, + description: 'This API operation has parameters or schemas that serialize to XML.', + }, + references: { + type: 'boolean', + description: + 'This API operation, after being dereferenced, has `x-readme-ref-name` entries defining what the original `$ref` schema pointers were named.', + }, + }, + additionalProperties: false, + description: 'OpenAPI features that are utilized within this API operation.', + }, + source: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + nullable: true, + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + nullable: true, + }, + }, + additionalProperties: false, + description: + 'Information about the API that this reference page is attached to. If you wish to detach this page from an API definition, making it a stand page, set `api.uri` to `null`.', + }, + position: { type: 'number' }, + }, + additionalProperties: false, + }, + }, + }, + }, + parameters: [ + { + schema: { type: 'string', pattern: 'stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?' }, + in: 'path', + name: 'version', + required: true, + description: 'Project version number or stable.', + }, + { + schema: { type: 'string', pattern: '([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+' }, + in: 'path', + name: 'slug', + required: true, + description: 'A URL-safe representation of the resource.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + category: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/categories\\/(guides|reference)\\/((.*))', + description: 'A URI to the category resource.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + content: { + type: 'object', + properties: { + body: { type: 'string', nullable: true }, + excerpt: { type: 'string', nullable: true }, + link: { + type: 'object', + properties: { + url: { type: 'string', nullable: true }, + new_tab: { + type: 'boolean', + nullable: true, + description: 'Should this URL be opened up in a new tab?', + }, + }, + required: ['url', 'new_tab'], + additionalProperties: false, + description: + 'Information about where this page should redirect to; only available when `type` is `link`.', + }, + next: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + pages: { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['basic', 'endpoint'] }, + }, + required: ['slug', 'title', 'type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + title: { type: 'string', nullable: true }, + type: { type: 'string', enum: ['link'] }, + url: { type: 'string' }, + }, + required: ['title', 'type', 'url'], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ['description', 'pages'], + additionalProperties: false, + }, + }, + required: ['body', 'excerpt', 'link', 'next'], + additionalProperties: false, + }, + href: { + type: 'object', + properties: { + dash: { + type: 'string', + format: 'uri', + description: 'A URL to this page in your ReadMe Dash.', + }, + }, + required: ['dash'], + additionalProperties: false, + }, + metadata: { + type: 'object', + properties: { + description: { type: 'string', nullable: true }, + image: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['uri', 'url'], + additionalProperties: false, + }, + keywords: { + type: 'string', + nullable: true, + description: 'A comma-separated list of keywords to place into your page metadata.', + }, + title: { type: 'string', nullable: true }, + }, + required: ['description', 'image', 'keywords', 'title'], + additionalProperties: false, + }, + parent: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: 'A URI to the parent page resource including the page ID or slug.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { type: 'string', enum: ['public', 'anyone_with_link'], default: 'anyone_with_link' }, + }, + additionalProperties: false, + }, + project: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the project.' }, + subdomain: { + type: 'string', + pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', + maxLength: 30, + description: 'The subdomain of the project.', + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project that this page belongs to.', + }, + }, + required: ['name', 'subdomain', 'uri'], + additionalProperties: false, + }, + renderable: { + type: 'object', + properties: { + status: { + type: 'boolean', + default: true, + description: 'A flag for if the page is renderable or not.', + }, + error: { type: 'string', nullable: true, description: 'The rendering error.' }, + message: { + type: 'string', + nullable: true, + description: 'Additional details about the rendering error.', + }, + }, + additionalProperties: false, + }, + slug: { + allOf: [{ type: 'string' }, { type: 'string', minLength: 1 }], + description: 'The accessible URL slug for the page.', + }, + state: { type: 'string', enum: ['current', 'deprecated'], default: 'current' }, + title: { type: 'string' }, + type: { type: 'string', enum: ['api_config', 'basic', 'endpoint', 'link'], default: 'basic' }, + updated_at: { + type: 'string', + format: 'date-time', + description: 'An ISO 8601 formatted date for when the page was updated.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/(guides|reference)\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: 'A URI to the page resource.', + }, + api_config: { + type: 'string', + enum: ['authentication', 'getting-started', 'my-requests'], + nullable: true, + }, + api: { + type: 'object', + properties: { + method: { + type: 'string', + enum: ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'], + description: 'The endpoint HTTP method.', + }, + path: { type: 'string', description: 'The endpoint path.' }, + schema: { + nullable: true, + description: + 'The API schema for this reference endpoint. This schema is a reduced version of the full API definition and only contains the necessary information for this endpoint.', + }, + stats: { + type: 'object', + properties: { + additional_properties: { + type: 'boolean', + default: false, + description: + 'This API operation uses `additionalProperties` for handling extra schema properties.', + }, + callbacks: { + type: 'boolean', + default: false, + description: 'This API operation has `callbacks` documented.', + }, + circular_references: { + type: 'boolean', + default: false, + description: + 'This API operation contains `$ref` schema pointers that resolve to itself.', + }, + common_parameters: { + type: 'boolean', + default: false, + description: 'This API operation utilizes common parameters set at the path level.', + }, + discriminators: { + type: 'boolean', + default: false, + description: + 'This API operation utilizes `discriminator` for discriminating between different parts in a polymorphic schema.', + }, + links: { + type: 'boolean', + default: false, + description: 'This API operation has `links` documented.', + }, + polymorphism: { + type: 'boolean', + default: false, + description: 'This API operation contains polymorphic schemas.', + }, + server_variables: { + type: 'boolean', + default: false, + description: + 'This API operation has composable variables configured for its server definition.', + }, + style: { + type: 'boolean', + default: false, + description: + 'This API operation has parameters that have specific `style` serializations.', + }, + webhooks: { + type: 'boolean', + default: false, + description: 'This API definition has `webhooks` documented.', + }, + xml: { + type: 'boolean', + default: false, + description: 'This API operation has parameters or schemas that serialize to XML.', + }, + references: { + type: 'boolean', + description: + 'This API operation, after being dereferenced, has `x-readme-ref-name` entries defining what the original `$ref` schema pointers were named.', + }, + }, + required: ['references'], + additionalProperties: false, + description: 'OpenAPI features that are utilized within this API operation.', + }, + source: { + type: 'string', + enum: ['api', 'apidesigner', 'apieditor', 'bidi', 'form', 'rdme', 'rdme_github', 'url'], + nullable: true, + description: 'The source by which this API definition was ingested.', + }, + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/apis\\/((([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+.(json|yaml|yml)))', + nullable: true, + description: 'A URI to the API resource.', + }, + }, + required: ['method', 'path', 'stats', 'source', 'uri'], + additionalProperties: false, + description: 'Information about the API that this reference page is attached to.', + }, + connections: { + type: 'object', + properties: { + recipes: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/recipes\\/(([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + description: + 'URI of the recipe that this API reference is connected to. The recipe and API reference must exist within the same version.', + }, + }, + required: ['uri'], + additionalProperties: false, + }, + nullable: true, + description: 'A collection of recipes that are displayed on this API reference.', + }, + }, + required: ['recipes'], + additionalProperties: false, + }, + }, + required: [ + 'category', + 'content', + 'href', + 'metadata', + 'parent', + 'privacy', + 'project', + 'renderable', + 'slug', + 'title', + 'updated_at', + 'uri', + 'api_config', + 'api', + 'connections', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/projects/me': { + get: { + operationId: 'getProject', + summary: 'Get project metadata', + tags: ['Projects'], + description: + "Returns data about your project.\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + allow_crawlers: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow indexing by robots.', + }, + api_designer: { + type: 'object', + properties: { + allow_editing: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'API Designer is enabled for this project.', + }, + }, + additionalProperties: false, + }, + appearance: { + type: 'object', + properties: { + brand: { + type: 'object', + properties: { + primary_color: { type: 'string', nullable: true }, + link_color: { type: 'string', nullable: true }, + theme: { type: 'string', enum: ['system', 'light', 'dark'], default: 'light' }, + }, + required: ['primary_color', 'link_color'], + additionalProperties: false, + }, + changelog: { + type: 'object', + properties: { + layout: { type: 'string', enum: ['collapsed', 'continuous'], default: 'collapsed' }, + show_author: { + type: 'boolean', + default: true, + description: 'Should the changelog author be shown?', + }, + show_exact_date: { + type: 'boolean', + default: false, + description: + 'Should the exact date of the changelog entry be shown, or should it be relative?', + }, + }, + additionalProperties: false, + }, + custom_code: { + type: 'object', + properties: { + css: { + type: 'string', + nullable: true, + description: + 'A chunk of custom CSS that you can use to override default CSS that we provide.', + }, + js: { + type: 'string', + nullable: true, + description: + 'A chunk of custom JS that you can use to override or add new behaviors to your documentation. Please note that we do not do any validation on the code that goes in here so you have the potential to negatively impact your users with broken code.', + }, + html: { + type: 'object', + properties: { + header: { + type: 'string', + nullable: true, + description: + 'A block of custom HTML that will be added to your `` tag. Good for things like `` tags or loading external CSS.', + }, + home_footer: { + type: 'string', + nullable: true, + description: + 'A block of custom HTML that will be added before the closing `` tag of your **home page**.', + }, + page_footer: { + type: 'string', + nullable: true, + description: + 'A block of custom HTML that will be added before the closing `` tag of your pages.', + }, + }, + required: ['header', 'home_footer', 'page_footer'], + additionalProperties: false, + }, + }, + required: ['css', 'js', 'html'], + additionalProperties: false, + }, + footer: { + type: 'object', + properties: { readme_logo: { type: 'string', enum: ['hide', 'show'], default: 'show' } }, + additionalProperties: false, + }, + header: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['solid', 'gradient', 'line', 'overlay'], + default: 'solid', + }, + gradient_color: { type: 'string', nullable: true }, + overlay: { + type: 'object', + properties: { + image: { + type: 'object', + properties: { + name: { type: 'string', nullable: true }, + width: { + type: 'number', + nullable: true, + description: 'The pixel width of the image. This is not present for SVGs.', + }, + height: { + type: 'number', + nullable: true, + description: 'The pixel height of the image. This is not present for SVGs.', + }, + color: { + type: 'string', + pattern: + '^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})$', + nullable: true, + description: 'The primary color contained within your image.', + }, + links: { + type: 'object', + properties: { + original_url: { + type: 'string', + format: 'uri', + nullable: true, + description: + 'If your image was resized upon upload this will be a URL to the original file.', + }, + }, + required: ['original_url'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['name', 'width', 'height', 'color', 'links', 'uri', 'url'], + additionalProperties: false, + }, + type: { + type: 'string', + enum: ['triangles', 'blueprint', 'grain', 'map', 'circuits', 'custom'], + default: 'triangles', + description: + 'The header overlay type. This value is only used if `appearance.header.type` is `overlay`.', + }, + fill: { + type: 'string', + enum: ['auto', 'tile', 'tile-x', 'tile-y', 'cover', 'contain'], + default: 'auto', + description: + 'The header fill type. This is only used if `appearance.header.overlay.type` is `custom`.', + }, + position: { + type: 'string', + enum: [ + 'top-left', + 'top-center', + 'top-right', + 'center-left', + 'center-center', + 'center-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ], + default: 'top-left', + description: + 'The positioning of the header. This is only used if `appearance.header.overlay.type` is `custom`.', + }, + }, + required: ['image'], + additionalProperties: false, + }, + }, + required: ['gradient_color', 'overlay'], + additionalProperties: false, + }, + logo: { + type: 'object', + properties: { + dark_mode: { + type: 'object', + properties: { + name: { type: 'string', nullable: true }, + width: { + type: 'number', + nullable: true, + description: 'The pixel width of the image. This is not present for SVGs.', + }, + height: { + type: 'number', + nullable: true, + description: 'The pixel height of the image. This is not present for SVGs.', + }, + color: { + type: 'string', + pattern: '^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})$', + nullable: true, + description: 'The primary color contained within your image.', + }, + links: { + type: 'object', + properties: { + original_url: { + type: 'string', + format: 'uri', + nullable: true, + description: + 'If your image was resized upon upload this will be a URL to the original file.', + }, + }, + required: ['original_url'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['name', 'width', 'height', 'color', 'links', 'uri', 'url'], + additionalProperties: false, + }, + main: { + type: 'object', + properties: { + name: { type: 'string', nullable: true }, + width: { + type: 'number', + nullable: true, + description: 'The pixel width of the image. This is not present for SVGs.', + }, + height: { + type: 'number', + nullable: true, + description: 'The pixel height of the image. This is not present for SVGs.', + }, + color: { + type: 'string', + pattern: '^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})$', + nullable: true, + description: 'The primary color contained within your image.', + }, + links: { + type: 'object', + properties: { + original_url: { + type: 'string', + format: 'uri', + nullable: true, + description: + 'If your image was resized upon upload this will be a URL to the original file.', + }, + }, + required: ['original_url'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['name', 'width', 'height', 'color', 'links', 'uri', 'url'], + additionalProperties: false, + }, + favicon: { + type: 'object', + properties: { + name: { type: 'string', nullable: true }, + width: { + type: 'number', + nullable: true, + description: 'The pixel width of the image. This is not present for SVGs.', + }, + height: { + type: 'number', + nullable: true, + description: 'The pixel height of the image. This is not present for SVGs.', + }, + color: { + type: 'string', + pattern: '^(?:#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})$', + nullable: true, + description: 'The primary color contained within your image.', + }, + links: { + type: 'object', + properties: { + original_url: { + type: 'string', + format: 'uri', + nullable: true, + description: + 'If your image was resized upon upload this will be a URL to the original file.', + }, + }, + required: ['original_url'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: '\\/images\\/([a-f\\d]{24})', + nullable: true, + description: + 'A URI to the `getImages` endpoint for this image. If the is a legacy image then this `uri` will be `null`. And if you wish to delete this image then you should set this to `null`.', + }, + url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['name', 'width', 'height', 'color', 'links', 'uri', 'url'], + additionalProperties: false, + }, + size: { type: 'string', enum: ['default', 'large'], default: 'default' }, + }, + required: ['dark_mode', 'main', 'favicon'], + additionalProperties: false, + }, + markdown: { + type: 'object', + properties: { + callouts: { + type: 'object', + properties: { + icon_font: { + type: 'string', + enum: ['emojis', 'fontawesome'], + default: 'emojis', + description: 'Handles the types of icons that are shown in Markdown callouts.', + }, + }, + additionalProperties: false, + }, + }, + required: ['callouts'], + additionalProperties: false, + }, + navigation: { + type: 'object', + properties: { + first_page: { + type: 'string', + enum: ['documentation', 'reference', 'landing_page'], + default: 'landing_page', + description: + 'The page that users will first see when they access your documentation hub.', + }, + left: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: [ + 'home', + 'guides', + 'discussions', + 'changelog', + 'search_box', + 'link_url', + 'custom_page', + 'user_controls', + 'reference', + 'recipes', + ], + }, + title: { type: 'string', nullable: true }, + url: { type: 'string', nullable: true }, + custom_page: { type: 'string', nullable: true }, + }, + required: ['type', 'title', 'url', 'custom_page'], + additionalProperties: false, + }, + description: + 'The navigation settings for the left side of your projects navigation bar.', + }, + links: { + type: 'object', + properties: { + changelog: { + type: 'object', + properties: { + label: { type: 'string', enum: ['Changelog'] }, + alias: { type: 'string', nullable: true }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + }, + }, + required: ['label', 'alias'], + additionalProperties: false, + }, + discussions: { + type: 'object', + properties: { + label: { type: 'string', enum: ['Discussions'] }, + alias: { type: 'string', nullable: true }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + }, + }, + required: ['label', 'alias'], + additionalProperties: false, + }, + home: { + type: 'object', + properties: { + label: { type: 'string', enum: ['Home'] }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + }, + }, + required: ['label'], + additionalProperties: false, + }, + graphql: { + type: 'object', + properties: { + label: { type: 'string', enum: ['GraphQL'] }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + nullable: true, + }, + }, + required: ['label'], + additionalProperties: false, + }, + guides: { + type: 'object', + properties: { + label: { type: 'string', enum: ['Guides'] }, + alias: { type: 'string', nullable: true }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + }, + }, + required: ['label', 'alias'], + additionalProperties: false, + }, + recipes: { + type: 'object', + properties: { + label: { type: 'string', enum: ['Recipes'] }, + alias: { type: 'string', nullable: true }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + }, + }, + required: ['label', 'alias'], + additionalProperties: false, + }, + reference: { + type: 'object', + properties: { + label: { type: 'string', enum: ['API Reference'] }, + alias: { type: 'string', nullable: true }, + visibility: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + }, + }, + required: ['label', 'alias'], + additionalProperties: false, + }, + }, + required: [ + 'changelog', + 'discussions', + 'home', + 'graphql', + 'guides', + 'recipes', + 'reference', + ], + additionalProperties: false, + }, + logo_link: { + type: 'string', + enum: ['landing_page', 'homepage'], + default: 'homepage', + description: + 'Where users will be directed to when they click on your logo in the navigation bar.', + }, + right: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: [ + 'home', + 'guides', + 'discussions', + 'changelog', + 'search_box', + 'link_url', + 'custom_page', + 'user_controls', + 'reference', + 'recipes', + ], + }, + title: { type: 'string', nullable: true }, + url: { type: 'string', nullable: true }, + custom_page: { type: 'string', nullable: true }, + }, + required: ['type', 'title', 'url', 'custom_page'], + additionalProperties: false, + }, + description: + 'The navigation settings for the right side of your projects navigation bar.', + }, + sub_nav: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: [ + 'home', + 'guides', + 'discussions', + 'changelog', + 'search_box', + 'link_url', + 'custom_page', + 'user_controls', + 'reference', + 'recipes', + ], + }, + title: { type: 'string', nullable: true }, + url: { type: 'string', nullable: true }, + custom_page: { type: 'string', nullable: true }, + }, + required: ['type', 'title', 'url', 'custom_page'], + additionalProperties: false, + }, + description: 'The navigation settings for your projects subnavigation bar.', + }, + subheader_layout: { type: 'string', enum: ['links', 'dropdown'], default: 'links' }, + version: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: + 'Should your current documentation version be shown in the navigation bar?', + }, + }, + required: ['left', 'links', 'right', 'sub_nav'], + additionalProperties: false, + }, + table_of_contents: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Should your guides show a table of contents?', + }, + whats_next_label: { + type: 'string', + nullable: true, + description: + 'What should we call the next steps section of your guides? Defaults to "What\'s Next".', + }, + }, + required: [ + 'brand', + 'changelog', + 'custom_code', + 'footer', + 'header', + 'logo', + 'markdown', + 'navigation', + 'whats_next_label', + ], + additionalProperties: false, + }, + canonical_url: { + type: 'string', + format: 'uri', + nullable: true, + description: + "The canonical base URL for your project defaults to your project's base URL, but you can override the canonical base URL with this field.", + }, + custom_login: { + type: 'object', + properties: { + jwt_secret: { type: 'string' }, + login_url: { type: 'string', format: 'uri', nullable: true }, + logout_url: { type: 'string', format: 'uri', nullable: true }, + }, + required: ['jwt_secret', 'login_url', 'logout_url'], + additionalProperties: false, + }, + description: { + type: 'string', + nullable: true, + description: + 'The description of your project. This is used in the page meta description and is seen by search engines and sites like Facebook.', + }, + glossary: { + type: 'array', + items: { + type: 'object', + properties: { + term: { + type: 'string', + description: + 'Glossary term is what gets displayed in your documentation when embedded.', + }, + definition: { + type: 'string', + description: + 'Glossary definition is revealed to users when they mouse over the glossary term.', + }, + }, + required: ['term', 'definition'], + additionalProperties: false, + }, + default: [], + description: + 'List of glossary terms in your project that can be used within your documentation.', + }, + health_check: { + type: 'object', + properties: { + provider: { + type: 'string', + enum: ['manual', 'statuspage', 'none'], + default: 'none', + description: + 'The type of provider you wish to use for for managing your APIs health: manually or through [Atlassian Statuspage](https://www.atlassian.com/software/statuspage).', + }, + settings: { + type: 'object', + properties: { + manual: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['up', 'down'], + default: 'up', + description: + 'If you are manually managing your APIs health this is a status boolean indicating if your API is up or down.', + }, + url: { + type: 'string', + nullable: true, + description: + 'The URL that we will show to your users when your API is down. This is only used when `health_check.provider` is set to `manual`.', + }, + }, + required: ['url'], + additionalProperties: false, + }, + statuspage: { + type: 'object', + properties: { + id: { + type: 'string', + nullable: true, + description: + 'If managing your APIs health through [Statuspage](https://www.atlassian.com/software/statuspage) this is your Statuspage ID.', + }, + }, + required: ['id'], + additionalProperties: false, + }, + }, + required: ['manual', 'statuspage'], + additionalProperties: false, + }, + }, + required: ['settings'], + additionalProperties: false, + }, + homepage_url: { + type: 'string', + nullable: true, + description: + 'The URL for your company\'s main website. We\'ll link to it in various places so people can "Go Home".', + }, + integrations: { + type: 'object', + properties: { + aws: { + type: 'object', + properties: { + readme_webhook_login: { + type: 'object', + properties: { + external_id: { type: 'string', nullable: true }, + region: { + type: 'string', + enum: [ + 'me-south-1', + 'cn-north-1', + 'ca-west-1', + 'us-west-1', + 'ca-central-1', + 'af-south-1', + 'eu-central-1', + 'ap-east-1', + 'ap-south-2', + 'eu-west-1', + 'ap-southeast-3', + 'eu-west-2', + 'ap-southeast-5', + 'ap-southeast-4', + 'eu-south-1', + 'ap-south-1', + 'cn-northwest-1', + 'us-east-2', + 'us-west-2', + 'ap-northeast-3', + 'eu-west-3', + 'sa-east-1', + 'ap-northeast-2', + 'ap-southeast-1', + 'eu-south-2', + 'eu-north-1', + 'ap-southeast-2', + 'il-central-1', + 'ap-northeast-1', + 'me-central-1', + 'us-east-1', + 'eu-central-2', + ], + nullable: true, + }, + role_arn: { type: 'string', nullable: true }, + usage_plan_id: { type: 'string', nullable: true }, + }, + required: ['external_id', 'region', 'role_arn', 'usage_plan_id'], + additionalProperties: false, + }, + }, + required: ['readme_webhook_login'], + additionalProperties: false, + }, + bing: { + type: 'object', + properties: { verify: { type: 'string', nullable: true } }, + required: ['verify'], + additionalProperties: false, + }, + google: { + type: 'object', + properties: { + analytics: { + type: 'string', + nullable: true, + description: + "Your Google Analytics ID. If it starts with UA-, we'll use Universal Analytics otherwise Google Analytics 4.", + }, + site_verification: { type: 'string', nullable: true }, + }, + required: ['analytics', 'site_verification'], + additionalProperties: false, + }, + heap: { + type: 'object', + properties: { id: { type: 'string', nullable: true } }, + required: ['id'], + additionalProperties: false, + }, + intercom: { + type: 'object', + properties: { + app_id: { type: 'string', nullable: true }, + secure_mode: { + type: 'object', + properties: { + key: { + type: 'string', + nullable: true, + description: + 'By supplying a secure mode key you will opt into [Intercoms Identity Verification](https://docs.intercom.io/configuring-intercom/enable-secure-mode) system.', + }, + email_only: { + type: 'boolean', + default: false, + description: + 'Should ReadMe only identify users by their email addresses? This integrates better with your existing Intercom but is possibly less secure.', + }, + }, + required: ['key'], + additionalProperties: false, + }, + }, + required: ['app_id', 'secure_mode'], + additionalProperties: false, + }, + koala: { + type: 'object', + properties: { key: { type: 'string', nullable: true } }, + required: ['key'], + additionalProperties: false, + }, + localize: { + type: 'object', + properties: { key: { type: 'string', nullable: true } }, + required: ['key'], + additionalProperties: false, + }, + recaptcha: { + type: 'object', + properties: { + site_key: { type: 'string', nullable: true }, + secret_key: { type: 'string', nullable: true }, + }, + required: ['site_key', 'secret_key'], + additionalProperties: false, + description: 'https://docs.readme.com/main/docs/recaptcha', + }, + segment: { + type: 'object', + properties: { + key: { type: 'string', nullable: true }, + domain: { + type: 'string', + nullable: true, + description: + 'If you are proxying [Segment](https://segment.com/) requests through a custom domain this is that domain. More information about this configuration can be found [here](https://docs.readme.com/main/docs/segment#using-a-custom-domain-with-segment).', + }, + }, + required: ['key', 'domain'], + additionalProperties: false, + }, + typekit: { + type: 'object', + properties: { key: { type: 'string', nullable: true } }, + required: ['key'], + additionalProperties: false, + }, + zendesk: { + type: 'object', + properties: { subdomain: { type: 'string', nullable: true } }, + required: ['subdomain'], + additionalProperties: false, + }, + }, + required: [ + 'aws', + 'bing', + 'google', + 'heap', + 'intercom', + 'koala', + 'localize', + 'recaptcha', + 'segment', + 'typekit', + 'zendesk', + ], + additionalProperties: false, + }, + name: { type: 'string', description: 'The name of the project.' }, + onboarding_completed: { + type: 'object', + properties: { + api: { type: 'boolean', default: false }, + appearance: { type: 'boolean', default: false }, + documentation: { type: 'boolean', default: false }, + domain: { type: 'boolean', default: false }, + jwt: { type: 'boolean', default: false }, + logs: { type: 'boolean', default: false }, + metricsSDK: { type: 'boolean', default: false }, + }, + additionalProperties: false, + }, + pages: { + type: 'object', + properties: { + not_found: { + type: 'string', + pattern: + '\\/versions\\/(stable|([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(-.*)?)\\/custom_pages\\/([a-f\\d]{24}|([a-z0-9-_ ]|[^\\\\x00-\\\\x7F])+)', + nullable: true, + description: + 'The page you wish to be served to your users when they encounter a 404. This can either map to the `uri` of a Custom Page on your project or be set to `null`. If `null` then the default ReadMe 404 page will be served. The version within the `uri` must be mapped to your stable version.', + }, + }, + required: ['not_found'], + additionalProperties: false, + }, + parent: { + type: 'string', + nullable: true, + description: + "Does the project have a parent project (enterprise)? If so, this resolves to the parent's subdomain.", + }, + plan: { + type: 'object', + properties: { + type: { + type: 'string', + enum: [ + 'business', + 'business2018', + 'business-annual-2024', + 'enterprise', + 'free', + 'freelaunch', + 'opensource', + 'startup', + 'startup2018', + 'startup-annual-2024', + ], + default: 'free', + }, + grace_period: { + type: 'object', + properties: { + enabled: { type: 'boolean', default: false }, + end_date: { type: 'string', format: 'date-time', nullable: true, default: null }, + }, + additionalProperties: false, + }, + trial: { + type: 'object', + properties: { + expired: { type: 'boolean', default: false }, + end_date: { + type: 'string', + format: 'date-time', + description: 'The end date for your two week trial.', + }, + }, + required: ['end_date'], + additionalProperties: false, + }, + }, + required: ['grace_period', 'trial'], + additionalProperties: false, + }, + privacy: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['public', 'admin', 'password', 'custom_login'], + default: 'public', + description: + '* `public` - Site is available to the public.\n* `admin` - Site is only available to users that have project permissions.\n* `password` - Site is gated behind a password authentication system.\n* `custom_login` - Users who view your site will be forwarded to a URL of your choice, having them login there and be forwarded back to your ReadMe site.', + }, + password: { + type: 'string', + nullable: true, + description: + "The project's password for when `privacy.view` is `password`. This field can be set, but it will not be returned by the API.", + }, + }, + required: ['password'], + additionalProperties: false, + }, + redirects: { + type: 'array', + items: { + type: 'object', + properties: { from: { type: 'string' }, to: { type: 'string' } }, + required: ['from', 'to'], + additionalProperties: false, + }, + description: + 'A collection of page redirects that ReadMe will permanently redirect users to when attempting to render a 404. Check out our [redirect docs](https://docs.readme.com/main/docs/error-pages#section-redirects) for more information on how they are handled.', + }, + refactored: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'Indicates if the project has our new Unified UI experience.', + }, + migrated: { + type: 'string', + enum: ['failed', 'processing', 'successful', 'unknown'], + default: 'unknown', + description: 'Indicates if the project has been migrated from Dash to Superhub.', + }, + }, + additionalProperties: false, + }, + reference: { + type: 'object', + properties: { + api_sdk_snippets: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Enable SDK-generated request code snippets.', + }, + defaults: { + type: 'string', + enum: ['always_use', 'use_only_if_required'], + default: 'use_only_if_required', + description: + 'When `always_use`, any `default` values defined in your API definition are used to populate your request data in the API Explorer, even if the parameter is not marked as `required`.', + }, + json_editor: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'When `enabled`, allows editing the request body with a JSON editor.', + }, + request_history: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'When `enabled`, request history for API endpoints are shown.', + }, + oauth_flows: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: + 'When `enabled`, enable the new OAuth Flows experience in the API Reference section.', + }, + response_examples: { + type: 'string', + enum: ['expanded', 'collapsed'], + default: 'collapsed', + description: + 'When `expanded`, response examples will be expanded by default if a 200 level response exists.', + }, + response_schemas: { + type: 'string', + enum: ['expanded', 'collapsed'], + default: 'collapsed', + description: + 'When `expanded`, response schemas will be expanded by default if a 200 level response schema exists.', + }, + }, + additionalProperties: false, + description: + 'Contains options to configure interactive sections on your API Reference pages.', + }, + seo: { + type: 'object', + properties: { + overwrite_title_tag: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: + "Overwrite pages' tag with their custom metadata title (if present).", + }, + }, + additionalProperties: false, + }, + sitemap: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'Expose a `sitemap.xml` directory on your project.', + }, + subdomain: { type: 'string', pattern: '[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*', maxLength: 30 }, + suggested_edits: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'enabled', + description: 'Allow users to suggest edits to your documentation.', + }, + variable_defaults: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Variable Identifier' }, + name: { type: 'string', description: 'The key name of the variable.' }, + default: { type: 'string', description: 'The default value of the variable.' }, + source: { + type: 'string', + enum: ['server', 'security', 'custom', ''], + default: '', + description: + 'The variables source. This can come from a user input or from syncing an OpenAPI definition.', + }, + type: { + type: 'string', + enum: ['http', 'apiKey', 'openIdConnect', 'oauth2', ''], + description: + 'If variable `source` is `security`, include the OpenAPI security auth type.', + }, + scheme: { + type: 'string', + description: + 'If variable `source` is `security`, include the OpenAPI security auth scheme.', + }, + }, + required: ['id', 'name'], + additionalProperties: false, + }, + default: [], + }, + webhooks: { + type: 'array', + items: { + type: 'object', + properties: { + action: { type: 'string', enum: ['login'], default: 'login' }, + timeout: { type: 'number', default: 5000 }, + url: { type: 'string', format: 'uri' }, + }, + required: ['url'], + additionalProperties: false, + }, + default: [], + }, + id: { + type: 'string', + pattern: '^[a-f\\d]{24}$', + description: 'The unique, immutable, identifier for the project.', + }, + features: { + type: 'object', + properties: { + custom_components: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'If this project supports creating custom components.', + }, + mdx: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'If this project supports MDX.', + }, + }, + additionalProperties: false, + }, + permissions: { + type: 'object', + properties: { + appearance: { + type: 'object', + properties: { + private_label: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: + 'If this project is allowed to private label their Hub and remove all ReadMe branding.', + }, + custom_code: { + type: 'object', + properties: { + css: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'If this project is allowed to utilize custom CSS.', + }, + html: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'If this project is allowed to utilize custom HTML.', + }, + js: { + type: 'string', + enum: ['enabled', 'disabled'], + default: 'disabled', + description: 'If this project is allowed to utilize custom JS.', + }, + }, + additionalProperties: false, + }, + }, + required: ['custom_code'], + additionalProperties: false, + }, + }, + required: ['appearance'], + additionalProperties: false, + }, + uri: { + type: 'string', + pattern: '\\/projects\\/(me|[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)', + description: 'A URI to the project resource.', + }, + }, + required: [ + 'api_designer', + 'appearance', + 'canonical_url', + 'custom_login', + 'description', + 'health_check', + 'homepage_url', + 'integrations', + 'name', + 'onboarding_completed', + 'pages', + 'parent', + 'plan', + 'privacy', + 'redirects', + 'refactored', + 'reference', + 'seo', + 'subdomain', + 'id', + 'features', + 'permissions', + 'uri', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/search': { + get: { + operationId: 'search', + summary: 'Perform a search query', + tags: ['Search'], + description: + "Searches the developer hub.\n\n>🚧 ReadMe's API v2 is currently in beta.\n> This API and its docs are a work in progress. While we don’t expect any major breaking changes, you may encounter occasional issues as we work toward a stable release. Make sure to [check out our API migration guide](https://docs.readme.com/main/reference/api-migration-guide), and [feel free to reach out](mailto:support@readme.io) if you have any questions or feedback!", + parameters: [ + { + schema: { type: 'string' }, + in: 'query', + name: 'query', + required: true, + description: 'The plain text search query used to search across the project.', + }, + { + schema: { + type: 'string', + enum: ['guides', 'reference', 'recipes', 'custom_pages', 'discuss', 'changelog'], + }, + in: 'query', + name: 'section', + required: false, + description: 'The section to search within.', + }, + { + schema: { type: 'string' }, + in: 'query', + name: 'version', + required: false, + description: 'The version to search within. For enterprise, this only applies to the current project.', + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + total: { type: 'number' }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + url: { + type: 'object', + properties: { + full: { type: 'string', description: 'The full URL of the page.' }, + relative: { + type: 'string', + description: 'The relative URL of the page without the version or base URL.', + }, + }, + required: ['full', 'relative'], + additionalProperties: false, + }, + title: { type: 'string' }, + excerpt: { type: 'string' }, + highlights: { + type: 'array', + items: { + type: 'object', + properties: { + score: { type: 'number' }, + path: { type: 'string', enum: ['title', 'excerpt', 'searchContents', 'body'] }, + texts: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string' }, + type: { type: 'string', enum: ['hit', 'text'] }, + }, + required: ['value', 'type'], + additionalProperties: false, + }, + }, + }, + required: ['score', 'path', 'texts'], + additionalProperties: false, + }, + }, + slug: { type: 'string' }, + section: { + type: 'string', + enum: ['guides', 'reference', 'recipes', 'custom_pages', 'discuss', 'changelog'], + }, + version: { + type: 'string', + nullable: true, + description: 'The semver version number this search is scoped to.', + }, + subdomain: { type: 'string' }, + api: { + type: 'object', + properties: { method: { type: 'string', nullable: true } }, + required: ['method'], + additionalProperties: false, + }, + uri: { type: 'string' }, + }, + required: [ + 'url', + 'title', + 'excerpt', + 'highlights', + 'slug', + 'section', + 'version', + 'subdomain', + 'api', + 'uri', + ], + additionalProperties: false, + }, + }, + }, + required: ['total', 'data'], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + }, + servers: [{ url: 'https://api.readme.com/v2', description: 'The ReadMe API' }], + security: [{ bearer: [] }], + 'x-readme': { 'proxy-enabled': true }, + tags: [ + { name: 'API Reference' }, + { name: 'APIs' }, + { name: 'Changelog' }, + { name: 'Custom Pages' }, + { name: 'Guides' }, + { name: 'Projects' }, + { name: 'Search' }, + ], +} as const satisfies OASDocument; + +export const categoryUriRegexPattern = + readmeAPIv2Oas.paths['/versions/{version}/guides'].post.requestBody.content['application/json'].schema.properties + .category.properties.uri.pattern; + +export const parentUriRegexPattern = + readmeAPIv2Oas.paths['/versions/{version}/guides'].post.requestBody.content['application/json'].schema.properties + .parent.properties.uri.pattern; + +type guidesRequestBodySchema = + (typeof readmeAPIv2Oas)['paths']['/versions/{version}/guides/{slug}']['patch']['requestBody']['content']['application/json']['schema']; + +/** + * Derived from our API documentation, this is the schema for the `guides` object + * as we send it to the ReadMe API. + * + * This is only for TypeScript type-checking purposes β€” we use ajv + * to validate the user's schema during runtime. + */ +export type GuidesRequestRepresentation = FromSchema< + guidesRequestBodySchema, + { keepDefaultedPropertiesOptional: true } +>;