diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx index 2291ed991..4272e80a6 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.jsx @@ -17,16 +17,22 @@ import { const parentTag = (node, tag) => node.parentNode?.tagName && node.parentNode.tagName.toLowerCase() === tag; -const cssProjectImgs = (projectFile, projectMedia) => { +// Media filenames are only meaningful inside the editor: the preview has no +// file system to resolve them against, so any reference to one has to be +// swapped for the asset's real URL before the file is handed to the iframe. +const mediaSubstitutedExtensions = ["css", "js"]; + +const substituteProjectMedia = (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, - ); + if (mediaSubstitutedExtensions.includes(projectFile.extension)) { + projectMedia.forEach((mediaFile) => { + // A callback, so $-sequences in the URL are not expanded as + // replacement patterns. + const mediaUrl = () => `"${mediaFile.url}"`; + // Matched with the quotes included, to prevent substring matches. + updatedProjectFile.content = updatedProjectFile.content + .replaceAll(`"${mediaFile.filename}"`, mediaUrl) + .replaceAll(`'${mediaFile.filename}'`, mediaUrl); }); } return updatedProjectFile; @@ -54,7 +60,7 @@ const replaceHrefNodes = (indexPage, projectMedia, projectCode) => { if (!!projectFile) { if (parentTag(hrefNode, "head")) { const projectFileBlob = getBlobURL( - cssProjectImgs(projectFile, projectMedia).content, + substituteProjectMedia(projectFile, projectMedia).content, mimeTypes.lookup(`${projectFile.name}.${projectFile.extension}`), ); hrefNode.setAttribute("href", projectFileBlob); @@ -113,7 +119,7 @@ const replaceSrcNodes = ( src = projectMediaFile.url; } else if (!!projectTextFile) { src = getBlobURL( - projectTextFile.content, + substituteProjectMedia(projectTextFile, projectMedia).content, mimeTypes.lookup( `${projectTextFile.name}.${projectTextFile.extension}`, ), diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx index e3550fed9..d9bf85493 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRenderer.test.jsx @@ -70,6 +70,32 @@ const mediaProject = { ], }; +const scriptMediaProject = { + components: [ + { + name: "index", + extension: "html", + content: '', + }, + { + name: "sketch", + extension: "js", + content: + "let img;\nfunction preload() {\n img = loadImage(\"image.jpeg\");\n bg = loadImage('cat (1)+x.jpeg');\n}\n", + }, + ], + image_list: [ + { + filename: "image.jpeg", + url: "https://example.com/image.jpeg", + }, + { + filename: "cat (1)+x.jpeg", + url: "https://example.com/cat.jpeg", + }, + ], +}; + const allowedExternalLink = { name: "allowed_external_link", extension: "html", @@ -285,4 +311,69 @@ describe("When run is triggered", () => { }); }); }); + + describe("When a JavaScript file references project media", () => { + // Stub Blob/createObjectURL here rather than relying on the runner's + // setup file, so the blobbed contents can be inspected under both + // Jest and Vitest. + let blobbedContents; + let originalBlob; + let originalCreateObjectURL; + + beforeEach(() => { + blobbedContents = []; + originalBlob = global.Blob; + originalCreateObjectURL = window.URL.createObjectURL; + + global.Blob = class { + constructor(parts) { + this.parts = parts; + blobbedContents.push(String(parts?.[0] ?? "")); + } + }; + window.URL.createObjectURL = () => "blob:stubbed"; + + window.postMessage( + { + type: MSG_HTML_PROJECT_UPDATE, + code: scriptMediaProject.components, + media: scriptMediaProject.image_list, + current: scriptMediaProject.components[0].content, + }, + "*", + ); + }); + + afterEach(() => { + global.Blob = originalBlob; + window.URL.createObjectURL = originalCreateObjectURL; + }); + + test("Substitutes the media URL into the script contents", async () => { + await waitFor(() => { + expect(blobbedContents).toContainEqual( + expect.stringContaining( + 'loadImage("https://example.com/image.jpeg")', + ), + ); + }); + }); + + test("Substitutes single-quoted filenames containing special characters", async () => { + await waitFor(() => { + expect(blobbedContents).toContainEqual( + expect.stringContaining('loadImage("https://example.com/cat.jpeg")'), + ); + }); + }); + + test("Does not leave the bare filename in the script contents", async () => { + await waitFor(() => { + expect(blobbedContents.length).toBeGreaterThan(0); + blobbedContents.forEach((contents) => { + expect(contents).not.toContain('"image.jpeg"'); + }); + }); + }); + }); }); diff --git a/src/projects/draw-a-cat.json b/src/projects/draw-a-cat.json new file mode 100644 index 000000000..1a0e9fd63 --- /dev/null +++ b/src/projects/draw-a-cat.json @@ -0,0 +1,36 @@ +{ + "identifier": "draw-a-cat", + "project_type": "html", + "locale": "en", + "name": "Draw a Cat", + "user_id": null, + "instructions": "", + "components": [ + { + "id": "2d45c779-ae6a-4da2-a843-fac28e98e29b", + "name": "index", + "extension": "html", + "content": "\n\n \n \n \n p5 Image Repro\n \n \n \n \n
\n

p5 image loading repro

\n

Waiting to load cat.png\u2026

\n
\n
\n \n \n\n" + }, + { + "id": "e18c5de0-cea7-4ff8-85b2-2093f52d3e02", + "name": "sketch", + "extension": "js", + "content": "let img;\n\nfunction preload() {\n img = loadImage(\n \"cat.png\",\n () => {\n const status = document.getElementById(\"status\");\n if (status) status.textContent = \"Loaded cat.png successfully.\";\n console.log(\"Image loaded\");\n },\n (err) => {\n const status = document.getElementById(\"status\");\n if (status) status.textContent = \"Failed to load cat.png. Check console/network tab.\";\n console.error(\"Image failed to load\", err);\n }\n );\n}\n\nfunction setup() {\n const canvas = createCanvas(256, 256);\n canvas.parent(\"canvas-wrap\");\n background(245);\n image(img, 0, 0, width, height);\n}\n\nfunction draw() {\n // No-op: static repro.\n}" + }, + { + "id": "39661499-1802-4481-9d18-e4711ebd3197", + "name": "styles", + "extension": "css", + "content": "html, body {\n margin: 0;\n padding: 0;\n font-family: Arial, sans-serif;\n background: #f7f7f7;\n color: #222;\n}\n\nmain {\n max-width: 720px;\n margin: 40px auto;\n padding: 24px;\n}\n\nh1 {\n margin-top: 0;\n font-size: 24px;\n}\n\n#status {\n margin: 0 0 16px;\n}\n\ncanvas {\n display: block;\n border: 1px solid #ccc;\n background: white;\n}\n" + } + ], + "image_list": [ + { + "filename": "cat.png", + "url": "data:image/png;base64,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" + } + ], + "videos": [], + "audio": [] +} diff --git a/web-component.html b/web-component.html index 4d8756389..a15e8bbde 100644 --- a/web-component.html +++ b/web-component.html @@ -79,6 +79,7 @@ Load Sample Projects: blank-html-starter cool-html + draw-a-cat blank-python-starter cool-python python-plotly