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/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..973fb2b --- /dev/null +++ b/src/utils/image-fallback.ts @@ -0,0 +1,98 @@ +import { resourceType } from '../types/resources' +import { getRepositoryRoot } from './lang/html' +import { getResourceType, isValidURL, processUrl } from './url' + +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 1fe3d40..28134b4 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 getFetchResolverScript() { return ` + +

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?: string, + ) => 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('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 = ` + + + + Delayed Demo Site + +

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() + }) + + 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 = ` + + + + Demo Site + + + 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/example-owner/example-static-site/master/img/logo.png' + const expectedStylesheetUrl = + 'https://raw.githubusercontent.com/example-owner/example-static-site/master/styles/site.css' + + const htmlPayload = ` + + + + Demo Site + + + + + + + ` + + 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/example-owner/example-static-site/master/js/lib.js' + + 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) + } + + 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/example-owner/example-static-site/master/js/a.js' + + 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) + } + + 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') + }) +})