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, })