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/bumpy-drinks-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a CSP violation when using both `security.csp` and `experimental.clientPrerender` with `data-astro-prefetch` links. The dynamically injected `<script type="speculationrules">` now uses a static `"source": "document"` approach with a CSS selector, producing a deterministic payload that is hashed and included in the CSP `script-src` directive at build time.
5 changes: 5 additions & 0 deletions .changeset/fix-cloudflare-peer-dep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes the `astro` peer dependency range from `^7.0.0` to `^7.2.0`. The adapter imports symbols (`beginContentEntryCollection`, `beginImageCollection`, `endContentEntryCollection`, `endImageCollection`) from `astro/app` that were added in Astro 7.2.0, so earlier versions fail at build time with a `MISSING_EXPORT` error.
5 changes: 5 additions & 0 deletions .changeset/fix-dynamic-redirect-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/underscore-redirects': patch
---

Fixes dynamic redirect routes to honour user-configured status codes instead of hardcoding 301. Previously, a redirect configured with `{ destination: '/new', status: 302 }` would be emitted as 301 in the `_redirects` file when the route was dynamic.
5 changes: 5 additions & 0 deletions .changeset/hungry-tips-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a regression where content collection `reference()` fields silently accepted entry IDs that don't exist, such as an ID that doesn't match a loader's slugified version of it. Astro now logs an error for references that point to a missing entry after all loaders finish syncing.
5 changes: 5 additions & 0 deletions .changeset/lucky-icons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/language-server': patch
---

Fixes the missing "Add all missing imports" and "Add import from" quick fixes for Astro components
8 changes: 4 additions & 4 deletions examples/basics/src/components/Welcome.astro
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ import background from '../assets/background.svg';
</section>
</main>

<a href="https://astro.build/blog/astro-6-beta/" id="news" class="box">
<a href="https://astro.build/blog/astro-7/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 6.0?</h2>
<h2>What's New in Astro 7.0?</h2>
<p>
Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to
explore Astro 6.0's new features.
Rust-powered compiler, advanced routing, AI agent support, and more! Click to explore Astro
7.0's new features.
</p>
</a>
</div>
Expand Down
74 changes: 74 additions & 0 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export class ContentLayer {
}
}),
);
this.#validateReferences(contentConfig.config.collections, logger);
await fs.mkdir(this.#settings.config.cacheDir, { recursive: true });
await fs.mkdir(this.#settings.dotAstroDir, { recursive: true });
const assetImportsFile = new URL(ASSET_IMPORTS_FILE, this.#settings.dotAstroDir);
Expand All @@ -360,6 +361,79 @@ export class ContentLayer {
}
}

/**
* After all loaders complete, walks every entry's data to find reference objects
* (`{ id, collection }`) and checks that the referenced entry exists in the store.
* This replaces the inline Zod validation that was removed in the Zod 4 upgrade.
*/
#validateReferences(collections: Record<string, any>, logger: { error(message: string): void }) {
const collectionNames = new Set(Object.keys(collections));
for (const collectionName of collectionNames) {
for (const entry of this.#store.values(collectionName)) {
if (entry?.data) {
this.#findInvalidReferences(
entry.data,
collectionNames,
collectionName,
entry.id,
logger,
'',
);
}
}
}
}

#findInvalidReferences(
value: unknown,
collectionNames: Set<string>,
ownerCollection: string,
ownerId: string,
logger: { error(message: string): void },
path: string,
) {
if (value == null || typeof value !== 'object') return;

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
this.#findInvalidReferences(
value[i],
collectionNames,
ownerCollection,
ownerId,
logger,
`${path}[${i}]`,
);
}
return;
}

const obj = value as Record<string, unknown>;
// A reference object has `id` (or `slug`) and `collection` string fields
if (typeof obj.collection === 'string' && collectionNames.has(obj.collection)) {
const refId =
typeof obj.id === 'string' ? obj.id : typeof obj.slug === 'string' ? obj.slug : undefined;
if (refId !== undefined && !this.#store.has(obj.collection, refId)) {
const fieldPath = path ? ` (field: ${path})` : '';
logger.error(
`Invalid content reference: entry "${ownerId}" in collection "${ownerCollection}"${fieldPath} references "${refId}" in collection "${obj.collection}", but that entry does not exist.`,
);
}
return;
}

for (const [key, val] of Object.entries(obj)) {
this.#findInvalidReferences(
val,
collectionNames,
ownerCollection,
ownerId,
logger,
path ? `${path}.${key}` : key,
);
}
}

async regenerateCollectionFileManifest() {
const collectionsManifest = new URL(COLLECTIONS_MANIFEST_FILE, this.#settings.dotAstroDir);
this.#logger.debug('content', 'Regenerating collection file manifest');
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export type SSRManifestCSP = {
resources: CspResourceEntry[];
hashes: CspHashEntry[];
};
/** Static speculation rules JSON to inject in the head when CSP + clientPrerender are both enabled. */
speculationRulesContent?: string;
};

export interface SSRManifestSession extends BaseSessionConfig {
Expand Down
18 changes: 17 additions & 1 deletion packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
trackStyleHashes,
} from '../../csp/common.js';
import { partitionByKind } from '../../csp/runtime.js';
import { encodeKey } from '../../encryption.js';
import { generateSpeculationRulesContent } from '../../../prefetch/speculation-rules.js';
import { encodeKey, generateCspDigest } from '../../encryption.js';
import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js';
import { DEFAULT_COMPONENTS } from '../../routing/default.js';
import { getOutFile, getOutFolder } from '../common.js';
Expand Down Expand Up @@ -324,6 +325,20 @@ async function buildManifest(
...(await trackStyleHashes(internals, settings, algorithm)),
];

// When both CSP and clientPrerender are enabled, generate a static speculation rules
// script whose hash can be included in the CSP policy. Dynamic per-URL injection would
// produce unpredictable hashes that cannot be whitelisted at build time.
let speculationRulesContent: string | undefined;
if (settings.config.experimental.clientPrerender && settings.config.prefetch) {
const prefetchAll =
typeof settings.config.prefetch === 'object'
? (settings.config.prefetch.prefetchAll ?? false)
: false;
speculationRulesContent = generateSpeculationRulesContent(prefetchAll);
const speculationRulesHash = await generateCspDigest(speculationRulesContent, algorithm);
scriptHashes.push(speculationRulesHash);
}

const scriptDirective = {
resources: getScriptResources(cspConfig),
hashes: scriptHashes,
Expand All @@ -348,6 +363,7 @@ async function buildManifest(
styleResources: styleDefault.resources,
scriptDirective,
styleDirective,
speculationRulesContent,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ export class FetchState implements AstroFetchState {
resources: manifest.csp?.styleDirective ? [...manifest.csp.styleDirective.resources] : [],
hashes: manifest.csp?.styleDirective ? [...manifest.csp.styleDirective.hashes] : [],
},
speculationRulesContent: manifest.csp?.speculationRulesContent,
internalFetchHeaders: manifest.internalFetchHeaders,
};

Expand Down
36 changes: 34 additions & 2 deletions packages/astro/src/prefetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,15 @@ export function prefetch(url: string, opts?: PrefetchOptions) {
if (!canPrefetchUrl(url, ignoreSlowConnection)) return;
prefetchedUrls.add(url);

// Prefetch with speculationrules if `clientPrerender` is enabled and supported
// Prefetch with speculationrules if `clientPrerender` is enabled and supported.
// Skip dynamic injection when a static document-source speculation rules script is already
// in the page (injected at build time for CSP compatibility).
// NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value
if (clientPrerender && HTMLScriptElement.supports?.('speculationrules')) {
if (
clientPrerender &&
HTMLScriptElement.supports?.('speculationrules') &&
!hasStaticSpeculationRules()
) {
debug?.(`[astro] Prefetching ${url} with <script type="speculationrules">`);
appendSpeculationRules(url, opts?.eagerness ?? 'immediate');
}
Expand Down Expand Up @@ -340,6 +346,32 @@ function onPageLoad(cb: () => void) {
}).observe(document.body, { childList: true, subtree: true });
}

/** Cached result of static speculation rules detection. */
let _hasStaticRules: boolean | undefined;

/**
* Returns `true` when a `<script type="speculationrules">` using `"source": "document"`
* is already present in the page (injected server-side for CSP compatibility).
* The browser handles URL matching via CSS selectors in that case, so per-URL
* dynamic injection is unnecessary.
*/
function hasStaticSpeculationRules(): boolean {
if (_hasStaticRules === undefined) {
_hasStaticRules = Array.from(document.querySelectorAll('script[type="speculationrules"]')).some(
(el) => {
try {
const rules = JSON.parse(el.textContent ?? '');
const entries = [...(rules.prerender ?? []), ...(rules.prefetch ?? [])];
return entries.some((entry: any) => entry.source === 'document');
} catch {
return false;
}
},
);
}
return _hasStaticRules;
}

/**
* Appends a `<script type="speculationrules">` tag to the head of the
* document that prerenders the `url` passed in.
Expand Down
24 changes: 24 additions & 0 deletions packages/astro/src/prefetch/speculation-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Generates static speculation rules JSON using `"source": "document"` with CSS selector matching.
* This produces a deterministic payload that can be hashed at build time for CSP compatibility,
* unlike the dynamic per-URL `"source": "list"` approach in `appendSpeculationRules()`.
*/
export function generateSpeculationRulesContent(prefetchAll: boolean): string {
const selector = prefetchAll ? 'a' : 'a[data-astro-prefetch]';
return JSON.stringify({
prerender: [
{
source: 'document',
where: { selector_matches: selector },
eagerness: 'moderate',
},
],
prefetch: [
{
source: 'document',
where: { selector_matches: selector },
eagerness: 'moderate',
},
],
});
}
13 changes: 13 additions & 0 deletions packages/astro/src/runtime/server/render/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ export function renderAllHeadContent(result: SSRResult) {
const sep = result.compressHTML === true || result.compressHTML === 'jsx' ? '' : '\n';
content += styles.join(sep) + links.join(sep) + scripts.join(sep);

// Inject static speculation rules when CSP + clientPrerender are both enabled.
// The content is pre-hashed at build time and included in the CSP script-src directive.
if (result.speculationRulesContent) {
content += renderElement(
'script',
{
props: { type: 'speculationrules' },
children: result.speculationRulesContent,
},
false,
);
}

content += result._metadata.extraHead.join('');

return markHTMLString(content);
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/types/public/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ export interface SSRResult {
isStrictDynamic: SSRManifestCSP['isStrictDynamic'];
scriptDirective: SSRManifestCSP['scriptDirective'];
styleDirective: SSRManifestCSP['styleDirective'];
/** Static speculation rules JSON to inject in the head when CSP + clientPrerender are both enabled. */
speculationRulesContent?: string;
internalFetchHeaders?: Record<string, string>;
}

Expand Down
Loading
Loading