diff --git a/.env.example b/.env.example
index 3bc11dc83..9993dafb2 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:3011'
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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 075bf7dae..a770970ef 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -18,6 +18,10 @@ on:
required: false
default: "https://staging-editor-static.raspberrypi.org"
type: string
+ html_renderer_url:
+ required: false
+ default: "https://staging-editor-static.raspberrypi.org"
+ type: string
react_app_api_endpoint:
required: false
default: "https://staging-editor-api.raspberrypi.org"
@@ -62,7 +66,7 @@ on:
required: false
default: "https://staging-editor.raspberrypi.org,https://staging-editor-static.raspberrypi.org,https://test-editor.raspberrypi.org"
type: string
-
+
secrets:
AWS_ACCESS_KEY_ID:
required: false
@@ -86,6 +90,7 @@ jobs:
deploy_dir: ${{ steps.setup-environment.outputs.deploy_dir }}
public_url: ${{ steps.setup-environment.outputs.public_url }}
assets_url: ${{ steps.setup-environment.outputs.assets_url }}
+ html_renderer_url: ${{ steps.setup-environment.outputs.html_renderer_url }}
react_app_base_url: ${{ steps.setup-environment.outputs.react_app_base_url }}
steps:
- id: setup-environment
@@ -94,9 +99,11 @@ jobs:
deploy_dir=${{inputs.prefix}}/$safe_ref_name
public_url=${{inputs.base_url}}/$deploy_dir
assets_url=${{inputs.assets_url}}/$deploy_dir
+ html_renderer_url=${{inputs.html_renderer_url}}/$deploy_dir
echo "deploy_dir=$deploy_dir" >> $GITHUB_OUTPUT
echo "public_url=$public_url" >> $GITHUB_OUTPUT
echo "assets_url=$assets_url" >> $GITHUB_OUTPUT
+ echo "html_renderer_url=$html_renderer_url" >> $GITHUB_OUTPUT
if [ "${{inputs.react_app_base_url}}" = "unspecified" ] ; then
echo "react_app_base_url=$deploy_dir" >> $GITHUB_OUTPUT
else
@@ -134,11 +141,12 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Build WC bundle
+ - name: Build WC and HTML renderer bundles
run: yarn build
env:
PUBLIC_URL: ${{ needs.setup-environment.outputs.public_url }}
ASSETS_URL: ${{ needs.setup-environment.outputs.assets_url }}
+ HTML_RENDERER_URL: ${{ needs.setup-environment.outputs.html_renderer_url }}
REACT_APP_API_ENDPOINT: ${{ inputs.react_app_api_endpoint }}
REACT_APP_AUTHENTICATION_CLIENT_ID: ${{ inputs.react_app_authentication_client_id }}
REACT_APP_AUTHENTICATION_URL: ${{ inputs.react_app_authentication_url }}
@@ -170,10 +178,6 @@ jobs:
fi
echo -n "${{ needs.setup-environment.outputs.deploy_dir }}" | aws s3 cp - s3://${{ secrets.AWS_S3_BUCKET }}/latest_version --endpoint ${{ secrets.AWS_ENDPOINT }} --content-type "text/plain"
- env:
- AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- AWS_REGION: ${{ secrets.AWS_REGION }}
- name: Purge Cloudflare cache
if: env.HAS_CLOUDFLARE_SECRETS == 'true'
@@ -182,7 +186,7 @@ jobs:
curl -sS --fail-with-body -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
- --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js", "${{ needs.setup-environment.outputs.public_url }}/scratch.html", "${{ needs.setup-environment.outputs.public_url }}/scratch.js", "${{ inputs.base_url }}/latest_version"]}'
+ --data '{"files":["${{ needs.setup-environment.outputs.public_url }}/web-component.html", "${{ needs.setup-environment.outputs.public_url }}/web-component.js", "${{ needs.setup-environment.outputs.public_url }}/scratch.html", "${{ needs.setup-environment.outputs.public_url }}/scratch.js", "${{ inputs.base_url }}/latest_version", "${{ needs.setup-environment.outputs.html_renderer_url}}/html-renderer.html", "${{ needs.setup-environment.outputs.html_renderer_url}}/html-renderer.js"]}'
)" || {
echo "Cloudflare purge request failed:"
echo "$response"
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/cypress/e2e/spec-html.cy.js b/cypress/e2e/spec-html.cy.js
deleted file mode 100644
index 1b358ad1b..000000000
--- a/cypress/e2e/spec-html.cy.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import {
- clickHtmlRunnerPreviewLink,
- expectErrorModalToNotExist,
- expectHtmlRunnerPreviewToContainText,
- expectHtmlRunnerPreviewToNotContainText,
- getEditorShadow,
- getErrorModalTitle,
- getHtmlRunnerContainer,
- makeNewFile,
- runProject,
- setCodeEditorContent,
-} from "../helpers/editor.js";
-import { defaultHtmlProject } from "../../src/utils/defaultProjects";
-
-const baseUrl =
- "http://localhost:3011/web-component.html?identifier=blank-html-starter";
-
-beforeEach(() => {
- cy.intercept(
- "GET",
- `${Cypress.env(
- "REACT_APP_API_ENDPOINT",
- )}/api/projects/blank-html-starter?locale=en`,
- defaultHtmlProject,
- );
-
- localStorage.clear();
- cy.visit(baseUrl);
-});
-
-it("blocks access to localStorage authKey", () => {
- setCodeEditorContent(`
authKey:
- `);
-
- runProject();
-
- expectHtmlRunnerPreviewToContainText("authKey: null");
-});
-
-it("blocks access to localStorage OIDC keys", () => {
- setCodeEditorContent(`oidcUser:
-`);
-
- runProject();
-
- expectHtmlRunnerPreviewToContainText("oidcUser: null");
-});
-
-it("allows access to other localStorage keys", () => {
- setCodeEditorContent(`foo:
-`);
-
- runProject();
-
- expectHtmlRunnerPreviewToContainText("foo: bar");
-});
-
-it("renders the html runner", () => {
- runProject();
- getHtmlRunnerContainer().should("be.visible");
-});
-
-it("can make a new file", () => {
- makeNewFile("amazing.html");
- getEditorShadow()
- .findByRole("button", { name: "amazing.html" })
- .should("be.visible");
-});
-
-it("updates the preview after a change when you click run", () => {
- runProject();
- expectHtmlRunnerPreviewToNotContainText("hello world");
-
- setCodeEditorContent("hello world
");
- runProject();
-
- expectHtmlRunnerPreviewToContainText("hello world");
-});
-
-it("blocks non-permitted external links", () => {
- setCodeEditorContent(
- 'some external link ',
- );
-
- runProject();
-
- clickHtmlRunnerPreviewLink("some external link");
- getErrorModalTitle().should("be.visible");
-});
-
-it("allows permitted external links", () => {
- setCodeEditorContent(
- 'some external link ',
- );
-
- runProject();
-
- clickHtmlRunnerPreviewLink("some external link");
- cy.url().should("eq", baseUrl);
- expectErrorModalToNotExist();
-});
-
-it("allows internal links", () => {
- setCodeEditorContent("hello world
");
- runProject();
-
- makeNewFile();
-
- setCodeEditorContent('some internal link ');
- runProject();
-
- clickHtmlRunnerPreviewLink("some internal link");
- expectHtmlRunnerPreviewToContainText("hello world");
-});
diff --git a/cypress/e2e/spec-wc.cy.js b/cypress/e2e/spec-wc.cy.js
index f9851b733..1d0cc8297 100644
--- a/cypress/e2e/spec-wc.cy.js
+++ b/cypress/e2e/spec-wc.cy.js
@@ -1,5 +1,4 @@
import {
- expectHtmlRunnerPreviewToContainText,
getCodeEditorInput,
getEditorShadow,
getRunButton,
@@ -148,28 +147,4 @@ describe("when embedded, output_only & output_split_view are true", () => {
getSkulptTabByName("Visual output").should("be.visible");
getSkulptTabByName("Visual output").should("have.length", 1);
});
-
- it("displays the embedded view for an HTML project", () => {
- cy.visit(urlFor("anime-expressions-solution"));
-
- // Check HTML preview output panel is visible and has a run button
- // Important to wait for this before making the negative assertions that follow
- getEditorShadow().contains("index.html preview").should("be.visible");
- getRunButton().should("not.be.disabled");
- getRunButton().should("be.visible");
-
- // Check that the code has automatically run i.e. the HTML has been rendered
- expectHtmlRunnerPreviewToContainText("Draw anime with me");
-
- // Check that the side bar is not displayed
- getEditorShadow().should("not.contain.text", "Project files");
- // Check that the project bar is not displayed
- getEditorShadow().should("not.contain.text", "Anime expressions solution");
- // Check that the editor input containing the code is not displayed
- getEditorShadow().should("not.contain.text", "Draw anime with me ");
-
- // Run the code and check it executed without error
- runProject();
- cy.get("#results").should("contain", '"errorDetails":{}');
- });
});
diff --git a/src/assets/stylesheets/HtmlRunner.scss b/src/assets/stylesheets/HtmlRunner.scss
index 0a74a5736..515201a35 100644
--- a/src/assets/stylesheets/HtmlRunner.scss
+++ b/src/assets/stylesheets/HtmlRunner.scss
@@ -25,3 +25,18 @@ a.htmlrunner-link {
block-size: 100%;
inline-size: 100%;
}
+
+.htmlrenderer-root {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ min-block-size: 0;
+ block-size: 100%;
+ inline-size: 100%;
+
+ .htmlrunner-iframe {
+ flex: 1 1 auto;
+ min-block-size: 0;
+ block-size: auto;
+ }
+}
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx
new file mode 100644
index 000000000..c575bbf46
--- /dev/null
+++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx
@@ -0,0 +1,202 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { parse } from "node-html-parser";
+import mimeTypes from "mime-types";
+import {
+ allowedExternalLinks,
+ allowedInternalLinks,
+ matchingRegexes,
+} from "../../../../utils/externalLinkHelper";
+import htmlRunnerStyles from "../../../../assets/stylesheets/HtmlRunner.scss";
+import {
+ allowedIframeHost,
+ MSG_HTML_PREVIEW_EVENT,
+ MSG_HTML_PREVIEW_READY,
+ MSG_HTML_PROJECT_UPDATE,
+} from "../../../../utils/iframeUtils";
+
+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({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'RELOAD', payload: { linkTo: '${projectFile.name}' }}, '${process.env.HTML_RENDERER_URL}')`;
+ }
+ } 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({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'ERROR: External link'}, '${process.env.HTML_RENDERER_URL}')`;
+ } else if (matchingExternalHref) {
+ onClick = `window.parent.postMessage({type: '${MSG_HTML_PREVIEW_EVENT}', msg: 'Allowed external link', payload: { linkTo: '${hrefNode.attrs.href}' }}, '${process.env.HTML_RENDERER_URL}')`;
+ }
+ }
+
+ 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 iframeHostOrigin = useRef();
+
+ const handlePreviewUpdateFromHost = useCallback(
+ (event) => {
+ const message = event.data;
+ if (
+ allowedIframeHost(event.origin) &&
+ message?.type === MSG_HTML_PROJECT_UPDATE &&
+ message?.current
+ ) {
+ if (!iframeHostOrigin.current) {
+ // Record the host's origin, so we can use it as a target for future messages.
+ iframeHostOrigin.current = event.origin;
+ }
+
+ 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],
+ );
+
+ const handleEventFromPreview = (event) => {
+ const message = event.data;
+ if (
+ message?.type === MSG_HTML_PREVIEW_EVENT &&
+ !!iframeHostOrigin.current
+ ) {
+ // Forward events originating from the previewed code back to the host.
+ window.parent.postMessage(message, iframeHostOrigin.current);
+ }
+ };
+
+ useEffect(() => {
+ window.addEventListener("message", handlePreviewUpdateFromHost);
+ window.addEventListener("message", handleEventFromPreview);
+
+ const source = window.opener || window.parent;
+ if (source) {
+ source.postMessage({ type: MSG_HTML_PREVIEW_READY }, "*");
+ }
+ return () => {
+ window.removeEventListener("message", handlePreviewUpdateFromHost);
+ window.removeEventListener("message", handleEventFromPreview);
+ };
+ }, [handlePreviewUpdateFromHost]);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default HtmlRenderer;
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js
new file mode 100644
index 000000000..9a7f2ca3e
--- /dev/null
+++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.js
@@ -0,0 +1,288 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { matchMedia } from "mock-match-media";
+import HtmlRenderer from "./HtmlRenderer";
+import {
+ MSG_HTML_PREVIEW_READY,
+ MSG_HTML_PROJECT_UPDATE,
+} from "../../../../utils/iframeUtils";
+
+let mockMediaQuery = (query) => {
+ return matchMedia(query).matches;
+};
+
+jest.mock("react-responsive", () => ({
+ ...jest.requireActual("react-responsive"),
+ useMediaQuery: ({ query }) => mockMediaQuery(query),
+}));
+
+const indexPage = {
+ name: "index",
+ extension: "html",
+ content: "hello world
",
+};
+
+const newTabLinkHTMLPage = {
+ name: "some_file",
+ extension: "html",
+ content:
+ 'NEW TAB LINK! ',
+};
+
+const internalLinkHTMLPage = {
+ name: "internal_link",
+ extension: "html",
+ content: 'ANCHOR LINK! ',
+};
+
+const internalLinkTargetHTMLPage = {
+ name: "test",
+ extension: "html",
+ content: "test file
",
+};
+
+const mediaProject = {
+ components: [
+ {
+ name: "index",
+ extension: "html",
+ content:
+ ' ',
+ },
+ ],
+ image_list: [
+ {
+ filename: "image.jpeg",
+ url: "https://example.com/image.jpeg",
+ },
+ ],
+ videos: [
+ {
+ filename: "video.mp4",
+ url: "https://example.com/video.mp4",
+ },
+ ],
+ audio: [
+ {
+ filename: "audio.mp3",
+ url: "https://example.com/audio.mp3",
+ },
+ ],
+};
+
+const allowedExternalLink = {
+ name: "allowed_external_link",
+ extension: "html",
+ content:
+ 'RPF link ',
+};
+
+const forbiddenExternalLinkHTMLPage = {
+ name: "forbidded_external_link",
+ extension: "html",
+ content:
+ 'EXTERNAL LINK! ',
+};
+
+describe("When run is triggered", () => {
+ beforeEach(async () => {
+ let ready = false;
+
+ const onMessage = (event) => {
+ if (event.data?.type === MSG_HTML_PREVIEW_READY) {
+ ready = true;
+ }
+ };
+ window.addEventListener("message", onMessage);
+
+ render( );
+
+ await waitFor(() => {
+ expect(ready).toBe(true);
+ });
+
+ window.removeEventListener("message", onMessage);
+ });
+
+ describe("When basic HTML is rendered", () => {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [],
+ media: [],
+ current: indexPage.content,
+ },
+ "*",
+ );
+ });
+
+ test("Runs HTML code", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain("hello world");
+ });
+ });
+ });
+
+ describe("When a non-permitted external link is rendered", () => {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [forbiddenExternalLinkHTMLPage],
+ media: [],
+ current: forbiddenExternalLinkHTMLPage.content,
+ },
+ "*",
+ );
+ });
+
+ test("Transforms the external link and includes the meta tag", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [indexPage, newTabLinkHTMLPage],
+ media: [],
+ current: newTabLinkHTMLPage.content,
+ },
+ "*",
+ );
+ });
+
+ test("Removes target attribute and adds onclick event", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).not.toContain('target="_blank"');
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [internalLinkTargetHTMLPage, internalLinkHTMLPage],
+ media: [],
+ current: internalLinkHTMLPage.content,
+ },
+ "*",
+ );
+ });
+
+ test("Transforms internal link", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [allowedExternalLink],
+ media: [],
+ current: allowedExternalLink.content,
+ },
+ "*",
+ );
+ });
+
+ test("Transforms allowed external link and includes meta tag", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ beforeEach(() => {
+ window.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: [mediaProject.components],
+ media: [
+ ...(mediaProject.image_list || []),
+ ...(mediaProject.audio || []),
+ ...(mediaProject.videos || []),
+ ],
+ current: mediaProject.components[0].content,
+ },
+ "*",
+ );
+ });
+
+ test("Transforms image sources", async () => {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ const iframe = screen.getByTitle("preview-sandbox");
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' {
+ const iframe = screen.getByTitle("preview-sandbox");
+
+ await waitFor(() => {
+ expect(iframe.getAttribute("srcdoc")).toContain(
+ ' state.editor.project);
@@ -54,6 +53,8 @@ function HtmlRunner() {
const browserPreview = useSelector((state) => state.editor.browserPreview);
const page = useSelector((state) => state.editor.page);
+ const [rendererReady, setRendererReady] = useState(false);
+
const { t, i18n } = useTranslation();
const locale = i18n.language;
@@ -99,44 +100,9 @@ function HtmlRunner() {
handleExternalLinkError,
} = useExternalLinkState(showModal);
- const getBlobURL = (code, type) => {
- const blob = new Blob([code], { type });
- return URL.createObjectURL(blob);
- };
-
- const getFilename = (iframe) => {
- let filename;
- if (iframe) {
- filename = iframe.querySelectorAll("meta[filename]")[0]
- ? iframe.querySelectorAll("meta[filename]")[0].getAttribute("filename")
- : externalLink;
- } else {
- filename = externalLink;
- }
- return filename;
- };
-
- const cssProjectImgs = (projectFile) => {
- var 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 parentTag = (node, tag) =>
- node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag;
-
const eventListener = () => {
window.addEventListener("message", (event) => {
- if (typeof event.data?.msg === "string") {
+ if (event.data?.type === MSG_HTML_PREVIEW_EVENT) {
if (event.data?.msg === "ERROR: External link") {
handleExternalLinkError(showModal);
} else if (event.data?.msg === "Allowed external link") {
@@ -157,29 +123,6 @@ function HtmlRunner() {
});
};
- const iframeReload = () => {
- const iframe = output.current.contentDocument;
- let filename = getFilename(iframe);
-
- if (runningFile !== filename) {
- setRunningFile(filename);
- }
-
- if (iframe) {
- const linkElement = iframe.querySelector("a");
-
- if (linkElement) {
- linkElement.addEventListener("click", (e) => {
- e.preventDefault();
-
- output.current.contentDocument.href =
- linkElement.getAttribute("href");
- });
- }
- }
- setExternalLink(null);
- };
-
useEffect(() => {
eventListener();
dispatch(loadingRunner("html"));
@@ -200,10 +143,10 @@ function HtmlRunner() {
}, [previewFile]);
useEffect(() => {
- if (codeRunTriggered) {
+ if (codeRunTriggered && rendererReady) {
runCode();
}
- }, [codeRunTriggered]);
+ }, [codeRunTriggered, rendererReady]);
useEffect(() => {
if (
@@ -229,189 +172,36 @@ function HtmlRunner() {
}
}, [runningFile]);
- const replaceHrefNodes = (indexPage, 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).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];
+ useEffect(() => {
+ window.addEventListener("message", listener);
+ return () => window.removeEventListener("message", listener);
+ });
+
+ const listener = useCallback(
+ (event) => {
+ const message = event.data;
+ if (message?.type === MSG_HTML_PREVIEW_READY) {
+ setRendererReady(true);
}
- srcNode.setAttribute(attr, src);
- srcNode.setAttribute("crossorigin", true);
- });
- };
+ },
+ [setRendererReady],
+ );
const runCode = () => {
setRunningFile(previewFile);
if (!externalLink) {
const indexPage = parse(focussedComponent(previewFile).content);
- const body = indexPage.querySelector("body") || indexPage;
- const htmlRoot = indexPage.querySelector("html") ?? indexPage;
-
- const disableLocalStorageScript = `
-
- `;
-
- const disableSessionStorageScript = `
-
- `;
- // insert scripts to disable access to specific localStorage keys and sessionStorage
- // entirely, they are both potential security risks when executing untrusted code
- htmlRoot.insertAdjacentHTML("afterbegin", disableLocalStorageScript);
- htmlRoot.insertAdjacentHTML("afterbegin", disableSessionStorageScript);
-
- replaceHrefNodes(indexPage, projectCode);
- replaceSrcNodes(indexPage, projectMedia, projectCode);
- replaceSrcNodes(indexPage, projectMedia, projectCode, "data-src");
-
- body.appendChild(parse(` `));
-
- const blob = getBlobURL(indexPage.toString(), "text/html");
- output.current.src = blob;
+
+ output.current.contentWindow.postMessage(
+ {
+ type: MSG_HTML_PROJECT_UPDATE,
+ code: projectCode,
+ media: projectMedia,
+ current: indexPage.toString(),
+ },
+ process.env.HTML_RENDERER_URL,
+ );
if (codeRunTriggered) {
dispatch(codeRunHandled());
@@ -462,7 +252,10 @@ function HtmlRunner() {
id="output-frame"
title={t("runners.HtmlOutput")}
ref={output}
- onLoad={iframeReload}
+ src={`${process.env.HTML_RENDERER_URL}/html-renderer.html`}
+ onLoad={() => {
+ setExternalLink(null);
+ }}
/>
diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
index e797c6cf7..3e85b72c5 100644
--- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
+++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js
@@ -1,12 +1,17 @@
import configureStore from "redux-mock-store";
-import { render, screen } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { Provider } from "react-redux";
+import { parse as parseHtml } from "node-html-parser";
import HtmlRunner from "./HtmlRunner";
import { codeRunHandled, triggerCodeRun } from "../../../../redux/EditorSlice";
import { MemoryRouter } from "react-router-dom";
import { matchMedia, setMedia } from "mock-match-media";
import { MOBILE_BREAKPOINT } from "../../../../utils/mediaQueryBreakpoints";
+import {
+ MSG_HTML_PREVIEW_READY,
+ MSG_HTML_PROJECT_UPDATE,
+} from "../../../../utils/iframeUtils";
let mockMediaQuery = (query) => {
return matchMedia(query).matches;
@@ -17,6 +22,14 @@ jest.mock("react-responsive", () => ({
useMediaQuery: ({ query }) => mockMediaQuery(query),
}));
+jest.mock("node-html-parser", () => {
+ const actual = jest.requireActual("node-html-parser");
+ return {
+ ...actual,
+ parse: jest.fn((...args) => actual.parse(...args)),
+ };
+});
+
const indexPage = {
name: "index",
extension: "html",
@@ -27,18 +40,6 @@ const anotherHTMLPage = {
extension: "html",
content: "My amazing page
",
};
-const internalLinkHTMLPage = {
- name: "internal_link",
- extension: "html",
- content: 'ANCHOR LINK! ',
-};
-
-const allowedExternalLink = {
- name: "allowed_external_link",
- extension: "html",
- content:
- 'RPF link ',
-};
describe("When page first loaded", () => {
let store;
@@ -107,7 +108,7 @@ describe("When focussed on another HTML file", () => {
});
test("Does not show page related to focussed file", () => {
- expect(Blob).not.toHaveBeenCalled();
+ expect(parseHtml).not.toHaveBeenCalled();
});
});
@@ -244,6 +245,7 @@ describe("When page does not exist", () => {
describe("When run is triggered", () => {
let store;
+ let mockPostMessageFn;
beforeEach(() => {
const middlewares = [];
@@ -261,94 +263,11 @@ describe("When run is triggered", () => {
},
};
store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- });
-
- test("Runs HTML code and adds meta tag", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
- expect(generatedHtml).toContain("hello world
");
- expect(generatedHtml).toContain(' {
- expect(store.getActions()).toEqual(
- expect.arrayContaining([codeRunHandled()]),
- );
- });
-
- test("Includes localStorage disabling script for disallowed keys in the iframe", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
-
- expect(generatedHtml).toContain("");
- });
-
- test("Includes localSession disabling script to prevent all access to the session object", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
- expect(generatedHtml).toContain("");
- });
-});
-
-describe("When a non-permitted external link is rendered", () => {
- let store;
- const input =
- 'EXTERNAL LINK! ';
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- {
- name: "index",
- extension: "html",
- content: input,
- },
- ],
- },
- focussedFileIndices: [0],
- openFiles: [["index.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- store = mockStore(initialState);
render(
@@ -358,343 +277,153 @@ describe("When a non-permitted external link is rendered", () => {
,
);
- });
- test("Transforms the external link and includes the meta tag", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
-
- expect(generatedHtml).toContain(' {
- let store;
- const input =
- ' NEW TAB LINK! ';
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- indexPage,
- {
- name: "some_file",
- extension: "html",
- content: input,
- },
- ],
- },
- focussedFileIndices: [1],
- openFiles: [["index.html", "some_file.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
+ const iframe = screen.getByTitle("runners.HtmlOutput");
+ mockPostMessageFn = jest
+ .spyOn(iframe.contentWindow, "postMessage")
+ .mockImplementation(() => {});
+
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { type: MSG_HTML_PREVIEW_READY },
+ }),
+ );
+ });
});
- test("Removes target attribute and adds onclick event", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
-
- expect(generatedHtml).not.toContain('target="_blank"');
- expect(generatedHtml).toContain(' {
+ mockPostMessageFn.mockRestore();
});
-});
-describe("When an internal link is rendered", () => {
- let store;
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- internalLinkHTMLPage,
- {
- name: "test",
- extension: "html",
- content: "test file
",
- },
- ],
- },
- focussedFileIndices: [0],
- openFiles: [["internal_link.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
+ test("Sends HTML code for current file to renderer", async () => {
+ await waitFor(() => {
+ expect(mockPostMessageFn).toHaveBeenCalled();
+ });
+ const payload = mockPostMessageFn.mock.calls[0][0];
+ expect(payload.type).toEqual(MSG_HTML_PROJECT_UPDATE);
+ expect(payload.current).toContain("hello world
");
});
- test("Transforms internal link and includes meta tag", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
-
- expect(generatedHtml).toContain(' {
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([codeRunHandled()]),
);
- expect(generatedHtml).toContain("ANCHOR LINK!");
- expect(generatedHtml).toContain(' {
- let store;
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [allowedExternalLink],
+ describe("When on desktop", () => {
+ let store;
+
+ beforeEach(() => {
+ cleanup();
+ setMedia({
+ width: "1000px",
+ });
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ components: [indexPage],
+ },
+ focussedFileIndices: [0],
+ openFiles: [["index.html"]],
+ codeRunTriggered: true,
+ codeHasBeenRun: true,
+ errorModalShowing: false,
},
- focussedFileIndices: [0],
- openFiles: [["allowed_external_link.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- });
-
- test("Transforms allowed external link and includes meta tag", () => {
- const [generatedHtml] = Blob.mock.calls[0][0];
+ };
+ store = mockStore(initialState);
+ render(
+
+
+
+
+
+
+ ,
+ );
+ });
- expect(generatedHtml).toContain(' {
+ expect(screen.queryByText("runButton.run")).not.toBeInTheDocument();
+ });
});
-});
-describe("When media is rendered", () => {
- const mediaHTML =
- ' ';
- let generatedHtml;
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [
- { name: "index", extension: "html", content: mediaHTML },
- ],
- image_list: [
- {
- filename: "image.jpeg",
- url: "https://example.com/image.jpeg",
- },
- ],
- videos: [
- {
- filename: "video.mp4",
- url: "https://example.com/video.mp4",
- },
- ],
- audio: [
- {
- filename: "audio.mp3",
- url: "https://example.com/audio.mp3",
- },
- ],
+ describe("When not embedded", () => {
+ let store;
+
+ beforeEach(() => {
+ cleanup();
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ components: [indexPage],
+ },
+ focussedFileIndices: [0],
+ openFiles: [["index.html"]],
+ codeHasBeenRun: true,
+ isEmbedded: false,
},
- focussedFileIndices: [0],
- openFiles: [["index.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- const store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- [generatedHtml] = Blob.mock.calls[0][0];
- });
-
- test("Transforms image sources", () => {
- expect(generatedHtml).toContain(
- ' {
- expect(generatedHtml).toContain(
- ' {
- expect(generatedHtml).toContain(
- ' {
- let store;
-
- beforeEach(() => {
- setMedia({
- width: "1000px",
+ };
+ store = mockStore(initialState);
+ render(
+
+
+
+
+
+
+ ,
+ );
});
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [indexPage],
- },
- focussedFileIndices: [0],
- openFiles: [["index.html"]],
- codeRunTriggered: true,
- codeHasBeenRun: true,
- errorModalShowing: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- });
- test("There is no run button", () => {
- expect(screen.queryByText("runButton.run")).not.toBeInTheDocument();
+ test("displays link to open preview in another browser tab", () => {
+ expect(screen.queryByText("output.newTab")).toBeInTheDocument();
+ });
});
-});
-describe("When not embedded", () => {
- let store;
-
- beforeEach(() => {
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [indexPage],
+ describe("When on mobile but not embedded", () => {
+ let store;
+
+ beforeEach(() => {
+ cleanup();
+ setMedia({
+ width: MOBILE_BREAKPOINT,
+ });
+
+ const middlewares = [];
+ const mockStore = configureStore(middlewares);
+ const initialState = {
+ editor: {
+ project: {
+ components: [indexPage],
+ },
+ focussedFileIndices: [0],
+ openFiles: [["index.html"]],
+ codeHasBeenRun: true,
+ isEmbedded: false,
},
- focussedFileIndices: [0],
- openFiles: [["index.html"]],
- codeHasBeenRun: true,
- isEmbedded: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- });
-
- test("displays link to open preview in another browser tab", () => {
- expect(screen.queryByText("output.newTab")).toBeInTheDocument();
- });
-});
-
-describe("When on mobile but not embedded", () => {
- let store;
-
- beforeEach(() => {
- setMedia({
- width: MOBILE_BREAKPOINT,
+ };
+ store = mockStore(initialState);
+ render(
+
+
+
+
+
+
+ ,
+ );
});
- const middlewares = [];
- const mockStore = configureStore(middlewares);
- const initialState = {
- editor: {
- project: {
- components: [indexPage],
- },
- focussedFileIndices: [0],
- openFiles: [["index.html"]],
- codeHasBeenRun: true,
- isEmbedded: false,
- },
- };
- store = mockStore(initialState);
- render(
-
-
-
-
-
-
- ,
- );
- });
-
- test("Has run button in tab bar", () => {
- const runButton =
- screen.getByText("runButton.run").parentElement.parentElement;
- const runButtonContainer = runButton.parentElement.parentElement;
- expect(runButtonContainer).toHaveClass("react-tabs__tab-container");
+ test("Has run button in tab bar", () => {
+ const runButton =
+ screen.getByText("runButton.run").parentElement.parentElement;
+ const runButtonContainer = runButton.parentElement.parentElement;
+ expect(runButtonContainer).toHaveClass("react-tabs__tab-container");
+ });
});
});
diff --git a/src/components/ScratchEditor/ScratchIntegrationHOC.jsx b/src/components/ScratchEditor/ScratchIntegrationHOC.jsx
index 338ee5fcb..083f0ec41 100644
--- a/src/components/ScratchEditor/ScratchIntegrationHOC.jsx
+++ b/src/components/ScratchEditor/ScratchIntegrationHOC.jsx
@@ -7,6 +7,7 @@ import {
manualUpdateProject,
setStageSize,
} from "@scratch/scratch-gui";
+import { allowedIframeHost } from "../../utils/iframeUtils";
const ScratchIntegrationHOC = function (WrappedComponent) {
class ScratchIntegrationComponent extends React.Component {
@@ -27,15 +28,8 @@ const ScratchIntegrationHOC = function (WrappedComponent) {
window.removeEventListener("message", this.handleMessage);
}
- allowedIframeHost(origin) {
- const allowedHosts = process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS
- ? process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS.split(",")
- : [];
- return allowedHosts.includes(origin);
- }
-
handleMessage(event) {
- if (!this.allowedIframeHost(event.origin)) {
+ if (!allowedIframeHost(event.origin)) {
console.warn(
"iFrame received message from unknown origin:",
event.origin,
diff --git a/src/html-renderer.jsx b/src/html-renderer.jsx
new file mode 100644
index 000000000..e4742feb1
--- /dev/null
+++ b/src/html-renderer.jsx
@@ -0,0 +1,11 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import { HtmlRenderer } from "./components/Editor/Runners/HtmlRunner/HtmlRenderer";
+
+const root = createRoot(document.getElementById("root"));
+
+root.render(
+
+
+ ,
+);
diff --git a/src/index-html-renderer.html b/src/index-html-renderer.html
new file mode 100644
index 000000000..552bdf68f
--- /dev/null
+++ b/src/index-html-renderer.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+ Editor HTML preview
+
+
+
+
+
+
diff --git a/src/utils/iframeUtils.js b/src/utils/iframeUtils.js
new file mode 100644
index 000000000..bfe88beae
--- /dev/null
+++ b/src/utils/iframeUtils.js
@@ -0,0 +1,10 @@
+export function allowedIframeHost(origin) {
+ const allowedHosts = process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS
+ ? process.env.REACT_APP_ALLOWED_IFRAME_ORIGINS.split(",")
+ : [];
+ return process.env.NODE_ENV === "test" || allowedHosts.includes(origin);
+}
+
+export const MSG_HTML_PREVIEW_READY = "editor-html-ready";
+export const MSG_HTML_PROJECT_UPDATE = "editor-html-preview";
+export const MSG_HTML_PREVIEW_EVENT = "editor-html-event";
diff --git a/webpack.config.js b/webpack.config.js
index 982ec3250..1ef2acfce 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -105,6 +105,7 @@ const mainConfig = {
entry: {
"web-component": path.resolve(__dirname, "./src/web-component.js"),
PyodideWorker: path.resolve(__dirname, "./src/PyodideWorker.js"),
+ "html-renderer": path.resolve(__dirname, "./src/html-renderer.jsx"),
},
module: { rules: moduleRules },
resolve: {
@@ -184,6 +185,12 @@ const mainConfig = {
filename: "web-component.html",
chunks: ["web-component"],
}),
+ new HtmlWebpackPlugin({
+ inject: "body",
+ template: "src/index-html-renderer.html",
+ filename: "html-renderer.html",
+ chunks: ["html-renderer"],
+ }),
new CopyWebpackPlugin({
patterns: [
{ from: "public", to: "" },