diff --git a/.changeset/busy-trains-see.md b/.changeset/busy-trains-see.md new file mode 100644 index 000000000000..462a05e16489 --- /dev/null +++ b/.changeset/busy-trains-see.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where renaming an image file while the dev server is running triggers a build error. Now Astro correctly hot-reloads the image without crashing. diff --git a/.changeset/hot-walls-grin.md b/.changeset/hot-walls-grin.md new file mode 100644 index 000000000000..48605813dc1d --- /dev/null +++ b/.changeset/hot-walls-grin.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Hardens `addAttribute` to drop attribute names containing characters that are invalid per the HTML spec (`"`, `'`, `>`, `/`, `=`, whitespace) diff --git a/.changeset/yummy-hoops-grin.md b/.changeset/yummy-hoops-grin.md new file mode 100644 index 000000000000..f6c98acc48bf --- /dev/null +++ b/.changeset/yummy-hoops-grin.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': patch +--- + +Hardens `remotePatterns` regex generation to match canonical wildcard semantics more strictly diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index 96183294e2af..456fed414c91 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -31,6 +31,26 @@ interface AstroContentVirtualModPluginParams { fs: typeof nodeFs; } +function invalidateAssetImports(viteServer: ViteDevServer, filePath: string) { + const timestamp = Date.now(); + for (const environment of Object.values(viteServer.environments)) { + const modules = environment.moduleGraph.getModulesByFile(filePath); + if (modules) { + for (const module of modules) { + environment.moduleGraph.invalidateModule(module, undefined, timestamp, true); + } + } + if (isRunnableDevEnvironment(environment)) { + const runnerModules = environment.runner.evaluatedModules.getModulesByFile(filePath); + if (runnerModules) { + for (const runnerModule of runnerModules) { + environment.runner.evaluatedModules.invalidateModule(runnerModule); + } + } + } + } +} + function invalidateDataStore(viteServer: ViteDevServer) { const environment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]; const module = environment.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID); @@ -84,10 +104,13 @@ export function astroContentVirtualModPlugin({ }, buildStart() { if (devServer) { + const assetImportsPath = fileURLToPath(new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir)); // We defer adding the data store file to the watcher until the server is ready devServer.watcher.add(fileURLToPath(dataStoreFile)); + devServer.watcher.add(assetImportsPath); // Manually invalidate the data store to avoid a race condition in file watching invalidateDataStore(devServer); + invalidateAssetImports(devServer, assetImportsPath); } }, resolveId: { @@ -209,17 +232,21 @@ export function astroContentVirtualModPlugin({ configureServer(server) { devServer = server; const dataStorePath = fileURLToPath(dataStoreFile); - // If the datastore file changes, invalidate the virtual module + const assetImportsPath = fileURLToPath(new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir)); server.watcher.on('add', (addedPath) => { if (addedPath === dataStorePath) { invalidateDataStore(server); + invalidateAssetImports(server, assetImportsPath); } }); server.watcher.on('change', (changedPath) => { if (changedPath === dataStorePath) { invalidateDataStore(server); + invalidateAssetImports(server, assetImportsPath); + } else if (changedPath === assetImportsPath) { + invalidateAssetImports(server, assetImportsPath); } }); }, diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index a2f30862eb19..88db687ad355 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -14,6 +14,9 @@ const DOUBLE_QUOTE_REGEX = /"/g; const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']); +// Per the HTML spec, attribute names must not contain ASCII whitespace, ", ', >, /, or =. +const INVALID_ATTR_NAME_CHAR = /[\s"'>/=]/; + // converts (most) arbitrary strings to valid JS identifiers const toIdent = (k: string) => k.trim().replace(/(?!^)\b\w|\s+|\W+/g, (match, index) => { @@ -83,6 +86,11 @@ export function addAttribute(value: any, key: string, shouldEscape = true, tagNa return ''; } + // Reject attribute names with characters that could break out of the attribute context. + if (INVALID_ATTR_NAME_CHAR.test(key)) { + return ''; + } + // compiler directives cannot be applied dynamically, log a warning and ignore. if (STATIC_DIRECTIVES.has(key)) { console.warn(`[astro] The "${key}" directive cannot be applied dynamically at runtime. It will not be rendered as an attribute. diff --git a/packages/astro/test/content-collections-image-hmr.test.ts b/packages/astro/test/content-collections-image-hmr.test.ts new file mode 100644 index 000000000000..497e7dd0d6e0 --- /dev/null +++ b/packages/astro/test/content-collections-image-hmr.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, it } from 'node:test'; +import { type DevServer, type Fixture, isWindows, loadFixture } from './test-utils.ts'; + +const assetsDir = fileURLToPath( + new URL('./fixtures/content-collections-image-hmr/src/assets/', import.meta.url), +); + +describe('HMR: Content Collections image url rename test', () => { + let fixture: Fixture; + let devServer: DevServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/content-collections-image-hmr/', + outDir: './dist/content-collections-image-hmr/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + const renamedPath = path.join(assetsDir, 'shuttle-renamed.jpg'); + const originalPath = path.join(assetsDir, 'shuttle.jpg'); + if (fs.existsSync(renamedPath) && !fs.existsSync(originalPath)) { + try { + await fs.promises.rename(renamedPath, originalPath); + } catch { + // ignore + } + } + fixture.resetAllFiles(); + await devServer.stop(); + }); + + it('should recover after renaming the primary image and updating the markdown reference', { + skip: isWindows, + }, async () => { + await fixture.fetch('/'); + + const originalImagePath = path.join(assetsDir, 'shuttle.jpg'); + const renamedImagePath = path.join(assetsDir, 'shuttle-renamed.jpg'); + + await fs.promises.rename(originalImagePath, renamedImagePath); + await fixture.editFile('/src/content/blog/post.md', (content) => + content.replace('shuttle.jpg', 'shuttle-renamed.jpg'), + ); + await fixture.onNextDataStoreChange(); + + const response = await fixture.fetch('/'); + assert.equal(response.status, 200); + const html = await response.text(); + assert.ok(html.includes('data-image="primary"')); + }); +}); diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/astro.config.mjs b/packages/astro/test/fixtures/content-collections-image-hmr/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/package.json b/packages/astro/test/fixtures/content-collections-image-hmr/package.json new file mode 100644 index 000000000000..526b819c4ea6 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/content-collections-image-hmr", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/src/assets/shuttle.jpg b/packages/astro/test/fixtures/content-collections-image-hmr/src/assets/shuttle.jpg new file mode 100644 index 000000000000..80b8ea67b8e4 Binary files /dev/null and b/packages/astro/test/fixtures/content-collections-image-hmr/src/assets/shuttle.jpg differ diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/src/content.config.ts b/packages/astro/test/fixtures/content-collections-image-hmr/src/content.config.ts new file mode 100644 index 000000000000..c1a7957bdd8d --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/src/content.config.ts @@ -0,0 +1,14 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro/zod'; + +const blog = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), + schema: ({ image }) => + z.object({ + title: z.string(), + image: image(), + }), +}); + +export const collections = { blog }; diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/src/content/blog/post.md b/packages/astro/test/fixtures/content-collections-image-hmr/src/content/blog/post.md new file mode 100644 index 000000000000..a68dabac59fc --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/src/content/blog/post.md @@ -0,0 +1,6 @@ +--- +title: Test Post +image: ../../assets/shuttle.jpg +--- + +Post content diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/src/pages/index.astro b/packages/astro/test/fixtures/content-collections-image-hmr/src/pages/index.astro new file mode 100644 index 000000000000..3ea2ac34beee --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/src/pages/index.astro @@ -0,0 +1,20 @@ +--- +import { Image } from 'astro:assets'; +import { getCollection } from 'astro:content'; + +const posts = await getCollection('blog'); +--- + + + + Content Collections Image HMR + + + {posts.map((post) => ( +
+

{post.data.title}

+ {post.data.title} +
+ ))} + + diff --git a/packages/astro/test/fixtures/content-collections-image-hmr/tsconfig.json b/packages/astro/test/fixtures/content-collections-image-hmr/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-image-hmr/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/astro/test/units/render/html-primitives.test.ts b/packages/astro/test/units/render/html-primitives.test.ts index adb08830cc8a..ff399518cc18 100644 --- a/packages/astro/test/units/render/html-primitives.test.ts +++ b/packages/astro/test/units/render/html-primitives.test.ts @@ -209,6 +209,60 @@ describe('renderElement', () => { // createComponent/createTestApp — no Vite build needed. // --------------------------------------------------------------------------- +describe('addAttribute rejects invalid attribute keys', () => { + it('drops keys containing double quotes', () => { + const result = String(addAttribute('val', 'x" onmousemove="alert(1)" y')); + assert.equal(result, ''); + }); + + it('drops keys containing spaces', () => { + const result = String(addAttribute('val', ' onerror=alert(1) ')); + assert.equal(result, ''); + }); + + it('drops keys containing >', () => { + const result = String(addAttribute('val', 'x>