From a6b08525021bed3f767e6db4e79e7dfdffba4681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Paternoster?= Date: Wed, 3 Jun 2026 11:44:38 +0200 Subject: [PATCH 01/10] docs(logger): fix "destinatin" typo in JSDoc (#16818) --- packages/astro/src/core/logger/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index 05d79929746e..511d7d08916d 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -248,7 +248,7 @@ export class AstroLogger { } /** - * It calls the `flush` function of the provided destinatin, if it exists. + * It calls the `flush` function of the provided destination, if it exists. */ flush() { if (this.options.destination.flush) { From 9a93d68429aa15e76f07268863badfbda7b59d23 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:47:09 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20update=20to=20S=C3=A4tteri=200.8.?= =?UTF-8?q?0=20(#16955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: update to Sätteri 0.7.0 * fix: update to 0.8.0 --- .changeset/satteri-gfm-math-options.md | 6 +++ packages/integrations/mdx/package.json | 2 +- packages/integrations/mdx/src/index.ts | 5 +- .../integrations/mdx/src/satteri/index.ts | 11 ++-- packages/markdown/satteri/package.json | 2 +- .../markdown/satteri/src/satteri-processor.ts | 2 +- pnpm-lock.yaml | 54 +++++++++---------- 7 files changed, 47 insertions(+), 35 deletions(-) create mode 100644 .changeset/satteri-gfm-math-options.md diff --git a/.changeset/satteri-gfm-math-options.md b/.changeset/satteri-gfm-math-options.md new file mode 100644 index 000000000000..e09777b0c7a3 --- /dev/null +++ b/.changeset/satteri-gfm-math-options.md @@ -0,0 +1,6 @@ +--- +'@astrojs/markdown-satteri': patch +'@astrojs/mdx': patch +--- + +Updates Sätteri processor to v0.8.0. See [its changelog](https://github.com/bruits/satteri/blob/main/packages/satteri/CHANGELOG.md#080--2026-06-03) for details on bugs fixed and features added. diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index c0e62f77faa1..6d18eb293ba6 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -76,7 +76,7 @@ "remark-math": "^6.0.0", "remark-rehype": "^11.1.2", "remark-toc": "^9.0.0", - "satteri": "^0.6.3", + "satteri": "^0.8.0", "shiki": "^4.0.2", "unified": "^11.0.5", "vite": "^7.3.2" diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 8099ccdad282..e9eb427dd36a 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -174,7 +174,10 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI // `gfm`/`smartPunctuation` from `satteri({ features: {...} })` apply to `.mdx` // too, unless `mdx({...})` set its own. Mirrors the unified branch above. const features = processor.options.features; - if (partialMdxOptions.gfm === undefined && features.gfm !== undefined) { + // `gfm` can be `boolean | GfmOptions`; only the boolean form is shape-compatible + // with `mdxOptions.gfm`. Object configs stay on the processor and are applied at + // the satteri/mdx boundary, like `smartPunctuation` below. + if (partialMdxOptions.gfm === undefined && typeof features.gfm === 'boolean') { resolvedMdxOptions.gfm = features.gfm; } // `smartPunctuation` can be `boolean | SmartPunctuationOptions`; only the boolean diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index 8fecaf91d10d..636b47f54be8 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -1,3 +1,4 @@ +import { pathToFileURL } from 'node:url'; import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import type { SatteriResolvedOptions } from '@astrojs/markdown-satteri'; @@ -150,14 +151,16 @@ export function createMdxProcessor( optimizeStatic, features: { ...satteriOptions.features, - gfm: mdxOptions.gfm !== false, - // `mdxOptions.smartypants` is always boolean-shaped; skip the override when - // satteri's `smartPunctuation` is an object so granular config isn't clobbered. + // `mdxOptions.gfm`/`smartypants` are always boolean-shaped; skip the override when + // satteri's feature is an object so granular config isn't clobbered. + ...(typeof satteriOptions.features.gfm === 'object' + ? {} + : { gfm: mdxOptions.gfm !== false }), ...(typeof satteriOptions.features.smartPunctuation === 'object' ? {} : { smartPunctuation: mdxOptions.smartypants !== false }), }, - filename: filePath, + fileURL: pathToFileURL(filePath), jsxImportSource: 'astro', }); let compiled = mdxResult.code; diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index c832f224a3e9..058e89c729f9 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -28,7 +28,7 @@ "dependencies": { "@astrojs/internal-helpers": "workspace:*", "github-slugger": "^2.0.0", - "satteri": "^0.6.3" + "satteri": "^0.8.0" }, "devDependencies": { "astro-scripts": "workspace:*" diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index 4c047b8d5a06..895924df914f 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -293,7 +293,7 @@ export async function createSatteriMarkdownProcessor( smartPunctuation: smartypants !== false, ...userFeatures, }, - filename: renderOpts?.fileURL?.pathname, + fileURL: renderOpts?.fileURL, }); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7c8cb83d398..b5d32c150382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5143,8 +5143,8 @@ importers: specifier: ^9.0.0 version: 9.0.0 satteri: - specifier: ^0.6.3 - version: 0.6.3 + specifier: ^0.8.0 + version: 0.8.0 shiki: specifier: ^4.0.2 version: 4.0.2 @@ -6749,8 +6749,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 satteri: - specifier: ^0.6.3 - version: 0.6.3 + specifier: ^0.8.0 + version: 0.8.0 devDependencies: astro-scripts: specifier: workspace:* @@ -7387,29 +7387,29 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@bruits/satteri-darwin-arm64@0.6.3': - resolution: {integrity: sha512-oKgMfpmNzQ8vaqmkE37PBu8tOyVjoOc4s+DV2tLpWvO6WO467qn/+Nbcirm/ceer7wUM8v4vMLGcZO0gkRzBEg==} + '@bruits/satteri-darwin-arm64@0.8.0': + resolution: {integrity: sha512-/ocDvu5Z0ndgPgprx6jGEhAwSxMUw7KUzwHcXqeZD3/mvLakHpAW/+VV6/vpJJ+0PXPbd2mfiPL0DWDgmFxiVQ==} cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-x64@0.6.3': - resolution: {integrity: sha512-9I5pbwZRWH5LvhoCtwpRr4rYSDe43/dLvps6zO70ipVF2XbH4rJ20T+EfvPcmou5jsWMsq9Ybn5GX3PwlSrBaw==} + '@bruits/satteri-darwin-x64@0.8.0': + resolution: {integrity: sha512-XtgdjitWXgogb9G5wy6C5wR/n+CSJ++FDeTtVLMnpT4NtAgQMXs+h/erZr6/yHbK0p+cNbT60jmtoHPvIYLwFw==} cpu: [x64] os: [darwin] - '@bruits/satteri-linux-x64-gnu@0.6.3': - resolution: {integrity: sha512-aFfw2DL2HpIcAQ8I3ZEtKuz+/GoF0H0sq387jAlvr00Q7buiiFbvARFuQlvTk00N7u3SJIh/3+YkKssTJDT+yQ==} + '@bruits/satteri-linux-x64-gnu@0.8.0': + resolution: {integrity: sha512-YijsSxIN1azfkOFRwPuci6wfmEy3qREO6PU1Beyf0EtQgMxlb2o/OHEGXygU7vs6pDrSSt7LZooCVNKjEvZWlQ==} cpu: [x64] os: [linux] libc: [glibc] - '@bruits/satteri-wasm32-wasi@0.6.3': - resolution: {integrity: sha512-DdpfnJ+04Mb4YtHaxAeETUvhdxFKg3URnroGY39FvweJEgeXx8cFNutuF5w904BhqaiblhmF76AoBnJwNLxsXg==} + '@bruits/satteri-wasm32-wasi@0.8.0': + resolution: {integrity: sha512-4t/1iW0HnWe8MCdlH+RAhgiw1vNQEvn2+nNn9z3nFtBjn1T3+XemPt15A08U9Rp4FeqtyZ/+NUyvOC4A8t/sGA==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-win32-x64-msvc@0.6.3': - resolution: {integrity: sha512-J/CZqACnBbv77eImx/JeO5RmCuCyliihiC81u3M4VobA8eupsygaPen3/UFFqf3yfeHvMlE3myilouh/2iHMOA==} + '@bruits/satteri-win32-x64-msvc@0.8.0': + resolution: {integrity: sha512-ouw20cuhG+dpamPrNuGviNI+wRdcbu2wGaMbxILKIqMveKo94mJMXEQe5uDXT3O1SgXQ738YwTmT0tMJsIvM7w==} cpu: [x64] os: [win32] @@ -14930,8 +14930,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - satteri@0.6.3: - resolution: {integrity: sha512-iY5xd2tBDQveYRFkL1F0cegkabWSVoXHi64e1p49SiCs1bZDaqTQPGQI+PqEQIaWkz0iqK80CuQQUYvhsssviw==} + satteri@0.8.0: + resolution: {integrity: sha512-+NcnzVfFmAoOG3UBDHSFNaZ68jjXilmK3aUW0pasVcS0LyqaWnsrXBUHPpLr3nv1t3UAFmprfZZBMtjFIUhtiw==} sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} @@ -17240,23 +17240,23 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@bruits/satteri-darwin-arm64@0.6.3': + '@bruits/satteri-darwin-arm64@0.8.0': optional: true - '@bruits/satteri-darwin-x64@0.6.3': + '@bruits/satteri-darwin-x64@0.8.0': optional: true - '@bruits/satteri-linux-x64-gnu@0.6.3': + '@bruits/satteri-linux-x64-gnu@0.8.0': optional: true - '@bruits/satteri-wasm32-wasi@0.6.3': + '@bruits/satteri-wasm32-wasi@0.8.0': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true - '@bruits/satteri-win32-x64-msvc@0.6.3': + '@bruits/satteri-win32-x64-msvc@0.8.0': optional: true '@capsizecss/unpack@4.0.0': @@ -25809,18 +25809,18 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 - satteri@0.6.3: + satteri@0.8.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.6.3 - '@bruits/satteri-darwin-x64': 0.6.3 - '@bruits/satteri-linux-x64-gnu': 0.6.3 - '@bruits/satteri-wasm32-wasi': 0.6.3 - '@bruits/satteri-win32-x64-msvc': 0.6.3 + '@bruits/satteri-darwin-arm64': 0.8.0 + '@bruits/satteri-darwin-x64': 0.8.0 + '@bruits/satteri-linux-x64-gnu': 0.8.0 + '@bruits/satteri-wasm32-wasi': 0.8.0 + '@bruits/satteri-win32-x64-msvc': 0.8.0 sax@1.6.0: {} From 2c0bc943d96d602b429ce3ecbb379d01a46903b5 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:59:38 -0700 Subject: [PATCH 03/10] Fix unnecessary backend reloads when editing client-side components (#16924) Co-authored-by: ematipico --- .changeset/fix-client-hmr-program-reload.md | 5 +++ .../astro/src/vite-plugin-hmr-reload/index.ts | 10 +++++ .../vite-plugin-hmr-reload/hmr-reload.test.ts | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .changeset/fix-client-hmr-program-reload.md diff --git a/.changeset/fix-client-hmr-program-reload.md b/.changeset/fix-client-hmr-program-reload.md new file mode 100644 index 000000000000..00daf20677c3 --- /dev/null +++ b/.changeset/fix-client-hmr-program-reload.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where editing a client-side component (e.g. with `client:idle`, `client:load`, etc.) caused an unnecessary full program reload of the backend during development. diff --git a/packages/astro/src/vite-plugin-hmr-reload/index.ts b/packages/astro/src/vite-plugin-hmr-reload/index.ts index af30b376fb1e..85f47aa94a3b 100644 --- a/packages/astro/src/vite-plugin-hmr-reload/index.ts +++ b/packages/astro/src/vite-plugin-hmr-reload/index.ts @@ -99,6 +99,16 @@ export default function hmrReload(): Plugin { if (hasSkippedStyleModules) { return []; } + + // If we processed modules but none were SSR-only (all were found in the + // client module graph), return an empty array to prevent Vite's default + // HMR propagation. Without this, Vite would propagate through the SSR + // module graph, find no HMR boundary (e.g. .astro files), and trigger + // a full-reload that causes unnecessary program reloads for the module + // runner. The client environment handles HMR for these modules natively. + if (modules.length > 0) { + return []; + } }, }, }; diff --git a/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts b/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts index 72b14a6f0af1..f3f1d3b2652a 100644 --- a/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts +++ b/packages/astro/test/units/vite-plugin-hmr-reload/hmr-reload.test.ts @@ -168,6 +168,7 @@ describe('astro:hmr-reload', () => { const result = ctx.call(); assert.ok(Array.isArray(result), 'should return an array'); assert.equal(result.length, 0, 'should return empty array for styles'); + assert.equal(ctx.wsSent.length, 0, 'should NOT send full-reload for style modules'); }); it('invalidates importers in the module graph for dynamic import chains', () => { @@ -209,4 +210,40 @@ describe('astro:hmr-reload', () => { assert.equal(ctx.invalidated.length, 1, 'should only invalidate SSR-only module'); assert.equal(ctx.invalidated[0], ssrMod); }); + + it('returns [] for modules that exist in the client module graph (prevents unnecessary program reload)', () => { + const mod = createMockModule('/src/components/Foo.tsx'); + const ctx = createMockContext({ + environmentName: 'ssr', + modules: [mod], + clientModuleIds: ['/src/components/Foo.tsx'], // module IS in client graph + }); + + const result = ctx.call(); + + assert.deepEqual( + result, + [], + 'Should return empty array to prevent Vite default HMR propagation', + ); + assert.equal(ctx.wsSent.length, 0, 'Should NOT send full-reload via WebSocket'); + assert.equal(ctx.hotSent.length, 0, 'Should NOT send full-reload via hot channel'); + assert.equal(ctx.invalidated.length, 0, 'Should NOT invalidate client-graph modules'); + }); + + it('sends full-reload when mix of client and SSR-only modules', () => { + const clientMod = createMockModule('/src/components/Foo.tsx'); + const ssrOnlyMod = createMockModule('/src/backend/db.ts'); + const ctx = createMockContext({ + environmentName: 'ssr', + modules: [clientMod, ssrOnlyMod], + clientModuleIds: ['/src/components/Foo.tsx'], // only Foo.tsx in client graph + }); + + const result = ctx.call(); + + assert.deepEqual(result, [], 'Should return empty array'); + assert.equal(ctx.wsSent.length, 1, 'Should send full-reload because of SSR-only module'); + assert.equal(ctx.wsSent[0].type, 'full-reload'); + }); }); From 17390a6184d5cbd5ff85b7f652a92f5a6a7b0557 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 3 Jun 2026 06:29:42 -0700 Subject: [PATCH 04/10] fix(astro): match case-mismatched project paths in normalizeFilename (#16703) Co-authored-by: Henry Co-authored-by: ematipico --- .changeset/normalize-filename-case.md | 5 ++ packages/astro/src/vite-plugin-utils/index.ts | 15 +++- packages/astro/test/css-path-case.test.ts | 82 +++++++++++++++++++ .../test/fixtures/css-path-case/package.json | 8 ++ .../css-path-case/src/pages/index.astro | 17 ++++ .../normalize-filename.test.ts | 45 ++++++++++ pnpm-lock.yaml | 6 ++ 7 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 .changeset/normalize-filename-case.md create mode 100644 packages/astro/test/css-path-case.test.ts create mode 100644 packages/astro/test/fixtures/css-path-case/package.json create mode 100644 packages/astro/test/fixtures/css-path-case/src/pages/index.astro create mode 100644 packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts diff --git a/.changeset/normalize-filename-case.md b/.changeset/normalize-filename-case.md new file mode 100644 index 000000000000..f004c318e59d --- /dev/null +++ b/.changeset/normalize-filename-case.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes styles being stripped when the project root is started with a path whose case differs from the actual filesystem case (e.g. running `astro dev` from `d:\dev\app` while the folder on disk is `D:\dev\app`). diff --git a/packages/astro/src/vite-plugin-utils/index.ts b/packages/astro/src/vite-plugin-utils/index.ts index 7a932a18dacc..0eacaad9bff0 100644 --- a/packages/astro/src/vite-plugin-utils/index.ts +++ b/packages/astro/src/vite-plugin-utils/index.ts @@ -46,13 +46,26 @@ export function normalizeFilename(filename: string, root: URL) { // is imported via a TypeScript path alias and Vite produces a relative virtual module ID. const url = new URL(filename, root); filename = viteID(url); - } else if (filename.startsWith('/') && !commonAncestorPath(filename, fileURLToPath(root))) { + } else if (filename.startsWith('/') && !isPathInRoot(filename, fileURLToPath(root))) { const url = new URL('.' + filename, root); filename = viteID(url); } return removeLeadingForwardSlashWindows(filename); } +/** + * Check whether `filename` lives under `rootPath`. Falls back to a case-insensitive + * comparison so that paths whose case differs from `rootPath` (e.g. a `d:\dev\foo` + * cwd versus a `D:\dev\foo` filesystem on Windows, or any case-insensitive macOS + * volume) are still recognized as project-internal absolute paths. + */ +function isPathInRoot(filename: string, rootPath: string) { + if (commonAncestorPath(filename, rootPath)) { + return true; + } + return commonAncestorPath(filename.toLowerCase(), rootPath.toLowerCase()) !== ''; +} + const postfixRE = /[?#].*$/s; export function cleanUrl(url: string): string { return url.replace(postfixRE, ''); diff --git a/packages/astro/test/css-path-case.test.ts b/packages/astro/test/css-path-case.test.ts new file mode 100644 index 000000000000..e7885e41f628 --- /dev/null +++ b/packages/astro/test/css-path-case.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +/** + * Regression test for https://github.com/withastro/astro/issues/14013 + * + * On case-insensitive filesystems (macOS, Windows) the dev server can be started + * from a project root whose case differs from the actual on-disk case (e.g. + * `d:\dev\app` vs `D:\dev\app`). `normalizeFilename` compares the configured + * `root` against Vite-resolved module ids via `commonAncestorPath`, which is + * case-sensitive. When the two disagree on case at the first path segment (a + * Windows drive letter, or the leading directory on macOS) `commonAncestorPath` + * returns `''`, so the absolute id is no longer recognized as project-internal + * and gets rewritten to a bogus path. That misses the compile-metadata cache and + * strips the component's scoped ` diff --git a/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts b/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts new file mode 100644 index 000000000000..ff1b64f44d90 --- /dev/null +++ b/packages/astro/test/units/vite-plugin-utils/normalize-filename.test.ts @@ -0,0 +1,45 @@ +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { normalizeFilename } from '../../../dist/vite-plugin-utils/index.js'; + +// Build a fixture path that is absolute on both POSIX and Windows. On POSIX, +// `path.resolve('/Users/me/project')` is `/Users/me/project`; on Windows it +// becomes something like `D:\\Users\\me\\project` (the CWD drive gets +// prepended). Using this lets tests that pass the resolved path to Node's URL +// machinery behave identically on both platforms. +const projectRoot = path.resolve('/Users/me/project'); +const projectRootUrl = pathToFileURL(projectRoot + path.sep); +// `normalizeFilename` returns paths with forward slashes (it runs the result +// through `viteID`/`slash`), so build expectations the same way. +const projectRootSlash = projectRoot.replaceAll(path.sep, '/'); + +describe('normalizeFilename', () => { + it('strips the /@fs prefix from filesystem paths', () => { + const root = pathToFileURL('/Users/me/project/'); + const result = normalizeFilename('/@fs/Users/me/project/src/pages/index.astro', root); + assert.equal(result, '/Users/me/project/src/pages/index.astro'); + }); + + it('resolves relative paths against root', () => { + const result = normalizeFilename('./src/components/Foo.astro', projectRootUrl); + assert.equal(result, `${projectRootSlash}/src/components/Foo.astro`); + }); + + it('preserves absolute paths that live inside root', () => { + const root = pathToFileURL('/Users/me/project/'); + const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root); + assert.equal(result, '/Users/me/project/src/pages/index.astro'); + }); + + it('preserves absolute paths when their case differs from root (issue #14013)', () => { + // Reproduces the case-insensitive filesystem scenario (Windows or macOS) where + // the user starts the dev server from a path whose case differs from disk. + // `root` comes from process.cwd() with one case, but Vite resolves modules with + // the canonical filesystem case. The two must still be treated as the same path. + const root = pathToFileURL('/users/me/project/'); + const result = normalizeFilename('/Users/me/project/src/pages/index.astro', root); + assert.equal(result, '/Users/me/project/src/pages/index.astro'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d32c150382..604b8d3e3f57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2992,6 +2992,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/css-path-case: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/css-pure-chunk-query-params: dependencies: '@astrojs/vue': From 556b0135a5b19bdf9d3cec51fb73367e9f4c7e9a Mon Sep 17 00:00:00 2001 From: Kai <42517005+kailauber@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:31:37 +0200 Subject: [PATCH 05/10] docs(astro): fix `allows to` grammar in two source comments (#16959) --- packages/astro/src/core/errors/dev/vite.ts | 2 +- packages/astro/src/core/routing/rewrite.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts index 7dce1b13bc8b..d3cfcc352e07 100644 --- a/packages/astro/src/core/errors/dev/vite.ts +++ b/packages/astro/src/core/errors/dev/vite.ts @@ -166,7 +166,7 @@ export async function getViteErrorPayload(err: ErrorWithMetadata): Promise Date: Wed, 3 Jun 2026 22:34:51 +0900 Subject: [PATCH 06/10] fix(routing): preserve .html in pathname for endpoint routes with dynamic params and solve conflicts (#16958) Co-authored-by: astrobot-houston --- .changeset/fix-endpoint-html-params.md | 5 ++++ packages/astro/src/core/fetch/fetch-state.ts | 9 ++++--- packages/astro/test/units/fetch/index.test.ts | 24 ++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-endpoint-html-params.md diff --git a/.changeset/fix-endpoint-html-params.md b/.changeset/fix-endpoint-html-params.md new file mode 100644 index 000000000000..f0f5fe88ff9b --- /dev/null +++ b/.changeset/fix-endpoint-html-params.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a bug where static file endpoints using `getStaticPaths` with `.html` in dynamic param values (e.g. `{ path: 'file.html' }`) would fail with a `NoMatchingStaticPathFound` error during build. The `.html` suffix is no longer incorrectly stripped from endpoint route pathnames. diff --git a/packages/astro/src/core/fetch/fetch-state.ts b/packages/astro/src/core/fetch/fetch-state.ts index f888f79a404c..80f9ca039be0 100644 --- a/packages/astro/src/core/fetch/fetch-state.ts +++ b/packages/astro/src/core/fetch/fetch-state.ts @@ -773,11 +773,14 @@ export class FetchState implements AstroFetchState { */ /** * Strip `.html` / `/index.html` suffixes from the pathname so the - * rendering pipeline sees the canonical route path. Skipped when the - * matched route itself has an `.html` extension in its definition. + * rendering pipeline sees the canonical route path. Only applies to + * page routes where `.html` is framework-injected. Endpoint routes + * preserve `.html` because any such suffix is user-provided (e.g. + * from `getStaticPaths` params). Skipped when the matched route + * itself has an `.html` extension in its definition. */ #stripHtmlExtension(): void { - if (this.routeData && !routeHasHtmlExtension(this.routeData)) { + if (this.routeData && this.routeData.type === 'page' && !routeHasHtmlExtension(this.routeData)) { this.pathname = this.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); } } diff --git a/packages/astro/test/units/fetch/index.test.ts b/packages/astro/test/units/fetch/index.test.ts index 1bf745f7b6e0..3b37f6410ee6 100644 --- a/packages/astro/test/units/fetch/index.test.ts +++ b/packages/astro/test/units/fetch/index.test.ts @@ -14,7 +14,7 @@ import { import { ALL_PIPELINE_FEATURES } from '../../../dist/core/base-pipeline.js'; import { createComponent, render } from '../../../dist/runtime/server/index.js'; import { createEndpoint, createPage, createRedirect, createTestApp } from '../mocks.ts'; -import { dynamicPart } from '../routing/test-helpers.ts'; +import { dynamicPart, spreadPart } from '../routing/test-helpers.ts'; /** A simple page component that renders `

Hello

`. */ const simplePage = createComponent((_result: any, _props: any, _slots: any) => { @@ -90,6 +90,28 @@ describe('FetchState (astro/fetch)', () => { assert.ok(state.routeData, 'routeData should fall back to the 404 route'); assert.equal(state.routeData!.route, '/404'); }); + + it('preserves .html in pathname for endpoint routes with dynamic params', () => { + // Regression test for #16941: when a dynamic endpoint returns a param + // value like `file.html`, the `.html` suffix must not be stripped from + // the pathname. Only page routes should have `.html` stripped (it is + // framework-injected there), but for endpoints the suffix is user-provided. + const endpoint = createEndpoint( + { GET: () => new Response('ok') }, + { + route: '/[...path]', + pathname: undefined, + segments: [[spreadPart('path')]], + }, + ); + const app = createTestApp([endpoint]); + const request = stampApp(new Request('http://example.com/file.html'), app); + const state = new FetchState(request); + + assert.ok(state.routeData, 'routeData should be set'); + assert.equal(state.routeData!.type, 'endpoint'); + assert.equal(state.pathname, '/file.html', '.html should be preserved for endpoint routes'); + }); }); // #endregion From 1adb8763979973664bedadfe9bed9a4548bfb56f Mon Sep 17 00:00:00 2001 From: fkatsuhiro Date: Wed, 3 Jun 2026 13:38:58 +0000 Subject: [PATCH 07/10] [ci] format --- packages/astro/src/core/fetch/fetch-state.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/core/fetch/fetch-state.ts b/packages/astro/src/core/fetch/fetch-state.ts index 80f9ca039be0..e072f65a8f57 100644 --- a/packages/astro/src/core/fetch/fetch-state.ts +++ b/packages/astro/src/core/fetch/fetch-state.ts @@ -780,7 +780,11 @@ export class FetchState implements AstroFetchState { * itself has an `.html` extension in its definition. */ #stripHtmlExtension(): void { - if (this.routeData && this.routeData.type === 'page' && !routeHasHtmlExtension(this.routeData)) { + if ( + this.routeData && + this.routeData.type === 'page' && + !routeHasHtmlExtension(this.routeData) + ) { this.pathname = this.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); } } From 16d49b694071be212fb8c5a141ade72e8717a30e Mon Sep 17 00:00:00 2001 From: Tom Callahan <109816146+thomas-callahan-collibra@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:41:54 -0400 Subject: [PATCH 08/10] Fix issue with dynamic routes in complex projects using workerd (#16720) Co-authored-by: Emanuele Stoppa --- .changeset/lazy-jeans-brake.md | 5 +++++ packages/astro/src/runtime/server/render/util.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/lazy-jeans-brake.md diff --git a/.changeset/lazy-jeans-brake.md b/.changeset/lazy-jeans-brake.md new file mode 100644 index 000000000000..d49080a13bb1 --- /dev/null +++ b/.changeset/lazy-jeans-brake.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fix an issue where dynamic routes would return the string `[object Object]` instead of the expected content, in certain runtimes. diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index a2f30862eb19..f07c6aacb9f9 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -274,8 +274,9 @@ export interface RendererFlusher { flush(): void | Promise; } -export const isNode = - typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]'; +export const isNode = typeof process !== "undefined" + && Object.prototype.toString.call(process) === "[object process]" + && !(typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"); // @ts-expect-error: Deno is not part of the types. export const isDeno = typeof Deno !== 'undefined'; From 1b39ae8485406937501d8a734afe2a464d671064 Mon Sep 17 00:00:00 2001 From: Naren <41925131+narendraio@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:12:25 +0530 Subject: [PATCH 09/10] fix(astro): guard App.match() against malformed request URIs (#16926) Co-authored-by: ematipico Co-authored-by: Emanuele Stoppa --- .changeset/fix-app-match-malformed-uri.md | 5 ++ packages/astro/src/core/app/base.ts | 33 ++++++--- .../test/units/app/malformed-uri.test.ts | 68 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-app-match-malformed-uri.md create mode 100644 packages/astro/test/units/app/malformed-uri.test.ts diff --git a/.changeset/fix-app-match-malformed-uri.md b/.changeset/fix-app-match-malformed-uri.md new file mode 100644 index 000000000000..e6858fde45e5 --- /dev/null +++ b/.changeset/fix-app-match-malformed-uri.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Prevents `App.match()` from throwing on request paths that contain an invalid percent-sequence. diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 37f2b9e69a99..aa5670c7a8d7 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -261,20 +261,35 @@ export abstract class BaseApp

{ } /** - * Extracts the base-stripped, decoded pathname from a request. - * Used by adapters to compute the pathname for dev-mode route matching. + * Decodes a pathname with `decodeURI`, falling back to the raw pathname when it + * contains an invalid percent-sequence (e.g. `%C0%AF`, an overlong-UTF-8 encoding of + * `/` commonly sent by path-traversal scanners). A raw `decodeURI()` would throw + * `URIError: URI malformed`, and because `match()` runs before `render()` that error + * escapes the adapter's request handler as an uncaught exception (HTTP 500) that user + * middleware can't catch. */ - public getPathnameFromRequest(request: Request): string { - const url = new URL(request.url); - const pathname = prependForwardSlash(this.removeBase(url.pathname)); + private safeDecodeURI(pathname: string): string { try { return decodeURI(pathname); } catch (e: any) { - this.adapterLogger.error(e.toString()); + // Malformed request paths are expected client input (commonly from automated + // scanners) rather than a server fault, and this runs per-request on the hot + // path. Log at `debug` so it stays diagnosable without flooding error logs. + this.adapterLogger.debug(e.toString()); return pathname; } } + /** + * Extracts the base-stripped, decoded pathname from a request. + * Used by adapters to compute the pathname for dev-mode route matching. + */ + public getPathnameFromRequest(request: Request): string { + const url = new URL(request.url); + const pathname = prependForwardSlash(this.removeBase(url.pathname)); + return this.safeDecodeURI(pathname); + } + /** * Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered * routes aren't returned, even if they are matched. @@ -291,7 +306,7 @@ export abstract class BaseApp

{ if (!pathname) { pathname = prependForwardSlash(this.removeBase(url.pathname)); } - const routeData = this.pipeline.matchRoute(decodeURI(pathname)); + const routeData = this.pipeline.matchRoute(this.safeDecodeURI(pathname)); if (!routeData) return undefined; if (allowPrerenderedRoutes) { return routeData; @@ -303,7 +318,7 @@ export abstract class BaseApp

{ // the same pattern should handle all other URLs. if (routeData.prerender) { if (routeData.params.length > 0) { - const allMatches = this.pipeline.matchAllRoutes(decodeURI(pathname)); + const allMatches = this.pipeline.matchAllRoutes(this.safeDecodeURI(pathname)); return allMatches.find((r) => !r.prerender); } return undefined; @@ -444,7 +459,7 @@ export abstract class BaseApp

{ if (!routeData) { const domainPathname = this.computePathnameFromDomain(request); if (domainPathname) { - routeData = this.pipeline.matchRoute(decodeURI(domainPathname)); + routeData = this.pipeline.matchRoute(this.safeDecodeURI(domainPathname)); } } const resolvedOptions: ResolvedRenderOptions = { diff --git a/packages/astro/test/units/app/malformed-uri.test.ts b/packages/astro/test/units/app/malformed-uri.test.ts new file mode 100644 index 000000000000..724e99e3c9b2 --- /dev/null +++ b/packages/astro/test/units/app/malformed-uri.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { App } from '../../../dist/core/app/app.js'; +import { parseRoute } from '../../../dist/core/routing/parse-route.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createManifest, createRouteInfo } from './test-helpers.ts'; + +/** + * Tests that a request path containing an invalid percent-sequence (one that is not + * valid UTF-8, e.g. `%C0%AF`) does not crash route matching. + * + * `App.match()` decodes the pathname with `decodeURI()`, which throws + * `URIError: URI malformed` on such input. Because matching happens before + * `App.render()`, that error used to escape the adapter request handler as an + * uncaught exception (HTTP 500) that user middleware could not catch. These paths + * are extremely common from automated path-traversal / `.env` scanners. + */ + +const routeOptions: Parameters[1] = { + config: { base: '/', trailingSlash: 'ignore' }, + pageExtensions: [], +} as any; + +const indexRouteData = parseRoute('index.astro', routeOptions, { + component: 'src/pages/index.astro', +}); + +const page = createComponent((_result: any, _props: any, _slots: any) => { + return render`

Page

`; +}); + +const pageModule = async () => ({ + page: async () => ({ + default: page, + }), +}); + +const pageMap = new Map([[indexRouteData.component, pageModule]]); + +const app = new App( + createManifest({ + routes: [createRouteInfo(indexRouteData)], + pageMap: pageMap as any, + }) as any, +); + +describe('Malformed URI handling in App.match', () => { + it('match() does not throw on an invalid percent-sequence', () => { + const request = new Request('http://example.com/%C0%AF'); + assert.doesNotThrow(() => app.match(request)); + assert.equal(app.match(request), undefined, 'no route should match a malformed path'); + }); + + it('render() returns a 404 for a malformed percent-sequence', async () => { + const request = new Request('http://example.com/%C0%AF'); + const response = await app.render(request); + assert.equal( + response.status, + 404, + 'a malformed path must resolve to a normal 404, not an uncaught 500', + ); + }); + + it('valid routes still match', () => { + const request = new Request('http://example.com/'); + assert.ok(app.match(request), '/ should still match'); + }); +}); From 29b01ee376875235417e117281056684e338b634 Mon Sep 17 00:00:00 2001 From: Naren Date: Wed, 3 Jun 2026 13:56:54 +0000 Subject: [PATCH 10/10] [ci] format --- packages/astro/src/runtime/server/render/util.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index f07c6aacb9f9..d56fc8be0efd 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -274,9 +274,10 @@ export interface RendererFlusher { flush(): void | Promise; } -export const isNode = typeof process !== "undefined" - && Object.prototype.toString.call(process) === "[object process]" - && !(typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"); +export const isNode = + typeof process !== 'undefined' && + Object.prototype.toString.call(process) === '[object process]' && + !(typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'); // @ts-expect-error: Deno is not part of the types. export const isDeno = typeof Deno !== 'undefined';