mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #641 from heygen-com/fix/bundler-runtime-and-joins
fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch binding
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
@@ -107,27 +107,10 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis
|
||||
}
|
||||
|
||||
async function bundleProjectHtml(projectDir: string): Promise<string> {
|
||||
// `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
|
||||
// previous post-bundle runtime substitution is no longer needed.
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
let html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
const runtimePath = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"core",
|
||||
"dist",
|
||||
"hyperframe.runtime.iife.js",
|
||||
);
|
||||
if (existsSync(runtimePath)) {
|
||||
const runtimeSource = readFileSync(runtimePath, "utf-8");
|
||||
html = html.replace(
|
||||
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
|
||||
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
|
||||
);
|
||||
}
|
||||
|
||||
return html;
|
||||
return bundleToSingleHtml(projectDir);
|
||||
}
|
||||
|
||||
async function alignViewportToComposition(
|
||||
|
||||
@@ -97,20 +97,9 @@ async function captureSnapshots(
|
||||
|
||||
const numFrames = opts.frames ?? 5;
|
||||
|
||||
// 1. Bundle
|
||||
let html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
// Inject local runtime if available.
|
||||
// Uses the same multi-strategy resolver as the studio preview server
|
||||
// (runtimeSource.ts) so snapshot works in dev (tsx), built CLI, and npx.
|
||||
const { loadRuntimeSource } = await import("../server/runtimeSource.js");
|
||||
const runtimeSource = await loadRuntimeSource();
|
||||
if (runtimeSource) {
|
||||
html = html.replace(
|
||||
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
|
||||
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
|
||||
);
|
||||
}
|
||||
// 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default,
|
||||
// so the previous post-bundle runtime substitution is no longer needed.
|
||||
const html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
const server = await serveStaticProjectHtml(projectDir, html);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve, join, dirname } from "node:path";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
@@ -113,24 +113,10 @@ async function validateInBrowser(
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
|
||||
let html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
const runtimePath = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"core",
|
||||
"dist",
|
||||
"hyperframe.runtime.iife.js",
|
||||
);
|
||||
if (existsSync(runtimePath)) {
|
||||
const runtimeSource = readFileSync(runtimePath, "utf-8");
|
||||
html = html.replace(
|
||||
/<script[^>]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/,
|
||||
() => `<script data-hyperframes-preview-runtime="1">${runtimeSource}</script>`,
|
||||
);
|
||||
}
|
||||
// `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
|
||||
// previous post-bundle regex substitution (which matched `src="..."` on the
|
||||
// runtime tag) is no longer needed — there's no `src` attribute to match.
|
||||
const html = await bundleToSingleHtml(projectDir);
|
||||
|
||||
const { createServer } = await import("node:http");
|
||||
const { getMimeType } = await import("@hyperframes/core/studio-api");
|
||||
|
||||
@@ -153,8 +153,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
async bundle(dir: string): Promise<string | null> {
|
||||
try {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
let html = await bundleToSingleHtml(dir);
|
||||
// Fix empty runtime src from bundler — point to the local runtime endpoint
|
||||
// Studio dev server: ask the bundler for an empty `src=""` placeholder so
|
||||
// we can point it at our hot-reloadable local runtime endpoint. Inlining
|
||||
// ~150 KB of runtime body on every preview render would defeat browser
|
||||
// caching across composition edits.
|
||||
let html = await bundleToSingleHtml(dir, { runtime: "placeholder" });
|
||||
html = html.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
|
||||
|
||||
@@ -212,7 +212,11 @@ export function wrapScopedCompositionScript(
|
||||
value: __hfFindRoot(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch (_err) {}
|
||||
} catch {
|
||||
// Best-effort: timelines coming from user code may have a frozen target
|
||||
// or a non-extensible defineProperty path. Swallow — the scoped root
|
||||
// is an enrichment, not a correctness invariant for playback.
|
||||
}
|
||||
return timeline;
|
||||
};
|
||||
var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap;
|
||||
@@ -282,5 +286,5 @@ ${source}
|
||||
};
|
||||
__hfFindRoot();
|
||||
__hfRun();
|
||||
})()`;
|
||||
})();`;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,116 @@ describe("bundleToSingleHtml", () => {
|
||||
)?.[0];
|
||||
|
||||
expect(runtimeBlock).toBeDefined();
|
||||
expect(runtimeBlock).not.toContain("getElementById");
|
||||
// The runtime block must contain the inlined HF runtime IIFE — bundled
|
||||
// output is self-contained, so the bundle's runtime body is loaded inline,
|
||||
// not referenced via src.
|
||||
expect(runtimeBlock).toMatch(/data-hyperframes-preview-runtime="1">/);
|
||||
expect(runtimeBlock).not.toMatch(/src=""/);
|
||||
// The author's specific composition script must NOT be merged INTO the
|
||||
// runtime tag — it stays as its own <script> elsewhere in the document.
|
||||
expect(runtimeBlock).not.toContain("window.__timelines.main = { duration:");
|
||||
expect(bundled).toContain('document.getElementById("scene")');
|
||||
});
|
||||
|
||||
it("produces a self-contained runtime script when no HYPERFRAME_RUNTIME_URL is set", async () => {
|
||||
// Regression guard: hf#XXX. The bundler used to emit
|
||||
// <script ... src=""></script> when no runtime URL was configured. An
|
||||
// empty src resolves to the page URL itself, which Chrome flags as an
|
||||
// infinite-fetch hazard. Verify that bundleToSingleHtml inlines the
|
||||
// runtime body so the bundle is genuinely self-contained.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180"></div>
|
||||
</body></html>`,
|
||||
});
|
||||
|
||||
const previousUrl = process.env.HYPERFRAME_RUNTIME_URL;
|
||||
delete process.env.HYPERFRAME_RUNTIME_URL;
|
||||
let bundled: string;
|
||||
try {
|
||||
bundled = await bundleToSingleHtml(dir);
|
||||
} finally {
|
||||
if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl;
|
||||
}
|
||||
|
||||
const runtimeBlock = bundled.match(
|
||||
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i,
|
||||
)?.[0];
|
||||
expect(runtimeBlock).toBeDefined();
|
||||
// Must NOT have an empty src attribute (would self-fetch).
|
||||
expect(runtimeBlock).not.toMatch(/src=""/);
|
||||
// Must have a non-trivial inlined body (the runtime IIFE is ~150KB).
|
||||
const innerLength = (runtimeBlock!.match(/>([\s\S]*?)<\/script>/)?.[1] ?? "").length;
|
||||
expect(innerLength).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => {
|
||||
// Regression guard for the joinJsChunks helper. If a chunk ends with `// ...`
|
||||
// and we naively appended `;` on the same line, the appended semicolon would
|
||||
// be eaten by the comment, leaving the next chunk's first statement attached
|
||||
// to the previous chunk's last expression. Verify the helper appends `\n;`
|
||||
// instead so the comment terminates and the semicolon stands alone.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180"></div>
|
||||
<script src="local-a.js"></script>
|
||||
<script src="local-b.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
|
||||
</body></html>`,
|
||||
// Chunk A ends with a // line comment — without the \n separator before
|
||||
// the appended ;, that ; would be eaten by the comment.
|
||||
"local-a.js": "window.__a = 1 // trailing line comment",
|
||||
"local-b.js": "window.__b = 2",
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
// Run every inline script body through esbuild; if the line comment ate
|
||||
// the separator, parse would fail with an unexpected-token error somewhere
|
||||
// around the chunk boundary. Use a real HTML parser (CodeQL flags regex-
|
||||
// based script extraction as bad-tag-filter).
|
||||
const { transformSync } = await import("esbuild");
|
||||
const { document } = parseHTML(bundled);
|
||||
for (const script of document.querySelectorAll("script")) {
|
||||
const body = script.textContent;
|
||||
if (!body || !body.trim()) continue;
|
||||
expect(() => transformSync(body, { loader: "js", minify: false })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not produce stray bare-semicolon lines between concatenated JS chunks", async () => {
|
||||
// Regression guard: hf#XXX. Earlier the bundler joined script chunks with
|
||||
// `\n;\n`, which produces a lone `;` on its own line between chunks. Valid
|
||||
// JS but reads as a code smell. Each chunk should end in `;` and chunks
|
||||
// should join with `\n`.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180">
|
||||
<div id="child-host"
|
||||
data-composition-id="child"
|
||||
data-composition-src="compositions/child.html"
|
||||
data-start="0" data-duration="2"></div>
|
||||
</div>
|
||||
<script src="local-a.js"></script>
|
||||
<script src="local-b.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
|
||||
</body></html>`,
|
||||
"local-a.js": "window.__a = 1",
|
||||
"local-b.js": "window.__b = 2",
|
||||
"compositions/child.html": `<template id="child-template">
|
||||
<div data-composition-id="child" data-width="320" data-height="180">
|
||||
<script>window.__c = 3</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
// No line is JUST a bare semicolon (with optional surrounding whitespace).
|
||||
expect(bundled).not.toMatch(/\n\s*;\s*\n/);
|
||||
});
|
||||
|
||||
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
@@ -84,8 +190,14 @@ describe("bundleToSingleHtml", () => {
|
||||
// GSAP CDN from main doc should still be present
|
||||
expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap");
|
||||
|
||||
// data-composition-src should be stripped (composition was inlined)
|
||||
expect(bundled).not.toContain("data-composition-src");
|
||||
// data-composition-src should be stripped from the host element (composition
|
||||
// was inlined). The literal string may still appear inside the inlined
|
||||
// runtime IIFE that knows how to look up that attribute — so check the DOM,
|
||||
// not the raw text.
|
||||
const { document: doc } = parseHTML(bundled);
|
||||
const hostEl = doc.getElementById("rockets-host");
|
||||
expect(hostEl).toBeTruthy();
|
||||
expect(hostEl?.hasAttribute("data-composition-src")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not duplicate CDN scripts already present in the main document", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
|
||||
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
|
||||
function safePath(projectDir: string, relativePath: string): string | null {
|
||||
@@ -26,12 +27,28 @@ function getRuntimeScriptUrl(): string {
|
||||
return configured || DEFAULT_RUNTIME_SCRIPT_URL;
|
||||
}
|
||||
|
||||
function injectInterceptor(html: string): string {
|
||||
function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = "inline"): string {
|
||||
const sanitized = stripEmbeddedRuntimeScripts(html);
|
||||
if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized;
|
||||
|
||||
const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, """);
|
||||
const tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeScriptUrl}"></script>`;
|
||||
// Three modes for the runtime <script>:
|
||||
// 1. HYPERFRAME_RUNTIME_URL env var set → emit src="<url>" (production CDN deploy).
|
||||
// 2. runtime: "placeholder" passed → emit src="" for the caller to substitute
|
||||
// (studio + vite preview hot-load a local
|
||||
// runtime endpoint via string replace).
|
||||
// 3. runtime: "inline" (default) → embed the IIFE body directly so the
|
||||
// bundle is genuinely self-contained.
|
||||
const runtimeScriptUrl = getRuntimeScriptUrl();
|
||||
let tag: string;
|
||||
if (runtimeScriptUrl) {
|
||||
const escaped = runtimeScriptUrl.replace(/"/g, """);
|
||||
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${escaped}"></script>`;
|
||||
} else if (runtimeMode === "placeholder") {
|
||||
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src=""></script>`;
|
||||
} else {
|
||||
const inlinedRuntime = getHyperframeRuntimeScript();
|
||||
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
|
||||
}
|
||||
if (sanitized.includes("</head>")) {
|
||||
return sanitized.replace("</head>", `${tag}\n</head>`);
|
||||
}
|
||||
@@ -268,11 +285,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
|
||||
return !type || type === "text/javascript" || type === "application/javascript";
|
||||
});
|
||||
if (bodyInlineScripts.length > 0) {
|
||||
const mergedJs = bodyInlineScripts
|
||||
.map((el) => (el.textContent || "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n;\n")
|
||||
.trim();
|
||||
const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || ""));
|
||||
for (const el of bodyInlineScripts) el.remove();
|
||||
if (mergedJs) {
|
||||
const stripped = stripJsCommentsParserSafe(mergedJs);
|
||||
@@ -283,6 +296,31 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate JS chunks safely. Goals:
|
||||
* - Each chunk's last statement is terminated, so joining can't introduce ASI
|
||||
* surprises (e.g. `a()` followed by `(b)()` — the second chunk would parse
|
||||
* as a call on the first's return value).
|
||||
* - In the common case (chunk already ends with `;` — typical of esbuild
|
||||
* output and IIFE-wrapped composition scripts ending in `})();`), the join
|
||||
* produces clean output: chunks separated by `\n` with no stray bare
|
||||
* semicolon lines.
|
||||
* - Defensive against trailing line comments. If a chunk ends with `// ...`
|
||||
* and we appended `;` on the same line, the appended semicolon would be
|
||||
* swallowed by the comment, leaving the next chunk's first statement
|
||||
* attached to the previous chunk's last expression — exactly the ASI
|
||||
* hazard this helper exists to prevent. So when a chunk doesn't already
|
||||
* end in `;`, we append `\n;` instead — the newline closes any line
|
||||
* comment, and the standalone `;` becomes the statement separator.
|
||||
*/
|
||||
function joinJsChunks(chunks: string[]): string {
|
||||
return chunks
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter((chunk) => chunk.length > 0)
|
||||
.map((chunk) => (chunk.endsWith(";") ? chunk : chunk + "\n;"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function stripJsCommentsParserSafe(source: string): string {
|
||||
if (!source) return source;
|
||||
try {
|
||||
@@ -296,6 +334,22 @@ function stripJsCommentsParserSafe(source: string): string {
|
||||
export interface BundleOptions {
|
||||
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
|
||||
probeMediaDuration?: MediaDurationProber;
|
||||
/**
|
||||
* How to handle the HyperFrames runtime <script> tag. Default: `"inline"`.
|
||||
*
|
||||
* - `"inline"` — embed the runtime IIFE body directly into the bundle. Produces
|
||||
* genuinely self-contained HTML. Right for CLI render output, validate,
|
||||
* snapshot, and any "ship a single .html file" use case.
|
||||
* - `"placeholder"` — emit `<script ... src=""></script>` so the caller can
|
||||
* substitute it with a real URL via string replace. Used by the dev studio
|
||||
* server and vite preview to point at a local runtime endpoint, which keeps
|
||||
* the runtime cacheable across hot-reloads instead of re-inlining ~150 KB
|
||||
* on every change.
|
||||
*
|
||||
* The `HYPERFRAME_RUNTIME_URL` env var, when set, takes precedence over both
|
||||
* modes and emits `<script ... src="<URL>">` directly.
|
||||
*/
|
||||
runtime?: "inline" | "placeholder";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,7 +378,7 @@ export async function bundleToSingleHtml(
|
||||
);
|
||||
}
|
||||
|
||||
const withInterceptor = injectInterceptor(compiled);
|
||||
const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline");
|
||||
const document = parseHTMLContent(withInterceptor);
|
||||
|
||||
// Inline local CSS
|
||||
@@ -379,12 +433,13 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
if (localJsChunks.length > 0) {
|
||||
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
|
||||
const joinedJs = joinJsChunks(localJsChunks);
|
||||
if (anchor) {
|
||||
anchor.removeAttribute("data-hf-bundled-local-js");
|
||||
anchor.textContent = localJsChunks.join("\n;\n");
|
||||
anchor.textContent = joinedJs;
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.textContent = localJsChunks.join("\n;\n");
|
||||
script.textContent = joinedJs;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
@@ -623,7 +678,7 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
if (compScriptChunks.length) {
|
||||
const compScript = document.createElement("script");
|
||||
compScript.textContent = compScriptChunks.join("\n;\n");
|
||||
compScript.textContent = joinJsChunks(compScriptChunks);
|
||||
document.body.appendChild(compScript);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ interface ScreenshotClip {
|
||||
|
||||
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
||||
// Lazy-load the bundler via Vite's SSR module loader
|
||||
let _bundler: ((dir: string) => Promise<string>) | null = null;
|
||||
let _bundler:
|
||||
| ((dir: string, options?: { runtime?: "inline" | "placeholder" }) => Promise<string>)
|
||||
| null = null;
|
||||
let _producerModulePromise: Promise<{
|
||||
createRenderJob: (config: {
|
||||
fps: 24 | 30 | 60;
|
||||
@@ -78,7 +80,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
if (!_bundler) {
|
||||
try {
|
||||
const mod = await server.ssrLoadModule("@hyperframes/core/compiler");
|
||||
_bundler = (dir: string) => mod.bundleToSingleHtml(dir);
|
||||
_bundler = (dir, options) => mod.bundleToSingleHtml(dir, options);
|
||||
} catch (err) {
|
||||
console.warn("[Studio] Failed to load compiler, previews will use raw HTML:", err);
|
||||
_bundler = null as never;
|
||||
@@ -171,8 +173,10 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
async bundle(dir: string) {
|
||||
const bundler = await getBundler();
|
||||
if (!bundler) return null;
|
||||
let html = await bundler(dir);
|
||||
// Fix empty runtime src from bundler — point to the CDN runtime
|
||||
// Studio vite preview: bundler emits an empty `src=""` placeholder so we
|
||||
// can point it at the local /api/runtime.js endpoint. Cached by the browser
|
||||
// across composition hot-reloads instead of being inlined fresh each time.
|
||||
let html = await bundler(dir, { runtime: "placeholder" });
|
||||
html = html.replace(
|
||||
'data-hyperframes-preview-runtime="1" src=""',
|
||||
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,
|
||||
|
||||
Reference in New Issue
Block a user