diff --git a/.npmignore b/.npmignore
index 51e02af..25abfac 100644
--- a/.npmignore
+++ b/.npmignore
@@ -3,4 +3,6 @@ docs/
src/**/*.test.ts
eslint.config.mjs
.prettierrc
-.gitignore
\ No newline at end of file
+.gitignore
+.claude/
+CLAUDE.md
\ No newline at end of file
diff --git a/docs/examples/redoc-dark.html b/docs/examples/redoc-dark.html
index 4ca3100..4f079b2 100644
--- a/docs/examples/redoc-dark.html
+++ b/docs/examples/redoc-dark.html
@@ -1,12 +1,132 @@
-
+
OpenAPI Docs
+
-
+
+
+
-
+
+
+
+
\ No newline at end of file
diff --git a/docs/examples/redoc-light.html b/docs/examples/redoc-light.html
index c640e0b..4686521 100644
--- a/docs/examples/redoc-light.html
+++ b/docs/examples/redoc-light.html
@@ -1,12 +1,132 @@
-
+
OpenAPI Docs
+
-
+
+
+
-
+
+
+
+
\ No newline at end of file
diff --git a/docs/examples/stoplight-dark.html b/docs/examples/stoplight-dark.html
index 5ac18c4..eea3d3e 100644
--- a/docs/examples/stoplight-dark.html
+++ b/docs/examples/stoplight-dark.html
@@ -1,12 +1,133 @@
-
+
OpenAPI Docs
+
+
+
+
-
+
+
+
+
diff --git a/docs/examples/stoplight-light.html b/docs/examples/stoplight-light.html
index e8e7565..05b7fa6 100644
--- a/docs/examples/stoplight-light.html
+++ b/docs/examples/stoplight-light.html
@@ -1,12 +1,133 @@
-
+
OpenAPI Docs
+
+
+
+
-
+
+
+
+
diff --git a/docs/examples/swagger-dark.html b/docs/examples/swagger-dark.html
index 40b262d..8202f4d 100644
--- a/docs/examples/swagger-dark.html
+++ b/docs/examples/swagger-dark.html
@@ -1,11 +1,132 @@
-
+
OpenAPI Docs
-
+
+
+
+
+
-
+
+
+
-
+
+
diff --git a/docs/examples/swagger-light.html b/docs/examples/swagger-light.html
index 3370563..1df1cab 100644
--- a/docs/examples/swagger-light.html
+++ b/docs/examples/swagger-light.html
@@ -1,11 +1,132 @@
-
+
OpenAPI Docs
-
+
+
+
+
+
-
+
+
+
-
+
+
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 64dbaee..1ef6541 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,6 +11,7 @@ export default [
...globals.jest,
SwaggerUIBundle: 'readonly',
Redoc: 'readonly',
+ autoComplete: 'readonly',
},
},
},
diff --git a/package-lock.json b/package-lock.json
index b951789..0884f77 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openapi-generate-html",
- "version": "0.4.4",
+ "version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openapi-generate-html",
- "version": "0.4.4",
+ "version": "0.5.0",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^14.1.0",
diff --git a/package.json b/package.json
index 42b6300..50231e8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openapi-generate-html",
- "version": "0.4.4",
+ "version": "0.5.0",
"description": "Generate standalone HTML from OpenAPI Specification",
"type": "module",
"bin": {
diff --git a/resources/common/index.css b/resources/common/index.css
new file mode 100644
index 0000000..2315f73
--- /dev/null
+++ b/resources/common/index.css
@@ -0,0 +1,118 @@
+#search-button {
+ position: fixed;
+ top: 28px;
+ right: 28px;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ background-color: #fff;
+ border-radius: 4px;
+ border: 1px solid #d9d9d9;
+ padding: 4px 12px 4px 8px;
+ cursor: pointer;
+
+ .icon {
+ font-size: 18px;
+ margin-right: 4px;
+ }
+}
+
+#search-dialog {
+ position: fixed;
+ top: 10px;
+ margin: 0 auto;
+ height: 100%;
+ background: transparent;
+ border: none;
+
+ .auto-complete-list {
+ max-height: 265px;
+ }
+}
+
+#auto-complete {
+ background-color: #fff;
+}
+
+.auto-complete-list {
+ .auto-complete-list-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ cursor: pointer;
+ border-bottom: 1px solid #f0f0f0;
+ border-radius: 0;
+ }
+}
+
+.method-tag {
+ display: inline-block;
+ padding: 2px 0;
+ border-radius: 4px;
+ font-size: 11px;
+ color: #fff;
+ min-width: 54px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.endpoint-info {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.endpoint-path {
+ font-size: 13px;
+}
+
+.endpoint-summary {
+ font-size: 11px;
+ color: #666;
+}
+
+[data-theme=dark] {
+ #search-dialog::backdrop {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ #search-button {
+ background-color: #1f1f1f;
+ border: 1px solid #444;
+ color: #fff;
+ }
+
+ #auto-complete {
+ background-color: #1f1f1f;
+ border: 1px solid #444;
+ color: #fff;
+ }
+
+ .auto-complete-list {
+ background-color: #1f1f1f;
+ border: 1px solid #444;
+
+ .auto-complete-list-item {
+ background-color: #1f1f1f;
+ border-bottom: 1px solid #444;
+ color: #fff;
+
+ &:hover {
+ background-color: #333;
+ }
+ }
+
+ .auto-complete-list-item[aria-selected="true"] {
+ background-color: #333;
+ }
+ }
+
+ .endpoint-path {
+ color: #ccc;
+ }
+
+ .endpoint-summary {
+ color: #888;
+ }
+}
\ No newline at end of file
diff --git a/resources/common/index.js b/resources/common/index.js
new file mode 100644
index 0000000..93672ed
--- /dev/null
+++ b/resources/common/index.js
@@ -0,0 +1,93 @@
+// eslint-disable-next-line no-unused-vars
+class SearchDialog {
+ dialog
+ autocomplete
+
+ constructor(dialogId, inputId, openapi) {
+ this.dialog = document.getElementById(dialogId)
+ if (!this.dialog) {
+ throw new Error(`Dialog with id "${dialogId}" not found`)
+ }
+
+ this.autocomplete = new autoComplete({
+ selector: `#${inputId}`,
+ placeHolder: 'Search endpoints',
+ data: {
+ src: this.filterEndpoints(openapi),
+ keys: ['method', 'path', 'summary'],
+ },
+ searchEngine: 'loose',
+ threshold: 0,
+ resultsList: {
+ maxResults: undefined,
+ class: 'auto-complete-list',
+ element: (list, data) => {
+ if (data.results.length > 0) {
+ list.innerHTML = ''
+ data.results.forEach((item) => {
+ const li = document.createElement('li')
+ li.className = 'auto-complete-list-item'
+ li.innerHTML = `
+ ${item.value.method}
+
+
${item.value.path}
+
${item.value.summary}
+
+ `
+ list.appendChild(li)
+ })
+ }
+ },
+ },
+ })
+
+ this.dialog.addEventListener('click', (event) => {
+ if (event.target === this.dialog) {
+ this.hide()
+ }
+ })
+ }
+
+ on(event, callback) {
+ if (event === 'select') {
+ this.autocomplete.input.addEventListener('selection', (event) => {
+ callback(event.detail.selection.value)
+ })
+ } else {
+ throw new Error(`Unsupported event: ${event}`)
+ }
+ }
+
+ show() {
+ this.autocomplete.input.value = ''
+ this.autocomplete.start()
+ this.dialog.showModal()
+ }
+
+ hide() {
+ this.dialog.close()
+ }
+
+ filterEndpoints(openapi) {
+ const endpoints = []
+ const paths = openapi.paths
+ for (const path in paths) {
+ const methods = Object.keys(paths[path])
+ for (const method of methods) {
+ if (method === 'parameters') {
+ continue
+ }
+ endpoints.push({
+ method: method.toUpperCase(),
+ path,
+ operationId: paths[path][method].operationId,
+ tag: paths[path][method].tags
+ ? paths[path][method].tags[0]
+ : 'default',
+ summary: paths[path][method].summary || '',
+ })
+ }
+ }
+ return endpoints
+ }
+}
diff --git a/resources/redoc/index.css b/resources/redoc/index.css
index 4e80383..d5d6c7f 100644
--- a/resources/redoc/index.css
+++ b/resources/redoc/index.css
@@ -12,4 +12,36 @@ body {
border: none !important;
background-color: #3f3f46 !important;
}
+}
+
+.method-get {
+ background-color: rgb(47, 129, 50);
+}
+
+.method-post {
+ background-color: rgb(24, 111, 175);
+}
+
+.method-put {
+ background-color: rgb(149, 80, 124);
+}
+
+.method-patch {
+ background-color: rgb(149, 80, 124);
+}
+
+.method-delete {
+ background-color: rgb(204, 51, 51);
+}
+
+.method-head {
+ background-color: rgb(162, 61, 173);
+}
+
+.method-options {
+ background-color: rgb(148, 112, 20);
+}
+
+.method-trace {
+ background-color: #000;
}
\ No newline at end of file
diff --git a/resources/redoc/index.js b/resources/redoc/index.js
index 6e2d2ad..6b9cc95 100644
--- a/resources/redoc/index.js
+++ b/resources/redoc/index.js
@@ -122,10 +122,46 @@ const redark = {
},
}
-let option = {}
+let option = {
+ disableSearch: true,
+}
if (document.documentElement.getAttribute('data-theme') === 'dark') {
option = { theme: redark }
}
// ref. https://redocly.com/docs/redoc/deployment/html#the-redoc-object
Redoc.init(window.apiDocs, option, document.getElementById('redoc-container'))
+
+// ref. https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples
+const modifierKeyPrefix =
+ navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
+ ? '⌘' // command key
+ : '^' // control key
+
+const searchButton = document.querySelector('#search-button')
+searchButton.addEventListener('click', () => searchDialog.show())
+searchButton.querySelector('.text').textContent =
+ `Search ${modifierKeyPrefix}+K`
+
+// eslint-disable-next-line no-undef
+const searchDialog = new SearchDialog(
+ 'search-dialog',
+ 'auto-complete',
+ window.apiDocs,
+)
+searchDialog.on('select', (selection) => {
+ const tag = selection.tag
+ const operationId = selection.operationId
+ const url = new URL(location.href)
+ url.hash = `#tag/${tag}/operation/${operationId}`
+ location.href = url.toString()
+ searchDialog.hide()
+})
+
+document.addEventListener('keydown', function (e) {
+ const isCmdOrCtrl = modifierKeyPrefix === '⌘' ? e.metaKey : e.ctrlKey
+ if (isCmdOrCtrl && e.key.toLowerCase() === 'k') {
+ searchDialog.show()
+ e.preventDefault()
+ }
+})
diff --git a/resources/redoc/template.ejs b/resources/redoc/template.ejs
index 8b87ea7..2b6fef2 100644
--- a/resources/redoc/template.ejs
+++ b/resources/redoc/template.ejs
@@ -1,18 +1,29 @@
-
+
<%= title %>
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/resources/stoplight/index.css b/resources/stoplight/index.css
index 5f65954..ed6805a 100644
--- a/resources/stoplight/index.css
+++ b/resources/stoplight/index.css
@@ -13,4 +13,36 @@
.sl-elements-api {
background-color: var(--color-canvas-pure);
}
+}
+
+.method-get {
+ background-color: #05b870;
+}
+
+.method-post {
+ background-color: #19abff;
+}
+
+.method-put {
+ background-color: #f46d2a;
+}
+
+.method-patch {
+ background-color: #f46d2a;
+}
+
+.method-delete {
+ background-color: #f05151;
+}
+
+.method-head {
+ background-color: rgb(144, 97, 249);
+}
+
+.method-options {
+ background-color: rgb(13, 90, 167);
+}
+
+.method-trace {
+ background-color: rgb(13, 11, 40);
}
\ No newline at end of file
diff --git a/resources/stoplight/index.js b/resources/stoplight/index.js
index 5155922..d0d1311 100644
--- a/resources/stoplight/index.js
+++ b/resources/stoplight/index.js
@@ -4,8 +4,41 @@ if (THEMES.includes(themeParam)) {
document.documentElement.setAttribute('data-theme', themeParam)
}
+// ref. https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples
+const modifierKeyPrefix =
+ navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
+ ? '⌘' // command key
+ : '^' // control key
+
/* ref. https://github.com/stoplightio/elements/blob/main/docs/getting-started/elements/html.md#examples */
;(async () => {
const docs = document.querySelector('.elements-api')
docs.apiDescriptionDocument = window.apiDocs
+
+ const searchButton = document.querySelector('#search-button')
+ searchButton.addEventListener('click', () => searchDialog.show())
+ searchButton.querySelector('.text').textContent =
+ `Search ${modifierKeyPrefix}+K`
+
+ // eslint-disable-next-line no-undef
+ const searchDialog = new SearchDialog(
+ 'search-dialog',
+ 'auto-complete',
+ window.apiDocs,
+ )
+ searchDialog.on('select', (selection) => {
+ const operationId = selection.operationId
+ const url = new URL(location.href)
+ url.hash = `#/operations/${operationId}`
+ location.href = url.toString()
+ searchDialog.hide()
+ })
+
+ document.addEventListener('keydown', function (e) {
+ const isCmdOrCtrl = modifierKeyPrefix === '⌘' ? e.metaKey : e.ctrlKey
+ if (isCmdOrCtrl && e.key.toLowerCase() === 'k') {
+ searchDialog.show()
+ e.preventDefault()
+ }
+ })
})()
diff --git a/resources/stoplight/template.ejs b/resources/stoplight/template.ejs
index ca18362..c2b323d 100644
--- a/resources/stoplight/template.ejs
+++ b/resources/stoplight/template.ejs
@@ -1,17 +1,29 @@
-
+
<%= title %>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/resources/swagger/index.css b/resources/swagger/index.css
index b1da224..8f26681 100644
--- a/resources/swagger/index.css
+++ b/resources/swagger/index.css
@@ -11,4 +11,36 @@ html[data-theme="dark"] {
.swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}
+}
+
+.method-get {
+ background-color: #61affe;
+}
+
+.method-post {
+ background-color: #49cc90;
+}
+
+.method-put {
+ background-color: #fca130;
+}
+
+.method-patch {
+ background-color: #50e3c2;
+}
+
+.method-delete {
+ background-color: #f93e3e;
+}
+
+.method-head {
+ background-color: #9012fe;
+}
+
+.method-options {
+ background-color: #0d5aa7;
+}
+
+.method-trace {
+ background-color: #000;
}
\ No newline at end of file
diff --git a/resources/swagger/index.js b/resources/swagger/index.js
index 6d07311..1dbe2a7 100644
--- a/resources/swagger/index.js
+++ b/resources/swagger/index.js
@@ -4,10 +4,57 @@ if (THEMES.includes(themeParam)) {
document.documentElement.setAttribute('data-theme', themeParam)
}
+// ref. https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples
+const modifierKeyPrefix =
+ navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
+ ? '⌘' // command key
+ : '^' // control key
+
/* ref. https://swagger.io/docs/open-source-tools/swagger-ui/usage/installation/ */
window.onload = () => {
window.ui = SwaggerUIBundle({
spec: window.apiDocs,
dom_id: '#swagger-ui',
+ deepLinking: true,
+ })
+
+ const searchButton = document.querySelector('#search-button')
+ searchButton.addEventListener('click', () => searchDialog.show())
+ searchButton.querySelector('.text').textContent =
+ `Search ${modifierKeyPrefix}+K`
+
+ // eslint-disable-next-line no-undef
+ const searchDialog = new SearchDialog(
+ 'search-dialog',
+ 'auto-complete',
+ window.apiDocs,
+ )
+ searchDialog.on('select', (selection) => {
+ const tag = selection.tag
+ const operationId = selection.operationId
+ const url = new URL(location.href)
+ url.hash = `#/${tag}/${operationId}`
+ location.href = url.toString()
+ searchDialog.hide()
+
+ const opblock = document.querySelector(`#operations-${tag}-${operationId}`)
+ opblock.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ })
+ const opblockButton = opblock.querySelector(
+ '.opblock-summary-control[aria-expanded="false"]',
+ )
+ if (opblockButton) {
+ opblockButton.click()
+ }
+ })
+
+ document.addEventListener('keydown', function (e) {
+ const isCmdOrCtrl = modifierKeyPrefix === '⌘' ? e.metaKey : e.ctrlKey
+ if (isCmdOrCtrl && e.key.toLowerCase() === 'k') {
+ searchDialog.show()
+ e.preventDefault()
+ }
})
}
diff --git a/resources/swagger/template.ejs b/resources/swagger/template.ejs
index c860131..d24947a 100644
--- a/resources/swagger/template.ejs
+++ b/resources/swagger/template.ejs
@@ -1,17 +1,29 @@
-
+
<%= title %>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
index be1e73a..1ca41dc 100755
--- a/src/index.js
+++ b/src/index.js
@@ -99,10 +99,18 @@ async function renderOpenApiHtml(result) {
path.resolve(__dirname, `../resources/${ui}/template.ejs`),
'utf-8',
)
+ const cssCommonContent = fs.readFileSync(
+ path.resolve(__dirname, `../resources/common/index.css`),
+ 'utf-8',
+ )
const cssContent = fs.readFileSync(
path.resolve(__dirname, `../resources/${ui}/index.css`),
'utf-8',
)
+ const jsCommonContent = fs.readFileSync(
+ path.resolve(__dirname, `../resources/common/index.js`),
+ 'utf-8',
+ )
const jsContent = fs.readFileSync(
path.resolve(__dirname, `../resources/${ui}/index.js`),
'utf-8',
@@ -121,7 +129,9 @@ async function renderOpenApiHtml(result) {
theme: result.theme,
title: result.title,
description: result.description,
+ jsCommonContent,
jsContent,
+ cssCommonContent,
cssContent,
apiDocs,
})