Files
suaveplan/packages/api-stack/api/api-openapi-ui-scalar/src/html/render-shell.ts
2026-05-27 23:29:53 +01:00

93 lines
3.6 KiB
TypeScript

import { hashedAssetPath } from "../assets/assets.js";
import type { ScalarUiOptions } from "../options/options.js";
/**
* Minimal HTML escape — replaces the five characters that have special
* meaning in HTML attribute / text contexts. Used to safely embed
* caller-supplied title / theme / endpoint strings into the rendered
* shell.
*/
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Inputs threaded into the rendered shell beyond the parsed options. */
export interface RenderShellInput {
readonly options: ScalarUiOptions;
/** Document URL the Scalar shell points at (may carry query string). */
readonly openapiUrl: string;
/** Resolved Scalar runtime URL (CDN or bundled asset path). */
readonly runtimeUrl: string;
/** Optional per-request CSP nonce embedded on inline JSON config. */
readonly nonce?: string;
}
/**
* Render the Scalar HTML shell.
*
* The shell embeds the configured `openapiUrl` into a
* `<script id="scalar-config" type="application/json">` block (NOT
* executable JavaScript — keeps the strict `script-src 'self'` posture
* intact) and references the Scalar runtime via the resolved
* `runtimeUrl` (either the upstream CDN or `<uiEndpoint>/assets/<hash>`).
*
* In bundled mode the local stylesheet is also referenced; in cdn mode
* the upstream bundle ships its own styles so the local sheet is omitted.
*
* `nonce`, when supplied, is added to the `<script>` tag attributes so
* hosts running nonce-based CSP can still embed the config block.
*/
export function renderShell(input: RenderShellInput): string {
const { options, openapiUrl, runtimeUrl, nonce } = input;
const config = {
openapiUrl,
theme: options.theme,
layout: options.layout,
title: options.title,
darkMode: options.darkMode,
hideSearch: options.hideSearch,
defaultOpenAllTags: options.defaultOpenAllTags,
editorOptions: options.editorOptions,
};
// Inside <script type="application/json"> only the literal "</script>"
// is unsafe — JSON syntax doesn't otherwise collide with HTML. We
// replace every "<" with its unicode escape so even string values
// like `"foo</script>"` cannot terminate the block.
const configJson = JSON.stringify(config).replace(/</g, "\\u003c");
const themeAttr = escapeHtml(options.theme);
const title = escapeHtml(options.title);
const nonceAttr =
nonce === undefined ? "" : ` nonce="${escapeHtml(nonce)}"`;
const bundledCssHref = `${escapeHtml(options.uiEndpoint)}/assets/${escapeHtml(hashedAssetPath("scalar.css"))}`;
const linkBlock =
options.assetMode === "bundled"
? `<link rel="stylesheet" href="${bundledCssHref}" />`
: "";
return `<!DOCTYPE html>
<html lang="en" data-theme="${themeAttr}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
${linkBlock}
</head>
<body data-theme="${themeAttr}">
<script id="scalar-config" type="application/json"${nonceAttr}>${configJson}</script>
<div id="scalar-root" class="scalar-shell">
<header class="scalar-header"><strong>${title}</strong></header>
<main class="scalar-body">
<p id="scalar-status" class="scalar-status">initialising…</p>
<ul id="scalar-paths" class="scalar-paths"></ul>
</main>
</div>
<script src="${escapeHtml(runtimeUrl)}"${nonceAttr} data-configuration-source="#scalar-config"></script>
</body>
</html>
`;
}