From 5f386edba4701e280e5357218ce4e1d7412dd8b6 Mon Sep 17 00:00:00 2001 From: Matthew Trew Date: Fri, 27 Mar 2026 11:23:19 +0000 Subject: [PATCH 1/5] Create new preview renderer + build config This moves preview rendering to an iframe with a document loaded cross-origin as a source. This enforces cross-origin separation and gives the preview window it's own localstorage, isolated from the parent. Content is injected into the doc via `postMessage`. --- .env.example | 3 +- .gitignore | 2 + package.json | 1 + public/index-html-renderer.html | 15 ++ .../Runners/HtmlRunner/HtmlRenderer.jsx | 172 ++++++++++++++ .../Editor/Runners/HtmlRunner/HtmlRunner.jsx | 218 +----------------- src/html-renderer.jsx | 11 + webpack.html-renderer.config.js | 99 ++++++++ 8 files changed, 314 insertions(+), 207 deletions(-) create mode 100644 public/index-html-renderer.html create mode 100644 src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx create mode 100644 src/html-renderer.jsx create mode 100644 webpack.html-renderer.config.js diff --git a/.env.example b/.env.example index 3bc11dc83..5744a3957 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ REACT_APP_SENTRY_DSN='' REACT_APP_SENTRY_ENV='local' PUBLIC_URL='http://localhost:3011' ASSETS_URL='http://localhost:3011' +HTML_RENDERER_URL='http://localhost:3003' REACT_APP_GOOGLE_TAG_MANAGER_ID='' REACT_APP_API_ENDPOINT='http://localhost:3009' REACT_APP_PLAUSIBLE_DATA_DOMAIN='' REACT_APP_PLAUSIBLE_SOURCE='' -REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012' \ No newline at end of file +REACT_APP_ALLOWED_IFRAME_ORIGINS='http://localhost:3011,http://localhost:3012' diff --git a/.gitignore b/.gitignore index 56ae9ffea..e6cd150ab 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ node_modules # production /build +/build-html-renderer /public/storybook # misc @@ -33,3 +34,4 @@ yarn-error.log* .yarn/install-state.gz .vscode/settings.json +.idea/* diff --git a/package.json b/package.json index 8ccb2d281..fdaeeab5d 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ }, "scripts": { "start": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.config.js", + "start-html-renderer": "NODE_ENV=development BABEL_ENV=development webpack serve -c ./webpack.html-renderer.config.js", "build": "NODE_ENV=production BABEL_ENV=production webpack build -c ./webpack.config.js", "analyze": "ANALYZE_WEBPACK_BUNDLE=true yarn build", "lint": "eslint 'src/**/*.{js,jsx}' cypress/**/*.js", diff --git a/public/index-html-renderer.html b/public/index-html-renderer.html new file mode 100644 index 000000000..2f66c6e74 --- /dev/null +++ b/public/index-html-renderer.html @@ -0,0 +1,15 @@ + + + + + + + + Editor HTML preview + + +
+
+ + diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx new file mode 100644 index 000000000..23f63891d --- /dev/null +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -0,0 +1,172 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { parse } from "node-html-parser"; +import mimeTypes from "mime-types"; +import { + allowedExternalLinks, + allowedInternalLinks, + matchingRegexes, +} from "../../../../utils/externalLinkHelper"; +import "../../../../assets/stylesheets/HtmlRunner.scss"; + +const parentTag = (node, tag) => + node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag; + +const cssProjectImgs = (projectFile, projectMedia) => { + let updatedProjectFile = { ...projectFile }; + if (projectFile.extension === "css") { + projectMedia.forEach((media_file) => { + const find = new RegExp(`['"]${media_file.filename}['"]`, "g"); // prevent substring matches + const replace = `"${media_file.url}"`; + updatedProjectFile.content = updatedProjectFile.content.replaceAll( + find, + replace, + ); + }); + } + return updatedProjectFile; +}; + +const getBlobURL = (code, type) => { + const blob = new Blob([code], { type }); + return URL.createObjectURL(blob); +}; + +const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { + const hrefNodes = indexPage.querySelectorAll("[href]"); + + hrefNodes.forEach((hrefNode) => { + const projectFile = projectCode.find( + (file) => `${file.name}.${file.extension}` === hrefNode.attrs.href, + ); + + if (hrefNode.attrs?.target === "_blank") { + hrefNode.removeAttribute("target"); + } + + let onClick; + + if (!!projectFile) { + if (parentTag(hrefNode, "head")) { + const projectFileBlob = getBlobURL( + cssProjectImgs(projectFile, projectMedia).content, + mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`), + ); + hrefNode.setAttribute("href", projectFileBlob); + } else { + // eslint-disable-next-line no-script-url + hrefNode.setAttribute("href", "javascript:void(0)"); + onClick = `window.parent.postMessage({msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }})`; + } + } else { + const matchingExternalHref = matchingRegexes( + allowedExternalLinks, + hrefNode.attrs.href, + ); + const matchingInternalHref = matchingRegexes( + allowedInternalLinks, + hrefNode.attrs.href, + ); + if ( + !matchingInternalHref && + !matchingExternalHref && + !parentTag(hrefNode, "head") + ) { + // eslint-disable-next-line no-script-url + hrefNode.setAttribute("href", "javascript:void(0)"); + onClick = "window.parent.postMessage({msg: 'ERROR: External link'})"; + } else if (matchingExternalHref) { + onClick = `window.parent.postMessage({msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }})`; + } + } + + if (onClick) { + hrefNode.removeAttribute("target"); + hrefNode.setAttribute("onclick", onClick); + } + }); +}; + +const replaceSrcNodes = ( + indexPage, + projectMedia, + projectCode, + attr = "src", +) => { + const srcNodes = indexPage.querySelectorAll(`[${attr}]`); + + srcNodes.forEach((srcNode) => { + const projectMediaFile = projectMedia.find( + (component) => component.filename === srcNode.attrs[attr], + ); + const projectTextFile = projectCode.find( + (file) => `${file.name}.${file.extension}` === srcNode.attrs[attr], + ); + + let src = ""; + if (!!projectMediaFile) { + src = projectMediaFile.url; + } else if (!!projectTextFile) { + src = getBlobURL( + projectTextFile.content, + mimeTypes.lookup( + `${projectTextFile.name}.${projectTextFile.extension}`, + ), + ); + } else if (matchingRegexes(allowedExternalLinks, srcNode.attrs[attr])) { + src = srcNode.attrs[attr]; + } + srcNode.setAttribute(attr, src); + srcNode.setAttribute("crossorigin", true); + }); +}; + +export function HtmlRenderer() { + const [previewHtml, setPreviewHtml] = useState(); + + const listener = useCallback( + (event) => { + // todo: validate message origin + const message = event.data; + if (message?.current) { + const transformedHtml = parse(message.current); + + replaceHrefNodes(transformedHtml, message.media, message.code); + replaceSrcNodes(transformedHtml, message.media, message.code); + replaceSrcNodes( + transformedHtml, + message.media, + message.code, + "data-src", + ); + + setPreviewHtml(transformedHtml); + } + }, + [setPreviewHtml], + ); + + useEffect(() => { + window.addEventListener("message", listener); + const source = window.opener || window.parent; + if (source) { + source.postMessage({ ready: true }, "*"); + } + return () => window.removeEventListener("message", listener); + }, [listener]); + + return previewHtml ? ( +