Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-preferred-locale-quality-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `Astro.preferredLocale` and `Astro.preferredLocaleList` ignoring `Accept-Language` quality values when they are absent or `0`. An entry without an explicit `q=` now correctly counts as quality `1.0` (per RFC 7231) and an entry with `q=0` is treated as not acceptable, so the highest-quality locale is selected regardless of header order.
5 changes: 5 additions & 0 deletions .changeset/gold-bugs-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a type error when passing an image from a content collection `image()` schema to a component or `<Image />`. The schema returned by `image()` was missing the `apng` format, so it no longer matched the type of an imported image.
5 changes: 5 additions & 0 deletions .changeset/long-tips-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `memoryCache()` storing responses that set cookies through `Astro.cookies` or `Astro.session`
5 changes: 5 additions & 0 deletions .changeset/modern-canyons-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `server:defer` crashing the dev server with "undefined is not a function" when a deferred component imports from `astro:i18n`
5 changes: 5 additions & 0 deletions .changeset/tidy-pears-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves the performance of the Astro CLI in local by enabling Node's module compilation cache.
5 changes: 5 additions & 0 deletions .changeset/tricky-hornets-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes dep scanning failure when `.astro` frontmatter contains regex literals with quote characters (e.g. `/"/g`)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Visit our [official documentation](https://docs.astro.build/).

## Support

Having trouble? Get help in the official [Astro Discord](https://astro.build/chat).
Having trouble? Get help in the official [Astro Discord](https://astro.build/chat) or via [GitHub discussions](https://github.com/withastro/community-support/discussions).

## Contributing

Expand Down
15 changes: 15 additions & 0 deletions packages/astro/bin/astro.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
#!/usr/bin/env node
'use strict';

import module from 'node:module';

// In CI writing the cache is (most of the time) harmful, as it'll never get re-used and just slows down the CLI.
if (!process.env.CI) {
try {
module.enableCompileCache?.();
// Long-running commands like `astro dev` never reach the flush that happens on process exit.
setTimeout(() => {
try {
module.flushCompileCache?.();
} catch {}
}, 10_000).unref();
} catch {}
}

const CI_INSTRUCTIONS = {
NETLIFY: 'https://docs.netlify.com/configure-builds/manage-dependencies/#node-js-and-javascript',
GITHUB_ACTIONS:
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type ImageFunction = () => z.ZodObject<{
zCore.$ZodLiteral<'gif'>,
zCore.$ZodLiteral<'svg'>,
zCore.$ZodLiteral<'avif'>,
zCore.$ZodLiteral<'apng'>,
]
>;
}>;
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/core/cache/memory-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import picomatch from 'picomatch';
import type { AstroCookies } from '../cookies/cookies.js';
import { getCookiesFromResponse } from '../cookies/response.js';
import { AstroError } from '../errors/errors.js';
import { CacheQueryConfigConflict } from '../errors/errors-data.js';
import type { CacheProvider, CacheProviderFactory, InvalidateOptions } from './types.js';
Expand Down Expand Up @@ -255,8 +257,13 @@ function matchesVary(request: Request, entry: CachedEntry): boolean {
return true;
}

function hasAtLeastOneCookie(cookies: AstroCookies | undefined): boolean {
return cookies ? !cookies.headers().next().done : false;
}

function hasSetCookieHeader(response: Response): boolean {
return response.headers.has('set-cookie');
if (response.headers.has('set-cookie')) return true;
return hasAtLeastOneCookie(getCookiesFromResponse(response));
}

function warnSkippedSetCookie(url: URL): void {
Expand Down
9 changes: 5 additions & 4 deletions packages/astro/src/i18n/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: Local
return true;
})
.sort((a, b) => {
if (a.qualityValue && b.qualityValue) {
return Math.sign(b.qualityValue - a.qualityValue);
}
return 0;
// An absent `q=` means quality 1.0 (RFC 7231), while a bare `*` defaults
// to 0 so it is never ranked above a real locale.
const qa = a.locale === '*' ? (a.qualityValue ?? 0) : (a.qualityValue ?? 1);
const qb = b.locale === '*' ? (b.qualityValue ?? 0) : (b.qualityValue ?? 1);
return qb - qa;
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/vite-plugin-routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export default async function astroPluginRoutes({
});

const code = `
import { deserializeRouteInfo } from 'astro/app';
import { deserializeRouteInfo } from 'astro/app/manifest';
const serializedData = ${JSON.stringify(filteredRoutes)};
const routes = serializedData.map(deserializeRouteInfo);
export { routes };
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/test/types/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { SessionDriverConfigSchema } from '../../dist/core/session/config.j
import type { SessionDriverConfig } from '../../dist/core/session/types.js';
import type { SvgOptimizer } from '../../dist/assets/svg/types.js';
import type { SvgOptimizerSchema } from '../../dist/assets/svg/config.js';
import type { ImageInputFormat } from '../../dist/assets/types.js';
import type { SchemaContext } from '../../dist/content/config.js';

describe('fonts', () => {
it('FontFamily type matches FontFamilySchema', () => {
Expand Down Expand Up @@ -44,3 +46,10 @@ describe('svgOptimizer', () => {
expectTypeOf<z.input<typeof SvgOptimizerSchema>>().toEqualTypeOf<SvgOptimizer>();
});
});

describe('content image()', () => {
it('image() schema format matches ImageInputFormat', () => {
type ImageSchema = ReturnType<SchemaContext['image']>;
expectTypeOf<z.output<ImageSchema>['format']>().toEqualTypeOf<ImageInputFormat>();
});
});
83 changes: 83 additions & 0 deletions packages/astro/test/units/cache/app-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import sessionMemoryDriver from 'unstorage/drivers/memory';
import memoryProvider from '../../../dist/core/cache/memory-provider.js';
import { createComponent, render, renderHead } from '../../../dist/runtime/server/index.js';
import { createEndpoint, createPage, createTestApp } from '../mocks.ts';
Expand All @@ -12,6 +13,14 @@ function createCacheManifestOverrides() {
};
}

function createSessionCacheManifestOverrides() {
return {
...createCacheManifestOverrides(),
sessionConfig: { driver: 'memory', cookie: 'astro-session' },
sessionDriver: async () => ({ default: () => sessionMemoryDriver() }),
};
}

// #region Route factories

function cachedEndpoint() {
Expand Down Expand Up @@ -73,6 +82,33 @@ function withCookieEndpoint() {
);
}

function withAstroCookieEndpoint() {
return createEndpoint(
{
GET: (ctx: APIContext) => {
ctx.cache.set({ maxAge: 300, tags: ['astro-cookie'] });
ctx.cookies.set('session', 'test', { path: '/', httpOnly: true });
return Response.json({ nonce: Math.random() });
},
},
{ route: '/with-astro-cookie' },
);
}

function withSessionEndpoint() {
return createEndpoint(
{
GET: async (ctx: APIContext) => {
ctx.cache.set({ maxAge: 300, tags: ['session'] });
const count = ((await ctx.session!.get<number>('count')) ?? 0) + 1;
ctx.session!.set('count', count);
return Response.json({ count, nonce: Math.random() });
},
},
{ route: '/with-session' },
);
}

function invalidateEndpoint() {
return createEndpoint(
{
Expand Down Expand Up @@ -269,6 +305,53 @@ describe('context.cache through App pipeline', () => {
assert.notEqual(firstBody.nonce, secondBody.nonce);
});

it('does not cache responses that set Astro cookies', async () => {
const overrides = createCacheManifestOverrides();
const app = createTestApp([withAstroCookieEndpoint()], overrides);

const first = await app.render(new Request('http://localhost/with-astro-cookie'), {
addCookieHeader: true,
});
assert.equal(first.headers.get('X-Astro-Cache'), null);
assert.ok(first.headers.get('Set-Cookie'));
const firstBody = await first.json();

const second = await app.render(new Request('http://localhost/with-astro-cookie'), {
addCookieHeader: true,
});
assert.equal(second.headers.get('X-Astro-Cache'), null);
assert.ok(second.headers.get('Set-Cookie'));
const secondBody = await second.json();

assert.notEqual(firstBody.nonce, secondBody.nonce);
});

it('does not cache responses that set an Astro session', async () => {
const overrides = createSessionCacheManifestOverrides();
const app = createTestApp([withSessionEndpoint()], overrides);

const first = await app.render(new Request('http://localhost/with-session'), {
addCookieHeader: true,
});
assert.equal(first.headers.get('X-Astro-Cache'), null);
const cookie = first.headers.get('Set-Cookie');
assert.ok(cookie);
const firstBody = await first.json();
assert.equal(firstBody.count, 1);

const second = await app.render(
new Request('http://localhost/with-session', {
headers: { Cookie: cookie.split(';', 1)[0] },
}),
{ addCookieHeader: true },
);
assert.equal(second.headers.get('X-Astro-Cache'), null);
const secondBody = await second.json();

assert.equal(secondBody.count, 2);
assert.notEqual(firstBody.nonce, secondBody.nonce);
});

it('normalizes query parameter order (sorting)', async () => {
const overrides = createCacheManifestOverrides();
const app = createTestApp([cachedEndpoint()], overrides);
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/test/units/i18n/i18n-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ describe('computePreferredLocale', () => {
assert.equal(computePreferredLocale(req, locales), 'fr');
});

it('prefers an implicit q=1 entry over a lower explicit-q entry regardless of header order', () => {
const req = new Request('http://example.com/', {
headers: { 'Accept-Language': 'en;q=0.7, de' },
});
assert.equal(computePreferredLocale(req, ['en', 'fr', 'de']), 'de');
});

it('excludes a q=0 entry from winning', () => {
const req = new Request('http://example.com/', {
headers: { 'Accept-Language': 'de;q=0, en;q=0.5' },
});
assert.equal(computePreferredLocale(req, ['en', 'fr', 'de']), 'en');
});

it('returns undefined when no match', () => {
const req = new Request('http://example.com/', {
headers: { 'Accept-Language': 'de,ja' },
Expand Down
50 changes: 50 additions & 0 deletions packages/astro/test/units/routing/routes-virtual-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import astroPluginRoutes from '../../../dist/vite-plugin-routes/index.js';
import { defaultLogger, createBasicSettings } from '../test-utils.ts';
import { makeRoute, staticPart } from './test-helpers.ts';

describe('astro:routes virtual module', () => {
it('imports deserializeRouteInfo from astro/app/manifest to avoid a circular dependency through the barrel', async () => {
const settings = await createBasicSettings();
const route = makeRoute({
segments: [[staticPart('')]],
trailingSlash: 'ignore',
route: '/',
pathname: '/',
});
const routesList = { routes: [route] };

const plugin = await astroPluginRoutes({
settings,
logger: defaultLogger,
routesList,
command: 'dev',
});

const loadHook = plugin.load;
const handler = typeof loadHook === 'function' ? loadHook : loadHook?.handler;
assert.ok(handler, 'plugin should have a load handler');

// Call the handler with a mock `this` providing the environment name.
// Only `environment.name` is accessed; cast to satisfy the type checker.
const rawResult = handler.call(
{ environment: { name: 'astro' } } as any,
'\0virtual:astro:routes',
);
const result = await rawResult;

assert.ok(result, 'load handler should return a result');
const code = typeof result === 'string' ? result : result.code;

assert.ok(
code.includes("from 'astro/app/manifest'"),
`Generated code should import from 'astro/app/manifest', not from 'astro/app' barrel. Got:\n${code}`,
);
assert.ok(
!code.includes("from 'astro/app';"),
`Generated code should NOT import from 'astro/app' barrel (circular dependency). Got:\n${code}`,
);
});
});

This file was deleted.

Loading
Loading