From 8180d2e5c09428d797360f92869965a74bb82181 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 23:44:44 +0000 Subject: [PATCH 1/9] Fix preview hangs on blocked external assets Co-authored-by: Siraj Chokshi --- src/components/Preview.svelte | 39 ++++++++--- src/routes/api/proxy/+server.ts | 96 ++++++++++++++++++++++++++ src/utils/preview.ts | 60 +++++++++++----- tests/preview.test.ts | 119 ++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 tests/preview.test.ts diff --git a/src/components/Preview.svelte b/src/components/Preview.svelte index 91426fb..91f8bdd 100644 --- a/src/components/Preview.svelte +++ b/src/components/Preview.svelte @@ -9,26 +9,49 @@ let loading = true let preview: Preview | undefined = undefined + let renderNonce = 0 let title = '' $: { if (url) { const decodedUrl = decodeURIComponent(url) + const currentRender = ++renderNonce if (typeof window !== 'undefined') { if (!preview) { preview = new Preview('#site-frame') } - preview.render(decodedUrl).then(() => { - loading = false - - if (preview?.pageData.title) { - // update with page title or URL - title = preview.pageData.title - } - }) + loading = true + title = '' + + preview + .render(decodedUrl) + .then(() => { + if (currentRender !== renderNonce) { + return + } + + if (preview?.pageData.title) { + // update with page title or URL + title = preview.pageData.title + } + }) + .catch(() => { + if (currentRender !== renderNonce) { + return + } + + title = decodedUrl + }) + .finally(() => { + if (currentRender !== renderNonce) { + return + } + + loading = false + }) } } } diff --git a/src/routes/api/proxy/+server.ts b/src/routes/api/proxy/+server.ts index 0ea3e8d..29fba45 100644 --- a/src/routes/api/proxy/+server.ts +++ b/src/routes/api/proxy/+server.ts @@ -1,4 +1,5 @@ import { error } from '@sveltejs/kit' +import { appendFileSync } from 'node:fs' import { buildCorsHeaders, getAllowedProxyOrigins, @@ -13,6 +14,28 @@ import { export const prerender = false +function writeDebugLog( + hypothesisId: string, + location: string, + message: string, + data: Record, +) { + try { + appendFileSync( + '/opt/cursor/logs/debug.log', + `${JSON.stringify({ + hypothesisId, + location, + message, + data, + timestamp: Date.now(), + })}\n`, + ) + } catch { + // Best-effort instrumentation only. + } +} + function enforceProxyPolicy(request: Request, requestUrl: URL) { const allowedOrigins = getAllowedProxyOrigins( process.env.PROXY_ALLOWED_ORIGINS, @@ -39,6 +62,15 @@ export function OPTIONS({ request, url }) { export async function GET({ request, url, fetch }) { const corsHeaders = enforceProxyPolicy(request, url) + // #region agent log + writeDebugLog('A', 'src/routes/api/proxy/+server.ts:69', 'Proxy GET entry', { + urlParam: url.searchParams.get('url'), + secFetchDest: request.headers.get('sec-fetch-dest'), + requestOrigin: + request.headers.get('origin') ?? request.headers.get('referer'), + }) + // #endregion + if (isNavigationProxyRequest(request.headers.get('sec-fetch-dest'))) { throw error(403, 'Direct navigation to proxy endpoint is not allowed') } @@ -49,12 +81,38 @@ export async function GET({ request, url, fetch }) { target = parseProxyTarget(url.searchParams.get('url')) } catch (err) { if (err instanceof ProxyRequestError) { + // #region agent log + writeDebugLog( + 'A', + 'src/routes/api/proxy/+server.ts:85', + 'Proxy target rejected by policy', + { + urlParam: url.searchParams.get('url'), + status: err.status, + reason: err.message, + }, + ) + // #endregion + throw error(err.status, err.message) } throw error(400, 'Invalid url param') } + // #region agent log + writeDebugLog( + 'C', + 'src/routes/api/proxy/+server.ts:102', + 'Proxy target accepted', + { + target: target.toString(), + hostname: target.hostname, + pathname: target.pathname, + }, + ) + // #endregion + let upstream: Response try { @@ -65,9 +123,34 @@ export async function GET({ request, url, fetch }) { }, }) } catch { + // #region agent log + writeDebugLog( + 'D', + 'src/routes/api/proxy/+server.ts:125', + 'Upstream fetch threw network error', + { + target: target.toString(), + }, + ) + // #endregion + throw error(502, `Could not load ${target.toString()}`) } + // #region agent log + writeDebugLog( + 'C', + 'src/routes/api/proxy/+server.ts:138', + 'Upstream fetch returned response', + { + target: target.toString(), + status: upstream.status, + contentType: upstream.headers.get('content-type'), + contentLength: upstream.headers.get('content-length'), + }, + ) + // #endregion + const contentLength = Number(upstream.headers.get('content-length')) if ( Number.isFinite(contentLength) && @@ -108,6 +191,19 @@ export async function GET({ request, url, fetch }) { headers.set('Last-Modified', lastModified) } + // #region agent log + writeDebugLog( + 'C', + 'src/routes/api/proxy/+server.ts:190', + 'Proxy returning payload', + { + target: target.toString(), + status: upstream.status, + payloadBytes: payload.byteLength, + }, + ) + // #endregion + return new Response(payload, { status: upstream.status, headers, diff --git a/src/utils/preview.ts b/src/utils/preview.ts index 5e92552..2b5045a 100644 --- a/src/utils/preview.ts +++ b/src/utils/preview.ts @@ -124,19 +124,27 @@ export class Preview { this.iframeDocument.document.querySelectorAll( 'link[rel=stylesheet]', ) - const links = [...$link].map(async ({ href }: { href: string }) => { - const payload = await this.load(`${href}`) - return { - url: href, - payload, + const links = [...$link] + .map(({ href }) => href) + .filter((href) => this.shouldProxyStylesheet(href)) + .map(async (href) => { + const payload = await this.load(href) + return { + url: href, + payload, + } + }) + + const stylesheetResults = await Promise.allSettled(links) + stylesheetResults.forEach((result) => { + if (result.status !== 'fulfilled') { + logger.warn(`Skipping stylesheet after failed fetch: ${result.reason}`) + return } - }) - await Promise.all(links).then((res) => { - res.forEach(({ payload, url: cssUrl }) => { - const processedCSS = processCSS(payload, cssUrl) - this.appendToHead(processedCSS, 'style') - }) + const { payload, url: cssUrl } = result.value + const processedCSS = processCSS(payload, cssUrl) + this.appendToHead(processedCSS, 'style') }) // Load page JS @@ -160,21 +168,39 @@ export class Preview { } }) - await Promise.all(scripts).then((res) => { - res.forEach(({ payload, type }) => this.appendScriptToHead(payload, type)) + const scriptResults = await Promise.allSettled(scripts) + scriptResults.forEach((result) => { + if (result.status !== 'fulfilled') { + logger.warn(`Skipping script after failed fetch: ${result.reason}`) + return + } - this.iframeDocument.document.dispatchEvent( - new Event('DOMContentLoaded', { bubbles: true, cancelable: true }), - ) // Dispatch DOMContentLoaded event after loading all scripts + const { payload, type } = result.value + this.appendScriptToHead(payload, type) }) + + this.iframeDocument.document.dispatchEvent( + new Event('DOMContentLoaded', { bubbles: true, cancelable: true }), + ) // Dispatch DOMContentLoaded event after loading all scripts + } + + /** + * Determine whether a script source should be fetched via proxy. + */ + private shouldProxyStylesheet(href: string): boolean { + return this.isProxySupportedUrl(href) } /** * Determine whether a script source should be fetched via proxy. */ private shouldProxyScript(src: string): boolean { + return this.isProxySupportedUrl(src) + } + + private isProxySupportedUrl(url: string): boolean { return ( - src.includes('//raw.githubusercontent.com') || src.includes('/-/raw/') + url.includes('//raw.githubusercontent.com') || url.includes('/-/raw/') ) } diff --git a/tests/preview.test.ts b/tests/preview.test.ts new file mode 100644 index 0000000..29886a1 --- /dev/null +++ b/tests/preview.test.ts @@ -0,0 +1,119 @@ +jest.mock('$app/navigation', () => ({ goto: jest.fn() }), { virtual: true }) + +import { Preview } from '../src/utils/preview' + +function mockResponse(status: number, body: string): Response { + return { + status, + ok: status >= 200 && status < 300, + text: async () => body, + } as unknown as Response +} + +function getProxyTarget(input: string | URL | Request): string { + if (typeof input === 'string') { + return new URL(input, 'http://localhost').searchParams.get('url') ?? '' + } + + if (input instanceof URL) { + return ( + new URL(input.toString(), 'http://localhost').searchParams.get('url') ?? + '' + ) + } + + return new URL(input.url, 'http://localhost').searchParams.get('url') ?? '' +} + +describe('[Preview] resource loading resilience', () => { + let originalFetch: typeof fetch | undefined + + beforeEach(() => { + document.body.innerHTML = '' + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch as typeof fetch + }) + + it('skips proxy for non-allowlisted external stylesheet URLs', async () => { + const htmlUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' + + const htmlPayload = ` + + + + OpenEmu + + + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const requestedTargets = fetchMock.mock.calls.map(([input]) => + getProxyTarget(input as string | URL | Request), + ) + + expect(requestedTargets).toContain(htmlUrl) + expect( + requestedTargets.some((target) => + target.includes('fonts.googleapis.com'), + ), + ).toBe(false) + }) + + it('continues rendering when a proxied stylesheet fetch fails', async () => { + const htmlUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' + const failingCssUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/failing.css' + + const htmlPayload = ` + + + + OpenEmu + + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + if (target === failingCssUrl) { + return mockResponse(403, '{"message":"Target url is not allowed"}') + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + }) +}) From 56feddeb7ac03953129e5d4241f89ba1757efb8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 23:56:18 +0000 Subject: [PATCH 2/9] Fix preview loading resilience for OpenEmu Co-authored-by: Siraj Chokshi --- src/routes/api/proxy/+server.ts | 96 --------------------------------- src/utils/preview.ts | 21 ++++---- tests/preview.test.ts | 46 ++++++++++++++++ 3 files changed, 55 insertions(+), 108 deletions(-) diff --git a/src/routes/api/proxy/+server.ts b/src/routes/api/proxy/+server.ts index 29fba45..0ea3e8d 100644 --- a/src/routes/api/proxy/+server.ts +++ b/src/routes/api/proxy/+server.ts @@ -1,5 +1,4 @@ import { error } from '@sveltejs/kit' -import { appendFileSync } from 'node:fs' import { buildCorsHeaders, getAllowedProxyOrigins, @@ -14,28 +13,6 @@ import { export const prerender = false -function writeDebugLog( - hypothesisId: string, - location: string, - message: string, - data: Record, -) { - try { - appendFileSync( - '/opt/cursor/logs/debug.log', - `${JSON.stringify({ - hypothesisId, - location, - message, - data, - timestamp: Date.now(), - })}\n`, - ) - } catch { - // Best-effort instrumentation only. - } -} - function enforceProxyPolicy(request: Request, requestUrl: URL) { const allowedOrigins = getAllowedProxyOrigins( process.env.PROXY_ALLOWED_ORIGINS, @@ -62,15 +39,6 @@ export function OPTIONS({ request, url }) { export async function GET({ request, url, fetch }) { const corsHeaders = enforceProxyPolicy(request, url) - // #region agent log - writeDebugLog('A', 'src/routes/api/proxy/+server.ts:69', 'Proxy GET entry', { - urlParam: url.searchParams.get('url'), - secFetchDest: request.headers.get('sec-fetch-dest'), - requestOrigin: - request.headers.get('origin') ?? request.headers.get('referer'), - }) - // #endregion - if (isNavigationProxyRequest(request.headers.get('sec-fetch-dest'))) { throw error(403, 'Direct navigation to proxy endpoint is not allowed') } @@ -81,38 +49,12 @@ export async function GET({ request, url, fetch }) { target = parseProxyTarget(url.searchParams.get('url')) } catch (err) { if (err instanceof ProxyRequestError) { - // #region agent log - writeDebugLog( - 'A', - 'src/routes/api/proxy/+server.ts:85', - 'Proxy target rejected by policy', - { - urlParam: url.searchParams.get('url'), - status: err.status, - reason: err.message, - }, - ) - // #endregion - throw error(err.status, err.message) } throw error(400, 'Invalid url param') } - // #region agent log - writeDebugLog( - 'C', - 'src/routes/api/proxy/+server.ts:102', - 'Proxy target accepted', - { - target: target.toString(), - hostname: target.hostname, - pathname: target.pathname, - }, - ) - // #endregion - let upstream: Response try { @@ -123,34 +65,9 @@ export async function GET({ request, url, fetch }) { }, }) } catch { - // #region agent log - writeDebugLog( - 'D', - 'src/routes/api/proxy/+server.ts:125', - 'Upstream fetch threw network error', - { - target: target.toString(), - }, - ) - // #endregion - throw error(502, `Could not load ${target.toString()}`) } - // #region agent log - writeDebugLog( - 'C', - 'src/routes/api/proxy/+server.ts:138', - 'Upstream fetch returned response', - { - target: target.toString(), - status: upstream.status, - contentType: upstream.headers.get('content-type'), - contentLength: upstream.headers.get('content-length'), - }, - ) - // #endregion - const contentLength = Number(upstream.headers.get('content-length')) if ( Number.isFinite(contentLength) && @@ -191,19 +108,6 @@ export async function GET({ request, url, fetch }) { headers.set('Last-Modified', lastModified) } - // #region agent log - writeDebugLog( - 'C', - 'src/routes/api/proxy/+server.ts:190', - 'Proxy returning payload', - { - target: target.toString(), - status: upstream.status, - payloadBytes: payload.byteLength, - }, - ) - // #endregion - return new Response(payload, { status: upstream.status, headers, diff --git a/src/utils/preview.ts b/src/utils/preview.ts index 2b5045a..90e0d33 100644 --- a/src/utils/preview.ts +++ b/src/utils/preview.ts @@ -41,18 +41,15 @@ export class Preview { // if the URL is an HTML file, we can just load it const htmlURL = urls[0] - this.load(htmlURL) - .then(async (data) => { - await this.loadHTML(data, htmlURL) - }) - .catch(() => { - throw new PreviewError( - `Could not find any valid HTML files. Tried:\n${urls.join( - '\n - ', - )}`, - errorType.NOT_FOUND, - ) - }) + try { + const data = await this.load(htmlURL) + await this.loadHTML(data, htmlURL) + } catch { + throw new PreviewError( + `Could not find any valid HTML files. Tried:\n${urls.join('\n - ')}`, + errorType.NOT_FOUND, + ) + } } else { // otherwise we have to attempt to load 3 possible URLs const errorTable = new Array(urls.length).fill(false) diff --git a/tests/preview.test.ts b/tests/preview.test.ts index 29886a1..2812a1c 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -116,4 +116,50 @@ describe('[Preview] resource loading resilience', () => { const preview = new Preview('#site-frame') await expect(preview.render(htmlUrl)).resolves.toBeUndefined() }) + + it('awaits single-url rendering before resolving', async () => { + const htmlUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' + + const htmlPayload = ` + + + + Delayed OpenEmu + +

Site

+ + ` + + let resolveFetch: + | ((value: Response | PromiseLike) => void) + | undefined + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return await new Promise((resolve) => { + resolveFetch = resolve + }) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + const renderPromise = preview.render(htmlUrl) + let isSettled = false + void renderPromise.then(() => { + isSettled = true + }) + + await Promise.resolve() + expect(isSettled).toBe(false) + + resolveFetch?.(mockResponse(200, htmlPayload)) + await expect(renderPromise).resolves.toBeUndefined() + }) }) From 83fd0982788f19647e2ffe3b974b5a850866d75a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 01:16:53 +0000 Subject: [PATCH 3/9] Fix OpenEmu preview asset loading and resilience Co-authored-by: Siraj Chokshi --- src/routes/img/[...path]/+server.ts | 67 ++++++++ src/utils/image-fallback.ts | 120 ++++++++++++++ src/utils/lang/html.ts | 236 +++++++++++++++++++++++++++- src/utils/preview.ts | 98 ++++++++---- tests/image-fallback.test.ts | 50 ++++++ tests/preview.test.ts | 201 ++++++++++++++++++++++- 6 files changed, 731 insertions(+), 41 deletions(-) create mode 100644 src/routes/img/[...path]/+server.ts create mode 100644 src/utils/image-fallback.ts create mode 100644 tests/image-fallback.test.ts diff --git a/src/routes/img/[...path]/+server.ts b/src/routes/img/[...path]/+server.ts new file mode 100644 index 0000000..faf1352 --- /dev/null +++ b/src/routes/img/[...path]/+server.ts @@ -0,0 +1,67 @@ +import { error } from '@sveltejs/kit' +import { + buildImageFallbackTargets, + decodePreviewUrlFromReferer, + isSupportedImageFallbackSource, + sanitizeRelativePath, +} from '../../../utils/image-fallback' + +export const prerender = false + +export async function GET({ request, params, fetch }) { + const imagePath = sanitizeRelativePath(params.path) + if (!imagePath) { + throw error(400, 'Invalid image path') + } + + const previewUrl = decodePreviewUrlFromReferer(request.headers.get('referer')) + if (!previewUrl) { + throw error(404, 'Preview context unavailable') + } + + if (!isSupportedImageFallbackSource(previewUrl)) { + throw error(404, 'Preview source not supported for image fallback') + } + + const targets = buildImageFallbackTargets(previewUrl, imagePath) + if (!targets.length) { + throw error(404, 'Could not resolve image target') + } + + for (const target of targets) { + let upstream: Response + try { + upstream = await fetch(target, { + redirect: 'follow', + headers: { + Accept: request.headers.get('accept') ?? 'image/*,*/*', + }, + }) + } catch { + continue + } + + if (!upstream.ok) { + continue + } + + const payload = await upstream.arrayBuffer() + const headers = new Headers() + + const contentType = upstream.headers.get('content-type') + if (contentType) { + headers.set('Content-Type', contentType) + } + headers.set( + 'Cache-Control', + upstream.headers.get('cache-control') ?? 'public, max-age=300', + ) + + return new Response(payload, { + status: upstream.status, + headers, + }) + } + + throw error(404, 'Image not found') +} diff --git a/src/utils/image-fallback.ts b/src/utils/image-fallback.ts new file mode 100644 index 0000000..cf052e4 --- /dev/null +++ b/src/utils/image-fallback.ts @@ -0,0 +1,120 @@ +import { resourceType } from '../types/resources' +import { getResourceType, isValidURL, processUrl } from './url' + +function getRepositoryRoot(rawHtmlUrl: URL): string | undefined { + const segments = rawHtmlUrl.pathname.split('/').filter(Boolean) + + if ( + rawHtmlUrl.hostname === 'raw.githubusercontent.com' && + segments.length >= 3 + ) { + const [owner, repo, branch] = segments + return `https://${rawHtmlUrl.hostname}/${owner}/${repo}/${branch}/` + } + + const rawMarker = segments.findIndex( + (segment, idx) => segment === '-' && segments[idx + 1] === 'raw', + ) + if (rawHtmlUrl.hostname === 'gitlab.com' && rawMarker >= 2) { + return `https://${rawHtmlUrl.hostname}/${segments + .slice(0, rawMarker + 3) + .join('/')}/` + } + + return undefined +} + +export function sanitizeRelativePath( + pathParam: string | undefined, +): string | undefined { + if (!pathParam) { + return undefined + } + + let decodedPath: string + try { + decodedPath = decodeURIComponent(pathParam) + } catch { + return undefined + } + + const segments = decodedPath.split('/').filter(Boolean) + if (!segments.length) { + return undefined + } + + if (segments.some((segment) => segment === '.' || segment === '..')) { + return undefined + } + + return segments.join('/') +} + +export function decodePreviewUrlFromReferer( + refererHeader: string | null, +): string | undefined { + if (!refererHeader) { + return undefined + } + + let refererUrl: URL + try { + refererUrl = new URL(refererHeader) + } catch { + return undefined + } + + const queryUrl = refererUrl.searchParams.get('url') + if (queryUrl && isValidURL(queryUrl)) { + return queryUrl + } + + const slug = refererUrl.pathname.replace(/^\/+/, '') + if (!slug) { + return undefined + } + + try { + const decodedSlug = decodeURIComponent(slug) + if (isValidURL(decodedSlug)) { + return decodedSlug + } + } catch { + return undefined + } + + return undefined +} + +export function buildImageFallbackTargets( + previewUrl: string, + imagePath: string, +): string[] { + const htmlCandidates = processUrl(previewUrl) + const targets: string[] = [] + + htmlCandidates.forEach((candidate) => { + let candidateUrl: URL + try { + candidateUrl = new URL(candidate) + } catch { + return + } + + const repositoryRoot = getRepositoryRoot(candidateUrl) + if (!repositoryRoot) { + return + } + + targets.push(new URL(`img/${imagePath}`, repositoryRoot).toString()) + }) + + return [...new Set(targets)] +} + +export function isSupportedImageFallbackSource(previewUrl: string): boolean { + const previewType = getResourceType(previewUrl, false) + return ( + previewType === resourceType.GITHUB || previewType === resourceType.GITLAB + ) +} diff --git a/src/utils/lang/html.ts b/src/utils/lang/html.ts index 8a897b2..ca26709 100644 --- a/src/utils/lang/html.ts +++ b/src/utils/lang/html.ts @@ -4,6 +4,11 @@ export function isHTML(maybeHTML: string): boolean { return [...$div.childNodes].reverse().some(($child) => $child.nodeType === 1) } +export const PREVIEW_SCRIPT_PLACEHOLDER_TYPE = + 'application/static-preview-script' +export const PREVIEW_SCRIPT_SRC_ATTRIBUTE = 'data-preview-script-src' +export const PREVIEW_SCRIPT_TYPE_ATTRIBUTE = 'data-preview-script-type' + function getTitle(html: string) { // TODO: Regex might reasonably be faster here const $div = document.createElement('div') @@ -12,6 +17,215 @@ function getTitle(html: string) { return $title?.textContent ?? undefined } +function serializeDoctype(doc: Document): string { + const { doctype } = doc + if (!doctype) { + return '' + } + + let serialized = `' + return serialized +} + +function rewriteRootRelativeAttributes(doc: Document) { + const rootRelativeAttributes = ['src', 'href'] as const + const $assets = doc.querySelectorAll('[src], [href], [content]') + + for (const $asset of $assets) { + for (const attr of rootRelativeAttributes) { + const value = $asset.getAttribute(attr) + if (!value) { + continue + } + + // Keep protocol-relative URLs (`//cdn.example.com`) untouched. + if (!value.startsWith('/') || value.startsWith('//')) { + continue + } + + $asset.setAttribute(attr, value.slice(1)) + } + } +} + +function isAbsoluteOrSpecialUrl(value: string): boolean { + const normalized = value.trim().toLowerCase() + + return ( + normalized.startsWith('http://') || + normalized.startsWith('https://') || + normalized.startsWith('//') || + normalized.startsWith('data:') || + normalized.startsWith('mailto:') || + normalized.startsWith('tel:') || + normalized.startsWith('javascript:') || + normalized.startsWith('#') + ) +} + +function rewriteRelativeResourceAttributes(doc: Document, pageUrl: URL) { + const urlAttributes = ['src', 'href'] as const + const $assets = doc.querySelectorAll('[src], [href], [content]') + + for (const $asset of $assets) { + const tagName = $asset.tagName.toLowerCase() + + for (const attr of urlAttributes) { + const value = $asset.getAttribute(attr) + if (!value) { + continue + } + + // Preserve navigation links and already-absolute values. + if ( + (attr === 'href' && (tagName === 'a' || tagName === 'area')) || + isAbsoluteOrSpecialUrl(value) || + value.startsWith('/') + ) { + continue + } + + try { + $asset.setAttribute(attr, new URL(value, pageUrl).toString()) + } catch { + // Keep original attribute when URL parsing fails. + } + } + } +} + +function normalizePathname(pathname: string): string { + const segments = pathname.split('/') + const normalized: string[] = [] + + for (const segment of segments) { + if (!segment || segment === '.') { + continue + } + + if (segment === '..') { + normalized.pop() + continue + } + + normalized.push(segment) + } + + return `/${normalized.join('/')}` +} + +function getRepositoryRoot(url: URL): string { + const pathname = normalizePathname(url.pathname) + const segments = pathname.split('/').filter(Boolean) + + if (url.hostname === 'raw.githubusercontent.com' && segments.length >= 3) { + const [owner, repo, branch] = segments + return `https://${url.hostname}/${owner}/${repo}/${branch}/` + } + + const rawMarker = segments.findIndex( + (segment, idx) => segment === '-' && segments[idx + 1] === 'raw', + ) + if (url.hostname === 'gitlab.com' && rawMarker >= 2) { + return `https://${url.hostname}/${segments + .slice(0, rawMarker + 3) + .join('/')}/` + } + + return new URL('.', url).toString() +} + +function rewriteRootRelativeToRepo(doc: Document, rawPageUrl: URL) { + const baseHref = doc.head.querySelector('base')?.getAttribute('href') + if (!baseHref) { + return + } + + let pageBaseUrl: URL + try { + pageBaseUrl = new URL(baseHref) + } catch { + return + } + + const repositoryRoot = getRepositoryRoot(rawPageUrl) + + const rootRelativeAttributes = ['src', 'href'] as const + const $assets = doc.querySelectorAll('[src], [href], [content]') + + for (const $asset of $assets) { + const tagName = $asset.tagName.toLowerCase() + const keepRootRelativeOnSameOrigin = + tagName === 'a' || + tagName === 'area' || + tagName === 'form' || + tagName === 'base' + + for (const attr of rootRelativeAttributes) { + const value = $asset.getAttribute(attr) + if (!value || !value.startsWith('/')) { + continue + } + + // Keep protocol-relative URLs untouched. + if (value.startsWith('//')) { + continue + } + + const resolvedUrl = new URL(value, pageBaseUrl) + if ( + resolvedUrl.origin === pageBaseUrl.origin && + keepRootRelativeOnSameOrigin + ) { + continue + } + + $asset.setAttribute( + attr, + new URL(value.slice(1), repositoryRoot).toString(), + ) + } + } +} + +function deferScriptExecution(doc: Document) { + const $scripts = doc.querySelectorAll('script') + + for (const $script of $scripts) { + const src = $script.getAttribute('src') + if (src) { + let resolvedSrc = src + try { + resolvedSrc = new URL(src, doc.baseURI).toString() + } catch { + // Keep the original script source when URL parsing fails. + } + + $script.setAttribute(PREVIEW_SCRIPT_SRC_ATTRIBUTE, resolvedSrc) + $script.removeAttribute('src') + } + + const type = $script.getAttribute('type') + if (type) { + $script.setAttribute(PREVIEW_SCRIPT_TYPE_ATTRIBUTE, type) + } + + $script.setAttribute('type', PREVIEW_SCRIPT_PLACEHOLDER_TYPE) + } +} + export interface HTMLPageData { // TODO - move this to a separate file title?: string @@ -22,11 +236,23 @@ export interface HTMLPageData { } export function processHTML(html: string, url: string): HTMLPageData { - const processedHTML = html - // Add base tag to iframe - .replace(/]*)>/i, ``) - // Replace absolute paths with relative paths - .replace(/((src|href|content)=")\/(.*?")/gm, '$1$3') + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + const pageUrl = new URL(url) + + if (!doc.head.querySelector('base')) { + const base = doc.createElement('base') + base.setAttribute('href', url) + doc.head.prepend(base) + } + + rewriteRootRelativeToRepo(doc, pageUrl) + rewriteRootRelativeAttributes(doc) + rewriteRelativeResourceAttributes(doc, pageUrl) + deferScriptExecution(doc) + + const doctype = serializeDoctype(doc) + const processedHTML = `${doctype}${doctype ? '\n' : ''}${doc.documentElement.outerHTML}` return { processedHTML, diff --git a/src/utils/preview.ts b/src/utils/preview.ts index 90e0d33..7d25b64 100644 --- a/src/utils/preview.ts +++ b/src/utils/preview.ts @@ -2,7 +2,14 @@ import { goto } from '$app/navigation' import { PreviewError, errorType } from './errors' import { proxyFetch } from './fetch' import { processCSS } from './lang/css' -import { isHTML, processHTML, type HTMLPageData } from './lang/html' +import { + isHTML, + PREVIEW_SCRIPT_PLACEHOLDER_TYPE, + PREVIEW_SCRIPT_SRC_ATTRIBUTE, + PREVIEW_SCRIPT_TYPE_ATTRIBUTE, + processHTML, + type HTMLPageData, +} from './lang/html' import logger from './logger' import { processUrl } from './url' @@ -12,6 +19,7 @@ export class Preview { // Session resources: Record = {} + currentPageUrl: URL | undefined // TODO - might be worth memoizing against the URL for the session pageData: Partial = {} @@ -85,6 +93,7 @@ export class Preview { } const { processedHTML, title } = processHTML(data, url) + this.currentPageUrl = new URL(url) this.pageData.title = title ?? url @@ -102,15 +111,28 @@ export class Preview { this.iframeDocument.document.addEventListener('click', (e) => { const $el = e.target as HTMLElement const $anchor = $el.closest('a') + const href = $anchor?.getAttribute('href') - if ($anchor) { - e.preventDefault() + if (!$anchor || !href || href.startsWith('#')) { + return + } - // TODO: Check if it's a relative link + let resolvedUrl: URL + try { + resolvedUrl = new URL(href, this.iframeDocument.location.href) + } catch { + return + } - // use Svelte navigation to trigger a re-render from the top of the UI - goto(`/${encodeURIComponent($anchor.href)}`) + // Keep external links inside the iframe navigation flow. + if (!this.isProxySupportedUrl(resolvedUrl.toString())) { + return } + + e.preventDefault() + + // use Svelte navigation to trigger a re-render from the top of the UI + goto(`/${encodeURIComponent(resolvedUrl.toString())}`) }) }) } @@ -145,36 +167,42 @@ export class Preview { }) // Load page JS + const scriptSelector = `script[src], script[type="${PREVIEW_SCRIPT_PLACEHOLDER_TYPE}"]` const $script = this.iframeDocument.document.querySelectorAll( - 'script[src]', + scriptSelector, ) - const scripts = [...$script] - .filter(({ src }) => this.shouldProxyScript(src)) - .map(async (script) => { - const payload = await this.load(script.src) - const scriptType = script.getAttribute('type')?.toLowerCase().trim() - - // Prevent the browser from trying to load unsupported raw URLs directly. - script.remove() - - return { - payload, - type: scriptType === 'module' ? ('module' as const) : undefined, + for (const script of [...$script]) { + const source = + script.getAttribute(PREVIEW_SCRIPT_SRC_ATTRIBUTE) ?? script.src ?? '' + const scriptType = + script + .getAttribute(PREVIEW_SCRIPT_TYPE_ATTRIBUTE) + ?.toLowerCase() + .trim() ?? script.getAttribute('type')?.toLowerCase().trim() + const type = scriptType === 'module' ? ('module' as const) : undefined + const inlinePayload = script.textContent ?? '' + + script.remove() + + if (source) { + if (this.shouldProxyScript(source)) { + try { + const payload = await this.load(source) + this.appendScriptToHead(payload, type) + } catch (err) { + logger.warn(`Skipping script after failed fetch: ${err}`) + } + continue } - }) - const scriptResults = await Promise.allSettled(scripts) - scriptResults.forEach((result) => { - if (result.status !== 'fulfilled') { - logger.warn(`Skipping script after failed fetch: ${result.reason}`) - return + await this.appendExternalScriptToHead(source, type) + continue } - const { payload, type } = result.value - this.appendScriptToHead(payload, type) - }) + this.appendScriptToHead(inlinePayload, type) + } this.iframeDocument.document.dispatchEvent( new Event('DOMContentLoaded', { bubbles: true, cancelable: true }), @@ -234,6 +262,20 @@ export class Preview { this.iframeDocument.document.head.appendChild(tag) } + private async appendExternalScriptToHead( + src: string, + type?: 'module', + ): Promise { + const tag = this.iframeDocument.document.createElement('script') + + if (type) { + tag.type = type + } + + tag.src = src + this.iframeDocument.document.head.appendChild(tag) + } + /** * Load a resource from the network and store it in the cache */ diff --git a/tests/image-fallback.test.ts b/tests/image-fallback.test.ts new file mode 100644 index 0000000..abc6d03 --- /dev/null +++ b/tests/image-fallback.test.ts @@ -0,0 +1,50 @@ +import { + buildImageFallbackTargets, + decodePreviewUrlFromReferer, + sanitizeRelativePath, +} from '../src/utils/image-fallback' + +describe('[Image fallback route]', () => { + it('extracts preview URL from encoded referer path', () => { + const referer = + 'http://localhost:5173/https%3A%2F%2Fgithub.com%2FOpenEmu%2Fopenemu.github.io' + + expect(decodePreviewUrlFromReferer(referer)).toBe( + 'https://github.com/OpenEmu/openemu.github.io', + ) + }) + + it('builds deduplicated raw targets for github repositories', () => { + const targets = buildImageFallbackTargets( + 'https://github.com/OpenEmu/openemu.github.io', + 'logo.png', + ) + + expect(targets).toEqual( + expect.arrayContaining([ + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png', + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/main/img/logo.png', + ]), + ) + }) + + it('sanitizes image fallback paths', () => { + expect(sanitizeRelativePath('logo.png')).toBe('logo.png') + expect(sanitizeRelativePath('nested/path/logo.png')).toBe( + 'nested/path/logo.png', + ) + expect(sanitizeRelativePath('../logo.png')).toBeUndefined() + expect(sanitizeRelativePath('./logo.png')).toBeUndefined() + }) + + it('resolves first candidate before fallback branch candidate', () => { + const targets = buildImageFallbackTargets( + 'https://github.com/OpenEmu/openemu.github.io', + 'logo.png', + ) + + expect(targets[0]).toBe( + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png', + ) + }) +}) diff --git a/tests/preview.test.ts b/tests/preview.test.ts index 2812a1c..126f256 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -2,6 +2,8 @@ jest.mock('$app/navigation', () => ({ goto: jest.fn() }), { virtual: true }) import { Preview } from '../src/utils/preview' +const originalWarn = console.warn + function mockResponse(status: number, body: string): Response { return { status, @@ -28,19 +30,23 @@ function getProxyTarget(input: string | URL | Request): string { describe('[Preview] resource loading resilience', () => { let originalFetch: typeof fetch | undefined + const htmlUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' + const proxiedCssUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/site.css' + beforeEach(() => { document.body.innerHTML = '' originalFetch = global.fetch + console.warn = jest.fn() }) afterEach(() => { global.fetch = originalFetch as typeof fetch + console.warn = originalWarn }) it('skips proxy for non-allowlisted external stylesheet URLs', async () => { - const htmlUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' - const htmlPayload = ` @@ -60,6 +66,10 @@ describe('[Preview] resource loading resilience', () => { return mockResponse(200, htmlPayload) } + if (target === proxiedCssUrl) { + return mockResponse(200, 'body { color: #111; }') + } + throw new Error(`Unexpected proxy target requested: ${target}`) }) @@ -78,11 +88,10 @@ describe('[Preview] resource loading resilience', () => { target.includes('fonts.googleapis.com'), ), ).toBe(false) + expect(requestedTargets).toContain(proxiedCssUrl) }) it('continues rendering when a proxied stylesheet fetch fails', async () => { - const htmlUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' const failingCssUrl = 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/failing.css' @@ -118,9 +127,6 @@ describe('[Preview] resource loading resilience', () => { }) it('awaits single-url rendering before resolving', async () => { - const htmlUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' - const htmlPayload = ` @@ -162,4 +168,183 @@ describe('[Preview] resource loading resilience', () => { resolveFetch?.(mockResponse(200, htmlPayload)) await expect(renderPromise).resolves.toBeUndefined() }) + + it('rewrites root-relative and hash links to remain in the proxied repo', async () => { + const htmlPayload = ` + + + + OpenEmu + + + Press Pack + Overview + Logo + + + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const doc = + document.querySelector('#site-frame')?.contentDocument + expect(doc).toBeTruthy() + + const rootLink = doc?.querySelector('#root-link') + const hashLink = doc?.querySelector('#hash-link') + const relativeLink = doc?.querySelector('#relative-link') + + expect(rootLink?.getAttribute('href')).toBe('files/press-pack-v2.zip') + expect(hashLink?.getAttribute('href')).toBe('#overview') + expect(relativeLink?.getAttribute('href')).toBe('img/logo.png') + }) + + it('rewrites media and stylesheet URLs to absolute raw repo URLs', async () => { + const expectedImageUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png' + const expectedStylesheetUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/site.css' + + const htmlPayload = ` + + + + OpenEmu + + + + + + + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + if (target === expectedStylesheetUrl) { + return mockResponse(200, 'body { color: #111; }') + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const doc = + document.querySelector('#site-frame')?.contentDocument + expect(doc).toBeTruthy() + + const hero = doc?.querySelector('#hero') + const css = doc?.querySelector('#repo-css') + + expect(hero?.getAttribute('src')).toBe(expectedImageUrl) + expect(css?.getAttribute('href')).toBe(expectedStylesheetUrl) + }) + + it('executes deferred scripts in order', async () => { + const libraryScriptUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/js/lib.js' + + const htmlPayload = ` + + + + OpenEmu + + + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + if (target === libraryScriptUrl) { + return mockResponse(200, 'window.__libReady = true;') + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const doc = + document.querySelector('#site-frame')?.contentDocument + expect(doc?.body.getAttribute('data-lib-loaded')).toBe('yes') + }) + + it('preserves script order for proxied and inline scripts', async () => { + const proxiedScriptUrl = + 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/js/a.js' + + const htmlPayload = ` + + + + OpenEmu + + + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + if (target === proxiedScriptUrl) { + return mockResponse(200, 'window.__scriptOrder = "proxied-first";') + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const doc = + document.querySelector('#site-frame')?.contentDocument + expect(doc?.body.getAttribute('data-inline-order')).toBe('proxied-first') + }) }) From 86ffc541fa769d62e377667064f40f16ce83235b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 01:34:14 +0000 Subject: [PATCH 4/9] Use synthetic URLs in preview tests Co-authored-by: Siraj Chokshi --- tests/image-fallback.test.ts | 14 +++++++------- tests/preview.test.ts | 34 ++++++++++++++++------------------ 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/tests/image-fallback.test.ts b/tests/image-fallback.test.ts index abc6d03..9353f84 100644 --- a/tests/image-fallback.test.ts +++ b/tests/image-fallback.test.ts @@ -7,23 +7,23 @@ import { describe('[Image fallback route]', () => { it('extracts preview URL from encoded referer path', () => { const referer = - 'http://localhost:5173/https%3A%2F%2Fgithub.com%2FOpenEmu%2Fopenemu.github.io' + 'http://localhost:5173/https%3A%2F%2Fgithub.invalid%2Fexample-owner%2Fexample-static-site' expect(decodePreviewUrlFromReferer(referer)).toBe( - 'https://github.com/OpenEmu/openemu.github.io', + 'https://github.invalid/example-owner/example-static-site', ) }) it('builds deduplicated raw targets for github repositories', () => { const targets = buildImageFallbackTargets( - 'https://github.com/OpenEmu/openemu.github.io', + 'https://github.invalid/example-owner/example-static-site', 'logo.png', ) expect(targets).toEqual( expect.arrayContaining([ - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png', - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/main/img/logo.png', + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/img/logo.png', + 'https://raw.githubusercontent.com/example-owner/example-static-site/main/img/logo.png', ]), ) }) @@ -39,12 +39,12 @@ describe('[Image fallback route]', () => { it('resolves first candidate before fallback branch candidate', () => { const targets = buildImageFallbackTargets( - 'https://github.com/OpenEmu/openemu.github.io', + 'https://github.invalid/example-owner/example-static-site', 'logo.png', ) expect(targets[0]).toBe( - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png', + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/img/logo.png', ) }) }) diff --git a/tests/preview.test.ts b/tests/preview.test.ts index 126f256..f3acc6f 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -31,9 +31,9 @@ describe('[Preview] resource loading resilience', () => { let originalFetch: typeof fetch | undefined const htmlUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/index.html' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/index.html' const proxiedCssUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/site.css' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/styles/site.css' beforeEach(() => { document.body.innerHTML = '' @@ -51,8 +51,8 @@ describe('[Preview] resource loading resilience', () => { - OpenEmu - + Demo Site +

Site

@@ -84,22 +84,20 @@ describe('[Preview] resource loading resilience', () => { expect(requestedTargets).toContain(htmlUrl) expect( - requestedTargets.some((target) => - target.includes('fonts.googleapis.com'), - ), + requestedTargets.some((target) => target.includes('cdn.invalid')), ).toBe(false) expect(requestedTargets).toContain(proxiedCssUrl) }) it('continues rendering when a proxied stylesheet fetch fails', async () => { const failingCssUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/failing.css' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/styles/failing.css' const htmlPayload = ` - OpenEmu + Demo Site

Site

@@ -131,7 +129,7 @@ describe('[Preview] resource loading resilience', () => { - Delayed OpenEmu + Delayed Demo Site

Site

@@ -174,7 +172,7 @@ describe('[Preview] resource loading resilience', () => { - OpenEmu + Demo Site Press Pack @@ -214,15 +212,15 @@ describe('[Preview] resource loading resilience', () => { it('rewrites media and stylesheet URLs to absolute raw repo URLs', async () => { const expectedImageUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/img/logo.png' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/img/logo.png' const expectedStylesheetUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/styles/site.css' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/styles/site.css' const htmlPayload = ` - OpenEmu + Demo Site @@ -263,13 +261,13 @@ describe('[Preview] resource loading resilience', () => { it('executes deferred scripts in order', async () => { const libraryScriptUrl = - 'https://raw.githubusercontent.com/OpenEmu/openemu.github.io/master/js/lib.js' + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/js/lib.js' const htmlPayload = ` - OpenEmu + Demo Site + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + const externalScriptSpy = jest + .spyOn( + preview as unknown as { + appendExternalScriptToHead: ( + src: string, + type?: 'module', + ) => Promise + }, + 'appendExternalScriptToHead', + ) + .mockRejectedValue(new Error('external script load failure')) + + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + expect(externalScriptSpy).toHaveBeenCalledWith( + 'https://cdn.invalid/app.js', + undefined, + ) + }) + it('awaits single-url rendering before resolving', async () => { const htmlPayload = ` From 9608488258770ff98a4fb94faad22bc12473c8a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 05:08:24 +0000 Subject: [PATCH 7/9] Preserve deferred script MIME types Co-authored-by: Siraj Chokshi --- src/utils/preview.ts | 15 ++++++++------- tests/preview.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/utils/preview.ts b/src/utils/preview.ts index 884115c..8e5e3e7 100644 --- a/src/utils/preview.ts +++ b/src/utils/preview.ts @@ -175,11 +175,12 @@ export class Preview { const source = script.getAttribute(PREVIEW_SCRIPT_SRC_ATTRIBUTE) ?? script.src ?? '' const scriptType = - script - .getAttribute(PREVIEW_SCRIPT_TYPE_ATTRIBUTE) - ?.toLowerCase() - .trim() ?? script.getAttribute('type')?.toLowerCase().trim() - const type = scriptType === 'module' ? ('module' as const) : undefined + script.getAttribute(PREVIEW_SCRIPT_TYPE_ATTRIBUTE)?.trim() ?? + script.getAttribute('type')?.trim() + const type = + scriptType && scriptType !== PREVIEW_SCRIPT_PLACEHOLDER_TYPE + ? scriptType + : undefined const inlinePayload = script.textContent ?? '' script.remove() @@ -249,7 +250,7 @@ export class Preview { /** * Inline JS into the preview head. */ - private appendScriptToHead(data: string, type?: 'module'): void { + private appendScriptToHead(data: string, type?: string): void { if (!data) { return } @@ -266,7 +267,7 @@ export class Preview { private appendExternalScriptToHead( src: string, - type?: 'module', + type?: string, ): Promise { return new Promise((resolve, reject) => { const tag = this.iframeDocument.document.createElement('script') diff --git a/tests/preview.test.ts b/tests/preview.test.ts index 6816164..d1a2e5f 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -154,7 +154,7 @@ describe('[Preview] resource loading resilience', () => { preview as unknown as { appendExternalScriptToHead: ( src: string, - type?: 'module', + type?: string, ) => Promise }, 'appendExternalScriptToHead', @@ -169,6 +169,44 @@ describe('[Preview] resource loading resilience', () => { ) }) + it('preserves non-module script types during deferred replay', async () => { + const htmlPayload = ` + + + + Demo Site + + +

Site

+ + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const doc = + document.querySelector('#site-frame')?.contentDocument + const scriptTypes = [...(doc?.querySelectorAll('script') ?? [])].map( + (script) => script.getAttribute('type'), + ) + + expect(scriptTypes).toContain('application/ld+json') + }) + it('awaits single-url rendering before resolving', async () => { const htmlPayload = ` From f1c95a72522bbd1df5915985d9871d49dbbe0cfb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 05:51:20 +0000 Subject: [PATCH 8/9] Resolve iframe anchor links via document base URI Co-authored-by: Siraj Chokshi --- src/utils/preview.ts | 5 +++- tests/preview.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/utils/preview.ts b/src/utils/preview.ts index 8e5e3e7..b3818f1 100644 --- a/src/utils/preview.ts +++ b/src/utils/preview.ts @@ -117,7 +117,10 @@ export class Preview { let resolvedUrl: URL try { - resolvedUrl = new URL(href, this.iframeDocument.location.href) + const resolutionBase = + this.iframeDocument.document.baseURI || + this.iframeDocument.location.href + resolvedUrl = new URL(href, resolutionBase) } catch { return } diff --git a/tests/preview.test.ts b/tests/preview.test.ts index d1a2e5f..0a9550c 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -1,8 +1,10 @@ jest.mock('$app/navigation', () => ({ goto: jest.fn() }), { virtual: true }) +import { goto } from '$app/navigation' import { Preview } from '../src/utils/preview' const originalWarn = console.warn +const gotoMock = goto as jest.MockedFunction function mockResponse(status: number, body: string): Response { return { @@ -39,6 +41,7 @@ describe('[Preview] resource loading resilience', () => { document.body.innerHTML = '' originalFetch = global.fetch console.warn = jest.fn() + gotoMock.mockClear() }) afterEach(() => { @@ -250,6 +253,56 @@ describe('[Preview] resource loading resilience', () => { await expect(renderPromise).resolves.toBeUndefined() }) + it('resolves relative anchor clicks against base URI for in-preview navigation', async () => { + const expectedNavigationTarget = + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/docs/setup.html' + const htmlPayload = ` + + + + Demo Site + + + Setup Guide + + + ` + + const fetchMock = jest.fn(async (input: string | URL | Request) => { + const target = getProxyTarget(input) + + if (target === htmlUrl) { + return mockResponse(200, htmlPayload) + } + + throw new Error(`Unexpected proxy target requested: ${target}`) + }) + + global.fetch = fetchMock as unknown as typeof fetch + + const preview = new Preview('#site-frame') + await expect(preview.render(htmlUrl)).resolves.toBeUndefined() + + const iframe = document.querySelector('#site-frame') + expect(iframe).toBeTruthy() + iframe?.dispatchEvent(new Event('load')) + + const relativeLink = + iframe?.contentDocument?.querySelector('#relative-nav') + expect(relativeLink).toBeTruthy() + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + const clickHandled = relativeLink?.dispatchEvent(clickEvent) + + expect(clickHandled).toBe(false) + expect(gotoMock).toHaveBeenCalledWith( + `/${encodeURIComponent(expectedNavigationTarget)}`, + ) + }) + it('rewrites root-relative and hash links to remain in the proxied repo', async () => { const htmlPayload = ` From 29084f6b4fe3c7b10e13d41ea74895bdd2fa9bbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 06:43:23 +0000 Subject: [PATCH 9/9] Remove unused content selectors in HTML rewrites Co-authored-by: Siraj Chokshi --- src/utils/lang/html.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/lang/html.ts b/src/utils/lang/html.ts index cd0df09..28134b4 100644 --- a/src/utils/lang/html.ts +++ b/src/utils/lang/html.ts @@ -88,7 +88,7 @@ function serializeDoctype(doc: Document): string { function rewriteRootRelativeAttributes(doc: Document) { const rootRelativeAttributes = ['src', 'href'] as const - const $assets = doc.querySelectorAll('[src], [href], [content]') + const $assets = doc.querySelectorAll('[src], [href]') for (const $asset of $assets) { for (const attr of rootRelativeAttributes) { @@ -124,7 +124,7 @@ function isAbsoluteOrSpecialUrl(value: string): boolean { function rewriteRelativeResourceAttributes(doc: Document, pageUrl: URL) { const urlAttributes = ['src', 'href'] as const - const $assets = doc.querySelectorAll('[src], [href], [content]') + const $assets = doc.querySelectorAll('[src], [href]') for (const $asset of $assets) { const tagName = $asset.tagName.toLowerCase() @@ -210,7 +210,7 @@ function rewriteRootRelativeToRepo(doc: Document, rawPageUrl: URL) { const repositoryRoot = getRepositoryRoot(rawPageUrl) const rootRelativeAttributes = ['src', 'href'] as const - const $assets = doc.querySelectorAll('[src], [href], [content]') + const $assets = doc.querySelectorAll('[src], [href]') for (const $asset of $assets) { const tagName = $asset.tagName.toLowerCase()