UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

242 lines (241 loc) • 9.88 kB
import { buildImporterGraphFromModuleInfo, computeInTreeAncestors } from "../core/head-propagation/graph.js"; import { getTopLevelPageModuleInfos } from "../core/build/graph.js"; import { PROPAGATED_ASSET_FLAG } from "../content/consts.js"; import { getAstroMetadata } from "../vite-plugin-astro/index.js"; import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js"; /** * A dev-only virtual module that exposes accumulated component metadata (containsHead, propagation) * as a serialized array that can be statically imported. * * This exists to serve environments that cannot do live module graph traversal at request time — * specifically the non-runnable dev environment (`core/environment/dev-nonrunnable.ts`), used by * adapters like Cloudflare that run requests through their own server runtime rather than Vite's * runner. That environment cannot call `getComponentMetadata()` (which requires a `ModuleLoader`), * so it imports this virtual module instead to get equivalent metadata. * * The runnable dev environment does NOT use this module; it calls `getComponentMetadata()` * directly, which traverses the live Vite module graph and produces more accurate per-request data. * * The virtual module is invalidated whenever metadata propagation runs (on transform, resolveId) * and on file add/unlink, ensuring it stays fresh during HMR. */ const VIRTUAL_COMPONENT_METADATA = "virtual:astro:component-metadata"; const RESOLVED_VIRTUAL_COMPONENT_METADATA = `\0${VIRTUAL_COMPONENT_METADATA}`; export default function configHeadVitePlugin() { // Adapters like `@astrojs/cloudflare` load page modules in the `prerender` // environment; default dev uses `ssr`. Track both so propagation covers either. let environments = []; function findModule(id) { for (const env of environments) { const mod = env.moduleGraph.getModuleById(id); if (mod) return mod; } return undefined; } function invalidateComponentMetadataModule() { for (const env of environments) { const virtualMod = env.moduleGraph.getModuleById(RESOLVED_VIRTUAL_COMPONENT_METADATA); // Clear only the transform result so the next dynamic import() // re-runs load/transform. A full invalidateModule() would cascade // to all importers, which in pre-bundled environments (e.g. // Cloudflare) includes the entire SSR module graph — causing // concurrent requests to observe partially-initialized modules. // See https://github.com/withastro/astro/issues/18132 if (virtualMod?.transformResult) { const etag = virtualMod.transformResult.etag; if (etag) env.moduleGraph.etagToModuleMap.delete(etag); virtualMod.transformResult = null; } } } function buildImporterGraphFromEnvironment(seed) { // Start from one changed/imported module and walk upward to collect ancestors. const queue = [seed]; const collected = new Set(); while (queue.length > 0) { const current = queue.pop(); if (collected.has(current)) continue; collected.add(current); const mod = findModule(current); for (const importer of mod?.importers ?? []) { if (importer.id) { queue.push(importer.id); } } } // Convert Vite's module graph shape into our plain importer adjacency map. return buildImporterGraphFromModuleInfo(collected, (id) => { const mod = findModule(id); if (!mod) return null; return { importers: Array.from(mod.importers).map((importer) => importer.id).filter((moduleId) => !!moduleId), dynamicImporters: [] }; }); } function propagateMetadata(seed, prop, value) { // Example: `HeadEntry -> Layout -> /src/pages/blog.astro` marks both ancestors. const importerGraph = buildImporterGraphFromEnvironment(seed); const allAncestors = computeInTreeAncestors({ seeds: [seed], importerGraph }); for (const id of allAncestors) { const info = this.getModuleInfo(id); if (info?.meta.astro) { const astroMetadata = getAstroMetadata(info); if (astroMetadata) { Reflect.set(astroMetadata, prop, value); } } } invalidateComponentMetadataModule(); } return { name: "astro:head-metadata", enforce: "pre", apply: "serve", configureServer(devServer) { environments = [devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr], devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.prerender]].filter((e) => !!e); devServer.watcher.on("add", invalidateComponentMetadataModule); devServer.watcher.on("unlink", invalidateComponentMetadataModule); devServer.watcher.on("change", invalidateComponentMetadataModule); }, load(id) { if (id !== RESOLVED_VIRTUAL_COMPONENT_METADATA) { return; } const seen = new Set(); const componentMetadataEntries = []; for (const env of environments) { for (const [moduleId, mod] of env.moduleGraph.idToModuleMap) { if (seen.has(moduleId)) continue; const info = this.getModuleInfo(moduleId) ?? (mod.id ? this.getModuleInfo(mod.id) : null); if (!info) continue; const astro = getAstroMetadata(info); if (!astro) continue; seen.add(moduleId); componentMetadataEntries.push([moduleId, { containsHead: astro.containsHead, propagation: astro.propagation }]); } } return { code: `export const componentMetadataEntries = ${JSON.stringify(componentMetadataEntries)};` }; }, resolveId(source, importer) { if (source === VIRTUAL_COMPONENT_METADATA) { return RESOLVED_VIRTUAL_COMPONENT_METADATA; } if (importer) { // Do propagation any time a new module is imported. This is because // A module with propagation might be loaded before one of its parent pages // is loaded, in which case that parent page won't have the in-tree and containsHead // values. Walking up the tree in resolveId ensures that they do return this.resolve(source, importer, { skipSelf: true }).then((result) => { if (result) { let info = this.getModuleInfo(result.id); const astro = info && getAstroMetadata(info); if (astro) { if (astro.propagation === "self" || astro.propagation === "in-tree") { propagateMetadata.call(this, importer, "propagation", "in-tree"); } if (astro.containsHead) { propagateMetadata.call(this, importer, "containsHead", true); } } } return result; }); } }, transform(_source, id) { if (id === RESOLVED_VIRTUAL_COMPONENT_METADATA) { // The virtual module only reports metadata that other modules own, so // transforming it cannot change what it emits. Invalidating it here // would make every evaluation schedule the next one: the invalidation // reaches the dev app entrypoint that imports it, the runner // re-evaluates that chain on the next request, re-fetches this module, // and arrives back here. https://github.com/withastro/astro/issues/17995 return; } let info = this.getModuleInfo(id); if (info && getAstroMetadata(info)?.containsHead) { // Keep bubbling `containsHead` when this module was already marked earlier. propagateMetadata.call(this, id, "containsHead", true); } if (id.includes(PROPAGATED_ASSET_FLAG)) { // Content render entries with ?astroPropagatedAssets need head propagation. propagateMetadata.call(this, id, "propagation", "in-tree"); } invalidateComponentMetadataModule(); } }; } export function astroHeadBuildPlugin(internals) { return { name: "astro:head-metadata-build", applyToEnvironment(environment) { return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr || environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender; }, generateBundle(_opts, bundle) { const map = internals.componentMetadata; const moduleIds = new Set(); // Explicit runtime entries (`createComponent({ propagation: 'self' })`). const selfPropagationSeeds = new Set(); // Head propagation hint seeds (content render entries with ?astroPropagatedAssets). const commentPropagationSeeds = new Set(); function getOrCreateMetadata(id) { if (map.has(id)) return map.get(id); const metadata = { propagation: "none", containsHead: false }; map.set(id, metadata); return metadata; } for (const [, output] of Object.entries(bundle)) { if (output.type !== "chunk") continue; for (const [id] of Object.entries(output.modules)) { moduleIds.add(id); const modinfo = this.getModuleInfo(id); // <head> tag in the tree if (modinfo) { const meta = getAstroMetadata(modinfo); if (meta?.containsHead) { for (const pageInfo of getTopLevelPageModuleInfos(id, this)) { let metadata = getOrCreateMetadata(pageInfo.id); metadata.containsHead = true; } } if (meta?.propagation === "self") { selfPropagationSeeds.add(id); } } // Head propagation (aka bubbling) // Modules with the ?astroPropagatedAssets query param are content render // entries that need head propagation. We identify them by their module ID // rather than scanning source code, which avoids an unfiltered transform // hook that would run on every module in the build. if (id.includes(PROPAGATED_ASSET_FLAG)) { commentPropagationSeeds.add(id); } } } // Build once, then compute all ancestors from both seed kinds. const importerGraph = buildImporterGraphFromModuleInfo(moduleIds, (id) => this.getModuleInfo(id)); const allPropagationSeeds = new Set([...selfPropagationSeeds, ...commentPropagationSeeds]); const allAncestors = computeInTreeAncestors({ seeds: allPropagationSeeds, importerGraph }); for (const id of allAncestors) { const metadata = getOrCreateMetadata(id); // Preserve explicit `self`; only promote others to `in-tree`. if (metadata.propagation !== "self") { metadata.propagation = "in-tree"; } } } }; }