+
diff --git a/src/utils/jsx/islands/swagger.tsx b/src/utils/jsx/islands/swagger.tsx
new file mode 100644
index 0000000..52fdae2
--- /dev/null
+++ b/src/utils/jsx/islands/swagger.tsx
@@ -0,0 +1,640 @@
+import { css } from '@emotion/css';
+import { Iterable, List, Map } from 'immutable';
+import { type ComponentType, type ReactNode, useState } from 'react';
+import SwaggerUI from 'swagger-ui-react';
+import swaggerStyles from 'swagger-ui-react/swagger-ui.css';
+
+import theme from '../../theme.ts';
+import createIsland from '../island.tsx';
+
+const mixins = {
+ pre: css`
+ background: ${theme.background.footer} !important;
+ color: ${theme.text.primary};
+ padding: ${theme.spacing(1.5, 2)} !important;
+ border-radius: ${theme.radius};
+ font-size: ${theme.font.small.size};
+ margin: 0;
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ max-height: ${theme.spacing(50)};
+ overflow: auto;
+ `,
+ code: css`
+ background: ${theme.background.body};
+ color: ${theme.text.primary};
+ padding: ${theme.spacing(0.25, 0.75)};
+ border-radius: ${theme.radius};
+ font-size: ${theme.font.small.size};
+ `,
+ body: css`
+ padding: ${theme.spacing(1.5, 2)};
+ `,
+};
+
+const styles = {
+ container: css`
+ .swagger-ui {
+ pre {
+ ${mixins.pre};
+ }
+
+ code:not(pre code) {
+ ${mixins.code};
+ }
+
+ svg {
+ fill: currentColor;
+ }
+
+ button {
+ color: inherit;
+ }
+
+ a {
+ color: ${theme.text.brand};
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ /* Remove the default styling of operation sections. */
+ .opblock {
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ background: none;
+ box-shadow: none;
+ }
+
+ /* Replace the default background of operation section headers. */
+ .opblock .opblock-section-header {
+ background: ${theme.background.footer};
+ box-shadow: none;
+ border-radius: ${theme.radius};
+ }
+
+ /* Add a consistent indicator to the "Parameters" + "Responses" tabs. */
+ .opblock .opblock-section-header .tab-item h4 span,
+ .opblock .responses-wrapper .opblock-section-header h4 {
+ position: relative;
+ display: inline-block;
+ width: fit-content;
+ flex: 0 0 auto;
+
+ &::after {
+ content: '';
+ display: block;
+ position: static;
+ transform: none;
+ width: 100%;
+ height: ${theme.spacing(0.5)};
+ background: ${theme.background.brand};
+ margin-top: ${theme.spacing(0.5)};
+ bottom: auto;
+ left: auto;
+ }
+ }
+
+ /* Present the "Execute" + "Clear" buttons in a single line. */
+ .opblock .execute-wrapper,
+ .opblock .btn-group {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: ${theme.spacing(1)};
+ padding: ${theme.spacing(1.5, 2)};
+ }
+ .opblock .btn.execute {
+ flex: 1 1 auto;
+ padding: ${theme.spacing(1, 2)};
+ font-size: ${theme.font.body.size};
+ font-weight: 600;
+ background: ${theme.background.brand};
+ border-color: ${theme.background.brand};
+ color: ${theme.text.inverted};
+ box-shadow: none;
+ text-shadow: none;
+ }
+ .opblock .btn-clear {
+ flex: 0 0 auto;
+ padding: ${theme.spacing(1, 2)};
+ font-size: ${theme.font.body.size};
+ font-weight: 600;
+ color: inherit;
+ }
+
+ /* Remove the duplicate model title from standalone schemas. */
+ section.models .model-box-control:has(.model-title) {
+ display: none;
+ }
+
+ /* Replace the default SVG with +/- for the toggles in models. */
+ .model-toggle {
+ transform: none;
+ vertical-align: middle;
+ top: 0;
+
+ &::after {
+ content: '+';
+ background: none;
+ width: auto;
+ height: auto;
+ display: inline-block;
+ font-family: monospace;
+ font-size: ${theme.font.small.size};
+ font-weight: ${theme.font.small.weight};
+ line-height: 1;
+ vertical-align: middle;
+ color: ${theme.text.secondary};
+ }
+ }
+ [aria-expanded='true'] > .model-toggle {
+ &::after {
+ content: '-';
+ }
+ }
+
+ /* Make the response schema models match the example value code. */
+ .model {
+ font-size: ${theme.font.small.size};
+ }
+ .model-example .model-box {
+ ${mixins.pre};
+ }
+
+ /* Remove the default links column from the responses table. */
+ .responses-table .response-col_links {
+ display: none;
+ }
+ }
+ `,
+ wrapper: css`
+ display: block;
+ border: ${theme.spacing(0.125)} solid ${theme.background.body};
+ border-radius: ${theme.radius};
+ background: ${theme.background.navigation};
+ margin: 0 0 ${theme.spacing(1.5)};
+ overflow: hidden;
+ `,
+ summary: css`
+ ${mixins.body};
+ display: flex;
+ align-items: center;
+ gap: ${theme.spacing(1.5)};
+ background: ${theme.background.footer};
+ cursor: pointer;
+
+ > span:first-child {
+ ${mixins.code};
+ font-weight: bold;
+ }
+
+ > svg:last-child {
+ margin-left: auto;
+ flex-shrink: 0;
+ }
+ `,
+ header: css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.spacing(1)};
+ font-size: ${theme.font.large.size};
+ font-weight: bold;
+ padding: ${theme.spacing(1.25, 2, 1.25, 1.25)};
+ margin: 0 0 ${theme.spacing(0.625)};
+ cursor: pointer;
+ color: inherit;
+
+ > svg:last-child {
+ margin-left: auto;
+ flex-shrink: 0;
+ }
+ `,
+ loader: css`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity ${theme.transition};
+ transition-delay: 0.5s;
+
+ @starting-style {
+ opacity: 0;
+ }
+ `,
+};
+
+interface System {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getComponent: (name: string, container?: boolean) => ComponentType
;
+ getConfigs: () => Record;
+ layoutSelectors: {
+ isShown: (key: readonly string[], def: boolean) => boolean;
+ };
+ layoutActions: {
+ show: (key: readonly string[], shown: boolean) => void;
+ };
+ specSelectors: {
+ isOAS3: () => boolean;
+ definitions: () => Map;
+ };
+}
+
+/**
+ * Custom wrapper component for all collapsible sections of the Swagger UI.
+ *
+ * @param props Component props.
+ * @param props.children Content to display inside the wrapper.
+ */
+const Wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+/**
+ * Custom summary component for operations and standalone schemas.
+ *
+ * @param props Component props.
+ * @param props.system Swagger UI system object passed to all components.
+ * @param props.onClick Handler to toggle the section expansion.
+ * @param props.expanded Whether the section is currently expanded.
+ * @param props.badge Text to show in the badge before the summary content.
+ * @param props.children Summary content to display.
+ */
+const Summary = ({
+ system,
+ onClick,
+ badge,
+ expanded,
+ children,
+}: {
+ system: System;
+ onClick: () => void;
+ expanded: boolean;
+ badge: ReactNode;
+ children: ReactNode;
+}) => {
+ const ArrowIcon = system.getComponent(
+ expanded ? 'ArrowUpIcon' : 'ArrowDownIcon',
+ );
+
+ return (
+
+
{badge}
+ {children}
+
+
+ );
+};
+
+/**
+ * Custom header component for collapsible sections of the Swagger UI.
+ *
+ * @param props Component props.
+ * @param props.system Swagger UI system object passed to all components.
+ * @param props.onClick Handler to toggle the section expansion.
+ * @param props.expanded Whether the section is currently expanded.
+ * @param props.children Header content to display.
+ */
+const Header = ({
+ system,
+ onClick,
+ expanded,
+ children,
+}: {
+ system: System;
+ onClick: () => void;
+ expanded: boolean;
+ children: ReactNode;
+}) => {
+ const ArrowIcon = system.getComponent(
+ expanded ? 'ArrowUpIcon' : 'ArrowDownIcon',
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+const plugin = (system: System) => ({
+ components: {
+ /**
+ * Replace the default layout with a custom implementation that only
+ * renders the operations and schema models, omitting other sections.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/components/layouts/base.jsx
+ */
+ BaseLayout: () => {
+ const SvgAssets = system.getComponent('SvgAssets');
+ const Operations = system.getComponent('operations', true);
+ const Models = system.getComponent('Models', true);
+
+ return (
+
+
+
+
+
+ );
+ },
+ /**
+ * Replace the default operation tag section with a custom implementation
+ * that uses our custom `Header`.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/components/operation-tag.jsx
+ *
+ * @param props Props passed by swagger-ui.
+ * @param props.tag Name of the tag this section represents.
+ * @param props.children Operations grouped under this tag.
+ */
+ OperationTag: ({
+ tag,
+ children,
+ }: {
+ tag: string;
+ children: ReactNode;
+ }) => {
+ const { docExpansion } = system.getConfigs();
+ const isShownKey = ['operations-tag', tag];
+ const expanded = system.layoutSelectors.isShown(
+ isShownKey,
+ docExpansion === 'full' || docExpansion === 'list',
+ );
+ const Collapse = system.getComponent('Collapse');
+ const onClick = () =>
+ system.layoutActions.show(isShownKey, !expanded);
+ const label = tag.charAt(0).toUpperCase() + tag.slice(1);
+
+ return (
+
+
+ {children}
+
+ );
+ },
+ /**
+ * Replace the default operation summary with our custom `Summary`.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/components/operation-summary.jsx
+ *
+ * @param props Props passed by swagger-ui.
+ * @param props.isShown Whether the operation body is currently expanded.
+ * @param props.toggleShown Handler to toggle the operation body expansion.
+ * @param props.operationProps Iterable of the operation's properties.
+ */
+ OperationSummary: ({
+ isShown,
+ toggleShown,
+ operationProps,
+ }: {
+ isShown: boolean;
+ toggleShown: () => void;
+ operationProps: Iterable;
+ }) => {
+ const { method, path, summary } = operationProps.toObject();
+ if (
+ typeof method !== 'string' ||
+ typeof path !== 'string' ||
+ (typeof summary !== 'undefined' && typeof summary !== 'string')
+ ) {
+ throw new Error('Invalid operationProps for OperationSummary');
+ }
+
+ return (
+
+
+ {path?.split(/\//g).map((part, i) =>
+ i === 0 ? (
+ part
+ ) : (
+ <>
+ /{part}
+ >
+ ),
+ )}
+
+ {summary}
+
+ );
+ },
+ /**
+ * Remove the default "Try it out" button as we always enable it.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/components/try-it-out-button.jsx
+ */
+ TryItOutButton: () => null,
+ /**
+ * Replace the default models section with a custom implementation
+ * that uses our custom `Header` + `Wrapper` + `Summary`.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/plugins/json-schema-5/components/models.jsx
+ */
+ Models: () => {
+ const { docExpansion, defaultModelsExpandDepth = 1 } =
+ system.getConfigs();
+ const definitions = system.specSelectors.definitions();
+ if (!definitions.size || Number(defaultModelsExpandDepth) < 0)
+ return null;
+
+ const specPathBase = system.specSelectors.isOAS3()
+ ? ['components', 'schemas']
+ : ['definitions'];
+
+ const sectionExpanded = system.layoutSelectors.isShown(
+ specPathBase,
+ Number(defaultModelsExpandDepth) > 0 && docExpansion !== 'none',
+ );
+ const onSectionClick = () =>
+ system.layoutActions.show(specPathBase, !sectionExpanded);
+
+ const Collapse = system.getComponent('Collapse');
+
+ return (
+
+
+
+ {definitions
+ .entrySeq()
+ .map((entry) => {
+ if (!entry) return null;
+
+ const [name, schema] = entry;
+ const fullPath = [...specPathBase, name];
+ const expanded = system.layoutSelectors.isShown(
+ fullPath,
+ false,
+ );
+ const onClick = () =>
+ system.layoutActions.show(
+ fullPath,
+ !expanded,
+ );
+ const ModelWrapper =
+ system.getComponent('ModelWrapper');
+
+ return (
+
+
+ {name}
+
+ {expanded && (
+
+
+
+ )}
+
+ );
+ })
+ .toArray()}
+
+
+ );
+ },
+ /**
+ * Replace the default enum model rendering with a single line of text.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/plugins/json-schema-5/components/enum-model.jsx
+ *
+ * @param props Props passed by swagger-ui.
+ * @param props.value Enum values to render.
+ */
+ EnumModel: ({ value }: { value: Iterable }) => (
+
+ Enum: [ {value.map(String).join(', ')} ]
+
+ ),
+ },
+ wrapComponents: {
+ /**
+ * Wrap model rendering to force all properties to expand by default.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/plugins/json-schema-5/components/model.jsx
+ *
+ * @param Original Original model component to wrap.
+ */
+ Model:
+ (Original: ComponentType>) =>
+ (props: Record) => (
+
+ ),
+ /**
+ * Wrap the entire operation with our custom `Wrapper`, matching the
+ * wrapper we apply in `Models` for standalone schemas.
+ *
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/containers/OperationContainer.jsx
+ * @see https://github.com/swagger-api/swagger-ui/blob/v5.32.5/src/core/components/operation.jsx
+ *
+ * @param Original Original operation component to wrap.
+ */
+ operation:
+ (Original: ComponentType>) =>
+ (props: Record) => (
+
+
+
+ ),
+ },
+});
+
+const scopedSwaggerStyles = swaggerStyles
+ .replace(/(?<=[{;]\s*)color:\s*[^;]+;/g, '')
+ .replace(/\.swagger-ui(?![\w-])/g, `.${styles.container} .swagger-ui`);
+
+/**
+ * Custom Swagger UI island component to render the OpenAPI spec for the API documentation page.
+ *
+ * @param props Component props.
+ * @param props.spec OpenAPI specification to render.
+ */
+const Swagger = ({ spec }: { spec: object }) => {
+ const [loading, setLoading] = useState(true);
+
+ return (
+ <>
+
+
+ setLoading(false)}
+ plugins={[plugin]}
+ supportedSubmitMethods={['get']}
+ tryItOutEnabled
+ displayRequestDuration
+ />
+
+ {loading && (
+
+ Loading OpenAPI specification...
+
+ )}
+ >
+ );
+};
+
+export default createIsland(Swagger, 'swagger.tsx');
diff --git a/src/utils/jsx/json.tsx b/src/utils/jsx/json.tsx
index b52c2ab..a0bc289 100644
--- a/src/utils/jsx/json.tsx
+++ b/src/utils/jsx/json.tsx
@@ -15,9 +15,9 @@ const styles = {
* Standard cdnjs HTML layout for pretty-printing JSON data.
*
* @param props Component props.
- * @param props.json Data to be pretty-printed on the page.
+ * @param props.data Data to be pretty-printed on the page.
*/
-export default ({ json }: { json: unknown }) => (
+export default ({ data }: { data: unknown }) => (
<>
(
- {JSON.stringify(json, null, 2)}
+ {JSON.stringify(data, null, 2)}
diff --git a/src/utils/jsx/layout.tsx b/src/utils/jsx/layout.tsx
index 2edd243..ae7fda3 100644
--- a/src/utils/jsx/layout.tsx
+++ b/src/utils/jsx/layout.tsx
@@ -24,6 +24,8 @@ const styles = {
`,
content: css`
flex-grow: 1;
+ isolation: isolate;
+ position: relative;
`,
container: css`
margin: 0 auto;
diff --git a/src/utils/respond.ts b/src/utils/respond.ts
index 0b37dc6..1392f3f 100644
--- a/src/utils/respond.ts
+++ b/src/utils/respond.ts
@@ -1,7 +1,7 @@
import { cache } from '@emotion/css';
import { env } from 'cloudflare:workers';
import type { Context } from 'hono';
-import { createElement } from 'react';
+import { type ComponentType, createElement } from 'react';
import { renderToString } from 'react-dom/server';
import type { ErrorResponse } from '../routes/errors.schema.ts';
@@ -77,8 +77,13 @@ export const withCache = (ctx: Context, age: number, immutable = false) => {
*
* @param ctx Request context.
* @param data Data to be included in the response.
+ * @param component Optional custom component to use for human-readable output (defaults to Json).
*/
-const respond = async (ctx: Context, data: NoInfer) => {
+const respond = async (
+ ctx: Context,
+ data: NoInfer,
+ component: ComponentType<{ data: NoInfer }> = Json,
+) => {
if (ctx.req.query('output') === 'human') {
event('human-output', { ctx });
ctx.header('X-Robots-Tag', 'noindex');
@@ -88,11 +93,7 @@ const respond = async (ctx: Context, data: NoInfer) => {
createElement(
Provider,
null,
- createElement(
- Layout,
- null,
- createElement(Json, { json: data }),
- ),
+ createElement(Layout, null, createElement(component, { data })),
),
);
const styles = getCriticalEmotionCss(body);
diff --git a/src/utils/spec/request.ts b/src/utils/spec/request.ts
index 419e7bb..6c09b9e 100644
--- a/src/utils/spec/request.ts
+++ b/src/utils/spec/request.ts
@@ -49,7 +49,7 @@ export const request = async (route: string, opts: RequestInit = {}) => {
);
}
- return Reflect.get(response, prop);
+ return Reflect.get(response, prop, response);
},
});
};
@@ -63,11 +63,15 @@ export const request = async (route: string, opts: RequestInit = {}) => {
export const beforeRequest = (route: string, opts: RequestInit = {}) => {
let response: Response;
- beforeAll(async () => {
- response = await request(route, opts);
- });
+ beforeAll(
+ async () => {
+ response = await request(route, opts);
+ },
+ // Allow time for the worker to compile when running against the Miniflare instance
+ externalApiUrl ? 5_000 : 30_000,
+ );
return new Proxy({} as Response, {
- get: (_, prop) => Reflect.get(response, prop),
+ get: (_, prop) => Reflect.get(response, prop, response),
});
};
diff --git a/src/utils/theme.ts b/src/utils/theme.ts
index 5714e50..6a7a9e2 100644
--- a/src/utils/theme.ts
+++ b/src/utils/theme.ts
@@ -20,7 +20,8 @@ export default {
major: '#e67e22',
critical: '#e74c3c',
},
- spacing: (value: number) => `${value * 8}px`,
+ spacing: (...values: number[]) =>
+ values.map((value) => `${value * 8}px`).join(' '),
breakpoints: {
medium: breakpoint(96),
},
diff --git a/vite.client.config.ts b/vite.client.config.ts
index 21fa090..d0a6c35 100644
--- a/vite.client.config.ts
+++ b/vite.client.config.ts
@@ -6,6 +6,8 @@ const outputDirectory = resolve('dist-client');
const virtualEntryPrefix = 'virtual:island-entry:';
const hydrationRuntimePath = resolve('src/utils/island.ts');
+const isCssImport = (source: string) => /\.css(?:$|\?)/.test(source);
+
const islandEntries = globSync('src/utils/jsx/islands/*.tsx')
.filter((file) => !file.endsWith('.spec.ts'))
.sort()
@@ -48,6 +50,22 @@ const parseCreateIslandDeclaration = (source: string) => {
export default defineConfig({
publicDir: false,
plugins: [
+ {
+ name: 'raw-css-imports',
+ enforce: 'pre',
+ // Force CSS to be imported as a raw string, matching how Wrangler handles CSS imports.
+ resolveId(source, importer, options) {
+ if (!isCssImport(source)) {
+ return null;
+ }
+
+ return this.resolve(
+ `${source}${source.includes('?') ? '&' : '?'}raw`,
+ importer,
+ { ...options, skipSelf: true },
+ );
+ },
+ },
{
name: 'virtual-island-entries',
resolveId(source) {
diff --git a/wrangler.toml b/wrangler.toml
index 8efd472..1ff0930 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -9,6 +9,11 @@ kv_namespaces = [ { binding = "CACHE" } ]
command = "vite build --config vite.client.config.ts"
watch_dir = "src"
+[[rules]]
+type = "Text"
+globs = [ "**/*.css" ]
+fallthrough = true
+
[assets]
directory = "./dist-client"
binding = "ASSETS"