93 lines
3.6 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/** 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>
|
|
`;
|
|
}
|