p5 image loading repro
\nWaiting to load cat.png\u2026
\n \ndiff --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 \nWaiting to load cat.png\u2026
\n \n