summaryrefslogtreecommitdiff
path: root/packages/astro/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src')
-rw-r--r--packages/astro/src/actions/consts.ts13
-rw-r--r--packages/astro/src/actions/integration.ts54
-rw-r--r--packages/astro/src/actions/plugins.ts98
-rw-r--r--packages/astro/src/actions/runtime/middleware.ts13
-rw-r--r--packages/astro/src/actions/runtime/route.ts26
-rw-r--r--packages/astro/src/actions/runtime/utils.ts45
-rw-r--r--packages/astro/src/actions/runtime/virtual/client.ts9
-rw-r--r--packages/astro/src/actions/runtime/virtual/get-action.ts39
-rw-r--r--packages/astro/src/actions/runtime/virtual/server.ts355
-rw-r--r--packages/astro/src/actions/runtime/virtual/shared.ts311
-rw-r--r--packages/astro/src/actions/utils.ts79
-rw-r--r--packages/astro/src/assets/README.md3
-rw-r--r--packages/astro/src/assets/build/generate.ts365
-rw-r--r--packages/astro/src/assets/build/remote.ts108
-rw-r--r--packages/astro/src/assets/consts.ts37
-rw-r--r--packages/astro/src/assets/endpoint/config.ts57
-rw-r--r--packages/astro/src/assets/endpoint/generic.ts79
-rw-r--r--packages/astro/src/assets/endpoint/node.ts134
-rw-r--r--packages/astro/src/assets/index.ts3
-rw-r--r--packages/astro/src/assets/internal.ts221
-rw-r--r--packages/astro/src/assets/layout.ts118
-rw-r--r--packages/astro/src/assets/runtime.ts97
-rw-r--r--packages/astro/src/assets/services/noop.ts15
-rw-r--r--packages/astro/src/assets/services/service.ts428
-rw-r--r--packages/astro/src/assets/services/sharp.ts123
-rw-r--r--packages/astro/src/assets/types.ts286
-rw-r--r--packages/astro/src/assets/utils/etag.ts45
-rw-r--r--packages/astro/src/assets/utils/getAssetsPrefix.ts12
-rw-r--r--packages/astro/src/assets/utils/imageAttributes.ts48
-rw-r--r--packages/astro/src/assets/utils/imageKind.ts13
-rw-r--r--packages/astro/src/assets/utils/index.ts16
-rw-r--r--packages/astro/src/assets/utils/metadata.ts33
-rw-r--r--packages/astro/src/assets/utils/node/emitAsset.ts88
-rw-r--r--packages/astro/src/assets/utils/proxy.ts23
-rw-r--r--packages/astro/src/assets/utils/queryParams.ts19
-rw-r--r--packages/astro/src/assets/utils/remotePattern.ts82
-rw-r--r--packages/astro/src/assets/utils/remoteProbe.ts56
-rw-r--r--packages/astro/src/assets/utils/resolveImports.ts44
-rw-r--r--packages/astro/src/assets/utils/svg.ts37
-rw-r--r--packages/astro/src/assets/utils/transformToPath.ts38
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/LICENSE9
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/README.md3
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/detector.ts25
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/lookup.ts43
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/cur.ts17
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/dds.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/gif.ts12
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/heif.ts55
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/icns.ts113
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/ico.ts75
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/index.ts44
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/interface.ts15
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts12
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts23
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts162
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts19
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/png.ts37
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts80
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/psd.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/svg.ts109
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/tga.ts15
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts93
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/utils.ts84
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/webp.ts68
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts247
-rw-r--r--packages/astro/src/cli/README.md5
-rw-r--r--packages/astro/src/cli/add/index.ts1056
-rw-r--r--packages/astro/src/cli/build/index.ts37
-rw-r--r--packages/astro/src/cli/check/index.ts43
-rw-r--r--packages/astro/src/cli/create-key/index.ts32
-rw-r--r--packages/astro/src/cli/db/index.ts34
-rw-r--r--packages/astro/src/cli/dev/index.ts36
-rw-r--r--packages/astro/src/cli/docs/index.ts22
-rw-r--r--packages/astro/src/cli/docs/open.ts33
-rw-r--r--packages/astro/src/cli/exec.ts26
-rw-r--r--packages/astro/src/cli/flags.ts49
-rw-r--r--packages/astro/src/cli/index.ts220
-rw-r--r--packages/astro/src/cli/info/index.ts193
-rw-r--r--packages/astro/src/cli/install-package.ts220
-rw-r--r--packages/astro/src/cli/preferences/index.ts378
-rw-r--r--packages/astro/src/cli/preview/index.ts34
-rw-r--r--packages/astro/src/cli/sync/index.ts26
-rw-r--r--packages/astro/src/cli/telemetry/index.ts52
-rw-r--r--packages/astro/src/cli/throw-and-exit.ts32
-rw-r--r--packages/astro/src/config/entrypoint.ts30
-rw-r--r--packages/astro/src/config/index.ts72
-rw-r--r--packages/astro/src/config/vite-plugin-content-listen.ts41
-rw-r--r--packages/astro/src/container/index.ts588
-rw-r--r--packages/astro/src/container/pipeline.ts94
-rw-r--r--packages/astro/src/container/polyfill.ts3
-rw-r--r--packages/astro/src/container/vite-plugin-container.ts15
-rw-r--r--packages/astro/src/content/consts.ts43
-rw-r--r--packages/astro/src/content/content-layer.ts440
-rw-r--r--packages/astro/src/content/data-store.ts128
-rw-r--r--packages/astro/src/content/index.ts7
-rw-r--r--packages/astro/src/content/loaders/file.ts114
-rw-r--r--packages/astro/src/content/loaders/glob.ts357
-rw-r--r--packages/astro/src/content/loaders/index.ts3
-rw-r--r--packages/astro/src/content/loaders/types.ts51
-rw-r--r--packages/astro/src/content/mutable-data-store.ts434
-rw-r--r--packages/astro/src/content/runtime-assets.ts35
-rw-r--r--packages/astro/src/content/runtime.ts701
-rw-r--r--packages/astro/src/content/server-listeners.ts123
-rw-r--r--packages/astro/src/content/types-generator.ts657
-rw-r--r--packages/astro/src/content/utils.ts851
-rw-r--r--packages/astro/src/content/vite-plugin-content-assets.ts184
-rw-r--r--packages/astro/src/content/vite-plugin-content-imports.ts411
-rw-r--r--packages/astro/src/content/vite-plugin-content-virtual-mod.ts370
-rw-r--r--packages/astro/src/content/watcher.ts62
-rw-r--r--packages/astro/src/core/README.md53
-rw-r--r--packages/astro/src/core/app/common.ts39
-rw-r--r--packages/astro/src/core/app/createOutgoingHttpHeaders.ts34
-rw-r--r--packages/astro/src/core/app/index.ts541
-rw-r--r--packages/astro/src/core/app/middlewares.ts67
-rw-r--r--packages/astro/src/core/app/node.ts228
-rw-r--r--packages/astro/src/core/app/pipeline.ts130
-rw-r--r--packages/astro/src/core/app/types.ts109
-rw-r--r--packages/astro/src/core/base-pipeline.ts127
-rw-r--r--packages/astro/src/core/build/add-rollup-input.ts46
-rw-r--r--packages/astro/src/core/build/common.ts109
-rw-r--r--packages/astro/src/core/build/consts.ts2
-rw-r--r--packages/astro/src/core/build/css-asset-name.ts131
-rw-r--r--packages/astro/src/core/build/generate.ts647
-rw-r--r--packages/astro/src/core/build/graph.ts94
-rw-r--r--packages/astro/src/core/build/index.ts312
-rw-r--r--packages/astro/src/core/build/internal.ts295
-rw-r--r--packages/astro/src/core/build/page-data.ts72
-rw-r--r--packages/astro/src/core/build/pipeline.ts349
-rw-r--r--packages/astro/src/core/build/plugin.ts104
-rw-r--r--packages/astro/src/core/build/plugins/README.md173
-rw-r--r--packages/astro/src/core/build/plugins/index.ts32
-rw-r--r--packages/astro/src/core/build/plugins/plugin-analyzer.ts98
-rw-r--r--packages/astro/src/core/build/plugins/plugin-chunks.ts37
-rw-r--r--packages/astro/src/core/build/plugins/plugin-component-entry.ts89
-rw-r--r--packages/astro/src/core/build/plugins/plugin-css.ts345
-rw-r--r--packages/astro/src/core/build/plugins/plugin-internals.ts66
-rw-r--r--packages/astro/src/core/build/plugins/plugin-manifest.ts309
-rw-r--r--packages/astro/src/core/build/plugins/plugin-middleware.ts21
-rw-r--r--packages/astro/src/core/build/plugins/plugin-pages.ts72
-rw-r--r--packages/astro/src/core/build/plugins/plugin-prerender.ts107
-rw-r--r--packages/astro/src/core/build/plugins/plugin-renderers.ts60
-rw-r--r--packages/astro/src/core/build/plugins/plugin-scripts.ts62
-rw-r--r--packages/astro/src/core/build/plugins/plugin-ssr.ts208
-rw-r--r--packages/astro/src/core/build/plugins/util.ts127
-rw-r--r--packages/astro/src/core/build/static-build.ts474
-rw-r--r--packages/astro/src/core/build/types.ts52
-rw-r--r--packages/astro/src/core/build/util.ts69
-rw-r--r--packages/astro/src/core/client-directive/build.ts38
-rw-r--r--packages/astro/src/core/client-directive/default.ts15
-rw-r--r--packages/astro/src/core/client-directive/index.ts2
-rw-r--r--packages/astro/src/core/compile/compile.ts137
-rw-r--r--packages/astro/src/core/compile/index.ts2
-rw-r--r--packages/astro/src/core/compile/style.ts109
-rw-r--r--packages/astro/src/core/compile/types.ts11
-rw-r--r--packages/astro/src/core/config/config.ts167
-rw-r--r--packages/astro/src/core/config/index.ts11
-rw-r--r--packages/astro/src/core/config/logging.ts12
-rw-r--r--packages/astro/src/core/config/merge.ts73
-rw-r--r--packages/astro/src/core/config/schema.ts801
-rw-r--r--packages/astro/src/core/config/settings.ts138
-rw-r--r--packages/astro/src/core/config/timer.ts65
-rw-r--r--packages/astro/src/core/config/tsconfig.ts194
-rw-r--r--packages/astro/src/core/config/validate.ts15
-rw-r--r--packages/astro/src/core/config/vite-load.ts53
-rw-r--r--packages/astro/src/core/constants.ts102
-rw-r--r--packages/astro/src/core/cookies/cookies.ts255
-rw-r--r--packages/astro/src/core/cookies/index.ts7
-rw-r--r--packages/astro/src/core/cookies/response.ts32
-rw-r--r--packages/astro/src/core/create-vite.ts345
-rw-r--r--packages/astro/src/core/dev/adapter-validation.ts50
-rw-r--r--packages/astro/src/core/dev/container.ts170
-rw-r--r--packages/astro/src/core/dev/dev.ts151
-rw-r--r--packages/astro/src/core/dev/index.ts3
-rw-r--r--packages/astro/src/core/dev/restart.ts210
-rw-r--r--packages/astro/src/core/dev/update-check.ts49
-rw-r--r--packages/astro/src/core/encryption.ts119
-rw-r--r--packages/astro/src/core/errors/README.md119
-rw-r--r--packages/astro/src/core/errors/dev/index.ts2
-rw-r--r--packages/astro/src/core/errors/dev/utils.ts265
-rw-r--r--packages/astro/src/core/errors/dev/vite.ts213
-rw-r--r--packages/astro/src/core/errors/errors-data.ts1852
-rw-r--r--packages/astro/src/core/errors/errors.ts196
-rw-r--r--packages/astro/src/core/errors/index.ts14
-rw-r--r--packages/astro/src/core/errors/overlay.ts751
-rw-r--r--packages/astro/src/core/errors/printer.ts35
-rw-r--r--packages/astro/src/core/errors/userError.ts1
-rw-r--r--packages/astro/src/core/errors/utils.ts105
-rw-r--r--packages/astro/src/core/errors/zod-error-map.ts126
-rw-r--r--packages/astro/src/core/fs/index.ts93
-rw-r--r--packages/astro/src/core/index.ts26
-rw-r--r--packages/astro/src/core/logger/console.ts16
-rw-r--r--packages/astro/src/core/logger/core.ts220
-rw-r--r--packages/astro/src/core/logger/node.ts49
-rw-r--r--packages/astro/src/core/logger/vite.ts106
-rw-r--r--packages/astro/src/core/messages.ts407
-rw-r--r--packages/astro/src/core/middleware/callMiddleware.ts104
-rw-r--r--packages/astro/src/core/middleware/index.ts205
-rw-r--r--packages/astro/src/core/middleware/loadMiddleware.ts18
-rw-r--r--packages/astro/src/core/middleware/noop-middleware.ts8
-rw-r--r--packages/astro/src/core/middleware/sequence.ts88
-rw-r--r--packages/astro/src/core/middleware/vite-plugin.ts121
-rw-r--r--packages/astro/src/core/module-loader/index.ts3
-rw-r--r--packages/astro/src/core/module-loader/loader.ts91
-rw-r--r--packages/astro/src/core/module-loader/vite.ts112
-rw-r--r--packages/astro/src/core/path.ts1
-rw-r--r--packages/astro/src/core/polyfill.ts22
-rw-r--r--packages/astro/src/core/preview/index.ts90
-rw-r--r--packages/astro/src/core/preview/static-preview-server.ts75
-rw-r--r--packages/astro/src/core/preview/util.ts19
-rw-r--r--packages/astro/src/core/preview/vite-plugin-astro-preview.ts103
-rw-r--r--packages/astro/src/core/redirects/component.ts17
-rw-r--r--packages/astro/src/core/redirects/helpers.ts13
-rw-r--r--packages/astro/src/core/redirects/index.ts4
-rw-r--r--packages/astro/src/core/redirects/render.ts56
-rw-r--r--packages/astro/src/core/redirects/validate.ts13
-rw-r--r--packages/astro/src/core/render-context.ts653
-rw-r--r--packages/astro/src/core/render/README.md70
-rw-r--r--packages/astro/src/core/render/index.ts22
-rw-r--r--packages/astro/src/core/render/paginate.ts105
-rw-r--r--packages/astro/src/core/render/params-and-props.ts124
-rw-r--r--packages/astro/src/core/render/renderer.ts17
-rw-r--r--packages/astro/src/core/render/route-cache.ts135
-rw-r--r--packages/astro/src/core/render/slots.ts84
-rw-r--r--packages/astro/src/core/render/ssr-element.ts75
-rw-r--r--packages/astro/src/core/request.ts97
-rw-r--r--packages/astro/src/core/routing/3xx.ts19
-rw-r--r--packages/astro/src/core/routing/astro-designed-error-pages.ts59
-rw-r--r--packages/astro/src/core/routing/default.ts36
-rw-r--r--packages/astro/src/core/routing/index.ts4
-rw-r--r--packages/astro/src/core/routing/manifest/create.ts767
-rw-r--r--packages/astro/src/core/routing/manifest/generator.ts65
-rw-r--r--packages/astro/src/core/routing/manifest/parts.ts28
-rw-r--r--packages/astro/src/core/routing/manifest/pattern.ts55
-rw-r--r--packages/astro/src/core/routing/manifest/prerender.ts29
-rw-r--r--packages/astro/src/core/routing/manifest/segment.ts24
-rw-r--r--packages/astro/src/core/routing/manifest/serialization.ts46
-rw-r--r--packages/astro/src/core/routing/match.ts89
-rw-r--r--packages/astro/src/core/routing/params.ts23
-rw-r--r--packages/astro/src/core/routing/priority.ts105
-rw-r--r--packages/astro/src/core/routing/request.ts20
-rw-r--r--packages/astro/src/core/routing/rewrite.ts135
-rw-r--r--packages/astro/src/core/routing/validation.ts98
-rw-r--r--packages/astro/src/core/server-islands/endpoint.ts162
-rw-r--r--packages/astro/src/core/server-islands/vite-plugin-server-islands.ts113
-rw-r--r--packages/astro/src/core/session.ts476
-rw-r--r--packages/astro/src/core/shiki.ts23
-rw-r--r--packages/astro/src/core/sync/index.ts320
-rw-r--r--packages/astro/src/core/util.ts190
-rw-r--r--packages/astro/src/core/viteUtils.ts70
-rw-r--r--packages/astro/src/env/README.md12
-rw-r--r--packages/astro/src/env/config.ts32
-rw-r--r--packages/astro/src/env/constants.ts11
-rw-r--r--packages/astro/src/env/env-loader.ts55
-rw-r--r--packages/astro/src/env/errors.ts22
-rw-r--r--packages/astro/src/env/runtime.ts38
-rw-r--r--packages/astro/src/env/schema.ts140
-rw-r--r--packages/astro/src/env/setup.ts1
-rw-r--r--packages/astro/src/env/sync.ts34
-rw-r--r--packages/astro/src/env/validators.ts179
-rw-r--r--packages/astro/src/env/vite-plugin-env.ts178
-rw-r--r--packages/astro/src/env/vite-plugin-import-meta-env.ts166
-rw-r--r--packages/astro/src/events/error.ts112
-rw-r--r--packages/astro/src/events/index.ts11
-rw-r--r--packages/astro/src/events/session.ts138
-rw-r--r--packages/astro/src/events/toolbar.ts15
-rw-r--r--packages/astro/src/i18n/index.ts403
-rw-r--r--packages/astro/src/i18n/middleware.ts166
-rw-r--r--packages/astro/src/i18n/utils.ts272
-rw-r--r--packages/astro/src/i18n/vite-plugin-i18n.ts55
-rw-r--r--packages/astro/src/integrations/features-validation.ts182
-rw-r--r--packages/astro/src/integrations/hooks.ts712
-rw-r--r--packages/astro/src/jsx-runtime/index.ts87
-rw-r--r--packages/astro/src/jsx/rehype.ts322
-rw-r--r--packages/astro/src/manifest/virtual-module.ts127
-rw-r--r--packages/astro/src/preferences/README.md33
-rw-r--r--packages/astro/src/preferences/constants.ts1
-rw-r--r--packages/astro/src/preferences/defaults.ts18
-rw-r--r--packages/astro/src/preferences/index.ts155
-rw-r--r--packages/astro/src/preferences/store.ts63
-rw-r--r--packages/astro/src/prefetch/index.ts349
-rw-r--r--packages/astro/src/prefetch/vite-plugin-prefetch.ts70
-rw-r--r--packages/astro/src/prerender/metadata.ts22
-rw-r--r--packages/astro/src/prerender/routing.ts87
-rw-r--r--packages/astro/src/prerender/utils.ts18
-rw-r--r--packages/astro/src/runtime/README.md9
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts466
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts222
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts707
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/index.ts78
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/perf.ts144
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-item.ts146
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-window.ts412
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-ui.ts178
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts217
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/utils/highlight.ts92
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/utils/icons.ts43
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/utils/window.ts50
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts183
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts284
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/helpers.ts107
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/settings.ts58
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/toolbar.ts604
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/badge.ts120
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/button.ts179
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/card.ts133
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/highlight.ts130
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/icon.ts51
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/icons.ts72
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/index.ts10
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/radio-checkbox.ts121
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/select.ts109
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/toggle.ts137
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/tooltip.ts172
-rw-r--r--packages/astro/src/runtime/client/dev-toolbar/ui-library/window.ts140
-rw-r--r--packages/astro/src/runtime/client/hmr.ts7
-rw-r--r--packages/astro/src/runtime/client/idle.ts23
-rw-r--r--packages/astro/src/runtime/client/load.ts8
-rw-r--r--packages/astro/src/runtime/client/media.ts22
-rw-r--r--packages/astro/src/runtime/client/only.ts11
-rw-r--r--packages/astro/src/runtime/client/visible.ts37
-rw-r--r--packages/astro/src/runtime/compiler/index.ts21
-rw-r--r--packages/astro/src/runtime/server/astro-component.ts54
-rw-r--r--packages/astro/src/runtime/server/astro-global.ts43
-rw-r--r--packages/astro/src/runtime/server/astro-island.ts216
-rw-r--r--packages/astro/src/runtime/server/endpoint.ts82
-rw-r--r--packages/astro/src/runtime/server/escape.ts113
-rw-r--r--packages/astro/src/runtime/server/hydration.ts187
-rw-r--r--packages/astro/src/runtime/server/index.ts106
-rw-r--r--packages/astro/src/runtime/server/jsx.ts191
-rw-r--r--packages/astro/src/runtime/server/render/any.ts54
-rw-r--r--packages/astro/src/runtime/server/render/astro/factory.ts28
-rw-r--r--packages/astro/src/runtime/server/render/astro/head-and-content.ts21
-rw-r--r--packages/astro/src/runtime/server/render/astro/index.ts7
-rw-r--r--packages/astro/src/runtime/server/render/astro/instance.ts105
-rw-r--r--packages/astro/src/runtime/server/render/astro/render-template.ts65
-rw-r--r--packages/astro/src/runtime/server/render/astro/render.ts341
-rw-r--r--packages/astro/src/runtime/server/render/common.ts146
-rw-r--r--packages/astro/src/runtime/server/render/component.ts575
-rw-r--r--packages/astro/src/runtime/server/render/dom.ts41
-rw-r--r--packages/astro/src/runtime/server/render/head.ts65
-rw-r--r--packages/astro/src/runtime/server/render/index.ts12
-rw-r--r--packages/astro/src/runtime/server/render/instruction.ts52
-rw-r--r--packages/astro/src/runtime/server/render/page.ts95
-rw-r--r--packages/astro/src/runtime/server/render/script.ts24
-rw-r--r--packages/astro/src/runtime/server/render/server-islands.ts152
-rw-r--r--packages/astro/src/runtime/server/render/slot.ts111
-rw-r--r--packages/astro/src/runtime/server/render/tags.ts22
-rw-r--r--packages/astro/src/runtime/server/render/util.ts258
-rw-r--r--packages/astro/src/runtime/server/scripts.ts49
-rw-r--r--packages/astro/src/runtime/server/serialize.ts115
-rw-r--r--packages/astro/src/runtime/server/shorthash.ts67
-rw-r--r--packages/astro/src/runtime/server/transition.ts263
-rw-r--r--packages/astro/src/runtime/server/util.ts19
-rw-r--r--packages/astro/src/template/4xx.ts158
-rw-r--r--packages/astro/src/toolbar/index.ts5
-rw-r--r--packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts119
-rw-r--r--packages/astro/src/transitions/events.ts186
-rw-r--r--packages/astro/src/transitions/index.ts78
-rw-r--r--packages/astro/src/transitions/router.ts706
-rw-r--r--packages/astro/src/transitions/swap-functions.ts159
-rw-r--r--packages/astro/src/transitions/types.ts10
-rw-r--r--packages/astro/src/transitions/vite-plugin-transitions.ts59
-rw-r--r--packages/astro/src/type-utils.ts46
-rw-r--r--packages/astro/src/types/README.md5
-rw-r--r--packages/astro/src/types/astro.ts92
-rw-r--r--packages/astro/src/types/public/common.ts182
-rw-r--r--packages/astro/src/types/public/config.ts2148
-rw-r--r--packages/astro/src/types/public/content.ts126
-rw-r--r--packages/astro/src/types/public/context.ts537
-rw-r--r--packages/astro/src/types/public/elements.ts47
-rw-r--r--packages/astro/src/types/public/extendables.ts21
-rw-r--r--packages/astro/src/types/public/index.ts45
-rw-r--r--packages/astro/src/types/public/integrations.ts287
-rw-r--r--packages/astro/src/types/public/internal.ts304
-rw-r--r--packages/astro/src/types/public/manifest.ts38
-rw-r--r--packages/astro/src/types/public/preview.ts28
-rw-r--r--packages/astro/src/types/public/toolbar.ts72
-rw-r--r--packages/astro/src/types/public/view-transitions.ts40
-rw-r--r--packages/astro/src/types/typed-emitter.ts47
-rw-r--r--packages/astro/src/virtual-modules/README.md3
-rw-r--r--packages/astro/src/virtual-modules/container.ts33
-rw-r--r--packages/astro/src/virtual-modules/i18n.ts390
-rw-r--r--packages/astro/src/virtual-modules/middleware.ts1
-rw-r--r--packages/astro/src/virtual-modules/prefetch.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions-events.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions-router.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions-swap-functions.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions-types.ts1
-rw-r--r--packages/astro/src/virtual-modules/transitions.ts1
-rw-r--r--packages/astro/src/vite-plugin-astro-postprocess/README.md3
-rw-r--r--packages/astro/src/vite-plugin-astro-postprocess/index.ts65
-rw-r--r--packages/astro/src/vite-plugin-astro-server/base.ts69
-rw-r--r--packages/astro/src/vite-plugin-astro-server/controller.ts107
-rw-r--r--packages/astro/src/vite-plugin-astro-server/css.ts70
-rw-r--r--packages/astro/src/vite-plugin-astro-server/error.ts32
-rw-r--r--packages/astro/src/vite-plugin-astro-server/index.ts3
-rw-r--r--packages/astro/src/vite-plugin-astro-server/metadata.ts42
-rw-r--r--packages/astro/src/vite-plugin-astro-server/pipeline.ts227
-rw-r--r--packages/astro/src/vite-plugin-astro-server/plugin.ts208
-rw-r--r--packages/astro/src/vite-plugin-astro-server/request.ts80
-rw-r--r--packages/astro/src/vite-plugin-astro-server/resolve.ts13
-rw-r--r--packages/astro/src/vite-plugin-astro-server/response.ts129
-rw-r--r--packages/astro/src/vite-plugin-astro-server/route.ts340
-rw-r--r--packages/astro/src/vite-plugin-astro-server/server-state.ts52
-rw-r--r--packages/astro/src/vite-plugin-astro-server/trailing-slash.ts38
-rw-r--r--packages/astro/src/vite-plugin-astro-server/util.ts9
-rw-r--r--packages/astro/src/vite-plugin-astro-server/vite.ts127
-rw-r--r--packages/astro/src/vite-plugin-astro/README.md3
-rw-r--r--packages/astro/src/vite-plugin-astro/compile.ts137
-rw-r--r--packages/astro/src/vite-plugin-astro/hmr.ts97
-rw-r--r--packages/astro/src/vite-plugin-astro/index.ts283
-rw-r--r--packages/astro/src/vite-plugin-astro/metadata.ts21
-rw-r--r--packages/astro/src/vite-plugin-astro/query.ts37
-rw-r--r--packages/astro/src/vite-plugin-astro/types.ts49
-rw-r--r--packages/astro/src/vite-plugin-astro/utils.ts21
-rw-r--r--packages/astro/src/vite-plugin-config-alias/README.md26
-rw-r--r--packages/astro/src/vite-plugin-config-alias/index.ts152
-rw-r--r--packages/astro/src/vite-plugin-fileurl/index.ts14
-rw-r--r--packages/astro/src/vite-plugin-head/index.ts162
-rw-r--r--packages/astro/src/vite-plugin-hmr-reload/index.ts36
-rw-r--r--packages/astro/src/vite-plugin-html/README.md3
-rw-r--r--packages/astro/src/vite-plugin-html/index.ts14
-rw-r--r--packages/astro/src/vite-plugin-html/transform/escape.ts33
-rw-r--r--packages/astro/src/vite-plugin-html/transform/index.ts20
-rw-r--r--packages/astro/src/vite-plugin-html/transform/slots.ts33
-rw-r--r--packages/astro/src/vite-plugin-html/transform/utils.ts60
-rw-r--r--packages/astro/src/vite-plugin-integrations-container/index.ts45
-rw-r--r--packages/astro/src/vite-plugin-load-fallback/README.md3
-rw-r--r--packages/astro/src/vite-plugin-load-fallback/index.ts80
-rw-r--r--packages/astro/src/vite-plugin-markdown/README.md3
-rw-r--r--packages/astro/src/vite-plugin-markdown/content-entry-type.ts35
-rw-r--r--packages/astro/src/vite-plugin-markdown/images.ts59
-rw-r--r--packages/astro/src/vite-plugin-markdown/index.ts166
-rw-r--r--packages/astro/src/vite-plugin-scanner/index.ts113
-rw-r--r--packages/astro/src/vite-plugin-scripts/README.md3
-rw-r--r--packages/astro/src/vite-plugin-scripts/index.ts64
-rw-r--r--packages/astro/src/vite-plugin-scripts/page-ssr.ts42
-rw-r--r--packages/astro/src/vite-plugin-ssr-manifest/index.ts25
-rw-r--r--packages/astro/src/vite-plugin-utils/index.ts61
440 files changed, 55688 insertions, 0 deletions
diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts
new file mode 100644
index 000000000..88fafb8a3
--- /dev/null
+++ b/packages/astro/src/actions/consts.ts
@@ -0,0 +1,13 @@
+export const VIRTUAL_MODULE_ID = 'astro:actions';
+export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
+export const ACTIONS_TYPES_FILE = 'actions.d.ts';
+export const VIRTUAL_INTERNAL_MODULE_ID = 'astro:internal-actions';
+export const RESOLVED_VIRTUAL_INTERNAL_MODULE_ID = '\0astro:internal-actions';
+export const NOOP_ACTIONS = '\0noop-actions';
+
+export const ACTION_QUERY_PARAMS = {
+ actionName: '_action',
+ actionPayload: '_astroActionPayload',
+};
+
+export const ACTION_RPC_ROUTE_PATTERN = '/_actions/[...path]';
diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts
new file mode 100644
index 000000000..13d76e8b6
--- /dev/null
+++ b/packages/astro/src/actions/integration.ts
@@ -0,0 +1,54 @@
+import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js';
+import { AstroError } from '../core/errors/errors.js';
+import { viteID } from '../core/util.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroIntegration } from '../types/public/integrations.js';
+import { ACTIONS_TYPES_FILE, ACTION_RPC_ROUTE_PATTERN, VIRTUAL_MODULE_ID } from './consts.js';
+
+/**
+ * This integration is applied when the user is using Actions in their project.
+ * It will inject the necessary routes and middlewares to handle actions.
+ */
+export default function astroIntegrationActionsRouteHandler({
+ settings,
+}: {
+ settings: AstroSettings;
+}): AstroIntegration {
+ return {
+ name: VIRTUAL_MODULE_ID,
+ hooks: {
+ async 'astro:config:setup'(params) {
+ settings.injectedRoutes.push({
+ pattern: ACTION_RPC_ROUTE_PATTERN,
+ entrypoint: 'astro/actions/runtime/route.js',
+ prerender: false,
+ origin: 'internal',
+ });
+
+ params.addMiddleware({
+ entrypoint: 'astro/actions/runtime/middleware.js',
+ order: 'post',
+ });
+ },
+ 'astro:config:done': async (params) => {
+ if (params.buildOutput === 'static') {
+ const error = new AstroError(ActionsWithoutServerOutputError);
+ error.stack = undefined;
+ throw error;
+ }
+
+ const stringifiedActionsImport = JSON.stringify(
+ viteID(new URL('./actions', params.config.srcDir)),
+ );
+ settings.injectedTypes.push({
+ filename: ACTIONS_TYPES_FILE,
+ content: `declare module "astro:actions" {
+ type Actions = typeof import(${stringifiedActionsImport})["server"];
+
+ export const actions: Actions;
+}`,
+ });
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/actions/plugins.ts b/packages/astro/src/actions/plugins.ts
new file mode 100644
index 000000000..4c1b930c3
--- /dev/null
+++ b/packages/astro/src/actions/plugins.ts
@@ -0,0 +1,98 @@
+import type fsMod from 'node:fs';
+import type { Plugin as VitePlugin } from 'vite';
+import { shouldAppendForwardSlash } from '../core/build/util.js';
+import type { AstroSettings } from '../types/astro.js';
+import {
+ NOOP_ACTIONS,
+ RESOLVED_VIRTUAL_INTERNAL_MODULE_ID,
+ RESOLVED_VIRTUAL_MODULE_ID,
+ VIRTUAL_INTERNAL_MODULE_ID,
+ VIRTUAL_MODULE_ID,
+} from './consts.js';
+import { isActionsFilePresent } from './utils.js';
+
+/**
+ * This plugin is responsible to load the known file `actions/index.js` / `actions.js`
+ * If the file doesn't exist, it returns an empty object.
+ * @param settings
+ */
+export function vitePluginUserActions({ settings }: { settings: AstroSettings }): VitePlugin {
+ let resolvedActionsId: string;
+ return {
+ name: '@astro/plugin-actions',
+ async resolveId(id) {
+ if (id === NOOP_ACTIONS) {
+ return NOOP_ACTIONS;
+ }
+ if (id === VIRTUAL_INTERNAL_MODULE_ID) {
+ const resolvedModule = await this.resolve(
+ `${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`,
+ );
+
+ if (!resolvedModule) {
+ return NOOP_ACTIONS;
+ }
+ resolvedActionsId = resolvedModule.id;
+ return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID;
+ }
+ },
+
+ load(id) {
+ if (id === NOOP_ACTIONS) {
+ return 'export const server = {}';
+ } else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) {
+ return `export { server } from '${resolvedActionsId}';`;
+ }
+ },
+ };
+}
+
+export function vitePluginActions({
+ fs,
+ settings,
+}: {
+ fs: typeof fsMod;
+ settings: AstroSettings;
+}): VitePlugin {
+ return {
+ name: VIRTUAL_MODULE_ID,
+ enforce: 'pre',
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ async configureServer(server) {
+ const filePresentOnStartup = await isActionsFilePresent(fs, settings.config.srcDir);
+ // Watch for the actions file to be created.
+ async function watcherCallback() {
+ const filePresent = await isActionsFilePresent(fs, settings.config.srcDir);
+ if (filePresentOnStartup !== filePresent) {
+ server.restart();
+ }
+ }
+ server.watcher.on('add', watcherCallback);
+ server.watcher.on('change', watcherCallback);
+ },
+ async load(id, opts) {
+ if (id !== RESOLVED_VIRTUAL_MODULE_ID) return;
+
+ let code = await fs.promises.readFile(
+ new URL('../../templates/actions.mjs', import.meta.url),
+ 'utf-8',
+ );
+ if (opts?.ssr) {
+ code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`;
+ } else {
+ code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`;
+ }
+ code = code.replace(
+ "'/** @TRAILING_SLASH@ **/'",
+ JSON.stringify(
+ shouldAppendForwardSlash(settings.config.trailingSlash, settings.config.build.format),
+ ),
+ );
+ return code;
+ },
+ };
+}
diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts
new file mode 100644
index 000000000..47adc2945
--- /dev/null
+++ b/packages/astro/src/actions/runtime/middleware.ts
@@ -0,0 +1,13 @@
+import { defineMiddleware } from '../../virtual-modules/middleware.js';
+import { getActionContext } from './virtual/server.js';
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ if (context.isPrerendered) return next();
+ const { action, setActionResult, serializeActionResult } = getActionContext(context);
+
+ if (action?.calledFrom === 'form') {
+ const actionResult = await action.handler();
+ setActionResult(action.name, serializeActionResult(actionResult));
+ }
+ return next();
+});
diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts
new file mode 100644
index 000000000..c7522328d
--- /dev/null
+++ b/packages/astro/src/actions/runtime/route.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from '../../types/public/common.js';
+import { getActionContext } from './virtual/server.js';
+
+export const POST: APIRoute = async (context) => {
+ const { action, serializeActionResult } = getActionContext(context);
+
+ if (action?.calledFrom !== 'rpc') {
+ return new Response('Not found', { status: 404 });
+ }
+
+ const result = await action.handler();
+ const serialized = serializeActionResult(result);
+
+ if (serialized.type === 'empty') {
+ return new Response(null, {
+ status: serialized.status,
+ });
+ }
+
+ return new Response(serialized.body, {
+ status: serialized.status,
+ headers: {
+ 'Content-Type': serialized.contentType,
+ },
+ });
+};
diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts
new file mode 100644
index 000000000..35c750055
--- /dev/null
+++ b/packages/astro/src/actions/runtime/utils.ts
@@ -0,0 +1,45 @@
+import type { APIContext } from '../../types/public/context.js';
+import type { SerializedActionResult } from './virtual/shared.js';
+
+export type ActionPayload = {
+ actionResult: SerializedActionResult;
+ actionName: string;
+};
+
+export type Locals = {
+ _actionPayload: ActionPayload;
+};
+
+export const ACTION_API_CONTEXT_SYMBOL = Symbol.for('astro.actionAPIContext');
+
+export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+export function hasContentType(contentType: string, expected: string[]) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType.split(';')[0].toLowerCase();
+
+ return expected.some((t) => type === t);
+}
+
+export type ActionAPIContext = Omit<
+ APIContext,
+ 'getActionResult' | 'callAction' | 'props' | 'redirect'
+>;
+export type MaybePromise<T> = T | Promise<T>;
+
+/**
+ * Used to preserve the input schema type in the error object.
+ * This allows for type inference on the `fields` property
+ * when type narrowed to an `ActionInputError`.
+ *
+ * Example: Action has an input schema of `{ name: z.string() }`.
+ * When calling the action and checking `isInputError(result.error)`,
+ * `result.error.fields` will be typed with the `name` field.
+ */
+export type ErrorInferenceObject = Record<string, any>;
+
+export function isActionAPIContext(ctx: ActionAPIContext): boolean {
+ const symbol = Reflect.get(ctx, ACTION_API_CONTEXT_SYMBOL);
+ return symbol === true;
+}
diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts
new file mode 100644
index 000000000..68407f4cb
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/client.ts
@@ -0,0 +1,9 @@
+export * from './shared.js';
+
+export function defineAction() {
+ throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
+}
+
+export function getActionContext() {
+ throw new Error('[astro:action] `getActionContext()` unexpectedly used on the client.');
+}
diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts
new file mode 100644
index 000000000..a11e72fc4
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/get-action.ts
@@ -0,0 +1,39 @@
+import type { ZodType } from 'zod';
+import { ActionNotFoundError } from '../../../core/errors/errors-data.js';
+import { AstroError } from '../../../core/errors/errors.js';
+import type { ActionAccept, ActionClient } from './server.js';
+
+/**
+ * Get server-side action based on the route path.
+ * Imports from the virtual module `astro:internal-actions`, which maps to
+ * the user's `src/actions/index.ts` file at build-time.
+ */
+export async function getAction(
+ path: string,
+): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
+ const pathKeys = path.split('.').map((key) => decodeURIComponent(key));
+ // @ts-expect-error virtual module
+ let { server: actionLookup } = await import('astro:internal-actions');
+
+ if (actionLookup == null || !(typeof actionLookup === 'object')) {
+ throw new TypeError(
+ `Expected \`server\` export in actions file to be an object. Received ${typeof actionLookup}.`,
+ );
+ }
+
+ for (const key of pathKeys) {
+ if (!(key in actionLookup)) {
+ throw new AstroError({
+ ...ActionNotFoundError,
+ message: ActionNotFoundError.message(pathKeys.join('.')),
+ });
+ }
+ actionLookup = actionLookup[key];
+ }
+ if (typeof actionLookup !== 'function') {
+ throw new TypeError(
+ `Expected handler for action ${pathKeys.join('.')} to be a function. Received ${typeof actionLookup}.`,
+ );
+ }
+ return actionLookup;
+}
diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts
new file mode 100644
index 000000000..b58d73376
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/server.ts
@@ -0,0 +1,355 @@
+import { z } from 'zod';
+import type { Pipeline } from '../../../core/base-pipeline.js';
+import { shouldAppendForwardSlash } from '../../../core/build/util.js';
+import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js';
+import { AstroError } from '../../../core/errors/errors.js';
+import { removeTrailingForwardSlash } from '../../../core/path.js';
+import { apiContextRoutesSymbol } from '../../../core/render-context.js';
+import type { APIContext } from '../../../types/public/index.js';
+import { ACTION_RPC_ROUTE_PATTERN } from '../../consts.js';
+import {
+ ACTION_API_CONTEXT_SYMBOL,
+ type ActionAPIContext,
+ type ErrorInferenceObject,
+ type MaybePromise,
+ formContentTypes,
+ hasContentType,
+ isActionAPIContext,
+} from '../utils.js';
+import type { Locals } from '../utils.js';
+import { getAction } from './get-action.js';
+import {
+ ACTION_QUERY_PARAMS,
+ ActionError,
+ ActionInputError,
+ type SafeResult,
+ type SerializedActionResult,
+ callSafely,
+ deserializeActionResult,
+ serializeActionResult,
+} from './shared.js';
+
+export * from './shared.js';
+
+export type ActionAccept = 'form' | 'json';
+
+export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
+ ? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
+ : (input: any, context: ActionAPIContext) => MaybePromise<TOutput>;
+
+export type ActionReturnType<T extends ActionHandler<any, any>> = Awaited<ReturnType<T>>;
+
+export type ActionClient<
+ TOutput,
+ TAccept extends ActionAccept | undefined,
+ TInputSchema extends z.ZodType | undefined,
+> = TInputSchema extends z.ZodType
+ ? ((
+ input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
+ ) => Promise<
+ SafeResult<
+ z.input<TInputSchema> extends ErrorInferenceObject
+ ? z.input<TInputSchema>
+ : ErrorInferenceObject,
+ Awaited<TOutput>
+ >
+ >) & {
+ queryString: string;
+ orThrow: (
+ input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
+ ) => Promise<Awaited<TOutput>>;
+ }
+ : ((input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>) & {
+ orThrow: (input?: any) => Promise<Awaited<TOutput>>;
+ };
+
+export function defineAction<
+ TOutput,
+ TAccept extends ActionAccept | undefined = undefined,
+ TInputSchema extends z.ZodType | undefined = TAccept extends 'form'
+ ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
+ z.ZodType<FormData>
+ : undefined,
+>({
+ accept,
+ input: inputSchema,
+ handler,
+}: {
+ input?: TInputSchema;
+ accept?: TAccept;
+ handler: ActionHandler<TInputSchema, TOutput>;
+}): ActionClient<TOutput, TAccept, TInputSchema> & string {
+ const serverHandler =
+ accept === 'form'
+ ? getFormServerHandler(handler, inputSchema)
+ : getJsonServerHandler(handler, inputSchema);
+
+ async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) {
+ // The ActionAPIContext should always contain the `params` property
+ if (typeof this === 'function' || !isActionAPIContext(this)) {
+ throw new AstroError(ActionCalledFromServerError);
+ }
+ return callSafely(() => serverHandler(unparsedInput, this));
+ }
+
+ Object.assign(safeServerHandler, {
+ orThrow(this: ActionAPIContext, unparsedInput: unknown) {
+ if (typeof this === 'function') {
+ throw new AstroError(ActionCalledFromServerError);
+ }
+ return serverHandler(unparsedInput, this);
+ },
+ });
+
+ return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
+}
+
+function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(
+ handler: ActionHandler<TInputSchema, TOutput>,
+ inputSchema?: TInputSchema,
+) {
+ return async (unparsedInput: unknown, context: ActionAPIContext): Promise<Awaited<TOutput>> => {
+ if (!(unparsedInput instanceof FormData)) {
+ throw new ActionError({
+ code: 'UNSUPPORTED_MEDIA_TYPE',
+ message: 'This action only accepts FormData.',
+ });
+ }
+
+ if (!inputSchema) return await handler(unparsedInput, context);
+
+ const baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput);
+ const parsed = await inputSchema.safeParseAsync(
+ baseSchema instanceof z.ZodObject
+ ? formDataToObject(unparsedInput, baseSchema)
+ : unparsedInput,
+ );
+ if (!parsed.success) {
+ throw new ActionInputError(parsed.error.issues);
+ }
+ return await handler(parsed.data, context);
+ };
+}
+
+function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
+ handler: ActionHandler<TInputSchema, TOutput>,
+ inputSchema?: TInputSchema,
+) {
+ return async (unparsedInput: unknown, context: ActionAPIContext): Promise<Awaited<TOutput>> => {
+ if (unparsedInput instanceof FormData) {
+ throw new ActionError({
+ code: 'UNSUPPORTED_MEDIA_TYPE',
+ message: 'This action only accepts JSON.',
+ });
+ }
+
+ if (!inputSchema) return await handler(unparsedInput, context);
+ const parsed = await inputSchema.safeParseAsync(unparsedInput);
+ if (!parsed.success) {
+ throw new ActionInputError(parsed.error.issues);
+ }
+ return await handler(parsed.data, context);
+ };
+}
+
+/** Transform form data to an object based on a Zod schema. */
+export function formDataToObject<T extends z.AnyZodObject>(
+ formData: FormData,
+ schema: T,
+): Record<string, unknown> {
+ const obj: Record<string, unknown> =
+ schema._def.unknownKeys === 'passthrough' ? Object.fromEntries(formData.entries()) : {};
+ for (const [key, baseValidator] of Object.entries(schema.shape)) {
+ let validator = baseValidator;
+
+ while (
+ validator instanceof z.ZodOptional ||
+ validator instanceof z.ZodNullable ||
+ validator instanceof z.ZodDefault
+ ) {
+ // use default value when key is undefined
+ if (validator instanceof z.ZodDefault && !formData.has(key)) {
+ obj[key] = validator._def.defaultValue();
+ }
+ validator = validator._def.innerType;
+ }
+
+ if (!formData.has(key) && key in obj) {
+ // continue loop if form input is not found and default value is set
+ continue;
+ } else if (validator instanceof z.ZodBoolean) {
+ const val = formData.get(key);
+ obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key);
+ } else if (validator instanceof z.ZodArray) {
+ obj[key] = handleFormDataGetAll(key, formData, validator);
+ } else {
+ obj[key] = handleFormDataGet(key, formData, validator, baseValidator);
+ }
+ }
+ return obj;
+}
+
+function handleFormDataGetAll(
+ key: string,
+ formData: FormData,
+ validator: z.ZodArray<z.ZodUnknown>,
+) {
+ const entries = Array.from(formData.getAll(key));
+ const elementValidator = validator._def.type;
+ if (elementValidator instanceof z.ZodNumber) {
+ return entries.map(Number);
+ } else if (elementValidator instanceof z.ZodBoolean) {
+ return entries.map(Boolean);
+ }
+ return entries;
+}
+
+function handleFormDataGet(
+ key: string,
+ formData: FormData,
+ validator: unknown,
+ baseValidator: unknown,
+) {
+ const value = formData.get(key);
+ if (!value) {
+ return baseValidator instanceof z.ZodOptional ? undefined : null;
+ }
+ return validator instanceof z.ZodNumber ? Number(value) : value;
+}
+
+function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
+ while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) {
+ if (schema instanceof z.ZodEffects) {
+ schema = schema._def.schema;
+ }
+ if (schema instanceof z.ZodPipeline) {
+ schema = schema._def.in;
+ }
+ }
+ if (schema instanceof z.ZodDiscriminatedUnion) {
+ const typeKey = schema._def.discriminator;
+ const typeValue = unparsedInput.get(typeKey);
+ if (typeof typeValue !== 'string') return schema;
+
+ const objSchema = schema._def.optionsMap.get(typeValue);
+ if (!objSchema) return schema;
+
+ return objSchema;
+ }
+ return schema;
+}
+
+export type ActionMiddlewareContext = {
+ /** Information about an incoming action request. */
+ action?: {
+ /** Whether an action was called using an RPC function or by using an HTML form action. */
+ calledFrom: 'rpc' | 'form';
+ /** The name of the action. Useful to track the source of an action result during a redirect. */
+ name: string;
+ /** Programmatically call the action to get the result. */
+ handler: () => Promise<SafeResult<any, any>>;
+ };
+ /**
+ * Manually set the action result accessed via `getActionResult()`.
+ * Calling this function from middleware will disable Astro's own action result handling.
+ */
+ setActionResult(actionName: string, actionResult: SerializedActionResult): void;
+ /**
+ * Serialize an action result to stored in a cookie or session.
+ * Also used to pass a result to Astro templates via `setActionResult()`.
+ */
+ serializeActionResult: typeof serializeActionResult;
+ /**
+ * Deserialize an action result to access data and error objects.
+ */
+ deserializeActionResult: typeof deserializeActionResult;
+};
+
+/**
+ * Access information about Action requests from middleware.
+ */
+export function getActionContext(context: APIContext): ActionMiddlewareContext {
+ const callerInfo = getCallerInfo(context);
+
+ // Prevents action results from being handled on a rewrite.
+ // Also prevents our *own* fallback middleware from running
+ // if the user's middleware has already handled the result.
+ const actionResultAlreadySet = Boolean((context.locals as Locals)._actionPayload);
+
+ let action: ActionMiddlewareContext['action'] = undefined;
+
+ if (callerInfo && context.request.method === 'POST' && !actionResultAlreadySet) {
+ action = {
+ calledFrom: callerInfo.from,
+ name: callerInfo.name,
+ handler: async () => {
+ const pipeline: Pipeline = Reflect.get(context, apiContextRoutesSymbol);
+ const callerInfoName = shouldAppendForwardSlash(
+ pipeline.manifest.trailingSlash,
+ pipeline.manifest.buildFormat,
+ )
+ ? removeTrailingForwardSlash(callerInfo.name)
+ : callerInfo.name;
+
+ const baseAction = await getAction(callerInfoName);
+ let input;
+ try {
+ input = await parseRequestBody(context.request);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ return { data: undefined, error: new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE' }) };
+ }
+ throw e;
+ }
+ const {
+ props: _props,
+ getActionResult: _getActionResult,
+ callAction: _callAction,
+ redirect: _redirect,
+ ...actionAPIContext
+ } = context;
+ Reflect.set(actionAPIContext, ACTION_API_CONTEXT_SYMBOL, true);
+ const handler = baseAction.bind(actionAPIContext satisfies ActionAPIContext);
+ return handler(input);
+ },
+ };
+ }
+
+ function setActionResult(actionName: string, actionResult: SerializedActionResult) {
+ (context.locals as Locals)._actionPayload = {
+ actionResult,
+ actionName,
+ };
+ }
+ return {
+ action,
+ setActionResult,
+ serializeActionResult,
+ deserializeActionResult,
+ };
+}
+
+function getCallerInfo(ctx: APIContext) {
+ if (ctx.routePattern === ACTION_RPC_ROUTE_PATTERN) {
+ return { from: 'rpc', name: ctx.url.pathname.replace(/^.*\/_actions\//, '') } as const;
+ }
+ const queryParam = ctx.url.searchParams.get(ACTION_QUERY_PARAMS.actionName);
+ if (queryParam) {
+ return { from: 'form', name: queryParam } as const;
+ }
+ return undefined;
+}
+
+async function parseRequestBody(request: Request) {
+ const contentType = request.headers.get('content-type');
+ const contentLength = request.headers.get('Content-Length');
+
+ if (!contentType) return undefined;
+ if (hasContentType(contentType, formContentTypes)) {
+ return await request.clone().formData();
+ }
+ if (hasContentType(contentType, ['application/json'])) {
+ return contentLength === '0' ? undefined : await request.clone().json();
+ }
+ throw new TypeError('Unsupported content type');
+}
diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts
new file mode 100644
index 000000000..02cc07b52
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/shared.ts
@@ -0,0 +1,311 @@
+import { parse as devalueParse, stringify as devalueStringify } from 'devalue';
+import type { z } from 'zod';
+import { REDIRECT_STATUS_CODES } from '../../../core/constants.js';
+import { ActionsReturnedInvalidDataError } from '../../../core/errors/errors-data.js';
+import { AstroError } from '../../../core/errors/errors.js';
+import { appendForwardSlash as _appendForwardSlash } from '../../../core/path.js';
+import { ACTION_QUERY_PARAMS as _ACTION_QUERY_PARAMS } from '../../consts.js';
+import type {
+ ErrorInferenceObject,
+ MaybePromise,
+ ActionAPIContext as _ActionAPIContext,
+} from '../utils.js';
+
+export type ActionAPIContext = _ActionAPIContext;
+export const ACTION_QUERY_PARAMS = _ACTION_QUERY_PARAMS;
+
+export const appendForwardSlash = _appendForwardSlash;
+
+export const ACTION_ERROR_CODES = [
+ 'BAD_REQUEST',
+ 'UNAUTHORIZED',
+ 'FORBIDDEN',
+ 'NOT_FOUND',
+ 'TIMEOUT',
+ 'CONFLICT',
+ 'PRECONDITION_FAILED',
+ 'PAYLOAD_TOO_LARGE',
+ 'UNSUPPORTED_MEDIA_TYPE',
+ 'UNPROCESSABLE_CONTENT',
+ 'TOO_MANY_REQUESTS',
+ 'CLIENT_CLOSED_REQUEST',
+ 'INTERNAL_SERVER_ERROR',
+] as const;
+
+export type ActionErrorCode = (typeof ACTION_ERROR_CODES)[number];
+
+const codeToStatusMap: Record<ActionErrorCode, number> = {
+ // Implemented from tRPC error code table
+ // https://trpc.io/docs/server/error-handling#error-codes
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ TIMEOUT: 405,
+ CONFLICT: 409,
+ PRECONDITION_FAILED: 412,
+ PAYLOAD_TOO_LARGE: 413,
+ UNSUPPORTED_MEDIA_TYPE: 415,
+ UNPROCESSABLE_CONTENT: 422,
+ TOO_MANY_REQUESTS: 429,
+ CLIENT_CLOSED_REQUEST: 499,
+ INTERNAL_SERVER_ERROR: 500,
+};
+
+const statusToCodeMap: Record<number, ActionErrorCode> = Object.entries(codeToStatusMap).reduce(
+ // reverse the key-value pairs
+ (acc, [key, value]) => ({ ...acc, [value]: key }),
+ {},
+);
+
+// T is used for error inference with SafeInput -> isInputError.
+// See: https://github.com/withastro/astro/pull/11173/files#r1622767246
+export class ActionError<_T extends ErrorInferenceObject = ErrorInferenceObject> extends Error {
+ type = 'AstroActionError';
+ code: ActionErrorCode = 'INTERNAL_SERVER_ERROR';
+ status = 500;
+
+ constructor(params: { message?: string; code: ActionErrorCode; stack?: string }) {
+ super(params.message);
+ this.code = params.code;
+ this.status = ActionError.codeToStatus(params.code);
+ if (params.stack) {
+ this.stack = params.stack;
+ }
+ }
+
+ static codeToStatus(code: ActionErrorCode): number {
+ return codeToStatusMap[code];
+ }
+
+ static statusToCode(status: number): ActionErrorCode {
+ return statusToCodeMap[status] ?? 'INTERNAL_SERVER_ERROR';
+ }
+
+ static fromJson(body: any) {
+ if (isInputError(body)) {
+ return new ActionInputError(body.issues);
+ }
+ if (isActionError(body)) {
+ return new ActionError(body);
+ }
+ return new ActionError({
+ code: 'INTERNAL_SERVER_ERROR',
+ });
+ }
+}
+
+export function isActionError(error?: unknown): error is ActionError {
+ return (
+ typeof error === 'object' &&
+ error != null &&
+ 'type' in error &&
+ error.type === 'AstroActionError'
+ );
+}
+
+export function isInputError<T extends ErrorInferenceObject>(
+ error?: ActionError<T>,
+): error is ActionInputError<T>;
+export function isInputError(error?: unknown): error is ActionInputError<ErrorInferenceObject>;
+export function isInputError<T extends ErrorInferenceObject>(
+ error?: unknown | ActionError<T>,
+): error is ActionInputError<T> {
+ return (
+ typeof error === 'object' &&
+ error != null &&
+ 'type' in error &&
+ error.type === 'AstroActionInputError' &&
+ 'issues' in error &&
+ Array.isArray(error.issues)
+ );
+}
+
+export type SafeResult<TInput extends ErrorInferenceObject, TOutput> =
+ | {
+ data: TOutput;
+ error: undefined;
+ }
+ | {
+ data: undefined;
+ error: ActionError<TInput>;
+ };
+
+export class ActionInputError<T extends ErrorInferenceObject> extends ActionError {
+ type = 'AstroActionInputError';
+
+ // We don't expose all ZodError properties.
+ // Not all properties will serialize from server to client,
+ // and we don't want to import the full ZodError object into the client.
+
+ issues: z.ZodIssue[];
+ fields: z.ZodError<T>['formErrors']['fieldErrors'];
+
+ constructor(issues: z.ZodIssue[]) {
+ super({
+ message: `Failed to validate: ${JSON.stringify(issues, null, 2)}`,
+ code: 'BAD_REQUEST',
+ });
+ this.issues = issues;
+ this.fields = {};
+ for (const issue of issues) {
+ if (issue.path.length > 0) {
+ const key = issue.path[0].toString() as keyof typeof this.fields;
+ this.fields[key] ??= [];
+ this.fields[key]?.push(issue.message);
+ }
+ }
+ }
+}
+
+export async function callSafely<TOutput>(
+ handler: () => MaybePromise<TOutput>,
+): Promise<SafeResult<z.ZodType, TOutput>> {
+ try {
+ const data = await handler();
+ return { data, error: undefined };
+ } catch (e) {
+ if (e instanceof ActionError) {
+ return { data: undefined, error: e };
+ }
+ return {
+ data: undefined,
+ error: new ActionError({
+ message: e instanceof Error ? e.message : 'Unknown error',
+ code: 'INTERNAL_SERVER_ERROR',
+ }),
+ };
+ }
+}
+
+export function getActionQueryString(name: string) {
+ const searchParams = new URLSearchParams({ [_ACTION_QUERY_PARAMS.actionName]: name });
+ return `?${searchParams.toString()}`;
+}
+
+export type SerializedActionResult =
+ | {
+ type: 'data';
+ contentType: 'application/json+devalue';
+ status: 200;
+ body: string;
+ }
+ | {
+ type: 'error';
+ contentType: 'application/json';
+ status: number;
+ body: string;
+ }
+ | {
+ type: 'empty';
+ status: 204;
+ };
+
+export function serializeActionResult(res: SafeResult<any, any>): SerializedActionResult {
+ if (res.error) {
+ if (import.meta.env?.DEV) {
+ actionResultErrorStack.set(res.error.stack);
+ }
+
+ let body: Record<string, any>;
+ if (res.error instanceof ActionInputError) {
+ body = {
+ type: res.error.type,
+ issues: res.error.issues,
+ fields: res.error.fields,
+ };
+ } else {
+ body = {
+ ...res.error,
+ message: res.error.message,
+ };
+ }
+
+ return {
+ type: 'error',
+ status: res.error.status,
+ contentType: 'application/json',
+ body: JSON.stringify(body),
+ };
+ }
+ if (res.data === undefined) {
+ return {
+ type: 'empty',
+ status: 204,
+ };
+ }
+ let body;
+ try {
+ body = devalueStringify(res.data, {
+ // Add support for URL objects
+ URL: (value) => value instanceof URL && value.href,
+ });
+ } catch (e) {
+ let hint = ActionsReturnedInvalidDataError.hint;
+ if (res.data instanceof Response) {
+ hint = REDIRECT_STATUS_CODES.includes(res.data.status as any)
+ ? 'If you need to redirect when the action succeeds, trigger a redirect where the action is called. See the Actions guide for server and client redirect examples: https://docs.astro.build/en/guides/actions.'
+ : 'If you need to return a Response object, try using a server endpoint instead. See https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes';
+ }
+ throw new AstroError({
+ ...ActionsReturnedInvalidDataError,
+ message: ActionsReturnedInvalidDataError.message(String(e)),
+ hint,
+ });
+ }
+ return {
+ type: 'data',
+ status: 200,
+ contentType: 'application/json+devalue',
+ body,
+ };
+}
+
+export function deserializeActionResult(res: SerializedActionResult): SafeResult<any, any> {
+ if (res.type === 'error') {
+ let json;
+ try {
+ json = JSON.parse(res.body);
+ } catch {
+ return {
+ data: undefined,
+ error: new ActionError({
+ message: res.body,
+ code: 'INTERNAL_SERVER_ERROR',
+ }),
+ };
+ }
+ if (import.meta.env?.PROD) {
+ return { error: ActionError.fromJson(json), data: undefined };
+ } else {
+ const error = ActionError.fromJson(json);
+ error.stack = actionResultErrorStack.get();
+ return {
+ error,
+ data: undefined,
+ };
+ }
+ }
+ if (res.type === 'empty') {
+ return { data: undefined, error: undefined };
+ }
+ return {
+ data: devalueParse(res.body, {
+ URL: (href) => new URL(href),
+ }),
+ error: undefined,
+ };
+}
+
+// in-memory singleton to save the stack trace
+const actionResultErrorStack = (function actionResultErrorStackFn() {
+ let errorStack: string | undefined;
+ return {
+ set(stack: string | undefined) {
+ errorStack = stack;
+ },
+ get() {
+ return errorStack;
+ },
+ };
+})();
diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts
new file mode 100644
index 000000000..b6fe63f6c
--- /dev/null
+++ b/packages/astro/src/actions/utils.ts
@@ -0,0 +1,79 @@
+import type fsMod from 'node:fs';
+import * as eslexer from 'es-module-lexer';
+import type { APIContext } from '../types/public/context.js';
+import { ACTION_API_CONTEXT_SYMBOL, type ActionAPIContext, type Locals } from './runtime/utils.js';
+import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js';
+
+export function hasActionPayload(locals: APIContext['locals']): locals is Locals {
+ return '_actionPayload' in locals;
+}
+
+export function createGetActionResult(locals: APIContext['locals']): APIContext['getActionResult'] {
+ return (actionFn): any => {
+ if (
+ !hasActionPayload(locals) ||
+ actionFn.toString() !== getActionQueryString(locals._actionPayload.actionName)
+ ) {
+ return undefined;
+ }
+ return deserializeActionResult(locals._actionPayload.actionResult);
+ };
+}
+
+export function createCallAction(context: ActionAPIContext): APIContext['callAction'] {
+ return (baseAction, input) => {
+ Reflect.set(context, ACTION_API_CONTEXT_SYMBOL, true);
+ const action = baseAction.bind(context);
+ return action(input) as any;
+ };
+}
+
+let didInitLexer = false;
+
+/**
+ * Check whether the Actions config file is present.
+ */
+export async function isActionsFilePresent(fs: typeof fsMod, srcDir: URL) {
+ if (!didInitLexer) await eslexer.init;
+
+ const actionsFile = search(fs, srcDir);
+ if (!actionsFile) return false;
+
+ let contents: string;
+ try {
+ contents = fs.readFileSync(actionsFile, 'utf-8');
+ } catch {
+ return false;
+ }
+
+ // Check if `server` export is present.
+ // If not, the user may have an empty `actions` file,
+ // or may be using the `actions` file for another purpose
+ // (possible since actions are non-breaking for v4.X).
+ const [, exports] = eslexer.parse(contents, actionsFile.pathname);
+ for (const exp of exports) {
+ if (exp.n === 'server') {
+ return true;
+ }
+ }
+ return false;
+}
+
+function search(fs: typeof fsMod, srcDir: URL) {
+ const paths = [
+ 'actions.mjs',
+ 'actions.js',
+ 'actions.mts',
+ 'actions.ts',
+ 'actions/index.mjs',
+ 'actions/index.js',
+ 'actions/index.mts',
+ 'actions/index.ts',
+ ].map((p) => new URL(p, srcDir));
+ for (const file of paths) {
+ if (fs.existsSync(file)) {
+ return file;
+ }
+ }
+ return undefined;
+}
diff --git a/packages/astro/src/assets/README.md b/packages/astro/src/assets/README.md
new file mode 100644
index 000000000..9de1c5eb4
--- /dev/null
+++ b/packages/astro/src/assets/README.md
@@ -0,0 +1,3 @@
+# assets
+
+This directory powers the Assets story in Astro. Notably, it contains all the code related to optimizing images and serving them in the different modes Astro can run in (SSG, SSR, dev, build etc).
diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts
new file mode 100644
index 000000000..d46b985c5
--- /dev/null
+++ b/packages/astro/src/assets/build/generate.ts
@@ -0,0 +1,365 @@
+import fs, { readFileSync } from 'node:fs';
+import { basename } from 'node:path/posix';
+import { dim, green } from 'kleur/colors';
+import { getOutDirWithinCwd } from '../../core/build/common.js';
+import type { BuildPipeline } from '../../core/build/pipeline.js';
+import { getTimeStat } from '../../core/build/util.js';
+import { AstroError } from '../../core/errors/errors.js';
+import { AstroErrorData } from '../../core/errors/index.js';
+import type { Logger } from '../../core/logger/core.js';
+import { isRemotePath, removeLeadingForwardSlash } from '../../core/path.js';
+import type { MapValue } from '../../type-utils.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { getConfiguredImageService } from '../internal.js';
+import type { LocalImageService } from '../services/service.js';
+import type { AssetsGlobalStaticImagesList, ImageMetadata, ImageTransform } from '../types.js';
+import { isESMImportedImage } from '../utils/imageKind.js';
+import { type RemoteCacheEntry, loadRemoteImage, revalidateRemoteImage } from './remote.js';
+
+interface GenerationDataUncached {
+ cached: 'miss';
+ weight: {
+ before: number;
+ after: number;
+ };
+}
+
+interface GenerationDataCached {
+ cached: 'revalidated' | 'hit';
+}
+
+type GenerationData = GenerationDataUncached | GenerationDataCached;
+
+type AssetEnv = {
+ logger: Logger;
+ isSSR: boolean;
+ count: { total: number; current: number };
+ useCache: boolean;
+ assetsCacheDir: URL;
+ serverRoot: URL;
+ clientRoot: URL;
+ imageConfig: AstroConfig['image'];
+ assetsFolder: AstroConfig['build']['assets'];
+};
+
+type ImageData = {
+ data: Uint8Array;
+ expires: number;
+ etag?: string;
+ lastModified?: string;
+};
+
+export async function prepareAssetsGenerationEnv(
+ pipeline: BuildPipeline,
+ totalCount: number,
+): Promise<AssetEnv> {
+ const { config, logger, settings } = pipeline;
+ let useCache = true;
+ const assetsCacheDir = new URL('assets/', config.cacheDir);
+ const count = { total: totalCount, current: 1 };
+
+ // Ensure that the cache directory exists
+ try {
+ await fs.promises.mkdir(assetsCacheDir, { recursive: true });
+ } catch (err) {
+ logger.warn(
+ null,
+ `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}`,
+ );
+ useCache = false;
+ }
+
+ const isServerOutput = settings.buildOutput === 'server';
+ let serverRoot: URL, clientRoot: URL;
+ if (isServerOutput) {
+ serverRoot = config.build.server;
+ clientRoot = config.build.client;
+ } else {
+ serverRoot = getOutDirWithinCwd(config.outDir);
+ clientRoot = config.outDir;
+ }
+
+ return {
+ logger,
+ isSSR: isServerOutput,
+ count,
+ useCache,
+ assetsCacheDir,
+ serverRoot,
+ clientRoot,
+ imageConfig: config.image,
+ assetsFolder: config.build.assets,
+ };
+}
+
+function getFullImagePath(originalFilePath: string, env: AssetEnv): URL {
+ return new URL(removeLeadingForwardSlash(originalFilePath), env.serverRoot);
+}
+
+export async function generateImagesForPath(
+ originalFilePath: string,
+ transformsAndPath: MapValue<AssetsGlobalStaticImagesList>,
+ env: AssetEnv,
+) {
+ let originalImage: ImageData;
+
+ for (const [_, transform] of transformsAndPath.transforms) {
+ await generateImage(transform.finalPath, transform.transform);
+ }
+
+ // In SSR, we cannot know if an image is referenced in a server-rendered page, so we can't delete anything
+ // For instance, the same image could be referenced in both a server-rendered page and build-time-rendered page
+ if (
+ !env.isSSR &&
+ transformsAndPath.originalSrcPath &&
+ !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath)
+ ) {
+ try {
+ if (transformsAndPath.originalSrcPath) {
+ env.logger.debug(
+ 'assets',
+ `Deleting ${originalFilePath} as it's not referenced outside of image processing.`,
+ );
+ await fs.promises.unlink(getFullImagePath(originalFilePath, env));
+ }
+ } catch {
+ /* No-op, it's okay if we fail to delete one of the file, we're not too picky. */
+ }
+ }
+
+ async function generateImage(filepath: string, options: ImageTransform) {
+ const timeStart = performance.now();
+ const generationData = await generateImageInternal(filepath, options);
+
+ const timeEnd = performance.now();
+ const timeChange = getTimeStat(timeStart, timeEnd);
+ const timeIncrease = `(+${timeChange})`;
+ const statsText =
+ generationData.cached !== 'miss'
+ ? generationData.cached === 'hit'
+ ? `(reused cache entry)`
+ : `(revalidated cache entry)`
+ : `(before: ${generationData.weight.before}kB, after: ${generationData.weight.after}kB)`;
+ const count = `(${env.count.current}/${env.count.total})`;
+ env.logger.info(
+ null,
+ ` ${green('▶')} ${filepath} ${dim(statsText)} ${dim(timeIncrease)} ${dim(count)}`,
+ );
+ env.count.current++;
+ }
+
+ async function generateImageInternal(
+ filepath: string,
+ options: ImageTransform,
+ ): Promise<GenerationData> {
+ const isLocalImage = isESMImportedImage(options.src);
+ const finalFileURL = new URL('.' + filepath, env.clientRoot);
+
+ const finalFolderURL = new URL('./', finalFileURL);
+ await fs.promises.mkdir(finalFolderURL, { recursive: true });
+
+ const cacheFile = basename(filepath);
+ const cachedFileURL = new URL(cacheFile, env.assetsCacheDir);
+
+ // For remote images, we also save a JSON file with the expiration date, etag and last-modified date from the server
+ const cacheMetaFile = cacheFile + '.json';
+ const cachedMetaFileURL = new URL(cacheMetaFile, env.assetsCacheDir);
+
+ // Check if we have a cached entry first
+ try {
+ if (isLocalImage) {
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+
+ return {
+ cached: 'hit',
+ };
+ } else {
+ const JSONData = JSON.parse(readFileSync(cachedMetaFileURL, 'utf-8')) as RemoteCacheEntry;
+
+ if (!JSONData.expires) {
+ try {
+ await fs.promises.unlink(cachedFileURL);
+ } catch {
+ /* Old caches may not have a separate image binary, no-op */
+ }
+ await fs.promises.unlink(cachedMetaFileURL);
+
+ throw new Error(
+ `Malformed cache entry for ${filepath}, cache will be regenerated for this file.`,
+ );
+ }
+
+ // Upgrade old base64 encoded asset cache to the new format
+ if (JSONData.data) {
+ const { data, ...meta } = JSONData;
+
+ await Promise.all([
+ fs.promises.writeFile(cachedFileURL, Buffer.from(data, 'base64')),
+ writeCacheMetaFile(cachedMetaFileURL, meta, env),
+ ]);
+ }
+
+ // If the cache entry is not expired, use it
+ if (JSONData.expires > Date.now()) {
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+
+ return {
+ cached: 'hit',
+ };
+ }
+
+ // Try to revalidate the cache
+ if (JSONData.etag || JSONData.lastModified) {
+ try {
+ const revalidatedData = await revalidateRemoteImage(options.src as string, {
+ etag: JSONData.etag,
+ lastModified: JSONData.lastModified,
+ });
+
+ if (revalidatedData.data.length) {
+ // Image cache was stale, update original image to avoid redownload
+ originalImage = revalidatedData;
+ } else {
+ // Freshen cache on disk
+ await writeCacheMetaFile(cachedMetaFileURL, revalidatedData, env);
+
+ await fs.promises.copyFile(
+ cachedFileURL,
+ finalFileURL,
+ fs.constants.COPYFILE_FICLONE,
+ );
+ return { cached: 'revalidated' };
+ }
+ } catch (e) {
+ // Reuse stale cache if revalidation fails
+ env.logger.warn(
+ null,
+ `An error was encountered while revalidating a cached remote asset. Proceeding with stale cache. ${e}`,
+ );
+
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+ return { cached: 'hit' };
+ }
+ }
+
+ await fs.promises.unlink(cachedFileURL);
+ await fs.promises.unlink(cachedMetaFileURL);
+ }
+ } catch (e: any) {
+ if (e.code !== 'ENOENT') {
+ throw new Error(`An error was encountered while reading the cache file. Error: ${e}`);
+ }
+ // If the cache file doesn't exist, just move on, and we'll generate it
+ }
+
+ // The original filepath or URL from the image transform
+ const originalImagePath = isLocalImage
+ ? (options.src as ImageMetadata).src
+ : (options.src as string);
+
+ if (!originalImage) {
+ originalImage = await loadImage(originalFilePath, env);
+ }
+
+ let resultData: Partial<ImageData> = {
+ data: undefined,
+ expires: originalImage.expires,
+ etag: originalImage.etag,
+ lastModified: originalImage.lastModified,
+ };
+
+ const imageService = (await getConfiguredImageService()) as LocalImageService;
+
+ try {
+ resultData.data = (
+ await imageService.transform(
+ originalImage.data,
+ { ...options, src: originalImagePath },
+ env.imageConfig,
+ )
+ ).data;
+ } catch (e) {
+ const error = new AstroError(
+ {
+ ...AstroErrorData.CouldNotTransformImage,
+ message: AstroErrorData.CouldNotTransformImage.message(originalFilePath),
+ },
+ { cause: e },
+ );
+
+ throw error;
+ }
+
+ try {
+ // Write the cache entry
+ if (env.useCache) {
+ if (isLocalImage) {
+ await fs.promises.writeFile(cachedFileURL, resultData.data);
+ } else {
+ await Promise.all([
+ fs.promises.writeFile(cachedFileURL, resultData.data),
+ writeCacheMetaFile(cachedMetaFileURL, resultData as ImageData, env),
+ ]);
+ }
+ }
+ } catch (e) {
+ env.logger.warn(
+ null,
+ `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}`,
+ );
+ } finally {
+ // Write the final file
+ await fs.promises.writeFile(finalFileURL, resultData.data);
+ }
+
+ return {
+ cached: 'miss',
+ weight: {
+ // Divide by 1024 to get size in kilobytes
+ before: Math.trunc(originalImage.data.byteLength / 1024),
+ after: Math.trunc(Buffer.from(resultData.data).byteLength / 1024),
+ },
+ };
+ }
+}
+
+async function writeCacheMetaFile(
+ cachedMetaFileURL: URL,
+ resultData: Omit<ImageData, 'data'>,
+ env: AssetEnv,
+) {
+ try {
+ return await fs.promises.writeFile(
+ cachedMetaFileURL,
+ JSON.stringify({
+ expires: resultData.expires,
+ etag: resultData.etag,
+ lastModified: resultData.lastModified,
+ }),
+ );
+ } catch (e) {
+ env.logger.warn(
+ null,
+ `An error was encountered while writing the cache file for a remote asset. Proceeding without caching this asset. Error: ${e}`,
+ );
+ }
+}
+
+export function getStaticImageList(): AssetsGlobalStaticImagesList {
+ if (!globalThis?.astroAsset?.staticImages) {
+ return new Map();
+ }
+
+ return globalThis.astroAsset.staticImages;
+}
+
+async function loadImage(path: string, env: AssetEnv): Promise<ImageData> {
+ if (isRemotePath(path)) {
+ return await loadRemoteImage(path);
+ }
+
+ return {
+ data: await fs.promises.readFile(getFullImagePath(path, env)),
+ expires: 0,
+ };
+}
diff --git a/packages/astro/src/assets/build/remote.ts b/packages/astro/src/assets/build/remote.ts
new file mode 100644
index 000000000..55ee9a205
--- /dev/null
+++ b/packages/astro/src/assets/build/remote.ts
@@ -0,0 +1,108 @@
+import CachePolicy from 'http-cache-semantics';
+
+export type RemoteCacheEntry = {
+ data?: string;
+ expires: number;
+ etag?: string;
+ lastModified?: string;
+};
+
+export async function loadRemoteImage(src: string) {
+ const req = new Request(src);
+ const res = await fetch(req);
+
+ if (!res.ok) {
+ throw new Error(
+ `Failed to load remote image ${src}. The request did not return a 200 OK response. (received ${res.status}))`,
+ );
+ }
+
+ // calculate an expiration date based on the response's TTL
+ const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res));
+ const expires = policy.storable() ? policy.timeToLive() : 0;
+
+ return {
+ data: Buffer.from(await res.arrayBuffer()),
+ expires: Date.now() + expires,
+ etag: res.headers.get('Etag') ?? undefined,
+ lastModified: res.headers.get('Last-Modified') ?? undefined,
+ };
+}
+
+/**
+ * Revalidate a cached remote asset using its entity-tag or modified date.
+ * Uses the [If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) and [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since)
+ * headers to check with the remote server if the cached version of a remote asset is still up to date.
+ * The remote server may respond that the cached asset is still up-to-date if the entity-tag or modification time matches (304 Not Modified), or respond with an updated asset (200 OK)
+ * @param src - url to remote asset
+ * @param revalidationData - an object containing the stored Entity-Tag of the cached asset and/or the Last Modified time
+ * @returns An ImageData object containing the asset data, a new expiry time, and the asset's etag. The data buffer will be empty if the asset was not modified.
+ */
+export async function revalidateRemoteImage(
+ src: string,
+ revalidationData: { etag?: string; lastModified?: string },
+) {
+ const headers = {
+ ...(revalidationData.etag && { 'If-None-Match': revalidationData.etag }),
+ ...(revalidationData.lastModified && { 'If-Modified-Since': revalidationData.lastModified }),
+ };
+ const req = new Request(src, { headers });
+ const res = await fetch(req);
+
+ // Asset not modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
+ if (!res.ok && res.status !== 304) {
+ throw new Error(
+ `Failed to revalidate cached remote image ${src}. The request did not return a 200 OK / 304 NOT MODIFIED response. (received ${res.status} ${res.statusText})`,
+ );
+ }
+
+ const data = Buffer.from(await res.arrayBuffer());
+
+ if (res.ok && !data.length) {
+ // Server did not include body but indicated cache was stale
+ return await loadRemoteImage(src);
+ }
+
+ // calculate an expiration date based on the response's TTL
+ const policy = new CachePolicy(
+ webToCachePolicyRequest(req),
+ webToCachePolicyResponse(
+ res.ok ? res : new Response(null, { status: 200, headers: res.headers }),
+ ), // 304 responses themselves are not cacheable, so just pretend to get the refreshed TTL
+ );
+ const expires = policy.storable() ? policy.timeToLive() : 0;
+
+ return {
+ data,
+ expires: Date.now() + expires,
+ // While servers should respond with the same headers as a 200 response, if they don't we should reuse the stored value
+ etag: res.headers.get('Etag') ?? (res.ok ? undefined : revalidationData.etag),
+ lastModified:
+ res.headers.get('Last-Modified') ?? (res.ok ? undefined : revalidationData.lastModified),
+ };
+}
+
+function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request {
+ let headers: CachePolicy.Headers = {};
+ // Be defensive here due to a cookie header bug in node@18.14.1 + undici
+ try {
+ headers = Object.fromEntries(_headers.entries());
+ } catch {}
+ return {
+ method,
+ url,
+ headers,
+ };
+}
+
+function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response {
+ let headers: CachePolicy.Headers = {};
+ // Be defensive here due to a cookie header bug in node@18.14.1 + undici
+ try {
+ headers = Object.fromEntries(_headers.entries());
+ } catch {}
+ return {
+ status,
+ headers,
+ };
+}
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
new file mode 100644
index 000000000..5fae641ae
--- /dev/null
+++ b/packages/astro/src/assets/consts.ts
@@ -0,0 +1,37 @@
+export const VIRTUAL_MODULE_ID = 'astro:assets';
+export const VIRTUAL_SERVICE_ID = 'virtual:image-service';
+export const VALID_INPUT_FORMATS = [
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'tiff',
+ 'webp',
+ 'gif',
+ 'svg',
+ 'avif',
+] as const;
+/**
+ * Valid formats that our base services support.
+ * Certain formats can be imported (namely SVGs) but will not be processed.
+ */
+export const VALID_SUPPORTED_FORMATS = [
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'tiff',
+ 'webp',
+ 'gif',
+ 'svg',
+ 'avif',
+] as const;
+export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
+export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
+export const DEFAULT_HASH_PROPS = [
+ 'src',
+ 'width',
+ 'height',
+ 'format',
+ 'quality',
+ 'fit',
+ 'position',
+];
diff --git a/packages/astro/src/assets/endpoint/config.ts b/packages/astro/src/assets/endpoint/config.ts
new file mode 100644
index 000000000..2deb3dd66
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/config.ts
@@ -0,0 +1,57 @@
+import {
+ removeLeadingForwardSlash,
+ removeTrailingForwardSlash,
+} from '@astrojs/internal-helpers/path';
+import { resolveInjectedRoute } from '../../core/routing/manifest/create.js';
+import { getPattern } from '../../core/routing/manifest/pattern.js';
+import type { AstroSettings, RoutesList } from '../../types/astro.js';
+import type { RouteData } from '../../types/public/internal.js';
+
+export function injectImageEndpoint(
+ settings: AstroSettings,
+ manifest: RoutesList,
+ mode: 'dev' | 'build',
+ cwd?: string,
+) {
+ manifest.routes.unshift(getImageEndpointData(settings, mode, cwd));
+}
+
+function getImageEndpointData(
+ settings: AstroSettings,
+ mode: 'dev' | 'build',
+ cwd?: string,
+): RouteData {
+ const endpointEntrypoint =
+ settings.config.image.endpoint.entrypoint === undefined // If not set, use default endpoint
+ ? mode === 'dev'
+ ? 'astro/assets/endpoint/node'
+ : 'astro/assets/endpoint/generic'
+ : settings.config.image.endpoint.entrypoint;
+
+ const segments = [
+ [
+ {
+ content: removeTrailingForwardSlash(
+ removeLeadingForwardSlash(settings.config.image.endpoint.route),
+ ),
+ dynamic: false,
+ spread: false,
+ },
+ ],
+ ];
+
+ return {
+ type: 'endpoint',
+ isIndex: false,
+ route: settings.config.image.endpoint.route,
+ pattern: getPattern(segments, settings.config.base, settings.config.trailingSlash),
+ segments,
+ params: [],
+ component: resolveInjectedRoute(endpointEntrypoint, settings.config.root, cwd).component,
+ generate: () => '',
+ pathname: settings.config.image.endpoint.route,
+ prerender: false,
+ fallbackRoutes: [],
+ origin: 'internal',
+ };
+}
diff --git a/packages/astro/src/assets/endpoint/generic.ts b/packages/astro/src/assets/endpoint/generic.ts
new file mode 100644
index 000000000..f8924134b
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/generic.ts
@@ -0,0 +1,79 @@
+// @ts-expect-error
+import { imageConfig } from 'astro:assets';
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import * as mime from 'mrmime';
+import type { APIRoute } from '../../types/public/common.js';
+import { getConfiguredImageService } from '../internal.js';
+import { etag } from '../utils/etag.js';
+import { isRemoteAllowed } from '../utils/remotePattern.js';
+
+async function loadRemoteImage(src: URL, headers: Headers) {
+ try {
+ const res = await fetch(src, {
+ // Forward all headers from the original request
+ headers,
+ });
+
+ if (!res.ok) {
+ return undefined;
+ }
+
+ return await res.arrayBuffer();
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Endpoint used in dev and SSR to serve optimized images by the base image services
+ */
+export const GET: APIRoute = async ({ request }) => {
+ try {
+ const imageService = await getConfiguredImageService();
+
+ if (!('transform' in imageService)) {
+ throw new Error('Configured image service is not a local service');
+ }
+
+ const url = new URL(request.url);
+ const transform = await imageService.parseURL(url, imageConfig);
+
+ if (!transform?.src) {
+ throw new Error('Incorrect transform returned by `parseURL`');
+ }
+
+ let inputBuffer: ArrayBuffer | undefined = undefined;
+
+ const isRemoteImage = isRemotePath(transform.src);
+ const sourceUrl = isRemoteImage ? new URL(transform.src) : new URL(transform.src, url.origin);
+
+ if (isRemoteImage && isRemoteAllowed(transform.src, imageConfig) === false) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ inputBuffer = await loadRemoteImage(sourceUrl, isRemoteImage ? new Headers() : request.headers);
+
+ if (!inputBuffer) {
+ return new Response('Not Found', { status: 404 });
+ }
+
+ const { data, format } = await imageService.transform(
+ new Uint8Array(inputBuffer),
+ transform,
+ imageConfig,
+ );
+
+ return new Response(data, {
+ status: 200,
+ headers: {
+ 'Content-Type': mime.lookup(format) ?? `image/${format}`,
+ 'Cache-Control': 'public, max-age=31536000',
+ ETag: etag(data.toString()),
+ Date: new Date().toUTCString(),
+ },
+ });
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return new Response(`Server Error: ${err}`, { status: 500 });
+ }
+};
diff --git a/packages/astro/src/assets/endpoint/node.ts b/packages/astro/src/assets/endpoint/node.ts
new file mode 100644
index 000000000..4b18deb38
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/node.ts
@@ -0,0 +1,134 @@
+import { readFile } from 'node:fs/promises';
+
+import os from 'node:os';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+// @ts-expect-error
+import { assetsDir, imageConfig, outDir } from 'astro:assets';
+import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path';
+import * as mime from 'mrmime';
+import type { APIRoute } from '../../types/public/common.js';
+import { getConfiguredImageService } from '../internal.js';
+import { etag } from '../utils/etag.js';
+import { isRemoteAllowed } from '../utils/remotePattern.js';
+
+function replaceFileSystemReferences(src: string) {
+ return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, '');
+}
+
+async function loadLocalImage(src: string, url: URL) {
+ const assetsDirPath = fileURLToPath(assetsDir);
+
+ let fileUrl;
+ if (import.meta.env.DEV) {
+ fileUrl = pathToFileURL(removeQueryString(replaceFileSystemReferences(src)));
+ } else {
+ try {
+ // If the _image segment isn't at the start of the path, we have a base
+ const idx = url.pathname.indexOf('/_image');
+ if (idx > 0) {
+ // Remove the base path
+ src = src.slice(idx);
+ }
+ fileUrl = new URL('.' + src, outDir);
+ const filePath = fileURLToPath(fileUrl);
+
+ if (!isAbsolute(filePath) || !filePath.startsWith(assetsDirPath)) {
+ return undefined;
+ }
+ } catch {
+ return undefined;
+ }
+ }
+
+ let buffer: Buffer | undefined = undefined;
+
+ try {
+ buffer = await readFile(fileUrl);
+ } catch {
+ // Fallback to try to load the file using `fetch`
+ try {
+ const sourceUrl = new URL(src, url.origin);
+ buffer = await loadRemoteImage(sourceUrl);
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return undefined;
+ }
+ }
+
+ return buffer;
+}
+
+async function loadRemoteImage(src: URL) {
+ try {
+ const res = await fetch(src);
+
+ if (!res.ok) {
+ return undefined;
+ }
+
+ return Buffer.from(await res.arrayBuffer());
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Endpoint used in dev and SSR to serve optimized images by the base image services
+ */
+export const GET: APIRoute = async ({ request }) => {
+ try {
+ const imageService = await getConfiguredImageService();
+
+ if (!('transform' in imageService)) {
+ throw new Error('Configured image service is not a local service');
+ }
+
+ const url = new URL(request.url);
+ const transform = await imageService.parseURL(url, imageConfig);
+
+ if (!transform?.src) {
+ const err = new Error(
+ 'Incorrect transform returned by `parseURL`. Expected a transform with a `src` property.',
+ );
+ console.error('Could not parse image transform from URL:', err);
+ return new Response('Internal Server Error', { status: 500 });
+ }
+
+ let inputBuffer: Buffer | undefined = undefined;
+
+ if (isRemotePath(transform.src)) {
+ if (isRemoteAllowed(transform.src, imageConfig) === false) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ inputBuffer = await loadRemoteImage(new URL(transform.src));
+ } else {
+ inputBuffer = await loadLocalImage(transform.src, url);
+ }
+
+ if (!inputBuffer) {
+ return new Response('Internal Server Error', { status: 500 });
+ }
+
+ const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig);
+
+ return new Response(data, {
+ status: 200,
+ headers: {
+ 'Content-Type': mime.lookup(format) ?? `image/${format}`,
+ 'Cache-Control': 'public, max-age=31536000',
+ ETag: etag(data.toString()),
+ Date: new Date().toUTCString(),
+ },
+ });
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return new Response(
+ import.meta.env.DEV ? `Could not process image request: ${err}` : `Internal Server Error`,
+ {
+ status: 500,
+ },
+ );
+ }
+};
diff --git a/packages/astro/src/assets/index.ts b/packages/astro/src/assets/index.ts
new file mode 100644
index 000000000..9eeccf250
--- /dev/null
+++ b/packages/astro/src/assets/index.ts
@@ -0,0 +1,3 @@
+export { getConfiguredImageService, getImage } from './internal.js';
+export { baseService, isLocalService } from './services/service.js';
+export { type LocalImageProps, type RemoteImageProps } from './types.js';
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
new file mode 100644
index 000000000..d9c2db5a0
--- /dev/null
+++ b/packages/astro/src/assets/internal.ts
@@ -0,0 +1,221 @@
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { AstroConfig } from '../types/public/config.js';
+import { DEFAULT_HASH_PROPS } from './consts.js';
+import {
+ DEFAULT_RESOLUTIONS,
+ LIMITED_RESOLUTIONS,
+ getSizesAttribute,
+ getWidths,
+} from './layout.js';
+import { type ImageService, isLocalService } from './services/service.js';
+import {
+ type GetImageResult,
+ type ImageTransform,
+ type SrcSetValue,
+ type UnresolvedImageTransform,
+ isImageMetadata,
+} from './types.js';
+import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
+import { inferRemoteSize } from './utils/remoteProbe.js';
+
+export async function getConfiguredImageService(): Promise<ImageService> {
+ if (!globalThis?.astroAsset?.imageService) {
+ const { default: service }: { default: ImageService } = await import(
+ // @ts-expect-error
+ 'virtual:image-service'
+ ).catch((e) => {
+ const error = new AstroError(AstroErrorData.InvalidImageService);
+ error.cause = e;
+ throw error;
+ });
+
+ if (!globalThis.astroAsset) globalThis.astroAsset = {};
+ globalThis.astroAsset.imageService = service;
+ return service;
+ }
+
+ return globalThis.astroAsset.imageService;
+}
+
+type ImageConfig = AstroConfig['image'] & {
+ experimentalResponsiveImages: boolean;
+};
+
+export async function getImage(
+ options: UnresolvedImageTransform,
+ imageConfig: ImageConfig,
+): Promise<GetImageResult> {
+ if (!options || typeof options !== 'object') {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImageOptions,
+ message: AstroErrorData.ExpectedImageOptions.message(JSON.stringify(options)),
+ });
+ }
+ if (typeof options.src === 'undefined') {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImage,
+ message: AstroErrorData.ExpectedImage.message(
+ options.src,
+ 'undefined',
+ JSON.stringify(options),
+ ),
+ });
+ }
+
+ if (isImageMetadata(options)) {
+ throw new AstroError(AstroErrorData.ExpectedNotESMImage);
+ }
+
+ const service = await getConfiguredImageService();
+
+ // If the user inlined an import, something fairly common especially in MDX, or passed a function that returns an Image, await it for them
+ const resolvedOptions: ImageTransform = {
+ ...options,
+ src: await resolveSrc(options.src),
+ };
+
+ let originalWidth: number | undefined;
+ let originalHeight: number | undefined;
+ let originalFormat: string | undefined;
+
+ // Infer size for remote images if inferSize is true
+ if (
+ options.inferSize &&
+ isRemoteImage(resolvedOptions.src) &&
+ isRemotePath(resolvedOptions.src)
+ ) {
+ const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
+ resolvedOptions.width ??= result.width;
+ resolvedOptions.height ??= result.height;
+ originalWidth = result.width;
+ originalHeight = result.height;
+ originalFormat = result.format;
+ delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes
+ }
+
+ const originalFilePath = isESMImportedImage(resolvedOptions.src)
+ ? resolvedOptions.src.fsPath
+ : undefined; // Only set for ESM imports, where we do have a file path
+
+ // Clone the `src` object if it's an ESM import so that we don't refer to any properties of the original object
+ // Causing our generate step to think the image is used outside of the image optimization pipeline
+ const clonedSrc = isESMImportedImage(resolvedOptions.src)
+ ? // @ts-expect-error - clone is a private, hidden prop
+ (resolvedOptions.src.clone ?? resolvedOptions.src)
+ : resolvedOptions.src;
+
+ if (isESMImportedImage(clonedSrc)) {
+ originalWidth = clonedSrc.width;
+ originalHeight = clonedSrc.height;
+ originalFormat = clonedSrc.format;
+ }
+
+ if (originalWidth && originalHeight) {
+ // Calculate any missing dimensions from the aspect ratio, if available
+ const aspectRatio = originalWidth / originalHeight;
+ if (resolvedOptions.height && !resolvedOptions.width) {
+ resolvedOptions.width = Math.round(resolvedOptions.height * aspectRatio);
+ } else if (resolvedOptions.width && !resolvedOptions.height) {
+ resolvedOptions.height = Math.round(resolvedOptions.width / aspectRatio);
+ } else if (!resolvedOptions.width && !resolvedOptions.height) {
+ resolvedOptions.width = originalWidth;
+ resolvedOptions.height = originalHeight;
+ }
+ }
+ resolvedOptions.src = clonedSrc;
+
+ const layout = options.layout ?? imageConfig.experimentalLayout;
+
+ if (imageConfig.experimentalResponsiveImages && layout) {
+ resolvedOptions.widths ||= getWidths({
+ width: resolvedOptions.width,
+ layout,
+ originalWidth,
+ breakpoints: imageConfig.experimentalBreakpoints?.length
+ ? imageConfig.experimentalBreakpoints
+ : isLocalService(service)
+ ? LIMITED_RESOLUTIONS
+ : DEFAULT_RESOLUTIONS,
+ });
+ resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });
+
+ if (resolvedOptions.priority) {
+ resolvedOptions.loading ??= 'eager';
+ resolvedOptions.decoding ??= 'sync';
+ resolvedOptions.fetchpriority ??= 'high';
+ } else {
+ resolvedOptions.loading ??= 'lazy';
+ resolvedOptions.decoding ??= 'async';
+ resolvedOptions.fetchpriority ??= 'auto';
+ }
+ delete resolvedOptions.priority;
+ delete resolvedOptions.densities;
+ }
+
+ const validatedOptions = service.validateOptions
+ ? await service.validateOptions(resolvedOptions, imageConfig)
+ : resolvedOptions;
+
+ // Get all the options for the different srcSets
+ const srcSetTransforms = service.getSrcSet
+ ? await service.getSrcSet(validatedOptions, imageConfig)
+ : [];
+
+ let imageURL = await service.getURL(validatedOptions, imageConfig);
+
+ const matchesOriginal = (transform: ImageTransform) =>
+ transform.width === originalWidth &&
+ transform.height === originalHeight &&
+ transform.format === originalFormat;
+
+ let srcSets: SrcSetValue[] = await Promise.all(
+ srcSetTransforms.map(async (srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : await service.getURL(srcSet.transform, imageConfig),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ }),
+ );
+
+ if (
+ isLocalService(service) &&
+ globalThis.astroAsset.addStaticImage &&
+ !(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
+ ) {
+ const propsToHash = service.propertiesToHash ?? DEFAULT_HASH_PROPS;
+ imageURL = globalThis.astroAsset.addStaticImage(
+ validatedOptions,
+ propsToHash,
+ originalFilePath,
+ );
+ srcSets = srcSetTransforms.map((srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ });
+ }
+
+ return {
+ rawOptions: resolvedOptions,
+ options: validatedOptions,
+ src: imageURL,
+ srcSet: {
+ values: srcSets,
+ attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(', '),
+ },
+ attributes:
+ service.getHTMLAttributes !== undefined
+ ? await service.getHTMLAttributes(validatedOptions, imageConfig)
+ : {},
+ };
+}
diff --git a/packages/astro/src/assets/layout.ts b/packages/astro/src/assets/layout.ts
new file mode 100644
index 000000000..adc117f39
--- /dev/null
+++ b/packages/astro/src/assets/layout.ts
@@ -0,0 +1,118 @@
+import type { ImageLayout } from './types.js';
+
+// Common screen widths. These will be filtered according to the image size and layout
+export const DEFAULT_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 960, // older horizontal phones
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 1920, // 1080p
+ 2048, // QXGA
+ 2560, // WQXGA
+ 3200, // QHD+
+ 3840, // 4K
+ 4480, // 4.5K
+ 5120, // 5K
+ 6016, // 6K
+];
+
+// A more limited set of screen widths, for statically generated images
+export const LIMITED_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 2048, // QXGA
+ 2560, // WQXGA
+];
+
+/**
+ * Gets the breakpoints for an image, based on the layout and width
+ *
+ * The rules are as follows:
+ *
+ * - For full-width layout we return all breakpoints smaller than the original image width
+ * - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that.
+ * - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that.
+ */
+export const getWidths = ({
+ width,
+ layout,
+ breakpoints = DEFAULT_RESOLUTIONS,
+ originalWidth,
+}: {
+ width?: number;
+ layout: ImageLayout;
+ breakpoints?: Array<number>;
+ originalWidth?: number;
+}): Array<number> => {
+ const smallerThanOriginal = (w: number) => !originalWidth || w <= originalWidth;
+
+ // For full-width layout we return all breakpoints smaller than the original image width
+ if (layout === 'full-width') {
+ return breakpoints.filter(smallerThanOriginal);
+ }
+ // For other layouts we need a width to generate breakpoints. If no width is provided, we return an empty array
+ if (!width) {
+ return [];
+ }
+ const doubleWidth = width * 2;
+ // For fixed layout we want to return the 1x and 2x widths. We only do this if the original image is large enough to do this though.
+ const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth;
+ if (layout === 'fixed') {
+ return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize];
+ }
+
+ // For responsive layout we want to return all breakpoints smaller than 2x requested width.
+ if (layout === 'responsive') {
+ return (
+ [
+ // Always include the image at 1x and 2x the specified width
+ width,
+ doubleWidth,
+ ...breakpoints,
+ ]
+ // Filter out any resolutions that are larger than the double-resolution image or source image
+ .filter((w) => w <= maxSize)
+ // Sort the resolutions in ascending order
+ .sort((a, b) => a - b)
+ );
+ }
+
+ return [];
+};
+
+/**
+ * Gets the `sizes` attribute for an image, based on the layout and width
+ */
+export const getSizesAttribute = ({
+ width,
+ layout,
+}: { width?: number; layout?: ImageLayout }): string | undefined => {
+ if (!width || !layout) {
+ return undefined;
+ }
+ switch (layout) {
+ // If screen is wider than the max size then image width is the max size,
+ // otherwise it's the width of the screen
+ case `responsive`:
+ return `(min-width: ${width}px) ${width}px, 100vw`;
+
+ // Image is always the same width, whatever the size of the screen
+ case `fixed`:
+ return `${width}px`;
+
+ // Image is always the width of the screen
+ case `full-width`:
+ return `100vw`;
+
+ case 'none':
+ default:
+ return undefined;
+ }
+};
diff --git a/packages/astro/src/assets/runtime.ts b/packages/astro/src/assets/runtime.ts
new file mode 100644
index 000000000..b0f23e4e8
--- /dev/null
+++ b/packages/astro/src/assets/runtime.ts
@@ -0,0 +1,97 @@
+import {
+ createComponent,
+ render,
+ spreadAttributes,
+ unescapeHTML,
+} from '../runtime/server/index.js';
+import type { SSRResult } from '../types/public/index.js';
+import type { ImageMetadata } from './types.js';
+
+export interface SvgComponentProps {
+ meta: ImageMetadata;
+ attributes: Record<string, string>;
+ children: string;
+}
+
+/**
+ * Make sure these IDs are kept on the module-level so they're incremented on a per-page basis
+ */
+const countersByPage = new WeakMap<SSRResult, number>();
+
+export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
+ const renderedIds = new WeakMap<SSRResult, string>();
+
+ const Component = createComponent((result, props) => {
+ let counter = countersByPage.get(result) ?? 0;
+
+ const {
+ title: titleProp,
+ viewBox,
+ mode,
+ ...normalizedProps
+ } = normalizeProps(attributes, props);
+ const title = titleProp ? unescapeHTML(`<title>${titleProp}</title>`) : '';
+
+ if (mode === 'sprite') {
+ // On the first render, include the symbol definition and bump the counter
+ let symbol: any = '';
+ let id = renderedIds.get(result);
+ if (!id) {
+ countersByPage.set(result, ++counter);
+ id = `a:${counter}`;
+ // We only need the viewBox on the symbol definition, we can drop it everywhere else
+ symbol = unescapeHTML(`<symbol${spreadAttributes({ viewBox, id })}>${children}</symbol>`);
+ renderedIds.set(result, id);
+ }
+
+ return render`<svg${spreadAttributes(normalizedProps)}>${title}${symbol}<use href="#${id}" /></svg>`;
+ }
+
+ // Default to inline mode
+ return render`<svg${spreadAttributes({ viewBox, ...normalizedProps })}>${title}${unescapeHTML(children)}</svg>`;
+ });
+
+ if (import.meta.env.DEV) {
+ // Prevent revealing that this is a component
+ makeNonEnumerable(Component);
+
+ // Maintaining the current `console.log` output for SVG imports
+ Object.defineProperty(Component, Symbol.for('nodejs.util.inspect.custom'), {
+ value: (_: any, opts: any, inspect: any) => inspect(meta, opts),
+ });
+ }
+
+ // Attaching the metadata to the component to maintain current functionality
+ return Object.assign(Component, meta);
+}
+
+type SvgAttributes = Record<string, any>;
+
+/**
+ * Some attributes required for `image/svg+xml` are irrelevant when inlined in a `text/html` document. We can save a few bytes by dropping them.
+ */
+const ATTRS_TO_DROP = ['xmlns', 'xmlns:xlink', 'version'];
+const DEFAULT_ATTRS: SvgAttributes = { role: 'img' };
+
+export function dropAttributes(attributes: SvgAttributes) {
+ for (const attr of ATTRS_TO_DROP) {
+ delete attributes[attr];
+ }
+
+ return attributes;
+}
+
+function normalizeProps(attributes: SvgAttributes, { size, ...props }: SvgAttributes) {
+ if (size !== undefined && props.width === undefined && props.height === undefined) {
+ props.height = size;
+ props.width = size;
+ }
+
+ return dropAttributes({ ...DEFAULT_ATTRS, ...attributes, ...props });
+}
+
+function makeNonEnumerable(object: Record<string, any>) {
+ for (const property in object) {
+ Object.defineProperty(object, property, { enumerable: false });
+ }
+}
diff --git a/packages/astro/src/assets/services/noop.ts b/packages/astro/src/assets/services/noop.ts
new file mode 100644
index 000000000..b0fe51733
--- /dev/null
+++ b/packages/astro/src/assets/services/noop.ts
@@ -0,0 +1,15 @@
+import { type LocalImageService, baseService } from './service.js';
+
+// Empty service used for platforms that don't support Sharp / users who don't want transformations.
+const noopService: LocalImageService = {
+ ...baseService,
+ propertiesToHash: ['src'],
+ async transform(inputBuffer, transformOptions) {
+ return {
+ data: inputBuffer,
+ format: transformOptions.format,
+ };
+ },
+};
+
+export default noopService;
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
new file mode 100644
index 000000000..ee3bcb587
--- /dev/null
+++ b/packages/astro/src/assets/services/service.ts
@@ -0,0 +1,428 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import { isRemotePath, joinPaths } from '../../core/path.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
+import type {
+ ImageFit,
+ ImageOutputFormat,
+ ImageTransform,
+ UnresolvedSrcSetValue,
+} from '../types.js';
+import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
+import { isRemoteAllowed } from '../utils/remotePattern.js';
+
+export type ImageService = LocalImageService | ExternalImageService;
+
+export function isLocalService(service: ImageService | undefined): service is LocalImageService {
+ if (!service) {
+ return false;
+ }
+
+ return 'transform' in service;
+}
+
+export function parseQuality(quality: string): string | number {
+ let result = parseInt(quality);
+ if (Number.isNaN(result)) {
+ return quality;
+ }
+
+ return result;
+}
+
+type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
+ service: { entrypoint: string; config: T };
+};
+
+interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
+ /**
+ * Return the URL to the endpoint or URL your images are generated from.
+ *
+ * For a local service, your service should expose an endpoint handling the image requests, or use Astro's which by default, is located at `/_image`.
+ *
+ * For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image`
+ *
+ */
+ getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
+ /**
+ * Generate additional `srcset` values for the image.
+ *
+ * While in most cases this is exclusively used for `srcset`, it can also be used in a more generic way to generate
+ * multiple variants of the same image. For instance, you can use this to generate multiple aspect ratios or multiple formats.
+ */
+ getSrcSet?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => UnresolvedSrcSetValue[] | Promise<UnresolvedSrcSetValue[]>;
+ /**
+ * Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
+ *
+ * For example, you might want to return the `width` and `height` to avoid CLS, or a particular `class` or `style`.
+ * In most cases, you'll want to return directly what your user supplied you, minus the attributes that were used to generate the image.
+ */
+ getHTMLAttributes?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => Record<string, any> | Promise<Record<string, any>>;
+ /**
+ * Validate and return the options passed by the user.
+ *
+ * This method is useful to present errors to users who have entered invalid options.
+ * For instance, if they are missing a required property or have entered an invalid image format.
+ *
+ * This method should returns options, and can be used to set defaults (ex: a default output format to be used if the user didn't specify one.)
+ */
+ validateOptions?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => ImageTransform | Promise<ImageTransform>;
+}
+
+export type ExternalImageService<T extends Record<string, any> = Record<string, any>> =
+ SharedServiceProps<T>;
+
+export type LocalImageTransform = {
+ src: string;
+ [key: string]: any;
+};
+
+export interface LocalImageService<T extends Record<string, any> = Record<string, any>>
+ extends SharedServiceProps<T> {
+ /**
+ * Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`.
+ *
+ * In most cases, this will get query parameters using, for example, `params.get('width')` and return those.
+ */
+ parseURL: (
+ url: URL,
+ imageConfig: ImageConfig<T>,
+ ) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
+ /**
+ * Performs the image transformations on the input image and returns both the binary data and
+ * final image format of the optimized image.
+ */
+ transform: (
+ inputBuffer: Uint8Array,
+ transform: LocalImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => Promise<{ data: Uint8Array; format: ImageOutputFormat }>;
+
+ /**
+ * A list of properties that should be used to generate the hash for the image.
+ *
+ * Generally, this should be all the properties that can change the result of the image. By default, this is `src`, `width`, `height`, `quality`, and `format`.
+ */
+ propertiesToHash?: string[];
+}
+
+export type BaseServiceTransform = {
+ src: string;
+ width?: number;
+ height?: number;
+ format: string;
+ quality?: string | null;
+ fit?: ImageFit;
+ position?: string;
+};
+
+const sortNumeric = (a: number, b: number) => a - b;
+
+/**
+ * Basic local service using the included `_image` endpoint.
+ * This service intentionally does not implement `transform`.
+ *
+ * Example usage:
+ * ```ts
+ * const service = {
+ * getURL: baseService.getURL,
+ * parseURL: baseService.parseURL,
+ * getHTMLAttributes: baseService.getHTMLAttributes,
+ * async transform(inputBuffer, transformOptions) {...}
+ * }
+ * ```
+ *
+ * This service adhere to the included services limitations:
+ * - Remote images are passed as is.
+ * - Only a limited amount of formats are supported.
+ * - For remote images, `width` and `height` are always required.
+ *
+ */
+export const baseService: Omit<LocalImageService, 'transform'> = {
+ propertiesToHash: DEFAULT_HASH_PROPS,
+ validateOptions(options) {
+ // `src` is missing or is `undefined`.
+ if (!options.src || (!isRemoteImage(options.src) && !isESMImportedImage(options.src))) {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImage,
+ message: AstroErrorData.ExpectedImage.message(
+ JSON.stringify(options.src),
+ typeof options.src,
+ JSON.stringify(options, (_, v) => (v === undefined ? null : v)),
+ ),
+ });
+ }
+
+ if (!isESMImportedImage(options.src)) {
+ // User passed an `/@fs/` path or a filesystem path instead of the full image.
+ if (
+ options.src.startsWith('/@fs/') ||
+ (!isRemotePath(options.src) && !options.src.startsWith('/'))
+ ) {
+ throw new AstroError({
+ ...AstroErrorData.LocalImageUsedWrongly,
+ message: AstroErrorData.LocalImageUsedWrongly.message(options.src),
+ });
+ }
+
+ // For remote images, width and height are explicitly required as we can't infer them from the file
+ let missingDimension: 'width' | 'height' | 'both' | undefined;
+ if (!options.width && !options.height) {
+ missingDimension = 'both';
+ } else if (!options.width && options.height) {
+ missingDimension = 'width';
+ } else if (options.width && !options.height) {
+ missingDimension = 'height';
+ }
+
+ if (missingDimension) {
+ throw new AstroError({
+ ...AstroErrorData.MissingImageDimension,
+ message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src),
+ });
+ }
+ } else {
+ if (!VALID_SUPPORTED_FORMATS.includes(options.src.format as any)) {
+ throw new AstroError({
+ ...AstroErrorData.UnsupportedImageFormat,
+ message: AstroErrorData.UnsupportedImageFormat.message(
+ options.src.format,
+ options.src.src,
+ VALID_SUPPORTED_FORMATS,
+ ),
+ });
+ }
+
+ if (options.widths && options.densities) {
+ throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions);
+ }
+
+ // We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one
+ if (options.src.format === 'svg') {
+ options.format = 'svg';
+ }
+
+ if (
+ (options.src.format === 'svg' && options.format !== 'svg') ||
+ (options.src.format !== 'svg' && options.format === 'svg')
+ ) {
+ throw new AstroError(AstroErrorData.UnsupportedImageConversion);
+ }
+ }
+
+ // If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality
+ // In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
+ if (!options.format) {
+ options.format = DEFAULT_OUTPUT_FORMAT;
+ }
+
+ // Sometimes users will pass number generated from division, which can result in floating point numbers
+ if (options.width) options.width = Math.round(options.width);
+ if (options.height) options.height = Math.round(options.height);
+ if (options.layout && options.width && options.height) {
+ options.fit ??= 'cover';
+ delete options.layout;
+ }
+ if (options.fit === 'none') {
+ delete options.fit;
+ }
+ return options;
+ },
+ getHTMLAttributes(options) {
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const {
+ src,
+ width,
+ height,
+ format,
+ quality,
+ densities,
+ widths,
+ formats,
+ layout,
+ priority,
+ fit,
+ position,
+ ...attributes
+ } = options;
+ return {
+ ...attributes,
+ width: targetWidth,
+ height: targetHeight,
+ loading: attributes.loading ?? 'lazy',
+ decoding: attributes.decoding ?? 'async',
+ };
+ },
+ getSrcSet(options): Array<UnresolvedSrcSetValue> {
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const aspectRatio = targetWidth / targetHeight;
+ const { widths, densities } = options;
+ const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
+
+ let transformedWidths = (widths ?? []).sort(sortNumeric);
+
+ // For remote images, we don't know the original image's dimensions, so we cannot know the maximum width
+ // It is ultimately the user's responsibility to make sure they don't request images larger than the original
+ let imageWidth = options.width;
+ let maxWidth = Infinity;
+
+ // However, if it's an imported image, we can use the original image's width as a maximum width
+ if (isESMImportedImage(options.src)) {
+ imageWidth = options.src.width;
+ maxWidth = imageWidth;
+
+ // We've already sorted the widths, so we'll remove any that are larger than the original image's width
+ if (transformedWidths.length > 0 && transformedWidths.at(-1)! > maxWidth) {
+ transformedWidths = transformedWidths.filter((width) => width <= maxWidth);
+ // If we've had to remove some widths, we'll add the maximum width back in
+ transformedWidths.push(maxWidth);
+ }
+ }
+
+ // Dedupe the widths
+ transformedWidths = Array.from(new Set(transformedWidths));
+
+ // Since `widths` and `densities` ultimately control the width and height of the image,
+ // we don't want the dimensions the user specified, we'll create those ourselves.
+ const {
+ width: transformWidth,
+ height: transformHeight,
+ ...transformWithoutDimensions
+ } = options;
+
+ // Collect widths to generate from specified densities or widths
+ let allWidths: Array<{
+ width: number;
+ descriptor: `${number}x` | `${number}w`;
+ }> = [];
+ if (densities) {
+ // Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers
+ const densityValues = densities.map((density) => {
+ if (typeof density === 'number') {
+ return density;
+ } else {
+ return parseFloat(density);
+ }
+ });
+
+ // Calculate the widths for each density, rounding to avoid floats.
+ const densityWidths = densityValues
+ .sort(sortNumeric)
+ .map((density) => Math.round(targetWidth * density));
+
+ allWidths = densityWidths.map((width, index) => ({
+ width,
+ descriptor: `${densityValues[index]}x`,
+ }));
+ } else if (transformedWidths.length > 0) {
+ allWidths = transformedWidths.map((width) => ({
+ width,
+ descriptor: `${width}w`,
+ }));
+ }
+
+ return allWidths.map(({ width, descriptor }) => {
+ const height = Math.round(width / aspectRatio);
+ const transform = { ...transformWithoutDimensions, width, height };
+ return {
+ transform,
+ descriptor,
+ attributes: {
+ type: `image/${targetFormat}`,
+ },
+ };
+ });
+ },
+ getURL(options, imageConfig) {
+ const searchParams = new URLSearchParams();
+
+ if (isESMImportedImage(options.src)) {
+ searchParams.append('href', options.src.src);
+ } else if (isRemoteAllowed(options.src, imageConfig)) {
+ searchParams.append('href', options.src);
+ } else {
+ // If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL
+ return options.src;
+ }
+
+ const params: Record<string, keyof typeof options> = {
+ w: 'width',
+ h: 'height',
+ q: 'quality',
+ f: 'format',
+ fit: 'fit',
+ position: 'position',
+ };
+
+ Object.entries(params).forEach(([param, key]) => {
+ options[key] && searchParams.append(param, options[key].toString());
+ });
+
+ const imageEndpoint = joinPaths(import.meta.env.BASE_URL, imageConfig.endpoint.route);
+ return `${imageEndpoint}?${searchParams}`;
+ },
+ parseURL(url) {
+ const params = url.searchParams;
+
+ if (!params.has('href')) {
+ return undefined;
+ }
+
+ const transform: BaseServiceTransform = {
+ src: params.get('href')!,
+ width: params.has('w') ? parseInt(params.get('w')!) : undefined,
+ height: params.has('h') ? parseInt(params.get('h')!) : undefined,
+ format: params.get('f') as ImageOutputFormat,
+ quality: params.get('q'),
+ fit: params.get('fit') as ImageFit,
+ position: params.get('position') ?? undefined,
+ };
+
+ return transform;
+ },
+};
+
+/**
+ * Returns the final dimensions of an image based on the user's options.
+ *
+ * For local images:
+ * - If the user specified both width and height, we'll use those.
+ * - If the user specified only one of them, we'll use the original image's aspect ratio to calculate the other.
+ * - If the user didn't specify either, we'll use the original image's dimensions.
+ *
+ * For remote images:
+ * - Widths and heights are always required, so we'll use the user's specified width and height.
+ */
+function getTargetDimensions(options: ImageTransform) {
+ let targetWidth = options.width;
+ let targetHeight = options.height;
+ if (isESMImportedImage(options.src)) {
+ const aspectRatio = options.src.width / options.src.height;
+ if (targetHeight && !targetWidth) {
+ // If we have a height but no width, use height to calculate the width
+ targetWidth = Math.round(targetHeight * aspectRatio);
+ } else if (targetWidth && !targetHeight) {
+ // If we have a width but no height, use width to calculate the height
+ targetHeight = Math.round(targetWidth / aspectRatio);
+ } else if (!targetWidth && !targetHeight) {
+ // If we have neither width or height, use the original image's dimensions
+ targetWidth = options.src.width;
+ targetHeight = options.src.height;
+ }
+ }
+
+ // TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined
+ return {
+ targetWidth: targetWidth!,
+ targetHeight: targetHeight!,
+ };
+}
diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts
new file mode 100644
index 000000000..bbae39eb0
--- /dev/null
+++ b/packages/astro/src/assets/services/sharp.ts
@@ -0,0 +1,123 @@
+import type { FitEnum, FormatEnum, SharpOptions } from 'sharp';
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js';
+import {
+ type BaseServiceTransform,
+ type LocalImageService,
+ baseService,
+ parseQuality,
+} from './service.js';
+
+export interface SharpImageServiceConfig {
+ /**
+ * The `limitInputPixels` option passed to Sharp. See https://sharp.pixelplumbing.com/api-constructor for more information
+ */
+ limitInputPixels?: SharpOptions['limitInputPixels'];
+}
+
+let sharp: typeof import('sharp');
+
+const qualityTable: Record<ImageQualityPreset, number> = {
+ low: 25,
+ mid: 50,
+ high: 80,
+ max: 100,
+};
+
+async function loadSharp() {
+ let sharpImport: typeof import('sharp');
+ try {
+ sharpImport = (await import('sharp')).default;
+ } catch {
+ throw new AstroError(AstroErrorData.MissingSharp);
+ }
+
+ // Disable the `sharp` `libvips` cache as it errors when the file is too small and operations are happening too fast (runs into a race condition) https://github.com/lovell/sharp/issues/3935#issuecomment-1881866341
+ sharpImport.cache(false);
+
+ return sharpImport;
+}
+
+const fitMap: Record<ImageFit, keyof FitEnum> = {
+ fill: 'fill',
+ contain: 'inside',
+ cover: 'cover',
+ none: 'outside',
+ 'scale-down': 'inside',
+ outside: 'outside',
+ inside: 'inside',
+};
+
+const sharpService: LocalImageService<SharpImageServiceConfig> = {
+ validateOptions: baseService.validateOptions,
+ getURL: baseService.getURL,
+ parseURL: baseService.parseURL,
+ getHTMLAttributes: baseService.getHTMLAttributes,
+ getSrcSet: baseService.getSrcSet,
+ async transform(inputBuffer, transformOptions, config) {
+ if (!sharp) sharp = await loadSharp();
+ const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
+
+ // Return SVGs as-is
+ // TODO: Sharp has some support for SVGs, we could probably support this once Sharp is the default and only service.
+ if (transform.format === 'svg') return { data: inputBuffer, format: 'svg' };
+
+ const result = sharp(inputBuffer, {
+ failOnError: false,
+ pages: -1,
+ limitInputPixels: config.service.config.limitInputPixels,
+ });
+
+ // always call rotate to adjust for EXIF data orientation
+ result.rotate();
+
+ // If `fit` isn't set then use old behavior:
+ // - Do not use both width and height for resizing, and prioritize width over height
+ // - Allow enlarging images
+
+ const withoutEnlargement = Boolean(transform.fit);
+ if (transform.width && transform.height && transform.fit) {
+ const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside';
+ result.resize({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ fit,
+ position: transform.position,
+ withoutEnlargement,
+ });
+ } else if (transform.height && !transform.width) {
+ result.resize({
+ height: Math.round(transform.height),
+ withoutEnlargement,
+ });
+ } else if (transform.width) {
+ result.resize({
+ width: Math.round(transform.width),
+ withoutEnlargement,
+ });
+ }
+
+ if (transform.format) {
+ let quality: number | string | undefined = undefined;
+ if (transform.quality) {
+ const parsedQuality = parseQuality(transform.quality);
+ if (typeof parsedQuality === 'number') {
+ quality = parsedQuality;
+ } else {
+ quality = transform.quality in qualityTable ? qualityTable[transform.quality] : undefined;
+ }
+ }
+
+ result.toFormat(transform.format as keyof FormatEnum, { quality: quality });
+ }
+
+ const { data, info } = await result.toBuffer({ resolveWithObject: true });
+
+ return {
+ data: data,
+ format: info.format as ImageOutputFormat,
+ };
+ },
+};
+
+export default sharpService;
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
new file mode 100644
index 000000000..ac6df6799
--- /dev/null
+++ b/packages/astro/src/assets/types.ts
@@ -0,0 +1,286 @@
+import type { OmitPreservingIndexSignature, Simplify, WithRequired } from '../type-utils.js';
+import type { VALID_INPUT_FORMATS, VALID_OUTPUT_FORMATS } from './consts.js';
+import type { ImageService } from './services/service.js';
+
+export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {});
+export type ImageQuality = ImageQualityPreset | number;
+export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number];
+export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {});
+export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none';
+export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
+
+export type AssetsGlobalStaticImagesList = Map<
+ string,
+ {
+ originalSrcPath: string | undefined;
+ transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
+ }
+>;
+
+declare global {
+ // eslint-disable-next-line no-var
+ var astroAsset: {
+ imageService?: ImageService;
+ addStaticImage?:
+ | ((options: ImageTransform, hashProperties: string[], fsPath: string | undefined) => string)
+ | undefined;
+ staticImages?: AssetsGlobalStaticImagesList;
+ referencedImages?: Set<string>;
+ };
+}
+
+const isESMImport = Symbol('#isESM');
+
+export type OmitBrand<T> = Omit<T, typeof isESMImport>;
+
+/**
+ * Type returned by ESM imports of images
+ */
+export type ImageMetadata = {
+ src: string;
+ width: number;
+ height: number;
+ format: ImageInputFormat;
+ orientation?: number;
+ /** @internal */
+ fsPath: string;
+ [isESMImport]?: true;
+};
+
+export function isImageMetadata(src: any): src is ImageMetadata {
+ // For ESM-imported images the fsPath property is set but not enumerable
+ return src.fsPath && !('fsPath' in src);
+}
+
+/**
+ * A yet to be completed with an url `SrcSetValue`. Other hooks will only see a resolved value, where the URL of the image has been added.
+ */
+export type UnresolvedSrcSetValue = {
+ transform: ImageTransform;
+ descriptor?: string;
+ attributes?: Record<string, any>;
+};
+
+export type SrcSetValue = UnresolvedSrcSetValue & {
+ url: string;
+};
+
+/**
+ * A yet to be resolved image transform. Used by `getImage`
+ */
+export type UnresolvedImageTransform = Simplify<
+ OmitPreservingIndexSignature<ImageTransform, 'src'> & {
+ src: ImageMetadata | string | Promise<{ default: ImageMetadata }>;
+ inferSize?: boolean;
+ }
+> & {
+ [isESMImport]?: never;
+};
+
+/**
+ * Options accepted by the image transformation service.
+ */
+export type ImageTransform = {
+ src: ImageMetadata | string;
+ width?: number | undefined;
+ widths?: number[] | undefined;
+ densities?: (number | `${number}x`)[] | undefined;
+ height?: number | undefined;
+ quality?: ImageQuality | undefined;
+ format?: ImageOutputFormat | undefined;
+ fit?: ImageFit | undefined;
+ position?: string | undefined;
+ [key: string]: any;
+};
+
+export interface GetImageResult {
+ rawOptions: ImageTransform;
+ options: ImageTransform;
+ src: string;
+ srcSet: {
+ values: SrcSetValue[];
+ attribute: string;
+ };
+ attributes: Record<string, any>;
+}
+
+type ImageSharedProps<T> = T & {
+ /**
+ * Width of the image, the value of this property will be used to assign the `width` property on the final `img` element.
+ *
+ * This value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} width={300} alt="..." />
+ * ```
+ * **Result**:
+ * ```html
+ * <img src="..." width="300" height="..." alt="..." />
+ * ```
+ */
+ width?: number | `${number}`;
+ /**
+ * Height of the image, the value of this property will be used to assign the `height` property on the final `img` element.
+ *
+ * For local images, if `width` is not present, this value will additionally be used to resize the image to the desired height, taking into account the original aspect ratio of the image.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} height={300} alt="..." />
+ * ```
+ * **Result**:
+ * ```html
+ * <img src="..." height="300" width="..." alt="..." />
+ * ```
+ */
+ height?: number | `${number}`;
+ /**
+ * Desired output format for the image. Defaults to `webp`.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} format="avif" alt="..." />
+ * ```
+ */
+ format?: ImageOutputFormat;
+ /**
+ * Desired quality for the image. Value can either be a preset such as `low` or `high`, or a numeric value from 0 to 100.
+ *
+ * The perceptual quality of the output image is service-specific.
+ * For instance, a certain service might decide that `high` results in a very beautiful image, but another could choose for it to be at best passable.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} quality='high' alt="..." />
+ * <Image src={...} quality={300} alt="..." />
+ * ```
+ */
+ quality?: ImageQuality;
+} & (
+ | {
+ /**
+ * The layout type for responsive images. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Allowed values are `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
+ *
+ * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
+ * - `fixed` - The image will maintain its original dimensions.
+ * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio, even if that means the image will exceed its original dimensions.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} layout="responsive" alt="..." />
+ * ```
+ */
+
+ layout?: ImageLayout;
+
+ /**
+ * Defines how the image should be cropped if the aspect ratio is changed. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} fit="contain" alt="..." />
+ * ```
+ */
+
+ fit?: ImageFit;
+
+ /**
+ * Defines the position of the image when cropping. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} position="center top" alt="..." />
+ * ```
+ */
+
+ position?: string;
+ /**
+ * If true, the image will be loaded with a higher priority. This can be useful for images that are visible above the fold. There should usually be only one image with `priority` set to `true` per page.
+ * All other images will be lazy-loaded according to when they are in the viewport.
+ * **Example**:
+ * ```astro
+ * <Image src={...} priority alt="..." />
+ * ```
+ */
+ priority?: boolean;
+
+ /**
+ * A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
+ *
+ * This attribute is incompatible with `densities`.
+ */
+ widths?: number[];
+ densities?: never;
+ }
+ | {
+ /**
+ * A list of pixel densities to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
+ *
+ * This attribute is incompatible with `widths`.
+ */
+ densities?: (number | `${number}x`)[];
+ widths?: never;
+ layout?: never;
+ fit?: never;
+ position?: never;
+ }
+ );
+
+export type LocalImageProps<T> = ImageSharedProps<T> & {
+ /**
+ * A reference to a local image imported through an ESM import.
+ *
+ * **Example**:
+ * ```js
+ * import myImage from "../assets/my_image.png";
+ * ```
+ * And then refer to the image, like so:
+ * ```astro
+ * <Image src={myImage} alt="..."></Image>
+ * ```
+ */
+ src: ImageMetadata | Promise<{ default: ImageMetadata }>;
+};
+
+export type RemoteImageProps<T> =
+ | (ImageSharedProps<T> & {
+ /**
+ * URL of a remote image. Can start with a protocol (ex: `https://`) or alternatively `/`, or `Astro.url`, for images in the `public` folder
+ *
+ * Remote images are not optimized, and require both `width` and `height` to be set.
+ *
+ * **Example**:
+ * ```
+ * <Image src="https://example.com/image.png" width={450} height={300} alt="..." />
+ * ```
+ */
+ src: string;
+ /**
+ * When inferSize is true width and height are not required
+ */
+ inferSize: true;
+ })
+ | (WithRequired<ImageSharedProps<T>, 'width' | 'height'> & {
+ /**
+ * URL of a remote image. Can start with a protocol (ex: `https://`) or alternatively `/`, or `Astro.url`, for images in the `public` folder
+ *
+ * Remote images are not optimized, and require both `width` and `height` to be set.
+ *
+ * **Example**:
+ * ```
+ * <Image src="https://example.com/image.png" width={450} height={300} alt="..." />
+ * ```
+ */
+ src: string;
+ /**
+ * When inferSize is false or undefined width and height are required
+ */
+ inferSize?: false | undefined;
+ });
diff --git a/packages/astro/src/assets/utils/etag.ts b/packages/astro/src/assets/utils/etag.ts
new file mode 100644
index 000000000..78d208b90
--- /dev/null
+++ b/packages/astro/src/assets/utils/etag.ts
@@ -0,0 +1,45 @@
+/**
+ * FNV-1a Hash implementation
+ * @author Travis Webb (tjwebb) <me@traviswebb.com>
+ *
+ * Ported from https://github.com/tjwebb/fnv-plus/blob/master/index.js
+ * License https://github.com/tjwebb/fnv-plus#license
+ *
+ * Simplified, optimized and add modified for 52 bit, which provides a larger hash space
+ * and still making use of Javascript's 53-bit integer space.
+ */
+export const fnv1a52 = (str: string) => {
+ const len = str.length;
+ let i = 0,
+ t0 = 0,
+ v0 = 0x2325,
+ t1 = 0,
+ v1 = 0x8422,
+ t2 = 0,
+ v2 = 0x9ce4,
+ t3 = 0,
+ v3 = 0xcbf2;
+
+ while (i < len) {
+ v0 ^= str.charCodeAt(i++);
+ t0 = v0 * 435;
+ t1 = v1 * 435;
+ t2 = v2 * 435;
+ t3 = v3 * 435;
+ t2 += v0 << 8;
+ t3 += v1 << 8;
+ t1 += t0 >>> 16;
+ v0 = t0 & 65535;
+ t2 += t1 >>> 16;
+ v1 = t1 & 65535;
+ v3 = (t3 + (t2 >>> 16)) & 65535;
+ v2 = t2 & 65535;
+ }
+
+ return (v3 & 15) * 281474976710656 + v2 * 4294967296 + v1 * 65536 + (v0 ^ (v3 >> 4));
+};
+
+export const etag = (payload: string, weak = false) => {
+ const prefix = weak ? 'W/"' : '"';
+ return prefix + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"';
+};
diff --git a/packages/astro/src/assets/utils/getAssetsPrefix.ts b/packages/astro/src/assets/utils/getAssetsPrefix.ts
new file mode 100644
index 000000000..1a8947b54
--- /dev/null
+++ b/packages/astro/src/assets/utils/getAssetsPrefix.ts
@@ -0,0 +1,12 @@
+import type { AssetsPrefix } from '../../core/app/types.js';
+
+export function getAssetsPrefix(fileExtension: string, assetsPrefix?: AssetsPrefix): string {
+ if (!assetsPrefix) return '';
+ if (typeof assetsPrefix === 'string') return assetsPrefix;
+ // we assume the file extension has a leading '.' and we remove it
+ const dotLessFileExtension = fileExtension.slice(1);
+ if (assetsPrefix[dotLessFileExtension]) {
+ return assetsPrefix[dotLessFileExtension];
+ }
+ return assetsPrefix.fallback;
+}
diff --git a/packages/astro/src/assets/utils/imageAttributes.ts b/packages/astro/src/assets/utils/imageAttributes.ts
new file mode 100644
index 000000000..aa67b528f
--- /dev/null
+++ b/packages/astro/src/assets/utils/imageAttributes.ts
@@ -0,0 +1,48 @@
+import { toStyleString } from '../../runtime/server/render/util.js';
+import type { GetImageResult, ImageLayout, LocalImageProps, RemoteImageProps } from '../types.js';
+
+export function addCSSVarsToStyle(
+ vars: Record<string, string | false | undefined>,
+ styles?: string | Record<string, any>,
+) {
+ const cssVars = Object.entries(vars)
+ .filter(([_, value]) => value !== undefined && value !== false)
+ .map(([key, value]) => `--${key}: ${value};`)
+ .join(' ');
+
+ if (!styles) {
+ return cssVars;
+ }
+ const style = typeof styles === 'string' ? styles : toStyleString(styles);
+
+ return `${cssVars} ${style}`;
+}
+
+const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
+
+export function applyResponsiveAttributes<
+ T extends LocalImageProps<unknown> | RemoteImageProps<unknown>,
+>({
+ layout,
+ image,
+ props,
+ additionalAttributes,
+}: {
+ layout: Exclude<ImageLayout, 'none'>;
+ image: GetImageResult;
+ additionalAttributes: Record<string, any>;
+ props: T;
+}) {
+ const attributes = { ...additionalAttributes, ...image.attributes };
+ attributes.style = addCSSVarsToStyle(
+ {
+ w: image.attributes.width ?? props.width ?? image.options.width,
+ h: image.attributes.height ?? props.height ?? image.options.height,
+ fit: cssFitValues.includes(props.fit ?? '') && props.fit,
+ pos: props.position,
+ },
+ attributes.style,
+ );
+ attributes['data-astro-image'] = layout;
+ return attributes;
+}
diff --git a/packages/astro/src/assets/utils/imageKind.ts b/packages/astro/src/assets/utils/imageKind.ts
new file mode 100644
index 000000000..87946364f
--- /dev/null
+++ b/packages/astro/src/assets/utils/imageKind.ts
@@ -0,0 +1,13 @@
+import type { ImageMetadata, UnresolvedImageTransform } from '../types.js';
+
+export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
+ return typeof src === 'object' || (typeof src === 'function' && 'src' in src);
+}
+
+export function isRemoteImage(src: ImageMetadata | string): src is string {
+ return typeof src === 'string';
+}
+
+export async function resolveSrc(src: UnresolvedImageTransform['src']) {
+ return typeof src === 'object' && 'then' in src ? ((await src).default ?? (await src)) : src;
+}
diff --git a/packages/astro/src/assets/utils/index.ts b/packages/astro/src/assets/utils/index.ts
new file mode 100644
index 000000000..3fae18200
--- /dev/null
+++ b/packages/astro/src/assets/utils/index.ts
@@ -0,0 +1,16 @@
+export { emitESMImage } from './node/emitAsset.js';
+export { isESMImportedImage, isRemoteImage } from './imageKind.js';
+export { imageMetadata } from './metadata.js';
+export { getOrigQueryParams } from './queryParams.js';
+export {
+ isRemoteAllowed,
+ matchHostname,
+ matchPathname,
+ matchPattern,
+ matchPort,
+ matchProtocol,
+ type RemotePattern,
+} from './remotePattern.js';
+export { hashTransform, propsToFilename } from './transformToPath.js';
+export { inferRemoteSize } from './remoteProbe.js';
+export { makeSvgComponent } from './svg.js';
diff --git a/packages/astro/src/assets/utils/metadata.ts b/packages/astro/src/assets/utils/metadata.ts
new file mode 100644
index 000000000..d212bdeb7
--- /dev/null
+++ b/packages/astro/src/assets/utils/metadata.ts
@@ -0,0 +1,33 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageInputFormat, ImageMetadata } from '../types.js';
+import { lookup as probe } from '../utils/vendor/image-size/lookup.js';
+
+export async function imageMetadata(
+ data: Uint8Array,
+ src?: string,
+): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
+ try {
+ const result = probe(data);
+ if (!result.height || !result.width || !result.type) {
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(src),
+ });
+ }
+
+ const { width, height, type, orientation } = result;
+ const isPortrait = (orientation || 0) >= 5;
+
+ return {
+ width: isPortrait ? height : width,
+ height: isPortrait ? width : height,
+ format: type as ImageInputFormat,
+ orientation,
+ };
+ } catch {
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(src),
+ });
+ }
+}
diff --git a/packages/astro/src/assets/utils/node/emitAsset.ts b/packages/astro/src/assets/utils/node/emitAsset.ts
new file mode 100644
index 000000000..1337ac880
--- /dev/null
+++ b/packages/astro/src/assets/utils/node/emitAsset.ts
@@ -0,0 +1,88 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import type * as vite from 'vite';
+import { prependForwardSlash, slash } from '../../../core/path.js';
+import type { ImageMetadata } from '../../types.js';
+import { imageMetadata } from '../metadata.js';
+
+type FileEmitter = vite.Rollup.EmitFile;
+type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
+
+export async function emitESMImage(
+ id: string | undefined,
+ /** @deprecated */
+ _watchMode: boolean,
+ // FIX: in Astro 6, this function should not be passed in dev mode at all.
+ // Or rethink the API so that a function that throws isn't passed through.
+ experimentalSvgEnabled: boolean,
+ fileEmitter?: FileEmitter,
+): Promise<ImageMetadataWithContents | undefined> {
+ if (!id) {
+ return undefined;
+ }
+
+ const url = pathToFileURL(id);
+ let fileData: Buffer;
+ try {
+ fileData = await fs.readFile(url);
+ } catch {
+ return undefined;
+ }
+
+ const fileMetadata = await imageMetadata(fileData, id);
+
+ const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = {
+ src: '',
+ ...fileMetadata,
+ };
+
+ // Private for now, we generally don't want users to rely on filesystem paths, but we need it so that we can maybe remove the original asset from the build if it's unused.
+ Object.defineProperty(emittedImage, 'fsPath', {
+ enumerable: false,
+ writable: false,
+ value: id,
+ });
+
+ // Attach file data for SVGs
+ // TODO: this is a workaround to prevent a memory leak, and it must be fixed before we remove the experimental flag, see
+ if (fileMetadata.format === 'svg' && experimentalSvgEnabled === true) {
+ emittedImage.contents = fileData;
+ }
+
+ // Build
+ let isBuild = typeof fileEmitter === 'function';
+ if (isBuild) {
+ const pathname = decodeURI(url.pathname);
+ const filename = path.basename(pathname, path.extname(pathname) + `.${fileMetadata.format}`);
+
+ try {
+ // fileEmitter throws in dev
+ const handle = fileEmitter!({
+ name: filename,
+ source: await fs.readFile(url),
+ type: 'asset',
+ });
+
+ emittedImage.src = `__ASTRO_ASSET_IMAGE__${handle}__`;
+ } catch {
+ isBuild = false;
+ }
+ }
+
+ if (!isBuild) {
+ // Pass the original file information through query params so we don't have to load the file twice
+ url.searchParams.append('origWidth', fileMetadata.width.toString());
+ url.searchParams.append('origHeight', fileMetadata.height.toString());
+ url.searchParams.append('origFormat', fileMetadata.format);
+
+ emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
+ }
+
+ return emittedImage as ImageMetadataWithContents;
+}
+
+function fileURLToNormalizedPath(filePath: URL): string {
+ // Uses `slash` instead of Vite's `normalizePath` to avoid CJS bundling issues.
+ return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/');
+}
diff --git a/packages/astro/src/assets/utils/proxy.ts b/packages/astro/src/assets/utils/proxy.ts
new file mode 100644
index 000000000..975e8e0f3
--- /dev/null
+++ b/packages/astro/src/assets/utils/proxy.ts
@@ -0,0 +1,23 @@
+import type { ImageMetadata } from '../types.js';
+
+export function getProxyCode(options: ImageMetadata, isSSR: boolean): string {
+ const stringifiedFSPath = JSON.stringify(options.fsPath);
+ return `
+ new Proxy(${JSON.stringify(options)}, {
+ get(target, name, receiver) {
+ if (name === 'clone') {
+ return structuredClone(target);
+ }
+ if (name === 'fsPath') {
+ return ${stringifiedFSPath};
+ }
+ ${
+ !isSSR
+ ? `if (target[name] !== undefined && globalThis.astroAsset) globalThis.astroAsset?.referencedImages.add(${stringifiedFSPath});`
+ : ''
+ }
+ return target[name];
+ }
+ })
+ `;
+}
diff --git a/packages/astro/src/assets/utils/queryParams.ts b/packages/astro/src/assets/utils/queryParams.ts
new file mode 100644
index 000000000..b073c9786
--- /dev/null
+++ b/packages/astro/src/assets/utils/queryParams.ts
@@ -0,0 +1,19 @@
+import type { ImageInputFormat, ImageMetadata } from '../types.js';
+
+export function getOrigQueryParams(
+ params: URLSearchParams,
+): Pick<ImageMetadata, 'width' | 'height' | 'format'> | undefined {
+ const width = params.get('origWidth');
+ const height = params.get('origHeight');
+ const format = params.get('origFormat');
+
+ if (!width || !height || !format) {
+ return undefined;
+ }
+
+ return {
+ width: parseInt(width),
+ height: parseInt(height),
+ format: format as ImageInputFormat,
+ };
+}
diff --git a/packages/astro/src/assets/utils/remotePattern.ts b/packages/astro/src/assets/utils/remotePattern.ts
new file mode 100644
index 000000000..d3e832573
--- /dev/null
+++ b/packages/astro/src/assets/utils/remotePattern.ts
@@ -0,0 +1,82 @@
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import type { AstroConfig } from '../../types/public/config.js';
+
+export type RemotePattern = {
+ hostname?: string;
+ pathname?: string;
+ protocol?: string;
+ port?: string;
+};
+
+export function matchPattern(url: URL, remotePattern: RemotePattern) {
+ return (
+ matchProtocol(url, remotePattern.protocol) &&
+ matchHostname(url, remotePattern.hostname, true) &&
+ matchPort(url, remotePattern.port) &&
+ matchPathname(url, remotePattern.pathname, true)
+ );
+}
+
+export function matchPort(url: URL, port?: string) {
+ return !port || port === url.port;
+}
+
+export function matchProtocol(url: URL, protocol?: string) {
+ return !protocol || protocol === url.protocol.slice(0, -1);
+}
+
+export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) {
+ if (!hostname) {
+ return true;
+ } else if (!allowWildcard || !hostname.startsWith('*')) {
+ return hostname === url.hostname;
+ } else if (hostname.startsWith('**.')) {
+ const slicedHostname = hostname.slice(2); // ** length
+ return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
+ } else if (hostname.startsWith('*.')) {
+ const slicedHostname = hostname.slice(1); // * length
+ const additionalSubdomains = url.hostname
+ .replace(slicedHostname, '')
+ .split('.')
+ .filter(Boolean);
+ return additionalSubdomains.length === 1;
+ }
+
+ return false;
+}
+
+export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) {
+ if (!pathname) {
+ return true;
+ } else if (!allowWildcard || !pathname.endsWith('*')) {
+ return pathname === url.pathname;
+ } else if (pathname.endsWith('/**')) {
+ const slicedPathname = pathname.slice(0, -2); // ** length
+ return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
+ } else if (pathname.endsWith('/*')) {
+ const slicedPathname = pathname.slice(0, -1); // * length
+ const additionalPathChunks = url.pathname
+ .replace(slicedPathname, '')
+ .split('/')
+ .filter(Boolean);
+ return additionalPathChunks.length === 1;
+ }
+
+ return false;
+}
+
+export function isRemoteAllowed(
+ src: string,
+ {
+ domains = [],
+ remotePatterns = [],
+ }: Partial<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>>,
+): boolean {
+ if (!isRemotePath(src)) return false;
+
+ const url = new URL(src);
+ return (
+ domains.some((domain) => matchHostname(url, domain)) ||
+ remotePatterns.some((remotePattern) => matchPattern(url, remotePattern))
+ );
+}
diff --git a/packages/astro/src/assets/utils/remoteProbe.ts b/packages/astro/src/assets/utils/remoteProbe.ts
new file mode 100644
index 000000000..32e237f89
--- /dev/null
+++ b/packages/astro/src/assets/utils/remoteProbe.ts
@@ -0,0 +1,56 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageMetadata } from '../types.js';
+import { imageMetadata } from './metadata.js';
+
+export async function inferRemoteSize(url: string): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
+ // Start fetching the image
+ const response = await fetch(url);
+ if (!response.body || !response.ok) {
+ throw new AstroError({
+ ...AstroErrorData.FailedToFetchRemoteImageDimensions,
+ message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
+ });
+ }
+
+ const reader = response.body.getReader();
+
+ let done: boolean | undefined, value: Uint8Array;
+ let accumulatedChunks = new Uint8Array();
+
+ // Process the stream chunk by chunk
+ while (!done) {
+ const readResult = await reader.read();
+ done = readResult.done;
+
+ if (done) break;
+
+ if (readResult.value) {
+ value = readResult.value;
+
+ // Accumulate chunks
+ let tmp = new Uint8Array(accumulatedChunks.length + value.length);
+ tmp.set(accumulatedChunks, 0);
+ tmp.set(value, accumulatedChunks.length);
+ accumulatedChunks = tmp;
+
+ try {
+ // Attempt to determine the size with each new chunk
+ const dimensions = await imageMetadata(accumulatedChunks, url);
+
+ if (dimensions) {
+ await reader.cancel(); // stop stream as we have size now
+
+ return dimensions;
+ }
+ } catch {
+ // This catch block is specifically for `imageMetadata` errors
+ // which might occur if the accumulated data isn't yet sufficient.
+ }
+ }
+ }
+
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(url),
+ });
+}
diff --git a/packages/astro/src/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts
new file mode 100644
index 000000000..93e312487
--- /dev/null
+++ b/packages/astro/src/assets/utils/resolveImports.ts
@@ -0,0 +1,44 @@
+import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path';
+import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js';
+import { shorthash } from '../../runtime/server/shorthash.js';
+import { VALID_INPUT_FORMATS } from '../consts.js';
+
+/**
+ * Resolves an image src from a content file (such as markdown) to a module ID or import that can be resolved by Vite.
+ *
+ * @param imageSrc The src attribute of an image tag
+ * @param filePath The path to the file that contains the imagem relative to the site root
+ * @returns A module id of the image that can be rsolved by Vite, or undefined if it is not a local image
+ */
+export function imageSrcToImportId(imageSrc: string, filePath?: string): string | undefined {
+ // If the import is coming from the data store it will have a special prefix to identify it
+ // as an image import. We remove this prefix so that we can resolve the image correctly.
+ imageSrc = removeBase(imageSrc, IMAGE_IMPORT_PREFIX);
+
+ // We only care about local imports
+ if (isRemotePath(imageSrc)) {
+ return;
+ }
+ // We only care about images
+ const ext = imageSrc.split('.').at(-1)?.toLowerCase() as
+ | (typeof VALID_INPUT_FORMATS)[number]
+ | undefined;
+ if (!ext || !VALID_INPUT_FORMATS.includes(ext)) {
+ return;
+ }
+
+ // The import paths are relative to the content (md) file, but when it's actually resolved it will
+ // be in a single assets file, so relative paths will no longer work. To deal with this we use
+ // a query parameter to store the original path to the file and append a query param flag.
+ // This allows our Vite plugin to intercept the import and resolve the path relative to the
+ // importer and get the correct full path for the imported image.
+
+ const params = new URLSearchParams(CONTENT_IMAGE_FLAG);
+ if (filePath) {
+ params.set('importer', filePath);
+ }
+ return `${imageSrc}?${params.toString()}`;
+}
+
+export const importIdToSymbolName = (importId: string) =>
+ `__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`;
diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts
new file mode 100644
index 000000000..bb6d944f9
--- /dev/null
+++ b/packages/astro/src/assets/utils/svg.ts
@@ -0,0 +1,37 @@
+import { parse, renderSync } from 'ultrahtml';
+import type { SvgComponentProps } from '../runtime.js';
+import { dropAttributes } from '../runtime.js';
+import type { ImageMetadata } from '../types.js';
+
+function parseSvg(contents: string) {
+ const root = parse(contents);
+ const svgNode = root.children.find(
+ ({ name, type }: { name: string; type: number }) => type === 1 /* Element */ && name === 'svg',
+ );
+ if (!svgNode) {
+ throw new Error('SVG file does not contain an <svg> element');
+ }
+ const { attributes, children } = svgNode;
+ const body = renderSync({ ...root, children });
+
+ return { attributes, body };
+}
+
+export type SvgRenderMode = 'inline' | 'sprite';
+
+export function makeSvgComponent(
+ meta: ImageMetadata,
+ contents: Buffer | string,
+ options?: { mode?: SvgRenderMode },
+) {
+ const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
+ const { attributes, body: children } = parseSvg(file);
+ const props: SvgComponentProps = {
+ meta,
+ attributes: dropAttributes({ mode: options?.mode, ...attributes }),
+ children,
+ };
+
+ return `import { createSvgComponent } from 'astro/assets/runtime';
+export default createSvgComponent(${JSON.stringify(props)})`;
+}
diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts
new file mode 100644
index 000000000..e11680a12
--- /dev/null
+++ b/packages/astro/src/assets/utils/transformToPath.ts
@@ -0,0 +1,38 @@
+import { basename, dirname, extname } from 'node:path';
+import { deterministicString } from 'deterministic-object-hash';
+import { removeQueryString } from '../../core/path.js';
+import { shorthash } from '../../runtime/server/shorthash.js';
+import type { ImageTransform } from '../types.js';
+import { isESMImportedImage } from './imageKind.js';
+
+export function propsToFilename(filePath: string, transform: ImageTransform, hash: string) {
+ let filename = decodeURIComponent(removeQueryString(filePath));
+ const ext = extname(filename);
+ if (filePath.startsWith('data:')) {
+ filename = shorthash(filePath);
+ } else {
+ filename = basename(filename, ext);
+ }
+ const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : '';
+
+ let outputExt = transform.format ? `.${transform.format}` : ext;
+ return decodeURIComponent(`${prefixDirname}/${filename}_${hash}${outputExt}`);
+}
+
+export function hashTransform(
+ transform: ImageTransform,
+ imageService: string,
+ propertiesToHash: string[],
+) {
+ // Extract the fields we want to hash
+ const hashFields = propertiesToHash.reduce(
+ (acc, prop) => {
+ // It's possible for `transform[prop]` here to be undefined, or null, but that's fine because it's still consistent
+ // between different transforms. (ex: every transform without a height will explicitly have a `height: undefined` property)
+ acc[prop] = transform[prop];
+ return acc;
+ },
+ { imageService } as Record<string, unknown>,
+ );
+ return shorthash(deterministicString(hashFields));
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/LICENSE b/packages/astro/src/assets/utils/vendor/image-size/LICENSE
new file mode 100644
index 000000000..8bdffcff7
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/LICENSE
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright © 2013-Present Aditya Yadav, http://netroy.in
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/astro/src/assets/utils/vendor/image-size/README.md b/packages/astro/src/assets/utils/vendor/image-size/README.md
new file mode 100644
index 000000000..5c800006a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/README.md
@@ -0,0 +1,3 @@
+This code comes from https://github.com/image-size/image-size/pull/370, and is slightly modified (all import statements have file extensions added to them).
+
+The `fromFile` functionality has also been removed, as it was not being used.
diff --git a/packages/astro/src/assets/utils/vendor/image-size/detector.ts b/packages/astro/src/assets/utils/vendor/image-size/detector.ts
new file mode 100644
index 000000000..367405281
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/detector.ts
@@ -0,0 +1,25 @@
+import type { imageType } from './types/index.js'
+import { typeHandlers, types } from './types/index.js'
+
+// This map helps avoid validating for every single image type
+const firstBytes = new Map<number, imageType>([
+ [0x38, 'psd'],
+ [0x42, 'bmp'],
+ [0x44, 'dds'],
+ [0x47, 'gif'],
+ [0x49, 'tiff'],
+ [0x4d, 'tiff'],
+ [0x52, 'webp'],
+ [0x69, 'icns'],
+ [0x89, 'png'],
+ [0xff, 'jpg'],
+])
+
+export function detector(input: Uint8Array): imageType | undefined {
+ const byte = input[0]
+ const type = firstBytes.get(byte)
+ if (type && typeHandlers.get(type)!.validate(input)) {
+ return type
+ }
+ return types.find((fileType) => typeHandlers.get(fileType)!.validate(input))
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/lookup.ts b/packages/astro/src/assets/utils/vendor/image-size/lookup.ts
new file mode 100644
index 000000000..d3283e72e
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/lookup.ts
@@ -0,0 +1,43 @@
+import type { imageType } from './types/index.js'
+import { typeHandlers } from './types/index.js'
+import { detector } from './detector.js'
+import type { ISizeCalculationResult } from './types/interface.ts'
+
+type Options = {
+ disabledTypes: imageType[]
+}
+
+const globalOptions: Options = {
+ disabledTypes: [],
+}
+
+/**
+ * Return size information based on an Uint8Array
+ *
+ * @param {Uint8Array} input
+ * @returns {ISizeCalculationResult}
+ */
+export function lookup(input: Uint8Array): ISizeCalculationResult {
+ // detect the file type... don't rely on the extension
+ const type = detector(input)
+
+ if (typeof type !== 'undefined') {
+ if (globalOptions.disabledTypes.includes(type)) {
+ throw new TypeError('disabled file type: ' + type)
+ }
+
+ // find an appropriate handler for this file type
+ const size = typeHandlers.get(type)!.calculate(input)
+ if (size !== undefined) {
+ size.type = size.type ?? type
+ return size
+ }
+ }
+
+ // throw up, if we don't understand the file
+ throw new TypeError('unsupported file type: ' + type)
+}
+
+export const disableTypes = (types: imageType[]): void => {
+ globalOptions.disabledTypes = types
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts b/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts
new file mode 100644
index 000000000..45c729c6a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readInt32LE, readUInt32LE } from './utils.js'
+
+export const BMP: IImage = {
+ validate: (input) => toUTF8String(input, 0, 2) === 'BM',
+
+ calculate: (input) => ({
+ height: Math.abs(readInt32LE(input, 22)),
+ width: readUInt32LE(input, 18),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts b/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts
new file mode 100644
index 000000000..19e00a836
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts
@@ -0,0 +1,17 @@
+import type { IImage } from './interface.ts'
+import { ICO } from './ico.js'
+import { readUInt16LE } from './utils.js'
+
+const TYPE_CURSOR = 2
+export const CUR: IImage = {
+ validate(input) {
+ const reserved = readUInt16LE(input, 0)
+ const imageCount = readUInt16LE(input, 4)
+ if (reserved !== 0 || imageCount === 0) return false
+
+ const imageType = readUInt16LE(input, 2)
+ return imageType === TYPE_CURSOR
+ },
+
+ calculate: (input) => ICO.calculate(input),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts b/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts
new file mode 100644
index 000000000..b5ab22649
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { readUInt32LE } from './utils.js'
+
+export const DDS: IImage = {
+ validate: (input) => readUInt32LE(input, 0) === 0x20534444,
+
+ calculate: (input) => ({
+ height: readUInt32LE(input, 12),
+ width: readUInt32LE(input, 16),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts b/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts
new file mode 100644
index 000000000..7ef2b7c7e
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts
@@ -0,0 +1,12 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt16LE } from './utils.js'
+
+const gifRegexp = /^GIF8[79]a/
+export const GIF: IImage = {
+ validate: (input) => gifRegexp.test(toUTF8String(input, 0, 6)),
+
+ calculate: (input) => ({
+ height: readUInt16LE(input, 8),
+ width: readUInt16LE(input, 6),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts b/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts
new file mode 100644
index 000000000..218b98fee
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts
@@ -0,0 +1,55 @@
+import type { IImage } from './interface.ts'
+import { findBox, readUInt32BE, toUTF8String } from './utils.js'
+
+const brandMap = {
+ avif: 'avif',
+ mif1: 'heif',
+ msf1: 'heif', // hief-sequence
+ heic: 'heic',
+ heix: 'heic',
+ hevc: 'heic', // heic-sequence
+ hevx: 'heic', // heic-sequence
+}
+
+function detectBrands(buffer: Uint8Array, start: number, end: number) {
+ let brandsDetected = {} as Record<keyof typeof brandMap, 1>;
+ for (let i = start; i <= end; i += 4) {
+ const brand = toUTF8String(buffer, i, i + 4);
+ if (brand in brandMap) {
+ brandsDetected[brand as keyof typeof brandMap] = 1;
+ }
+ }
+
+ // Determine the most relevant type based on detected brands
+ if ('avif' in brandsDetected) {
+ return 'avif';
+ } else if ('heic' in brandsDetected || 'heix' in brandsDetected || 'hevc' in brandsDetected || 'hevx' in brandsDetected) {
+ return 'heic';
+ } else if ('mif1' in brandsDetected || 'msf1' in brandsDetected) {
+ return 'heif';
+ }
+}
+
+export const HEIF: IImage = {
+ validate(buffer) {
+ const ftype = toUTF8String(buffer, 4, 8)
+ const brand = toUTF8String(buffer, 8, 12)
+ return 'ftyp' === ftype && brand in brandMap
+ },
+
+ calculate(buffer) {
+ // Based on https://nokiatech.github.io/heif/technical.html
+ const metaBox = findBox(buffer, 'meta', 0)
+ const iprpBox = metaBox && findBox(buffer, 'iprp', metaBox.offset + 12)
+ const ipcoBox = iprpBox && findBox(buffer, 'ipco', iprpBox.offset + 8)
+ const ispeBox = ipcoBox && findBox(buffer, 'ispe', ipcoBox.offset + 8)
+ if (ispeBox) {
+ return {
+ height: readUInt32BE(buffer, ispeBox.offset + 16),
+ width: readUInt32BE(buffer, ispeBox.offset + 12),
+ type: detectBrands(buffer, 8, metaBox.offset),
+ }
+ }
+ throw new TypeError('Invalid HEIF, no size found')
+ }
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts b/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts
new file mode 100644
index 000000000..d5bd12fad
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts
@@ -0,0 +1,113 @@
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+/**
+ * ICNS Header
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 4 | Magic literal, must be "icns" (0x69, 0x63, 0x6e, 0x73) |
+ * | 4 | 4 | Length of file, in bytes, msb first. |
+ *
+ */
+const SIZE_HEADER = 4 + 4 // 8
+const FILE_LENGTH_OFFSET = 4 // MSB => BIG ENDIAN
+
+/**
+ * Image Entry
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 4 | Icon type, see OSType below. |
+ * | 4 | 4 | Length of data, in bytes (including type and length), msb first. |
+ * | 8 | n | Icon data |
+ */
+const ENTRY_LENGTH_OFFSET = 4 // MSB => BIG ENDIAN
+
+const ICON_TYPE_SIZE: { [key: string]: number } = {
+ ICON: 32,
+ 'ICN#': 32,
+ // m => 16 x 16
+ 'icm#': 16,
+ icm4: 16,
+ icm8: 16,
+ // s => 16 x 16
+ 'ics#': 16,
+ ics4: 16,
+ ics8: 16,
+ is32: 16,
+ s8mk: 16,
+ icp4: 16,
+ // l => 32 x 32
+ icl4: 32,
+ icl8: 32,
+ il32: 32,
+ l8mk: 32,
+ icp5: 32,
+ ic11: 32,
+ // h => 48 x 48
+ ich4: 48,
+ ich8: 48,
+ ih32: 48,
+ h8mk: 48,
+ // . => 64 x 64
+ icp6: 64,
+ ic12: 32,
+ // t => 128 x 128
+ it32: 128,
+ t8mk: 128,
+ ic07: 128,
+ // . => 256 x 256
+ ic08: 256,
+ ic13: 256,
+ // . => 512 x 512
+ ic09: 512,
+ ic14: 512,
+ // . => 1024 x 1024
+ ic10: 1024,
+}
+
+function readImageHeader(
+ input: Uint8Array,
+ imageOffset: number,
+): [string, number] {
+ const imageLengthOffset = imageOffset + ENTRY_LENGTH_OFFSET
+ return [
+ toUTF8String(input, imageOffset, imageLengthOffset),
+ readUInt32BE(input, imageLengthOffset),
+ ]
+}
+
+function getImageSize(type: string): ISize {
+ const size = ICON_TYPE_SIZE[type]
+ return { width: size, height: size, type }
+}
+
+export const ICNS: IImage = {
+ validate: (input) => toUTF8String(input, 0, 4) === 'icns',
+
+ calculate(input) {
+ const inputLength = input.length
+ const fileLength = readUInt32BE(input, FILE_LENGTH_OFFSET)
+ let imageOffset = SIZE_HEADER
+
+ let imageHeader = readImageHeader(input, imageOffset)
+ let imageSize = getImageSize(imageHeader[0])
+ imageOffset += imageHeader[1]
+
+ if (imageOffset === fileLength) return imageSize
+
+ const result = {
+ height: imageSize.height,
+ images: [imageSize],
+ width: imageSize.width,
+ }
+
+ while (imageOffset < fileLength && imageOffset < inputLength) {
+ imageHeader = readImageHeader(input, imageOffset)
+ imageSize = getImageSize(imageHeader[0])
+ imageOffset += imageHeader[1]
+ result.images.push(imageSize)
+ }
+
+ return result
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts b/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts
new file mode 100644
index 000000000..42021544c
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts
@@ -0,0 +1,75 @@
+import type { IImage, ISize } from './interface.ts'
+import { readUInt16LE } from './utils.js'
+
+const TYPE_ICON = 1
+
+/**
+ * ICON Header
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 2 | Reserved. Must always be 0. |
+ * | 2 | 2 | Image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid. |
+ * | 4 | 2 | Number of images in the file. |
+ *
+ */
+const SIZE_HEADER = 2 + 2 + 2 // 6
+
+/**
+ * Image Entry
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 1 | Image width in pixels. Can be any number between 0 and 255. Value 0 means width is 256 pixels. |
+ * | 1 | 1 | Image height in pixels. Can be any number between 0 and 255. Value 0 means height is 256 pixels. |
+ * | 2 | 1 | Number of colors in the color palette. Should be 0 if the image does not use a color palette. |
+ * | 3 | 1 | Reserved. Should be 0. |
+ * | 4 | 2 | ICO format: Color planes. Should be 0 or 1. |
+ * | | | CUR format: The horizontal coordinates of the hotspot in number of pixels from the left. |
+ * | 6 | 2 | ICO format: Bits per pixel. |
+ * | | | CUR format: The vertical coordinates of the hotspot in number of pixels from the top. |
+ * | 8 | 4 | The size of the image's data in bytes |
+ * | 12 | 4 | The offset of BMP or PNG data from the beginning of the ICO/CUR file |
+ *
+ */
+const SIZE_IMAGE_ENTRY = 1 + 1 + 1 + 1 + 2 + 2 + 4 + 4 // 16
+
+function getSizeFromOffset(input: Uint8Array, offset: number): number {
+ const value = input[offset]
+ return value === 0 ? 256 : value
+}
+
+function getImageSize(input: Uint8Array, imageIndex: number): ISize {
+ const offset = SIZE_HEADER + imageIndex * SIZE_IMAGE_ENTRY
+ return {
+ height: getSizeFromOffset(input, offset + 1),
+ width: getSizeFromOffset(input, offset),
+ }
+}
+
+export const ICO: IImage = {
+ validate(input) {
+ const reserved = readUInt16LE(input, 0)
+ const imageCount = readUInt16LE(input, 4)
+ if (reserved !== 0 || imageCount === 0) return false
+
+ const imageType = readUInt16LE(input, 2)
+ return imageType === TYPE_ICON
+ },
+
+ calculate(input) {
+ const nbImages = readUInt16LE(input, 4)
+ const imageSize = getImageSize(input, 0)
+
+ if (nbImages === 1) return imageSize
+
+ const imgs: ISize[] = [imageSize]
+ for (let imageIndex = 1; imageIndex < nbImages; imageIndex += 1) {
+ imgs.push(getImageSize(input, imageIndex))
+ }
+
+ return {
+ height: imageSize.height,
+ images: imgs,
+ width: imageSize.width,
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/index.ts b/packages/astro/src/assets/utils/vendor/image-size/types/index.ts
new file mode 100644
index 000000000..f5f5fb34b
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/index.ts
@@ -0,0 +1,44 @@
+// load all available handlers explicitly for browserify support
+import { BMP } from './bmp.js'
+import { CUR } from './cur.js'
+import { DDS } from './dds.js'
+import { GIF } from './gif.js'
+import { HEIF } from './heif.js'
+import { ICNS } from './icns.js'
+import { ICO } from './ico.js'
+import { J2C } from './j2c.js'
+import { JP2 } from './jp2.js'
+import { JPG } from './jpg.js'
+import { KTX } from './ktx.js'
+import { PNG } from './png.js'
+import { PNM } from './pnm.js'
+import { PSD } from './psd.js'
+import { SVG } from './svg.js'
+import { TGA } from './tga.js'
+import { TIFF } from './tiff.js'
+import { WEBP } from './webp.js'
+
+export const typeHandlers = new Map([
+ ['bmp', BMP],
+ ['cur', CUR],
+ ['dds', DDS],
+ ['gif', GIF],
+ ['heif', HEIF],
+ ['icns', ICNS],
+ ['ico', ICO],
+ ['j2c', J2C],
+ ['jp2', JP2],
+ ['jpg', JPG],
+ ['ktx', KTX],
+ ['png', PNG],
+ ['pnm', PNM],
+ ['psd', PSD],
+ ['svg', SVG],
+ ['tga', TGA],
+ ['tiff', TIFF],
+ ['webp', WEBP],
+] as const)
+
+
+export const types = Array.from(typeHandlers.keys())
+export type imageType = typeof types[number]
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts b/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts
new file mode 100644
index 000000000..4450c87a9
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts
@@ -0,0 +1,15 @@
+export type ISize = {
+ width: number | undefined
+ height: number | undefined
+ orientation?: number
+ type?: string
+}
+
+export type ISizeCalculationResult = {
+ images?: ISize[]
+} & ISize
+
+export type IImage = {
+ validate: (input: Uint8Array) => boolean
+ calculate: (input: Uint8Array) => ISizeCalculationResult
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts b/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts
new file mode 100644
index 000000000..77a9ae7be
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts
@@ -0,0 +1,12 @@
+import type { IImage } from './interface.ts'
+import { toHexString, readUInt32BE } from './utils.js'
+
+export const J2C: IImage = {
+ // TODO: this doesn't seem right. SIZ marker doesn't have to be right after the SOC
+ validate: (input) => toHexString(input, 0, 4) === 'ff4fff51',
+
+ calculate: (input) => ({
+ height: readUInt32BE(input, 12),
+ width: readUInt32BE(input, 8),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts b/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts
new file mode 100644
index 000000000..683540f1f
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts
@@ -0,0 +1,23 @@
+import type { IImage } from './interface.ts'
+import { readUInt32BE, findBox } from './utils.js'
+
+export const JP2: IImage = {
+ validate(input) {
+ if (readUInt32BE(input, 4) !== 0x6a502020 || readUInt32BE(input, 0) < 1) return false
+ const ftypBox = findBox(input, 'ftyp', 0)
+ if (!ftypBox) return false
+ return readUInt32BE(input, ftypBox.offset + 4) === 0x66747970
+ },
+
+ calculate(input) {
+ const jp2hBox = findBox(input, 'jp2h', 0)
+ const ihdrBox = jp2hBox && findBox(input, 'ihdr', jp2hBox.offset + 8)
+ if (ihdrBox) {
+ return {
+ height: readUInt32BE(input, ihdrBox.offset + 8),
+ width: readUInt32BE(input, ihdrBox.offset + 12),
+ }
+ }
+ throw new TypeError('Unsupported JPEG 2000 format')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts b/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts
new file mode 100644
index 000000000..763cfc98c
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts
@@ -0,0 +1,162 @@
+// NOTE: we only support baseline and progressive JPGs here
+// due to the structure of the loader class, we only get a buffer
+// with a maximum size of 4096 bytes. so if the SOF marker is outside
+// if this range we can't detect the file size correctly.
+
+import type { IImage, ISize } from './interface.ts'
+import { readUInt, readUInt16BE, toHexString } from './utils.js'
+
+const EXIF_MARKER = '45786966'
+const APP1_DATA_SIZE_BYTES = 2
+const EXIF_HEADER_BYTES = 6
+const TIFF_BYTE_ALIGN_BYTES = 2
+const BIG_ENDIAN_BYTE_ALIGN = '4d4d'
+const LITTLE_ENDIAN_BYTE_ALIGN = '4949'
+
+// Each entry is exactly 12 bytes
+const IDF_ENTRY_BYTES = 12
+const NUM_DIRECTORY_ENTRIES_BYTES = 2
+
+function isEXIF(input: Uint8Array): boolean {
+ return toHexString(input, 2, 6) === EXIF_MARKER
+}
+
+function extractSize(input: Uint8Array, index: number): ISize {
+ return {
+ height: readUInt16BE(input, index),
+ width: readUInt16BE(input, index + 2),
+ }
+}
+
+function extractOrientation(exifBlock: Uint8Array, isBigEndian: boolean) {
+ // TODO: assert that this contains 0x002A
+ // let STATIC_MOTOROLA_TIFF_HEADER_BYTES = 2
+ // let TIFF_IMAGE_FILE_DIRECTORY_BYTES = 4
+
+ // TODO: derive from TIFF_IMAGE_FILE_DIRECTORY_BYTES
+ const idfOffset = 8
+
+ // IDF osset works from right after the header bytes
+ // (so the offset includes the tiff byte align)
+ const offset = EXIF_HEADER_BYTES + idfOffset
+
+ const idfDirectoryEntries = readUInt(exifBlock, 16, offset, isBigEndian)
+
+ for (
+ let directoryEntryNumber = 0;
+ directoryEntryNumber < idfDirectoryEntries;
+ directoryEntryNumber++
+ ) {
+ const start =
+ offset +
+ NUM_DIRECTORY_ENTRIES_BYTES +
+ directoryEntryNumber * IDF_ENTRY_BYTES
+ const end = start + IDF_ENTRY_BYTES
+
+ // Skip on corrupt EXIF blocks
+ if (start > exifBlock.length) {
+ return
+ }
+
+ const block = exifBlock.slice(start, end)
+ const tagNumber = readUInt(block, 16, 0, isBigEndian)
+
+ // 0x0112 (decimal: 274) is the `orientation` tag ID
+ if (tagNumber === 274) {
+ const dataFormat = readUInt(block, 16, 2, isBigEndian)
+ if (dataFormat !== 3) {
+ return
+ }
+
+ // unsigned int has 2 bytes per component
+ // if there would more than 4 bytes in total it's a pointer
+ const numberOfComponents = readUInt(block, 32, 4, isBigEndian)
+ if (numberOfComponents !== 1) {
+ return
+ }
+
+ return readUInt(block, 16, 8, isBigEndian)
+ }
+ }
+}
+
+function validateExifBlock(input: Uint8Array, index: number) {
+ // Skip APP1 Data Size
+ const exifBlock = input.slice(APP1_DATA_SIZE_BYTES, index)
+
+ // Consider byte alignment
+ const byteAlign = toHexString(
+ exifBlock,
+ EXIF_HEADER_BYTES,
+ EXIF_HEADER_BYTES + TIFF_BYTE_ALIGN_BYTES,
+ )
+
+ // Ignore Empty EXIF. Validate byte alignment
+ const isBigEndian = byteAlign === BIG_ENDIAN_BYTE_ALIGN
+ const isLittleEndian = byteAlign === LITTLE_ENDIAN_BYTE_ALIGN
+
+ if (isBigEndian || isLittleEndian) {
+ return extractOrientation(exifBlock, isBigEndian)
+ }
+}
+
+function validateInput(input: Uint8Array, index: number): void {
+ // index should be within buffer limits
+ if (index > input.length) {
+ throw new TypeError('Corrupt JPG, exceeded buffer limits')
+ }
+}
+
+export const JPG: IImage = {
+ validate: (input) => toHexString(input, 0, 2) === 'ffd8',
+
+ calculate(input) {
+ // Skip 4 chars, they are for signature
+ input = input.slice(4)
+
+ let orientation: number | undefined
+ let next: number
+ while (input.length) {
+ // read length of the next block
+ const i = readUInt16BE(input, 0)
+
+ // Every JPEG block must begin with a 0xFF
+ if (input[i] !== 0xff) {
+ // Change from upstream: fix non-0xFF blocks skipping
+ input = input.slice(i)
+ continue
+ }
+
+ if (isEXIF(input)) {
+ orientation = validateExifBlock(input, i)
+ }
+
+ // ensure correct format
+ validateInput(input, i)
+
+ // 0xFFC0 is baseline standard(SOF)
+ // 0xFFC1 is baseline optimized(SOF)
+ // 0xFFC2 is progressive(SOF2)
+ next = input[i + 1]
+ if (next === 0xc0 || next === 0xc1 || next === 0xc2) {
+ const size = extractSize(input, i + 5)
+
+ // TODO: is orientation=0 a valid answer here?
+ if (!orientation) {
+ return size
+ }
+
+ return {
+ height: size.height,
+ orientation,
+ width: size.width,
+ }
+ }
+
+ // move to the next block
+ input = input.slice(i + 2)
+ }
+
+ throw new TypeError('Invalid JPG, no size found')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts b/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts
new file mode 100644
index 000000000..6dc5a95ff
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts
@@ -0,0 +1,19 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32LE } from './utils.js'
+
+export const KTX: IImage = {
+ validate: (input) => {
+ const signature = toUTF8String(input, 1, 7)
+ return ['KTX 11', 'KTX 20'].includes(signature)
+ },
+
+ calculate: (input) => {
+ const type = input[5] === 0x31 ? 'ktx' : 'ktx2'
+ const offset = type === 'ktx' ? 36 : 20
+ return ({
+ height: readUInt32LE(input, offset + 4),
+ width: readUInt32LE(input, offset),
+ type,
+ })
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/png.ts b/packages/astro/src/assets/utils/vendor/image-size/types/png.ts
new file mode 100644
index 000000000..768a7f39e
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/png.ts
@@ -0,0 +1,37 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+const pngSignature = 'PNG\r\n\x1a\n'
+const pngImageHeaderChunkName = 'IHDR'
+
+// Used to detect "fried" png's: http://www.jongware.com/pngdefry.html
+const pngFriedChunkName = 'CgBI'
+
+export const PNG: IImage = {
+ validate(input) {
+ if (pngSignature === toUTF8String(input, 1, 8)) {
+ let chunkName = toUTF8String(input, 12, 16)
+ if (chunkName === pngFriedChunkName) {
+ chunkName = toUTF8String(input, 28, 32)
+ }
+ if (chunkName !== pngImageHeaderChunkName) {
+ throw new TypeError('Invalid PNG')
+ }
+ return true
+ }
+ return false
+ },
+
+ calculate(input) {
+ if (toUTF8String(input, 12, 16) === pngFriedChunkName) {
+ return {
+ height: readUInt32BE(input, 36),
+ width: readUInt32BE(input, 32),
+ }
+ }
+ return {
+ height: readUInt32BE(input, 20),
+ width: readUInt32BE(input, 16),
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts b/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts
new file mode 100644
index 000000000..3f489f2af
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts
@@ -0,0 +1,80 @@
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String } from './utils.js'
+
+const PNMTypes = {
+ P1: 'pbm/ascii',
+ P2: 'pgm/ascii',
+ P3: 'ppm/ascii',
+ P4: 'pbm',
+ P5: 'pgm',
+ P6: 'ppm',
+ P7: 'pam',
+ PF: 'pfm',
+} as const
+
+type ValidSignature = keyof typeof PNMTypes
+type Handler = (type: string[]) => ISize
+
+const handlers: { [type: string]: Handler } = {
+ default: (lines) => {
+ let dimensions: string[] = []
+
+ while (lines.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ const line = lines.shift() as string
+ if (line[0] === '#') {
+ continue
+ }
+ dimensions = line.split(' ')
+ break
+ }
+
+ if (dimensions.length === 2) {
+ return {
+ height: parseInt(dimensions[1], 10),
+ width: parseInt(dimensions[0], 10),
+ }
+ } else {
+ throw new TypeError('Invalid PNM')
+ }
+ },
+ pam: (lines) => {
+ const size: { [key: string]: number } = {}
+ while (lines.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ const line = lines.shift() as string
+ if (line.length > 16 || line.charCodeAt(0) > 128) {
+ continue
+ }
+ const [key, value] = line.split(' ')
+ if (key && value) {
+ size[key.toLowerCase()] = parseInt(value, 10)
+ }
+ if (size.height && size.width) {
+ break
+ }
+ }
+
+ if (size.height && size.width) {
+ return {
+ height: size.height,
+ width: size.width,
+ }
+ } else {
+ throw new TypeError('Invalid PAM')
+ }
+ },
+}
+
+export const PNM: IImage = {
+ validate: (input) => toUTF8String(input, 0, 2) in PNMTypes,
+
+ calculate(input) {
+ const signature = toUTF8String(input, 0, 2) as ValidSignature
+ const type = PNMTypes[signature]
+ // TODO: this probably generates garbage. move to a stream based parser
+ const lines = toUTF8String(input, 3).split(/[\r\n]+/)
+ const handler = handlers[type] || handlers.default
+ return handler(lines)
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts b/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts
new file mode 100644
index 000000000..b260e4abc
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+export const PSD: IImage = {
+ validate: (input) => toUTF8String(input, 0, 4) === '8BPS',
+
+ calculate: (input) => ({
+ height: readUInt32BE(input, 14),
+ width: readUInt32BE(input, 18),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts b/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts
new file mode 100644
index 000000000..ea5a05ac1
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts
@@ -0,0 +1,109 @@
+/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
+
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String } from './utils.js'
+
+type IAttributes = {
+ width: number | null
+ height: number | null
+ viewbox?: IAttributes | null
+}
+
+const svgReg = /<svg\s([^>"']|"[^"]*"|'[^']*')*>/
+
+const extractorRegExps = {
+ height: /\sheight=(['"])([^%]+?)\1/,
+ root: svgReg,
+ viewbox: /\sviewBox=(['"])(.+?)\1/i,
+ width: /\swidth=(['"])([^%]+?)\1/,
+}
+
+const INCH_CM = 2.54
+const units: { [unit: string]: number } = {
+ in: 96,
+ cm: 96 / INCH_CM,
+ em: 16,
+ ex: 8,
+ m: (96 / INCH_CM) * 100,
+ mm: 96 / INCH_CM / 10,
+ pc: 96 / 72 / 12,
+ pt: 96 / 72,
+ px: 1,
+}
+
+const unitsReg = new RegExp(
+ `^([0-9.]+(?:e\\d+)?)(${Object.keys(units).join('|')})?$`,
+)
+
+function parseLength(len: string) {
+ const m = unitsReg.exec(len)
+ if (!m) {
+ return undefined
+ }
+ return Math.round(Number(m[1]) * (units[m[2]] || 1))
+}
+
+function parseViewbox(viewbox: string): IAttributes {
+ const bounds = viewbox.split(' ')
+ return {
+ height: parseLength(bounds[3]) as number,
+ width: parseLength(bounds[2]) as number,
+ }
+}
+
+function parseAttributes(root: string): IAttributes {
+ const width = extractorRegExps.width.exec(root)
+ const height = extractorRegExps.height.exec(root)
+ const viewbox = extractorRegExps.viewbox.exec(root)
+ return {
+ height: height && (parseLength(height[2]) as number),
+ viewbox: viewbox && (parseViewbox(viewbox[2]) as IAttributes),
+ width: width && (parseLength(width[2]) as number),
+ }
+}
+
+function calculateByDimensions(attrs: IAttributes): ISize {
+ return {
+ height: attrs.height as number,
+ width: attrs.width as number,
+ }
+}
+
+function calculateByViewbox(attrs: IAttributes, viewbox: IAttributes): ISize {
+ const ratio = (viewbox.width as number) / (viewbox.height as number)
+ if (attrs.width) {
+ return {
+ height: Math.floor(attrs.width / ratio),
+ width: attrs.width,
+ }
+ }
+ if (attrs.height) {
+ return {
+ height: attrs.height,
+ width: Math.floor(attrs.height * ratio),
+ }
+ }
+ return {
+ height: viewbox.height as number,
+ width: viewbox.width as number,
+ }
+}
+
+export const SVG: IImage = {
+ // Scan only the first kilo-byte to speed up the check on larger files
+ validate: (input) => svgReg.test(toUTF8String(input, 0, 1000)),
+
+ calculate(input) {
+ const root = extractorRegExps.root.exec(toUTF8String(input))
+ if (root) {
+ const attrs = parseAttributes(root[0])
+ if (attrs.width && attrs.height) {
+ return calculateByDimensions(attrs)
+ }
+ if (attrs.viewbox) {
+ return calculateByViewbox(attrs, attrs.viewbox)
+ }
+ }
+ throw new TypeError('Invalid SVG')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts b/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts
new file mode 100644
index 000000000..1fd504b55
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts
@@ -0,0 +1,15 @@
+import type { IImage } from './interface.ts'
+import { readUInt16LE } from './utils.js'
+
+export const TGA: IImage = {
+ validate(input) {
+ return readUInt16LE(input, 0) === 0 && readUInt16LE(input, 4) === 0
+ },
+
+ calculate(input) {
+ return {
+ height: readUInt16LE(input, 14),
+ width: readUInt16LE(input, 12),
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts b/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts
new file mode 100644
index 000000000..c8590f11f
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts
@@ -0,0 +1,93 @@
+// based on http://www.compix.com/fileformattif.htm
+// TO-DO: support big-endian as well
+import type { IImage } from './interface.ts'
+import { readUInt, toHexString, toUTF8String } from './utils.js'
+
+// Read IFD (image-file-directory) into a buffer
+function readIFD(input: Uint8Array, isBigEndian: boolean) {
+ const ifdOffset = readUInt(input, 32, 4, isBigEndian)
+ return input.slice(ifdOffset + 2)
+}
+
+// TIFF values seem to be messed up on Big-Endian, this helps
+function readValue(input: Uint8Array, isBigEndian: boolean): number {
+ const low = readUInt(input, 16, 8, isBigEndian)
+ const high = readUInt(input, 16, 10, isBigEndian)
+ return (high << 16) + low
+}
+
+// move to the next tag
+function nextTag(input: Uint8Array) {
+ if (input.length > 24) {
+ return input.slice(12)
+ }
+}
+
+// Extract IFD tags from TIFF metadata
+function extractTags(input: Uint8Array, isBigEndian: boolean) {
+ const tags: { [key: number]: number } = {}
+
+ let temp: Uint8Array | undefined = input
+ while (temp && temp.length) {
+ const code = readUInt(temp, 16, 0, isBigEndian)
+ const type = readUInt(temp, 16, 2, isBigEndian)
+ const length = readUInt(temp, 32, 4, isBigEndian)
+
+ // 0 means end of IFD
+ if (code === 0) {
+ break
+ } else {
+ // 256 is width, 257 is height
+ // if (code === 256 || code === 257) {
+ if (length === 1 && (type === 3 || type === 4)) {
+ tags[code] = readValue(temp, isBigEndian)
+ }
+
+ // move to the next tag
+ temp = nextTag(temp)
+ }
+ }
+
+ return tags
+}
+
+// Test if the TIFF is Big Endian or Little Endian
+function determineEndianness(input: Uint8Array) {
+ const signature = toUTF8String(input, 0, 2)
+ if ('II' === signature) {
+ return 'LE'
+ } else if ('MM' === signature) {
+ return 'BE'
+ }
+}
+
+const signatures = [
+ // '492049', // currently not supported
+ '49492a00', // Little endian
+ '4d4d002a', // Big Endian
+ // '4d4d002a', // BigTIFF > 4GB. currently not supported
+]
+
+export const TIFF: IImage = {
+ validate: (input) => signatures.includes(toHexString(input, 0, 4)),
+
+ calculate(input) {
+ // Determine BE/LE
+ const isBigEndian = determineEndianness(input) === 'BE'
+
+ // read the IFD
+ const ifdBuffer = readIFD(input, isBigEndian)
+
+ // extract the tags from the IFD
+ const tags = extractTags(ifdBuffer, isBigEndian)
+
+ const width = tags[256]
+ const height = tags[257]
+
+ if (!width || !height) {
+ throw new TypeError('Invalid Tiff. Missing tags')
+ }
+
+ return { height, width }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts b/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts
new file mode 100644
index 000000000..c7f87da9a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts
@@ -0,0 +1,84 @@
+const decoder = new TextDecoder()
+export const toUTF8String = (
+ input: Uint8Array,
+ start = 0,
+ end = input.length,
+) => decoder.decode(input.slice(start, end))
+
+export const toHexString = (input: Uint8Array, start = 0, end = input.length) =>
+ input
+ .slice(start, end)
+ .reduce((memo, i) => memo + ('0' + i.toString(16)).slice(-2), '')
+
+export const readInt16LE = (input: Uint8Array, offset = 0) => {
+ const val = input[offset] + input[offset + 1] * 2 ** 8
+ return val | ((val & (2 ** 15)) * 0x1fffe)
+}
+
+export const readUInt16BE = (input: Uint8Array, offset = 0) =>
+ input[offset] * 2 ** 8 + input[offset + 1]
+
+export const readUInt16LE = (input: Uint8Array, offset = 0) =>
+ input[offset] + input[offset + 1] * 2 ** 8
+
+export const readUInt24LE = (input: Uint8Array, offset = 0) =>
+ input[offset] + input[offset + 1] * 2 ** 8 + input[offset + 2] * 2 ** 16
+
+export const readInt32LE = (input: Uint8Array, offset = 0) =>
+ input[offset] +
+ input[offset + 1] * 2 ** 8 +
+ input[offset + 2] * 2 ** 16 +
+ (input[offset + 3] << 24)
+
+export const readUInt32BE = (input: Uint8Array, offset = 0) =>
+ input[offset] * 2 ** 24 +
+ input[offset + 1] * 2 ** 16 +
+ input[offset + 2] * 2 ** 8 +
+ input[offset + 3]
+
+export const readUInt32LE = (input: Uint8Array, offset = 0) =>
+ input[offset] +
+ input[offset + 1] * 2 ** 8 +
+ input[offset + 2] * 2 ** 16 +
+ input[offset + 3] * 2 ** 24
+
+// Abstract reading multi-byte unsigned integers
+const methods = {
+ readUInt16BE,
+ readUInt16LE,
+ readUInt32BE,
+ readUInt32LE,
+} as const
+
+type MethodName = keyof typeof methods
+export function readUInt(
+ input: Uint8Array,
+ bits: 16 | 32,
+ offset: number,
+ isBigEndian: boolean,
+): number {
+ offset = offset || 0
+ const endian = isBigEndian ? 'BE' : 'LE'
+ const methodName: MethodName = ('readUInt' + bits + endian) as MethodName
+ return methods[methodName](input, offset)
+}
+
+function readBox(buffer: Uint8Array, offset: number) {
+ if (buffer.length - offset < 4) return
+ const boxSize = readUInt32BE(buffer, offset)
+ if (buffer.length - offset < boxSize) return
+ return {
+ name: toUTF8String(buffer, 4 + offset, 8 + offset),
+ offset,
+ size: boxSize,
+ }
+}
+
+export function findBox(buffer: Uint8Array, boxName: string, offset: number) {
+ while (offset < buffer.length) {
+ const box = readBox(buffer, offset)
+ if (!box) break
+ if (box.name === boxName) return box
+ offset += box.size
+ }
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts b/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts
new file mode 100644
index 000000000..79291df05
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts
@@ -0,0 +1,68 @@
+// based on https://developers.google.com/speed/webp/docs/riff_container
+import type { IImage, ISize } from './interface.ts'
+import { toHexString, toUTF8String, readInt16LE, readUInt24LE } from './utils.js'
+
+function calculateExtended(input: Uint8Array): ISize {
+ return {
+ height: 1 + readUInt24LE(input, 7),
+ width: 1 + readUInt24LE(input, 4),
+ }
+}
+
+function calculateLossless(input: Uint8Array): ISize {
+ return {
+ height:
+ 1 +
+ (((input[4] & 0xf) << 10) | (input[3] << 2) | ((input[2] & 0xc0) >> 6)),
+ width: 1 + (((input[2] & 0x3f) << 8) | input[1]),
+ }
+}
+
+function calculateLossy(input: Uint8Array): ISize {
+ // `& 0x3fff` returns the last 14 bits
+ // TO-DO: include webp scaling in the calculations
+ return {
+ height: readInt16LE(input, 8) & 0x3fff,
+ width: readInt16LE(input, 6) & 0x3fff,
+ }
+}
+
+export const WEBP: IImage = {
+ validate(input) {
+ const riffHeader = 'RIFF' === toUTF8String(input, 0, 4)
+ const webpHeader = 'WEBP' === toUTF8String(input, 8, 12)
+ const vp8Header = 'VP8' === toUTF8String(input, 12, 15)
+ return riffHeader && webpHeader && vp8Header
+ },
+
+ calculate(input) {
+ const chunkHeader = toUTF8String(input, 12, 16)
+ input = input.slice(20, 30)
+
+ // Extended webp stream signature
+ if (chunkHeader === 'VP8X') {
+ const extendedHeader = input[0]
+ const validStart = (extendedHeader & 0xc0) === 0
+ const validEnd = (extendedHeader & 0x01) === 0
+ if (validStart && validEnd) {
+ return calculateExtended(input)
+ } else {
+ // TODO: breaking change
+ throw new TypeError('Invalid WebP')
+ }
+ }
+
+ // Lossless webp stream signature
+ if (chunkHeader === 'VP8 ' && input[0] !== 0x2f) {
+ return calculateLossy(input)
+ }
+
+ // Lossy webp stream signature
+ const signature = toHexString(input, 3, 6)
+ if (chunkHeader === 'VP8L' && signature !== '9d012a') {
+ return calculateLossless(input)
+ }
+
+ throw new TypeError('Invalid WebP')
+ },
+}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
new file mode 100644
index 000000000..c3f37b8f9
--- /dev/null
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -0,0 +1,247 @@
+import { extname } from 'node:path';
+import MagicString from 'magic-string';
+import type * as vite from 'vite';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import {
+ appendForwardSlash,
+ joinPaths,
+ prependForwardSlash,
+ removeBase,
+ removeQueryString,
+} from '../core/path.js';
+import { normalizePath } from '../core/viteUtils.js';
+import type { AstroSettings } from '../types/astro.js';
+import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
+import type { ImageTransform } from './types.js';
+import { getAssetsPrefix } from './utils/getAssetsPrefix.js';
+import { isESMImportedImage } from './utils/imageKind.js';
+import { emitESMImage } from './utils/node/emitAsset.js';
+import { getProxyCode } from './utils/proxy.js';
+import { makeSvgComponent } from './utils/svg.js';
+import { hashTransform, propsToFilename } from './utils/transformToPath.js';
+
+const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
+
+const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})`, 'i');
+const assetRegexEnds = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})$`, 'i');
+const addStaticImageFactory = (
+ settings: AstroSettings,
+): typeof globalThis.astroAsset.addStaticImage => {
+ return (options, hashProperties, originalFSPath) => {
+ if (!globalThis.astroAsset.staticImages) {
+ globalThis.astroAsset.staticImages = new Map<
+ string,
+ {
+ originalSrcPath: string;
+ transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
+ }
+ >();
+ }
+
+ // Rollup will copy the file to the output directory, as such this is the path in the output directory, including the asset prefix / base
+ const ESMImportedImageSrc = isESMImportedImage(options.src) ? options.src.src : options.src;
+ const fileExtension = extname(ESMImportedImageSrc);
+ const assetPrefix = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);
+
+ // This is the path to the original image, from the dist root, without the base or the asset prefix (e.g. /_astro/image.hash.png)
+ const finalOriginalPath = removeBase(
+ removeBase(ESMImportedImageSrc, settings.config.base),
+ assetPrefix,
+ );
+
+ const hash = hashTransform(options, settings.config.image.service.entrypoint, hashProperties);
+
+ let finalFilePath: string;
+ let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath);
+ const transformForHash = transformsForPath?.transforms.get(hash);
+
+ // If the same image has already been transformed with the same options, we'll reuse the final path
+ if (transformsForPath && transformForHash) {
+ finalFilePath = transformForHash.finalPath;
+ } else {
+ finalFilePath = prependForwardSlash(
+ joinPaths(
+ isESMImportedImage(options.src) ? '' : settings.config.build.assets,
+ prependForwardSlash(propsToFilename(finalOriginalPath, options, hash)),
+ ),
+ );
+
+ if (!transformsForPath) {
+ globalThis.astroAsset.staticImages.set(finalOriginalPath, {
+ originalSrcPath: originalFSPath,
+ transforms: new Map(),
+ });
+ transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath)!;
+ }
+
+ transformsForPath.transforms.set(hash, {
+ finalPath: finalFilePath,
+ transform: options,
+ });
+ }
+
+ // The paths here are used for URLs, so we need to make sure they have the proper format for an URL
+ // (leading slash, prefixed with the base / assets prefix, encoded, etc)
+ if (settings.config.build.assetsPrefix) {
+ return encodeURI(joinPaths(assetPrefix, finalFilePath));
+ } else {
+ return encodeURI(prependForwardSlash(joinPaths(settings.config.base, finalFilePath)));
+ }
+ };
+};
+
+export default function assets({ settings }: { settings: AstroSettings }): vite.Plugin[] {
+ let resolvedConfig: vite.ResolvedConfig;
+ let shouldEmitFile = false;
+ let isBuild = false;
+
+ globalThis.astroAsset = {
+ referencedImages: new Set(),
+ };
+
+ const imageComponentPrefix = settings.config.experimental.responsiveImages ? 'Responsive' : '';
+ return [
+ // Expose the components and different utilities from `astro:assets`
+ {
+ name: 'astro:assets',
+ config(_, env) {
+ isBuild = env.command === 'build';
+ },
+ async resolveId(id) {
+ if (id === VIRTUAL_SERVICE_ID) {
+ return await this.resolve(settings.config.image.service.entrypoint);
+ }
+ if (id === VIRTUAL_MODULE_ID) {
+ return resolvedVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedVirtualModuleId) {
+ return /* ts */ `
+ export { getConfiguredImageService, isLocalService } from "astro/assets";
+ import { getImage as getImageInternal } from "astro/assets";
+ export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro";
+ export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro";
+ export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";
+
+ export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })};
+ // This is used by the @astrojs/node integration to locate images.
+ // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
+ // new URL("dist/...") is interpreted by the bundler as a signal to include that directory
+ // in the Lambda bundle, which would bloat the bundle with images.
+ // To prevent this, we mark the URL construction as pure,
+ // so that it's tree-shaken away for all platforms that don't need it.
+ export const outDir = /* #__PURE__ */ new URL(${JSON.stringify(
+ new URL(
+ settings.buildOutput === 'server'
+ ? settings.config.build.client
+ : settings.config.outDir,
+ ),
+ )});
+ export const assetsDir = /* #__PURE__ */ new URL(${JSON.stringify(
+ settings.config.build.assets,
+ )}, outDir);
+ export const getImage = async (options) => await getImageInternal(options, imageConfig);
+ `;
+ }
+ },
+ buildStart() {
+ if (!isBuild) return;
+ globalThis.astroAsset.addStaticImage = addStaticImageFactory(settings);
+ },
+ // In build, rewrite paths to ESM imported images in code to their final location
+ async renderChunk(code) {
+ const assetUrlRE = /__ASTRO_ASSET_IMAGE__([\w$]{8})__(?:_(.*?)__)?/g;
+
+ let match;
+ let s;
+ while ((match = assetUrlRE.exec(code))) {
+ s = s || (s = new MagicString(code));
+ const [full, hash, postfix = ''] = match;
+
+ const file = this.getFileName(hash);
+ const fileExtension = extname(file);
+ const pf = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);
+ const prefix = pf ? appendForwardSlash(pf) : resolvedConfig.base;
+ const outputFilepath = prefix + normalizePath(file + postfix);
+
+ s.overwrite(match.index, match.index + full.length, outputFilepath);
+ }
+
+ if (s) {
+ return {
+ code: s.toString(),
+ map: resolvedConfig.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null,
+ };
+ } else {
+ return null;
+ }
+ },
+ },
+ // Return a more advanced shape for images imported in ESM
+ {
+ name: 'astro:assets:esm',
+ enforce: 'pre',
+ config(_, env) {
+ shouldEmitFile = env.command === 'build';
+ },
+ configResolved(viteConfig) {
+ resolvedConfig = viteConfig;
+ },
+ async load(id, options) {
+ if (assetRegex.test(id)) {
+ if (!globalThis.astroAsset.referencedImages)
+ globalThis.astroAsset.referencedImages = new Set();
+
+ if (id !== removeQueryString(id)) {
+ // If our import has any query params, we'll let Vite handle it, nonetheless we'll make sure to not delete it
+ // See https://github.com/withastro/astro/issues/8333
+ globalThis.astroAsset.referencedImages.add(removeQueryString(id));
+ return;
+ }
+
+ // If the requested ID doesn't end with a valid image extension, we'll let Vite handle it
+ if (!assetRegexEnds.test(id)) {
+ return;
+ }
+
+ const emitFile = shouldEmitFile ? this.emitFile : undefined;
+ const imageMetadata = await emitESMImage(
+ id,
+ this.meta.watchMode,
+ !!settings.config.experimental.svg,
+ emitFile,
+ );
+
+ if (!imageMetadata) {
+ throw new AstroError({
+ ...AstroErrorData.ImageNotFound,
+ message: AstroErrorData.ImageNotFound.message(id),
+ });
+ }
+
+ if (settings.config.experimental.svg && /\.svg$/.test(id)) {
+ const { contents, ...metadata } = imageMetadata;
+ // We know that the contents are present, as we only emit this property for SVG files
+ return makeSvgComponent(metadata, contents!, {
+ mode: settings.config.experimental.svg.mode,
+ });
+ }
+
+ // We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build.
+ // Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported
+ // an image on the client, it should be present in the final build.
+ if (options?.ssr) {
+ return `export default ${getProxyCode(
+ imageMetadata,
+ settings.buildOutput === 'server',
+ )}`;
+ } else {
+ globalThis.astroAsset.referencedImages.add(imageMetadata.fsPath);
+ return `export default ${JSON.stringify(imageMetadata)}`;
+ }
+ }
+ },
+ },
+ ];
+}
diff --git a/packages/astro/src/cli/README.md b/packages/astro/src/cli/README.md
new file mode 100644
index 000000000..c8f85dc6f
--- /dev/null
+++ b/packages/astro/src/cli/README.md
@@ -0,0 +1,5 @@
+# `cli/`
+
+Code that controls Astro’s binfile and is responsible for `astro *` CLI commands.
+
+[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts
new file mode 100644
index 000000000..d4a413c72
--- /dev/null
+++ b/packages/astro/src/cli/add/index.ts
@@ -0,0 +1,1056 @@
+import fsMod, { existsSync, promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import boxen from 'boxen';
+import { diffWords } from 'diff';
+import { bold, cyan, dim, green, magenta, red, yellow } from 'kleur/colors';
+import { type ASTNode, type ProxifiedModule, builders, generateCode, loadFile } from 'magicast';
+import { getDefaultExportOptions } from 'magicast/helpers';
+import preferredPM from 'preferred-pm';
+import prompts from 'prompts';
+import maxSatisfying from 'semver/ranges/max-satisfying.js';
+import yoctoSpinner from 'yocto-spinner';
+import {
+ loadTSConfig,
+ resolveConfig,
+ resolveConfigPath,
+ resolveRoot,
+} from '../../core/config/index.js';
+import {
+ defaultTSConfig,
+ type frameworkWithTSSettings,
+ presets,
+ updateTSConfigForFramework,
+} from '../../core/config/tsconfig.js';
+import type { Logger } from '../../core/logger/core.js';
+import * as msg from '../../core/messages.js';
+import { printHelp } from '../../core/messages.js';
+import { appendForwardSlash } from '../../core/path.js';
+import { apply as applyPolyfill } from '../../core/polyfill.js';
+import { ensureProcessNodeEnv, parseNpmName } from '../../core/util.js';
+import { eventCliSession, telemetry } from '../../events/index.js';
+import { exec } from '../exec.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { fetchPackageJson, fetchPackageVersions } from '../install-package.js';
+
+interface AddOptions {
+ flags: Flags;
+}
+
+interface IntegrationInfo {
+ id: string;
+ packageName: string;
+ integrationName: string;
+ dependencies: [name: string, version: string][];
+ type: 'integration' | 'adapter';
+}
+
+const ALIASES = new Map([
+ ['solid', 'solid-js'],
+ ['tailwindcss', 'tailwind'],
+]);
+
+const STUBS = {
+ ASTRO_CONFIG: `import { defineConfig } from 'astro/config';\n// https://astro.build/config\nexport default defineConfig({});`,
+ TAILWIND_GLOBAL_CSS: `@import "tailwindcss";`,
+ SVELTE_CONFIG: `\
+import { vitePreprocess } from '@astrojs/svelte';
+
+export default {
+ preprocess: vitePreprocess(),
+}\n`,
+ LIT_NPMRC: `\
+# Lit libraries are required to be hoisted due to dependency issues.
+public-hoist-pattern[]=*lit*
+`,
+ DB_CONFIG: `\
+import { defineDb } from 'astro:db';
+
+// https://astro.build/db/config
+export default defineDb({
+ tables: {}
+});
+`,
+ DB_SEED: `\
+import { db } from 'astro:db';
+
+// https://astro.build/db/seed
+export default async function seed() {
+ // TODO
+}
+`,
+};
+
+const OFFICIAL_ADAPTER_TO_IMPORT_MAP: Record<string, string> = {
+ netlify: '@astrojs/netlify',
+ vercel: '@astrojs/vercel',
+ cloudflare: '@astrojs/cloudflare',
+ node: '@astrojs/node',
+};
+
+export async function add(names: string[], { flags }: AddOptions) {
+ ensureProcessNodeEnv('production');
+ applyPolyfill();
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+ const { userConfig } = await resolveConfig(inlineConfig, 'add');
+ telemetry.record(eventCliSession('add', userConfig));
+ if (flags.help || names.length === 0) {
+ printHelp({
+ commandName: 'astro add',
+ usage: '[...integrations] [...adapters]',
+ tables: {
+ Flags: [
+ ['--yes', 'Accept all prompts.'],
+ ['--help', 'Show this help message.'],
+ ],
+ 'UI Frameworks': [
+ ['react', 'astro add react'],
+ ['preact', 'astro add preact'],
+ ['vue', 'astro add vue'],
+ ['svelte', 'astro add svelte'],
+ ['solid-js', 'astro add solid-js'],
+ ['lit', 'astro add lit'],
+ ['alpinejs', 'astro add alpinejs'],
+ ],
+ 'Documentation Frameworks': [['starlight', 'astro add starlight']],
+ 'SSR Adapters': [
+ ['netlify', 'astro add netlify'],
+ ['vercel', 'astro add vercel'],
+ ['deno', 'astro add deno'],
+ ['cloudflare', 'astro add cloudflare'],
+ ['node', 'astro add node'],
+ ],
+ Others: [
+ ['db', 'astro add db'],
+ ['tailwind', 'astro add tailwind'],
+ ['mdx', 'astro add mdx'],
+ ['markdoc', 'astro add markdoc'],
+ ['partytown', 'astro add partytown'],
+ ['sitemap', 'astro add sitemap'],
+ ],
+ },
+ description: `For more integrations, check out: ${cyan('https://astro.build/integrations')}`,
+ });
+ return;
+ }
+
+ // Some packages might have a common alias! We normalize those here.
+ const cwd = inlineConfig.root;
+ const logger = createLoggerFromFlags(flags);
+ const integrationNames = names.map((name) => (ALIASES.has(name) ? ALIASES.get(name)! : name));
+ const integrations = await validateIntegrations(integrationNames);
+ let installResult = await tryToInstallIntegrations({ integrations, cwd, flags, logger });
+ const rootPath = resolveRoot(cwd);
+ const root = pathToFileURL(rootPath);
+ // Append forward slash to compute relative paths
+ root.href = appendForwardSlash(root.href);
+
+ switch (installResult) {
+ case UpdateResult.updated: {
+ if (integrations.find((integration) => integration.id === 'tailwind')) {
+ const dir = new URL('./styles/', new URL(userConfig.srcDir ?? './src/', root));
+ const styles = new URL('./global.css', dir);
+ if (!existsSync(styles)) {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta(`Astro will scaffold ${green('./src/styles/global.css')}.`)}\n`,
+ );
+
+ if (await askToContinue({ flags })) {
+ if (!existsSync(dir)) {
+ await fs.mkdir(dir);
+ }
+ await fs.writeFile(styles, STUBS.TAILWIND_GLOBAL_CSS, 'utf-8');
+ } else {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n @astrojs/tailwind requires additional configuration. Please refer to https://docs.astro.build/en/guides/integrations-guide/tailwind/`,
+ );
+ }
+ } else {
+ logger.debug('add', `Using existing tailwind configuration`);
+ }
+ }
+ if (integrations.find((integration) => integration.id === 'svelte')) {
+ await setupIntegrationConfig({
+ root,
+ logger,
+ flags,
+ integrationName: 'Svelte',
+ possibleConfigFiles: ['./svelte.config.js', './svelte.config.cjs', './svelte.config.mjs'],
+ defaultConfigFile: './svelte.config.js',
+ defaultConfigContent: STUBS.SVELTE_CONFIG,
+ });
+ }
+ if (integrations.find((integration) => integration.id === 'db')) {
+ if (!existsSync(new URL('./db/', root))) {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta(
+ `Astro will scaffold ${green('./db/config.ts')}${magenta(' and ')}${green(
+ './db/seed.ts',
+ )}${magenta(' files.')}`,
+ )}\n`,
+ );
+
+ if (await askToContinue({ flags })) {
+ await fs.mkdir(new URL('./db', root));
+ await Promise.all([
+ fs.writeFile(new URL('./db/config.ts', root), STUBS.DB_CONFIG, { encoding: 'utf-8' }),
+ fs.writeFile(new URL('./db/seed.ts', root), STUBS.DB_SEED, { encoding: 'utf-8' }),
+ ]);
+ } else {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n Astro DB requires additional configuration. Please refer to https://astro.build/db/config`,
+ );
+ }
+ } else {
+ logger.debug('add', `Using existing db configuration`);
+ }
+ }
+ // Some lit dependencies needs to be hoisted, so for strict package managers like pnpm,
+ // we add an .npmrc to hoist them
+ if (
+ integrations.find((integration) => integration.id === 'lit') &&
+ (await preferredPM(fileURLToPath(root)))?.name === 'pnpm'
+ ) {
+ await setupIntegrationConfig({
+ root,
+ logger,
+ flags,
+ integrationName: 'Lit',
+ possibleConfigFiles: ['./.npmrc'],
+ defaultConfigFile: './.npmrc',
+ defaultConfigContent: STUBS.LIT_NPMRC,
+ });
+ }
+ break;
+ }
+ case UpdateResult.cancelled: {
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.cancelled(
+ `Dependencies ${bold('NOT')} installed.`,
+ `Be sure to install them manually before continuing!`,
+ ),
+ );
+ break;
+ }
+ case UpdateResult.failure: {
+ throw createPrettyError(new Error(`Unable to install dependencies`));
+ }
+ case UpdateResult.none:
+ break;
+ }
+
+ const rawConfigPath = await resolveConfigPath({
+ root: rootPath,
+ configFile: inlineConfig.configFile,
+ fs: fsMod,
+ });
+ let configURL = rawConfigPath ? pathToFileURL(rawConfigPath) : undefined;
+
+ if (configURL) {
+ logger.debug('add', `Found config at ${configURL}`);
+ } else {
+ logger.info('add', `Unable to locate a config file, generating one for you.`);
+ configURL = new URL('./astro.config.mjs', root);
+ await fs.writeFile(fileURLToPath(configURL), STUBS.ASTRO_CONFIG, { encoding: 'utf-8' });
+ }
+
+ let mod: ProxifiedModule<any> | undefined;
+ try {
+ mod = await loadFile(fileURLToPath(configURL));
+ logger.debug('add', 'Parsed astro config');
+
+ if (mod.exports.default.$type !== 'function-call') {
+ // ensure config is wrapped with `defineConfig`
+ mod.imports.$prepend({ imported: 'defineConfig', from: 'astro/config' });
+ mod.exports.default = builders.functionCall('defineConfig', mod.exports.default);
+ } else if (mod.exports.default.$args[0] == null) {
+ // ensure first argument of `defineConfig` is not empty
+ mod.exports.default.$args[0] = {};
+ }
+ logger.debug('add', 'Astro config ensured `defineConfig`');
+
+ for (const integration of integrations) {
+ if (isAdapter(integration)) {
+ const officialExportName = OFFICIAL_ADAPTER_TO_IMPORT_MAP[integration.id];
+ if (officialExportName) {
+ setAdapter(mod, integration, officialExportName);
+ } else {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta(
+ `Check our deployment docs for ${bold(
+ integration.integrationName,
+ )} to update your "adapter" config.`,
+ )}`,
+ );
+ }
+ } else if (integration.id === 'tailwind') {
+ addVitePlugin(mod, 'tailwindcss', '@tailwindcss/vite');
+ } else {
+ addIntegration(mod, integration);
+ }
+ logger.debug('add', `Astro config added integration ${integration.id}`);
+ }
+ } catch (err) {
+ logger.debug('add', 'Error parsing/modifying astro config: ', err);
+ throw createPrettyError(err as Error);
+ }
+
+ let configResult: UpdateResult | undefined;
+
+ if (mod) {
+ try {
+ configResult = await updateAstroConfig({
+ configURL,
+ mod,
+ flags,
+ logger,
+ logAdapterInstructions: integrations.some(isAdapter),
+ });
+ } catch (err) {
+ logger.debug('add', 'Error updating astro config', err);
+ throw createPrettyError(err as Error);
+ }
+ }
+
+ switch (configResult) {
+ case UpdateResult.cancelled: {
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.cancelled(`Your configuration has ${bold('NOT')} been updated.`),
+ );
+ break;
+ }
+ case UpdateResult.none: {
+ const pkgURL = new URL('./package.json', configURL);
+ if (existsSync(fileURLToPath(pkgURL))) {
+ const { dependencies = {}, devDependencies = {} } = await fs
+ .readFile(fileURLToPath(pkgURL))
+ .then((res) => JSON.parse(res.toString()));
+ const deps = Object.keys(Object.assign(dependencies, devDependencies));
+ const missingDeps = integrations.filter(
+ (integration) => !deps.includes(integration.packageName),
+ );
+ if (missingDeps.length === 0) {
+ logger.info('SKIP_FORMAT', msg.success(`Configuration up-to-date.`));
+ break;
+ }
+ }
+
+ logger.info('SKIP_FORMAT', msg.success(`Configuration up-to-date.`));
+ break;
+ }
+ // NOTE: failure shouldn't happen in practice because `updateAstroConfig` doesn't return that.
+ // Pipe this to the same handling as `UpdateResult.updated` for now.
+ case UpdateResult.failure:
+ case UpdateResult.updated:
+ case undefined: {
+ const list = integrations
+ .map((integration) => ` - ${integration.integrationName}`)
+ .join('\n');
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.success(
+ `Added the following integration${
+ integrations.length === 1 ? '' : 's'
+ } to your project:\n${list}`,
+ ),
+ );
+ if (integrations.find((integration) => integration.integrationName === 'tailwind')) {
+ const code = boxen(
+ getDiffContent('---\n---', "---\nimport './src/styles/global.css'\n---")!,
+ {
+ margin: 0.5,
+ padding: 0.5,
+ borderStyle: 'round',
+ title: 'src/layouts/Layout.astro',
+ },
+ );
+ logger.warn(
+ 'SKIP_FORMAT',
+ msg.actionRequired(
+ 'You must import your Tailwind stylesheet, e.g. in a shared layout:\n',
+ ),
+ );
+ logger.info('SKIP_FORMAT', code + '\n');
+ }
+ }
+ }
+
+ const updateTSConfigResult = await updateTSConfig(cwd, logger, integrations, flags);
+
+ switch (updateTSConfigResult) {
+ case UpdateResult.none: {
+ break;
+ }
+ case UpdateResult.cancelled: {
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.cancelled(`Your TypeScript configuration has ${bold('NOT')} been updated.`),
+ );
+ break;
+ }
+ case UpdateResult.failure: {
+ throw new Error(
+ `Unknown error parsing tsconfig.json or jsconfig.json. Could not update TypeScript settings.`,
+ );
+ }
+ case UpdateResult.updated:
+ logger.info('SKIP_FORMAT', msg.success(`Successfully updated TypeScript settings`));
+ }
+}
+
+function isAdapter(
+ integration: IntegrationInfo,
+): integration is IntegrationInfo & { type: 'adapter' } {
+ return integration.type === 'adapter';
+}
+
+// Convert an arbitrary NPM package name into a JS identifier
+// Some examples:
+// - @astrojs/image => image
+// - @astrojs/markdown-component => markdownComponent
+// - @astrojs/image@beta => image
+// - astro-cast => cast
+// - astro-cast@next => cast
+// - markdown-astro => markdown
+// - some-package => somePackage
+// - example.com => exampleCom
+// - under_score => underScore
+// - 123numeric => numeric
+// - @npm/thingy => npmThingy
+// - @npm/thingy@1.2.3 => npmThingy
+// - @jane/foo.js => janeFoo
+// - @tokencss/astro => tokencss
+const toIdent = (name: string) => {
+ const ident = name
+ .trim()
+ // Remove astro or (astrojs) prefix and suffix
+ .replace(/[-_./]?astro(?:js)?[-_.]?/g, '')
+ // drop .js suffix
+ .replace(/\.js/, '')
+ // convert to camel case
+ .replace(/[.\-_/]+([a-zA-Z])/g, (_, w) => w.toUpperCase())
+ // drop invalid first characters
+ .replace(/^[^a-zA-Z$_]+/, '')
+ // drop version or tag
+ .replace(/@.*$/, '');
+ return `${ident[0].toLowerCase()}${ident.slice(1)}`;
+};
+
+function createPrettyError(err: Error) {
+ err.message = `Astro could not update your astro.config.js file safely.
+Reason: ${err.message}
+
+You will need to add these integration(s) manually.
+Documentation: https://docs.astro.build/en/guides/integrations-guide/`;
+ return err;
+}
+
+function addIntegration(mod: ProxifiedModule<any>, integration: IntegrationInfo) {
+ const config = getDefaultExportOptions(mod);
+ const integrationId = toIdent(integration.id);
+
+ if (!mod.imports.$items.some((imp) => imp.local === integrationId)) {
+ mod.imports.$append({
+ imported: 'default',
+ local: integrationId,
+ from: integration.packageName,
+ });
+ }
+
+ config.integrations ??= [];
+ if (
+ !config.integrations.$ast.elements.some(
+ (el: ASTNode) =>
+ el.type === 'CallExpression' &&
+ el.callee.type === 'Identifier' &&
+ el.callee.name === integrationId,
+ )
+ ) {
+ config.integrations.push(builders.functionCall(integrationId));
+ }
+}
+
+function addVitePlugin(mod: ProxifiedModule<any>, pluginId: string, packageName: string) {
+ const config = getDefaultExportOptions(mod);
+
+ if (!mod.imports.$items.some((imp) => imp.local === pluginId)) {
+ mod.imports.$append({
+ imported: 'default',
+ local: pluginId,
+ from: packageName,
+ });
+ }
+
+ config.vite ??= {};
+ config.vite.plugins ??= [];
+ if (
+ !config.vite.plugins.$ast.elements.some(
+ (el: ASTNode) =>
+ el.type === 'CallExpression' &&
+ el.callee.type === 'Identifier' &&
+ el.callee.name === pluginId,
+ )
+ ) {
+ config.vite.plugins.push(builders.functionCall(pluginId));
+ }
+}
+
+export function setAdapter(
+ mod: ProxifiedModule<any>,
+ adapter: IntegrationInfo,
+ exportName: string,
+) {
+ const config = getDefaultExportOptions(mod);
+ const adapterId = toIdent(adapter.id);
+
+ if (!mod.imports.$items.some((imp) => imp.local === adapterId)) {
+ mod.imports.$append({
+ imported: 'default',
+ local: adapterId,
+ from: exportName,
+ });
+ }
+
+ switch (adapter.id) {
+ case 'node':
+ config.adapter = builders.functionCall(adapterId, { mode: 'standalone' });
+ break;
+ default:
+ config.adapter = builders.functionCall(adapterId);
+ break;
+ }
+}
+
+const enum UpdateResult {
+ none,
+ updated,
+ cancelled,
+ failure,
+}
+
+async function updateAstroConfig({
+ configURL,
+ mod,
+ flags,
+ logger,
+ logAdapterInstructions,
+}: {
+ configURL: URL;
+ mod: ProxifiedModule<any>;
+ flags: Flags;
+ logger: Logger;
+ logAdapterInstructions: boolean;
+}): Promise<UpdateResult> {
+ const input = await fs.readFile(fileURLToPath(configURL), { encoding: 'utf-8' });
+ const output = generateCode(mod, {
+ format: {
+ objectCurlySpacing: true,
+ useTabs: false,
+ tabWidth: 2,
+ },
+ }).code;
+
+ if (input === output) {
+ return UpdateResult.none;
+ }
+
+ const diff = getDiffContent(input, output);
+
+ if (!diff) {
+ return UpdateResult.none;
+ }
+
+ const message = `\n${boxen(diff, {
+ margin: 0.5,
+ padding: 0.5,
+ borderStyle: 'round',
+ title: configURL.pathname.split('/').pop(),
+ })}\n`;
+
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta('Astro will make the following changes to your config file:')}\n${message}`,
+ );
+
+ if (logAdapterInstructions) {
+ logger.info(
+ 'SKIP_FORMAT',
+ magenta(
+ ` For complete deployment options, visit\n ${bold(
+ 'https://docs.astro.build/en/guides/deploy/',
+ )}\n`,
+ ),
+ );
+ }
+
+ if (await askToContinue({ flags })) {
+ await fs.writeFile(fileURLToPath(configURL), output, { encoding: 'utf-8' });
+ logger.debug('add', `Updated astro config`);
+ return UpdateResult.updated;
+ } else {
+ return UpdateResult.cancelled;
+ }
+}
+
+interface InstallCommand {
+ pm: string;
+ command: string;
+ flags: string[];
+ dependencies: string[];
+}
+
+async function getInstallIntegrationsCommand({
+ integrations,
+ logger,
+ cwd = process.cwd(),
+}: {
+ integrations: IntegrationInfo[];
+ logger: Logger;
+ cwd?: string;
+}): Promise<InstallCommand | null> {
+ const pm = await preferredPM(cwd);
+ logger.debug('add', `package manager: ${JSON.stringify(pm)}`);
+ if (!pm) return null;
+
+ const dependencies = await convertIntegrationsToInstallSpecifiers(integrations);
+ switch (pm.name) {
+ case 'npm':
+ return { pm: 'npm', command: 'install', flags: [], dependencies };
+ case 'yarn':
+ return { pm: 'yarn', command: 'add', flags: [], dependencies };
+ case 'pnpm':
+ return { pm: 'pnpm', command: 'add', flags: [], dependencies };
+ case 'bun':
+ return { pm: 'bun', command: 'add', flags: [], dependencies };
+ default:
+ return null;
+ }
+}
+
+async function convertIntegrationsToInstallSpecifiers(
+ integrations: IntegrationInfo[],
+): Promise<string[]> {
+ const ranges: Record<string, string> = {};
+ for (let { dependencies } of integrations) {
+ for (const [name, range] of dependencies) {
+ ranges[name] = range;
+ }
+ }
+ return Promise.all(
+ Object.entries(ranges).map(([name, range]) => resolveRangeToInstallSpecifier(name, range)),
+ );
+}
+
+/**
+ * Resolves package with a given range to a STABLE version
+ * peerDependencies might specify a compatible prerelease,
+ * but `astro add` should only ever install stable releases
+ */
+async function resolveRangeToInstallSpecifier(name: string, range: string): Promise<string> {
+ const versions = await fetchPackageVersions(name);
+ if (versions instanceof Error) return name;
+ // Filter out any prerelease versions, but fallback if there are no stable versions
+ const stableVersions = versions.filter((v) => !v.includes('-'));
+ const maxStable = maxSatisfying(stableVersions, range) ?? maxSatisfying(versions, range);
+ if (!maxStable) return name;
+ return `${name}@^${maxStable}`;
+}
+
+// Allow forwarding of standard `npm install` flags
+// See https://docs.npmjs.com/cli/v8/commands/npm-install#description
+const INHERITED_FLAGS = new Set<string>([
+ 'P',
+ 'save-prod',
+ 'D',
+ 'save-dev',
+ 'E',
+ 'save-exact',
+ 'no-save',
+]);
+
+async function tryToInstallIntegrations({
+ integrations,
+ cwd,
+ flags,
+ logger,
+}: {
+ integrations: IntegrationInfo[];
+ cwd?: string;
+ flags: Flags;
+ logger: Logger;
+}): Promise<UpdateResult> {
+ const installCommand = await getInstallIntegrationsCommand({ integrations, cwd, logger });
+
+ const inheritedFlags = Object.entries(flags)
+ .map(([flag]) => {
+ if (flag == '_') return;
+ if (INHERITED_FLAGS.has(flag)) {
+ if (flag.length === 1) return `-${flag}`;
+ return `--${flag}`;
+ }
+ })
+ .filter(Boolean)
+ .flat() as string[];
+
+ if (installCommand === null) {
+ return UpdateResult.none;
+ } else {
+ const coloredOutput = `${bold(installCommand.pm)} ${installCommand.command}${[
+ '',
+ ...installCommand.flags,
+ ...inheritedFlags,
+ ].join(' ')} ${cyan(installCommand.dependencies.join(' '))}`;
+ const message = `\n${boxen(coloredOutput, {
+ margin: 0.5,
+ padding: 0.5,
+ borderStyle: 'round',
+ })}\n`;
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta('Astro will run the following command:')}\n ${dim(
+ 'If you skip this step, you can always run it yourself later',
+ )}\n${message}`,
+ );
+
+ if (await askToContinue({ flags })) {
+ const spinner = yoctoSpinner({ text: 'Installing dependencies...' }).start();
+ try {
+ await exec(
+ installCommand.pm,
+ [
+ installCommand.command,
+ ...installCommand.flags,
+ ...inheritedFlags,
+ ...installCommand.dependencies,
+ ],
+ {
+ nodeOptions: {
+ cwd,
+ // reset NODE_ENV to ensure install command run in dev mode
+ env: { NODE_ENV: undefined },
+ },
+ },
+ );
+ spinner.success();
+ return UpdateResult.updated;
+ } catch (err: any) {
+ spinner.error();
+ logger.debug('add', 'Error installing dependencies', err);
+ // NOTE: `err.stdout` can be an empty string, so log the full error instead for a more helpful log
+ console.error('\n', err.stdout || err.message, '\n');
+ return UpdateResult.failure;
+ }
+ } else {
+ return UpdateResult.cancelled;
+ }
+ }
+}
+
+async function validateIntegrations(integrations: string[]): Promise<IntegrationInfo[]> {
+ const spinner = yoctoSpinner({ text: 'Resolving packages...' }).start();
+ try {
+ const integrationEntries = await Promise.all(
+ integrations.map(async (integration): Promise<IntegrationInfo> => {
+ const parsed = parseIntegrationName(integration);
+ if (!parsed) {
+ throw new Error(`${bold(integration)} does not appear to be a valid package name!`);
+ }
+ let { scope, name, tag } = parsed;
+ let pkgJson;
+ let pkgType: 'first-party' | 'third-party';
+
+ if (scope && scope !== '@astrojs') {
+ pkgType = 'third-party';
+ } else {
+ const firstPartyPkgCheck = await fetchPackageJson('@astrojs', name, tag);
+ if (firstPartyPkgCheck instanceof Error) {
+ if (firstPartyPkgCheck.message) {
+ spinner.warning(yellow(firstPartyPkgCheck.message));
+ }
+ spinner.warning(yellow(`${bold(integration)} is not an official Astro package.`));
+ const response = await prompts({
+ type: 'confirm',
+ name: 'askToContinue',
+ message: 'Continue?',
+ initial: true,
+ });
+ if (!response.askToContinue) {
+ throw new Error(
+ `No problem! Find our official integrations at ${cyan(
+ 'https://astro.build/integrations',
+ )}`,
+ );
+ }
+ spinner.start('Resolving with third party packages...');
+ pkgType = 'third-party';
+ } else {
+ pkgType = 'first-party';
+ pkgJson = firstPartyPkgCheck as any;
+ }
+ }
+ if (pkgType === 'third-party') {
+ const thirdPartyPkgCheck = await fetchPackageJson(scope, name, tag);
+ if (thirdPartyPkgCheck instanceof Error) {
+ if (thirdPartyPkgCheck.message) {
+ spinner.warning(yellow(thirdPartyPkgCheck.message));
+ }
+ throw new Error(`Unable to fetch ${bold(integration)}. Does the package exist?`);
+ } else {
+ pkgJson = thirdPartyPkgCheck as any;
+ }
+ }
+
+ const resolvedScope = pkgType === 'first-party' ? '@astrojs' : scope;
+ const packageName = `${resolvedScope ? `${resolvedScope}/` : ''}${name}`;
+ let integrationName = packageName;
+ let dependencies: IntegrationInfo['dependencies'] = [
+ [pkgJson['name'], `^${pkgJson['version']}`],
+ ];
+
+ if (pkgJson['peerDependencies']) {
+ const meta = pkgJson['peerDependenciesMeta'] || {};
+ for (const peer in pkgJson['peerDependencies']) {
+ const optional = meta[peer]?.optional || false;
+ const isAstro = peer === 'astro';
+ if (!optional && !isAstro) {
+ dependencies.push([peer, pkgJson['peerDependencies'][peer]]);
+ }
+ }
+ }
+
+ let integrationType: IntegrationInfo['type'];
+ const keywords = Array.isArray(pkgJson['keywords']) ? pkgJson['keywords'] : [];
+ if (keywords.includes('astro-integration')) {
+ integrationType = 'integration';
+ } else if (keywords.includes('astro-adapter')) {
+ integrationType = 'adapter';
+ } else {
+ throw new Error(
+ `${bold(
+ packageName,
+ )} doesn't appear to be an integration or an adapter. Find our official integrations at ${cyan(
+ 'https://astro.build/integrations',
+ )}`,
+ );
+ }
+
+ if (integration === 'tailwind') {
+ integrationName = 'tailwind';
+ dependencies = [
+ ['@tailwindcss/vite', '^4.0.0'],
+ ['tailwindcss', '^4.0.0'],
+ ];
+ }
+ return {
+ id: integration,
+ packageName,
+ dependencies,
+ type: integrationType,
+ integrationName,
+ };
+ }),
+ );
+ spinner.success();
+ return integrationEntries;
+ } catch (e) {
+ if (e instanceof Error) {
+ spinner.error(e.message);
+ process.exit(1);
+ } else {
+ throw e;
+ }
+ }
+}
+
+async function updateTSConfig(
+ cwd = process.cwd(),
+ logger: Logger,
+ integrationsInfo: IntegrationInfo[],
+ flags: Flags,
+): Promise<UpdateResult> {
+ const integrations = integrationsInfo.map(
+ (integration) => integration.id as frameworkWithTSSettings,
+ );
+ const firstIntegrationWithTSSettings = integrations.find((integration) =>
+ presets.has(integration),
+ );
+
+ if (!firstIntegrationWithTSSettings) {
+ return UpdateResult.none;
+ }
+
+ let inputConfig = await loadTSConfig(cwd);
+ let inputConfigText = '';
+
+ if (inputConfig === 'invalid-config' || inputConfig === 'unknown-error') {
+ return UpdateResult.failure;
+ } else if (inputConfig === 'missing-config') {
+ logger.debug('add', "Couldn't find tsconfig.json or jsconfig.json, generating one");
+ inputConfig = {
+ tsconfig: defaultTSConfig,
+ tsconfigFile: path.join(cwd, 'tsconfig.json'),
+ rawConfig: defaultTSConfig,
+ };
+ } else {
+ inputConfigText = JSON.stringify(inputConfig.rawConfig, null, 2);
+ }
+
+ const configFileName = path.basename(inputConfig.tsconfigFile);
+
+ const outputConfig = updateTSConfigForFramework(
+ inputConfig.rawConfig,
+ firstIntegrationWithTSSettings,
+ );
+
+ const output = JSON.stringify(outputConfig, null, 2);
+ const diff = getDiffContent(inputConfigText, output);
+
+ if (!diff) {
+ return UpdateResult.none;
+ }
+
+ const message = `\n${boxen(diff, {
+ margin: 0.5,
+ padding: 0.5,
+ borderStyle: 'round',
+ title: configFileName,
+ })}\n`;
+
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta(`Astro will make the following changes to your ${configFileName}:`)}\n${message}`,
+ );
+
+ // Every major framework, apart from Vue and Svelte requires different `jsxImportSource`, as such it's impossible to config
+ // all of them in the same `tsconfig.json`. However, Vue only need `"jsx": "preserve"` for template intellisense which
+ // can be compatible with some frameworks (ex: Solid)
+ const conflictingIntegrations = [...Object.keys(presets).filter((config) => config !== 'vue')];
+ const hasConflictingIntegrations =
+ integrations.filter((integration) => presets.has(integration)).length > 1 &&
+ integrations.filter((integration) => conflictingIntegrations.includes(integration)).length > 0;
+
+ if (hasConflictingIntegrations) {
+ logger.info(
+ 'SKIP_FORMAT',
+ red(
+ ` ${bold(
+ 'Caution:',
+ )} Selected UI frameworks require conflicting tsconfig.json settings, as such only settings for ${bold(
+ firstIntegrationWithTSSettings,
+ )} were used.\n More information: https://docs.astro.build/en/guides/typescript/#errors-typing-multiple-jsx-frameworks-at-the-same-time\n`,
+ ),
+ );
+ }
+
+ if (await askToContinue({ flags })) {
+ await fs.writeFile(inputConfig.tsconfigFile, output, {
+ encoding: 'utf-8',
+ });
+ logger.debug('add', `Updated ${configFileName} file`);
+ return UpdateResult.updated;
+ } else {
+ return UpdateResult.cancelled;
+ }
+}
+
+function parseIntegrationName(spec: string) {
+ const result = parseNpmName(spec);
+ if (!result) return;
+ let { scope, name } = result;
+ let tag = 'latest';
+ if (scope) {
+ name = name.replace(scope + '/', '');
+ }
+ if (name.includes('@')) {
+ const tagged = name.split('@');
+ name = tagged[0];
+ tag = tagged[1];
+ }
+ return { scope, name, tag };
+}
+
+async function askToContinue({ flags }: { flags: Flags }): Promise<boolean> {
+ if (flags.yes || flags.y) return true;
+
+ const response = await prompts({
+ type: 'confirm',
+ name: 'askToContinue',
+ message: 'Continue?',
+ initial: true,
+ });
+
+ return Boolean(response.askToContinue);
+}
+
+function getDiffContent(input: string, output: string): string | null {
+ let changes = [];
+ for (const change of diffWords(input, output)) {
+ let lines = change.value.trim().split('\n').slice(0, change.count);
+ if (lines.length === 0) continue;
+ if (change.added) {
+ if (!change.value.trim()) continue;
+ changes.push(change.value);
+ }
+ }
+ if (changes.length === 0) {
+ return null;
+ }
+
+ let diffed = output;
+ for (let newContent of changes) {
+ const coloredOutput = newContent
+ .split('\n')
+ .map((ln) => (ln ? green(ln) : ''))
+ .join('\n');
+ diffed = diffed.replace(newContent, coloredOutput);
+ }
+
+ return diffed;
+}
+
+async function setupIntegrationConfig(opts: {
+ root: URL;
+ logger: Logger;
+ flags: Flags;
+ integrationName: string;
+ possibleConfigFiles: string[];
+ defaultConfigFile: string;
+ defaultConfigContent: string;
+}) {
+ const logger = opts.logger;
+ const possibleConfigFiles = opts.possibleConfigFiles.map((p) =>
+ fileURLToPath(new URL(p, opts.root)),
+ );
+ let alreadyConfigured = false;
+ for (const possibleConfigPath of possibleConfigFiles) {
+ if (existsSync(possibleConfigPath)) {
+ alreadyConfigured = true;
+ break;
+ }
+ }
+ if (!alreadyConfigured) {
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta(`Astro will generate a minimal ${bold(opts.defaultConfigFile)} file.`)}\n`,
+ );
+ if (await askToContinue({ flags: opts.flags })) {
+ await fs.writeFile(
+ fileURLToPath(new URL(opts.defaultConfigFile, opts.root)),
+ opts.defaultConfigContent,
+ {
+ encoding: 'utf-8',
+ },
+ );
+ logger.debug('add', `Generated default ${opts.defaultConfigFile} file`);
+ }
+ } else {
+ logger.debug('add', `Using existing ${opts.integrationName} configuration`);
+ }
+}
diff --git a/packages/astro/src/cli/build/index.ts b/packages/astro/src/cli/build/index.ts
new file mode 100644
index 000000000..30f19bdcc
--- /dev/null
+++ b/packages/astro/src/cli/build/index.ts
@@ -0,0 +1,37 @@
+import _build from '../../core/build/index.js';
+import { printHelp } from '../../core/messages.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface BuildOptions {
+ flags: Flags;
+}
+
+export async function build({ flags }: BuildOptions) {
+ if (flags?.help || flags?.h) {
+ printHelp({
+ commandName: 'astro build',
+ usage: '[...flags]',
+ tables: {
+ Flags: [
+ ['--outDir <directory>', `Specify the output directory for the build.`],
+ ['--mode', `Specify the mode of the project. Defaults to "production".`],
+ [
+ '--devOutput',
+ 'Output a development-based build similar to code transformed in `astro dev`.',
+ ],
+ [
+ '--force',
+ 'Clear the content layer and content collection cache, forcing a full rebuild.',
+ ],
+ ['--help (-h)', 'See all available flags.'],
+ ],
+ },
+ description: `Builds your site for deployment.`,
+ });
+ return;
+ }
+
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+
+ await _build(inlineConfig, { devOutput: !!flags.devOutput });
+}
diff --git a/packages/astro/src/cli/check/index.ts b/packages/astro/src/cli/check/index.ts
new file mode 100644
index 000000000..b7e03aa30
--- /dev/null
+++ b/packages/astro/src/cli/check/index.ts
@@ -0,0 +1,43 @@
+import path from 'node:path';
+import { ensureProcessNodeEnv } from '../../core/util.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { getPackage } from '../install-package.js';
+
+export async function check(flags: Flags) {
+ ensureProcessNodeEnv('production');
+ const logger = createLoggerFromFlags(flags);
+ const getPackageOpts = {
+ skipAsk: !!flags.yes || !!flags.y,
+ cwd: flags.root,
+ };
+ const checkPackage = await getPackage<typeof import('@astrojs/check')>(
+ '@astrojs/check',
+ logger,
+ getPackageOpts,
+ ['typescript'],
+ );
+ const typescript = await getPackage('typescript', logger, getPackageOpts);
+
+ if (!checkPackage || !typescript) {
+ logger.error(
+ 'check',
+ 'The `@astrojs/check` and `typescript` packages are required for this command to work. Please manually install them into your project and try again.',
+ );
+ return;
+ }
+
+ if (!flags.noSync && !flags.help) {
+ // Run sync before check to make sure types are generated.
+ // NOTE: In the future, `@astrojs/check` can expose a `before lint` hook so that this works during `astro check --watch` too.
+ // For now, we run this once as usually `astro check --watch` is ran alongside `astro dev` which also calls `astro sync`.
+ const { default: sync } = await import('../../core/sync/index.js');
+ await sync(flagsToAstroInlineConfig(flags));
+ }
+
+ const { check: checker, parseArgsAsCheckConfig } = checkPackage;
+
+ const config = parseArgsAsCheckConfig(process.argv);
+
+ logger.info('check', `Getting diagnostics for Astro files in ${path.resolve(config.root)}...`);
+ return await checker(config);
+}
diff --git a/packages/astro/src/cli/create-key/index.ts b/packages/astro/src/cli/create-key/index.ts
new file mode 100644
index 000000000..bc03c5357
--- /dev/null
+++ b/packages/astro/src/cli/create-key/index.ts
@@ -0,0 +1,32 @@
+import { createNodeLogger } from '../../core/config/logging.js';
+import { createKey as createCryptoKey, encodeKey } from '../../core/encryption.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface CreateKeyOptions {
+ flags: Flags;
+}
+
+export async function createKey({ flags }: CreateKeyOptions): Promise<0 | 1> {
+ try {
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+ const logger = createNodeLogger(inlineConfig);
+
+ const keyPromise = createCryptoKey();
+ const key = await keyPromise;
+ const encoded = await encodeKey(key);
+
+ logger.info(
+ 'crypto',
+ `Generated a key to encrypt props passed to Server islands. To reuse the same key across builds, set this value as ASTRO_KEY in an environment variable on your build server.
+
+ASTRO_KEY=${encoded}`,
+ );
+ } catch (err: unknown) {
+ if (err != null) {
+ console.error(err.toString());
+ }
+ return 1;
+ }
+
+ return 0;
+}
diff --git a/packages/astro/src/cli/db/index.ts b/packages/astro/src/cli/db/index.ts
new file mode 100644
index 000000000..0dda4f30b
--- /dev/null
+++ b/packages/astro/src/cli/db/index.ts
@@ -0,0 +1,34 @@
+import type { Arguments } from 'yargs-parser';
+import { resolveConfig } from '../../core/config/config.js';
+import { apply as applyPolyfill } from '../../core/polyfill.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { getPackage } from '../install-package.js';
+
+type DBPackage = {
+ cli: (args: { flags: Arguments; config: AstroConfig }) => unknown;
+};
+
+export async function db({ flags }: { flags: Arguments }) {
+ applyPolyfill();
+ const logger = createLoggerFromFlags(flags);
+ const getPackageOpts = {
+ skipAsk: !!flags.yes || !!flags.y,
+ cwd: flags.root,
+ };
+ const dbPackage = await getPackage<DBPackage>('@astrojs/db', logger, getPackageOpts, []);
+
+ if (!dbPackage) {
+ logger.error(
+ 'check',
+ 'The `@astrojs/db` package is required for this command to work. Please manually install it in your project and try again.',
+ );
+ return;
+ }
+
+ const { cli } = dbPackage;
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+ const { astroConfig } = await resolveConfig(inlineConfig, 'build');
+
+ await cli({ flags, config: astroConfig });
+}
diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts
new file mode 100644
index 000000000..4bf888c43
--- /dev/null
+++ b/packages/astro/src/cli/dev/index.ts
@@ -0,0 +1,36 @@
+import { cyan } from 'kleur/colors';
+import devServer from '../../core/dev/index.js';
+import { printHelp } from '../../core/messages.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface DevOptions {
+ flags: Flags;
+}
+
+export async function dev({ flags }: DevOptions) {
+ if (flags.help || flags.h) {
+ printHelp({
+ commandName: 'astro dev',
+ usage: '[...flags]',
+ tables: {
+ Flags: [
+ ['--mode', `Specify the mode of the project. Defaults to "development".`],
+ ['--port', `Specify which port to run on. Defaults to 4321.`],
+ ['--host', `Listen on all addresses, including LAN and public addresses.`],
+ ['--host <custom-address>', `Expose on a network IP address at <custom-address>`],
+ ['--open', 'Automatically open the app in the browser on server start'],
+ ['--force', 'Clear the content layer cache, forcing a full rebuild.'],
+ ['--help (-h)', 'See all available flags.'],
+ ],
+ },
+ description: `Check ${cyan(
+ 'https://docs.astro.build/en/reference/cli-reference/#astro-dev',
+ )} for more information.`,
+ });
+ return;
+ }
+
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+
+ return await devServer(inlineConfig);
+}
diff --git a/packages/astro/src/cli/docs/index.ts b/packages/astro/src/cli/docs/index.ts
new file mode 100644
index 000000000..afb5a1c62
--- /dev/null
+++ b/packages/astro/src/cli/docs/index.ts
@@ -0,0 +1,22 @@
+import { printHelp } from '../../core/messages.js';
+import type { Flags } from '../flags.js';
+import { openInBrowser } from './open.js';
+
+interface DocsOptions {
+ flags: Flags;
+}
+
+export async function docs({ flags }: DocsOptions) {
+ if (flags.help || flags.h) {
+ printHelp({
+ commandName: 'astro docs',
+ tables: {
+ Flags: [['--help (-h)', 'See all available flags.']],
+ },
+ description: `Launches the Astro Docs website directly from the terminal.`,
+ });
+ return;
+ }
+
+ return await openInBrowser('https://docs.astro.build/');
+}
diff --git a/packages/astro/src/cli/docs/open.ts b/packages/astro/src/cli/docs/open.ts
new file mode 100644
index 000000000..b37028449
--- /dev/null
+++ b/packages/astro/src/cli/docs/open.ts
@@ -0,0 +1,33 @@
+import type { Result } from 'tinyexec';
+import { exec } from '../exec.js';
+
+/**
+ * Credit: Azhar22
+ * @see https://github.com/azhar22k/ourl/blob/master/index.js
+ */
+const getPlatformSpecificCommand = (): [string] | [string, string[]] => {
+ const isGitPod = Boolean(process.env.GITPOD_REPO_ROOT);
+ const platform = isGitPod ? 'gitpod' : process.platform;
+
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (platform) {
+ case 'android':
+ case 'linux':
+ return ['xdg-open'];
+ case 'darwin':
+ return ['open'];
+ case 'win32':
+ return ['cmd', ['/c', 'start']];
+ case 'gitpod':
+ return ['/ide/bin/remote-cli/gitpod-code', ['--openExternal']];
+ default:
+ throw new Error(
+ `It looks like your platform ("${platform}") isn't supported!\nTo view Astro's docs, please visit https://docs.astro.build`,
+ );
+ }
+};
+
+export async function openInBrowser(url: string): Promise<Result> {
+ const [command, args = []] = getPlatformSpecificCommand();
+ return exec(command, [...args, encodeURI(url)]);
+}
diff --git a/packages/astro/src/cli/exec.ts b/packages/astro/src/cli/exec.ts
new file mode 100644
index 000000000..b2af3c377
--- /dev/null
+++ b/packages/astro/src/cli/exec.ts
@@ -0,0 +1,26 @@
+import { NonZeroExitError, type Options, x } from 'tinyexec';
+
+/**
+ * Improve tinyexec error logging and set `throwOnError` to `true` by default
+ */
+export function exec(command: string, args?: string[], options?: Partial<Options>) {
+ return x(command, args, {
+ throwOnError: true,
+ ...options,
+ }).then(
+ (o) => o,
+ (e) => {
+ if (e instanceof NonZeroExitError) {
+ const fullCommand = args?.length
+ ? `${command} ${args.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`
+ : command;
+ const message = `The command \`${fullCommand}\` exited with code ${e.exitCode}`;
+ const newError = new Error(message, e.cause ? { cause: e.cause } : undefined);
+ (newError as any).stderr = e.output?.stderr;
+ (newError as any).stdout = e.output?.stdout;
+ throw newError;
+ }
+ throw e;
+ },
+ );
+}
diff --git a/packages/astro/src/cli/flags.ts b/packages/astro/src/cli/flags.ts
new file mode 100644
index 000000000..7466fdda7
--- /dev/null
+++ b/packages/astro/src/cli/flags.ts
@@ -0,0 +1,49 @@
+import type { Arguments } from 'yargs-parser';
+import { type LogOptions, Logger } from '../core/logger/core.js';
+import { nodeLogDestination } from '../core/logger/node.js';
+import type { AstroInlineConfig } from '../types/public/config.js';
+
+// Alias for now, but allows easier migration to node's `parseArgs` in the future.
+export type Flags = Arguments;
+
+export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig {
+ return {
+ // Inline-only configs
+ configFile: typeof flags.config === 'string' ? flags.config : undefined,
+ mode: typeof flags.mode === 'string' ? flags.mode : undefined,
+ logLevel: flags.verbose ? 'debug' : flags.silent ? 'silent' : undefined,
+ force: flags.force ? true : undefined,
+
+ // Astro user configs
+ root: typeof flags.root === 'string' ? flags.root : undefined,
+ site: typeof flags.site === 'string' ? flags.site : undefined,
+ base: typeof flags.base === 'string' ? flags.base : undefined,
+ outDir: typeof flags.outDir === 'string' ? flags.outDir : undefined,
+ server: {
+ port: typeof flags.port === 'number' ? flags.port : undefined,
+ host:
+ typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined,
+ open:
+ typeof flags.open === 'string' || typeof flags.open === 'boolean' ? flags.open : undefined,
+ },
+ };
+}
+
+/**
+ * The `logging` is usually created from an `AstroInlineConfig`, but some flows like `add`
+ * doesn't read the AstroConfig directly, so we create a `logging` object from the CLI flags instead.
+ */
+export function createLoggerFromFlags(flags: Flags): Logger {
+ const logging: LogOptions = {
+ dest: nodeLogDestination,
+ level: 'info',
+ };
+
+ if (flags.verbose) {
+ logging.level = 'debug';
+ } else if (flags.silent) {
+ logging.level = 'silent';
+ }
+
+ return new Logger(logging);
+}
diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts
new file mode 100644
index 000000000..8511a8304
--- /dev/null
+++ b/packages/astro/src/cli/index.ts
@@ -0,0 +1,220 @@
+import * as colors from 'kleur/colors';
+import yargs from 'yargs-parser';
+import { ASTRO_VERSION } from '../core/constants.js';
+
+type CLICommand =
+ | 'help'
+ | 'version'
+ | 'add'
+ | 'create-key'
+ | 'docs'
+ | 'dev'
+ | 'build'
+ | 'preview'
+ | 'db'
+ | 'sync'
+ | 'check'
+ | 'info'
+ | 'preferences'
+ | 'telemetry';
+
+/** Display --help flag */
+async function printAstroHelp() {
+ const { printHelp } = await import('../core/messages.js');
+ printHelp({
+ commandName: 'astro',
+ usage: '[command] [...flags]',
+ headline: 'Build faster websites.',
+ tables: {
+ Commands: [
+ ['add', 'Add an integration.'],
+ ['build', 'Build your project and write it to disk.'],
+ ['check', 'Check your project for errors.'],
+ ['create-key', 'Create a cryptography key'],
+ ['db', 'Manage your Astro database.'],
+ ['dev', 'Start the development server.'],
+ ['docs', 'Open documentation in your web browser.'],
+ ['info', 'List info about your current Astro setup.'],
+ ['preview', 'Preview your build locally.'],
+ ['sync', 'Generate content collection types.'],
+ ['preferences', 'Configure user preferences.'],
+ ['telemetry', 'Configure telemetry settings.'],
+ ],
+ 'Studio Commands': [
+ ['login', 'Authenticate your machine with Astro Studio.'],
+ ['logout', 'End your authenticated session with Astro Studio.'],
+ ['link', 'Link this project directory to an Astro Studio project.'],
+ ],
+ 'Global Flags': [
+ ['--config <path>', 'Specify your config file.'],
+ ['--root <path>', 'Specify your project root folder.'],
+ ['--site <url>', 'Specify your project site.'],
+ ['--base <pathname>', 'Specify your project base.'],
+ ['--verbose', 'Enable verbose logging.'],
+ ['--silent', 'Disable all logging.'],
+ ['--version', 'Show the version number and exit.'],
+ ['--help', 'Show this help message.'],
+ ],
+ },
+ });
+}
+
+/** Display --version flag */
+function printVersion() {
+ console.log();
+ console.log(` ${colors.bgGreen(colors.black(` astro `))} ${colors.green(`v${ASTRO_VERSION}`)}`);
+}
+
+/** Determine which command the user requested */
+function resolveCommand(flags: yargs.Arguments): CLICommand {
+ const cmd = flags._[2] as string;
+ if (flags.version) return 'version';
+
+ const supportedCommands = new Set([
+ 'add',
+ 'sync',
+ 'telemetry',
+ 'preferences',
+ 'dev',
+ 'build',
+ 'preview',
+ 'check',
+ 'create-key',
+ 'docs',
+ 'db',
+ 'info',
+ 'login',
+ 'logout',
+ 'link',
+ 'init',
+ ]);
+ if (supportedCommands.has(cmd)) {
+ return cmd as CLICommand;
+ }
+ return 'help';
+}
+
+/**
+ * Run the given command with the given flags.
+ * NOTE: This function provides no error handling, so be sure
+ * to present user-friendly error output where the fn is called.
+ **/
+async function runCommand(cmd: string, flags: yargs.Arguments) {
+ // These commands can run directly without parsing the user config.
+ switch (cmd) {
+ case 'help':
+ await printAstroHelp();
+ return;
+ case 'version':
+ printVersion();
+ return;
+ case 'info': {
+ const { printInfo } = await import('./info/index.js');
+ await printInfo({ flags });
+ return;
+ }
+ case 'create-key': {
+ const { createKey } = await import('./create-key/index.js');
+ const exitCode = await createKey({ flags });
+ return process.exit(exitCode);
+ }
+ case 'docs': {
+ const { docs } = await import('./docs/index.js');
+ await docs({ flags });
+ return;
+ }
+ case 'telemetry': {
+ // Do not track session start, since the user may be trying to enable,
+ // disable, or modify telemetry settings.
+ const { update } = await import('./telemetry/index.js');
+ const subcommand = flags._[3]?.toString();
+ await update(subcommand, { flags });
+ return;
+ }
+ case 'sync': {
+ const { sync } = await import('./sync/index.js');
+ await sync({ flags });
+ return;
+ }
+ case 'preferences': {
+ const { preferences } = await import('./preferences/index.js');
+ const [subcommand, key, value] = flags._.slice(3).map((v) => v.toString());
+ const exitCode = await preferences(subcommand, key, value, { flags });
+ return process.exit(exitCode);
+ }
+ }
+
+ // In verbose/debug mode, we log the debug logs asap before any potential errors could appear
+ if (flags.verbose) {
+ const { enableVerboseLogging } = await import('../core/logger/node.js');
+ enableVerboseLogging();
+ }
+
+ const { notify } = await import('./telemetry/index.js');
+ await notify();
+
+ // These commands uses the logging and user config. All commands are assumed to have been handled
+ // by the end of this switch statement.
+ switch (cmd) {
+ case 'add': {
+ const { add } = await import('./add/index.js');
+ const packages = flags._.slice(3) as string[];
+ await add(packages, { flags });
+ return;
+ }
+ case 'db':
+ case 'login':
+ case 'logout':
+ case 'link':
+ case 'init': {
+ const { db } = await import('./db/index.js');
+ await db({ flags });
+ return;
+ }
+ case 'dev': {
+ const { dev } = await import('./dev/index.js');
+ const server = await dev({ flags });
+ if (server) {
+ return await new Promise(() => {}); // lives forever
+ }
+ return;
+ }
+ case 'build': {
+ const { build } = await import('./build/index.js');
+ await build({ flags });
+ return;
+ }
+ case 'preview': {
+ const { preview } = await import('./preview/index.js');
+ const server = await preview({ flags });
+ if (server) {
+ return await server.closed(); // keep alive until the server is closed
+ }
+ return;
+ }
+ case 'check': {
+ const { check } = await import('./check/index.js');
+ const checkServer = await check(flags);
+ if (flags.watch) {
+ return await new Promise(() => {}); // lives forever
+ } else {
+ return process.exit(checkServer ? 1 : 0);
+ }
+ }
+ }
+
+ // No command handler matched! This is unexpected.
+ throw new Error(`Error running ${cmd} -- no command found.`);
+}
+
+/** The primary CLI action */
+export async function cli(argv: string[]) {
+ const flags = yargs(argv, { boolean: ['global'], alias: { g: 'global' } });
+ const cmd = resolveCommand(flags);
+ try {
+ await runCommand(cmd, flags);
+ } catch (err) {
+ const { throwAndExit } = await import('./throw-and-exit.js');
+ await throwAndExit(cmd, err);
+ }
+}
diff --git a/packages/astro/src/cli/info/index.ts b/packages/astro/src/cli/info/index.ts
new file mode 100644
index 000000000..aca66ad71
--- /dev/null
+++ b/packages/astro/src/cli/info/index.ts
@@ -0,0 +1,193 @@
+import { spawnSync } from 'node:child_process';
+import { arch, platform } from 'node:os';
+import * as colors from 'kleur/colors';
+import prompts from 'prompts';
+import { resolveConfig } from '../../core/config/index.js';
+import { ASTRO_VERSION } from '../../core/constants.js';
+import { apply as applyPolyfill } from '../../core/polyfill.js';
+import type { AstroConfig, AstroUserConfig } from '../../types/public/config.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface InfoOptions {
+ flags: Flags;
+}
+
+export async function getInfoOutput({
+ userConfig,
+ print,
+}: {
+ userConfig: AstroUserConfig | AstroConfig;
+ print: boolean;
+}): Promise<string> {
+ const rows: Array<[string, string | string[]]> = [
+ ['Astro', `v${ASTRO_VERSION}`],
+ ['Node', process.version],
+ ['System', getSystem()],
+ ['Package Manager', getPackageManager()],
+ ];
+
+ try {
+ rows.push(['Output', userConfig.output ?? 'static']);
+ rows.push(['Adapter', userConfig.adapter?.name ?? 'none']);
+ const integrations = (userConfig?.integrations ?? [])
+ .filter(Boolean)
+ .flat()
+ .map((i: any) => i?.name)
+ .filter(Boolean);
+ rows.push(['Integrations', integrations.length > 0 ? integrations : 'none']);
+ } catch {}
+
+ let output = '';
+ for (const [label, value] of rows) {
+ output += printRow(label, value, print);
+ }
+
+ return output.trim();
+}
+
+export async function printInfo({ flags }: InfoOptions) {
+ applyPolyfill();
+ const { userConfig } = await resolveConfig(flagsToAstroInlineConfig(flags), 'info');
+ const output = await getInfoOutput({ userConfig, print: true });
+ await copyToClipboard(output, flags.copy);
+}
+
+export async function copyToClipboard(text: string, force?: boolean) {
+ text = text.trim();
+ const system = platform();
+ let command = '';
+ let args: Array<string> = [];
+
+ if (system === 'darwin') {
+ command = 'pbcopy';
+ } else if (system === 'win32') {
+ command = 'clip';
+ } else {
+ // Unix: check if a supported command is installed
+
+ const unixCommands: Array<[string, Array<string>]> = [
+ ['xclip', ['-selection', 'clipboard', '-l', '1']],
+ ['wl-copy', []],
+ ];
+ for (const [unixCommand, unixArgs] of unixCommands) {
+ try {
+ const output = spawnSync('which', [unixCommand], { encoding: 'utf8' });
+ if (output.stdout.trim()) {
+ command = unixCommand;
+ args = unixArgs;
+ break;
+ }
+ } catch {
+ continue;
+ }
+ }
+ }
+
+ if (!command) {
+ console.error(colors.red('\nClipboard command not found!'));
+ console.info('Please manually copy the text above.');
+ return;
+ }
+
+ if (!force) {
+ const { shouldCopy } = await prompts({
+ type: 'confirm',
+ name: 'shouldCopy',
+ message: 'Copy to clipboard?',
+ initial: true,
+ });
+
+ if (!shouldCopy) return;
+ }
+
+ try {
+ const result = spawnSync(command, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
+ if (result.error) {
+ throw result.error;
+ }
+ console.info(colors.green('Copied to clipboard!'));
+ } catch {
+ console.error(
+ colors.red(`\nSorry, something went wrong!`) + ` Please copy the text above manually.`,
+ );
+ }
+}
+
+export function readFromClipboard() {
+ const system = platform();
+ let command = '';
+ let args: Array<string> = [];
+
+ if (system === 'darwin') {
+ command = 'pbpaste';
+ } else if (system === 'win32') {
+ command = 'powershell';
+ args = ['-command', 'Get-Clipboard'];
+ } else {
+ const unixCommands: Array<[string, Array<string>]> = [
+ ['xclip', ['-sel', 'clipboard', '-o']],
+ ['wl-paste', []],
+ ];
+ for (const [unixCommand, unixArgs] of unixCommands) {
+ try {
+ const output = spawnSync('which', [unixCommand], { encoding: 'utf8' });
+ if (output.stdout.trim()) {
+ command = unixCommand;
+ args = unixArgs;
+ break;
+ }
+ } catch {
+ continue;
+ }
+ }
+ }
+
+ if (!command) {
+ throw new Error('Clipboard read command not found!');
+ }
+
+ const result = spawnSync(command, args, { encoding: 'utf8' });
+ if (result.error) {
+ throw result.error;
+ }
+ return result.stdout.trim();
+}
+
+const PLATFORM_TO_OS: Partial<Record<ReturnType<typeof platform>, string>> = {
+ darwin: 'macOS',
+ win32: 'Windows',
+ linux: 'Linux',
+};
+
+function getSystem() {
+ const system = PLATFORM_TO_OS[platform()] ?? platform();
+ return `${system} (${arch()})`;
+}
+
+function getPackageManager() {
+ if (!process.env.npm_config_user_agent) {
+ return 'unknown';
+ }
+ const specifier = process.env.npm_config_user_agent.split(' ')[0];
+ const name = specifier.substring(0, specifier.lastIndexOf('/'));
+ return name === 'npminstall' ? 'cnpm' : name;
+}
+
+const MAX_PADDING = 25;
+function printRow(label: string, value: string | string[], print: boolean) {
+ const padding = MAX_PADDING - label.length;
+ const [first, ...rest] = Array.isArray(value) ? value : [value];
+ let plaintext = `${label}${' '.repeat(padding)}${first}`;
+ let richtext = `${colors.bold(label)}${' '.repeat(padding)}${colors.green(first)}`;
+ if (rest.length > 0) {
+ for (const entry of rest) {
+ plaintext += `\n${' '.repeat(MAX_PADDING)}${entry}`;
+ richtext += `\n${' '.repeat(MAX_PADDING)}${colors.green(entry)}`;
+ }
+ }
+ plaintext += '\n';
+ if (print) {
+ console.info(richtext);
+ }
+ return plaintext;
+}
diff --git a/packages/astro/src/cli/install-package.ts b/packages/astro/src/cli/install-package.ts
new file mode 100644
index 000000000..2b3dc80bd
--- /dev/null
+++ b/packages/astro/src/cli/install-package.ts
@@ -0,0 +1,220 @@
+import { createRequire } from 'node:module';
+import boxen from 'boxen';
+import ci from 'ci-info';
+import { bold, cyan, dim, magenta } from 'kleur/colors';
+import preferredPM from 'preferred-pm';
+import prompts from 'prompts';
+import whichPm from 'which-pm';
+import yoctoSpinner from 'yocto-spinner';
+import type { Logger } from '../core/logger/core.js';
+import { exec } from './exec.js';
+
+const require = createRequire(import.meta.url);
+
+type GetPackageOptions = {
+ skipAsk?: boolean;
+ optional?: boolean;
+ cwd?: string;
+};
+
+export async function getPackage<T>(
+ packageName: string,
+ logger: Logger,
+ options: GetPackageOptions,
+ otherDeps: string[] = [],
+): Promise<T | undefined> {
+ try {
+ // Try to resolve with `createRequire` first to prevent ESM caching of the package
+ // if it errors and fails here
+ require.resolve(packageName, { paths: [options.cwd ?? process.cwd()] });
+ const packageImport = await import(packageName);
+ return packageImport as T;
+ } catch {
+ if (options.optional) return undefined;
+ let message = `To continue, Astro requires the following dependency to be installed: ${bold(
+ packageName,
+ )}.`;
+
+ if (ci.isCI) {
+ message += ` Packages cannot be installed automatically in CI environments.`;
+ }
+
+ logger.info('SKIP_FORMAT', message);
+
+ if (ci.isCI) {
+ return undefined;
+ }
+
+ const result = await installPackage([packageName, ...otherDeps], options, logger);
+
+ if (result) {
+ const packageImport = await import(packageName);
+ return packageImport;
+ } else {
+ return undefined;
+ }
+ }
+}
+
+function getInstallCommand(packages: string[], packageManager: string) {
+ switch (packageManager) {
+ case 'npm':
+ return { pm: 'npm', command: 'install', flags: [], dependencies: packages };
+ case 'yarn':
+ return { pm: 'yarn', command: 'add', flags: [], dependencies: packages };
+ case 'pnpm':
+ return { pm: 'pnpm', command: 'add', flags: [], dependencies: packages };
+ case 'bun':
+ return { pm: 'bun', command: 'add', flags: [], dependencies: packages };
+ default:
+ return null;
+ }
+}
+
+/**
+ * Get the command to execute and download a package (e.g. `npx`, `yarn dlx`, `pnpm dlx`, etc.)
+ * @param packageManager - Optional package manager to use. If not provided, Astro will attempt to detect the preferred package manager.
+ * @returns The command to execute and download a package
+ */
+export async function getExecCommand(packageManager?: string): Promise<string> {
+ if (!packageManager) {
+ packageManager = (await preferredPM(process.cwd()))?.name ?? 'npm';
+ }
+
+ switch (packageManager) {
+ case 'npm':
+ return 'npx';
+ case 'yarn':
+ return 'yarn dlx';
+ case 'pnpm':
+ return 'pnpm dlx';
+ case 'bun':
+ return 'bunx';
+ default:
+ return 'npx';
+ }
+}
+
+async function installPackage(
+ packageNames: string[],
+ options: GetPackageOptions,
+ logger: Logger,
+): Promise<boolean> {
+ const cwd = options.cwd ?? process.cwd();
+ const packageManager = (await whichPm(cwd))?.name ?? 'npm';
+ const installCommand = getInstallCommand(packageNames, packageManager);
+
+ if (!installCommand) {
+ return false;
+ }
+
+ const coloredOutput = `${bold(installCommand.pm)} ${installCommand.command}${[
+ '',
+ ...installCommand.flags,
+ ].join(' ')} ${cyan(installCommand.dependencies.join(' '))}`;
+ const message = `\n${boxen(coloredOutput, {
+ margin: 0.5,
+ padding: 0.5,
+ borderStyle: 'round',
+ })}\n`;
+ logger.info(
+ 'SKIP_FORMAT',
+ `\n ${magenta('Astro will run the following command:')}\n ${dim(
+ 'If you skip this step, you can always run it yourself later',
+ )}\n${message}`,
+ );
+
+ let response;
+ if (options.skipAsk) {
+ response = true;
+ } else {
+ response = (
+ await prompts({
+ type: 'confirm',
+ name: 'askToContinue',
+ message: 'Continue?',
+ initial: true,
+ })
+ ).askToContinue;
+ }
+
+ if (Boolean(response)) {
+ const spinner = yoctoSpinner({ text: 'Installing dependencies...' }).start();
+ try {
+ await exec(
+ installCommand.pm,
+ [installCommand.command, ...installCommand.flags, ...installCommand.dependencies],
+ {
+ nodeOptions: {
+ cwd,
+ // reset NODE_ENV to ensure install command run in dev mode
+ env: { NODE_ENV: undefined },
+ },
+ },
+ );
+ spinner.success();
+
+ return true;
+ } catch (err) {
+ logger.debug('add', 'Error installing dependencies', err);
+ spinner.error();
+
+ return false;
+ }
+ } else {
+ return false;
+ }
+}
+
+export async function fetchPackageJson(
+ scope: string | undefined,
+ name: string,
+ tag: string,
+): Promise<Record<string, any> | Error> {
+ const packageName = `${scope ? `${scope}/` : ''}${name}`;
+ const registry = await getRegistry();
+ const res = await fetch(`${registry}/${packageName}/${tag}`);
+ if (res.status >= 200 && res.status < 300) {
+ return await res.json();
+ } else if (res.status === 404) {
+ // 404 means the package doesn't exist, so we don't need an error message here
+ return new Error();
+ } else {
+ return new Error(`Failed to fetch ${registry}/${packageName}/${tag} - GET ${res.status}`);
+ }
+}
+
+export async function fetchPackageVersions(packageName: string): Promise<string[] | Error> {
+ const registry = await getRegistry();
+ const res = await fetch(`${registry}/${packageName}`, {
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
+ });
+ if (res.status >= 200 && res.status < 300) {
+ return await res.json().then((data) => Object.keys(data.versions));
+ } else if (res.status === 404) {
+ // 404 means the package doesn't exist, so we don't need an error message here
+ return new Error();
+ } else {
+ return new Error(`Failed to fetch ${registry}/${packageName} - GET ${res.status}`);
+ }
+}
+
+// Users might lack access to the global npm registry, this function
+// checks the user's project type and will return the proper npm registry
+//
+// A copy of this function also exists in the create-astro package
+let _registry: string;
+async function getRegistry(): Promise<string> {
+ if (_registry) return _registry;
+ const fallback = 'https://registry.npmjs.org';
+ const packageManager = (await preferredPM(process.cwd()))?.name || 'npm';
+ try {
+ const { stdout } = await exec(packageManager, ['config', 'get', 'registry']);
+ _registry = stdout.trim()?.replace(/\/$/, '') || fallback;
+ // Detect cases where the shell command returned a non-URL (e.g. a warning)
+ if (!new URL(_registry).host) _registry = fallback;
+ } catch {
+ _registry = fallback;
+ }
+ return _registry;
+}
diff --git a/packages/astro/src/cli/preferences/index.ts b/packages/astro/src/cli/preferences/index.ts
new file mode 100644
index 000000000..c31841d24
--- /dev/null
+++ b/packages/astro/src/cli/preferences/index.ts
@@ -0,0 +1,378 @@
+import { fileURLToPath } from 'node:url';
+import { formatWithOptions } from 'node:util';
+import dlv from 'dlv';
+import { flattie } from 'flattie';
+import { bgGreen, black, bold, dim, yellow } from 'kleur/colors';
+import { resolveConfig } from '../../core/config/config.js';
+import { createSettings } from '../../core/config/settings.js';
+import { collectErrorMetadata } from '../../core/errors/dev/utils.js';
+import * as msg from '../../core/messages.js';
+import { apply as applyPolyfill } from '../../core/polyfill.js';
+import { DEFAULT_PREFERENCES } from '../../preferences/defaults.js';
+import { type PreferenceKey, coerce, isValidKey } from '../../preferences/index.js';
+import type { AstroSettings } from '../../types/astro.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface PreferencesOptions {
+ flags: Flags;
+}
+
+const PREFERENCES_SUBCOMMANDS = [
+ 'get',
+ 'set',
+ 'enable',
+ 'disable',
+ 'delete',
+ 'reset',
+ 'list',
+] as const;
+export type Subcommand = (typeof PREFERENCES_SUBCOMMANDS)[number];
+
+type AnnotatedValue = { annotation: string; value: string | number | boolean };
+type AnnotatedValues = Record<string, AnnotatedValue>;
+
+function isValidSubcommand(subcommand: string): subcommand is Subcommand {
+ return PREFERENCES_SUBCOMMANDS.includes(subcommand as Subcommand);
+}
+
+export async function preferences(
+ subcommand: string,
+ key: string,
+ value: string | undefined,
+ { flags }: PreferencesOptions,
+): Promise<number> {
+ applyPolyfill();
+ if (!isValidSubcommand(subcommand) || flags?.help || flags?.h) {
+ msg.printHelp({
+ commandName: 'astro preferences',
+ usage: '[command]',
+ tables: {
+ Commands: [
+ ['list', 'Pretty print all current preferences'],
+ ['list --json', 'Log all current preferences as a JSON object'],
+ ['get [key]', 'Log current preference value'],
+ ['set [key] [value]', 'Update preference value'],
+ ['reset [key]', 'Reset preference value to default'],
+ ['enable [key]', 'Set a boolean preference to true'],
+ ['disable [key]', 'Set a boolean preference to false'],
+ ],
+ Flags: [
+ [
+ '--global',
+ 'Scope command to global preferences (all Astro projects) rather than the current project',
+ ],
+ ],
+ },
+ });
+ return 0;
+ }
+
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+ const logger = createLoggerFromFlags(flags);
+ const { astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev');
+ const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
+ const opts: SubcommandOptions = {
+ location: flags.global ? 'global' : undefined,
+ json: !!flags.json,
+ };
+
+ if (subcommand === 'list') {
+ return listPreferences(settings, opts);
+ }
+
+ if (subcommand === 'enable' || subcommand === 'disable') {
+ key = `${key}.enabled` as PreferenceKey;
+ }
+
+ if (!isValidKey(key)) {
+ logger.error('preferences', `Unknown preference "${key}"\n`);
+ return 1;
+ }
+
+ if (subcommand === 'set' && value === undefined) {
+ const type = typeof dlv(DEFAULT_PREFERENCES, key);
+ console.error(
+ msg.formatErrorMessage(
+ collectErrorMetadata(new Error(`Please provide a ${type} value for "${key}"`)),
+ true,
+ ),
+ );
+ return 1;
+ }
+
+ switch (subcommand) {
+ case 'get':
+ return getPreference(settings, key, opts);
+ case 'set':
+ return setPreference(settings, key, value, opts);
+ case 'reset':
+ case 'delete':
+ return resetPreference(settings, key, opts);
+ case 'enable':
+ return enablePreference(settings, key, opts);
+ case 'disable':
+ return disablePreference(settings, key, opts);
+ }
+}
+
+interface SubcommandOptions {
+ location?: 'global' | 'project';
+ json?: boolean;
+}
+
+// Default `location` to "project" to avoid reading default preferences
+async function getPreference(
+ settings: AstroSettings,
+ key: PreferenceKey,
+ { location = 'project' }: SubcommandOptions,
+) {
+ try {
+ let value = await settings.preferences.get(key, { location });
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ if (Object.keys(value).length === 0) {
+ value = dlv(DEFAULT_PREFERENCES, key);
+ console.log(msg.preferenceDefaultIntro(key));
+ }
+ prettyPrint({ [key]: value });
+ return 0;
+ }
+ if (value === undefined) {
+ const defaultValue = await settings.preferences.get(key);
+ console.log(msg.preferenceDefault(key, defaultValue));
+ return 0;
+ }
+ console.log(msg.preferenceGet(key, value));
+ return 0;
+ } catch {}
+ return 1;
+}
+
+async function setPreference(
+ settings: AstroSettings,
+ key: PreferenceKey,
+ value: unknown,
+ { location }: SubcommandOptions,
+) {
+ try {
+ const defaultType = typeof dlv(DEFAULT_PREFERENCES, key);
+ if (typeof coerce(key, value) !== defaultType) {
+ throw new Error(`${key} expects a "${defaultType}" value!`);
+ }
+
+ await settings.preferences.set(key, coerce(key, value), { location });
+ console.log(msg.preferenceSet(key, value));
+ return 0;
+ } catch (e) {
+ if (e instanceof Error) {
+ console.error(msg.formatErrorMessage(collectErrorMetadata(e), true));
+ return 1;
+ }
+ throw e;
+ }
+}
+
+async function enablePreference(
+ settings: AstroSettings,
+ key: PreferenceKey,
+ { location }: SubcommandOptions,
+) {
+ try {
+ await settings.preferences.set(key, true, { location });
+ console.log(msg.preferenceEnabled(key.replace('.enabled', '')));
+ return 0;
+ } catch {}
+ return 1;
+}
+
+async function disablePreference(
+ settings: AstroSettings,
+ key: PreferenceKey,
+ { location }: SubcommandOptions,
+) {
+ try {
+ await settings.preferences.set(key, false, { location });
+ console.log(msg.preferenceDisabled(key.replace('.enabled', '')));
+ return 0;
+ } catch {}
+ return 1;
+}
+
+async function resetPreference(
+ settings: AstroSettings,
+ key: PreferenceKey,
+ { location }: SubcommandOptions,
+) {
+ try {
+ await settings.preferences.set(key, undefined as any, { location });
+ console.log(msg.preferenceReset(key));
+ return 0;
+ } catch {}
+ return 1;
+}
+
+function annotate(flat: Record<string, any>, annotation: string) {
+ return Object.fromEntries(
+ Object.entries(flat).map(([key, value]) => [key, { annotation, value }]),
+ );
+}
+function userValues(
+ flatDefault: Record<string, string | number | boolean>,
+ flatProject: Record<string, string | number | boolean>,
+ flatGlobal: Record<string, string | number | boolean>,
+) {
+ const result: AnnotatedValues = {};
+ for (const key of Object.keys(flatDefault)) {
+ if (key in flatProject) {
+ result[key] = {
+ value: flatProject[key],
+ annotation: '',
+ };
+ if (key in flatGlobal) {
+ result[key].annotation += ` (also modified globally)`;
+ }
+ } else if (key in flatGlobal) {
+ result[key] = { value: flatGlobal[key], annotation: '(global)' };
+ }
+ }
+ return result;
+}
+
+async function listPreferences(settings: AstroSettings, { location, json }: SubcommandOptions) {
+ if (json) {
+ const resolved = await settings.preferences.getAll();
+ console.log(JSON.stringify(resolved, null, 2));
+ return 0;
+ }
+ const { global, project, fromAstroConfig, defaults } = await settings.preferences.list({
+ location,
+ });
+ const flatProject = flattie(project);
+ const flatGlobal = flattie(global);
+ const flatDefault = flattie(defaults);
+ const flatUser = userValues(flatDefault, flatProject, flatGlobal);
+
+ const userKeys = Object.keys(flatUser);
+
+ if (userKeys.length > 0) {
+ const badge = bgGreen(black(` Your Preferences `));
+ const table = formatTable(flatUser, ['Preference', 'Value']);
+
+ console.log(['', badge, table].join('\n'));
+ } else {
+ const badge = bgGreen(black(` Your Preferences `));
+ const message = dim('No preferences set');
+ console.log(['', badge, '', message].join('\n'));
+ }
+ const flatUnset = annotate(Object.assign({}, flatDefault), '');
+ for (const key of userKeys) {
+ delete flatUnset[key];
+ }
+ const unsetKeys = Object.keys(flatUnset);
+
+ if (unsetKeys.length > 0) {
+ const badge = bgGreen(black(` Default Preferences `));
+ const table = formatTable(flatUnset, ['Preference', 'Value']);
+
+ console.log(['', badge, table].join('\n'));
+ } else {
+ const badge = bgGreen(black(` Default Preferences `));
+ const message = dim('All preferences have been set');
+ console.log(['', badge, '', message].join('\n'));
+ }
+ if (
+ fromAstroConfig.devToolbar?.enabled === false &&
+ flatUser['devToolbar.enabled']?.value !== false
+ ) {
+ console.log(
+ yellow(
+ 'The dev toolbar is currently disabled. To enable it, set devToolbar: {enabled: true} in your astroConfig file.',
+ ),
+ );
+ }
+
+ return 0;
+}
+
+function prettyPrint(value: Record<string, string | number | boolean>) {
+ const flattened = flattie(value);
+ const table = formatTable(flattened, ['Preference', 'Value']);
+ console.log(table);
+}
+
+const chars = {
+ h: '─',
+ hThick: '━',
+ hThickCross: '┿',
+ v: '│',
+ vRight: '├',
+ vRightThick: '┝',
+ vLeft: '┤',
+ vLeftThick: '┥',
+ hTop: '┴',
+ hBottom: '┬',
+ topLeft: '╭',
+ topRight: '╮',
+ bottomLeft: '╰',
+ bottomRight: '╯',
+};
+
+// this is only used to determine the column width
+function annotatedFormat(mv: AnnotatedValue) {
+ return mv.annotation ? `${mv.value} ${mv.annotation}` : mv.value.toString();
+}
+// this is the real formatting for annotated values
+function formatAnnotated(
+ mv: AnnotatedValue,
+ style: (value: string | number | boolean) => string = (v) => v.toString(),
+) {
+ return mv.annotation ? `${style(mv.value)} ${dim(mv.annotation)}` : style(mv.value);
+}
+function formatTable(object: Record<string, AnnotatedValue>, columnLabels: [string, string]) {
+ const [colA, colB] = columnLabels;
+ const colALength = [colA, ...Object.keys(object)].reduce(longest, 0) + 3;
+ const colBLength = [colB, ...Object.values(object).map(annotatedFormat)].reduce(longest, 0) + 3;
+ function formatRow(
+ _i: number,
+ a: string,
+ b: AnnotatedValue,
+ style: (value: string | number | boolean) => string = (v) => v.toString(),
+ ): string {
+ return `${dim(chars.v)} ${style(a)} ${space(colALength - a.length - 2)} ${dim(
+ chars.v,
+ )} ${formatAnnotated(b, style)} ${space(colBLength - annotatedFormat(b).length - 3)} ${dim(
+ chars.v,
+ )}`;
+ }
+ const top = dim(
+ `${chars.topLeft}${chars.h.repeat(colALength + 1)}${chars.hBottom}${chars.h.repeat(
+ colBLength,
+ )}${chars.topRight}`,
+ );
+ const bottom = dim(
+ `${chars.bottomLeft}${chars.h.repeat(colALength + 1)}${chars.hTop}${chars.h.repeat(
+ colBLength,
+ )}${chars.bottomRight}`,
+ );
+ const divider = dim(
+ `${chars.vRightThick}${chars.hThick.repeat(colALength + 1)}${
+ chars.hThickCross
+ }${chars.hThick.repeat(colBLength)}${chars.vLeftThick}`,
+ );
+ const rows: string[] = [top, formatRow(-1, colA, { value: colB, annotation: '' }, bold), divider];
+ let i = 0;
+ for (const [key, value] of Object.entries(object)) {
+ rows.push(formatRow(i, key, value, (v) => formatWithOptions({ colors: true }, v)));
+ i++;
+ }
+ rows.push(bottom);
+ return rows.join('\n');
+}
+
+function space(len: number) {
+ return ' '.repeat(len);
+}
+
+const longest = (a: number, b: string | number | boolean) => {
+ const { length: len } = b.toString();
+ return a > len ? a : len;
+};
diff --git a/packages/astro/src/cli/preview/index.ts b/packages/astro/src/cli/preview/index.ts
new file mode 100644
index 000000000..468332ce3
--- /dev/null
+++ b/packages/astro/src/cli/preview/index.ts
@@ -0,0 +1,34 @@
+import { cyan } from 'kleur/colors';
+import { printHelp } from '../../core/messages.js';
+import previewServer from '../../core/preview/index.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface PreviewOptions {
+ flags: Flags;
+}
+
+export async function preview({ flags }: PreviewOptions) {
+ if (flags?.help || flags?.h) {
+ printHelp({
+ commandName: 'astro preview',
+ usage: '[...flags]',
+ tables: {
+ Flags: [
+ ['--port', `Specify which port to run on. Defaults to 4321.`],
+ ['--host', `Listen on all addresses, including LAN and public addresses.`],
+ ['--host <custom-address>', `Expose on a network IP address at <custom-address>`],
+ ['--open', 'Automatically open the app in the browser on server start'],
+ ['--help (-h)', 'See all available flags.'],
+ ],
+ },
+ description: `Starts a local server to serve your static dist/ directory. Check ${cyan(
+ 'https://docs.astro.build/en/reference/cli-reference/#astro-preview',
+ )} for more information.`,
+ });
+ return;
+ }
+
+ const inlineConfig = flagsToAstroInlineConfig(flags);
+
+ return await previewServer(inlineConfig);
+}
diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts
new file mode 100644
index 000000000..7f488836d
--- /dev/null
+++ b/packages/astro/src/cli/sync/index.ts
@@ -0,0 +1,26 @@
+import { printHelp } from '../../core/messages.js';
+import _sync from '../../core/sync/index.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
+
+interface SyncOptions {
+ flags: Flags;
+}
+
+export async function sync({ flags }: SyncOptions) {
+ if (flags?.help || flags?.h) {
+ printHelp({
+ commandName: 'astro sync',
+ usage: '[...flags]',
+ tables: {
+ Flags: [
+ ['--force', 'Clear the content layer cache, forcing a full rebuild.'],
+ ['--help (-h)', 'See all available flags.'],
+ ],
+ },
+ description: `Generates TypeScript types for all Astro modules.`,
+ });
+ return 0;
+ }
+
+ await _sync(flagsToAstroInlineConfig(flags), { telemetry: true });
+}
diff --git a/packages/astro/src/cli/telemetry/index.ts b/packages/astro/src/cli/telemetry/index.ts
new file mode 100644
index 000000000..a854220c3
--- /dev/null
+++ b/packages/astro/src/cli/telemetry/index.ts
@@ -0,0 +1,52 @@
+import * as msg from '../../core/messages.js';
+import { telemetry } from '../../events/index.js';
+import { type Flags, createLoggerFromFlags } from '../flags.js';
+
+interface TelemetryOptions {
+ flags: Flags;
+}
+
+export async function notify() {
+ await telemetry.notify(() => {
+ console.log(msg.telemetryNotice() + '\n');
+ return true;
+ });
+}
+
+export async function update(subcommand: string, { flags }: TelemetryOptions) {
+ const isValid = ['enable', 'disable', 'reset'].includes(subcommand);
+ const logger = createLoggerFromFlags(flags);
+
+ if (flags.help || flags.h || !isValid) {
+ msg.printHelp({
+ commandName: 'astro telemetry',
+ usage: '[command]',
+ tables: {
+ Commands: [
+ ['enable', 'Enable anonymous data collection.'],
+ ['disable', 'Disable anonymous data collection.'],
+ ['reset', 'Reset anonymous data collection settings.'],
+ ],
+ },
+ });
+ return;
+ }
+
+ switch (subcommand) {
+ case 'enable': {
+ telemetry.setEnabled(true);
+ logger.info('SKIP_FORMAT', msg.telemetryEnabled());
+ return;
+ }
+ case 'disable': {
+ telemetry.setEnabled(false);
+ logger.info('SKIP_FORMAT', msg.telemetryDisabled());
+ return;
+ }
+ case 'reset': {
+ telemetry.clear();
+ logger.info('SKIP_FORMAT', msg.telemetryReset());
+ return;
+ }
+ }
+}
diff --git a/packages/astro/src/cli/throw-and-exit.ts b/packages/astro/src/cli/throw-and-exit.ts
new file mode 100644
index 000000000..b7821caa5
--- /dev/null
+++ b/packages/astro/src/cli/throw-and-exit.ts
@@ -0,0 +1,32 @@
+import { collectErrorMetadata } from '../core/errors/dev/index.js';
+import { isAstroConfigZodError } from '../core/errors/errors.js';
+import { createSafeError } from '../core/errors/index.js';
+import { debug } from '../core/logger/core.js';
+import { formatErrorMessage } from '../core/messages.js';
+import { eventError, telemetry } from '../events/index.js';
+
+/** Display error and exit */
+export async function throwAndExit(cmd: string, err: unknown) {
+ // Suppress ZodErrors from AstroConfig as the pre-logged error is sufficient
+ if (isAstroConfigZodError(err)) return;
+
+ let telemetryPromise: Promise<any>;
+ let errorMessage: string;
+ function exitWithErrorMessage() {
+ console.error(errorMessage);
+ process.exit(1);
+ }
+
+ const errorWithMetadata = collectErrorMetadata(createSafeError(err));
+ telemetryPromise = telemetry.record(eventError({ cmd, err: errorWithMetadata, isFatal: true }));
+ errorMessage = formatErrorMessage(errorWithMetadata, true);
+
+ // Timeout the error reporter (very short) because the user is waiting.
+ // NOTE(fks): It is better that we miss some events vs. holding too long.
+ // TODO(fks): Investigate using an AbortController once we drop Node v14.
+ setTimeout(exitWithErrorMessage, 400);
+ // Wait for the telemetry event to send, then exit. Ignore any error.
+ await telemetryPromise
+ .catch((err2) => debug('telemetry', `record() error: ${err2.message}`))
+ .then(exitWithErrorMessage);
+}
diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts
new file mode 100644
index 000000000..4951792d6
--- /dev/null
+++ b/packages/astro/src/config/entrypoint.ts
@@ -0,0 +1,30 @@
+// IMPORTANT: this file is the entrypoint for "astro/config". Keep it as light as possible!
+
+import type { SharpImageServiceConfig } from '../assets/services/sharp.js';
+import type { ImageServiceConfig } from '../types/public/index.js';
+
+export { defineConfig, getViteConfig } from './index.js';
+export { envField } from '../env/config.js';
+
+/**
+ * Return the configuration needed to use the Sharp-based image service
+ */
+export function sharpImageService(config: SharpImageServiceConfig = {}): ImageServiceConfig {
+ return {
+ entrypoint: 'astro/assets/services/sharp',
+ config,
+ };
+}
+
+/**
+ * Return the configuration needed to use the passthrough image service. This image services does not perform
+ * any image transformations, and is mainly useful when your platform does not support other image services, or you are
+ * not using Astro's built-in image processing.
+ * See: https://docs.astro.build/en/guides/images/#configure-no-op-passthrough-service
+ */
+export function passthroughImageService(): ImageServiceConfig {
+ return {
+ entrypoint: 'astro/assets/services/noop',
+ config: {},
+ };
+}
diff --git a/packages/astro/src/config/index.ts b/packages/astro/src/config/index.ts
new file mode 100644
index 000000000..593f60f30
--- /dev/null
+++ b/packages/astro/src/config/index.ts
@@ -0,0 +1,72 @@
+import type { UserConfig as ViteUserConfig, UserConfigFn as ViteUserConfigFn } from 'vite';
+import { createRoutesList } from '../core/routing/index.js';
+import type {
+ AstroInlineConfig,
+ AstroUserConfig,
+ Locales,
+ SessionDriverName,
+} from '../types/public/config.js';
+import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js';
+
+/**
+ * See the full Astro Configuration API Documentation
+ * https://astro.build/config
+ */
+export function defineConfig<
+ const TLocales extends Locales = never,
+ const TDriver extends SessionDriverName = never,
+>(config: AstroUserConfig<TLocales, TDriver>) {
+ return config;
+}
+
+/**
+ * Use Astro to generate a fully resolved Vite config
+ */
+export function getViteConfig(
+ userViteConfig: ViteUserConfig,
+ inlineAstroConfig: AstroInlineConfig = {},
+): ViteUserConfigFn {
+ // Return an async Vite config getter which exposes a resolved `mode` and `command`
+ return async ({ mode, command }) => {
+ // Vite `command` is `serve | build`, but Astro uses `dev | build`
+ const cmd = command === 'serve' ? 'dev' : 'build';
+
+ // Use dynamic import to avoid pulling in deps unless used
+ const [
+ fs,
+ { mergeConfig },
+ { createNodeLogger },
+ { resolveConfig, createSettings },
+ { createVite },
+ { runHookConfigSetup, runHookConfigDone },
+ { astroContentListenPlugin },
+ ] = await Promise.all([
+ import('node:fs'),
+ import('vite'),
+ import('../core/config/logging.js'),
+ import('../core/config/index.js'),
+ import('../core/create-vite.js'),
+ import('../integrations/hooks.js'),
+ import('./vite-plugin-content-listen.js'),
+ ]);
+ const logger = createNodeLogger(inlineAstroConfig);
+ const { astroConfig: config } = await resolveConfig(inlineAstroConfig, cmd);
+ let settings = await createSettings(config, userViteConfig.root);
+ settings = await runHookConfigSetup({ settings, command: cmd, logger });
+ const routesList = await createRoutesList({ settings }, logger);
+ const manifest = createDevelopmentManifest(settings);
+ const viteConfig = await createVite(
+ {
+ plugins: config.legacy.collections
+ ? [
+ // Initialize the content listener
+ astroContentListenPlugin({ settings, logger, fs }),
+ ]
+ : [],
+ },
+ { settings, command: cmd, logger, mode, sync: false, manifest, routesList },
+ );
+ await runHookConfigDone({ settings, logger });
+ return mergeConfig(viteConfig, userViteConfig);
+ };
+}
diff --git a/packages/astro/src/config/vite-plugin-content-listen.ts b/packages/astro/src/config/vite-plugin-content-listen.ts
new file mode 100644
index 000000000..6c0408001
--- /dev/null
+++ b/packages/astro/src/config/vite-plugin-content-listen.ts
@@ -0,0 +1,41 @@
+import type fsMod from 'node:fs';
+import type { Plugin, ViteDevServer } from 'vite';
+import { attachContentServerListeners } from '../content/server-listeners.js';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+
+/**
+ * Listen for Astro content directory changes and generate types.
+ *
+ * This is a separate plugin for `getViteConfig` as the `attachContentServerListeners` API
+ * needs to be called at different times in `astro dev` and `getViteConfig`. For `astro dev`,
+ * it needs to be called after the Astro server is started (packages/astro/src/core/dev/dev.ts).
+ * For `getViteConfig`, it needs to be called after the Vite server is started.
+ */
+export function astroContentListenPlugin({
+ settings,
+ logger,
+ fs,
+}: {
+ settings: AstroSettings;
+ logger: Logger;
+ fs: typeof fsMod;
+}): Plugin {
+ let server: ViteDevServer;
+
+ return {
+ name: 'astro:content-listen',
+ apply: 'serve',
+ configureServer(_server) {
+ server = _server;
+ },
+ async buildStart() {
+ await attachContentServerListeners({
+ fs: fs,
+ settings,
+ logger,
+ viteServer: server,
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts
new file mode 100644
index 000000000..3c927ec4d
--- /dev/null
+++ b/packages/astro/src/container/index.ts
@@ -0,0 +1,588 @@
+import './polyfill.js';
+import { posix } from 'node:path';
+import { getDefaultClientDirectives } from '../core/client-directive/index.js';
+import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schema.js';
+import { validateConfig } from '../core/config/validate.js';
+import { createKey } from '../core/encryption.js';
+import { Logger } from '../core/logger/core.js';
+import { nodeLogDestination } from '../core/logger/node.js';
+import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js';
+import { removeLeadingForwardSlash } from '../core/path.js';
+import { RenderContext } from '../core/render-context.js';
+import { getParts } from '../core/routing/manifest/parts.js';
+import { getPattern } from '../core/routing/manifest/pattern.js';
+import { validateSegment } from '../core/routing/manifest/segment.js';
+import type { AstroComponentFactory } from '../runtime/server/index.js';
+import type { ComponentInstance } from '../types/astro.js';
+import type { AstroMiddlewareInstance, MiddlewareHandler, Props } from '../types/public/common.js';
+import type { AstroConfig, AstroUserConfig } from '../types/public/config.js';
+import type {
+ NamedSSRLoadedRendererValue,
+ RouteData,
+ RouteType,
+ SSRLoadedRenderer,
+ SSRLoadedRendererValue,
+ SSRManifest,
+ SSRResult,
+} from '../types/public/internal.js';
+import { ContainerPipeline } from './pipeline.js';
+
+/** Public type, used for integrations to define a renderer for the container API */
+export type ContainerRenderer = {
+ /**
+ * The name of the renderer.
+ */
+ name: string;
+ /**
+ * The entrypoint that is used to render a component on the server
+ */
+ serverEntrypoint: string;
+};
+
+/**
+ * Options to be passed when rendering a route
+ */
+export type ContainerRenderOptions = {
+ /**
+ * If your component renders slots, that's where you want to fill the slots.
+ * A single slot should have the `default` field:
+ *
+ * ## Examples
+ *
+ * **Default slot**
+ *
+ * ```js
+ * container.renderToString(Component, { slots: { default: "Some value"}});
+ * ```
+ *
+ * **Named slots**
+ *
+ * ```js
+ * container.renderToString(Component, { slots: { "foo": "Some value", "bar": "Lorem Ipsum" }});
+ * ```
+ */
+ slots?: Record<string, any>;
+ /**
+ * The request is used to understand which path/URL the component is about to render.
+ *
+ * Use this option in case your component or middleware needs to read information like `Astro.url` or `Astro.request`.
+ */
+ request?: Request;
+ /**
+ * Useful for dynamic routes. If your component is something like `src/pages/blog/[id]/[...slug]`, you'll want to provide:
+ * ```js
+ * container.renderToString(Component, { params: ["id", "...slug"] });
+ * ```
+ */
+ params?: Record<string, string | undefined>;
+ /**
+ * Useful if your component needs to access some locals without the use a middleware.
+ * ```js
+ * container.renderToString(Component, { locals: { getSomeValue() {} } });
+ * ```
+ */
+ locals?: App.Locals;
+ /**
+ * Useful in case you're attempting to render an endpoint:
+ * ```js
+ * container.renderToString(Endpoint, { routeType: "endpoint" });
+ * ```
+ */
+ routeType?: RouteType;
+
+ /**
+ * Allows to pass `Astro.props` to an Astro component:
+ *
+ * ```js
+ * container.renderToString(Endpoint, { props: { "lorem": "ipsum" } });
+ * ```
+ */
+ props?: Props;
+
+ /**
+ * When `false`, it forces to render the component as it was a full-fledged page.
+ *
+ * By default, the container API render components as [partials](https://docs.astro.build/en/basics/astro-pages/#page-partials).
+ *
+ */
+ partial?: boolean;
+};
+
+export type AddServerRenderer =
+ | {
+ renderer: NamedSSRLoadedRendererValue;
+ name: never;
+ }
+ | {
+ renderer: SSRLoadedRendererValue;
+ name: string;
+ };
+
+export type AddClientRenderer = {
+ name: string;
+ entrypoint: string;
+};
+
+type ContainerImportRendererFn = (
+ containerRenderer: ContainerRenderer,
+) => Promise<SSRLoadedRenderer>;
+
+function createManifest(
+ manifest?: AstroContainerManifest,
+ renderers?: SSRLoadedRenderer[],
+ middleware?: MiddlewareHandler,
+): SSRManifest {
+ function middlewareInstance(): AstroMiddlewareInstance {
+ return {
+ onRequest: middleware ?? NOOP_MIDDLEWARE_FN,
+ };
+ }
+
+ return {
+ hrefRoot: import.meta.url,
+ srcDir: manifest?.srcDir ?? ASTRO_CONFIG_DEFAULTS.srcDir,
+ buildClientDir: manifest?.buildClientDir ?? ASTRO_CONFIG_DEFAULTS.build.client,
+ buildServerDir: manifest?.buildServerDir ?? ASTRO_CONFIG_DEFAULTS.build.server,
+ publicDir: manifest?.publicDir ?? ASTRO_CONFIG_DEFAULTS.publicDir,
+ outDir: manifest?.outDir ?? ASTRO_CONFIG_DEFAULTS.outDir,
+ cacheDir: manifest?.cacheDir ?? ASTRO_CONFIG_DEFAULTS.cacheDir,
+ trailingSlash: manifest?.trailingSlash ?? ASTRO_CONFIG_DEFAULTS.trailingSlash,
+ buildFormat: manifest?.buildFormat ?? ASTRO_CONFIG_DEFAULTS.build.format,
+ compressHTML: manifest?.compressHTML ?? ASTRO_CONFIG_DEFAULTS.compressHTML,
+ assets: manifest?.assets ?? new Set(),
+ assetsPrefix: manifest?.assetsPrefix ?? undefined,
+ entryModules: manifest?.entryModules ?? {},
+ routes: manifest?.routes ?? [],
+ adapterName: '',
+ clientDirectives: manifest?.clientDirectives ?? getDefaultClientDirectives(),
+ renderers: renderers ?? manifest?.renderers ?? [],
+ base: manifest?.base ?? ASTRO_CONFIG_DEFAULTS.base,
+ componentMetadata: manifest?.componentMetadata ?? new Map(),
+ inlinedScripts: manifest?.inlinedScripts ?? new Map(),
+ i18n: manifest?.i18n,
+ checkOrigin: false,
+ middleware: manifest?.middleware ?? middlewareInstance,
+ key: createKey(),
+ };
+}
+
+export type AstroContainerUserConfig = Omit<AstroUserConfig, 'integrations' | 'adapter'>;
+
+/**
+ * Options that are used for the entire lifecycle of the current instance of the container.
+ */
+export type AstroContainerOptions = {
+ /**
+ * @default false
+ *
+ * @description
+ *
+ * Enables streaming during rendering
+ *
+ * ## Example
+ *
+ * ```js
+ * const container = await AstroContainer.create({
+ * streaming: true
+ * });
+ * ```
+ */
+ streaming?: boolean;
+ /**
+ * @default []
+ * @description
+ *
+ * List or renderers to use when rendering components. Usually, you want to pass these in an SSR context.
+ */
+ renderers?: SSRLoadedRenderer[];
+ /**
+ * @default {}
+ * @description
+ *
+ * A subset of the astro configuration object.
+ *
+ * ## Example
+ *
+ * ```js
+ * const container = await AstroContainer.create({
+ * astroConfig: {
+ * trailingSlash: "never"
+ * }
+ * });
+ * ```
+ */
+ astroConfig?: AstroContainerUserConfig;
+
+ // TODO: document out of experimental
+ resolve?: SSRResult['resolve'];
+
+ /**
+ * @default {}
+ * @description
+ *
+ * The raw manifest from the build output.
+ */
+ manifest?: SSRManifest;
+};
+
+type AstroContainerManifest = Pick<
+ SSRManifest,
+ | 'middleware'
+ | 'clientDirectives'
+ | 'inlinedScripts'
+ | 'componentMetadata'
+ | 'renderers'
+ | 'assetsPrefix'
+ | 'base'
+ | 'routes'
+ | 'assets'
+ | 'entryModules'
+ | 'compressHTML'
+ | 'trailingSlash'
+ | 'buildFormat'
+ | 'i18n'
+ | 'srcDir'
+ | 'buildClientDir'
+ | 'buildServerDir'
+ | 'publicDir'
+ | 'outDir'
+ | 'cacheDir'
+>;
+
+type AstroContainerConstructor = {
+ streaming?: boolean;
+ renderers?: SSRLoadedRenderer[];
+ manifest?: AstroContainerManifest;
+ resolve?: SSRResult['resolve'];
+ astroConfig?: AstroConfig;
+};
+
+export class experimental_AstroContainer {
+ #pipeline: ContainerPipeline;
+
+ /**
+ * Internally used to check if the container was created with a manifest.
+ * @private
+ */
+ #withManifest = false;
+
+ /**
+ * Internal function responsible for importing a renderer
+ * @private
+ */
+ #getRenderer: ContainerImportRendererFn | undefined;
+
+ private constructor({
+ streaming = false,
+ manifest,
+ renderers,
+ resolve,
+ astroConfig,
+ }: AstroContainerConstructor) {
+ this.#pipeline = ContainerPipeline.create({
+ logger: new Logger({
+ level: 'info',
+ dest: nodeLogDestination,
+ }),
+ manifest: createManifest(manifest, renderers),
+ streaming,
+ serverLike: true,
+ renderers: renderers ?? manifest?.renderers ?? [],
+ resolve: async (specifier: string) => {
+ if (this.#withManifest) {
+ return this.#containerResolve(specifier, astroConfig);
+ } else if (resolve) {
+ return resolve(specifier);
+ }
+ return specifier;
+ },
+ });
+ }
+
+ async #containerResolve(specifier: string, astroConfig?: AstroConfig): Promise<string> {
+ const found = this.#pipeline.manifest.entryModules[specifier];
+ if (found) {
+ return new URL(found, astroConfig?.build.client).toString();
+ }
+ return found;
+ }
+
+ /**
+ * Creates a new instance of a container.
+ *
+ * @param {AstroContainerOptions=} containerOptions
+ */
+ public static async create(
+ containerOptions: AstroContainerOptions = {},
+ ): Promise<experimental_AstroContainer> {
+ const { streaming = false, manifest, renderers = [], resolve } = containerOptions;
+ const astroConfig = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container');
+ return new experimental_AstroContainer({
+ streaming,
+ manifest,
+ renderers,
+ astroConfig,
+ resolve,
+ });
+ }
+
+ /**
+ * Use this function to manually add a **server** renderer to the container.
+ *
+ * This function is preferred when you require to use the container with a renderer in environments such as on-demand pages.
+ *
+ * ## Example
+ *
+ * ```js
+ * import reactRenderer from "@astrojs/react/server.js";
+ * import vueRenderer from "@astrojs/vue/server.js";
+ * import customRenderer from "../renderer/customRenderer.js";
+ * import { experimental_AstroContainer as AstroContainer } from "astro/container"
+ *
+ * const container = await AstroContainer.create();
+ * container.addServerRenderer(reactRenderer);
+ * container.addServerRenderer(vueRenderer);
+ * container.addServerRenderer("customRenderer", customRenderer);
+ * ```
+ *
+ * @param options {object}
+ * @param options.name The name of the renderer. The name **isn't** arbitrary, and it should match the name of the package.
+ * @param options.renderer The server renderer exported by integration.
+ */
+ public addServerRenderer(options: AddServerRenderer): void {
+ const { renderer, name } = options;
+ if (!renderer.check || !renderer.renderToStaticMarkup) {
+ throw new Error(
+ "The renderer you passed isn't valid. A renderer is usually an object that exposes the `check` and `renderToStaticMarkup` functions.\n" +
+ "Usually, the renderer is exported by a /server.js entrypoint e.g. `import renderer from '@astrojs/react/server.js'`",
+ );
+ }
+ if (isNamedRenderer(renderer)) {
+ this.#pipeline.manifest.renderers.push({
+ name: renderer.name,
+ ssr: renderer,
+ });
+ } else {
+ this.#pipeline.manifest.renderers.push({
+ name,
+ ssr: renderer,
+ });
+ }
+ }
+
+ /**
+ * Use this function to manually add a **client** renderer to the container.
+ *
+ * When rendering components that use the `client:*` directives, you need to use this function.
+ *
+ * ## Example
+ *
+ * ```js
+ * import reactRenderer from "@astrojs/react/server.js";
+ * import { experimental_AstroContainer as AstroContainer } from "astro/container"
+ *
+ * const container = await AstroContainer.create();
+ * container.addServerRenderer(reactRenderer);
+ * container.addClientRenderer({
+ * name: "@astrojs/react",
+ * entrypoint: "@astrojs/react/client.js"
+ * });
+ * ```
+ *
+ * @param options {object}
+ * @param options.name The name of the renderer. The name **isn't** arbitrary, and it should match the name of the package.
+ * @param options.entrypoint The entrypoint of the client renderer.
+ */
+ public addClientRenderer(options: AddClientRenderer): void {
+ const { entrypoint, name } = options;
+
+ const rendererIndex = this.#pipeline.manifest.renderers.findIndex((r) => r.name === name);
+ if (rendererIndex === -1) {
+ throw new Error(
+ 'You tried to add the ' +
+ name +
+ " client renderer, but its server renderer wasn't added. You must add the server renderer first. Use the `addServerRenderer` function.",
+ );
+ }
+ const renderer = this.#pipeline.manifest.renderers[rendererIndex];
+ renderer.clientEntrypoint = entrypoint;
+
+ this.#pipeline.manifest.renderers[rendererIndex] = renderer;
+ }
+
+ // NOTE: we keep this private via TS instead via `#` so it's still available on the surface, so we can play with it.
+ // @ematipico: I plan to use it for a possible integration that could help people
+ private static async createFromManifest(
+ manifest: SSRManifest,
+ ): Promise<experimental_AstroContainer> {
+ const astroConfig = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container');
+ const container = new experimental_AstroContainer({
+ manifest,
+ astroConfig,
+ });
+ container.#withManifest = true;
+ return container;
+ }
+
+ #insertRoute({
+ path,
+ componentInstance,
+ params = {},
+ type = 'page',
+ }: {
+ path: string;
+ componentInstance: ComponentInstance;
+ route?: string;
+ params?: Record<string, string | undefined>;
+ type?: RouteType;
+ }): RouteData {
+ const pathUrl = new URL(path, 'https://example.com');
+ const routeData: RouteData = this.#createRoute(pathUrl, params, type);
+ this.#pipeline.manifest.routes.push({
+ routeData,
+ file: '',
+ links: [],
+ styles: [],
+ scripts: [],
+ });
+ this.#pipeline.insertRoute(routeData, componentInstance);
+ return routeData;
+ }
+
+ /**
+ * @description
+ * It renders a component and returns the result as a string.
+ *
+ * ## Example
+ *
+ * ```js
+ * import Card from "../src/components/Card.astro";
+ *
+ * const container = await AstroContainer.create();
+ * const result = await container.renderToString(Card);
+ *
+ * console.log(result); // it's a string
+ * ```
+ *
+ *
+ * @param {AstroComponentFactory} component The instance of the component.
+ * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component.
+ */
+ public async renderToString(
+ component: AstroComponentFactory,
+ options: ContainerRenderOptions = {},
+ ): Promise<string> {
+ const response = await this.renderToResponse(component, options);
+ return await response.text();
+ }
+
+ /**
+ * @description
+ * It renders a component and returns the `Response` as result of the rendering phase.
+ *
+ * ## Example
+ *
+ * ```js
+ * import Card from "../src/components/Card.astro";
+ *
+ * const container = await AstroContainer.create();
+ * const response = await container.renderToResponse(Card);
+ *
+ * console.log(response.status); // it's a number
+ * ```
+ *
+ *
+ * @param {AstroComponentFactory} component The instance of the component.
+ * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component.
+ */
+ public async renderToResponse(
+ component: AstroComponentFactory,
+ options: ContainerRenderOptions = {},
+ ): Promise<Response> {
+ const { routeType = 'page', slots } = options;
+ const request = options?.request ?? new Request('https://example.com/');
+ const url = new URL(request.url);
+ const componentInstance =
+ routeType === 'endpoint'
+ ? (component as unknown as ComponentInstance)
+ : this.#wrapComponent(component, options.params);
+ const routeData = this.#insertRoute({
+ path: request.url,
+ componentInstance,
+ params: options.params,
+ type: routeType,
+ });
+ const renderContext = await RenderContext.create({
+ pipeline: this.#pipeline,
+ routeData,
+ status: 200,
+ request,
+ pathname: url.pathname,
+ locals: options?.locals ?? {},
+ partial: options?.partial ?? true,
+ clientAddress: '',
+ });
+ if (options.params) {
+ renderContext.params = options.params;
+ }
+ if (options.props) {
+ renderContext.props = options.props;
+ }
+
+ return renderContext.render(componentInstance, slots);
+ }
+
+ #createRoute(url: URL, params: Record<string, string | undefined>, type: RouteType): RouteData {
+ const segments = removeLeadingForwardSlash(url.pathname)
+ .split(posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, url.pathname);
+ });
+ return {
+ route: url.pathname,
+ component: '',
+ generate(_data: any): string {
+ return '';
+ },
+ params: Object.keys(params),
+ pattern: getPattern(
+ segments,
+ ASTRO_CONFIG_DEFAULTS.base,
+ ASTRO_CONFIG_DEFAULTS.trailingSlash,
+ ),
+ prerender: false,
+ segments,
+ type,
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'internal',
+ };
+ }
+
+ /**
+ * If the provided component isn't a default export, the function wraps it in an object `{default: Component }` to mimic the default export.
+ * @param componentFactory
+ * @param params
+ * @private
+ */
+ #wrapComponent(
+ componentFactory: AstroComponentFactory,
+ params?: Record<string, string | undefined>,
+ ): ComponentInstance {
+ if (params) {
+ return {
+ default: componentFactory,
+ getStaticPaths() {
+ return [{ params }];
+ },
+ };
+ }
+ return { default: componentFactory };
+ }
+}
+
+function isNamedRenderer(renderer: any): renderer is NamedSSRLoadedRendererValue {
+ return !!renderer?.name;
+}
diff --git a/packages/astro/src/container/pipeline.ts b/packages/astro/src/container/pipeline.ts
new file mode 100644
index 000000000..154713028
--- /dev/null
+++ b/packages/astro/src/container/pipeline.ts
@@ -0,0 +1,94 @@
+import { type HeadElements, Pipeline, type TryRewriteResult } from '../core/base-pipeline.js';
+import type { SinglePageBuiltModule } from '../core/build/types.js';
+import {
+ createModuleScriptElement,
+ createStylesheetElementSet,
+} from '../core/render/ssr-element.js';
+import { findRouteToRewrite } from '../core/routing/rewrite.js';
+import type { ComponentInstance } from '../types/astro.js';
+import type { RewritePayload } from '../types/public/common.js';
+import type { RouteData, SSRElement, SSRResult } from '../types/public/internal.js';
+
+export class ContainerPipeline extends Pipeline {
+ /**
+ * Internal cache to store components instances by `RouteData`.
+ * @private
+ */
+ #componentsInterner: WeakMap<RouteData, SinglePageBuiltModule> = new WeakMap<
+ RouteData,
+ SinglePageBuiltModule
+ >();
+
+ static create({
+ logger,
+ manifest,
+ renderers,
+ resolve,
+ serverLike,
+ streaming,
+ }: Pick<
+ ContainerPipeline,
+ 'logger' | 'manifest' | 'renderers' | 'resolve' | 'serverLike' | 'streaming'
+ >) {
+ return new ContainerPipeline(
+ logger,
+ manifest,
+ 'development',
+ renderers,
+ resolve,
+ serverLike,
+ streaming,
+ );
+ }
+
+ componentMetadata(_routeData: RouteData): Promise<SSRResult['componentMetadata']> | void {}
+
+ headElements(routeData: RouteData): Promise<HeadElements> | HeadElements {
+ const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
+ const links = new Set<never>();
+ const scripts = new Set<SSRElement>();
+ const styles = createStylesheetElementSet(routeInfo?.styles ?? []);
+
+ for (const script of routeInfo?.scripts ?? []) {
+ if ('stage' in script) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.children,
+ });
+ }
+ } else {
+ scripts.add(createModuleScriptElement(script));
+ }
+ }
+ return { links, styles, scripts };
+ }
+
+ async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
+ const { newUrl, pathname, routeData } = findRouteToRewrite({
+ payload,
+ request,
+ routes: this.manifest?.routes.map((r) => r.routeData),
+ trailingSlash: this.manifest.trailingSlash,
+ buildFormat: this.manifest.buildFormat,
+ base: this.manifest.base,
+ });
+
+ const componentInstance = await this.getComponentByRoute(routeData);
+ return { componentInstance, routeData, newUrl, pathname };
+ }
+
+ insertRoute(route: RouteData, componentInstance: ComponentInstance): void {
+ this.#componentsInterner.set(route, {
+ page() {
+ return Promise.resolve(componentInstance);
+ },
+ renderers: this.manifest.renderers,
+ onRequest: this.resolvedMiddleware,
+ });
+ }
+
+ // At the moment it's not used by the container via any public API
+ // @ts-expect-error It needs to be implemented.
+ async getComponentByRoute(_routeData: RouteData): Promise<ComponentInstance> {}
+}
diff --git a/packages/astro/src/container/polyfill.ts b/packages/astro/src/container/polyfill.ts
new file mode 100644
index 000000000..baf533596
--- /dev/null
+++ b/packages/astro/src/container/polyfill.ts
@@ -0,0 +1,3 @@
+import { applyPolyfills } from '../core/app/node.js';
+
+applyPolyfills();
diff --git a/packages/astro/src/container/vite-plugin-container.ts b/packages/astro/src/container/vite-plugin-container.ts
new file mode 100644
index 000000000..2098ff086
--- /dev/null
+++ b/packages/astro/src/container/vite-plugin-container.ts
@@ -0,0 +1,15 @@
+import type * as vite from 'vite';
+
+const virtualModuleId = 'astro:container';
+
+export default function astroContainer(): vite.Plugin {
+ return {
+ name: 'astro:container',
+ enforce: 'pre',
+ resolveId(id) {
+ if (id === virtualModuleId) {
+ return this.resolve('astro/virtual-modules/container.js');
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts
new file mode 100644
index 000000000..76218d7e8
--- /dev/null
+++ b/packages/astro/src/content/consts.ts
@@ -0,0 +1,43 @@
+export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets';
+export const CONTENT_RENDER_FLAG = 'astroRenderContent';
+export const CONTENT_FLAG = 'astroContentCollectionEntry';
+export const DATA_FLAG = 'astroDataCollectionEntry';
+export const CONTENT_IMAGE_FLAG = 'astroContentImageFlag';
+export const CONTENT_MODULE_FLAG = 'astroContentModuleFlag';
+
+export const VIRTUAL_MODULE_ID = 'astro:content';
+export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
+export const DATA_STORE_VIRTUAL_ID = 'astro:data-layer-content';
+export const RESOLVED_DATA_STORE_VIRTUAL_ID = '\0' + DATA_STORE_VIRTUAL_ID;
+
+// Used by the content layer to create a virtual module that loads the `modules.mjs`, a file created by the content layer
+// to map modules that are renderer at runtime
+export const MODULES_MJS_ID = 'astro:content-module-imports';
+export const MODULES_MJS_VIRTUAL_ID = '\0' + MODULES_MJS_ID;
+
+export const DEFERRED_MODULE = 'astro:content-layer-deferred-module';
+
+// Used by the content layer to create a virtual module that loads the `assets.mjs`
+export const ASSET_IMPORTS_VIRTUAL_ID = 'astro:asset-imports';
+export const ASSET_IMPORTS_RESOLVED_STUB_ID = '\0' + ASSET_IMPORTS_VIRTUAL_ID;
+export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
+export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
+export const IMAGE_IMPORT_PREFIX = '__ASTRO_IMAGE_';
+
+export const CONTENT_FLAGS = [
+ CONTENT_FLAG,
+ CONTENT_RENDER_FLAG,
+ DATA_FLAG,
+ PROPAGATED_ASSET_FLAG,
+ CONTENT_IMAGE_FLAG,
+ CONTENT_MODULE_FLAG,
+] as const;
+
+export const CONTENT_TYPES_FILE = 'content.d.ts';
+export const DATA_STORE_FILE = 'data-store.json';
+export const ASSET_IMPORTS_FILE = 'content-assets.mjs';
+export const MODULES_IMPORTS_FILE = 'content-modules.mjs';
+export const COLLECTIONS_MANIFEST_FILE = 'collections/collections.json';
+export const COLLECTIONS_DIR = 'collections/';
+
+export const CONTENT_LAYER_TYPE = 'content_layer';
diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts
new file mode 100644
index 000000000..64212c4d6
--- /dev/null
+++ b/packages/astro/src/content/content-layer.ts
@@ -0,0 +1,440 @@
+import { promises as fs, existsSync } from 'node:fs';
+import PQueue from 'p-queue';
+import type { FSWatcher } from 'vite';
+import xxhash from 'xxhash-wasm';
+import type { z } from 'zod';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { ContentEntryType, RefreshContentOptions } from '../types/public/content.js';
+import {
+ ASSET_IMPORTS_FILE,
+ COLLECTIONS_MANIFEST_FILE,
+ CONTENT_LAYER_TYPE,
+ DATA_STORE_FILE,
+ MODULES_IMPORTS_FILE,
+} from './consts.js';
+import type { LoaderContext } from './loaders/types.js';
+import type { MutableDataStore } from './mutable-data-store.js';
+import {
+ type ContentObservable,
+ getEntryConfigByExtMap,
+ getEntryDataAndImages,
+ globalContentConfigObserver,
+ loaderReturnSchema,
+ safeStringify,
+} from './utils.js';
+import { type WrappedWatcher, createWatcherWrapper } from './watcher.js';
+
+export interface ContentLayerOptions {
+ store: MutableDataStore;
+ settings: AstroSettings;
+ logger: Logger;
+ watcher?: FSWatcher;
+}
+
+type CollectionLoader<TData> = () =>
+ | Array<TData>
+ | Promise<Array<TData>>
+ | Record<string, Record<string, unknown>>
+ | Promise<Record<string, Record<string, unknown>>>;
+
+export class ContentLayer {
+ #logger: Logger;
+ #store: MutableDataStore;
+ #settings: AstroSettings;
+ #watcher?: WrappedWatcher;
+ #lastConfigDigest?: string;
+ #unsubscribe?: () => void;
+
+ #generateDigest?: (data: Record<string, unknown> | string) => string;
+
+ #queue: PQueue;
+
+ constructor({ settings, logger, store, watcher }: ContentLayerOptions) {
+ // The default max listeners is 10, which can be exceeded when using a lot of loaders
+ watcher?.setMaxListeners(50);
+
+ this.#logger = logger;
+ this.#store = store;
+ this.#settings = settings;
+ if (watcher) {
+ this.#watcher = createWatcherWrapper(watcher);
+ }
+ this.#queue = new PQueue({ concurrency: 1 });
+ }
+
+ /**
+ * Whether the content layer is currently loading content
+ */
+ get loading() {
+ return this.#queue.size > 0 || this.#queue.pending > 0;
+ }
+
+ /**
+ * Watch for changes to the content config and trigger a sync when it changes.
+ */
+ watchContentConfig() {
+ this.#unsubscribe?.();
+ this.#unsubscribe = globalContentConfigObserver.subscribe(async (ctx) => {
+ if (ctx.status === 'loaded' && ctx.config.digest !== this.#lastConfigDigest) {
+ this.sync();
+ }
+ });
+ }
+
+ unwatchContentConfig() {
+ this.#unsubscribe?.();
+ }
+
+ dispose() {
+ this.#queue.clear();
+ this.#unsubscribe?.();
+ this.#watcher?.removeAllTrackedListeners();
+ }
+
+ async #getGenerateDigest() {
+ if (this.#generateDigest) {
+ return this.#generateDigest;
+ }
+ // xxhash is a very fast non-cryptographic hash function that is used to generate a content digest
+ // It uses wasm, so we need to load it asynchronously.
+ const { h64ToString } = await xxhash();
+
+ this.#generateDigest = (data: unknown) => {
+ const dataString = typeof data === 'string' ? data : JSON.stringify(data);
+ return h64ToString(dataString);
+ };
+
+ return this.#generateDigest;
+ }
+
+ async #getLoaderContext({
+ collectionName,
+ loaderName = 'content',
+ parseData,
+ refreshContextData,
+ }: {
+ collectionName: string;
+ loaderName: string;
+ parseData: LoaderContext['parseData'];
+ refreshContextData?: Record<string, unknown>;
+ }): Promise<LoaderContext> {
+ return {
+ collection: collectionName,
+ store: this.#store.scopedStore(collectionName),
+ meta: this.#store.metaStore(collectionName),
+ logger: this.#logger.forkIntegrationLogger(loaderName),
+ config: this.#settings.config,
+ parseData,
+ generateDigest: await this.#getGenerateDigest(),
+ watcher: this.#watcher,
+ refreshContextData,
+ entryTypes: getEntryConfigByExtMap([
+ ...this.#settings.contentEntryTypes,
+ ...this.#settings.dataEntryTypes,
+ ] as Array<ContentEntryType>),
+ };
+ }
+
+ /**
+ * Enqueues a sync job that runs the `load()` method of each collection's loader, which will load the data and save it in the data store.
+ * The loader itself is responsible for deciding whether this will clear and reload the full collection, or
+ * perform an incremental update. After the data is loaded, the data store is written to disk. Jobs are queued,
+ * so that only one sync can run at a time. The function returns a promise that resolves when this sync job is complete.
+ */
+
+ sync(options: RefreshContentOptions = {}): Promise<void> {
+ return this.#queue.add(() => this.#doSync(options));
+ }
+
+ async #doSync(options: RefreshContentOptions) {
+ let contentConfig = globalContentConfigObserver.get();
+ const logger = this.#logger.forkIntegrationLogger('content');
+
+ if (contentConfig?.status === 'loading') {
+ contentConfig = await Promise.race<ReturnType<ContentObservable['get']>>([
+ new Promise((resolve) => {
+ const unsub = globalContentConfigObserver.subscribe((ctx) => {
+ unsub();
+ resolve(ctx);
+ });
+ }),
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({ status: 'error', error: new Error('Content config loading timed out') }),
+ 5000,
+ ),
+ ),
+ ]);
+ }
+
+ if (contentConfig?.status === 'error') {
+ logger.error(`Error loading content config. Skipping sync.\n${contentConfig.error.message}`);
+ return;
+ }
+
+ // It shows as loaded with no collections even if there's no config
+ if (contentConfig?.status !== 'loaded') {
+ logger.error(`Content config not loaded, skipping sync. Status was ${contentConfig?.status}`);
+ return;
+ }
+
+ logger.info('Syncing content');
+ const {
+ vite: _vite,
+ integrations: _integrations,
+ adapter: _adapter,
+ ...hashableConfig
+ } = this.#settings.config;
+
+ const astroConfigDigest = safeStringify(hashableConfig);
+
+ const { digest: currentConfigDigest } = contentConfig.config;
+ this.#lastConfigDigest = currentConfigDigest;
+
+ let shouldClear = false;
+ const previousConfigDigest = await this.#store.metaStore().get('content-config-digest');
+ const previousAstroConfigDigest = await this.#store.metaStore().get('astro-config-digest');
+ const previousAstroVersion = await this.#store.metaStore().get('astro-version');
+
+ if (previousAstroConfigDigest && previousAstroConfigDigest !== astroConfigDigest) {
+ logger.info('Astro config changed');
+ shouldClear = true;
+ }
+
+ if (previousConfigDigest && previousConfigDigest !== currentConfigDigest) {
+ logger.info('Content config changed');
+ shouldClear = true;
+ }
+ if (previousAstroVersion && previousAstroVersion !== process.env.ASTRO_VERSION) {
+ logger.info('Astro version changed');
+ shouldClear = true;
+ }
+ if (shouldClear) {
+ logger.info('Clearing content store');
+ this.#store.clearAll();
+ }
+ if (process.env.ASTRO_VERSION) {
+ await this.#store.metaStore().set('astro-version', process.env.ASTRO_VERSION);
+ }
+ if (currentConfigDigest) {
+ await this.#store.metaStore().set('content-config-digest', currentConfigDigest);
+ }
+ if (astroConfigDigest) {
+ await this.#store.metaStore().set('astro-config-digest', astroConfigDigest);
+ }
+
+ if (!options?.loaders?.length) {
+ // Remove all listeners before syncing, as they will be re-added by the loaders, but not if this is a selective sync
+ this.#watcher?.removeAllTrackedListeners();
+ }
+
+ await Promise.all(
+ Object.entries(contentConfig.config.collections).map(async ([name, collection]) => {
+ if (collection.type !== CONTENT_LAYER_TYPE) {
+ return;
+ }
+
+ let { schema } = collection;
+
+ if (!schema && typeof collection.loader === 'object') {
+ schema = collection.loader.schema;
+ if (typeof schema === 'function') {
+ schema = await schema();
+ }
+ }
+
+ // If loaders are specified, only sync the specified loaders
+ if (
+ options?.loaders &&
+ (typeof collection.loader !== 'object' ||
+ !options.loaders.includes(collection.loader.name))
+ ) {
+ return;
+ }
+
+ const collectionWithResolvedSchema = { ...collection, schema };
+
+ const parseData: LoaderContext['parseData'] = async ({ id, data, filePath = '' }) => {
+ const { data: parsedData } = await getEntryDataAndImages(
+ {
+ id,
+ collection: name,
+ unvalidatedData: data,
+ _internal: {
+ rawData: undefined,
+ filePath,
+ },
+ },
+ collectionWithResolvedSchema,
+ false,
+ !!this.#settings.config.experimental.svg,
+ );
+
+ return parsedData;
+ };
+
+ const context = await this.#getLoaderContext({
+ collectionName: name,
+ parseData,
+ loaderName: collection.loader.name,
+ refreshContextData: options?.context,
+ });
+
+ if (typeof collection.loader === 'function') {
+ return simpleLoader(collection.loader as CollectionLoader<{ id: string }>, context);
+ }
+
+ if (!collection.loader.load) {
+ throw new Error(`Collection loader for ${name} does not have a load method`);
+ }
+
+ return collection.loader.load(context);
+ }),
+ );
+ await fs.mkdir(this.#settings.config.cacheDir, { recursive: true });
+ await fs.mkdir(this.#settings.dotAstroDir, { recursive: true });
+ await this.#store.writeToDisk();
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, this.#settings.dotAstroDir);
+ await this.#store.writeAssetImports(assetImportsFile);
+ const modulesImportsFile = new URL(MODULES_IMPORTS_FILE, this.#settings.dotAstroDir);
+ await this.#store.writeModuleImports(modulesImportsFile);
+ logger.info('Synced content');
+ if (this.#settings.config.experimental.contentIntellisense) {
+ await this.regenerateCollectionFileManifest();
+ }
+ }
+
+ async regenerateCollectionFileManifest() {
+ const collectionsManifest = new URL(COLLECTIONS_MANIFEST_FILE, this.#settings.dotAstroDir);
+ this.#logger.debug('content', 'Regenerating collection file manifest');
+ if (existsSync(collectionsManifest)) {
+ try {
+ const collections = await fs.readFile(collectionsManifest, 'utf-8');
+ const collectionsJson = JSON.parse(collections);
+ collectionsJson.entries ??= {};
+
+ for (const { hasSchema, name } of collectionsJson.collections) {
+ if (!hasSchema) {
+ continue;
+ }
+ const entries = this.#store.values(name);
+ if (!entries?.[0]?.filePath) {
+ continue;
+ }
+ for (const { filePath } of entries) {
+ if (!filePath) {
+ continue;
+ }
+ const key = new URL(filePath, this.#settings.config.root).href.toLowerCase();
+ collectionsJson.entries[key] = name;
+ }
+ }
+ await fs.writeFile(collectionsManifest, JSON.stringify(collectionsJson, null, 2));
+ } catch {
+ this.#logger.error('content', 'Failed to regenerate collection file manifest');
+ }
+ }
+ this.#logger.debug('content', 'Regenerated collection file manifest');
+ }
+}
+
+export async function simpleLoader<TData extends { id: string }>(
+ handler: CollectionLoader<TData>,
+ context: LoaderContext,
+) {
+ const unsafeData = await handler();
+ const parsedData = loaderReturnSchema.safeParse(unsafeData);
+
+ if (!parsedData.success) {
+ const issue = parsedData.error.issues[0] as z.ZodInvalidUnionIssue;
+
+ // Due to this being a union, zod will always throw an "Expected array, received object" error along with the other errors.
+ // This error is in the second position if the data is an array, and in the first position if the data is an object.
+ const parseIssue = Array.isArray(unsafeData) ? issue.unionErrors[0] : issue.unionErrors[1];
+
+ const error = parseIssue.errors[0];
+ const firstPathItem = error.path[0];
+
+ const entry = Array.isArray(unsafeData)
+ ? unsafeData[firstPathItem as number]
+ : unsafeData[firstPathItem as string];
+
+ throw new AstroError({
+ ...AstroErrorData.ContentLoaderReturnsInvalidId,
+ message: AstroErrorData.ContentLoaderReturnsInvalidId.message(context.collection, entry),
+ });
+ }
+
+ const data = parsedData.data;
+
+ context.store.clear();
+
+ if (Array.isArray(data)) {
+ for (const raw of data) {
+ if (!raw.id) {
+ throw new AstroError({
+ ...AstroErrorData.ContentLoaderInvalidDataError,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Entry missing ID:\n${JSON.stringify({ ...raw, id: undefined }, null, 2)}`,
+ ),
+ });
+ }
+ const item = await context.parseData({ id: raw.id, data: raw });
+ context.store.set({ id: raw.id, data: item });
+ }
+ return;
+ }
+ if (typeof data === 'object') {
+ for (const [id, raw] of Object.entries(data)) {
+ if (raw.id && raw.id !== id) {
+ throw new AstroError({
+ ...AstroErrorData.ContentLoaderInvalidDataError,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Object key ${JSON.stringify(id)} does not match ID ${JSON.stringify(raw.id)}`,
+ ),
+ });
+ }
+ const item = await context.parseData({ id, data: raw });
+ context.store.set({ id, data: item });
+ }
+ return;
+ }
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImageOptions,
+ message: AstroErrorData.ContentLoaderInvalidDataError.message(
+ context.collection,
+ `Invalid data type: ${typeof data}`,
+ ),
+ });
+}
+/**
+ * Get the path to the data store file.
+ * During development, this is in the `.astro` directory so that the Vite watcher can see it.
+ * In production, it's in the cache directory so that it's preserved between builds.
+ */
+export function getDataStoreFile(settings: AstroSettings, isDev: boolean) {
+ return new URL(DATA_STORE_FILE, isDev ? settings.dotAstroDir : settings.config.cacheDir);
+}
+
+function contentLayerSingleton() {
+ let instance: ContentLayer | null = null;
+ return {
+ init: (options: ContentLayerOptions) => {
+ instance?.dispose();
+ instance = new ContentLayer(options);
+ return instance;
+ },
+ get: () => instance,
+ dispose: () => {
+ instance?.dispose();
+ instance = null;
+ },
+ };
+}
+
+export const globalContentLayer = contentLayerSingleton();
diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts
new file mode 100644
index 000000000..82875b1ec
--- /dev/null
+++ b/packages/astro/src/content/data-store.ts
@@ -0,0 +1,128 @@
+import type { MarkdownHeading } from '@astrojs/markdown-remark';
+import * as devalue from 'devalue';
+
+export interface RenderedContent {
+ /** Rendered HTML string. If present then `render(entry)` will return a component that renders this HTML. */
+ html: string;
+ metadata?: {
+ /** Any images that are present in this entry. Relative to the {@link DataEntry} filePath. */
+ imagePaths?: Array<string>;
+ /** Any headings that are present in this file. */
+ headings?: MarkdownHeading[];
+ /** Raw frontmatter, parsed parsed from the file. This may include data from remark plugins. */
+ frontmatter?: Record<string, any>;
+ /** Any other metadata that is present in this file. */
+ [key: string]: unknown;
+ };
+}
+
+export interface DataEntry<TData extends Record<string, unknown> = Record<string, unknown>> {
+ /** The ID of the entry. Unique per collection. */
+ id: string;
+ /** The parsed entry data */
+ data: TData;
+ /** The file path of the content, if applicable. Relative to the site root. */
+ filePath?: string;
+ /** The raw body of the content, if applicable. */
+ body?: string;
+ /** An optional content digest, to check if the content has changed. */
+ digest?: number | string;
+ /** The rendered content of the entry, if applicable. */
+ rendered?: RenderedContent;
+ /**
+ * If an entry is a deferred, its rendering phase is delegated to a virtual module during the runtime phase when calling `renderEntry`.
+ */
+ deferredRender?: boolean;
+ assetImports?: Array<string>;
+ /** @deprecated */
+ legacyId?: string;
+}
+
+/**
+ * A read-only data store for content collections. This is used to retrieve data from the content layer at runtime.
+ * To add or modify data, use {@link MutableDataStore} instead.
+ */
+
+export class ImmutableDataStore {
+ protected _collections = new Map<string, Map<string, any>>();
+
+ constructor() {
+ this._collections = new Map();
+ }
+
+ get<T = DataEntry>(collectionName: string, key: string): T | undefined {
+ return this._collections.get(collectionName)?.get(String(key));
+ }
+
+ entries<T = DataEntry>(collectionName: string): Array<[id: string, T]> {
+ const collection = this._collections.get(collectionName) ?? new Map();
+ return [...collection.entries()];
+ }
+
+ values<T = DataEntry>(collectionName: string): Array<T> {
+ const collection = this._collections.get(collectionName) ?? new Map();
+ return [...collection.values()];
+ }
+
+ keys(collectionName: string): Array<string> {
+ const collection = this._collections.get(collectionName) ?? new Map();
+ return [...collection.keys()];
+ }
+
+ has(collectionName: string, key: string) {
+ const collection = this._collections.get(collectionName);
+ if (collection) {
+ return collection.has(String(key));
+ }
+ return false;
+ }
+
+ hasCollection(collectionName: string) {
+ return this._collections.has(collectionName);
+ }
+
+ collections() {
+ return this._collections;
+ }
+
+ /**
+ * Attempts to load a DataStore from the virtual module.
+ * This only works in Vite.
+ */
+ static async fromModule() {
+ try {
+ // @ts-expect-error - this is a virtual module
+ const data = await import('astro:data-layer-content');
+ if (data.default instanceof Map) {
+ return ImmutableDataStore.fromMap(data.default);
+ }
+ const map = devalue.unflatten(data.default);
+ return ImmutableDataStore.fromMap(map);
+ } catch {}
+ return new ImmutableDataStore();
+ }
+
+ static async fromMap(data: Map<string, Map<string, any>>) {
+ const store = new ImmutableDataStore();
+ store._collections = data;
+ return store;
+ }
+}
+
+function dataStoreSingleton() {
+ let instance: Promise<ImmutableDataStore> | ImmutableDataStore | undefined = undefined;
+ return {
+ get: async () => {
+ if (!instance) {
+ instance = ImmutableDataStore.fromModule();
+ }
+ return instance;
+ },
+ set: (store: ImmutableDataStore) => {
+ instance = store;
+ },
+ };
+}
+
+/** @internal */
+export const globalDataStore = dataStoreSingleton();
diff --git a/packages/astro/src/content/index.ts b/packages/astro/src/content/index.ts
new file mode 100644
index 000000000..2aef23e85
--- /dev/null
+++ b/packages/astro/src/content/index.ts
@@ -0,0 +1,7 @@
+export { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from './consts.js';
+export { attachContentServerListeners } from './server-listeners.js';
+export { createContentTypesGenerator } from './types-generator.js';
+export { contentObservable, getContentPaths, hasAssetPropagationFlag } from './utils.js';
+export { astroContentAssetPropagationPlugin } from './vite-plugin-content-assets.js';
+export { astroContentImportPlugin } from './vite-plugin-content-imports.js';
+export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
diff --git a/packages/astro/src/content/loaders/file.ts b/packages/astro/src/content/loaders/file.ts
new file mode 100644
index 000000000..c37998a76
--- /dev/null
+++ b/packages/astro/src/content/loaders/file.ts
@@ -0,0 +1,114 @@
+import { promises as fs, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import yaml from 'js-yaml';
+import { posixRelative } from '../utils.js';
+import type { Loader, LoaderContext } from './types.js';
+
+export interface FileOptions {
+ /**
+ * the parsing function to use for this data
+ * @default JSON.parse or yaml.load, depending on the extension of the file
+ * */
+ parser?: (
+ text: string,
+ ) => Record<string, Record<string, unknown>> | Array<Record<string, unknown>>;
+}
+
+/**
+ * Loads entries from a JSON file. The file must contain an array of objects that contain unique `id` fields, or an object with string keys.
+ * @param fileName The path to the JSON file to load, relative to the content directory.
+ * @param options Additional options for the file loader
+ */
+export function file(fileName: string, options?: FileOptions): Loader {
+ if (fileName.includes('*')) {
+ // TODO: AstroError
+ throw new Error('Glob patterns are not supported in `file` loader. Use `glob` loader instead.');
+ }
+
+ let parse: ((text: string) => any) | null = null;
+
+ const ext = fileName.split('.').at(-1);
+ if (ext === 'json') {
+ parse = JSON.parse;
+ } else if (ext === 'yml' || ext === 'yaml') {
+ parse = (text) =>
+ yaml.load(text, {
+ filename: fileName,
+ });
+ }
+ if (options?.parser) parse = options.parser;
+
+ if (parse === null) {
+ // TODO: AstroError
+ throw new Error(
+ `No parser found for file '${fileName}'. Try passing a parser to the \`file\` loader.`,
+ );
+ }
+
+ async function syncData(filePath: string, { logger, parseData, store, config }: LoaderContext) {
+ let data: Array<Record<string, unknown>> | Record<string, Record<string, unknown>>;
+
+ try {
+ const contents = await fs.readFile(filePath, 'utf-8');
+ data = parse!(contents);
+ } catch (error: any) {
+ logger.error(`Error reading data from ${fileName}`);
+ logger.debug(error.message);
+ return;
+ }
+
+ const normalizedFilePath = posixRelative(fileURLToPath(config.root), filePath);
+
+ if (Array.isArray(data)) {
+ if (data.length === 0) {
+ logger.warn(`No items found in ${fileName}`);
+ }
+ logger.debug(`Found ${data.length} item array in ${fileName}`);
+ store.clear();
+ for (const rawItem of data) {
+ const id = (rawItem.id ?? rawItem.slug)?.toString();
+ if (!id) {
+ logger.error(`Item in ${fileName} is missing an id or slug field.`);
+ continue;
+ }
+ const parsedData = await parseData({ id, data: rawItem, filePath });
+ store.set({ id, data: parsedData, filePath: normalizedFilePath });
+ }
+ } else if (typeof data === 'object') {
+ const entries = Object.entries<Record<string, unknown>>(data);
+ logger.debug(`Found object with ${entries.length} entries in ${fileName}`);
+ store.clear();
+ for (const [id, rawItem] of entries) {
+ const parsedData = await parseData({ id, data: rawItem, filePath });
+ store.set({ id, data: parsedData, filePath: normalizedFilePath });
+ }
+ } else {
+ logger.error(`Invalid data in ${fileName}. Must be an array or object.`);
+ }
+ }
+
+ return {
+ name: 'file-loader',
+ load: async (context) => {
+ const { config, logger, watcher } = context;
+ logger.debug(`Loading data from ${fileName}`);
+ const url = new URL(fileName, config.root);
+ if (!existsSync(url)) {
+ logger.error(`File not found: ${fileName}`);
+ return;
+ }
+ const filePath = fileURLToPath(url);
+
+ await syncData(filePath, context);
+
+ watcher?.add(filePath);
+
+ watcher?.on('change', async (changedPath) => {
+ if (changedPath === filePath) {
+ logger.info(`Reloading data from ${fileName}`);
+ await syncData(filePath, context);
+ }
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/content/loaders/glob.ts b/packages/astro/src/content/loaders/glob.ts
new file mode 100644
index 000000000..7299c7ca3
--- /dev/null
+++ b/packages/astro/src/content/loaders/glob.ts
@@ -0,0 +1,357 @@
+import { promises as fs, existsSync } from 'node:fs';
+import { relative } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import fastGlob from 'fast-glob';
+import { bold, green } from 'kleur/colors';
+import micromatch from 'micromatch';
+import pLimit from 'p-limit';
+import type { ContentEntryRenderFunction, ContentEntryType } from '../../types/public/content.js';
+import type { RenderedContent } from '../data-store.js';
+import { getContentEntryIdAndSlug, posixRelative } from '../utils.js';
+import type { Loader } from './types.js';
+
+export interface GenerateIdOptions {
+ /** The path to the entry file, relative to the base directory. */
+ entry: string;
+
+ /** The base directory URL. */
+ base: URL;
+ /** The parsed, unvalidated data of the entry. */
+ data: Record<string, unknown>;
+}
+
+export interface GlobOptions {
+ /** The glob pattern to match files, relative to the base directory */
+ pattern: string | Array<string>;
+ /** The base directory to resolve the glob pattern from. Relative to the root directory, or an absolute file URL. Defaults to `.` */
+ base?: string | URL;
+ /**
+ * Function that generates an ID for an entry. Default implementation generates a slug from the entry path.
+ * @returns The ID of the entry. Must be unique per collection.
+ **/
+ generateId?: (options: GenerateIdOptions) => string;
+}
+
+function generateIdDefault({ entry, base, data }: GenerateIdOptions): string {
+ if (data.slug) {
+ return data.slug as string;
+ }
+ const entryURL = new URL(encodeURI(entry), base);
+ const { slug } = getContentEntryIdAndSlug({
+ entry: entryURL,
+ contentDir: base,
+ collection: '',
+ });
+ return slug;
+}
+
+function checkPrefix(pattern: string | Array<string>, prefix: string) {
+ if (Array.isArray(pattern)) {
+ return pattern.some((p) => p.startsWith(prefix));
+ }
+ return pattern.startsWith(prefix);
+}
+
+/**
+ * Loads multiple entries, using a glob pattern to match files.
+ * @param pattern A glob pattern to match files, relative to the content directory.
+ */
+export function glob(globOptions: GlobOptions): Loader;
+/** @private */
+export function glob(
+ globOptions: GlobOptions & {
+ /** @deprecated */
+ _legacy?: true;
+ },
+): Loader;
+
+export function glob(globOptions: GlobOptions): Loader {
+ if (checkPrefix(globOptions.pattern, '../')) {
+ throw new Error(
+ 'Glob patterns cannot start with `../`. Set the `base` option to a parent directory instead.',
+ );
+ }
+ if (checkPrefix(globOptions.pattern, '/')) {
+ throw new Error(
+ 'Glob patterns cannot start with `/`. Set the `base` option to a parent directory or use a relative path instead.',
+ );
+ }
+
+ const generateId = globOptions?.generateId ?? generateIdDefault;
+
+ const fileToIdMap = new Map<string, string>();
+
+ return {
+ name: 'glob-loader',
+ load: async ({ config, logger, watcher, parseData, store, generateDigest, entryTypes }) => {
+ const renderFunctionByContentType = new WeakMap<
+ ContentEntryType,
+ ContentEntryRenderFunction
+ >();
+
+ const untouchedEntries = new Set(store.keys());
+ const isLegacy = (globOptions as any)._legacy;
+ // If global legacy collection handling flag is *not* enabled then this loader is used to emulate them instead
+ const emulateLegacyCollections = !config.legacy.collections;
+ async function syncData(
+ entry: string,
+ base: URL,
+ entryType?: ContentEntryType,
+ oldId?: string,
+ ) {
+ if (!entryType) {
+ logger.warn(`No entry type found for ${entry}`);
+ return;
+ }
+ const fileUrl = new URL(encodeURI(entry), base);
+ const contents = await fs.readFile(fileUrl, 'utf-8').catch((err) => {
+ logger.error(`Error reading ${entry}: ${err.message}`);
+ return;
+ });
+
+ if (!contents && contents !== '') {
+ logger.warn(`No contents found for ${entry}`);
+ return;
+ }
+
+ const { body, data } = await entryType.getEntryInfo({
+ contents,
+ fileUrl,
+ });
+
+ const id = generateId({ entry, base, data });
+
+ if (oldId && oldId !== id) {
+ store.delete(oldId);
+ }
+
+ let legacyId: string | undefined;
+
+ if (isLegacy) {
+ const entryURL = new URL(encodeURI(entry), base);
+ const legacyOptions = getContentEntryIdAndSlug({
+ entry: entryURL,
+ contentDir: base,
+ collection: '',
+ });
+ legacyId = legacyOptions.id;
+ }
+ untouchedEntries.delete(id);
+
+ const existingEntry = store.get(id);
+
+ const digest = generateDigest(contents);
+ const filePath = fileURLToPath(fileUrl);
+
+ if (existingEntry && existingEntry.digest === digest && existingEntry.filePath) {
+ if (existingEntry.deferredRender) {
+ store.addModuleImport(existingEntry.filePath);
+ }
+
+ if (existingEntry.assetImports?.length) {
+ // Add asset imports for existing entries
+ store.addAssetImports(existingEntry.assetImports, existingEntry.filePath);
+ }
+
+ fileToIdMap.set(filePath, id);
+ return;
+ }
+
+ const relativePath = posixRelative(fileURLToPath(config.root), filePath);
+
+ const parsedData = await parseData({
+ id,
+ data,
+ filePath,
+ });
+ if (entryType.getRenderFunction) {
+ if (isLegacy && data.layout) {
+ logger.error(
+ `The Markdown "layout" field is not supported in content collections in Astro 5. Ignoring layout for ${JSON.stringify(entry)}. Enable "legacy.collections" if you need to use the layout field.`,
+ );
+ }
+
+ let render = renderFunctionByContentType.get(entryType);
+ if (!render) {
+ render = await entryType.getRenderFunction(config);
+ // Cache the render function for this content type, so it can re-use parsers and other expensive setup
+ renderFunctionByContentType.set(entryType, render);
+ }
+ let rendered: RenderedContent | undefined = undefined;
+
+ try {
+ rendered = await render?.({
+ id,
+ data,
+ body,
+ filePath,
+ digest,
+ });
+ } catch (error: any) {
+ logger.error(`Error rendering ${entry}: ${error.message}`);
+ }
+
+ store.set({
+ id,
+ data: parsedData,
+ body,
+ filePath: relativePath,
+ digest,
+ rendered,
+ assetImports: rendered?.metadata?.imagePaths,
+ legacyId,
+ });
+
+ // todo: add an explicit way to opt in to deferred rendering
+ } else if ('contentModuleTypes' in entryType) {
+ store.set({
+ id,
+ data: parsedData,
+ body,
+ filePath: relativePath,
+ digest,
+ deferredRender: true,
+ legacyId,
+ });
+ } else {
+ store.set({ id, data: parsedData, body, filePath: relativePath, digest, legacyId });
+ }
+
+ fileToIdMap.set(filePath, id);
+ }
+
+ const baseDir = globOptions.base ? new URL(globOptions.base, config.root) : config.root;
+
+ if (!baseDir.pathname.endsWith('/')) {
+ baseDir.pathname = `${baseDir.pathname}/`;
+ }
+
+ const filePath = fileURLToPath(baseDir);
+ const relativePath = relative(fileURLToPath(config.root), filePath);
+
+ const exists = existsSync(baseDir);
+
+ if (!exists) {
+ // We warn and don't return because we will still set up the watcher in case the directory is created later
+ logger.warn(`The base directory "${fileURLToPath(baseDir)}" does not exist.`);
+ }
+
+ const files = await fastGlob(globOptions.pattern, {
+ cwd: fileURLToPath(baseDir),
+ });
+
+ if (exists && files.length === 0) {
+ logger.warn(
+ `No files found matching "${globOptions.pattern}" in directory "${relativePath}"`,
+ );
+ return;
+ }
+
+ function configForFile(file: string) {
+ const ext = file.split('.').at(-1);
+ if (!ext) {
+ logger.warn(`No extension found for ${file}`);
+ return;
+ }
+ return entryTypes.get(`.${ext}`);
+ }
+
+ const limit = pLimit(10);
+ const skippedFiles: Array<string> = [];
+
+ const contentDir = new URL('content/', config.srcDir);
+
+ function isInContentDir(file: string) {
+ const fileUrl = new URL(file, baseDir);
+ return fileUrl.href.startsWith(contentDir.href);
+ }
+
+ const configFiles = new Set(
+ ['config.js', 'config.ts', 'config.mjs'].map((file) => new URL(file, contentDir).href),
+ );
+
+ function isConfigFile(file: string) {
+ const fileUrl = new URL(file, baseDir);
+ return configFiles.has(fileUrl.href);
+ }
+
+ await Promise.all(
+ files.map((entry) => {
+ if (isConfigFile(entry)) {
+ return;
+ }
+ if (!emulateLegacyCollections && isInContentDir(entry)) {
+ skippedFiles.push(entry);
+ return;
+ }
+ return limit(async () => {
+ const entryType = configForFile(entry);
+ await syncData(entry, baseDir, entryType);
+ });
+ }),
+ );
+
+ const skipCount = skippedFiles.length;
+
+ if (skipCount > 0) {
+ const patternList = Array.isArray(globOptions.pattern)
+ ? globOptions.pattern.join(', ')
+ : globOptions.pattern;
+
+ logger.warn(
+ `The glob() loader cannot be used for files in ${bold('src/content')} when legacy mode is enabled.`,
+ );
+ if (skipCount > 10) {
+ logger.warn(
+ `Skipped ${green(skippedFiles.length)} files that matched ${green(patternList)}.`,
+ );
+ } else {
+ logger.warn(`Skipped the following files that matched ${green(patternList)}:`);
+ skippedFiles.forEach((file) => logger.warn(`• ${green(file)}`));
+ }
+ }
+
+ // Remove entries that were not found this time
+ untouchedEntries.forEach((id) => store.delete(id));
+
+ if (!watcher) {
+ return;
+ }
+
+ watcher.add(filePath);
+
+ const matchesGlob = (entry: string) =>
+ !entry.startsWith('../') && micromatch.isMatch(entry, globOptions.pattern);
+
+ const basePath = fileURLToPath(baseDir);
+
+ async function onChange(changedPath: string) {
+ const entry = posixRelative(basePath, changedPath);
+ if (!matchesGlob(entry)) {
+ return;
+ }
+ const entryType = configForFile(changedPath);
+ const baseUrl = pathToFileURL(basePath);
+ const oldId = fileToIdMap.get(changedPath);
+ await syncData(entry, baseUrl, entryType, oldId);
+ logger.info(`Reloaded data from ${green(entry)}`);
+ }
+
+ watcher.on('change', onChange);
+
+ watcher.on('add', onChange);
+
+ watcher.on('unlink', async (deletedPath) => {
+ const entry = posixRelative(basePath, deletedPath);
+ if (!matchesGlob(entry)) {
+ return;
+ }
+ const id = fileToIdMap.get(deletedPath);
+ if (id) {
+ store.delete(id);
+ fileToIdMap.delete(deletedPath);
+ }
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/content/loaders/index.ts b/packages/astro/src/content/loaders/index.ts
new file mode 100644
index 000000000..30b4bfbe5
--- /dev/null
+++ b/packages/astro/src/content/loaders/index.ts
@@ -0,0 +1,3 @@
+export { file } from './file.js';
+export { glob } from './glob.js';
+export * from './types.js';
diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts
new file mode 100644
index 000000000..4c2d8a359
--- /dev/null
+++ b/packages/astro/src/content/loaders/types.ts
@@ -0,0 +1,51 @@
+import type { FSWatcher } from 'vite';
+import type { ZodSchema } from 'zod';
+import type { AstroIntegrationLogger } from '../../core/logger/core.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { ContentEntryType } from '../../types/public/content.js';
+import type { DataStore, MetaStore } from '../mutable-data-store.js';
+
+export type { DataStore, MetaStore };
+
+export interface ParseDataOptions<TData extends Record<string, unknown>> {
+ /** The ID of the entry. Unique per collection */
+ id: string;
+ /** The raw, unvalidated data of the entry */
+ data: TData;
+ /** An optional file path, where the entry represents a local file. */
+ filePath?: string;
+}
+
+export interface LoaderContext {
+ /** The unique name of the collection */
+ collection: string;
+ /** A database to store the actual data */
+ store: DataStore;
+ /** A simple KV store, designed for things like sync tokens */
+ meta: MetaStore;
+ logger: AstroIntegrationLogger;
+ /** Astro config, with user config and merged defaults */
+ config: AstroConfig;
+ /** Validates and parses the data according to the collection schema */
+ parseData<TData extends Record<string, unknown>>(props: ParseDataOptions<TData>): Promise<TData>;
+
+ /** Generates a non-cryptographic content digest. This can be used to check if the data has changed */
+ generateDigest(data: Record<string, unknown> | string): string;
+
+ /** When running in dev, this is a filesystem watcher that can be used to trigger updates */
+ watcher?: FSWatcher;
+
+ /** If the loader has been triggered by an integration, this may optionally contain extra data set by that integration */
+ refreshContextData?: Record<string, unknown>;
+ /** @internal */
+ entryTypes: Map<string, ContentEntryType>;
+}
+
+export interface Loader {
+ /** Unique name of the loader, e.g. the npm package name */
+ name: string;
+ /** Do the actual loading of the data */
+ load: (context: LoaderContext) => Promise<void>;
+ /** Optionally, define the schema of the data. Will be overridden by user-defined schema */
+ schema?: ZodSchema | Promise<ZodSchema> | (() => ZodSchema | Promise<ZodSchema>);
+}
diff --git a/packages/astro/src/content/mutable-data-store.ts b/packages/astro/src/content/mutable-data-store.ts
new file mode 100644
index 000000000..75ae88e9a
--- /dev/null
+++ b/packages/astro/src/content/mutable-data-store.ts
@@ -0,0 +1,434 @@
+import { promises as fs, type PathLike, existsSync } from 'node:fs';
+import * as devalue from 'devalue';
+import { Traverse } from 'neotraverse/modern';
+import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { IMAGE_IMPORT_PREFIX } from './consts.js';
+import { type DataEntry, ImmutableDataStore } from './data-store.js';
+import { contentModuleToId } from './utils.js';
+
+const SAVE_DEBOUNCE_MS = 500;
+
+const MAX_DEPTH = 10;
+
+/**
+ * Extends the DataStore with the ability to change entries and write them to disk.
+ * This is kept as a separate class to avoid needing node builtins at runtime, when read-only access is all that is needed.
+ */
+export class MutableDataStore extends ImmutableDataStore {
+ #file?: PathLike;
+
+ #assetsFile?: PathLike;
+ #modulesFile?: PathLike;
+
+ #saveTimeout: NodeJS.Timeout | undefined;
+ #assetsSaveTimeout: NodeJS.Timeout | undefined;
+ #modulesSaveTimeout: NodeJS.Timeout | undefined;
+
+ #dirty = false;
+ #assetsDirty = false;
+ #modulesDirty = false;
+
+ #assetImports = new Set<string>();
+ #moduleImports = new Map<string, string>();
+
+ set(collectionName: string, key: string, value: unknown) {
+ const collection = this._collections.get(collectionName) ?? new Map();
+ collection.set(String(key), value);
+ this._collections.set(collectionName, collection);
+ this.#saveToDiskDebounced();
+ }
+
+ delete(collectionName: string, key: string) {
+ const collection = this._collections.get(collectionName);
+ if (collection) {
+ collection.delete(String(key));
+ this.#saveToDiskDebounced();
+ }
+ }
+
+ clear(collectionName: string) {
+ this._collections.delete(collectionName);
+ this.#saveToDiskDebounced();
+ }
+
+ clearAll() {
+ this._collections.clear();
+ this.#saveToDiskDebounced();
+ }
+
+ addAssetImport(assetImport: string, filePath?: string) {
+ const id = imageSrcToImportId(assetImport, filePath);
+ if (id) {
+ this.#assetImports.add(id);
+ // We debounce the writes to disk because addAssetImport is called for every image in every file,
+ // and can be called many times in quick succession by a filesystem watcher. We only want to write
+ // the file once, after all the imports have been added.
+ this.#writeAssetsImportsDebounced();
+ }
+ }
+
+ addAssetImports(assets: Array<string>, filePath?: string) {
+ assets.forEach((asset) => this.addAssetImport(asset, filePath));
+ }
+
+ addModuleImport(fileName: string) {
+ const id = contentModuleToId(fileName);
+ if (id) {
+ this.#moduleImports.set(fileName, id);
+ // We debounce the writes to disk because addAssetImport is called for every image in every file,
+ // and can be called many times in quick succession by a filesystem watcher. We only want to write
+ // the file once, after all the imports have been added.
+ this.#writeModulesImportsDebounced();
+ }
+ }
+
+ async writeAssetImports(filePath: PathLike) {
+ this.#assetsFile = filePath;
+
+ if (this.#assetImports.size === 0) {
+ try {
+ await this.#writeFileAtomic(filePath, 'export default new Map();');
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ if (!this.#assetsDirty && existsSync(filePath)) {
+ return;
+ }
+ // Import the assets, with a symbol name that is unique to the import id. The import
+ // for each asset is an object with path, format and dimensions.
+ // We then export them all, mapped by the import id, so we can find them again in the build.
+ const imports: Array<string> = [];
+ const exports: Array<string> = [];
+ this.#assetImports.forEach((id) => {
+ const symbol = importIdToSymbolName(id);
+ imports.push(`import ${symbol} from ${JSON.stringify(id)};`);
+ exports.push(`[${JSON.stringify(id)}, ${symbol}]`);
+ });
+ const code = /* js */ `
+${imports.join('\n')}
+export default new Map([${exports.join(', ')}]);
+ `;
+ try {
+ await this.#writeFileAtomic(filePath, code);
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ this.#assetsDirty = false;
+ }
+
+ async writeModuleImports(filePath: PathLike) {
+ this.#modulesFile = filePath;
+
+ if (this.#moduleImports.size === 0) {
+ try {
+ await this.#writeFileAtomic(filePath, 'export default new Map();');
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ if (!this.#modulesDirty && existsSync(filePath)) {
+ return;
+ }
+
+ // Import the assets, with a symbol name that is unique to the import id. The import
+ // for each asset is an object with path, format and dimensions.
+ // We then export them all, mapped by the import id, so we can find them again in the build.
+ const lines: Array<string> = [];
+ for (const [fileName, specifier] of this.#moduleImports) {
+ lines.push(`[${JSON.stringify(fileName)}, () => import(${JSON.stringify(specifier)})]`);
+ }
+ const code = `
+export default new Map([\n${lines.join(',\n')}]);
+ `;
+ try {
+ await this.#writeFileAtomic(filePath, code);
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ this.#modulesDirty = false;
+ }
+
+ #writeAssetsImportsDebounced() {
+ this.#assetsDirty = true;
+ if (this.#assetsFile) {
+ if (this.#assetsSaveTimeout) {
+ clearTimeout(this.#assetsSaveTimeout);
+ }
+ this.#assetsSaveTimeout = setTimeout(() => {
+ this.#assetsSaveTimeout = undefined;
+ this.writeAssetImports(this.#assetsFile!);
+ }, SAVE_DEBOUNCE_MS);
+ }
+ }
+
+ #writeModulesImportsDebounced() {
+ this.#modulesDirty = true;
+ if (this.#modulesFile) {
+ if (this.#modulesSaveTimeout) {
+ clearTimeout(this.#modulesSaveTimeout);
+ }
+ this.#modulesSaveTimeout = setTimeout(() => {
+ this.#modulesSaveTimeout = undefined;
+ this.writeModuleImports(this.#modulesFile!);
+ }, SAVE_DEBOUNCE_MS);
+ }
+ }
+
+ #saveToDiskDebounced() {
+ this.#dirty = true;
+ if (this.#saveTimeout) {
+ clearTimeout(this.#saveTimeout);
+ }
+ this.#saveTimeout = setTimeout(() => {
+ this.#saveTimeout = undefined;
+ if (this.#file) {
+ this.writeToDisk();
+ }
+ }, SAVE_DEBOUNCE_MS);
+ }
+
+ #writing = new Set<string>();
+ #pending = new Set<string>();
+
+ async #writeFileAtomic(filePath: PathLike, data: string, depth = 0) {
+ if (depth > MAX_DEPTH) {
+ // If we hit the max depth, we skip a write to prevent the stack from growing too large
+ // In theory this means we may miss the latest data, but in practice this will only happen when the file is being written to very frequently
+ // so it will be saved on the next write. This is unlikely to ever happen in practice, as the writes are debounced. It requires lots of writes to very large files.
+ return;
+ }
+ const fileKey = filePath.toString();
+ // If we are already writing this file, instead of writing now, flag it as pending and write it when we're done.
+ if (this.#writing.has(fileKey)) {
+ this.#pending.add(fileKey);
+ return;
+ }
+ // Prevent concurrent writes to this file by flagging it as being written
+ this.#writing.add(fileKey);
+
+ const tempFile = filePath instanceof URL ? new URL(`${filePath.href}.tmp`) : `${filePath}.tmp`;
+ try {
+ const oldData = await fs.readFile(filePath, 'utf-8').catch(() => '');
+ if (oldData === data) {
+ // If the data hasn't changed, we can skip the write
+ return;
+ }
+ // Write it to a temporary file first and then move it to prevent partial reads.
+ await fs.writeFile(tempFile, data);
+ await fs.rename(tempFile, filePath);
+ } finally {
+ // We're done writing. Unflag the file and check if there are any pending writes for this file.
+ this.#writing.delete(fileKey);
+ // If there are pending writes, we need to write again to ensure we flush the latest data.
+ if (this.#pending.has(fileKey)) {
+ this.#pending.delete(fileKey);
+ // Call ourself recursively to write the file again
+ await this.#writeFileAtomic(filePath, data, depth + 1);
+ }
+ }
+ }
+
+ scopedStore(collectionName: string): DataStore {
+ return {
+ get: <TData extends Record<string, unknown> = Record<string, unknown>>(key: string) =>
+ this.get<DataEntry<TData>>(collectionName, key),
+ entries: () => this.entries(collectionName),
+ values: () => this.values(collectionName),
+ keys: () => this.keys(collectionName),
+ set: ({
+ id: key,
+ data,
+ body,
+ filePath,
+ deferredRender,
+ digest,
+ rendered,
+ assetImports,
+ legacyId,
+ }) => {
+ if (!key) {
+ throw new Error(`ID must be a non-empty string`);
+ }
+ const id = String(key);
+ if (digest) {
+ const existing = this.get<DataEntry>(collectionName, id);
+ if (existing && existing.digest === digest) {
+ return false;
+ }
+ }
+ const foundAssets = new Set<string>(assetImports);
+ // Check for image imports in the data. These will have been prefixed during schema parsing
+ new Traverse(data).forEach((_, val) => {
+ if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) {
+ const src = val.replace(IMAGE_IMPORT_PREFIX, '');
+ foundAssets.add(src);
+ }
+ });
+
+ const entry: DataEntry = {
+ id,
+ data,
+ };
+ // We do it like this so we don't waste space stringifying
+ // the fields if they are not set
+ if (body) {
+ entry.body = body;
+ }
+ if (filePath) {
+ if (filePath.startsWith('/')) {
+ throw new Error(`File path must be relative to the site root. Got: ${filePath}`);
+ }
+ entry.filePath = filePath;
+ }
+
+ if (foundAssets.size) {
+ entry.assetImports = Array.from(foundAssets);
+ this.addAssetImports(entry.assetImports, filePath);
+ }
+
+ if (digest) {
+ entry.digest = digest;
+ }
+ if (rendered) {
+ entry.rendered = rendered;
+ }
+ if (legacyId) {
+ entry.legacyId = legacyId;
+ }
+ if (deferredRender) {
+ entry.deferredRender = deferredRender;
+ if (filePath) {
+ this.addModuleImport(filePath);
+ }
+ }
+ this.set(collectionName, id, entry);
+ return true;
+ },
+ delete: (key: string) => this.delete(collectionName, key),
+ clear: () => this.clear(collectionName),
+ has: (key: string) => this.has(collectionName, key),
+ addAssetImport: (assetImport: string, fileName: string) =>
+ this.addAssetImport(assetImport, fileName),
+ addAssetImports: (assets: Array<string>, fileName: string) =>
+ this.addAssetImports(assets, fileName),
+ addModuleImport: (fileName: string) => this.addModuleImport(fileName),
+ };
+ }
+ /**
+ * Returns a MetaStore for a given collection, or if no collection is provided, the default meta collection.
+ */
+ metaStore(collectionName = ':meta'): MetaStore {
+ const collectionKey = `meta:${collectionName}`;
+ return {
+ get: (key: string) => this.get(collectionKey, key),
+ set: (key: string, data: string) => this.set(collectionKey, key, data),
+ delete: (key: string) => this.delete(collectionKey, key),
+ has: (key: string) => this.has(collectionKey, key),
+ };
+ }
+
+ toString() {
+ return devalue.stringify(this._collections);
+ }
+
+ async writeToDisk() {
+ if (!this.#dirty) {
+ return;
+ }
+ if (!this.#file) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError);
+ }
+ try {
+ await this.#writeFileAtomic(this.#file, this.toString());
+ this.#dirty = false;
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ /**
+ * Attempts to load a MutableDataStore from the virtual module.
+ * This only works in Vite.
+ */
+ static async fromModule() {
+ try {
+ // @ts-expect-error - this is a virtual module
+ const data = await import('astro:data-layer-content');
+ const map = devalue.unflatten(data.default);
+ return MutableDataStore.fromMap(map);
+ } catch {}
+ return new MutableDataStore();
+ }
+
+ static async fromMap(data: Map<string, Map<string, any>>) {
+ const store = new MutableDataStore();
+ store._collections = data;
+ return store;
+ }
+
+ static async fromString(data: string) {
+ const map = devalue.parse(data);
+ return MutableDataStore.fromMap(map);
+ }
+
+ static async fromFile(filePath: string | URL) {
+ try {
+ if (existsSync(filePath)) {
+ const data = await fs.readFile(filePath, 'utf-8');
+ const store = await MutableDataStore.fromString(data);
+ store.#file = filePath;
+ return store;
+ } else {
+ await fs.mkdir(new URL('./', filePath), { recursive: true });
+ }
+ } catch {}
+ const store = new MutableDataStore();
+ store.#file = filePath;
+ return store;
+ }
+}
+
+// This is the scoped store for a single collection. It's a subset of the MutableDataStore API, and is the only public type.
+export interface DataStore {
+ get: <TData extends Record<string, unknown> = Record<string, unknown>>(
+ key: string,
+ ) => DataEntry<TData> | undefined;
+ entries: () => Array<[id: string, DataEntry]>;
+ set: <TData extends Record<string, unknown>>(opts: DataEntry<TData>) => boolean;
+ values: () => Array<DataEntry>;
+ keys: () => Array<string>;
+ delete: (key: string) => void;
+ clear: () => void;
+ has: (key: string) => boolean;
+ /**
+ * @internal Adds asset imports to the store. This is used to track image imports for the build. This API is subject to change.
+ */
+ addAssetImports: (assets: Array<string>, fileName: string) => void;
+ /**
+ * @internal Adds an asset import to the store. This is used to track image imports for the build. This API is subject to change.
+ */
+ addAssetImport: (assetImport: string, fileName: string) => void;
+ /**
+ * Adds a single asset to the store. This asset will be transformed
+ * by Vite, and the URL will be available in the final build.
+ * @param fileName
+ * @param specifier
+ * @returns
+ */
+ addModuleImport: (fileName: string) => void;
+}
+
+/**
+ * A key-value store for metadata strings. Useful for storing things like sync tokens.
+ */
+
+export interface MetaStore {
+ get: (key: string) => string | undefined;
+ set: (key: string, value: string) => void;
+ has: (key: string) => boolean;
+ delete: (key: string) => void;
+}
diff --git a/packages/astro/src/content/runtime-assets.ts b/packages/astro/src/content/runtime-assets.ts
new file mode 100644
index 000000000..74204e127
--- /dev/null
+++ b/packages/astro/src/content/runtime-assets.ts
@@ -0,0 +1,35 @@
+import type { PluginContext } from 'rollup';
+import { z } from 'zod';
+import type { ImageMetadata, OmitBrand } from '../assets/types.js';
+import { emitESMImage } from '../assets/utils/node/emitAsset.js';
+
+export function createImage(
+ pluginContext: PluginContext,
+ shouldEmitFile: boolean,
+ entryFilePath: string,
+ experimentalSvgEnabled: boolean,
+) {
+ return () => {
+ return z.string().transform(async (imagePath, ctx) => {
+ const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id;
+ const metadata = (await emitESMImage(
+ resolvedFilePath,
+ pluginContext.meta.watchMode,
+ experimentalSvgEnabled,
+ shouldEmitFile ? pluginContext.emitFile : undefined,
+ )) as OmitBrand<ImageMetadata>;
+
+ if (!metadata) {
+ ctx.addIssue({
+ code: 'custom',
+ message: `Image ${imagePath} does not exist. Is the path correct?`,
+ fatal: true,
+ });
+
+ return z.never();
+ }
+
+ return { ...metadata, ASTRO_ASSET: metadata.fsPath };
+ });
+ };
+}
diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts
new file mode 100644
index 000000000..019a4c8b1
--- /dev/null
+++ b/packages/astro/src/content/runtime.ts
@@ -0,0 +1,701 @@
+import type { MarkdownHeading } from '@astrojs/markdown-remark';
+import { Traverse } from 'neotraverse/modern';
+import pLimit from 'p-limit';
+import { ZodIssueCode, z } from 'zod';
+import type { GetImageResult, ImageMetadata } from '../assets/types.js';
+import { imageSrcToImportId } from '../assets/utils/resolveImports.js';
+import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js';
+import { prependForwardSlash } from '../core/path.js';
+import {
+ type AstroComponentFactory,
+ createComponent,
+ createHeadAndContent,
+ renderComponent,
+ renderScriptElement,
+ renderTemplate,
+ renderUniqueStylesheet,
+ render as serverRender,
+ unescapeHTML,
+} from '../runtime/server/index.js';
+import { CONTENT_LAYER_TYPE, IMAGE_IMPORT_PREFIX } from './consts.js';
+import { type DataEntry, type ImmutableDataStore, globalDataStore } from './data-store.js';
+import type { ContentLookupMap } from './utils.js';
+
+type LazyImport = () => Promise<any>;
+type GlobResult = Record<string, LazyImport>;
+type CollectionToEntryMap = Record<string, GlobResult>;
+type GetEntryImport = (collection: string, lookupId: string) => Promise<LazyImport>;
+
+export function getImporterFilename() {
+ // The 4th line in the stack trace should be the importer filename
+ const stackLine = new Error().stack?.split('\n')?.[3];
+ if (!stackLine) {
+ return null;
+ }
+ // Extract the relative path from the stack line
+ const match = /\/(src\/.*?):\d+:\d+/.exec(stackLine);
+ return match?.[1] ?? null;
+}
+
+export function defineCollection(config: any) {
+ if ('loader' in config) {
+ if (config.type && config.type !== CONTENT_LAYER_TYPE) {
+ throw new AstroUserError(
+ `Collections that use the Content Layer API must have a \`loader\` defined and no \`type\` set. Check your collection definitions in ${getImporterFilename() ?? 'your content config file'}.`,
+ );
+ }
+ config.type = CONTENT_LAYER_TYPE;
+ }
+ if (!config.type) config.type = 'content';
+ return config;
+}
+
+export function createCollectionToGlobResultMap({
+ globResult,
+ contentDir,
+}: {
+ globResult: GlobResult;
+ contentDir: string;
+}) {
+ const collectionToGlobResultMap: CollectionToEntryMap = {};
+ for (const key in globResult) {
+ const keyRelativeToContentDir = key.replace(new RegExp(`^${contentDir}`), '');
+ const segments = keyRelativeToContentDir.split('/');
+ if (segments.length <= 1) continue;
+ const collection = segments[0];
+ collectionToGlobResultMap[collection] ??= {};
+ collectionToGlobResultMap[collection][key] = globResult[key];
+ }
+ return collectionToGlobResultMap;
+}
+
+export function createGetCollection({
+ contentCollectionToEntryMap,
+ dataCollectionToEntryMap,
+ getRenderEntryImport,
+ cacheEntriesByCollection,
+}: {
+ contentCollectionToEntryMap: CollectionToEntryMap;
+ dataCollectionToEntryMap: CollectionToEntryMap;
+ getRenderEntryImport: GetEntryImport;
+ cacheEntriesByCollection: Map<string, any[]>;
+}) {
+ return async function getCollection(collection: string, filter?: (entry: any) => unknown) {
+ const hasFilter = typeof filter === 'function';
+ const store = await globalDataStore.get();
+ let type: 'content' | 'data';
+ if (collection in contentCollectionToEntryMap) {
+ type = 'content';
+ } else if (collection in dataCollectionToEntryMap) {
+ type = 'data';
+ } else if (store.hasCollection(collection)) {
+ // @ts-expect-error virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+
+ const result = [];
+ for (const rawEntry of store.values<DataEntry>(collection)) {
+ const data = updateImageReferencesInData(rawEntry.data, rawEntry.filePath, imageAssetMap);
+
+ let entry = {
+ ...rawEntry,
+ data,
+ collection,
+ };
+
+ if (entry.legacyId) {
+ entry = emulateLegacyEntry(entry);
+ }
+
+ if (hasFilter && !filter(entry)) {
+ continue;
+ }
+ result.push(entry);
+ }
+ return result;
+ } else {
+ console.warn(
+ `The collection ${JSON.stringify(
+ collection,
+ )} does not exist or is empty. Please check your content config file for errors.`,
+ );
+ return [];
+ }
+
+ const lazyImports = Object.values(
+ type === 'content'
+ ? contentCollectionToEntryMap[collection]
+ : dataCollectionToEntryMap[collection],
+ );
+ let entries: any[] = [];
+ // Cache `getCollection()` calls in production only
+ // prevents stale cache in development
+ if (!import.meta.env?.DEV && cacheEntriesByCollection.has(collection)) {
+ entries = cacheEntriesByCollection.get(collection)!;
+ } else {
+ const limit = pLimit(10);
+ entries = await Promise.all(
+ lazyImports.map((lazyImport) =>
+ limit(async () => {
+ const entry = await lazyImport();
+ return type === 'content'
+ ? {
+ id: entry.id,
+ slug: entry.slug,
+ body: entry.body,
+ collection: entry.collection,
+ data: entry.data,
+ async render() {
+ return render({
+ collection: entry.collection,
+ id: entry.id,
+ renderEntryImport: await getRenderEntryImport(collection, entry.slug),
+ });
+ },
+ }
+ : {
+ id: entry.id,
+ collection: entry.collection,
+ data: entry.data,
+ };
+ }),
+ ),
+ );
+ cacheEntriesByCollection.set(collection, entries);
+ }
+ if (hasFilter) {
+ return entries.filter(filter);
+ } else {
+ // Clone the array so users can safely mutate it.
+ // slice() is faster than ...spread for large arrays.
+ return entries.slice();
+ }
+ };
+}
+
+export function createGetEntryBySlug({
+ getEntryImport,
+ getRenderEntryImport,
+ collectionNames,
+ getEntry,
+}: {
+ getEntryImport: GetEntryImport;
+ getRenderEntryImport: GetEntryImport;
+ collectionNames: Set<string>;
+ getEntry: ReturnType<typeof createGetEntry>;
+}) {
+ return async function getEntryBySlug(collection: string, slug: string) {
+ const store = await globalDataStore.get();
+
+ if (!collectionNames.has(collection)) {
+ if (store.hasCollection(collection)) {
+ const entry = await getEntry(collection, slug);
+ if (entry && 'slug' in entry) {
+ return entry;
+ }
+ throw new AstroError({
+ ...AstroErrorData.GetEntryDeprecationError,
+ message: AstroErrorData.GetEntryDeprecationError.message(collection, 'getEntryBySlug'),
+ });
+ }
+ console.warn(
+ `The collection ${JSON.stringify(collection)} does not exist. Please ensure it is defined in your content config.`,
+ );
+ return undefined;
+ }
+
+ const entryImport = await getEntryImport(collection, slug);
+ if (typeof entryImport !== 'function') return undefined;
+
+ const entry = await entryImport();
+
+ return {
+ id: entry.id,
+ slug: entry.slug,
+ body: entry.body,
+ collection: entry.collection,
+ data: entry.data,
+ async render() {
+ return render({
+ collection: entry.collection,
+ id: entry.id,
+ renderEntryImport: await getRenderEntryImport(collection, slug),
+ });
+ },
+ };
+ };
+}
+
+export function createGetDataEntryById({
+ getEntryImport,
+ collectionNames,
+ getEntry,
+}: {
+ getEntryImport: GetEntryImport;
+ collectionNames: Set<string>;
+ getEntry: ReturnType<typeof createGetEntry>;
+}) {
+ return async function getDataEntryById(collection: string, id: string) {
+ const store = await globalDataStore.get();
+
+ if (!collectionNames.has(collection)) {
+ if (store.hasCollection(collection)) {
+ return getEntry(collection, id);
+ }
+ console.warn(
+ `The collection ${JSON.stringify(collection)} does not exist. Please ensure it is defined in your content config.`,
+ );
+ return undefined;
+ }
+
+ const lazyImport = await getEntryImport(collection, id);
+
+ if (!lazyImport) throw new Error(`Entry ${collection} → ${id} was not found.`);
+ const entry = await lazyImport();
+
+ return {
+ id: entry.id,
+ collection: entry.collection,
+ data: entry.data,
+ };
+ };
+}
+
+type ContentEntryResult = {
+ id: string;
+ slug: string;
+ body: string;
+ collection: string;
+ data: Record<string, any>;
+ render(): Promise<RenderResult>;
+};
+
+type DataEntryResult = {
+ id: string;
+ collection: string;
+ data: Record<string, any>;
+};
+
+type EntryLookupObject = { collection: string; id: string } | { collection: string; slug: string };
+
+function emulateLegacyEntry({ legacyId, ...entry }: DataEntry & { collection: string }) {
+ // Define this first so it's in scope for the render function
+ const legacyEntry = {
+ ...entry,
+ id: legacyId!,
+ slug: entry.id,
+ };
+ return {
+ ...legacyEntry,
+ // Define separately so the render function isn't included in the object passed to `renderEntry()`
+ render: () => renderEntry(legacyEntry),
+ } as ContentEntryResult;
+}
+
+export function createGetEntry({
+ getEntryImport,
+ getRenderEntryImport,
+ collectionNames,
+}: {
+ getEntryImport: GetEntryImport;
+ getRenderEntryImport: GetEntryImport;
+ collectionNames: Set<string>;
+}) {
+ return async function getEntry(
+ // Can either pass collection and identifier as 2 positional args,
+ // Or pass a single object with the collection and identifier as properties.
+ // This means the first positional arg can have different shapes.
+ collectionOrLookupObject: string | EntryLookupObject,
+ _lookupId?: string,
+ ): Promise<ContentEntryResult | DataEntryResult | undefined> {
+ let collection: string, lookupId: string;
+ if (typeof collectionOrLookupObject === 'string') {
+ collection = collectionOrLookupObject;
+ if (!_lookupId)
+ throw new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: '`getEntry()` requires an entry identifier as the second argument.',
+ });
+ lookupId = _lookupId;
+ } else {
+ collection = collectionOrLookupObject.collection;
+ // Identifier could be `slug` for content entries, or `id` for data entries
+ lookupId =
+ 'id' in collectionOrLookupObject
+ ? collectionOrLookupObject.id
+ : collectionOrLookupObject.slug;
+ }
+
+ const store = await globalDataStore.get();
+
+ if (store.hasCollection(collection)) {
+ const entry = store.get<DataEntry>(collection, lookupId);
+ if (!entry) {
+ console.warn(`Entry ${collection} → ${lookupId} was not found.`);
+ return;
+ }
+
+ // @ts-expect-error virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+ entry.data = updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap);
+ if (entry.legacyId) {
+ return emulateLegacyEntry({ ...entry, collection });
+ }
+ return {
+ ...entry,
+ collection,
+ } as DataEntryResult | ContentEntryResult;
+ }
+
+ if (!collectionNames.has(collection)) {
+ console.warn(
+ `The collection ${JSON.stringify(collection)} does not exist. Please ensure it is defined in your content config.`,
+ );
+ return undefined;
+ }
+
+ const entryImport = await getEntryImport(collection, lookupId);
+ if (typeof entryImport !== 'function') return undefined;
+
+ const entry = await entryImport();
+
+ if (entry._internal.type === 'content') {
+ return {
+ id: entry.id,
+ slug: entry.slug,
+ body: entry.body,
+ collection: entry.collection,
+ data: entry.data,
+ async render() {
+ return render({
+ collection: entry.collection,
+ id: entry.id,
+ renderEntryImport: await getRenderEntryImport(collection, lookupId),
+ });
+ },
+ };
+ } else if (entry._internal.type === 'data') {
+ return {
+ id: entry.id,
+ collection: entry.collection,
+ data: entry.data,
+ };
+ }
+ return undefined;
+ };
+}
+
+export function createGetEntries(getEntry: ReturnType<typeof createGetEntry>) {
+ return async function getEntries(
+ entries: { collection: string; id: string }[] | { collection: string; slug: string }[],
+ ) {
+ return Promise.all(entries.map((e) => getEntry(e)));
+ };
+}
+
+type RenderResult = {
+ Content: AstroComponentFactory;
+ headings: MarkdownHeading[];
+ remarkPluginFrontmatter: Record<string, any>;
+};
+
+const CONTENT_LAYER_IMAGE_REGEX = /__ASTRO_IMAGE_="([^"]+)"/g;
+
+async function updateImageReferencesInBody(html: string, fileName: string) {
+ // @ts-expect-error Virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+
+ const imageObjects = new Map<string, GetImageResult>();
+
+ // @ts-expect-error Virtual module resolved at runtime
+ const { getImage } = await import('astro:assets');
+
+ // First load all the images. This is done outside of the replaceAll
+ // function because getImage is async.
+ for (const [_full, imagePath] of html.matchAll(CONTENT_LAYER_IMAGE_REGEX)) {
+ try {
+ const decodedImagePath = JSON.parse(imagePath.replaceAll('&#x22;', '"'));
+ const id = imageSrcToImportId(decodedImagePath.src, fileName);
+
+ const imported = imageAssetMap.get(id);
+ if (!id || imageObjects.has(id) || !imported) {
+ continue;
+ }
+ const image: GetImageResult = await getImage({ ...decodedImagePath, src: imported });
+ imageObjects.set(imagePath, image);
+ } catch {
+ throw new Error(`Failed to parse image reference: ${imagePath}`);
+ }
+ }
+
+ return html.replaceAll(CONTENT_LAYER_IMAGE_REGEX, (full, imagePath) => {
+ const image = imageObjects.get(imagePath);
+
+ if (!image) {
+ return full;
+ }
+
+ const { index, ...attributes } = image.attributes;
+
+ return Object.entries({
+ ...attributes,
+ src: image.src,
+ srcset: image.srcSet.attribute,
+ })
+ .map(([key, value]) => (value ? `${key}=${JSON.stringify(String(value))}` : ''))
+ .join(' ');
+ });
+}
+
+function updateImageReferencesInData<T extends Record<string, unknown>>(
+ data: T,
+ fileName?: string,
+ imageAssetMap?: Map<string, ImageMetadata>,
+): T {
+ return new Traverse(data).map(function (ctx, val) {
+ if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) {
+ const src = val.replace(IMAGE_IMPORT_PREFIX, '');
+
+ const id = imageSrcToImportId(src, fileName);
+ if (!id) {
+ ctx.update(src);
+ return;
+ }
+ const imported = imageAssetMap?.get(id);
+ if (imported) {
+ ctx.update(imported);
+ } else {
+ ctx.update(src);
+ }
+ }
+ });
+}
+
+export async function renderEntry(
+ entry:
+ | DataEntry
+ | { render: () => Promise<{ Content: AstroComponentFactory }> }
+ | (DataEntry & { render: () => Promise<{ Content: AstroComponentFactory }> }),
+) {
+ if (!entry) {
+ throw new AstroError(AstroErrorData.RenderUndefinedEntryError);
+ }
+
+ if ('render' in entry && !('legacyId' in entry)) {
+ // This is an old content collection entry, so we use its render method
+ return entry.render();
+ }
+
+ if (entry.deferredRender) {
+ try {
+ // @ts-expect-error virtual module
+ const { default: contentModules } = await import('astro:content-module-imports');
+ const renderEntryImport = contentModules.get(entry.filePath);
+ return render({
+ collection: '',
+ id: entry.id,
+ renderEntryImport,
+ });
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ const html =
+ entry?.rendered?.metadata?.imagePaths?.length && entry.filePath
+ ? await updateImageReferencesInBody(entry.rendered.html, entry.filePath)
+ : entry?.rendered?.html;
+
+ const Content = createComponent(() => serverRender`${unescapeHTML(html)}`);
+ return {
+ Content,
+ headings: entry?.rendered?.metadata?.headings ?? [],
+ remarkPluginFrontmatter: entry?.rendered?.metadata?.frontmatter ?? {},
+ };
+}
+
+async function render({
+ collection,
+ id,
+ renderEntryImport,
+}: {
+ collection: string;
+ id: string;
+ renderEntryImport?: LazyImport;
+}): Promise<RenderResult> {
+ const UnexpectedRenderError = new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: `Unexpected error while rendering ${String(collection)} → ${String(id)}.`,
+ });
+
+ if (typeof renderEntryImport !== 'function') throw UnexpectedRenderError;
+
+ const baseMod = await renderEntryImport();
+ if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError;
+ const { default: defaultMod } = baseMod;
+
+ if (isPropagatedAssetsModule(defaultMod)) {
+ const { collectedStyles, collectedLinks, collectedScripts, getMod } = defaultMod;
+ if (typeof getMod !== 'function') throw UnexpectedRenderError;
+ const propagationMod = await getMod();
+ if (propagationMod == null || typeof propagationMod !== 'object') throw UnexpectedRenderError;
+
+ const Content = createComponent({
+ factory(result, baseProps, slots) {
+ let styles = '',
+ links = '',
+ scripts = '';
+ if (Array.isArray(collectedStyles)) {
+ styles = collectedStyles
+ .map((style: any) => {
+ return renderUniqueStylesheet(result, {
+ type: 'inline',
+ content: style,
+ });
+ })
+ .join('');
+ }
+ if (Array.isArray(collectedLinks)) {
+ links = collectedLinks
+ .map((link: any) => {
+ return renderUniqueStylesheet(result, {
+ type: 'external',
+ src: prependForwardSlash(link),
+ });
+ })
+ .join('');
+ }
+ if (Array.isArray(collectedScripts)) {
+ scripts = collectedScripts.map((script: any) => renderScriptElement(script)).join('');
+ }
+
+ let props = baseProps;
+ // Auto-apply MDX components export
+ if (id.endsWith('mdx')) {
+ props = {
+ components: propagationMod.components ?? {},
+ ...baseProps,
+ };
+ }
+
+ return createHeadAndContent(
+ unescapeHTML(styles + links + scripts) as any,
+ renderTemplate`${renderComponent(
+ result,
+ 'Content',
+ propagationMod.Content,
+ props,
+ slots,
+ )}`,
+ );
+ },
+ propagation: 'self',
+ });
+
+ return {
+ Content,
+ headings: propagationMod.getHeadings?.() ?? [],
+ remarkPluginFrontmatter: propagationMod.frontmatter ?? {},
+ };
+ } else if (baseMod.Content && typeof baseMod.Content === 'function') {
+ return {
+ Content: baseMod.Content,
+ headings: baseMod.getHeadings?.() ?? [],
+ remarkPluginFrontmatter: baseMod.frontmatter ?? {},
+ };
+ } else {
+ throw UnexpectedRenderError;
+ }
+}
+
+export function createReference({ lookupMap }: { lookupMap: ContentLookupMap }) {
+ // We're handling it like this to avoid needing an async schema. Not ideal, but should
+ // be safe because the store will already have been loaded by the time this is called.
+ let store: ImmutableDataStore | null = null;
+ globalDataStore.get().then((s) => (store = s));
+ return function reference(collection: string) {
+ return z
+ .union([
+ z.string(),
+ z.object({
+ id: z.string(),
+ collection: z.string(),
+ }),
+ z.object({
+ slug: z.string(),
+ collection: z.string(),
+ }),
+ ])
+ .transform(
+ (
+ lookup:
+ | string
+ | { id: string; collection: string }
+ | { slug: string; collection: string },
+ ctx,
+ ) => {
+ if (!store) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${ctx.path.join('.')}:** Reference to ${collection} could not be resolved: store not available.\nThis is an Astro bug, so please file an issue at https://github.com/withastro/astro/issues.`,
+ });
+ return;
+ }
+
+ const flattenedErrorPath = ctx.path.join('.');
+
+ if (typeof lookup === 'object') {
+ // If these don't match then something is wrong with the reference
+ if (lookup.collection !== collection) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${collection}. Received ${lookup.collection}.`,
+ });
+ return;
+ }
+ // We won't throw if the collection is missing, because it may be a content layer collection and the store may not yet be populated.
+ // If it is an object then we're validating later in the build, so we can check the collection at that point.
+
+ return lookup;
+ }
+
+ // If the collection is not in the lookup map it may be a content layer collection and the store may not yet be populated.
+ if (!lookupMap[collection]) {
+ // For now, we can't validate this reference, so we'll optimistically convert it to a reference object which we'll validate
+ // later in the pipeline when we do have access to the store.
+ return { id: lookup, collection };
+ }
+ const { type, entries } = lookupMap[collection];
+ const entry = entries[lookup];
+
+ if (!entry) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${Object.keys(
+ entries,
+ )
+ .map((c) => JSON.stringify(c))
+ .join(' | ')}. Received ${JSON.stringify(lookup)}.`,
+ });
+ return;
+ }
+ // Content is still identified by slugs, so map to a `slug` key for consistency.
+ if (type === 'content') {
+ return { slug: lookup, collection };
+ }
+ return { id: lookup, collection };
+ },
+ );
+ };
+}
+
+type PropagatedAssetsModule = {
+ __astroPropagation: true;
+ getMod: () => Promise<any>;
+ collectedStyles: string[];
+ collectedLinks: string[];
+ collectedScripts: string[];
+};
+
+function isPropagatedAssetsModule(module: any): module is PropagatedAssetsModule {
+ return typeof module === 'object' && module != null && '__astroPropagation' in module;
+}
diff --git a/packages/astro/src/content/server-listeners.ts b/packages/astro/src/content/server-listeners.ts
new file mode 100644
index 000000000..492a5a516
--- /dev/null
+++ b/packages/astro/src/content/server-listeners.ts
@@ -0,0 +1,123 @@
+import type fsMod from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { bold, cyan, underline } from 'kleur/colors';
+import type { ViteDevServer } from 'vite';
+import { loadTSConfig } from '../core/config/tsconfig.js';
+import type { Logger } from '../core/logger/core.js';
+import { appendForwardSlash } from '../core/path.js';
+import type { AstroSettings } from '../types/astro.js';
+import { createContentTypesGenerator } from './types-generator.js';
+import { type ContentPaths, getContentPaths, globalContentConfigObserver } from './utils.js';
+
+interface ContentServerListenerParams {
+ fs: typeof fsMod;
+ logger: Logger;
+ settings: AstroSettings;
+ viteServer: ViteDevServer;
+}
+
+export async function attachContentServerListeners({
+ viteServer,
+ fs,
+ logger,
+ settings,
+}: ContentServerListenerParams) {
+ const contentPaths = getContentPaths(settings.config, fs);
+ if (!settings.config.legacy?.collections) {
+ await attachListeners();
+ } else if (fs.existsSync(contentPaths.contentDir)) {
+ logger.debug(
+ 'content',
+ `Watching ${cyan(
+ contentPaths.contentDir.href.replace(settings.config.root.href, ''),
+ )} for changes`,
+ );
+ const maybeTsConfigStats = await getTSConfigStatsWhenAllowJsFalse({ contentPaths, settings });
+ if (maybeTsConfigStats) warnAllowJsIsFalse({ ...maybeTsConfigStats, logger });
+ await attachListeners();
+ } else {
+ viteServer.watcher.on('addDir', contentDirListener);
+ async function contentDirListener(dir: string) {
+ if (appendForwardSlash(pathToFileURL(dir).href) === contentPaths.contentDir.href) {
+ logger.debug('content', `Content directory found. Watching for changes`);
+ await attachListeners();
+ viteServer.watcher.removeListener('addDir', contentDirListener);
+ }
+ }
+ }
+
+ async function attachListeners() {
+ const contentGenerator = await createContentTypesGenerator({
+ fs,
+ settings,
+ logger,
+ viteServer,
+ contentConfigObserver: globalContentConfigObserver,
+ });
+ await contentGenerator.init();
+ logger.debug('content', 'Types generated');
+
+ viteServer.watcher.on('add', (entry) => {
+ contentGenerator.queueEvent({ name: 'add', entry });
+ });
+ viteServer.watcher.on('addDir', (entry) =>
+ contentGenerator.queueEvent({ name: 'addDir', entry }),
+ );
+ viteServer.watcher.on('change', (entry) => {
+ contentGenerator.queueEvent({ name: 'change', entry });
+ });
+ viteServer.watcher.on('unlink', (entry) => {
+ contentGenerator.queueEvent({ name: 'unlink', entry });
+ });
+ viteServer.watcher.on('unlinkDir', (entry) =>
+ contentGenerator.queueEvent({ name: 'unlinkDir', entry }),
+ );
+ }
+}
+
+function warnAllowJsIsFalse({
+ logger,
+ tsConfigFileName,
+ contentConfigFileName,
+}: {
+ logger: Logger;
+ tsConfigFileName: string;
+ contentConfigFileName: string;
+}) {
+ logger.warn(
+ 'content',
+ `Make sure you have the ${bold('allowJs')} compiler option set to ${bold(
+ 'true',
+ )} in your ${bold(tsConfigFileName)} file to have autocompletion in your ${bold(
+ contentConfigFileName,
+ )} file. See ${underline(
+ cyan('https://www.typescriptlang.org/tsconfig#allowJs'),
+ )} for more information.`,
+ );
+}
+
+async function getTSConfigStatsWhenAllowJsFalse({
+ contentPaths,
+ settings,
+}: {
+ contentPaths: ContentPaths;
+ settings: AstroSettings;
+}) {
+ const isContentConfigJsFile = ['.js', '.mjs'].some((ext) =>
+ contentPaths.config.url.pathname.endsWith(ext),
+ );
+ if (!isContentConfigJsFile) return;
+
+ const inputConfig = await loadTSConfig(fileURLToPath(settings.config.root));
+ if (typeof inputConfig === 'string') return;
+
+ const tsConfigFileName = inputConfig.tsconfigFile.split(path.sep).pop();
+ if (!tsConfigFileName) return;
+
+ const contentConfigFileName = contentPaths.config.url.pathname.split(path.sep).pop()!;
+ const allowJSOption = inputConfig.tsconfig.compilerOptions?.allowJs;
+ if (allowJSOption) return;
+
+ return { tsConfigFileName, contentConfigFileName };
+}
diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts
new file mode 100644
index 000000000..92a83367c
--- /dev/null
+++ b/packages/astro/src/content/types-generator.ts
@@ -0,0 +1,657 @@
+import type fsMod from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import glob from 'fast-glob';
+import { bold, cyan } from 'kleur/colors';
+import { type ViteDevServer, normalizePath } from 'vite';
+import { type ZodSchema, z } from 'zod';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+import { AstroError } from '../core/errors/errors.js';
+import { AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import { isRelativePath } from '../core/path.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { ContentEntryType } from '../types/public/content.js';
+import {
+ COLLECTIONS_DIR,
+ CONTENT_LAYER_TYPE,
+ CONTENT_TYPES_FILE,
+ VIRTUAL_MODULE_ID,
+} from './consts.js';
+import {
+ type CollectionConfig,
+ type ContentConfig,
+ type ContentObservable,
+ type ContentPaths,
+ getContentEntryIdAndSlug,
+ getContentPaths,
+ getDataEntryExts,
+ getDataEntryId,
+ getEntryCollectionName,
+ getEntryConfigByExtMap,
+ getEntrySlug,
+ getEntryType,
+ reloadContentConfigObserver,
+} from './utils.js';
+
+type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
+type RawContentEvent = { name: ChokidarEvent; entry: string };
+type ContentEvent = { name: ChokidarEvent; entry: URL };
+
+type DataEntryMetadata = Record<string, never>;
+type ContentEntryMetadata = { slug: string };
+type CollectionEntryMap = {
+ [collection: string]:
+ | {
+ type: 'unknown';
+ entries: Record<string, never>;
+ }
+ | {
+ type: 'content';
+ entries: Record<string, ContentEntryMetadata>;
+ }
+ | {
+ type: 'data' | typeof CONTENT_LAYER_TYPE;
+ entries: Record<string, DataEntryMetadata>;
+ };
+};
+
+type CreateContentGeneratorParams = {
+ contentConfigObserver: ContentObservable;
+ logger: Logger;
+ settings: AstroSettings;
+ /** This is required for loading the content config */
+ viteServer: ViteDevServer;
+ fs: typeof fsMod;
+};
+
+export async function createContentTypesGenerator({
+ contentConfigObserver,
+ fs,
+ logger,
+ settings,
+ viteServer,
+}: CreateContentGeneratorParams) {
+ const collectionEntryMap: CollectionEntryMap = {};
+ const contentPaths = getContentPaths(settings.config, fs);
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const contentEntryExts = [...contentEntryConfigByExt.keys()];
+ const dataEntryExts = getDataEntryExts(settings);
+
+ let events: ContentEvent[] = [];
+ let debounceTimeout: NodeJS.Timeout | undefined;
+
+ const typeTemplateContent = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8');
+
+ async function init(): Promise<
+ { typesGenerated: true } | { typesGenerated: false; reason: 'no-content-dir' }
+ > {
+ events.push({ name: 'add', entry: contentPaths.config.url });
+
+ if (settings.config.legacy.collections) {
+ if (!fs.existsSync(contentPaths.contentDir)) {
+ return { typesGenerated: false, reason: 'no-content-dir' };
+ }
+ const globResult = await glob('**', {
+ cwd: fileURLToPath(contentPaths.contentDir),
+ fs: {
+ readdir: fs.readdir.bind(fs),
+ readdirSync: fs.readdirSync.bind(fs),
+ },
+ onlyFiles: false,
+ objectMode: true,
+ });
+
+ for (const entry of globResult) {
+ const fullPath = path.join(fileURLToPath(contentPaths.contentDir), entry.path);
+ const entryURL = pathToFileURL(fullPath);
+ if (entryURL.href.startsWith(contentPaths.config.url.href)) continue;
+ if (entry.dirent.isFile()) {
+ events.push({ name: 'add', entry: entryURL });
+ } else if (entry.dirent.isDirectory()) {
+ events.push({ name: 'addDir', entry: entryURL });
+ }
+ }
+ }
+ await runEvents();
+ return { typesGenerated: true };
+ }
+
+ async function handleEvent(event: ContentEvent): Promise<{ shouldGenerateTypes: boolean }> {
+ if (event.name === 'addDir' || event.name === 'unlinkDir') {
+ const collection = normalizePath(
+ path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry)),
+ );
+ const collectionKey = JSON.stringify(collection);
+ // If directory is multiple levels deep, it is not a collection. Ignore event.
+ const isCollectionEvent = collection.split('/').length === 1;
+ if (!isCollectionEvent) return { shouldGenerateTypes: false };
+
+ switch (event.name) {
+ case 'addDir':
+ collectionEntryMap[collectionKey] = {
+ type: 'unknown',
+ entries: {},
+ };
+ logger.debug('content', `${cyan(collection)} collection added`);
+ break;
+ case 'unlinkDir':
+ delete collectionEntryMap[collectionKey];
+ break;
+ }
+ return { shouldGenerateTypes: true };
+ }
+ const fileType = getEntryType(
+ fileURLToPath(event.entry),
+ contentPaths,
+ contentEntryExts,
+ dataEntryExts,
+ );
+ if (fileType === 'ignored') {
+ return { shouldGenerateTypes: false };
+ }
+ if (fileType === 'config') {
+ await reloadContentConfigObserver({ fs, settings, viteServer });
+ return { shouldGenerateTypes: true };
+ }
+
+ const { entry } = event;
+ const { contentDir } = contentPaths;
+
+ const collection = getEntryCollectionName({ entry, contentDir });
+ if (collection === undefined) {
+ logger.warn(
+ 'content',
+ `${bold(
+ normalizePath(
+ path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry)),
+ ),
+ )} must live in a ${bold('content/...')} collection subdirectory.`,
+ );
+ return { shouldGenerateTypes: false };
+ }
+
+ if (fileType === 'data') {
+ const id = getDataEntryId({ entry, contentDir, collection });
+ const collectionKey = JSON.stringify(collection);
+ const entryKey = JSON.stringify(id);
+
+ switch (event.name) {
+ case 'add':
+ if (!(collectionKey in collectionEntryMap)) {
+ collectionEntryMap[collectionKey] = { type: 'data', entries: {} };
+ }
+ const collectionInfo = collectionEntryMap[collectionKey];
+ if (collectionInfo.type === 'content') {
+ viteServer.hot.send({
+ type: 'error',
+ err: new AstroError({
+ ...AstroErrorData.MixedContentDataCollectionError,
+ message: AstroErrorData.MixedContentDataCollectionError.message(collectionKey),
+ location: { file: entry.pathname },
+ }) as any,
+ });
+ return { shouldGenerateTypes: false };
+ }
+ if (!(entryKey in collectionEntryMap[collectionKey])) {
+ collectionEntryMap[collectionKey] = {
+ type: 'data',
+ entries: { ...collectionInfo.entries, [entryKey]: {} },
+ };
+ }
+ return { shouldGenerateTypes: true };
+ case 'unlink':
+ if (
+ collectionKey in collectionEntryMap &&
+ entryKey in collectionEntryMap[collectionKey].entries
+ ) {
+ delete collectionEntryMap[collectionKey].entries[entryKey];
+ }
+ return { shouldGenerateTypes: true };
+ case 'change':
+ return { shouldGenerateTypes: false };
+ }
+ }
+
+ const contentEntryType = contentEntryConfigByExt.get(path.extname(event.entry.pathname));
+ if (!contentEntryType) return { shouldGenerateTypes: false };
+ const { id, slug: generatedSlug } = getContentEntryIdAndSlug({
+ entry,
+ contentDir,
+ collection,
+ });
+
+ const collectionKey = JSON.stringify(collection);
+ if (!(collectionKey in collectionEntryMap)) {
+ collectionEntryMap[collectionKey] = { type: 'content', entries: {} };
+ }
+ const collectionInfo = collectionEntryMap[collectionKey];
+ if (collectionInfo.type === 'data') {
+ viteServer.hot.send({
+ type: 'error',
+ err: new AstroError({
+ ...AstroErrorData.MixedContentDataCollectionError,
+ message: AstroErrorData.MixedContentDataCollectionError.message(collectionKey),
+ location: { file: entry.pathname },
+ }) as any,
+ });
+ return { shouldGenerateTypes: false };
+ }
+ const entryKey = JSON.stringify(id);
+
+ switch (event.name) {
+ case 'add':
+ const addedSlug = await getEntrySlug({
+ generatedSlug,
+ id,
+ collection,
+ fileUrl: event.entry,
+ contentEntryType,
+ fs,
+ });
+ if (!(entryKey in collectionEntryMap[collectionKey].entries)) {
+ collectionEntryMap[collectionKey] = {
+ type: 'content',
+ entries: {
+ ...(collectionInfo.entries as Record<string, ContentEntryMetadata>),
+ [entryKey]: { slug: addedSlug },
+ },
+ };
+ }
+ return { shouldGenerateTypes: true };
+ case 'unlink':
+ if (
+ collectionKey in collectionEntryMap &&
+ entryKey in collectionEntryMap[collectionKey].entries
+ ) {
+ delete collectionEntryMap[collectionKey].entries[entryKey];
+ }
+ return { shouldGenerateTypes: true };
+ case 'change':
+ // User may modify `slug` in their frontmatter.
+ // Only regen types if this change is detected.
+ const changedSlug = await getEntrySlug({
+ generatedSlug,
+ id,
+ collection,
+ fileUrl: event.entry,
+ contentEntryType,
+ fs,
+ });
+ const entryMetadata = collectionInfo.entries[entryKey];
+ if (entryMetadata?.slug !== changedSlug) {
+ collectionInfo.entries[entryKey].slug = changedSlug;
+ return { shouldGenerateTypes: true };
+ }
+ return { shouldGenerateTypes: false };
+ }
+ }
+
+ function queueEvent(rawEvent: RawContentEvent) {
+ const event = {
+ entry: pathToFileURL(rawEvent.entry),
+ name: rawEvent.name,
+ };
+
+ if (settings.config.legacy.collections) {
+ if (!event.entry.pathname.startsWith(contentPaths.contentDir.pathname)) {
+ return;
+ }
+ } else if (contentPaths.config.url.pathname !== event.entry.pathname) {
+ return;
+ }
+
+ events.push(event);
+
+ debounceTimeout && clearTimeout(debounceTimeout);
+ const runEventsSafe = async () => {
+ try {
+ await runEvents();
+ } catch {
+ // Prevent frontmatter errors from crashing the server. The errors
+ // are still reported on page reflects as desired.
+ // Errors still crash dev from *starting*.
+ }
+ };
+ debounceTimeout = setTimeout(runEventsSafe, 50 /* debounce to batch chokidar events */);
+ }
+
+ async function runEvents() {
+ const eventResponses = [];
+
+ for (const event of events) {
+ const response = await handleEvent(event);
+ eventResponses.push(response);
+ }
+
+ events = [];
+ const observable = contentConfigObserver.get();
+ if (eventResponses.some((r) => r.shouldGenerateTypes)) {
+ await writeContentFiles({
+ fs,
+ collectionEntryMap,
+ contentPaths,
+ typeTemplateContent,
+ contentConfig: observable.status === 'loaded' ? observable.config : undefined,
+ contentEntryTypes: settings.contentEntryTypes,
+ viteServer,
+ logger,
+ settings,
+ });
+ invalidateVirtualMod(viteServer);
+ }
+ }
+ return { init, queueEvent };
+}
+
+// The virtual module contains a lookup map from slugs to content imports.
+// Invalidate whenever content types change.
+function invalidateVirtualMod(viteServer: ViteDevServer) {
+ const virtualMod = viteServer.moduleGraph.getModuleById('\0' + VIRTUAL_MODULE_ID);
+ if (!virtualMod) return;
+
+ viteServer.moduleGraph.invalidateModule(virtualMod);
+}
+
+/**
+ * Takes the source (`from`) and destination (`to`) of a config path and
+ * returns a normalized relative version:
+ * - If is not relative, it adds `./` to the beginning.
+ * - If it ends with `.ts`, it replaces it with `.js`.
+ * - It adds `""` around the string.
+ * @param from Config path source.
+ * @param to Config path destination.
+ * @returns Normalized config path.
+ */
+function normalizeConfigPath(from: string, to: string) {
+ const configPath = path.relative(from, to).replace(/\.ts$/, '.js');
+ // on windows `path.relative` will use backslashes, these must be replaced with forward slashes
+ const normalizedPath = configPath.replaceAll('\\', '/');
+
+ return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const;
+}
+
+const schemaCache = new Map<string, ZodSchema>();
+
+async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
+ collection: ContentConfig['collections'][T],
+ collectionKey: T,
+): Promise<ZodSchema | undefined> {
+ const cached = schemaCache.get(collectionKey);
+ if (cached) {
+ return cached;
+ }
+
+ if (
+ collection?.type === CONTENT_LAYER_TYPE &&
+ typeof collection.loader === 'object' &&
+ collection.loader.schema
+ ) {
+ let schema = collection.loader.schema;
+ if (typeof schema === 'function') {
+ schema = await schema();
+ }
+ if (schema) {
+ schemaCache.set(collectionKey, await schema);
+ return schema;
+ }
+ }
+}
+
+async function typeForCollection<T extends keyof ContentConfig['collections']>(
+ collection: ContentConfig['collections'][T] | undefined,
+ collectionKey: T,
+): Promise<string> {
+ if (collection?.schema) {
+ return `InferEntrySchema<${collectionKey}>`;
+ }
+
+ if (collection?.type === CONTENT_LAYER_TYPE) {
+ const schema = await getContentLayerSchema(collection, collectionKey);
+ if (schema) {
+ try {
+ const zodToTs = await import('zod-to-ts');
+ const ast = zodToTs.zodToTs(schema);
+ return zodToTs.printNode(ast.node);
+ } catch (err: any) {
+ // zod-to-ts is sad if we don't have TypeScript installed, but that's fine as we won't be needing types in that case
+ if (err.message.includes("Cannot find package 'typescript'")) {
+ return 'any';
+ }
+ throw err;
+ }
+ }
+ }
+ return 'any';
+}
+
+async function writeContentFiles({
+ fs,
+ contentPaths,
+ collectionEntryMap,
+ typeTemplateContent,
+ contentEntryTypes,
+ contentConfig,
+ viteServer,
+ logger,
+ settings,
+}: {
+ fs: typeof fsMod;
+ contentPaths: ContentPaths;
+ collectionEntryMap: CollectionEntryMap;
+ typeTemplateContent: string;
+ contentEntryTypes: Pick<ContentEntryType, 'contentModuleTypes'>[];
+ contentConfig?: ContentConfig;
+ viteServer: Pick<ViteDevServer, 'hot'>;
+ logger: Logger;
+ settings: AstroSettings;
+}) {
+ let contentTypesStr = '';
+ let dataTypesStr = '';
+
+ const collectionSchemasDir = new URL(COLLECTIONS_DIR, settings.dotAstroDir);
+ fs.mkdirSync(collectionSchemasDir, { recursive: true });
+
+ for (const [collection, config] of Object.entries(contentConfig?.collections ?? {})) {
+ collectionEntryMap[JSON.stringify(collection)] ??= {
+ type: config.type,
+ entries: {},
+ };
+ }
+
+ let contentCollectionsMap: CollectionEntryMap = {};
+ for (const collectionKey of Object.keys(collectionEntryMap).sort()) {
+ const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)];
+ const collection = collectionEntryMap[collectionKey];
+ if (
+ collectionConfig?.type &&
+ collection.type !== 'unknown' &&
+ collectionConfig.type !== CONTENT_LAYER_TYPE &&
+ collection.type !== collectionConfig.type
+ ) {
+ viteServer.hot.send({
+ type: 'error',
+ err: new AstroError({
+ ...AstroErrorData.ContentCollectionTypeMismatchError,
+ message: AstroErrorData.ContentCollectionTypeMismatchError.message(
+ collectionKey,
+ collection.type,
+ collectionConfig.type,
+ ),
+ hint:
+ collection.type === 'data'
+ ? "Try adding `type: 'data'` to your collection config."
+ : undefined,
+ location: {
+ file: '' /** required for error overlay `hot` messages */,
+ },
+ }) as any,
+ });
+ return;
+ }
+ const resolvedType =
+ collection.type === 'unknown'
+ ? // Add empty / unknown collections to the data type map by default
+ // This ensures `getCollection('empty-collection')` doesn't raise a type error
+ (collectionConfig?.type ?? 'data')
+ : collection.type;
+ const collectionEntryKeys = Object.keys(collection.entries).sort();
+ const dataType = await typeForCollection(collectionConfig, collectionKey);
+ switch (resolvedType) {
+ case 'content':
+ if (collectionEntryKeys.length === 0) {
+ contentTypesStr += `${collectionKey}: Record<string, {\n id: string;\n slug: string;\n body: string;\n collection: ${collectionKey};\n data: ${dataType};\n render(): Render[".md"];\n}>;\n`;
+ break;
+ }
+ contentTypesStr += `${collectionKey}: {\n`;
+ for (const entryKey of collectionEntryKeys) {
+ const entryMetadata = collection.entries[entryKey];
+ const renderType = `{ render(): Render[${JSON.stringify(
+ path.extname(JSON.parse(entryKey)),
+ )}] }`;
+
+ const slugType = JSON.stringify(entryMetadata.slug);
+ contentTypesStr += `${entryKey}: {\n id: ${entryKey};\n slug: ${slugType};\n body: string;\n collection: ${collectionKey};\n data: ${dataType}\n} & ${renderType};\n`;
+ }
+ contentTypesStr += `};\n`;
+ break;
+ case CONTENT_LAYER_TYPE:
+ const legacyTypes = (collectionConfig as any)?._legacy
+ ? 'render(): Render[".md"];\n slug: string;\n body: string;\n'
+ : 'body?: string;\n';
+ dataTypesStr += `${collectionKey}: Record<string, {\n id: string;\n ${legacyTypes} collection: ${collectionKey};\n data: ${dataType};\n rendered?: RenderedContent;\n filePath?: string;\n}>;\n`;
+ break;
+ case 'data':
+ if (collectionEntryKeys.length === 0) {
+ dataTypesStr += `${collectionKey}: Record<string, {\n id: string;\n collection: ${collectionKey};\n data: ${dataType};\n}>;\n`;
+ } else {
+ dataTypesStr += `${collectionKey}: {\n`;
+ for (const entryKey of collectionEntryKeys) {
+ dataTypesStr += `${entryKey}: {\n id: ${entryKey};\n collection: ${collectionKey};\n data: ${dataType}\n};\n`;
+ }
+ dataTypesStr += `};\n`;
+ }
+
+ break;
+ }
+
+ if (
+ collectionConfig &&
+ (collectionConfig.schema || (await getContentLayerSchema(collectionConfig, collectionKey)))
+ ) {
+ await generateJSONSchema(fs, collectionConfig, collectionKey, collectionSchemasDir, logger);
+
+ contentCollectionsMap[collectionKey] = collection;
+ }
+ }
+
+ if (settings.config.experimental.contentIntellisense) {
+ let contentCollectionManifest: {
+ collections: { hasSchema: boolean; name: string }[];
+ entries: Record<string, string>;
+ } = {
+ collections: [],
+ entries: {},
+ };
+ Object.entries(contentCollectionsMap).forEach(([collectionKey, collection]) => {
+ const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)];
+ const key = JSON.parse(collectionKey);
+
+ contentCollectionManifest.collections.push({
+ hasSchema: Boolean(collectionConfig?.schema || schemaCache.has(collectionKey)),
+ name: key,
+ });
+
+ Object.keys(collection.entries).forEach((entryKey) => {
+ const entryPath = new URL(
+ JSON.parse(entryKey),
+ contentPaths.contentDir + `${key}/`,
+ ).toString();
+
+ // Save entry path in lower case to avoid case sensitivity issues between Windows and Unix
+ contentCollectionManifest.entries[entryPath.toLowerCase()] = key;
+ });
+ });
+
+ await fs.promises.writeFile(
+ new URL('./collections.json', collectionSchemasDir),
+ JSON.stringify(contentCollectionManifest, null, 2),
+ );
+ }
+
+ const configPathRelativeToCacheDir = normalizeConfigPath(
+ settings.dotAstroDir.pathname,
+ contentPaths.config.url.pathname,
+ );
+
+ for (const contentEntryType of contentEntryTypes) {
+ if (contentEntryType.contentModuleTypes) {
+ typeTemplateContent = contentEntryType.contentModuleTypes + '\n' + typeTemplateContent;
+ }
+ }
+ typeTemplateContent = typeTemplateContent.replace('// @@CONTENT_ENTRY_MAP@@', contentTypesStr);
+ typeTemplateContent = typeTemplateContent.replace('// @@DATA_ENTRY_MAP@@', dataTypesStr);
+ typeTemplateContent = typeTemplateContent.replace(
+ "'@@CONTENT_CONFIG_TYPE@@'",
+ contentConfig ? `typeof import(${configPathRelativeToCacheDir})` : 'never',
+ );
+
+ // If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
+ if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) {
+ await fs.promises.writeFile(
+ new URL(CONTENT_TYPES_FILE, settings.dotAstroDir),
+ typeTemplateContent,
+ 'utf-8',
+ );
+ } else {
+ settings.injectedTypes.push({
+ filename: CONTENT_TYPES_FILE,
+ content: typeTemplateContent,
+ });
+ }
+}
+
+async function generateJSONSchema(
+ fsMod: typeof import('node:fs'),
+ collectionConfig: CollectionConfig,
+ collectionKey: string,
+ collectionSchemasDir: URL,
+ logger: Logger,
+) {
+ let zodSchemaForJson =
+ typeof collectionConfig.schema === 'function'
+ ? collectionConfig.schema({ image: () => z.string() })
+ : collectionConfig.schema;
+
+ if (!zodSchemaForJson && collectionConfig.type === CONTENT_LAYER_TYPE) {
+ zodSchemaForJson = await getContentLayerSchema(collectionConfig, collectionKey);
+ }
+
+ if (zodSchemaForJson instanceof z.ZodObject) {
+ zodSchemaForJson = zodSchemaForJson.extend({
+ $schema: z.string().optional(),
+ });
+ }
+ try {
+ await fsMod.promises.writeFile(
+ new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir),
+ JSON.stringify(
+ zodToJsonSchema(zodSchemaForJson, {
+ name: collectionKey.replace(/"/g, ''),
+ markdownDescription: true,
+ errorMessages: true,
+ // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110
+ dateStrategy: ['format:date-time', 'format:date', 'integer'],
+ }),
+ null,
+ 2,
+ ),
+ );
+ } catch (err) {
+ // This should error gracefully and not crash the dev server
+ logger.warn(
+ 'content',
+ `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`,
+ );
+ }
+}
diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts
new file mode 100644
index 000000000..b08ea252e
--- /dev/null
+++ b/packages/astro/src/content/utils.ts
@@ -0,0 +1,851 @@
+import fsMod from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { parseFrontmatter } from '@astrojs/markdown-remark';
+import { slug as githubSlug } from 'github-slugger';
+import { green } from 'kleur/colors';
+import type { PluginContext } from 'rollup';
+import type { ViteDevServer } from 'vite';
+import xxhash from 'xxhash-wasm';
+import { z } from 'zod';
+import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/errors/index.js';
+import { isYAMLException } from '../core/errors/utils.js';
+import type { Logger } from '../core/logger/core.js';
+import { appendForwardSlash } from '../core/path.js';
+import { normalizePath } from '../core/viteUtils.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+import type { ContentEntryType, DataEntryType } from '../types/public/content.js';
+import {
+ CONTENT_FLAGS,
+ CONTENT_LAYER_TYPE,
+ CONTENT_MODULE_FLAG,
+ DEFERRED_MODULE,
+ IMAGE_IMPORT_PREFIX,
+ PROPAGATED_ASSET_FLAG,
+} from './consts.js';
+import { glob } from './loaders/glob.js';
+import { createImage } from './runtime-assets.js';
+
+/**
+ * A map from a collection + slug to the local file path.
+ * This is used internally to resolve entry imports when using `getEntry()`.
+ * @see `templates/content/module.mjs`
+ */
+export type ContentLookupMap = {
+ [collectionName: string]: { type: 'content' | 'data'; entries: { [lookupId: string]: string } };
+};
+
+const entryTypeSchema = z
+ .object({
+ id: z.string({
+ invalid_type_error: 'Content entry `id` must be a string',
+ // Default to empty string so we can validate properly in the loader
+ }),
+ })
+ .passthrough();
+
+export const loaderReturnSchema = z.union([
+ z.array(entryTypeSchema),
+ z.record(
+ z.string(),
+ z
+ .object({
+ id: z
+ .string({
+ invalid_type_error: 'Content entry `id` must be a string',
+ })
+ .optional(),
+ })
+ .passthrough(),
+ ),
+]);
+
+const collectionConfigParser = z.union([
+ z.object({
+ type: z.literal('content').optional().default('content'),
+ schema: z.any().optional(),
+ }),
+ z.object({
+ type: z.literal('data'),
+ schema: z.any().optional(),
+ }),
+ z.object({
+ type: z.literal(CONTENT_LAYER_TYPE),
+ schema: z.any().optional(),
+ loader: z.union([
+ z.function(),
+ z.object({
+ name: z.string(),
+ load: z.function(
+ z.tuple(
+ [
+ z.object({
+ collection: z.string(),
+ store: z.any(),
+ meta: z.any(),
+ logger: z.any(),
+ config: z.any(),
+ entryTypes: z.any(),
+ parseData: z.any(),
+ generateDigest: z.function(z.tuple([z.any()], z.string())),
+ watcher: z.any().optional(),
+ refreshContextData: z.record(z.unknown()).optional(),
+ }),
+ ],
+ z.unknown(),
+ ),
+ ),
+ schema: z.any().optional(),
+ render: z.function(z.tuple([z.any()], z.unknown())).optional(),
+ }),
+ ]),
+ /** deprecated */
+ _legacy: z.boolean().optional(),
+ }),
+]);
+
+const contentConfigParser = z.object({
+ collections: z.record(collectionConfigParser),
+});
+
+export type CollectionConfig = z.infer<typeof collectionConfigParser>;
+export type ContentConfig = z.infer<typeof contentConfigParser> & { digest?: string };
+
+type EntryInternal = { rawData: string | undefined; filePath: string };
+
+export function parseEntrySlug({
+ id,
+ collection,
+ generatedSlug,
+ frontmatterSlug,
+}: {
+ id: string;
+ collection: string;
+ generatedSlug: string;
+ frontmatterSlug?: unknown;
+}) {
+ try {
+ return z.string().default(generatedSlug).parse(frontmatterSlug);
+ } catch {
+ throw new AstroError({
+ ...AstroErrorData.InvalidContentEntrySlugError,
+ message: AstroErrorData.InvalidContentEntrySlugError.message(collection, id),
+ });
+ }
+}
+
+export async function getEntryDataAndImages<
+ TInputData extends Record<string, unknown> = Record<string, unknown>,
+ TOutputData extends TInputData = TInputData,
+>(
+ entry: {
+ id: string;
+ collection: string;
+ unvalidatedData: TInputData;
+ _internal: EntryInternal;
+ },
+ collectionConfig: CollectionConfig,
+ shouldEmitFile: boolean,
+ experimentalSvgEnabled: boolean,
+ pluginContext?: PluginContext,
+): Promise<{ data: TOutputData; imageImports: Array<string> }> {
+ let data: TOutputData;
+ // Legacy content collections have 'slug' removed
+ if (collectionConfig.type === 'content' || (collectionConfig as any)._legacy) {
+ const { slug, ...unvalidatedData } = entry.unvalidatedData;
+ data = unvalidatedData as TOutputData;
+ } else {
+ data = entry.unvalidatedData as TOutputData;
+ }
+
+ let schema = collectionConfig.schema;
+
+ const imageImports = new Set<string>();
+
+ if (typeof schema === 'function') {
+ if (pluginContext) {
+ schema = schema({
+ image: createImage(
+ pluginContext,
+ shouldEmitFile,
+ entry._internal.filePath,
+ experimentalSvgEnabled,
+ ),
+ });
+ } else if (collectionConfig.type === CONTENT_LAYER_TYPE) {
+ schema = schema({
+ image: () =>
+ z.string().transform((val) => {
+ imageImports.add(val);
+ return `${IMAGE_IMPORT_PREFIX}${val}`;
+ }),
+ });
+ }
+ }
+
+ if (schema) {
+ // Catch reserved `slug` field inside content schemas
+ // Note: will not warn for `z.union` or `z.intersection` schemas
+ if (
+ collectionConfig.type === 'content' &&
+ typeof schema === 'object' &&
+ 'shape' in schema &&
+ schema.shape.slug
+ ) {
+ throw new AstroError({
+ ...AstroErrorData.ContentSchemaContainsSlugError,
+ message: AstroErrorData.ContentSchemaContainsSlugError.message(entry.collection),
+ });
+ }
+
+ // Use `safeParseAsync` to allow async transforms
+ let formattedError;
+ const parsed = await (schema as z.ZodSchema).safeParseAsync(data, {
+ errorMap(error, ctx) {
+ if (error.code === 'custom' && error.params?.isHoistedAstroError) {
+ formattedError = error.params?.astroError;
+ }
+ return errorMap(error, ctx);
+ },
+ });
+ if (parsed.success) {
+ data = parsed.data as TOutputData;
+ } else {
+ if (!formattedError) {
+ const errorType =
+ collectionConfig.type === 'content'
+ ? AstroErrorData.InvalidContentEntryFrontmatterError
+ : AstroErrorData.InvalidContentEntryDataError;
+ formattedError = new AstroError({
+ ...errorType,
+ message: errorType.message(entry.collection, entry.id, parsed.error),
+ location: {
+ file: entry._internal?.filePath,
+ line: getYAMLErrorLine(
+ entry._internal?.rawData,
+ String(parsed.error.errors[0].path[0]),
+ ),
+ column: 0,
+ },
+ });
+ }
+ throw formattedError;
+ }
+ }
+
+ return { data, imageImports: Array.from(imageImports) };
+}
+
+export async function getEntryData(
+ entry: {
+ id: string;
+ collection: string;
+ unvalidatedData: Record<string, unknown>;
+ _internal: EntryInternal;
+ },
+ collectionConfig: CollectionConfig,
+ shouldEmitFile: boolean,
+ experimentalSvgEnabled: boolean,
+ pluginContext?: PluginContext,
+) {
+ const { data } = await getEntryDataAndImages(
+ entry,
+ collectionConfig,
+ shouldEmitFile,
+ experimentalSvgEnabled,
+ pluginContext,
+ );
+ return data;
+}
+
+export function getContentEntryExts(settings: Pick<AstroSettings, 'contentEntryTypes'>) {
+ return settings.contentEntryTypes.map((t) => t.extensions).flat();
+}
+
+export function getDataEntryExts(settings: Pick<AstroSettings, 'dataEntryTypes'>) {
+ return settings.dataEntryTypes.map((t) => t.extensions).flat();
+}
+
+export function getEntryConfigByExtMap<TEntryType extends ContentEntryType | DataEntryType>(
+ entryTypes: TEntryType[],
+): Map<string, TEntryType> {
+ const map = new Map<string, TEntryType>();
+ for (const entryType of entryTypes) {
+ for (const ext of entryType.extensions) {
+ map.set(ext, entryType);
+ }
+ }
+ return map;
+}
+
+export async function getSymlinkedContentCollections({
+ contentDir,
+ logger,
+ fs,
+}: {
+ contentDir: URL;
+ logger: Logger;
+ fs: typeof fsMod;
+}): Promise<Map<string, string>> {
+ const contentPaths = new Map<string, string>();
+ const contentDirPath = fileURLToPath(contentDir);
+ try {
+ if (!fs.existsSync(contentDirPath) || !fs.lstatSync(contentDirPath).isDirectory()) {
+ return contentPaths;
+ }
+ } catch {
+ // Ignore if there isn't a valid content directory
+ return contentPaths;
+ }
+ try {
+ const contentDirEntries = await fs.promises.readdir(contentDir, { withFileTypes: true });
+ for (const entry of contentDirEntries) {
+ if (entry.isSymbolicLink()) {
+ const entryPath = path.join(contentDirPath, entry.name);
+ const realPath = await fs.promises.realpath(entryPath);
+ contentPaths.set(normalizePath(realPath), entry.name);
+ }
+ }
+ } catch (e) {
+ logger.warn('content', `Error when reading content directory "${contentDir}"`);
+ logger.debug('content', e);
+ // If there's an error, return an empty map
+ return new Map<string, string>();
+ }
+
+ return contentPaths;
+}
+
+export function reverseSymlink({
+ entry,
+ symlinks,
+ contentDir,
+}: {
+ entry: string | URL;
+ contentDir: string | URL;
+ symlinks?: Map<string, string>;
+}): string {
+ const entryPath = normalizePath(typeof entry === 'string' ? entry : fileURLToPath(entry));
+ const contentDirPath = typeof contentDir === 'string' ? contentDir : fileURLToPath(contentDir);
+ if (!symlinks || symlinks.size === 0) {
+ return entryPath;
+ }
+
+ for (const [realPath, symlinkName] of symlinks) {
+ if (entryPath.startsWith(realPath)) {
+ return normalizePath(path.join(contentDirPath, symlinkName, entryPath.replace(realPath, '')));
+ }
+ }
+ return entryPath;
+}
+
+export function getEntryCollectionName({
+ contentDir,
+ entry,
+}: Pick<ContentPaths, 'contentDir'> & { entry: string | URL }) {
+ const entryPath = typeof entry === 'string' ? entry : fileURLToPath(entry);
+ const rawRelativePath = path.relative(fileURLToPath(contentDir), entryPath);
+ const collectionName = path.dirname(rawRelativePath).split(path.sep)[0];
+ const isOutsideCollection =
+ !collectionName || collectionName === '' || collectionName === '..' || collectionName === '.';
+
+ if (isOutsideCollection) {
+ return undefined;
+ }
+
+ return collectionName;
+}
+
+export function getDataEntryId({
+ entry,
+ contentDir,
+ collection,
+}: Pick<ContentPaths, 'contentDir'> & { entry: URL; collection: string }): string {
+ const relativePath = getRelativeEntryPath(entry, collection, contentDir);
+ const withoutFileExt = normalizePath(relativePath).replace(
+ new RegExp(path.extname(relativePath) + '$'),
+ '',
+ );
+
+ return withoutFileExt;
+}
+
+export function getContentEntryIdAndSlug({
+ entry,
+ contentDir,
+ collection,
+}: Pick<ContentPaths, 'contentDir'> & { entry: URL; collection: string }): {
+ id: string;
+ slug: string;
+} {
+ const relativePath = getRelativeEntryPath(entry, collection, contentDir);
+ const withoutFileExt = relativePath.replace(new RegExp(path.extname(relativePath) + '$'), '');
+ const rawSlugSegments = withoutFileExt.split(path.sep);
+
+ const slug = rawSlugSegments
+ // Slugify each route segment to handle capitalization and spaces.
+ // Note: using `slug` instead of `new Slugger()` means no slug deduping.
+ .map((segment) => githubSlug(segment))
+ .join('/')
+ .replace(/\/index$/, '');
+
+ const res = {
+ id: normalizePath(relativePath),
+ slug,
+ };
+ return res;
+}
+
+function getRelativeEntryPath(entry: URL, collection: string, contentDir: URL) {
+ const relativeToContent = path.relative(fileURLToPath(contentDir), fileURLToPath(entry));
+ const relativeToCollection = path.relative(collection, relativeToContent);
+ return relativeToCollection;
+}
+
+function isParentDirectory(parent: URL, child: URL) {
+ const relative = path.relative(fileURLToPath(parent), fileURLToPath(child));
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
+}
+
+export function getEntryType(
+ entryPath: string,
+ paths: Pick<ContentPaths, 'config' | 'contentDir' | 'root'>,
+ contentFileExts: string[],
+ dataFileExts: string[],
+): 'content' | 'data' | 'config' | 'ignored' {
+ const { ext } = path.parse(entryPath);
+ const fileUrl = pathToFileURL(entryPath);
+
+ const dotAstroDir = new URL('./.astro/', paths.root);
+
+ if (fileUrl.href === paths.config.url.href) {
+ return 'config';
+ } else if (hasUnderscoreBelowContentDirectoryPath(fileUrl, paths.contentDir)) {
+ return 'ignored';
+ } else if (isParentDirectory(dotAstroDir, fileUrl)) {
+ return 'ignored';
+ } else if (contentFileExts.includes(ext)) {
+ return 'content';
+ } else if (dataFileExts.includes(ext)) {
+ return 'data';
+ } else {
+ return 'ignored';
+ }
+}
+
+function hasUnderscoreBelowContentDirectoryPath(
+ fileUrl: URL,
+ contentDir: ContentPaths['contentDir'],
+): boolean {
+ const parts = fileUrl.pathname.replace(contentDir.pathname, '').split('/');
+ for (const part of parts) {
+ if (part.startsWith('_')) return true;
+ }
+ return false;
+}
+
+function getYAMLErrorLine(rawData: string | undefined, objectKey: string) {
+ if (!rawData) return 0;
+ const indexOfObjectKey = rawData.search(
+ // Match key either at the top of the file or after a newline
+ // Ensures matching on top-level object keys only
+ new RegExp(`(\n|^)${objectKey}`),
+ );
+ if (indexOfObjectKey === -1) return 0;
+
+ const dataBeforeKey = rawData.substring(0, indexOfObjectKey + 1);
+ const numNewlinesBeforeKey = dataBeforeKey.split('\n').length;
+ return numNewlinesBeforeKey;
+}
+
+export function safeParseFrontmatter(source: string, id?: string) {
+ try {
+ return parseFrontmatter(source, { frontmatter: 'empty-with-spaces' });
+ } catch (err: any) {
+ const markdownError = new MarkdownError({
+ name: 'MarkdownError',
+ message: err.message,
+ stack: err.stack,
+ location: id
+ ? {
+ file: id,
+ }
+ : undefined,
+ });
+
+ if (isYAMLException(err)) {
+ markdownError.setLocation({
+ file: id,
+ line: err.mark.line,
+ column: err.mark.column,
+ });
+
+ markdownError.setMessage(err.reason);
+ }
+
+ throw markdownError;
+ }
+}
+
+/**
+ * The content config is loaded separately from other `src/` files.
+ * This global observable lets dependent plugins (like the content flag plugin)
+ * subscribe to changes during dev server updates.
+ */
+export const globalContentConfigObserver = contentObservable({ status: 'init' });
+
+export function hasAnyContentFlag(viteId: string): boolean {
+ const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
+ const flag = Array.from(flags.keys()).at(0);
+ if (typeof flag !== 'string') {
+ return false;
+ }
+ return CONTENT_FLAGS.includes(flag as any);
+}
+
+export function hasContentFlag(viteId: string, flag: (typeof CONTENT_FLAGS)[number]): boolean {
+ const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
+ return flags.has(flag);
+}
+
+export function isDeferredModule(viteId: string): boolean {
+ const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
+ return flags.has(CONTENT_MODULE_FLAG);
+}
+
+async function loadContentConfig({
+ fs,
+ settings,
+ viteServer,
+}: {
+ fs: typeof fsMod;
+ settings: AstroSettings;
+ viteServer: ViteDevServer;
+}): Promise<ContentConfig | undefined> {
+ const contentPaths = getContentPaths(settings.config, fs);
+ let unparsedConfig;
+ if (!contentPaths.config.exists) {
+ return undefined;
+ }
+ const configPathname = fileURLToPath(contentPaths.config.url);
+ unparsedConfig = await viteServer.ssrLoadModule(configPathname);
+
+ const config = contentConfigParser.safeParse(unparsedConfig);
+ if (config.success) {
+ // Generate a digest of the config file so we can invalidate the cache if it changes
+ const hasher = await xxhash();
+ const digest = await hasher.h64ToString(await fs.promises.readFile(configPathname, 'utf-8'));
+ return { ...config.data, digest };
+ } else {
+ return undefined;
+ }
+}
+
+export async function autogenerateCollections({
+ config,
+ settings,
+ fs,
+}: {
+ config?: ContentConfig;
+ settings: AstroSettings;
+ fs: typeof fsMod;
+}): Promise<ContentConfig | undefined> {
+ if (settings.config.legacy.collections) {
+ return config;
+ }
+ const contentDir = new URL('./content/', settings.config.srcDir);
+
+ const collections: Record<string, CollectionConfig> = config?.collections ?? {};
+
+ const contentExts = getContentEntryExts(settings);
+ const dataExts = getDataEntryExts(settings);
+
+ const contentPattern = globWithUnderscoresIgnored('', contentExts);
+ const dataPattern = globWithUnderscoresIgnored('', dataExts);
+ let usesContentLayer = false;
+ for (const collectionName of Object.keys(collections)) {
+ if (collections[collectionName]?.type === 'content_layer') {
+ usesContentLayer = true;
+ // This is already a content layer, skip
+ continue;
+ }
+
+ const isDataCollection = collections[collectionName]?.type === 'data';
+ const base = new URL(`${collectionName}/`, contentDir);
+ // Only "content" collections need special legacy handling
+ const _legacy = !isDataCollection || undefined;
+ collections[collectionName] = {
+ ...collections[collectionName],
+ type: 'content_layer',
+ _legacy,
+ loader: glob({
+ base,
+ pattern: isDataCollection ? dataPattern : contentPattern,
+ _legacy,
+ // Legacy data collections IDs aren't slugified
+ generateId: isDataCollection
+ ? ({ entry }) =>
+ getDataEntryId({
+ entry: new URL(entry, base),
+ collection: collectionName,
+ contentDir,
+ })
+ : undefined,
+
+ // Zod weirdness has trouble with typing the args to the load function
+ }) as any,
+ };
+ }
+ if (!usesContentLayer && fs.existsSync(contentDir)) {
+ // If the user hasn't defined any collections using the content layer, we'll try and help out by checking for
+ // any orphaned folders in the content directory and creating collections for them.
+ const orphanedCollections = [];
+ for (const entry of await fs.promises.readdir(contentDir, { withFileTypes: true })) {
+ const collectionName = entry.name;
+ if (['_', '.'].includes(collectionName.at(0) ?? '')) {
+ continue;
+ }
+ if (entry.isDirectory() && !(collectionName in collections)) {
+ orphanedCollections.push(collectionName);
+ const base = new URL(`${collectionName}/`, contentDir);
+ collections[collectionName] = {
+ type: 'content_layer',
+ loader: glob({
+ base,
+ pattern: contentPattern,
+ _legacy: true,
+ }) as any,
+ };
+ }
+ }
+ if (orphanedCollections.length > 0) {
+ console.warn(
+ `
+Auto-generating collections for folders in "src/content/" that are not defined as collections.
+This is deprecated, so you should define these collections yourself in "src/content.config.ts".
+The following collections have been auto-generated: ${orphanedCollections
+ .map((name) => green(name))
+ .join(', ')}\n`,
+ );
+ }
+ }
+ return { ...config, collections };
+}
+
+export async function reloadContentConfigObserver({
+ observer = globalContentConfigObserver,
+ ...loadContentConfigOpts
+}: {
+ fs: typeof fsMod;
+ settings: AstroSettings;
+ viteServer: ViteDevServer;
+ observer?: ContentObservable;
+}) {
+ observer.set({ status: 'loading' });
+ try {
+ let config = await loadContentConfig(loadContentConfigOpts);
+
+ config = await autogenerateCollections({
+ config,
+ ...loadContentConfigOpts,
+ });
+
+ if (config) {
+ observer.set({ status: 'loaded', config });
+ } else {
+ observer.set({ status: 'does-not-exist' });
+ }
+ } catch (e) {
+ observer.set({
+ status: 'error',
+ error: e instanceof Error ? e : new AstroError(AstroErrorData.UnknownContentCollectionError),
+ });
+ }
+}
+
+type ContentCtx =
+ | { status: 'init' }
+ | { status: 'loading' }
+ | { status: 'does-not-exist' }
+ | { status: 'loaded'; config: ContentConfig }
+ | { status: 'error'; error: Error };
+
+type Observable<C> = {
+ get: () => C;
+ set: (ctx: C) => void;
+ subscribe: (fn: (ctx: C) => void) => () => void;
+};
+
+export type ContentObservable = Observable<ContentCtx>;
+
+export function contentObservable(initialCtx: ContentCtx): ContentObservable {
+ type Subscriber = (ctx: ContentCtx) => void;
+ const subscribers = new Set<Subscriber>();
+ let ctx = initialCtx;
+ function get() {
+ return ctx;
+ }
+ function set(_ctx: ContentCtx) {
+ ctx = _ctx;
+ subscribers.forEach((fn) => fn(ctx));
+ }
+ function subscribe(fn: Subscriber) {
+ subscribers.add(fn);
+ return () => {
+ subscribers.delete(fn);
+ };
+ }
+ return {
+ get,
+ set,
+ subscribe,
+ };
+}
+
+export type ContentPaths = {
+ root: URL;
+ contentDir: URL;
+ assetsDir: URL;
+ typesTemplate: URL;
+ virtualModTemplate: URL;
+ config: {
+ exists: boolean;
+ url: URL;
+ };
+};
+
+export function getContentPaths(
+ { srcDir, legacy, root }: Pick<AstroConfig, 'root' | 'srcDir' | 'legacy'>,
+ fs: typeof fsMod = fsMod,
+): ContentPaths {
+ const configStats = search(fs, srcDir, legacy?.collections);
+ const pkgBase = new URL('../../', import.meta.url);
+ return {
+ root: new URL('./', root),
+ contentDir: new URL('./content/', srcDir),
+ assetsDir: new URL('./assets/', srcDir),
+ typesTemplate: new URL('templates/content/types.d.ts', pkgBase),
+ virtualModTemplate: new URL('templates/content/module.mjs', pkgBase),
+ config: configStats,
+ };
+}
+function search(fs: typeof fsMod, srcDir: URL, legacy?: boolean) {
+ const paths = [
+ ...(legacy
+ ? []
+ : ['content.config.mjs', 'content.config.js', 'content.config.mts', 'content.config.ts']),
+ 'content/config.mjs',
+ 'content/config.js',
+ 'content/config.mts',
+ 'content/config.ts',
+ ].map((p) => new URL(`./${p}`, srcDir));
+ for (const file of paths) {
+ if (fs.existsSync(file)) {
+ return { exists: true, url: file };
+ }
+ }
+ return { exists: false, url: paths[0] };
+}
+
+/**
+ * Check for slug in content entry frontmatter and validate the type,
+ * falling back to the `generatedSlug` if none is found.
+ */
+export async function getEntrySlug({
+ id,
+ collection,
+ generatedSlug,
+ contentEntryType,
+ fileUrl,
+ fs,
+}: {
+ fs: typeof fsMod;
+ id: string;
+ collection: string;
+ generatedSlug: string;
+ fileUrl: URL;
+ contentEntryType: Pick<ContentEntryType, 'getEntryInfo'>;
+}) {
+ let contents: string;
+ try {
+ contents = await fs.promises.readFile(fileUrl, 'utf-8');
+ } catch (e) {
+ // File contents should exist. Raise unexpected error as "unknown" if not.
+ throw new AstroError(AstroErrorData.UnknownContentCollectionError, { cause: e });
+ }
+ const { slug: frontmatterSlug } = await contentEntryType.getEntryInfo({
+ fileUrl,
+ contents,
+ });
+ return parseEntrySlug({ generatedSlug, frontmatterSlug, id, collection });
+}
+
+export function getExtGlob(exts: string[]) {
+ return exts.length === 1
+ ? // Wrapping {...} breaks when there is only one extension
+ exts[0]
+ : `{${exts.join(',')}}`;
+}
+
+export function hasAssetPropagationFlag(id: string): boolean {
+ try {
+ return new URL(id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG);
+ } catch {
+ return false;
+ }
+}
+
+export function globWithUnderscoresIgnored(relContentDir: string, exts: string[]): string[] {
+ const extGlob = getExtGlob(exts);
+ const contentDir = relContentDir.length > 0 ? appendForwardSlash(relContentDir) : relContentDir;
+ return [
+ `${contentDir}**/*${extGlob}`,
+ `!${contentDir}**/_*/**/*${extGlob}`,
+ `!${contentDir}**/_*${extGlob}`,
+ ];
+}
+
+/**
+ * Convert a platform path to a posix path.
+ */
+export function posixifyPath(filePath: string) {
+ return filePath.split(path.sep).join('/');
+}
+
+/**
+ * Unlike `path.posix.relative`, this function will accept a platform path and return a posix path.
+ */
+export function posixRelative(from: string, to: string) {
+ return posixifyPath(path.relative(from, to));
+}
+
+export function contentModuleToId(fileName: string) {
+ const params = new URLSearchParams(DEFERRED_MODULE);
+ params.set('fileName', fileName);
+ params.set(CONTENT_MODULE_FLAG, 'true');
+ return `${DEFERRED_MODULE}?${params.toString()}`;
+}
+
+// Based on https://github.com/sindresorhus/safe-stringify
+function safeStringifyReplacer(seen: WeakSet<object>) {
+ return function (_key: string, value: unknown) {
+ if (!(value !== null && typeof value === 'object')) {
+ return value;
+ }
+ if (seen.has(value)) {
+ return '[Circular]';
+ }
+ seen.add(value);
+ const newValue = Array.isArray(value) ? [] : {};
+ for (const [key2, value2] of Object.entries(value)) {
+ (newValue as Record<string, unknown>)[key2] = safeStringifyReplacer(seen)(key2, value2);
+ }
+ seen.delete(value);
+ return newValue;
+ };
+}
+export function safeStringify(value: unknown) {
+ const seen = new WeakSet();
+ return JSON.stringify(value, safeStringifyReplacer(seen));
+}
diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts
new file mode 100644
index 000000000..33084d6e3
--- /dev/null
+++ b/packages/astro/src/content/vite-plugin-content-assets.ts
@@ -0,0 +1,184 @@
+import { extname } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import type { Plugin } from 'vite';
+import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js';
+import type { BuildInternals } from '../core/build/internal.js';
+import type { AstroBuildPlugin } from '../core/build/plugin.js';
+import type { StaticBuildOptions } from '../core/build/types.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { ModuleLoader } from '../core/module-loader/loader.js';
+import { createViteLoader } from '../core/module-loader/vite.js';
+import { joinPaths, prependForwardSlash } from '../core/path.js';
+import type { AstroSettings } from '../types/astro.js';
+import { getStylesForURL } from '../vite-plugin-astro-server/css.js';
+import {
+ CONTENT_IMAGE_FLAG,
+ CONTENT_RENDER_FLAG,
+ LINKS_PLACEHOLDER,
+ PROPAGATED_ASSET_FLAG,
+ STYLES_PLACEHOLDER,
+} from './consts.js';
+import { hasContentFlag } from './utils.js';
+
+export function astroContentAssetPropagationPlugin({
+ settings,
+}: {
+ settings: AstroSettings;
+}): Plugin {
+ let devModuleLoader: ModuleLoader;
+ return {
+ name: 'astro:content-asset-propagation',
+ enforce: 'pre',
+ async resolveId(id, importer, opts) {
+ if (hasContentFlag(id, CONTENT_IMAGE_FLAG)) {
+ const [base, query] = id.split('?');
+ const params = new URLSearchParams(query);
+ const importerParam = params.get('importer');
+
+ const importerPath = importerParam
+ ? fileURLToPath(new URL(importerParam, settings.config.root))
+ : importer;
+
+ const resolved = await this.resolve(base, importerPath, { skipSelf: true, ...opts });
+ if (!resolved) {
+ throw new AstroError({
+ ...AstroErrorData.ImageNotFound,
+ message: AstroErrorData.ImageNotFound.message(base),
+ });
+ }
+ return resolved;
+ }
+ if (hasContentFlag(id, CONTENT_RENDER_FLAG)) {
+ const base = id.split('?')[0];
+
+ for (const { extensions, handlePropagation = true } of settings.contentEntryTypes) {
+ if (handlePropagation && extensions.includes(extname(base))) {
+ return this.resolve(`${base}?${PROPAGATED_ASSET_FLAG}`, importer, {
+ skipSelf: true,
+ ...opts,
+ });
+ }
+ }
+ // Resolve to the base id (no content flags)
+ // if Astro doesn't need to handle propagation.
+ return this.resolve(base, importer, { skipSelf: true, ...opts });
+ }
+ },
+ configureServer(server) {
+ devModuleLoader = createViteLoader(server);
+ },
+ async transform(_, id, options) {
+ if (hasContentFlag(id, PROPAGATED_ASSET_FLAG)) {
+ const basePath = id.split('?')[0];
+ let stringifiedLinks: string, stringifiedStyles: string;
+
+ // We can access the server in dev,
+ // so resolve collected styles and scripts here.
+ if (options?.ssr && devModuleLoader) {
+ if (!devModuleLoader.getModuleById(basePath)?.ssrModule) {
+ await devModuleLoader.import(basePath);
+ }
+ const {
+ styles,
+ urls,
+ crawledFiles: styleCrawledFiles,
+ } = await getStylesForURL(pathToFileURL(basePath), devModuleLoader);
+
+ // Register files we crawled to be able to retrieve the rendered styles and scripts,
+ // as when they get updated, we need to re-transform ourselves.
+ // We also only watch files within the user source code, as changes in node_modules
+ // are usually also ignored by Vite.
+ for (const file of styleCrawledFiles) {
+ if (!file.includes('node_modules')) {
+ this.addWatchFile(file);
+ }
+ }
+
+ stringifiedLinks = JSON.stringify([...urls]);
+ stringifiedStyles = JSON.stringify(styles.map((s) => s.content));
+ } else {
+ // Otherwise, use placeholders to inject styles and scripts
+ // during the production bundle step.
+ // @see the `astro:content-build-plugin` below.
+ stringifiedLinks = JSON.stringify(LINKS_PLACEHOLDER);
+ stringifiedStyles = JSON.stringify(STYLES_PLACEHOLDER);
+ }
+
+ const code = `
+ async function getMod() {
+ return import(${JSON.stringify(basePath)});
+ }
+ const collectedLinks = ${stringifiedLinks};
+ const collectedStyles = ${stringifiedStyles};
+ const defaultMod = { __astroPropagation: true, getMod, collectedLinks, collectedStyles, collectedScripts: [] };
+ export default defaultMod;
+ `;
+ // ^ Use a default export for tools like Markdoc
+ // to catch the `__astroPropagation` identifier
+ return { code, map: { mappings: '' } };
+ }
+ },
+ };
+}
+
+export function astroConfigBuildPlugin(
+ options: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:post': ({ ssrOutputs, mutate }) => {
+ const outputs = ssrOutputs.flatMap((o) => o.output);
+ const prependBase = (src: string) => {
+ const { assetsPrefix } = options.settings.config.build;
+ if (assetsPrefix) {
+ const fileExtension = extname(src);
+ const pf = getAssetsPrefix(fileExtension, assetsPrefix);
+ return joinPaths(pf, src);
+ } else {
+ return prependForwardSlash(joinPaths(options.settings.config.base, src));
+ }
+ };
+ for (const chunk of outputs) {
+ if (chunk.type === 'chunk' && chunk.code.includes(LINKS_PLACEHOLDER)) {
+ const entryStyles = new Set<string>();
+ const entryLinks = new Set<string>();
+
+ for (const id of chunk.moduleIds) {
+ const _entryCss = internals.propagatedStylesMap.get(id);
+ if (_entryCss) {
+ // TODO: Separating styles and links this way is not ideal. The `entryCss` list is order-sensitive
+ // and splitting them into two sets causes the order to be lost, because styles are rendered after
+ // links. Refactor this away in the future.
+ for (const value of _entryCss) {
+ if (value.type === 'inline') entryStyles.add(value.content);
+ if (value.type === 'external') entryLinks.add(value.src);
+ }
+ }
+ }
+
+ let newCode = chunk.code;
+ if (entryStyles.size) {
+ newCode = newCode.replace(
+ JSON.stringify(STYLES_PLACEHOLDER),
+ JSON.stringify(Array.from(entryStyles)),
+ );
+ } else {
+ newCode = newCode.replace(JSON.stringify(STYLES_PLACEHOLDER), '[]');
+ }
+ if (entryLinks.size) {
+ newCode = newCode.replace(
+ JSON.stringify(LINKS_PLACEHOLDER),
+ JSON.stringify(Array.from(entryLinks).map(prependBase)),
+ );
+ } else {
+ newCode = newCode.replace(JSON.stringify(LINKS_PLACEHOLDER), '[]');
+ }
+ mutate(chunk, ['server'], newCode);
+ }
+ }
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts
new file mode 100644
index 000000000..68f9cf706
--- /dev/null
+++ b/packages/astro/src/content/vite-plugin-content-imports.ts
@@ -0,0 +1,411 @@
+import type fsMod from 'node:fs';
+import { extname } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import * as devalue from 'devalue';
+import type { PluginContext } from 'rollup';
+import type { Plugin } from 'vite';
+import { getProxyCode } from '../assets/utils/proxy.js';
+import { AstroError } from '../core/errors/errors.js';
+import { AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+import type {
+ ContentEntryModule,
+ ContentEntryType,
+ DataEntryModule,
+ DataEntryType,
+} from '../types/public/content.js';
+import { CONTENT_FLAG, DATA_FLAG } from './consts.js';
+import {
+ type ContentConfig,
+ getContentEntryExts,
+ getContentEntryIdAndSlug,
+ getContentPaths,
+ getDataEntryExts,
+ getDataEntryId,
+ getEntryCollectionName,
+ getEntryConfigByExtMap,
+ getEntryData,
+ getEntryType,
+ getSymlinkedContentCollections,
+ globalContentConfigObserver,
+ hasContentFlag,
+ parseEntrySlug,
+ reloadContentConfigObserver,
+ reverseSymlink,
+} from './utils.js';
+
+function getContentRendererByViteId(
+ viteId: string,
+ settings: Pick<AstroSettings, 'contentEntryTypes'>,
+): ContentEntryType['getRenderModule'] | undefined {
+ let ext = viteId.split('.').pop();
+ if (!ext) return undefined;
+ for (const contentEntryType of settings.contentEntryTypes) {
+ if (
+ Boolean(contentEntryType.getRenderModule) &&
+ contentEntryType.extensions.includes('.' + ext)
+ ) {
+ return contentEntryType.getRenderModule;
+ }
+ }
+ return undefined;
+}
+
+const CHOKIDAR_MODIFIED_EVENTS = ['add', 'unlink', 'change'];
+/**
+ * If collection entries change, import modules need to be invalidated.
+ * Reasons why:
+ * - 'config' - content imports depend on the config file for parsing schemas
+ * - 'data' | 'content' - the config may depend on collection entries via `reference()`
+ */
+const COLLECTION_TYPES_TO_INVALIDATE_ON = ['data', 'content', 'config'];
+
+export function astroContentImportPlugin({
+ fs,
+ settings,
+ logger,
+}: {
+ fs: typeof fsMod;
+ settings: AstroSettings;
+ logger: Logger;
+}): Plugin[] {
+ const contentPaths = getContentPaths(settings.config, fs);
+ const contentEntryExts = getContentEntryExts(settings);
+ const dataEntryExts = getDataEntryExts(settings);
+
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const dataEntryConfigByExt = getEntryConfigByExtMap(settings.dataEntryTypes);
+ const { contentDir } = contentPaths;
+ let shouldEmitFile = false;
+ let symlinks: Map<string, string>;
+ const plugins: Plugin[] = [
+ {
+ name: 'astro:content-imports',
+ config(_config, env) {
+ shouldEmitFile = env.command === 'build';
+ },
+ async buildStart() {
+ // Get symlinks once at build start
+ symlinks = await getSymlinkedContentCollections({ contentDir, logger, fs });
+ },
+ async transform(_, viteId) {
+ if (hasContentFlag(viteId, DATA_FLAG)) {
+ // By default, Vite will resolve symlinks to their targets. We need to reverse this for
+ // content entries, so we can get the path relative to the content directory.
+ const fileId = reverseSymlink({
+ entry: viteId.split('?')[0] ?? viteId,
+ contentDir,
+ symlinks,
+ });
+ // Data collections don't need to rely on the module cache.
+ // This cache only exists for the `render()` function specific to content.
+ const { id, data, collection, _internal } = await getDataEntryModule({
+ fileId,
+ entryConfigByExt: dataEntryConfigByExt,
+ contentDir,
+ config: settings.config,
+ fs,
+ pluginContext: this,
+ shouldEmitFile,
+ });
+
+ const code = `
+export const id = ${JSON.stringify(id)};
+export const collection = ${JSON.stringify(collection)};
+export const data = ${stringifyEntryData(data, settings.buildOutput === 'server')};
+export const _internal = {
+ type: 'data',
+ filePath: ${JSON.stringify(_internal.filePath)},
+ rawData: ${JSON.stringify(_internal.rawData)},
+};
+`;
+ return code;
+ } else if (hasContentFlag(viteId, CONTENT_FLAG)) {
+ const fileId = reverseSymlink({ entry: viteId.split('?')[0], contentDir, symlinks });
+ const { id, slug, collection, body, data, _internal } = await getContentEntryModule({
+ fileId,
+ entryConfigByExt: contentEntryConfigByExt,
+ contentDir,
+ config: settings.config,
+ fs,
+ pluginContext: this,
+ shouldEmitFile,
+ });
+
+ const code = `
+ export const id = ${JSON.stringify(id)};
+ export const collection = ${JSON.stringify(collection)};
+ export const slug = ${JSON.stringify(slug)};
+ export const body = ${JSON.stringify(body)};
+ export const data = ${stringifyEntryData(data, settings.buildOutput === 'server')};
+ export const _internal = {
+ type: 'content',
+ filePath: ${JSON.stringify(_internal.filePath)},
+ rawData: ${JSON.stringify(_internal.rawData)},
+ };`;
+
+ return { code, map: { mappings: '' } };
+ }
+ },
+ configureServer(viteServer) {
+ viteServer.watcher.on('all', async (event, entry) => {
+ if (CHOKIDAR_MODIFIED_EVENTS.includes(event)) {
+ const entryType = getEntryType(entry, contentPaths, contentEntryExts, dataEntryExts);
+ if (!COLLECTION_TYPES_TO_INVALIDATE_ON.includes(entryType)) return;
+
+ // The content config could depend on collection entries via `reference()`.
+ // Reload the config in case of changes.
+ // Changes to the config file itself are handled in types-generator.ts, so we skip them here
+ if (entryType === 'content' || entryType === 'data') {
+ await reloadContentConfigObserver({ fs, settings, viteServer });
+ }
+
+ // Invalidate all content imports and `render()` modules.
+ // TODO: trace `reference()` calls for fine-grained invalidation.
+ for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
+ if (
+ hasContentFlag(modUrl, CONTENT_FLAG) ||
+ hasContentFlag(modUrl, DATA_FLAG) ||
+ Boolean(getContentRendererByViteId(modUrl, settings))
+ ) {
+ try {
+ const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
+ if (mod) {
+ viteServer.moduleGraph.invalidateModule(mod);
+ }
+ } catch (e: any) {
+ // The server may be closed due to a restart caused by this file change
+ if (e.code === 'ERR_CLOSED_SERVER') break;
+ throw e;
+ }
+ }
+ }
+ }
+ });
+ },
+ },
+ ];
+
+ if (settings.contentEntryTypes.some((t) => t.getRenderModule)) {
+ plugins.push({
+ name: 'astro:content-render-imports',
+ async transform(contents, viteId) {
+ const contentRenderer = getContentRendererByViteId(viteId, settings);
+ if (!contentRenderer) return;
+
+ const fileId = viteId.split('?')[0];
+ return contentRenderer.bind(this)({ viteId, contents, fileUrl: pathToFileURL(fileId) });
+ },
+ });
+ }
+
+ return plugins;
+}
+
+type GetEntryModuleParams<TEntryType extends ContentEntryType | DataEntryType> = {
+ fs: typeof fsMod;
+ fileId: string;
+ contentDir: URL;
+ pluginContext: PluginContext;
+ entryConfigByExt: Map<string, TEntryType>;
+ config: AstroConfig;
+ shouldEmitFile: boolean;
+};
+
+async function getContentEntryModule(
+ params: GetEntryModuleParams<ContentEntryType>,
+): Promise<ContentEntryModule> {
+ const { fileId, contentDir, pluginContext } = params;
+ const { collectionConfig, entryConfig, entry, rawContents, collection } =
+ await getEntryModuleBaseInfo(params);
+
+ const {
+ rawData,
+ data: unvalidatedData,
+ body,
+ slug: frontmatterSlug,
+ } = await entryConfig.getEntryInfo({
+ fileUrl: pathToFileURL(fileId),
+ contents: rawContents,
+ });
+ const _internal = { filePath: fileId, rawData };
+ const { id, slug: generatedSlug } = getContentEntryIdAndSlug({ entry, contentDir, collection });
+
+ const slug = parseEntrySlug({
+ id,
+ collection,
+ generatedSlug,
+ frontmatterSlug,
+ });
+
+ const data = collectionConfig
+ ? await getEntryData(
+ { id, collection, _internal, unvalidatedData },
+ collectionConfig,
+ params.shouldEmitFile,
+ !!params.config.experimental.svg,
+ pluginContext,
+ )
+ : unvalidatedData;
+
+ const contentEntryModule: ContentEntryModule = {
+ id,
+ slug,
+ collection,
+ data,
+ body,
+ _internal,
+ };
+
+ return contentEntryModule;
+}
+
+async function getDataEntryModule(
+ params: GetEntryModuleParams<DataEntryType>,
+): Promise<DataEntryModule> {
+ const { fileId, contentDir, pluginContext } = params;
+ const { collectionConfig, entryConfig, entry, rawContents, collection } =
+ await getEntryModuleBaseInfo(params);
+
+ const { rawData = '', data: unvalidatedData } = await entryConfig.getEntryInfo({
+ fileUrl: pathToFileURL(fileId),
+ contents: rawContents,
+ });
+ const _internal = { filePath: fileId, rawData };
+ const id = getDataEntryId({ entry, contentDir, collection });
+
+ const data = collectionConfig
+ ? await getEntryData(
+ { id, collection, _internal, unvalidatedData },
+ collectionConfig,
+ params.shouldEmitFile,
+ !!params.config.experimental.svg,
+ pluginContext,
+ )
+ : unvalidatedData;
+
+ const dataEntryModule: DataEntryModule = {
+ id,
+ collection,
+ data,
+ _internal,
+ };
+
+ return dataEntryModule;
+}
+
+// Shared logic for `getContentEntryModule` and `getDataEntryModule`
+// Extracting to a helper was easier that conditionals and generics :)
+async function getEntryModuleBaseInfo<TEntryType extends ContentEntryType | DataEntryType>({
+ fileId,
+ entryConfigByExt,
+ contentDir,
+ fs,
+}: GetEntryModuleParams<TEntryType>) {
+ const contentConfig = await getContentConfigFromGlobal();
+ let rawContents;
+ try {
+ rawContents = await fs.promises.readFile(fileId, 'utf-8');
+ } catch (e) {
+ throw new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: `Unexpected error reading entry ${JSON.stringify(fileId)}.`,
+ stack: e instanceof Error ? e.stack : undefined,
+ });
+ }
+ const fileExt = extname(fileId);
+ const entryConfig = entryConfigByExt.get(fileExt);
+
+ if (!entryConfig) {
+ throw new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: `No parser found for data entry ${JSON.stringify(
+ fileId,
+ )}. Did you apply an integration for this file type?`,
+ });
+ }
+ const entry = pathToFileURL(fileId);
+ const collection = getEntryCollectionName({ entry, contentDir });
+ if (collection === undefined) throw new AstroError(AstroErrorData.UnknownContentCollectionError);
+
+ const collectionConfig = contentConfig?.collections[collection];
+
+ return {
+ collectionConfig,
+ entry,
+ entryConfig,
+ collection,
+ rawContents,
+ };
+}
+
+async function getContentConfigFromGlobal() {
+ const observable = globalContentConfigObserver.get();
+
+ // Content config should be loaded before being accessed from Vite plugins
+ if (observable.status === 'init') {
+ throw new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: 'Content config failed to load.',
+ });
+ }
+ if (observable.status === 'error') {
+ // Throw here to bubble content config errors
+ // to the error overlay in development
+ throw observable.error;
+ }
+
+ let contentConfig: ContentConfig | undefined =
+ observable.status === 'loaded' ? observable.config : undefined;
+ if (observable.status === 'loading') {
+ // Wait for config to load
+ contentConfig = await new Promise((resolve) => {
+ const unsubscribe = globalContentConfigObserver.subscribe((ctx) => {
+ if (ctx.status === 'loaded') {
+ resolve(ctx.config);
+ unsubscribe();
+ }
+ if (ctx.status === 'error') {
+ resolve(undefined);
+ unsubscribe();
+ }
+ });
+ });
+ }
+
+ return contentConfig;
+}
+
+/** Stringify entry `data` at build time to be used as a Vite module */
+function stringifyEntryData(data: Record<string, any>, isSSR: boolean): string {
+ try {
+ return devalue.uneval(data, (value) => {
+ // Add support for URL objects
+ if (value instanceof URL) {
+ return `new URL(${JSON.stringify(value.href)})`;
+ }
+
+ // For Astro assets, add a proxy to track references
+ if (typeof value === 'object' && 'ASTRO_ASSET' in value) {
+ const { ASTRO_ASSET, ...asset } = value;
+ asset.fsPath = ASTRO_ASSET;
+ return getProxyCode(asset, isSSR);
+ }
+ });
+ } catch (e) {
+ if (e instanceof Error) {
+ throw new AstroError({
+ ...AstroErrorData.UnsupportedConfigTransformError,
+ message: AstroErrorData.UnsupportedConfigTransformError.message(e.message),
+ stack: e.stack,
+ });
+ } else {
+ throw new AstroError({
+ name: 'PluginContentImportsError',
+ message: 'Unexpected error processing content collection data.',
+ });
+ }
+ }
+}
diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
new file mode 100644
index 000000000..fcb6c4371
--- /dev/null
+++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
@@ -0,0 +1,370 @@
+import nodeFs from 'node:fs';
+import { extname } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { dataToEsm } from '@rollup/pluginutils';
+import glob from 'fast-glob';
+import pLimit from 'p-limit';
+import type { Plugin, ViteDevServer } from 'vite';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { rootRelativePath } from '../core/viteUtils.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroPluginMetadata } from '../vite-plugin-astro/index.js';
+import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js';
+import {
+ ASSET_IMPORTS_FILE,
+ ASSET_IMPORTS_RESOLVED_STUB_ID,
+ ASSET_IMPORTS_VIRTUAL_ID,
+ CONTENT_FLAG,
+ CONTENT_RENDER_FLAG,
+ DATA_FLAG,
+ DATA_STORE_VIRTUAL_ID,
+ MODULES_IMPORTS_FILE,
+ MODULES_MJS_ID,
+ MODULES_MJS_VIRTUAL_ID,
+ RESOLVED_DATA_STORE_VIRTUAL_ID,
+ RESOLVED_VIRTUAL_MODULE_ID,
+ VIRTUAL_MODULE_ID,
+} from './consts.js';
+import { getDataStoreFile } from './content-layer.js';
+import {
+ type ContentLookupMap,
+ getContentEntryIdAndSlug,
+ getContentPaths,
+ getDataEntryExts,
+ getDataEntryId,
+ getEntryCollectionName,
+ getEntryConfigByExtMap,
+ getEntrySlug,
+ getEntryType,
+ getExtGlob,
+ globWithUnderscoresIgnored,
+ isDeferredModule,
+} from './utils.js';
+
+interface AstroContentVirtualModPluginParams {
+ settings: AstroSettings;
+ fs: typeof nodeFs;
+}
+
+function invalidateDataStore(server: ViteDevServer) {
+ const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
+ if (module) {
+ server.moduleGraph.invalidateModule(module);
+ }
+ server.ws.send({
+ type: 'full-reload',
+ path: '*',
+ });
+}
+
+export function astroContentVirtualModPlugin({
+ settings,
+ fs,
+}: AstroContentVirtualModPluginParams): Plugin {
+ let dataStoreFile: URL;
+ let devServer: ViteDevServer;
+ return {
+ name: 'astro-content-virtual-mod-plugin',
+ enforce: 'pre',
+ config(_, env) {
+ dataStoreFile = getDataStoreFile(settings, env.command === 'serve');
+ },
+ buildStart() {
+ if (devServer) {
+ // We defer adding the data store file to the watcher until the server is ready
+ devServer.watcher.add(fileURLToPath(dataStoreFile));
+ // Manually invalidate the data store to avoid a race condition in file watching
+ invalidateDataStore(devServer);
+ }
+ },
+ async resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ if (id === DATA_STORE_VIRTUAL_ID) {
+ return RESOLVED_DATA_STORE_VIRTUAL_ID;
+ }
+
+ if (isDeferredModule(id)) {
+ const [, query] = id.split('?');
+ const params = new URLSearchParams(query);
+ const fileName = params.get('fileName');
+ let importPath = undefined;
+ if (fileName && URL.canParse(fileName, settings.config.root.toString())) {
+ importPath = fileURLToPath(new URL(fileName, settings.config.root));
+ }
+ if (importPath) {
+ return await this.resolve(`${importPath}?${CONTENT_RENDER_FLAG}`);
+ }
+ }
+
+ if (id === MODULES_MJS_ID) {
+ const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
+ if (fs.existsSync(modules)) {
+ return fileURLToPath(modules);
+ }
+ return MODULES_MJS_VIRTUAL_ID;
+ }
+
+ if (id === ASSET_IMPORTS_VIRTUAL_ID) {
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
+ if (fs.existsSync(assetImportsFile)) {
+ return fileURLToPath(assetImportsFile);
+ }
+ return ASSET_IMPORTS_RESOLVED_STUB_ID;
+ }
+ },
+ async load(id, args) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ const lookupMap = settings.config.legacy.collections
+ ? await generateLookupMap({
+ settings,
+ fs,
+ })
+ : {};
+ const isClient = !args?.ssr;
+ const code = await generateContentEntryFile({
+ settings,
+ fs,
+ lookupMap,
+ isClient,
+ });
+
+ const astro = createDefaultAstroMetadata();
+ astro.propagation = 'in-tree';
+ return {
+ code,
+ meta: {
+ astro,
+ } satisfies AstroPluginMetadata,
+ };
+ }
+ if (id === RESOLVED_DATA_STORE_VIRTUAL_ID) {
+ if (!fs.existsSync(dataStoreFile)) {
+ return 'export default new Map()';
+ }
+ const jsonData = await fs.promises.readFile(dataStoreFile, 'utf-8');
+
+ try {
+ const parsed = JSON.parse(jsonData);
+ return {
+ code: dataToEsm(parsed, {
+ compact: true,
+ }),
+ map: { mappings: '' },
+ };
+ } catch (err) {
+ const message = 'Could not parse JSON file';
+ this.error({ message, id, cause: err });
+ }
+ }
+
+ if (id === ASSET_IMPORTS_RESOLVED_STUB_ID) {
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
+ if (!fs.existsSync(assetImportsFile)) {
+ return 'export default new Map()';
+ }
+ return fs.readFileSync(assetImportsFile, 'utf-8');
+ }
+
+ if (id === MODULES_MJS_VIRTUAL_ID) {
+ const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
+ if (!fs.existsSync(modules)) {
+ return 'export default new Map()';
+ }
+ return fs.readFileSync(modules, 'utf-8');
+ }
+ },
+
+ configureServer(server) {
+ devServer = server;
+ const dataStorePath = fileURLToPath(dataStoreFile);
+ // If the datastore file changes, invalidate the virtual module
+
+ server.watcher.on('add', (addedPath) => {
+ if (addedPath === dataStorePath) {
+ invalidateDataStore(server);
+ }
+ });
+
+ server.watcher.on('change', (changedPath) => {
+ if (changedPath === dataStorePath) {
+ invalidateDataStore(server);
+ }
+ });
+ },
+ };
+}
+
+export async function generateContentEntryFile({
+ settings,
+ lookupMap,
+ isClient,
+}: {
+ settings: AstroSettings;
+ fs: typeof nodeFs;
+ lookupMap: ContentLookupMap;
+ isClient: boolean;
+}) {
+ const contentPaths = getContentPaths(settings.config);
+ const relContentDir = rootRelativePath(settings.config.root, contentPaths.contentDir);
+
+ let contentEntryGlobResult = '""';
+ let dataEntryGlobResult = '""';
+ let renderEntryGlobResult = '""';
+ if (settings.config.legacy.collections) {
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const contentEntryExts = [...contentEntryConfigByExt.keys()];
+ const dataEntryExts = getDataEntryExts(settings);
+ const createGlob = (value: string[], flag: string) =>
+ `import.meta.glob(${JSON.stringify(value)}, { query: { ${flag}: true } })`;
+ contentEntryGlobResult = createGlob(
+ globWithUnderscoresIgnored(relContentDir, contentEntryExts),
+ CONTENT_FLAG,
+ );
+ dataEntryGlobResult = createGlob(
+ globWithUnderscoresIgnored(relContentDir, dataEntryExts),
+ DATA_FLAG,
+ );
+ renderEntryGlobResult = createGlob(
+ globWithUnderscoresIgnored(relContentDir, contentEntryExts),
+ CONTENT_RENDER_FLAG,
+ );
+ }
+
+ let virtualModContents: string;
+ if (isClient) {
+ throw new AstroError({
+ ...AstroErrorData.ServerOnlyModule,
+ message: AstroErrorData.ServerOnlyModule.message('astro:content'),
+ });
+ } else {
+ virtualModContents = nodeFs
+ .readFileSync(contentPaths.virtualModTemplate, 'utf-8')
+ .replace('@@CONTENT_DIR@@', relContentDir)
+ .replace("'@@CONTENT_ENTRY_GLOB_PATH@@'", contentEntryGlobResult)
+ .replace("'@@DATA_ENTRY_GLOB_PATH@@'", dataEntryGlobResult)
+ .replace("'@@RENDER_ENTRY_GLOB_PATH@@'", renderEntryGlobResult)
+ .replace('/* @@LOOKUP_MAP_ASSIGNMENT@@ */', `lookupMap = ${JSON.stringify(lookupMap)};`);
+ }
+
+ return virtualModContents;
+}
+
+/**
+ * Generate a map from a collection + slug to the local file path.
+ * This is used internally to resolve entry imports when using `getEntry()`.
+ * @see `templates/content/module.mjs`
+ */
+export async function generateLookupMap({
+ settings,
+ fs,
+}: {
+ settings: AstroSettings;
+ fs: typeof nodeFs;
+}) {
+ const { root } = settings.config;
+ const contentPaths = getContentPaths(settings.config);
+ const relContentDir = rootRelativePath(root, contentPaths.contentDir, false);
+
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const dataEntryExts = getDataEntryExts(settings);
+
+ const { contentDir } = contentPaths;
+
+ const contentEntryExts = [...contentEntryConfigByExt.keys()];
+
+ let lookupMap: ContentLookupMap = {};
+ const contentGlob = await glob(
+ `${relContentDir}**/*${getExtGlob([...dataEntryExts, ...contentEntryExts])}`,
+ {
+ absolute: true,
+ cwd: fileURLToPath(root),
+ fs,
+ },
+ );
+
+ // Run 10 at a time to prevent `await getEntrySlug` from accessing the filesystem all at once.
+ // Each await shouldn't take too long for the work to be noticeably slow too.
+ const limit = pLimit(10);
+ const promises: Promise<void>[] = [];
+
+ for (const filePath of contentGlob) {
+ promises.push(
+ limit(async () => {
+ const entryType = getEntryType(filePath, contentPaths, contentEntryExts, dataEntryExts);
+ // Globbed ignored or unsupported entry.
+ // Logs warning during type generation, should ignore in lookup map.
+ if (entryType !== 'content' && entryType !== 'data') return;
+
+ const collection = getEntryCollectionName({ contentDir, entry: pathToFileURL(filePath) });
+ if (!collection) throw UnexpectedLookupMapError;
+
+ if (lookupMap[collection]?.type && lookupMap[collection].type !== entryType) {
+ throw new AstroError({
+ ...AstroErrorData.MixedContentDataCollectionError,
+ message: AstroErrorData.MixedContentDataCollectionError.message(collection),
+ });
+ }
+
+ if (entryType === 'content') {
+ const contentEntryType = contentEntryConfigByExt.get(extname(filePath));
+ if (!contentEntryType) throw UnexpectedLookupMapError;
+
+ const { id, slug: generatedSlug } = getContentEntryIdAndSlug({
+ entry: pathToFileURL(filePath),
+ contentDir,
+ collection,
+ });
+ const slug = await getEntrySlug({
+ id,
+ collection,
+ generatedSlug,
+ fs,
+ fileUrl: pathToFileURL(filePath),
+ contentEntryType,
+ });
+ if (lookupMap[collection]?.entries?.[slug]) {
+ throw new AstroError({
+ ...AstroErrorData.DuplicateContentEntrySlugError,
+ message: AstroErrorData.DuplicateContentEntrySlugError.message(
+ collection,
+ slug,
+ lookupMap[collection].entries[slug],
+ rootRelativePath(root, filePath),
+ ),
+ hint:
+ slug !== generatedSlug
+ ? `Check the \`slug\` frontmatter property in **${id}**.`
+ : undefined,
+ });
+ }
+ lookupMap[collection] = {
+ type: 'content',
+ entries: {
+ ...lookupMap[collection]?.entries,
+ [slug]: rootRelativePath(root, filePath),
+ },
+ };
+ } else {
+ const id = getDataEntryId({ entry: pathToFileURL(filePath), contentDir, collection });
+ lookupMap[collection] = {
+ type: 'data',
+ entries: {
+ ...lookupMap[collection]?.entries,
+ [id]: rootRelativePath(root, filePath),
+ },
+ };
+ }
+ }),
+ );
+ }
+
+ await Promise.all(promises);
+ return lookupMap;
+}
+
+const UnexpectedLookupMapError = new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: `Unexpected error while parsing content entry IDs and slugs.`,
+});
diff --git a/packages/astro/src/content/watcher.ts b/packages/astro/src/content/watcher.ts
new file mode 100644
index 000000000..a1ae0284e
--- /dev/null
+++ b/packages/astro/src/content/watcher.ts
@@ -0,0 +1,62 @@
+import type { FSWatcher } from 'vite';
+
+type WatchEventName = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
+type WatchEventCallback = (path: string) => void;
+
+export type WrappedWatcher = FSWatcher & {
+ removeAllTrackedListeners(): void;
+};
+
+// This lets us use the standard Vite FSWatcher, but also track all listeners added by the content loaders
+// We do this so we can remove them all when we re-sync.
+export function createWatcherWrapper(watcher: FSWatcher): WrappedWatcher {
+ const listeners = new Map<WatchEventName, Set<WatchEventCallback>>();
+
+ const handler: ProxyHandler<FSWatcher> = {
+ get(target, prop, receiver) {
+ // Intercept the 'on' method and track the listener
+ if (prop === 'on') {
+ return function (event: WatchEventName, callback: WatchEventCallback) {
+ if (!listeners.has(event)) {
+ listeners.set(event, new Set());
+ }
+
+ // Track the listener
+ listeners.get(event)!.add(callback);
+
+ // Call the original method
+ return Reflect.get(target, prop, receiver).call(target, event, callback);
+ };
+ }
+
+ // Intercept the 'off' method
+ if (prop === 'off') {
+ return function (event: WatchEventName, callback: WatchEventCallback) {
+ // Remove from our tracking
+ listeners.get(event)?.delete(callback);
+
+ // Call the original method
+ return Reflect.get(target, prop, receiver).call(target, event, callback);
+ };
+ }
+
+ // Adds a function to remove all listeners added by us
+ if (prop === 'removeAllTrackedListeners') {
+ return function () {
+ for (const [event, callbacks] of listeners.entries()) {
+ for (const callback of callbacks) {
+ target.off(event, callback);
+ }
+ callbacks.clear();
+ }
+ listeners.clear();
+ };
+ }
+
+ // Return original method/property for everything else
+ return Reflect.get(target, prop, receiver);
+ },
+ };
+
+ return new Proxy(watcher, handler) as WrappedWatcher;
+}
diff --git a/packages/astro/src/core/README.md b/packages/astro/src/core/README.md
new file mode 100644
index 000000000..bc04c3501
--- /dev/null
+++ b/packages/astro/src/core/README.md
@@ -0,0 +1,53 @@
+# `core/`
+
+Code that executes directly on Node (not processed by vite). Contains the main Astro logic for the `build`, `dev`, `preview`, and `sync` commands, and also manages the lifecycle of the Vite server.
+
+The `core/index.ts` module exports the CLI commands as functions and is the main entrypoint of the `astro` package.
+
+```ts
+import { dev, build, preview, sync } from 'astro';
+```
+
+[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
+
+```
+ Pages
+ used by /
+ /
+ creates /
+ App --------- AppPipeline AstroGlobal
+ \ implements /
+ \ creates /
+ creates impl.\ provided to /
+vite-plugin-astro-server --------- DevPipeline ------ Pipeline ------------- RenderContext Middleware
+ / \ used by /
+ / creates \ /
+ creates / implements \ /
+ AstroBuilder --------- BuildPipeline APIContext
+ \
+ \
+ used by \
+ Endpoints
+```
+
+## `App`
+
+## `vite-plugin-astro-server` (see `../vite-plugin-astro-server/`)
+
+## `AstroBuilder`
+
+## `Pipeline`
+
+The pipeline is an interface representing data that stays unchanged throughout the duration of the server or build. For example: the user configuration, the list of pages and endpoints in the project, and environment-specific way of gathering scripts and styles.
+
+There are 3 implementations of the pipeline:
+
+- `DevPipeline`: in-use during the `astro dev` CLI command. Created and used by `vite-plugin-astro-server`, and then forwarded to other internals.
+- `BuildPipeline`: in-use during the `astro build` command in `"static"` mode, and for prerendering in `"server"` and `"hybrid"` output modes. See `core/build/`.
+- `AppPipeline`: in-use during production server(less) deployments. Created and used by `App` (see `core/app/`), and then forwarded to other internals.
+
+All 3 expose a common, environment-agnostic interface which is used by the rest of the internals, most notably by `RenderContext`.
+
+## `RenderContext`
+
+Each request is rendered using a `RenderContext`. It manages data unique to each request. For example: the parsed `URL`, internationalization data, the `locals` object, and the route that matched the request. It is responsible for executing middleware, calling endpoints, and rendering pages by gathering necessary data from a `Pipeline`.
diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts
new file mode 100644
index 000000000..2a5922bca
--- /dev/null
+++ b/packages/astro/src/core/app/common.ts
@@ -0,0 +1,39 @@
+import { decodeKey } from '../encryption.js';
+import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
+import { deserializeRouteData } from '../routing/manifest/serialization.js';
+import type { RouteInfo, SSRManifest, SerializedSSRManifest } from './types.js';
+
+export function deserializeManifest(serializedManifest: SerializedSSRManifest): SSRManifest {
+ const routes: RouteInfo[] = [];
+ for (const serializedRoute of serializedManifest.routes) {
+ routes.push({
+ ...serializedRoute,
+ routeData: deserializeRouteData(serializedRoute.routeData),
+ });
+
+ const route = serializedRoute as unknown as RouteInfo;
+ route.routeData = deserializeRouteData(serializedRoute.routeData);
+ }
+
+ const assets = new Set<string>(serializedManifest.assets);
+ const componentMetadata = new Map(serializedManifest.componentMetadata);
+ const inlinedScripts = new Map(serializedManifest.inlinedScripts);
+ const clientDirectives = new Map(serializedManifest.clientDirectives);
+ const serverIslandNameMap = new Map(serializedManifest.serverIslandNameMap);
+ const key = decodeKey(serializedManifest.key);
+
+ return {
+ // in case user middleware exists, this no-op middleware will be reassigned (see plugin-ssr.ts)
+ middleware() {
+ return { onRequest: NOOP_MIDDLEWARE_FN };
+ },
+ ...serializedManifest,
+ assets,
+ componentMetadata,
+ inlinedScripts,
+ clientDirectives,
+ routes,
+ serverIslandNameMap,
+ key,
+ };
+}
diff --git a/packages/astro/src/core/app/createOutgoingHttpHeaders.ts b/packages/astro/src/core/app/createOutgoingHttpHeaders.ts
new file mode 100644
index 000000000..64d2d5135
--- /dev/null
+++ b/packages/astro/src/core/app/createOutgoingHttpHeaders.ts
@@ -0,0 +1,34 @@
+import type { OutgoingHttpHeaders } from 'node:http';
+
+/**
+ * Takes in a nullable WebAPI Headers object and produces a NodeJS OutgoingHttpHeaders object suitable for usage
+ * with ServerResponse.writeHead(..) or ServerResponse.setHeader(..)
+ *
+ * @param headers WebAPI Headers object
+ * @returns {OutgoingHttpHeaders} NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values
+ */
+export const createOutgoingHttpHeaders = (
+ headers: Headers | undefined | null,
+): OutgoingHttpHeaders | undefined => {
+ if (!headers) {
+ return undefined;
+ }
+
+ // at this point, a multi-value'd set-cookie header is invalid (it was concatenated as a single CSV, which is not valid for set-cookie)
+ const nodeHeaders: OutgoingHttpHeaders = Object.fromEntries(headers.entries());
+
+ if (Object.keys(nodeHeaders).length === 0) {
+ return undefined;
+ }
+
+ // if there is > 1 set-cookie header, we have to fix it to be an array of values
+ if (headers.has('set-cookie')) {
+ const cookieHeaders = headers.getSetCookie();
+ if (cookieHeaders.length > 1) {
+ // the Headers.entries() API already normalized all header names to lower case so we can safely index this as 'set-cookie'
+ nodeHeaders['set-cookie'] = cookieHeaders;
+ }
+ }
+
+ return nodeHeaders;
+};
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
new file mode 100644
index 000000000..543d82d0f
--- /dev/null
+++ b/packages/astro/src/core/app/index.ts
@@ -0,0 +1,541 @@
+import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
+import { normalizeTheLocale } from '../../i18n/index.js';
+import type { RoutesList } from '../../types/astro.js';
+import type { RouteData, SSRManifest } from '../../types/public/internal.js';
+import {
+ REROUTABLE_STATUS_CODES,
+ REROUTE_DIRECTIVE_HEADER,
+ clientAddressSymbol,
+ responseSentSymbol,
+} from '../constants.js';
+import { getSetCookiesFromResponse } from '../cookies/index.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { consoleLogDestination } from '../logger/console.js';
+import { AstroIntegrationLogger, Logger } from '../logger/core.js';
+import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
+import {
+ appendForwardSlash,
+ joinPaths,
+ prependForwardSlash,
+ removeTrailingForwardSlash,
+} from '../path.js';
+import { RenderContext } from '../render-context.js';
+import { createAssetLink } from '../render/ssr-element.js';
+import { redirectTemplate } from '../routing/3xx.js';
+import { ensure404Route } from '../routing/astro-designed-error-pages.js';
+import { createDefaultRoutes } from '../routing/default.js';
+import { matchRoute } from '../routing/match.js';
+import { type AstroSession, PERSIST_SYMBOL } from '../session.js';
+import { AppPipeline } from './pipeline.js';
+
+export { deserializeManifest } from './common.js';
+
+export interface RenderOptions {
+ /**
+ * Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
+ *
+ * When set to `true`, they will be added to the `Set-Cookie` header as comma-separated key=value pairs. You can use the standard `response.headers.getSetCookie()` API to read them individually.
+ *
+ * When set to `false`, the cookies will only be available from `App.getSetCookieFromResponse(response)`.
+ *
+ * @default {false}
+ */
+ addCookieHeader?: boolean;
+
+ /**
+ * The client IP address that will be made available as `Astro.clientAddress` in pages, and as `ctx.clientAddress` in API routes and middleware.
+ *
+ * Default: `request[Symbol.for("astro.clientAddress")]`
+ */
+ clientAddress?: string;
+
+ /**
+ * The mutable object that will be made available as `Astro.locals` in pages, and as `ctx.locals` in API routes and middleware.
+ */
+ locals?: object;
+
+ /**
+ * **Advanced API**: you probably do not need to use this.
+ *
+ * Default: `app.match(request)`
+ */
+ routeData?: RouteData;
+}
+
+export interface RenderErrorOptions {
+ locals?: App.Locals;
+ routeData?: RouteData;
+ response?: Response;
+ status: 404 | 500;
+ /**
+ * Whether to skip middleware while rendering the error page. Defaults to false.
+ */
+ skipMiddleware?: boolean;
+ /**
+ * Allows passing an error to 500.astro. It will be available through `Astro.props.error`.
+ */
+ error?: unknown;
+ clientAddress: string | undefined;
+}
+
+export class App {
+ #manifest: SSRManifest;
+ #manifestData: RoutesList;
+ #logger = new Logger({
+ dest: consoleLogDestination,
+ level: 'info',
+ });
+ #baseWithoutTrailingSlash: string;
+ #pipeline: AppPipeline;
+ #adapterLogger: AstroIntegrationLogger;
+ #renderOptionsDeprecationWarningShown = false;
+
+ constructor(manifest: SSRManifest, streaming = true) {
+ this.#manifest = manifest;
+ this.#manifestData = {
+ routes: manifest.routes.map((route) => route.routeData),
+ };
+ // This is necessary to allow running middlewares for 404 in SSR. There's special handling
+ // to return the host 404 if the user doesn't provide a custom 404
+ ensure404Route(this.#manifestData);
+ this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
+ this.#pipeline = this.#createPipeline(this.#manifestData, streaming);
+ this.#adapterLogger = new AstroIntegrationLogger(
+ this.#logger.options,
+ this.#manifest.adapterName,
+ );
+ }
+
+ getAdapterLogger(): AstroIntegrationLogger {
+ return this.#adapterLogger;
+ }
+
+ /**
+ * Creates a pipeline by reading the stored manifest
+ *
+ * @param manifestData
+ * @param streaming
+ * @private
+ */
+ #createPipeline(manifestData: RoutesList, streaming = false) {
+ return AppPipeline.create(manifestData, {
+ logger: this.#logger,
+ manifest: this.#manifest,
+ runtimeMode: 'production',
+ renderers: this.#manifest.renderers,
+ defaultRoutes: createDefaultRoutes(this.#manifest),
+ resolve: async (specifier: string) => {
+ if (!(specifier in this.#manifest.entryModules)) {
+ throw new Error(`Unable to resolve [${specifier}]`);
+ }
+ const bundlePath = this.#manifest.entryModules[specifier];
+ if (bundlePath.startsWith('data:') || bundlePath.length === 0) {
+ return bundlePath;
+ } else {
+ return createAssetLink(bundlePath, this.#manifest.base, this.#manifest.assetsPrefix);
+ }
+ },
+ serverLike: true,
+ streaming,
+ });
+ }
+
+ set setManifestData(newManifestData: RoutesList) {
+ this.#manifestData = newManifestData;
+ }
+
+ removeBase(pathname: string) {
+ if (pathname.startsWith(this.#manifest.base)) {
+ return pathname.slice(this.#baseWithoutTrailingSlash.length + 1);
+ }
+ return pathname;
+ }
+
+ /**
+ * It removes the base from the request URL, prepends it with a forward slash and attempts to decoded it.
+ *
+ * If the decoding fails, it logs the error and return the pathname as is.
+ * @param request
+ * @private
+ */
+ #getPathnameFromRequest(request: Request): string {
+ const url = new URL(request.url);
+ const pathname = prependForwardSlash(this.removeBase(url.pathname));
+ try {
+ return decodeURI(pathname);
+ } catch (e: any) {
+ this.getAdapterLogger().error(e.toString());
+ return pathname;
+ }
+ }
+
+ match(request: Request): RouteData | undefined {
+ const url = new URL(request.url);
+ // ignore requests matching public assets
+ if (this.#manifest.assets.has(url.pathname)) return undefined;
+ let pathname = this.#computePathnameFromDomain(request);
+ if (!pathname) {
+ pathname = prependForwardSlash(this.removeBase(url.pathname));
+ }
+ let routeData = matchRoute(pathname, this.#manifestData);
+
+ // missing routes fall-through, pre rendered are handled by static layer
+ if (!routeData || routeData.prerender) return undefined;
+ return routeData;
+ }
+
+ #computePathnameFromDomain(request: Request): string | undefined {
+ let pathname: string | undefined = undefined;
+ const url = new URL(request.url);
+
+ if (
+ this.#manifest.i18n &&
+ (this.#manifest.i18n.strategy === 'domains-prefix-always' ||
+ this.#manifest.i18n.strategy === 'domains-prefix-other-locales' ||
+ this.#manifest.i18n.strategy === 'domains-prefix-always-no-redirect')
+ ) {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
+ let host = request.headers.get('X-Forwarded-Host');
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
+ let protocol = request.headers.get('X-Forwarded-Proto');
+ if (protocol) {
+ // this header doesn't have a colon at the end, so we add to be in line with URL#protocol, which does have it
+ protocol = protocol + ':';
+ } else {
+ // we fall back to the protocol of the request
+ protocol = url.protocol;
+ }
+ if (!host) {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host
+ host = request.headers.get('Host');
+ }
+ // If we don't have a host and a protocol, it's impossible to proceed
+ if (host && protocol) {
+ // The header might have a port in their name, so we remove it
+ host = host.split(':')[0];
+ try {
+ let locale;
+ const hostAsUrl = new URL(`${protocol}//${host}`);
+ for (const [domainKey, localeValue] of Object.entries(
+ this.#manifest.i18n.domainLookupTable,
+ )) {
+ // This operation should be safe because we force the protocol via zod inside the configuration
+ // If not, then it means that the manifest was tampered
+ const domainKeyAsUrl = new URL(domainKey);
+
+ if (
+ hostAsUrl.host === domainKeyAsUrl.host &&
+ hostAsUrl.protocol === domainKeyAsUrl.protocol
+ ) {
+ locale = localeValue;
+ break;
+ }
+ }
+
+ if (locale) {
+ pathname = prependForwardSlash(
+ joinPaths(normalizeTheLocale(locale), this.removeBase(url.pathname)),
+ );
+ if (url.pathname.endsWith('/')) {
+ pathname = appendForwardSlash(pathname);
+ }
+ }
+ } catch (e: any) {
+ this.#logger.error(
+ 'router',
+ `Astro tried to parse ${protocol}//${host} as an URL, but it threw a parsing error. Check the X-Forwarded-Host and X-Forwarded-Proto headers.`,
+ );
+ this.#logger.error('router', `Error: ${e}`);
+ }
+ }
+ }
+ return pathname;
+ }
+
+ #redirectTrailingSlash(pathname: string): string {
+ const { trailingSlash } = this.#manifest;
+
+ // Ignore root and internal paths
+ if (pathname === '/' || pathname.startsWith('/_')) {
+ return pathname;
+ }
+
+ // Redirect multiple trailing slashes to collapsed path
+ const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== 'never');
+ if (path !== pathname) {
+ return path;
+ }
+
+ if (trailingSlash === 'ignore') {
+ return pathname;
+ }
+
+ if (trailingSlash === 'always' && !hasFileExtension(pathname)) {
+ return appendForwardSlash(pathname);
+ }
+ if (trailingSlash === 'never') {
+ return removeTrailingForwardSlash(pathname);
+ }
+
+ return pathname;
+ }
+
+ async render(request: Request, renderOptions?: RenderOptions): Promise<Response> {
+ let routeData: RouteData | undefined;
+ let locals: object | undefined;
+ let clientAddress: string | undefined;
+ let addCookieHeader: boolean | undefined;
+ const url = new URL(request.url);
+ const redirect = this.#redirectTrailingSlash(url.pathname);
+
+ if (redirect !== url.pathname) {
+ const status = request.method === 'GET' ? 301 : 308;
+ return new Response(redirectTemplate({ status, location: redirect, from: request.url }), {
+ status,
+ headers: {
+ location: redirect + url.search,
+ },
+ });
+ }
+
+ addCookieHeader = renderOptions?.addCookieHeader;
+ clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);
+ routeData = renderOptions?.routeData;
+ locals = renderOptions?.locals;
+
+ if (routeData) {
+ this.#logger.debug(
+ 'router',
+ 'The adapter ' + this.#manifest.adapterName + ' provided a custom RouteData for ',
+ request.url,
+ );
+ this.#logger.debug('router', 'RouteData:\n' + routeData);
+ }
+ if (locals) {
+ if (typeof locals !== 'object') {
+ const error = new AstroError(AstroErrorData.LocalsNotAnObject);
+ this.#logger.error(null, error.stack!);
+ return this.#renderError(request, { status: 500, error, clientAddress });
+ }
+ }
+ if (!routeData) {
+ routeData = this.match(request);
+ this.#logger.debug('router', 'Astro matched the following route for ' + request.url);
+ this.#logger.debug('router', 'RouteData:\n' + routeData);
+ }
+ if (!routeData) {
+ this.#logger.debug('router', "Astro hasn't found routes that match " + request.url);
+ this.#logger.debug('router', "Here's the available routes:\n", this.#manifestData);
+ return this.#renderError(request, { locals, status: 404, clientAddress });
+ }
+ const pathname = this.#getPathnameFromRequest(request);
+ const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
+
+ let response;
+ let session: AstroSession | undefined;
+ try {
+ // Load route module. We also catch its error here if it fails on initialization
+ const mod = await this.#pipeline.getModuleForRoute(routeData);
+
+ const renderContext = await RenderContext.create({
+ pipeline: this.#pipeline,
+ locals,
+ pathname,
+ request,
+ routeData,
+ status: defaultStatus,
+ clientAddress,
+ });
+ session = renderContext.session;
+ response = await renderContext.render(await mod.page());
+ } catch (err: any) {
+ this.#logger.error(null, err.stack || err.message || String(err));
+ return this.#renderError(request, { locals, status: 500, error: err, clientAddress });
+ } finally {
+ await session?.[PERSIST_SYMBOL]();
+ }
+
+ if (
+ REROUTABLE_STATUS_CODES.includes(response.status) &&
+ response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
+ ) {
+ return this.#renderError(request, {
+ locals,
+ response,
+ status: response.status as 404 | 500,
+ // We don't have an error to report here. Passing null means we pass nothing intentionally
+ // while undefined means there's no error
+ error: response.status === 500 ? null : undefined,
+ clientAddress,
+ });
+ }
+
+ // We remove internally-used header before we send the response to the user agent.
+ if (response.headers.has(REROUTE_DIRECTIVE_HEADER)) {
+ response.headers.delete(REROUTE_DIRECTIVE_HEADER);
+ }
+
+ if (addCookieHeader) {
+ for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) {
+ response.headers.append('set-cookie', setCookieHeaderValue);
+ }
+ }
+
+ Reflect.set(response, responseSentSymbol, true);
+ return response;
+ }
+
+ setCookieHeaders(response: Response) {
+ return getSetCookiesFromResponse(response);
+ }
+
+ /**
+ * Reads all the cookies written by `Astro.cookie.set()` onto the passed response.
+ * For example,
+ * ```ts
+ * for (const cookie_ of App.getSetCookieFromResponse(response)) {
+ * const cookie: string = cookie_
+ * }
+ * ```
+ * @param response The response to read cookies from.
+ * @returns An iterator that yields key-value pairs as equal-sign-separated strings.
+ */
+ static getSetCookieFromResponse = getSetCookiesFromResponse;
+
+ /**
+ * If it is a known error code, try sending the according page (e.g. 404.astro / 500.astro).
+ * This also handles pre-rendered /404 or /500 routes
+ */
+ async #renderError(
+ request: Request,
+ {
+ locals,
+ status,
+ response: originalResponse,
+ skipMiddleware = false,
+ error,
+ clientAddress,
+ }: RenderErrorOptions,
+ ): Promise<Response> {
+ const errorRoutePath = `/${status}${this.#manifest.trailingSlash === 'always' ? '/' : ''}`;
+ const errorRouteData = matchRoute(errorRoutePath, this.#manifestData);
+ const url = new URL(request.url);
+ if (errorRouteData) {
+ if (errorRouteData.prerender) {
+ const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : '';
+ const statusURL = new URL(
+ `${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`,
+ url,
+ );
+ if (statusURL.toString() !== request.url) {
+ const response = await fetch(statusURL.toString());
+
+ // response for /404.html and 500.html is 200, which is not meaningful
+ // so we create an override
+ const override = { status };
+
+ return this.#mergeResponses(response, originalResponse, override);
+ }
+ }
+ const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
+ let session: AstroSession | undefined;
+ try {
+ const renderContext = await RenderContext.create({
+ locals,
+ pipeline: this.#pipeline,
+ middleware: skipMiddleware ? NOOP_MIDDLEWARE_FN : undefined,
+ pathname: this.#getPathnameFromRequest(request),
+ request,
+ routeData: errorRouteData,
+ status,
+ props: { error },
+ clientAddress,
+ });
+ session = renderContext.session;
+ const response = await renderContext.render(await mod.page());
+ return this.#mergeResponses(response, originalResponse);
+ } catch {
+ // Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
+ if (skipMiddleware === false) {
+ return this.#renderError(request, {
+ locals,
+ status,
+ response: originalResponse,
+ skipMiddleware: true,
+ clientAddress,
+ });
+ }
+ } finally {
+ await session?.[PERSIST_SYMBOL]();
+ }
+ }
+
+ const response = this.#mergeResponses(new Response(null, { status }), originalResponse);
+ Reflect.set(response, responseSentSymbol, true);
+ return response;
+ }
+
+ #mergeResponses(
+ newResponse: Response,
+ originalResponse?: Response,
+ override?: { status: 404 | 500 },
+ ) {
+ if (!originalResponse) {
+ if (override !== undefined) {
+ return new Response(newResponse.body, {
+ status: override.status,
+ statusText: newResponse.statusText,
+ headers: newResponse.headers,
+ });
+ }
+ return newResponse;
+ }
+
+ // If the new response did not have a meaningful status, an override may have been provided
+ // If the original status was 200 (default), override it with the new status (probably 404 or 500)
+ // Otherwise, the user set a specific status while rendering and we should respect that one
+ const status = override?.status
+ ? override.status
+ : originalResponse.status === 200
+ ? newResponse.status
+ : originalResponse.status;
+
+ try {
+ // this function could throw an error...
+ originalResponse.headers.delete('Content-type');
+ } catch {}
+ // we use a map to remove duplicates
+ const mergedHeaders = new Map([
+ ...Array.from(newResponse.headers),
+ ...Array.from(originalResponse.headers),
+ ]);
+ const newHeaders = new Headers();
+ for (const [name, value] of mergedHeaders) {
+ newHeaders.set(name, value);
+ }
+ return new Response(newResponse.body, {
+ status,
+ statusText: status === 200 ? newResponse.statusText : originalResponse.statusText,
+ // If you're looking at here for possible bugs, it means that it's not a bug.
+ // With the middleware, users can meddle with headers, and we should pass to the 404/500.
+ // If users see something weird, it's because they are setting some headers they should not.
+ //
+ // Although, we don't want it to replace the content-type, because the error page must return `text/html`
+ headers: newHeaders,
+ });
+ }
+
+ #getDefaultStatusCode(routeData: RouteData, pathname: string): number {
+ if (!routeData.pattern.test(pathname)) {
+ for (const fallbackRoute of routeData.fallbackRoutes) {
+ if (fallbackRoute.pattern.test(pathname)) {
+ return 302;
+ }
+ }
+ }
+ const route = removeTrailingForwardSlash(routeData.route);
+ if (route.endsWith('/404')) return 404;
+ if (route.endsWith('/500')) return 500;
+ return 200;
+ }
+}
diff --git a/packages/astro/src/core/app/middlewares.ts b/packages/astro/src/core/app/middlewares.ts
new file mode 100644
index 000000000..7c589f0c4
--- /dev/null
+++ b/packages/astro/src/core/app/middlewares.ts
@@ -0,0 +1,67 @@
+import type { MiddlewareHandler } from '../../types/public/common.js';
+import { defineMiddleware } from '../middleware/index.js';
+
+/**
+ * Content types that can be passed when sending a request via a form
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype
+ * @private
+ */
+const FORM_CONTENT_TYPES = [
+ 'application/x-www-form-urlencoded',
+ 'multipart/form-data',
+ 'text/plain',
+];
+
+/**
+ * Returns a middleware function in charge to check the `origin` header.
+ *
+ * @private
+ */
+export function createOriginCheckMiddleware(): MiddlewareHandler {
+ return defineMiddleware((context, next) => {
+ const { request, url, isPrerendered } = context;
+ // Prerendered pages should be excluded
+ if (isPrerendered) {
+ return next();
+ }
+ if (request.method === 'GET') {
+ return next();
+ }
+ const sameOrigin =
+ (request.method === 'POST' ||
+ request.method === 'PUT' ||
+ request.method === 'PATCH' ||
+ request.method === 'DELETE') &&
+ request.headers.get('origin') === url.origin;
+
+ const hasContentType = request.headers.has('content-type');
+ if (hasContentType) {
+ const formLikeHeader = hasFormLikeHeader(request.headers.get('content-type'));
+ if (formLikeHeader && !sameOrigin) {
+ return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
+ status: 403,
+ });
+ }
+ } else {
+ if (!sameOrigin) {
+ return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
+ status: 403,
+ });
+ }
+ }
+
+ return next();
+ });
+}
+
+function hasFormLikeHeader(contentType: string | null): boolean {
+ if (contentType) {
+ for (const FORM_CONTENT_TYPE of FORM_CONTENT_TYPES) {
+ if (contentType.toLowerCase().includes(FORM_CONTENT_TYPE)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts
new file mode 100644
index 000000000..79e649ed5
--- /dev/null
+++ b/packages/astro/src/core/app/node.ts
@@ -0,0 +1,228 @@
+import fs from 'node:fs';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import { Http2ServerResponse } from 'node:http2';
+import type { RouteData } from '../../types/public/internal.js';
+import { clientAddressSymbol } from '../constants.js';
+import { deserializeManifest } from './common.js';
+import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
+import { App } from './index.js';
+import type { RenderOptions } from './index.js';
+import type { SSRManifest, SerializedSSRManifest } from './types.js';
+
+export { apply as applyPolyfills } from '../polyfill.js';
+
+/**
+ * Allow the request body to be explicitly overridden. For example, this
+ * is used by the Express JSON middleware.
+ */
+interface NodeRequest extends IncomingMessage {
+ body?: unknown;
+}
+
+export class NodeApp extends App {
+ match(req: NodeRequest | Request) {
+ if (!(req instanceof Request)) {
+ req = NodeApp.createRequest(req, {
+ skipBody: true,
+ });
+ }
+ return super.match(req);
+ }
+ render(request: NodeRequest | Request, options?: RenderOptions): Promise<Response>;
+ /**
+ * @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties.
+ * See https://github.com/withastro/astro/pull/9199 for more information.
+ */
+ render(request: NodeRequest | Request, routeData?: RouteData, locals?: object): Promise<Response>;
+ render(
+ req: NodeRequest | Request,
+ routeDataOrOptions?: RouteData | RenderOptions,
+ maybeLocals?: object,
+ ) {
+ if (!(req instanceof Request)) {
+ req = NodeApp.createRequest(req);
+ }
+ // @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible.
+ return super.render(req, routeDataOrOptions, maybeLocals);
+ }
+
+ /**
+ * Converts a NodeJS IncomingMessage into a web standard Request.
+ * ```js
+ * import { NodeApp } from 'astro/app/node';
+ * import { createServer } from 'node:http';
+ *
+ * const server = createServer(async (req, res) => {
+ * const request = NodeApp.createRequest(req);
+ * const response = await app.render(request);
+ * await NodeApp.writeResponse(response, res);
+ * })
+ * ```
+ */
+ static createRequest(req: NodeRequest, { skipBody = false } = {}): Request {
+ const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted;
+
+ // Parses multiple header and returns first value if available.
+ const getFirstForwardedValue = (multiValueHeader?: string | string[]) => {
+ return multiValueHeader
+ ?.toString()
+ ?.split(',')
+ .map((e) => e.trim())?.[0];
+ };
+
+ // Get the used protocol between the end client and first proxy.
+ // NOTE: Some proxies append values with spaces and some do not.
+ // We need to handle it here and parse the header correctly.
+ // @example "https, http,http" => "http"
+ const forwardedProtocol = getFirstForwardedValue(req.headers['x-forwarded-proto']);
+ const protocol = forwardedProtocol ?? (isEncrypted ? 'https' : 'http');
+
+ // @example "example.com,www2.example.com" => "example.com"
+ const forwardedHostname = getFirstForwardedValue(req.headers['x-forwarded-host']);
+ const hostname = forwardedHostname ?? req.headers.host ?? req.headers[':authority'];
+
+ // @example "443,8080,80" => "443"
+ const port = getFirstForwardedValue(req.headers['x-forwarded-port']);
+
+ const portInHostname = typeof hostname === 'string' && /:\d+$/.test(hostname);
+ const hostnamePort = portInHostname ? hostname : `${hostname}${port ? `:${port}` : ''}`;
+
+ const url = `${protocol}://${hostnamePort}${req.url}`;
+ const options: RequestInit = {
+ method: req.method || 'GET',
+ headers: makeRequestHeaders(req),
+ };
+ const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false;
+ if (bodyAllowed) {
+ Object.assign(options, makeRequestBody(req));
+ }
+
+ const request = new Request(url, options);
+
+ // Get the IP of end client behind the proxy.
+ // @example "1.1.1.1,8.8.8.8" => "1.1.1.1"
+ const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']);
+ const clientIp = forwardedClientIp || req.socket?.remoteAddress;
+ if (clientIp) {
+ Reflect.set(request, clientAddressSymbol, clientIp);
+ }
+
+ return request;
+ }
+
+ /**
+ * Streams a web-standard Response into a NodeJS Server Response.
+ * ```js
+ * import { NodeApp } from 'astro/app/node';
+ * import { createServer } from 'node:http';
+ *
+ * const server = createServer(async (req, res) => {
+ * const request = NodeApp.createRequest(req);
+ * const response = await app.render(request);
+ * await NodeApp.writeResponse(response, res);
+ * })
+ * ```
+ * @param source WhatWG Response
+ * @param destination NodeJS ServerResponse
+ */
+ static async writeResponse(source: Response, destination: ServerResponse) {
+ const { status, headers, body, statusText } = source;
+ // HTTP/2 doesn't support statusMessage
+ if (!(destination instanceof Http2ServerResponse)) {
+ destination.statusMessage = statusText;
+ }
+ destination.writeHead(status, createOutgoingHttpHeaders(headers));
+ if (!body) return destination.end();
+ try {
+ const reader = body.getReader();
+ destination.on('close', () => {
+ // Cancelling the reader may reject not just because of
+ // an error in the ReadableStream's cancel callback, but
+ // also because of an error anywhere in the stream.
+ reader.cancel().catch((err) => {
+ console.error(
+ `There was an uncaught error in the middle of the stream while rendering ${destination.req.url}.`,
+ err,
+ );
+ });
+ });
+ let result = await reader.read();
+ while (!result.done) {
+ destination.write(result.value);
+ result = await reader.read();
+ }
+ destination.end();
+ // the error will be logged by the "on end" callback above
+ } catch (err) {
+ destination.write('Internal server error', () => {
+ err instanceof Error ? destination.destroy(err) : destination.destroy();
+ });
+ }
+ }
+}
+
+function makeRequestHeaders(req: NodeRequest): Headers {
+ const headers = new Headers();
+ for (const [name, value] of Object.entries(req.headers)) {
+ if (value === undefined) {
+ continue;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ headers.append(name, item);
+ }
+ } else {
+ headers.append(name, value);
+ }
+ }
+ return headers;
+}
+
+function makeRequestBody(req: NodeRequest): RequestInit {
+ if (req.body !== undefined) {
+ if (typeof req.body === 'string' && req.body.length > 0) {
+ return { body: Buffer.from(req.body) };
+ }
+
+ if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length > 0) {
+ return { body: Buffer.from(JSON.stringify(req.body)) };
+ }
+
+ // This covers all async iterables including Readable and ReadableStream.
+ if (
+ typeof req.body === 'object' &&
+ req.body !== null &&
+ typeof (req.body as any)[Symbol.asyncIterator] !== 'undefined'
+ ) {
+ return asyncIterableToBodyProps(req.body as AsyncIterable<any>);
+ }
+ }
+
+ // Return default body.
+ return asyncIterableToBodyProps(req);
+}
+
+function asyncIterableToBodyProps(iterable: AsyncIterable<any>): RequestInit {
+ return {
+ // Node uses undici for the Request implementation. Undici accepts
+ // a non-standard async iterable for the body.
+ // @ts-expect-error
+ body: iterable,
+ // The duplex property is required when using a ReadableStream or async
+ // iterable for the body. The type definitions do not include the duplex
+ // property because they are not up-to-date.
+ duplex: 'half',
+ };
+}
+
+export async function loadManifest(rootFolder: URL): Promise<SSRManifest> {
+ const manifestFile = new URL('./manifest.json', rootFolder);
+ const rawManifest = await fs.promises.readFile(manifestFile, 'utf-8');
+ const serializedManifest: SerializedSSRManifest = JSON.parse(rawManifest);
+ return deserializeManifest(serializedManifest);
+}
+
+export async function loadApp(rootFolder: URL): Promise<NodeApp> {
+ const manifest = await loadManifest(rootFolder);
+ return new NodeApp(manifest);
+}
diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts
new file mode 100644
index 000000000..fe9b6257d
--- /dev/null
+++ b/packages/astro/src/core/app/pipeline.ts
@@ -0,0 +1,130 @@
+import type { ComponentInstance, RoutesList } from '../../types/astro.js';
+import type { RewritePayload } from '../../types/public/common.js';
+import type { RouteData, SSRElement, SSRResult } from '../../types/public/internal.js';
+import { Pipeline, type TryRewriteResult } from '../base-pipeline.js';
+import type { SinglePageBuiltModule } from '../build/types.js';
+import { RedirectSinglePageBuiltModule } from '../redirects/component.js';
+import { createModuleScriptElement, createStylesheetElementSet } from '../render/ssr-element.js';
+import { findRouteToRewrite } from '../routing/rewrite.js';
+
+export class AppPipeline extends Pipeline {
+ #manifestData: RoutesList | undefined;
+
+ static create(
+ manifestData: RoutesList,
+ {
+ logger,
+ manifest,
+ runtimeMode,
+ renderers,
+ resolve,
+ serverLike,
+ streaming,
+ defaultRoutes,
+ }: Pick<
+ AppPipeline,
+ | 'logger'
+ | 'manifest'
+ | 'runtimeMode'
+ | 'renderers'
+ | 'resolve'
+ | 'serverLike'
+ | 'streaming'
+ | 'defaultRoutes'
+ >,
+ ) {
+ const pipeline = new AppPipeline(
+ logger,
+ manifest,
+ runtimeMode,
+ renderers,
+ resolve,
+ serverLike,
+ streaming,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ defaultRoutes,
+ );
+ pipeline.#manifestData = manifestData;
+ return pipeline;
+ }
+
+ headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
+ const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
+ // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
+ const links = new Set<never>();
+ const scripts = new Set<SSRElement>();
+ const styles = createStylesheetElementSet(routeInfo?.styles ?? []);
+
+ for (const script of routeInfo?.scripts ?? []) {
+ if ('stage' in script) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.children,
+ });
+ }
+ } else {
+ scripts.add(createModuleScriptElement(script));
+ }
+ }
+ return { links, styles, scripts };
+ }
+
+ componentMetadata() {}
+
+ async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
+ const module = await this.getModuleForRoute(routeData);
+ return module.page();
+ }
+
+ async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
+ const { newUrl, pathname, routeData } = findRouteToRewrite({
+ payload,
+ request,
+ routes: this.manifest?.routes.map((r) => r.routeData),
+ trailingSlash: this.manifest.trailingSlash,
+ buildFormat: this.manifest.buildFormat,
+ base: this.manifest.base,
+ });
+
+ const componentInstance = await this.getComponentByRoute(routeData);
+ return { newUrl, pathname, componentInstance, routeData };
+ }
+
+ async getModuleForRoute(route: RouteData): Promise<SinglePageBuiltModule> {
+ for (const defaultRoute of this.defaultRoutes) {
+ if (route.component === defaultRoute.component) {
+ return {
+ page: () => Promise.resolve(defaultRoute.instance),
+ renderers: [],
+ };
+ }
+ }
+
+ if (route.type === 'redirect') {
+ return RedirectSinglePageBuiltModule;
+ } else {
+ if (this.manifest.pageMap) {
+ const importComponentInstance = this.manifest.pageMap.get(route.component);
+ if (!importComponentInstance) {
+ throw new Error(
+ `Unexpectedly unable to find a component instance for route ${route.route}`,
+ );
+ }
+ return await importComponentInstance();
+ } else if (this.manifest.pageModule) {
+ return this.manifest.pageModule;
+ }
+ throw new Error(
+ "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue.",
+ );
+ }
+ }
+}
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
new file mode 100644
index 000000000..a05230703
--- /dev/null
+++ b/packages/astro/src/core/app/types.ts
@@ -0,0 +1,109 @@
+import type { RoutingStrategies } from '../../i18n/utils.js';
+import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js';
+import type { AstroMiddlewareInstance } from '../../types/public/common.js';
+import type { AstroConfig, Locales, ResolvedSessionConfig } from '../../types/public/config.js';
+import type {
+ RouteData,
+ SSRComponentMetadata,
+ SSRLoadedRenderer,
+ SSRResult,
+} from '../../types/public/internal.js';
+import type { SinglePageBuiltModule } from '../build/types.js';
+
+export type ComponentPath = string;
+
+export type StylesheetAsset =
+ | { type: 'inline'; content: string }
+ | { type: 'external'; src: string };
+
+export interface RouteInfo {
+ routeData: RouteData;
+ file: string;
+ links: string[];
+ scripts: // Integration injected
+ (
+ | { children: string; stage: string }
+ // Hoisted
+ | { type: 'inline' | 'external'; value: string }
+ )[];
+ styles: StylesheetAsset[];
+}
+
+export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
+ routeData: SerializedRouteData;
+};
+
+export type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
+
+export type AssetsPrefix =
+ | string
+ | ({
+ fallback: string;
+ } & Record<string, string>)
+ | undefined;
+
+export type SSRManifest = {
+ hrefRoot: string;
+ adapterName: string;
+ routes: RouteInfo[];
+ site?: string;
+ base: string;
+ trailingSlash: AstroConfig['trailingSlash'];
+ buildFormat: NonNullable<AstroConfig['build']>['format'];
+ compressHTML: boolean;
+ assetsPrefix?: AssetsPrefix;
+ renderers: SSRLoadedRenderer[];
+ /**
+ * Map of directive name (e.g. `load`) to the directive script code
+ */
+ clientDirectives: Map<string, string>;
+ entryModules: Record<string, string>;
+ inlinedScripts: Map<string, string>;
+ assets: Set<string>;
+ componentMetadata: SSRResult['componentMetadata'];
+ pageModule?: SinglePageBuiltModule;
+ pageMap?: Map<ComponentPath, ImportComponentInstance>;
+ serverIslandMap?: Map<string, () => Promise<ComponentInstance>>;
+ serverIslandNameMap?: Map<string, string>;
+ key: Promise<CryptoKey>;
+ i18n: SSRManifestI18n | undefined;
+ middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
+ checkOrigin: boolean;
+ sessionConfig?: ResolvedSessionConfig<any>;
+ cacheDir: string | URL;
+ srcDir: string | URL;
+ outDir: string | URL;
+ publicDir: string | URL;
+ buildClientDir: string | URL;
+ buildServerDir: string | URL;
+};
+
+export type SSRManifestI18n = {
+ fallback: Record<string, string> | undefined;
+ fallbackType: 'redirect' | 'rewrite';
+ strategy: RoutingStrategies;
+ locales: Locales;
+ defaultLocale: string;
+ domainLookupTable: Record<string, string>;
+};
+
+/** Public type exposed through the `astro:build:ssr` integration hook */
+export type SerializedSSRManifest = Omit<
+ SSRManifest,
+ | 'middleware'
+ | 'routes'
+ | 'assets'
+ | 'componentMetadata'
+ | 'inlinedScripts'
+ | 'clientDirectives'
+ | 'serverIslandNameMap'
+ | 'key'
+> & {
+ routes: SerializedRouteInfo[];
+ assets: string[];
+ componentMetadata: [string, SSRComponentMetadata][];
+ inlinedScripts: [string, string][];
+ clientDirectives: [string, string][];
+ serverIslandNameMap: [string, string][];
+ key: string;
+};
diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts
new file mode 100644
index 000000000..81cc553ba
--- /dev/null
+++ b/packages/astro/src/core/base-pipeline.ts
@@ -0,0 +1,127 @@
+import { createI18nMiddleware } from '../i18n/middleware.js';
+import type { ComponentInstance } from '../types/astro.js';
+import type { MiddlewareHandler, RewritePayload } from '../types/public/common.js';
+import type { RuntimeMode } from '../types/public/config.js';
+import type {
+ RouteData,
+ SSRLoadedRenderer,
+ SSRManifest,
+ SSRResult,
+} from '../types/public/internal.js';
+import { createOriginCheckMiddleware } from './app/middlewares.js';
+import type { Logger } from './logger/core.js';
+import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js';
+import { sequence } from './middleware/sequence.js';
+import { RouteCache } from './render/route-cache.js';
+import { createDefaultRoutes } from './routing/default.js';
+
+/**
+ * The `Pipeline` represents the static parts of rendering that do not change between requests.
+ * These are mostly known when the server first starts up and do not change.
+ *
+ * Thus, a `Pipeline` is created once at process start and then used by every `RenderContext`.
+ */
+export abstract class Pipeline {
+ readonly internalMiddleware: MiddlewareHandler[];
+ resolvedMiddleware: MiddlewareHandler | undefined = undefined;
+
+ constructor(
+ readonly logger: Logger,
+ readonly manifest: SSRManifest,
+ /**
+ * "development" or "production" only
+ */
+ readonly runtimeMode: RuntimeMode,
+ readonly renderers: SSRLoadedRenderer[],
+ readonly resolve: (s: string) => Promise<string>,
+ /**
+ * Based on Astro config's `output` option, `true` if "server" or "hybrid".
+ */
+ readonly serverLike: boolean,
+ readonly streaming: boolean,
+ /**
+ * Used to provide better error messages for `Astro.clientAddress`
+ */
+ readonly adapterName = manifest.adapterName,
+ readonly clientDirectives = manifest.clientDirectives,
+ readonly inlinedScripts = manifest.inlinedScripts,
+ readonly compressHTML = manifest.compressHTML,
+ readonly i18n = manifest.i18n,
+ readonly middleware = manifest.middleware,
+ readonly routeCache = new RouteCache(logger, runtimeMode),
+ /**
+ * Used for `Astro.site`.
+ */
+ readonly site = manifest.site ? new URL(manifest.site) : undefined,
+ /**
+ * Array of built-in, internal, routes.
+ * Used to find the route module
+ */
+ readonly defaultRoutes = createDefaultRoutes(manifest),
+ ) {
+ this.internalMiddleware = [];
+ // We do use our middleware only if the user isn't using the manual setup
+ if (i18n?.strategy !== 'manual') {
+ this.internalMiddleware.push(
+ createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat),
+ );
+ }
+ }
+
+ abstract headElements(routeData: RouteData): Promise<HeadElements> | HeadElements;
+
+ abstract componentMetadata(routeData: RouteData): Promise<SSRResult['componentMetadata']> | void;
+
+ /**
+ * It attempts to retrieve the `RouteData` that matches the input `url`, and the component that belongs to the `RouteData`.
+ *
+ * ## Errors
+ *
+ * - if not `RouteData` is found
+ *
+ * @param {RewritePayload} rewritePayload The payload provided by the user
+ * @param {Request} request The original request
+ */
+ abstract tryRewrite(rewritePayload: RewritePayload, request: Request): Promise<TryRewriteResult>;
+
+ /**
+ * Tells the pipeline how to retrieve a component give a `RouteData`
+ * @param routeData
+ */
+ abstract getComponentByRoute(routeData: RouteData): Promise<ComponentInstance>;
+
+ /**
+ * Resolves the middleware from the manifest, and returns the `onRequest` function. If `onRequest` isn't there,
+ * it returns a no-op function
+ */
+ async getMiddleware(): Promise<MiddlewareHandler> {
+ if (this.resolvedMiddleware) {
+ return this.resolvedMiddleware;
+ }
+ // The middleware can be undefined when using edge middleware.
+ // This is set to undefined by the plugin-ssr.ts
+ else if (this.middleware) {
+ const middlewareInstance = await this.middleware();
+ const onRequest = middlewareInstance.onRequest ?? NOOP_MIDDLEWARE_FN;
+ if (this.manifest.checkOrigin) {
+ this.resolvedMiddleware = sequence(createOriginCheckMiddleware(), onRequest);
+ } else {
+ this.resolvedMiddleware = onRequest;
+ }
+ return this.resolvedMiddleware;
+ } else {
+ this.resolvedMiddleware = NOOP_MIDDLEWARE_FN;
+ return this.resolvedMiddleware;
+ }
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export interface HeadElements extends Pick<SSRResult, 'scripts' | 'styles' | 'links'> {}
+
+export interface TryRewriteResult {
+ routeData: RouteData;
+ componentInstance: ComponentInstance;
+ newUrl: URL;
+ pathname: string;
+}
diff --git a/packages/astro/src/core/build/add-rollup-input.ts b/packages/astro/src/core/build/add-rollup-input.ts
new file mode 100644
index 000000000..073fb5582
--- /dev/null
+++ b/packages/astro/src/core/build/add-rollup-input.ts
@@ -0,0 +1,46 @@
+import type { Rollup } from 'vite';
+
+function fromEntries<V>(entries: [string, V][]) {
+ const obj: Record<string, V> = {};
+ for (const [k, v] of entries) {
+ obj[k] = v;
+ }
+ return obj;
+}
+
+export function addRollupInput(
+ inputOptions: Rollup.InputOptions,
+ newInputs: string[],
+): Rollup.InputOptions {
+ // Add input module ids to existing input option, whether it's a string, array or object
+ // this way you can use multiple html plugins all adding their own inputs
+ if (!inputOptions.input) {
+ return { ...inputOptions, input: newInputs };
+ }
+
+ if (typeof inputOptions.input === 'string') {
+ return {
+ ...inputOptions,
+ input: [inputOptions.input, ...newInputs],
+ };
+ }
+
+ if (Array.isArray(inputOptions.input)) {
+ return {
+ ...inputOptions,
+ input: [...inputOptions.input, ...newInputs],
+ };
+ }
+
+ if (typeof inputOptions.input === 'object') {
+ return {
+ ...inputOptions,
+ input: {
+ ...inputOptions.input,
+ ...fromEntries(newInputs.map((i) => [i.split('/').slice(-1)[0].split('.')[0], i])),
+ },
+ };
+ }
+
+ throw new Error(`Unknown rollup input type. Supported inputs are string, array and object.`);
+}
diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts
new file mode 100644
index 000000000..4ee826f8b
--- /dev/null
+++ b/packages/astro/src/core/build/common.ts
@@ -0,0 +1,109 @@
+import npath from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { appendForwardSlash } from '../../core/path.js';
+import type { AstroSettings } from '../../types/astro.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { RouteData } from '../../types/public/internal.js';
+
+const STATUS_CODE_PAGES = new Set(['/404', '/500']);
+const FALLBACK_OUT_DIR_NAME = './.astro/';
+
+function getOutRoot(astroSettings: AstroSettings): URL {
+ if (astroSettings.buildOutput === 'static') {
+ return new URL('./', astroSettings.config.outDir);
+ } else {
+ return new URL('./', astroSettings.config.build.client);
+ }
+}
+
+export function getOutFolder(
+ astroSettings: AstroSettings,
+ pathname: string,
+ routeData: RouteData,
+): URL {
+ const outRoot = getOutRoot(astroSettings);
+ const routeType = routeData.type;
+
+ // This is the root folder to write to.
+ switch (routeType) {
+ case 'endpoint':
+ return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
+ case 'fallback':
+ case 'page':
+ case 'redirect':
+ switch (astroSettings.config.build.format) {
+ case 'directory': {
+ if (STATUS_CODE_PAGES.has(pathname)) {
+ return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot);
+ }
+ return new URL('.' + appendForwardSlash(pathname), outRoot);
+ }
+ case 'file': {
+ const d = pathname === '' ? pathname : npath.dirname(pathname);
+ return new URL('.' + appendForwardSlash(d), outRoot);
+ }
+ case 'preserve': {
+ let dir;
+ // If the pathname is '' then this is the root index.html
+ // If this is an index route, the folder should be the pathname, not the parent
+ if (pathname === '' || routeData.isIndex) {
+ dir = pathname;
+ } else {
+ dir = npath.dirname(pathname);
+ }
+ return new URL('.' + appendForwardSlash(dir), outRoot);
+ }
+ }
+ }
+}
+
+export function getOutFile(
+ astroConfig: AstroConfig,
+ outFolder: URL,
+ pathname: string,
+ routeData: RouteData,
+): URL {
+ const routeType = routeData.type;
+ switch (routeType) {
+ case 'endpoint':
+ return new URL(npath.basename(pathname), outFolder);
+ case 'page':
+ case 'fallback':
+ case 'redirect':
+ switch (astroConfig.build.format) {
+ case 'directory': {
+ if (STATUS_CODE_PAGES.has(pathname)) {
+ const baseName = npath.basename(pathname);
+ return new URL('./' + (baseName || 'index') + '.html', outFolder);
+ }
+ return new URL('./index.html', outFolder);
+ }
+ case 'file': {
+ const baseName = npath.basename(pathname);
+ return new URL('./' + (baseName || 'index') + '.html', outFolder);
+ }
+ case 'preserve': {
+ let baseName = npath.basename(pathname);
+ // If there is no base name this is the root route.
+ // If this is an index route, the name should be `index.html`.
+ if (!baseName || routeData.isIndex) {
+ baseName = 'index';
+ }
+ return new URL(`./${baseName}.html`, outFolder);
+ }
+ }
+ }
+}
+
+/**
+ * Ensures the `outDir` is within `process.cwd()`. If not it will fallback to `<cwd>/.astro`.
+ * This is used for static `ssrBuild` so the output can access node_modules when we import
+ * the output files. A hardcoded fallback dir is fine as it would be cleaned up after build.
+ */
+export function getOutDirWithinCwd(outDir: URL): URL {
+ if (fileURLToPath(outDir).startsWith(process.cwd())) {
+ return outDir;
+ } else {
+ return new URL(FALLBACK_OUT_DIR_NAME, pathToFileURL(process.cwd() + npath.sep));
+ }
+}
diff --git a/packages/astro/src/core/build/consts.ts b/packages/astro/src/core/build/consts.ts
new file mode 100644
index 000000000..2926d1e86
--- /dev/null
+++ b/packages/astro/src/core/build/consts.ts
@@ -0,0 +1,2 @@
+export const CHUNKS_PATH = 'chunks/';
+export const CONTENT_PATH = 'content/';
diff --git a/packages/astro/src/core/build/css-asset-name.ts b/packages/astro/src/core/build/css-asset-name.ts
new file mode 100644
index 000000000..84fdaf9e9
--- /dev/null
+++ b/packages/astro/src/core/build/css-asset-name.ts
@@ -0,0 +1,131 @@
+import type { GetModuleInfo, ModuleInfo } from 'rollup';
+
+import crypto from 'node:crypto';
+import npath from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { AstroSettings } from '../../types/astro.js';
+import { viteID } from '../util.js';
+import { normalizePath } from '../viteUtils.js';
+import { getTopLevelPageModuleInfos } from './graph.js';
+
+// These pages could be used as base names for the chunk hashed name, but they are confusing
+// and should be avoided it possible
+const confusingBaseNames = ['404', '500'];
+
+// The short name for when the hash can be included
+// We could get rid of this and only use the createSlugger implementation, but this creates
+// slightly prettier names.
+export function shortHashedName(settings: AstroSettings) {
+ return function (id: string, ctx: { getModuleInfo: GetModuleInfo }): string {
+ const parents = getTopLevelPageModuleInfos(id, ctx);
+ return createNameHash(
+ getFirstParentId(parents),
+ parents.map((page) => page.id),
+ settings,
+ );
+ };
+}
+
+export function createNameHash(
+ baseId: string | undefined,
+ hashIds: string[],
+ settings: AstroSettings,
+): string {
+ const baseName = baseId ? prettifyBaseName(npath.parse(baseId).name) : 'index';
+ const hash = crypto.createHash('sha256');
+ const root = fileURLToPath(settings.config.root);
+
+ for (const id of hashIds) {
+ // Strip the project directory from the paths before they are hashed, so that assets
+ // that import these css files have consistent hashes when built in different environments.
+ const relativePath = npath.relative(root, id);
+ // Normalize the path to fix differences between windows and other environments
+ hash.update(normalizePath(relativePath), 'utf-8');
+ }
+ const h = hash.digest('hex').slice(0, 8);
+ const proposedName = baseName + '.' + h;
+ return proposedName;
+}
+
+export function createSlugger(settings: AstroSettings) {
+ const pagesDir = viteID(new URL('./pages', settings.config.srcDir));
+ const indexPage = viteID(new URL('./pages/index', settings.config.srcDir));
+ const map = new Map<string, Map<string, number>>();
+ const sep = '-';
+ return function (id: string, ctx: { getModuleInfo: GetModuleInfo }): string {
+ const parents = Array.from(getTopLevelPageModuleInfos(id, ctx));
+ const allParentsKey = parents
+ .map((page) => page.id)
+ .sort()
+ .join('-');
+ const firstParentId = getFirstParentId(parents) || indexPage;
+
+ // Use the last two segments, for ex /docs/index
+ let dir = firstParentId;
+ let key = '';
+ let i = 0;
+ while (i < 2) {
+ if (dir === pagesDir) {
+ break;
+ }
+
+ const name = prettifyBaseName(npath.parse(npath.basename(dir)).name);
+ key = key.length ? name + sep + key : name;
+ dir = npath.dirname(dir);
+ i++;
+ }
+
+ // Keep track of how many times this was used.
+ let name = key;
+
+ // The map keeps track of how many times a key, like `pages_index` is used as the name.
+ // If the same key is used more than once we increment a number so it becomes `pages-index-1`.
+ // This guarantees that it stays unique, without sacrificing pretty names.
+ if (!map.has(key)) {
+ map.set(key, new Map([[allParentsKey, 0]]));
+ } else {
+ const inner = map.get(key)!;
+ if (inner.has(allParentsKey)) {
+ const num = inner.get(allParentsKey)!;
+ if (num > 0) {
+ name = name + sep + num;
+ }
+ } else {
+ const num = inner.size;
+ inner.set(allParentsKey, num);
+ name = name + sep + num;
+ }
+ }
+
+ return name;
+ };
+}
+
+/**
+ * Find the first parent id from `parents` where its name is not confusing.
+ * Returns undefined if there's no parents.
+ */
+function getFirstParentId(parents: ModuleInfo[]) {
+ for (const parent of parents) {
+ const id = parent.id;
+ const baseName = npath.parse(id).name;
+ if (!confusingBaseNames.includes(baseName)) {
+ return id;
+ }
+ }
+ // If all parents are confusing, just use the first one. Or if there's no
+ // parents, this will return undefined.
+ return parents[0]?.id;
+}
+
+const charsToReplaceRe = /[.[\]]/g;
+const underscoresRe = /_+/g;
+/**
+ * Prettify base names so they're easier to read:
+ * - index -> index
+ * - [slug] -> _slug_
+ * - [...spread] -> _spread_
+ */
+function prettifyBaseName(str: string) {
+ return str.replace(charsToReplaceRe, '_').replace(underscoresRe, '_');
+}
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
new file mode 100644
index 000000000..5a9f8a6ae
--- /dev/null
+++ b/packages/astro/src/core/build/generate.ts
@@ -0,0 +1,647 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import { bgGreen, black, blue, bold, dim, green, magenta, red, yellow } from 'kleur/colors';
+import PLimit from 'p-limit';
+import PQueue from 'p-queue';
+import {
+ generateImagesForPath,
+ getStaticImageList,
+ prepareAssetsGenerationEnv,
+} from '../../assets/build/generate.js';
+import { type BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js';
+import {
+ isRelativePath,
+ joinPaths,
+ removeLeadingForwardSlash,
+ removeTrailingForwardSlash,
+} from '../../core/path.js';
+import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js';
+import { runHookBuildGenerated } from '../../integrations/hooks.js';
+import { getOutputDirectory } from '../../prerender/utils.js';
+import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
+import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type {
+ RouteData,
+ RouteType,
+ SSRError,
+ SSRLoadedRenderer,
+} from '../../types/public/internal.js';
+import type { SSRManifest, SSRManifestI18n } from '../app/types.js';
+import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
+import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js';
+import { RenderContext } from '../render-context.js';
+import { callGetStaticPaths } from '../render/route-cache.js';
+import { createRequest } from '../request.js';
+import { redirectTemplate } from '../routing/3xx.js';
+import { matchRoute } from '../routing/match.js';
+import { stringifyParams } from '../routing/params.js';
+import { getOutputFilename } from '../util.js';
+import { getOutFile, getOutFolder } from './common.js';
+import { cssOrder, mergeInlineCss } from './internal.js';
+import { BuildPipeline } from './pipeline.js';
+import type {
+ PageBuildData,
+ SinglePageBuiltModule,
+ StaticBuildOptions,
+ StylesheetAsset,
+} from './types.js';
+import { getTimeStat, shouldAppendForwardSlash } from './util.js';
+
+export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) {
+ const generatePagesTimer = performance.now();
+ const ssr = options.settings.buildOutput === 'server';
+ let manifest: SSRManifest;
+ if (ssr) {
+ manifest = await BuildPipeline.retrieveManifest(options.settings, internals);
+ } else {
+ const baseDirectory = getOutputDirectory(options.settings);
+ const renderersEntryUrl = new URL('renderers.mjs', baseDirectory);
+ const renderers = await import(renderersEntryUrl.toString());
+ const middleware: MiddlewareHandler = internals.middlewareEntryPoint
+ ? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest)
+ : NOOP_MIDDLEWARE_FN;
+ manifest = createBuildManifest(
+ options.settings,
+ internals,
+ renderers.renderers as SSRLoadedRenderer[],
+ middleware,
+ options.key,
+ );
+ }
+ const pipeline = BuildPipeline.create({ internals, manifest, options });
+ const { config, logger } = pipeline;
+
+ // HACK! `astro:assets` relies on a global to know if its running in dev, prod, ssr, ssg, full moon
+ // If we don't delete it here, it's technically not impossible (albeit improbable) for it to leak
+ if (ssr && !hasPrerenderedPages(internals)) {
+ delete globalThis?.astroAsset?.addStaticImage;
+ }
+
+ const verb = ssr ? 'prerendering' : 'generating';
+ logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`);
+ const builtPaths = new Set<string>();
+ const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
+ if (ssr) {
+ for (const [pageData, filePath] of pagesToGenerate) {
+ if (pageData.route.prerender) {
+ // i18n domains won't work with pre rendered routes at the moment, so we need to throw an error
+ if (config.i18n?.domains && Object.keys(config.i18n.domains).length > 0) {
+ throw new AstroError({
+ ...NoPrerenderedRoutesWithDomains,
+ message: NoPrerenderedRoutesWithDomains.message(pageData.component),
+ });
+ }
+
+ const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath);
+
+ const ssrEntry = ssrEntryPage as SinglePageBuiltModule;
+ await generatePage(pageData, ssrEntry, builtPaths, pipeline);
+ }
+ }
+ } else {
+ for (const [pageData, filePath] of pagesToGenerate) {
+ const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath);
+ await generatePage(pageData, entry, builtPaths, pipeline);
+ }
+ }
+ logger.info(
+ null,
+ green(`✓ Completed in ${getTimeStat(generatePagesTimer, performance.now())}.\n`),
+ );
+
+ const staticImageList = getStaticImageList();
+ if (staticImageList.size) {
+ logger.info('SKIP_FORMAT', `${bgGreen(black(` generating optimized images `))}`);
+
+ const totalCount = Array.from(staticImageList.values())
+ .map((x) => x.transforms.size)
+ .reduce((a, b) => a + b, 0);
+ const cpuCount = os.cpus().length;
+ const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount);
+ const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) });
+
+ const assetsTimer = performance.now();
+ for (const [originalPath, transforms] of staticImageList) {
+ // Process each source image in parallel based on the queue’s concurrency
+ // (`cpuCount`). Process each transform for a source image sequentially.
+ //
+ // # Design Decision:
+ // We have 3 source images (A.png, B.png, C.png) and 3 transforms for
+ // each:
+ // ```
+ // A1.png A2.png A3.png
+ // B1.png B2.png B3.png
+ // C1.png C2.png C3.png
+ // ```
+ //
+ // ## Option 1
+ // Enqueue all transforms indiscriminantly
+ // ```
+ // |_A1.png |_B2.png |_C1.png
+ // |_B3.png |_A2.png |_C3.png
+ // |_C2.png |_A3.png |_B1.png
+ // ```
+ // * Advantage: Maximum parallelism, saturate CPU
+ // * Disadvantage: Spike in context switching
+ //
+ // ## Option 2
+ // Enqueue all transforms, but constrain processing order by source image
+ // ```
+ // |_A3.png |_B1.png |_C2.png
+ // |_A1.png |_B3.png |_C1.png
+ // |_A2.png |_B2.png |_C3.png
+ // ```
+ // * Advantage: Maximum parallelism, saturate CPU (same as Option 1) in
+ // hope to avoid context switching
+ // * Disadvantage: Context switching still occurs and performance still
+ // suffers
+ //
+ // ## Option 3
+ // Enqueue each source image, but perform the transforms for that source
+ // image sequentially
+ // ```
+ // \_A1.png \_B1.png \_C1.png
+ // \_A2.png \_B2.png \_C2.png
+ // \_A3.png \_B3.png \_C3.png
+ // ```
+ // * Advantage: Less context switching
+ // * Disadvantage: If you have a low number of source images with high
+ // number of transforms then this is suboptimal.
+ //
+ // ## BEST OPTION:
+ // **Option 3**. Most projects will have a higher number of source images
+ // with a few transforms on each. Even though Option 2 should be faster
+ // and _should_ prevent context switching, this was not observed in
+ // nascent tests. Context switching was high and the overall performance
+ // was half of Option 3.
+ //
+ // If looking to optimize further, please consider the following:
+ // * Avoid `queue.add()` in an async for loop. Notice the `await
+ // queue.onIdle();` after this loop. We do not want to create a scenario
+ // where tasks are added to the queue after the queue.onIdle() resolves.
+ // This can break tests and create annoying race conditions.
+ // * Exposing a concurrency property in `astro.config.mjs` to allow users
+ // to override Node’s os.cpus().length default.
+ // * Create a proper performance benchmark for asset transformations of
+ // projects in varying sizes of source images and transforms.
+ queue
+ .add(() => generateImagesForPath(originalPath, transforms, assetsCreationPipeline))
+ .catch((e) => {
+ throw e;
+ });
+ }
+
+ await queue.onIdle();
+ const assetsTimeEnd = performance.now();
+ logger.info(null, green(`✓ Completed in ${getTimeStat(assetsTimer, assetsTimeEnd)}.\n`));
+
+ delete globalThis?.astroAsset?.addStaticImage;
+ }
+
+ await runHookBuildGenerated({ settings: options.settings, logger });
+}
+
+const THRESHOLD_SLOW_RENDER_TIME_MS = 500;
+
+async function generatePage(
+ pageData: PageBuildData,
+ ssrEntry: SinglePageBuiltModule,
+ builtPaths: Set<string>,
+ pipeline: BuildPipeline,
+) {
+ // prepare information we need
+ const { config, logger } = pipeline;
+ const pageModulePromise = ssrEntry.page;
+
+ // Calculate information of the page, like scripts, links and styles
+ const styles = pageData.styles
+ .sort(cssOrder)
+ .map(({ sheet }) => sheet)
+ .reduce(mergeInlineCss, []);
+ // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
+ const linkIds: [] = [];
+ if (!pageModulePromise) {
+ throw new Error(
+ `Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`,
+ );
+ }
+ const pageModule = await pageModulePromise();
+ const generationOptions: Readonly<GeneratePathOptions> = {
+ pageData,
+ linkIds,
+ scripts: null,
+ styles,
+ mod: pageModule,
+ };
+
+ async function generatePathWithLogs(
+ path: string,
+ route: RouteData,
+ index: number,
+ paths: string[],
+ isConcurrent: boolean,
+ ) {
+ const timeStart = performance.now();
+ pipeline.logger.debug('build', `Generating: ${path}`);
+
+ const filePath = getOutputFilename(config, path, pageData.route);
+ const lineIcon =
+ (index === paths.length - 1 && !isConcurrent) || paths.length === 1 ? '└─' : '├─';
+
+ // Log the rendering path first if not concurrent. We'll later append the time taken to render.
+ // We skip if it's concurrent as the logs may overlap
+ if (!isConcurrent) {
+ logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
+ }
+
+ const created = await generatePath(path, pipeline, generationOptions, route);
+
+ const timeEnd = performance.now();
+ const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS;
+ const timeIncrease = (isSlow ? red : dim)(`(+${getTimeStat(timeStart, timeEnd)})`);
+ const notCreated =
+ created === false ? yellow('(file not created, response body was empty)') : '';
+
+ if (isConcurrent) {
+ logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)} ${timeIncrease} ${notCreated}`);
+ } else {
+ logger.info('SKIP_FORMAT', ` ${timeIncrease} ${notCreated}`);
+ }
+ }
+
+ // Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing)
+ for (const route of eachRouteInRouteData(pageData)) {
+ const icon =
+ route.type === 'page' || route.type === 'redirect' || route.type === 'fallback'
+ ? green('▶')
+ : magenta('λ');
+ logger.info(null, `${icon} ${getPrettyRouteName(route)}`);
+
+ // Get paths for the route, calling getStaticPaths if needed.
+ const paths = await getPathsForRoute(route, pageModule, pipeline, builtPaths);
+
+ // Generate each paths
+ if (config.build.concurrency > 1) {
+ const limit = PLimit(config.build.concurrency);
+ const promises: Promise<void>[] = [];
+ for (let i = 0; i < paths.length; i++) {
+ const path = paths[i];
+ promises.push(limit(() => generatePathWithLogs(path, route, i, paths, true)));
+ }
+ await Promise.allSettled(promises);
+ } else {
+ for (let i = 0; i < paths.length; i++) {
+ const path = paths[i];
+ await generatePathWithLogs(path, route, i, paths, false);
+ }
+ }
+ }
+}
+
+function* eachRouteInRouteData(data: PageBuildData) {
+ yield data.route;
+ for (const fallbackRoute of data.route.fallbackRoutes) {
+ yield fallbackRoute;
+ }
+}
+
+async function getPathsForRoute(
+ route: RouteData,
+ mod: ComponentInstance,
+ pipeline: BuildPipeline,
+ builtPaths: Set<string>,
+): Promise<Array<string>> {
+ const { logger, options, routeCache, serverLike, config } = pipeline;
+ let paths: Array<string> = [];
+ if (route.pathname) {
+ paths.push(route.pathname);
+ builtPaths.add(removeTrailingForwardSlash(route.pathname));
+ } else {
+ const staticPaths = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ logger,
+ ssr: serverLike,
+ base: config.base,
+ }).catch((err) => {
+ logger.error('build', `Failed to call getStaticPaths for ${route.component}`);
+ throw err;
+ });
+
+ const label = staticPaths.length === 1 ? 'page' : 'pages';
+ logger.debug(
+ 'build',
+ `├── ${bold(green('√'))} ${route.component} → ${magenta(`[${staticPaths.length} ${label}]`)}`,
+ );
+
+ paths = staticPaths
+ .map((staticPath) => {
+ try {
+ return stringifyParams(staticPath.params, route);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ throw getInvalidRouteSegmentError(e, route, staticPath);
+ }
+ throw e;
+ }
+ })
+ .filter((staticPath) => {
+ // The path hasn't been built yet, include it
+ if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) {
+ return true;
+ }
+
+ // The path was already built once. Check the manifest to see if
+ // this route takes priority for the final URL.
+ // NOTE: The same URL may match multiple routes in the manifest.
+ // Routing priority needs to be verified here for any duplicate
+ // paths to ensure routing priority rules are enforced in the final build.
+ const matchedRoute = matchRoute(staticPath, options.routesList);
+ return matchedRoute === route;
+ });
+
+ // Add each path to the builtPaths set, to avoid building it again later.
+ for (const staticPath of paths) {
+ builtPaths.add(removeTrailingForwardSlash(staticPath));
+ }
+ }
+
+ return paths;
+}
+
+function getInvalidRouteSegmentError(
+ e: TypeError,
+ route: RouteData,
+ staticPath: GetStaticPathsItem,
+): AstroError {
+ const invalidParam = /^Expected "([^"]+)"/.exec(e.message)?.[1];
+ const received = invalidParam ? staticPath.params[invalidParam] : undefined;
+ let hint =
+ 'Learn about dynamic routes at https://docs.astro.build/en/core-concepts/routing/#dynamic-routes';
+ if (invalidParam && typeof received === 'string') {
+ const matchingSegment = route.segments.find(
+ (segment) => segment[0]?.content === invalidParam,
+ )?.[0];
+ const mightBeMissingSpread = matchingSegment?.dynamic && !matchingSegment?.spread;
+ if (mightBeMissingSpread) {
+ hint = `If the param contains slashes, try using a rest parameter: **[...${invalidParam}]**. Learn more at https://docs.astro.build/en/core-concepts/routing/#dynamic-routes`;
+ }
+ }
+ return new AstroError({
+ ...AstroErrorData.InvalidDynamicRoute,
+ message: invalidParam
+ ? AstroErrorData.InvalidDynamicRoute.message(
+ route.route,
+ JSON.stringify(invalidParam),
+ JSON.stringify(received),
+ )
+ : `Generated path for ${route.route} is invalid.`,
+ hint,
+ });
+}
+
+function addPageName(pathname: string, opts: StaticBuildOptions): void {
+ const trailingSlash = opts.settings.config.trailingSlash;
+ const buildFormat = opts.settings.config.build.format;
+ const pageName = shouldAppendForwardSlash(trailingSlash, buildFormat)
+ ? pathname.replace(/\/?$/, '/').replace(/^\//, '')
+ : pathname.replace(/^\//, '');
+ opts.pageNames.push(pageName);
+}
+
+function getUrlForPath(
+ pathname: string,
+ base: string,
+ origin: string,
+ format: AstroConfig['build']['format'],
+ trailingSlash: AstroConfig['trailingSlash'],
+ routeType: RouteType,
+): URL {
+ /**
+ * Examples:
+ * pathname: /, /foo
+ * base: /
+ */
+
+ let ending: string;
+ switch (format) {
+ case 'directory':
+ case 'preserve': {
+ ending = trailingSlash === 'never' ? '' : '/';
+ break;
+ }
+ case 'file':
+ default: {
+ ending = '.html';
+ break;
+ }
+ }
+ let buildPathname: string;
+ if (pathname === '/' || pathname === '') {
+ buildPathname = base;
+ } else if (routeType === 'endpoint') {
+ const buildPathRelative = removeLeadingForwardSlash(pathname);
+ buildPathname = joinPaths(base, buildPathRelative);
+ } else {
+ const buildPathRelative =
+ removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending;
+ buildPathname = joinPaths(base, buildPathRelative);
+ }
+ const url = new URL(buildPathname, origin);
+ return url;
+}
+
+interface GeneratePathOptions {
+ pageData: PageBuildData;
+ linkIds: string[];
+ scripts: { type: 'inline' | 'external'; value: string } | null;
+ styles: StylesheetAsset[];
+ mod: ComponentInstance;
+}
+
+/**
+ *
+ * @param pathname
+ * @param pipeline
+ * @param gopts
+ * @param route
+ * @return {Promise<boolean | undefined>} If `false` the file hasn't been created. If `undefined` it's expected to not be created.
+ */
+async function generatePath(
+ pathname: string,
+ pipeline: BuildPipeline,
+ gopts: GeneratePathOptions,
+ route: RouteData,
+): Promise<boolean | undefined> {
+ const { mod } = gopts;
+ const { config, logger, options } = pipeline;
+ logger.debug('build', `Generating: ${pathname}`);
+
+ // This adds the page name to the array so it can be shown as part of stats.
+ if (route.type === 'page') {
+ addPageName(pathname, options);
+ }
+
+ // Do not render the fallback route if there is already a translated page
+ // with the same path
+ if (
+ route.type === 'fallback' &&
+ // If route is index page, continue rendering. The index page should
+ // always be rendered
+ route.pathname !== '/' &&
+ // Check if there is a translated page with the same path
+ Object.values(options.allPages).some((val) => val.route.pattern.test(pathname))
+ ) {
+ return undefined;
+ }
+
+ const url = getUrlForPath(
+ pathname,
+ config.base,
+ options.origin,
+ config.build.format,
+ config.trailingSlash,
+ route.type,
+ );
+
+ const request = createRequest({
+ url,
+ headers: new Headers(),
+ logger,
+ isPrerendered: true,
+ routePattern: route.component,
+ });
+ const renderContext = await RenderContext.create({
+ pipeline,
+ pathname: pathname,
+ request,
+ routeData: route,
+ clientAddress: undefined,
+ });
+
+ let body: string | Uint8Array;
+ let response: Response;
+ try {
+ response = await renderContext.render(mod);
+ } catch (err) {
+ if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
+ (err as SSRError).id = route.component;
+ }
+ throw err;
+ }
+
+ if (response.status >= 300 && response.status < 400) {
+ // Adapters may handle redirects themselves, turning off Astro's redirect handling using `config.build.redirects` in the process.
+ // In that case, we skip rendering static files for the redirect routes.
+ if (routeIsRedirect(route) && !config.build.redirects) {
+ return undefined;
+ }
+ const locationSite = getRedirectLocationOrThrow(response.headers);
+ const siteURL = config.site;
+ const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
+ const fromPath = new URL(request.url).pathname;
+ body = redirectTemplate({ status: response.status, location, from: fromPath });
+ if (config.compressHTML === true) {
+ body = body.replaceAll('\n', '');
+ }
+ // A dynamic redirect, set the location so that integrations know about it.
+ if (route.type !== 'redirect') {
+ route.redirect = location.toString();
+ }
+ } else {
+ // If there's no body, do nothing
+ if (!response.body) return false;
+ body = Buffer.from(await response.arrayBuffer());
+ }
+
+ // We encode the path because some paths will received encoded characters, e.g. /[page] VS /%5Bpage%5D.
+ // Node.js decodes the paths, so to avoid a clash between paths, do encode paths again, so we create the correct files and folders requested by the user.
+ const encodedPath = encodeURI(pathname);
+ const outFolder = getOutFolder(pipeline.settings, encodedPath, route);
+ const outFile = getOutFile(config, outFolder, encodedPath, route);
+ if (route.distURL) {
+ route.distURL.push(outFile);
+ } else {
+ route.distURL = [outFile];
+ }
+
+ await fs.promises.mkdir(outFolder, { recursive: true });
+ await fs.promises.writeFile(outFile, body);
+
+ return true;
+}
+
+function getPrettyRouteName(route: RouteData): string {
+ if (isRelativePath(route.component)) {
+ return route.route;
+ }
+ if (route.component.includes('node_modules/')) {
+ // For routes from node_modules (usually injected by integrations),
+ // prettify it by only grabbing the part after the last `node_modules/`
+ return /.*node_modules\/(.+)/.exec(route.component)?.[1] ?? route.component;
+ }
+ return route.component;
+}
+
+/**
+ * It creates a `SSRManifest` from the `AstroSettings`.
+ *
+ * Renderers needs to be pulled out from the page module emitted during the build.
+ * @param settings
+ * @param renderers
+ */
+function createBuildManifest(
+ settings: AstroSettings,
+ internals: BuildInternals,
+ renderers: SSRLoadedRenderer[],
+ middleware: MiddlewareHandler,
+ key: Promise<CryptoKey>,
+): SSRManifest {
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.i18n) {
+ i18nManifest = {
+ fallback: settings.config.i18n.fallback,
+ fallbackType: toFallbackType(settings.config.i18n.routing),
+ strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
+ defaultLocale: settings.config.i18n.defaultLocale,
+ locales: settings.config.i18n.locales,
+ domainLookupTable: {},
+ };
+ }
+ return {
+ hrefRoot: settings.config.root.toString(),
+ srcDir: settings.config.srcDir,
+ buildClientDir: settings.config.build.client,
+ buildServerDir: settings.config.build.server,
+ publicDir: settings.config.publicDir,
+ outDir: settings.config.outDir,
+ cacheDir: settings.config.cacheDir,
+ trailingSlash: settings.config.trailingSlash,
+ assets: new Set(),
+ entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
+ inlinedScripts: internals.inlinedScripts,
+ routes: [],
+ adapterName: '',
+ clientDirectives: settings.clientDirectives,
+ compressHTML: settings.config.compressHTML,
+ renderers,
+ base: settings.config.base,
+ assetsPrefix: settings.config.build.assetsPrefix,
+ site: settings.config.site,
+ componentMetadata: internals.componentMetadata,
+ i18n: i18nManifest,
+ buildFormat: settings.config.build.format,
+ middleware() {
+ return {
+ onRequest: middleware,
+ };
+ },
+ checkOrigin:
+ (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
+ key,
+ };
+}
diff --git a/packages/astro/src/core/build/graph.ts b/packages/astro/src/core/build/graph.ts
new file mode 100644
index 000000000..e017bcb0f
--- /dev/null
+++ b/packages/astro/src/core/build/graph.ts
@@ -0,0 +1,94 @@
+import type { GetModuleInfo, ModuleInfo } from 'rollup';
+
+import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
+
+interface ExtendedModuleInfo {
+ info: ModuleInfo;
+ depth: number;
+ order: number;
+}
+
+// This walks up the dependency graph and yields out each ModuleInfo object.
+export function getParentExtendedModuleInfos(
+ id: string,
+ ctx: { getModuleInfo: GetModuleInfo },
+ until?: (importer: string) => boolean,
+ depth = 0,
+ order = 0,
+ childId = '',
+ seen = new Set<string>(),
+ accumulated: ExtendedModuleInfo[] = [],
+): ExtendedModuleInfo[] {
+ seen.add(id);
+
+ const info = ctx.getModuleInfo(id);
+ if (info) {
+ if (childId) {
+ const idx = info.importedIds.indexOf(childId);
+ if (idx === -1) {
+ // Dynamic imports come after all normal imports. So first add the number of normal imports.
+ order += info.importedIds.length;
+ // Then add on the dynamic ones.
+ order += info.dynamicallyImportedIds.indexOf(childId);
+ } else {
+ order += idx;
+ }
+ }
+ accumulated.push({ info, depth, order });
+ }
+
+ if (info && !until?.(id)) {
+ const importers = info.importers.concat(info.dynamicImporters);
+ for (const imp of importers) {
+ if (!seen.has(imp)) {
+ getParentExtendedModuleInfos(imp, ctx, until, depth + 1, order, id, seen, accumulated);
+ }
+ }
+ }
+
+ return accumulated;
+}
+
+export function getParentModuleInfos(
+ id: string,
+ ctx: { getModuleInfo: GetModuleInfo },
+ until?: (importer: string) => boolean,
+ seen = new Set<string>(),
+ accumulated: ModuleInfo[] = [],
+): ModuleInfo[] {
+ seen.add(id);
+
+ const info = ctx.getModuleInfo(id);
+ if (info) {
+ accumulated.push(info);
+ }
+
+ if (info && !until?.(id)) {
+ const importers = info.importers.concat(info.dynamicImporters);
+ for (const imp of importers) {
+ if (!seen.has(imp)) {
+ getParentModuleInfos(imp, ctx, until, seen, accumulated);
+ }
+ }
+ }
+
+ return accumulated;
+}
+
+// Returns true if a module is a top-level page. We determine this based on whether
+// it is imported by the top-level virtual module.
+export function moduleIsTopLevelPage(info: ModuleInfo): boolean {
+ return (
+ info.importers[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) ||
+ info.dynamicImporters[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)
+ );
+}
+
+// This function walks the dependency graph, going up until it finds a page component.
+// This could be a .astro page, a .markdown or a .md (or really any file extension for markdown files) page.
+export function getTopLevelPageModuleInfos(
+ id: string,
+ ctx: { getModuleInfo: GetModuleInfo },
+): ModuleInfo[] {
+ return getParentModuleInfos(id, ctx).filter(moduleIsTopLevelPage);
+}
diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts
new file mode 100644
index 000000000..d771ad2e9
--- /dev/null
+++ b/packages/astro/src/core/build/index.ts
@@ -0,0 +1,312 @@
+import fs from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { fileURLToPath } from 'node:url';
+import { blue, bold, green } from 'kleur/colors';
+import type * as vite from 'vite';
+import { telemetry } from '../../events/index.js';
+import { eventCliSession } from '../../events/session.js';
+import {
+ runHookBuildDone,
+ runHookBuildStart,
+ runHookConfigDone,
+ runHookConfigSetup,
+} from '../../integrations/hooks.js';
+import type { AstroSettings, RoutesList } from '../../types/astro.js';
+import type { AstroInlineConfig, RuntimeMode } from '../../types/public/config.js';
+import { createDevelopmentManifest } from '../../vite-plugin-astro-server/plugin.js';
+import type { SSRManifest } from '../app/types.js';
+import { resolveConfig } from '../config/config.js';
+import { createNodeLogger } from '../config/logging.js';
+import { createSettings } from '../config/settings.js';
+import { createVite } from '../create-vite.js';
+import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../encryption.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+import { levels, timerMessage } from '../logger/core.js';
+import { apply as applyPolyfill } from '../polyfill.js';
+import { createRoutesList } from '../routing/index.js';
+import { getServerIslandRouteData } from '../server-islands/endpoint.js';
+import { clearContentLayerCache } from '../sync/index.js';
+import { ensureProcessNodeEnv } from '../util.js';
+import { collectPagesData } from './page-data.js';
+import { staticBuild, viteBuild } from './static-build.js';
+import type { StaticBuildOptions } from './types.js';
+import { getTimeStat } from './util.js';
+
+export interface BuildOptions {
+ /**
+ * Output a development-based build similar to code transformed in `astro dev`. This
+ * can be useful to test build-only issues with additional debugging information included.
+ *
+ * @default false
+ */
+ devOutput?: boolean;
+ /**
+ * Teardown the compiler WASM instance after build. This can improve performance when
+ * building once, but may cause a performance hit if building multiple times in a row.
+ *
+ * @internal only used for testing
+ * @default true
+ */
+ teardownCompiler?: boolean;
+}
+
+/**
+ * Builds your site for deployment. By default, this will generate static files and place them in a dist/ directory.
+ * If SSR is enabled, this will generate the necessary server files to serve your site.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+export default async function build(
+ inlineConfig: AstroInlineConfig,
+ options: BuildOptions = {},
+): Promise<void> {
+ ensureProcessNodeEnv(options.devOutput ? 'development' : 'production');
+ applyPolyfill();
+ const logger = createNodeLogger(inlineConfig);
+ const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build');
+ telemetry.record(eventCliSession('build', userConfig));
+
+ const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
+
+ if (inlineConfig.force) {
+ // isDev is always false, because it's interested in the build command, not the output type
+ await clearContentLayerCache({ settings, logger, fs, isDev: false });
+ }
+
+ const builder = new AstroBuilder(settings, {
+ ...options,
+ logger,
+ mode: inlineConfig.mode ?? 'production',
+ runtimeMode: options.devOutput ? 'development' : 'production',
+ });
+ await builder.run();
+}
+
+interface AstroBuilderOptions extends BuildOptions {
+ logger: Logger;
+ mode: string;
+ runtimeMode: RuntimeMode;
+}
+
+class AstroBuilder {
+ private settings: AstroSettings;
+ private logger: Logger;
+ private mode: string;
+ private runtimeMode: RuntimeMode;
+ private origin: string;
+ private routesList: RoutesList;
+ private timer: Record<string, number>;
+ private teardownCompiler: boolean;
+ private manifest: SSRManifest;
+
+ constructor(settings: AstroSettings, options: AstroBuilderOptions) {
+ this.mode = options.mode;
+ this.runtimeMode = options.runtimeMode;
+ this.settings = settings;
+ this.logger = options.logger;
+ this.teardownCompiler = options.teardownCompiler ?? true;
+ this.origin = settings.config.site
+ ? new URL(settings.config.site).origin
+ : `http://localhost:${settings.config.server.port}`;
+ this.routesList = { routes: [] };
+ // NOTE: this manifest is only used by the first build pass to make the `astro:manifest` function.
+ // After the first build, the BuildPipeline comes into play, and it creates the proper manifest for generating the pages.
+ this.manifest = createDevelopmentManifest(settings);
+ this.timer = {};
+ }
+
+ /** Setup Vite and run any async setup logic that couldn't run inside of the constructor. */
+ private async setup() {
+ this.logger.debug('build', 'Initial setup...');
+ const { logger } = this;
+ this.timer.init = performance.now();
+ this.settings = await runHookConfigSetup({
+ settings: this.settings,
+ command: 'build',
+ logger: logger,
+ });
+
+ this.routesList = await createRoutesList({ settings: this.settings }, this.logger);
+
+ await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' });
+
+ // If we're building for the server, we need to ensure that an adapter is installed.
+ // If the adapter installed does not support a server output, an error will be thrown when the adapter is added, so no need to check here.
+ if (!this.settings.config.adapter && this.settings.buildOutput === 'server') {
+ throw new AstroError(AstroErrorData.NoAdapterInstalled);
+ }
+
+ const viteConfig = await createVite(
+ {
+ server: {
+ hmr: false,
+ middlewareMode: true,
+ },
+ },
+ {
+ settings: this.settings,
+ logger: this.logger,
+ mode: this.mode,
+ command: 'build',
+ sync: false,
+ routesList: this.routesList,
+ manifest: this.manifest,
+ },
+ );
+
+ const { syncInternal } = await import('../sync/index.js');
+ await syncInternal({
+ mode: this.mode,
+ settings: this.settings,
+ logger,
+ fs,
+ routesList: this.routesList,
+ command: 'build',
+ manifest: this.manifest,
+ });
+
+ return { viteConfig };
+ }
+
+ /** Run the build logic. build() is marked private because usage should go through ".run()" */
+ private async build({ viteConfig }: { viteConfig: vite.InlineConfig }) {
+ await runHookBuildStart({ config: this.settings.config, logging: this.logger });
+ this.validateConfig();
+
+ this.logger.info('build', `output: ${blue('"' + this.settings.buildOutput + '"')}`);
+ this.logger.info('build', `directory: ${blue(fileURLToPath(this.settings.config.outDir))}`);
+ if (this.settings.adapter) {
+ this.logger.info('build', `adapter: ${green(this.settings.adapter.name)}`);
+ }
+ this.logger.info('build', 'Collecting build info...');
+ this.timer.loadStart = performance.now();
+ const { assets, allPages } = collectPagesData({
+ settings: this.settings,
+ logger: this.logger,
+ manifest: this.routesList,
+ });
+
+ this.logger.debug('build', timerMessage('All pages loaded', this.timer.loadStart));
+
+ // The names of each pages
+ const pageNames: string[] = [];
+
+ // Bundle the assets in your final build: This currently takes the HTML output
+ // of every page (stored in memory) and bundles the assets pointed to on those pages.
+ this.timer.buildStart = performance.now();
+ this.logger.info(
+ 'build',
+ green(`✓ Completed in ${getTimeStat(this.timer.init, performance.now())}.`),
+ );
+
+ const hasKey = hasEnvironmentKey();
+ const keyPromise = hasKey ? getEnvironmentKey() : createKey();
+
+ const opts: StaticBuildOptions = {
+ allPages,
+ settings: this.settings,
+ logger: this.logger,
+ routesList: this.routesList,
+ runtimeMode: this.runtimeMode,
+ origin: this.origin,
+ pageNames,
+ teardownCompiler: this.teardownCompiler,
+ viteConfig,
+ key: keyPromise,
+ };
+
+ const { internals, ssrOutputChunkNames } = await viteBuild(opts);
+
+ const hasServerIslands = this.settings.serverIslandNameMap.size > 0;
+ // Error if there are server islands but no adapter provided.
+ if (hasServerIslands && this.settings.buildOutput !== 'server') {
+ throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands);
+ }
+
+ await staticBuild(opts, internals, ssrOutputChunkNames);
+
+ // Write any additionally generated assets to disk.
+ this.timer.assetsStart = performance.now();
+ Object.keys(assets).map((k) => {
+ if (!assets[k]) return;
+ const filePath = new URL(`file://${k}`);
+ fs.mkdirSync(new URL('./', filePath), { recursive: true });
+ fs.writeFileSync(filePath, assets[k], 'utf8');
+ delete assets[k]; // free up memory
+ });
+ this.logger.debug('build', timerMessage('Additional assets copied', this.timer.assetsStart));
+
+ // You're done! Time to clean up.
+ await runHookBuildDone({
+ settings: this.settings,
+ pages: pageNames,
+ routes: Object.values(allPages)
+ .flat()
+ .map((pageData) => pageData.route)
+ .concat(hasServerIslands ? getServerIslandRouteData(this.settings.config) : []),
+ logging: this.logger,
+ });
+
+ if (this.logger.level && levels[this.logger.level()] <= levels['info']) {
+ await this.printStats({
+ logger: this.logger,
+ timeStart: this.timer.init,
+ pageCount: pageNames.length,
+ buildMode: this.settings.buildOutput!, // buildOutput is always set at this point
+ });
+ }
+ }
+
+ /** Build the given Astro project. */
+ async run() {
+ this.settings.timer.start('Total build');
+
+ const setupData = await this.setup();
+ try {
+ await this.build(setupData);
+ } catch (_err) {
+ throw _err;
+ } finally {
+ this.settings.timer.end('Total build');
+ // Benchmark results
+ this.settings.timer.writeStats();
+ }
+ }
+
+ private validateConfig() {
+ const { config } = this.settings;
+
+ // outDir gets blown away so it can't be the root.
+ if (config.outDir.toString() === config.root.toString()) {
+ throw new Error(
+ `the outDir cannot be the root folder. Please build to a folder such as dist.`,
+ );
+ }
+ }
+
+ /** Stats */
+ private async printStats({
+ logger,
+ timeStart,
+ pageCount,
+ buildMode,
+ }: {
+ logger: Logger;
+ timeStart: number;
+ pageCount: number;
+ buildMode: AstroSettings['buildOutput'];
+ }) {
+ const total = getTimeStat(timeStart, performance.now());
+
+ let messages: string[] = [];
+ if (buildMode === 'static') {
+ messages = [`${pageCount} page(s) built in`, bold(total)];
+ } else {
+ messages = ['Server built in', bold(total)];
+ }
+
+ logger.info('build', messages.join(' '));
+ logger.info('build', `${bold('Complete!')}`);
+ }
+}
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
new file mode 100644
index 000000000..4e3675876
--- /dev/null
+++ b/packages/astro/src/core/build/internal.ts
@@ -0,0 +1,295 @@
+import type { Rollup } from 'vite';
+import type { RouteData, SSRResult } from '../../types/public/internal.js';
+import { prependForwardSlash, removeFileExtension } from '../path.js';
+import { viteID } from '../util.js';
+import { makePageDataKey } from './plugins/util.js';
+import type { PageBuildData, StylesheetAsset, ViteID } from './types.js';
+
+export interface BuildInternals {
+ /**
+ * Each CSS module is named with a chunk id derived from the Astro pages they
+ * are used in by default. It's easy to crawl this relation in the SSR build as
+ * the Astro pages are the entrypoint, but not for the client build as hydratable
+ * components are the entrypoint instead. This map is used as a cache from the SSR
+ * build so the client can pick up the same information and use the same chunk ids.
+ */
+ cssModuleToChunkIdMap: Map<string, string>;
+
+ /**
+ * If script is inlined, its id and inlined code is mapped here. The resolved id is
+ * an URL like "/_astro/something.js" but will no longer exist as the content is now
+ * inlined in this map.
+ */
+ inlinedScripts: Map<string, string>;
+
+ // A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
+ // Used to render pages with the correct specifiers.
+ entrySpecifierToBundleMap: Map<string, string>;
+
+ /**
+ * A map for page-specific information.
+ */
+ pagesByKeys: Map<string, PageBuildData>;
+
+ /**
+ * A map for page-specific information by Vite ID (a path-like string)
+ */
+ pagesByViteID: Map<ViteID, PageBuildData>;
+
+ /**
+ * A map for page-specific information by a client:only component
+ */
+ pagesByClientOnly: Map<string, Set<PageBuildData>>;
+
+ /**
+ * A map for page-specific information by a script in an Astro file
+ */
+ pagesByScriptId: Map<string, Set<PageBuildData>>;
+
+ /**
+ * A map of hydrated components to export names that are discovered during the SSR build.
+ * These will be used as the top-level entrypoints for the client build.
+ *
+ * @example
+ * '/project/Component1.jsx' => ['default']
+ * '/project/Component2.jsx' => ['Counter', 'Timer']
+ * '/project/Component3.jsx' => ['*']
+ */
+ discoveredHydratedComponents: Map<string, string[]>;
+ /**
+ * A list of client:only components to export names that are discovered during the SSR build.
+ * These will be used as the top-level entrypoints for the client build.
+ *
+ * @example
+ * '/project/Component1.jsx' => ['default']
+ * '/project/Component2.jsx' => ['Counter', 'Timer']
+ * '/project/Component3.jsx' => ['*']
+ */
+ discoveredClientOnlyComponents: Map<string, string[]>;
+ /**
+ * A list of scripts that are discovered during the SSR build.
+ * These will be used as the top-level entrypoints for the client build.
+ */
+ discoveredScripts: Set<string>;
+
+ /**
+ * Map of propagated module ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`)
+ * to a set of stylesheets that it uses.
+ */
+ propagatedStylesMap: Map<string, Set<StylesheetAsset>>;
+
+ // A list of all static files created during the build. Used for SSR.
+ staticFiles: Set<string>;
+ // The SSR entry chunk. Kept in internals to share between ssr/client build steps
+ ssrEntryChunk?: Rollup.OutputChunk;
+ // The SSR manifest entry chunk.
+ manifestEntryChunk?: Rollup.OutputChunk;
+ manifestFileName?: string;
+ entryPoints: Map<RouteData, URL>;
+ componentMetadata: SSRResult['componentMetadata'];
+ middlewareEntryPoint?: URL;
+
+ /**
+ * Chunks in the bundle that are only used in prerendering that we can delete later
+ */
+ prerenderOnlyChunks: Rollup.OutputChunk[];
+}
+
+/**
+ * Creates internal maps used to coordinate the CSS and HTML plugins.
+ * @returns {BuildInternals}
+ */
+export function createBuildInternals(): BuildInternals {
+ return {
+ cssModuleToChunkIdMap: new Map(),
+ inlinedScripts: new Map(),
+ entrySpecifierToBundleMap: new Map<string, string>(),
+ pagesByKeys: new Map(),
+ pagesByViteID: new Map(),
+ pagesByClientOnly: new Map(),
+ pagesByScriptId: new Map(),
+
+ propagatedStylesMap: new Map(),
+
+ discoveredHydratedComponents: new Map(),
+ discoveredClientOnlyComponents: new Map(),
+ discoveredScripts: new Set(),
+ staticFiles: new Set(),
+ componentMetadata: new Map(),
+ entryPoints: new Map(),
+ prerenderOnlyChunks: [],
+ };
+}
+
+export function trackPageData(
+ internals: BuildInternals,
+ _component: string,
+ pageData: PageBuildData,
+ componentModuleId: string,
+ componentURL: URL,
+): void {
+ pageData.moduleSpecifier = componentModuleId;
+ internals.pagesByKeys.set(pageData.key, pageData);
+ internals.pagesByViteID.set(viteID(componentURL), pageData);
+}
+
+/**
+ * Tracks client-only components to the pages they are associated with.
+ */
+export function trackClientOnlyPageDatas(
+ internals: BuildInternals,
+ pageData: PageBuildData,
+ clientOnlys: string[],
+) {
+ for (const clientOnlyComponent of clientOnlys) {
+ let pageDataSet: Set<PageBuildData>;
+ // clientOnlyComponent will be similar to `/@fs{moduleID}`
+ if (internals.pagesByClientOnly.has(clientOnlyComponent)) {
+ pageDataSet = internals.pagesByClientOnly.get(clientOnlyComponent)!;
+ } else {
+ pageDataSet = new Set<PageBuildData>();
+ internals.pagesByClientOnly.set(clientOnlyComponent, pageDataSet);
+ }
+ pageDataSet.add(pageData);
+ }
+}
+
+/**
+ * Tracks scripts to the pages they are associated with.
+ */
+export function trackScriptPageDatas(
+ internals: BuildInternals,
+ pageData: PageBuildData,
+ scriptIds: string[],
+) {
+ for (const scriptId of scriptIds) {
+ let pageDataSet: Set<PageBuildData>;
+ if (internals.pagesByScriptId.has(scriptId)) {
+ pageDataSet = internals.pagesByScriptId.get(scriptId)!;
+ } else {
+ pageDataSet = new Set<PageBuildData>();
+ internals.pagesByScriptId.set(scriptId, pageDataSet);
+ }
+ pageDataSet.add(pageData);
+ }
+}
+
+export function* getPageDatasByClientOnlyID(
+ internals: BuildInternals,
+ viteid: ViteID,
+): Generator<PageBuildData, void, unknown> {
+ const pagesByClientOnly = internals.pagesByClientOnly;
+ if (pagesByClientOnly.size) {
+ // 1. Try the viteid
+ let pageBuildDatas = pagesByClientOnly.get(viteid);
+
+ // 2. Try prepending /@fs
+ if (!pageBuildDatas) {
+ let pathname = `/@fs${prependForwardSlash(viteid)}`;
+ pageBuildDatas = pagesByClientOnly.get(pathname);
+ }
+
+ // 3. Remove the file extension
+ // BUG! The compiler partially resolves .jsx to remove the file extension so we have to check again.
+ // We should probably get rid of all `@fs` usage and always fully resolve via Vite,
+ // but this would be a bigger change.
+ if (!pageBuildDatas) {
+ let pathname = `/@fs${prependForwardSlash(removeFileExtension(viteid))}`;
+ pageBuildDatas = pagesByClientOnly.get(pathname);
+ }
+ if (pageBuildDatas) {
+ for (const pageData of pageBuildDatas) {
+ yield pageData;
+ }
+ }
+ }
+}
+
+/**
+ * From its route and component, get the page data from the build internals.
+ * @param internals Build Internals with all the pages
+ * @param route The route of the page, used to identify the page
+ * @param component The component of the page, used to identify the page
+ */
+export function getPageData(
+ internals: BuildInternals,
+ route: string,
+ component: string,
+): PageBuildData | undefined {
+ let pageData = internals.pagesByKeys.get(makePageDataKey(route, component));
+ if (pageData) {
+ return pageData;
+ }
+ return undefined;
+}
+
+export function getPageDataByViteID(
+ internals: BuildInternals,
+ viteid: ViteID,
+): PageBuildData | undefined {
+ if (internals.pagesByViteID.has(viteid)) {
+ return internals.pagesByViteID.get(viteid);
+ }
+ return undefined;
+}
+
+export function hasPrerenderedPages(internals: BuildInternals) {
+ for (const pageData of internals.pagesByKeys.values()) {
+ if (pageData.route.prerender) {
+ return true;
+ }
+ }
+ return false;
+}
+
+interface OrderInfo {
+ depth: number;
+ order: number;
+}
+
+/**
+ * Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
+ * A lower depth means it comes directly from the top-level page.
+ * Can be used to sort stylesheets so that shared rules come first
+ * and page-specific rules come after.
+ */
+export function cssOrder(a: OrderInfo, b: OrderInfo) {
+ let depthA = a.depth,
+ depthB = b.depth,
+ orderA = a.order,
+ orderB = b.order;
+
+ if (orderA === -1 && orderB >= 0) {
+ return 1;
+ } else if (orderB === -1 && orderA >= 0) {
+ return -1;
+ } else if (orderA > orderB) {
+ return 1;
+ } else if (orderA < orderB) {
+ return -1;
+ } else {
+ if (depthA === -1) {
+ return -1;
+ } else if (depthB === -1) {
+ return 1;
+ } else {
+ return depthA > depthB ? -1 : 1;
+ }
+ }
+}
+
+export function mergeInlineCss(
+ acc: Array<StylesheetAsset>,
+ current: StylesheetAsset,
+): Array<StylesheetAsset> {
+ const lastAdded = acc.at(acc.length - 1);
+ const lastWasInline = lastAdded?.type === 'inline';
+ const currentIsInline = current?.type === 'inline';
+ if (lastWasInline && currentIsInline) {
+ const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
+ acc[acc.length - 1] = merged;
+ return acc;
+ }
+ acc.push(current);
+ return acc;
+}
diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts
new file mode 100644
index 000000000..78271d4e9
--- /dev/null
+++ b/packages/astro/src/core/build/page-data.ts
@@ -0,0 +1,72 @@
+import type { AstroSettings, RoutesList } from '../../types/astro.js';
+import type { Logger } from '../logger/core.js';
+import type { AllPagesData } from './types.js';
+
+import * as colors from 'kleur/colors';
+import { debug } from '../logger/core.js';
+import { DEFAULT_COMPONENTS } from '../routing/default.js';
+import { makePageDataKey } from './plugins/util.js';
+
+export interface CollectPagesDataOptions {
+ settings: AstroSettings;
+ logger: Logger;
+ manifest: RoutesList;
+}
+
+export interface CollectPagesDataResult {
+ assets: Record<string, string>;
+ allPages: AllPagesData;
+}
+
+// Examines the routes and returns a collection of information about each page.
+export function collectPagesData(opts: CollectPagesDataOptions): CollectPagesDataResult {
+ const { settings, manifest } = opts;
+
+ const assets: Record<string, string> = {};
+ const allPages: AllPagesData = {};
+
+ // Collect all routes ahead-of-time, before we start the build.
+ // NOTE: This enforces that `getStaticPaths()` is only called once per route,
+ // and is then cached across all future SSR builds. In the past, we've had trouble
+ // with parallelized builds without guaranteeing that this is called first.
+ for (const route of manifest.routes) {
+ // There's special handling in SSR
+ if (DEFAULT_COMPONENTS.some((component) => route.component === component)) {
+ continue;
+ }
+
+ // Generate a unique key to identify each page in the build process.
+ const key = makePageDataKey(route.route, route.component);
+ // static route:
+ if (route.pathname) {
+ allPages[key] = {
+ key: key,
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ };
+
+ if (settings.buildOutput === 'static') {
+ const html = `${route.pathname}`.replace(/\/?$/, '/index.html');
+ debug(
+ 'build',
+ `├── ${colors.bold(colors.green('✔'))} ${route.component} → ${colors.yellow(html)}`,
+ );
+ } else {
+ debug('build', `├── ${colors.bold(colors.green('✔'))} ${route.component}`);
+ }
+ continue;
+ }
+ // dynamic route:
+ allPages[key] = {
+ key: key,
+ component: route.component,
+ route,
+ moduleSpecifier: '',
+ styles: [],
+ };
+ }
+
+ return { assets, allPages };
+}
diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts
new file mode 100644
index 000000000..70be64fdf
--- /dev/null
+++ b/packages/astro/src/core/build/pipeline.ts
@@ -0,0 +1,349 @@
+import { getOutputDirectory } from '../../prerender/utils.js';
+import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
+import type { RewritePayload } from '../../types/public/common.js';
+import type {
+ RouteData,
+ SSRElement,
+ SSRLoadedRenderer,
+ SSRResult,
+} from '../../types/public/internal.js';
+import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
+import type { SSRManifest } from '../app/types.js';
+import type { TryRewriteResult } from '../base-pipeline.js';
+import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js';
+import { RedirectSinglePageBuiltModule } from '../redirects/index.js';
+import { Pipeline } from '../render/index.js';
+import { createAssetLink, createStylesheetElementSet } from '../render/ssr-element.js';
+import { createDefaultRoutes } from '../routing/default.js';
+import { findRouteToRewrite } from '../routing/rewrite.js';
+import { getOutDirWithinCwd } from './common.js';
+import { type BuildInternals, cssOrder, getPageData, mergeInlineCss } from './internal.js';
+import { ASTRO_PAGE_MODULE_ID, ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
+import { getPagesFromVirtualModulePageName, getVirtualModulePageName } from './plugins/util.js';
+import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js';
+import { i18nHasFallback } from './util.js';
+
+/**
+ * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files.
+ */
+export class BuildPipeline extends Pipeline {
+ #componentsInterner: WeakMap<RouteData, SinglePageBuiltModule> = new WeakMap<
+ RouteData,
+ SinglePageBuiltModule
+ >();
+ /**
+ * This cache is needed to map a single `RouteData` to its file path.
+ * @private
+ */
+ #routesByFilePath: WeakMap<RouteData, string> = new WeakMap<RouteData, string>();
+
+ get outFolder() {
+ return this.settings.buildOutput === 'server'
+ ? this.settings.config.build.server
+ : getOutDirWithinCwd(this.settings.config.outDir);
+ }
+
+ private constructor(
+ readonly internals: BuildInternals,
+ readonly manifest: SSRManifest,
+ readonly options: StaticBuildOptions,
+ readonly config = options.settings.config,
+ readonly settings = options.settings,
+ readonly defaultRoutes = createDefaultRoutes(manifest),
+ ) {
+ const resolveCache = new Map<string, string>();
+
+ async function resolve(specifier: string) {
+ if (resolveCache.has(specifier)) {
+ return resolveCache.get(specifier)!;
+ }
+ const hashedFilePath = manifest.entryModules[specifier];
+ if (typeof hashedFilePath !== 'string' || hashedFilePath === '') {
+ // If no "astro:scripts/before-hydration.js" script exists in the build,
+ // then we can assume that no before-hydration scripts are needed.
+ if (specifier === BEFORE_HYDRATION_SCRIPT_ID) {
+ resolveCache.set(specifier, '');
+ return '';
+ }
+ throw new Error(`Cannot find the built path for ${specifier}`);
+ }
+ const assetLink = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
+ resolveCache.set(specifier, assetLink);
+ return assetLink;
+ }
+
+ const serverLike = settings.buildOutput === 'server';
+ // We can skip streaming in SSG for performance as writing as strings are faster
+ const streaming = serverLike;
+ super(
+ options.logger,
+ manifest,
+ options.runtimeMode,
+ manifest.renderers,
+ resolve,
+ serverLike,
+ streaming,
+ );
+ }
+
+ getRoutes(): RouteData[] {
+ return this.options.routesList.routes;
+ }
+
+ static create({
+ internals,
+ manifest,
+ options,
+ }: Pick<BuildPipeline, 'internals' | 'manifest' | 'options'>) {
+ return new BuildPipeline(internals, manifest, options);
+ }
+
+ /**
+ * The SSR build emits two important files:
+ * - dist/server/manifest.mjs
+ * - dist/renderers.mjs
+ *
+ * These two files, put together, will be used to generate the pages.
+ *
+ * ## Errors
+ *
+ * It will throw errors if the previous files can't be found in the file system.
+ *
+ * @param staticBuildOptions
+ */
+ static async retrieveManifest(
+ settings: AstroSettings,
+ internals: BuildInternals,
+ ): Promise<SSRManifest> {
+ const baseDirectory = getOutputDirectory(settings);
+ const manifestEntryUrl = new URL(
+ `${internals.manifestFileName}?time=${Date.now()}`,
+ baseDirectory,
+ );
+ const { manifest } = await import(manifestEntryUrl.toString());
+ if (!manifest) {
+ throw new Error(
+ "Astro couldn't find the emitted manifest. This is an internal error, please file an issue.",
+ );
+ }
+
+ const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory);
+ const renderers = await import(renderersEntryUrl.toString());
+
+ const middleware = internals.middlewareEntryPoint
+ ? async function () {
+ // @ts-expect-error: the compiler can't understand the previous check
+ const mod = await import(internals.middlewareEntryPoint.toString());
+ return { onRequest: mod.onRequest };
+ }
+ : manifest.middleware;
+
+ if (!renderers) {
+ throw new Error(
+ "Astro couldn't find the emitted renderers. This is an internal error, please file an issue.",
+ );
+ }
+ return {
+ ...manifest,
+ renderers: renderers.renderers as SSRLoadedRenderer[],
+ middleware,
+ };
+ }
+
+ headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
+ const {
+ internals,
+ manifest: { assetsPrefix, base },
+ settings,
+ } = this;
+ const links = new Set<never>();
+ const pageBuildData = getPageData(internals, routeData.route, routeData.component);
+ const scripts = new Set<SSRElement>();
+ const sortedCssAssets = pageBuildData?.styles
+ .sort(cssOrder)
+ .map(({ sheet }) => sheet)
+ .reduce(mergeInlineCss, []);
+ const styles = createStylesheetElementSet(sortedCssAssets ?? [], base, assetsPrefix);
+
+ if (settings.scripts.some((script) => script.stage === 'page')) {
+ const hashedFilePath = internals.entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID);
+ if (typeof hashedFilePath !== 'string') {
+ throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`);
+ }
+ const src = createAssetLink(hashedFilePath, base, assetsPrefix);
+ scripts.add({
+ props: { type: 'module', src },
+ children: '',
+ });
+ }
+
+ // Add all injected scripts to the page.
+ for (const script of settings.scripts) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.content,
+ });
+ }
+ }
+ return { scripts, styles, links };
+ }
+
+ componentMetadata() {}
+
+ /**
+ * It collects the routes to generate during the build.
+ * It returns a map of page information and their relative entry point as a string.
+ */
+ retrieveRoutesToGenerate(): Map<PageBuildData, string> {
+ const pages = new Map<PageBuildData, string>();
+
+ for (const [virtualModulePageName, filePath] of this.internals.entrySpecifierToBundleMap) {
+ // virtual pages are emitted with the 'plugin-pages' prefix
+ if (virtualModulePageName.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
+ let pageDatas: PageBuildData[] = [];
+ pageDatas.push(
+ ...getPagesFromVirtualModulePageName(
+ this.internals,
+ ASTRO_PAGE_RESOLVED_MODULE_ID,
+ virtualModulePageName,
+ ),
+ );
+ for (const pageData of pageDatas) {
+ pages.set(pageData, filePath);
+ }
+ }
+ }
+
+ for (const pageData of this.internals.pagesByKeys.values()) {
+ if (routeIsRedirect(pageData.route)) {
+ pages.set(pageData, pageData.component);
+ } else if (
+ routeIsFallback(pageData.route) &&
+ (i18nHasFallback(this.config) ||
+ (routeIsFallback(pageData.route) && pageData.route.route === '/'))
+ ) {
+ // The original component is transformed during the first build, so we have to retrieve
+ // the actual `.mjs` that was created.
+ // During the build, we transform the names of our pages with some weird name, and those weird names become the keys of a map.
+ // The values of the map are the actual `.mjs` files that are generated during the build
+
+ // Here, we take the component path and transform it in the virtual module name
+ const moduleSpecifier = getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component);
+ // We retrieve the original JS module
+ const filePath = this.internals.entrySpecifierToBundleMap.get(moduleSpecifier);
+ if (filePath) {
+ // it exists, added it to pages to render, using the file path that we just retrieved
+ pages.set(pageData, filePath);
+ }
+ }
+ }
+
+ for (const [buildData, filePath] of pages.entries()) {
+ this.#routesByFilePath.set(buildData.route, filePath);
+ }
+
+ return pages;
+ }
+
+ async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
+ if (this.#componentsInterner.has(routeData)) {
+ // SAFETY: checked before
+ const entry = this.#componentsInterner.get(routeData)!;
+ return await entry.page();
+ }
+
+ for (const route of this.defaultRoutes) {
+ if (route.component === routeData.component) {
+ return route.instance;
+ }
+ }
+
+ // SAFETY: the pipeline calls `retrieveRoutesToGenerate`, which is in charge to fill the cache.
+ const filePath = this.#routesByFilePath.get(routeData)!;
+ const module = await this.retrieveSsrEntry(routeData, filePath);
+ return module.page();
+ }
+
+ async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
+ const { routeData, pathname, newUrl } = findRouteToRewrite({
+ payload,
+ request,
+ routes: this.options.routesList.routes,
+ trailingSlash: this.config.trailingSlash,
+ buildFormat: this.config.build.format,
+ base: this.config.base,
+ });
+
+ const componentInstance = await this.getComponentByRoute(routeData);
+ return { routeData, componentInstance, newUrl, pathname };
+ }
+
+ async retrieveSsrEntry(route: RouteData, filePath: string): Promise<SinglePageBuiltModule> {
+ if (this.#componentsInterner.has(route)) {
+ // SAFETY: it is checked inside the if
+ return this.#componentsInterner.get(route)!;
+ }
+ let entry;
+ if (routeIsRedirect(route)) {
+ entry = await this.#getEntryForRedirectRoute(route, this.outFolder);
+ } else if (routeIsFallback(route)) {
+ entry = await this.#getEntryForFallbackRoute(route, this.outFolder);
+ } else {
+ const ssrEntryURLPage = createEntryURL(filePath, this.outFolder);
+ entry = await import(ssrEntryURLPage.toString());
+ }
+ this.#componentsInterner.set(route, entry);
+ return entry;
+ }
+
+ async #getEntryForFallbackRoute(
+ route: RouteData,
+ outFolder: URL,
+ ): Promise<SinglePageBuiltModule> {
+ if (route.type !== 'fallback') {
+ throw new Error(`Expected a redirect route.`);
+ }
+ if (route.redirectRoute) {
+ const filePath = getEntryFilePath(this.internals, route.redirectRoute);
+ if (filePath) {
+ const url = createEntryURL(filePath, outFolder);
+ const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
+ return ssrEntryPage;
+ }
+ }
+
+ return RedirectSinglePageBuiltModule;
+ }
+
+ async #getEntryForRedirectRoute(
+ route: RouteData,
+ outFolder: URL,
+ ): Promise<SinglePageBuiltModule> {
+ if (route.type !== 'redirect') {
+ throw new Error(`Expected a redirect route.`);
+ }
+ if (route.redirectRoute) {
+ const filePath = getEntryFilePath(this.internals, route.redirectRoute);
+ if (filePath) {
+ const url = createEntryURL(filePath, outFolder);
+ const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
+ return ssrEntryPage;
+ }
+ }
+
+ return RedirectSinglePageBuiltModule;
+ }
+}
+
+function createEntryURL(filePath: string, outFolder: URL) {
+ return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
+}
+
+/**
+ * For a given pageData, returns the entry file path—aka a resolved virtual module in our internals' specifiers.
+ */
+function getEntryFilePath(internals: BuildInternals, pageData: RouteData) {
+ const id = '\x00' + getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component);
+ return internals.entrySpecifierToBundleMap.get(id);
+}
diff --git a/packages/astro/src/core/build/plugin.ts b/packages/astro/src/core/build/plugin.ts
new file mode 100644
index 000000000..f16b5a1d9
--- /dev/null
+++ b/packages/astro/src/core/build/plugin.ts
@@ -0,0 +1,104 @@
+import type { Rollup, Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from './internal.js';
+import type { StaticBuildOptions, ViteBuildReturn } from './types.js';
+
+type RollupOutputArray = Extract<ViteBuildReturn, Array<any>>;
+type OutputChunkorAsset = RollupOutputArray[number]['output'][number];
+type OutputChunk = Extract<OutputChunkorAsset, { type: 'chunk' }>;
+export type BuildTarget = 'server' | 'client';
+
+type MutateChunk = (chunk: OutputChunk, targets: BuildTarget[], newCode: string) => void;
+
+export interface BuildBeforeHookResult {
+ enforce?: 'after-user-plugins';
+ vitePlugin: VitePlugin | VitePlugin[] | undefined;
+}
+
+export type AstroBuildPlugin = {
+ targets: BuildTarget[];
+ hooks?: {
+ 'build:before'?: (opts: {
+ target: BuildTarget;
+ input: Set<string>;
+ }) => BuildBeforeHookResult | Promise<BuildBeforeHookResult>;
+ 'build:post'?: (opts: {
+ ssrOutputs: RollupOutputArray;
+ clientOutputs: RollupOutputArray;
+ mutate: MutateChunk;
+ }) => void | Promise<void>;
+ };
+};
+
+export function createPluginContainer(options: StaticBuildOptions, internals: BuildInternals) {
+ const plugins = new Map<BuildTarget, AstroBuildPlugin[]>();
+ const allPlugins = new Set<AstroBuildPlugin>();
+ for (const target of ['client', 'server'] satisfies BuildTarget[]) {
+ plugins.set(target, []);
+ }
+
+ return {
+ options,
+ internals,
+ register(plugin: AstroBuildPlugin) {
+ allPlugins.add(plugin);
+ for (const target of plugin.targets) {
+ const targetPlugins = plugins.get(target) ?? [];
+ targetPlugins.push(plugin);
+ plugins.set(target, targetPlugins);
+ }
+ },
+
+ // Hooks
+ async runBeforeHook(target: BuildTarget, input: Set<string>) {
+ let targetPlugins = plugins.get(target) ?? [];
+ let vitePlugins: Array<VitePlugin | VitePlugin[]> = [];
+ let lastVitePlugins: Array<VitePlugin | VitePlugin[]> = [];
+ for (const plugin of targetPlugins) {
+ if (plugin.hooks?.['build:before']) {
+ let result = await plugin.hooks['build:before']({ target, input });
+ if (result.vitePlugin) {
+ vitePlugins.push(result.vitePlugin);
+ }
+ }
+ }
+
+ return {
+ vitePlugins,
+ lastVitePlugins,
+ };
+ },
+
+ async runPostHook(ssrOutputs: Rollup.RollupOutput[], clientOutputs: Rollup.RollupOutput[]) {
+ const mutations = new Map<
+ string,
+ {
+ targets: BuildTarget[];
+ code: string;
+ }
+ >();
+
+ const mutate: MutateChunk = (chunk, targets, newCode) => {
+ chunk.code = newCode;
+ mutations.set(chunk.fileName, {
+ targets,
+ code: newCode,
+ });
+ };
+
+ for (const plugin of allPlugins) {
+ const postHook = plugin.hooks?.['build:post'];
+ if (postHook) {
+ await postHook({
+ ssrOutputs,
+ clientOutputs,
+ mutate,
+ });
+ }
+ }
+
+ return mutations;
+ },
+ };
+}
+
+export type AstroBuildPluginContainer = ReturnType<typeof createPluginContainer>;
diff --git a/packages/astro/src/core/build/plugins/README.md b/packages/astro/src/core/build/plugins/README.md
new file mode 100644
index 000000000..667ec4a86
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/README.md
@@ -0,0 +1,173 @@
+# Plugin directory (WIP)
+
+This file serves as developer documentation to explain how the internal plugins work
+
+## `plugin-middleware`
+
+This plugin is responsible to retrieve the `src/middleware.{ts.js}` file and emit an entry point during the SSR build.
+
+The final file is emitted only if the user has the middleware file. The final name of the file is `middleware.mjs`.
+
+This is **not** a virtual module. The plugin will try to resolve the physical file.
+
+## `plugin-renderers`
+
+This plugin is responsible to collect all the renderers inside an Astro application and emit them in a single file.
+
+The emitted file is called `renderers.mjs`.
+
+The emitted file has content similar to:
+
+```js
+const renderers = [
+ Object.assign(
+ { name: 'astro:framework', serverEntrypoint: '@astrojs/framework/server.js' },
+ { ssr: server_default },
+ ),
+];
+
+export { renderers };
+```
+
+## `plugin-pages`
+
+This plugin is responsible to collect all pages inside an Astro application, and emit a single entry point file for each page.
+
+This plugin **will emit code** only when building a static site.
+
+In order to achieve that, the plugin emits these pages as **virtual modules**. Doing so allows us to bypass:
+
+- rollup resolution of the files
+- possible plugins that get triggered when the name of the module has an extension e.g. `.astro`
+
+The plugin does the following operations:
+
+- loop through all the pages and collects their paths;
+- with each path, we create a new [string](#plugin-pages-mapping-resolution) that will serve and virtual module for that particular page
+- when resolving the page, we check if the `id` of the module starts with `@astro-page`
+- once the module is resolved, we emit [the code of the module](#plugin-pages-code-generation)
+
+### `plugin pages` mapping resolution
+
+The mapping is as follows:
+
+```
+src/pages/index.astro => @astro-page:src/pages/index@_@astro
+```
+
+1. We add a fixed prefix, which is used as virtual module naming convention;
+2. We replace the dot that belongs extension with an arbitrary string.
+
+This kind of patterns will then allow us to retrieve the path physical path of the
+file back from that string. This is important for the [code generation](#plugin-pages-code-generation)
+
+### `plugin pages` code generation
+
+When generating the code of the page, we will import and export the following modules:
+
+- the `renderers.mjs`
+- the `middleware.mjs`
+- the page, via dynamic import
+
+The emitted code of each entry point will look like this:
+
+```js
+export { renderers } from '../renderers.mjs';
+import { _ as _middleware } from '../middleware.mjs';
+import '../chunks/astro.540fbe4e.mjs';
+
+const page = () => import('../chunks/pages/index.astro.8aad0438.mjs');
+const middleware = _middleware;
+
+export { middleware, page };
+```
+
+If we have a `pages/` folder that looks like this:
+
+```
+├── blog
+│ ├── first.astro
+│ └── post.astro
+├── first.astro
+├── index.astro
+├── issue.md
+└── second.astro
+```
+
+The emitted entry points will be stored inside a `pages/` folder, and they
+will look like this:
+
+```
+├── _astro
+│ ├── first.132e69e0.css
+│ ├── first.49cbf029.css
+│ ├── post.a3e86c58.css
+│ └── second.d178d0b2.css
+├── chunks
+│ ├── astro.540fbe4e.mjs
+│ └── pages
+│ ├── first.astro.493fa853.mjs
+│ ├── index.astro.8aad0438.mjs
+│ ├── issue.md.535b7d3b.mjs
+│ ├── post.astro.26e892d9.mjs
+│ └── second.astro.76540694.mjs
+├── middleware.mjs
+├── pages
+│ ├── blog
+│ │ ├── first.astro.mjs
+│ │ └── post.astro.mjs
+│ ├── first.astro.mjs
+│ ├── index.astro.mjs
+│ ├── issue.md.mjs
+│ └── second.astro.mjs
+└── renderers.mjs
+```
+
+Of course, all these files will be deleted by Astro at the end build.
+
+## `plugin-ssr`
+
+This plugin is responsible to create the JS files that will be executed in SSR.
+
+### Classic mode
+
+The plugin will emit a single entry point called `entry.mjs`.
+
+This plugin **will emit code** only when building an **SSR** site.
+
+The plugin will collect all the [virtual pages](#plugin-pages) and create
+a JavaScript `Map`. These map will look like this:
+
+```js
+const _page$0 = () => import('../chunks/<INDEX.ASTRO_CHUNK>.mjs');
+const _page$1 = () => import('../chunks/<ABOUT.ASTRO_CHUNK>.mjs');
+
+const pageMap = new Map([
+ ['src/pages/index.astro', _page$0],
+ ['src/pages/about.astro', _page$1],
+]);
+```
+
+It will also import the [`renderers`](#plugin-renderers) virtual module
+and the [`manifest`](#plugin-manifest) virtual module.
+
+### Split mode
+
+The plugin will emit various entry points. Each route will be an entry point.
+
+Each entry point will contain the necessary code to **render one single route**.
+
+Each entry point will also import the [`renderers`](#plugin-renderers) virtual module
+and the [`manifest`](#plugin-manifest) virtual module.
+
+## `plugin-manifest`
+
+This plugin is responsible to create a file called `manifest.mjs`. In SSG, the file is saved
+in `config.outDir`, in SSR the file is saved in `config.build.server`.
+
+This file is important to do two things:
+
+- generate the pages during the SSG;
+- render the pages in SSR;
+
+The file contains all the information needed to Astro to accomplish the operations mentioned above.
diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts
new file mode 100644
index 000000000..8f814946d
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/index.ts
@@ -0,0 +1,32 @@
+import { astroConfigBuildPlugin } from '../../../content/vite-plugin-content-assets.js';
+import { astroHeadBuildPlugin } from '../../../vite-plugin-head/index.js';
+import type { AstroBuildPluginContainer } from '../plugin.js';
+import { pluginAnalyzer } from './plugin-analyzer.js';
+import { pluginChunks } from './plugin-chunks.js';
+import { pluginComponentEntry } from './plugin-component-entry.js';
+import { pluginCSS } from './plugin-css.js';
+import { pluginInternals } from './plugin-internals.js';
+import { pluginManifest } from './plugin-manifest.js';
+import { pluginMiddleware } from './plugin-middleware.js';
+import { pluginPages } from './plugin-pages.js';
+import { pluginPrerender } from './plugin-prerender.js';
+import { pluginRenderers } from './plugin-renderers.js';
+import { pluginScripts } from './plugin-scripts.js';
+import { pluginSSR } from './plugin-ssr.js';
+
+export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) {
+ register(pluginComponentEntry(internals));
+ register(pluginAnalyzer(internals));
+ register(pluginInternals(internals));
+ register(pluginManifest(options, internals));
+ register(pluginRenderers(options));
+ register(pluginMiddleware(options, internals));
+ register(pluginPages(options, internals));
+ register(pluginCSS(options, internals));
+ register(astroHeadBuildPlugin(internals));
+ register(pluginPrerender(options, internals));
+ register(astroConfigBuildPlugin(options, internals));
+ register(pluginScripts(internals));
+ register(pluginSSR(options, internals));
+ register(pluginChunks());
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-analyzer.ts b/packages/astro/src/core/build/plugins/plugin-analyzer.ts
new file mode 100644
index 000000000..2b64d3383
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-analyzer.ts
@@ -0,0 +1,98 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types.js';
+import { getTopLevelPageModuleInfos } from '../graph.js';
+import type { BuildInternals } from '../internal.js';
+import {
+ getPageDataByViteID,
+ trackClientOnlyPageDatas,
+ trackScriptPageDatas,
+} from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+
+export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
+ return {
+ name: '@astro/rollup-plugin-astro-analyzer',
+ async generateBundle() {
+ const ids = this.getModuleIds();
+
+ for (const id of ids) {
+ const info = this.getModuleInfo(id);
+ if (!info?.meta?.astro) continue;
+
+ const astro = info.meta.astro as AstroPluginMetadata['astro'];
+
+ for (const c of astro.hydratedComponents) {
+ const rid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier;
+ if (internals.discoveredHydratedComponents.has(rid)) {
+ const exportNames = internals.discoveredHydratedComponents.get(rid);
+ exportNames?.push(c.exportName);
+ } else {
+ internals.discoveredHydratedComponents.set(rid, [c.exportName]);
+ }
+ }
+
+ if (astro.clientOnlyComponents.length) {
+ const clientOnlys: string[] = [];
+
+ for (const c of astro.clientOnlyComponents) {
+ const cid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier;
+ if (internals.discoveredClientOnlyComponents.has(cid)) {
+ const exportNames = internals.discoveredClientOnlyComponents.get(cid);
+ exportNames?.push(c.exportName);
+ } else {
+ internals.discoveredClientOnlyComponents.set(cid, [c.exportName]);
+ }
+ clientOnlys.push(cid);
+
+ const resolvedId = await this.resolve(c.specifier, id);
+ if (resolvedId) {
+ clientOnlys.push(resolvedId.id);
+ }
+ }
+
+ for (const pageInfo of getTopLevelPageModuleInfos(id, this)) {
+ const newPageData = getPageDataByViteID(internals, pageInfo.id);
+ if (!newPageData) continue;
+
+ trackClientOnlyPageDatas(internals, newPageData, clientOnlys);
+ }
+ }
+
+ // When directly rendering scripts, we don't need to group them together when bundling,
+ // each script module is its own entrypoint, so we directly assign each script modules to
+ // `discoveredScripts` here, which will eventually be passed as inputs of the client build.
+ if (astro.scripts.length) {
+ const scriptIds = astro.scripts.map(
+ (_, i) => `${id.replace('/@fs', '')}?astro&type=script&index=${i}&lang.ts`,
+ );
+
+ // Assign as entrypoints for the client bundle
+ for (const scriptId of scriptIds) {
+ internals.discoveredScripts.add(scriptId);
+ }
+
+ // The script may import CSS, so we also have to track the pages that use this script
+ for (const pageInfo of getTopLevelPageModuleInfos(id, this)) {
+ const newPageData = getPageDataByViteID(internals, pageInfo.id);
+ if (!newPageData) continue;
+
+ trackScriptPageDatas(internals, newPageData, scriptIds);
+ }
+ }
+ }
+ },
+ };
+}
+
+export function pluginAnalyzer(internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginAnalyzer(internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-chunks.ts b/packages/astro/src/core/build/plugins/plugin-chunks.ts
new file mode 100644
index 000000000..3348e126c
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-chunks.ts
@@ -0,0 +1,37 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { AstroBuildPlugin } from '../plugin.js';
+import { extendManualChunks } from './util.js';
+
+export function vitePluginChunks(): VitePlugin {
+ return {
+ name: 'astro:chunks',
+ outputOptions(outputOptions) {
+ extendManualChunks(outputOptions, {
+ after(id) {
+ // Place Astro's server runtime in a single `astro/server.mjs` file
+ if (id.includes('astro/dist/runtime/server/')) {
+ return 'astro/server';
+ }
+ // Split the Astro runtime into a separate chunk for readability
+ if (id.includes('astro/dist/runtime')) {
+ return 'astro';
+ }
+ },
+ });
+ },
+ };
+}
+
+// Build plugin that configures specific chunking behavior
+export function pluginChunks(): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginChunks(),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-component-entry.ts b/packages/astro/src/core/build/plugins/plugin-component-entry.ts
new file mode 100644
index 000000000..bfa2ce58c
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-component-entry.ts
@@ -0,0 +1,89 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+
+export const astroEntryPrefix = '\0astro-entry:';
+
+/**
+ * When adding hydrated or client:only components as Rollup inputs, sometimes we're not using all
+ * of the export names, e.g. `import { Counter } from './ManyComponents.jsx'`. This plugin proxies
+ * entries to re-export only the names the user is using.
+ */
+export function vitePluginComponentEntry(internals: BuildInternals): VitePlugin {
+ const componentToExportNames = new Map<string, string[]>();
+
+ mergeComponentExportNames(internals.discoveredHydratedComponents);
+ mergeComponentExportNames(internals.discoveredClientOnlyComponents);
+
+ for (const [componentId, exportNames] of componentToExportNames) {
+ // If one of the imports has a dot, it's a namespaced import, e.g. `import * as foo from 'foo'`
+ // and `<foo.Counter />`, in which case we re-export `foo` entirely and we don't need to handle
+ // it in this plugin as it's default behaviour from Rollup.
+ if (exportNames.some((name) => name.includes('.') || name === '*')) {
+ componentToExportNames.delete(componentId);
+ } else {
+ componentToExportNames.set(componentId, Array.from(new Set(exportNames)));
+ }
+ }
+
+ function mergeComponentExportNames(components: Map<string, string[]>) {
+ for (const [componentId, exportNames] of components) {
+ if (componentToExportNames.has(componentId)) {
+ componentToExportNames.get(componentId)?.push(...exportNames);
+ } else {
+ componentToExportNames.set(componentId, exportNames);
+ }
+ }
+ }
+
+ return {
+ name: '@astro/plugin-component-entry',
+ enforce: 'pre',
+ config(config) {
+ const rollupInput = config.build?.rollupOptions?.input;
+ // Astro passes an array of inputs by default. Even though other Vite plugins could
+ // change this to an object, it shouldn't happen in practice as our plugin runs first.
+ if (Array.isArray(rollupInput)) {
+ // @ts-expect-error input is definitely defined here, but typescript thinks it doesn't
+ config.build.rollupOptions.input = rollupInput.map((id) => {
+ if (componentToExportNames.has(id)) {
+ return astroEntryPrefix + id;
+ } else {
+ return id;
+ }
+ });
+ }
+ },
+ async resolveId(id) {
+ if (id.startsWith(astroEntryPrefix)) {
+ return id;
+ }
+ },
+ async load(id) {
+ if (id.startsWith(astroEntryPrefix)) {
+ const componentId = id.slice(astroEntryPrefix.length);
+ const exportNames = componentToExportNames.get(componentId);
+ if (exportNames) {
+ return `export { ${exportNames.join(', ')} } from ${JSON.stringify(componentId)}`;
+ }
+ }
+ },
+ };
+}
+
+export function normalizeEntryId(id: string): string {
+ return id.startsWith(astroEntryPrefix) ? id.slice(astroEntryPrefix.length) : id;
+}
+
+export function pluginComponentEntry(internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['client'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginComponentEntry(internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts
new file mode 100644
index 000000000..c39d4da6f
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-css.ts
@@ -0,0 +1,345 @@
+import type { GetModuleInfo } from 'rollup';
+import type { BuildOptions, ResolvedConfig, Rollup, Plugin as VitePlugin } from 'vite';
+import { isBuildableCSSRequest } from '../../../vite-plugin-astro-server/util.js';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin, BuildTarget } from '../plugin.js';
+import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types.js';
+
+import { hasAssetPropagationFlag } from '../../../content/index.js';
+import type { AstroPluginCssMetadata } from '../../../vite-plugin-astro/index.js';
+import * as assetName from '../css-asset-name.js';
+import {
+ getParentExtendedModuleInfos,
+ getParentModuleInfos,
+ moduleIsTopLevelPage,
+} from '../graph.js';
+import { getPageDataByViteID, getPageDatasByClientOnlyID } from '../internal.js';
+import { extendManualChunks, shouldInlineAsset } from './util.js';
+
+interface PluginOptions {
+ internals: BuildInternals;
+ buildOptions: StaticBuildOptions;
+ target: BuildTarget;
+}
+
+/***** ASTRO PLUGIN *****/
+
+export function pluginCSS(
+ options: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ return {
+ targets: ['client', 'server'],
+ hooks: {
+ 'build:before': ({ target }) => {
+ let plugins = rollupPluginAstroBuildCSS({
+ buildOptions: options,
+ internals,
+ target,
+ });
+
+ return {
+ vitePlugin: plugins,
+ };
+ },
+ },
+ };
+}
+
+/***** ROLLUP SUB-PLUGINS *****/
+
+function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
+ const { internals, buildOptions } = options;
+ const { settings } = buildOptions;
+
+ let resolvedConfig: ResolvedConfig;
+
+ // stylesheet filenames are kept in here until "post", when they are rendered and ready to be inlined
+ const pagesToCss: Record<string, Record<string, { order: number; depth: number }>> = {};
+ // Map of module Ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) to its imported CSS
+ const moduleIdToPropagatedCss: Record<string, Set<string>> = {};
+
+ const cssBuildPlugin: VitePlugin = {
+ name: 'astro:rollup-plugin-build-css',
+
+ outputOptions(outputOptions) {
+ const assetFileNames = outputOptions.assetFileNames;
+ const namingIncludesHash = assetFileNames?.toString().includes('[hash]');
+ const createNameForParentPages = namingIncludesHash
+ ? assetName.shortHashedName(settings)
+ : assetName.createSlugger(settings);
+
+ extendManualChunks(outputOptions, {
+ after(id, meta) {
+ // For CSS, create a hash of all of the pages that use it.
+ // This causes CSS to be built into shared chunks when used by multiple pages.
+ if (isBuildableCSSRequest(id)) {
+ // For client builds that has hydrated components as entrypoints, there's no way
+ // to crawl up and find the pages that use it. So we lookup the cache during SSR
+ // build (that has the pages information) to derive the same chunk id so they
+ // match up on build, making sure both builds has the CSS deduped.
+ // NOTE: Components that are only used with `client:only` may not exist in the cache
+ // and that's okay. We can use Rollup's default chunk strategy instead as these CSS
+ // are outside of the SSR build scope, which no dedupe is needed.
+ if (options.target === 'client') {
+ return internals.cssModuleToChunkIdMap.get(id)!;
+ }
+
+ const ctx = { getModuleInfo: meta.getModuleInfo };
+ for (const pageInfo of getParentModuleInfos(id, ctx)) {
+ if (hasAssetPropagationFlag(pageInfo.id)) {
+ // Split delayed assets to separate modules
+ // so they can be injected where needed
+ const chunkId = assetName.createNameHash(id, [id], settings);
+ internals.cssModuleToChunkIdMap.set(id, chunkId);
+ return chunkId;
+ }
+ }
+ const chunkId = createNameForParentPages(id, meta);
+ internals.cssModuleToChunkIdMap.set(id, chunkId);
+ return chunkId;
+ }
+ },
+ });
+ },
+
+ async generateBundle(_outputOptions, bundle) {
+ for (const [, chunk] of Object.entries(bundle)) {
+ if (chunk.type !== 'chunk') continue;
+ if ('viteMetadata' in chunk === false) continue;
+ const meta = chunk.viteMetadata as ViteMetadata;
+
+ // Skip if the chunk has no CSS, we want to handle CSS chunks only
+ if (meta.importedCss.size < 1) continue;
+
+ // For the client build, client:only styles need to be mapped
+ // over to their page. For this chunk, determine if it's a child of a
+ // client:only component and if so, add its CSS to the page it belongs to.
+ if (options.target === 'client') {
+ for (const id of Object.keys(chunk.modules)) {
+ for (const pageData of getParentClientOnlys(id, this, internals)) {
+ for (const importedCssImport of meta.importedCss) {
+ const cssToInfoRecord = (pagesToCss[pageData.moduleSpecifier] ??= {});
+ cssToInfoRecord[importedCssImport] = { depth: -1, order: -1 };
+ }
+ }
+ }
+ }
+
+ // For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
+ for (const id of Object.keys(chunk.modules)) {
+ const parentModuleInfos = getParentExtendedModuleInfos(id, this, hasAssetPropagationFlag);
+ for (const { info: pageInfo, depth, order } of parentModuleInfos) {
+ if (hasAssetPropagationFlag(pageInfo.id)) {
+ const propagatedCss = (moduleIdToPropagatedCss[pageInfo.id] ??= new Set());
+ for (const css of meta.importedCss) {
+ propagatedCss.add(css);
+ }
+ } else if (moduleIsTopLevelPage(pageInfo)) {
+ const pageViteID = pageInfo.id;
+ const pageData = getPageDataByViteID(internals, pageViteID);
+ if (pageData) {
+ appendCSSToPage(pageData, meta, pagesToCss, depth, order);
+ }
+ } else if (options.target === 'client') {
+ // For scripts, walk parents until you find a page, and add the CSS to that page.
+ const pageDatas = internals.pagesByScriptId.get(pageInfo.id)!;
+ if (pageDatas) {
+ for (const pageData of pageDatas) {
+ appendCSSToPage(pageData, meta, pagesToCss, -1, order);
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ };
+
+ /**
+ * This plugin is a port of https://github.com/vitejs/vite/pull/16058. It enables removing unused
+ * scoped CSS from the bundle if the scoped target (e.g. Astro files) were not bundled.
+ * Once/If that PR is merged, we can refactor this away, renaming `meta.astroCss` to `meta.vite`.
+ */
+ const cssScopeToPlugin: VitePlugin = {
+ name: 'astro:rollup-plugin-css-scope-to',
+ renderChunk(_, chunk, __, meta) {
+ for (const id in chunk.modules) {
+ // If this CSS is scoped to its importers exports, check if those importers exports
+ // are rendered in the chunks. If they are not, we can skip bundling this CSS.
+ const modMeta = this.getModuleInfo(id)?.meta as AstroPluginCssMetadata | undefined;
+ const cssScopeTo = modMeta?.astroCss?.cssScopeTo;
+ if (cssScopeTo && !isCssScopeToRendered(cssScopeTo, Object.values(meta.chunks))) {
+ // If this CSS is not used, delete it from the chunk modules so that Vite is unable
+ // to trace that it's used
+ delete chunk.modules[id];
+ const moduleIdsIndex = chunk.moduleIds.indexOf(id);
+ if (moduleIdsIndex > -1) {
+ chunk.moduleIds.splice(moduleIdsIndex, 1);
+ }
+ }
+ }
+ },
+ };
+
+ const singleCssPlugin: VitePlugin = {
+ name: 'astro:rollup-plugin-single-css',
+ enforce: 'post',
+ configResolved(config) {
+ resolvedConfig = config;
+ },
+ generateBundle(_, bundle) {
+ // If user disable css code-splitting, search for Vite's hardcoded
+ // `style.css` and add it as css for each page.
+ // Ref: https://github.com/vitejs/vite/blob/b2c0ee04d4db4a0ef5a084c50f49782c5f88587c/packages/vite/src/node/plugins/html.ts#L690-L705
+ if (resolvedConfig.build.cssCodeSplit) return;
+ const cssChunk = Object.values(bundle).find(
+ (chunk) => chunk.type === 'asset' && chunk.name === 'style.css',
+ );
+ if (cssChunk === undefined) return;
+ for (const pageData of internals.pagesByKeys.values()) {
+ const cssToInfoMap = (pagesToCss[pageData.moduleSpecifier] ??= {});
+ cssToInfoMap[cssChunk.fileName] = { depth: -1, order: -1 };
+ }
+ },
+ };
+
+ let assetsInlineLimit: NonNullable<BuildOptions['assetsInlineLimit']>;
+ const inlineStylesheetsPlugin: VitePlugin = {
+ name: 'astro:rollup-plugin-inline-stylesheets',
+ enforce: 'post',
+ configResolved(config) {
+ assetsInlineLimit = config.build.assetsInlineLimit;
+ },
+ async generateBundle(_outputOptions, bundle) {
+ const inlineConfig = settings.config.build.inlineStylesheets;
+ Object.entries(bundle).forEach(([id, stylesheet]) => {
+ if (
+ stylesheet.type !== 'asset' ||
+ stylesheet.name?.endsWith('.css') !== true ||
+ typeof stylesheet.source !== 'string'
+ )
+ return;
+
+ const toBeInlined =
+ inlineConfig === 'always'
+ ? true
+ : inlineConfig === 'never'
+ ? false
+ : shouldInlineAsset(stylesheet.source, stylesheet.fileName, assetsInlineLimit);
+
+ // there should be a single js object for each stylesheet,
+ // allowing the single reference to be shared and checked for duplicates
+ const sheet: StylesheetAsset = toBeInlined
+ ? { type: 'inline', content: stylesheet.source }
+ : { type: 'external', src: stylesheet.fileName };
+
+ let sheetAddedToPage = false;
+
+ internals.pagesByKeys.forEach((pageData) => {
+ const orderingInfo = pagesToCss[pageData.moduleSpecifier]?.[stylesheet.fileName];
+ if (orderingInfo !== undefined) {
+ pageData.styles.push({ ...orderingInfo, sheet });
+ sheetAddedToPage = true;
+ }
+ });
+
+ // Apply `moduleIdToPropagatedCss` information to `internals.propagatedStylesMap`.
+ // NOTE: It's pretty much a copy over to `internals.propagatedStylesMap` as it should be
+ // completely empty. The whole propagation handling could be better refactored in the future.
+ for (const moduleId in moduleIdToPropagatedCss) {
+ if (!moduleIdToPropagatedCss[moduleId].has(stylesheet.fileName)) continue;
+ let propagatedStyles = internals.propagatedStylesMap.get(moduleId);
+ if (!propagatedStyles) {
+ propagatedStyles = new Set();
+ internals.propagatedStylesMap.set(moduleId, propagatedStyles);
+ }
+ propagatedStyles.add(sheet);
+ sheetAddedToPage = true;
+ }
+
+ if (toBeInlined && sheetAddedToPage) {
+ // CSS is already added to all used pages, we can delete it from the bundle
+ // and make sure no chunks reference it via `importedCss` (for Vite preloading)
+ // to avoid duplicate CSS.
+ delete bundle[id];
+ for (const chunk of Object.values(bundle)) {
+ if (chunk.type === 'chunk') {
+ chunk.viteMetadata?.importedCss?.delete(id);
+ }
+ }
+ }
+ });
+ },
+ };
+
+ return [cssBuildPlugin, cssScopeToPlugin, singleCssPlugin, inlineStylesheetsPlugin];
+}
+
+/***** UTILITY FUNCTIONS *****/
+
+function* getParentClientOnlys(
+ id: string,
+ ctx: { getModuleInfo: GetModuleInfo },
+ internals: BuildInternals,
+): Generator<PageBuildData, void, unknown> {
+ for (const info of getParentModuleInfos(id, ctx)) {
+ yield* getPageDatasByClientOnlyID(internals, info.id);
+ }
+}
+
+type ViteMetadata = {
+ importedAssets: Set<string>;
+ importedCss: Set<string>;
+};
+
+function appendCSSToPage(
+ pageData: PageBuildData,
+ meta: ViteMetadata,
+ pagesToCss: Record<string, Record<string, { order: number; depth: number }>>,
+ depth: number,
+ order: number,
+) {
+ for (const importedCssImport of meta.importedCss) {
+ // CSS is prioritized based on depth. Shared CSS has a higher depth due to being imported by multiple pages.
+ // Depth info is used when sorting the links on the page.
+ const cssInfo = pagesToCss[pageData.moduleSpecifier]?.[importedCssImport];
+ if (cssInfo !== undefined) {
+ if (depth < cssInfo.depth) {
+ cssInfo.depth = depth;
+ }
+
+ // Update the order, preferring the lowest order we have.
+ if (cssInfo.order === -1) {
+ cssInfo.order = order;
+ } else if (order < cssInfo.order && order > -1) {
+ cssInfo.order = order;
+ }
+ } else {
+ const cssToInfoRecord = (pagesToCss[pageData.moduleSpecifier] ??= {});
+ cssToInfoRecord[importedCssImport] = { depth, order };
+ }
+ }
+}
+
+/**
+ * `cssScopeTo` is a map of `importer`s to its `export`s. This function iterate each `cssScopeTo` entries
+ * and check if the `importer` and its `export`s exists in the final chunks. If at least one matches,
+ * `cssScopeTo` is considered "rendered" by Rollup and we return true.
+ */
+function isCssScopeToRendered(
+ cssScopeTo: Record<string, string[]>,
+ chunks: Rollup.RenderedChunk[],
+) {
+ for (const moduleId in cssScopeTo) {
+ const exports = cssScopeTo[moduleId];
+ // Find the chunk that renders this `moduleId` and get the rendered module
+ const renderedModule = chunks.find((c) => c.moduleIds.includes(moduleId))?.modules[moduleId];
+ // Return true if `renderedModule` exists and one of its exports is rendered
+ if (renderedModule?.renderedExports.some((e) => exports.includes(e))) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts
new file mode 100644
index 000000000..01d524515
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-internals.ts
@@ -0,0 +1,66 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import { normalizeEntryId } from './plugin-component-entry.js';
+
+export function vitePluginInternals(input: Set<string>, internals: BuildInternals): VitePlugin {
+ return {
+ name: '@astro/plugin-build-internals',
+
+ config(config, options) {
+ if (options.command === 'build' && config.build?.ssr) {
+ return {
+ ssr: {
+ // Always bundle Astro runtime when building for SSR
+ noExternal: ['astro'],
+ // Except for these packages as they're not bundle-friendly. Users with strict package installations
+ // need to manually install these themselves if they use the related features.
+ external: [
+ 'sharp', // For sharp image service
+ ],
+ },
+ };
+ }
+ },
+
+ async generateBundle(_options, bundle) {
+ const promises = [];
+ const mapping = new Map<string, Set<string>>();
+ for (const specifier of input) {
+ promises.push(
+ this.resolve(specifier).then((result) => {
+ if (result) {
+ if (mapping.has(result.id)) {
+ mapping.get(result.id)!.add(specifier);
+ } else {
+ mapping.set(result.id, new Set<string>([specifier]));
+ }
+ }
+ }),
+ );
+ }
+ await Promise.all(promises);
+ for (const [, chunk] of Object.entries(bundle)) {
+ if (chunk.type === 'chunk' && chunk.facadeModuleId) {
+ const specifiers = mapping.get(chunk.facadeModuleId) || new Set([chunk.facadeModuleId]);
+ for (const specifier of specifiers) {
+ internals.entrySpecifierToBundleMap.set(normalizeEntryId(specifier), chunk.fileName);
+ }
+ }
+ }
+ },
+ };
+}
+
+export function pluginInternals(internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['client', 'server'],
+ hooks: {
+ 'build:before': ({ input }) => {
+ return {
+ vitePlugin: vitePluginInternals(input, internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
new file mode 100644
index 000000000..de9645e0a
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -0,0 +1,309 @@
+import { fileURLToPath } from 'node:url';
+import glob from 'fast-glob';
+import type { OutputChunk } from 'rollup';
+import type { Plugin as VitePlugin } from 'vite';
+import { getAssetsPrefix } from '../../../assets/utils/getAssetsPrefix.js';
+import { normalizeTheLocale } from '../../../i18n/index.js';
+import { toFallbackType, toRoutingStrategy } from '../../../i18n/utils.js';
+import { runHookBuildSsr } from '../../../integrations/hooks.js';
+import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
+import type {
+ SSRManifestI18n,
+ SerializedRouteInfo,
+ SerializedSSRManifest,
+} from '../../app/types.js';
+import { encodeKey } from '../../encryption.js';
+import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js';
+import { DEFAULT_COMPONENTS } from '../../routing/default.js';
+import { serializeRouteData } from '../../routing/index.js';
+import { resolveSessionDriver } from '../../session.js';
+import { addRollupInput } from '../add-rollup-input.js';
+import { getOutFile, getOutFolder } from '../common.js';
+import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+import { makePageDataKey } from './util.js';
+
+const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
+const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g');
+
+export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest';
+export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID;
+
+function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
+ return {
+ name: '@astro/plugin-build-manifest',
+ enforce: 'post',
+ options(opts) {
+ return addRollupInput(opts, [SSR_MANIFEST_VIRTUAL_MODULE_ID]);
+ },
+ resolveId(id) {
+ if (id === SSR_MANIFEST_VIRTUAL_MODULE_ID) {
+ return RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID;
+ }
+ },
+ augmentChunkHash(chunkInfo) {
+ if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
+ return Date.now().toString();
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
+ const imports = [
+ `import { deserializeManifest as _deserializeManifest } from 'astro/app'`,
+ `import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`,
+ ];
+
+ const resolvedDriver = await resolveSessionDriver(
+ options.settings.config.experimental?.session?.driver,
+ );
+
+ const contents = [
+ `const manifest = _deserializeManifest('${manifestReplace}');`,
+ `if (manifest.sessionConfig) manifest.sessionConfig.driverModule = ${resolvedDriver ? `() => import(${JSON.stringify(resolvedDriver)})` : 'null'};`,
+ `_privateSetManifestDontUseThis(manifest);`,
+ ];
+ const exports = [`export { manifest }`];
+
+ return [...imports, ...contents, ...exports].join('\n');
+ }
+ },
+
+ async generateBundle(_opts, bundle) {
+ for (const [chunkName, chunk] of Object.entries(bundle)) {
+ if (chunk.type === 'asset') {
+ continue;
+ }
+ if (chunk.modules[RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID]) {
+ internals.manifestEntryChunk = chunk;
+ delete bundle[chunkName];
+ }
+ if (chunkName.startsWith('manifest')) {
+ internals.manifestFileName = chunkName;
+ }
+ }
+ },
+ };
+}
+
+export function pluginManifest(
+ options: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginManifest(options, internals),
+ };
+ },
+
+ 'build:post': async ({ mutate }) => {
+ if (!internals.manifestEntryChunk) {
+ throw new Error(`Did not generate an entry chunk for SSR`);
+ }
+
+ const manifest = await createManifest(options, internals);
+ const shouldPassMiddlewareEntryPoint =
+ options.settings.adapter?.adapterFeatures?.edgeMiddleware;
+ await runHookBuildSsr({
+ config: options.settings.config,
+ manifest,
+ logger: options.logger,
+ entryPoints: internals.entryPoints,
+ middlewareEntryPoint: shouldPassMiddlewareEntryPoint
+ ? internals.middlewareEntryPoint
+ : undefined,
+ });
+ const code = injectManifest(manifest, internals.manifestEntryChunk);
+ mutate(internals.manifestEntryChunk, ['server'], code);
+ },
+ },
+ };
+}
+
+async function createManifest(
+ buildOpts: StaticBuildOptions,
+ internals: BuildInternals,
+): Promise<SerializedSSRManifest> {
+ if (!internals.manifestEntryChunk) {
+ throw new Error(`Did not generate an entry chunk for SSR`);
+ }
+
+ // Add assets from the client build.
+ const clientStatics = new Set(
+ await glob('**/*', {
+ cwd: fileURLToPath(buildOpts.settings.config.build.client),
+ }),
+ );
+ for (const file of clientStatics) {
+ internals.staticFiles.add(file);
+ }
+
+ const staticFiles = internals.staticFiles;
+ const encodedKey = await encodeKey(await buildOpts.key);
+ return buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
+}
+
+/**
+ * It injects the manifest in the given output rollup chunk. It returns the new emitted code
+ */
+function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly<OutputChunk>) {
+ const code = chunk.code;
+
+ return code.replace(replaceExp, () => {
+ return JSON.stringify(manifest);
+ });
+}
+
+function buildManifest(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+ staticFiles: string[],
+ encodedKey: string,
+): SerializedSSRManifest {
+ const { settings } = opts;
+
+ const routes: SerializedRouteInfo[] = [];
+ const domainLookupTable: Record<string, string> = {};
+ const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries());
+ if (settings.scripts.some((script) => script.stage === 'page')) {
+ staticFiles.push(entryModules[PAGE_SCRIPT_ID]);
+ }
+
+ const prefixAssetPath = (pth: string) => {
+ if (settings.config.build.assetsPrefix) {
+ const pf = getAssetsPrefix(fileExtension(pth), settings.config.build.assetsPrefix);
+ return joinPaths(pf, pth);
+ } else {
+ return prependForwardSlash(joinPaths(settings.config.base, pth));
+ }
+ };
+
+ // Default components follow a special flow during build. We prevent their processing earlier
+ // in the build. As a result, they are not present on `internals.pagesByKeys` and not serialized
+ // in the manifest file. But we need them in the manifest, so we handle them here
+ for (const route of opts.routesList.routes) {
+ if (!DEFAULT_COMPONENTS.find((component) => route.component === component)) {
+ continue;
+ }
+ routes.push({
+ file: '',
+ links: [],
+ scripts: [],
+ styles: [],
+ routeData: serializeRouteData(route, settings.config.trailingSlash),
+ });
+ }
+
+ for (const route of opts.routesList.routes) {
+ if (!route.prerender) continue;
+ if (!route.pathname) continue;
+
+ const outFolder = getOutFolder(opts.settings, route.pathname, route);
+ const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route);
+ const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
+ routes.push({
+ file,
+ links: [],
+ scripts: [],
+ styles: [],
+ routeData: serializeRouteData(route, settings.config.trailingSlash),
+ });
+ staticFiles.push(file);
+ }
+
+ for (const route of opts.routesList.routes) {
+ const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component));
+ if (route.prerender || !pageData) continue;
+ const scripts: SerializedRouteInfo['scripts'] = [];
+ if (settings.scripts.some((script) => script.stage === 'page')) {
+ const src = entryModules[PAGE_SCRIPT_ID];
+
+ scripts.push({
+ type: 'external',
+ value: prefixAssetPath(src),
+ });
+ }
+
+ // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
+ const links: [] = [];
+
+ const styles = pageData.styles
+ .sort(cssOrder)
+ .map(({ sheet }) => sheet)
+ .map((s) => (s.type === 'external' ? { ...s, src: prefixAssetPath(s.src) } : s))
+ .reduce(mergeInlineCss, []);
+
+ routes.push({
+ file: '',
+ links,
+ scripts: [
+ ...scripts,
+ ...settings.scripts
+ .filter((script) => script.stage === 'head-inline')
+ .map(({ stage, content }) => ({ stage, children: content })),
+ ],
+ styles,
+ routeData: serializeRouteData(route, settings.config.trailingSlash),
+ });
+ }
+
+ /**
+ * logic meant for i18n domain support, where we fill the lookup table
+ */
+ const i18n = settings.config.i18n;
+ if (i18n && i18n.domains) {
+ for (const [locale, domainValue] of Object.entries(i18n.domains)) {
+ domainLookupTable[domainValue] = normalizeTheLocale(locale);
+ }
+ }
+
+ // HACK! Patch this special one.
+ if (!(BEFORE_HYDRATION_SCRIPT_ID in entryModules)) {
+ // Set this to an empty string so that the runtime knows not to try and load this.
+ entryModules[BEFORE_HYDRATION_SCRIPT_ID] = '';
+ }
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.i18n) {
+ i18nManifest = {
+ fallback: settings.config.i18n.fallback,
+ fallbackType: toFallbackType(settings.config.i18n.routing),
+ strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
+ locales: settings.config.i18n.locales,
+ defaultLocale: settings.config.i18n.defaultLocale,
+ domainLookupTable,
+ };
+ }
+
+ return {
+ hrefRoot: opts.settings.config.root.toString(),
+ cacheDir: opts.settings.config.cacheDir.toString(),
+ outDir: opts.settings.config.outDir.toString(),
+ srcDir: opts.settings.config.srcDir.toString(),
+ publicDir: opts.settings.config.publicDir.toString(),
+ buildClientDir: opts.settings.config.build.client.toString(),
+ buildServerDir: opts.settings.config.build.server.toString(),
+ adapterName: opts.settings.adapter?.name ?? '',
+ routes,
+ site: settings.config.site,
+ base: settings.config.base,
+ trailingSlash: settings.config.trailingSlash,
+ compressHTML: settings.config.compressHTML,
+ assetsPrefix: settings.config.build.assetsPrefix,
+ componentMetadata: Array.from(internals.componentMetadata),
+ renderers: [],
+ clientDirectives: Array.from(settings.clientDirectives),
+ entryModules,
+ inlinedScripts: Array.from(internals.inlinedScripts),
+ assets: staticFiles.map(prefixAssetPath),
+ i18n: i18nManifest,
+ buildFormat: settings.config.build.format,
+ checkOrigin:
+ (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
+ serverIslandNameMap: Array.from(settings.serverIslandNameMap),
+ key: encodedKey,
+ sessionConfig: settings.config.experimental.session,
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts
new file mode 100644
index 000000000..6b982053f
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts
@@ -0,0 +1,21 @@
+import { vitePluginMiddlewareBuild } from '../../middleware/vite-plugin.js';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+export { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js';
+
+export function pluginMiddleware(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginMiddlewareBuild(opts, internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts
new file mode 100644
index 000000000..75cbde220
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-pages.ts
@@ -0,0 +1,72 @@
+import type { Plugin as VitePlugin } from 'vite';
+import { routeIsRedirect } from '../../redirects/index.js';
+import { addRollupInput } from '../add-rollup-input.js';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
+import { getPagesFromVirtualModulePageName, getVirtualModulePageName } from './util.js';
+
+export const ASTRO_PAGE_MODULE_ID = '@astro-page:';
+export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0' + ASTRO_PAGE_MODULE_ID;
+
+function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
+ return {
+ name: '@astro/plugin-build-pages',
+ options(options) {
+ if (opts.settings.buildOutput === 'static') {
+ const inputs = new Set<string>();
+
+ for (const pageData of Object.values(opts.allPages)) {
+ if (routeIsRedirect(pageData.route)) {
+ continue;
+ }
+ inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
+ }
+
+ return addRollupInput(options, Array.from(inputs));
+ }
+ },
+ resolveId(id) {
+ if (id.startsWith(ASTRO_PAGE_MODULE_ID)) {
+ return '\0' + id;
+ }
+ },
+ async load(id) {
+ if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
+ const imports: string[] = [];
+ const exports: string[] = [];
+ const pageDatas = getPagesFromVirtualModulePageName(
+ internals,
+ ASTRO_PAGE_RESOLVED_MODULE_ID,
+ id,
+ );
+ for (const pageData of pageDatas) {
+ const resolvedPage = await this.resolve(pageData.moduleSpecifier);
+ if (resolvedPage) {
+ imports.push(`import * as _page from ${JSON.stringify(pageData.moduleSpecifier)};`);
+ exports.push(`export const page = () => _page`);
+
+ imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
+ exports.push(`export { renderers };`);
+
+ return `${imports.join('\n')}${exports.join('\n')}`;
+ }
+ }
+ }
+ },
+ };
+}
+
+export function pluginPages(opts: StaticBuildOptions, internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginPages(opts, internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-prerender.ts b/packages/astro/src/core/build/plugins/plugin-prerender.ts
new file mode 100644
index 000000000..f915c9270
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-prerender.ts
@@ -0,0 +1,107 @@
+import type { Rollup, Plugin as VitePlugin } from 'vite';
+import { getPrerenderMetadata } from '../../../prerender/metadata.js';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugin-pages.js';
+import { getPagesFromVirtualModulePageName } from './util.js';
+
+function vitePluginPrerender(internals: BuildInternals): VitePlugin {
+ return {
+ name: 'astro:rollup-plugin-prerender',
+
+ generateBundle(_, bundle) {
+ const moduleIds = this.getModuleIds();
+ for (const id of moduleIds) {
+ const pageInfo = internals.pagesByViteID.get(id);
+ if (!pageInfo) continue;
+ const moduleInfo = this.getModuleInfo(id);
+ if (!moduleInfo) continue;
+
+ const prerender = !!getPrerenderMetadata(moduleInfo);
+ pageInfo.route.prerender = prerender;
+ }
+
+ // Find all chunks used in the SSR runtime (that aren't used for prerendering only), then use
+ // the Set to find the inverse, where chunks that are only used for prerendering. It's faster
+ // to compute `internals.prerenderOnlyChunks` this way. The prerendered chunks will be deleted
+ // after we finish prerendering.
+ const nonPrerenderOnlyChunks = getNonPrerenderOnlyChunks(bundle, internals);
+ internals.prerenderOnlyChunks = Object.values(bundle).filter((chunk) => {
+ return chunk.type === 'chunk' && !nonPrerenderOnlyChunks.has(chunk);
+ }) as Rollup.OutputChunk[];
+ },
+ };
+}
+
+function getNonPrerenderOnlyChunks(bundle: Rollup.OutputBundle, internals: BuildInternals) {
+ const chunks = Object.values(bundle);
+
+ const prerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
+ const nonPrerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
+ for (const chunk of chunks) {
+ if (chunk.type === 'chunk' && chunk.isEntry) {
+ // See if this entry chunk is prerendered, if so, skip it
+ if (chunk.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
+ const pageDatas = getPagesFromVirtualModulePageName(
+ internals,
+ ASTRO_PAGE_RESOLVED_MODULE_ID,
+ chunk.facadeModuleId,
+ );
+ const prerender = pageDatas.every((pageData) => pageData.route.prerender);
+ if (prerender) {
+ prerenderOnlyEntryChunks.add(chunk);
+ continue;
+ }
+ }
+
+ nonPrerenderOnlyEntryChunks.add(chunk);
+ }
+ }
+
+ // From the `nonPrerenderedEntryChunks`, we crawl all the imports/dynamicImports to find all
+ // other chunks that are use by the non-prerendered runtime
+ const nonPrerenderOnlyChunks = new Set(nonPrerenderOnlyEntryChunks);
+ for (const chunk of nonPrerenderOnlyChunks) {
+ for (const importFileName of chunk.imports) {
+ const importChunk = bundle[importFileName];
+ if (importChunk?.type === 'chunk') {
+ nonPrerenderOnlyChunks.add(importChunk);
+ }
+ }
+ for (const dynamicImportFileName of chunk.dynamicImports) {
+ const dynamicImportChunk = bundle[dynamicImportFileName];
+ // The main server entry (entry.mjs) may import a prerender-only entry chunk, we skip in this case
+ // to prevent incorrectly marking it as non-prerendered.
+ if (
+ dynamicImportChunk?.type === 'chunk' &&
+ !prerenderOnlyEntryChunks.has(dynamicImportChunk)
+ ) {
+ nonPrerenderOnlyChunks.add(dynamicImportChunk);
+ }
+ }
+ }
+
+ return nonPrerenderOnlyChunks;
+}
+
+export function pluginPrerender(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ // Static output can skip prerender completely because we're already rendering all pages
+ if (opts.settings.buildOutput === 'static') {
+ return { targets: ['server'] };
+ }
+
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginPrerender(internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-renderers.ts b/packages/astro/src/core/build/plugins/plugin-renderers.ts
new file mode 100644
index 000000000..99fb4e4ce
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-renderers.ts
@@ -0,0 +1,60 @@
+import type { Plugin as VitePlugin } from 'vite';
+import { addRollupInput } from '../add-rollup-input.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+
+export const RENDERERS_MODULE_ID = '@astro-renderers';
+export const RESOLVED_RENDERERS_MODULE_ID = `\0${RENDERERS_MODULE_ID}`;
+
+export function vitePluginRenderers(opts: StaticBuildOptions): VitePlugin {
+ return {
+ name: '@astro/plugin-renderers',
+
+ options(options) {
+ return addRollupInput(options, [RENDERERS_MODULE_ID]);
+ },
+
+ resolveId(id) {
+ if (id === RENDERERS_MODULE_ID) {
+ return RESOLVED_RENDERERS_MODULE_ID;
+ }
+ },
+
+ async load(id) {
+ if (id === RESOLVED_RENDERERS_MODULE_ID) {
+ if (opts.settings.renderers.length > 0) {
+ const imports: string[] = [];
+ const exports: string[] = [];
+ let i = 0;
+ let rendererItems = '';
+
+ for (const renderer of opts.settings.renderers) {
+ const variable = `_renderer${i}`;
+ imports.push(`import ${variable} from ${JSON.stringify(renderer.serverEntrypoint)};`);
+ rendererItems += `Object.assign(${JSON.stringify(renderer)}, { ssr: ${variable} }),`;
+ i++;
+ }
+
+ exports.push(`export const renderers = [${rendererItems}];`);
+
+ return `${imports.join('\n')}\n${exports.join('\n')}`;
+ } else {
+ return `export const renderers = [];`;
+ }
+ }
+ },
+ };
+}
+
+export function pluginRenderers(opts: StaticBuildOptions): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginRenderers(opts),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-scripts.ts b/packages/astro/src/core/build/plugins/plugin-scripts.ts
new file mode 100644
index 000000000..e41d1fe44
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-scripts.ts
@@ -0,0 +1,62 @@
+import type { BuildOptions, Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import { shouldInlineAsset } from './util.js';
+
+/**
+ * Inline scripts from Astro files directly into the HTML.
+ */
+export function vitePluginScripts(internals: BuildInternals): VitePlugin {
+ let assetInlineLimit: NonNullable<BuildOptions['assetsInlineLimit']>;
+
+ return {
+ name: '@astro/plugin-scripts',
+
+ configResolved(config) {
+ assetInlineLimit = config.build.assetsInlineLimit;
+ },
+
+ async generateBundle(_options, bundle) {
+ const outputs = Object.values(bundle);
+
+ // Track ids that are imported by chunks so we don't inline scripts that are imported
+ const importedIds = new Set<string>();
+ for (const output of outputs) {
+ if (output.type === 'chunk') {
+ for (const id of output.imports) {
+ importedIds.add(id);
+ }
+ }
+ }
+
+ for (const output of outputs) {
+ // Try to inline scripts that don't import anything as is within the inline limit
+ if (
+ output.type === 'chunk' &&
+ output.facadeModuleId &&
+ internals.discoveredScripts.has(output.facadeModuleId) &&
+ !importedIds.has(output.fileName) &&
+ output.imports.length === 0 &&
+ output.dynamicImports.length === 0 &&
+ shouldInlineAsset(output.code, output.fileName, assetInlineLimit)
+ ) {
+ internals.inlinedScripts.set(output.facadeModuleId, output.code.trim());
+ delete bundle[output.fileName];
+ }
+ }
+ },
+ };
+}
+
+export function pluginScripts(internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['client'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginScripts(internals),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
new file mode 100644
index 000000000..eea2be3e8
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -0,0 +1,208 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { AstroAdapter } from '../../../types/public/integrations.js';
+import { routeIsRedirect } from '../../redirects/index.js';
+import { VIRTUAL_ISLAND_MAP_ID } from '../../server-islands/vite-plugin-server-islands.js';
+import { addRollupInput } from '../add-rollup-input.js';
+import type { BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js';
+import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
+import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js';
+import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
+import { getVirtualModulePageName } from './util.js';
+
+export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
+export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
+
+const ADAPTER_VIRTUAL_MODULE_ID = '@astrojs-ssr-adapter';
+const RESOLVED_ADAPTER_VIRTUAL_MODULE_ID = '\0' + ADAPTER_VIRTUAL_MODULE_ID;
+
+function vitePluginAdapter(adapter: AstroAdapter): VitePlugin {
+ return {
+ name: '@astrojs/vite-plugin-astro-adapter',
+ enforce: 'post',
+ resolveId(id) {
+ if (id === ADAPTER_VIRTUAL_MODULE_ID) {
+ return RESOLVED_ADAPTER_VIRTUAL_MODULE_ID;
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_ADAPTER_VIRTUAL_MODULE_ID) {
+ return `export * from ${JSON.stringify(adapter.serverEntrypoint)};`;
+ }
+ },
+ };
+}
+
+function vitePluginSSR(
+ internals: BuildInternals,
+ adapter: AstroAdapter,
+ options: StaticBuildOptions,
+): VitePlugin {
+ return {
+ name: '@astrojs/vite-plugin-astro-ssr-server',
+ enforce: 'post',
+ options(opts) {
+ const inputs = new Set<string>();
+
+ for (const pageData of Object.values(options.allPages)) {
+ if (routeIsRedirect(pageData.route)) {
+ continue;
+ }
+ inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
+ }
+
+ const adapterServerEntrypoint = options.settings.adapter?.serverEntrypoint;
+ if (adapterServerEntrypoint) {
+ inputs.add(ADAPTER_VIRTUAL_MODULE_ID);
+ }
+
+ inputs.add(SSR_VIRTUAL_MODULE_ID);
+ return addRollupInput(opts, Array.from(inputs));
+ },
+ resolveId(id) {
+ if (id === SSR_VIRTUAL_MODULE_ID) {
+ return RESOLVED_SSR_VIRTUAL_MODULE_ID;
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
+ const { allPages } = options;
+ const imports: string[] = [];
+ const contents: string[] = [];
+ const exports: string[] = [];
+ let i = 0;
+ const pageMap: string[] = [];
+
+ for (const pageData of Object.values(allPages)) {
+ if (routeIsRedirect(pageData.route)) {
+ continue;
+ }
+ const virtualModuleName = getVirtualModulePageName(
+ ASTRO_PAGE_MODULE_ID,
+ pageData.component,
+ );
+ let module = await this.resolve(virtualModuleName);
+ if (module) {
+ const variable = `_page${i}`;
+ // we need to use the non-resolved ID in order to resolve correctly the virtual module
+ imports.push(`const ${variable} = () => import("${virtualModuleName}");`);
+
+ const pageData2 = internals.pagesByKeys.get(pageData.key);
+ if (pageData2) {
+ pageMap.push(`[${JSON.stringify(pageData2.component)}, ${variable}]`);
+ }
+ i++;
+ }
+ }
+ contents.push(`const pageMap = new Map([\n ${pageMap.join(',\n ')}\n]);`);
+ exports.push(`export { pageMap }`);
+ const middleware = await this.resolve(MIDDLEWARE_MODULE_ID);
+ const ssrCode = generateSSRCode(adapter, middleware!.id);
+ imports.push(...ssrCode.imports);
+ contents.push(...ssrCode.contents);
+ return [...imports, ...contents, ...exports].join('\n');
+ }
+ },
+ async generateBundle(_opts, bundle) {
+ // Add assets from this SSR chunk as well.
+ for (const [, chunk] of Object.entries(bundle)) {
+ if (chunk.type === 'asset') {
+ internals.staticFiles.add(chunk.fileName);
+ }
+ }
+
+ for (const [, chunk] of Object.entries(bundle)) {
+ if (chunk.type === 'asset') {
+ continue;
+ }
+ if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) {
+ internals.ssrEntryChunk = chunk;
+ }
+ }
+ },
+ };
+}
+
+export function pluginSSR(
+ options: StaticBuildOptions,
+ internals: BuildInternals,
+): AstroBuildPlugin {
+ const ssr = options.settings.buildOutput === 'server';
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ // We check before this point if there's an adapter, so we can safely assume it exists here.
+ const adapter = options.settings.adapter!;
+ const ssrPlugin = ssr && vitePluginSSR(internals, adapter, options);
+ const vitePlugin = [vitePluginAdapter(adapter)];
+ if (ssrPlugin) {
+ vitePlugin.unshift(ssrPlugin);
+ }
+
+ return {
+ enforce: 'after-user-plugins',
+ vitePlugin: vitePlugin,
+ };
+ },
+ 'build:post': async () => {
+ if (!ssr) {
+ return;
+ }
+
+ if (!internals.ssrEntryChunk) {
+ throw new Error(`Did not generate an entry chunk for SSR`);
+ }
+ // Mutate the filename
+ internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry;
+ },
+ },
+ };
+}
+
+function generateSSRCode(adapter: AstroAdapter, middlewareId: string) {
+ const edgeMiddleware = adapter?.adapterFeatures?.edgeMiddleware ?? false;
+
+ const imports = [
+ `import { renderers } from '${RENDERERS_MODULE_ID}';`,
+ `import * as serverEntrypointModule from '${ADAPTER_VIRTUAL_MODULE_ID}';`,
+ `import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
+ `import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`,
+ ];
+
+ const contents = [
+ edgeMiddleware ? `const middleware = (_, next) => next()` : '',
+ `const _manifest = Object.assign(defaultManifest, {`,
+ ` pageMap,`,
+ ` serverIslandMap,`,
+ ` renderers,`,
+ ` middleware: ${edgeMiddleware ? 'undefined' : `() => import("${middlewareId}")`}`,
+ `});`,
+ `const _args = ${adapter.args ? JSON.stringify(adapter.args, null, 4) : 'undefined'};`,
+ adapter.exports
+ ? `const _exports = serverEntrypointModule.createExports(_manifest, _args);`
+ : '',
+ ...(adapter.exports?.map((name) => {
+ if (name === 'default') {
+ return `export default _exports.default;`;
+ } else {
+ return `export const ${name} = _exports['${name}'];`;
+ }
+ }) ?? []),
+ // NOTE: This is intentionally obfuscated!
+ // Do NOT simplify this to something like `serverEntrypointModule.start?.(_manifest, _args)`
+ // They are NOT equivalent! Some bundlers will throw if `start` is not exported, but we
+ // only want to silently ignore it... hence the dynamic, obfuscated weirdness.
+ `const _start = 'start';
+if (_start in serverEntrypointModule) {
+ serverEntrypointModule[_start](_manifest, _args);
+}`,
+ ];
+
+ return {
+ imports,
+ contents,
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/util.ts b/packages/astro/src/core/build/plugins/util.ts
new file mode 100644
index 000000000..c636928d0
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/util.ts
@@ -0,0 +1,127 @@
+import { extname } from 'node:path';
+import type { BuildOptions, Rollup, Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from '../internal.js';
+import type { PageBuildData } from '../types.js';
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+type OutputOptionsHook = Extract<VitePlugin['outputOptions'], Function>;
+type OutputOptions = Parameters<OutputOptionsHook>[0];
+
+type ExtendManualChunksHooks = {
+ before?: Rollup.GetManualChunk;
+ after?: Rollup.GetManualChunk;
+};
+
+export function extendManualChunks(outputOptions: OutputOptions, hooks: ExtendManualChunksHooks) {
+ const manualChunks = outputOptions.manualChunks;
+ outputOptions.manualChunks = function (id, meta) {
+ if (hooks.before) {
+ let value = hooks.before(id, meta);
+ if (value) {
+ return value;
+ }
+ }
+
+ // Defer to user-provided `manualChunks`, if it was provided.
+ if (typeof manualChunks == 'object') {
+ if (id in manualChunks) {
+ let value = manualChunks[id];
+ return value[0];
+ }
+ } else if (typeof manualChunks === 'function') {
+ const outid = manualChunks.call(this, id, meta);
+ if (outid) {
+ return outid;
+ }
+ }
+
+ if (hooks.after) {
+ return hooks.after(id, meta) || null;
+ }
+ return null;
+ };
+}
+
+// This is an arbitrary string that we use to replace the dot of the extension.
+export const ASTRO_PAGE_EXTENSION_POST_PATTERN = '@_@';
+// This is an arbitrary string that we use to make a pageData key
+// Has to be a invalid character for a route, to avoid conflicts.
+export const ASTRO_PAGE_KEY_SEPARATOR = '&';
+
+/**
+ * Generate a unique key to identify each page in the build process.
+ * @param route Usually pageData.route.route
+ * @param componentPath Usually pageData.component
+ */
+export function makePageDataKey(route: string, componentPath: string): string {
+ return route + ASTRO_PAGE_KEY_SEPARATOR + componentPath;
+}
+
+/**
+ * Prevents Rollup from triggering other plugins in the process by masking the extension (hence the virtual file).
+ * Inverse function of getComponentFromVirtualModulePageName() below.
+ * @param virtualModulePrefix The prefix used to create the virtual module
+ * @param path Page component path
+ */
+export function getVirtualModulePageName(virtualModulePrefix: string, path: string): string {
+ const extension = extname(path);
+ return (
+ virtualModulePrefix +
+ (extension.startsWith('.')
+ ? path.slice(0, -extension.length) + extension.replace('.', ASTRO_PAGE_EXTENSION_POST_PATTERN)
+ : path)
+ );
+}
+
+/**
+ * From the VirtualModulePageName, and the internals, get all pageDatas that use this
+ * component as their entry point.
+ * @param virtualModulePrefix The prefix used to create the virtual module
+ * @param id Virtual module name
+ */
+export function getPagesFromVirtualModulePageName(
+ internals: BuildInternals,
+ virtualModulePrefix: string,
+ id: string,
+): PageBuildData[] {
+ const path = getComponentFromVirtualModulePageName(virtualModulePrefix, id);
+
+ const pages: PageBuildData[] = [];
+ internals.pagesByKeys.forEach((pageData) => {
+ if (pageData.component === path) {
+ pages.push(pageData);
+ }
+ });
+
+ return pages;
+}
+
+/**
+ * From the VirtualModulePageName, get the component path.
+ * Remember that the component can be use by multiple routes.
+ * Inverse function of getVirtualModulePageName() above.
+ * @param virtualModulePrefix The prefix at the beginning of the virtual module
+ * @param id Virtual module name
+ */
+export function getComponentFromVirtualModulePageName(
+ virtualModulePrefix: string,
+ id: string,
+): string {
+ return id.slice(virtualModulePrefix.length).replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.');
+}
+
+export function shouldInlineAsset(
+ assetContent: string,
+ assetPath: string,
+ assetsInlineLimit: NonNullable<BuildOptions['assetsInlineLimit']>,
+) {
+ if (typeof assetsInlineLimit === 'function') {
+ const result = assetsInlineLimit(assetPath, Buffer.from(assetContent));
+ if (result != null) {
+ return result;
+ } else {
+ return Buffer.byteLength(assetContent) < 4096; // Fallback to 4096kb by default (same as Vite)
+ }
+ }
+ return Buffer.byteLength(assetContent) < Number(assetsInlineLimit);
+}
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
new file mode 100644
index 000000000..71046956c
--- /dev/null
+++ b/packages/astro/src/core/build/static-build.ts
@@ -0,0 +1,474 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { teardown } from '@astrojs/compiler';
+import glob from 'fast-glob';
+import { bgGreen, black, green } from 'kleur/colors';
+import * as vite from 'vite';
+import { type BuildInternals, createBuildInternals } from '../../core/build/internal.js';
+import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js';
+import { appendForwardSlash, prependForwardSlash } from '../../core/path.js';
+import { runHookBuildSetup } from '../../integrations/hooks.js';
+import { getOutputDirectory } from '../../prerender/utils.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
+import { routeIsRedirect } from '../redirects/index.js';
+import { getOutDirWithinCwd } from './common.js';
+import { CHUNKS_PATH } from './consts.js';
+import { generatePages } from './generate.js';
+import { trackPageData } from './internal.js';
+import { type AstroBuildPluginContainer, createPluginContainer } from './plugin.js';
+import { registerAllPlugins } from './plugins/index.js';
+import { RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugins/plugin-manifest.js';
+import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
+import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
+import { RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
+import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
+import type { StaticBuildOptions } from './types.js';
+import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
+
+export async function viteBuild(opts: StaticBuildOptions) {
+ const { allPages, settings } = opts;
+
+ settings.timer.start('SSR build');
+
+ // The pages to be built for rendering purposes.
+ // (comment above may be outdated ?)
+ const pageInput = new Set<string>();
+
+ // Build internals needed by the CSS plugin
+ const internals = createBuildInternals();
+
+ for (const pageData of Object.values(allPages)) {
+ const astroModuleURL = new URL('./' + pageData.component, settings.config.root);
+ const astroModuleId = prependForwardSlash(pageData.component);
+
+ // Track the page data in internals
+ trackPageData(internals, pageData.component, pageData, astroModuleId, astroModuleURL);
+
+ if (!routeIsRedirect(pageData.route)) {
+ pageInput.add(astroModuleId);
+ }
+ }
+
+ // Empty out the dist folder, if needed. Vite has a config for doing this
+ // but because we are running 2 vite builds in parallel, that would cause a race
+ // condition, so we are doing it ourselves
+ if (settings.config?.vite?.build?.emptyOutDir !== false) {
+ emptyDir(settings.config.outDir, new Set('.git'));
+ }
+
+ // Register plugins
+ const container = createPluginContainer(opts, internals);
+ registerAllPlugins(container);
+ // Build your project (SSR application code, assets, client JS, etc.)
+ const ssrTime = performance.now();
+ opts.logger.info('build', `Building ${settings.buildOutput} entrypoints...`);
+ const ssrOutput = await ssrBuild(opts, internals, pageInput, container);
+ opts.logger.info('build', green(`✓ Completed in ${getTimeStat(ssrTime, performance.now())}.`));
+
+ settings.timer.end('SSR build');
+
+ settings.timer.start('Client build');
+
+ const rendererClientEntrypoints = settings.renderers
+ .map((r) => r.clientEntrypoint)
+ .filter((a) => typeof a === 'string') as string[];
+
+ const clientInput = new Set([
+ ...internals.discoveredHydratedComponents.keys(),
+ ...internals.discoveredClientOnlyComponents.keys(),
+ ...rendererClientEntrypoints,
+ ...internals.discoveredScripts,
+ ]);
+
+ if (settings.scripts.some((script) => script.stage === 'page')) {
+ clientInput.add(PAGE_SCRIPT_ID);
+ }
+
+ // Run client build first, so the assets can be fed into the SSR rendered version.
+ const clientOutput = await clientBuild(opts, internals, clientInput, container);
+
+ const ssrOutputs = viteBuildReturnToRollupOutputs(ssrOutput);
+ const clientOutputs = viteBuildReturnToRollupOutputs(clientOutput ?? []);
+ await runPostBuildHooks(container, ssrOutputs, clientOutputs);
+ settings.timer.end('Client build');
+
+ // Free up memory
+ internals.ssrEntryChunk = undefined;
+ if (opts.teardownCompiler) {
+ teardown();
+ }
+
+ // For static builds, the SSR output won't be needed anymore after page generation.
+ // We keep track of the names here so we only remove these specific files when finished.
+ const ssrOutputChunkNames: string[] = [];
+ for (const output of ssrOutputs) {
+ for (const chunk of output.output) {
+ if (chunk.type === 'chunk') {
+ ssrOutputChunkNames.push(chunk.fileName);
+ }
+ }
+ }
+
+ return { internals, ssrOutputChunkNames };
+}
+
+export async function staticBuild(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+ ssrOutputChunkNames: string[],
+) {
+ const { settings } = opts;
+ if (settings.buildOutput === 'static') {
+ settings.timer.start('Static generate');
+ await generatePages(opts, internals);
+ await cleanServerOutput(opts, ssrOutputChunkNames, internals);
+ settings.timer.end('Static generate');
+ } else if (settings.buildOutput === 'server') {
+ settings.timer.start('Server generate');
+ await generatePages(opts, internals);
+ await cleanStaticOutput(opts, internals);
+ await ssrMoveAssets(opts);
+ settings.timer.end('Server generate');
+ }
+}
+
+async function ssrBuild(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+ input: Set<string>,
+ container: AstroBuildPluginContainer,
+) {
+ const { allPages, settings, viteConfig } = opts;
+ const ssr = settings.buildOutput === 'server';
+ const out = getOutputDirectory(settings);
+ const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
+ const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);
+ const viteBuildConfig: vite.InlineConfig = {
+ ...viteConfig,
+ logLevel: viteConfig.logLevel ?? 'error',
+ build: {
+ target: 'esnext',
+ // Vite defaults cssMinify to false in SSR by default, but we want to minify it
+ // as the CSS generated are used and served to the client.
+ cssMinify: viteConfig.build?.minify == null ? true : !!viteConfig.build?.minify,
+ ...viteConfig.build,
+ emptyOutDir: false,
+ manifest: false,
+ outDir: fileURLToPath(out),
+ copyPublicDir: !ssr,
+ rollupOptions: {
+ ...viteConfig.build?.rollupOptions,
+ // Setting as `exports-only` allows us to safely delete inputs that are only used during prerendering
+ preserveEntrySignatures: 'exports-only',
+ input: [],
+ output: {
+ hoistTransitiveImports: false,
+ format: 'esm',
+ minifyInternalExports: true,
+ // Server chunks can't go in the assets (_astro) folder
+ // We need to keep these separate
+ chunkFileNames(chunkInfo) {
+ const { name } = chunkInfo;
+ let prefix = CHUNKS_PATH;
+ let suffix = '_[hash].mjs';
+
+ // Sometimes chunks have the `@_@astro` suffix due to SSR logic. Remove it!
+ // TODO: refactor our build logic to avoid this
+ if (name.includes(ASTRO_PAGE_EXTENSION_POST_PATTERN)) {
+ const [sanitizedName] = name.split(ASTRO_PAGE_EXTENSION_POST_PATTERN);
+ return [prefix, sanitizedName, suffix].join('');
+ }
+ // Injected routes include "pages/[name].[ext]" already. Clean those up!
+ if (name.startsWith('pages/')) {
+ const sanitizedName = name.split('.')[0];
+ return [prefix, sanitizedName, suffix].join('');
+ }
+ const encoded = encodeName(name);
+ return [prefix, encoded, suffix].join('');
+ },
+ assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
+ ...viteConfig.build?.rollupOptions?.output,
+ entryFileNames(chunkInfo) {
+ if (chunkInfo.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
+ return makeAstroPageEntryPointFileName(
+ ASTRO_PAGE_RESOLVED_MODULE_ID,
+ chunkInfo.facadeModuleId,
+ routes,
+ );
+ } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
+ return opts.settings.config.build.serverEntry;
+ } else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) {
+ return 'renderers.mjs';
+ } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
+ return 'manifest_[hash].mjs';
+ } else if (chunkInfo.facadeModuleId === settings.adapter?.serverEntrypoint) {
+ return 'adapter_[hash].mjs';
+ } else {
+ return '[name].mjs';
+ }
+ },
+ },
+ },
+ ssr: true,
+ ssrEmitAssets: true,
+ // improve build performance
+ minify: false,
+ modulePreload: { polyfill: false },
+ reportCompressedSize: false,
+ },
+ plugins: [...vitePlugins, ...(viteConfig.plugins || []), ...lastVitePlugins],
+ envPrefix: viteConfig.envPrefix ?? 'PUBLIC_',
+ base: settings.config.base,
+ };
+
+ const updatedViteBuildConfig = await runHookBuildSetup({
+ config: settings.config,
+ pages: internals.pagesByKeys,
+ vite: viteBuildConfig,
+ target: 'server',
+ logger: opts.logger,
+ });
+
+ return await vite.build(updatedViteBuildConfig);
+}
+
+async function clientBuild(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+ input: Set<string>,
+ container: AstroBuildPluginContainer,
+) {
+ const { settings, viteConfig } = opts;
+ const ssr = settings.buildOutput === 'server';
+ const out = ssr ? settings.config.build.client : getOutDirWithinCwd(settings.config.outDir);
+
+ // Nothing to do if there is no client-side JS.
+ if (!input.size) {
+ // If SSR, copy public over
+ if (ssr) {
+ await copyFiles(settings.config.publicDir, out, true);
+ }
+
+ return null;
+ }
+
+ const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('client', input);
+ opts.logger.info('SKIP_FORMAT', `\n${bgGreen(black(' building client (vite) '))}`);
+
+ const viteBuildConfig: vite.InlineConfig = {
+ ...viteConfig,
+ build: {
+ target: 'esnext',
+ ...viteConfig.build,
+ emptyOutDir: false,
+ outDir: fileURLToPath(out),
+ copyPublicDir: ssr,
+ rollupOptions: {
+ ...viteConfig.build?.rollupOptions,
+ input: Array.from(input),
+ output: {
+ format: 'esm',
+ entryFileNames: `${settings.config.build.assets}/[name].[hash].js`,
+ chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`,
+ assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
+ ...viteConfig.build?.rollupOptions?.output,
+ },
+ preserveEntrySignatures: 'exports-only',
+ },
+ },
+ plugins: [...vitePlugins, ...(viteConfig.plugins || []), ...lastVitePlugins],
+ envPrefix: viteConfig.envPrefix ?? 'PUBLIC_',
+ base: settings.config.base,
+ };
+
+ const updatedViteBuildConfig = await runHookBuildSetup({
+ config: settings.config,
+ pages: internals.pagesByKeys,
+ vite: viteBuildConfig,
+ target: 'client',
+ logger: opts.logger,
+ });
+
+ const buildResult = await vite.build(updatedViteBuildConfig);
+ return buildResult;
+}
+
+async function runPostBuildHooks(
+ container: AstroBuildPluginContainer,
+ ssrOutputs: vite.Rollup.RollupOutput[],
+ clientOutputs: vite.Rollup.RollupOutput[],
+) {
+ const mutations = await container.runPostHook(ssrOutputs, clientOutputs);
+ const config = container.options.settings.config;
+ const build = container.options.settings.config.build;
+ for (const [fileName, mutation] of mutations) {
+ const root =
+ container.options.settings.buildOutput === 'server'
+ ? mutation.targets.includes('server')
+ ? build.server
+ : build.client
+ : getOutDirWithinCwd(config.outDir);
+ const fullPath = path.join(fileURLToPath(root), fileName);
+ const fileURL = pathToFileURL(fullPath);
+ await fs.promises.mkdir(new URL('./', fileURL), { recursive: true });
+ await fs.promises.writeFile(fileURL, mutation.code, 'utf-8');
+ }
+}
+
+/**
+ * Remove chunks that are used for prerendering only
+ */
+async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) {
+ const ssr = opts.settings.buildOutput === 'server';
+ const out = ssr
+ ? opts.settings.config.build.server
+ : getOutDirWithinCwd(opts.settings.config.outDir);
+ await Promise.all(
+ internals.prerenderOnlyChunks.map(async (chunk) => {
+ const url = new URL(chunk.fileName, out);
+ try {
+ // Entry chunks may be referenced by non-deleted code, so we don't actually delete it
+ // but only empty its content. These chunks should never be executed in practice, but
+ // it should prevent broken import paths if adapters do a secondary bundle.
+ if (chunk.isEntry || chunk.isDynamicEntry) {
+ await fs.promises.writeFile(
+ url,
+ "// Contents removed by Astro as it's used for prerendering only",
+ 'utf-8',
+ );
+ } else {
+ await fs.promises.unlink(url);
+ }
+ } catch {
+ // Best-effort only. Sometimes some chunks may be deleted by other plugins, like pure CSS chunks,
+ // so they may already not exist.
+ }
+ }),
+ );
+}
+
+async function cleanServerOutput(
+ opts: StaticBuildOptions,
+ ssrOutputChunkNames: string[],
+ internals: BuildInternals,
+) {
+ const out = getOutDirWithinCwd(opts.settings.config.outDir);
+ // The SSR output chunks for Astro are all .mjs files
+ const files = ssrOutputChunkNames.filter((f) => f.endsWith('.mjs'));
+ if (internals.manifestFileName) {
+ files.push(internals.manifestFileName);
+ }
+ if (files.length) {
+ // Remove all the SSR generated .mjs files
+ await Promise.all(
+ files.map(async (filename) => {
+ const url = new URL(filename, out);
+ const map = new URL(url + '.map');
+ // Sourcemaps may not be generated, so ignore any errors if fail to remove it
+ await Promise.all([fs.promises.rm(url), fs.promises.rm(map).catch(() => {})]);
+ }),
+ );
+
+ removeEmptyDirs(fileURLToPath(out));
+ }
+
+ // Clean out directly if the outDir is outside of root
+ if (out.toString() !== opts.settings.config.outDir.toString()) {
+ // Remove .d.ts files
+ const fileNames = await fs.promises.readdir(out);
+ await Promise.all(
+ fileNames
+ .filter((fileName) => fileName.endsWith('.d.ts'))
+ .map((fileName) => fs.promises.rm(new URL(fileName, out))),
+ );
+ // Copy assets before cleaning directory if outside root
+ await copyFiles(out, opts.settings.config.outDir, true);
+ await fs.promises.rm(out, { recursive: true });
+ return;
+ }
+}
+
+export async function copyFiles(fromFolder: URL, toFolder: URL, includeDotfiles = false) {
+ const files = await glob('**/*', {
+ cwd: fileURLToPath(fromFolder),
+ dot: includeDotfiles,
+ });
+ if (files.length === 0) return;
+ return await Promise.all(
+ files.map(async function copyFile(filename) {
+ const from = new URL(filename, fromFolder);
+ const to = new URL(filename, toFolder);
+ const lastFolder = new URL('./', to);
+ return fs.promises.mkdir(lastFolder, { recursive: true }).then(async function fsCopyFile() {
+ const p = await fs.promises.copyFile(from, to, fs.constants.COPYFILE_FICLONE);
+ return p;
+ });
+ }),
+ );
+}
+
+async function ssrMoveAssets(opts: StaticBuildOptions) {
+ opts.logger.info('build', 'Rearranging server assets...');
+ const serverRoot =
+ opts.settings.buildOutput === 'static'
+ ? opts.settings.config.build.client
+ : opts.settings.config.build.server;
+ const clientRoot = opts.settings.config.build.client;
+ const assets = opts.settings.config.build.assets;
+ const serverAssets = new URL(`./${assets}/`, appendForwardSlash(serverRoot.toString()));
+ const clientAssets = new URL(`./${assets}/`, appendForwardSlash(clientRoot.toString()));
+ const files = await glob(`**/*`, {
+ cwd: fileURLToPath(serverAssets),
+ });
+
+ if (files.length > 0) {
+ await Promise.all(
+ files.map(async function moveAsset(filename) {
+ const currentUrl = new URL(filename, appendForwardSlash(serverAssets.toString()));
+ const clientUrl = new URL(filename, appendForwardSlash(clientAssets.toString()));
+ const dir = new URL(path.parse(clientUrl.href).dir);
+ // It can't find this file because the user defines a custom path
+ // that includes the folder paths in `assetFileNames
+ if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true });
+ return fs.promises.rename(currentUrl, clientUrl);
+ }),
+ );
+ removeEmptyDirs(fileURLToPath(serverRoot));
+ }
+}
+
+/**
+ * This function takes the virtual module name of any page entrypoint and
+ * transforms it to generate a final `.mjs` output file.
+ *
+ * Input: `@astro-page:src/pages/index@_@astro`
+ * Output: `pages/index.astro.mjs`
+ * Input: `@astro-page:../node_modules/my-dep/injected@_@astro`
+ * Output: `pages/injected.mjs`
+ *
+ * 1. We clean the `facadeModuleId` by removing the `ASTRO_PAGE_MODULE_ID` prefix and `ASTRO_PAGE_EXTENSION_POST_PATTERN`.
+ * 2. We find the matching route pattern in the manifest (or fallback to the cleaned module id)
+ * 3. We replace square brackets with underscore (`[slug]` => `_slug_`) and `...` with `` (`[...slug]` => `_---slug_`).
+ * 4. We append the `.mjs` extension, so the file will always be an ESM module
+ *
+ * @param prefix string
+ * @param facadeModuleId string
+ * @param pages AllPagesData
+ */
+export function makeAstroPageEntryPointFileName(
+ prefix: string,
+ facadeModuleId: string,
+ routes: RouteData[],
+) {
+ const pageModuleId = facadeModuleId
+ .replace(prefix, '')
+ .replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.');
+ const route = routes.find((routeData) => routeData.component === pageModuleId);
+ const name = route?.route ?? pageModuleId;
+ return `pages${name
+ .replace(/\/$/, '/index')
+ .replaceAll(/[[\]]/g, '_')
+ .replaceAll('...', '---')}.astro.mjs`;
+}
diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts
new file mode 100644
index 000000000..f647e053a
--- /dev/null
+++ b/packages/astro/src/core/build/types.ts
@@ -0,0 +1,52 @@
+import type * as vite from 'vite';
+import type { InlineConfig } from 'vite';
+import type { AstroSettings, ComponentInstance, RoutesList } from '../../types/astro.js';
+import type { MiddlewareHandler } from '../../types/public/common.js';
+import type { RuntimeMode } from '../../types/public/config.js';
+import type { RouteData, SSRLoadedRenderer } from '../../types/public/internal.js';
+import type { Logger } from '../logger/core.js';
+
+export type ComponentPath = string;
+export type ViteID = string;
+
+export type StylesheetAsset =
+ | { type: 'inline'; content: string }
+ | { type: 'external'; src: string };
+
+/** Public type exposed through the `astro:build:setup` integration hook */
+export interface PageBuildData {
+ key: string;
+ component: ComponentPath;
+ route: RouteData;
+ moduleSpecifier: string;
+ styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>;
+}
+
+export type AllPagesData = Record<ComponentPath, PageBuildData>;
+
+/** Options for the static build */
+export interface StaticBuildOptions {
+ allPages: AllPagesData;
+ settings: AstroSettings;
+ logger: Logger;
+ routesList: RoutesList;
+ runtimeMode: RuntimeMode;
+ origin: string;
+ pageNames: string[];
+ viteConfig: InlineConfig;
+ teardownCompiler: boolean;
+ key: Promise<CryptoKey>;
+}
+
+type ImportComponentInstance = () => Promise<ComponentInstance>;
+
+export interface SinglePageBuiltModule {
+ page: ImportComponentInstance;
+ /**
+ * The `onRequest` hook exported by the middleware
+ */
+ onRequest?: MiddlewareHandler;
+ renderers: SSRLoadedRenderer[];
+}
+
+export type ViteBuildReturn = Awaited<ReturnType<typeof vite.build>>;
diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts
new file mode 100644
index 000000000..b6b313254
--- /dev/null
+++ b/packages/astro/src/core/build/util.ts
@@ -0,0 +1,69 @@
+import type { Rollup } from 'vite';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { ViteBuildReturn } from './types.js';
+
+export function getTimeStat(timeStart: number, timeEnd: number) {
+ const buildTime = timeEnd - timeStart;
+ return buildTime < 1000 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`;
+}
+
+/**
+ * Given the Astro configuration, it tells if a slash should be appended or not
+ */
+export function shouldAppendForwardSlash(
+ trailingSlash: AstroConfig['trailingSlash'],
+ buildFormat: AstroConfig['build']['format'],
+): boolean {
+ switch (trailingSlash) {
+ case 'always':
+ return true;
+ case 'never':
+ return false;
+ case 'ignore': {
+ switch (buildFormat) {
+ case 'directory':
+ return true;
+ case 'preserve':
+ case 'file':
+ return false;
+ }
+ }
+ }
+}
+
+export function i18nHasFallback(config: AstroConfig): boolean {
+ if (config.i18n && config.i18n.fallback) {
+ // we have some fallback and the control is not none
+ return Object.keys(config.i18n.fallback).length > 0;
+ }
+
+ return false;
+}
+
+export function encodeName(name: string): string {
+ // Detect if the chunk name has as % sign that is not encoded.
+ // This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391
+ // We do this because you cannot import a module with this character in it.
+ for (let i = 0; i < name.length; i++) {
+ if (name[i] === '%') {
+ const third = name.codePointAt(i + 2)! | 0x20;
+ if (name[i + 1] !== '2' || third !== 102) {
+ return `${name.replace(/%/g, '_percent_')}`;
+ }
+ }
+ }
+
+ return name;
+}
+
+export function viteBuildReturnToRollupOutputs(
+ viteBuildReturn: ViteBuildReturn,
+): Rollup.RollupOutput[] {
+ const result: Rollup.RollupOutput[] = [];
+ if (Array.isArray(viteBuildReturn)) {
+ result.push(...viteBuildReturn);
+ } else if ('output' in viteBuildReturn) {
+ result.push(viteBuildReturn);
+ }
+ return result;
+}
diff --git a/packages/astro/src/core/client-directive/build.ts b/packages/astro/src/core/client-directive/build.ts
new file mode 100644
index 000000000..eafd07c3c
--- /dev/null
+++ b/packages/astro/src/core/client-directive/build.ts
@@ -0,0 +1,38 @@
+import { fileURLToPath } from 'node:url';
+import { build } from 'esbuild';
+
+/**
+ * Build a client directive entrypoint into code that can directly run in a `<script>` tag.
+ */
+export async function buildClientDirectiveEntrypoint(
+ name: string,
+ entrypoint: string | URL,
+ root: URL,
+) {
+ const stringifiedName = JSON.stringify(name);
+ const stringifiedEntrypoint = JSON.stringify(entrypoint);
+
+ // NOTE: when updating this stdin code, make sure to also update `packages/astro/scripts/prebuild.ts`
+ // that prebuilds the client directive with a similar code too.
+ const output = await build({
+ stdin: {
+ contents: `\
+import directive from ${stringifiedEntrypoint};
+
+(self.Astro || (self.Astro = {}))[${stringifiedName}] = directive;
+
+window.dispatchEvent(new Event('astro:' + ${stringifiedName}));`,
+ resolveDir: fileURLToPath(root),
+ },
+ absWorkingDir: fileURLToPath(root),
+ format: 'iife',
+ minify: true,
+ bundle: true,
+ write: false,
+ });
+
+ const outputFile = output.outputFiles?.[0];
+ if (!outputFile) return '';
+
+ return outputFile.text;
+}
diff --git a/packages/astro/src/core/client-directive/default.ts b/packages/astro/src/core/client-directive/default.ts
new file mode 100644
index 000000000..352763ba6
--- /dev/null
+++ b/packages/astro/src/core/client-directive/default.ts
@@ -0,0 +1,15 @@
+import idlePrebuilt from '../../runtime/client/idle.prebuilt.js';
+import loadPrebuilt from '../../runtime/client/load.prebuilt.js';
+import mediaPrebuilt from '../../runtime/client/media.prebuilt.js';
+import onlyPrebuilt from '../../runtime/client/only.prebuilt.js';
+import visiblePrebuilt from '../../runtime/client/visible.prebuilt.js';
+
+export function getDefaultClientDirectives() {
+ return new Map([
+ ['idle', idlePrebuilt],
+ ['load', loadPrebuilt],
+ ['media', mediaPrebuilt],
+ ['only', onlyPrebuilt],
+ ['visible', visiblePrebuilt],
+ ]);
+}
diff --git a/packages/astro/src/core/client-directive/index.ts b/packages/astro/src/core/client-directive/index.ts
new file mode 100644
index 000000000..7c1a9a71c
--- /dev/null
+++ b/packages/astro/src/core/client-directive/index.ts
@@ -0,0 +1,2 @@
+export { buildClientDirectiveEntrypoint } from './build.js';
+export { getDefaultClientDirectives } from './default.js';
diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts
new file mode 100644
index 000000000..5cfe92e1b
--- /dev/null
+++ b/packages/astro/src/core/compile/compile.ts
@@ -0,0 +1,137 @@
+import { fileURLToPath } from 'node:url';
+import type { TransformResult } from '@astrojs/compiler';
+import { transform } from '@astrojs/compiler';
+import type { ResolvedConfig } from 'vite';
+import type { AstroPreferences } from '../../preferences/index.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { AstroError } from '../errors/errors.js';
+import { AggregateError, CompilerError } from '../errors/errors.js';
+import { AstroErrorData } from '../errors/index.js';
+import { normalizePath, resolvePath } from '../viteUtils.js';
+import { type PartialCompileCssResult, createStylePreprocessor } from './style.js';
+import type { CompileCssResult } from './types.js';
+
+export interface CompileProps {
+ astroConfig: AstroConfig;
+ viteConfig: ResolvedConfig;
+ preferences: AstroPreferences;
+ filename: string;
+ source: string;
+}
+
+export interface CompileResult extends Omit<TransformResult, 'css'> {
+ css: CompileCssResult[];
+}
+
+export async function compile({
+ astroConfig,
+ viteConfig,
+ preferences,
+ filename,
+ source,
+}: CompileProps): Promise<CompileResult> {
+ // Because `@astrojs/compiler` can't return the dependencies for each style transformed,
+ // we need to use an external array to track the dependencies whenever preprocessing is called,
+ // and we'll rebuild the final `css` result after transformation.
+ const cssPartialCompileResults: PartialCompileCssResult[] = [];
+ const cssTransformErrors: AstroError[] = [];
+ let transformResult: TransformResult;
+
+ try {
+ // Transform from `.astro` to valid `.ts`
+ // use `sourcemap: "both"` so that sourcemap is included in the code
+ // result passed to esbuild, but also available in the catch handler.
+ transformResult = await transform(source, {
+ compact: astroConfig.compressHTML,
+ filename,
+ normalizedFilename: normalizeFilename(filename, astroConfig.root),
+ sourcemap: 'both',
+ internalURL: 'astro/compiler-runtime',
+ // TODO: this is no longer necessary for `Astro.site`
+ // but it somehow allows working around caching issues in content collections for some tests
+ astroGlobalArgs: JSON.stringify(astroConfig.site),
+ scopedStyleStrategy: astroConfig.scopedStyleStrategy,
+ resultScopedSlot: true,
+ transitionsAnimationURL: 'astro/components/viewtransitions.css',
+ annotateSourceFile:
+ viteConfig.command === 'serve' &&
+ astroConfig.devToolbar &&
+ astroConfig.devToolbar.enabled &&
+ (await preferences.get('devToolbar.enabled')),
+ renderScript: true,
+ preprocessStyle: createStylePreprocessor({
+ filename,
+ viteConfig,
+ cssPartialCompileResults,
+ cssTransformErrors,
+ }),
+ async resolvePath(specifier) {
+ return resolvePath(specifier, filename);
+ },
+ });
+ } catch (err: any) {
+ // The compiler should be able to handle errors by itself, however
+ // for the rare cases where it can't let's directly throw here with as much info as possible
+ throw new CompilerError({
+ ...AstroErrorData.UnknownCompilerError,
+ message: err.message ?? 'Unknown compiler error',
+ stack: err.stack,
+ location: {
+ file: filename,
+ },
+ });
+ }
+
+ handleCompileResultErrors(transformResult, cssTransformErrors);
+
+ return {
+ ...transformResult,
+ css: transformResult.css.map((code, i) => ({
+ ...cssPartialCompileResults[i],
+ code,
+ })),
+ };
+}
+
+function handleCompileResultErrors(result: TransformResult, cssTransformErrors: AstroError[]) {
+ // TODO: Export the DiagnosticSeverity enum from @astrojs/compiler?
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
+ const compilerError = result.diagnostics.find((diag) => diag.severity === 1);
+
+ if (compilerError) {
+ throw new CompilerError({
+ name: 'CompilerError',
+ message: compilerError.text,
+ location: {
+ line: compilerError.location.line,
+ column: compilerError.location.column,
+ file: compilerError.location.file,
+ },
+ hint: compilerError.hint,
+ });
+ }
+
+ switch (cssTransformErrors.length) {
+ case 0:
+ break;
+ case 1: {
+ throw cssTransformErrors[0];
+ }
+ default: {
+ throw new AggregateError({
+ ...cssTransformErrors[0],
+ errors: cssTransformErrors,
+ });
+ }
+ }
+}
+
+function normalizeFilename(filename: string, root: URL) {
+ const normalizedFilename = normalizePath(filename);
+ const normalizedRoot = normalizePath(fileURLToPath(root));
+ if (normalizedFilename.startsWith(normalizedRoot)) {
+ return normalizedFilename.slice(normalizedRoot.length - 1);
+ } else {
+ return normalizedFilename;
+ }
+}
diff --git a/packages/astro/src/core/compile/index.ts b/packages/astro/src/core/compile/index.ts
new file mode 100644
index 000000000..3af50fa40
--- /dev/null
+++ b/packages/astro/src/core/compile/index.ts
@@ -0,0 +1,2 @@
+export { compile } from './compile.js';
+export type { CompileProps, CompileResult } from './compile.js';
diff --git a/packages/astro/src/core/compile/style.ts b/packages/astro/src/core/compile/style.ts
new file mode 100644
index 000000000..ce1f6638c
--- /dev/null
+++ b/packages/astro/src/core/compile/style.ts
@@ -0,0 +1,109 @@
+import fs from 'node:fs';
+import type { TransformOptions } from '@astrojs/compiler';
+import { type ResolvedConfig, preprocessCSS } from 'vite';
+import { AstroErrorData, CSSError, positionAt } from '../errors/index.js';
+import { normalizePath } from '../viteUtils.js';
+import type { CompileCssResult } from './types.js';
+
+export type PartialCompileCssResult = Pick<CompileCssResult, 'isGlobal' | 'dependencies'>;
+
+export function createStylePreprocessor({
+ filename,
+ viteConfig,
+ cssPartialCompileResults,
+ cssTransformErrors,
+}: {
+ filename: string;
+ viteConfig: ResolvedConfig;
+ cssPartialCompileResults: Partial<CompileCssResult>[];
+ cssTransformErrors: Error[];
+}): TransformOptions['preprocessStyle'] {
+ let processedStylesCount = 0;
+
+ return async (content, attrs) => {
+ const index = processedStylesCount++;
+ const lang = `.${attrs?.lang || 'css'}`.toLowerCase();
+ const id = `${filename}?astro&type=style&index=${index}&lang${lang}`;
+ try {
+ const result = await preprocessCSS(content, id, viteConfig);
+
+ cssPartialCompileResults[index] = {
+ isGlobal: !!attrs['is:global'],
+ dependencies: result.deps ? [...result.deps].map((dep) => normalizePath(dep)) : [],
+ };
+
+ let map: string | undefined;
+ if (result.map) {
+ if (typeof result.map === 'string') {
+ map = result.map;
+ } else if (result.map.mappings) {
+ map = result.map.toString();
+ }
+ }
+
+ return { code: result.code, map };
+ } catch (err: any) {
+ try {
+ err = enhanceCSSError(err, filename, content);
+ } catch {}
+ cssTransformErrors.push(err);
+ return { error: err + '' };
+ }
+ };
+}
+
+function enhanceCSSError(err: any, filename: string, cssContent: string) {
+ const fileContent = fs.readFileSync(filename).toString();
+ const styleTagBeginning = fileContent.indexOf(cssContent);
+
+ // PostCSS Syntax Error
+ if (err.name === 'CssSyntaxError') {
+ const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0);
+
+ // Vite will handle creating the frame for us with proper line numbers, no need to create one
+
+ return new CSSError({
+ ...AstroErrorData.CSSSyntaxError,
+ message: err.reason,
+ location: {
+ file: filename,
+ line: errorLine,
+ column: err.column,
+ },
+ stack: err.stack,
+ });
+ }
+
+ // Some CSS processor will return a line and a column, so let's try to show a pretty error
+ if (err.line && err.column) {
+ const errorLine = positionAt(styleTagBeginning, fileContent).line + (err.line ?? 0);
+
+ return new CSSError({
+ ...AstroErrorData.UnknownCSSError,
+ message: err.message,
+ location: {
+ file: filename,
+ line: errorLine,
+ column: err.column,
+ },
+ frame: err.frame,
+ stack: err.stack,
+ });
+ }
+
+ // For other errors we'll just point to the beginning of the style tag
+ const errorPosition = positionAt(styleTagBeginning, fileContent);
+ errorPosition.line += 1;
+
+ return new CSSError({
+ name: 'CSSError',
+ message: err.message,
+ location: {
+ file: filename,
+ line: errorPosition.line,
+ column: 0,
+ },
+ frame: err.frame,
+ stack: err.stack,
+ });
+}
diff --git a/packages/astro/src/core/compile/types.ts b/packages/astro/src/core/compile/types.ts
new file mode 100644
index 000000000..02f6d5ac9
--- /dev/null
+++ b/packages/astro/src/core/compile/types.ts
@@ -0,0 +1,11 @@
+export interface CompileCssResult {
+ code: string;
+ /**
+ * Whether this is `<style is:global>`
+ */
+ isGlobal: boolean;
+ /**
+ * The dependencies of the transformed CSS (Normalized/forward-slash-only absolute paths)
+ */
+ dependencies: string[];
+}
diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts
new file mode 100644
index 000000000..ae15805ff
--- /dev/null
+++ b/packages/astro/src/core/config/config.ts
@@ -0,0 +1,167 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import * as colors from 'kleur/colors';
+import { ZodError } from 'zod';
+import { eventConfigError, telemetry } from '../../events/index.js';
+import type {
+ AstroConfig,
+ AstroInlineConfig,
+ AstroInlineOnlyConfig,
+ AstroUserConfig,
+} from '../../types/public/config.js';
+import { trackAstroConfigZodError } from '../errors/errors.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { formatConfigErrorMessage } from '../messages.js';
+import { mergeConfig } from './merge.js';
+import { validateConfig } from './validate.js';
+import { loadConfigWithVite } from './vite-load.js';
+
+export function resolveRoot(cwd?: string | URL): string {
+ if (cwd instanceof URL) {
+ cwd = fileURLToPath(cwd);
+ }
+ return cwd ? path.resolve(cwd) : process.cwd();
+}
+
+// Config paths to search for. In order of likely appearance
+// to speed up the check.
+export const configPaths = Object.freeze([
+ 'astro.config.mjs',
+ 'astro.config.js',
+ 'astro.config.ts',
+ 'astro.config.mts',
+ 'astro.config.cjs',
+ 'astro.config.cts',
+]);
+
+async function search(fsMod: typeof fs, root: string) {
+ const paths = configPaths.map((p) => path.join(root, p));
+
+ for (const file of paths) {
+ if (fsMod.existsSync(file)) {
+ return file;
+ }
+ }
+}
+
+interface ResolveConfigPathOptions {
+ root: string;
+ configFile?: string | false;
+ fs: typeof fs;
+}
+
+/**
+ * Resolve the file URL of the user's `astro.config.js|cjs|mjs|ts` file
+ */
+export async function resolveConfigPath(
+ options: ResolveConfigPathOptions,
+): Promise<string | undefined> {
+ let userConfigPath: string | undefined;
+ if (options.configFile) {
+ userConfigPath = path.join(options.root, options.configFile);
+ if (!options.fs.existsSync(userConfigPath)) {
+ throw new AstroError({
+ ...AstroErrorData.ConfigNotFound,
+ message: AstroErrorData.ConfigNotFound.message(options.configFile),
+ });
+ }
+ } else {
+ userConfigPath = await search(options.fs, options.root);
+ }
+
+ return userConfigPath;
+}
+
+async function loadConfig(
+ root: string,
+ configFile?: string | false,
+ fsMod = fs,
+): Promise<Record<string, any>> {
+ if (configFile === false) return {};
+
+ const configPath = await resolveConfigPath({
+ root,
+ configFile,
+ fs: fsMod,
+ });
+ if (!configPath) return {};
+
+ // Create a vite server to load the config
+ try {
+ return await loadConfigWithVite({
+ root,
+ configPath,
+ fs: fsMod,
+ });
+ } catch (e) {
+ const configPathText = configFile ? colors.bold(configFile) : 'your Astro config';
+ // Config errors should bypass log level as it breaks startup
+ console.error(`${colors.bold(colors.red('[astro]'))} Unable to load ${configPathText}\n`);
+ throw e;
+ }
+}
+
+/**
+ * `AstroInlineConfig` is a union of `AstroUserConfig` and `AstroInlineOnlyConfig`.
+ * This functions splits it up.
+ */
+function splitInlineConfig(inlineConfig: AstroInlineConfig): {
+ inlineUserConfig: AstroUserConfig;
+ inlineOnlyConfig: AstroInlineOnlyConfig;
+} {
+ const { configFile, mode, logLevel, ...inlineUserConfig } = inlineConfig;
+ return {
+ inlineUserConfig,
+ inlineOnlyConfig: {
+ configFile,
+ mode,
+ logLevel,
+ },
+ };
+}
+
+interface ResolveConfigResult {
+ userConfig: AstroUserConfig;
+ astroConfig: AstroConfig;
+}
+
+/**
+ * Resolves the Astro config with a given inline config.
+ *
+ * @param inlineConfig An inline config that takes highest priority when merging and resolving the final config.
+ * @param command The running command that uses this config. Usually 'dev' or 'build'.
+ */
+export async function resolveConfig(
+ inlineConfig: AstroInlineConfig,
+ command: string,
+ fsMod = fs,
+): Promise<ResolveConfigResult> {
+ const root = resolveRoot(inlineConfig.root);
+ const { inlineUserConfig, inlineOnlyConfig } = splitInlineConfig(inlineConfig);
+
+ // If the root is specified, assign the resolved path so it takes the highest priority
+ if (inlineConfig.root) {
+ inlineUserConfig.root = root;
+ }
+
+ const userConfig = await loadConfig(root, inlineOnlyConfig.configFile, fsMod);
+ const mergedConfig = mergeConfig(userConfig, inlineUserConfig);
+ // First-Pass Validation
+ let astroConfig: AstroConfig;
+ try {
+ astroConfig = await validateConfig(mergedConfig, root, command);
+ } catch (e) {
+ // Improve config zod error messages
+ if (e instanceof ZodError) {
+ // Mark this error so the callee can decide to suppress Zod's error if needed.
+ // We still want to throw the error to signal an error in validation.
+ trackAstroConfigZodError(e);
+ console.error(formatConfigErrorMessage(e) + '\n');
+ telemetry.record(eventConfigError({ cmd: command, err: e, isFatal: true }));
+ }
+ throw e;
+ }
+
+ return { userConfig: mergedConfig, astroConfig };
+}
diff --git a/packages/astro/src/core/config/index.ts b/packages/astro/src/core/config/index.ts
new file mode 100644
index 000000000..7ffc29014
--- /dev/null
+++ b/packages/astro/src/core/config/index.ts
@@ -0,0 +1,11 @@
+export {
+ configPaths,
+ resolveConfig,
+ resolveConfigPath,
+ resolveRoot,
+} from './config.js';
+export { createNodeLogger } from './logging.js';
+export { mergeConfig } from './merge.js';
+export type { AstroConfigType } from './schema.js';
+export { createSettings } from './settings.js';
+export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js';
diff --git a/packages/astro/src/core/config/logging.ts b/packages/astro/src/core/config/logging.ts
new file mode 100644
index 000000000..bd72f8b5e
--- /dev/null
+++ b/packages/astro/src/core/config/logging.ts
@@ -0,0 +1,12 @@
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import { Logger } from '../logger/core.js';
+import { nodeLogDestination } from '../logger/node.js';
+
+export function createNodeLogger(inlineConfig: AstroInlineConfig): Logger {
+ if (inlineConfig.logger) return inlineConfig.logger;
+
+ return new Logger({
+ dest: nodeLogDestination,
+ level: inlineConfig.logLevel ?? 'info',
+ });
+}
diff --git a/packages/astro/src/core/config/merge.ts b/packages/astro/src/core/config/merge.ts
new file mode 100644
index 000000000..c897c1441
--- /dev/null
+++ b/packages/astro/src/core/config/merge.ts
@@ -0,0 +1,73 @@
+import { mergeConfig as mergeViteConfig } from 'vite';
+import { arraify, isObject, isURL } from '../util.js';
+
+function mergeConfigRecursively(
+ defaults: Record<string, any>,
+ overrides: Record<string, any>,
+ rootPath: string,
+) {
+ const merged: Record<string, any> = { ...defaults };
+ for (const key in overrides) {
+ const value = overrides[key];
+ if (value == null) {
+ continue;
+ }
+
+ let existing = merged[key];
+
+ if (existing == null) {
+ merged[key] = value;
+ continue;
+ }
+
+ // fields that require special handling:
+ if (key === 'vite' && rootPath === '') {
+ merged[key] = mergeViteConfig(existing, value);
+ continue;
+ }
+ if (key === 'server' && rootPath === '') {
+ // server config can be a function or an object, if one of the two values is a function,
+ // create a new wrapper function that merges them
+ if (typeof existing === 'function' || typeof value === 'function') {
+ merged[key] = (...args: any[]) => {
+ const existingConfig = typeof existing === 'function' ? existing(...args) : existing;
+ const valueConfig = typeof value === 'function' ? value(...args) : value;
+ return mergeConfigRecursively(existingConfig, valueConfig, key);
+ };
+ continue;
+ }
+ }
+
+ if (key === 'data' && rootPath === 'db') {
+ // db.data can be a function or an array of functions. When
+ // merging, make sure they become an array
+ if (!Array.isArray(existing) && !Array.isArray(value)) {
+ existing = [existing];
+ }
+ }
+
+ if (Array.isArray(existing) || Array.isArray(value)) {
+ merged[key] = [...arraify(existing ?? []), ...arraify(value ?? [])];
+ continue;
+ }
+ if (isURL(existing) && isURL(value)) {
+ merged[key] = value;
+ continue;
+ }
+ if (isObject(existing) && isObject(value)) {
+ merged[key] = mergeConfigRecursively(existing, value, rootPath ? `${rootPath}.${key}` : key);
+ continue;
+ }
+
+ merged[key] = value;
+ }
+ return merged;
+}
+
+export function mergeConfig(
+ defaults: Record<string, any>,
+ overrides: Record<string, any>,
+ isRoot = true,
+): Record<string, any> {
+ return mergeConfigRecursively(defaults, overrides, isRoot ? '' : '.');
+}
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
new file mode 100644
index 000000000..777e8df26
--- /dev/null
+++ b/packages/astro/src/core/config/schema.ts
@@ -0,0 +1,801 @@
+import type { OutgoingHttpHeaders } from 'node:http';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import type {
+ ShikiConfig,
+ RehypePlugin as _RehypePlugin,
+ RemarkPlugin as _RemarkPlugin,
+ RemarkRehype as _RemarkRehype,
+} from '@astrojs/markdown-remark';
+import { markdownConfigDefaults } from '@astrojs/markdown-remark';
+import { type BuiltinTheme, bundledThemes } from 'shiki';
+import { z } from 'zod';
+import type { SvgRenderMode } from '../../assets/utils/svg.js';
+import { EnvSchema } from '../../env/schema.js';
+import type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.js';
+import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js';
+
+// The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version,
+// Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references
+// to transitive dependencies that Astro don't depend on (e.g. `mdast-util-to-hast` or `remark-rehype`). For example:
+//
+// ```ts
+// // input
+// type Foo = { bar: string };
+// export const value: Foo;
+//
+// // output
+// export const value: { bar: string }; // <-- `Foo` is gone
+// ```
+//
+// The types below will "complexify" the types so that TypeScript would not simplify them. This way it will
+// reference the complex type directly, instead of referencing non-existent transitive dependencies.
+//
+// Also, make sure to not index the complexified type, as it would return a simplified value type, which goes
+// back to the issue again. The complexified type should be the base representation that we want to expose.
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+interface ComplexifyUnionObj {}
+
+type ComplexifyWithUnion<T> = T & ComplexifyUnionObj;
+type ComplexifyWithOmit<T> = Omit<T, '__nonExistent'>;
+
+type ShikiLang = ComplexifyWithUnion<NonNullable<ShikiConfig['langs']>[number]>;
+type ShikiTheme = ComplexifyWithUnion<NonNullable<ShikiConfig['theme']>>;
+type ShikiTransformer = ComplexifyWithUnion<NonNullable<ShikiConfig['transformers']>[number]>;
+type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>;
+type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>;
+type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>;
+
+export const ASTRO_CONFIG_DEFAULTS = {
+ root: '.',
+ srcDir: './src',
+ publicDir: './public',
+ outDir: './dist',
+ cacheDir: './node_modules/.astro',
+ base: '/',
+ trailingSlash: 'ignore',
+ build: {
+ format: 'directory',
+ client: './client/',
+ server: './server/',
+ assets: '_astro',
+ serverEntry: 'entry.mjs',
+ redirects: true,
+ inlineStylesheets: 'auto',
+ concurrency: 1,
+ },
+ image: {
+ endpoint: { entrypoint: undefined, route: '/_image' },
+ service: { entrypoint: 'astro/assets/services/sharp', config: {} },
+ },
+ devToolbar: {
+ enabled: true,
+ },
+ compressHTML: true,
+ server: {
+ host: false,
+ port: 4321,
+ open: false,
+ },
+ integrations: [],
+ markdown: markdownConfigDefaults,
+ vite: {},
+ legacy: {
+ collections: false,
+ },
+ redirects: {},
+ security: {
+ checkOrigin: true,
+ },
+ env: {
+ schema: {},
+ validateSecrets: false,
+ },
+ experimental: {
+ clientPrerender: false,
+ contentIntellisense: false,
+ responsiveImages: false,
+ svg: false,
+ serializeConfig: false,
+ },
+} satisfies AstroUserConfig & { server: { open: boolean } };
+
+export const AstroConfigSchema = z.object({
+ root: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.root)
+ .transform((val) => new URL(val)),
+ srcDir: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.srcDir)
+ .transform((val) => new URL(val)),
+ publicDir: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.publicDir)
+ .transform((val) => new URL(val)),
+ outDir: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.outDir)
+ .transform((val) => new URL(val)),
+ cacheDir: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.cacheDir)
+ .transform((val) => new URL(val)),
+ site: z.string().url().optional(),
+ compressHTML: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.compressHTML),
+ base: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.base),
+ trailingSlash: z
+ .union([z.literal('always'), z.literal('never'), z.literal('ignore')])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.trailingSlash),
+ output: z
+ .union([z.literal('static'), z.literal('server')])
+ .optional()
+ .default('static'),
+ scopedStyleStrategy: z
+ .union([z.literal('where'), z.literal('class'), z.literal('attribute')])
+ .optional()
+ .default('attribute'),
+ adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(),
+ integrations: z.preprocess(
+ // preprocess
+ (val) => (Array.isArray(val) ? val.flat(Infinity).filter(Boolean) : val),
+ // validate
+ z
+ .array(z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }))
+ .default(ASTRO_CONFIG_DEFAULTS.integrations),
+ ),
+ build: z
+ .object({
+ format: z
+ .union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.format),
+ client: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.client)
+ .transform((val) => new URL(val)),
+ server: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.server)
+ .transform((val) => new URL(val)),
+ assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
+ assetsPrefix: z
+ .string()
+ .optional()
+ .or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional())
+ .refine(
+ (value) => {
+ if (value && typeof value !== 'string') {
+ if (!value.fallback) {
+ return false;
+ }
+ }
+ return true;
+ },
+ {
+ message: 'The `fallback` is mandatory when defining the option as an object.',
+ },
+ ),
+ serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
+ redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
+ inlineStylesheets: z
+ .enum(['always', 'auto', 'never'])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
+ concurrency: z.number().min(1).optional().default(ASTRO_CONFIG_DEFAULTS.build.concurrency),
+ })
+ .default({}),
+ server: z.preprocess(
+ // preprocess
+ // NOTE: Uses the "error" command here because this is overwritten by the
+ // individualized schema parser with the correct command.
+ (val) => (typeof val === 'function' ? val({ command: 'error' }) : val),
+ // validate
+ z
+ .object({
+ open: z
+ .union([z.string(), z.boolean()])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.server.open),
+ host: z
+ .union([z.string(), z.boolean()])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.server.host),
+ port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
+ headers: z.custom<OutgoingHttpHeaders>().optional(),
+ })
+ .default({}),
+ ),
+ redirects: z
+ .record(
+ z.string(),
+ z.union([
+ z.string(),
+ z.object({
+ status: z.union([
+ z.literal(300),
+ z.literal(301),
+ z.literal(302),
+ z.literal(303),
+ z.literal(304),
+ z.literal(307),
+ z.literal(308),
+ ]),
+ destination: z.string(),
+ }),
+ ]),
+ )
+ .default(ASTRO_CONFIG_DEFAULTS.redirects),
+ prefetch: z
+ .union([
+ z.boolean(),
+ z.object({
+ prefetchAll: z.boolean().optional(),
+ defaultStrategy: z.enum(['tap', 'hover', 'viewport', 'load']).optional(),
+ }),
+ ])
+ .optional(),
+ image: z
+ .object({
+ endpoint: z
+ .object({
+ route: z
+ .literal('/_image')
+ .or(z.string())
+ .default(ASTRO_CONFIG_DEFAULTS.image.endpoint.route),
+ entrypoint: z.string().optional(),
+ })
+ .default(ASTRO_CONFIG_DEFAULTS.image.endpoint),
+ service: z
+ .object({
+ entrypoint: z
+ .union([z.literal('astro/assets/services/sharp'), z.string()])
+ .default(ASTRO_CONFIG_DEFAULTS.image.service.entrypoint),
+ config: z.record(z.any()).default({}),
+ })
+ .default(ASTRO_CONFIG_DEFAULTS.image.service),
+ domains: z.array(z.string()).default([]),
+ remotePatterns: z
+ .array(
+ z.object({
+ protocol: z.string().optional(),
+ hostname: z
+ .string()
+ .refine(
+ (val) => !val.includes('*') || val.startsWith('*.') || val.startsWith('**.'),
+ {
+ message: 'wildcards can only be placed at the beginning of the hostname',
+ },
+ )
+ .optional(),
+ port: z.string().optional(),
+ pathname: z
+ .string()
+ .refine((val) => !val.includes('*') || val.endsWith('/*') || val.endsWith('/**'), {
+ message: 'wildcards can only be placed at the end of a pathname',
+ })
+ .optional(),
+ }),
+ )
+ .default([]),
+ experimentalLayout: z.enum(['responsive', 'fixed', 'full-width', 'none']).optional(),
+ experimentalObjectFit: z.string().optional(),
+ experimentalObjectPosition: z.string().optional(),
+ experimentalBreakpoints: z.array(z.number()).optional(),
+ })
+ .default(ASTRO_CONFIG_DEFAULTS.image),
+ devToolbar: z
+ .object({
+ enabled: z.boolean().default(ASTRO_CONFIG_DEFAULTS.devToolbar.enabled),
+ })
+ .default(ASTRO_CONFIG_DEFAULTS.devToolbar),
+ markdown: z
+ .object({
+ syntaxHighlight: z
+ .union([z.literal('shiki'), z.literal('prism'), z.literal(false)])
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.syntaxHighlight),
+ shikiConfig: z
+ .object({
+ langs: z
+ .custom<ShikiLang>()
+ .array()
+ .transform((langs) => {
+ for (const lang of langs) {
+ // shiki 1.0 compat
+ if (typeof lang === 'object') {
+ // `id` renamed to `name` (always override)
+ if ((lang as any).id) {
+ lang.name = (lang as any).id;
+ }
+ // `grammar` flattened to lang itself
+ if ((lang as any).grammar) {
+ Object.assign(lang, (lang as any).grammar);
+ }
+ }
+ }
+ return langs;
+ })
+ .default([]),
+ langAlias: z
+ .record(z.string(), z.string())
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.shikiConfig.langAlias!),
+ theme: z
+ .enum(Object.keys(bundledThemes) as [BuiltinTheme, ...BuiltinTheme[]])
+ .or(z.custom<ShikiTheme>())
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.shikiConfig.theme!),
+ themes: z
+ .record(
+ z
+ .enum(Object.keys(bundledThemes) as [BuiltinTheme, ...BuiltinTheme[]])
+ .or(z.custom<ShikiTheme>()),
+ )
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.shikiConfig.themes!),
+ defaultColor: z
+ .union([z.literal('light'), z.literal('dark'), z.string(), z.literal(false)])
+ .optional(),
+ wrap: z.boolean().or(z.null()).default(ASTRO_CONFIG_DEFAULTS.markdown.shikiConfig.wrap!),
+ transformers: z
+ .custom<ShikiTransformer>()
+ .array()
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.shikiConfig.transformers!),
+ })
+ .default({}),
+ remarkPlugins: z
+ .union([
+ z.string(),
+ z.tuple([z.string(), z.any()]),
+ z.custom<RemarkPlugin>((data) => typeof data === 'function'),
+ z.tuple([z.custom<RemarkPlugin>((data) => typeof data === 'function'), z.any()]),
+ ])
+ .array()
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkPlugins),
+ rehypePlugins: z
+ .union([
+ z.string(),
+ z.tuple([z.string(), z.any()]),
+ z.custom<RehypePlugin>((data) => typeof data === 'function'),
+ z.tuple([z.custom<RehypePlugin>((data) => typeof data === 'function'), z.any()]),
+ ])
+ .array()
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.rehypePlugins),
+ remarkRehype: z
+ .custom<RemarkRehype>((data) => data instanceof Object && !Array.isArray(data))
+ .default(ASTRO_CONFIG_DEFAULTS.markdown.remarkRehype),
+ gfm: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.gfm),
+ smartypants: z.boolean().default(ASTRO_CONFIG_DEFAULTS.markdown.smartypants),
+ })
+ .default({}),
+ vite: z
+ .custom<ViteUserConfig>((data) => data instanceof Object && !Array.isArray(data))
+ .default(ASTRO_CONFIG_DEFAULTS.vite),
+ i18n: z.optional(
+ z
+ .object({
+ defaultLocale: z.string(),
+ locales: z.array(
+ z.union([
+ z.string(),
+ z.object({
+ path: z.string(),
+ codes: z.string().array().nonempty(),
+ }),
+ ]),
+ ),
+ domains: z
+ .record(
+ z.string(),
+ z
+ .string()
+ .url(
+ "The domain value must be a valid URL, and it has to start with 'https' or 'http'.",
+ ),
+ )
+ .optional(),
+ fallback: z.record(z.string(), z.string()).optional(),
+ routing: z
+ .literal('manual')
+ .or(
+ z
+ .object({
+ prefixDefaultLocale: z.boolean().optional().default(false),
+ redirectToDefaultLocale: z.boolean().optional().default(true),
+ fallbackType: z.enum(['redirect', 'rewrite']).optional().default('redirect'),
+ })
+ .refine(
+ ({ prefixDefaultLocale, redirectToDefaultLocale }) => {
+ return !(prefixDefaultLocale === false && redirectToDefaultLocale === false);
+ },
+ {
+ message:
+ 'The option `i18n.redirectToDefaultLocale` is only useful when the `i18n.prefixDefaultLocale` is set to `true`. Remove the option `i18n.redirectToDefaultLocale`, or change its value to `true`.',
+ },
+ ),
+ )
+ .optional()
+ .default({}),
+ })
+ .optional()
+ .superRefine((i18n, ctx) => {
+ if (i18n) {
+ const { defaultLocale, locales: _locales, fallback, domains } = i18n;
+ const locales = _locales.map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ } else {
+ return locale.path;
+ }
+ });
+ if (!locales.includes(defaultLocale)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The default locale \`${defaultLocale}\` is not present in the \`i18n.locales\` array.`,
+ });
+ }
+ if (fallback) {
+ for (const [fallbackFrom, fallbackTo] of Object.entries(fallback)) {
+ if (!locales.includes(fallbackFrom)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The locale \`${fallbackFrom}\` key in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
+ });
+ }
+
+ if (fallbackFrom === defaultLocale) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `You can't use the default locale as a key. The default locale can only be used as value.`,
+ });
+ }
+
+ if (!locales.includes(fallbackTo)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The locale \`${fallbackTo}\` value in the \`i18n.fallback\` record doesn't exist in the \`i18n.locales\` array.`,
+ });
+ }
+ }
+ }
+ if (domains) {
+ const entries = Object.entries(domains);
+ const hasDomains = domains ? Object.keys(domains).length > 0 : false;
+ if (entries.length > 0 && !hasDomains) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `When specifying some domains, the property \`i18n.routingStrategy\` must be set to \`"domains"\`.`,
+ });
+ }
+
+ for (const [domainKey, domainValue] of entries) {
+ if (!locales.includes(domainKey)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The locale \`${domainKey}\` key in the \`i18n.domains\` record doesn't exist in the \`i18n.locales\` array.`,
+ });
+ }
+ if (!domainValue.startsWith('https') && !domainValue.startsWith('http')) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "The domain value must be a valid URL, and it has to start with 'https' or 'http'.",
+ path: ['domains'],
+ });
+ } else {
+ try {
+ const domainUrl = new URL(domainValue);
+ if (domainUrl.pathname !== '/') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The URL \`${domainValue}\` must contain only the origin. A subsequent pathname isn't allowed here. Remove \`${domainUrl.pathname}\`.`,
+ path: ['domains'],
+ });
+ }
+ } catch {
+ // no need to catch the error
+ }
+ }
+ }
+ }
+ }
+ }),
+ ),
+ security: z
+ .object({
+ checkOrigin: z.boolean().default(ASTRO_CONFIG_DEFAULTS.security.checkOrigin),
+ })
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.security),
+ env: z
+ .object({
+ schema: EnvSchema.optional().default(ASTRO_CONFIG_DEFAULTS.env.schema),
+ validateSecrets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.env.validateSecrets),
+ })
+ .strict()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.env),
+ experimental: z
+ .object({
+ clientPrerender: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.clientPrerender),
+ contentIntellisense: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
+ responsiveImages: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
+ session: z
+ .object({
+ driver: z.string(),
+ options: z.record(z.any()).optional(),
+ cookie: z
+ .union([
+ z.object({
+ name: z.string().optional(),
+ domain: z.string().optional(),
+ path: z.string().optional(),
+ maxAge: z.number().optional(),
+ sameSite: z.union([z.enum(['strict', 'lax', 'none']), z.boolean()]).optional(),
+ secure: z.boolean().optional(),
+ }),
+ z.string(),
+ ])
+ .transform((val) => {
+ if (typeof val === 'string') {
+ return { name: val };
+ }
+ return val;
+ })
+ .optional(),
+ ttl: z.number().optional(),
+ })
+ .optional(),
+ svg: z
+ .union([
+ z.boolean(),
+ z
+ .object({
+ mode: z.union([z.literal('inline'), z.literal('sprite')]).optional(),
+ })
+ .optional(),
+ ])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.svg)
+ .transform((svgConfig) => {
+ // Handle normalization of `experimental.svg` config boolean values
+ if (typeof svgConfig === 'boolean') {
+ return svgConfig
+ ? {
+ mode: 'inline' as SvgRenderMode,
+ }
+ : undefined;
+ } else {
+ if (!svgConfig.mode) {
+ return {
+ mode: 'inline' as SvgRenderMode,
+ };
+ }
+ }
+ return svgConfig;
+ }),
+ serializeConfig: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.serializeConfig),
+ })
+ .strict(
+ `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`,
+ )
+ .default({}),
+ legacy: z
+ .object({
+ collections: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.legacy.collections),
+ })
+ .default({}),
+});
+
+export type AstroConfigType = z.infer<typeof AstroConfigSchema>;
+
+export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
+ let originalBuildClient: string;
+ let originalBuildServer: string;
+
+ // We need to extend the global schema to add transforms that are relative to root.
+ // This is type checked against the global schema to make sure we still match.
+ const AstroConfigRelativeSchema = AstroConfigSchema.extend({
+ root: z
+ .string()
+ .default(ASTRO_CONFIG_DEFAULTS.root)
+ .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
+ srcDir: z
+ .string()
+ .default(ASTRO_CONFIG_DEFAULTS.srcDir)
+ .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
+ compressHTML: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.compressHTML),
+ publicDir: z
+ .string()
+ .default(ASTRO_CONFIG_DEFAULTS.publicDir)
+ .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
+ outDir: z
+ .string()
+ .default(ASTRO_CONFIG_DEFAULTS.outDir)
+ .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
+ cacheDir: z
+ .string()
+ .default(ASTRO_CONFIG_DEFAULTS.cacheDir)
+ .transform((val) => resolveDirAsUrl(val, fileProtocolRoot)),
+ build: z
+ .object({
+ format: z
+ .union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.format),
+ // NOTE: `client` and `server` are transformed relative to the default outDir first,
+ // later we'll fix this to be relative to the actual `outDir`
+ client: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.client)
+ .transform((val) => {
+ originalBuildClient = val;
+ return resolveDirAsUrl(
+ val,
+ path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
+ );
+ }),
+ server: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.server)
+ .transform((val) => {
+ originalBuildServer = val;
+ return resolveDirAsUrl(
+ val,
+ path.resolve(fileProtocolRoot, ASTRO_CONFIG_DEFAULTS.outDir),
+ );
+ }),
+ assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets),
+ assetsPrefix: z
+ .string()
+ .optional()
+ .or(z.object({ fallback: z.string() }).and(z.record(z.string())).optional())
+ .refine(
+ (value) => {
+ if (value && typeof value !== 'string') {
+ if (!value.fallback) {
+ return false;
+ }
+ }
+ return true;
+ },
+ {
+ message: 'The `fallback` is mandatory when defining the option as an object.',
+ },
+ ),
+ serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
+ redirects: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.redirects),
+ inlineStylesheets: z
+ .enum(['always', 'auto', 'never'])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets),
+ concurrency: z.number().min(1).optional().default(ASTRO_CONFIG_DEFAULTS.build.concurrency),
+ })
+ .optional()
+ .default({}),
+ server: z.preprocess(
+ // preprocess
+ (val) => {
+ if (typeof val === 'function') {
+ return val({ command: cmd === 'dev' ? 'dev' : 'preview' });
+ } else {
+ return val;
+ }
+ },
+ // validate
+ z
+ .object({
+ open: z
+ .union([z.string(), z.boolean()])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.server.open),
+ host: z
+ .union([z.string(), z.boolean()])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.server.host),
+ port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
+ headers: z.custom<OutgoingHttpHeaders>().optional(),
+ streaming: z.boolean().optional().default(true),
+ })
+ .optional()
+ .default({}),
+ ),
+ })
+ .transform((config) => {
+ // If the user changed `outDir`, we need to also update `build.client` and `build.server`
+ // the be based on the correct `outDir`
+ if (
+ config.outDir.toString() !==
+ resolveDirAsUrl(ASTRO_CONFIG_DEFAULTS.outDir, fileProtocolRoot).toString()
+ ) {
+ const outDirPath = fileURLToPath(config.outDir);
+ config.build.client = resolveDirAsUrl(originalBuildClient, outDirPath);
+ config.build.server = resolveDirAsUrl(originalBuildServer, outDirPath);
+ }
+
+ // Handle `base` and `image.endpoint.route` trailing slash based on `trailingSlash` config
+ if (config.trailingSlash === 'never') {
+ config.base = prependForwardSlash(removeTrailingForwardSlash(config.base));
+ config.image.endpoint.route = prependForwardSlash(
+ removeTrailingForwardSlash(config.image.endpoint.route),
+ );
+ } else if (config.trailingSlash === 'always') {
+ config.base = prependForwardSlash(appendForwardSlash(config.base));
+ config.image.endpoint.route = prependForwardSlash(
+ appendForwardSlash(config.image.endpoint.route),
+ );
+ } else {
+ config.base = prependForwardSlash(config.base);
+ config.image.endpoint.route = prependForwardSlash(config.image.endpoint.route);
+ }
+
+ return config;
+ })
+ .refine((obj) => !obj.outDir.toString().startsWith(obj.publicDir.toString()), {
+ message:
+ 'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop',
+ })
+ .superRefine((configuration, ctx) => {
+ const { site, i18n, output, image, experimental } = configuration;
+ const hasDomains = i18n?.domains ? Object.keys(i18n.domains).length > 0 : false;
+ if (hasDomains) {
+ if (!site) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.",
+ });
+ }
+ if (output !== 'server') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Domain support is only available when `output` is `"server"`.',
+ });
+ }
+ }
+ if (
+ !experimental.responsiveImages &&
+ (image.experimentalLayout ||
+ image.experimentalObjectFit ||
+ image.experimentalObjectPosition ||
+ image.experimentalBreakpoints)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ 'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.',
+ });
+ }
+ });
+
+ return AstroConfigRelativeSchema;
+}
+
+function resolveDirAsUrl(dir: string, root: string) {
+ let resolvedDir = path.resolve(root, dir);
+ if (!resolvedDir.endsWith(path.sep)) {
+ resolvedDir += path.sep;
+ }
+ return pathToFileURL(resolvedDir);
+}
diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts
new file mode 100644
index 000000000..e333d6b06
--- /dev/null
+++ b/packages/astro/src/core/config/settings.ts
@@ -0,0 +1,138 @@
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import yaml from 'js-yaml';
+import { getContentPaths } from '../../content/index.js';
+import createPreferences from '../../preferences/index.js';
+import type { AstroSettings } from '../../types/astro.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { markdownContentEntryType } from '../../vite-plugin-markdown/content-entry-type.js';
+import { getDefaultClientDirectives } from '../client-directive/index.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { formatYAMLException, isYAMLException } from '../errors/utils.js';
+import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../constants.js';
+import { AstroTimer } from './timer.js';
+import { loadTSConfig } from './tsconfig.js';
+
+export function createBaseSettings(config: AstroConfig): AstroSettings {
+ const { contentDir } = getContentPaths(config);
+ const dotAstroDir = new URL('.astro/', config.root);
+ const preferences = createPreferences(config, dotAstroDir);
+ return {
+ config,
+ preferences,
+ tsConfig: undefined,
+ tsConfigPath: undefined,
+ adapter: undefined,
+ injectedRoutes: [],
+ resolvedInjectedRoutes: [],
+ serverIslandMap: new Map(),
+ serverIslandNameMap: new Map(),
+ pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
+ contentEntryTypes: [markdownContentEntryType],
+ dataEntryTypes: [
+ {
+ extensions: ['.json'],
+ getEntryInfo({ contents, fileUrl }) {
+ if (contents === undefined || contents === '') return { data: {} };
+
+ const pathRelToContentDir = path.relative(
+ fileURLToPath(contentDir),
+ fileURLToPath(fileUrl),
+ );
+ let data;
+ try {
+ data = JSON.parse(contents);
+ } catch (e) {
+ throw new AstroError({
+ ...AstroErrorData.DataCollectionEntryParseError,
+ message: AstroErrorData.DataCollectionEntryParseError.message(
+ pathRelToContentDir,
+ e instanceof Error ? e.message : 'contains invalid JSON.',
+ ),
+ location: { file: fileUrl.pathname },
+ stack: e instanceof Error ? e.stack : undefined,
+ });
+ }
+
+ if (data == null || typeof data !== 'object') {
+ throw new AstroError({
+ ...AstroErrorData.DataCollectionEntryParseError,
+ message: AstroErrorData.DataCollectionEntryParseError.message(
+ pathRelToContentDir,
+ 'data is not an object.',
+ ),
+ location: { file: fileUrl.pathname },
+ });
+ }
+
+ return { data };
+ },
+ },
+ {
+ extensions: ['.yaml', '.yml'],
+ getEntryInfo({ contents, fileUrl }) {
+ try {
+ const data = yaml.load(contents, { filename: fileURLToPath(fileUrl) });
+ const rawData = contents;
+
+ return { data, rawData };
+ } catch (e) {
+ const pathRelToContentDir = path.relative(
+ fileURLToPath(contentDir),
+ fileURLToPath(fileUrl),
+ );
+ const formattedError = isYAMLException(e)
+ ? formatYAMLException(e)
+ : new Error('contains invalid YAML.');
+
+ throw new AstroError({
+ ...AstroErrorData.DataCollectionEntryParseError,
+ message: AstroErrorData.DataCollectionEntryParseError.message(
+ pathRelToContentDir,
+ formattedError.message,
+ ),
+ stack: formattedError.stack,
+ location:
+ 'loc' in formattedError
+ ? { file: fileUrl.pathname, ...formattedError.loc }
+ : { file: fileUrl.pathname },
+ });
+ }
+ },
+ },
+ ],
+ renderers: [],
+ scripts: [],
+ clientDirectives: getDefaultClientDirectives(),
+ middlewares: { pre: [], post: [] },
+ watchFiles: [],
+ devToolbarApps: [],
+ timer: new AstroTimer(),
+ dotAstroDir,
+ latestAstroVersion: undefined, // Will be set later if applicable when the dev server starts
+ injectedTypes: [],
+ buildOutput: undefined,
+ };
+}
+
+export async function createSettings(config: AstroConfig, cwd?: string): Promise<AstroSettings> {
+ const tsconfig = await loadTSConfig(cwd);
+ const settings = createBaseSettings(config);
+
+ let watchFiles = [];
+ if (cwd) {
+ watchFiles.push(fileURLToPath(new URL('./package.json', pathToFileURL(cwd))));
+ }
+
+ if (typeof tsconfig !== 'string') {
+ watchFiles.push(
+ ...[tsconfig.tsconfigFile, ...(tsconfig.extended ?? []).map((e) => e.tsconfigFile)],
+ );
+ settings.tsConfig = tsconfig.tsconfig;
+ settings.tsConfigPath = tsconfig.tsconfigFile;
+ }
+
+ settings.watchFiles = watchFiles;
+
+ return settings;
+}
diff --git a/packages/astro/src/core/config/timer.ts b/packages/astro/src/core/config/timer.ts
new file mode 100644
index 000000000..d41009b11
--- /dev/null
+++ b/packages/astro/src/core/config/timer.ts
@@ -0,0 +1,65 @@
+import fs from 'node:fs';
+
+// Type used by `bench-memory.js`
+export interface Stat {
+ elapsedTime: number;
+ heapUsedChange: number;
+ heapUsedTotal: number;
+}
+
+interface OngoingStat {
+ startTime: number;
+ startHeap: number;
+}
+
+/**
+ * Timer to track certain operations' performance. Used by Astro's scripts only.
+ * Set `process.env.ASTRO_TIMER_PATH` truthy to enable.
+ */
+export class AstroTimer {
+ private enabled: boolean;
+ private ongoingTimers = new Map<string, OngoingStat>();
+ private stats: Record<string, Stat> = {};
+
+ constructor() {
+ this.enabled = !!process.env.ASTRO_TIMER_PATH;
+ }
+
+ /**
+ * Start a timer for a scope with a given name.
+ */
+ start(name: string) {
+ if (!this.enabled) return;
+ globalThis.gc?.();
+ this.ongoingTimers.set(name, {
+ startTime: performance.now(),
+ startHeap: process.memoryUsage().heapUsed,
+ });
+ }
+
+ /**
+ * End a timer for a scope with a given name.
+ */
+ end(name: string) {
+ if (!this.enabled) return;
+ const stat = this.ongoingTimers.get(name);
+ if (!stat) return;
+ globalThis.gc?.();
+ const endHeap = process.memoryUsage().heapUsed;
+ this.stats[name] = {
+ elapsedTime: performance.now() - stat.startTime,
+ heapUsedChange: endHeap - stat.startHeap,
+ heapUsedTotal: endHeap,
+ };
+ this.ongoingTimers.delete(name);
+ }
+
+ /**
+ * Write stats to `process.env.ASTRO_TIMER_PATH`
+ */
+ writeStats() {
+ if (!this.enabled) return;
+ // @ts-expect-error
+ fs.writeFileSync(process.env.ASTRO_TIMER_PATH, JSON.stringify(this.stats, null, 2));
+ }
+}
diff --git a/packages/astro/src/core/config/tsconfig.ts b/packages/astro/src/core/config/tsconfig.ts
new file mode 100644
index 000000000..03d89e30c
--- /dev/null
+++ b/packages/astro/src/core/config/tsconfig.ts
@@ -0,0 +1,194 @@
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import {
+ TSConfckParseError,
+ type TSConfckParseOptions,
+ type TSConfckParseResult,
+ find,
+ parse,
+ toJson,
+} from 'tsconfck';
+import type { CompilerOptions, TypeAcquisition } from 'typescript';
+
+export const defaultTSConfig: TSConfig = { extends: 'astro/tsconfigs/base' };
+
+export type frameworkWithTSSettings = 'vue' | 'react' | 'preact' | 'solid-js';
+// The following presets unfortunately cannot be inside the specific integrations, as we need
+// them even in cases where the integrations are not installed
+export const presets = new Map<frameworkWithTSSettings, TSConfig>([
+ [
+ 'vue', // Settings needed for template intellisense when using Volar
+ {
+ compilerOptions: {
+ jsx: 'preserve',
+ },
+ },
+ ],
+ [
+ 'react', // Default TypeScript settings, but we need to redefine them in case the users changed them previously
+ {
+ compilerOptions: {
+ jsx: 'react-jsx',
+ jsxImportSource: 'react',
+ },
+ },
+ ],
+ [
+ 'preact', // https://preactjs.com/guide/v10/typescript/#typescript-configuration
+ {
+ compilerOptions: {
+ jsx: 'react-jsx',
+ jsxImportSource: 'preact',
+ },
+ },
+ ],
+ [
+ 'solid-js', // https://www.solidjs.com/guides/typescript#configuring-typescript
+ {
+ compilerOptions: {
+ jsx: 'preserve',
+ jsxImportSource: 'solid-js',
+ },
+ },
+ ],
+]);
+
+type TSConfigResult<T = object> = Promise<
+ (TSConfckParseResult & T) | 'invalid-config' | 'missing-config' | 'unknown-error'
+>;
+
+/**
+ * Load a tsconfig.json or jsconfig.json is the former is not found
+ * @param root The root directory to search in, defaults to `process.cwd()`.
+ * @param findUp Whether to search for the config file in parent directories, by default only the root directory is searched.
+ */
+export async function loadTSConfig(
+ root: string | undefined,
+ findUp = false,
+): Promise<TSConfigResult<{ rawConfig: TSConfig }>> {
+ const safeCwd = root ?? process.cwd();
+
+ const [jsconfig, tsconfig] = await Promise.all(
+ ['jsconfig.json', 'tsconfig.json'].map((configName) =>
+ // `tsconfck` expects its first argument to be a file path, not a directory path, so we'll fake one
+ find(join(safeCwd, './dummy.txt'), {
+ root: findUp ? undefined : root,
+ configName: configName,
+ }),
+ ),
+ );
+
+ // If we have both files, prefer tsconfig.json
+ if (tsconfig) {
+ const parsedConfig = await safeParse(tsconfig, { root: root });
+
+ if (typeof parsedConfig === 'string') {
+ return parsedConfig;
+ }
+
+ // tsconfck does not return the original config, so we need to parse it ourselves
+ // https://github.com/dominikg/tsconfck/issues/138
+ const rawConfig = await readFile(tsconfig, 'utf-8')
+ .then(toJson)
+ .then((content) => JSON.parse(content) as TSConfig);
+
+ return { ...parsedConfig, rawConfig };
+ }
+
+ if (jsconfig) {
+ const parsedConfig = await safeParse(jsconfig, { root: root });
+
+ if (typeof parsedConfig === 'string') {
+ return parsedConfig;
+ }
+
+ const rawConfig = await readFile(jsconfig, 'utf-8')
+ .then(toJson)
+ .then((content) => JSON.parse(content) as TSConfig);
+
+ return { ...parsedConfig, rawConfig: rawConfig };
+ }
+
+ return 'missing-config';
+}
+
+async function safeParse(tsconfigPath: string, options: TSConfckParseOptions = {}): TSConfigResult {
+ try {
+ const parseResult = await parse(tsconfigPath, options);
+
+ if (parseResult.tsconfig == null) {
+ return 'missing-config';
+ }
+
+ return parseResult;
+ } catch (e) {
+ if (e instanceof TSConfckParseError) {
+ return 'invalid-config';
+ }
+
+ return 'unknown-error';
+ }
+}
+
+export function updateTSConfigForFramework(
+ target: TSConfig,
+ framework: frameworkWithTSSettings,
+): TSConfig {
+ if (!presets.has(framework)) {
+ return target;
+ }
+
+ return deepMergeObjects(target, presets.get(framework)!);
+}
+
+// Simple deep merge implementation that merges objects and strings
+function deepMergeObjects<T extends Record<string, any>>(a: T, b: T): T {
+ const merged: T = { ...a };
+
+ for (const key in b) {
+ const value = b[key];
+
+ if (a[key] == null) {
+ merged[key] = value;
+ continue;
+ }
+
+ if (typeof a[key] === 'object' && typeof value === 'object') {
+ merged[key] = deepMergeObjects(a[key], value);
+ continue;
+ }
+
+ merged[key] = value;
+ }
+
+ return merged;
+}
+
+// The code below is adapted from `pkg-types`
+// `pkg-types` offer more types and utilities, but since we only want the TSConfig type, we'd rather avoid adding a dependency.
+// https://github.com/unjs/pkg-types/blob/78328837d369d0145a8ddb35d7fe1fadda4bfadf/src/types/tsconfig.ts
+// See https://github.com/unjs/pkg-types/blob/78328837d369d0145a8ddb35d7fe1fadda4bfadf/LICENSE for license information
+
+export type StripEnums<T extends Record<string, any>> = {
+ [K in keyof T]: T[K] extends boolean
+ ? T[K]
+ : T[K] extends string
+ ? T[K]
+ : T[K] extends object
+ ? T[K]
+ : T[K] extends Array<any>
+ ? T[K]
+ : T[K] extends undefined
+ ? undefined
+ : any;
+};
+
+export interface TSConfig {
+ compilerOptions?: StripEnums<CompilerOptions>;
+ compileOnSave?: boolean;
+ extends?: string;
+ files?: string[];
+ include?: string[];
+ exclude?: string[];
+ typeAcquisition?: TypeAcquisition;
+}
diff --git a/packages/astro/src/core/config/validate.ts b/packages/astro/src/core/config/validate.ts
new file mode 100644
index 000000000..c293b5b58
--- /dev/null
+++ b/packages/astro/src/core/config/validate.ts
@@ -0,0 +1,15 @@
+import type { AstroConfig } from '../../types/public/config.js';
+import { errorMap } from '../errors/index.js';
+import { createRelativeSchema } from './schema.js';
+
+/** Turn raw config values into normalized values */
+export async function validateConfig(
+ userConfig: any,
+ root: string,
+ cmd: string,
+): Promise<AstroConfig> {
+ const AstroConfigRelativeSchema = createRelativeSchema(cmd, root);
+
+ // First-Pass Validation
+ return await AstroConfigRelativeSchema.parseAsync(userConfig, { errorMap });
+}
diff --git a/packages/astro/src/core/config/vite-load.ts b/packages/astro/src/core/config/vite-load.ts
new file mode 100644
index 000000000..cf6e4a0b0
--- /dev/null
+++ b/packages/astro/src/core/config/vite-load.ts
@@ -0,0 +1,53 @@
+import type fsType from 'node:fs';
+import { pathToFileURL } from 'node:url';
+import { type ViteDevServer, createServer } from 'vite';
+import loadFallbackPlugin from '../../vite-plugin-load-fallback/index.js';
+import { debug } from '../logger/core.js';
+
+async function createViteServer(root: string, fs: typeof fsType): Promise<ViteDevServer> {
+ const viteServer = await createServer({
+ configFile: false,
+ server: { middlewareMode: true, hmr: false, watch: null, ws: false },
+ optimizeDeps: { noDiscovery: true },
+ clearScreen: false,
+ appType: 'custom',
+ ssr: { external: true },
+ plugins: [loadFallbackPlugin({ fs, root: pathToFileURL(root) })],
+ });
+
+ return viteServer;
+}
+
+interface LoadConfigWithViteOptions {
+ root: string;
+ configPath: string;
+ fs: typeof fsType;
+}
+
+export async function loadConfigWithVite({
+ configPath,
+ fs,
+ root,
+}: LoadConfigWithViteOptions): Promise<Record<string, any>> {
+ if (/\.[cm]?js$/.test(configPath)) {
+ try {
+ const config = await import(pathToFileURL(configPath).toString() + '?t=' + Date.now());
+ return config.default ?? {};
+ } catch (e) {
+ // We do not need to throw the error here as we have a Vite fallback below
+ debug('Failed to load config with Node', e);
+ }
+ }
+
+ // Try Loading with Vite
+ let server: ViteDevServer | undefined;
+ try {
+ server = await createViteServer(root, fs);
+ const mod = await server.ssrLoadModule(configPath, { fixStacktrace: true });
+ return mod.default ?? {};
+ } finally {
+ if (server) {
+ await server.close();
+ }
+ }
+}
diff --git a/packages/astro/src/core/constants.ts b/packages/astro/src/core/constants.ts
new file mode 100644
index 000000000..dc09a1f69
--- /dev/null
+++ b/packages/astro/src/core/constants.ts
@@ -0,0 +1,102 @@
+// process.env.PACKAGE_VERSION is injected when we build and publish the astro package.
+export const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development';
+
+/**
+ * The name for the header used to help rerouting behavior.
+ * When set to "no", astro will NOT try to reroute an error response to the corresponding error page, which is the default behavior that can sometimes lead to loops.
+ *
+ * ```ts
+ * const response = new Response("keep this content as-is", {
+ * status: 404,
+ * headers: {
+ * // note that using a variable name as the key of an object needs to be wrapped in square brackets in javascript
+ * // without them, the header name will be interpreted as "REROUTE_DIRECTIVE_HEADER" instead of "X-Astro-Reroute"
+ * [REROUTE_DIRECTIVE_HEADER]: 'no',
+ * }
+ * })
+ * ```
+ * Alternatively...
+ * ```ts
+ * response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
+ * ```
+ */
+export const REROUTE_DIRECTIVE_HEADER = 'X-Astro-Reroute';
+
+/**
+ * Header and value that are attached to a Response object when a **user rewrite** occurs.
+ *
+ * This metadata is used to determine the origin of a Response. If a rewrite has occurred, it should be prioritised over other logic.
+ */
+export const REWRITE_DIRECTIVE_HEADER_KEY = 'X-Astro-Rewrite';
+
+export const REWRITE_DIRECTIVE_HEADER_VALUE = 'yes';
+
+/**
+ * This header is set by the no-op Astro middleware.
+ */
+export const NOOP_MIDDLEWARE_HEADER = 'X-Astro-Noop';
+
+/**
+ * The name for the header used to help i18n middleware, which only needs to act on "page" and "fallback" route types.
+ */
+export const ROUTE_TYPE_HEADER = 'X-Astro-Route-Type';
+
+/**
+ * The value of the `component` field of the default 404 page, which is used when there is no user-provided 404.astro page.
+ */
+export const DEFAULT_404_COMPONENT = 'astro-default-404.astro';
+
+/**
+ * The value of the `component` field of the default 500 page, which is used when there is no user-provided 404.astro page.
+ */
+export const DEFAULT_500_COMPONENT = 'astro-default-500.astro';
+
+/**
+ * A response with one of these status codes will create a redirect response.
+ */
+export const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308, 300, 304] as const;
+
+/**
+ * A response with one of these status codes will be rewritten
+ * with the result of rendering the respective error page.
+ */
+export const REROUTABLE_STATUS_CODES = [404, 500];
+
+/**
+ * The symbol which is used as a field on the request object to store the client address.
+ * The clientAddress provided by the adapter (or the dev server) is stored on this field.
+ */
+export const clientAddressSymbol = Symbol.for('astro.clientAddress');
+
+/**
+ * The symbol used as a field on the request object to store the object to be made available to Astro APIs as `locals`.
+ * Use judiciously, as locals are now stored within `RenderContext` by default. Tacking it onto request is no longer necessary.
+ */
+export const clientLocalsSymbol = Symbol.for('astro.locals');
+
+/**
+ * Use this symbol to set and retrieve the original pathname of a request. This is useful when working with redirects and rewrites
+ */
+export const originPathnameSymbol = Symbol.for('astro.originPathname');
+
+/**
+ * The symbol used as a field on the response object to keep track of streaming.
+ *
+ * It is set when the `<head>` element has been completely generated, rendered, and the response object has been passed onto the adapter.
+ *
+ * Used to provide helpful errors and warnings when headers or cookies are added during streaming, after the response has already been sent.
+ */
+export const responseSentSymbol = Symbol.for('astro.responseSent');
+
+// possible extensions for markdown files
+export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
+ '.markdown',
+ '.mdown',
+ '.mkdn',
+ '.mkd',
+ '.mdwn',
+ '.md',
+] as const;
+
+// The folder name where to find the middleware
+export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';
diff --git a/packages/astro/src/core/cookies/cookies.ts b/packages/astro/src/core/cookies/cookies.ts
new file mode 100644
index 000000000..f2679f23f
--- /dev/null
+++ b/packages/astro/src/core/cookies/cookies.ts
@@ -0,0 +1,255 @@
+import type { CookieSerializeOptions } from 'cookie';
+import { parse, serialize } from 'cookie';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+
+export type AstroCookieSetOptions = Pick<
+ CookieSerializeOptions,
+ 'domain' | 'path' | 'expires' | 'maxAge' | 'httpOnly' | 'sameSite' | 'secure' | 'encode'
+>;
+
+export interface AstroCookieGetOptions {
+ decode?: (value: string) => string;
+}
+
+type AstroCookieDeleteOptions = Omit<AstroCookieSetOptions, 'expires' | 'maxAge' | 'encode'>;
+
+interface AstroCookieInterface {
+ value: string;
+ json(): Record<string, any>;
+ number(): number;
+ boolean(): boolean;
+}
+
+interface AstroCookiesInterface {
+ get(key: string): AstroCookieInterface | undefined;
+ has(key: string): boolean;
+ set(
+ key: string,
+ value: string | number | boolean | Record<string, any>,
+ options?: AstroCookieSetOptions,
+ ): void;
+ delete(key: string, options?: AstroCookieDeleteOptions): void;
+}
+
+const DELETED_EXPIRATION = new Date(0);
+const DELETED_VALUE = 'deleted';
+const responseSentSymbol = Symbol.for('astro.responseSent');
+
+class AstroCookie implements AstroCookieInterface {
+ constructor(public value: string) {}
+ json() {
+ if (this.value === undefined) {
+ throw new Error(`Cannot convert undefined to an object.`);
+ }
+ return JSON.parse(this.value);
+ }
+ number() {
+ return Number(this.value);
+ }
+ boolean() {
+ if (this.value === 'false') return false;
+ if (this.value === '0') return false;
+ return Boolean(this.value);
+ }
+}
+
+class AstroCookies implements AstroCookiesInterface {
+ #request: Request;
+ #requestValues: Record<string, string> | null;
+ #outgoing: Map<string, [string, string, boolean]> | null;
+ #consumed: boolean;
+ constructor(request: Request) {
+ this.#request = request;
+ this.#requestValues = null;
+ this.#outgoing = null;
+ this.#consumed = false;
+ }
+
+ /**
+ * Astro.cookies.delete(key) is used to delete a cookie. Using this method will result
+ * in a Set-Cookie header added to the response.
+ * @param key The cookie to delete
+ * @param options Options related to this deletion, such as the path of the cookie.
+ */
+ delete(key: string, options?: AstroCookieDeleteOptions): void {
+ /**
+ * The `@ts-expect-error` is necessary because `maxAge` and `expires` properties
+ * must not appear in the AstroCookieDeleteOptions type.
+ */
+ const {
+ // @ts-expect-error
+ maxAge: _ignoredMaxAge,
+ // @ts-expect-error
+ expires: _ignoredExpires,
+ ...sanitizedOptions
+ } = options || {};
+ const serializeOptions: CookieSerializeOptions = {
+ expires: DELETED_EXPIRATION,
+ ...sanitizedOptions,
+ };
+
+ // Set-Cookie: token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT
+ this.#ensureOutgoingMap().set(key, [
+ DELETED_VALUE,
+ serialize(key, DELETED_VALUE, serializeOptions),
+ false,
+ ]);
+ }
+
+ /**
+ * Astro.cookies.get(key) is used to get a cookie value. The cookie value is read from the
+ * request. If you have set a cookie via Astro.cookies.set(key, value), the value will be taken
+ * from that set call, overriding any values already part of the request.
+ * @param key The cookie to get.
+ * @returns An object containing the cookie value as well as convenience methods for converting its value.
+ */
+ get(
+ key: string,
+ options: AstroCookieGetOptions | undefined = undefined,
+ ): AstroCookie | undefined {
+ // Check for outgoing Set-Cookie values first
+ if (this.#outgoing?.has(key)) {
+ let [serializedValue, , isSetValue] = this.#outgoing.get(key)!;
+ if (isSetValue) {
+ return new AstroCookie(serializedValue);
+ } else {
+ return undefined;
+ }
+ }
+
+ const values = this.#ensureParsed(options);
+ if (key in values) {
+ const value = values[key];
+ return new AstroCookie(value);
+ }
+ }
+
+ /**
+ * Astro.cookies.has(key) returns a boolean indicating whether this cookie is either
+ * part of the initial request or set via Astro.cookies.set(key)
+ * @param key The cookie to check for.
+ * @returns
+ */
+ has(key: string, options: AstroCookieGetOptions | undefined = undefined): boolean {
+ if (this.#outgoing?.has(key)) {
+ let [, , isSetValue] = this.#outgoing.get(key)!;
+ return isSetValue;
+ }
+ const values = this.#ensureParsed(options);
+ return !!values[key];
+ }
+
+ /**
+ * Astro.cookies.set(key, value) is used to set a cookie's value. If provided
+ * an object it will be stringified via JSON.stringify(value). Additionally you
+ * can provide options customizing how this cookie will be set, such as setting httpOnly
+ * in order to prevent the cookie from being read in client-side JavaScript.
+ * @param key The name of the cookie to set.
+ * @param value A value, either a string or other primitive or an object.
+ * @param options Options for the cookie, such as the path and security settings.
+ */
+ set(key: string, value: string | Record<string, any>, options?: AstroCookieSetOptions): void {
+ if (this.#consumed) {
+ const warning = new Error(
+ 'Astro.cookies.set() was called after the cookies had already been sent to the browser.\n' +
+ 'This may have happened if this method was called in an imported component.\n' +
+ 'Please make sure that Astro.cookies.set() is only called in the frontmatter of the main page.',
+ );
+ warning.name = 'Warning';
+ console.warn(warning);
+ }
+ let serializedValue: string;
+ if (typeof value === 'string') {
+ serializedValue = value;
+ } else {
+ // Support stringifying JSON objects for convenience. First check that this is
+ // a plain object and if it is, stringify. If not, allow support for toString() overrides.
+ let toStringValue = value.toString();
+ if (toStringValue === Object.prototype.toString.call(value)) {
+ serializedValue = JSON.stringify(value);
+ } else {
+ serializedValue = toStringValue;
+ }
+ }
+
+ const serializeOptions: CookieSerializeOptions = {};
+ if (options) {
+ Object.assign(serializeOptions, options);
+ }
+
+ this.#ensureOutgoingMap().set(key, [
+ serializedValue,
+ serialize(key, serializedValue, serializeOptions),
+ true,
+ ]);
+
+ if ((this.#request as any)[responseSentSymbol]) {
+ throw new AstroError({
+ ...AstroErrorData.ResponseSentError,
+ });
+ }
+ }
+
+ /**
+ * Merges a new AstroCookies instance into the current instance. Any new cookies
+ * will be added to the current instance, overwriting any existing cookies with the same name.
+ */
+ merge(cookies: AstroCookies) {
+ const outgoing = cookies.#outgoing;
+ if (outgoing) {
+ for (const [key, value] of outgoing) {
+ this.#ensureOutgoingMap().set(key, value);
+ }
+ }
+ }
+
+ /**
+ * Astro.cookies.header() returns an iterator for the cookies that have previously
+ * been set by either Astro.cookies.set() or Astro.cookies.delete().
+ * This method is primarily used by adapters to set the header on outgoing responses.
+ * @returns
+ */
+ *headers(): Generator<string, void, unknown> {
+ if (this.#outgoing == null) return;
+ for (const [, value] of this.#outgoing) {
+ yield value[1];
+ }
+ }
+
+ /**
+ * Behaves the same as AstroCookies.prototype.headers(),
+ * but allows a warning when cookies are set after the instance is consumed.
+ */
+ static consume(cookies: AstroCookies): Generator<string, void, unknown> {
+ cookies.#consumed = true;
+ return cookies.headers();
+ }
+
+ #ensureParsed(options: AstroCookieGetOptions | undefined = undefined): Record<string, string> {
+ if (!this.#requestValues) {
+ this.#parse(options);
+ }
+ if (!this.#requestValues) {
+ this.#requestValues = {};
+ }
+ return this.#requestValues;
+ }
+
+ #ensureOutgoingMap(): Map<string, [string, string, boolean]> {
+ if (!this.#outgoing) {
+ this.#outgoing = new Map();
+ }
+ return this.#outgoing;
+ }
+
+ #parse(options: AstroCookieGetOptions | undefined = undefined) {
+ const raw = this.#request.headers.get('cookie');
+ if (!raw) {
+ return;
+ }
+
+ this.#requestValues = parse(raw, options);
+ }
+}
+
+export { AstroCookies };
diff --git a/packages/astro/src/core/cookies/index.ts b/packages/astro/src/core/cookies/index.ts
new file mode 100644
index 000000000..1ac732f1b
--- /dev/null
+++ b/packages/astro/src/core/cookies/index.ts
@@ -0,0 +1,7 @@
+export { AstroCookies } from './cookies.js';
+export {
+ attachCookiesToResponse,
+ getSetCookiesFromResponse,
+ responseHasCookies,
+} from './response.js';
+export type { AstroCookieSetOptions, AstroCookieGetOptions } from './cookies.js';
diff --git a/packages/astro/src/core/cookies/response.ts b/packages/astro/src/core/cookies/response.ts
new file mode 100644
index 000000000..288bb3e93
--- /dev/null
+++ b/packages/astro/src/core/cookies/response.ts
@@ -0,0 +1,32 @@
+import { AstroCookies } from './cookies.js';
+
+const astroCookiesSymbol = Symbol.for('astro.cookies');
+
+export function attachCookiesToResponse(response: Response, cookies: AstroCookies) {
+ Reflect.set(response, astroCookiesSymbol, cookies);
+}
+
+export function responseHasCookies(response: Response): boolean {
+ return Reflect.has(response, astroCookiesSymbol);
+}
+
+export function getCookiesFromResponse(response: Response): AstroCookies | undefined {
+ let cookies = Reflect.get(response, astroCookiesSymbol);
+ if (cookies != null) {
+ return cookies as AstroCookies;
+ } else {
+ return undefined;
+ }
+}
+
+export function* getSetCookiesFromResponse(response: Response): Generator<string, string[]> {
+ const cookies = getCookiesFromResponse(response);
+ if (!cookies) {
+ return [];
+ }
+ for (const headerValue of AstroCookies.consume(cookies)) {
+ yield headerValue;
+ }
+
+ return [];
+}
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
new file mode 100644
index 000000000..6e10ec38c
--- /dev/null
+++ b/packages/astro/src/core/create-vite.ts
@@ -0,0 +1,345 @@
+import nodeFs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import glob from 'fast-glob';
+import * as vite from 'vite';
+import { crawlFrameworkPkgs } from 'vitefu';
+import { vitePluginActions, vitePluginUserActions } from '../actions/plugins.js';
+import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js';
+import astroAssetsPlugin from '../assets/vite-plugin-assets.js';
+import astroContainer from '../container/vite-plugin-container.js';
+import {
+ astroContentAssetPropagationPlugin,
+ astroContentImportPlugin,
+ astroContentVirtualModPlugin,
+} from '../content/index.js';
+import { createEnvLoader } from '../env/env-loader.js';
+import { astroEnv } from '../env/vite-plugin-env.js';
+import { importMetaEnv } from '../env/vite-plugin-import-meta-env.js';
+import astroInternationalization from '../i18n/vite-plugin-i18n.js';
+import astroVirtualManifestPlugin from '../manifest/virtual-module.js';
+import astroPrefetch from '../prefetch/vite-plugin-prefetch.js';
+import astroDevToolbar from '../toolbar/vite-plugin-dev-toolbar.js';
+import astroTransitions from '../transitions/vite-plugin-transitions.js';
+import type { AstroSettings, RoutesList } from '../types/astro.js';
+import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
+import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
+import astroVitePlugin from '../vite-plugin-astro/index.js';
+import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
+import vitePluginFileURL from '../vite-plugin-fileurl/index.js';
+import astroHeadPlugin from '../vite-plugin-head/index.js';
+import astroHmrReloadPlugin from '../vite-plugin-hmr-reload/index.js';
+import htmlVitePlugin from '../vite-plugin-html/index.js';
+import astroIntegrationsContainerPlugin from '../vite-plugin-integrations-container/index.js';
+import astroLoadFallbackPlugin from '../vite-plugin-load-fallback/index.js';
+import markdownVitePlugin from '../vite-plugin-markdown/index.js';
+import astroScannerPlugin from '../vite-plugin-scanner/index.js';
+import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
+import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
+import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js';
+import type { SSRManifest } from './app/types.js';
+import type { Logger } from './logger/core.js';
+import { createViteLogger } from './logger/vite.js';
+import { vitePluginMiddleware } from './middleware/vite-plugin.js';
+import { joinPaths } from './path.js';
+import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js';
+import { isObject } from './util.js';
+
+type CreateViteOptions = {
+ settings: AstroSettings;
+ logger: Logger;
+ mode: string;
+ fs?: typeof nodeFs;
+ sync: boolean;
+ routesList: RoutesList;
+ manifest: SSRManifest;
+} & (
+ | {
+ command: 'dev';
+ manifest: SSRManifest;
+ }
+ | {
+ command: 'build';
+ manifest?: SSRManifest;
+ }
+);
+
+const ALWAYS_NOEXTERNAL = [
+ // This is only because Vite's native ESM doesn't resolve "exports" correctly.
+ 'astro',
+ // Vite fails on nested `.astro` imports without bundling
+ 'astro/components',
+ // Handle recommended nanostores. Only @nanostores/preact is required from our testing!
+ // Full explanation and related bug report: https://github.com/withastro/astro/pull/3667
+ '@nanostores/preact',
+ // fontsource packages are CSS that need to be processed
+ '@fontsource/*',
+];
+
+// These specifiers are usually dependencies written in CJS, but loaded through Vite's transform
+// pipeline, which Vite doesn't support in development time. This hardcoded list temporarily
+// fixes things until Vite can properly handle them, or when they support ESM.
+const ONLY_DEV_EXTERNAL = [
+ // Imported by `@astrojs/prism` which exposes `<Prism/>` that is processed by Vite
+ 'prismjs/components/index.js',
+ // Imported by `astro/assets` -> `packages/astro/src/core/logger/core.ts`
+ 'string-width',
+ // Imported by `astro:transitions` -> packages/astro/src/runtime/server/transition.ts
+ 'cssesc',
+];
+
+/** Return a base vite config as a common starting point for all Vite commands. */
+export async function createVite(
+ commandConfig: vite.InlineConfig,
+ { settings, logger, mode, command, fs = nodeFs, sync, routesList, manifest }: CreateViteOptions,
+): Promise<vite.InlineConfig> {
+ const astroPkgsConfig = await crawlFrameworkPkgs({
+ root: fileURLToPath(settings.config.root),
+ isBuild: command === 'build',
+ viteUserConfig: settings.config.vite,
+ isFrameworkPkgByJson(pkgJson) {
+ // Certain packages will trigger the checks below, but need to be external. A common example are SSR adapters
+ // for node-based platforms, as we need to control the order of the import paths to make sure polyfills are applied in time.
+ if (pkgJson?.astro?.external === true) {
+ return false;
+ }
+
+ return (
+ // Attempt: package relies on `astro`. ✅ Definitely an Astro package
+ pkgJson.peerDependencies?.astro ||
+ pkgJson.dependencies?.astro ||
+ // Attempt: package is tagged with `astro` or `astro-component`. ✅ Likely a community package
+ pkgJson.keywords?.includes('astro') ||
+ pkgJson.keywords?.includes('astro-component') ||
+ // Attempt: package is named `astro-something` or `@scope/astro-something`. ✅ Likely a community package
+ /^(?:@[^/]+\/)?astro-/.test(pkgJson.name)
+ );
+ },
+ isFrameworkPkgByName(pkgName) {
+ const isNotAstroPkg = isCommonNotAstro(pkgName);
+ if (isNotAstroPkg) {
+ return false;
+ } else {
+ return undefined;
+ }
+ },
+ });
+
+ const srcDirPattern = glob.convertPathToPattern(fileURLToPath(settings.config.srcDir));
+ const envLoader = createEnvLoader(mode, settings.config);
+
+ // Start with the Vite configuration that Astro core needs
+ const commonConfig: vite.InlineConfig = {
+ // Tell Vite not to combine config from vite.config.js with our provided inline config
+ configFile: false,
+ mode,
+ cacheDir: fileURLToPath(new URL('./node_modules/.vite/', settings.config.root)), // using local caches allows Astro to be used in monorepos, etc.
+ clearScreen: false, // we want to control the output, not Vite
+ customLogger: createViteLogger(logger, settings.config.vite.logLevel),
+ appType: 'custom',
+ optimizeDeps: {
+ // Scan for component code within `srcDir`
+ entries: [`${srcDirPattern}**/*.{jsx,tsx,vue,svelte,html,astro}`],
+ exclude: ['astro', 'node-fetch'],
+ },
+ plugins: [
+ astroVirtualManifestPlugin({ settings, logger, manifest }),
+ configAliasVitePlugin({ settings }),
+ astroLoadFallbackPlugin({ fs, root: settings.config.root }),
+ astroVitePlugin({ settings, logger }),
+ astroScriptsPlugin({ settings }),
+ // The server plugin is for dev only and having it run during the build causes
+ // the build to run very slow as the filewatcher is triggered often.
+ command === 'dev' && vitePluginAstroServer({ settings, logger, fs, routesList, manifest }), // manifest is only required in dev mode, where it gets created before a Vite instance is created, and get passed to this function
+ importMetaEnv({ envLoader }),
+ astroEnv({ settings, sync, envLoader }),
+ markdownVitePlugin({ settings, logger }),
+ htmlVitePlugin(),
+ astroPostprocessVitePlugin(),
+ astroIntegrationsContainerPlugin({ settings, logger }),
+ astroScriptsPageSSRPlugin({ settings }),
+ astroHeadPlugin(),
+ astroScannerPlugin({ settings, logger, routesList }),
+ astroContentVirtualModPlugin({ fs, settings }),
+ astroContentImportPlugin({ fs, settings, logger }),
+ astroContentAssetPropagationPlugin({ settings }),
+ vitePluginMiddleware({ settings }),
+ vitePluginSSRManifest(),
+ astroAssetsPlugin({ settings }),
+ astroPrefetch({ settings }),
+ astroTransitions({ settings }),
+ astroDevToolbar({ settings, logger }),
+ vitePluginFileURL(),
+ astroInternationalization({ settings }),
+ vitePluginActions({ fs, settings }),
+ vitePluginUserActions({ settings }),
+ vitePluginServerIslands({ settings, logger }),
+ astroContainer(),
+ astroHmrReloadPlugin(),
+ ],
+ publicDir: fileURLToPath(settings.config.publicDir),
+ root: fileURLToPath(settings.config.root),
+ envPrefix: settings.config.vite?.envPrefix ?? 'PUBLIC_',
+ define: {
+ 'import.meta.env.SITE': stringifyForDefine(settings.config.site),
+ 'import.meta.env.BASE_URL': stringifyForDefine(settings.config.base),
+ 'import.meta.env.ASSETS_PREFIX': stringifyForDefine(settings.config.build.assetsPrefix),
+ },
+ server: {
+ hmr:
+ process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'production'
+ ? false
+ : undefined, // disable HMR for test
+ watch: {
+ // Prevent watching during the build to speed it up
+ ignored: command === 'build' ? ['**'] : undefined,
+ },
+ },
+ resolve: {
+ alias: [
+ {
+ // This is needed for Deno compatibility, as the non-browser version
+ // of this module depends on Node `crypto`
+ find: 'randombytes',
+ replacement: 'randombytes/browser',
+ },
+ {
+ // Typings are imported from 'astro' (e.g. import { Type } from 'astro')
+ find: /^astro$/,
+ replacement: fileURLToPath(new URL('../types/public/index.js', import.meta.url)),
+ },
+ {
+ find: 'astro:middleware',
+ replacement: 'astro/virtual-modules/middleware.js',
+ },
+ {
+ find: 'astro:schema',
+ replacement: 'astro/zod',
+ },
+ {
+ find: 'astro:components',
+ replacement: 'astro/components',
+ },
+ ],
+ // Astro imports in third-party packages should use the same version as root
+ dedupe: ['astro'],
+ },
+ ssr: {
+ noExternal: [...ALWAYS_NOEXTERNAL, ...astroPkgsConfig.ssr.noExternal],
+ external: [...(command === 'dev' ? ONLY_DEV_EXTERNAL : []), ...astroPkgsConfig.ssr.external],
+ },
+ build: { assetsDir: settings.config.build.assets },
+ };
+
+ // If the user provides a custom assets prefix, make sure assets handled by Vite
+ // are prefixed with it too. This uses one of it's experimental features, but it
+ // has been stable for a long time now.
+ const assetsPrefix = settings.config.build.assetsPrefix;
+ if (assetsPrefix) {
+ commonConfig.experimental = {
+ renderBuiltUrl(filename, { type, hostType }) {
+ if (type === 'asset') {
+ return joinPaths(getAssetsPrefix(`.${hostType}`, assetsPrefix), filename);
+ }
+ },
+ };
+ }
+
+ // Merge configs: we merge vite configuration objects together in the following order,
+ // where future values will override previous values.
+ // 1. common vite config
+ // 2. user-provided vite config, via AstroConfig
+ // 3. integration-provided vite config, via the `config:setup` hook
+ // 4. command vite config, passed as the argument to this function
+ let result = commonConfig;
+ // PR #6238 Calls user integration `astro:config:setup` hooks when running `astro sync`.
+ // Without proper filtering, user integrations may run twice unexpectedly:
+ // - with `command` set to `build/dev` (src/core/build/index.ts L72)
+ // - and again in the `sync` module to generate `Content Collections` (src/core/sync/index.ts L36)
+ // We need to check if the command is `build` or `dev` before merging the user-provided vite config.
+ // We also need to filter out the plugins that are not meant to be applied to the current command:
+ // - If the command is `build`, we filter out the plugins that are meant to be applied for `serve`.
+ // - If the command is `dev`, we filter out the plugins that are meant to be applied for `build`.
+ if (command && settings.config.vite?.plugins) {
+ let { plugins, ...rest } = settings.config.vite;
+ const applyToFilter = command === 'build' ? 'serve' : 'build';
+ const applyArgs = [
+ { ...settings.config.vite, mode },
+ { command: command === 'dev' ? 'serve' : command, mode },
+ ];
+ // @ts-expect-error ignore TS2589: Type instantiation is excessively deep and possibly infinite.
+ plugins = plugins.flat(Infinity).filter((p) => {
+ if (!p || p?.apply === applyToFilter) {
+ return false;
+ }
+
+ if (typeof p.apply === 'function') {
+ return p.apply(applyArgs[0], applyArgs[1]);
+ }
+
+ return true;
+ });
+ result = vite.mergeConfig(result, { ...rest, plugins });
+ } else {
+ result = vite.mergeConfig(result, settings.config.vite || {});
+ }
+ result = vite.mergeConfig(result, commandConfig);
+
+ return result;
+}
+
+const COMMON_DEPENDENCIES_NOT_ASTRO = [
+ 'autoprefixer',
+ 'react',
+ 'react-dom',
+ 'preact',
+ 'preact-render-to-string',
+ 'vue',
+ 'svelte',
+ 'solid-js',
+ 'lit',
+ 'cookie',
+ 'dotenv',
+ 'esbuild',
+ 'eslint',
+ 'jest',
+ 'postcss',
+ 'prettier',
+ 'astro',
+ 'tslib',
+ 'typescript',
+ 'vite',
+];
+
+const COMMON_PREFIXES_NOT_ASTRO = [
+ '@webcomponents/',
+ '@fontsource/',
+ '@postcss-plugins/',
+ '@rollup/',
+ '@astrojs/renderer-',
+ '@types/',
+ '@typescript-eslint/',
+ 'eslint-',
+ 'jest-',
+ 'postcss-plugin-',
+ 'prettier-plugin-',
+ 'remark-',
+ 'rehype-',
+ 'rollup-plugin-',
+ 'vite-plugin-',
+];
+
+function isCommonNotAstro(dep: string): boolean {
+ return (
+ COMMON_DEPENDENCIES_NOT_ASTRO.includes(dep) ||
+ COMMON_PREFIXES_NOT_ASTRO.some(
+ (prefix) =>
+ prefix.startsWith('@')
+ ? dep.startsWith(prefix)
+ : dep.substring(dep.lastIndexOf('/') + 1).startsWith(prefix), // check prefix omitting @scope/
+ )
+ );
+}
+
+function stringifyForDefine(value: string | undefined | object): string {
+ return typeof value === 'string' || isObject(value) ? JSON.stringify(value) : 'undefined';
+}
diff --git a/packages/astro/src/core/dev/adapter-validation.ts b/packages/astro/src/core/dev/adapter-validation.ts
new file mode 100644
index 000000000..43471d248
--- /dev/null
+++ b/packages/astro/src/core/dev/adapter-validation.ts
@@ -0,0 +1,50 @@
+import { getAdapterStaticRecommendation } from '../../integrations/features-validation.js';
+import type { AstroSettings } from '../../types/astro.js';
+import type { AstroAdapter } from '../../types/public/integrations.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+
+let hasWarnedMissingAdapter = false;
+
+export function warnMissingAdapter(logger: Logger, settings: AstroSettings) {
+ if (hasWarnedMissingAdapter) return;
+ if (settings.buildOutput === 'server' && !settings.config.adapter) {
+ logger.warn(
+ 'config',
+ 'This project contains server-rendered routes, but no adapter is installed. This is fine for development, but an adapter will be required to build your site for production.',
+ );
+ hasWarnedMissingAdapter = true;
+ }
+}
+
+export function validateSetAdapter(
+ logger: Logger,
+ settings: AstroSettings,
+ adapter: AstroAdapter,
+ maybeConflictingIntegration: string,
+ command?: 'dev' | 'build' | string,
+) {
+ if (settings.adapter && settings.adapter.name !== adapter.name) {
+ throw new Error(
+ `Integration "${maybeConflictingIntegration}" conflicts with "${settings.adapter.name}". You can only configure one deployment integration.`,
+ );
+ }
+
+ if (settings.buildOutput === 'server' && adapter.adapterFeatures?.buildOutput === 'static') {
+ // If the adapter is not compatible with the build output, throw an error
+ if (command === 'build') {
+ const adapterRecommendation = getAdapterStaticRecommendation(adapter.name);
+
+ throw new AstroError({
+ ...AstroErrorData.AdapterSupportOutputMismatch,
+ message: AstroErrorData.AdapterSupportOutputMismatch.message(adapter.name),
+ hint: adapterRecommendation ? adapterRecommendation : undefined,
+ });
+ } else if (command === 'dev') {
+ logger.warn(
+ null,
+ `The adapter ${adapter.name} does not support emitting a server output, but the project contain server-rendered pages. Your project will not build correctly.`,
+ );
+ }
+ }
+}
diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts
new file mode 100644
index 000000000..d1570f492
--- /dev/null
+++ b/packages/astro/src/core/dev/container.ts
@@ -0,0 +1,170 @@
+import type * as http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import type { AstroSettings } from '../../types/astro.js';
+
+import nodeFs from 'node:fs';
+import * as vite from 'vite';
+import {
+ runHookConfigDone,
+ runHookConfigSetup,
+ runHookServerDone,
+ runHookServerStart,
+} from '../../integrations/hooks.js';
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import { createDevelopmentManifest } from '../../vite-plugin-astro-server/plugin.js';
+import { createVite } from '../create-vite.js';
+import type { Logger } from '../logger/core.js';
+import { apply as applyPolyfill } from '../polyfill.js';
+import { createRoutesList } from '../routing/index.js';
+import { syncInternal } from '../sync/index.js';
+import { warnMissingAdapter } from './adapter-validation.js';
+
+export interface Container {
+ fs: typeof nodeFs;
+ logger: Logger;
+ settings: AstroSettings;
+ viteServer: vite.ViteDevServer;
+ inlineConfig: AstroInlineConfig;
+ restartInFlight: boolean; // gross
+ handle: (req: http.IncomingMessage, res: http.ServerResponse) => void;
+ close: () => Promise<void>;
+}
+
+export interface CreateContainerParams {
+ logger: Logger;
+ settings: AstroSettings;
+ inlineConfig?: AstroInlineConfig;
+ isRestart?: boolean;
+ fs?: typeof nodeFs;
+}
+
+export async function createContainer({
+ isRestart = false,
+ logger,
+ inlineConfig,
+ settings,
+ fs = nodeFs,
+}: CreateContainerParams): Promise<Container> {
+ // Initialize
+ applyPolyfill();
+ settings = await runHookConfigSetup({
+ settings,
+ command: 'dev',
+ logger: logger,
+ isRestart,
+ });
+
+ const {
+ base,
+ server: { host, headers, open: serverOpen },
+ } = settings.config;
+
+ // serverOpen = true, isRestart = false
+ // when astro dev --open command is run the first time
+ // expected behavior: spawn a new tab
+ // ------------------------------------------------------
+ // serverOpen = true, isRestart = true
+ // when config file is saved
+ // expected behavior: do not spawn a new tab
+ // ------------------------------------------------------
+ // Non-config files don't reach this point
+ const isServerOpenURL = typeof serverOpen == 'string' && !isRestart;
+ const isServerOpenBoolean = serverOpen && !isRestart;
+
+ // Open server to the correct path. We pass the `base` here as we didn't pass the
+ // base to the initial Vite config
+ const open = isServerOpenURL ? serverOpen : isServerOpenBoolean ? base : false;
+
+ // The client entrypoint for renderers. Since these are imported dynamically
+ // we need to tell Vite to preoptimize them.
+ const rendererClientEntries = settings.renderers
+ .map((r) => r.clientEntrypoint)
+ .filter(Boolean) as string[];
+
+ // Create the route manifest already outside of Vite so that `runHookConfigDone` can use it to inform integrations of the build output
+ const routesList = await createRoutesList({ settings, fsMod: fs }, logger, { dev: true });
+ const manifest = createDevelopmentManifest(settings);
+
+ await runHookConfigDone({ settings, logger, command: 'dev' });
+
+ warnMissingAdapter(logger, settings);
+
+ const mode = inlineConfig?.mode ?? 'development';
+ const viteConfig = await createVite(
+ {
+ server: { host, headers, open },
+ optimizeDeps: {
+ include: rendererClientEntries,
+ },
+ },
+ {
+ settings,
+ logger,
+ mode,
+ command: 'dev',
+ fs,
+ sync: false,
+ routesList,
+ manifest,
+ },
+ );
+ const viteServer = await vite.createServer(viteConfig);
+
+ await syncInternal({
+ settings,
+ mode,
+ logger,
+ skip: {
+ content: !isRestart,
+ cleanup: true,
+ },
+ force: inlineConfig?.force,
+ routesList,
+ manifest,
+ command: 'dev',
+ watcher: viteServer.watcher,
+ });
+
+ const container: Container = {
+ inlineConfig: inlineConfig ?? {},
+ fs,
+ logger,
+ restartInFlight: false,
+ settings,
+ viteServer,
+ handle(req, res) {
+ viteServer.middlewares.handle(req, res, Function.prototype);
+ },
+ // TODO deprecate and remove
+ close() {
+ return closeContainer(container);
+ },
+ };
+
+ return container;
+}
+
+async function closeContainer({ viteServer, settings, logger }: Container) {
+ await viteServer.close();
+ await runHookServerDone({
+ config: settings.config,
+ logger,
+ });
+}
+
+export async function startContainer({
+ settings,
+ viteServer,
+ logger,
+}: Container): Promise<AddressInfo> {
+ const { port } = settings.config.server;
+ await viteServer.listen(port);
+ const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo;
+ await runHookServerStart({
+ config: settings.config,
+ address: devServerAddressInfo,
+ logger,
+ });
+
+ return devServerAddressInfo;
+}
diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts
new file mode 100644
index 000000000..606d292e6
--- /dev/null
+++ b/packages/astro/src/core/dev/dev.ts
@@ -0,0 +1,151 @@
+import fs from 'node:fs';
+import type http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { performance } from 'node:perf_hooks';
+import { green } from 'kleur/colors';
+import { gt, major, minor, patch } from 'semver';
+import type * as vite from 'vite';
+import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js';
+import { attachContentServerListeners } from '../../content/index.js';
+import { MutableDataStore } from '../../content/mutable-data-store.js';
+import { globalContentConfigObserver } from '../../content/utils.js';
+import { telemetry } from '../../events/index.js';
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import * as msg from '../messages.js';
+import { ensureProcessNodeEnv } from '../util.js';
+import { startContainer } from './container.js';
+import { createContainerWithAutomaticRestart } from './restart.js';
+import {
+ MAX_PATCH_DISTANCE,
+ fetchLatestAstroVersion,
+ shouldCheckForUpdates,
+} from './update-check.js';
+
+export interface DevServer {
+ address: AddressInfo;
+ handle: (req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) => void;
+ watcher: vite.FSWatcher;
+ stop(): Promise<void>;
+}
+
+/**
+ * Runs Astro’s development server. This is a local HTTP server that doesn’t bundle assets.
+ * It uses Hot Module Replacement (HMR) to update your browser as you save changes in your editor.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevServer> {
+ ensureProcessNodeEnv('development');
+ const devStart = performance.now();
+ await telemetry.record([]);
+
+ // Create a container which sets up the Vite server.
+ const restart = await createContainerWithAutomaticRestart({ inlineConfig, fs });
+ const logger = restart.container.logger;
+
+ const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
+ const isPrerelease = currentVersion.includes('-');
+
+ if (!isPrerelease) {
+ try {
+ // Don't await this, we don't want to block the dev server from starting
+ shouldCheckForUpdates(restart.container.settings.preferences)
+ .then(async (shouldCheck) => {
+ if (shouldCheck) {
+ const version = await fetchLatestAstroVersion(restart.container.settings.preferences);
+
+ if (gt(version, currentVersion)) {
+ // Only update the latestAstroVersion if the latest version is greater than the current version, that way we don't need to check that again
+ // whenever we check for the latest version elsewhere
+ restart.container.settings.latestAstroVersion = version;
+
+ const sameMajor = major(version) === major(currentVersion);
+ const sameMinor = minor(version) === minor(currentVersion);
+ const patchDistance = patch(version) - patch(currentVersion);
+
+ if (sameMajor && sameMinor && patchDistance < MAX_PATCH_DISTANCE) {
+ // Don't bother the user with a log if they're only a few patch versions behind
+ // We can still tell them in the dev toolbar, which has a more opt-in nature
+ return;
+ }
+
+ logger.warn(
+ 'SKIP_FORMAT',
+ await msg.newVersionAvailable({
+ latestVersion: version,
+ }),
+ );
+ }
+ }
+ })
+ .catch(() => {});
+ } catch {
+ // Just ignore the error, we don't want to block the dev server from starting and this is just a nice-to-have feature
+ }
+ }
+
+ let store: MutableDataStore | undefined;
+ try {
+ const dataStoreFile = getDataStoreFile(restart.container.settings, true);
+ store = await MutableDataStore.fromFile(dataStoreFile);
+ } catch (err: any) {
+ logger.error('content', err.message);
+ }
+
+ if (!store) {
+ logger.error('content', 'Failed to create data store');
+ }
+
+ await attachContentServerListeners(restart.container);
+
+ const config = globalContentConfigObserver.get();
+ if (config.status === 'error') {
+ logger.error('content', config.error.message);
+ }
+ if (config.status === 'loaded' && store) {
+ const contentLayer = globalContentLayer.init({
+ settings: restart.container.settings,
+ logger,
+ watcher: restart.container.viteServer.watcher,
+ store,
+ });
+ contentLayer.watchContentConfig();
+ await contentLayer.sync();
+ } else {
+ logger.warn('content', 'Content config not loaded');
+ }
+
+ // Start listening to the port
+ const devServerAddressInfo = await startContainer(restart.container);
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.serverStart({
+ startupTime: performance.now() - devStart,
+ resolvedUrls: restart.container.viteServer.resolvedUrls || { local: [], network: [] },
+ host: restart.container.settings.config.server.host,
+ base: restart.container.settings.config.base,
+ }),
+ );
+
+ if (isPrerelease) {
+ logger.warn('SKIP_FORMAT', msg.prerelease({ currentVersion }));
+ }
+ if (restart.container.viteServer.config.server?.fs?.strict === false) {
+ logger.warn('SKIP_FORMAT', msg.fsStrictWarning());
+ }
+
+ logger.info(null, green('watching for file changes...'));
+
+ return {
+ address: devServerAddressInfo,
+ get watcher() {
+ return restart.container.viteServer.watcher;
+ },
+ handle(req, res) {
+ return restart.container.handle(req, res);
+ },
+ async stop() {
+ await restart.container.close();
+ },
+ };
+}
diff --git a/packages/astro/src/core/dev/index.ts b/packages/astro/src/core/dev/index.ts
new file mode 100644
index 000000000..47de19bde
--- /dev/null
+++ b/packages/astro/src/core/dev/index.ts
@@ -0,0 +1,3 @@
+export { createContainer, startContainer } from './container.js';
+export { default } from './dev.js';
+export { createContainerWithAutomaticRestart } from './restart.js';
diff --git a/packages/astro/src/core/dev/restart.ts b/packages/astro/src/core/dev/restart.ts
new file mode 100644
index 000000000..4aa1e2b74
--- /dev/null
+++ b/packages/astro/src/core/dev/restart.ts
@@ -0,0 +1,210 @@
+import type nodeFs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import * as vite from 'vite';
+import { globalContentLayer } from '../../content/content-layer.js';
+import { attachContentServerListeners } from '../../content/server-listeners.js';
+import { eventCliSession, telemetry } from '../../events/index.js';
+import { SETTINGS_FILE } from '../../preferences/constants.js';
+import type { AstroSettings } from '../../types/astro.js';
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import { createNodeLogger, createSettings, resolveConfig } from '../config/index.js';
+import { collectErrorMetadata } from '../errors/dev/utils.js';
+import { isAstroConfigZodError } from '../errors/errors.js';
+import { createSafeError } from '../errors/index.js';
+import { formatErrorMessage } from '../messages.js';
+import type { Container } from './container.js';
+import { createContainer, startContainer } from './container.js';
+
+async function createRestartedContainer(
+ container: Container,
+ settings: AstroSettings,
+): Promise<Container> {
+ const { logger, fs, inlineConfig } = container;
+ const newContainer = await createContainer({
+ isRestart: true,
+ logger: logger,
+ settings,
+ inlineConfig,
+ fs,
+ });
+
+ await startContainer(newContainer);
+
+ return newContainer;
+}
+
+const configRE = /.*astro.config.(?:mjs|mts|cjs|cts|js|ts)$/;
+
+function shouldRestartContainer(
+ { settings, inlineConfig, restartInFlight }: Container,
+ changedFile: string,
+): boolean {
+ if (restartInFlight) return false;
+
+ let shouldRestart = false;
+ const normalizedChangedFile = vite.normalizePath(changedFile);
+
+ // If the config file changed, reload the config and restart the server.
+ if (inlineConfig.configFile) {
+ shouldRestart = vite.normalizePath(inlineConfig.configFile) === normalizedChangedFile;
+ }
+ // Otherwise, watch for any astro.config.* file changes in project root
+ else {
+ shouldRestart = configRE.test(normalizedChangedFile);
+ const settingsPath = vite.normalizePath(
+ fileURLToPath(new URL(SETTINGS_FILE, settings.dotAstroDir)),
+ );
+ if (settingsPath.endsWith(normalizedChangedFile)) {
+ shouldRestart = settings.preferences.ignoreNextPreferenceReload ? false : true;
+
+ settings.preferences.ignoreNextPreferenceReload = false;
+ }
+ }
+
+ if (!shouldRestart && settings.watchFiles.length > 0) {
+ // If the config file didn't change, check if any of the watched files changed.
+ shouldRestart = settings.watchFiles.some(
+ (path) => vite.normalizePath(path) === vite.normalizePath(changedFile),
+ );
+ }
+
+ return shouldRestart;
+}
+
+async function restartContainer(container: Container): Promise<Container | Error> {
+ const { logger, close, settings: existingSettings } = container;
+ container.restartInFlight = true;
+
+ try {
+ const { astroConfig } = await resolveConfig(container.inlineConfig, 'dev', container.fs);
+ const settings = await createSettings(astroConfig, fileURLToPath(existingSettings.config.root));
+ await close();
+ return await createRestartedContainer(container, settings);
+ } catch (_err) {
+ const error = createSafeError(_err);
+ // Print all error messages except ZodErrors from AstroConfig as the pre-logged error is sufficient
+ if (!isAstroConfigZodError(_err)) {
+ logger.error(
+ 'config',
+ formatErrorMessage(collectErrorMetadata(error), logger.level() === 'debug') + '\n',
+ );
+ }
+ // Inform connected clients of the config error
+ container.viteServer.hot.send({
+ type: 'error',
+ err: {
+ message: error.message,
+ stack: error.stack || '',
+ },
+ });
+ container.restartInFlight = false;
+ logger.error(null, 'Continuing with previous valid configuration\n');
+ return error;
+ }
+}
+
+export interface CreateContainerWithAutomaticRestart {
+ inlineConfig?: AstroInlineConfig;
+ fs: typeof nodeFs;
+}
+
+interface Restart {
+ container: Container;
+ restarted: () => Promise<Error | null>;
+}
+
+export async function createContainerWithAutomaticRestart({
+ inlineConfig,
+ fs,
+}: CreateContainerWithAutomaticRestart): Promise<Restart> {
+ const logger = createNodeLogger(inlineConfig ?? {});
+ const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev', fs);
+ telemetry.record(eventCliSession('dev', userConfig));
+
+ const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
+
+ const initialContainer = await createContainer({ settings, logger: logger, inlineConfig, fs });
+
+ let resolveRestart: (value: Error | null) => void;
+ let restartComplete = new Promise<Error | null>((resolve) => {
+ resolveRestart = resolve;
+ });
+
+ let restart: Restart = {
+ container: initialContainer,
+ restarted() {
+ return restartComplete;
+ },
+ };
+
+ async function handleServerRestart(logMsg = '', server?: vite.ViteDevServer) {
+ logger.info(null, (logMsg + ' Restarting...').trim());
+ const container = restart.container;
+ const result = await restartContainer(container);
+ if (result instanceof Error) {
+ // Failed to restart, use existing container
+ resolveRestart(result);
+ } else {
+ // Restart success. Add new watches because this is a new container with a new Vite server
+ restart.container = result;
+ setupContainer();
+ await attachContentServerListeners(restart.container);
+
+ if (server) {
+ // Vite expects the resolved URLs to be available
+ server.resolvedUrls = result.viteServer.resolvedUrls;
+ }
+
+ resolveRestart(null);
+ }
+ restartComplete = new Promise<Error | null>((resolve) => {
+ resolveRestart = resolve;
+ });
+ }
+
+ function handleChangeRestart(logMsg: string) {
+ return async function (changedFile: string) {
+ if (shouldRestartContainer(restart.container, changedFile)) {
+ handleServerRestart(logMsg);
+ }
+ };
+ }
+
+ // Set up watchers, vite restart API, and shortcuts
+ function setupContainer() {
+ const watcher = restart.container.viteServer.watcher;
+ watcher.on('change', handleChangeRestart('Configuration file updated.'));
+ watcher.on('unlink', handleChangeRestart('Configuration file removed.'));
+ watcher.on('add', handleChangeRestart('Configuration file added.'));
+
+ // Restart the Astro dev server instead of Vite's when the API is called by plugins.
+ // Ignore the `forceOptimize` parameter for now.
+ restart.container.viteServer.restart = async () => {
+ if (!restart.container.restartInFlight) {
+ await handleServerRestart('', restart.container.viteServer);
+ }
+ };
+
+ // Set up shortcuts
+
+ const customShortcuts: Array<vite.CLIShortcut> = [
+ // Disable default Vite shortcuts that don't work well with Astro
+ { key: 'r', description: '' },
+ { key: 'u', description: '' },
+ { key: 'c', description: '' },
+ ];
+
+ customShortcuts.push({
+ key: 's',
+ description: 'sync content layer',
+ action: () => {
+ globalContentLayer.get()?.sync();
+ },
+ });
+ restart.container.viteServer.bindCLIShortcuts({
+ customShortcuts,
+ });
+ }
+ setupContainer();
+ return restart;
+}
diff --git a/packages/astro/src/core/dev/update-check.ts b/packages/astro/src/core/dev/update-check.ts
new file mode 100644
index 000000000..ff5444686
--- /dev/null
+++ b/packages/astro/src/core/dev/update-check.ts
@@ -0,0 +1,49 @@
+import ci from 'ci-info';
+import { fetchPackageJson } from '../../cli/install-package.js';
+import type { AstroPreferences } from '../../preferences/index.js';
+
+export const MAX_PATCH_DISTANCE = 5; // If the patch distance is less than this, don't bother the user
+const CHECK_MS_INTERVAL = 1_036_800_000; // 12 days, give or take
+
+let _latestVersion: string | undefined = undefined;
+
+export async function fetchLatestAstroVersion(
+ preferences: AstroPreferences | undefined,
+): Promise<string> {
+ if (_latestVersion) {
+ return _latestVersion;
+ }
+
+ const packageJson = await fetchPackageJson(undefined, 'astro', 'latest');
+ if (packageJson instanceof Error) {
+ throw packageJson;
+ }
+
+ const version = packageJson?.version;
+
+ if (!version) {
+ throw new Error('Failed to fetch latest Astro version');
+ }
+
+ if (preferences) {
+ await preferences.set('_variables.lastUpdateCheck', Date.now(), { reloadServer: false });
+ }
+
+ _latestVersion = version;
+ return version;
+}
+
+export async function shouldCheckForUpdates(preferences: AstroPreferences): Promise<boolean> {
+ if (ci.isCI) {
+ return false;
+ }
+
+ const timeSinceLastCheck = Date.now() - (await preferences.get('_variables.lastUpdateCheck'));
+ const hasCheckUpdatesEnabled = await preferences.get('checkUpdates.enabled');
+
+ return (
+ timeSinceLastCheck > CHECK_MS_INTERVAL &&
+ process.env.ASTRO_DISABLE_UPDATE_CHECK !== 'true' &&
+ hasCheckUpdatesEnabled
+ );
+}
diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts
new file mode 100644
index 000000000..253e5f3c9
--- /dev/null
+++ b/packages/astro/src/core/encryption.ts
@@ -0,0 +1,119 @@
+import { decodeBase64, decodeHex, encodeBase64, encodeHexUpperCase } from '@oslojs/encoding';
+
+// Chose this algorithm for no particular reason, can change.
+// This algo does check against text manipulation though. See
+// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm
+const ALGORITHM = 'AES-GCM';
+
+/**
+ * Creates a CryptoKey object that can be used to encrypt any string.
+ */
+export async function createKey() {
+ const key = await crypto.subtle.generateKey(
+ {
+ name: ALGORITHM,
+ length: 256,
+ },
+ true,
+ ['encrypt', 'decrypt'],
+ );
+ return key;
+}
+
+// The environment variable name that can be used to provide the encrypted key.
+const ENVIRONMENT_KEY_NAME = 'ASTRO_KEY' as const;
+
+/**
+ * Get the encoded value of the ASTRO_KEY env var.
+ */
+export function getEncodedEnvironmentKey(): string {
+ return process.env[ENVIRONMENT_KEY_NAME] || '';
+}
+
+/**
+ * See if the environment variable key ASTRO_KEY is set.
+ */
+export function hasEnvironmentKey(): boolean {
+ return getEncodedEnvironmentKey() !== '';
+}
+
+/**
+ * Get the environment variable key and decode it into a CryptoKey.
+ */
+export async function getEnvironmentKey(): Promise<CryptoKey> {
+ // This should never happen, because we always check `hasEnvironmentKey` before this is called.
+ if (!hasEnvironmentKey()) {
+ throw new Error(
+ `There is no environment key defined. If you see this error there is a bug in Astro.`,
+ );
+ }
+ const encodedKey = getEncodedEnvironmentKey();
+ return decodeKey(encodedKey);
+}
+
+/**
+ * Takes a key that has been serialized to an array of bytes and returns a CryptoKey
+ */
+export async function importKey(bytes: Uint8Array): Promise<CryptoKey> {
+ const key = await crypto.subtle.importKey('raw', bytes, ALGORITHM, true, ['encrypt', 'decrypt']);
+ return key;
+}
+
+/**
+ * Encodes a CryptoKey to base64 string, so that it can be embedded in JSON / JavaScript
+ */
+export async function encodeKey(key: CryptoKey) {
+ const exported = await crypto.subtle.exportKey('raw', key);
+ const encodedKey = encodeBase64(new Uint8Array(exported));
+ return encodedKey;
+}
+
+/**
+ * Decodes a base64 string into bytes and then imports the key.
+ */
+export async function decodeKey(encoded: string): Promise<CryptoKey> {
+ const bytes = decodeBase64(encoded);
+ return crypto.subtle.importKey('raw', bytes, ALGORITHM, true, ['encrypt', 'decrypt']);
+}
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+// The length of the initialization vector
+// See https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams
+const IV_LENGTH = 24;
+
+/**
+ * Using a CryptoKey, encrypt a string into a base64 string.
+ */
+export async function encryptString(key: CryptoKey, raw: string) {
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH / 2));
+ const data = encoder.encode(raw);
+ const buffer = await crypto.subtle.encrypt(
+ {
+ name: ALGORITHM,
+ iv,
+ },
+ key,
+ data,
+ );
+ // iv is 12, hex brings it to 24
+ return encodeHexUpperCase(iv) + encodeBase64(new Uint8Array(buffer));
+}
+
+/**
+ * Takes a base64 encoded string, decodes it and returns the decrypted text.
+ */
+export async function decryptString(key: CryptoKey, encoded: string) {
+ const iv = decodeHex(encoded.slice(0, IV_LENGTH));
+ const dataArray = decodeBase64(encoded.slice(IV_LENGTH));
+ const decryptedBuffer = await crypto.subtle.decrypt(
+ {
+ name: ALGORITHM,
+ iv,
+ },
+ key,
+ dataArray,
+ );
+ const decryptedString = decoder.decode(decryptedBuffer);
+ return decryptedString;
+}
diff --git a/packages/astro/src/core/errors/README.md b/packages/astro/src/core/errors/README.md
new file mode 100644
index 000000000..65e7743a0
--- /dev/null
+++ b/packages/astro/src/core/errors/README.md
@@ -0,0 +1,119 @@
+# Errors
+
+> Interested in the technical details? See the comments in [errors-data.ts.](./errors-data.ts)
+
+## Writing error messages for Astro
+
+### Tips
+
+**Error Format**
+
+Name:
+
+- This property is a static reference to the error. The shape should be similar to JavaScript's native errors (TypeError, ReferenceError): pascal-cased, no spaces, no special characters etc. (ex: `ClientAddressNotAvailable`)
+- This is the only part of the error message that should not be written as a full, proper sentence complete with Capitalization and end punctuation.
+
+Title:
+
+- Use this property to briefly describe the error in a few words. This is the user's way to see at a glance what has happened and will be prominently displayed in the UI (ex: `{feature} is not available in static mode.`) Do not include further details such as why this error occurred or possible solutions.
+
+Message:
+
+- Begin with **what happened** and **why**. (ex: `Could not use {feature} because Server-side Rendering is not enabled.`)
+- Then, **describe the action the user should take**. (ex: `Update your Astro config with `output: 'server'` to enable Server-side Rendering.`)
+- Although this does not need to be as brief as the `title`, try to keep sentences short, clear and direct to give the reader all the necessary information quickly as possible. Users should be able to skim the message and understand the problem and solution.
+- If your message is too long, or the solution is not guaranteed to work, use the `hint` property to provide more information.
+
+Hint:
+
+- A `hint` can be used for any additional info that might help the user. (ex: a link to the documentation, or a common cause)
+
+**Writing Style**
+
+- Write in proper sentences. Include periods at the end of sentences. Avoid using exclamation marks! (Leave them to Houston!)
+- Technical jargon is mostly okay! But, most abbreviations should be avoided. If a developer is unfamiliar with a technical term, spelling it out in full allows them to look it up on the web more easily.
+- Describe the _what_, _why_ and _action to take_ from the user's perspective. Assume they don't know Astro internals, and care only about how Astro is _used_. (ex: `You are missing...` vs `Astro/file cannot find...`)
+- Avoid using cutesy language. (ex: Oops!) This tone minimizes the significance of the error, which _is_ important to the developer. The developer may be frustrated and your error message shouldn't be making jokes about their struggles. Only include words and phrases that help the developer **interpret the error** and **fix the problem**.
+
+If you are unsure about anything, ask [Erika](https://github.com/Princesseuh)!
+
+### CLI specifics tips:
+
+- If the error happened **during an action that changes the state of the project** (ex: editing configuration, creating files), the error should **reassure the user** about the state of their project (ex: "Failed to update configuration. Your project has been restored to its previous state.")
+- If an "error" happened because of a conscious user action (ex: pressing CTRL+C during a choice), it is okay to add more personality (ex: "Operation cancelled. See you later, astronaut!"). Do keep in mind the previous point however (ex: "Operation cancelled. No worries, your project folder has already been created")
+
+### Shape
+
+- **Names are permanent**, and should never be changed. Users should always be able to find an error by searching, and this ensures a matching result.
+- Contextual information may be used to enhance the message or the hint. However, the code that caused the error or the position of the error should not be included in the message as they will already be shown as part of the error.
+- Do not prefix `title`, `message` and `hint` with descriptive words such as "Error:" or "Hint:" as it may lead to duplicated labels in the UI / CLI.
+- Dynamic error messages **must** use the following shape:
+
+```js
+message: (arguments) => `text ${substitute}`;
+```
+
+Please avoid including too much logic inside the errors if you can. The last thing you want is for a bug to happen inside what's already an error!
+
+If the different arguments needs processing before being shown (ex: `toString`, `JSON.stringify`), the processing should happen where the error is thrown and not inside the message itself.
+
+Using light logic to add / remove different parts of the message is okay, however make sure to include a `@message` tag in the JSDoc comment for the auto-generated documentation. See below for more information.
+
+### Documentation support through JSDoc
+
+Using JSDoc comments, [a reference for every error message](https://docs.astro.build/en/reference/error-reference/) is built automatically on our docs.
+
+Here's how to create and format the comments:
+
+```js
+/**
+ * @docs <- Needed for the comment to be used for docs
+ * @message <- (Optional) Clearer error message to show in cases where the original one is too complex (ex: because of conditional messages)
+ * @see <- (Optional) List of additional references users can look at
+ * @description <- Description of the error
+ * @deprecated <- (Optional) If the error is no longer relevant, when it was removed and why (see "Removing errors" section below)
+ */
+```
+
+Example:
+
+```js
+/**
+ * @docs
+ * @message Route returned a `returnedValue`. Only a Response can be returned from Astro files.
+ * @see
+ * - [Response](https://docs.astro.build/en/guides/server-side-rendering/#response)
+ * @description
+ * Only instances of [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned inside Astro files.
+ */
+```
+
+The `@message` property is intended to provide slightly more context when it is helpful: a more descriptive error message or a collection of common messages if there are multiple possible error messages. Try to avoid making substantial changes to existing messages so that they are easy to find for users who copy and search the exact content of an error message.
+
+### Removing errors
+
+If the error cannot be triggered at all anymore, it can deprecated by adding a `@deprecated` tag to the JSDoc comment with a message that will be shown in the docs. This message is useful for users on previous versions who might still encounter the error so that they can know that upgrading to a newer version of Astro would perhaps solve their issue.
+
+```js
+/**
+ * @docs
+ * @deprecated Removed in Astro v9.8.6 as it is no longer relevant due to...
+ */
+```
+
+Alternatively, if no special deprecation message is needed, errors can be directly removed from the `errors-data.ts` file. A basic message will be shown in the docs stating that the error can no longer appear in the latest version of Astro.
+
+### Always remember
+
+Error are a reactive strategy. They are the last line of defense against a mistake.
+
+While adding a new error message, ask yourself, "Was there a way this situation could've been avoided in the first place?" (docs, editor tooling etc).
+
+**If you can prevent the error, you don't need an error message!**
+
+## Additional resources on writing good error messages
+
+- [Compiler errors for humans](https://elm-lang.org/news/compiler-errors-for-humans)
+- [When life gives you lemons, write better error messages](https://wix-ux.com/when-life-gives-you-lemons-write-better-error-messages-46c5223e1a2f)
+- [RustConf 2020 - Bending the Curve: A Personal Tutor at Your Fingertips by Esteban Kuber](https://www.youtube.com/watch?v=Z6X7Ada0ugE)
+- [What's in a good error](https://erika.florist/articles/gooderrors) (by the person who wrote this document!)
diff --git a/packages/astro/src/core/errors/dev/index.ts b/packages/astro/src/core/errors/dev/index.ts
new file mode 100644
index 000000000..12e07a9fa
--- /dev/null
+++ b/packages/astro/src/core/errors/dev/index.ts
@@ -0,0 +1,2 @@
+export { collectErrorMetadata } from './utils.js';
+export { enhanceViteSSRError, getViteErrorPayload } from './vite.js';
diff --git a/packages/astro/src/core/errors/dev/utils.ts b/packages/astro/src/core/errors/dev/utils.ts
new file mode 100644
index 000000000..d757bb33a
--- /dev/null
+++ b/packages/astro/src/core/errors/dev/utils.ts
@@ -0,0 +1,265 @@
+import * as fs from 'node:fs';
+import { isAbsolute, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { stripVTControlCharacters } from 'node:util';
+import { escape } from 'html-escaper';
+import { bold, underline } from 'kleur/colors';
+import type { ESBuildTransformResult } from 'vite';
+import type { SSRError } from '../../../types/public/internal.js';
+import { removeLeadingForwardSlashWindows } from '../../path.js';
+import { normalizePath } from '../../viteUtils.js';
+import { AggregateError, type ErrorWithMetadata } from '../errors.js';
+import { AstroErrorData } from '../index.js';
+import { codeFrame } from '../printer.js';
+import { normalizeLF } from '../utils.js';
+
+type EsbuildMessage = ESBuildTransformResult['warnings'][number];
+
+/**
+ * Takes any error-like object and returns a standardized Error + metadata object.
+ * Useful for consistent reporting regardless of where the error surfaced from.
+ */
+export function collectErrorMetadata(e: any, rootFolder?: URL): ErrorWithMetadata {
+ const err =
+ AggregateError.is(e) || Array.isArray(e.errors) ? (e.errors as SSRError[]) : [e as SSRError];
+
+ err.forEach((error) => {
+ if (e.stack) {
+ const stackInfo = collectInfoFromStacktrace(e);
+ try {
+ error.stack = stripVTControlCharacters(stackInfo.stack);
+ } catch {}
+ error.loc = stackInfo.loc;
+ error.plugin = stackInfo.plugin;
+ error.pluginCode = stackInfo.pluginCode;
+ }
+
+ // Make sure the file location is absolute, otherwise:
+ // - It won't be clickable in the terminal
+ // - We'll fail to show the file's content in the browser
+ // - We'll fail to show the code frame in the terminal
+ // - The "Open in Editor" button won't work
+
+ // Normalize the paths so that we can correctly detect if it's absolute on any platform
+ const normalizedFile = normalizePath(error.loc?.file || '');
+ const normalizedRootFolder = removeLeadingForwardSlashWindows(rootFolder?.pathname || '');
+
+ if (
+ error.loc?.file &&
+ rootFolder &&
+ (!normalizedFile?.startsWith(normalizedRootFolder) || !isAbsolute(normalizedFile))
+ ) {
+ error.loc.file = join(fileURLToPath(rootFolder), error.loc.file);
+ }
+
+ // If we don't have a frame, but we have a location let's try making up a frame for it
+ if (error.loc && (!error.frame || !error.fullCode)) {
+ try {
+ const fileContents = fs.readFileSync(error.loc.file!, 'utf8');
+
+ if (!error.frame) {
+ const frame = codeFrame(fileContents, error.loc);
+ error.frame = stripVTControlCharacters(frame);
+ }
+
+ if (!error.fullCode) {
+ error.fullCode = fileContents;
+ }
+ } catch {}
+ }
+
+ // Generic error (probably from Vite, and already formatted)
+ error.hint = generateHint(e);
+
+ // Strip ANSI for `message` property. Note that ESBuild errors may not have the property,
+ // but it will be handled and added below, which is already ANSI-free
+ if (error.message) {
+ try {
+ error.message = stripVTControlCharacters(error.message);
+ } catch {
+ // Setting `error.message` can fail here if the message is read-only, which for the vast majority of cases will never happen, however some somewhat obscure cases can cause this to happen.
+ }
+ }
+ });
+
+ // If we received an array of errors and it's not from us, it's most likely from ESBuild, try to extract info for Vite to display
+ // NOTE: We still need to be defensive here, because it might not necessarily be from ESBuild, it's just fairly likely.
+ if (!AggregateError.is(e) && Array.isArray(e.errors)) {
+ (e.errors as EsbuildMessage[]).forEach((buildError, i) => {
+ const { location, pluginName, text } = buildError;
+
+ // ESBuild can give us a slightly better error message than the one in the error, so let's use it
+ if (text) {
+ try {
+ err[i].message = text;
+ } catch {}
+ }
+
+ if (location) {
+ err[i].loc = { file: location.file, line: location.line, column: location.column };
+ err[i].id = err[0].id || location?.file;
+ }
+
+ // Vite adds the error message to the frame for ESBuild errors, we don't want that
+ if (err[i].frame) {
+ const errorLines = err[i].frame?.trim().split('\n');
+
+ if (errorLines) {
+ err[i].frame = !/^\d/.test(errorLines[0])
+ ? errorLines?.slice(1).join('\n')
+ : err[i].frame;
+ }
+ }
+
+ const possibleFilePath = location?.file ?? err[i].id;
+ if (possibleFilePath && err[i].loc && (!err[i].frame || !err[i].fullCode)) {
+ try {
+ const fileContents = fs.readFileSync(possibleFilePath, 'utf8');
+ if (!err[i].frame) {
+ err[i].frame = codeFrame(fileContents, { ...err[i].loc, file: possibleFilePath });
+ }
+
+ err[i].fullCode = fileContents;
+ } catch {
+ err[i].fullCode = err[i].pluginCode;
+ }
+ }
+
+ if (pluginName) {
+ err[i].plugin = pluginName;
+ }
+
+ err[i].hint = generateHint(err[0]);
+ });
+ }
+
+ // TODO: Handle returning multiple errors
+ return err[0];
+}
+
+function generateHint(err: ErrorWithMetadata): string | undefined {
+ const commonBrowserAPIs = ['document', 'window'];
+
+ if (/Unknown file extension "\.(?:jsx|vue|svelte|astro|css)" for /.test(err.message)) {
+ return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.';
+ } else if (commonBrowserAPIs.some((api) => err.toString().includes(api))) {
+ const hint = `Browser APIs are not available on the server.
+
+${
+ err.loc?.file?.endsWith('.astro')
+ ? 'Move your code to a <script> tag outside of the frontmatter, so the code runs on the client.'
+ : 'If the code is in a framework component, try to access these objects after rendering using lifecycle methods or use a `client:only` directive to make the component exclusively run on the client.'
+}
+
+See https://docs.astro.build/en/guides/troubleshooting/#document-or-window-is-not-defined for more information.
+ `;
+ return hint;
+ }
+ return err.hint;
+}
+
+type StackInfo = Pick<SSRError, 'stack' | 'loc' | 'plugin' | 'pluginCode'>;
+
+function collectInfoFromStacktrace(error: SSRError & { stack: string }): StackInfo {
+ let stackInfo: StackInfo = {
+ stack: error.stack,
+ plugin: error.plugin,
+ pluginCode: error.pluginCode,
+ loc: error.loc,
+ };
+
+ // normalize error stack line-endings to \n
+ stackInfo.stack = normalizeLF(error.stack);
+ const stackText = stripVTControlCharacters(error.stack);
+
+ // Try to find possible location from stack if we don't have one
+ if (!stackInfo.loc || (!stackInfo.loc.column && !stackInfo.loc.line)) {
+ const possibleFilePath =
+ error.loc?.file ||
+ error.pluginCode ||
+ error.id ||
+ // TODO: this could be better, `src` might be something else
+ stackText
+ .split('\n')
+ .find((ln) => ln.includes('src') || ln.includes('node_modules'));
+ // Disable eslint as we're not sure how to improve this regex yet
+ // eslint-disable-next-line regexp/no-super-linear-backtracking
+ const source = possibleFilePath?.replace?.(/^[^(]+\(([^)]+).*$/, '$1').replace(/^\s+at\s+/, '');
+
+ let file = source?.replace(/:\d+/g, '');
+ const location = /:(\d+):(\d+)/.exec(source!) ?? [];
+ const line = location[1];
+ const column = location[2];
+
+ if (file && line && column) {
+ try {
+ file = fileURLToPath(file);
+ } catch {}
+
+ stackInfo.loc = {
+ file,
+ line: Number.parseInt(line),
+ column: Number.parseInt(column),
+ };
+ }
+ }
+
+ // Derive plugin from stack (if possible)
+ if (!stackInfo.plugin) {
+ stackInfo.plugin =
+ /withastro\/astro\/packages\/integrations\/([\w-]+)/i.exec(stackText)?.at(1) ||
+ /(@astrojs\/[\w-]+)\/(server|client|index)/i.exec(stackText)?.at(1) ||
+ undefined;
+ }
+
+ // Normalize stack (remove `/@fs/` urls, etc)
+ stackInfo.stack = cleanErrorStack(error.stack);
+
+ return stackInfo;
+}
+
+function cleanErrorStack(stack: string) {
+ return stack
+ .split(/\n/)
+ .map((l) => l.replace(/\/@fs\//g, '/'))
+ .join('\n');
+}
+
+export function getDocsForError(err: ErrorWithMetadata): string | undefined {
+ if (err.name !== 'UnknownError' && err.name in AstroErrorData) {
+ return `https://docs.astro.build/en/reference/errors/${getKebabErrorName(err.name)}/`;
+ }
+
+ return undefined;
+
+ /**
+ * The docs has kebab-case urls for errors, so we need to convert the error name
+ * @param errorName
+ */
+ function getKebabErrorName(errorName: string): string {
+ return errorName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+ }
+}
+
+const linkRegex = /\[([^[]+)\]\((.*)\)/g;
+const boldRegex = /\*\*(.+)\*\*/g;
+const urlRegex = / ((?:https?|ftp):\/\/[-\w+&@#\\/%?=~|!:,.;]*[-\w+&@#\\/%=~|])/gi;
+const codeRegex = /`([^`]+)`/g;
+
+/**
+ * Render a subset of Markdown to HTML or a CLI output
+ */
+export function renderErrorMarkdown(markdown: string, target: 'html' | 'cli') {
+ if (target === 'html') {
+ return escape(markdown)
+ .replace(linkRegex, `<a href="$2" target="_blank">$1</a>`)
+ .replace(boldRegex, '<b>$1</b>')
+ .replace(urlRegex, ' <a href="$1" target="_blank">$1</a>')
+ .replace(codeRegex, '<code>$1</code>');
+ } else {
+ return markdown
+ .replace(linkRegex, (_, m1, m2) => `${bold(m1)} ${underline(m2)}`)
+ .replace(urlRegex, (fullMatch) => ` ${underline(fullMatch.trim())}`)
+ .replace(boldRegex, (_, m1) => `${bold(m1)}`);
+ }
+}
diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts
new file mode 100644
index 000000000..44b41e379
--- /dev/null
+++ b/packages/astro/src/core/errors/dev/vite.ts
@@ -0,0 +1,213 @@
+import * as fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { codeToHtml, createCssVariablesTheme } from 'shiki';
+import type { ShikiTransformer } from 'shiki';
+import type { ErrorPayload } from 'vite';
+import type { SSRLoadedRenderer } from '../../../types/public/internal.js';
+import type { ModuleLoader } from '../../module-loader/index.js';
+import { FailedToLoadModuleSSR, InvalidGlob, MdxIntegrationMissingError } from '../errors-data.js';
+import { AstroError, type ErrorWithMetadata } from '../errors.js';
+import { createSafeError } from '../utils.js';
+import { getDocsForError, renderErrorMarkdown } from './utils.js';
+
+export function enhanceViteSSRError({
+ error,
+ filePath,
+ loader,
+ renderers,
+}: {
+ error: unknown;
+ filePath?: URL;
+ loader?: ModuleLoader;
+ renderers?: SSRLoadedRenderer[];
+}): Error {
+ // NOTE: We don't know where the error that's coming here comes from, so we need to be defensive regarding what we do
+ // to it to make sure we keep as much information as possible. It's very possible that we receive an error that does not
+ // follow any kind of standard formats (ex: a number, a string etc)
+ let safeError = createSafeError(error) as ErrorWithMetadata;
+
+ // Vite will give you better stacktraces, using sourcemaps.
+ if (loader) {
+ try {
+ loader.fixStacktrace(safeError as Error);
+ } catch {}
+ }
+
+ if (filePath) {
+ const path = fileURLToPath(filePath);
+ const content = fs.readFileSync(path).toString();
+ const lns = content.split('\n');
+
+ // Vite has a fairly generic error message when it fails to load a module, let's try to enhance it a bit
+ // https://github.com/vitejs/vite/blob/ee7c28a46a6563d54b828af42570c55f16b15d2c/packages/vite/src/node/ssr/ssrModuleLoader.ts#L91
+ let importName: string | undefined;
+ if ((importName = /Failed to load url (.*?) \(resolved id:/.exec(safeError.message)?.[1])) {
+ safeError.title = FailedToLoadModuleSSR.title;
+ safeError.name = 'FailedToLoadModuleSSR';
+ safeError.message = FailedToLoadModuleSSR.message(importName);
+ safeError.hint = FailedToLoadModuleSSR.hint;
+ const line = lns.findIndex((ln) => ln.includes(importName!));
+
+ if (line !== -1) {
+ const column = lns[line]?.indexOf(importName);
+
+ safeError.loc = {
+ file: path,
+ line: line + 1,
+ column,
+ };
+ }
+ }
+
+ const fileId = safeError.id ?? safeError.loc?.file;
+
+ // Vite throws a syntax error trying to parse MDX without a plugin.
+ // Suggest installing the MDX integration if none is found.
+ if (
+ fileId &&
+ !renderers?.find((r) => r.name === '@astrojs/mdx') &&
+ safeError.message.includes('Syntax error') &&
+ /.mdx$/.test(fileId)
+ ) {
+ safeError = new AstroError({
+ ...MdxIntegrationMissingError,
+ message: MdxIntegrationMissingError.message(JSON.stringify(fileId)),
+ location: safeError.loc,
+ stack: safeError.stack,
+ }) as ErrorWithMetadata;
+ }
+
+ // Since Astro.glob is a wrapper around Vite's import.meta.glob, errors don't show accurate information, let's fix that
+ if (safeError.message.includes('Invalid glob')) {
+ const globPattern = /glob: "(.+)" \(/.exec(safeError.message)?.[1];
+
+ if (globPattern) {
+ safeError.message = InvalidGlob.message(globPattern);
+ safeError.name = 'InvalidGlob';
+ safeError.title = InvalidGlob.title;
+
+ const line = lns.findIndex((ln) => ln.includes(globPattern));
+
+ if (line !== -1) {
+ const column = lns[line]?.indexOf(globPattern);
+
+ safeError.loc = {
+ file: path,
+ line: line + 1,
+ column,
+ };
+ }
+ }
+ }
+ }
+
+ return safeError;
+}
+
+export interface AstroErrorPayload {
+ __isEnhancedAstroErrorPayload: true;
+ type: ErrorPayload['type'];
+ err: Omit<ErrorPayload['err'], 'loc'> & {
+ name?: string;
+ title?: string;
+ hint?: string;
+ docslink?: string;
+ highlightedCode?: string;
+ loc?: {
+ file?: string;
+ line?: number;
+ column?: number;
+ };
+ cause?: unknown;
+ };
+}
+
+// Shiki does not support `mjs` or `cjs` aliases by default.
+// Map these to `.js` during error highlighting.
+const ALTERNATIVE_JS_EXTS = ['cjs', 'mjs'];
+const ALTERNATIVE_MD_EXTS = ['mdoc'];
+
+let _cssVariablesTheme: ReturnType<typeof createCssVariablesTheme>;
+const cssVariablesTheme = () =>
+ _cssVariablesTheme ??
+ (_cssVariablesTheme = createCssVariablesTheme({ variablePrefix: '--astro-code-' }));
+
+/**
+ * Generate a payload for Vite's error overlay
+ */
+export async function getViteErrorPayload(err: ErrorWithMetadata): Promise<AstroErrorPayload> {
+ let plugin = err.plugin;
+ if (!plugin && err.hint) {
+ plugin = 'astro';
+ }
+
+ const message = renderErrorMarkdown(err.message.trim(), 'html');
+ const hint = err.hint ? renderErrorMarkdown(err.hint.trim(), 'html') : undefined;
+
+ const docslink = getDocsForError(err);
+
+ let highlighterLang = err.loc?.file?.split('.').pop();
+ if (ALTERNATIVE_JS_EXTS.includes(highlighterLang ?? '')) {
+ highlighterLang = 'js';
+ } else if (ALTERNATIVE_MD_EXTS.includes(highlighterLang ?? '')) {
+ highlighterLang = 'md';
+ }
+ const highlightedCode = err.fullCode
+ ? await codeToHtml(err.fullCode, {
+ lang: highlighterLang ?? 'text',
+ theme: cssVariablesTheme(),
+ transformers: [
+ transformerCompactLineOptions(
+ err.loc?.line ? [{ line: err.loc.line, classes: ['error-line'] }] : undefined,
+ ),
+ ],
+ })
+ : undefined;
+
+ return {
+ __isEnhancedAstroErrorPayload: true,
+ type: 'error',
+ err: {
+ ...err,
+ name: err.name,
+ type: err.type,
+ message,
+ hint,
+ frame: err.frame,
+ highlightedCode,
+ docslink,
+ loc: {
+ file: err.loc?.file,
+ line: err.loc?.line,
+ column: err.loc?.column,
+ },
+ plugin,
+ stack: err.stack,
+ cause: err.cause,
+ },
+ };
+}
+
+/**
+ * Transformer for `shiki`'s legacy `lineOptions`, allows to add classes to specific lines
+ * FROM: https://github.com/shikijs/shiki/blob/4a58472070a9a359a4deafec23bb576a73e24c6a/packages/transformers/src/transformers/compact-line-options.ts
+ * LICENSE: https://github.com/shikijs/shiki/blob/4a58472070a9a359a4deafec23bb576a73e24c6a/LICENSE
+ */
+function transformerCompactLineOptions(
+ lineOptions: {
+ /**
+ * 1-based line number.
+ */
+ line: number;
+ classes?: string[];
+ }[] = [],
+): ShikiTransformer {
+ return {
+ name: '@shikijs/transformers:compact-line-options',
+ line(node, line) {
+ const lineOption = lineOptions.find((o) => o.line === line);
+ if (lineOption?.classes) this.addClassToHast(node, lineOption.classes);
+ return node;
+ },
+ };
+}
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
new file mode 100644
index 000000000..0a7981634
--- /dev/null
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -0,0 +1,1852 @@
+// BEFORE ADDING AN ERROR: Please look at the README.md in this folder for general guidelines on writing error messages
+// Additionally, this code, much like `types/public/config.ts`, is used to generate documentation, so make sure to pass
+// your changes by our wonderful docs team before merging!
+
+import type { ZodError } from 'zod';
+
+export interface ErrorData {
+ name: string;
+ title: string;
+ message?: string | ((...params: any) => string);
+ hint?: string | ((...params: any) => string);
+}
+
+/**
+ * @docs
+ * @kind heading
+ * @name Astro Errors
+ */
+// Astro Errors, most errors will go here!
+
+/**
+ * @docs
+ * @description
+ * Cannot use the module `astro:config` without enabling the experimental feature.
+ */
+export const CantUseAstroConfigModuleError = {
+ name: 'CantUseAstroConfigModuleError',
+ title: 'Cannot use the `astro:config` module without enabling the experimental feature.',
+ message: (moduleName) =>
+ `Cannot import the module "${moduleName}" because the experimental feature is disabled. Enable \`experimental.serializeManifest\` in your \`astro.config.mjs\` `,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * Unknown compiler error.
+ * @see
+ * - [withastro/compiler issues list](https://astro.build/issues/compiler)
+ * @description
+ * Astro encountered an unknown error while compiling your files. In most cases, this is not your fault, but an issue in our compiler.
+ *
+ * If there isn't one already, please [create an issue](https://astro.build/issues/compiler).
+ */
+export const UnknownCompilerError = {
+ name: 'UnknownCompilerError',
+ title: 'Unknown compiler error.',
+ hint: 'This is almost always a problem with the Astro compiler, not your code. Please open an issue at https://astro.build/issues/compiler.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Official integrations](https://docs.astro.build/en/guides/integrations-guide/#official-integrations)
+ * - [Astro.clientAddress](https://docs.astro.build/en/reference/api-reference/#clientaddress)
+ * @description
+ * The adapter you're using unfortunately does not support `Astro.clientAddress`.
+ */
+export const ClientAddressNotAvailable = {
+ name: 'ClientAddressNotAvailable',
+ title: '`Astro.clientAddress` is not available in current adapter.',
+ message: (adapterName: string) =>
+ `\`Astro.clientAddress\` is not available in the \`${adapterName}\` adapter. File an issue with the adapter to add support.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [On-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * - [Astro.clientAddress](https://docs.astro.build/en/reference/api-reference/#clientaddress)
+ * @description
+ * The `Astro.clientAddress` property cannot be used inside prerendered routes.
+ */
+export const PrerenderClientAddressNotAvailable = {
+ name: 'PrerenderClientAddressNotAvailable',
+ title: '`Astro.clientAddress` cannot be used inside prerendered routes.',
+ message: `\`Astro.clientAddress\` cannot be used inside prerendered routes`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Enabling SSR in Your Project](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * - [Astro.clientAddress](https://docs.astro.build/en/reference/api-reference/#clientaddress)
+ * @description
+ * The `Astro.clientAddress` property is only available when [Server-side rendering](https://docs.astro.build/en/guides/on-demand-rendering/) is enabled.
+ *
+ * To get the user's IP address in static mode, different APIs such as [Ipify](https://www.ipify.org/) can be used in a [Client-side script](https://docs.astro.build/en/guides/client-side-scripts/) or it may be possible to get the user's IP using a serverless function hosted on your hosting provider.
+ */
+export const StaticClientAddressNotAvailable = {
+ name: 'StaticClientAddressNotAvailable',
+ title: '`Astro.clientAddress` is not available in prerendered pages.',
+ message: '`Astro.clientAddress` is only available on pages that are server-rendered.',
+ hint: 'See https://docs.astro.build/en/guides/on-demand-rendering/ for more information on how to enable SSR.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [getStaticPaths()](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * @description
+ * A [dynamic route](https://docs.astro.build/en/guides/routing/#dynamic-routes) was matched, but no corresponding path was found for the requested parameters. This is often caused by a typo in either the generated or the requested path.
+ */
+export const NoMatchingStaticPathFound = {
+ name: 'NoMatchingStaticPathFound',
+ title: 'No static path found for requested path.',
+ message: (pathName: string) =>
+ `A \`getStaticPaths()\` route pattern was matched, but no matching static path was found for requested path \`${pathName}\`.`,
+ hint: (possibleRoutes: string[]) =>
+ `Possible dynamic routes being matched: ${possibleRoutes.join(', ')}.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message Route returned a `RETURNED_VALUE`. Only a Response can be returned from Astro files.
+ * @see
+ * - [Response](https://docs.astro.build/en/guides/on-demand-rendering/#response)
+ * @description
+ * Only instances of [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned inside Astro files.
+ * ```astro title="pages/login.astro"
+ * ---
+ * return new Response(null, {
+ * status: 404,
+ * statusText: 'Not found'
+ * });
+ *
+ * // Alternatively, for redirects, Astro.redirect also returns an instance of Response
+ * return Astro.redirect('/login');
+ * ---
+ * ```
+ *
+ */
+export const OnlyResponseCanBeReturned = {
+ name: 'OnlyResponseCanBeReturned',
+ title: 'Invalid type returned by Astro page.',
+ message: (route: string | undefined, returnedValue: string) =>
+ `Route \`${
+ route ? route : ''
+ }\` returned a \`${returnedValue}\`. Only a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned from Astro files.`,
+ hint: 'See https://docs.astro.build/en/guides/on-demand-rendering/#response for more information.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`client:media`](https://docs.astro.build/en/reference/directives-reference/#clientmedia)
+ * @description
+ * A [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) parameter is required when using the `client:media` directive.
+ *
+ * ```astro
+ * <Counter client:media="(max-width: 640px)" />
+ * ```
+ */
+export const MissingMediaQueryDirective = {
+ name: 'MissingMediaQueryDirective',
+ title: 'Missing value for `client:media` directive.',
+ message:
+ 'Media query not provided for `client:media` directive. A media query similar to `client:media="(max-width: 600px)"` must be provided',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message Unable to render `COMPONENT_NAME`. There are `RENDERER_COUNT` renderer(s) configured in your `astro.config.mjs` file, but none were able to server-side render `COMPONENT_NAME`.
+ * @see
+ * - [Frameworks components](https://docs.astro.build/en/guides/framework-components/)
+ * - [UI Frameworks](https://docs.astro.build/en/guides/integrations-guide/#official-integrations)
+ * @description
+ * None of the installed integrations were able to render the component you imported. Make sure to install the appropriate integration for the type of component you are trying to include in your page.
+ *
+ * For JSX / TSX files, [@astrojs/react](https://docs.astro.build/en/guides/integrations-guide/react/), [@astrojs/preact](https://docs.astro.build/en/guides/integrations-guide/preact/) or [@astrojs/solid-js](https://docs.astro.build/en/guides/integrations-guide/solid-js/) can be used. For Vue and Svelte files, the [@astrojs/vue](https://docs.astro.build/en/guides/integrations-guide/vue/) and [@astrojs/svelte](https://docs.astro.build/en/guides/integrations-guide/svelte/) integrations can be used respectively
+ */
+export const NoMatchingRenderer = {
+ name: 'NoMatchingRenderer',
+ title: 'No matching renderer found.',
+ message: (
+ componentName: string,
+ componentExtension: string | undefined,
+ plural: boolean,
+ validRenderersCount: number,
+ ) =>
+ `Unable to render \`${componentName}\`.
+
+${
+ validRenderersCount > 0
+ ? `There ${plural ? 'are' : 'is'} ${validRenderersCount} renderer${plural ? 's' : ''} configured in your \`astro.config.mjs\` file,
+but ${plural ? 'none were' : 'it was not'} able to server-side render \`${componentName}\`.`
+ : `No valid renderer was found ${
+ componentExtension
+ ? `for the \`.${componentExtension}\` file extension.`
+ : `for this file extension.`
+ }`
+}`,
+ hint: (probableRenderers: string) =>
+ `Did you mean to enable the ${probableRenderers} integration?\n\nSee https://docs.astro.build/en/guides/framework-components/ for more information on how to install and configure integrations.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [addRenderer option](https://docs.astro.build/en/reference/integrations-reference/#addrenderer-option)
+ * - [Hydrating framework components](https://docs.astro.build/en/guides/framework-components/#hydrating-interactive-components)
+ * @description
+ * Astro tried to hydrate a component on the client, but the renderer used does not provide a client entrypoint to use to hydrate.
+ *
+ */
+export const NoClientEntrypoint = {
+ name: 'NoClientEntrypoint',
+ title: 'No client entrypoint specified in renderer.',
+ message: (componentName: string, clientDirective: string, rendererName: string) =>
+ `\`${componentName}\` component has a \`client:${clientDirective}\` directive, but no client entrypoint was provided by \`${rendererName}\`.`,
+ hint: 'See https://docs.astro.build/en/reference/integrations-reference/#addrenderer-option for more information on how to configure your renderer.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`client:only`](https://docs.astro.build/en/reference/directives-reference/#clientonly)
+ * @description
+ *
+ * `client:only` components are not run on the server, as such Astro does not know (and cannot guess) which renderer to use and require a hint. Like such:
+ *
+ * ```astro
+ * <SomeReactComponent client:only="react" />
+ * ```
+ */
+export const NoClientOnlyHint = {
+ name: 'NoClientOnlyHint',
+ title: 'Missing hint on client:only directive.',
+ message: (componentName: string) =>
+ `Unable to render \`${componentName}\`. When using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.`,
+ hint: (probableRenderers: string) =>
+ `Did you mean to pass \`client:only="${probableRenderers}"\`? See https://docs.astro.build/en/reference/directives-reference/#clientonly for more information on client:only`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [`params`](https://docs.astro.build/en/reference/api-reference/#params)
+ * @description
+ * The `params` property in `getStaticPaths`'s return value (an array of objects) should also be an object.
+ *
+ * ```astro title="pages/blog/[id].astro"
+ * ---
+ * export async function getStaticPaths() {
+ * return [
+ * { params: { slug: "blog" } },
+ * { params: { slug: "about" } }
+ * ];
+ *}
+ *---
+ * ```
+ */
+export const InvalidGetStaticPathParam = {
+ name: 'InvalidGetStaticPathParam',
+ title: 'Invalid value returned by a `getStaticPaths` path.',
+ message: (paramType) =>
+ `Invalid params given to \`getStaticPaths\` path. Expected an \`object\`, got \`${paramType}\``,
+ hint: 'See https://docs.astro.build/en/reference/routing-reference/#getstaticpaths for more information on getStaticPaths.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * @description
+ * `getStaticPaths`'s return value must be an array of objects. In most cases, this error happens because an array of array was returned. Using [`.flatMap()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap) or a [`.flat()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat) call may be useful.
+ *
+ * ```ts title="pages/blog/[id].astro"
+ * export async function getStaticPaths() {
+ * return [ // <-- Array
+ * { params: { slug: "blog" } }, // <-- Object
+ * { params: { slug: "about" } }
+ * ];
+ *}
+ * ```
+ */
+export const InvalidGetStaticPathsEntry = {
+ name: 'InvalidGetStaticPathsEntry',
+ title: "Invalid entry inside getStaticPath's return value",
+ message: (entryType) =>
+ `Invalid entry returned by getStaticPaths. Expected an object, got \`${entryType}\``,
+ hint: "If you're using a `.map` call, you might be looking for `.flatMap()` instead. See https://docs.astro.build/en/reference/routing-reference/#getstaticpaths for more information on getStaticPaths.",
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [`params`](https://docs.astro.build/en/reference/api-reference/#params)
+ * @description
+ * `getStaticPaths`'s return value must be an array of objects.
+ *
+ * ```ts title="pages/blog/[id].astro"
+ * export async function getStaticPaths() {
+ * return [ // <-- Array
+ * { params: { slug: "blog" } },
+ * { params: { slug: "about" } }
+ * ];
+ *}
+ * ```
+ */
+export const InvalidGetStaticPathsReturn = {
+ name: 'InvalidGetStaticPathsReturn',
+ title: 'Invalid value returned by getStaticPaths.',
+ message: (returnType) =>
+ `Invalid type returned by \`getStaticPaths\`. Expected an \`array\`, got \`${returnType}\``,
+ hint: 'See https://docs.astro.build/en/reference/routing-reference/#getstaticpaths for more information on getStaticPaths.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [`params`](https://docs.astro.build/en/reference/api-reference/#params)
+ * @description
+ * Every route specified by `getStaticPaths` require a `params` property specifying the path parameters needed to match the route.
+ *
+ * For instance, the following code:
+ * ```astro title="pages/blog/[id].astro"
+ * ---
+ * export async function getStaticPaths() {
+ * return [
+ * { params: { id: '1' } }
+ * ];
+ * }
+ * ---
+ * ```
+ * Will create the following route: `site.com/blog/1`.
+ */
+export const GetStaticPathsExpectedParams = {
+ name: 'GetStaticPathsExpectedParams',
+ title: 'Missing params property on `getStaticPaths` route.',
+ message: 'Missing or empty required `params` property on `getStaticPaths` route.',
+ hint: 'See https://docs.astro.build/en/reference/routing-reference/#getstaticpaths for more information on getStaticPaths.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [`params`](https://docs.astro.build/en/reference/api-reference/#params)
+ * @description
+ * Since `params` are encoded into the URL, only certain types are supported as values.
+ *
+ * ```astro title="/route/[id].astro"
+ * ---
+ * export async function getStaticPaths() {
+ * return [
+ * { params: { id: '1' } } // Works
+ * { params: { id: 2 } } // Works
+ * { params: { id: false } } // Does not work
+ * ];
+ * }
+ * ---
+ * ```
+ *
+ * In routes using [rest parameters](https://docs.astro.build/en/guides/routing/#rest-parameters), `undefined` can be used to represent a path with no parameters passed in the URL:
+ *
+ * ```astro title="/route/[...id].astro"
+ * ---
+ * export async function getStaticPaths() {
+ * return [
+ * { params: { id: 1 } } // /route/1
+ * { params: { id: 2 } } // /route/2
+ * { params: { id: undefined } } // /route/
+ * ];
+ * }
+ * ---
+ * ```
+ */
+export const GetStaticPathsInvalidRouteParam = {
+ name: 'GetStaticPathsInvalidRouteParam',
+ title: 'Invalid value for `getStaticPaths` route parameter.',
+ message: (key: string, value: any, valueType: any) =>
+ `Invalid getStaticPaths route parameter for \`${key}\`. Expected undefined, a string or a number, received \`${valueType}\` (\`${value}\`)`,
+ hint: 'See https://docs.astro.build/en/reference/routing-reference/#getstaticpaths for more information on getStaticPaths.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Dynamic Routes](https://docs.astro.build/en/guides/routing/#dynamic-routes)
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [Server-side Rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * @description
+ * In [Static Mode](https://docs.astro.build/en/guides/routing/#static-ssg-mode), all routes must be determined at build time. As such, dynamic routes must `export` a `getStaticPaths` function returning the different paths to generate.
+ */
+export const GetStaticPathsRequired = {
+ name: 'GetStaticPathsRequired',
+ title: '`getStaticPaths()` function required for dynamic routes.',
+ message:
+ '`getStaticPaths()` function is required for dynamic routes. Make sure that you `export` a `getStaticPaths` function from your dynamic route.',
+ hint: `See https://docs.astro.build/en/guides/routing/#dynamic-routes for more information on dynamic routes.
+
+ If you meant for this route to be server-rendered, set \`export const prerender = false;\` in the page.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Named slots](https://docs.astro.build/en/basics/astro-components/#named-slots)
+ * @description
+ * Certain words cannot be used for slot names due to being already used internally.
+ */
+export const ReservedSlotName = {
+ name: 'ReservedSlotName',
+ title: 'Invalid slot name.',
+ message: (slotName: string) =>
+ `Unable to create a slot named \`${slotName}\`. \`${slotName}\` is a reserved slot name. Please update the name of this slot.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Server-side Rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * @description
+ * To use server-side rendering, an adapter needs to be installed so Astro knows how to generate the proper output for your targeted deployment platform.
+ */
+export const NoAdapterInstalled = {
+ name: 'NoAdapterInstalled',
+ title: 'Cannot use Server-side Rendering without an adapter.',
+ message: `Cannot use server-rendered pages without an adapter. Please install and configure the appropriate server adapter for your final deployment.`,
+ hint: 'See https://docs.astro.build/en/guides/on-demand-rendering/ for more information.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Server-side Rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * @description
+ * The currently configured adapter does not support server-side rendering, which is required for the current project setup.
+ *
+ * Depending on your adapter, there may be a different entrypoint to use for server-side rendering. For example, the `@astrojs/vercel` adapter has a `@astrojs/vercel/static` entrypoint for static rendering, and a `@astrojs/vercel/serverless` entrypoint for server-side rendering.
+ *
+ */
+export const AdapterSupportOutputMismatch = {
+ name: 'AdapterSupportOutputMismatch',
+ title: 'Adapter does not support server output.',
+ message: (adapterName: string) =>
+ `The \`${adapterName}\` adapter is configured to output a static website, but the project contains server-rendered pages. Please install and configure the appropriate server adapter for your final deployment.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [On-demand Rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * @description
+ * To use server islands, the same constraints exist as for sever-side rendering, so an adapter is needed.
+ */
+export const NoAdapterInstalledServerIslands = {
+ name: 'NoAdapterInstalledServerIslands',
+ title: 'Cannot use Server Islands without an adapter.',
+ message: `Cannot use server islands without an adapter. Please install and configure the appropriate server adapter for your final deployment.`,
+ hint: 'See https://docs.astro.build/en/guides/on-demand-rendering/ for more information.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @description
+ * No import statement was found for one of the components. If there is an import statement, make sure you are using the same identifier in both the imports and the component usage.
+ */
+export const NoMatchingImport = {
+ name: 'NoMatchingImport',
+ title: 'No import found for component.',
+ message: (componentName: string) =>
+ `Could not render \`${componentName}\`. No matching import has been found for \`${componentName}\`.`,
+ hint: 'Please make sure the component is properly imported.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * **Example error messages:**<br/>
+ * InvalidPrerenderExport: A `prerender` export has been detected, but its value cannot be statically analyzed.
+ * @description
+ * The `prerender` feature only supports a subset of valid JavaScript — be sure to use exactly `export const prerender = true` so that our compiler can detect this directive at build time. Variables, `let`, and `var` declarations are not supported.
+ */
+export const InvalidPrerenderExport = {
+ name: 'InvalidPrerenderExport',
+ title: 'Invalid prerender export.',
+ message(prefix: string, suffix: string, isHydridOutput: boolean) {
+ const defaultExpectedValue = isHydridOutput ? 'false' : 'true';
+ let msg = `A \`prerender\` export has been detected, but its value cannot be statically analyzed.`;
+ if (prefix !== 'const') msg += `\nExpected \`const\` declaration but got \`${prefix}\`.`;
+ if (suffix !== 'true')
+ msg += `\nExpected \`${defaultExpectedValue}\` value but got \`${suffix}\`.`;
+ return msg;
+ },
+ hint: 'Mutable values declared at runtime are not supported. Please make sure to use exactly `export const prerender = true`.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * **Example error messages:**<br/>
+ * InvalidComponentArgs: Invalid arguments passed to `<MyAstroComponent>` component.
+ * @description
+ * Astro components cannot be rendered manually via a function call, such as `Component()` or `{items.map(Component)}`. Prefer the component syntax `<Component />` or `{items.map(item => <Component {...item} />)}`.
+ */
+export const InvalidComponentArgs = {
+ name: 'InvalidComponentArgs',
+ title: 'Invalid component arguments.',
+ message: (name: string) => `Invalid arguments passed to${name ? ` <${name}>` : ''} component.`,
+ hint: 'Astro components cannot be rendered directly via function call, such as `Component()` or `{items.map(Component)}`.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Pagination](https://docs.astro.build/en/guides/routing/#pagination)
+ * @description
+ * The page number parameter was not found in your filepath.
+ */
+export const PageNumberParamNotFound = {
+ name: 'PageNumberParamNotFound',
+ title: 'Page number param not found.',
+ message: (paramName: string) =>
+ `[paginate()] page number param \`${paramName}\` not found in your filepath.`,
+ hint: 'Rename your file to `[page].astro` or `[...page].astro`.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * - [Image component](https://docs.astro.build/en/reference/modules/astro-assets/#image-)
+ * - [Image component#alt](https://docs.astro.build/en/reference/modules/astro-assets/#alt-required)
+ * @description
+ * The `alt` property allows you to provide descriptive alt text to users of screen readers and other assistive technologies. In order to ensure your images are accessible, the `Image` component requires that an `alt` be specified.
+ *
+ * If the image is merely decorative (i.e. doesn’t contribute to the understanding of the page), set `alt=""` so that screen readers know to ignore the image.
+ */
+export const ImageMissingAlt = {
+ name: 'ImageMissingAlt',
+ title: 'Image missing required "alt" property.',
+ message:
+ 'Image missing "alt" property. "alt" text is required to describe important images on the page.',
+ hint: 'Use an empty string ("") for decorative images.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Image Service API](https://docs.astro.build/en/reference/image-service-reference/)
+ * @description
+ * There was an error while loading the configured image service. This can be caused by various factors, such as your image service not properly exporting a compatible object in its default export, or an incorrect path.
+ *
+ * If you believe that your service is properly configured and this error is wrong, please [open an issue](https://astro.build/issues/).
+ */
+export const InvalidImageService = {
+ name: 'InvalidImageService',
+ title: 'Error while loading image service.',
+ message:
+ 'There was an error loading the configured image service. Please see the stack trace for more information.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * Missing width and height attributes for `IMAGE_URL`. When using remote images, both dimensions are required in order to avoid cumulative layout shift (CLS).
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * - [Image component#width-and-height-required](https://docs.astro.build/en/reference/modules/astro-assets/#width-and-height-required-for-images-in-public)
+ * @description
+ * For remote images, `width` and `height` cannot automatically be inferred from the original file. To avoid cumulative layout shift (CLS), either specify these two properties, or set [`inferSize`](https://docs.astro.build/en/reference/modules/astro-assets/#infersize) to `true` to fetch a remote image's original dimensions.
+ *
+ * If your image is inside your `src` folder, you probably meant to import it instead. See [the Imports guide for more information](https://docs.astro.build/en/guides/imports/#other-assets).
+ */
+export const MissingImageDimension = {
+ name: 'MissingImageDimension',
+ title: 'Missing image dimensions',
+ message: (missingDimension: 'width' | 'height' | 'both', imageURL: string) =>
+ `Missing ${
+ missingDimension === 'both' ? 'width and height attributes' : `${missingDimension} attribute`
+ } for ${imageURL}. When using remote images, both dimensions are required in order to avoid CLS.`,
+ hint: 'If your image is inside your `src` folder, you probably meant to import it instead. See [the Imports guide for more information](https://docs.astro.build/en/guides/imports/#other-assets). You can also use `inferSize={true}` for remote images to get the original dimensions.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * Failed to get the dimensions for `IMAGE_URL`.
+ * @description
+ * Determining the remote image's dimensions failed. This is typically caused by an incorrect URL or attempting to infer the size of an image in the public folder which is not possible.
+ */
+export const FailedToFetchRemoteImageDimensions = {
+ name: 'FailedToFetchRemoteImageDimensions',
+ title: 'Failed to retrieve remote image dimensions',
+ message: (imageURL: string) => `Failed to get the dimensions for ${imageURL}.`,
+ hint: 'Verify your remote image URL is accurate, and that you are not using `inferSize` with a file located in your `public/` folder.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @description
+ * The built-in image services do not currently support optimizing all image formats.
+ *
+ * For unsupported formats such as GIFs, you may be able to use an `img` tag directly:
+ * ```astro
+ * ---
+ * import rocket from '../assets/images/rocket.gif';
+ * ---
+ *
+ * <img src={rocket.src} width={rocket.width} height={rocket.height} alt="A rocketship in space." />
+ * ```
+ */
+export const UnsupportedImageFormat = {
+ name: 'UnsupportedImageFormat',
+ title: 'Unsupported image format',
+ message: (format: string, imagePath: string, supportedFormats: readonly string[]) =>
+ `Received unsupported format \`${format}\` from \`${imagePath}\`. Currently only ${supportedFormats.join(
+ ', ',
+ )} are supported by our image services.`,
+ hint: "Using an `img` tag directly instead of the `Image` component might be what you're looking for.",
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * Astro does not currently supporting converting between vector (such as SVGs) and raster (such as PNGs and JPEGs) images.
+ */
+export const UnsupportedImageConversion = {
+ name: 'UnsupportedImageConversion',
+ title: 'Unsupported image conversion',
+ message:
+ 'Converting between vector (such as SVGs) and raster (such as PNGs and JPEGs) images is not currently supported.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [`getStaticPaths()`](https://docs.astro.build/en/reference/routing-reference/#getstaticpaths)
+ * - [`params`](https://docs.astro.build/en/reference/api-reference/#params)
+ * @description
+ * The endpoint is prerendered with an `undefined` param so the generated path will collide with another route.
+ *
+ * If you cannot prevent passing `undefined`, then an additional extension can be added to the endpoint file name to generate the file with a different name. For example, renaming `pages/api/[slug].ts` to `pages/api/[slug].json.ts`.
+ */
+export const PrerenderDynamicEndpointPathCollide = {
+ name: 'PrerenderDynamicEndpointPathCollide',
+ title: 'Prerendered dynamic endpoint has path collision.',
+ message: (pathname: string) =>
+ `Could not render \`${pathname}\` with an \`undefined\` param as the generated path will collide during prerendering. Prevent passing \`undefined\` as \`params\` for the endpoint's \`getStaticPaths()\` function, or add an additional extension to the endpoint's filename.`,
+ hint: (filename: string) =>
+ `Rename \`${filename}\` to \`${filename.replace(/\.(?:js|ts)/, (m) => `.json` + m)}\``,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * An image's `src` property is not valid. The Image component requires the `src` attribute to be either an image that has been ESM imported or a string. This is also true for the first parameter of `getImage()`.
+ *
+ * ```astro
+ * ---
+ * import { Image } from "astro:assets";
+ * import myImage from "../assets/my_image.png";
+ * ---
+ *
+ * <Image src={myImage} alt="..." />
+ * <Image src="https://example.com/logo.png" width={300} height={300} alt="..." />
+ * ```
+ *
+ * In most cases, this error happens when the value passed to `src` is undefined.
+ */
+export const ExpectedImage = {
+ name: 'ExpectedImage',
+ title: 'Expected src to be an image.',
+ message: (src: string, typeofOptions: string, fullOptions: string) =>
+ `Expected \`src\` property for \`getImage\` or \`<Image />\` to be either an ESM imported image or a string with the path of a remote image. Received \`${src}\` (type: \`${typeofOptions}\`).\n\nFull serialized options received: \`${fullOptions}\`.`,
+ hint: "This error can often happen because of a wrong path. Make sure the path to your image is correct. If you're passing an async function, make sure to call and await it.",
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * `getImage()`'s first parameter should be an object with the different properties to apply to your image.
+ *
+ * ```ts
+ * import { getImage } from "astro:assets";
+ * import myImage from "../assets/my_image.png";
+ *
+ * const optimizedImage = await getImage({src: myImage, width: 300, height: 300});
+ * ```
+ *
+ * In most cases, this error happens because parameters were passed directly instead of inside an object.
+ */
+export const ExpectedImageOptions = {
+ name: 'ExpectedImageOptions',
+ title: 'Expected image options.',
+ message: (options: string) =>
+ `Expected getImage() parameter to be an object. Received \`${options}\`.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * An ESM-imported image cannot be passed directly to `getImage()`. Instead, pass an object with the image in the `src` property.
+ *
+ * ```diff
+ * import { getImage } from "astro:assets";
+ * import myImage from "../assets/my_image.png";
+ * - const optimizedImage = await getImage( myImage );
+ * + const optimizedImage = await getImage({ src: myImage });
+ * ```
+ */
+
+export const ExpectedNotESMImage = {
+ name: 'ExpectedNotESMImage',
+ title: 'Expected image options, not an ESM-imported image.',
+ message:
+ 'An ESM-imported image cannot be passed directly to `getImage()`. Instead, pass an object with the image in the `src` property.',
+ hint: 'Try changing `getImage(myImage)` to `getImage({ src: myImage })`',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * Only one of `densities` or `widths` can be specified. Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.
+ */
+export const IncompatibleDescriptorOptions = {
+ name: 'IncompatibleDescriptorOptions',
+ title: 'Cannot set both `densities` and `widths`',
+ message:
+ "Only one of `densities` or `widths` can be specified. In most cases, you'll probably want to use only `widths` if you require specific widths.",
+ hint: 'Those attributes are used to construct a `srcset` attribute, which cannot have both `x` and `w` descriptors.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * Astro could not find an image you imported. Often, this is simply caused by a typo in the path.
+ *
+ * Images in Markdown are relative to the current file. To refer to an image that is located in the same folder as the `.md` file, the path should start with `./`
+ */
+export const ImageNotFound = {
+ name: 'ImageNotFound',
+ title: 'Image not found.',
+ message: (imagePath: string) => `Could not find requested image \`${imagePath}\`. Does it exist?`,
+ hint: 'This is often caused by a typo in the image path. Please make sure the file exists, and is spelled correctly.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message Could not process image metadata for `IMAGE_PATH`.
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * Astro could not process the metadata of an image you imported. This is often caused by a corrupted or malformed image and re-exporting the image from your image editor may fix this issue.
+ */
+export const NoImageMetadata = {
+ name: 'NoImageMetadata',
+ title: 'Could not process image metadata.',
+ message: (imagePath: string | undefined) =>
+ `Could not process image metadata${imagePath ? ` for \`${imagePath}\`` : ''}.`,
+ hint: 'This is often caused by a corrupted or malformed image. Re-exporting the image from your image editor may fix this issue.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * Astro could not transform one of your images. Often, this is caused by a corrupted or malformed image. Re-exporting the image from your image editor may fix this issue.
+ *
+ * Depending on the image service you are using, the stack trace may contain more information on the specific error encountered.
+ */
+export const CouldNotTransformImage = {
+ name: 'CouldNotTransformImage',
+ title: 'Could not transform image.',
+ message: (imagePath: string) =>
+ `Could not transform image \`${imagePath}\`. See the stack trace for more information.`,
+ hint: 'This is often caused by a corrupted or malformed image. Re-exporting the image from your image editor may fix this issue.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Making changes to the response, such as setting headers, cookies, and the status code cannot be done outside of page components.
+ */
+export const ResponseSentError = {
+ name: 'ResponseSentError',
+ title: 'Unable to set response.',
+ message: 'The response has already been sent to the browser and cannot be altered.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown when the middleware does not return any data or call the `next` function.
+ *
+ * For example:
+ * ```ts
+ * import {defineMiddleware} from "astro:middleware";
+ * export const onRequest = defineMiddleware((context, _) => {
+ * // doesn't return anything or call `next`
+ * context.locals.someData = false;
+ * });
+ * ```
+ */
+export const MiddlewareNoDataOrNextCalled = {
+ name: 'MiddlewareNoDataOrNextCalled',
+ title: "The middleware didn't return a `Response`.",
+ message:
+ 'Make sure your middleware returns a `Response` object, either directly or by returning the `Response` from calling the `next` function.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown in development mode when middleware returns something that is not a `Response` object.
+ *
+ * For example:
+ * ```ts
+ * import {defineMiddleware} from "astro:middleware";
+ * export const onRequest = defineMiddleware(() => {
+ * return "string"
+ * });
+ * ```
+ */
+export const MiddlewareNotAResponse = {
+ name: 'MiddlewareNotAResponse',
+ title: 'The middleware returned something that is not a `Response` object.',
+ message: 'Any data returned from middleware must be a valid `Response` object.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown when an endpoint does not return anything or returns an object that is not a `Response` object.
+ *
+ * An endpoint must return either a `Response`, or a `Promise` that resolves with a `Response`. For example:
+ * ```ts
+ * import type { APIContext } from 'astro';
+ *
+ * export async function GET({ request, url, cookies }: APIContext): Promise<Response> {
+ * return Response.json({
+ * success: true,
+ * result: 'Data from Astro Endpoint!'
+ * })
+ * }
+ * ```
+ */
+export const EndpointDidNotReturnAResponse = {
+ name: 'EndpointDidNotReturnAResponse',
+ title: 'The endpoint did not return a `Response`.',
+ message:
+ 'An endpoint must return either a `Response`, or a `Promise` that resolves with a `Response`.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ *
+ * Thrown when `locals` is overwritten with something that is not an object
+ *
+ * For example:
+ * ```ts
+ * import {defineMiddleware} from "astro:middleware";
+ * export const onRequest = defineMiddleware((context, next) => {
+ * context.locals = 1541;
+ * return next();
+ * });
+ * ```
+ */
+export const LocalsNotAnObject = {
+ name: 'LocalsNotAnObject',
+ title: 'Value assigned to `locals` is not accepted.',
+ message:
+ '`locals` can only be assigned to an object. Other values like numbers, strings, etc. are not accepted.',
+ hint: 'If you tried to remove some information from the `locals` object, try to use `delete` or set the property to `undefined`.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown when a value is being set as the `locals` field on the Astro global or context.
+ */
+export const LocalsReassigned = {
+ name: 'LocalsReassigned',
+ title: '`locals` must not be reassigned.',
+ message: '`locals` can not be assigned directly.',
+ hint: 'Set a `locals` property instead.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown when a value is being set as the `headers` field on the `ResponseInit` object available as `Astro.response`.
+ */
+export const AstroResponseHeadersReassigned = {
+ name: 'AstroResponseHeadersReassigned',
+ title: '`Astro.response.headers` must not be reassigned.',
+ message:
+ 'Individual headers can be added to and removed from `Astro.response.headers`, but it must not be replaced with another instance of `Headers` altogether.',
+ hint: 'Consider using `Astro.response.headers.add()`, and `Astro.response.headers.delete()`.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message Error when initializing session storage with driver `DRIVER`. `ERROR`
+ * @see
+ * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
+ * @description
+ * Thrown when the session storage could not be initialized.
+ */
+export const SessionStorageInitError = {
+ name: 'SessionStorageInitError',
+ title: 'Session storage could not be initialized.',
+ message: (error: string, driver?: string) =>
+ `Error when initializing session storage${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
+ hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message Error when saving session data with driver `DRIVER`. `ERROR`
+ * @see
+ * - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
+ * @description
+ * Thrown when the session data could not be saved.
+ */
+export const SessionStorageSaveError = {
+ name: 'SessionStorageSaveError',
+ title: 'Session data could not be saved.',
+ message: (error: string, driver?: string) =>
+ `Error when saving session data${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
+ hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Thrown in development mode when middleware throws an error while attempting to loading it.
+ *
+ * For example:
+ * ```ts
+ * import {defineMiddleware} from "astro:middleware";
+ * throw new Error("Error thrown while loading the middleware.")
+ * export const onRequest = defineMiddleware(() => {
+ * return "string"
+ * });
+ * ```
+ */
+export const MiddlewareCantBeLoaded = {
+ name: 'MiddlewareCantBeLoaded',
+ title: "Can't load the middleware.",
+ message: 'An unknown error was thrown while loading your middleware.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Images](https://docs.astro.build/en/guides/images/)
+ * @description
+ * When using the default image services, `Image`'s and `getImage`'s `src` parameter must be either an imported image or an URL, it cannot be a string of a filepath.
+ *
+ * For local images from content collections, you can use the [image() schema helper](https://docs.astro.build/en/guides/images/#images-in-content-collections) to resolve the images.
+ *
+ * ```astro
+ * ---
+ * import { Image } from "astro:assets";
+ * import myImage from "../my_image.png";
+ * ---
+ *
+ * <!-- GOOD: `src` is the full imported image. -->
+ * <Image src={myImage} alt="Cool image" />
+ *
+ * <!-- GOOD: `src` is a URL. -->
+ * <Image src="https://example.com/my_image.png" alt="Cool image" />
+ *
+ * <!-- BAD: `src` is an image's `src` path instead of the full image object. -->
+ * <Image src={myImage.src} alt="Cool image" />
+ *
+ * <!-- BAD: `src` is a string filepath. -->
+ * <Image src="../my_image.png" alt="Cool image" />
+ * ```
+ */
+export const LocalImageUsedWrongly = {
+ name: 'LocalImageUsedWrongly',
+ title: 'Local images must be imported.',
+ message: (imageFilePath: string) =>
+ `\`Image\`'s and \`getImage\`'s \`src\` parameter must be an imported image or an URL, it cannot be a string filepath. Received \`${imageFilePath}\`.`,
+ hint: 'If you want to use an image from your `src` folder, you need to either import it or if the image is coming from a content collection, use the [image() schema helper](https://docs.astro.build/en/guides/images/#images-in-content-collections). See https://docs.astro.build/en/guides/images/#src-required for more information on the `src` property.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Astro.glob](https://docs.astro.build/en/reference/api-reference/#astroglob)
+ * @description
+ * `Astro.glob()` can only be used in `.astro` files. You can use [`import.meta.glob()`](https://vite.dev/guide/features.html#glob-import) instead to achieve the same result.
+ */
+export const AstroGlobUsedOutside = {
+ name: 'AstroGlobUsedOutside',
+ title: 'Astro.glob() used outside of an Astro file.',
+ message: (globStr: string) =>
+ `\`Astro.glob(${globStr})\` can only be used in \`.astro\` files. \`import.meta.glob(${globStr})\` can be used instead to achieve a similar result.`,
+ hint: "See Vite's documentation on `import.meta.glob` for more information: https://vite.dev/guide/features.html#glob-import",
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Astro.glob](https://docs.astro.build/en/reference/api-reference/#astroglob)
+ * @description
+ * `Astro.glob()` did not return any matching files. There might be a typo in the glob pattern.
+ */
+export const AstroGlobNoMatch = {
+ name: 'AstroGlobNoMatch',
+ title: 'Astro.glob() did not match any files.',
+ message: (globStr: string) => `\`Astro.glob(${globStr})\` did not return any matching files.`,
+ hint: 'Check the pattern for typos.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect)
+ * @description
+ * A redirect must be given a location with the `Location` header.
+ */
+export const RedirectWithNoLocation = {
+ name: 'RedirectWithNoLocation',
+ title: 'A redirect must be given a location with the `Location` header.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect)
+ * @description
+ * An external redirect must start with http or https, and must be a valid URL.
+ */
+export const UnsupportedExternalRedirect = {
+ name: 'UnsupportedExternalRedirect',
+ title: 'Unsupported or malformed URL.',
+ message: 'An external redirect must start with http or https, and must be a valid URL.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Dynamic routes](https://docs.astro.build/en/guides/routing/#dynamic-routes)
+ * @description
+ * A dynamic route param is invalid. This is often caused by an `undefined` parameter or a missing [rest parameter](https://docs.astro.build/en/guides/routing/#rest-parameters).
+ */
+export const InvalidDynamicRoute = {
+ name: 'InvalidDynamicRoute',
+ title: 'Invalid dynamic route.',
+ message: (route: string, invalidParam: string, received: string) =>
+ `The ${invalidParam} param for route ${route} is invalid. Received **${received}**.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Default Image Service](https://docs.astro.build/en/guides/images/#default-image-service)
+ * - [Image Services API](https://docs.astro.build/en/reference/image-service-reference/)
+ * @description
+ * Sharp is the default image service used for `astro:assets`. When using a [strict package manager](https://pnpm.io/pnpm-vs-npm#npms-flat-tree) like pnpm, Sharp must be installed manually into your project in order to use image processing.
+ *
+ * If you are not using `astro:assets` for image processing, and do not wish to install Sharp, you can configure the following passthrough image service that does no processing:
+ *
+ * ```js
+ * import { defineConfig, passthroughImageService } from "astro/config";
+ * export default defineConfig({
+ * image: {
+ * service: passthroughImageService(),
+ * },
+ * });
+ * ```
+ */
+export const MissingSharp = {
+ name: 'MissingSharp',
+ title: 'Could not find Sharp.',
+ message:
+ 'Could not find Sharp. Please install Sharp (`sharp`) manually into your project or migrate to another image service.',
+ hint: "See Sharp's installation instructions for more information: https://sharp.pixelplumbing.com/install. If you are not relying on `astro:assets` to optimize, transform, or process any images, you can configure a passthrough image service instead of installing Sharp. See https://docs.astro.build/en/reference/errors/missing-sharp for more information.\n\nSee https://docs.astro.build/en/guides/images/#default-image-service for more information on how to migrate to another image service.",
+};
+// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
+// Vite Errors - 4xxx
+/**
+ * @docs
+ * @see
+ * - [Vite troubleshooting guide](https://vite.dev/guide/troubleshooting.html)
+ * @description
+ * Vite encountered an unknown error while rendering your project. We unfortunately do not know what happened (or we would tell you!)
+ *
+ * If you can reliably cause this error to happen, we'd appreciate if you could [open an issue](https://astro.build/issues/)
+ */
+export const UnknownViteError = {
+ name: 'UnknownViteError',
+ title: 'Unknown Vite Error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Type Imports](https://docs.astro.build/en/guides/typescript/#type-imports)
+ * @description
+ * Astro could not import the requested file. Oftentimes, this is caused by the import path being wrong (either because the file does not exist, or there is a typo in the path)
+ *
+ * This message can also appear when a type is imported without specifying that it is a [type import](https://docs.astro.build/en/guides/typescript/#type-imports).
+ */
+export const FailedToLoadModuleSSR = {
+ name: 'FailedToLoadModuleSSR',
+ title: 'Could not import file.',
+ message: (importName: string) => `Could not import \`${importName}\`.`,
+ hint: 'This is often caused by a typo in the import path. Please make sure the file exists.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Glob Patterns](https://docs.astro.build/en/guides/imports/#glob-patterns)
+ * @description
+ * Astro encountered an invalid glob pattern. This is often caused by the glob pattern not being a valid file path.
+ */
+export const InvalidGlob = {
+ name: 'InvalidGlob',
+ title: 'Invalid glob pattern.',
+ message: (globPattern: string) =>
+ `Invalid glob pattern: \`${globPattern}\`. Glob patterns must start with './', '../' or '/'.`,
+ hint: 'See https://docs.astro.build/en/guides/imports/#glob-patterns for more information on supported glob patterns.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @description
+ * Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error.
+ */
+export const FailedToFindPageMapSSR = {
+ name: 'FailedToFindPageMapSSR',
+ title: "Astro couldn't find the correct page to render",
+ message:
+ "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error. Please file an issue.",
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro can't find the requested locale. All supported locales must be configured in [i18n.locales](/en/reference/configuration-reference/#i18nlocales) and have corresponding directories within `src/pages/`.
+ */
+export const MissingLocale = {
+ name: 'MissingLocaleError',
+ title: 'The provided locale does not exist.',
+ message: (locale: string) =>
+ `The locale/path \`${locale}\` does not exist in the configured \`i18n.locales\`.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro could not find the index URL of your website. An index page is required so that Astro can create a redirect from the main index page to the localized index page of the default locale when using [`i18n.routing.prefixDefaultLocale`](https://docs.astro.build/en/reference/configuration-reference/#i18nroutingprefixdefaultlocale).
+ * @see
+ * - [Internationalization](https://docs.astro.build/en/guides/internationalization/#routing)
+ * - [`i18n.routing` Configuration Reference](https://docs.astro.build/en/reference/configuration-reference/#i18nrouting)
+ */
+export const MissingIndexForInternationalization = {
+ name: 'MissingIndexForInternationalizationError',
+ title: 'Index page not found.',
+ message: (defaultLocale: string) =>
+ `Could not find index page. A root index page is required in order to create a redirect to the index URL of the default locale. (\`/${defaultLocale}\`)`,
+ hint: (src: string) => `Create an index page (\`index.astro, index.md, etc.\`) in \`${src}\`.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Some internationalization functions are only available when Astro's own i18n routing is disabled by the configuration setting `i18n.routing: "manual"`.
+ *
+ * @see
+ * - [`i18n` routing](https://docs.astro.build/en/guides/internationalization/#routing)
+ */
+export const IncorrectStrategyForI18n = {
+ name: 'IncorrectStrategyForI18n',
+ title: "You can't use the current function with the current strategy",
+ message: (functionName: string) =>
+ `The function \`${functionName}\` can only be used when the \`i18n.routing.strategy\` is set to \`"manual"\`.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Static pages aren't yet supported with i18n domains. If you wish to enable this feature, you have to disable prerendering.
+ */
+export const NoPrerenderedRoutesWithDomains = {
+ name: 'NoPrerenderedRoutesWithDomains',
+ title: "Prerendered routes aren't supported when internationalization domains are enabled.",
+ message: (component: string) =>
+ `Static pages aren't yet supported with multiple domains. To enable this feature, you must disable prerendering for the page ${component}`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro throws an error if the user enables manual routing, but it doesn't have a middleware file.
+ */
+export const MissingMiddlewareForInternationalization = {
+ name: 'MissingMiddlewareForInternationalization',
+ title: 'Enabled manual internationalization routing without having a middleware.',
+ message:
+ "Your configuration setting `i18n.routing: 'manual'` requires you to provide your own i18n `middleware` file.",
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro could not find an associated file with content while trying to render the route. This is an Astro error and not a user error. If restarting the dev server does not fix the problem, please file an issue.
+ */
+export const CantRenderPage = {
+ name: 'CantRenderPage',
+ title: "Astro can't render the route.",
+ message:
+ 'Astro cannot find any content to render for this route. There is no file or redirect associated with this route.',
+ hint: 'If you expect to find a route here, this may be an Astro bug. Please file an issue/restart the dev server',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro could not find any code to handle a rejected `Promise`. Make sure all your promises have an `await` or `.catch()` handler.
+ */
+export const UnhandledRejection = {
+ name: 'UnhandledRejection',
+ title: 'Unhandled rejection',
+ message: (stack: string) =>
+ `Astro detected an unhandled rejection. Here's the stack trace:\n${stack}`,
+ hint: 'Make sure your promises all have an `await` or a `.catch()` handler.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * The `astro:i18n` module can not be used without enabling i18n in your Astro config. To enable i18n, add a default locale and a list of supported locales to your Astro config:
+ * ```js
+ * import { defineConfig } from 'astro'
+ * export default defineConfig({
+ * i18n: {
+ * locales: ['en', 'fr'],
+ * defaultLocale: 'en',
+ * },
+ * })
+ * ```
+ *
+ * For more information on internationalization support in Astro, see our [Internationalization guide](https://docs.astro.build/en/guides/internationalization/).
+ * @see
+ * - [Internationalization](https://docs.astro.build/en/guides/internationalization/)
+ * - [`i18n` Configuration Reference](https://docs.astro.build/en/reference/configuration-reference/#i18n)
+ */
+export const i18nNotEnabled = {
+ name: 'i18nNotEnabled',
+ title: 'i18n Not Enabled',
+ message: 'The `astro:i18n` module can not be used without enabling i18n in your Astro config.',
+ hint: 'See https://docs.astro.build/en/guides/internationalization for a guide on setting up i18n.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * An i18n utility tried to use the locale from a URL path that does not contain one. You can prevent this error by using pathHasLocale to check URLs for a locale first before using i18n utilities.
+ *
+ */
+export const i18nNoLocaleFoundInPath = {
+ name: 'i18nNoLocaleFoundInPath',
+ title: "The path doesn't contain any locale",
+ message:
+ "You tried to use an i18n utility on a path that doesn't contain any locale. You can use `pathHasLocale` first to determine if the path has a locale.",
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro couldn't find a route matching the one provided by the user
+ */
+export const RouteNotFound = {
+ name: 'RouteNotFound',
+ title: 'Route not found.',
+ message: `Astro could not find a route that matches the one you requested.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Some environment variables do not match the data type and/or properties defined in `env.schema`.
+ * @message
+ * The following environment variables defined in `env.schema` are invalid.
+ */
+export const EnvInvalidVariables = {
+ name: 'EnvInvalidVariables',
+ title: 'Invalid Environment Variables',
+ message: (errors: Array<string>) =>
+ `The following environment variables defined in \`env.schema\` are invalid:\n\n${errors.map((err) => `- ${err}`).join('\n')}\n`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * This module is only available server-side.
+ */
+export const ServerOnlyModule = {
+ name: 'ServerOnlyModule',
+ title: 'Module is only available server-side',
+ message: (name: string) => `The "${name}" module is only available server-side.`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * `Astro.rewrite()` cannot be used if the request body has already been read. If you need to read the body, first clone the request. For example:
+ *
+ * ```js
+ * const data = await Astro.request.clone().formData();
+ *
+ * Astro.rewrite("/target")
+ * ```
+ *
+ * @see
+ * - [Request.clone()](https://developer.mozilla.org/en-US/docs/Web/API/Request/clone)
+ * - [Astro.rewrite](https://docs.astro.build/en/reference/api-reference/#rewrite)
+ */
+
+export const RewriteWithBodyUsed = {
+ name: 'RewriteWithBodyUsed',
+ title: 'Cannot use Astro.rewrite after the request body has been read',
+ message:
+ 'Astro.rewrite() cannot be used if the request body has already been read. If you need to read the body, first clone the request.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * `Astro.rewrite()` can't be used to rewrite an on-demand route with a static route when using the `"server"` output.
+ *
+ */
+export const ForbiddenRewrite = {
+ name: 'ForbiddenRewrite',
+ title: 'Forbidden rewrite to a static route.',
+ message: (from: string, to: string, component: string) =>
+ `You tried to rewrite the on-demand route '${from}' with the static route '${to}', when using the 'server' output. \n\nThe static route '${to}' is rendered by the component
+'${component}', which is marked as prerendered. This is a forbidden operation because during the build the component '${component}' is compiled to an
+HTML file, which can't be retrieved at runtime by Astro.`,
+ hint: (component: string) =>
+ `Add \`export const prerender = false\` to the component '${component}', or use a Astro.redirect().`,
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * An unknown error occurred while reading or writing files to disk. It can be caused by many things, eg. missing permissions or a file not existing we attempt to read.
+ */
+export const UnknownFilesystemError = {
+ name: 'UnknownFilesystemError',
+ title: 'An unknown error occurred while reading or writing files to disk.',
+ hint: 'It can be caused by many things, eg. missing permissions or a file not existing we attempt to read. Check the error cause for more details.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @kind heading
+ * @name CSS Errors
+ */
+// CSS Errors
+/**
+ * @docs
+ * @see
+ * - [Styles and CSS](https://docs.astro.build/en/guides/styling/)
+ * @description
+ * Astro encountered an unknown error while parsing your CSS. Oftentimes, this is caused by a syntax error and the error message should contain more information.
+ */
+export const UnknownCSSError = {
+ name: 'UnknownCSSError',
+ title: 'Unknown CSS Error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * **Example error messages:**<br/>
+ * CSSSyntaxError: Missed semicolon<br/>
+ * CSSSyntaxError: Unclosed string<br/>
+ * @description
+ * Astro encountered an error while parsing your CSS, due to a syntax error. This is often caused by a missing semicolon.
+ */
+export const CSSSyntaxError = {
+ name: 'CSSSyntaxError',
+ title: 'CSS Syntax Error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @kind heading
+ * @name Markdown Errors
+ */
+// Markdown Errors
+/**
+ * @docs
+ * @description
+ * Astro encountered an unknown error while parsing your Markdown. Oftentimes, this is caused by a syntax error and the error message should contain more information.
+ */
+export const UnknownMarkdownError = {
+ name: 'UnknownMarkdownError',
+ title: 'Unknown Markdown Error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message
+ * **Example error messages:**<br/>
+ * can not read an implicit mapping pair; a colon is missed<br/>
+ * unexpected end of the stream within a double quoted scalar<br/>
+ * can not read a block mapping entry; a multiline key may not be an implicit key
+ * @description
+ * Astro encountered an error while parsing the frontmatter of your Markdown file.
+ * This is often caused by a mistake in the syntax, such as a missing colon or a missing end quote.
+ */
+export const MarkdownFrontmatterParseError = {
+ name: 'MarkdownFrontmatterParseError',
+ title: 'Failed to parse Markdown frontmatter.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Modifying frontmatter programmatically](https://docs.astro.build/en/guides/markdown-content/#modifying-frontmatter-programmatically)
+ * @description
+ * A remark or rehype plugin attempted to inject invalid frontmatter. This occurs when "astro.frontmatter" is set to `null`, `undefined`, or an invalid JSON object.
+ */
+export const InvalidFrontmatterInjectionError = {
+ name: 'InvalidFrontmatterInjectionError',
+ title: 'Invalid frontmatter injection.',
+ message:
+ 'A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.',
+ hint: 'See the frontmatter injection docs https://docs.astro.build/en/guides/markdown-content/#modifying-frontmatter-programmatically for more information.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [MDX installation and usage](https://docs.astro.build/en/guides/integrations-guide/mdx/)
+ * @description
+ * Unable to find the official `@astrojs/mdx` integration. This error is raised when using MDX files without an MDX integration installed.
+ */
+export const MdxIntegrationMissingError = {
+ name: 'MdxIntegrationMissingError',
+ title: 'MDX integration missing.',
+ message: (file: string) =>
+ `Unable to render ${file}. Ensure that the \`@astrojs/mdx\` integration is installed.`,
+ hint: 'See the MDX integration docs for installation and usage instructions: https://docs.astro.build/en/guides/integrations-guide/mdx/',
+} satisfies ErrorData;
+// Config Errors - 7xxx
+/**
+ * @docs
+ * @see
+ * - [Configuration Reference](https://docs.astro.build/en/reference/configuration-reference/)
+ * @description
+ * Astro encountered an unknown error loading your Astro configuration file.
+ * This is often caused by a syntax error in your config and the message should offer more information.
+ *
+ * If you can reliably cause this error to happen, we'd appreciate if you could [open an issue](https://astro.build/issues/)
+ */
+export const UnknownConfigError = {
+ name: 'UnknownConfigError',
+ title: 'Unknown configuration error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [--config](https://docs.astro.build/en/reference/cli-reference/#--config-path)
+ * @description
+ * The specified configuration file using `--config` could not be found. Make sure that it exists or that the path is correct
+ */
+export const ConfigNotFound = {
+ name: 'ConfigNotFound',
+ title: 'Specified configuration file not found.',
+ message: (configFile: string) =>
+ `Unable to resolve \`--config "${configFile}"\`. Does the file exist?`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
+ * @description
+ * Astro detected a legacy configuration option in your configuration file.
+ */
+export const ConfigLegacyKey = {
+ name: 'ConfigLegacyKey',
+ title: 'Legacy configuration detected.',
+ message: (legacyConfigKey: string) => `Legacy configuration detected: \`${legacyConfigKey}\`.`,
+ hint: 'Please update your configuration to the new format.\nSee https://astro.build/config for more information.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @kind heading
+ * @name CLI Errors
+ */
+// CLI Errors
+/**
+ * @docs
+ * @description
+ * Astro encountered an unknown error while starting one of its CLI commands. The error message should contain more information.
+ *
+ * If you can reliably cause this error to happen, we'd appreciate if you could [open an issue](https://astro.build/issues/)
+ */
+export const UnknownCLIError = {
+ name: 'UnknownCLIError',
+ title: 'Unknown CLI Error.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @description
+ * `astro sync` command failed to generate content collection types.
+ * @see
+ * - [Content collections documentation](https://docs.astro.build/en/guides/content-collections/)
+ */
+export const GenerateContentTypesError = {
+ name: 'GenerateContentTypesError',
+ title: 'Failed to generate content types.',
+ message: (errorMessage: string) =>
+ `\`astro sync\` command failed to generate content collection types: ${errorMessage}`,
+ hint: (fileName?: string) =>
+ `This error is often caused by a syntax error inside your content, or your content configuration file. Check your ${fileName ?? 'content config'} file for typos.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @kind heading
+ * @name Content Collection Errors
+ */
+// Content Collection Errors
+/**
+ * @docs
+ * @description
+ * Astro encountered an unknown error loading your content collections.
+ * This can be caused by certain errors inside your `src/content.config.ts` file or some internal errors.
+ *
+ * If you can reliably cause this error to happen, we'd appreciate if you could [open an issue](https://astro.build/issues/)
+ */
+export const UnknownContentCollectionError = {
+ name: 'UnknownContentCollectionError',
+ title: 'Unknown Content Collection Error.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * Astro tried to render a content collection entry that was undefined. This can happen if you try to render an entry that does not exist.
+ */
+export const RenderUndefinedEntryError = {
+ name: 'RenderUndefinedEntryError',
+ title: 'Attempted to render an undefined content collection entry.',
+ hint: 'Check if the entry is undefined before passing it to `render()`',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * The `getDataEntryById` and `getEntryBySlug` functions are deprecated and cannot be used with content layer collections. Use the `getEntry` function instead.
+ */
+export const GetEntryDeprecationError = {
+ name: 'GetEntryDeprecationError',
+ title: 'Invalid use of `getDataEntryById` or `getEntryBySlug` function.',
+ message: (collection: string, method: string) =>
+ `The \`${method}\` function is deprecated and cannot be used to query the "${collection}" collection. Use \`getEntry\` instead.`,
+ hint: 'Use the `getEntry` or `getCollection` functions to query content layer collections.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * **blog** → **post.md** frontmatter does not match collection schema.<br/>
+ * "title" is required.<br/>
+ * "date" must be a valid date.
+ * @description
+ * A Markdown or MDX entry does not match its collection schema.
+ * Make sure that all required fields are present, and that all fields are of the correct type.
+ * You can check against the collection schema in your `src/content.config.*` file.
+ * See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
+ */
+export const InvalidContentEntryFrontmatterError = {
+ name: 'InvalidContentEntryFrontmatterError',
+ title: 'Content entry frontmatter does not match schema.',
+ message(collection: string, entryId: string, error: ZodError) {
+ return [
+ `**${String(collection)} → ${String(
+ entryId,
+ )}** frontmatter does not match collection schema.`,
+ ...error.errors.map((zodError) => zodError.message),
+ ].join('\n');
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * **blog** → **post** frontmatter does not match collection schema.<br/>
+ * "title" is required.<br/>
+ * "date" must be a valid date.
+ * @description
+ * A content entry does not match its collection schema.
+ * Make sure that all required fields are present, and that all fields are of the correct type.
+ * You can check against the collection schema in your `src/content.config.*` file.
+ * See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
+ */
+export const InvalidContentEntryDataError = {
+ name: 'InvalidContentEntryDataError',
+ title: 'Content entry data does not match schema.',
+ message(collection: string, entryId: string, error: ZodError) {
+ return [
+ `**${String(collection)} → ${String(entryId)}** data does not match collection schema.`,
+ ...error.errors.map((zodError) => zodError.message),
+ ].join('\n');
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * The content loader for the collection **blog** returned an entry with an invalid `id`:<br/>
+ * &#123;<br/>
+ * "id": 1,<br/>
+ * "title": "Hello, World!"<br/>
+ * &#125;
+ * @description
+ * A content loader returned an invalid `id`.
+ * Make sure that the `id` of the entry is a string.
+ * See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
+ */
+export const ContentLoaderReturnsInvalidId = {
+ name: 'ContentLoaderReturnsInvalidId',
+ title: 'Content loader returned an entry with an invalid `id`.',
+ message(collection: string, entry: any) {
+ return [
+ `The content loader for the collection **${String(collection)}** returned an entry with an invalid \`id\`:`,
+ JSON.stringify(entry, null, 2),
+ ].join('\n');
+ },
+ hint: 'Make sure that the `id` of the entry is a string. See https://docs.astro.build/en/guides/content-collections/ for more information on content loaders.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * **blog** → **post** data does not match collection schema.<br/>
+ * "title" is required.<br/>
+ * "date" must be a valid date.
+ * @description
+ * A content entry does not match its collection schema.
+ * Make sure that all required fields are present, and that all fields are of the correct type.
+ * You can check against the collection schema in your `src/content.config.*` file.
+ * See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) for more information.
+ */
+export const ContentEntryDataError = {
+ name: 'ContentEntryDataError',
+ title: 'Content entry data does not match schema.',
+ message(collection: string, entryId: string, error: ZodError) {
+ return [
+ `**${String(collection)} → ${String(entryId)}** data does not match collection schema.`,
+ ...error.errors.map((zodError) => zodError.message),
+ ].join('\n');
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message
+ * **Example error message:**<br/>
+ * The loader for **blog** returned invalid data.<br/>
+ * Object is missing required property "id".
+ * @description
+ * The loader for a content collection returned invalid data.
+ * Inline loaders must return an array of objects with unique ID fields or a plain object with IDs as keys and entries as values.
+ */
+export const ContentLoaderInvalidDataError = {
+ name: 'ContentLoaderInvalidDataError',
+ title: 'Content entry is missing an ID',
+ message(collection: string, extra: string) {
+ return `**${String(collection)}** entry is missing an ID.\n${extra}`;
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content loaders.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @message `COLLECTION_NAME` → `ENTRY_ID` has an invalid slug. `slug` must be a string.
+ * @see
+ * - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
+ * @description
+ * A collection entry has an invalid `slug`. This field is reserved for generating entry slugs, and must be a string when present.
+ */
+export const InvalidContentEntrySlugError = {
+ name: 'InvalidContentEntrySlugError',
+ title: 'Invalid content entry slug.',
+ message(collection: string, entryId: string) {
+ return `${String(collection)} → ${String(
+ entryId,
+ )} has an invalid slug. \`slug\` must be a string.`;
+ },
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Legacy content collections](https://docs.astro.build/en/guides/upgrade-to/v5/#updating-existing-collections)
+ * @description
+ * A legacy content collection schema should not contain the `slug` field. This is reserved by Astro for generating entry slugs. Remove `slug` from your schema. You can still use custom slugs in your frontmatter.
+ */
+export const ContentSchemaContainsSlugError = {
+ name: 'ContentSchemaContainsSlugError',
+ title: 'Content Schema should not contain `slug`.',
+ message: (collectionName: string) =>
+ `A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collectionName} collection schema.`,
+ hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Legacy content collections](https://docs.astro.build/en/guides/upgrade-to/v5/#updating-existing-collections)
+ * @description
+ * A legacy content collection cannot contain a mix of content and data entries. You must store entries in separate collections by type.
+ */
+export const MixedContentDataCollectionError = {
+ name: 'MixedContentDataCollectionError',
+ title: 'Content and data cannot be in same collection.',
+ message: (collectionName: string) =>
+ `**${collectionName}** contains a mix of content and data entries. All entries must be of the same type.`,
+ hint: 'Store data entries in a new collection separate from your content collection.',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @see
+ * - [Legacy content collections](https://docs.astro.build/en/guides/upgrade-to/v5/#updating-existing-collections)
+ * @description
+ * Legacy content collections must contain entries of the type configured. Collections are `type: 'content'` by default. Try adding `type: 'data'` to your collection config for data collections.
+ */
+export const ContentCollectionTypeMismatchError = {
+ name: 'ContentCollectionTypeMismatchError',
+ title: 'Collection contains entries of a different type.',
+ message: (collection: string, expectedType: string, actualType: string) =>
+ `${collection} contains ${expectedType} entries, but is configured as a ${actualType} collection.`,
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message `COLLECTION_ENTRY_NAME` failed to parse.
+ * @description
+ * Collection entries of `type: 'data'` must return an object with valid JSON (for `.json` entries) or YAML (for `.yaml` entries).
+ */
+export const DataCollectionEntryParseError = {
+ name: 'DataCollectionEntryParseError',
+ title: 'Data collection entry failed to parse.',
+ message(entryId: string, errorMessage: string) {
+ return `**${entryId}** failed to parse: ${errorMessage}`;
+ },
+ hint: 'Ensure your data entry is an object with valid JSON (for `.json` entries) or YAML (for `.yaml` entries).',
+} satisfies ErrorData;
+/**
+ * @docs
+ * @message `COLLECTION_NAME` contains multiple entries with the same slug: `SLUG`. Slugs must be unique.
+ * @description
+ * Content collection entries must have unique slugs. Duplicates are often caused by the `slug` frontmatter property.
+ */
+export const DuplicateContentEntrySlugError = {
+ name: 'DuplicateContentEntrySlugError',
+ title: 'Duplicate content entry slug.',
+ message(collection: string, slug: string, preExisting: string, alsoFound: string) {
+ return (
+ `**${collection}** contains multiple entries with the same slug: \`${slug}\`. ` +
+ `Slugs must be unique.\n\n` +
+ `Entries: \n` +
+ `- ${preExisting}\n` +
+ `- ${alsoFound}`
+ );
+ },
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [devalue library](https://github.com/rich-harris/devalue)
+ * @description
+ * `transform()` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets).
+ */
+export const UnsupportedConfigTransformError = {
+ name: 'UnsupportedConfigTransformError',
+ title: 'Unsupported transform in content config.',
+ message: (parseError: string) =>
+ `\`transform()\` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets).\nFull error: ${parseError}`,
+ hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @kind heading
+ * @name Action Errors
+ */
+// Action Errors
+/**
+ * @docs
+ * @see
+ * - [On-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/)
+ * @description
+ * Your project must have a server output to create backend functions with Actions.
+ */
+export const ActionsWithoutServerOutputError = {
+ name: 'ActionsWithoutServerOutputError',
+ title: 'Actions must be used with server output.',
+ message:
+ 'A server is required to create callable backend functions. To deploy routes to a server, add an adapter to your Astro config and configure your route for on-demand rendering',
+ hint: 'Add an adapter and enable on-demand rendering: https://docs.astro.build/en/guides/on-demand-rendering/',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [Actions handler reference](https://docs.astro.build/en/reference/modules/astro-actions/#handler-property)
+ * @description
+ * Action handler returned invalid data. Handlers should return serializable data types, and cannot return a Response object.
+ */
+export const ActionsReturnedInvalidDataError = {
+ name: 'ActionsReturnedInvalidDataError',
+ title: 'Action handler returned invalid data.',
+ message: (error: string) =>
+ `Action handler returned invalid data. Handlers should return serializable data types like objects, arrays, strings, and numbers. Parse error: ${error}`,
+ hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * The server received a request for an action but could not find a match with the same name.
+ */
+export const ActionNotFoundError = {
+ name: 'ActionNotFoundError',
+ title: 'Action not found.',
+ message: (actionName: string) =>
+ `The server received a request for an action named \`${actionName}\` but could not find a match. If you renamed an action, check that you've updated your \`actions/index\` file and your calling code to match.`,
+ hint: 'You can run `astro check` to detect type errors caused by mismatched action names.',
+} satisfies ErrorData;
+
+/**
+ * @docs
+ * @see
+ * - [`Astro.callAction()` reference](https://docs.astro.build/en/reference/api-reference/#callaction)
+ * @description
+ * Action called from a server page or endpoint without using `Astro.callAction()`.
+ */
+export const ActionCalledFromServerError = {
+ name: 'ActionCalledFromServerError',
+ title: 'Action unexpected called from the server.',
+ message:
+ 'Action called from a server page or endpoint without using `Astro.callAction()`. This wrapper must be used to call actions from server code.',
+ hint: 'See the `Astro.callAction()` reference for usage examples: https://docs.astro.build/en/reference/api-reference/#callaction',
+} satisfies ErrorData;
+
+// Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip.
+export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData;
+
+/*
+ * Adding an error? Follow these steps:
+ * 1. Determine in which category it belongs (Astro, Vite, CSS, Content Collections etc.)
+ * 2. Add it at the bottom of the corresponding category above (see the @kind heading tags to see where they start), following the shape of the other errors.
+ * 4. If your message is dynamic, make sure the function shape is the following: `message: (something: type) => "my message"`, no `{}`, no `return` etc.
+ * - It has to be the simple shape, or the docs generator will not be able to parse it correctly.
+ * - If your message is fully dynamic (ex: lots of conditional logic), make `message` a proper function, like such: `message(parameters) { logic }`.
+ * Make sure to add a `@message` tag with a static example of the error message, the docs won't be able to parse it otherwise.
+ * - If your message is static, you can just use a string, `message: "my message"`.
+ * 5. Make sure to add a JSdoc comment with the `@docs` tag so that it shows up in the docs, otherwise the error overlay will point to a 404!
+ * For more information, see the README in this folder!
+ */
diff --git a/packages/astro/src/core/errors/errors.ts b/packages/astro/src/core/errors/errors.ts
new file mode 100644
index 000000000..88ff70de3
--- /dev/null
+++ b/packages/astro/src/core/errors/errors.ts
@@ -0,0 +1,196 @@
+import type { ZodError } from 'zod';
+import { codeFrame } from './printer.js';
+
+interface ErrorProperties {
+ title?: string;
+ name: string;
+ message?: string;
+ location?: ErrorLocation;
+ hint?: string;
+ stack?: string;
+ frame?: string;
+}
+
+export interface ErrorLocation {
+ file?: string;
+ line?: number;
+ column?: number;
+}
+
+type ErrorTypes =
+ | 'AstroError'
+ | 'AstroUserError'
+ | 'CompilerError'
+ | 'CSSError'
+ | 'MarkdownError'
+ | 'InternalError'
+ | 'AggregateError';
+
+export function isAstroError(e: unknown): e is AstroError {
+ return e instanceof AstroError;
+}
+
+export class AstroError extends Error {
+ public loc: ErrorLocation | undefined;
+ public title: string | undefined;
+ public hint: string | undefined;
+ public frame: string | undefined;
+
+ type: ErrorTypes = 'AstroError';
+
+ constructor(props: ErrorProperties, options?: ErrorOptions) {
+ const { name, title, message, stack, location, hint, frame } = props;
+ super(message, options);
+
+ this.title = title;
+ this.name = name;
+
+ if (message) this.message = message;
+ // Only set this if we actually have a stack passed, otherwise uses Error's
+ this.stack = stack ? stack : this.stack;
+ this.loc = location;
+ this.hint = hint;
+ this.frame = frame;
+ }
+
+ public setLocation(location: ErrorLocation): void {
+ this.loc = location;
+ }
+
+ public setName(name: string): void {
+ this.name = name;
+ }
+
+ public setMessage(message: string): void {
+ this.message = message;
+ }
+
+ public setHint(hint: string): void {
+ this.hint = hint;
+ }
+
+ public setFrame(source: string, location: ErrorLocation): void {
+ this.frame = codeFrame(source, location);
+ }
+
+ static is(err: unknown): err is AstroError {
+ return (err as AstroError).type === 'AstroError';
+ }
+}
+
+export class CompilerError extends AstroError {
+ type: ErrorTypes = 'CompilerError';
+
+ constructor(props: ErrorProperties, options?: ErrorOptions) {
+ super(props, options);
+ }
+
+ static is(err: unknown): err is CompilerError {
+ return (err as CompilerError).type === 'CompilerError';
+ }
+}
+
+export class CSSError extends AstroError {
+ type: ErrorTypes = 'CSSError';
+
+ static is(err: unknown): err is CSSError {
+ return (err as CSSError).type === 'CSSError';
+ }
+}
+
+export class MarkdownError extends AstroError {
+ type: ErrorTypes = 'MarkdownError';
+
+ static is(err: unknown): err is MarkdownError {
+ return (err as MarkdownError).type === 'MarkdownError';
+ }
+}
+
+export class InternalError extends AstroError {
+ type: ErrorTypes = 'InternalError';
+
+ static is(err: unknown): err is InternalError {
+ return (err as InternalError).type === 'InternalError';
+ }
+}
+
+export class AggregateError extends AstroError {
+ type: ErrorTypes = 'AggregateError';
+ errors: AstroError[];
+
+ // Despite being a collection of errors, AggregateError still needs to have a main error attached to it
+ // This is because Vite expects every thrown errors handled during HMR to be, well, Error and have a message
+ constructor(props: ErrorProperties & { errors: AstroError[] }, options?: ErrorOptions) {
+ super(props, options);
+
+ this.errors = props.errors;
+ }
+
+ static is(err: unknown): err is AggregateError {
+ return (err as AggregateError).type === 'AggregateError';
+ }
+}
+
+const astroConfigZodErrors = new WeakSet<ZodError>();
+
+/**
+ * Check if an error is a ZodError from an AstroConfig validation.
+ * Used to suppress formatting a ZodError if needed.
+ */
+export function isAstroConfigZodError(error: unknown): error is ZodError {
+ return astroConfigZodErrors.has(error as ZodError);
+}
+
+/**
+ * Track that a ZodError comes from an AstroConfig validation.
+ */
+export function trackAstroConfigZodError(error: ZodError): void {
+ astroConfigZodErrors.add(error);
+}
+
+/**
+ * Generic object representing an error with all possible data
+ * Compatible with both Astro's and Vite's errors
+ */
+export interface ErrorWithMetadata {
+ [name: string]: any;
+ name: string;
+ title?: string;
+ type?: ErrorTypes;
+ message: string;
+ stack: string;
+ hint?: string;
+ id?: string;
+ frame?: string;
+ plugin?: string;
+ pluginCode?: string;
+ fullCode?: string;
+ loc?: {
+ file?: string;
+ line?: number;
+ column?: number;
+ };
+ cause?: any;
+}
+
+/**
+ * Special error that is exposed to users.
+ * Compared to AstroError, it contains a subset of information.
+ */
+export class AstroUserError extends Error {
+ type: ErrorTypes = 'AstroUserError';
+ /**
+ * A message that explains to the user how they can fix the error.
+ */
+ hint: string | undefined;
+ name = 'AstroUserError';
+ constructor(message: string, hint?: string) {
+ super();
+ this.message = message;
+ this.hint = hint;
+ }
+
+ static is(err: unknown): err is AstroUserError {
+ return (err as AstroUserError).type === 'AstroUserError';
+ }
+}
diff --git a/packages/astro/src/core/errors/index.ts b/packages/astro/src/core/errors/index.ts
new file mode 100644
index 000000000..1889a72c6
--- /dev/null
+++ b/packages/astro/src/core/errors/index.ts
@@ -0,0 +1,14 @@
+export * as AstroErrorData from './errors-data.js';
+export {
+ AggregateError,
+ AstroError,
+ AstroUserError,
+ CSSError,
+ CompilerError,
+ MarkdownError,
+ isAstroError,
+} from './errors.js';
+export type { ErrorLocation, ErrorWithMetadata } from './errors.js';
+export { codeFrame } from './printer.js';
+export { createSafeError, positionAt } from './utils.js';
+export { errorMap } from './zod-error-map.js';
diff --git a/packages/astro/src/core/errors/overlay.ts b/packages/astro/src/core/errors/overlay.ts
new file mode 100644
index 000000000..0579f5b1f
--- /dev/null
+++ b/packages/astro/src/core/errors/overlay.ts
@@ -0,0 +1,751 @@
+import type { AstroErrorPayload } from './dev/vite.js';
+
+const style = /* css */ `
+* {
+ box-sizing: border-box;
+}
+
+:host {
+ /** Needed so Playwright can find the element */
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 99999;
+
+ /* Fonts */
+ --font-normal: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
+ "Helvetica Neue", Arial, sans-serif;
+ --font-monospace: ui-monospace, Menlo, Monaco, "Cascadia Mono",
+ "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace",
+ "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
+
+ /* Borders */
+ --roundiness: 4px;
+
+ /* Colors */
+ --background: #ffffff;
+ --error-text: #ba1212;
+ --error-text-hover: #a10000;
+ --title-text: #090b11;
+ --box-background: #f3f4f7;
+ --box-background-hover: #dadbde;
+ --hint-text: #505d84;
+ --hint-text-hover: #37446b;
+ --border: #c3cadb;
+ --accent: #5f11a6;
+ --accent-hover: #792bc0;
+ --stack-text: #3d4663;
+ --misc-text: #6474a2;
+
+ --houston-overlay: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 3.95%,
+ rgba(255, 255, 255, 0.0086472) 9.68%,
+ rgba(255, 255, 255, 0.03551) 15.4%,
+ rgba(255, 255, 255, 0.0816599) 21.13%,
+ rgba(255, 255, 255, 0.147411) 26.86%,
+ rgba(255, 255, 255, 0.231775) 32.58%,
+ rgba(255, 255, 255, 0.331884) 38.31%,
+ rgba(255, 255, 255, 0.442691) 44.03%,
+ rgba(255, 255, 255, 0.557309) 49.76%,
+ rgba(255, 255, 255, 0.668116) 55.48%,
+ rgba(255, 255, 255, 0.768225) 61.21%,
+ rgba(255, 255, 255, 0.852589) 66.93%,
+ rgba(255, 255, 255, 0.91834) 72.66%,
+ rgba(255, 255, 255, 0.96449) 78.38%,
+ rgba(255, 255, 255, 0.991353) 84.11%,
+ #ffffff 89.84%
+ );
+
+ /* Theme toggle */
+ --toggle-ball-color: var(--accent);
+ --toggle-table-background: var(--background);
+ --sun-icon-color: #ffffff;
+ --moon-icon-color: #a3acc8;
+ --toggle-border-color: #C3CADB;
+
+ /* Syntax Highlighting */
+ --astro-code-foreground: #000000;
+ --astro-code-token-constant: #4ca48f;
+ --astro-code-token-string: #9f722a;
+ --astro-code-token-comment: #8490b5;
+ --astro-code-token-keyword: var(--accent);
+ --astro-code-token-parameter: #aa0000;
+ --astro-code-token-function: #4ca48f;
+ --astro-code-token-string-expression: #9f722a;
+ --astro-code-token-punctuation: #000000;
+ --astro-code-token-link: #9f722a;
+}
+
+:host(.astro-dark) {
+ --background: #090b11;
+ --error-text: #f49090;
+ --error-text-hover: #ffaaaa;
+ --title-text: #ffffff;
+ --box-background: #141925;
+ --box-background-hover: #2e333f;
+ --hint-text: #a3acc8;
+ --hint-text-hover: #bdc6e2;
+ --border: #283044;
+ --accent: #c490f4;
+ --accent-hover: #deaaff;
+ --stack-text: #c3cadb;
+ --misc-text: #8490b5;
+
+ --houston-overlay: linear-gradient(
+ 180deg,
+ rgba(9, 11, 17, 0) 3.95%,
+ rgba(9, 11, 17, 0.0086472) 9.68%,
+ rgba(9, 11, 17, 0.03551) 15.4%,
+ rgba(9, 11, 17, 0.0816599) 21.13%,
+ rgba(9, 11, 17, 0.147411) 26.86%,
+ rgba(9, 11, 17, 0.231775) 32.58%,
+ rgba(9, 11, 17, 0.331884) 38.31%,
+ rgba(9, 11, 17, 0.442691) 44.03%,
+ rgba(9, 11, 17, 0.557309) 49.76%,
+ rgba(9, 11, 17, 0.668116) 55.48%,
+ rgba(9, 11, 17, 0.768225) 61.21%,
+ rgba(9, 11, 17, 0.852589) 66.93%,
+ rgba(9, 11, 17, 0.91834) 72.66%,
+ rgba(9, 11, 17, 0.96449) 78.38%,
+ rgba(9, 11, 17, 0.991353) 84.11%,
+ #090b11 89.84%
+ );
+
+ /* Theme toggle */
+ --sun-icon-color: #505D84;
+ --moon-icon-color: #090B11;
+ --toggle-border-color: #3D4663;
+
+ /* Syntax Highlighting */
+ --astro-code-foreground: #ffffff;
+ --astro-code-token-constant: #90f4e3;
+ --astro-code-token-string: #f4cf90;
+ --astro-code-token-comment: #8490b5;
+ --astro-code-token-keyword: var(--accent);
+ --astro-code-token-parameter: #aa0000;
+ --astro-code-token-function: #90f4e3;
+ --astro-code-token-string-expression: #f4cf90;
+ --astro-code-token-punctuation: #ffffff;
+ --astro-code-token-link: #f4cf90;
+}
+
+#theme-toggle-wrapper{
+ position: relative;
+ display: inline-block
+}
+
+#theme-toggle-wrapper > div{
+ position: absolute;
+ right: 3px;
+ margin-top: 3px;
+}
+
+.theme-toggle-checkbox {
+ opacity: 0;
+ position: absolute;
+}
+
+#theme-toggle-label {
+ background-color: var(--toggle-table-background);
+ border-radius: 50px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 7.5px;
+ position: relative;
+ width: 66px;
+ height: 30px;
+ transform: scale(1.2);
+ box-shadow: 0 0 0 1px var(--toggle-border-color);
+ outline: 1px solid transparent;
+}
+
+.theme-toggle-checkbox:focus ~ #theme-toggle-label {
+ outline: 2px solid var(--toggle-border-color);
+ outline-offset: 4px;
+}
+
+#theme-toggle-label #theme-toggle-ball {
+ background-color: var(--accent);
+ border-radius: 50%;
+ position: absolute;
+ height: 30px;
+ width: 30px;
+ transform: translateX(-7.5px);
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+}
+
+@media (forced-colors: active) {
+ #theme-toggle-label {
+ --moon-icon-color: CanvasText;
+ --sun-icon-color: CanvasText;
+ }
+ #theme-toggle-label #theme-toggle-ball {
+ background-color: SelectedItem;
+ }
+}
+
+.theme-toggle-checkbox:checked + #theme-toggle-label #theme-toggle-ball {
+ transform: translateX(28.5px);
+}
+
+.sr-only {
+ border: 0 !important;
+ clip: rect(1px, 1px, 1px, 1px) !important;
+ -webkit-clip-path: inset(50%) !important;
+ clip-path: inset(50%) !important;
+ height: 1px !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ padding: 0 !important;
+ position: absolute !important;
+ width: 1px !important;
+ white-space: nowrap !important;
+}
+
+.icon-tabler{
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+ z-index: 10;
+}
+
+.icon-tabler-moon {
+ color: var(--moon-icon-color);
+}
+
+.icon-tabler-sun {
+ color: var(--sun-icon-color);
+}
+
+#backdrop {
+ font-family: var(--font-monospace);
+ position: fixed;
+ z-index: 99999;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--background);
+ overflow-y: auto;
+}
+
+#layout {
+ max-width: min(100%, 1280px);
+ position: relative;
+ width: 1280px;
+ margin: 0 auto;
+ padding: 40px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+@media (max-width: 768px) {
+ #header {
+ padding: 12px;
+ margin-top: 12px;
+ }
+
+ #theme-toggle-wrapper > div{
+ position: absolute;
+ right: 22px;
+ margin-top: 47px;
+ }
+
+ #layout {
+ padding: 0;
+ }
+}
+
+@media (max-width: 1024px) {
+ #houston,
+ #houston-overlay {
+ display: none;
+ }
+}
+
+#header {
+ position: relative;
+ margin-top: 48px;
+}
+
+#header-left {
+ min-height: 63px;
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ justify-content: end;
+}
+
+#name {
+ font-size: 18px;
+ font-weight: normal;
+ line-height: 22px;
+ color: var(--error-text);
+ margin: 0;
+ padding: 0;
+}
+
+#title {
+ font-size: 34px;
+ line-height: 41px;
+ font-weight: 600;
+ margin: 0;
+ padding: 0;
+ color: var(--title-text);
+ font-family: var(--font-normal);
+}
+
+#houston {
+ position: absolute;
+ bottom: -50px;
+ right: 32px;
+ z-index: -50;
+ color: var(--error-text);
+}
+
+#houston-overlay {
+ width: 175px;
+ height: 250px;
+ position: absolute;
+ bottom: -100px;
+ right: 32px;
+ z-index: -25;
+ background: var(--houston-overlay);
+}
+
+#message-hints,
+#stack,
+#code,
+#cause {
+ border-radius: var(--roundiness);
+ background-color: var(--box-background);
+}
+
+#message,
+#hint {
+ display: flex;
+ padding: 16px;
+ gap: 16px;
+}
+
+#message-content,
+#hint-content {
+ white-space: pre-wrap;
+ line-height: 26px;
+ flex-grow: 1;
+}
+
+#message {
+ color: var(--error-text);
+}
+
+#message-content a {
+ color: var(--error-text);
+}
+
+#message-content a:hover {
+ color: var(--error-text-hover);
+}
+
+#hint {
+ color: var(--hint-text);
+ border-top: 1px solid var(--border);
+ display: none;
+}
+
+#hint a {
+ color: var(--hint-text);
+}
+
+#hint a:hover {
+ color: var(--hint-text-hover);
+}
+
+#message-hints code {
+ font-family: var(--font-monospace);
+ background-color: var(--border);
+ padding: 2px 4px;
+ border-radius: var(--roundiness);
+ white-space: nowrap;
+}
+
+.link {
+ min-width: fit-content;
+ padding-right: 8px;
+ padding-top: 8px;
+}
+
+.link button {
+ background: none;
+ border: none;
+ font-size: inherit;
+ font-family: inherit;
+}
+
+.link a, .link button {
+ color: var(--accent);
+ text-decoration: none;
+ display: flex;
+ gap: 8px;
+}
+
+.link a:hover, .link button:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.link svg {
+ vertical-align: text-top;
+}
+
+#code {
+ display: none;
+}
+
+#code header {
+ padding: 24px;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+#code h2 {
+ font-family: var(--font-monospace);
+ color: var(--title-text);
+ font-size: 18px;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+#code .link {
+ padding: 0;
+}
+
+.shiki {
+ margin: 0;
+ border-top: 1px solid var(--border);
+ max-height: 17rem;
+ overflow: auto;
+}
+
+.shiki code {
+ font-family: var(--font-monospace);
+ counter-reset: step;
+ counter-increment: step 0;
+ font-size: 14px;
+ line-height: 21px;
+ tab-size: 1;
+}
+
+.shiki code .line:not(.error-caret)::before {
+ content: counter(step);
+ counter-increment: step;
+ width: 1rem;
+ margin-right: 16px;
+ display: inline-block;
+ text-align: right;
+ padding: 0 8px;
+ color: var(--misc-text);
+ border-right: solid 1px var(--border);
+}
+
+.shiki code .line:first-child::before {
+ padding-top: 8px;
+}
+
+.shiki code .line:last-child::before {
+ padding-bottom: 8px;
+}
+
+.error-line {
+ background-color: #f4909026;
+ display: inline-block;
+ width: 100%;
+}
+
+.error-caret {
+ margin-left: calc(33px + 1rem);
+ color: var(--error-text);
+}
+
+#stack h2,
+#cause h2 {
+ color: var(--title-text);
+ font-family: var(--font-normal);
+ font-size: 22px;
+ margin: 0;
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+#stack-content,
+#cause-content {
+ font-size: 14px;
+ white-space: pre;
+ line-height: 21px;
+ overflow: auto;
+ padding: 24px;
+ color: var(--stack-text);
+}
+
+#cause {
+ display: none;
+}
+`;
+
+const overlayTemplate = /* html */ `
+<style>
+${style.trim()}
+</style>
+<div id="backdrop">
+ <div id="layout">
+ <div id="theme-toggle-wrapper">
+ <div>
+ <input type="checkbox" class="theme-toggle-checkbox" id="chk" />
+ <label id="theme-toggle-label" for="chk">
+ <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="icon-tabler icon-tabler-sun" width="15px" height="15px" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+ <circle cx="12" cy="12" r="4" />
+ <path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7" />
+ </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="icon-tabler icon-tabler-moon" width="15" height="15" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+ <path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" />
+ </svg>
+ <div id="theme-toggle-ball">
+ <span class="sr-only">Use dark theme</span>
+ </div>
+ </label>
+ </div>
+ </div>
+ <header id="header">
+ <section id="header-left">
+ <h2 id="name"></h2>
+ <h1 id="title">An error occurred.</h1>
+ </section>
+ <div id="houston-overlay"></div>
+ <div id="houston">
+ <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="175" height="131" fill="none"><path fill="currentColor" d="M55.977 81.512c0 8.038-6.516 14.555-14.555 14.555S26.866 89.55 26.866 81.512c0-8.04 6.517-14.556 14.556-14.556 8.039 0 14.555 6.517 14.555 14.556Zm24.745-5.822c0-.804.651-1.456 1.455-1.456h11.645c.804 0 1.455.652 1.455 1.455v11.645c0 .804-.651 1.455-1.455 1.455H82.177a1.456 1.456 0 0 1-1.455-1.455V75.689Zm68.411 5.822c0 8.038-6.517 14.555-14.556 14.555-8.039 0-14.556-6.517-14.556-14.555 0-8.04 6.517-14.556 14.556-14.556 8.039 0 14.556 6.517 14.556 14.556Z"/><rect width="168.667" height="125" x="3.667" y="3" stroke="currentColor" stroke-width="4" rx="20.289"/></svg>
+ </div>
+ </header>
+
+ <section id="message-hints">
+ <section id="message">
+ <span id="message-icon">
+ <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="24" height="24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 7v6m0 4.01.01-.011M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/></svg>
+ </span>
+ <div id="message-content"></div>
+ </section>
+ <section id="hint">
+ <span id="hint-icon">
+ <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="24" height="24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 2-1 1M3 2l1 1m17 13-1-1M3 16l1-1m5 3h6m-5 3h4M12 3C8 3 5.952 4.95 6 8c.023 1.487.5 2.5 1.5 3.5S9 13 9 15h6c0-2 .5-2.5 1.5-3.5h0c1-1 1.477-2.013 1.5-3.5.048-3.05-2-5-6-5Z"/></svg>
+ </span>
+ <div id="hint-content"></div>
+ </section>
+ </section>
+
+ <section id="code">
+ <header>
+ <h2></h2>
+ </header>
+ <div id="code-content"></div>
+ </section>
+
+ <section id="stack">
+ <h2>Stack Trace</h2>
+ <div id="stack-content"></div>
+ </section>
+
+ <section id="cause">
+ <h2>Cause</h2>
+ <div id="cause-content"></div>
+ </section>
+ </div>
+</div>
+`;
+
+const openNewWindowIcon =
+ /* html */
+ '<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="16" height="16" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 2h-4m4 0L8 8m6-6v4"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M14 8.667V12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3.333"/></svg>';
+
+// Make HTMLElement available in non-browser environments
+const { HTMLElement = class {} as typeof globalThis.HTMLElement } = globalThis;
+class ErrorOverlay extends HTMLElement {
+ root: ShadowRoot;
+
+ constructor(err: AstroErrorPayload['err']) {
+ super();
+ this.root = this.attachShadow({ mode: 'open' });
+ this.root.innerHTML = overlayTemplate;
+ this.dir = 'ltr';
+
+ // theme toggle logic
+ const themeToggle = this.root.querySelector<HTMLInputElement>('.theme-toggle-checkbox');
+ if (
+ localStorage.astroErrorOverlayTheme === 'dark' ||
+ (!('astroErrorOverlayTheme' in localStorage) &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches)
+ ) {
+ this?.classList.add('astro-dark');
+ localStorage.astroErrorOverlayTheme = 'dark';
+ themeToggle!.checked = true;
+ } else {
+ this?.classList.remove('astro-dark');
+ themeToggle!.checked = false;
+ }
+ themeToggle?.addEventListener('click', () => {
+ const isDark =
+ localStorage.astroErrorOverlayTheme === 'dark' || this?.classList.contains('astro-dark');
+ this?.classList.toggle('astro-dark', !isDark);
+ localStorage.astroErrorOverlayTheme = isDark ? 'light' : 'dark';
+ });
+
+ this.text('#name', err.name);
+ this.text('#title', err.title);
+ this.text('#message-content', err.message, true);
+
+ const cause = this.root.querySelector<HTMLElement>('#cause');
+ if (cause && err.cause) {
+ if (typeof err.cause === 'string') {
+ this.text('#cause-content', err.cause);
+ cause.style.display = 'block';
+ } else {
+ this.text('#cause-content', JSON.stringify(err.cause, null, 2));
+ cause.style.display = 'block';
+ }
+ }
+
+ const hint = this.root.querySelector<HTMLElement>('#hint');
+ if (hint && err.hint) {
+ this.text('#hint-content', err.hint, true);
+ hint.style.display = 'flex';
+ }
+
+ const docslink = this.root.querySelector<HTMLElement>('#message');
+ if (docslink && err.docslink) {
+ docslink.appendChild(this.createLink(`See Docs Reference${openNewWindowIcon}`, err.docslink));
+ }
+
+ const code = this.root.querySelector<HTMLElement>('#code');
+ if (code && err.loc?.file) {
+ code.style.display = 'block';
+ const codeHeader = code.querySelector<HTMLHeadingElement>('#code header');
+ const codeContent = code.querySelector<HTMLDivElement>('#code-content');
+
+ if (codeHeader) {
+ const separator = err.loc.file.includes('/') ? '/' : '\\';
+ const cleanFile = err.loc.file.split(separator).slice(-2).join('/');
+ const fileLocation = [cleanFile, err.loc.line, err.loc.column].filter(Boolean).join(':');
+ const absoluteFileLocation = [err.loc.file, err.loc.line, err.loc.column]
+ .filter(Boolean)
+ .join(':');
+
+ const codeFile = codeHeader.getElementsByTagName('h2')[0];
+ codeFile.textContent = fileLocation;
+ codeFile.title = absoluteFileLocation;
+
+ const editorLink = this.createLink(`Open in editor${openNewWindowIcon}`, undefined);
+ editorLink.onclick = () => {
+ fetch('/__open-in-editor?file=' + encodeURIComponent(absoluteFileLocation));
+ };
+
+ codeHeader.appendChild(editorLink);
+ }
+
+ if (codeContent && err.highlightedCode) {
+ codeContent.innerHTML = err.highlightedCode;
+
+ window.requestAnimationFrame(() => {
+ // NOTE: This cannot be `codeContent.querySelector` because `codeContent` still contain the old HTML
+ const errorLine = this.root.querySelector<HTMLSpanElement>('.error-line');
+
+ if (errorLine) {
+ if (errorLine.parentElement?.parentElement) {
+ errorLine.parentElement.parentElement.scrollTop =
+ errorLine.offsetTop - errorLine.parentElement.parentElement.offsetTop - 8;
+ }
+
+ // Add an empty line below the error line so we can show a caret under the error
+ if (err.loc?.column) {
+ errorLine.insertAdjacentHTML(
+ 'afterend',
+ `\n<span class="line error-caret"><span style="padding-left:${
+ err.loc.column - 1
+ }ch;">^</span></span>`,
+ );
+ }
+ }
+ });
+ }
+ }
+
+ this.text('#stack-content', err.stack);
+ }
+
+ text(selector: string, text: string | undefined, html = false): void {
+ if (!text) {
+ return;
+ }
+
+ const el = this.root.querySelector(selector);
+
+ if (!el) {
+ return;
+ }
+
+ if (html) {
+ // Automatically detect links
+ text = text
+ .split(' ')
+ .map((v) => {
+ if (!v.startsWith('https://')) return v;
+ if (v.endsWith('.'))
+ return `<a target="_blank" href="${v.slice(0, -1)}">${v.slice(0, -1)}</a>.`;
+ return `<a target="_blank" href="${v}">${v}</a>`;
+ })
+ .join(' ');
+
+ el.innerHTML = text.trim();
+ } else {
+ el.textContent = text.trim();
+ }
+ }
+
+ createLink(text: string, href: string | undefined): HTMLDivElement {
+ const linkContainer = document.createElement('div');
+ const linkElement = href ? document.createElement('a') : document.createElement('button');
+ linkElement.innerHTML = text;
+
+ if (href && linkElement instanceof HTMLAnchorElement) {
+ linkElement.href = href;
+ linkElement.target = '_blank';
+ }
+
+ linkContainer.appendChild(linkElement);
+ linkContainer.className = 'link';
+
+ return linkContainer;
+ }
+
+ close(): void {
+ this.parentNode?.removeChild(this);
+ }
+}
+
+function getOverlayCode() {
+ return `
+ const overlayTemplate = \`${overlayTemplate}\`;
+ const openNewWindowIcon = \`${openNewWindowIcon}\`;
+ ${ErrorOverlay.toString()}
+ `;
+}
+
+export function patchOverlay(code: string) {
+ return code.replace('class ErrorOverlay', getOverlayCode() + '\nclass ViteErrorOverlay');
+}
diff --git a/packages/astro/src/core/errors/printer.ts b/packages/astro/src/core/errors/printer.ts
new file mode 100644
index 000000000..d33a3f6bf
--- /dev/null
+++ b/packages/astro/src/core/errors/printer.ts
@@ -0,0 +1,35 @@
+import type { ErrorLocation } from './errors.js';
+import { normalizeLF } from './utils.js';
+
+/** Generate a code frame from string and an error location */
+export function codeFrame(src: string, loc: ErrorLocation): string {
+ if (!loc || loc.line === undefined || loc.column === undefined) {
+ return '';
+ }
+ const lines = normalizeLF(src)
+ .split('\n')
+ .map((ln) => ln.replace(/\t/g, ' '));
+ // grab 2 lines before, and 3 lines after focused line
+ const visibleLines = [];
+ for (let n = -2; n <= 2; n++) {
+ if (lines[loc.line + n]) visibleLines.push(loc.line + n);
+ }
+ // figure out gutter width
+ let gutterWidth = 0;
+ for (const lineNo of visibleLines) {
+ let w = `> ${lineNo}`;
+ if (w.length > gutterWidth) gutterWidth = w.length;
+ }
+ // print lines
+ let output = '';
+ for (const lineNo of visibleLines) {
+ const isFocusedLine = lineNo === loc.line - 1;
+ output += isFocusedLine ? '> ' : ' ';
+ output += `${lineNo + 1} | ${lines[lineNo]}\n`;
+ if (isFocusedLine)
+ output += `${Array.from({ length: gutterWidth }).join(' ')} | ${Array.from({
+ length: loc.column,
+ }).join(' ')}^\n`;
+ }
+ return output;
+}
diff --git a/packages/astro/src/core/errors/userError.ts b/packages/astro/src/core/errors/userError.ts
new file mode 100644
index 000000000..663549314
--- /dev/null
+++ b/packages/astro/src/core/errors/userError.ts
@@ -0,0 +1 @@
+export { AstroUserError as AstroError } from './errors.js';
diff --git a/packages/astro/src/core/errors/utils.ts b/packages/astro/src/core/errors/utils.ts
new file mode 100644
index 000000000..6754656b9
--- /dev/null
+++ b/packages/astro/src/core/errors/utils.ts
@@ -0,0 +1,105 @@
+import type { YAMLException } from 'js-yaml';
+import type { ErrorPayload as ViteErrorPayload } from 'vite';
+import type { SSRError } from '../../types/public/internal.js';
+
+/**
+ * Get the line and character based on the offset
+ * @param offset The index of the position
+ * @param text The text for which the position should be retrieved
+ */
+export function positionAt(
+ offset: number,
+ text: string,
+): {
+ line: number;
+ column: number;
+} {
+ const lineOffsets = getLineOffsets(text);
+ offset = Math.max(0, Math.min(text.length, offset));
+
+ let low = 0;
+ let high = lineOffsets.length;
+ if (high === 0) {
+ return {
+ line: 0,
+ column: offset,
+ };
+ }
+
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ const lineOffset = lineOffsets[mid];
+
+ if (lineOffset === offset) {
+ return {
+ line: mid,
+ column: 0,
+ };
+ } else if (offset > lineOffset) {
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ // low is the least x for which the line offset is larger than the current offset
+ // or array.length if no line offset is larger than the current offset
+ const line = low - 1;
+ return { line, column: offset - lineOffsets[line] };
+}
+
+function getLineOffsets(text: string) {
+ const lineOffsets = [];
+ let isLineStart = true;
+
+ for (let i = 0; i < text.length; i++) {
+ if (isLineStart) {
+ lineOffsets.push(i);
+ isLineStart = false;
+ }
+ const ch = text.charAt(i);
+ isLineStart = ch === '\r' || ch === '\n';
+ if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') {
+ i++;
+ }
+ }
+
+ if (isLineStart && text.length > 0) {
+ lineOffsets.push(text.length);
+ }
+
+ return lineOffsets;
+}
+
+export function isYAMLException(err: unknown): err is YAMLException {
+ return err instanceof Error && err.name === 'YAMLException';
+}
+
+/** Format YAML exceptions as Vite errors */
+export function formatYAMLException(e: YAMLException): ViteErrorPayload['err'] {
+ return {
+ name: e.name,
+ id: e.mark.name,
+ loc: { file: e.mark.name, line: e.mark.line + 1, column: e.mark.column },
+ message: e.reason,
+ stack: e.stack ?? '',
+ };
+}
+
+/** Coalesce any throw variable to an Error instance. */
+export function createSafeError(err: any): Error {
+ if (err instanceof Error || (err?.name && err.message)) {
+ return err;
+ } else {
+ const error = new Error(JSON.stringify(err));
+
+ (error as SSRError).hint =
+ `To get as much information as possible from your errors, make sure to throw Error objects instead of \`${typeof err}\`. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error for more information.`;
+
+ return error;
+ }
+}
+
+export function normalizeLF(code: string) {
+ return code.replace(/\r\n|\r(?!\n)|\n/g, '\n');
+}
diff --git a/packages/astro/src/core/errors/zod-error-map.ts b/packages/astro/src/core/errors/zod-error-map.ts
new file mode 100644
index 000000000..4137191e4
--- /dev/null
+++ b/packages/astro/src/core/errors/zod-error-map.ts
@@ -0,0 +1,126 @@
+import type { ZodErrorMap } from 'zod';
+
+type TypeOrLiteralErrByPathEntry = {
+ code: 'invalid_type' | 'invalid_literal';
+ received: unknown;
+ expected: unknown[];
+};
+
+export const errorMap: ZodErrorMap = (baseError, ctx) => {
+ const baseErrorPath = flattenErrorPath(baseError.path);
+ if (baseError.code === 'invalid_union') {
+ // Optimization: Combine type and literal errors for keys that are common across ALL union types
+ // Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
+ // raise a single error when `key` does not match:
+ // > Did not match union.
+ // > key: Expected `'tutorial' | 'blog'`, received 'foo'
+ let typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>();
+ for (const unionError of baseError.unionErrors.map((e) => e.errors).flat()) {
+ if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
+ const flattenedErrorPath = flattenErrorPath(unionError.path);
+ if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
+ typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected);
+ } else {
+ typeOrLiteralErrByPath.set(flattenedErrorPath, {
+ code: unionError.code,
+ received: (unionError as any).received,
+ expected: [unionError.expected],
+ });
+ }
+ }
+ }
+ const messages: string[] = [prefix(baseErrorPath, 'Did not match union.')];
+ const details: string[] = [...typeOrLiteralErrByPath.entries()]
+ // If type or literal error isn't common to ALL union types,
+ // filter it out. Can lead to confusing noise.
+ .filter(([, error]) => error.expected.length === baseError.unionErrors.length)
+ .map(([key, error]) =>
+ key === baseErrorPath
+ ? // Avoid printing the key again if it's a base error
+ `> ${getTypeOrLiteralMsg(error)}`
+ : `> ${prefix(key, getTypeOrLiteralMsg(error))}`,
+ );
+
+ if (details.length === 0) {
+ const expectedShapes: string[] = [];
+ for (const unionError of baseError.unionErrors) {
+ const expectedShape: string[] = [];
+ for (const issue of unionError.issues) {
+ // If the issue is a nested union error, show the associated error message instead of the
+ // base error message.
+ if (issue.code === 'invalid_union') {
+ return errorMap(issue, ctx);
+ }
+ const relativePath = flattenErrorPath(issue.path)
+ .replace(baseErrorPath, '')
+ .replace(leadingPeriod, '');
+ if ('expected' in issue && typeof issue.expected === 'string') {
+ expectedShape.push(
+ relativePath ? `${relativePath}: ${issue.expected}` : issue.expected,
+ );
+ } else {
+ expectedShape.push(relativePath);
+ }
+ }
+ if (expectedShape.length === 1 && !expectedShape[0]?.includes(':')) {
+ // In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`.
+ expectedShapes.push(expectedShape.join(''));
+ } else {
+ expectedShapes.push(`{ ${expectedShape.join('; ')} }`);
+ }
+ }
+ if (expectedShapes.length) {
+ details.push('> Expected type `' + expectedShapes.join(' | ') + '`');
+ details.push('> Received `' + stringify(ctx.data) + '`');
+ }
+ }
+
+ return {
+ message: messages.concat(details).join('\n'),
+ };
+ } else if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
+ return {
+ message: prefix(
+ baseErrorPath,
+ getTypeOrLiteralMsg({
+ code: baseError.code,
+ received: (baseError as any).received,
+ expected: [baseError.expected],
+ }),
+ ),
+ };
+ } else if (baseError.message) {
+ return { message: prefix(baseErrorPath, baseError.message) };
+ } else {
+ return { message: prefix(baseErrorPath, ctx.defaultError) };
+ }
+};
+
+const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
+ // received could be `undefined` or the string `'undefined'`
+ if (typeof error.received === 'undefined' || error.received === 'undefined') return 'Required';
+ const expectedDeduped = new Set(error.expected);
+ switch (error.code) {
+ case 'invalid_type':
+ return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
+ error.received,
+ )}\``;
+ case 'invalid_literal':
+ return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
+ error.received,
+ )}\``;
+ }
+};
+
+const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);
+
+const unionExpectedVals = (expectedVals: Set<unknown>) =>
+ [...expectedVals].map((expectedVal) => stringify(expectedVal)).join(' | ');
+
+const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
+
+/** `JSON.stringify()` a value with spaces around object/array entries. */
+const stringify = (val: unknown) =>
+ JSON.stringify(val, null, 1).split(newlinePlusWhitespace).join(' ');
+const newlinePlusWhitespace = /\n\s*/;
+const leadingPeriod = /^\./;
diff --git a/packages/astro/src/core/fs/index.ts b/packages/astro/src/core/fs/index.ts
new file mode 100644
index 000000000..b9e3154c7
--- /dev/null
+++ b/packages/astro/src/core/fs/index.ts
@@ -0,0 +1,93 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const isWindows = process.platform === 'win32';
+
+export function removeEmptyDirs(dir: string): void {
+ if (!fs.statSync(dir).isDirectory()) return;
+ let files = fs.readdirSync(dir);
+
+ if (files.length > 0) {
+ files.map((file) => {
+ removeEmptyDirs(path.join(dir, file));
+ });
+ files = fs.readdirSync(dir);
+ }
+
+ if (files.length === 0) {
+ fs.rmdirSync(dir);
+ }
+}
+
+export function emptyDir(_dir: URL, skip?: Set<string>): void {
+ const dir = fileURLToPath(_dir);
+ if (!fs.existsSync(dir)) return undefined;
+ for (const file of fs.readdirSync(dir)) {
+ if (skip?.has(file)) {
+ continue;
+ }
+
+ const p = path.resolve(dir, file);
+ const rmOptions = { recursive: true, force: true, maxRetries: 3 };
+
+ try {
+ fs.rmSync(p, rmOptions);
+ } catch (er: any) {
+ if (er.code === 'ENOENT') {
+ return;
+ }
+ if (er.code === 'EPERM' && isWindows) {
+ fixWinEPERMSync(p, rmOptions, er);
+ }
+ }
+ }
+}
+
+/**
+ * https://github.com/isaacs/rimraf/blob/8c10fb8d685d5cc35708e0ffc4dac9ec5dd5b444/rimraf.js#L183
+ * @license ISC
+ * The ISC License
+ *
+ * Copyright (c) Isaac Z. Schlueter and Contributors
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+const fixWinEPERMSync = (p: string, options: fs.RmDirOptions, er: any) => {
+ try {
+ fs.chmodSync(p, 0o666);
+ } catch (er2: any) {
+ if (er2.code === 'ENOENT') {
+ return;
+ } else {
+ throw er;
+ }
+ }
+
+ let stats;
+ try {
+ stats = fs.statSync(p);
+ } catch (er3: any) {
+ if (er3.code === 'ENOENT') {
+ return;
+ } else {
+ throw er;
+ }
+ }
+
+ if (stats.isDirectory()) {
+ fs.rmdirSync(p, options);
+ } else {
+ fs.unlinkSync(p);
+ }
+};
diff --git a/packages/astro/src/core/index.ts b/packages/astro/src/core/index.ts
new file mode 100644
index 000000000..14a8c2f99
--- /dev/null
+++ b/packages/astro/src/core/index.ts
@@ -0,0 +1,26 @@
+// This is the main entrypoint when importing the `astro` package.
+
+import type { AstroInlineConfig } from '../types/public/config.js';
+import { default as _build } from './build/index.js';
+import { default as _sync } from './sync/index.js';
+
+export { default as dev } from './dev/index.js';
+export { default as preview } from './preview/index.js';
+
+/**
+ * Builds your site for deployment. By default, this will generate static files and place them in a dist/ directory.
+ * If SSR is enabled, this will generate the necessary server files to serve your site.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+// Wrap `_build` to prevent exposing the second internal options parameter
+export const build = (inlineConfig: AstroInlineConfig) => _build(inlineConfig);
+
+/**
+ * Generates TypeScript types for all Astro modules. This sets up a `src/env.d.ts` file for type inferencing,
+ * and defines the `astro:content` module for the Content Collections API.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+// Wrap `_sync` to prevent exposing internal options
+export const sync = (inlineConfig: AstroInlineConfig) => _sync(inlineConfig);
diff --git a/packages/astro/src/core/logger/console.ts b/packages/astro/src/core/logger/console.ts
new file mode 100644
index 000000000..30368bd86
--- /dev/null
+++ b/packages/astro/src/core/logger/console.ts
@@ -0,0 +1,16 @@
+import { type LogMessage, type LogWritable, getEventPrefix, levels } from './core.js';
+
+export const consoleLogDestination: LogWritable<LogMessage> = {
+ write(event: LogMessage) {
+ let dest = console.error;
+ if (levels[event.level] < levels['error']) {
+ dest = console.log;
+ }
+ if (event.label === 'SKIP_FORMAT') {
+ dest(event.message);
+ } else {
+ dest(getEventPrefix(event) + ' ' + event.message);
+ }
+ return true;
+ },
+};
diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts
new file mode 100644
index 000000000..e5c91f653
--- /dev/null
+++ b/packages/astro/src/core/logger/core.ts
@@ -0,0 +1,220 @@
+import { blue, bold, dim, red, yellow } from 'kleur/colors';
+
+export interface LogWritable<T> {
+ write: (chunk: T) => boolean;
+}
+
+export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // same as Pino
+
+/**
+ * Defined logger labels. Add more as needed, but keep them high-level & reusable,
+ * rather than specific to a single command, function, use, etc. The label will be
+ * shown in the log message to the user, so it should be relevant.
+ */
+export type LoggerLabel =
+ | 'add'
+ | 'build'
+ | 'check'
+ | 'config'
+ | 'content'
+ | 'crypto'
+ | 'deprecated'
+ | 'markdown'
+ | 'router'
+ | 'types'
+ | 'vite'
+ | 'watch'
+ | 'middleware'
+ | 'preferences'
+ | 'redirects'
+ | 'sync'
+ | 'toolbar'
+ | 'assets'
+ | 'env'
+ | 'update'
+ | 'adapter'
+ | 'islands'
+ // SKIP_FORMAT: A special label that tells the logger not to apply any formatting.
+ // Useful for messages that are already formatted, like the server start message.
+ | 'SKIP_FORMAT';
+
+export interface LogOptions {
+ dest: LogWritable<LogMessage>;
+ level: LoggerLevel;
+}
+
+// Hey, locales are pretty complicated! Be careful modifying this logic...
+// If we throw at the top-level, international users can't use Astro.
+//
+// Using `[]` sets the default locale properly from the system!
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#parameters
+//
+// Here be the dragons we've slain:
+// https://github.com/withastro/astro/issues/2625
+// https://github.com/withastro/astro/issues/3309
+export const dateTimeFormat = new Intl.DateTimeFormat([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+});
+
+export interface LogMessage {
+ label: string | null;
+ level: LoggerLevel;
+ message: string;
+ newLine: boolean;
+}
+
+export const levels: Record<LoggerLevel, number> = {
+ debug: 20,
+ info: 30,
+ warn: 40,
+ error: 50,
+ silent: 90,
+};
+
+/** Full logging API */
+export function log(
+ opts: LogOptions,
+ level: LoggerLevel,
+ label: string | null,
+ message: string,
+ newLine = true,
+) {
+ const logLevel = opts.level;
+ const dest = opts.dest;
+ const event: LogMessage = {
+ label,
+ level,
+ message,
+ newLine,
+ };
+
+ // test if this level is enabled or not
+ if (!isLogLevelEnabled(logLevel, level)) {
+ return; // do nothing
+ }
+
+ dest.write(event);
+}
+
+export function isLogLevelEnabled(configuredLogLevel: LoggerLevel, level: LoggerLevel) {
+ return levels[configuredLogLevel] <= levels[level];
+}
+
+/** Emit a user-facing message. Useful for UI and other console messages. */
+export function info(opts: LogOptions, label: string | null, message: string, newLine = true) {
+ return log(opts, 'info', label, message, newLine);
+}
+
+/** Emit a warning message. Useful for high-priority messages that aren't necessarily errors. */
+export function warn(opts: LogOptions, label: string | null, message: string, newLine = true) {
+ return log(opts, 'warn', label, message, newLine);
+}
+
+/** Emit a error message, Useful when Astro can't recover from some error. */
+export function error(opts: LogOptions, label: string | null, message: string, newLine = true) {
+ return log(opts, 'error', label, message, newLine);
+}
+
+export function debug(...args: any[]) {
+ if ('_astroGlobalDebug' in globalThis) {
+ (globalThis as any)._astroGlobalDebug(...args);
+ }
+}
+
+/**
+ * Get the prefix for a log message.
+ * This includes the timestamp, log level, and label all properly formatted
+ * with colors. This is shared across different loggers, so it's defined here.
+ */
+export function getEventPrefix({ level, label }: LogMessage) {
+ const timestamp = `${dateTimeFormat.format(new Date())}`;
+ const prefix = [];
+ if (level === 'error' || level === 'warn') {
+ prefix.push(bold(timestamp));
+ prefix.push(`[${level.toUpperCase()}]`);
+ } else {
+ prefix.push(timestamp);
+ }
+ if (label) {
+ prefix.push(`[${label}]`);
+ }
+ if (level === 'error') {
+ return red(prefix.join(' '));
+ }
+ if (level === 'warn') {
+ return yellow(prefix.join(' '));
+ }
+ if (prefix.length === 1) {
+ return dim(prefix[0]);
+ }
+ return dim(prefix[0]) + ' ' + blue(prefix.splice(1).join(' '));
+}
+
+/** Print out a timer message for debug() */
+export function timerMessage(message: string, startTime: number = Date.now()) {
+ let timeDiff = Date.now() - startTime;
+ let timeDisplay =
+ timeDiff < 750 ? `${Math.round(timeDiff)}ms` : `${(timeDiff / 1000).toFixed(1)}s`;
+ return `${message} ${dim(timeDisplay)}`;
+}
+
+export class Logger {
+ options: LogOptions;
+ constructor(options: LogOptions) {
+ this.options = options;
+ }
+
+ info(label: LoggerLabel | null, message: string, newLine = true) {
+ info(this.options, label, message, newLine);
+ }
+ warn(label: LoggerLabel | null, message: string, newLine = true) {
+ warn(this.options, label, message, newLine);
+ }
+ error(label: LoggerLabel | null, message: string, newLine = true) {
+ error(this.options, label, message, newLine);
+ }
+ debug(label: LoggerLabel, ...messages: any[]) {
+ debug(label, ...messages);
+ }
+
+ level() {
+ return this.options.level;
+ }
+
+ forkIntegrationLogger(label: string) {
+ return new AstroIntegrationLogger(this.options, label);
+ }
+}
+
+export class AstroIntegrationLogger {
+ options: LogOptions;
+ label: string;
+
+ constructor(logging: LogOptions, label: string) {
+ this.options = logging;
+ this.label = label;
+ }
+
+ /**
+ * Creates a new logger instance with a new label, but the same log options.
+ */
+ fork(label: string): AstroIntegrationLogger {
+ return new AstroIntegrationLogger(this.options, label);
+ }
+
+ info(message: string) {
+ info(this.options, this.label, message);
+ }
+ warn(message: string) {
+ warn(this.options, this.label, message);
+ }
+ error(message: string) {
+ error(this.options, this.label, message);
+ }
+ debug(message: string) {
+ debug(this.label, message);
+ }
+}
diff --git a/packages/astro/src/core/logger/node.ts b/packages/astro/src/core/logger/node.ts
new file mode 100644
index 000000000..f78d4d247
--- /dev/null
+++ b/packages/astro/src/core/logger/node.ts
@@ -0,0 +1,49 @@
+import type { Writable } from 'node:stream';
+import debugPackage from 'debug';
+import { type LogMessage, type LogWritable, getEventPrefix, levels } from './core.js';
+
+type ConsoleStream = Writable & {
+ fd: 1 | 2;
+};
+
+export const nodeLogDestination: LogWritable<LogMessage> = {
+ write(event: LogMessage) {
+ let dest: ConsoleStream = process.stderr;
+ if (levels[event.level] < levels['error']) {
+ dest = process.stdout;
+ }
+ let trailingLine = event.newLine ? '\n' : '';
+ if (event.label === 'SKIP_FORMAT') {
+ dest.write(event.message + trailingLine);
+ } else {
+ dest.write(getEventPrefix(event) + ' ' + event.message + trailingLine);
+ }
+ return true;
+ },
+};
+
+const debuggers: Record<string, debugPackage.Debugger['log']> = {};
+
+/**
+ * Emit a message only shown in debug mode.
+ * Astro (along with many of its dependencies) uses the `debug` package for debug logging.
+ * You can enable these logs with the `DEBUG=astro:*` environment variable.
+ * More info https://github.com/debug-js/debug#environment-variables
+ */
+function debug(type: string, ...messages: Array<any>) {
+ const namespace = `astro:${type}`;
+ debuggers[namespace] = debuggers[namespace] || debugPackage(namespace);
+ return debuggers[namespace](...messages);
+}
+
+// This is gross, but necessary since we are depending on globals.
+(globalThis as any)._astroGlobalDebug = debug;
+
+export function enableVerboseLogging() {
+ debugPackage.enable('astro:*,vite:*');
+ debug('cli', '--verbose flag enabled! Enabling: DEBUG="astro:*,vite:*"');
+ debug(
+ 'cli',
+ 'Tip: Set the DEBUG env variable directly for more control. Example: "DEBUG=astro:*,vite:* astro build".',
+ );
+}
diff --git a/packages/astro/src/core/logger/vite.ts b/packages/astro/src/core/logger/vite.ts
new file mode 100644
index 000000000..e8c9717b2
--- /dev/null
+++ b/packages/astro/src/core/logger/vite.ts
@@ -0,0 +1,106 @@
+import { fileURLToPath } from 'node:url';
+import { stripVTControlCharacters } from 'node:util';
+import type { LogLevel, Rollup, Logger as ViteLogger } from 'vite';
+import { isAstroError } from '../errors/errors.js';
+import { serverShortcuts as formatServerShortcuts } from '../messages.js';
+import { type Logger as AstroLogger, isLogLevelEnabled } from './core.js';
+
+const PKG_PREFIX = fileURLToPath(new URL('../../../', import.meta.url));
+const E2E_PREFIX = fileURLToPath(new URL('../../../e2e', import.meta.url));
+function isAstroSrcFile(id: string | null) {
+ return id?.startsWith(PKG_PREFIX) && !id.startsWith(E2E_PREFIX);
+}
+
+// capture "page reload some/Component.vue (additional info)" messages
+const vitePageReloadMsg = /page reload (.*)/;
+// capture "hmr update some/Component.vue" messages
+const viteHmrUpdateMsg = /hmr update (.*)/;
+// capture "vite v5.0.0 building SSR bundle for production..." and "vite v5.0.0 building for production..." messages
+const viteBuildMsg = /vite.*building.*for production/;
+// capture "\n Shortcuts" messages
+const viteShortcutTitleMsg = /^\s*Shortcuts\s*$/;
+// capture "press * + enter to ..." messages
+const viteShortcutHelpMsg = /press (.+?) to (.+)$/s;
+
+export function createViteLogger(
+ astroLogger: AstroLogger,
+ viteLogLevel: LogLevel = 'info',
+): ViteLogger {
+ const warnedMessages = new Set<string>();
+ const loggedErrors = new WeakSet<Error | Rollup.RollupError>();
+
+ const logger: ViteLogger = {
+ hasWarned: false,
+ info(msg) {
+ if (!isLogLevelEnabled(viteLogLevel, 'info')) return;
+
+ const stripped = stripVTControlCharacters(msg);
+ let m;
+ // Rewrite HMR page reload message
+ if ((m = vitePageReloadMsg.exec(stripped))) {
+ if (isAstroSrcFile(m[1])) return;
+ astroLogger.info('watch', m[1]);
+ }
+ // Rewrite HMR update message
+ else if ((m = viteHmrUpdateMsg.exec(stripped))) {
+ if (isAstroSrcFile(m[1])) return;
+ astroLogger.info('watch', m[1]);
+ }
+ // Don't log Vite build messages and shortcut titles
+ else if (viteBuildMsg.test(stripped) || viteShortcutTitleMsg.test(stripped)) {
+ // noop
+ }
+ // Log shortcuts help messages without indent
+ else if (viteShortcutHelpMsg.test(stripped)) {
+ const [, key, label] = viteShortcutHelpMsg.exec(stripped)! as string[];
+ astroLogger.info('SKIP_FORMAT', formatServerShortcuts({ key, label }));
+ }
+ // Fallback
+ else {
+ astroLogger.info('vite', msg);
+ }
+ },
+ warn(msg) {
+ if (!isLogLevelEnabled(viteLogLevel, 'warn')) return;
+
+ logger.hasWarned = true;
+ astroLogger.warn('vite', msg);
+ },
+ warnOnce(msg) {
+ if (!isLogLevelEnabled(viteLogLevel, 'warn')) return;
+
+ if (warnedMessages.has(msg)) return;
+ logger.hasWarned = true;
+ astroLogger.warn('vite', msg);
+ warnedMessages.add(msg);
+ },
+ error(msg, opts) {
+ if (!isLogLevelEnabled(viteLogLevel, 'error')) return;
+
+ logger.hasWarned = true;
+
+ const err = opts?.error;
+ if (err) loggedErrors.add(err);
+ // Astro errors are already logged by us, skip logging
+ if (err && isAstroError(err)) return;
+ // SSR module and pre-transform errors are always handled by us,
+ // send to debug logs
+ if (
+ msg.includes('Error when evaluating SSR module') ||
+ msg.includes('Pre-transform error:')
+ ) {
+ astroLogger.debug('vite', msg);
+ return;
+ }
+
+ astroLogger.error('vite', msg);
+ },
+ // Don't allow clear screen
+ clearScreen: () => {},
+ hasErrorLogged(error) {
+ return loggedErrors.has(error);
+ },
+ };
+
+ return logger;
+}
diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts
new file mode 100644
index 000000000..66011febf
--- /dev/null
+++ b/packages/astro/src/core/messages.ts
@@ -0,0 +1,407 @@
+import {
+ bgCyan,
+ bgGreen,
+ bgRed,
+ bgWhite,
+ bgYellow,
+ black,
+ blue,
+ bold,
+ cyan,
+ dim,
+ green,
+ red,
+ underline,
+ yellow,
+} from 'kleur/colors';
+import type { ResolvedServerUrls } from 'vite';
+import type { ZodError } from 'zod';
+import { getExecCommand } from '../cli/install-package.js';
+import { getDocsForError, renderErrorMarkdown } from './errors/dev/utils.js';
+import {
+ AstroError,
+ AstroUserError,
+ CompilerError,
+ type ErrorWithMetadata,
+} from './errors/index.js';
+import { padMultilineString } from './util.js';
+
+/**
+ * Prestyled messages for the CLI. Used by astro CLI commands.
+ */
+
+/** Display each request being served with the path and the status code. */
+export function req({
+ url,
+ method,
+ statusCode,
+ reqTime,
+ isRewrite,
+}: {
+ url: string;
+ statusCode: number;
+ method?: string;
+ reqTime?: number;
+ isRewrite?: boolean;
+}): string {
+ const color = statusCode >= 500 ? red : statusCode >= 300 ? yellow : blue;
+ return (
+ color(`[${statusCode}]`) +
+ ` ` +
+ `${isRewrite ? color('(rewrite) ') : ''}` +
+ (method && method !== 'GET' ? color(method) + ' ' : '') +
+ url +
+ ` ` +
+ (reqTime ? dim(Math.round(reqTime) + 'ms') : '')
+ );
+}
+
+/** Display server host and startup time */
+export function serverStart({
+ startupTime,
+ resolvedUrls,
+ host,
+ base,
+}: {
+ startupTime: number;
+ resolvedUrls: ResolvedServerUrls;
+ host: string | boolean;
+ base: string;
+}): string {
+ // PACKAGE_VERSION is injected at build-time
+ const version = process.env.PACKAGE_VERSION ?? '0.0.0';
+ const localPrefix = `${dim('┃')} Local `;
+ const networkPrefix = `${dim('┃')} Network `;
+ const emptyPrefix = ' '.repeat(11);
+
+ const localUrlMessages = resolvedUrls.local.map((url, i) => {
+ return `${i === 0 ? localPrefix : emptyPrefix}${cyan(new URL(url).origin + base)}`;
+ });
+ const networkUrlMessages = resolvedUrls.network.map((url, i) => {
+ return `${i === 0 ? networkPrefix : emptyPrefix}${cyan(new URL(url).origin + base)}`;
+ });
+
+ if (networkUrlMessages.length === 0) {
+ const networkLogging = getNetworkLogging(host);
+ if (networkLogging === 'host-to-expose') {
+ networkUrlMessages.push(`${networkPrefix}${dim('use --host to expose')}`);
+ } else if (networkLogging === 'visible') {
+ networkUrlMessages.push(`${networkPrefix}${dim('unable to find network to expose')}`);
+ }
+ }
+
+ const messages = [
+ '',
+ `${bgGreen(bold(` astro `))} ${green(`v${version}`)} ${dim(`ready in`)} ${Math.round(
+ startupTime,
+ )} ${dim('ms')}`,
+ '',
+ ...localUrlMessages,
+ ...networkUrlMessages,
+ '',
+ ];
+ return messages.filter((msg) => typeof msg === 'string').join('\n');
+}
+
+/** Display custom dev server shortcuts */
+export function serverShortcuts({ key, label }: { key: string; label: string }): string {
+ return [dim(' Press'), key, dim('to'), label].join(' ');
+}
+
+export async function newVersionAvailable({ latestVersion }: { latestVersion: string }) {
+ const badge = bgYellow(black(` update `));
+ const headline = yellow(`▶ New version of Astro available: ${latestVersion}`);
+ const execCommand = await getExecCommand();
+
+ const details = ` Run ${cyan(`${execCommand} @astrojs/upgrade`)} to update`;
+ return ['', `${badge} ${headline}`, details, ''].join('\n');
+}
+
+export function telemetryNotice() {
+ const headline = blue(`▶ Astro collects anonymous usage data.`);
+ const why = ' This information helps us improve Astro.';
+ const disable = ` Run "astro telemetry disable" to opt-out.`;
+ const details = ` ${cyan(underline('https://astro.build/telemetry'))}`;
+ return [headline, why, disable, details].join('\n');
+}
+
+export function telemetryEnabled() {
+ return [
+ green('▶ Anonymous telemetry ') + bgGreen(' enabled '),
+ ` Thank you for helping us improve Astro!`,
+ ``,
+ ].join('\n');
+}
+
+export function preferenceEnabled(name: string) {
+ return `${green('◉')} ${name} is now ${bgGreen(black(' enabled '))}\n`;
+}
+
+export function preferenceSet(name: string, value: any) {
+ return `${green('◉')} ${name} has been set to ${bgGreen(black(` ${JSON.stringify(value)} `))}\n`;
+}
+
+export function preferenceGet(name: string, value: any) {
+ return `${green('◉')} ${name} is set to ${bgGreen(black(` ${JSON.stringify(value)} `))}\n`;
+}
+
+export function preferenceDefaultIntro(name: string) {
+ return `${yellow('◯')} ${name} has not been set. It defaults to\n`;
+}
+
+export function preferenceDefault(name: string, value: any) {
+ return `${yellow('◯')} ${name} has not been set. It defaults to ${bgYellow(
+ black(` ${JSON.stringify(value)} `),
+ )}\n`;
+}
+
+export function preferenceDisabled(name: string) {
+ return `${yellow('◯')} ${name} is now ${bgYellow(black(' disabled '))}\n`;
+}
+
+export function preferenceReset(name: string) {
+ return `${cyan('◆')} ${name} has been ${bgCyan(black(' reset '))}\n`;
+}
+
+export function telemetryDisabled() {
+ return [
+ green('▶ Anonymous telemetry ') + bgGreen(' disabled '),
+ ` Astro is no longer collecting anonymous usage data.`,
+ ``,
+ ].join('\n');
+}
+
+export function telemetryReset() {
+ return [green('▶ Anonymous telemetry preferences reset.'), ``].join('\n');
+}
+
+export function fsStrictWarning() {
+ const title = yellow('▶ ' + `${bold('vite.server.fs.strict')} has been disabled!`);
+ const subtitle = ` Files on your machine are likely accessible on your network.`;
+ return `${title}\n${subtitle}\n`;
+}
+
+export function prerelease({ currentVersion }: { currentVersion: string }) {
+ const tag = currentVersion.split('-').slice(1).join('-').replace(/\..*$/, '') || 'unknown';
+ const badge = bgYellow(black(` ${tag} `));
+ const title = yellow('▶ ' + `This is a ${badge} prerelease build!`);
+ const subtitle = ` Report issues here: ${cyan(underline('https://astro.build/issues'))}`;
+ return `${title}\n${subtitle}\n`;
+}
+
+export function success(message: string, tip?: string) {
+ const badge = bgGreen(black(` success `));
+ const headline = green(message);
+ const footer = tip ? `\n ▶ ${tip}` : undefined;
+ return ['', `${badge} ${headline}`, footer]
+ .filter((v) => v !== undefined)
+ .map((msg) => ` ${msg}`)
+ .join('\n');
+}
+
+export function failure(message: string, tip?: string) {
+ const badge = bgRed(black(` error `));
+ const headline = red(message);
+ const footer = tip ? `\n ▶ ${tip}` : undefined;
+ return ['', `${badge} ${headline}`, footer]
+ .filter((v) => v !== undefined)
+ .map((msg) => ` ${msg}`)
+ .join('\n');
+}
+
+export function actionRequired(message: string) {
+ const badge = bgYellow(black(` action required `));
+ const headline = yellow(message);
+ return ['', `${badge} ${headline}`]
+ .filter((v) => v !== undefined)
+ .map((msg) => ` ${msg}`)
+ .join('\n');
+}
+
+export function cancelled(message: string, tip?: string) {
+ const badge = bgYellow(black(` cancelled `));
+ const headline = yellow(message);
+ const footer = tip ? `\n ▶ ${tip}` : undefined;
+ return ['', `${badge} ${headline}`, footer]
+ .filter((v) => v !== undefined)
+ .map((msg) => ` ${msg}`)
+ .join('\n');
+}
+
+const LOCAL_IP_HOSTS = new Set(['localhost', '127.0.0.1']);
+
+function getNetworkLogging(host: string | boolean): 'none' | 'host-to-expose' | 'visible' {
+ if (host === false) {
+ return 'host-to-expose';
+ } else if (typeof host === 'string' && LOCAL_IP_HOSTS.has(host)) {
+ return 'none';
+ } else {
+ return 'visible';
+ }
+}
+
+const codeRegex = /`([^`]+)`/g;
+
+export function formatConfigErrorMessage(err: ZodError) {
+ const errorList = err.issues.map((issue) =>
+ `! ${renderErrorMarkdown(issue.message, 'cli')}`
+ // Make text wrapped in backticks blue.
+ .replaceAll(codeRegex, blue('$1'))
+ // Make the first line red and indent the rest.
+ .split('\n')
+ .map((line, index) => (index === 0 ? red(line) : ' ' + line))
+ .join('\n'),
+ );
+ return `${red('[config]')} Astro found issue(s) with your configuration:\n\n${errorList.join(
+ '\n\n',
+ )}`;
+}
+
+// a regex to match the first line of a stack trace
+const STACK_LINE_REGEXP = /^\s+at /g;
+const IRRELEVANT_STACK_REGEXP = /node_modules|astro[/\\]dist/g;
+
+function formatErrorStackTrace(
+ err: Error | ErrorWithMetadata,
+ showFullStacktrace: boolean,
+): string {
+ const stackLines = (err.stack || '').split('\n').filter((line) => STACK_LINE_REGEXP.test(line));
+ // If full details are required, just return the entire stack trace.
+ if (showFullStacktrace) {
+ return stackLines.join('\n');
+ }
+ // Grab every string from the user's codebase, exit when you hit node_modules or astro/dist
+ const irrelevantStackIndex = stackLines.findIndex((line) => IRRELEVANT_STACK_REGEXP.test(line));
+ if (irrelevantStackIndex <= 0) {
+ const errorId = (err as ErrorWithMetadata).id;
+ const errorLoc = (err as ErrorWithMetadata).loc;
+ if (errorId || errorLoc?.file) {
+ const prettyLocation = ` at ${errorId ?? errorLoc?.file}${
+ errorLoc?.line && errorLoc.column ? `:${errorLoc.line}:${errorLoc.column}` : ''
+ }`;
+ return (
+ prettyLocation + '\n [...] See full stack trace in the browser, or rerun with --verbose.'
+ );
+ } else {
+ return stackLines.join('\n');
+ }
+ }
+ // If the error occurred inside of a dependency, grab the entire stack.
+ // Otherwise, only grab the part of the stack that is relevant to the user's codebase.
+ return (
+ stackLines.splice(0, irrelevantStackIndex).join('\n') +
+ '\n [...] See full stack trace in the browser, or rerun with --verbose.'
+ );
+}
+
+export function formatErrorMessage(err: ErrorWithMetadata, showFullStacktrace: boolean): string {
+ const isOurError = AstroError.is(err) || CompilerError.is(err) || AstroUserError.is(err);
+ let message = '';
+ if (isOurError) {
+ message += red(`[${err.name}]`) + ' ' + renderErrorMarkdown(err.message, 'cli');
+ } else {
+ message += err.message;
+ }
+ const output = [message];
+
+ if (err.hint) {
+ output.push(` ${bold('Hint:')}`);
+ output.push(yellow(padMultilineString(renderErrorMarkdown(err.hint, 'cli'), 4)));
+ }
+
+ const docsLink = getDocsForError(err);
+ if (docsLink) {
+ output.push(` ${bold('Error reference:')}`);
+ output.push(` ${cyan(underline(docsLink))}`);
+ }
+
+ if (showFullStacktrace && err.loc) {
+ output.push(` ${bold('Location:')}`);
+ output.push(` ${underline(`${err.loc.file}:${err.loc.line ?? 0}:${err.loc.column ?? 0}`)}`);
+ }
+
+ if (err.stack) {
+ output.push(` ${bold('Stack trace:')}`);
+ output.push(dim(formatErrorStackTrace(err, showFullStacktrace)));
+ }
+
+ if (err.cause) {
+ output.push(` ${bold('Caused by:')}`);
+ let causeMessage = ' ';
+ if (err.cause instanceof Error) {
+ causeMessage +=
+ err.cause.message + '\n' + formatErrorStackTrace(err.cause, showFullStacktrace);
+ } else {
+ causeMessage += JSON.stringify(err.cause);
+ }
+ output.push(dim(causeMessage));
+ }
+
+ return output.join('\n');
+}
+
+export function printHelp({
+ commandName,
+ headline,
+ usage,
+ tables,
+ description,
+}: {
+ commandName: string;
+ headline?: string;
+ usage?: string;
+ tables?: Record<string, [command: string, help: string][]>;
+ description?: string;
+}) {
+ const linebreak = () => '';
+ const title = (label: string) => ` ${bgWhite(black(` ${label} `))}`;
+ const table = (rows: [string, string][], { padding }: { padding: number }) => {
+ const split = process.stdout.columns < 60;
+ let raw = '';
+
+ for (const row of rows) {
+ if (split) {
+ raw += ` ${row[0]}\n `;
+ } else {
+ raw += `${`${row[0]}`.padStart(padding)}`;
+ }
+ raw += ' ' + dim(row[1]) + '\n';
+ }
+
+ return raw.slice(0, -1); // remove latest \n
+ };
+
+ let message = [];
+
+ if (headline) {
+ message.push(
+ linebreak(),
+ ` ${bgGreen(black(` ${commandName} `))} ${green(
+ `v${process.env.PACKAGE_VERSION ?? ''}`,
+ )} ${headline}`,
+ );
+ }
+
+ if (usage) {
+ message.push(linebreak(), ` ${green(commandName)} ${bold(usage)}`);
+ }
+
+ if (tables) {
+ function calculateTablePadding(rows: [string, string][]) {
+ return rows.reduce((val, [first]) => Math.max(val, first.length), 0) + 2;
+ }
+
+ const tableEntries = Object.entries(tables);
+ const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows)));
+ for (const [tableTitle, tableRows] of tableEntries) {
+ message.push(linebreak(), title(tableTitle), table(tableRows, { padding }));
+ }
+ }
+
+ if (description) {
+ message.push(linebreak(), `${description}`);
+ }
+
+ // biome-ignore lint/suspicious/noConsoleLog: allowed
+ console.log(message.join('\n') + '\n');
+}
diff --git a/packages/astro/src/core/middleware/callMiddleware.ts b/packages/astro/src/core/middleware/callMiddleware.ts
new file mode 100644
index 000000000..4cc7b6586
--- /dev/null
+++ b/packages/astro/src/core/middleware/callMiddleware.ts
@@ -0,0 +1,104 @@
+import type {
+ MiddlewareHandler,
+ MiddlewareNext,
+ RewritePayload,
+} from '../../types/public/common.js';
+import type { APIContext } from '../../types/public/context.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+
+/**
+ * Utility function that is in charge of calling the middleware.
+ *
+ * It accepts a `R` generic, which usually is the `Response` returned.
+ * It is a generic because endpoints can return a different payload.
+ *
+ * When calling a middleware, we provide a `next` function, this function might or
+ * might not be called.
+ *
+ * A middleware, to behave correctly, can:
+ * - return a `Response`;
+ * - call `next`;
+ *
+ * Failing doing so will result an error. A middleware can call `next` and do not return a
+ * response. A middleware can not call `next` and return a new `Response` from scratch (maybe with a redirect).
+ *
+ * ```js
+ * const onRequest = async (context, next) => {
+ * const response = await next(context);
+ * return response;
+ * }
+ * ```
+ *
+ * ```js
+ * const onRequest = async (context, next) => {
+ * context.locals = "foo";
+ * next();
+ * }
+ * ```
+ *
+ * @param onRequest The function called which accepts a `context` and a `resolve` function
+ * @param apiContext The API context
+ * @param responseFunction A callback function that should return a promise with the response
+ */
+export async function callMiddleware(
+ onRequest: MiddlewareHandler,
+ apiContext: APIContext,
+ responseFunction: (
+ apiContext: APIContext,
+ rewritePayload?: RewritePayload,
+ ) => Promise<Response> | Response,
+): Promise<Response> {
+ let nextCalled = false;
+ let responseFunctionPromise: Promise<Response> | Response | undefined = undefined;
+ const next: MiddlewareNext = async (payload) => {
+ nextCalled = true;
+ responseFunctionPromise = responseFunction(apiContext, payload);
+ // We need to pass the APIContext pass to `callMiddleware` because it can be mutated across middleware functions
+ return responseFunctionPromise;
+ };
+
+ let middlewarePromise = onRequest(apiContext, next);
+
+ return await Promise.resolve(middlewarePromise).then(async (value) => {
+ // first we check if `next` was called
+ if (nextCalled) {
+ /**
+ * Then we check if a value is returned. If so, we need to return the value returned by the
+ * middleware.
+ * e.g.
+ * ```js
+ * const response = await next();
+ * const new Response(null, { status: 500, headers: response.headers });
+ * ```
+ */
+ if (typeof value !== 'undefined') {
+ if (value instanceof Response === false) {
+ throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
+ }
+ return value;
+ } else {
+ /**
+ * Here we handle the case where `next` was called and returned nothing.
+ */
+ if (responseFunctionPromise) {
+ return responseFunctionPromise;
+ } else {
+ throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
+ }
+ }
+ } else if (typeof value === 'undefined') {
+ /**
+ * There might be cases where `next` isn't called and the middleware **must** return
+ * something.
+ *
+ * If not thing is returned, then we raise an Astro error.
+ */
+ throw new AstroError(AstroErrorData.MiddlewareNoDataOrNextCalled);
+ } else if (value instanceof Response === false) {
+ throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
+ } else {
+ // Middleware did not call resolve and returned a value
+ return value;
+ }
+ });
+}
diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts
new file mode 100644
index 000000000..7f4c2867e
--- /dev/null
+++ b/packages/astro/src/core/middleware/index.ts
@@ -0,0 +1,205 @@
+import { createCallAction, createGetActionResult } from '../../actions/utils.js';
+import {
+ computeCurrentLocale,
+ computePreferredLocale,
+ computePreferredLocaleList,
+} from '../../i18n/utils.js';
+import type { MiddlewareHandler, Params, RewritePayload } from '../../types/public/common.js';
+import type { APIContext } from '../../types/public/context.js';
+import { ASTRO_VERSION, clientLocalsSymbol } from '../constants.js';
+import { AstroCookies } from '../cookies/index.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { getClientIpAddress } from '../routing/request.js';
+import { getOriginPathname } from '../routing/rewrite.js';
+import { sequence } from './sequence.js';
+
+function defineMiddleware(fn: MiddlewareHandler) {
+ return fn;
+}
+
+/**
+ * Payload for creating a context to be passed to Astro middleware
+ */
+export type CreateContext = {
+ /**
+ * The incoming request
+ */
+ request: Request;
+ /**
+ * Optional parameters
+ */
+ params?: Params;
+
+ /**
+ * A list of locales that are supported by the user
+ */
+ userDefinedLocales?: string[];
+
+ /**
+ * User defined default locale
+ */
+ defaultLocale: string;
+
+ /**
+ * Initial value of the locals
+ */
+ locals: App.Locals;
+};
+
+/**
+ * Creates a context to be passed to Astro middleware `onRequest` function.
+ */
+function createContext({
+ request,
+ params = {},
+ userDefinedLocales = [],
+ defaultLocale = '',
+ locals,
+}: CreateContext): APIContext {
+ let preferredLocale: string | undefined = undefined;
+ let preferredLocaleList: string[] | undefined = undefined;
+ let currentLocale: string | undefined = undefined;
+ let clientIpAddress: string | undefined;
+ const url = new URL(request.url);
+ const route = url.pathname;
+
+ // TODO verify that this function works in an edge middleware environment
+ const rewrite = (_reroutePayload: RewritePayload) => {
+ // return dummy response
+ return Promise.resolve(new Response(null));
+ };
+ const context: Omit<APIContext, 'getActionResult' | 'callAction'> = {
+ cookies: new AstroCookies(request),
+ request,
+ params,
+ site: undefined,
+ generator: `Astro v${ASTRO_VERSION}`,
+ props: {},
+ rewrite,
+ routePattern: '',
+ redirect(path, status) {
+ return new Response(null, {
+ status: status || 302,
+ headers: {
+ Location: path,
+ },
+ });
+ },
+ isPrerendered: false,
+ get preferredLocale(): string | undefined {
+ return (preferredLocale ??= computePreferredLocale(request, userDefinedLocales));
+ },
+ get preferredLocaleList(): string[] | undefined {
+ return (preferredLocaleList ??= computePreferredLocaleList(request, userDefinedLocales));
+ },
+ get currentLocale(): string | undefined {
+ return (currentLocale ??= computeCurrentLocale(route, userDefinedLocales, defaultLocale));
+ },
+ url,
+ get originPathname() {
+ return getOriginPathname(request);
+ },
+ get clientAddress() {
+ if (clientIpAddress) {
+ return clientIpAddress;
+ }
+ clientIpAddress = getClientIpAddress(request);
+ if (!clientIpAddress) {
+ throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
+ }
+ return clientIpAddress;
+ },
+ get locals() {
+ // TODO: deprecate this usage. This is used only by the edge middleware for now, so its usage should be basically none.
+ let _locals = locals ?? Reflect.get(request, clientLocalsSymbol);
+ if (locals === undefined) {
+ _locals = {};
+ }
+ if (typeof _locals !== 'object') {
+ throw new AstroError(AstroErrorData.LocalsNotAnObject);
+ }
+ return _locals;
+ },
+ set locals(_) {
+ throw new AstroError(AstroErrorData.LocalsReassigned);
+ },
+ };
+ return Object.assign(context, {
+ getActionResult: createGetActionResult(context.locals),
+ callAction: createCallAction(context),
+ });
+}
+
+/**
+ * Checks whether the passed `value` is serializable.
+ *
+ * A serializable value contains plain values. For example, `Proxy`, `Set`, `Map`, functions, etc.
+ * are not accepted because they can't be serialized.
+ */
+function isLocalsSerializable(value: unknown): boolean {
+ let type = typeof value;
+ let plainObject = true;
+ if (type === 'object' && isPlainObject(value)) {
+ for (const [, nestedValue] of Object.entries(value)) {
+ if (!isLocalsSerializable(nestedValue)) {
+ plainObject = false;
+ break;
+ }
+ }
+ } else {
+ plainObject = false;
+ }
+ let result =
+ value === null ||
+ type === 'string' ||
+ type === 'number' ||
+ type === 'boolean' ||
+ Array.isArray(value) ||
+ plainObject;
+
+ return result;
+}
+
+/**
+ *
+ * From [redux-toolkit](https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/isPlainObject.ts)
+ *
+ * Returns true if the passed value is "plain" object, i.e. an object whose
+ * prototype is the root `Object.prototype`. This includes objects created
+ * using object literals, but not for instance for class instances.
+ */
+function isPlainObject(value: unknown): value is object {
+ if (typeof value !== 'object' || value === null) return false;
+
+ let proto = Object.getPrototypeOf(value);
+ if (proto === null) return true;
+
+ let baseProto = proto;
+ while (Object.getPrototypeOf(baseProto) !== null) {
+ baseProto = Object.getPrototypeOf(baseProto);
+ }
+
+ return proto === baseProto;
+}
+
+/**
+ * It attempts to serialize `value` and return it as a string.
+ *
+ * ## Errors
+ * If the `value` is not serializable if the function will throw a runtime error.
+ *
+ * Something is **not serializable** when it contains properties/values like functions, `Map`, `Set`, `Date`,
+ * and other types that can't be made a string.
+ *
+ * @param value
+ */
+function trySerializeLocals(value: unknown) {
+ if (isLocalsSerializable(value)) {
+ return JSON.stringify(value);
+ } else {
+ throw new Error("The passed value can't be serialized.");
+ }
+}
+
+// NOTE: this export must export only the functions that will be exposed to user-land as officials APIs
+export { createContext, defineMiddleware, sequence, trySerializeLocals };
diff --git a/packages/astro/src/core/middleware/loadMiddleware.ts b/packages/astro/src/core/middleware/loadMiddleware.ts
new file mode 100644
index 000000000..e6b8bfb90
--- /dev/null
+++ b/packages/astro/src/core/middleware/loadMiddleware.ts
@@ -0,0 +1,18 @@
+import { MiddlewareCantBeLoaded } from '../errors/errors-data.js';
+import { AstroError } from '../errors/index.js';
+import type { ModuleLoader } from '../module-loader/index.js';
+import { MIDDLEWARE_MODULE_ID } from './vite-plugin.js';
+
+/**
+ * It accepts a module loader and the astro settings, and it attempts to load the middlewares defined in the configuration.
+ *
+ * If not middlewares were not set, the function returns an empty array.
+ */
+export async function loadMiddleware(moduleLoader: ModuleLoader) {
+ try {
+ return await moduleLoader.import(MIDDLEWARE_MODULE_ID);
+ } catch (error: any) {
+ const astroError = new AstroError(MiddlewareCantBeLoaded, { cause: error });
+ throw astroError;
+ }
+}
diff --git a/packages/astro/src/core/middleware/noop-middleware.ts b/packages/astro/src/core/middleware/noop-middleware.ts
new file mode 100644
index 000000000..6c5b4049a
--- /dev/null
+++ b/packages/astro/src/core/middleware/noop-middleware.ts
@@ -0,0 +1,8 @@
+import type { MiddlewareHandler } from '../../types/public/common.js';
+import { NOOP_MIDDLEWARE_HEADER } from '../constants.js';
+
+export const NOOP_MIDDLEWARE_FN: MiddlewareHandler = async (_ctx, next) => {
+ const response = await next();
+ response.headers.set(NOOP_MIDDLEWARE_HEADER, 'true');
+ return response;
+};
diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts
new file mode 100644
index 000000000..082ac8153
--- /dev/null
+++ b/packages/astro/src/core/middleware/sequence.ts
@@ -0,0 +1,88 @@
+import type { MiddlewareHandler, RewritePayload } from '../../types/public/common.js';
+import type { APIContext } from '../../types/public/context.js';
+import { AstroCookies } from '../cookies/cookies.js';
+import { ForbiddenRewrite } from '../errors/errors-data.js';
+import { AstroError } from '../errors/index.js';
+import { apiContextRoutesSymbol } from '../render-context.js';
+import { type Pipeline, getParams } from '../render/index.js';
+import { defineMiddleware } from './index.js';
+
+// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js
+/**
+ *
+ * It accepts one or more middleware handlers and makes sure that they are run in sequence.
+ */
+export function sequence(...handlers: MiddlewareHandler[]): MiddlewareHandler {
+ const filtered = handlers.filter((h) => !!h);
+ const length = filtered.length;
+ if (!length) {
+ return defineMiddleware((_context, next) => {
+ return next();
+ });
+ }
+ return defineMiddleware((context, next) => {
+ /**
+ * This variable is used to carry the rerouting payload across middleware functions.
+ */
+ let carriedPayload: RewritePayload | undefined = undefined;
+ return applyHandle(0, context);
+
+ function applyHandle(i: number, handleContext: APIContext) {
+ const handle = filtered[i];
+ // @ts-expect-error
+ // SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually
+ // doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`.
+ const result = handle(handleContext, async (payload?: RewritePayload) => {
+ if (i < length - 1) {
+ if (payload) {
+ let newRequest;
+ if (payload instanceof Request) {
+ newRequest = payload;
+ } else if (payload instanceof URL) {
+ newRequest = new Request(payload, handleContext.request);
+ } else {
+ newRequest = new Request(
+ new URL(payload, handleContext.url.origin),
+ handleContext.request,
+ );
+ }
+ const pipeline: Pipeline = Reflect.get(handleContext, apiContextRoutesSymbol);
+ const { routeData, pathname } = await pipeline.tryRewrite(
+ payload,
+ handleContext.request,
+ );
+
+ // This is a case where the user tries to rewrite from a SSR route to a prerendered route (SSG).
+ // This case isn't valid because when building for SSR, the prerendered route disappears from the server output because it becomes an HTML file,
+ // so Astro can't retrieve it from the emitted manifest.
+ if (
+ pipeline.serverLike === true &&
+ handleContext.isPrerendered === false &&
+ routeData.prerender === true
+ ) {
+ throw new AstroError({
+ ...ForbiddenRewrite,
+ message: ForbiddenRewrite.message(
+ handleContext.url.pathname,
+ pathname,
+ routeData.component,
+ ),
+ hint: ForbiddenRewrite.hint(routeData.component),
+ });
+ }
+
+ carriedPayload = payload;
+ handleContext.request = newRequest;
+ handleContext.url = new URL(newRequest.url);
+ handleContext.cookies = new AstroCookies(newRequest);
+ handleContext.params = getParams(routeData, pathname);
+ }
+ return applyHandle(i + 1, handleContext);
+ } else {
+ return next(payload ?? carriedPayload);
+ }
+ });
+ return result;
+ }
+ });
+}
diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts
new file mode 100644
index 000000000..2587c4565
--- /dev/null
+++ b/packages/astro/src/core/middleware/vite-plugin.ts
@@ -0,0 +1,121 @@
+import type { Plugin as VitePlugin } from 'vite';
+import { getOutputDirectory } from '../../prerender/utils.js';
+import type { AstroSettings } from '../../types/astro.js';
+import { addRollupInput } from '../build/add-rollup-input.js';
+import type { BuildInternals } from '../build/internal.js';
+import type { StaticBuildOptions } from '../build/types.js';
+import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js';
+import { MissingMiddlewareForInternationalization } from '../errors/errors-data.js';
+import { AstroError } from '../errors/index.js';
+import { normalizePath } from '../viteUtils.js';
+
+export const MIDDLEWARE_MODULE_ID = '\0astro-internal:middleware';
+const NOOP_MIDDLEWARE = '\0noop-middleware';
+
+export function vitePluginMiddleware({ settings }: { settings: AstroSettings }): VitePlugin {
+ let resolvedMiddlewareId: string | undefined = undefined;
+ const hasIntegrationMiddleware =
+ settings.middlewares.pre.length > 0 || settings.middlewares.post.length > 0;
+ let userMiddlewareIsPresent = false;
+
+ return {
+ name: '@astro/plugin-middleware',
+ async resolveId(id) {
+ if (id === MIDDLEWARE_MODULE_ID) {
+ const middlewareId = await this.resolve(
+ `${decodeURI(settings.config.srcDir.pathname)}${MIDDLEWARE_PATH_SEGMENT_NAME}`,
+ );
+ userMiddlewareIsPresent = !!middlewareId;
+ if (middlewareId) {
+ resolvedMiddlewareId = middlewareId.id;
+ return MIDDLEWARE_MODULE_ID;
+ } else if (hasIntegrationMiddleware) {
+ return MIDDLEWARE_MODULE_ID;
+ } else {
+ return NOOP_MIDDLEWARE;
+ }
+ }
+ if (id === NOOP_MIDDLEWARE) {
+ return NOOP_MIDDLEWARE;
+ }
+ },
+ async load(id) {
+ if (id === NOOP_MIDDLEWARE) {
+ if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') {
+ throw new AstroError(MissingMiddlewareForInternationalization);
+ }
+ return 'export const onRequest = (_, next) => next()';
+ } else if (id === MIDDLEWARE_MODULE_ID) {
+ if (!userMiddlewareIsPresent && settings.config.i18n?.routing === 'manual') {
+ throw new AstroError(MissingMiddlewareForInternationalization);
+ }
+
+ const preMiddleware = createMiddlewareImports(settings.middlewares.pre, 'pre');
+ const postMiddleware = createMiddlewareImports(settings.middlewares.post, 'post');
+
+ const source = `
+ ${
+ userMiddlewareIsPresent
+ ? `import { onRequest as userOnRequest } from '${resolvedMiddlewareId}';`
+ : ''
+ }
+import { sequence } from 'astro:middleware';
+${preMiddleware.importsCode}${postMiddleware.importsCode}
+
+export const onRequest = sequence(
+ ${preMiddleware.sequenceCode}${preMiddleware.sequenceCode ? ',' : ''}
+ ${userMiddlewareIsPresent ? `userOnRequest${postMiddleware.sequenceCode ? ',' : ''}` : ''}
+ ${postMiddleware.sequenceCode}
+);
+`.trim();
+
+ return source;
+ }
+ },
+ };
+}
+
+function createMiddlewareImports(
+ entrypoints: string[],
+ prefix: string,
+): {
+ importsCode: string;
+ sequenceCode: string;
+} {
+ let importsRaw = '';
+ let sequenceRaw = '';
+ let index = 0;
+ for (const entrypoint of entrypoints) {
+ const name = `_${prefix}_${index}`;
+ importsRaw += `import { onRequest as ${name} } from '${normalizePath(entrypoint)}';\n`;
+ sequenceRaw += `${index > 0 ? ',' : ''}${name}`;
+ index++;
+ }
+
+ return {
+ importsCode: importsRaw,
+ sequenceCode: sequenceRaw,
+ };
+}
+
+export function vitePluginMiddlewareBuild(
+ opts: StaticBuildOptions,
+ internals: BuildInternals,
+): VitePlugin {
+ return {
+ name: '@astro/plugin-middleware-build',
+
+ options(options) {
+ return addRollupInput(options, [MIDDLEWARE_MODULE_ID]);
+ },
+
+ writeBundle(_, bundle) {
+ for (const [chunkName, chunk] of Object.entries(bundle)) {
+ if (chunk.type !== 'asset' && chunk.facadeModuleId === MIDDLEWARE_MODULE_ID) {
+ const outputDirectory = getOutputDirectory(opts.settings);
+ internals.middlewareEntryPoint = new URL(chunkName, outputDirectory);
+ }
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/core/module-loader/index.ts b/packages/astro/src/core/module-loader/index.ts
new file mode 100644
index 000000000..4d21148b6
--- /dev/null
+++ b/packages/astro/src/core/module-loader/index.ts
@@ -0,0 +1,3 @@
+export { createLoader } from './loader.js';
+export type { LoaderEvents, ModuleInfo, ModuleLoader, ModuleNode } from './loader.js';
+export { createViteLoader } from './vite.js';
diff --git a/packages/astro/src/core/module-loader/loader.ts b/packages/astro/src/core/module-loader/loader.ts
new file mode 100644
index 000000000..9973ae657
--- /dev/null
+++ b/packages/astro/src/core/module-loader/loader.ts
@@ -0,0 +1,91 @@
+import { EventEmitter } from 'node:events';
+import type * as fs from 'node:fs';
+import type { TypedEventEmitter } from '../../types/typed-emitter.js';
+
+// This is a generic interface for a module loader. In the astro cli this is
+// fulfilled by Vite, see vite.ts
+
+export type LoaderEvents = {
+ 'file-add': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
+ 'file-change': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
+ 'file-unlink': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
+ 'hmr-error': (msg: {
+ type: 'error';
+ err: {
+ message: string;
+ stack: string;
+ };
+ }) => void;
+};
+
+export type ModuleLoaderEventEmitter = TypedEventEmitter<LoaderEvents>;
+
+export interface ModuleLoader {
+ import: (src: string) => Promise<Record<string, any>>;
+ resolveId: (specifier: string, parentId: string | undefined) => Promise<string | undefined>;
+ getModuleById: (id: string) => ModuleNode | undefined;
+ getModulesByFile: (file: string) => Set<ModuleNode> | undefined;
+ getModuleInfo: (id: string) => ModuleInfo | null;
+
+ eachModule(callbackfn: (value: ModuleNode, key: string) => void): void;
+ invalidateModule(mod: ModuleNode): void;
+
+ fixStacktrace: (error: Error) => void;
+
+ clientReload: () => void;
+ webSocketSend: (msg: any) => void;
+ isHttps: () => boolean;
+ events: TypedEventEmitter<LoaderEvents>;
+}
+
+export interface ModuleNode {
+ id: string | null;
+ url: string;
+ file: string | null;
+ ssrModule: Record<string, any> | null;
+ ssrTransformResult: {
+ deps?: string[];
+ dynamicDeps?: string[];
+ } | null;
+ ssrError: Error | null;
+ importedModules: Set<ModuleNode>;
+ importers: Set<ModuleNode>;
+}
+
+export interface ModuleInfo {
+ id: string;
+ meta?: Record<string, any>;
+}
+
+export function createLoader(overrides: Partial<ModuleLoader>): ModuleLoader {
+ return {
+ import() {
+ throw new Error(`Not implemented`);
+ },
+ resolveId(id) {
+ return Promise.resolve(id);
+ },
+ getModuleById() {
+ return undefined;
+ },
+ getModulesByFile() {
+ return undefined;
+ },
+ getModuleInfo() {
+ return null;
+ },
+ eachModule() {
+ throw new Error(`Not implemented`);
+ },
+ invalidateModule() {},
+ fixStacktrace() {},
+ clientReload() {},
+ webSocketSend() {},
+ isHttps() {
+ return true;
+ },
+ events: new EventEmitter() as ModuleLoaderEventEmitter,
+
+ ...overrides,
+ };
+}
diff --git a/packages/astro/src/core/module-loader/vite.ts b/packages/astro/src/core/module-loader/vite.ts
new file mode 100644
index 000000000..20aea87e2
--- /dev/null
+++ b/packages/astro/src/core/module-loader/vite.ts
@@ -0,0 +1,112 @@
+import { EventEmitter } from 'node:events';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import type * as vite from 'vite';
+import { collectErrorMetadata } from '../errors/dev/utils.js';
+import { getViteErrorPayload } from '../errors/dev/vite.js';
+import type { ModuleLoader, ModuleLoaderEventEmitter } from './loader.js';
+
+export function createViteLoader(viteServer: vite.ViteDevServer): ModuleLoader {
+ const events = new EventEmitter() as ModuleLoaderEventEmitter;
+
+ let isTsconfigUpdated = false;
+ function isTsconfigUpdate(filePath: string) {
+ const result = path.basename(filePath) === 'tsconfig.json';
+ if (result) isTsconfigUpdated = true;
+ return result;
+ }
+
+ // Skip event emit on tsconfig change as Vite restarts the server, and we don't
+ // want to trigger unnecessary work that will be invalidated shortly.
+ viteServer.watcher.on('add', (...args) => {
+ if (!isTsconfigUpdate(args[0])) {
+ events.emit('file-add', args);
+ }
+ });
+ viteServer.watcher.on('unlink', (...args) => {
+ if (!isTsconfigUpdate(args[0])) {
+ events.emit('file-unlink', args);
+ }
+ });
+ viteServer.watcher.on('change', (...args) => {
+ if (!isTsconfigUpdate(args[0])) {
+ events.emit('file-change', args);
+ }
+ });
+
+ const _wsSend = viteServer.hot.send;
+ viteServer.hot.send = function (...args: any) {
+ // If the tsconfig changed, Vite will trigger a reload as it invalidates the module.
+ // However in Astro, the whole server is restarted when the tsconfig changes. If we
+ // do a restart and reload at the same time, the browser will refetch and the server
+ // is not ready yet, causing a blank page. Here we block that reload from happening.
+ if (isTsconfigUpdated) {
+ isTsconfigUpdated = false;
+ return;
+ }
+ const msg = args[0] as vite.HMRPayload;
+ if (msg?.type === 'error') {
+ // If we have an error, but it didn't go through our error enhancement program, it means that it's a HMR error from
+ // vite itself, which goes through a different path. We need to enhance it here.
+ if (!(msg as any)['__isEnhancedAstroErrorPayload']) {
+ const err = collectErrorMetadata(msg.err, pathToFileURL(viteServer.config.root));
+ getViteErrorPayload(err).then((payload) => {
+ events.emit('hmr-error', {
+ type: 'error',
+ err: {
+ message: payload.err.message,
+ stack: payload.err.stack,
+ },
+ });
+
+ args[0] = payload;
+ _wsSend.apply(this, args);
+ });
+ return;
+ }
+ events.emit('hmr-error', msg);
+ }
+ _wsSend.apply(this, args);
+ };
+
+ return {
+ import(src) {
+ return viteServer.ssrLoadModule(src);
+ },
+ async resolveId(spec, parent) {
+ const ret = await viteServer.pluginContainer.resolveId(spec, parent);
+ return ret?.id;
+ },
+ getModuleById(id) {
+ return viteServer.moduleGraph.getModuleById(id);
+ },
+ getModulesByFile(file) {
+ return viteServer.moduleGraph.getModulesByFile(file);
+ },
+ getModuleInfo(id) {
+ return viteServer.pluginContainer.getModuleInfo(id);
+ },
+ eachModule(cb) {
+ return viteServer.moduleGraph.idToModuleMap.forEach(cb);
+ },
+ invalidateModule(mod) {
+ viteServer.moduleGraph.invalidateModule(mod as vite.ModuleNode);
+ },
+ fixStacktrace(err) {
+ return viteServer.ssrFixStacktrace(err);
+ },
+ clientReload() {
+ viteServer.hot.send({
+ type: 'full-reload',
+ path: '*',
+ });
+ },
+ webSocketSend(msg) {
+ return viteServer.hot.send(msg);
+ },
+ isHttps() {
+ return !!viteServer.config.server.https;
+ },
+ events,
+ };
+}
diff --git a/packages/astro/src/core/path.ts b/packages/astro/src/core/path.ts
new file mode 100644
index 000000000..cbc3b6900
--- /dev/null
+++ b/packages/astro/src/core/path.ts
@@ -0,0 +1 @@
+export * from '@astrojs/internal-helpers/path';
diff --git a/packages/astro/src/core/polyfill.ts b/packages/astro/src/core/polyfill.ts
new file mode 100644
index 000000000..7b0280fb7
--- /dev/null
+++ b/packages/astro/src/core/polyfill.ts
@@ -0,0 +1,22 @@
+import buffer from 'node:buffer';
+import crypto from 'node:crypto';
+
+/**
+ * Astro aims to compatible with web standards as much as possible.
+ * This function adds two objects that are globally-available on most javascript runtimes but not on node 18.
+ */
+export function apply() {
+ // Remove when Node 18 is dropped for Node 20
+ if (!globalThis.crypto) {
+ Object.defineProperty(globalThis, 'crypto', {
+ value: crypto.webcrypto,
+ });
+ }
+
+ // Remove when Node 18 is dropped for Node 20
+ if (!globalThis.File) {
+ Object.defineProperty(globalThis, 'File', {
+ value: buffer.File,
+ });
+ }
+}
diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts
new file mode 100644
index 000000000..7a11a755e
--- /dev/null
+++ b/packages/astro/src/core/preview/index.ts
@@ -0,0 +1,90 @@
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { AstroIntegrationLogger } from '../../core/logger/core.js';
+import { telemetry } from '../../events/index.js';
+import { eventCliSession } from '../../events/session.js';
+import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import type { PreviewModule, PreviewServer } from '../../types/public/preview.js';
+import { resolveConfig } from '../config/config.js';
+import { createNodeLogger } from '../config/logging.js';
+import { createSettings } from '../config/settings.js';
+import { apply as applyPolyfills } from '../polyfill.js';
+import { createRoutesList } from '../routing/index.js';
+import { ensureProcessNodeEnv } from '../util.js';
+import createStaticPreviewServer from './static-preview-server.js';
+import { getResolvedHostForHttpServer } from './util.js';
+
+/**
+ * Starts a local server to serve your static dist/ directory. This command is useful for previewing
+ * your build locally, before deploying it. It is not designed to be run in production.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+export default async function preview(inlineConfig: AstroInlineConfig): Promise<PreviewServer> {
+ applyPolyfills();
+ ensureProcessNodeEnv('production');
+ const logger = createNodeLogger(inlineConfig);
+ const { userConfig, astroConfig } = await resolveConfig(inlineConfig ?? {}, 'preview');
+ telemetry.record(eventCliSession('preview', userConfig));
+
+ const _settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
+
+ const settings = await runHookConfigSetup({
+ settings: _settings,
+ command: 'preview',
+ logger: logger,
+ });
+
+ // Create a route manifest so we can know if the build output is a static site or not
+ await createRoutesList({ settings: settings, cwd: inlineConfig.root }, logger);
+
+ await runHookConfigDone({ settings: settings, logger: logger, command: 'preview' });
+
+ if (settings.buildOutput === 'static') {
+ if (!fs.existsSync(settings.config.outDir)) {
+ const outDirPath = fileURLToPath(settings.config.outDir);
+ throw new Error(
+ `[preview] The output directory ${outDirPath} does not exist. Did you run \`astro build\`?`,
+ );
+ }
+ const server = await createStaticPreviewServer(settings, logger);
+ return server;
+ }
+
+ if (!settings.adapter) {
+ throw new Error(`[preview] No adapter found.`);
+ }
+
+ if (!settings.adapter.previewEntrypoint) {
+ throw new Error(
+ `[preview] The ${settings.adapter.name} adapter does not support the preview command.`,
+ );
+ }
+ // We need to use require.resolve() here so that advanced package managers like pnpm
+ // don't treat this as a dependency of Astro itself. This correctly resolves the
+ // preview entrypoint of the integration package, relative to the user's project root.
+ const require = createRequire(settings.config.root);
+ const previewEntrypointUrl = pathToFileURL(
+ require.resolve(settings.adapter.previewEntrypoint.toString()),
+ ).href;
+
+ const previewModule = (await import(previewEntrypointUrl)) as Partial<PreviewModule>;
+ if (typeof previewModule.default !== 'function') {
+ throw new Error(`[preview] ${settings.adapter.name} cannot preview your app.`);
+ }
+
+ const server = await previewModule.default({
+ outDir: settings.config.outDir,
+ client: settings.config.build.client,
+ serverEntrypoint: new URL(settings.config.build.serverEntry, settings.config.build.server),
+ host: getResolvedHostForHttpServer(settings.config.server.host),
+ port: settings.config.server.port,
+ base: settings.config.base,
+ logger: new AstroIntegrationLogger(logger.options, settings.adapter.name),
+ headers: settings.config.server.headers,
+ });
+
+ return server;
+}
diff --git a/packages/astro/src/core/preview/static-preview-server.ts b/packages/astro/src/core/preview/static-preview-server.ts
new file mode 100644
index 000000000..855506ef9
--- /dev/null
+++ b/packages/astro/src/core/preview/static-preview-server.ts
@@ -0,0 +1,75 @@
+import type http from 'node:http';
+import { performance } from 'node:perf_hooks';
+import { fileURLToPath } from 'node:url';
+import { type PreviewServer as VitePreviewServer, preview } from 'vite';
+import type { AstroSettings } from '../../types/astro.js';
+import type { Logger } from '../logger/core.js';
+import * as msg from '../messages.js';
+import { getResolvedHostForHttpServer } from './util.js';
+import { vitePluginAstroPreview } from './vite-plugin-astro-preview.js';
+
+export interface PreviewServer {
+ host?: string;
+ port: number;
+ server: http.Server;
+ closed(): Promise<void>;
+ stop(): Promise<void>;
+}
+
+export default async function createStaticPreviewServer(
+ settings: AstroSettings,
+ logger: Logger,
+): Promise<PreviewServer> {
+ const startServerTime = performance.now();
+
+ let previewServer: VitePreviewServer;
+ try {
+ previewServer = await preview({
+ configFile: false,
+ base: settings.config.base,
+ appType: 'mpa',
+ build: {
+ outDir: fileURLToPath(settings.config.outDir),
+ },
+ preview: {
+ host: settings.config.server.host,
+ port: settings.config.server.port,
+ headers: settings.config.server.headers,
+ open: settings.config.server.open,
+ },
+ plugins: [vitePluginAstroPreview(settings)],
+ });
+ } catch (err) {
+ if (err instanceof Error) {
+ logger.error(null, err.stack || err.message);
+ }
+ throw err;
+ }
+
+ // Log server start URLs
+ logger.info(
+ 'SKIP_FORMAT',
+ msg.serverStart({
+ startupTime: performance.now() - startServerTime,
+ resolvedUrls: previewServer.resolvedUrls ?? { local: [], network: [] },
+ host: settings.config.server.host,
+ base: settings.config.base,
+ }),
+ );
+
+ // Resolves once the server is closed
+ function closed() {
+ return new Promise<void>((resolve, reject) => {
+ previewServer.httpServer.addListener('close', resolve);
+ previewServer.httpServer.addListener('error', reject);
+ });
+ }
+
+ return {
+ host: getResolvedHostForHttpServer(settings.config.server.host),
+ port: settings.config.server.port,
+ closed,
+ server: previewServer.httpServer as http.Server,
+ stop: previewServer.close.bind(previewServer),
+ };
+}
diff --git a/packages/astro/src/core/preview/util.ts b/packages/astro/src/core/preview/util.ts
new file mode 100644
index 000000000..d02e4c3c8
--- /dev/null
+++ b/packages/astro/src/core/preview/util.ts
@@ -0,0 +1,19 @@
+export function getResolvedHostForHttpServer(host: string | boolean) {
+ if (host === false) {
+ // Use a secure default
+ return 'localhost';
+ } else if (host === true) {
+ // If passed --host in the CLI without arguments
+ return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
+ } else {
+ return host;
+ }
+}
+
+export function stripBase(path: string, base: string): string {
+ if (path === base) {
+ return '/';
+ }
+ const baseWithSlash = base.endsWith('/') ? base : base + '/';
+ return path.replace(RegExp('^' + baseWithSlash), '/');
+}
diff --git a/packages/astro/src/core/preview/vite-plugin-astro-preview.ts b/packages/astro/src/core/preview/vite-plugin-astro-preview.ts
new file mode 100644
index 000000000..a8043192d
--- /dev/null
+++ b/packages/astro/src/core/preview/vite-plugin-astro-preview.ts
@@ -0,0 +1,103 @@
+import fs from 'node:fs';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import { fileURLToPath } from 'node:url';
+import type { Connect, Plugin } from 'vite';
+import { notFoundTemplate, subpathNotUsedTemplate } from '../../template/4xx.js';
+import type { AstroSettings } from '../../types/astro.js';
+import { cleanUrl } from '../../vite-plugin-utils/index.js';
+import { stripBase } from './util.js';
+
+const HAS_FILE_EXTENSION_REGEXP = /\.[^/]+$/;
+
+export function vitePluginAstroPreview(settings: AstroSettings): Plugin {
+ const { base, outDir, trailingSlash } = settings.config;
+
+ function handle404(req: IncomingMessage, res: ServerResponse) {
+ const errorPagePath = fileURLToPath(outDir + '/404.html');
+ if (fs.existsSync(errorPagePath)) {
+ res.statusCode = 404;
+ res.setHeader('Content-Type', 'text/html');
+ res.end(fs.readFileSync(errorPagePath));
+ } else {
+ res.statusCode = 404;
+ res.end(notFoundTemplate(req.url!, 'Not Found'));
+ }
+ }
+
+ return {
+ name: 'astro:preview',
+ apply: 'serve',
+ configurePreviewServer(server) {
+ server.middlewares.use((req, res, next) => {
+ // respond 404 to requests outside the base request directory
+ if (!req.url!.startsWith(base)) {
+ res.statusCode = 404;
+ res.end(subpathNotUsedTemplate(base, req.url!));
+ return;
+ }
+
+ const pathname = cleanUrl(stripBase(req.url!, base));
+ const isRoot = pathname === '/';
+
+ // Validate trailingSlash
+ if (!isRoot) {
+ const hasTrailingSlash = pathname.endsWith('/');
+
+ if (hasTrailingSlash && trailingSlash == 'never') {
+ res.statusCode = 404;
+ res.end(notFoundTemplate(pathname, 'Not Found (trailingSlash is set to "never")'));
+ return;
+ }
+
+ if (
+ !hasTrailingSlash &&
+ trailingSlash == 'always' &&
+ !HAS_FILE_EXTENSION_REGEXP.test(pathname)
+ ) {
+ res.statusCode = 404;
+ res.end(notFoundTemplate(pathname, 'Not Found (trailingSlash is set to "always")'));
+ return;
+ }
+ }
+
+ // TODO: look into why the replacement needs to happen here
+ for (const middleware of server.middlewares.stack) {
+ // This hardcoded name will not break between Vite versions
+ if ((middleware.handle as Connect.HandleFunction).name === 'vite404Middleware') {
+ middleware.handle = handle404;
+ }
+ }
+
+ next();
+ });
+
+ return () => {
+ // NOTE: the `base` is stripped from `req.url` for post middlewares
+
+ server.middlewares.use((req, _res, next) => {
+ const pathname = cleanUrl(req.url!);
+
+ // Vite doesn't handle /foo/ if /foo.html exists, we handle it anyways
+ if (pathname.endsWith('/')) {
+ const pathnameWithoutSlash = pathname.slice(0, -1);
+ const htmlPath = fileURLToPath(outDir + pathnameWithoutSlash + '.html');
+ if (fs.existsSync(htmlPath)) {
+ req.url = pathnameWithoutSlash + '.html';
+ return next();
+ }
+ }
+ // Vite doesn't handle /foo if /foo/index.html exists, we handle it anyways
+ else {
+ const htmlPath = fileURLToPath(outDir + pathname + '/index.html');
+ if (fs.existsSync(htmlPath)) {
+ req.url = pathname + '/index.html';
+ return next();
+ }
+ }
+
+ next();
+ });
+ };
+ },
+ };
+}
diff --git a/packages/astro/src/core/redirects/component.ts b/packages/astro/src/core/redirects/component.ts
new file mode 100644
index 000000000..12b37ae00
--- /dev/null
+++ b/packages/astro/src/core/redirects/component.ts
@@ -0,0 +1,17 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type { SinglePageBuiltModule } from '../build/types.js';
+
+// A stub of a component instance for a given route
+export const RedirectComponentInstance: ComponentInstance = {
+ default() {
+ return new Response(null, {
+ status: 301,
+ });
+ },
+};
+
+export const RedirectSinglePageBuiltModule: SinglePageBuiltModule = {
+ page: () => Promise.resolve(RedirectComponentInstance),
+ onRequest: (_, next) => next(),
+ renderers: [],
+};
diff --git a/packages/astro/src/core/redirects/helpers.ts b/packages/astro/src/core/redirects/helpers.ts
new file mode 100644
index 000000000..a2dc42df9
--- /dev/null
+++ b/packages/astro/src/core/redirects/helpers.ts
@@ -0,0 +1,13 @@
+import type { RouteData } from '../../types/public/internal.js';
+
+type RedirectRouteData = RouteData & {
+ redirect: string;
+};
+
+export function routeIsRedirect(route: RouteData | undefined): route is RedirectRouteData {
+ return route?.type === 'redirect';
+}
+
+export function routeIsFallback(route: RouteData | undefined): route is RedirectRouteData {
+ return route?.type === 'fallback';
+}
diff --git a/packages/astro/src/core/redirects/index.ts b/packages/astro/src/core/redirects/index.ts
new file mode 100644
index 000000000..321195cbd
--- /dev/null
+++ b/packages/astro/src/core/redirects/index.ts
@@ -0,0 +1,4 @@
+export { RedirectComponentInstance, RedirectSinglePageBuiltModule } from './component.js';
+export { routeIsRedirect } from './helpers.js';
+export { getRedirectLocationOrThrow } from './validate.js';
+export { renderRedirect } from './render.js';
diff --git a/packages/astro/src/core/redirects/render.ts b/packages/astro/src/core/redirects/render.ts
new file mode 100644
index 000000000..4044747be
--- /dev/null
+++ b/packages/astro/src/core/redirects/render.ts
@@ -0,0 +1,56 @@
+import type { RedirectConfig } from '../../types/public/index.js';
+import type { RenderContext } from '../render-context.js';
+
+export function redirectIsExternal(redirect: RedirectConfig): boolean {
+ if (typeof redirect === 'string') {
+ return redirect.startsWith('http://') || redirect.startsWith('https://');
+ } else {
+ return (
+ redirect.destination.startsWith('http://') || redirect.destination.startsWith('https://')
+ );
+ }
+}
+
+export async function renderRedirect(renderContext: RenderContext) {
+ const {
+ request: { method },
+ routeData,
+ } = renderContext;
+ const { redirect, redirectRoute } = routeData;
+ const status =
+ redirectRoute && typeof redirect === 'object' ? redirect.status : method === 'GET' ? 301 : 308;
+ const headers = { location: encodeURI(redirectRouteGenerate(renderContext)) };
+ if (redirect && redirectIsExternal(redirect)) {
+ if (typeof redirect === 'string') {
+ return Response.redirect(redirect, status);
+ } else {
+ return Response.redirect(redirect.destination, status);
+ }
+ }
+ return new Response(null, { status, headers });
+}
+
+function redirectRouteGenerate(renderContext: RenderContext): string {
+ const {
+ params,
+ routeData: { redirect, redirectRoute },
+ } = renderContext;
+
+ if (typeof redirectRoute !== 'undefined') {
+ return redirectRoute?.generate(params) || redirectRoute?.pathname || '/';
+ } else if (typeof redirect === 'string') {
+ if (redirectIsExternal(redirect)) {
+ return redirect;
+ } else {
+ let target = redirect;
+ for (const param of Object.keys(params)) {
+ const paramValue = params[param]!;
+ target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
+ }
+ return target;
+ }
+ } else if (typeof redirect === 'undefined') {
+ return '/';
+ }
+ return redirect.destination;
+}
diff --git a/packages/astro/src/core/redirects/validate.ts b/packages/astro/src/core/redirects/validate.ts
new file mode 100644
index 000000000..3075e1f3b
--- /dev/null
+++ b/packages/astro/src/core/redirects/validate.ts
@@ -0,0 +1,13 @@
+import { AstroError, AstroErrorData } from '../errors/index.js';
+
+export function getRedirectLocationOrThrow(headers: Headers): string {
+ let location = headers.get('location');
+
+ if (!location) {
+ throw new AstroError({
+ ...AstroErrorData.RedirectWithNoLocation,
+ });
+ }
+
+ return location;
+}
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
new file mode 100644
index 000000000..2509c82ab
--- /dev/null
+++ b/packages/astro/src/core/render-context.ts
@@ -0,0 +1,653 @@
+import type { ActionAPIContext } from '../actions/runtime/utils.js';
+import { deserializeActionResult } from '../actions/runtime/virtual/shared.js';
+import { createCallAction, createGetActionResult, hasActionPayload } from '../actions/utils.js';
+import {
+ computeCurrentLocale,
+ computePreferredLocale,
+ computePreferredLocaleList,
+} from '../i18n/utils.js';
+import { renderEndpoint } from '../runtime/server/endpoint.js';
+import { renderPage } from '../runtime/server/index.js';
+import type { ComponentInstance } from '../types/astro.js';
+import type { MiddlewareHandler, Props, RewritePayload } from '../types/public/common.js';
+import type { APIContext, AstroGlobal, AstroGlobalPartial } from '../types/public/context.js';
+import type { RouteData, SSRResult } from '../types/public/internal.js';
+import {
+ ASTRO_VERSION,
+ REROUTE_DIRECTIVE_HEADER,
+ REWRITE_DIRECTIVE_HEADER_KEY,
+ REWRITE_DIRECTIVE_HEADER_VALUE,
+ ROUTE_TYPE_HEADER,
+ clientAddressSymbol,
+ responseSentSymbol,
+} from './constants.js';
+import { AstroCookies, attachCookiesToResponse } from './cookies/index.js';
+import { getCookiesFromResponse } from './cookies/response.js';
+import { ForbiddenRewrite } from './errors/errors-data.js';
+import { AstroError, AstroErrorData } from './errors/index.js';
+import { callMiddleware } from './middleware/callMiddleware.js';
+import { sequence } from './middleware/index.js';
+import { renderRedirect } from './redirects/render.js';
+import { type Pipeline, Slots, getParams, getProps } from './render/index.js';
+import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js';
+import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
+import { AstroSession } from './session.js';
+
+export const apiContextRoutesSymbol = Symbol.for('context.routes');
+
+/**
+ * Each request is rendered using a `RenderContext`.
+ * It contains data unique to each request. It is responsible for executing middleware, calling endpoints, and rendering the page by gathering necessary data from a `Pipeline`.
+ */
+export class RenderContext {
+ private constructor(
+ readonly pipeline: Pipeline,
+ public locals: App.Locals,
+ readonly middleware: MiddlewareHandler,
+ // It must be a DECODED pathname
+ public pathname: string,
+ public request: Request,
+ public routeData: RouteData,
+ public status: number,
+ public clientAddress: string | undefined,
+ protected cookies = new AstroCookies(request),
+ public params = getParams(routeData, pathname),
+ protected url = new URL(request.url),
+ public props: Props = {},
+ public partial: undefined | boolean = undefined,
+ public session: AstroSession | undefined = pipeline.manifest.sessionConfig
+ ? new AstroSession(cookies, pipeline.manifest.sessionConfig)
+ : undefined,
+ ) {}
+
+ /**
+ * A flag that tells the render content if the rewriting was triggered
+ */
+ isRewriting = false;
+ /**
+ * A safety net in case of loops
+ */
+ counter = 0;
+
+ static async create({
+ locals = {},
+ middleware,
+ pathname,
+ pipeline,
+ request,
+ routeData,
+ clientAddress,
+ status = 200,
+ props,
+ partial = undefined,
+ }: Pick<RenderContext, 'pathname' | 'pipeline' | 'request' | 'routeData' | 'clientAddress'> &
+ Partial<
+ Pick<RenderContext, 'locals' | 'middleware' | 'status' | 'props' | 'partial'>
+ >): Promise<RenderContext> {
+ const pipelineMiddleware = await pipeline.getMiddleware();
+ setOriginPathname(request, pathname);
+ return new RenderContext(
+ pipeline,
+ locals,
+ sequence(...pipeline.internalMiddleware, middleware ?? pipelineMiddleware),
+ pathname,
+ request,
+ routeData,
+ status,
+ clientAddress,
+ undefined,
+ undefined,
+ undefined,
+ props,
+ partial,
+ );
+ }
+ /**
+ * The main function of the RenderContext.
+ *
+ * Use this function to render any route known to Astro.
+ * It attempts to render a route. A route can be a:
+ *
+ * - page
+ * - redirect
+ * - endpoint
+ * - fallback
+ */
+ async render(
+ componentInstance: ComponentInstance | undefined,
+ slots: Record<string, any> = {},
+ ): Promise<Response> {
+ const { cookies, middleware, pipeline } = this;
+ const { logger, serverLike, streaming, manifest } = pipeline;
+
+ const props =
+ Object.keys(this.props).length > 0
+ ? this.props
+ : await getProps({
+ mod: componentInstance,
+ routeData: this.routeData,
+ routeCache: this.pipeline.routeCache,
+ pathname: this.pathname,
+ logger,
+ serverLike,
+ base: manifest.base,
+ });
+ const apiContext = this.createAPIContext(props);
+
+ this.counter++;
+ if (this.counter === 4) {
+ return new Response('Loop Detected', {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/508
+ status: 508,
+ statusText:
+ 'Astro detected a loop where you tried to call the rewriting logic more than four times.',
+ });
+ }
+ const lastNext = async (ctx: APIContext, payload?: RewritePayload) => {
+ if (payload) {
+ pipeline.logger.debug('router', 'Called rewriting to:', payload);
+ // we intentionally let the error bubble up
+ const {
+ routeData,
+ componentInstance: newComponent,
+ pathname,
+ newUrl,
+ } = await pipeline.tryRewrite(payload, this.request);
+
+ // This is a case where the user tries to rewrite from a SSR route to a prerendered route (SSG).
+ // This case isn't valid because when building for SSR, the prerendered route disappears from the server output because it becomes an HTML file,
+ // so Astro can't retrieve it from the emitted manifest.
+ if (
+ this.pipeline.serverLike === true &&
+ this.routeData.prerender === false &&
+ routeData.prerender === true
+ ) {
+ throw new AstroError({
+ ...ForbiddenRewrite,
+ message: ForbiddenRewrite.message(this.pathname, pathname, routeData.component),
+ hint: ForbiddenRewrite.hint(routeData.component),
+ });
+ }
+
+ this.routeData = routeData;
+ componentInstance = newComponent;
+ if (payload instanceof Request) {
+ this.request = payload;
+ } else {
+ this.request = copyRequest(
+ newUrl,
+ this.request,
+ // need to send the flag of the previous routeData
+ routeData.prerender,
+ this.pipeline.logger,
+ this.routeData.route,
+ );
+ }
+ this.isRewriting = true;
+ this.url = new URL(this.request.url);
+ this.cookies = new AstroCookies(this.request);
+ this.params = getParams(routeData, pathname);
+ this.pathname = pathname;
+ this.status = 200;
+ }
+ let response: Response;
+
+ switch (this.routeData.type) {
+ case 'endpoint': {
+ response = await renderEndpoint(
+ componentInstance as any,
+ ctx,
+ this.routeData.prerender,
+ logger,
+ );
+ break;
+ }
+ case 'redirect':
+ return renderRedirect(this);
+ case 'page': {
+ const result = await this.createResult(componentInstance!);
+ try {
+ response = await renderPage(
+ result,
+ componentInstance?.default as any,
+ props,
+ slots,
+ streaming,
+ this.routeData,
+ );
+ } catch (e) {
+ // If there is an error in the page's frontmatter or instantiation of the RenderTemplate fails midway,
+ // we signal to the rest of the internals that we can ignore the results of existing renders and avoid kicking off more of them.
+ result.cancelled = true;
+ throw e;
+ }
+
+ // Signal to the i18n middleware to maybe act on this response
+ response.headers.set(ROUTE_TYPE_HEADER, 'page');
+ // Signal to the error-page-rerouting infra to let this response pass through to avoid loops
+ if (this.routeData.route === '/404' || this.routeData.route === '/500') {
+ response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
+ }
+ if (this.isRewriting) {
+ response.headers.set(REWRITE_DIRECTIVE_HEADER_KEY, REWRITE_DIRECTIVE_HEADER_VALUE);
+ }
+ break;
+ }
+ case 'fallback': {
+ return new Response(null, { status: 500, headers: { [ROUTE_TYPE_HEADER]: 'fallback' } });
+ }
+ }
+ // We need to merge the cookies from the response back into this.cookies
+ // because they may need to be passed along from a rewrite.
+ const responseCookies = getCookiesFromResponse(response);
+ if (responseCookies) {
+ cookies.merge(responseCookies);
+ }
+ return response;
+ };
+
+ // If we are rendering an extrnal redirect, we don't need go through the middleware,
+ // otherwise Astro will attempt to render the external website
+ if (isRouteExternalRedirect(this.routeData)) {
+ return renderRedirect(this);
+ }
+
+ const response = await callMiddleware(middleware, apiContext, lastNext);
+ if (response.headers.get(ROUTE_TYPE_HEADER)) {
+ response.headers.delete(ROUTE_TYPE_HEADER);
+ }
+ // LEGACY: we put cookies on the response object,
+ // where the adapter might be expecting to read it.
+ // New code should be using `app.render({ addCookieHeader: true })` instead.
+ attachCookiesToResponse(response, cookies);
+ return response;
+ }
+
+ createAPIContext(props: APIContext['props']): APIContext {
+ const context = this.createActionAPIContext();
+ const redirect = (path: string, status = 302) =>
+ new Response(null, { status, headers: { Location: path } });
+ Reflect.set(context, apiContextRoutesSymbol, this.pipeline);
+
+ return Object.assign(context, {
+ props,
+ redirect,
+ getActionResult: createGetActionResult(context.locals),
+ callAction: createCallAction(context),
+ });
+ }
+
+ async #executeRewrite(reroutePayload: RewritePayload) {
+ this.pipeline.logger.debug('router', 'Calling rewrite: ', reroutePayload);
+ const { routeData, componentInstance, newUrl, pathname } = await this.pipeline.tryRewrite(
+ reroutePayload,
+ this.request,
+ );
+ // This is a case where the user tries to rewrite from a SSR route to a prerendered route (SSG).
+ // This case isn't valid because when building for SSR, the prerendered route disappears from the server output because it becomes an HTML file,
+ // so Astro can't retrieve it from the emitted manifest.
+ if (
+ this.pipeline.serverLike === true &&
+ this.routeData.prerender === false &&
+ routeData.prerender === true
+ ) {
+ throw new AstroError({
+ ...ForbiddenRewrite,
+ message: ForbiddenRewrite.message(this.pathname, pathname, routeData.component),
+ hint: ForbiddenRewrite.hint(routeData.component),
+ });
+ }
+
+ this.routeData = routeData;
+ if (reroutePayload instanceof Request) {
+ this.request = reroutePayload;
+ } else {
+ this.request = copyRequest(
+ newUrl,
+ this.request,
+ // need to send the flag of the previous routeData
+ routeData.prerender,
+ this.pipeline.logger,
+ this.routeData.route,
+ );
+ }
+ this.url = new URL(this.request.url);
+ this.cookies = new AstroCookies(this.request);
+ this.params = getParams(routeData, pathname);
+ this.pathname = pathname;
+ this.isRewriting = true;
+ // we found a route and a component, we can change the status code to 200
+ this.status = 200;
+ return await this.render(componentInstance);
+ }
+
+ createActionAPIContext(): ActionAPIContext {
+ const renderContext = this;
+ const { cookies, params, pipeline, url, session } = this;
+ const generator = `Astro v${ASTRO_VERSION}`;
+
+ const rewrite = async (reroutePayload: RewritePayload) => {
+ return await this.#executeRewrite(reroutePayload);
+ };
+
+ return {
+ cookies,
+ routePattern: this.routeData.route,
+ isPrerendered: this.routeData.prerender,
+ get clientAddress() {
+ return renderContext.getClientAddress();
+ },
+ get currentLocale() {
+ return renderContext.computeCurrentLocale();
+ },
+ generator,
+ get locals() {
+ return renderContext.locals;
+ },
+ set locals(_) {
+ throw new AstroError(AstroErrorData.LocalsReassigned);
+ },
+ params,
+ get preferredLocale() {
+ return renderContext.computePreferredLocale();
+ },
+ get preferredLocaleList() {
+ return renderContext.computePreferredLocaleList();
+ },
+ rewrite,
+ request: this.request,
+ site: pipeline.site,
+ url,
+ get originPathname() {
+ return getOriginPathname(renderContext.request);
+ },
+ session,
+ };
+ }
+
+ async createResult(mod: ComponentInstance) {
+ const { cookies, pathname, pipeline, routeData, status } = this;
+ const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
+ pipeline;
+ const { links, scripts, styles } = await pipeline.headElements(routeData);
+ const componentMetadata =
+ (await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata;
+ const headers = new Headers({ 'Content-Type': 'text/html' });
+ const partial = typeof this.partial === 'boolean' ? this.partial : Boolean(mod.partial);
+ const actionResult = hasActionPayload(this.locals)
+ ? deserializeActionResult(this.locals._actionPayload.actionResult)
+ : undefined;
+ const response = {
+ status: actionResult?.error ? actionResult?.error.status : status,
+ statusText: actionResult?.error ? actionResult?.error.type : 'OK',
+ get headers() {
+ return headers;
+ },
+ // Disallow `Astro.response.headers = new Headers`
+ set headers(_) {
+ throw new AstroError(AstroErrorData.AstroResponseHeadersReassigned);
+ },
+ } satisfies AstroGlobal['response'];
+
+ // Create the result object that will be passed into the renderPage function.
+ // This object starts here as an empty shell (not yet the result) but then
+ // calling the render() function will populate the object with scripts, styles, etc.
+ const result: SSRResult = {
+ base: manifest.base,
+ cancelled: false,
+ clientDirectives,
+ inlinedScripts,
+ componentMetadata,
+ compressHTML,
+ cookies,
+ /** This function returns the `Astro` faux-global */
+ createAstro: (astroGlobal, props, slots) =>
+ this.createAstro(result, astroGlobal, props, slots),
+ links,
+ params: this.params,
+ partial,
+ pathname,
+ renderers,
+ resolve,
+ response,
+ request: this.request,
+ scripts,
+ styles,
+ actionResult,
+ serverIslandNameMap: manifest.serverIslandNameMap ?? new Map(),
+ key: manifest.key,
+ trailingSlash: manifest.trailingSlash,
+ _metadata: {
+ hasHydrationScript: false,
+ rendererSpecificHydrationScripts: new Set(),
+ hasRenderedHead: false,
+ renderedScripts: new Set(),
+ hasDirectives: new Set(),
+ headInTree: false,
+ extraHead: [],
+ propagators: new Set(),
+ },
+ };
+
+ return result;
+ }
+
+ #astroPagePartial?: Omit<AstroGlobal, 'props' | 'self' | 'slots'>;
+
+ /**
+ * The Astro global is sourced in 3 different phases:
+ * - **Static**: `.generator` and `.glob` is printed by the compiler, instantiated once per process per astro file
+ * - **Page-level**: `.request`, `.cookies`, `.locals` etc. These remain the same for the duration of the request.
+ * - **Component-level**: `.props`, `.slots`, and `.self` are unique to each _use_ of each component.
+ *
+ * The page level partial is used as the prototype of the user-visible `Astro` global object, which is instantiated once per use of a component.
+ */
+ createAstro(
+ result: SSRResult,
+ astroStaticPartial: AstroGlobalPartial,
+ props: Record<string, any>,
+ slotValues: Record<string, any> | null,
+ ): AstroGlobal {
+ let astroPagePartial;
+ // During rewriting, we must recompute the Astro global, because we need to purge the previous params/props/etc.
+ if (this.isRewriting) {
+ astroPagePartial = this.#astroPagePartial = this.createAstroPagePartial(
+ result,
+ astroStaticPartial,
+ );
+ } else {
+ // Create page partial with static partial so they can be cached together.
+ astroPagePartial = this.#astroPagePartial ??= this.createAstroPagePartial(
+ result,
+ astroStaticPartial,
+ );
+ }
+ // Create component-level partials. `Astro.self` is added by the compiler.
+ const astroComponentPartial = { props, self: null };
+
+ // Create final object. `Astro.slots` will be lazily created.
+ const Astro: Omit<AstroGlobal, 'self' | 'slots'> = Object.assign(
+ Object.create(astroPagePartial),
+ astroComponentPartial,
+ );
+
+ // Handle `Astro.slots`
+ let _slots: AstroGlobal['slots'];
+ Object.defineProperty(Astro, 'slots', {
+ get: () => {
+ if (!_slots) {
+ _slots = new Slots(
+ result,
+ slotValues,
+ this.pipeline.logger,
+ ) as unknown as AstroGlobal['slots'];
+ }
+ return _slots;
+ },
+ });
+
+ return Astro as AstroGlobal;
+ }
+
+ createAstroPagePartial(
+ result: SSRResult,
+ astroStaticPartial: AstroGlobalPartial,
+ ): Omit<AstroGlobal, 'props' | 'self' | 'slots'> {
+ const renderContext = this;
+ const { cookies, locals, params, pipeline, url, session } = this;
+ const { response } = result;
+ const redirect = (path: string, status = 302) => {
+ // If the response is already sent, error as we cannot proceed with the redirect.
+ if ((this.request as any)[responseSentSymbol]) {
+ throw new AstroError({
+ ...AstroErrorData.ResponseSentError,
+ });
+ }
+ return new Response(null, { status, headers: { Location: path } });
+ };
+
+ const rewrite = async (reroutePayload: RewritePayload) => {
+ return await this.#executeRewrite(reroutePayload);
+ };
+
+ return {
+ generator: astroStaticPartial.generator,
+ glob: astroStaticPartial.glob,
+ routePattern: this.routeData.route,
+ isPrerendered: this.routeData.prerender,
+ cookies,
+ session,
+ get clientAddress() {
+ return renderContext.getClientAddress();
+ },
+ get currentLocale() {
+ return renderContext.computeCurrentLocale();
+ },
+ params,
+ get preferredLocale() {
+ return renderContext.computePreferredLocale();
+ },
+ get preferredLocaleList() {
+ return renderContext.computePreferredLocaleList();
+ },
+ locals,
+ redirect,
+ rewrite,
+ request: this.request,
+ response,
+ site: pipeline.site,
+ getActionResult: createGetActionResult(locals),
+ get callAction() {
+ return createCallAction(this);
+ },
+ url,
+ get originPathname() {
+ return getOriginPathname(renderContext.request);
+ },
+ };
+ }
+
+ getClientAddress() {
+ const { pipeline, request, routeData, clientAddress } = this;
+
+ if (routeData.prerender) {
+ throw new AstroError(AstroErrorData.PrerenderClientAddressNotAvailable);
+ }
+
+ if (clientAddress) {
+ return clientAddress;
+ }
+
+ // TODO: Legacy, should not need to get here.
+ // Some adapters set this symbol so we can't remove support yet.
+ // Adapters should be updated to provide it via RenderOptions instead.
+ if (clientAddressSymbol in request) {
+ return Reflect.get(request, clientAddressSymbol) as string;
+ }
+
+ if (pipeline.adapterName) {
+ throw new AstroError({
+ ...AstroErrorData.ClientAddressNotAvailable,
+ message: AstroErrorData.ClientAddressNotAvailable.message(pipeline.adapterName),
+ });
+ }
+
+ throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
+ }
+
+ /**
+ * API Context may be created multiple times per request, i18n data needs to be computed only once.
+ * So, it is computed and saved here on creation of the first APIContext and reused for later ones.
+ */
+ #currentLocale: APIContext['currentLocale'];
+
+ computeCurrentLocale() {
+ const {
+ url,
+ pipeline: { i18n },
+ routeData,
+ } = this;
+ if (!i18n) return;
+
+ const { defaultLocale, locales, strategy } = i18n;
+
+ const fallbackTo =
+ strategy === 'pathname-prefix-other-locales' || strategy === 'domains-prefix-other-locales'
+ ? defaultLocale
+ : undefined;
+
+ if (this.#currentLocale) {
+ return this.#currentLocale;
+ }
+
+ let computedLocale;
+ if (isRouteServerIsland(routeData)) {
+ let referer = this.request.headers.get('referer');
+ if (referer) {
+ if (URL.canParse(referer)) {
+ referer = new URL(referer).pathname;
+ }
+ computedLocale = computeCurrentLocale(referer, locales, defaultLocale);
+ }
+ } else {
+ // For SSG we match the route naively, for dev we handle fallback on 404, for SSR we find route from fallbackRoutes
+ let pathname = routeData.pathname;
+ if (!routeData.pattern.test(url.pathname)) {
+ for (const fallbackRoute of routeData.fallbackRoutes) {
+ if (fallbackRoute.pattern.test(url.pathname)) {
+ pathname = fallbackRoute.pathname;
+ break;
+ }
+ }
+ }
+ pathname = pathname && !isRoute404or500(routeData) ? pathname : url.pathname;
+ computedLocale = computeCurrentLocale(pathname, locales, defaultLocale);
+ }
+
+ this.#currentLocale = computedLocale ?? fallbackTo;
+
+ return this.#currentLocale;
+ }
+
+ #preferredLocale: APIContext['preferredLocale'];
+
+ computePreferredLocale() {
+ const {
+ pipeline: { i18n },
+ request,
+ } = this;
+ if (!i18n) return;
+ return (this.#preferredLocale ??= computePreferredLocale(request, i18n.locales));
+ }
+
+ #preferredLocaleList: APIContext['preferredLocaleList'];
+
+ computePreferredLocaleList() {
+ const {
+ pipeline: { i18n },
+ request,
+ } = this;
+ if (!i18n) return;
+ return (this.#preferredLocaleList ??= computePreferredLocaleList(request, i18n.locales));
+ }
+}
diff --git a/packages/astro/src/core/render/README.md b/packages/astro/src/core/render/README.md
new file mode 100644
index 000000000..f8074cb68
--- /dev/null
+++ b/packages/astro/src/core/render/README.md
@@ -0,0 +1,70 @@
+# `core/render`
+
+This directory contains most of Astro's high-level rendering APIs, including `renderPage`, `createRenderContext`, etc.
+
+- For rendering an Astro file, see [src/runtime/server/](../../runtime/server/).
+- For rendering an endpoint, see [src/core/endpoint/](../endpoint/).
+
+## Concepts
+
+The codebase has a few abstractions for rendering:
+
+### `RenderContext`
+
+Each render (page or endpoint) requires a `RenderContext` that contains:
+
+- The `Request` object
+- The matched `route`
+- The matched `pathname` (without the `base`)
+- The route `params` and `props`
+- Additional `styles`, `links`, `scripts`
+- And more!
+
+The `RenderContext` is agnostic to what's being rendered.
+
+**Permitted state:** `RenderContext` should only contain per-request information.
+
+### `Environment`
+
+Every app (dev and prod) has one `Environment`. It contains a subset of the Astro `settings`, `config`, and `routes` information needed for Astro's runtime to work.
+
+In dev, all of `settings`, `config`, and `routes` are available, so the `Environment` (aka `DevelopmentEnvironment`) is derived directly from them. In prod, the `Environment` is derived from the `SSRManifest`, an intermediate layer that helps keep the build output lean.
+
+**Permitted state:** `Environment` should only contain the global state shared across all requests.
+
+### `SSRManifest`
+
+An `SSRManifest` is created during a build to save information needed to create an `Environment` during runtime start-up. The values should be serializable (`buildManifest`) and deserializable (`deserializeManifest`).
+
+The serialized string is inlined in the server output and can usually be read from the compiled module's `manifest` export.
+
+### `SSRResult`
+
+The `SSRResult` is used by the public rendering APIs at [`src/runtime/server/`](../../runtime/server/). At the top level, it is created by `renderPage` and passed down to the public rendering APIs. It is also often used in non-Astro pages, like `.mdx` and `.md`.
+
+The `SSRResult` object contains a subset of `RenderContext`, the state used by the compiled output (`cookies`, `createAstro`, `resolve`, etc), and the state used by the rendering APIs (`_metadata`).
+
+### `SSROptions`
+
+The `SSROptions` is a small wrapper used only in dev to create a `RenderContext`. It is abstracted to share the same shape for `renderPage` and `renderEndpoint` ([src/core/endpoint/](../endpoint/)).
+
+## Flow
+
+### Development
+
+NOTE: The development flow has a different API under [src/core/render/dev/](./dev/) that wraps the core API in this directory.
+
+1. Create the `Environment` with `settings`, `config`, and `routes`.
+2. Create `SSROptions` containing the `Request` and `Environment`.
+3. Call `renderPage` with `SSROptions`.
+4. Internally, `renderPage` creates a `RenderContext` and calls the core-`renderPage` API.
+5. The core-`renderPage` creates the `SSRResult`.
+6. Call the core-`render` API to render the page!
+
+### Production
+
+1. Deserialize the `SSRManifest`.
+2. Create the `Environment` with the `SSRManifest`.
+3. Create `RenderContext` from `Request` and `SSRManifest`.
+4. Call the core-`renderPage` with `RenderContext` and `Environment`.
+5. Call the core-`render` API to render the page!
diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts
new file mode 100644
index 000000000..b56a2eaf2
--- /dev/null
+++ b/packages/astro/src/core/render/index.ts
@@ -0,0 +1,22 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type { RouteData } from '../../types/public/internal.js';
+import type { Pipeline } from '../base-pipeline.js';
+export { Pipeline } from '../base-pipeline.js';
+export { getParams, getProps } from './params-and-props.js';
+export { loadRenderer } from './renderer.js';
+export { Slots } from './slots.js';
+
+export interface SSROptions {
+ /** The pipeline instance */
+ pipeline: Pipeline;
+ /** location of file on disk */
+ filePath: URL;
+ /** the web request (needed for dynamic routes) */
+ pathname: string;
+ /** The runtime component instance */
+ preload: ComponentInstance;
+ /** Request */
+ request: Request;
+ /** optional, in case we need to render something outside a dev server */
+ route: RouteData;
+}
diff --git a/packages/astro/src/core/render/paginate.ts b/packages/astro/src/core/render/paginate.ts
new file mode 100644
index 000000000..77ee5e9fb
--- /dev/null
+++ b/packages/astro/src/core/render/paginate.ts
@@ -0,0 +1,105 @@
+import type {
+ Page,
+ PaginateFunction,
+ PaginateOptions,
+ Params,
+ Props,
+} from '../../types/public/common.js';
+import type { AstroConfig } from '../../types/public/index.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import { joinPaths } from '../path.js';
+
+export function generatePaginateFunction(
+ routeMatch: RouteData,
+ base: AstroConfig['base'],
+): (...args: Parameters<PaginateFunction>) => ReturnType<PaginateFunction> {
+ return function paginateUtility(
+ data: any[],
+ args: PaginateOptions<Props, Params> = {},
+ ): ReturnType<PaginateFunction> {
+ let { pageSize: _pageSize, params: _params, props: _props } = args;
+ const pageSize = _pageSize || 10;
+ const paramName = 'page';
+ const additionalParams = _params || {};
+ const additionalProps = _props || {};
+ let includesFirstPageNumber: boolean;
+ if (routeMatch.params.includes(`...${paramName}`)) {
+ includesFirstPageNumber = false;
+ } else if (routeMatch.params.includes(`${paramName}`)) {
+ includesFirstPageNumber = true;
+ } else {
+ throw new AstroError({
+ ...AstroErrorData.PageNumberParamNotFound,
+ message: AstroErrorData.PageNumberParamNotFound.message(paramName),
+ });
+ }
+ const lastPage = Math.max(1, Math.ceil(data.length / pageSize));
+
+ const result = [...Array(lastPage).keys()].map((num) => {
+ const pageNum = num + 1;
+ const start = pageSize === Infinity ? 0 : (pageNum - 1) * pageSize; // currentPage is 1-indexed
+ const end = Math.min(start + pageSize, data.length);
+ const params = {
+ ...additionalParams,
+ [paramName]: includesFirstPageNumber || pageNum > 1 ? String(pageNum) : undefined,
+ };
+ const current = addRouteBase(routeMatch.generate({ ...params }), base);
+ const next =
+ pageNum === lastPage
+ ? undefined
+ : addRouteBase(routeMatch.generate({ ...params, page: String(pageNum + 1) }), base);
+ const prev =
+ pageNum === 1
+ ? undefined
+ : addRouteBase(
+ routeMatch.generate({
+ ...params,
+ page:
+ !includesFirstPageNumber && pageNum - 1 === 1 ? undefined : String(pageNum - 1),
+ }),
+ base,
+ );
+ const first =
+ pageNum === 1
+ ? undefined
+ : addRouteBase(
+ routeMatch.generate({
+ ...params,
+ page: includesFirstPageNumber ? '1' : undefined,
+ }),
+ base,
+ );
+ const last =
+ pageNum === lastPage
+ ? undefined
+ : addRouteBase(routeMatch.generate({ ...params, page: String(lastPage) }), base);
+ return {
+ params,
+ props: {
+ ...additionalProps,
+ page: {
+ data: data.slice(start, end),
+ start,
+ end: end - 1,
+ size: pageSize,
+ total: data.length,
+ currentPage: pageNum,
+ lastPage: lastPage,
+ url: { current, next, prev, first, last },
+ } as Page,
+ },
+ };
+ });
+ return result;
+ };
+}
+
+function addRouteBase(route: string, base: AstroConfig['base']) {
+ // `routeMatch.generate` avoids appending `/`
+ // unless `trailingSlash: 'always'` is configured.
+ // This means an empty string is possible for the index route.
+ let routeWithBase = joinPaths(base, route);
+ if (routeWithBase === '') routeWithBase = '/';
+ return routeWithBase;
+}
diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts
new file mode 100644
index 000000000..4fae3d01a
--- /dev/null
+++ b/packages/astro/src/core/render/params-and-props.ts
@@ -0,0 +1,124 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type { Params, Props } from '../../types/public/common.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { DEFAULT_404_COMPONENT } from '../constants.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+import { routeIsFallback } from '../redirects/helpers.js';
+import { routeIsRedirect } from '../redirects/index.js';
+import type { RouteCache } from './route-cache.js';
+import { callGetStaticPaths, findPathItemByKey } from './route-cache.js';
+
+interface GetParamsAndPropsOptions {
+ mod: ComponentInstance | undefined;
+ routeData?: RouteData | undefined;
+ routeCache: RouteCache;
+ pathname: string;
+ logger: Logger;
+ serverLike: boolean;
+ base: string;
+}
+
+export async function getProps(opts: GetParamsAndPropsOptions): Promise<Props> {
+ const { logger, mod, routeData: route, routeCache, pathname, serverLike, base } = opts;
+
+ // If there's no route, or if there's a pathname (e.g. a static `src/pages/normal.astro` file),
+ // then we know for sure they don't have params and props, return a fallback value.
+ if (!route || route.pathname) {
+ return {};
+ }
+
+ if (
+ routeIsRedirect(route) ||
+ routeIsFallback(route) ||
+ route.component === DEFAULT_404_COMPONENT
+ ) {
+ return {};
+ }
+
+ // During build, the route cache should already be populated.
+ // During development, the route cache is filled on-demand and may be empty.
+ const staticPaths = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ logger,
+ ssr: serverLike,
+ base,
+ });
+
+ // The pathname used here comes from the server, which already encoded.
+ // Since we decided to not mess up with encoding anymore, we need to decode them back so the parameters can match
+ // the ones expected from the users
+ const params = getParams(route, pathname);
+ const matchedStaticPath = findPathItemByKey(staticPaths, params, route, logger);
+ if (!matchedStaticPath && (serverLike ? route.prerender : true)) {
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingStaticPathFound,
+ message: AstroErrorData.NoMatchingStaticPathFound.message(pathname),
+ hint: AstroErrorData.NoMatchingStaticPathFound.hint([route.component]),
+ });
+ }
+
+ if (mod) {
+ validatePrerenderEndpointCollision(route, mod, params);
+ }
+
+ const props: Props = matchedStaticPath?.props ? { ...matchedStaticPath.props } : {};
+
+ return props;
+}
+
+/**
+ * When given a route with the pattern `/[x]/[y]/[z]/svelte`, and a pathname `/a/b/c/svelte`,
+ * returns the params object: { x: "a", y: "b", z: "c" }.
+ */
+export function getParams(route: RouteData, pathname: string): Params {
+ if (!route.params.length) return {};
+ // The RegExp pattern expects a decoded string, but the pathname is encoded
+ // when the URL contains non-English characters.
+ const paramsMatch =
+ route.pattern.exec(pathname) ||
+ route.fallbackRoutes
+ .map((fallbackRoute) => fallbackRoute.pattern.exec(pathname))
+ .find((x) => x);
+ if (!paramsMatch) return {};
+ const params: Params = {};
+ route.params.forEach((key, i) => {
+ if (key.startsWith('...')) {
+ params[key.slice(3)] = paramsMatch[i + 1] ? paramsMatch[i + 1] : undefined;
+ } else {
+ params[key] = paramsMatch[i + 1];
+ }
+ });
+ return params;
+}
+
+/**
+ * If we have an endpoint at `src/pages/api/[slug].ts` that's prerendered, and the `slug`
+ * is `undefined`, throw an error as we can't generate the `/api` file and `/api` directory
+ * at the same time. Using something like `[slug].json.ts` instead will work.
+ */
+function validatePrerenderEndpointCollision(
+ route: RouteData,
+ mod: ComponentInstance,
+ params: Params,
+) {
+ if (route.type === 'endpoint' && mod.getStaticPaths) {
+ const lastSegment = route.segments[route.segments.length - 1];
+ const paramValues = Object.values(params);
+ const lastParam = paramValues[paramValues.length - 1];
+ // Check last segment is solely `[slug]` or `[...slug]` case (dynamic). Make sure it's not
+ // `foo[slug].js` by checking segment length === 1. Also check here if that param is undefined.
+ if (lastSegment.length === 1 && lastSegment[0].dynamic && lastParam === undefined) {
+ throw new AstroError({
+ ...AstroErrorData.PrerenderDynamicEndpointPathCollide,
+ message: AstroErrorData.PrerenderDynamicEndpointPathCollide.message(route.route),
+ hint: AstroErrorData.PrerenderDynamicEndpointPathCollide.hint(route.component),
+ location: {
+ file: route.component,
+ },
+ });
+ }
+ }
+}
diff --git a/packages/astro/src/core/render/renderer.ts b/packages/astro/src/core/render/renderer.ts
new file mode 100644
index 000000000..8db3799e9
--- /dev/null
+++ b/packages/astro/src/core/render/renderer.ts
@@ -0,0 +1,17 @@
+import type { AstroRenderer } from '../../types/public/integrations.js';
+import type { SSRLoadedRenderer } from '../../types/public/internal.js';
+import type { ModuleLoader } from '../module-loader/index.js';
+
+export async function loadRenderer(
+ renderer: AstroRenderer,
+ moduleLoader: ModuleLoader,
+): Promise<SSRLoadedRenderer | undefined> {
+ const mod = await moduleLoader.import(renderer.serverEntrypoint.toString());
+ if (typeof mod.default !== 'undefined') {
+ return {
+ ...renderer,
+ ssr: mod.default,
+ };
+ }
+ return undefined;
+}
diff --git a/packages/astro/src/core/render/route-cache.ts b/packages/astro/src/core/render/route-cache.ts
new file mode 100644
index 000000000..21c823cd0
--- /dev/null
+++ b/packages/astro/src/core/render/route-cache.ts
@@ -0,0 +1,135 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type {
+ GetStaticPathsItem,
+ GetStaticPathsResult,
+ GetStaticPathsResultKeyed,
+ PaginateFunction,
+ Params,
+} from '../../types/public/common.js';
+import type { AstroConfig, RuntimeMode } from '../../types/public/config.js';
+import type { RouteData } from '../../types/public/internal.js';
+import type { Logger } from '../logger/core.js';
+
+import { stringifyParams } from '../routing/params.js';
+import { validateDynamicRouteModule, validateGetStaticPathsResult } from '../routing/validation.js';
+import { generatePaginateFunction } from './paginate.js';
+
+interface CallGetStaticPathsOptions {
+ mod: ComponentInstance | undefined;
+ route: RouteData;
+ routeCache: RouteCache;
+ logger: Logger;
+ ssr: boolean;
+ base: AstroConfig['base'];
+}
+
+export async function callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ logger,
+ ssr,
+ base,
+}: CallGetStaticPathsOptions): Promise<GetStaticPathsResultKeyed> {
+ const cached = routeCache.get(route);
+ if (!mod) {
+ throw new Error('This is an error caused by Astro and not your code. Please file an issue.');
+ }
+ if (cached?.staticPaths) {
+ return cached.staticPaths;
+ }
+
+ validateDynamicRouteModule(mod, { ssr, route });
+
+ // No static paths in SSR mode. Return an empty RouteCacheEntry.
+ if (ssr && !route.prerender) {
+ const entry: GetStaticPathsResultKeyed = Object.assign([], { keyed: new Map() });
+ routeCache.set(route, { ...cached, staticPaths: entry });
+ return entry;
+ }
+
+ let staticPaths: GetStaticPathsResult = [];
+ // Add a check here to make TypeScript happy.
+ // This is already checked in validateDynamicRouteModule().
+ if (!mod.getStaticPaths) {
+ throw new Error('Unexpected Error.');
+ }
+
+ // Calculate your static paths.
+ staticPaths = await mod.getStaticPaths({
+ // Q: Why the cast?
+ // A: So users downstream can have nicer typings, we have to make some sacrifice in our internal typings, which necessitate a cast here
+ paginate: generatePaginateFunction(route, base) as PaginateFunction,
+ });
+
+ validateGetStaticPathsResult(staticPaths, logger, route);
+
+ const keyedStaticPaths = staticPaths as GetStaticPathsResultKeyed;
+ keyedStaticPaths.keyed = new Map<string, GetStaticPathsItem>();
+
+ for (const sp of keyedStaticPaths) {
+ const paramsKey = stringifyParams(sp.params, route);
+ keyedStaticPaths.keyed.set(paramsKey, sp);
+ }
+
+ routeCache.set(route, { ...cached, staticPaths: keyedStaticPaths });
+ return keyedStaticPaths;
+}
+
+interface RouteCacheEntry {
+ staticPaths: GetStaticPathsResultKeyed;
+}
+
+/**
+ * Manage the route cache, responsible for caching data related to each route,
+ * including the result of calling getStaticPath() so that it can be reused across
+ * responses during dev and only ever called once during build.
+ */
+export class RouteCache {
+ private logger: Logger;
+ private cache: Record<string, RouteCacheEntry> = {};
+ private runtimeMode: RuntimeMode;
+
+ constructor(logger: Logger, runtimeMode: RuntimeMode = 'production') {
+ this.logger = logger;
+ this.runtimeMode = runtimeMode;
+ }
+
+ /** Clear the cache. */
+ clearAll() {
+ this.cache = {};
+ }
+
+ set(route: RouteData, entry: RouteCacheEntry): void {
+ const key = this.key(route);
+ // NOTE: This shouldn't be called on an already-cached component.
+ // Warn here so that an unexpected double-call of getStaticPaths()
+ // isn't invisible and developer can track down the issue.
+ if (this.runtimeMode === 'production' && this.cache[key]?.staticPaths) {
+ this.logger.warn(null, `Internal Warning: route cache overwritten. (${key})`);
+ }
+ this.cache[key] = entry;
+ }
+
+ get(route: RouteData): RouteCacheEntry | undefined {
+ return this.cache[this.key(route)];
+ }
+
+ key(route: RouteData) {
+ return `${route.route}_${route.component}`;
+ }
+}
+
+export function findPathItemByKey(
+ staticPaths: GetStaticPathsResultKeyed,
+ params: Params,
+ route: RouteData,
+ logger: Logger,
+) {
+ const paramsKey = stringifyParams(params, route);
+ const matchedStaticPath = staticPaths.keyed.get(paramsKey);
+ if (matchedStaticPath) {
+ return matchedStaticPath;
+ }
+ logger.debug('router', `findPathItemByKey() - Unexpected cache miss looking for ${paramsKey}`);
+}
diff --git a/packages/astro/src/core/render/slots.ts b/packages/astro/src/core/render/slots.ts
new file mode 100644
index 000000000..1c767083d
--- /dev/null
+++ b/packages/astro/src/core/render/slots.ts
@@ -0,0 +1,84 @@
+import { type ComponentSlots, renderSlotToString } from '../../runtime/server/index.js';
+import { renderJSX } from '../../runtime/server/jsx.js';
+import { chunkToString } from '../../runtime/server/render/index.js';
+import { isRenderInstruction } from '../../runtime/server/render/instruction.js';
+import type { SSRResult } from '../../types/public/internal.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+
+function getFunctionExpression(slot: any) {
+ if (!slot) return;
+ const expressions = slot?.expressions?.filter((e: unknown) => isRenderInstruction(e) === false);
+ if (expressions?.length !== 1) return;
+ return expressions[0] as (...args: any[]) => any;
+}
+
+export class Slots {
+ #result: SSRResult;
+ #slots: ComponentSlots | null;
+ #logger: Logger;
+
+ constructor(result: SSRResult, slots: ComponentSlots | null, logger: Logger) {
+ this.#result = result;
+ this.#slots = slots;
+ this.#logger = logger;
+
+ if (slots) {
+ for (const key of Object.keys(slots)) {
+ if ((this as any)[key] !== undefined) {
+ throw new AstroError({
+ ...AstroErrorData.ReservedSlotName,
+ message: AstroErrorData.ReservedSlotName.message(key),
+ });
+ }
+ Object.defineProperty(this, key, {
+ get() {
+ return true;
+ },
+ enumerable: true,
+ });
+ }
+ }
+ }
+
+ public has(name: string) {
+ if (!this.#slots) return false;
+ return Boolean(this.#slots[name]);
+ }
+
+ public async render(name: string, args: any[] = []) {
+ if (!this.#slots || !this.has(name)) return;
+
+ const result = this.#result;
+ if (!Array.isArray(args)) {
+ this.#logger.warn(
+ null,
+ `Expected second parameter to be an array, received a ${typeof args}. If you're trying to pass an array as a single argument and getting unexpected results, make sure you're passing your array as a item of an array. Ex: Astro.slots.render('default', [["Hello", "World"]])`,
+ );
+ } else if (args.length > 0) {
+ const slotValue = this.#slots[name];
+ const component = typeof slotValue === 'function' ? await slotValue(result) : await slotValue;
+
+ // Astro
+ const expression = getFunctionExpression(component);
+ if (expression) {
+ const slot = async () =>
+ typeof expression === 'function' ? expression(...args) : expression;
+ return await renderSlotToString(result, slot).then((res) => {
+ return res;
+ });
+ }
+ // JSX
+ if (typeof component === 'function') {
+ return await renderJSX(result, (component as any)(...args)).then((res) =>
+ res != null ? String(res) : res,
+ );
+ }
+ }
+
+ const content = await renderSlotToString(result, this.#slots[name]);
+ const outHTML = chunkToString(result, content);
+
+ return outHTML;
+ }
+}
diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts
new file mode 100644
index 000000000..7b5cac844
--- /dev/null
+++ b/packages/astro/src/core/render/ssr-element.ts
@@ -0,0 +1,75 @@
+import { getAssetsPrefix } from '../../assets/utils/getAssetsPrefix.js';
+import { fileExtension, joinPaths, prependForwardSlash, slash } from '../../core/path.js';
+import type { SSRElement } from '../../types/public/internal.js';
+import type { AssetsPrefix, StylesheetAsset } from '../app/types.js';
+
+export function createAssetLink(href: string, base?: string, assetsPrefix?: AssetsPrefix): string {
+ if (assetsPrefix) {
+ const pf = getAssetsPrefix(fileExtension(href), assetsPrefix);
+ return joinPaths(pf, slash(href));
+ } else if (base) {
+ return prependForwardSlash(joinPaths(base, slash(href)));
+ } else {
+ return href;
+ }
+}
+
+export function createStylesheetElement(
+ stylesheet: StylesheetAsset,
+ base?: string,
+ assetsPrefix?: AssetsPrefix,
+): SSRElement {
+ if (stylesheet.type === 'inline') {
+ return {
+ props: {},
+ children: stylesheet.content,
+ };
+ } else {
+ return {
+ props: {
+ rel: 'stylesheet',
+ href: createAssetLink(stylesheet.src, base, assetsPrefix),
+ },
+ children: '',
+ };
+ }
+}
+
+export function createStylesheetElementSet(
+ stylesheets: StylesheetAsset[],
+ base?: string,
+ assetsPrefix?: AssetsPrefix,
+): Set<SSRElement> {
+ return new Set(stylesheets.map((s) => createStylesheetElement(s, base, assetsPrefix)));
+}
+
+export function createModuleScriptElement(
+ script: { type: 'inline' | 'external'; value: string },
+ base?: string,
+ assetsPrefix?: AssetsPrefix,
+): SSRElement {
+ if (script.type === 'external') {
+ return createModuleScriptElementWithSrc(script.value, base, assetsPrefix);
+ } else {
+ return {
+ props: {
+ type: 'module',
+ },
+ children: script.value,
+ };
+ }
+}
+
+export function createModuleScriptElementWithSrc(
+ src: string,
+ base?: string,
+ assetsPrefix?: AssetsPrefix,
+): SSRElement {
+ return {
+ props: {
+ type: 'module',
+ src: createAssetLink(src, base, assetsPrefix),
+ },
+ children: '',
+ };
+}
diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts
new file mode 100644
index 000000000..5e646bb89
--- /dev/null
+++ b/packages/astro/src/core/request.ts
@@ -0,0 +1,97 @@
+import type { IncomingHttpHeaders } from 'node:http';
+import type { Logger } from './logger/core.js';
+
+type HeaderType = Headers | Record<string, any> | IncomingHttpHeaders;
+
+export interface CreateRequestOptions {
+ url: URL | string;
+ clientAddress?: string | undefined;
+ headers: HeaderType;
+ method?: string;
+ body?: RequestInit['body'];
+ logger: Logger;
+ locals?: object | undefined;
+ /**
+ * Whether the request is being created for a static build or for a prerendered page within a hybrid/SSR build, or for emulating one of those in dev mode.
+ *
+ * When `true`, the request will not include search parameters or body, and warn when headers are accessed.
+ *
+ * @default false
+ */
+ isPrerendered?: boolean;
+
+ routePattern: string;
+
+ init?: RequestInit;
+}
+
+/**
+ * Used by astro internals to create a web standard request object.
+ *
+ * The user of this function may provide the data in a runtime-agnostic way.
+ *
+ * This is used by the static build to create fake requests for prerendering, and by the dev server to convert node requests into the standard request object.
+ */
+export function createRequest({
+ url,
+ headers,
+ method = 'GET',
+ body = undefined,
+ logger,
+ isPrerendered = false,
+ routePattern,
+ init,
+}: CreateRequestOptions): Request {
+ // headers are made available on the created request only if the request is for a page that will be on-demand rendered
+ const headersObj = isPrerendered
+ ? undefined
+ : headers instanceof Headers
+ ? headers
+ : new Headers(
+ // Filter out HTTP/2 pseudo-headers. These are internally-generated headers added to all HTTP/2 requests with trusted metadata about the request.
+ // Examples include `:method`, `:scheme`, `:authority`, and `:path`.
+ // They are always prefixed with a colon to distinguish them from other headers, and it is an error to add the to a Headers object manually.
+ // See https://httpwg.org/specs/rfc7540.html#HttpRequest
+ Object.entries(headers as Record<string, any>).filter(([name]) => !name.startsWith(':')),
+ );
+
+ if (typeof url === 'string') url = new URL(url);
+
+ // Remove search parameters if the request is for a page that will be on-demand rendered
+ if (isPrerendered) {
+ url.search = '';
+ }
+
+ const request = new Request(url, {
+ method: method,
+ headers: headersObj,
+ // body is made available only if the request is for a page that will be on-demand rendered
+ body: isPrerendered ? null : body,
+ ...init,
+ });
+
+ if (isPrerendered) {
+ // Warn when accessing headers in SSG mode
+ let _headers = request.headers;
+
+ // We need to remove descriptor's value and writable properties because we're adding getters and setters.
+ const { value, writable, ...headersDesc } =
+ Object.getOwnPropertyDescriptor(request, 'headers') || {};
+
+ Object.defineProperty(request, 'headers', {
+ ...headersDesc,
+ get() {
+ logger.warn(
+ null,
+ `\`Astro.request.headers\` was used when rendering the route \`${routePattern}'\`. \`Astro.request.headers\` is not available on prerendered pages. If you need access to request headers, make sure that the page is server-rendered using \`export const prerender = false;\` or by setting \`output\` to \`"server"\` in your Astro config to make all your pages server-rendered by default.`,
+ );
+ return _headers;
+ },
+ set(newHeaders: Headers) {
+ _headers = newHeaders;
+ },
+ });
+ }
+
+ return request;
+}
diff --git a/packages/astro/src/core/routing/3xx.ts b/packages/astro/src/core/routing/3xx.ts
new file mode 100644
index 000000000..c05d7a894
--- /dev/null
+++ b/packages/astro/src/core/routing/3xx.ts
@@ -0,0 +1,19 @@
+export type RedirectTemplate = {
+ from?: string;
+ location: string | URL;
+ status: number;
+};
+
+export function redirectTemplate({ status, location, from }: RedirectTemplate) {
+ // A short delay causes Google to interpret the redirect as temporary.
+ // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
+ const delay = status === 302 ? 2 : 0;
+ return `<!doctype html>
+<title>Redirecting to: ${location}</title>
+<meta http-equiv="refresh" content="${delay};url=${location}">
+<meta name="robots" content="noindex">
+<link rel="canonical" href="${location}">
+<body>
+ <a href="${location}">Redirecting ${from ? `from <code>${from}</code> ` : ''}to <code>${location}</code></a>
+</body>`;
+}
diff --git a/packages/astro/src/core/routing/astro-designed-error-pages.ts b/packages/astro/src/core/routing/astro-designed-error-pages.ts
new file mode 100644
index 000000000..6cca57a59
--- /dev/null
+++ b/packages/astro/src/core/routing/astro-designed-error-pages.ts
@@ -0,0 +1,59 @@
+import notFoundTemplate from '../../template/4xx.js';
+import type { ComponentInstance, RoutesList } from '../../types/astro.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { DEFAULT_404_COMPONENT, DEFAULT_500_COMPONENT } from '../constants.js';
+
+export const DEFAULT_404_ROUTE: RouteData = {
+ component: DEFAULT_404_COMPONENT,
+ generate: () => '',
+ params: [],
+ pattern: /\/404/,
+ prerender: false,
+ pathname: '/404',
+ segments: [[{ content: '404', dynamic: false, spread: false }]],
+ type: 'page',
+ route: '/404',
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'internal',
+};
+
+export const DEFAULT_500_ROUTE: RouteData = {
+ component: DEFAULT_500_COMPONENT,
+ generate: () => '',
+ params: [],
+ pattern: /\/500/,
+ prerender: false,
+ pathname: '/500',
+ segments: [[{ content: '500', dynamic: false, spread: false }]],
+ type: 'page',
+ route: '/500',
+ fallbackRoutes: [],
+ isIndex: false,
+ origin: 'internal',
+};
+
+export function ensure404Route(manifest: RoutesList) {
+ if (!manifest.routes.some((route) => route.route === '/404')) {
+ manifest.routes.push(DEFAULT_404_ROUTE);
+ }
+ return manifest;
+}
+
+async function default404Page({ pathname }: { pathname: string }) {
+ return new Response(
+ notFoundTemplate({
+ statusCode: 404,
+ title: 'Not found',
+ tabTitle: '404: Not Found',
+ pathname,
+ }),
+ { status: 404, headers: { 'Content-Type': 'text/html' } },
+ );
+}
+// mark the function as an AstroComponentFactory for the rendering internals
+default404Page.isAstroComponentFactory = true;
+
+export const default404Instance: ComponentInstance = {
+ default: default404Page,
+};
diff --git a/packages/astro/src/core/routing/default.ts b/packages/astro/src/core/routing/default.ts
new file mode 100644
index 000000000..d255b1327
--- /dev/null
+++ b/packages/astro/src/core/routing/default.ts
@@ -0,0 +1,36 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type { SSRManifest } from '../app/types.js';
+import { DEFAULT_404_COMPONENT } from '../constants.js';
+import {
+ SERVER_ISLAND_COMPONENT,
+ SERVER_ISLAND_ROUTE,
+ createEndpoint as createServerIslandEndpoint,
+} from '../server-islands/endpoint.js';
+import { DEFAULT_404_ROUTE, default404Instance } from './astro-designed-error-pages.js';
+
+type DefaultRouteParams = {
+ instance: ComponentInstance;
+ matchesComponent(filePath: URL): boolean;
+ route: string;
+ component: string;
+};
+
+export const DEFAULT_COMPONENTS = [DEFAULT_404_COMPONENT, SERVER_ISLAND_COMPONENT];
+
+export function createDefaultRoutes(manifest: SSRManifest): DefaultRouteParams[] {
+ const root = new URL(manifest.hrefRoot);
+ return [
+ {
+ instance: default404Instance,
+ matchesComponent: (filePath) => filePath.href === new URL(DEFAULT_404_COMPONENT, root).href,
+ route: DEFAULT_404_ROUTE.route,
+ component: DEFAULT_404_COMPONENT,
+ },
+ {
+ instance: createServerIslandEndpoint(manifest),
+ matchesComponent: (filePath) => filePath.href === new URL(SERVER_ISLAND_COMPONENT, root).href,
+ route: SERVER_ISLAND_ROUTE,
+ component: SERVER_ISLAND_COMPONENT,
+ },
+ ];
+}
diff --git a/packages/astro/src/core/routing/index.ts b/packages/astro/src/core/routing/index.ts
new file mode 100644
index 000000000..1267d67e7
--- /dev/null
+++ b/packages/astro/src/core/routing/index.ts
@@ -0,0 +1,4 @@
+export { createRoutesList } from './manifest/create.js';
+export { deserializeRouteData, serializeRouteData } from './manifest/serialization.js';
+export { matchAllRoutes, matchRoute } from './match.js';
+export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js';
diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts
new file mode 100644
index 000000000..692d47beb
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/create.ts
@@ -0,0 +1,767 @@
+import type { AstroSettings, RoutesList } from '../../../types/astro.js';
+import type { Logger } from '../../logger/core.js';
+
+import nodeFs from 'node:fs';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bold } from 'kleur/colors';
+import pLimit from 'p-limit';
+import { injectImageEndpoint } from '../../../assets/endpoint/config.js';
+import { toRoutingStrategy } from '../../../i18n/utils.js';
+import { runHookRoutesResolved } from '../../../integrations/hooks.js';
+import { getPrerenderDefault } from '../../../prerender/utils.js';
+import type { AstroConfig } from '../../../types/public/config.js';
+import type { RouteData, RoutePart } from '../../../types/public/internal.js';
+import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
+import {
+ MissingIndexForInternationalization,
+ UnsupportedExternalRedirect,
+} from '../../errors/errors-data.js';
+import { AstroError } from '../../errors/index.js';
+import { hasFileExtension, removeLeadingForwardSlash, slash } from '../../path.js';
+import { injectServerIslandRoute } from '../../server-islands/endpoint.js';
+import { resolvePages } from '../../util.js';
+import { ensure404Route } from '../astro-designed-error-pages.js';
+import { routeComparator } from '../priority.js';
+import { getRouteGenerator } from './generator.js';
+import { getPattern } from './pattern.js';
+import { getRoutePrerenderOption } from './prerender.js';
+import { validateSegment } from './segment.js';
+
+const require = createRequire(import.meta.url);
+
+interface Item {
+ basename: string;
+ ext: string;
+ parts: RoutePart[];
+ file: string;
+ isDir: boolean;
+ isIndex: boolean;
+ isPage: boolean;
+ routeSuffix: string;
+}
+
+// Disable eslint as we're not sure how to improve this regex yet
+// eslint-disable-next-line regexp/no-super-linear-backtracking
+const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/;
+const ROUTE_SPREAD = /^\.{3}.+$/;
+
+export function getParts(part: string, file: string) {
+ const result: RoutePart[] = [];
+ part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => {
+ if (!str) return;
+ const dynamic = i % 2 === 1;
+
+ const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str];
+
+ if (!content || (dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content))) {
+ throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`);
+ }
+
+ result.push({
+ content,
+ dynamic,
+ spread: dynamic && ROUTE_SPREAD.test(content),
+ });
+ });
+
+ return result;
+}
+/**
+ * Checks whether two route segments are semantically equivalent.
+ *
+ * Two segments are equivalent if they would match the same paths. This happens when:
+ * - They have the same length.
+ * - Each part in the same position is either:
+ * - Both static and with the same content (e.g. `/foo` and `/foo`).
+ * - Both dynamic, regardless of the content (e.g. `/[bar]` and `/[baz]`).
+ * - Both rest parameters, regardless of the content (e.g. `/[...bar]` and `/[...baz]`).
+ */
+function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[]) {
+ if (segmentA.length !== segmentB.length) {
+ return false;
+ }
+
+ for (const [index, partA] of segmentA.entries()) {
+ // Safe to use the index of one segment for the other because the segments have the same length
+ const partB = segmentB[index];
+
+ if (partA.dynamic !== partB.dynamic || partA.spread !== partB.spread) {
+ return false;
+ }
+
+ // Only compare the content on non-dynamic segments
+ // `/[bar]` and `/[baz]` are effectively the same route,
+ // only bound to a different path parameter.
+ if (!partA.dynamic && partA.content !== partB.content) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+export interface CreateRouteManifestParams {
+ /** Astro Settings object */
+ settings: AstroSettings;
+ /** Current working directory */
+ cwd?: string;
+ /** fs module, for testing */
+ fsMod?: typeof nodeFs;
+}
+
+function createFileBasedRoutes(
+ { settings, cwd, fsMod }: CreateRouteManifestParams,
+ logger: Logger,
+): RouteData[] {
+ const components: string[] = [];
+ const routes: RouteData[] = [];
+ const validPageExtensions = new Set<string>([
+ '.astro',
+ ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
+ ...settings.pageExtensions,
+ ]);
+ const validEndpointExtensions = new Set<string>(['.js', '.ts']);
+ const localFs = fsMod ?? nodeFs;
+ const prerender = getPrerenderDefault(settings.config);
+
+ function walk(
+ fs: typeof nodeFs,
+ dir: string,
+ parentSegments: RoutePart[][],
+ parentParams: string[],
+ ) {
+ let items: Item[] = [];
+ const files = fs.readdirSync(dir);
+ for (const basename of files) {
+ const resolved = path.join(dir, basename);
+ const file = slash(path.relative(cwd || fileURLToPath(settings.config.root), resolved));
+ const isDir = fs.statSync(resolved).isDirectory();
+
+ const ext = path.extname(basename);
+ const name = ext ? basename.slice(0, -ext.length) : basename;
+ if (name[0] === '_') {
+ continue;
+ }
+ if (basename[0] === '.' && basename !== '.well-known') {
+ continue;
+ }
+ // filter out "foo.astro_tmp" files, etc
+ if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) {
+ logger.warn(
+ null,
+ `Unsupported file type ${bold(
+ resolved,
+ )} found. Prefix filename with an underscore (\`_\`) to ignore.`,
+ );
+
+ continue;
+ }
+ const segment = isDir ? basename : name;
+ validateSegment(segment, file);
+
+ const parts = getParts(segment, file);
+ const isIndex = isDir ? false : basename.substring(0, basename.lastIndexOf('.')) === 'index';
+ const routeSuffix = basename.slice(basename.indexOf('.'), -ext.length);
+ const isPage = validPageExtensions.has(ext);
+
+ items.push({
+ basename,
+ ext,
+ parts,
+ file: file.replace(/\\/g, '/'),
+ isDir,
+ isIndex,
+ isPage,
+ routeSuffix,
+ });
+ }
+
+ for (const item of items) {
+ const segments = parentSegments.slice();
+
+ if (item.isIndex) {
+ if (item.routeSuffix) {
+ if (segments.length > 0) {
+ const lastSegment = segments[segments.length - 1].slice();
+ const lastPart = lastSegment[lastSegment.length - 1];
+
+ if (lastPart.dynamic) {
+ lastSegment.push({
+ dynamic: false,
+ spread: false,
+ content: item.routeSuffix,
+ });
+ } else {
+ lastSegment[lastSegment.length - 1] = {
+ dynamic: false,
+ spread: false,
+ content: `${lastPart.content}${item.routeSuffix}`,
+ };
+ }
+
+ segments[segments.length - 1] = lastSegment;
+ } else {
+ segments.push(item.parts);
+ }
+ }
+ } else {
+ segments.push(item.parts);
+ }
+
+ const params = parentParams.slice();
+ params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content));
+
+ if (item.isDir) {
+ walk(fsMod ?? fs, path.join(dir, item.basename), segments, params);
+ } else {
+ components.push(item.file);
+ const component = item.file;
+ const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
+ ? `/${segments.map((segment) => segment[0].content).join('/')}`
+ : null;
+ const trailingSlash = trailingSlashForPath(pathname, settings.config);
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const generate = getRouteGenerator(segments, trailingSlash);
+ const route = joinSegments(segments);
+ routes.push({
+ route,
+ isIndex: item.isIndex,
+ type: item.isPage ? 'page' : 'endpoint',
+ pattern,
+ segments,
+ params,
+ component,
+ generate,
+ pathname: pathname || undefined,
+ prerender,
+ fallbackRoutes: [],
+ distURL: [],
+ origin: 'project',
+ });
+ }
+ }
+ }
+
+ const { config } = settings;
+ const pages = resolvePages(config);
+
+ if (localFs.existsSync(pages)) {
+ walk(localFs, fileURLToPath(pages), [], []);
+ } else if (settings.injectedRoutes.length === 0) {
+ const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length);
+ logger.warn(null, `Missing pages directory: ${pagesDirRootRelative}`);
+ }
+
+ return routes;
+}
+
+// Get trailing slash rule for a path, based on the config and whether the path has an extension.
+// TODO: in Astro 6, change endpoints with extentions to use 'never'
+const trailingSlashForPath = (
+ pathname: string | null,
+ config: AstroConfig,
+): AstroConfig['trailingSlash'] =>
+ pathname && hasFileExtension(pathname) ? 'ignore' : config.trailingSlash;
+
+function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): RouteData[] {
+ const { config } = settings;
+ const prerender = getPrerenderDefault(config);
+
+ const routes: RouteData[] = [];
+
+ for (const injectedRoute of settings.injectedRoutes) {
+ const { pattern: name, entrypoint, prerender: prerenderInjected, origin } = injectedRoute;
+ const { resolved, component } = resolveInjectedRoute(entrypoint.toString(), config.root, cwd);
+
+ const segments = removeLeadingForwardSlash(name)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, component);
+ });
+
+ const type = resolved.endsWith('.astro') ? 'page' : 'endpoint';
+ const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
+ ? `/${segments.map((segment) => segment[0].content).join('/')}`
+ : null;
+
+ const trailingSlash = trailingSlashForPath(pathname, config);
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const generate = getRouteGenerator(segments, trailingSlash);
+ const params = segments
+ .flat()
+ .filter((p) => p.dynamic)
+ .map((p) => p.content);
+ const route = joinSegments(segments);
+
+ routes.push({
+ type,
+ // For backwards compatibility, an injected route is never considered an index route.
+ isIndex: false,
+ route,
+ pattern,
+ segments,
+ params,
+ component,
+ generate,
+ pathname: pathname || void 0,
+ prerender: prerenderInjected ?? prerender,
+ fallbackRoutes: [],
+ distURL: [],
+ origin,
+ });
+ }
+
+ return routes;
+}
+
+/**
+ * Create route data for all configured redirects.
+ */
+function createRedirectRoutes(
+ { settings }: CreateRouteManifestParams,
+ routeMap: Map<string, RouteData>,
+): RouteData[] {
+ const { config } = settings;
+ const trailingSlash = config.trailingSlash;
+
+ const routes: RouteData[] = [];
+
+ for (const [from, to] of Object.entries(settings.config.redirects)) {
+ const segments = removeLeadingForwardSlash(from)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, from);
+ });
+
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
+ const generate = getRouteGenerator(segments, trailingSlash);
+ const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
+ ? `/${segments.map((segment) => segment[0].content).join('/')}`
+ : null;
+ const params = segments
+ .flat()
+ .filter((p) => p.dynamic)
+ .map((p) => p.content);
+ const route = joinSegments(segments);
+
+ let destination: string;
+ if (typeof to === 'string') {
+ destination = to;
+ } else {
+ destination = to.destination;
+ }
+
+ // URLs that don't start with leading slash should be considered external
+ if (!destination.startsWith('/')) {
+ // check if the link starts with http or https; if not, log a warning
+ if (!/^https?:\/\//.test(destination) && !URL.canParse(destination)) {
+ throw new AstroError(UnsupportedExternalRedirect);
+ }
+ }
+
+ routes.push({
+ type: 'redirect',
+ // For backwards compatibility, a redirect is never considered an index route.
+ isIndex: false,
+ route,
+ pattern,
+ segments,
+ params,
+ component: from,
+ generate,
+ pathname: pathname || void 0,
+ prerender: false,
+ redirect: to,
+ redirectRoute: routeMap.get(destination),
+ fallbackRoutes: [],
+ distURL: [],
+ origin: 'project',
+ });
+ }
+
+ return routes;
+}
+
+/**
+ * Checks whether a route segment is static.
+ */
+function isStaticSegment(segment: RoutePart[]) {
+ return segment.every((part) => !part.dynamic && !part.spread);
+}
+
+/**
+ * Check whether two are sure to collide in clearly unintended ways report appropriately.
+ *
+ * Fallback routes are never considered to collide with any other route.
+ * Routes that may collide depending on the parameters returned by their `getStaticPaths`
+ * are not reported as collisions at this stage.
+ *
+ * Two routes are guaranteed to collide in the following scenarios:
+ * - Both are the exact same static route.
+ * For example, `/foo` from an injected route and `/foo` from a file in the project.
+ * - Both are non-prerendered dynamic routes with equal static parts in matching positions
+ * and dynamic parts of same type in the same positions.
+ * For example, `/foo/[bar]` and `/foo/[baz]` or `/foo/[...bar]` and `/foo/[...baz]`
+ * but not `/foo/[bar]` and `/foo/[...baz]`.
+ */
+function detectRouteCollision(a: RouteData, b: RouteData, _config: AstroConfig, logger: Logger) {
+ if (a.type === 'fallback' || b.type === 'fallback') {
+ // If either route is a fallback route, they don't collide.
+ // Fallbacks are always added below other routes exactly to avoid collisions.
+ return;
+ }
+
+ if (
+ a.route === b.route &&
+ a.segments.every(isStaticSegment) &&
+ b.segments.every(isStaticSegment)
+ ) {
+ // If both routes are the same and completely static they are guaranteed to collide
+ // such that one of them will never be matched.
+ logger.warn(
+ 'router',
+ `The route "${a.route}" is defined in both "${a.component}" and "${b.component}". A static route cannot be defined more than once.`,
+ );
+ logger.warn(
+ 'router',
+ 'A collision will result in an hard error in following versions of Astro.',
+ );
+ return;
+ }
+
+ if (a.prerender || b.prerender) {
+ // If either route is prerendered, it is impossible to know if they collide
+ // at this stage because it depends on the parameters returned by `getStaticPaths`.
+ return;
+ }
+
+ if (a.segments.length !== b.segments.length) {
+ // If the routes have different number of segments, they cannot perfectly overlap
+ // each other, so a collision is either not guaranteed or may be intentional.
+ return;
+ }
+
+ // Routes have the same number of segments, can use either.
+ const segmentCount = a.segments.length;
+
+ for (let index = 0; index < segmentCount; index++) {
+ const segmentA = a.segments[index];
+ const segmentB = b.segments[index];
+
+ if (!isSemanticallyEqualSegment(segmentA, segmentB)) {
+ // If any segment is not semantically equal between the routes
+ // it is not certain that the routes collide.
+ return;
+ }
+ }
+
+ // Both routes are guaranteed to collide such that one will never be matched.
+ logger.warn(
+ 'router',
+ `The route "${a.route}" is defined in both "${a.component}" and "${b.component}" using SSR mode. A dynamic SSR route cannot be defined more than once.`,
+ );
+ logger.warn('router', 'A collision will result in an hard error in following versions of Astro.');
+}
+
+/** Create manifest of all static routes */
+export async function createRoutesList(
+ params: CreateRouteManifestParams,
+ logger: Logger,
+ { dev = false }: { dev?: boolean } = {},
+): Promise<RoutesList> {
+ const { settings } = params;
+ const { config } = settings;
+ // Create a map of all routes so redirects can refer to any route
+ const routeMap = new Map();
+
+ const fileBasedRoutes = createFileBasedRoutes(params, logger);
+ for (const route of fileBasedRoutes) {
+ routeMap.set(route.route, route);
+ }
+
+ const injectedRoutes = createInjectedRoutes(params);
+ for (const route of injectedRoutes) {
+ routeMap.set(route.route, route);
+ }
+
+ const redirectRoutes = createRedirectRoutes(params, routeMap);
+
+ // we remove the file based routes that were deemed redirects
+ const filteredFiledBasedRoutes = fileBasedRoutes.filter((fileBasedRoute) => {
+ const isRedirect = redirectRoutes.findIndex((rd) => rd.route === fileBasedRoute.route);
+ return isRedirect < 0;
+ });
+
+ const routes: RouteData[] = [
+ ...[...filteredFiledBasedRoutes, ...injectedRoutes, ...redirectRoutes].sort(routeComparator),
+ ];
+
+ settings.buildOutput = getPrerenderDefault(config) ? 'static' : 'server';
+
+ // Check the prerender option for each route
+ const limit = pLimit(10);
+ let promises = [];
+ for (const route of routes) {
+ promises.push(
+ limit(async () => {
+ if (route.type !== 'page' && route.type !== 'endpoint') return;
+ const localFs = params.fsMod ?? nodeFs;
+ const content = await localFs.promises.readFile(
+ fileURLToPath(new URL(route.component, settings.config.root)),
+ 'utf-8',
+ );
+
+ await getRoutePrerenderOption(content, route, settings, logger);
+ }),
+ );
+ }
+ await Promise.all(promises);
+
+ // Report route collisions
+ for (const [index, higherRoute] of routes.entries()) {
+ for (const lowerRoute of routes.slice(index + 1)) {
+ detectRouteCollision(higherRoute, lowerRoute, config, logger);
+ }
+ }
+
+ const i18n = settings.config.i18n;
+ if (i18n) {
+ const strategy = toRoutingStrategy(i18n.routing, i18n.domains);
+ // First we check if the user doesn't have an index page.
+ if (strategy === 'pathname-prefix-always') {
+ let index = routes.find((route) => route.route === '/');
+ if (!index) {
+ let relativePath = path.relative(
+ fileURLToPath(settings.config.root),
+ fileURLToPath(new URL('pages', settings.config.srcDir)),
+ );
+ throw new AstroError({
+ ...MissingIndexForInternationalization,
+ message: MissingIndexForInternationalization.message(i18n.defaultLocale),
+ hint: MissingIndexForInternationalization.hint(relativePath),
+ });
+ }
+ }
+
+ // In this block of code we group routes based on their locale
+
+ // A map like: locale => RouteData[]
+ const routesByLocale = new Map<string, RouteData[]>();
+ // This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice.
+ // The assumption is that a route in the file system belongs to only one locale.
+ const setRoutes = new Set(routes.filter((route) => route.type === 'page'));
+
+ // First loop
+ // We loop over the locales minus the default locale and add only the routes that contain `/<locale>`.
+ const filteredLocales = i18n.locales
+ .filter((loc) => {
+ if (typeof loc === 'string') {
+ return loc !== i18n.defaultLocale;
+ }
+ return loc.path !== i18n.defaultLocale;
+ })
+ .map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ }
+ return locale.path;
+ });
+ for (const locale of filteredLocales) {
+ for (const route of setRoutes) {
+ if (!route.route.includes(`/${locale}`)) {
+ continue;
+ }
+ const currentRoutes = routesByLocale.get(locale);
+ if (currentRoutes) {
+ currentRoutes.push(route);
+ routesByLocale.set(locale, currentRoutes);
+ } else {
+ routesByLocale.set(locale, [route]);
+ }
+ setRoutes.delete(route);
+ }
+ }
+
+ // we loop over the remaining routes and add them to the default locale
+ for (const route of setRoutes) {
+ const currentRoutes = routesByLocale.get(i18n.defaultLocale);
+ if (currentRoutes) {
+ currentRoutes.push(route);
+ routesByLocale.set(i18n.defaultLocale, currentRoutes);
+ } else {
+ routesByLocale.set(i18n.defaultLocale, [route]);
+ }
+ setRoutes.delete(route);
+ }
+
+ // Work done, now we start creating "fallback" routes based on the configuration
+
+ if (strategy === 'pathname-prefix-always') {
+ // we attempt to retrieve the index page of the default locale
+ const defaultLocaleRoutes = routesByLocale.get(i18n.defaultLocale);
+ if (defaultLocaleRoutes) {
+ // The index for the default locale will be either already at the root path
+ // or at the root of the locale.
+ const indexDefaultRoute =
+ defaultLocaleRoutes.find(({ route }) => route === '/') ??
+ defaultLocaleRoutes.find(({ route }) => route === `/${i18n.defaultLocale}`);
+
+ if (indexDefaultRoute) {
+ // we found the index of the default locale, now we create a root index that will redirect to the index of the default locale
+ const pathname = '/';
+ const route = '/';
+
+ const segments = removeLeadingForwardSlash(route)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, route);
+ });
+
+ routes.push({
+ ...indexDefaultRoute,
+ pathname,
+ route,
+ segments,
+ pattern: getPattern(segments, config.base, config.trailingSlash),
+ type: 'fallback',
+ });
+ }
+ }
+ }
+
+ if (i18n.fallback) {
+ let fallback = Object.entries(i18n.fallback);
+
+ if (fallback.length > 0) {
+ for (const [fallbackFromLocale, fallbackToLocale] of fallback) {
+ let fallbackToRoutes;
+ if (fallbackToLocale === i18n.defaultLocale) {
+ fallbackToRoutes = routesByLocale.get(i18n.defaultLocale);
+ } else {
+ fallbackToRoutes = routesByLocale.get(fallbackToLocale);
+ }
+ const fallbackFromRoutes = routesByLocale.get(fallbackFromLocale);
+
+ // Technically, we should always have a fallback to. Added this to make TS happy.
+ if (!fallbackToRoutes) {
+ continue;
+ }
+
+ for (const fallbackToRoute of fallbackToRoutes) {
+ const hasRoute =
+ fallbackFromRoutes &&
+ // we check if the fallback from locale (the origin) has already this route
+ fallbackFromRoutes.some((route) => {
+ if (fallbackToLocale === i18n.defaultLocale) {
+ return (
+ route.route.replace(`/${fallbackFromLocale}`, '') === fallbackToRoute.route
+ );
+ } else {
+ return (
+ route.route.replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`) ===
+ fallbackToRoute.route
+ );
+ }
+ });
+
+ if (!hasRoute) {
+ let pathname: string | undefined;
+ let route: string;
+ if (
+ fallbackToLocale === i18n.defaultLocale &&
+ strategy === 'pathname-prefix-other-locales'
+ ) {
+ if (fallbackToRoute.pathname) {
+ pathname = `/${fallbackFromLocale}${fallbackToRoute.pathname}`;
+ }
+ route = `/${fallbackFromLocale}${fallbackToRoute.route}`;
+ } else {
+ pathname = fallbackToRoute.pathname
+ ?.replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`)
+ .replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`);
+ route = fallbackToRoute.route
+ .replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`)
+ .replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`);
+ }
+ const segments = removeLeadingForwardSlash(route)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, route);
+ });
+ const generate = getRouteGenerator(segments, config.trailingSlash);
+ const index = routes.findIndex((r) => r === fallbackToRoute);
+ if (index >= 0) {
+ const fallbackRoute: RouteData = {
+ ...fallbackToRoute,
+ pathname,
+ route,
+ segments,
+ generate,
+ pattern: getPattern(segments, config.base, config.trailingSlash),
+ type: 'fallback',
+ fallbackRoutes: [],
+ };
+ const routeData = routes[index];
+ routeData.fallbackRoutes.push(fallbackRoute);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (dev) {
+ // In SSR, a 404 route is injected in the App directly for some special handling,
+ // it must not appear in the manifest
+ ensure404Route({ routes });
+ }
+ if (dev || settings.buildOutput === 'server') {
+ injectImageEndpoint(settings, { routes }, dev ? 'dev' : 'build');
+ }
+
+ // If an adapter is added, we unconditionally inject the server islands route.
+ // Ideally we would only inject the server islands route if server islands are used in the project.
+ // Unfortunately, there is a "circular dependency": to know if server islands are used, we need to run
+ // the build but the build relies on the routes manifest.
+ if (dev || settings.config.adapter) {
+ injectServerIslandRoute(settings.config, { routes });
+ }
+ await runHookRoutesResolved({ routes, settings, logger });
+
+ return {
+ routes,
+ };
+}
+
+export function resolveInjectedRoute(entrypoint: string, root: URL, cwd?: string) {
+ let resolved;
+ try {
+ resolved = require.resolve(entrypoint, { paths: [cwd || fileURLToPath(root)] });
+ } catch {
+ resolved = fileURLToPath(new URL(entrypoint, root));
+ }
+
+ return {
+ resolved: resolved,
+ component: slash(path.relative(cwd || fileURLToPath(root), resolved)),
+ };
+}
+
+function joinSegments(segments: RoutePart[][]): string {
+ const arr = segments.map((segment) => {
+ return segment.map((rp) => (rp.dynamic ? `[${rp.content}]` : rp.content)).join('');
+ });
+
+ return `/${arr.join('/')}`.toLowerCase();
+}
diff --git a/packages/astro/src/core/routing/manifest/generator.ts b/packages/astro/src/core/routing/manifest/generator.ts
new file mode 100644
index 000000000..9674a862e
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/generator.ts
@@ -0,0 +1,65 @@
+import type { AstroConfig } from '../../../types/public/config.js';
+import type { RoutePart } from '../../../types/public/internal.js';
+
+/**
+ * Sanitizes the parameters object by normalizing string values and replacing certain characters with their URL-encoded equivalents.
+ * @param {Record<string, string | number>} params - The parameters object to be sanitized.
+ * @returns {Record<string, string | number>} The sanitized parameters object.
+ */
+function sanitizeParams(params: Record<string, string | number>): Record<string, string | number> {
+ return Object.fromEntries(
+ Object.entries(params).map(([key, value]) => {
+ if (typeof value === 'string') {
+ return [key, value.normalize().replace(/#/g, '%23').replace(/\?/g, '%3F')];
+ }
+ return [key, value];
+ }),
+ );
+}
+
+function getParameter(part: RoutePart, params: Record<string, string | number>): string | number {
+ if (part.spread) {
+ return params[part.content.slice(3)] || '';
+ }
+
+ if (part.dynamic) {
+ if (!params[part.content]) {
+ throw new TypeError(`Missing parameter: ${part.content}`);
+ }
+
+ return params[part.content];
+ }
+
+ return part.content
+ .normalize()
+ .replace(/\?/g, '%3F')
+ .replace(/#/g, '%23')
+ .replace(/%5B/g, '[')
+ .replace(/%5D/g, ']');
+}
+
+function getSegment(segment: RoutePart[], params: Record<string, string | number>): string {
+ const segmentPath = segment.map((part) => getParameter(part, params)).join('');
+
+ return segmentPath ? '/' + segmentPath : '';
+}
+
+export function getRouteGenerator(
+ segments: RoutePart[][],
+ addTrailingSlash: AstroConfig['trailingSlash'],
+) {
+ return (params: Record<string, string | number>): string => {
+ const sanitizedParams = sanitizeParams(params);
+
+ // Unless trailingSlash config is set to 'always', don't automatically append it.
+ let trailing: '/' | '' = '';
+ if (addTrailingSlash === 'always' && segments.length) {
+ trailing = '/';
+ }
+
+ const path =
+ segments.map((segment) => getSegment(segment, sanitizedParams)).join('') + trailing;
+
+ return path || '/';
+ };
+}
diff --git a/packages/astro/src/core/routing/manifest/parts.ts b/packages/astro/src/core/routing/manifest/parts.ts
new file mode 100644
index 000000000..77aa70b2b
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/parts.ts
@@ -0,0 +1,28 @@
+import type { RoutePart } from '../../../types/public/index.js';
+
+// Disable eslint as we're not sure how to improve this regex yet
+// eslint-disable-next-line regexp/no-super-linear-backtracking
+const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/;
+const ROUTE_SPREAD = /^\.{3}.+$/;
+
+export function getParts(part: string, file: string) {
+ const result: RoutePart[] = [];
+ part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => {
+ if (!str) return;
+ const dynamic = i % 2 === 1;
+
+ const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str];
+
+ if (!content || (dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content))) {
+ throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`);
+ }
+
+ result.push({
+ content,
+ dynamic,
+ spread: dynamic && ROUTE_SPREAD.test(content),
+ });
+ });
+
+ return result;
+}
diff --git a/packages/astro/src/core/routing/manifest/pattern.ts b/packages/astro/src/core/routing/manifest/pattern.ts
new file mode 100644
index 000000000..8a9a9d27f
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/pattern.ts
@@ -0,0 +1,55 @@
+import type { AstroConfig } from '../../../types/public/config.js';
+import type { RoutePart } from '../../../types/public/internal.js';
+
+export function getPattern(
+ segments: RoutePart[][],
+ base: AstroConfig['base'],
+ addTrailingSlash: AstroConfig['trailingSlash'],
+) {
+ const pathname = segments
+ .map((segment) => {
+ if (segment.length === 1 && segment[0].spread) {
+ return '(?:\\/(.*?))?';
+ } else {
+ return (
+ '\\/' +
+ segment
+ .map((part) => {
+ if (part.spread) {
+ return '(.*?)';
+ } else if (part.dynamic) {
+ return '([^/]+?)';
+ } else {
+ return part.content
+ .normalize()
+ .replace(/\?/g, '%3F')
+ .replace(/#/g, '%23')
+ .replace(/%5B/g, '[')
+ .replace(/%5D/g, ']')
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+ })
+ .join('')
+ );
+ }
+ })
+ .join('');
+
+ const trailing =
+ addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$';
+ let initial = '\\/';
+ if (addTrailingSlash === 'never' && base !== '/') {
+ initial = '';
+ }
+ return new RegExp(`^${pathname || initial}${trailing}`);
+}
+
+function getTrailingSlashPattern(addTrailingSlash: AstroConfig['trailingSlash']): string {
+ if (addTrailingSlash === 'always') {
+ return '\\/$';
+ }
+ if (addTrailingSlash === 'never') {
+ return '$';
+ }
+ return '\\/?$';
+}
diff --git a/packages/astro/src/core/routing/manifest/prerender.ts b/packages/astro/src/core/routing/manifest/prerender.ts
new file mode 100644
index 000000000..98321d18c
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/prerender.ts
@@ -0,0 +1,29 @@
+import { runHookRouteSetup } from '../../../integrations/hooks.js';
+import { getPrerenderDefault } from '../../../prerender/utils.js';
+import type { AstroSettings } from '../../../types/astro.js';
+import type { RouteData } from '../../../types/public/internal.js';
+import type { Logger } from '../../logger/core.js';
+
+const PRERENDER_REGEX = /^\s*export\s+const\s+prerender\s*=\s*(true|false);?/m;
+
+export async function getRoutePrerenderOption(
+ content: string,
+ route: RouteData,
+ settings: AstroSettings,
+ logger: Logger,
+) {
+ // Check if the route is pre-rendered or not
+ const match = PRERENDER_REGEX.exec(content);
+ if (match) {
+ route.prerender = match[1] === 'true';
+ }
+
+ await runHookRouteSetup({ route, settings, logger });
+
+ // If not explicitly set, default to the global setting
+ if (typeof route.prerender === undefined) {
+ route.prerender = getPrerenderDefault(settings.config);
+ }
+
+ if (!route.prerender) settings.buildOutput = 'server';
+}
diff --git a/packages/astro/src/core/routing/manifest/segment.ts b/packages/astro/src/core/routing/manifest/segment.ts
new file mode 100644
index 000000000..d09f4d565
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/segment.ts
@@ -0,0 +1,24 @@
+export function validateSegment(segment: string, file = '') {
+ if (!file) file = segment;
+
+ if (segment.includes('][')) {
+ throw new Error(`Invalid route ${file} \u2014 parameters must be separated`);
+ }
+ if (countOccurrences('[', segment) !== countOccurrences(']', segment)) {
+ throw new Error(`Invalid route ${file} \u2014 brackets are unbalanced`);
+ }
+ if (
+ (/.+\[\.\.\.[^\]]+\]/.test(segment) || /\[\.\.\.[^\]]+\].+/.test(segment)) &&
+ file.endsWith('.astro')
+ ) {
+ throw new Error(`Invalid route ${file} \u2014 rest parameter must be a standalone segment`);
+ }
+}
+
+function countOccurrences(needle: string, haystack: string) {
+ let count = 0;
+ for (const hay of haystack) {
+ if (hay === needle) count += 1;
+ }
+ return count;
+}
diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts
new file mode 100644
index 000000000..3d6214876
--- /dev/null
+++ b/packages/astro/src/core/routing/manifest/serialization.ts
@@ -0,0 +1,46 @@
+import type { SerializedRouteData } from '../../../types/astro.js';
+import type { AstroConfig } from '../../../types/public/config.js';
+import type { RouteData } from '../../../types/public/internal.js';
+
+import { getRouteGenerator } from './generator.js';
+
+export function serializeRouteData(
+ routeData: RouteData,
+ trailingSlash: AstroConfig['trailingSlash'],
+): SerializedRouteData {
+ return {
+ ...routeData,
+ generate: undefined,
+ pattern: routeData.pattern.source,
+ redirectRoute: routeData.redirectRoute
+ ? serializeRouteData(routeData.redirectRoute, trailingSlash)
+ : undefined,
+ fallbackRoutes: routeData.fallbackRoutes.map((fallbackRoute) => {
+ return serializeRouteData(fallbackRoute, trailingSlash);
+ }),
+ _meta: { trailingSlash },
+ };
+}
+
+export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteData {
+ return {
+ route: rawRouteData.route,
+ type: rawRouteData.type,
+ pattern: new RegExp(rawRouteData.pattern),
+ params: rawRouteData.params,
+ component: rawRouteData.component,
+ generate: getRouteGenerator(rawRouteData.segments, rawRouteData._meta.trailingSlash),
+ pathname: rawRouteData.pathname || undefined,
+ segments: rawRouteData.segments,
+ prerender: rawRouteData.prerender,
+ redirect: rawRouteData.redirect,
+ redirectRoute: rawRouteData.redirectRoute
+ ? deserializeRouteData(rawRouteData.redirectRoute)
+ : undefined,
+ fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => {
+ return deserializeRouteData(fallback);
+ }),
+ isIndex: rawRouteData.isIndex,
+ origin: rawRouteData.origin,
+ };
+}
diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts
new file mode 100644
index 000000000..0dc12bd52
--- /dev/null
+++ b/packages/astro/src/core/routing/match.ts
@@ -0,0 +1,89 @@
+import type { RoutesList } from '../../types/astro.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { redirectIsExternal } from '../redirects/render.js';
+import { SERVER_ISLAND_BASE_PREFIX, SERVER_ISLAND_COMPONENT } from '../server-islands/endpoint.js';
+
+/** Find matching route from pathname */
+export function matchRoute(pathname: string, manifest: RoutesList): RouteData | undefined {
+ const decodedPathname = decodeURI(pathname);
+ return manifest.routes.find((route) => {
+ return (
+ route.pattern.test(decodedPathname) ||
+ route.fallbackRoutes.some((fallbackRoute) => fallbackRoute.pattern.test(decodedPathname))
+ );
+ });
+}
+
+/** Finds all matching routes from pathname */
+export function matchAllRoutes(pathname: string, manifest: RoutesList): RouteData[] {
+ return manifest.routes.filter((route) => route.pattern.test(decodeURI(pathname)));
+}
+
+const ROUTE404_RE = /^\/404\/?$/;
+const ROUTE500_RE = /^\/500\/?$/;
+
+export function isRoute404(route: string) {
+ return ROUTE404_RE.test(route);
+}
+
+export function isRoute500(route: string) {
+ return ROUTE500_RE.test(route);
+}
+
+/**
+ * Determines if the given route matches a 404 or 500 error page.
+ *
+ * @param {RouteData} route - The route data to check.
+ * @returns {boolean} `true` if the route matches a 404 or 500 error page, otherwise `false`.
+ */
+export function isRoute404or500(route: RouteData): boolean {
+ return isRoute404(route.route) || isRoute500(route.route);
+}
+
+/**
+ * Determines if a given route is associated with the server island component.
+ *
+ * @param {RouteData} route - The route data object to evaluate.
+ * @return {boolean} Returns true if the route's component is the server island component, otherwise false.
+ */
+export function isRouteServerIsland(route: RouteData): boolean {
+ return route.component === SERVER_ISLAND_COMPONENT;
+}
+
+/**
+ * Determines whether the given `Request` is targeted to a "server island" based on its URL.
+ *
+ * @param {Request} request - The request object to be evaluated.
+ * @param {string} [base=''] - The base path provided via configuration.
+ * @return {boolean} - Returns `true` if the request is for a server island, otherwise `false`.
+ */
+export function isRequestServerIsland(request: Request, base = ''): boolean {
+ const url = new URL(request.url);
+ const pathname = url.pathname.slice(base.length);
+
+ return pathname.startsWith(SERVER_ISLAND_BASE_PREFIX);
+}
+
+/**
+ * Checks if the given request corresponds to a 404 or 500 route based on the specified base path.
+ *
+ * @param {Request} request - The HTTP request object to be checked.
+ * @param {string} [base=''] - The base path to trim from the request's URL before checking the route. Default is an empty string.
+ * @return {boolean} Returns true if the request matches a 404 or 500 route; otherwise, returns false.
+ */
+export function requestIs404Or500(request: Request, base = '') {
+ const url = new URL(request.url);
+ const pathname = url.pathname.slice(base.length);
+
+ return isRoute404(pathname) || isRoute500(pathname);
+}
+
+/**
+ * Determines whether a given route is an external redirect.
+ *
+ * @param {RouteData} route - The route object to check.
+ * @return {boolean} Returns true if the route is an external redirect, otherwise false.
+ */
+export function isRouteExternalRedirect(route: RouteData): boolean {
+ return !!(route.type === 'redirect' && route.redirect && redirectIsExternal(route.redirect));
+}
diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts
new file mode 100644
index 000000000..802c39cc5
--- /dev/null
+++ b/packages/astro/src/core/routing/params.ts
@@ -0,0 +1,23 @@
+import type { GetStaticPathsItem, Params } from '../../types/public/common.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { trimSlashes } from '../path.js';
+import { validateGetStaticPathsParameter } from './validation.js';
+
+/**
+ * given a route's Params object, validate parameter
+ * values and create a stringified key for the route
+ * that can be used to match request routes
+ */
+export function stringifyParams(params: GetStaticPathsItem['params'], route: RouteData) {
+ // validate parameter values then stringify each value
+ const validatedParams = Object.entries(params).reduce((acc, next) => {
+ validateGetStaticPathsParameter(next, route.component);
+ const [key, value] = next;
+ if (value !== undefined) {
+ acc[key] = typeof value === 'string' ? trimSlashes(value) : value.toString();
+ }
+ return acc;
+ }, {} as Params);
+
+ return route.generate(validatedParams);
+}
diff --git a/packages/astro/src/core/routing/priority.ts b/packages/astro/src/core/routing/priority.ts
new file mode 100644
index 000000000..dc1c665c6
--- /dev/null
+++ b/packages/astro/src/core/routing/priority.ts
@@ -0,0 +1,105 @@
+import type { RouteData } from '../../types/public/internal.js';
+
+/**
+ * Comparator for sorting routes in resolution order.
+ *
+ * The routes are sorted in by the following rules in order, following the first rule that
+ * applies:
+ * - More specific routes are sorted before less specific routes. Here, "specific" means
+ * the number of segments in the route, so a parent route is always sorted after its children.
+ * For example, `/foo/bar` is sorted before `/foo`.
+ * Index routes, originating from a file named `index.astro`, are considered to have one more
+ * segment than the URL they represent.
+ * - Static routes are sorted before dynamic routes.
+ * For example, `/foo/bar` is sorted before `/foo/[bar]`.
+ * - Dynamic routes with single parameters are sorted before dynamic routes with rest parameters.
+ * For example, `/foo/[bar]` is sorted before `/foo/[...bar]`.
+ * - Prerendered routes are sorted before non-prerendered routes.
+ * - Endpoints are sorted before pages.
+ * For example, a file `/foo.ts` is sorted before `/bar.astro`.
+ * - If both routes are equal regarding all previous conditions, they are sorted alphabetically.
+ * For example, `/bar` is sorted before `/foo`.
+ * The definition of "alphabetically" is dependent on the default locale of the running system.
+ */
+export function routeComparator(a: RouteData, b: RouteData) {
+ const commonLength = Math.min(a.segments.length, b.segments.length);
+
+ for (let index = 0; index < commonLength; index++) {
+ const aSegment = a.segments[index];
+ const bSegment = b.segments[index];
+
+ const aIsStatic = aSegment.every((part) => !part.dynamic && !part.spread);
+ const bIsStatic = bSegment.every((part) => !part.dynamic && !part.spread);
+
+ if (aIsStatic && bIsStatic) {
+ // Both segments are static, they are sorted alphabetically if they are different
+ const aContent = aSegment.map((part) => part.content).join('');
+ const bContent = bSegment.map((part) => part.content).join('');
+
+ if (aContent !== bContent) {
+ return aContent.localeCompare(bContent);
+ }
+ }
+
+ // Sort static routes before dynamic routes
+ if (aIsStatic !== bIsStatic) {
+ return aIsStatic ? -1 : 1;
+ }
+
+ const aAllDynamic = aSegment.every((part) => part.dynamic);
+ const bAllDynamic = bSegment.every((part) => part.dynamic);
+
+ // Some route might have partial dynamic segments, e.g. game-[title].astro
+ // These routes should have higher priority against route that have **only** dynamic segments, e.g. [title].astro
+ if (aAllDynamic !== bAllDynamic) {
+ return aAllDynamic ? 1 : -1;
+ }
+
+ const aHasSpread = aSegment.some((part) => part.spread);
+ const bHasSpread = bSegment.some((part) => part.spread);
+
+ // Sort dynamic routes with rest parameters after dynamic routes with single parameters
+ // (also after static, but that is already covered by the previous condition)
+ if (aHasSpread !== bHasSpread) {
+ return aHasSpread ? 1 : -1;
+ }
+ }
+
+ const aLength = a.segments.length;
+ const bLength = b.segments.length;
+
+ if (aLength !== bLength) {
+ const aEndsInRest = a.segments.at(-1)?.some((part) => part.spread);
+ const bEndsInRest = b.segments.at(-1)?.some((part) => part.spread);
+
+ if (aEndsInRest !== bEndsInRest && Math.abs(aLength - bLength) === 1) {
+ // If only one of the routes ends in a rest parameter
+ // and the difference in length is exactly 1
+ // and the shorter route is the one that ends in a rest parameter
+ // the shorter route is considered more specific.
+ // I.e. `/foo` is considered more specific than `/foo/[...bar]`
+ if (aLength > bLength && aEndsInRest) {
+ // b: /foo
+ // a: /foo/[...bar]
+ return 1;
+ }
+
+ if (bLength > aLength && bEndsInRest) {
+ // a: /foo
+ // b: /foo/[...bar]
+ return -1;
+ }
+ }
+
+ // Sort routes by length
+ return aLength > bLength ? -1 : 1;
+ }
+
+ // Sort endpoints before pages
+ if ((a.type === 'endpoint') !== (b.type === 'endpoint')) {
+ return a.type === 'endpoint' ? -1 : 1;
+ }
+
+ // Both routes have segments with the same properties
+ return a.route.localeCompare(b.route);
+}
diff --git a/packages/astro/src/core/routing/request.ts b/packages/astro/src/core/routing/request.ts
new file mode 100644
index 000000000..f7e917a53
--- /dev/null
+++ b/packages/astro/src/core/routing/request.ts
@@ -0,0 +1,20 @@
+/**
+ * Utilities for extracting information from `Request`
+ */
+
+// Parses multiple header and returns first value if available.
+export function getFirstForwardedValue(multiValueHeader?: string | string[] | null) {
+ return multiValueHeader
+ ?.toString()
+ ?.split(',')
+ .map((e) => e.trim())?.[0];
+}
+
+/**
+ * Returns the first value associated to the `x-forwarded-for` header.
+ *
+ * @param {Request} request
+ */
+export function getClientIpAddress(request: Request): string | undefined {
+ return getFirstForwardedValue(request.headers.get('x-forwarded-for'));
+}
diff --git a/packages/astro/src/core/routing/rewrite.ts b/packages/astro/src/core/routing/rewrite.ts
new file mode 100644
index 000000000..78f70e847
--- /dev/null
+++ b/packages/astro/src/core/routing/rewrite.ts
@@ -0,0 +1,135 @@
+import type { RewritePayload } from '../../types/public/common.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { shouldAppendForwardSlash } from '../build/util.js';
+import { originPathnameSymbol } from '../constants.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+import { appendForwardSlash, removeTrailingForwardSlash } from '../path.js';
+import { createRequest } from '../request.js';
+import { DEFAULT_404_ROUTE } from './astro-designed-error-pages.js';
+
+export type FindRouteToRewrite = {
+ payload: RewritePayload;
+ routes: RouteData[];
+ request: Request;
+ trailingSlash: AstroConfig['trailingSlash'];
+ buildFormat: AstroConfig['build']['format'];
+ base: AstroConfig['base'];
+};
+
+export interface FindRouteToRewriteResult {
+ routeData: RouteData;
+ newUrl: URL;
+ pathname: string;
+}
+
+/**
+ * Shared logic to retrieve the rewritten route. It returns a tuple that represents:
+ * 1. The new `Request` object. It contains `base`
+ * 2.
+ */
+export function findRouteToRewrite({
+ payload,
+ routes,
+ request,
+ trailingSlash,
+ buildFormat,
+ base,
+}: FindRouteToRewrite): FindRouteToRewriteResult {
+ let newUrl: URL | undefined = undefined;
+ if (payload instanceof URL) {
+ newUrl = payload;
+ } else if (payload instanceof Request) {
+ newUrl = new URL(payload.url);
+ } else {
+ newUrl = new URL(payload, new URL(request.url).origin);
+ }
+ let pathname = newUrl.pathname;
+ if (base !== '/' && newUrl.pathname.startsWith(base)) {
+ pathname = shouldAppendForwardSlash(trailingSlash, buildFormat)
+ ? appendForwardSlash(newUrl.pathname)
+ : removeTrailingForwardSlash(newUrl.pathname);
+ pathname = pathname.slice(base.length);
+ }
+
+ const decodedPathname = decodeURI(pathname);
+ let foundRoute;
+ for (const route of routes) {
+ if (route.pattern.test(decodedPathname)) {
+ foundRoute = route;
+ break;
+ }
+ }
+
+ if (foundRoute) {
+ return {
+ routeData: foundRoute,
+ newUrl,
+ pathname: decodedPathname,
+ };
+ } else {
+ const custom404 = routes.find((route) => route.route === '/404');
+ if (custom404) {
+ return { routeData: custom404, newUrl, pathname };
+ } else {
+ return { routeData: DEFAULT_404_ROUTE, newUrl, pathname };
+ }
+ }
+}
+
+/**
+ * Utility function that creates a new `Request` with a new URL from an old `Request`.
+ *
+ * @param newUrl The new `URL`
+ * @param oldRequest The old `Request`
+ * @param isPrerendered It needs to be the flag of the previous routeData, before the rewrite
+ * @param logger
+ * @param routePattern
+ */
+export function copyRequest(
+ newUrl: URL,
+ oldRequest: Request,
+ isPrerendered: boolean,
+ logger: Logger,
+ routePattern: string,
+): Request {
+ if (oldRequest.bodyUsed) {
+ throw new AstroError(AstroErrorData.RewriteWithBodyUsed);
+ }
+ return createRequest({
+ url: newUrl,
+ method: oldRequest.method,
+ body: oldRequest.body,
+ isPrerendered,
+ logger,
+ headers: isPrerendered ? {} : oldRequest.headers,
+ routePattern,
+ init: {
+ referrer: oldRequest.referrer,
+ referrerPolicy: oldRequest.referrerPolicy,
+ mode: oldRequest.mode,
+ credentials: oldRequest.credentials,
+ cache: oldRequest.cache,
+ redirect: oldRequest.redirect,
+ integrity: oldRequest.integrity,
+ signal: oldRequest.signal,
+ keepalive: oldRequest.keepalive,
+ // https://fetch.spec.whatwg.org/#dom-request-duplex
+ // @ts-expect-error It isn't part of the types, but undici accepts it and it allows to carry over the body to a new request
+ duplex: 'half',
+ },
+ });
+}
+
+export function setOriginPathname(request: Request, pathname: string): void {
+ Reflect.set(request, originPathnameSymbol, encodeURIComponent(pathname));
+}
+
+export function getOriginPathname(request: Request): string {
+ const origin = Reflect.get(request, originPathnameSymbol);
+ if (origin) {
+ return decodeURIComponent(origin);
+ }
+ return new URL(request.url).pathname;
+}
diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts
new file mode 100644
index 000000000..a2f9a25ba
--- /dev/null
+++ b/packages/astro/src/core/routing/validation.ts
@@ -0,0 +1,98 @@
+import type { ComponentInstance } from '../../types/astro.js';
+import type { GetStaticPathsResult } from '../../types/public/common.js';
+import type { RouteData } from '../../types/public/internal.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+
+const VALID_PARAM_TYPES = ['string', 'number', 'undefined'];
+
+/** Throws error for invalid parameter in getStaticPaths() response */
+export function validateGetStaticPathsParameter([key, value]: [string, any], route: string) {
+ if (!VALID_PARAM_TYPES.includes(typeof value)) {
+ throw new AstroError({
+ ...AstroErrorData.GetStaticPathsInvalidRouteParam,
+ message: AstroErrorData.GetStaticPathsInvalidRouteParam.message(key, value, typeof value),
+ location: {
+ file: route,
+ },
+ });
+ }
+}
+
+/** Error for deprecated or malformed route components */
+export function validateDynamicRouteModule(
+ mod: ComponentInstance,
+ {
+ ssr,
+ route,
+ }: {
+ ssr: boolean;
+ route: RouteData;
+ },
+) {
+ if ((!ssr || route.prerender) && !mod.getStaticPaths) {
+ throw new AstroError({
+ ...AstroErrorData.GetStaticPathsRequired,
+ location: { file: route.component },
+ });
+ }
+}
+
+/** Throw error and log warnings for malformed getStaticPaths() response */
+export function validateGetStaticPathsResult(
+ result: GetStaticPathsResult,
+ logger: Logger,
+ route: RouteData,
+) {
+ if (!Array.isArray(result)) {
+ throw new AstroError({
+ ...AstroErrorData.InvalidGetStaticPathsReturn,
+ message: AstroErrorData.InvalidGetStaticPathsReturn.message(typeof result),
+ location: {
+ file: route.component,
+ },
+ });
+ }
+
+ result.forEach((pathObject) => {
+ if ((typeof pathObject === 'object' && Array.isArray(pathObject)) || pathObject === null) {
+ throw new AstroError({
+ ...AstroErrorData.InvalidGetStaticPathsEntry,
+ message: AstroErrorData.InvalidGetStaticPathsEntry.message(
+ Array.isArray(pathObject) ? 'array' : typeof pathObject,
+ ),
+ });
+ }
+
+ if (
+ pathObject.params === undefined ||
+ pathObject.params === null ||
+ (pathObject.params && Object.keys(pathObject.params).length === 0)
+ ) {
+ throw new AstroError({
+ ...AstroErrorData.GetStaticPathsExpectedParams,
+ location: {
+ file: route.component,
+ },
+ });
+ }
+
+ // TODO: Replace those with errors? They technically don't crash the build, but users might miss the warning. - erika, 2022-11-07
+ for (const [key, val] of Object.entries(pathObject.params)) {
+ if (!(typeof val === 'undefined' || typeof val === 'string' || typeof val === 'number')) {
+ logger.warn(
+ 'router',
+ `getStaticPaths() returned an invalid path param: "${key}". A string, number or undefined value was expected, but got \`${JSON.stringify(
+ val,
+ )}\`.`,
+ );
+ }
+ if (typeof val === 'string' && val === '') {
+ logger.warn(
+ 'router',
+ `getStaticPaths() returned an invalid path param: "${key}". \`undefined\` expected for an optional param, but got empty string.`,
+ );
+ }
+ }
+ });
+}
diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts
new file mode 100644
index 000000000..6a640ee41
--- /dev/null
+++ b/packages/astro/src/core/server-islands/endpoint.ts
@@ -0,0 +1,162 @@
+import {
+ type AstroComponentFactory,
+ type ComponentSlots,
+ renderComponent,
+ renderTemplate,
+} from '../../runtime/server/index.js';
+import { isAstroComponentFactory } from '../../runtime/server/render/astro/factory.js';
+import { createSlotValueFromString } from '../../runtime/server/render/slot.js';
+import type { ComponentInstance, RoutesList } from '../../types/astro.js';
+import type { RouteData, SSRManifest } from '../../types/public/internal.js';
+import { decryptString } from '../encryption.js';
+import { getPattern } from '../routing/manifest/pattern.js';
+
+export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]';
+export const SERVER_ISLAND_COMPONENT = '_server-islands.astro';
+export const SERVER_ISLAND_BASE_PREFIX = '_server-islands';
+
+type ConfigFields = Pick<SSRManifest, 'base' | 'trailingSlash'>;
+
+export function getServerIslandRouteData(config: ConfigFields) {
+ const segments = [
+ [{ content: '_server-islands', dynamic: false, spread: false }],
+ [{ content: 'name', dynamic: true, spread: false }],
+ ];
+ const route: RouteData = {
+ type: 'page',
+ component: SERVER_ISLAND_COMPONENT,
+ generate: () => '',
+ params: ['name'],
+ segments,
+ pattern: getPattern(segments, config.base, config.trailingSlash),
+ prerender: false,
+ isIndex: false,
+ fallbackRoutes: [],
+ route: SERVER_ISLAND_ROUTE,
+ origin: 'internal',
+ };
+ return route;
+}
+
+export function injectServerIslandRoute(config: ConfigFields, routeManifest: RoutesList) {
+ routeManifest.routes.unshift(getServerIslandRouteData(config));
+}
+
+type RenderOptions = {
+ componentExport: string;
+ encryptedProps: string;
+ slots: Record<string, string>;
+};
+
+function badRequest(reason: string) {
+ return new Response(null, {
+ status: 400,
+ statusText: 'Bad request: ' + reason,
+ });
+}
+
+async function getRequestData(request: Request): Promise<Response | RenderOptions> {
+ switch (request.method) {
+ case 'GET': {
+ const url = new URL(request.url);
+ const params = url.searchParams;
+
+ if (!params.has('s') || !params.has('e') || !params.has('p')) {
+ return badRequest('Missing required query parameters.');
+ }
+
+ const rawSlots = params.get('s')!;
+ try {
+ return {
+ componentExport: params.get('e')!,
+ encryptedProps: params.get('p')!,
+ slots: JSON.parse(rawSlots),
+ };
+ } catch {
+ return badRequest('Invalid slots format.');
+ }
+ }
+ case 'POST': {
+ try {
+ const raw = await request.text();
+ const data = JSON.parse(raw) as RenderOptions;
+ return data;
+ } catch {
+ return badRequest('Request format is invalid.');
+ }
+ }
+ default: {
+ // Method not allowed: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
+ return new Response(null, { status: 405 });
+ }
+ }
+}
+
+export function createEndpoint(manifest: SSRManifest) {
+ const page: AstroComponentFactory = async (result) => {
+ const params = result.params;
+ if (!params.name) {
+ return new Response(null, {
+ status: 400,
+ statusText: 'Bad request',
+ });
+ }
+ const componentId = params.name;
+
+ // Get the request data from the body or search params
+ const data = await getRequestData(result.request);
+ // probably error
+ if (data instanceof Response) {
+ return data;
+ }
+
+ const imp = manifest.serverIslandMap?.get(componentId);
+ if (!imp) {
+ return new Response(null, {
+ status: 404,
+ statusText: 'Not found',
+ });
+ }
+
+ const key = await manifest.key;
+ const encryptedProps = data.encryptedProps;
+
+ const propString = encryptedProps === '' ? '{}' : await decryptString(key, encryptedProps);
+ const props = JSON.parse(propString);
+
+ const componentModule = await imp();
+ let Component = (componentModule as any)[data.componentExport];
+
+ const slots: ComponentSlots = {};
+ for (const prop in data.slots) {
+ slots[prop] = createSlotValueFromString(data.slots[prop]);
+ }
+
+ // Prevent server islands from being indexed
+ result.response.headers.set('X-Robots-Tag', 'noindex');
+
+ // Wrap Astro components so we can set propagation to
+ // `self` which is needed to force the runtime to wait
+ // on the component before sending out the response headers.
+ // This allows the island to set headers (cookies).
+ if (isAstroComponentFactory(Component)) {
+ const ServerIsland = Component;
+ Component = function (this: typeof ServerIsland, ...args: Parameters<typeof ServerIsland>) {
+ return ServerIsland.apply(this, args);
+ };
+ Object.assign(Component, ServerIsland);
+ Component.propagation = 'self';
+ }
+
+ return renderTemplate`${renderComponent(result, 'Component', Component, props, slots)}`;
+ };
+
+ page.isAstroComponentFactory = true;
+
+ const instance: ComponentInstance = {
+ default: page,
+ partial: true,
+ };
+
+ return instance;
+}
diff --git a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts
new file mode 100644
index 000000000..3673df534
--- /dev/null
+++ b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts
@@ -0,0 +1,113 @@
+import MagicString from 'magic-string';
+import type { ConfigEnv, ViteDevServer, Plugin as VitePlugin } from 'vite';
+import type { AstroPluginOptions } from '../../types/astro.js';
+import type { AstroPluginMetadata } from '../../vite-plugin-astro/index.js';
+
+export const VIRTUAL_ISLAND_MAP_ID = '@astro-server-islands';
+export const RESOLVED_VIRTUAL_ISLAND_MAP_ID = '\0' + VIRTUAL_ISLAND_MAP_ID;
+const serverIslandPlaceholder = "'$$server-islands$$'";
+
+export function vitePluginServerIslands({ settings, logger }: AstroPluginOptions): VitePlugin {
+ let command: ConfigEnv['command'] = 'serve';
+ let viteServer: ViteDevServer | null = null;
+ const referenceIdMap = new Map<string, string>();
+ return {
+ name: 'astro:server-islands',
+ enforce: 'post',
+ config(_config, { command: _command }) {
+ command = _command;
+ },
+ configureServer(_server) {
+ viteServer = _server;
+ },
+ resolveId(name) {
+ if (name === VIRTUAL_ISLAND_MAP_ID) {
+ return RESOLVED_VIRTUAL_ISLAND_MAP_ID;
+ }
+ },
+ load(id) {
+ if (id === RESOLVED_VIRTUAL_ISLAND_MAP_ID) {
+ return `export const serverIslandMap = ${serverIslandPlaceholder};`;
+ }
+ },
+ transform(_code, id) {
+ if (id.endsWith('.astro')) {
+ const info = this.getModuleInfo(id);
+ if (info?.meta) {
+ const astro = info.meta.astro as AstroPluginMetadata['astro'] | undefined;
+ if (astro?.serverComponents.length) {
+ for (const comp of astro.serverComponents) {
+ if (!settings.serverIslandNameMap.has(comp.resolvedPath)) {
+ if (!settings.adapter) {
+ logger.error(
+ 'islands',
+ 'You tried to render a server island without an adapter added to your project. An adapter is required to use the `server:defer` attribute on any component. Your project will fail to build unless you add an adapter or remove the attribute.',
+ );
+ }
+
+ let name = comp.localName;
+ let idx = 1;
+
+ while (true) {
+ // Name not taken, let's use it.
+ if (!settings.serverIslandMap.has(name)) {
+ break;
+ }
+ // Increment a number onto the name: Avatar -> Avatar1
+ name += idx++;
+ }
+
+ // Append the name map, for prod
+ settings.serverIslandNameMap.set(comp.resolvedPath, name);
+
+ settings.serverIslandMap.set(name, () => {
+ return viteServer?.ssrLoadModule(comp.resolvedPath) as any;
+ });
+
+ // Build mode
+ if (command === 'build') {
+ let referenceId = this.emitFile({
+ type: 'chunk',
+ id: comp.specifier,
+ importer: id,
+ name: comp.localName,
+ });
+
+ referenceIdMap.set(comp.resolvedPath, referenceId);
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ renderChunk(code) {
+ if (code.includes(serverIslandPlaceholder)) {
+ // If there's no reference, we can fast-path to an empty map replacement
+ // without sourcemaps as it doesn't shift rows
+ if (referenceIdMap.size === 0) {
+ return {
+ code: code.replace(serverIslandPlaceholder, 'new Map();'),
+ map: null,
+ };
+ }
+
+ let mapSource = 'new Map([';
+ for (let [resolvedPath, referenceId] of referenceIdMap) {
+ const fileName = this.getFileName(referenceId);
+ const islandName = settings.serverIslandNameMap.get(resolvedPath)!;
+ mapSource += `\n\t['${islandName}', () => import('./${fileName}')],`;
+ }
+ mapSource += '\n]);';
+ referenceIdMap.clear();
+
+ const ms = new MagicString(code);
+ ms.replace(serverIslandPlaceholder, mapSource);
+ return {
+ code: ms.toString(),
+ map: ms.generateMap({ hires: 'boundary' }),
+ };
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/core/session.ts b/packages/astro/src/core/session.ts
new file mode 100644
index 000000000..ccb01ef61
--- /dev/null
+++ b/packages/astro/src/core/session.ts
@@ -0,0 +1,476 @@
+import { stringify as rawStringify, unflatten as rawUnflatten } from 'devalue';
+import {
+ type BuiltinDriverOptions,
+ type Driver,
+ type Storage,
+ builtinDrivers,
+ createStorage,
+} from 'unstorage';
+import type {
+ ResolvedSessionConfig,
+ SessionConfig,
+ SessionDriverName,
+} from '../types/public/config.js';
+import type { AstroCookies } from './cookies/cookies.js';
+import type { AstroCookieSetOptions } from './cookies/cookies.js';
+import { SessionStorageInitError, SessionStorageSaveError } from './errors/errors-data.js';
+import { AstroError } from './errors/index.js';
+
+export const PERSIST_SYMBOL = Symbol();
+
+const DEFAULT_COOKIE_NAME = 'astro-session';
+const VALID_COOKIE_REGEX = /^[\w-]+$/;
+
+interface SessionEntry {
+ data: any;
+ expires?: number;
+}
+
+const unflatten: typeof rawUnflatten = (parsed, _) => {
+ // Revive URL objects
+ return rawUnflatten(parsed, {
+ URL: (href) => new URL(href),
+ });
+};
+
+const stringify: typeof rawStringify = (data, _) => {
+ return rawStringify(data, {
+ // Support URL objects
+ URL: (val) => val instanceof URL && val.href,
+ });
+};
+
+export class AstroSession<TDriver extends SessionDriverName = any> {
+ // The cookies object.
+ #cookies: AstroCookies;
+ // The session configuration.
+ #config: Omit<ResolvedSessionConfig<TDriver>, 'cookie'>;
+ // The cookie config
+ #cookieConfig?: AstroCookieSetOptions;
+ // The cookie name
+ #cookieName: string;
+ // The unstorage object for the session driver.
+ #storage: Storage | undefined;
+ #data: Map<string, SessionEntry> | undefined;
+ // The session ID. A v4 UUID.
+ #sessionID: string | undefined;
+ // Sessions to destroy. Needed because we won't have the old session ID after it's destroyed locally.
+ #toDestroy = new Set<string>();
+ // Session keys to delete. Used for partial data sets to avoid overwriting the deleted value.
+ #toDelete = new Set<string>();
+ // Whether the session is dirty and needs to be saved.
+ #dirty = false;
+ // Whether the session cookie has been set.
+ #cookieSet = false;
+ // The local data is "partial" if it has not been loaded from storage yet and only
+ // contains values that have been set or deleted in-memory locally.
+ // We do this to avoid the need to block on loading data when it is only being set.
+ // When we load the data from storage, we need to merge it with the local partial data,
+ // preserving in-memory changes and deletions.
+ #partial = true;
+
+ constructor(
+ cookies: AstroCookies,
+ {
+ cookie: cookieConfig = DEFAULT_COOKIE_NAME,
+ ...config
+ }: Exclude<ResolvedSessionConfig<TDriver>, undefined>,
+ ) {
+ this.#cookies = cookies;
+ let cookieConfigObject: AstroCookieSetOptions | undefined;
+ if (typeof cookieConfig === 'object') {
+ const { name = DEFAULT_COOKIE_NAME, ...rest } = cookieConfig;
+ this.#cookieName = name;
+ cookieConfigObject = rest;
+ } else {
+ this.#cookieName = cookieConfig || DEFAULT_COOKIE_NAME;
+ }
+ this.#cookieConfig = {
+ sameSite: 'lax',
+ secure: true,
+ path: '/',
+ ...cookieConfigObject,
+ httpOnly: true,
+ };
+ this.#config = config;
+ }
+
+ /**
+ * Gets a session value. Returns `undefined` if the session or value does not exist.
+ */
+ async get<T = any>(key: string): Promise<T | undefined> {
+ return (await this.#ensureData()).get(key)?.data;
+ }
+
+ /**
+ * Checks if a session value exists.
+ */
+ async has(key: string): Promise<boolean> {
+ return (await this.#ensureData()).has(key);
+ }
+
+ /**
+ * Gets all session values.
+ */
+ async keys() {
+ return (await this.#ensureData()).keys();
+ }
+
+ /**
+ * Gets all session values.
+ */
+ async values() {
+ return [...(await this.#ensureData()).values()].map((entry) => entry.data);
+ }
+
+ /**
+ * Gets all session entries.
+ */
+ async entries() {
+ return [...(await this.#ensureData()).entries()].map(([key, entry]) => [key, entry.data]);
+ }
+
+ /**
+ * Deletes a session value.
+ */
+ delete(key: string) {
+ this.#data?.delete(key);
+ if (this.#partial) {
+ this.#toDelete.add(key);
+ }
+ this.#dirty = true;
+ }
+
+ /**
+ * Sets a session value. The session is created if it does not exist.
+ */
+
+ set<T = any>(key: string, value: T, { ttl }: { ttl?: number } = {}) {
+ if (!key) {
+ throw new AstroError({
+ ...SessionStorageSaveError,
+ message: 'The session key was not provided.',
+ });
+ }
+ // save a clone of the passed in object so later updates are not
+ // persisted into the store. Attempting to serialize also allows
+ // us to throw an error early if needed.
+ let cloned: T;
+ try {
+ cloned = unflatten(JSON.parse(stringify(value)));
+ } catch (err) {
+ throw new AstroError(
+ {
+ ...SessionStorageSaveError,
+ message: `The session data for ${key} could not be serialized.`,
+ hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue',
+ },
+ { cause: err },
+ );
+ }
+ if (!this.#cookieSet) {
+ this.#setCookie();
+ this.#cookieSet = true;
+ }
+ this.#data ??= new Map();
+ const lifetime = ttl ?? this.#config.ttl;
+ // If ttl is numeric, it is the number of seconds until expiry. To get an expiry timestamp, we convert to milliseconds and add to the current time.
+ const expires = typeof lifetime === 'number' ? Date.now() + lifetime * 1000 : lifetime;
+ this.#data.set(key, {
+ data: cloned,
+ expires,
+ });
+ this.#dirty = true;
+ }
+
+ /**
+ * Destroys the session, clearing the cookie and storage if it exists.
+ */
+
+ destroy() {
+ this.#destroySafe();
+ }
+
+ /**
+ * Regenerates the session, creating a new session ID. The existing session data is preserved.
+ */
+
+ async regenerate() {
+ let data = new Map();
+ try {
+ data = await this.#ensureData();
+ } catch (err) {
+ // Log the error but continue with empty data
+ console.error('Failed to load session data during regeneration:', err);
+ }
+
+ // Store the old session ID for cleanup
+ const oldSessionId = this.#sessionID;
+
+ // Create new session
+ this.#sessionID = crypto.randomUUID();
+ this.#data = data;
+ await this.#setCookie();
+
+ // Clean up old session asynchronously
+ if (oldSessionId && this.#storage) {
+ this.#storage.removeItem(oldSessionId).catch((err) => {
+ console.error('Failed to remove old session data:', err);
+ });
+ }
+ }
+
+ // Persists the session data to storage.
+ // This is called automatically at the end of the request.
+ // Uses a symbol to prevent users from calling it directly.
+ async [PERSIST_SYMBOL]() {
+ // Handle session data persistence
+
+ if (!this.#dirty && !this.#toDestroy.size) {
+ return;
+ }
+
+ const storage = await this.#ensureStorage();
+
+ if (this.#dirty && this.#data) {
+ const data = await this.#ensureData();
+ this.#toDelete.forEach((key) => data.delete(key));
+ const key = this.#ensureSessionID();
+ let serialized;
+ try {
+ serialized = stringify(data);
+ } catch (err) {
+ throw new AstroError(
+ {
+ ...SessionStorageSaveError,
+ message: SessionStorageSaveError.message(
+ 'The session data could not be serialized.',
+ this.#config.driver,
+ ),
+ },
+ { cause: err },
+ );
+ }
+ await storage.setItem(key, serialized);
+ this.#dirty = false;
+ }
+
+ // Handle destroyed session cleanup
+ if (this.#toDestroy.size > 0) {
+ const cleanupPromises = [...this.#toDestroy].map((sessionId) =>
+ storage.removeItem(sessionId).catch((err) => {
+ console.error(`Failed to clean up session ${sessionId}:`, err);
+ }),
+ );
+ await Promise.all(cleanupPromises);
+ this.#toDestroy.clear();
+ }
+ }
+
+ get sessionID() {
+ return this.#sessionID;
+ }
+
+ /**
+ * Sets the session cookie.
+ */
+ async #setCookie() {
+ if (!VALID_COOKIE_REGEX.test(this.#cookieName)) {
+ throw new AstroError({
+ ...SessionStorageSaveError,
+ message: 'Invalid cookie name. Cookie names can only contain letters, numbers, and dashes.',
+ });
+ }
+
+ const value = this.#ensureSessionID();
+ this.#cookies.set(this.#cookieName, value, this.#cookieConfig);
+ }
+
+ /**
+ * Attempts to load the session data from storage, or creates a new data object if none exists.
+ * If there is existing partial data, it will be merged into the new data object.
+ */
+
+ async #ensureData() {
+ const storage = await this.#ensureStorage();
+ if (this.#data && !this.#partial) {
+ return this.#data;
+ }
+ this.#data ??= new Map();
+
+ // We stored this as a devalue string, but unstorage will have parsed it as JSON
+ const raw = await storage.get<any[]>(this.#ensureSessionID());
+ if (!raw) {
+ // If there is no existing data in storage we don't need to merge anything
+ // and can just return the existing local data.
+ return this.#data;
+ }
+
+ try {
+ const storedMap = unflatten(raw);
+ if (!(storedMap instanceof Map)) {
+ await this.#destroySafe();
+ throw new AstroError({
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message(
+ 'The session data was an invalid type.',
+ this.#config.driver,
+ ),
+ });
+ }
+
+ const now = Date.now();
+
+ // Only copy values from storage that:
+ // 1. Don't exist in memory (preserving in-memory changes)
+ // 2. Haven't been marked for deletion
+ // 3. Haven't expired
+ for (const [key, value] of storedMap) {
+ const expired = typeof value.expires === 'number' && value.expires < now;
+ if (!this.#data.has(key) && !this.#toDelete.has(key) && !expired) {
+ this.#data.set(key, value);
+ }
+ }
+
+ this.#partial = false;
+ return this.#data;
+ } catch (err) {
+ await this.#destroySafe();
+ if (err instanceof AstroError) {
+ throw err;
+ }
+ throw new AstroError(
+ {
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message(
+ 'The session data could not be parsed.',
+ this.#config.driver,
+ ),
+ },
+ { cause: err },
+ );
+ }
+ }
+ /**
+ * Safely destroys the session, clearing the cookie and storage if it exists.
+ */
+ #destroySafe() {
+ if (this.#sessionID) {
+ this.#toDestroy.add(this.#sessionID);
+ }
+ if (this.#cookieName) {
+ this.#cookies.delete(this.#cookieName, this.#cookieConfig);
+ }
+ this.#sessionID = undefined;
+ this.#data = undefined;
+ this.#dirty = true;
+ }
+
+ /**
+ * Returns the session ID, generating a new one if it does not exist.
+ */
+ #ensureSessionID() {
+ this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID();
+ return this.#sessionID;
+ }
+
+ /**
+ * Ensures the storage is initialized.
+ * This is called automatically when a storage operation is needed.
+ */
+ async #ensureStorage(): Promise<Storage> {
+ if (this.#storage) {
+ return this.#storage;
+ }
+
+ if (this.#config.driver === 'test') {
+ this.#storage = (this.#config as SessionConfig<'test'>).options.mockStorage;
+ return this.#storage!;
+ }
+ // Use fs-lite rather than fs, because fs can't be bundled. Add a default base path if not provided.
+ if (
+ this.#config.driver === 'fs' ||
+ this.#config.driver === 'fsLite' ||
+ this.#config.driver === 'fs-lite'
+ ) {
+ this.#config.options ??= {};
+ this.#config.driver = 'fs-lite';
+ (this.#config.options as BuiltinDriverOptions['fs-lite']).base ??= '.astro/session';
+ }
+
+ if (!this.#config?.driver) {
+ throw new AstroError({
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message(
+ 'No driver was defined in the session configuration and the adapter did not provide a default driver.',
+ ),
+ });
+ }
+
+ let driver: ((config: SessionConfig<TDriver>['options']) => Driver) | null = null;
+
+ const driverPackage = await resolveSessionDriver(this.#config.driver);
+ try {
+ if (this.#config.driverModule) {
+ driver = (await this.#config.driverModule()).default;
+ } else if (driverPackage) {
+ driver = (await import(driverPackage)).default;
+ }
+ } catch (err: any) {
+ // If the driver failed to load, throw an error.
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
+ throw new AstroError(
+ {
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message(
+ err.message.includes(`Cannot find package '${driverPackage}'`)
+ ? 'The driver module could not be found.'
+ : err.message,
+ this.#config.driver,
+ ),
+ },
+ { cause: err },
+ );
+ }
+ throw err;
+ }
+
+ if (!driver) {
+ throw new AstroError({
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message(
+ 'The module did not export a driver.',
+ this.#config.driver,
+ ),
+ });
+ }
+
+ try {
+ this.#storage = createStorage({
+ driver: driver(this.#config.options),
+ });
+ return this.#storage;
+ } catch (err) {
+ throw new AstroError(
+ {
+ ...SessionStorageInitError,
+ message: SessionStorageInitError.message('Unknown error', this.#config.driver),
+ },
+ { cause: err },
+ );
+ }
+ }
+}
+// TODO: make this sync when we drop support for Node < 18.19.0
+export function resolveSessionDriver(driver: string | undefined): Promise<string> | string | null {
+ if (!driver) {
+ return null;
+ }
+ if (driver === 'fs') {
+ return import.meta.resolve(builtinDrivers.fsLite);
+ }
+ if (driver in builtinDrivers) {
+ return import.meta.resolve(builtinDrivers[driver as keyof typeof builtinDrivers]);
+ }
+ return driver;
+}
diff --git a/packages/astro/src/core/shiki.ts b/packages/astro/src/core/shiki.ts
new file mode 100644
index 000000000..e1b643fa5
--- /dev/null
+++ b/packages/astro/src/core/shiki.ts
@@ -0,0 +1,23 @@
+import {
+ type ShikiConfig,
+ type ShikiHighlighter,
+ createShikiHighlighter,
+} from '@astrojs/markdown-remark';
+
+// Caches Promise<ShikiHighlighter> for reuse when the same theme and langs are provided
+const cachedHighlighters = new Map();
+
+export function getCachedHighlighter(opts: ShikiConfig): Promise<ShikiHighlighter> {
+ // Always sort keys before stringifying to make sure objects match regardless of parameter ordering
+ const key = JSON.stringify(opts, Object.keys(opts).sort());
+
+ // Highlighter has already been requested, reuse the same instance
+ if (cachedHighlighters.has(key)) {
+ return cachedHighlighters.get(key);
+ }
+
+ const highlighter = createShikiHighlighter(opts);
+ cachedHighlighters.set(key, highlighter);
+
+ return highlighter;
+}
diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts
new file mode 100644
index 000000000..9c3ee053f
--- /dev/null
+++ b/packages/astro/src/core/sync/index.ts
@@ -0,0 +1,320 @@
+import fsMod from 'node:fs';
+import { dirname, relative } from 'node:path';
+import { performance } from 'node:perf_hooks';
+import { fileURLToPath } from 'node:url';
+import { dim } from 'kleur/colors';
+import { type FSWatcher, type HMRPayload, createServer } from 'vite';
+import { CONTENT_TYPES_FILE } from '../../content/consts.js';
+import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js';
+import { createContentTypesGenerator } from '../../content/index.js';
+import { MutableDataStore } from '../../content/mutable-data-store.js';
+import { getContentPaths, globalContentConfigObserver } from '../../content/utils.js';
+import { syncAstroEnv } from '../../env/sync.js';
+import { telemetry } from '../../events/index.js';
+import { eventCliSession } from '../../events/session.js';
+import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
+import type { AstroSettings, RoutesList } from '../../types/astro.js';
+import type { AstroInlineConfig } from '../../types/public/config.js';
+import { createDevelopmentManifest } from '../../vite-plugin-astro-server/plugin.js';
+import type { SSRManifest } from '../app/types.js';
+import { getTimeStat } from '../build/util.js';
+import { resolveConfig } from '../config/config.js';
+import { createNodeLogger } from '../config/logging.js';
+import { createSettings } from '../config/settings.js';
+import { createVite } from '../create-vite.js';
+import {
+ AstroError,
+ AstroErrorData,
+ AstroUserError,
+ type ErrorWithMetadata,
+ createSafeError,
+ isAstroError,
+} from '../errors/index.js';
+import type { Logger } from '../logger/core.js';
+import { createRoutesList } from '../routing/index.js';
+import { ensureProcessNodeEnv } from '../util.js';
+import { normalizePath } from '../viteUtils.js';
+
+export type SyncOptions = {
+ mode: string;
+ /**
+ * @internal only used for testing
+ */
+ fs?: typeof fsMod;
+ logger: Logger;
+ settings: AstroSettings;
+ force?: boolean;
+ skip?: {
+ // Must be skipped in dev
+ content?: boolean;
+ // Cleanup can be skipped in dev as some state can be reused on updates
+ cleanup?: boolean;
+ };
+ routesList: RoutesList;
+ manifest: SSRManifest;
+ command: 'build' | 'dev' | 'sync';
+ watcher?: FSWatcher;
+};
+
+export default async function sync(
+ inlineConfig: AstroInlineConfig,
+ { fs, telemetry: _telemetry = false }: { fs?: typeof fsMod; telemetry?: boolean } = {},
+) {
+ ensureProcessNodeEnv('production');
+ const logger = createNodeLogger(inlineConfig);
+ const { astroConfig, userConfig } = await resolveConfig(inlineConfig ?? {}, 'sync');
+ if (_telemetry) {
+ telemetry.record(eventCliSession('sync', userConfig));
+ }
+ let settings = await createSettings(astroConfig, inlineConfig.root);
+ settings = await runHookConfigSetup({
+ command: 'sync',
+ settings,
+ logger,
+ });
+ const routesList = await createRoutesList({ settings, fsMod: fs }, logger);
+ const manifest = createDevelopmentManifest(settings);
+ await runHookConfigDone({ settings, logger });
+
+ return await syncInternal({
+ settings,
+ logger,
+ mode: 'production',
+ fs,
+ force: inlineConfig.force,
+ routesList,
+ command: 'sync',
+ manifest,
+ });
+}
+
+/**
+ * Clears the content layer and content collection cache, forcing a full rebuild.
+ */
+export async function clearContentLayerCache({
+ settings,
+ logger,
+ fs = fsMod,
+ isDev,
+}: {
+ settings: AstroSettings;
+ logger: Logger;
+ fs?: typeof fsMod;
+ isDev: boolean;
+}) {
+ const dataStore = getDataStoreFile(settings, isDev);
+ if (fs.existsSync(dataStore)) {
+ logger.debug('content', 'clearing data store');
+ await fs.promises.rm(dataStore, { force: true });
+ logger.warn('content', 'data store cleared (force)');
+ }
+}
+
+/**
+ * Generates TypeScript types for all Astro modules. This sets up a `src/env.d.ts` file for type inferencing,
+ * and defines the `astro:content` module for the Content Collections API.
+ *
+ * @experimental The JavaScript API is experimental
+ */
+export async function syncInternal({
+ mode,
+ logger,
+ fs = fsMod,
+ settings,
+ skip,
+ force,
+ routesList,
+ command,
+ watcher,
+ manifest,
+}: SyncOptions): Promise<void> {
+ const isDev = command === 'dev';
+ if (force) {
+ await clearContentLayerCache({ settings, logger, fs, isDev });
+ }
+
+ const timerStart = performance.now();
+
+ if (!skip?.content) {
+ await syncContentCollections(settings, { mode, fs, logger, routesList, manifest });
+ settings.timer.start('Sync content layer');
+
+ let store: MutableDataStore | undefined;
+ try {
+ const dataStoreFile = getDataStoreFile(settings, isDev);
+ store = await MutableDataStore.fromFile(dataStoreFile);
+ } catch (err: any) {
+ logger.error('content', err.message);
+ }
+ if (!store) {
+ logger.error('content', 'Failed to load content store');
+ return;
+ }
+
+ const contentLayer = globalContentLayer.init({
+ settings,
+ logger,
+ store,
+ watcher,
+ });
+ if (watcher) {
+ contentLayer.watchContentConfig();
+ }
+ await contentLayer.sync();
+ if (!skip?.cleanup) {
+ // Free up memory (usually in builds since we only need to use this once)
+ contentLayer.dispose();
+ }
+ settings.timer.end('Sync content layer');
+ } else {
+ const paths = getContentPaths(settings.config, fs);
+ if (
+ paths.config.exists ||
+ // Legacy collections don't require a config file
+ (settings.config.legacy?.collections && fs.existsSync(paths.contentDir))
+ ) {
+ // We only create the reference, without a stub to avoid overriding the
+ // already generated types
+ settings.injectedTypes.push({
+ filename: CONTENT_TYPES_FILE,
+ });
+ }
+ }
+ syncAstroEnv(settings);
+
+ writeInjectedTypes(settings, fs);
+ logger.info('types', `Generated ${dim(getTimeStat(timerStart, performance.now()))}`);
+}
+
+function getTsReference(type: 'path' | 'types', value: string) {
+ return `/// <reference ${type}=${JSON.stringify(value)} />`;
+}
+
+const CLIENT_TYPES_REFERENCE = getTsReference('types', 'astro/client');
+
+function writeInjectedTypes(settings: AstroSettings, fs: typeof fsMod) {
+ const references: Array<string> = [];
+
+ for (const { filename, content } of settings.injectedTypes) {
+ const filepath = fileURLToPath(new URL(filename, settings.dotAstroDir));
+ fs.mkdirSync(dirname(filepath), { recursive: true });
+ if (content) {
+ fs.writeFileSync(filepath, content, 'utf-8');
+ }
+ references.push(normalizePath(relative(fileURLToPath(settings.dotAstroDir), filepath)));
+ }
+
+ const astroDtsContent = `${CLIENT_TYPES_REFERENCE}\n${references.map((reference) => getTsReference('path', reference)).join('\n')}`;
+ if (references.length === 0) {
+ fs.mkdirSync(settings.dotAstroDir, { recursive: true });
+ }
+ fs.writeFileSync(
+ fileURLToPath(new URL('./types.d.ts', settings.dotAstroDir)),
+ astroDtsContent,
+ 'utf-8',
+ );
+}
+
+/**
+ * Generate content collection types, and then returns the process exit signal.
+ *
+ * A non-zero process signal is emitted in case there's an error while generating content collection types.
+ *
+ * This should only be used when the callee already has an `AstroSetting`, otherwise use `sync()` instead.
+ * @internal
+ *
+ * @param {SyncOptions} options
+ * @param {AstroSettings} settings Astro settings
+ * @param {typeof fsMod} options.fs The file system
+ * @param {LogOptions} options.logging Logging options
+ * @return {Promise<ProcessExit>}
+ */
+async function syncContentCollections(
+ settings: AstroSettings,
+ {
+ mode,
+ logger,
+ fs,
+ routesList,
+ manifest,
+ }: Required<Pick<SyncOptions, 'mode' | 'logger' | 'fs' | 'routesList' | 'manifest'>>,
+): Promise<void> {
+ // Needed to load content config
+ const tempViteServer = await createServer(
+ await createVite(
+ {
+ server: { middlewareMode: true, hmr: false, watch: null, ws: false },
+ optimizeDeps: { noDiscovery: true },
+ ssr: { external: [] },
+ logLevel: 'silent',
+ },
+ { settings, logger, mode, command: 'build', fs, sync: true, routesList, manifest },
+ ),
+ );
+
+ // Patch `hot.send` to bubble up error events
+ // `hot.on('error')` does not fire for some reason
+ const hotSend = tempViteServer.hot.send;
+ tempViteServer.hot.send = (payload: HMRPayload) => {
+ if (payload.type === 'error') {
+ throw payload.err;
+ }
+ return hotSend(payload);
+ };
+
+ try {
+ const contentTypesGenerator = await createContentTypesGenerator({
+ contentConfigObserver: globalContentConfigObserver,
+ logger: logger,
+ fs,
+ settings,
+ viteServer: tempViteServer,
+ });
+ const typesResult = await contentTypesGenerator.init();
+
+ const contentConfig = globalContentConfigObserver.get();
+ if (contentConfig.status === 'error') {
+ throw contentConfig.error;
+ }
+
+ if (typesResult.typesGenerated === false) {
+ switch (typesResult.reason) {
+ case 'no-content-dir':
+ default:
+ logger.debug('types', 'No content directory found. Skipping type generation.');
+ }
+ }
+ } catch (e) {
+ const safeError = createSafeError(e) as ErrorWithMetadata;
+ if (isAstroError(e)) {
+ throw e;
+ }
+ let configFile;
+ try {
+ const contentPaths = getContentPaths(settings.config, fs);
+ if (contentPaths.config.exists) {
+ const matches = /\/(src\/.+)/.exec(contentPaths.config.url.href);
+ if (matches) {
+ configFile = matches[1];
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ const hint = AstroUserError.is(e)
+ ? e.hint
+ : AstroErrorData.GenerateContentTypesError.hint(configFile);
+ throw new AstroError(
+ {
+ ...AstroErrorData.GenerateContentTypesError,
+ hint,
+ message: AstroErrorData.GenerateContentTypesError.message(safeError.message),
+ location: safeError.loc,
+ },
+ { cause: e },
+ );
+ } finally {
+ await tempViteServer.close();
+ }
+}
diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts
new file mode 100644
index 000000000..22be8d0ba
--- /dev/null
+++ b/packages/astro/src/core/util.ts
@@ -0,0 +1,190 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+import type { RouteData } from '../types/public/internal.js';
+import { hasSpecialQueries } from '../vite-plugin-utils/index.js';
+import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js';
+import { removeQueryString, removeTrailingForwardSlash, slash } from './path.js';
+
+/** Returns true if argument is an object of any prototype/class (but not null). */
+export function isObject(value: unknown): value is Record<string, any> {
+ return typeof value === 'object' && value != null;
+}
+
+/** Cross-realm compatible URL */
+export function isURL(value: unknown): value is URL {
+ return Object.prototype.toString.call(value) === '[object URL]';
+}
+/** Check if a file is a markdown file based on its extension */
+export function isMarkdownFile(fileId: string, option?: { suffix?: string }): boolean {
+ if (hasSpecialQueries(fileId)) {
+ return false;
+ }
+ const id = removeQueryString(fileId);
+ const _suffix = option?.suffix ?? '';
+ for (let markdownFileExtension of SUPPORTED_MARKDOWN_FILE_EXTENSIONS) {
+ if (id.endsWith(`${markdownFileExtension}${_suffix}`)) return true;
+ }
+ return false;
+}
+
+/** Wraps an object in an array. If an array is passed, ignore it. */
+export function arraify<T>(target: T | T[]): T[] {
+ return Array.isArray(target) ? target : [target];
+}
+
+export function padMultilineString(source: string, n = 2) {
+ const lines = source.split(/\r?\n/);
+ return lines.map((l) => ` `.repeat(n) + l).join(`\n`);
+}
+
+const STATUS_CODE_PAGES = new Set(['/404', '/500']);
+
+/**
+ * Get the correct output filename for a route, based on your config.
+ * Handles both "/foo" and "foo" `name` formats.
+ * Handles `/404` and `/` correctly.
+ */
+export function getOutputFilename(astroConfig: AstroConfig, name: string, routeData: RouteData) {
+ if (routeData.type === 'endpoint') {
+ return name;
+ }
+ if (name === '/' || name === '') {
+ return path.posix.join(name, 'index.html');
+ }
+ if (astroConfig.build.format === 'file' || STATUS_CODE_PAGES.has(name)) {
+ return `${removeTrailingForwardSlash(name || 'index')}.html`;
+ }
+ if (astroConfig.build.format === 'preserve' && !routeData.isIndex) {
+ return `${removeTrailingForwardSlash(name || 'index')}.html`;
+ }
+ return path.posix.join(name, 'index.html');
+}
+
+/** is a specifier an npm package? */
+export function parseNpmName(
+ spec: string,
+): { scope?: string; name: string; subpath?: string } | undefined {
+ // not an npm package
+ if (!spec || spec[0] === '.' || spec[0] === '/') return undefined;
+
+ let scope: string | undefined;
+ let name = '';
+
+ let parts = spec.split('/');
+ if (parts[0][0] === '@') {
+ scope = parts[0];
+ name = parts.shift() + '/';
+ }
+ name += parts.shift();
+
+ let subpath = parts.length ? `./${parts.join('/')}` : undefined;
+
+ return {
+ scope,
+ name,
+ subpath,
+ };
+}
+
+/**
+ * Convert file URL to ID for viteServer.moduleGraph.idToModuleMap.get(:viteID)
+ * Format:
+ * Linux/Mac: /Users/astro/code/my-project/src/pages/index.astro
+ * Windows: C:/Users/astro/code/my-project/src/pages/index.astro
+ */
+export function viteID(filePath: URL): string {
+ return slash(fileURLToPath(filePath) + filePath.search);
+}
+
+export const VALID_ID_PREFIX = `/@id/`;
+export const NULL_BYTE_PLACEHOLDER = `__x00__`;
+
+// Strip valid id prefix and replace null byte placeholder. Both are prepended to resolved ids
+// as they are not valid browser import specifiers (by the Vite's importAnalysis plugin)
+export function unwrapId(id: string): string {
+ return id.startsWith(VALID_ID_PREFIX)
+ ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0')
+ : id;
+}
+
+export function resolvePages(config: AstroConfig) {
+ return new URL('./pages', config.srcDir);
+}
+
+function isInPagesDir(file: URL, config: AstroConfig): boolean {
+ const pagesDir = resolvePages(config);
+ return file.toString().startsWith(pagesDir.toString());
+}
+
+function isInjectedRoute(file: URL, settings: AstroSettings) {
+ let fileURL = file.toString();
+ for (const route of settings.resolvedInjectedRoutes) {
+ if (
+ route.resolvedEntryPoint &&
+ removeQueryString(fileURL) === removeQueryString(route.resolvedEntryPoint.toString())
+ )
+ return true;
+ }
+ return false;
+}
+
+function isPublicRoute(file: URL, config: AstroConfig): boolean {
+ const rootDir = config.root.toString();
+ const pagesDir = resolvePages(config).toString();
+ const fileDir = file.toString();
+
+ // Normalize the file directory path by removing the pagesDir prefix if it exists,
+ // otherwise remove the rootDir prefix.
+ const normalizedDir = fileDir.startsWith(pagesDir)
+ ? fileDir.slice(pagesDir.length)
+ : fileDir.slice(rootDir.length);
+
+ const parts = normalizedDir.replace(pagesDir.toString(), '').split('/').slice(1);
+
+ for (const part of parts) {
+ if (part.startsWith('_')) return false;
+ }
+
+ return true;
+}
+
+function endsWithPageExt(file: URL, settings: AstroSettings): boolean {
+ for (const ext of settings.pageExtensions) {
+ if (file.toString().endsWith(ext)) return true;
+ }
+ return false;
+}
+
+export function isPage(file: URL, settings: AstroSettings): boolean {
+ if (!isInPagesDir(file, settings.config) && !isInjectedRoute(file, settings)) return false;
+ if (!isPublicRoute(file, settings.config)) return false;
+ return endsWithPageExt(file, settings);
+}
+
+export function isEndpoint(file: URL, settings: AstroSettings): boolean {
+ if (!isInPagesDir(file, settings.config) && !isInjectedRoute(file, settings)) return false;
+ if (!isPublicRoute(file, settings.config)) return false;
+ return !endsWithPageExt(file, settings) && !file.toString().includes('?astro');
+}
+
+export function resolveJsToTs(filePath: string) {
+ if (filePath.endsWith('.jsx') && !fs.existsSync(filePath)) {
+ const tryPath = filePath.slice(0, -4) + '.tsx';
+ if (fs.existsSync(tryPath)) {
+ return tryPath;
+ }
+ }
+ return filePath;
+}
+
+/**
+ * Set a default NODE_ENV so Vite doesn't set an incorrect default when loading the Astro config
+ */
+export function ensureProcessNodeEnv(defaultNodeEnv: string) {
+ if (!process.env.NODE_ENV) {
+ process.env.NODE_ENV = defaultNodeEnv;
+ }
+}
diff --git a/packages/astro/src/core/viteUtils.ts b/packages/astro/src/core/viteUtils.ts
new file mode 100644
index 000000000..46c59d25d
--- /dev/null
+++ b/packages/astro/src/core/viteUtils.ts
@@ -0,0 +1,70 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { prependForwardSlash, slash } from '../core/path.js';
+import type { ModuleLoader } from './module-loader/index.js';
+import { VALID_ID_PREFIX, resolveJsToTs, unwrapId, viteID } from './util.js';
+
+const isWindows = typeof process !== 'undefined' && process.platform === 'win32';
+
+/**
+ * Re-implementation of Vite's normalizePath that can be used without Vite
+ */
+export function normalizePath(id: string) {
+ return path.posix.normalize(isWindows ? slash(id) : id);
+}
+
+/**
+ * Resolve the hydration paths so that it can be imported in the client
+ */
+export function resolvePath(specifier: string, importer: string) {
+ if (specifier.startsWith('.')) {
+ const absoluteSpecifier = path.resolve(path.dirname(importer), specifier);
+ return resolveJsToTs(normalizePath(absoluteSpecifier));
+ } else {
+ return specifier;
+ }
+}
+
+export function rootRelativePath(
+ root: URL,
+ idOrUrl: URL | string,
+ shouldPrependForwardSlash = true,
+) {
+ let id: string;
+ if (typeof idOrUrl !== 'string') {
+ id = unwrapId(viteID(idOrUrl));
+ } else {
+ id = idOrUrl;
+ }
+ const normalizedRoot = normalizePath(fileURLToPath(root));
+ if (id.startsWith(normalizedRoot)) {
+ id = id.slice(normalizedRoot.length);
+ }
+ return shouldPrependForwardSlash ? prependForwardSlash(id) : id;
+}
+
+/**
+ * Simulate Vite's resolve and import analysis so we can import the id as an URL
+ * through a script tag or a dynamic import as-is.
+ */
+// NOTE: `/@id/` should only be used when the id is fully resolved
+export async function resolveIdToUrl(loader: ModuleLoader, id: string, root?: URL) {
+ let resultId = await loader.resolveId(id, undefined);
+ // Try resolve jsx to tsx
+ if (!resultId && id.endsWith('.jsx')) {
+ resultId = await loader.resolveId(id.slice(0, -4), undefined);
+ }
+ if (!resultId) {
+ return VALID_ID_PREFIX + id;
+ }
+ if (path.isAbsolute(resultId)) {
+ const normalizedRoot = root && normalizePath(fileURLToPath(root));
+ // Convert to root-relative path if path is inside root
+ if (normalizedRoot && resultId.startsWith(normalizedRoot)) {
+ return resultId.slice(normalizedRoot.length - 1);
+ } else {
+ return '/@fs' + prependForwardSlash(resultId);
+ }
+ }
+ return VALID_ID_PREFIX + resultId;
+}
diff --git a/packages/astro/src/env/README.md b/packages/astro/src/env/README.md
new file mode 100644
index 000000000..349ca140f
--- /dev/null
+++ b/packages/astro/src/env/README.md
@@ -0,0 +1,12 @@
+# env
+
+The content of this directory is for `astro:env` features, except for `vite-plugin-import-meta-env.ts`.
+
+# vite-plugin-import-meta-env
+
+Improves Vite's [Env Variables](https://vite.dev/guide/env-and-mode.html#env-files) support to include **private** env variables during Server-Side Rendering (SSR) but never in client-side rendering (CSR).
+
+Private env variables can be accessed through `import.meta.env.SECRET` like Vite. Where the env variable is declared changes how it is replaced when transforming it:
+
+- If it's from a `.env` file, it gets replaced with the actual value. (static)
+- If it's from `process.env`, it gets replaced as `process.env.SECRET`. (dynamic)
diff --git a/packages/astro/src/env/config.ts b/packages/astro/src/env/config.ts
new file mode 100644
index 000000000..beb91b43b
--- /dev/null
+++ b/packages/astro/src/env/config.ts
@@ -0,0 +1,32 @@
+import type {
+ BooleanField,
+ BooleanFieldInput,
+ EnumField,
+ EnumFieldInput,
+ NumberField,
+ NumberFieldInput,
+ StringField,
+ StringFieldInput,
+} from './schema.js';
+
+/**
+ * Return a valid env field to use in this Astro config for `env.schema`.
+ */
+export const envField = {
+ string: (options: StringFieldInput): StringField => ({
+ ...options,
+ type: 'string',
+ }),
+ number: (options: NumberFieldInput): NumberField => ({
+ ...options,
+ type: 'number',
+ }),
+ boolean: (options: BooleanFieldInput): BooleanField => ({
+ ...options,
+ type: 'boolean',
+ }),
+ enum: <T extends string>(options: EnumFieldInput<T>): EnumField => ({
+ ...options,
+ type: 'enum',
+ }),
+};
diff --git a/packages/astro/src/env/constants.ts b/packages/astro/src/env/constants.ts
new file mode 100644
index 000000000..220f63373
--- /dev/null
+++ b/packages/astro/src/env/constants.ts
@@ -0,0 +1,11 @@
+export const VIRTUAL_MODULES_IDS = {
+ client: 'astro:env/client',
+ server: 'astro:env/server',
+ internal: 'virtual:astro:env/internal',
+};
+export const VIRTUAL_MODULES_IDS_VALUES = new Set(Object.values(VIRTUAL_MODULES_IDS));
+
+export const ENV_TYPES_FILE = 'env.d.ts';
+
+const PKG_BASE = new URL('../../', import.meta.url);
+export const MODULE_TEMPLATE_URL = new URL('templates/env.mjs', PKG_BASE);
diff --git a/packages/astro/src/env/env-loader.ts b/packages/astro/src/env/env-loader.ts
new file mode 100644
index 000000000..02f396652
--- /dev/null
+++ b/packages/astro/src/env/env-loader.ts
@@ -0,0 +1,55 @@
+import { fileURLToPath } from 'node:url';
+import { loadEnv } from 'vite';
+import type { AstroConfig } from '../types/public/index.js';
+
+// Match valid JS variable names (identifiers), which accepts most alphanumeric characters,
+// except that the first character cannot be a number.
+const isValidIdentifierRe = /^[_$a-zA-Z][\w$]*$/;
+
+function getPrivateEnv(
+ fullEnv: Record<string, string>,
+ astroConfig: AstroConfig,
+): Record<string, string> {
+ const viteConfig = astroConfig.vite;
+ let envPrefixes: string[] = ['PUBLIC_'];
+ if (viteConfig.envPrefix) {
+ envPrefixes = Array.isArray(viteConfig.envPrefix)
+ ? viteConfig.envPrefix
+ : [viteConfig.envPrefix];
+ }
+
+ const privateEnv: Record<string, string> = {};
+ for (const key in fullEnv) {
+ // Ignore public env var
+ if (isValidIdentifierRe.test(key) && envPrefixes.every((prefix) => !key.startsWith(prefix))) {
+ if (typeof process.env[key] !== 'undefined') {
+ let value = process.env[key];
+ // Replacements are always strings, so try to convert to strings here first
+ if (typeof value !== 'string') {
+ value = `${value}`;
+ }
+ // Boolean values should be inlined to support `export const prerender`
+ // We already know that these are NOT sensitive values, so inlining is safe
+ if (value === '0' || value === '1' || value === 'true' || value === 'false') {
+ privateEnv[key] = value;
+ } else {
+ privateEnv[key] = `process.env.${key}`;
+ }
+ } else {
+ privateEnv[key] = JSON.stringify(fullEnv[key]);
+ }
+ }
+ }
+ return privateEnv;
+}
+
+export const createEnvLoader = (mode: string, config: AstroConfig) => {
+ const loaded = loadEnv(mode, config.vite.envDir ?? fileURLToPath(config.root), '');
+ const privateEnv = getPrivateEnv(loaded, config);
+ return {
+ get: () => loaded,
+ getPrivateEnv: () => privateEnv,
+ };
+};
+
+export type EnvLoader = ReturnType<typeof createEnvLoader>;
diff --git a/packages/astro/src/env/errors.ts b/packages/astro/src/env/errors.ts
new file mode 100644
index 000000000..6fcbd5b3d
--- /dev/null
+++ b/packages/astro/src/env/errors.ts
@@ -0,0 +1,22 @@
+import type { ValidationResultErrors } from './validators.js';
+
+export interface InvalidVariable {
+ key: string;
+ type: string;
+ errors: ValidationResultErrors;
+}
+
+export function invalidVariablesToError(invalid: Array<InvalidVariable>) {
+ const _errors: Array<string> = [];
+ for (const { key, type, errors } of invalid) {
+ if (errors[0] === 'missing') {
+ _errors.push(`${key} is missing`);
+ } else if (errors[0] === 'type') {
+ _errors.push(`${key}'s type is invalid, expected: ${type}`);
+ } else {
+ // constraints
+ _errors.push(`The following constraints for ${key} are not met: ${errors.join(', ')}`);
+ }
+ }
+ return _errors;
+}
diff --git a/packages/astro/src/env/runtime.ts b/packages/astro/src/env/runtime.ts
new file mode 100644
index 000000000..25a87d4bc
--- /dev/null
+++ b/packages/astro/src/env/runtime.ts
@@ -0,0 +1,38 @@
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { invalidVariablesToError } from './errors.js';
+import type { ValidationResultInvalid } from './validators.js';
+export { validateEnvVariable, getEnvFieldType } from './validators.js';
+
+export type GetEnv = (key: string) => string | undefined;
+type OnSetGetEnv = () => void;
+
+let _getEnv: GetEnv = (key) => process.env[key];
+
+export function setGetEnv(fn: GetEnv) {
+ _getEnv = fn;
+
+ _onSetGetEnv();
+}
+
+let _onSetGetEnv: OnSetGetEnv = () => {};
+
+export function setOnSetGetEnv(fn: OnSetGetEnv) {
+ _onSetGetEnv = fn;
+}
+
+export function getEnv(...args: Parameters<GetEnv>) {
+ return _getEnv(...args);
+}
+
+export function createInvalidVariablesError(
+ key: string,
+ type: string,
+ result: ValidationResultInvalid,
+) {
+ return new AstroError({
+ ...AstroErrorData.EnvInvalidVariables,
+ message: AstroErrorData.EnvInvalidVariables.message(
+ invalidVariablesToError([{ key, type, errors: result.errors }]),
+ ),
+ });
+}
diff --git a/packages/astro/src/env/schema.ts b/packages/astro/src/env/schema.ts
new file mode 100644
index 000000000..f000ec1b9
--- /dev/null
+++ b/packages/astro/src/env/schema.ts
@@ -0,0 +1,140 @@
+import { z } from 'zod';
+
+const StringSchema = z.object({
+ type: z.literal('string'),
+ optional: z.boolean().optional(),
+ default: z.string().optional(),
+ max: z.number().optional(),
+ min: z.number().min(0).optional(),
+ length: z.number().optional(),
+ url: z.boolean().optional(),
+ includes: z.string().optional(),
+ startsWith: z.string().optional(),
+ endsWith: z.string().optional(),
+});
+export type StringSchema = z.infer<typeof StringSchema>;
+const NumberSchema = z.object({
+ type: z.literal('number'),
+ optional: z.boolean().optional(),
+ default: z.number().optional(),
+ gt: z.number().optional(),
+ min: z.number().optional(),
+ lt: z.number().optional(),
+ max: z.number().optional(),
+ int: z.boolean().optional(),
+});
+export type NumberSchema = z.infer<typeof NumberSchema>;
+const BooleanSchema = z.object({
+ type: z.literal('boolean'),
+ optional: z.boolean().optional(),
+ default: z.boolean().optional(),
+});
+const EnumSchema = z.object({
+ type: z.literal('enum'),
+ values: z.array(
+ // We use "'" for codegen so it can't be passed here
+ z
+ .string()
+ .refine((v) => !v.includes("'"), {
+ message: `The "'" character can't be used as an enum value`,
+ }),
+ ),
+ optional: z.boolean().optional(),
+ default: z.string().optional(),
+});
+export type EnumSchema = z.infer<typeof EnumSchema>;
+
+const EnvFieldType = z.union([
+ StringSchema,
+ NumberSchema,
+ BooleanSchema,
+ EnumSchema.superRefine((schema, ctx) => {
+ if (schema.default) {
+ if (!schema.values.includes(schema.default)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The default value "${
+ schema.default
+ }" must be one of the specified values: ${schema.values.join(', ')}.`,
+ });
+ }
+ }
+ }),
+]);
+export type EnvFieldType = z.infer<typeof EnvFieldType>;
+
+const PublicClientEnvFieldMetadata = z.object({
+ context: z.literal('client'),
+ access: z.literal('public'),
+});
+const PublicServerEnvFieldMetadata = z.object({
+ context: z.literal('server'),
+ access: z.literal('public'),
+});
+const SecretServerEnvFieldMetadata = z.object({
+ context: z.literal('server'),
+ access: z.literal('secret'),
+});
+const _EnvFieldMetadata = z.union([
+ PublicClientEnvFieldMetadata,
+ PublicServerEnvFieldMetadata,
+ SecretServerEnvFieldMetadata,
+]);
+const EnvFieldMetadata = z.custom<z.input<typeof _EnvFieldMetadata>>().superRefine((data, ctx) => {
+ const result = _EnvFieldMetadata.safeParse(data);
+ if (result.success) {
+ return;
+ }
+ for (const issue of result.error.issues) {
+ if (issue.code === z.ZodIssueCode.invalid_union) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `**Invalid combination** of "access" and "context" options:\n Secret client variables are not supported. Please review the configuration of \`env.schema.${ctx.path.at(-1)}\`.\n Learn more at https://docs.astro.build/en/guides/environment-variables/#variable-types`,
+ path: ['context', 'access'],
+ });
+ } else {
+ ctx.addIssue(issue);
+ }
+ }
+});
+
+const EnvSchemaKey = z
+ .string()
+ .min(1)
+ .refine(([firstChar]) => isNaN(Number.parseInt(firstChar)), {
+ message: 'A valid variable name cannot start with a number.',
+ })
+ .refine((str) => /^[A-Z0-9_]+$/.test(str), {
+ message: 'A valid variable name can only contain uppercase letters, numbers and underscores.',
+ });
+
+export const EnvSchema = z.record(EnvSchemaKey, z.intersection(EnvFieldMetadata, EnvFieldType));
+
+// https://www.totaltypescript.com/concepts/the-prettify-helper
+type Prettify<T> = {
+ [K in keyof T]: T[K];
+} & {};
+
+export type EnvSchema = z.infer<typeof EnvSchema>;
+
+type _Field<T extends z.ZodType> = Prettify<z.infer<typeof EnvFieldMetadata & T>>;
+type _FieldInput<T extends z.ZodType, TKey extends string = 'type'> = Prettify<
+ z.infer<typeof EnvFieldMetadata> & Omit<z.infer<T>, TKey>
+>;
+
+export type StringField = _Field<typeof StringSchema>;
+export type StringFieldInput = _FieldInput<typeof StringSchema>;
+
+export type NumberField = _Field<typeof NumberSchema>;
+export type NumberFieldInput = _FieldInput<typeof NumberSchema>;
+
+export type BooleanField = _Field<typeof BooleanSchema>;
+export type BooleanFieldInput = _FieldInput<typeof BooleanSchema>;
+
+export type EnumField = _Field<typeof EnumSchema>;
+export type EnumFieldInput<T extends string> = Prettify<
+ _FieldInput<typeof EnumSchema, 'type' | 'values' | 'default'> & {
+ values: Array<T>;
+ default?: NoInfer<T> | undefined;
+ }
+>;
diff --git a/packages/astro/src/env/setup.ts b/packages/astro/src/env/setup.ts
new file mode 100644
index 000000000..179067b10
--- /dev/null
+++ b/packages/astro/src/env/setup.ts
@@ -0,0 +1 @@
+export { setGetEnv, type GetEnv } from './runtime.js';
diff --git a/packages/astro/src/env/sync.ts b/packages/astro/src/env/sync.ts
new file mode 100644
index 000000000..5949a4766
--- /dev/null
+++ b/packages/astro/src/env/sync.ts
@@ -0,0 +1,34 @@
+import type { AstroSettings } from '../types/astro.js';
+import { ENV_TYPES_FILE } from './constants.js';
+import { getEnvFieldType } from './validators.js';
+
+export function syncAstroEnv(settings: AstroSettings): void {
+ let client = '';
+ let server = '';
+
+ for (const [key, options] of Object.entries(settings.config.env.schema)) {
+ const str = ` export const ${key}: ${getEnvFieldType(options)}; \n`;
+ if (options.context === 'client') {
+ client += str;
+ } else {
+ server += str;
+ }
+ }
+
+ let content = '';
+ if (client !== '') {
+ content = `declare module 'astro:env/client' {
+${client}}`;
+ }
+ if (server !== '') {
+ content += `declare module 'astro:env/server' {
+${server}}`;
+ }
+
+ if (content !== '') {
+ settings.injectedTypes.push({
+ filename: ENV_TYPES_FILE,
+ content,
+ });
+ }
+}
diff --git a/packages/astro/src/env/validators.ts b/packages/astro/src/env/validators.ts
new file mode 100644
index 000000000..f506bb8e7
--- /dev/null
+++ b/packages/astro/src/env/validators.ts
@@ -0,0 +1,179 @@
+import type { EnumSchema, EnvFieldType, NumberSchema, StringSchema } from './schema.js';
+
+export type ValidationResultValue = EnvFieldType['default'];
+export type ValidationResultErrors = ['missing'] | ['type'] | Array<string>;
+interface ValidationResultValid {
+ ok: true;
+ value: ValidationResultValue;
+}
+export interface ValidationResultInvalid {
+ ok: false;
+ errors: ValidationResultErrors;
+}
+type ValidationResult = ValidationResultValid | ValidationResultInvalid;
+
+export function getEnvFieldType(options: EnvFieldType) {
+ const optional = options.optional ? (options.default !== undefined ? false : true) : false;
+
+ let type: string;
+ if (options.type === 'enum') {
+ type = options.values.map((v) => `'${v}'`).join(' | ');
+ } else {
+ type = options.type;
+ }
+
+ return `${type}${optional ? ' | undefined' : ''}`;
+}
+
+type ValueValidator = (input: string | undefined) => ValidationResult;
+
+const stringValidator =
+ ({ max, min, length, url, includes, startsWith, endsWith }: StringSchema): ValueValidator =>
+ (input) => {
+ if (typeof input !== 'string') {
+ return {
+ ok: false,
+ errors: ['type'],
+ };
+ }
+ const errors: Array<string> = [];
+
+ if (max !== undefined && !(input.length <= max)) {
+ errors.push('max');
+ }
+ if (min !== undefined && !(input.length >= min)) {
+ errors.push('min');
+ }
+ if (length !== undefined && !(input.length === length)) {
+ errors.push('length');
+ }
+ if (url !== undefined && !URL.canParse(input)) {
+ errors.push('url');
+ }
+ if (includes !== undefined && !input.includes(includes)) {
+ errors.push('includes');
+ }
+ if (startsWith !== undefined && !input.startsWith(startsWith)) {
+ errors.push('startsWith');
+ }
+ if (endsWith !== undefined && !input.endsWith(endsWith)) {
+ errors.push('endsWith');
+ }
+
+ if (errors.length > 0) {
+ return {
+ ok: false,
+ errors,
+ };
+ }
+ return {
+ ok: true,
+ value: input,
+ };
+ };
+
+const numberValidator =
+ ({ gt, min, lt, max, int }: NumberSchema): ValueValidator =>
+ (input) => {
+ const num = parseFloat(input ?? '');
+ if (isNaN(num)) {
+ return {
+ ok: false,
+ errors: ['type'],
+ };
+ }
+ const errors: Array<string> = [];
+
+ if (gt !== undefined && !(num > gt)) {
+ errors.push('gt');
+ }
+ if (min !== undefined && !(num >= min)) {
+ errors.push('min');
+ }
+ if (lt !== undefined && !(num < lt)) {
+ errors.push('lt');
+ }
+ if (max !== undefined && !(num <= max)) {
+ errors.push('max');
+ }
+ if (int !== undefined) {
+ const isInt = Number.isInteger(num);
+ if (!(int ? isInt : !isInt)) {
+ errors.push('int');
+ }
+ }
+
+ if (errors.length > 0) {
+ return {
+ ok: false,
+ errors,
+ };
+ }
+ return {
+ ok: true,
+ value: num,
+ };
+ };
+
+const booleanValidator: ValueValidator = (input) => {
+ const bool = input === 'true' ? true : input === 'false' ? false : undefined;
+ if (typeof bool !== 'boolean') {
+ return {
+ ok: false,
+ errors: ['type'],
+ };
+ }
+ return {
+ ok: true,
+ value: bool,
+ };
+};
+
+const enumValidator =
+ ({ values }: EnumSchema): ValueValidator =>
+ (input) => {
+ if (!(typeof input === 'string' ? values.includes(input) : false)) {
+ return {
+ ok: false,
+ errors: ['type'],
+ };
+ }
+ return {
+ ok: true,
+ value: input,
+ };
+ };
+
+function selectValidator(options: EnvFieldType): ValueValidator {
+ switch (options.type) {
+ case 'string':
+ return stringValidator(options);
+ case 'number':
+ return numberValidator(options);
+ case 'boolean':
+ return booleanValidator;
+ case 'enum':
+ return enumValidator(options);
+ }
+}
+
+export function validateEnvVariable(
+ value: string | undefined,
+ options: EnvFieldType,
+): ValidationResult {
+ const isOptional = options.optional || options.default !== undefined;
+ if (isOptional && value === undefined) {
+ return {
+ ok: true,
+ value: options.default,
+ };
+ }
+ if (!isOptional && value === undefined) {
+ return {
+ ok: false,
+ errors: ['missing'],
+ };
+ }
+
+ return selectValidator(options)(value);
+}
diff --git a/packages/astro/src/env/vite-plugin-env.ts b/packages/astro/src/env/vite-plugin-env.ts
new file mode 100644
index 000000000..e01d8f3d6
--- /dev/null
+++ b/packages/astro/src/env/vite-plugin-env.ts
@@ -0,0 +1,178 @@
+import { readFileSync } from 'node:fs';
+import type { Plugin } from 'vite';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { AstroSettings } from '../types/astro.js';
+import {
+ MODULE_TEMPLATE_URL,
+ VIRTUAL_MODULES_IDS,
+ VIRTUAL_MODULES_IDS_VALUES,
+} from './constants.js';
+import type { EnvLoader } from './env-loader.js';
+import { type InvalidVariable, invalidVariablesToError } from './errors.js';
+import type { EnvSchema } from './schema.js';
+import { getEnvFieldType, validateEnvVariable } from './validators.js';
+
+interface AstroEnvPluginParams {
+ settings: AstroSettings;
+ sync: boolean;
+ envLoader: EnvLoader;
+}
+
+export function astroEnv({ settings, sync, envLoader }: AstroEnvPluginParams): Plugin {
+ const { schema, validateSecrets } = settings.config.env;
+ let isDev: boolean;
+
+ let templates: { client: string; server: string; internal: string } | null = null;
+
+ function ensureTemplateAreLoaded() {
+ if (templates !== null) {
+ return;
+ }
+
+ const loadedEnv = envLoader.get();
+
+ if (!isDev) {
+ for (const [key, value] of Object.entries(loadedEnv)) {
+ if (value !== undefined) {
+ process.env[key] = value;
+ }
+ }
+ }
+
+ const validatedVariables = validatePublicVariables({
+ schema,
+ loadedEnv,
+ validateSecrets,
+ sync,
+ });
+
+ templates = {
+ ...getTemplates(schema, validatedVariables, isDev ? loadedEnv : null),
+ internal: `export const schema = ${JSON.stringify(schema)};`,
+ };
+ }
+
+ return {
+ name: 'astro-env-plugin',
+ enforce: 'pre',
+ config(_, { command }) {
+ isDev = command !== 'build';
+ },
+ buildStart() {
+ ensureTemplateAreLoaded();
+ },
+ buildEnd() {
+ templates = null;
+ },
+ resolveId(id) {
+ if (VIRTUAL_MODULES_IDS_VALUES.has(id)) {
+ return resolveVirtualModuleId(id);
+ }
+ },
+ load(id, options) {
+ if (id === resolveVirtualModuleId(VIRTUAL_MODULES_IDS.client)) {
+ ensureTemplateAreLoaded();
+ return templates!.client;
+ }
+ if (id === resolveVirtualModuleId(VIRTUAL_MODULES_IDS.server)) {
+ if (options?.ssr) {
+ ensureTemplateAreLoaded();
+ return templates!.server;
+ }
+ throw new AstroError({
+ ...AstroErrorData.ServerOnlyModule,
+ message: AstroErrorData.ServerOnlyModule.message(VIRTUAL_MODULES_IDS.server),
+ });
+ }
+ if (id === resolveVirtualModuleId(VIRTUAL_MODULES_IDS.internal)) {
+ ensureTemplateAreLoaded();
+ return templates!.internal;
+ }
+ },
+ };
+}
+
+function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` {
+ return `\0${id}`;
+}
+
+function validatePublicVariables({
+ schema,
+ loadedEnv,
+ validateSecrets,
+ sync,
+}: {
+ schema: EnvSchema;
+ loadedEnv: Record<string, string>;
+ validateSecrets: boolean;
+ sync: boolean;
+}) {
+ const valid: Array<{ key: string; value: any; type: string; context: 'server' | 'client' }> = [];
+ const invalid: Array<InvalidVariable> = [];
+
+ for (const [key, options] of Object.entries(schema)) {
+ const variable = loadedEnv[key] === '' ? undefined : loadedEnv[key];
+
+ if (options.access === 'secret' && !validateSecrets) {
+ continue;
+ }
+
+ const result = validateEnvVariable(variable, options);
+ const type = getEnvFieldType(options);
+ if (!result.ok) {
+ invalid.push({ key, type, errors: result.errors });
+ // We don't do anything with validated secrets so we don't store them
+ } else if (options.access === 'public') {
+ valid.push({ key, value: result.value, type, context: options.context });
+ }
+ }
+
+ if (invalid.length > 0 && !sync) {
+ throw new AstroError({
+ ...AstroErrorData.EnvInvalidVariables,
+ message: AstroErrorData.EnvInvalidVariables.message(invalidVariablesToError(invalid)),
+ });
+ }
+
+ return valid;
+}
+
+function getTemplates(
+ schema: EnvSchema,
+ validatedVariables: ReturnType<typeof validatePublicVariables>,
+ loadedEnv: Record<string, string> | null,
+) {
+ let client = '';
+ let server = readFileSync(MODULE_TEMPLATE_URL, 'utf-8');
+ let onSetGetEnv = '';
+
+ for (const { key, value, context } of validatedVariables) {
+ const str = `export const ${key} = ${JSON.stringify(value)};`;
+ if (context === 'client') {
+ client += str;
+ } else {
+ server += str;
+ }
+ }
+
+ for (const [key, options] of Object.entries(schema)) {
+ if (!(options.context === 'server' && options.access === 'secret')) {
+ continue;
+ }
+
+ server += `export let ${key} = _internalGetSecret(${JSON.stringify(key)});\n`;
+ onSetGetEnv += `${key} = _internalGetSecret(${JSON.stringify(key)});\n`;
+ }
+
+ server = server.replace('// @@ON_SET_GET_ENV@@', onSetGetEnv);
+ if (loadedEnv) {
+ server = server.replace('// @@GET_ENV@@', `return (${JSON.stringify(loadedEnv)})[key];`);
+ } else {
+ server = server.replace('// @@GET_ENV@@', 'return _getEnv(key);');
+ }
+
+ return {
+ client,
+ server,
+ };
+}
diff --git a/packages/astro/src/env/vite-plugin-import-meta-env.ts b/packages/astro/src/env/vite-plugin-import-meta-env.ts
new file mode 100644
index 000000000..bfc032897
--- /dev/null
+++ b/packages/astro/src/env/vite-plugin-import-meta-env.ts
@@ -0,0 +1,166 @@
+import { transform } from 'esbuild';
+import MagicString from 'magic-string';
+import type * as vite from 'vite';
+import { createFilter, isCSSRequest } from 'vite';
+import type { EnvLoader } from './env-loader.js';
+
+interface EnvPluginOptions {
+ envLoader: EnvLoader;
+}
+
+// Match `import.meta.env` directly without trailing property access
+const importMetaEnvOnlyRe = /\bimport\.meta\.env\b(?!\.)/;
+
+function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any>): Set<string> {
+ const references = new Set<string>();
+ for (const key in privateEnv) {
+ if (source.includes(key)) {
+ references.add(key);
+ }
+ }
+ return references;
+}
+
+/**
+ * Use esbuild to perform replacememts like Vite
+ * https://github.com/vitejs/vite/blob/5ea9edbc9ceb991e85f893fe62d68ed028677451/packages/vite/src/node/plugins/define.ts#L130
+ */
+async function replaceDefine(
+ code: string,
+ id: string,
+ define: Record<string, string>,
+ config: vite.ResolvedConfig,
+): Promise<{ code: string; map: string | null }> {
+ // Since esbuild doesn't support replacing complex expressions, we replace `import.meta.env`
+ // with a marker string first, then postprocess and apply the `Object.assign` code.
+ const replacementMarkers: Record<string, string> = {};
+ const env = define['import.meta.env'];
+ if (env) {
+ // Compute the marker from the length of the replaced code. We do this so that esbuild generates
+ // the sourcemap with the right column offset when we do the postprocessing.
+ const marker = `__astro_import_meta_env${'_'.repeat(
+ env.length - 23 /* length of preceding string */,
+ )}`;
+ replacementMarkers[marker] = env;
+ define = { ...define, 'import.meta.env': marker };
+ }
+
+ const esbuildOptions = config.esbuild || {};
+
+ const result = await transform(code, {
+ loader: 'js',
+ charset: esbuildOptions.charset ?? 'utf8',
+ platform: 'neutral',
+ define,
+ sourcefile: id,
+ sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,
+ });
+
+ for (const marker in replacementMarkers) {
+ result.code = result.code.replaceAll(marker, replacementMarkers[marker]);
+ }
+
+ return {
+ code: result.code,
+ map: result.map || null,
+ };
+}
+
+export function importMetaEnv({ envLoader }: EnvPluginOptions): vite.Plugin {
+ let privateEnv: Record<string, string>;
+ let defaultDefines: Record<string, string>;
+ let isDev: boolean;
+ let devImportMetaEnvPrepend: string;
+ let viteConfig: vite.ResolvedConfig;
+ const filter = createFilter(null, ['**/*.html', '**/*.htm', '**/*.json']);
+ return {
+ name: 'astro:vite-plugin-env',
+ config(_, { command }) {
+ isDev = command !== 'build';
+ },
+ configResolved(resolvedConfig) {
+ viteConfig = resolvedConfig;
+
+ // HACK: move ourselves before Vite's define plugin to apply replacements at the right time (before Vite normal plugins)
+ const viteDefinePluginIndex = resolvedConfig.plugins.findIndex(
+ (p) => p.name === 'vite:define',
+ );
+ if (viteDefinePluginIndex !== -1) {
+ const myPluginIndex = resolvedConfig.plugins.findIndex(
+ (p) => p.name === 'astro:vite-plugin-env',
+ );
+ if (myPluginIndex !== -1) {
+ const myPlugin = resolvedConfig.plugins[myPluginIndex];
+ // @ts-ignore-error ignore readonly annotation
+ resolvedConfig.plugins.splice(viteDefinePluginIndex, 0, myPlugin);
+ // @ts-ignore-error ignore readonly annotation
+ resolvedConfig.plugins.splice(myPluginIndex, 1);
+ }
+ }
+ },
+
+ transform(source, id, options) {
+ if (
+ !options?.ssr ||
+ !source.includes('import.meta.env') ||
+ !filter(id) ||
+ isCSSRequest(id) ||
+ viteConfig.assetsInclude(id)
+ ) {
+ return;
+ }
+ // Find matches for *private* env and do our own replacement.
+ // Env is retrieved before process.env is populated by astro:env
+ // so that import.meta.env is first replaced by values, not process.env
+ privateEnv ??= envLoader.getPrivateEnv();
+
+ // In dev, we can assign the private env vars to `import.meta.env` directly for performance
+ if (isDev) {
+ const s = new MagicString(source);
+
+ if (!devImportMetaEnvPrepend) {
+ devImportMetaEnvPrepend = `Object.assign(import.meta.env,{`;
+ for (const key in privateEnv) {
+ devImportMetaEnvPrepend += `${key}:${privateEnv[key]},`;
+ }
+ devImportMetaEnvPrepend += '});';
+ }
+ s.prepend(devImportMetaEnvPrepend);
+
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary' }),
+ };
+ }
+
+ // In build, use esbuild to perform replacements. Compute the default defines for esbuild here as a
+ // separate object as it could be extended by `import.meta.env` later.
+ if (!defaultDefines) {
+ defaultDefines = {};
+ for (const key in privateEnv) {
+ defaultDefines[`import.meta.env.${key}`] = privateEnv[key];
+ }
+ }
+
+ let defines = defaultDefines;
+
+ // If reference the `import.meta.env` object directly, we want to inject private env vars
+ // into Vite's injected `import.meta.env` object. To do this, we use `Object.assign` and keeping
+ // the `import.meta.env` identifier so Vite sees it.
+ if (importMetaEnvOnlyRe.test(source)) {
+ const references = getReferencedPrivateKeys(source, privateEnv);
+ let replacement = `(Object.assign(import.meta.env,{`;
+ for (const key of references.values()) {
+ replacement += `${key}:${privateEnv[key]},`;
+ }
+ replacement += '}))';
+ defines = {
+ ...defaultDefines,
+ 'import.meta.env': replacement,
+ };
+ }
+
+ return replaceDefine(source, id, defines, viteConfig);
+ },
+ };
+}
diff --git a/packages/astro/src/events/error.ts b/packages/astro/src/events/error.ts
new file mode 100644
index 000000000..65e0cabb7
--- /dev/null
+++ b/packages/astro/src/events/error.ts
@@ -0,0 +1,112 @@
+import type { ZodError } from 'zod';
+import type { ErrorData } from '../core/errors/errors-data.js';
+import { AstroError, AstroErrorData, type ErrorWithMetadata } from '../core/errors/index.js';
+
+const EVENT_ERROR = 'ASTRO_CLI_ERROR';
+
+interface ErrorEventPayload {
+ name: string;
+ isFatal: boolean;
+ plugin?: string | undefined;
+ cliCommand: string;
+ anonymousMessageHint?: string | undefined;
+}
+
+interface ConfigErrorEventPayload extends ErrorEventPayload {
+ isConfig: true;
+ configErrorPaths: string[];
+}
+
+/**
+ * This regex will grab a small snippet at the start of an error message.
+ * This was designed to stop capturing at the first sign of some non-message
+ * content like a filename, filepath, or any other code-specific value.
+ * We also trim this value even further to just a few words.
+ *
+ * This is only used for errors that do not come from us so we can get a basic
+ * and anonymous idea of what the error is about.
+ */
+const ANONYMIZE_MESSAGE_REGEX = /^(?:\w| )+/;
+function anonymizeErrorMessage(msg: string): string | undefined {
+ const matchedMessage = ANONYMIZE_MESSAGE_REGEX.exec(msg);
+ if (!matchedMessage?.[0]) {
+ return undefined;
+ }
+ return matchedMessage[0].trim().substring(0, 20);
+}
+
+export function eventConfigError({
+ err,
+ cmd,
+ isFatal,
+}: {
+ err: ZodError;
+ cmd: string;
+ isFatal: boolean;
+}): { eventName: string; payload: ConfigErrorEventPayload }[] {
+ const payload: ConfigErrorEventPayload = {
+ name: 'ZodError',
+ isFatal,
+ isConfig: true,
+ cliCommand: cmd,
+ configErrorPaths: err.issues.map((issue) => issue.path.join('.')),
+ };
+ return [{ eventName: EVENT_ERROR, payload }];
+}
+
+export function eventError({
+ cmd,
+ err,
+ isFatal,
+}: {
+ err: ErrorWithMetadata;
+ cmd: string;
+ isFatal: boolean;
+}): { eventName: string; payload: ErrorEventPayload }[] {
+ const errorData =
+ AstroError.is(err) && (AstroErrorData[err.name as keyof typeof AstroErrorData] as ErrorData);
+
+ const payload: ErrorEventPayload = {
+ name: err.name,
+ plugin: err.plugin,
+ cliCommand: cmd,
+ isFatal: isFatal,
+ anonymousMessageHint:
+ errorData && errorData.message
+ ? getSafeErrorMessage(errorData.message)
+ : anonymizeErrorMessage(err.message),
+ };
+ return [{ eventName: EVENT_ERROR, payload }];
+}
+
+/**
+ * Safely get the error message from an error, even if it's a function.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+function getSafeErrorMessage(message: string | Function): string {
+ if (typeof message === 'string') {
+ return message;
+ } else {
+ return String.raw({
+ raw: extractStringFromFunction(message.toString()),
+ });
+ }
+
+ function extractStringFromFunction(func: string) {
+ const arrowIndex = func.indexOf('=>') + '=>'.length;
+
+ return func
+ .slice(arrowIndex)
+ .trim()
+ .slice(1, -1)
+ .replace(
+ /\$\{([^}]+)\}/g,
+ (_str, match1) =>
+ `${match1
+ .split(/\.?(?=[A-Z])/)
+ .join('_')
+ .toUpperCase()}`,
+ )
+ .replace(/\\`/g, '`');
+ }
+}
diff --git a/packages/astro/src/events/index.ts b/packages/astro/src/events/index.ts
new file mode 100644
index 000000000..7af647d44
--- /dev/null
+++ b/packages/astro/src/events/index.ts
@@ -0,0 +1,11 @@
+import { AstroTelemetry } from '@astrojs/telemetry';
+import { version as viteVersion } from 'vite';
+import { ASTRO_VERSION } from '../core/constants.js';
+
+export const telemetry = new AstroTelemetry({
+ astroVersion: ASTRO_VERSION,
+ viteVersion,
+});
+
+export * from './error.js';
+export * from './session.js';
diff --git a/packages/astro/src/events/session.ts b/packages/astro/src/events/session.ts
new file mode 100644
index 000000000..6e919f127
--- /dev/null
+++ b/packages/astro/src/events/session.ts
@@ -0,0 +1,138 @@
+import { AstroConfigSchema } from '../core/config/schema.js';
+import type { AstroUserConfig } from '../types/public/config.js';
+import type { AstroIntegration } from '../types/public/integrations.js';
+
+const EVENT_SESSION = 'ASTRO_CLI_SESSION_STARTED';
+
+interface EventPayload {
+ cliCommand: string;
+ config?: ConfigInfo;
+ configKeys?: string[];
+ flags?: string[];
+ optionalIntegrations?: number;
+}
+
+type ConfigInfoValue = string | boolean | string[] | undefined;
+type ConfigInfoRecord = Record<string, ConfigInfoValue>;
+type ConfigInfoBase = {
+ [alias in keyof AstroUserConfig]: ConfigInfoValue | ConfigInfoRecord;
+};
+export interface ConfigInfo extends ConfigInfoBase {
+ build: ConfigInfoRecord;
+ image: ConfigInfoRecord;
+ markdown: ConfigInfoRecord;
+ experimental: ConfigInfoRecord;
+ legacy: ConfigInfoRecord;
+ vite: ConfigInfoRecord | undefined;
+}
+
+function measureIsDefined(val: unknown) {
+ // if val is undefined, measure undefined as a value
+ if (val === undefined) {
+ return undefined;
+ }
+ // otherwise, convert the value to a boolean
+ return Boolean(val);
+}
+
+type StringLiteral<T> = T extends string ? (string extends T ? never : T) : never;
+
+/**
+ * Measure supports string literal values. Passing a generic `string` type
+ * results in an error, to make sure generic user input is never measured directly.
+ */
+function measureStringLiteral<T extends string>(
+ val: StringLiteral<T> | boolean | undefined,
+): string | boolean | undefined {
+ return val;
+}
+
+function measureIntegration(val: AstroIntegration | false | null | undefined): string | undefined {
+ if (!val || !val.name) {
+ return undefined;
+ }
+ return val.name;
+}
+
+function sanitizeConfigInfo(obj: object | undefined, validKeys: string[]): ConfigInfoRecord {
+ if (!obj || validKeys.length === 0) {
+ return {};
+ }
+ return validKeys.reduce(
+ (result, key) => {
+ result[key] = measureIsDefined((obj as Record<string, unknown>)[key]);
+ return result;
+ },
+ {} as Record<string, boolean | undefined>,
+ );
+}
+
+/**
+ * This function creates an anonymous ConfigInfo object from the user's config.
+ * All values are sanitized to preserve anonymity. Simple "exist" boolean checks
+ * are used by default, with a few additional sanitized values added manually.
+ * Helper functions should always be used to ensure correct sanitization.
+ */
+function createAnonymousConfigInfo(userConfig: AstroUserConfig) {
+ // Sanitize and measure the generic config object
+ // NOTE(fks): Using _def is the correct, documented way to get the `shape`
+ // from a Zod object that includes a wrapping default(), optional(), etc.
+ // Even though `_def` appears private, it is type-checked for us so that
+ // any changes between versions will be detected.
+ const configInfo: ConfigInfo = {
+ ...sanitizeConfigInfo(userConfig, Object.keys(AstroConfigSchema.shape)),
+ build: sanitizeConfigInfo(
+ userConfig.build,
+ Object.keys(AstroConfigSchema.shape.build._def.innerType.shape),
+ ),
+ image: sanitizeConfigInfo(
+ userConfig.image,
+ Object.keys(AstroConfigSchema.shape.image._def.innerType.shape),
+ ),
+ markdown: sanitizeConfigInfo(
+ userConfig.markdown,
+ Object.keys(AstroConfigSchema.shape.markdown._def.innerType.shape),
+ ),
+ experimental: sanitizeConfigInfo(
+ userConfig.experimental,
+ Object.keys(AstroConfigSchema.shape.experimental._def.innerType.shape),
+ ),
+ legacy: sanitizeConfigInfo(
+ userConfig.legacy,
+ Object.keys(AstroConfigSchema.shape.legacy._def.innerType.shape),
+ ),
+ vite: userConfig.vite
+ ? sanitizeConfigInfo(userConfig.vite, Object.keys(userConfig.vite))
+ : undefined,
+ };
+ // Measure string literal/enum configuration values
+ configInfo.build.format = measureStringLiteral(userConfig.build?.format);
+ configInfo.markdown.syntaxHighlight = measureStringLiteral(userConfig.markdown?.syntaxHighlight);
+ configInfo.output = measureStringLiteral(userConfig.output);
+ configInfo.scopedStyleStrategy = measureStringLiteral(userConfig.scopedStyleStrategy);
+ configInfo.trailingSlash = measureStringLiteral(userConfig.trailingSlash);
+ // Measure integration & adapter usage
+ configInfo.adapter = measureIntegration(userConfig.adapter);
+ configInfo.integrations = userConfig.integrations
+ ?.flat(100)
+ .map(measureIntegration)
+ .filter(Boolean) as string[];
+ // Return the sanitized ConfigInfo object
+ return configInfo;
+}
+
+export function eventCliSession(
+ cliCommand: string,
+ userConfig: AstroUserConfig,
+ flags?: Record<string, any>,
+): { eventName: string; payload: EventPayload }[] {
+ // Filter out yargs default `_` flag which is the cli command
+ const cliFlags = flags ? Object.keys(flags).filter((name) => name != '_') : undefined;
+
+ const payload: EventPayload = {
+ cliCommand,
+ config: createAnonymousConfigInfo(userConfig),
+ flags: cliFlags,
+ };
+ return [{ eventName: EVENT_SESSION, payload }];
+}
diff --git a/packages/astro/src/events/toolbar.ts b/packages/astro/src/events/toolbar.ts
new file mode 100644
index 000000000..995cb463e
--- /dev/null
+++ b/packages/astro/src/events/toolbar.ts
@@ -0,0 +1,15 @@
+const EVENT_TOOLBAR_APP_TOGGLED = 'ASTRO_TOOLBAR_APP_TOGGLED';
+
+interface AppToggledEventPayload {
+ app: string;
+}
+
+export function eventAppToggled(options: {
+ appName: 'other' | (string & {});
+}): { eventName: string; payload: AppToggledEventPayload }[] {
+ const payload: AppToggledEventPayload = {
+ app: options.appName,
+ };
+
+ return [{ eventName: EVENT_TOOLBAR_APP_TOGGLED, payload }];
+}
diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts
new file mode 100644
index 000000000..73b33fa60
--- /dev/null
+++ b/packages/astro/src/i18n/index.ts
@@ -0,0 +1,403 @@
+import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path';
+import type { SSRManifest } from '../core/app/types.js';
+import { shouldAppendForwardSlash } from '../core/build/util.js';
+import { REROUTE_DIRECTIVE_HEADER } from '../core/constants.js';
+import { MissingLocale, i18nNoLocaleFoundInPath } from '../core/errors/errors-data.js';
+import { AstroError } from '../core/errors/index.js';
+import type { AstroConfig, Locales, ValidRedirectStatus } from '../types/public/config.js';
+import type { APIContext } from '../types/public/context.js';
+import { createI18nMiddleware } from './middleware.js';
+import type { RoutingStrategies } from './utils.js';
+
+export function requestHasLocale(locales: Locales) {
+ return function (context: APIContext): boolean {
+ return pathHasLocale(context.url.pathname, locales);
+ };
+}
+
+// Checks if the pathname has any locale
+export function pathHasLocale(path: string, locales: Locales): boolean {
+ const segments = path.split('/');
+ for (const segment of segments) {
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) {
+ return true;
+ }
+ } else if (segment === locale.path) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+type GetLocaleRelativeUrl = GetLocaleOptions & {
+ locale: string;
+ base: string;
+ locales: Locales;
+ trailingSlash: AstroConfig['trailingSlash'];
+ format: AstroConfig['build']['format'];
+ strategy?: RoutingStrategies;
+ defaultLocale: string;
+ domains: Record<string, string> | undefined;
+ path?: string;
+};
+
+export type GetLocaleOptions = {
+ /**
+ * Makes the locale URL-friendly by replacing underscores with dashes, and converting the locale to lower case.
+ * @default true
+ */
+ normalizeLocale?: boolean;
+ /**
+ * An optional path to prepend to `locale`.
+ */
+ prependWith?: string;
+};
+
+type GetLocaleAbsoluteUrl = GetLocaleRelativeUrl & {
+ site: AstroConfig['site'];
+ isBuild: boolean;
+};
+
+/**
+ * The base URL
+ */
+export function getLocaleRelativeUrl({
+ locale,
+ base,
+ locales: _locales,
+ trailingSlash,
+ format,
+ path,
+ prependWith,
+ normalizeLocale = true,
+ strategy = 'pathname-prefix-other-locales',
+ defaultLocale,
+}: GetLocaleRelativeUrl) {
+ const codeToUse = peekCodePathToUse(_locales, locale);
+ if (!codeToUse) {
+ throw new AstroError({
+ ...MissingLocale,
+ message: MissingLocale.message(locale),
+ });
+ }
+ const pathsToJoin = [base, prependWith];
+ const normalizedLocale = normalizeLocale ? normalizeTheLocale(codeToUse) : codeToUse;
+ if (
+ strategy === 'pathname-prefix-always' ||
+ strategy === 'pathname-prefix-always-no-redirect' ||
+ strategy === 'domains-prefix-always' ||
+ strategy === 'domains-prefix-always-no-redirect'
+ ) {
+ pathsToJoin.push(normalizedLocale);
+ } else if (locale !== defaultLocale) {
+ pathsToJoin.push(normalizedLocale);
+ }
+ pathsToJoin.push(path);
+
+ let relativePath: string;
+ if (shouldAppendForwardSlash(trailingSlash, format)) {
+ relativePath = appendForwardSlash(joinPaths(...pathsToJoin));
+ } else {
+ relativePath = joinPaths(...pathsToJoin);
+ }
+
+ if (relativePath === '') {
+ return '/';
+ }
+ return relativePath;
+}
+
+/**
+ * The absolute URL
+ */
+export function getLocaleAbsoluteUrl({ site, isBuild, ...rest }: GetLocaleAbsoluteUrl) {
+ const localeUrl = getLocaleRelativeUrl(rest);
+ const { domains, locale } = rest;
+ let url;
+ if (isBuild && domains && domains[locale]) {
+ const base = domains[locale];
+ url = joinPaths(base, localeUrl.replace(`/${rest.locale}`, ''));
+ } else {
+ if (localeUrl === '/') {
+ url = site || '/';
+ } else if (site) {
+ url = joinPaths(site, localeUrl);
+ } else {
+ url = localeUrl;
+ }
+ }
+
+ if (shouldAppendForwardSlash(rest.trailingSlash, rest.format)) {
+ return appendForwardSlash(url);
+ } else {
+ return url;
+ }
+}
+
+interface GetLocalesRelativeUrlList extends GetLocaleOptions {
+ base: string;
+ path?: string;
+ locales: Locales;
+ trailingSlash: AstroConfig['trailingSlash'];
+ format: AstroConfig['build']['format'];
+ strategy?: RoutingStrategies;
+ defaultLocale: string;
+ domains: Record<string, string> | undefined;
+}
+
+export function getLocaleRelativeUrlList({
+ locales: _locales,
+ ...rest
+}: GetLocalesRelativeUrlList) {
+ const locales = toPaths(_locales);
+ return locales.map((locale) => {
+ return getLocaleRelativeUrl({ ...rest, locales, locale });
+ });
+}
+
+interface GetLocalesAbsoluteUrlList extends GetLocalesRelativeUrlList {
+ site: AstroConfig['site'];
+ isBuild: boolean;
+}
+
+export function getLocaleAbsoluteUrlList(params: GetLocalesAbsoluteUrlList) {
+ const locales = toCodes(params.locales);
+ return locales.map((currentLocale) => {
+ return getLocaleAbsoluteUrl({ ...params, locale: currentLocale });
+ });
+}
+
+/**
+ * Given a locale (code), it returns its corresponding path
+ * @param locale
+ * @param locales
+ */
+export function getPathByLocale(locale: string, locales: Locales): string {
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (loopLocale === locale) {
+ return loopLocale;
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === locale) {
+ return loopLocale.path;
+ }
+ }
+ }
+ }
+ throw new AstroError(i18nNoLocaleFoundInPath);
+}
+
+/**
+ * A utility function that retrieves the preferred locale that correspond to a path.
+ *
+ * @param path
+ * @param locales
+ */
+export function getLocaleByPath(path: string, locales: Locales): string {
+ for (const locale of locales) {
+ if (typeof locale !== 'string') {
+ if (locale.path === path) {
+ // the first code is the one that user usually wants
+ const code = locale.codes.at(0);
+ if (code === undefined) throw new AstroError(i18nNoLocaleFoundInPath);
+ return code;
+ }
+ } else if (locale === path) {
+ return locale;
+ }
+ }
+ throw new AstroError(i18nNoLocaleFoundInPath);
+}
+
+/**
+ *
+ * Given a locale, this function:
+ * - replaces the `_` with a `-`;
+ * - transforms all letters to be lower case;
+ */
+export function normalizeTheLocale(locale: string): string {
+ return locale.replaceAll('_', '-').toLowerCase();
+}
+
+/**
+ * Returns an array of only locales, by picking the `code`
+ * @param locales
+ */
+export function toCodes(locales: Locales): string[] {
+ return locales.map((loopLocale) => {
+ if (typeof loopLocale === 'string') {
+ return loopLocale;
+ } else {
+ return loopLocale.codes[0];
+ }
+ });
+}
+
+/**
+ * It returns the array of paths
+ * @param locales
+ */
+export function toPaths(locales: Locales): string[] {
+ return locales.map((loopLocale) => {
+ if (typeof loopLocale === 'string') {
+ return loopLocale;
+ } else {
+ return loopLocale.path;
+ }
+ });
+}
+
+function peekCodePathToUse(locales: Locales, locale: string): undefined | string {
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (loopLocale === locale) {
+ return loopLocale;
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === locale) {
+ return loopLocale.path;
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
+
+export type MiddlewarePayload = {
+ base: string;
+ locales: Locales;
+ trailingSlash: AstroConfig['trailingSlash'];
+ format: AstroConfig['build']['format'];
+ strategy: RoutingStrategies;
+ defaultLocale: string;
+ domains: Record<string, string> | undefined;
+ fallback: Record<string, string> | undefined;
+ fallbackType: 'redirect' | 'rewrite';
+};
+
+// NOTE: public function exported to the users via `astro:i18n` module
+export function redirectToDefaultLocale({
+ trailingSlash,
+ format,
+ base,
+ defaultLocale,
+}: MiddlewarePayload) {
+ return function (context: APIContext, statusCode?: ValidRedirectStatus) {
+ if (shouldAppendForwardSlash(trailingSlash, format)) {
+ return context.redirect(`${appendForwardSlash(joinPaths(base, defaultLocale))}`, statusCode);
+ } else {
+ return context.redirect(`${joinPaths(base, defaultLocale)}`, statusCode);
+ }
+ };
+}
+
+// NOTE: public function exported to the users via `astro:i18n` module
+export function notFound({ base, locales, fallback }: MiddlewarePayload) {
+ return function (context: APIContext, response?: Response): Response | undefined {
+ if (
+ response?.headers.get(REROUTE_DIRECTIVE_HEADER) === 'no' &&
+ typeof fallback === 'undefined'
+ ) {
+ return response;
+ }
+
+ const url = context.url;
+ // We return a 404 if:
+ // - the current path isn't a root. e.g. / or /<base>
+ // - the URL doesn't contain a locale
+ const isRoot = url.pathname === base + '/' || url.pathname === base;
+ if (!(isRoot || pathHasLocale(url.pathname, locales))) {
+ if (response) {
+ response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
+ return new Response(response.body, {
+ status: 404,
+ headers: response.headers,
+ });
+ } else {
+ return new Response(null, {
+ status: 404,
+ headers: {
+ [REROUTE_DIRECTIVE_HEADER]: 'no',
+ },
+ });
+ }
+ }
+
+ return undefined;
+ };
+}
+
+// NOTE: public function exported to the users via `astro:i18n` module
+export type RedirectToFallback = (context: APIContext, response: Response) => Promise<Response>;
+
+export function redirectToFallback({
+ fallback,
+ locales,
+ defaultLocale,
+ strategy,
+ base,
+ fallbackType,
+}: MiddlewarePayload) {
+ return async function (context: APIContext, response: Response): Promise<Response> {
+ if (response.status >= 300 && fallback) {
+ const fallbackKeys = fallback ? Object.keys(fallback) : [];
+ // we split the URL using the `/`, and then check in the returned array we have the locale
+ const segments = context.url.pathname.split('/');
+ const urlLocale = segments.find((segment) => {
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (locale === segment) {
+ return true;
+ }
+ } else if (locale.path === segment) {
+ return true;
+ }
+ }
+ return false;
+ });
+
+ if (urlLocale && fallbackKeys.includes(urlLocale)) {
+ const fallbackLocale = fallback[urlLocale];
+ // the user might have configured the locale using the granular locales, so we want to retrieve its corresponding path instead
+ const pathFallbackLocale = getPathByLocale(fallbackLocale, locales);
+ let newPathname: string;
+ // If a locale falls back to the default locale, we want to **remove** the locale because
+ // the default locale doesn't have a prefix
+ if (pathFallbackLocale === defaultLocale && strategy === 'pathname-prefix-other-locales') {
+ if (context.url.pathname.includes(`${base}`)) {
+ newPathname = context.url.pathname.replace(`/${urlLocale}`, ``);
+ } else {
+ newPathname = context.url.pathname.replace(`/${urlLocale}`, `/`);
+ }
+ } else {
+ newPathname = context.url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`);
+ }
+
+ if (fallbackType === 'rewrite') {
+ return await context.rewrite(newPathname + context.url.search);
+ } else {
+ return context.redirect(newPathname + context.url.search);
+ }
+ }
+ }
+ return response;
+ };
+}
+
+// NOTE: public function exported to the users via `astro:i18n` module
+export function createMiddleware(
+ i18nManifest: SSRManifest['i18n'],
+ base: SSRManifest['base'],
+ trailingSlash: SSRManifest['trailingSlash'],
+ format: SSRManifest['buildFormat'],
+) {
+ return createI18nMiddleware(i18nManifest, base, trailingSlash, format);
+}
diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts
new file mode 100644
index 000000000..6b8407498
--- /dev/null
+++ b/packages/astro/src/i18n/middleware.ts
@@ -0,0 +1,166 @@
+import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
+import { REROUTE_DIRECTIVE_HEADER, ROUTE_TYPE_HEADER } from '../core/constants.js';
+import { isRequestServerIsland, requestIs404Or500 } from '../core/routing/match.js';
+import type { MiddlewareHandler } from '../types/public/common.js';
+import type { APIContext } from '../types/public/context.js';
+import {
+ type MiddlewarePayload,
+ normalizeTheLocale,
+ notFound,
+ redirectToDefaultLocale,
+ redirectToFallback,
+ requestHasLocale,
+} from './index.js';
+
+export function createI18nMiddleware(
+ i18n: SSRManifest['i18n'],
+ base: SSRManifest['base'],
+ trailingSlash: SSRManifest['trailingSlash'],
+ format: SSRManifest['buildFormat'],
+): MiddlewareHandler {
+ if (!i18n) return (_, next) => next();
+ const payload: MiddlewarePayload = {
+ ...i18n,
+ trailingSlash,
+ base,
+ format,
+ domains: {},
+ };
+ const _redirectToDefaultLocale = redirectToDefaultLocale(payload);
+ const _noFoundForNonLocaleRoute = notFound(payload);
+ const _requestHasLocale = requestHasLocale(payload.locales);
+ const _redirectToFallback = redirectToFallback(payload);
+
+ const prefixAlways = (context: APIContext, response: Response): Response | undefined => {
+ const url = context.url;
+ if (url.pathname === base + '/' || url.pathname === base) {
+ return _redirectToDefaultLocale(context);
+ }
+
+ // Astro can't know where the default locale is supposed to be, so it returns a 404.
+ else if (!_requestHasLocale(context)) {
+ return _noFoundForNonLocaleRoute(context, response);
+ }
+
+ return undefined;
+ };
+
+ const prefixOtherLocales = (context: APIContext, response: Response): Response | undefined => {
+ let pathnameContainsDefaultLocale = false;
+ const url = context.url;
+ for (const segment of url.pathname.split('/')) {
+ if (normalizeTheLocale(segment) === normalizeTheLocale(i18n.defaultLocale)) {
+ pathnameContainsDefaultLocale = true;
+ break;
+ }
+ }
+ if (pathnameContainsDefaultLocale) {
+ const newLocation = url.pathname.replace(`/${i18n.defaultLocale}`, '');
+ response.headers.set('Location', newLocation);
+ return _noFoundForNonLocaleRoute(context);
+ }
+
+ return undefined;
+ };
+
+ return async (context, next) => {
+ const response = await next();
+ const type = response.headers.get(ROUTE_TYPE_HEADER);
+
+ // This is case where we are internally rendering a 404/500, so we need to bypass checks that were done already
+ const isReroute = response.headers.get(REROUTE_DIRECTIVE_HEADER);
+ if (isReroute === 'no' && typeof i18n.fallback === 'undefined') {
+ return response;
+ }
+ // If the route we're processing is not a page, then we ignore it
+ if (type !== 'page' && type !== 'fallback') {
+ return response;
+ }
+
+ // 404 and 500 are **known** routes (users can have their custom pages), so we need to let them be
+ if (requestIs404Or500(context.request, base)) {
+ return response;
+ }
+
+ // This is a case where the rendering phase belongs to a server island. Server island are
+ // special routes, and should be exhempt from i18n routing
+ if (isRequestServerIsland(context.request, base)) {
+ return response;
+ }
+
+ const { currentLocale } = context;
+ switch (i18n.strategy) {
+ // NOTE: theoretically, we should never hit this code path
+ case 'manual': {
+ return response;
+ }
+ case 'domains-prefix-other-locales': {
+ if (localeHasntDomain(i18n, currentLocale)) {
+ const result = prefixOtherLocales(context, response);
+ if (result) {
+ return result;
+ }
+ }
+ break;
+ }
+ case 'pathname-prefix-other-locales': {
+ const result = prefixOtherLocales(context, response);
+ if (result) {
+ return result;
+ }
+ break;
+ }
+
+ case 'domains-prefix-always-no-redirect': {
+ if (localeHasntDomain(i18n, currentLocale)) {
+ const result = _noFoundForNonLocaleRoute(context, response);
+ if (result) {
+ return result;
+ }
+ }
+ break;
+ }
+
+ case 'pathname-prefix-always-no-redirect': {
+ const result = _noFoundForNonLocaleRoute(context, response);
+ if (result) {
+ return result;
+ }
+ break;
+ }
+
+ case 'pathname-prefix-always': {
+ const result = prefixAlways(context, response);
+ if (result) {
+ return result;
+ }
+ break;
+ }
+ case 'domains-prefix-always': {
+ if (localeHasntDomain(i18n, currentLocale)) {
+ const result = prefixAlways(context, response);
+ if (result) {
+ return result;
+ }
+ }
+ break;
+ }
+ }
+
+ return _redirectToFallback(context, response);
+ };
+}
+
+/**
+ * Checks if the current locale doesn't belong to a configured domain
+ * @param i18n
+ * @param currentLocale
+ */
+function localeHasntDomain(i18n: SSRManifestI18n, currentLocale: string | undefined) {
+ for (const domainLocale of Object.values(i18n.domainLookupTable)) {
+ if (domainLocale === currentLocale) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/packages/astro/src/i18n/utils.ts b/packages/astro/src/i18n/utils.ts
new file mode 100644
index 000000000..18a6f8dca
--- /dev/null
+++ b/packages/astro/src/i18n/utils.ts
@@ -0,0 +1,272 @@
+import type { SSRManifest } from '../core/app/types.js';
+import type { AstroConfig, Locales } from '../types/public/config.js';
+import { normalizeTheLocale, toCodes } from './index.js';
+
+type BrowserLocale = {
+ locale: string;
+ qualityValue: number | undefined;
+};
+
+/**
+ * Parses the value of the `Accept-Language` header:
+ *
+ * More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
+ *
+ * Complex example: `fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5`
+ *
+ */
+export function parseLocale(header: string): BrowserLocale[] {
+ // Any language, early return
+ if (header === '*') {
+ return [{ locale: header, qualityValue: undefined }];
+ }
+ const result: BrowserLocale[] = [];
+ // we split by `,` and trim the white spaces
+ const localeValues = header.split(',').map((str) => str.trim());
+
+ for (const localeValue of localeValues) {
+ // split the locale name from the quality value
+ const split = localeValue.split(';').map((str) => str.trim());
+ const localeName: string = split[0];
+ const qualityValue: string | undefined = split[1];
+
+ if (!split) {
+ // invalid value
+ continue;
+ }
+
+ // we check if the quality value is present, and it is actually `q=`
+ if (qualityValue && qualityValue.startsWith('q=')) {
+ const qualityValueAsFloat = Number.parseFloat(qualityValue.slice('q='.length));
+ // The previous operation can return a `NaN`, so we check if it is a safe operation
+ if (Number.isNaN(qualityValueAsFloat) || qualityValueAsFloat > 1) {
+ result.push({
+ locale: localeName,
+ qualityValue: undefined,
+ });
+ } else {
+ result.push({
+ locale: localeName,
+ qualityValue: qualityValueAsFloat,
+ });
+ }
+ } else {
+ result.push({
+ locale: localeName,
+ qualityValue: undefined,
+ });
+ }
+ }
+
+ return result;
+}
+
+function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: Locales) {
+ const normalizedLocales = toCodes(locales).map(normalizeTheLocale);
+ return browserLocaleList
+ .filter((browserLocale) => {
+ if (browserLocale.locale !== '*') {
+ return normalizedLocales.includes(normalizeTheLocale(browserLocale.locale));
+ }
+ return true;
+ })
+ .sort((a, b) => {
+ if (a.qualityValue && b.qualityValue) {
+ return Math.sign(b.qualityValue - a.qualityValue);
+ }
+ return 0;
+ });
+}
+
+/**
+ * Set the current locale by parsing the value passed from the `Accept-Header`.
+ *
+ * If multiple locales are present in the header, they are sorted by their quality value and the highest is selected as current locale.
+ *
+ */
+export function computePreferredLocale(request: Request, locales: Locales): string | undefined {
+ const acceptHeader = request.headers.get('Accept-Language');
+ let result: string | undefined = undefined;
+ if (acceptHeader) {
+ const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);
+
+ const firstResult = browserLocaleList.at(0);
+ if (firstResult && firstResult.locale !== '*') {
+ for (const currentLocale of locales) {
+ if (typeof currentLocale === 'string') {
+ if (normalizeTheLocale(currentLocale) === normalizeTheLocale(firstResult.locale)) {
+ result = currentLocale;
+ }
+ } else {
+ for (const currentCode of currentLocale.codes) {
+ if (normalizeTheLocale(currentCode) === normalizeTheLocale(firstResult.locale)) {
+ result = currentLocale.path;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+export function computePreferredLocaleList(request: Request, locales: Locales): string[] {
+ const acceptHeader = request.headers.get('Accept-Language');
+ let result: string[] = [];
+ if (acceptHeader) {
+ const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);
+
+ // SAFETY: bang operator is safe because checked by the previous condition
+ if (browserLocaleList.length === 1 && browserLocaleList.at(0)!.locale === '*') {
+ return locales.map((locale) => {
+ if (typeof locale === 'string') {
+ return locale;
+ } else {
+ // SAFETY: codes is never empty
+ return locale.codes.at(0)!;
+ }
+ });
+ } else if (browserLocaleList.length > 0) {
+ for (const browserLocale of browserLocaleList) {
+ for (const loopLocale of locales) {
+ if (typeof loopLocale === 'string') {
+ if (normalizeTheLocale(loopLocale) === normalizeTheLocale(browserLocale.locale)) {
+ result.push(loopLocale);
+ }
+ } else {
+ for (const code of loopLocale.codes) {
+ if (code === browserLocale.locale) {
+ result.push(loopLocale.path);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+export function computeCurrentLocale(
+ pathname: string,
+ locales: Locales,
+ defaultLocale: string,
+): string | undefined {
+ for (const segment of pathname.split('/')) {
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ // we skip ta locale that isn't present in the current segment
+ if (!segment.includes(locale)) continue;
+ if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
+ return locale;
+ }
+ } else {
+ if (locale.path === segment) {
+ return locale.codes.at(0);
+ } else {
+ for (const code of locale.codes) {
+ if (normalizeTheLocale(code) === normalizeTheLocale(segment)) {
+ return code;
+ }
+ }
+ }
+ }
+ }
+ }
+ // If we didn't exit, it's probably because we don't have any code/locale in the URL.
+ // We use the default locale.
+ for (const locale of locales) {
+ if (typeof locale === 'string') {
+ if (locale === defaultLocale) {
+ return locale;
+ }
+ } else {
+ if (locale.path === defaultLocale) {
+ return locale.codes.at(0);
+ }
+ }
+ }
+}
+
+export type RoutingStrategies =
+ | 'manual'
+ | 'pathname-prefix-always'
+ | 'pathname-prefix-other-locales'
+ | 'pathname-prefix-always-no-redirect'
+ | 'domains-prefix-always'
+ | 'domains-prefix-other-locales'
+ | 'domains-prefix-always-no-redirect';
+export function toRoutingStrategy(
+ routing: NonNullable<AstroConfig['i18n']>['routing'],
+ domains: NonNullable<AstroConfig['i18n']>['domains'],
+) {
+ let strategy: RoutingStrategies;
+ const hasDomains = domains ? Object.keys(domains).length > 0 : false;
+ if (routing === 'manual') {
+ strategy = 'manual';
+ } else {
+ if (!hasDomains) {
+ if (routing?.prefixDefaultLocale === true) {
+ if (routing.redirectToDefaultLocale) {
+ strategy = 'pathname-prefix-always';
+ } else {
+ strategy = 'pathname-prefix-always-no-redirect';
+ }
+ } else {
+ strategy = 'pathname-prefix-other-locales';
+ }
+ } else {
+ if (routing?.prefixDefaultLocale === true) {
+ if (routing.redirectToDefaultLocale) {
+ strategy = 'domains-prefix-always';
+ } else {
+ strategy = 'domains-prefix-always-no-redirect';
+ }
+ } else {
+ strategy = 'domains-prefix-other-locales';
+ }
+ }
+ }
+
+ return strategy;
+}
+
+const PREFIX_DEFAULT_LOCALE = new Set([
+ 'pathname-prefix-always',
+ 'domains-prefix-always',
+ 'pathname-prefix-always-no-redirect',
+ 'domains-prefix-always-no-redirect',
+]);
+
+const REDIRECT_TO_DEFAULT_LOCALE = new Set([
+ 'pathname-prefix-always-no-redirect',
+ 'domains-prefix-always-no-redirect',
+]);
+
+export function fromRoutingStrategy(
+ strategy: RoutingStrategies,
+ fallbackType: NonNullable<SSRManifest['i18n']>['fallbackType'],
+): NonNullable<AstroConfig['i18n']>['routing'] {
+ let routing: NonNullable<AstroConfig['i18n']>['routing'];
+ if (strategy === 'manual') {
+ routing = 'manual';
+ } else {
+ routing = {
+ prefixDefaultLocale: PREFIX_DEFAULT_LOCALE.has(strategy),
+ redirectToDefaultLocale: !REDIRECT_TO_DEFAULT_LOCALE.has(strategy),
+ fallbackType,
+ };
+ }
+ return routing;
+}
+
+export function toFallbackType(
+ routing: NonNullable<AstroConfig['i18n']>['routing'],
+): 'redirect' | 'rewrite' {
+ if (routing === 'manual') {
+ return 'rewrite';
+ }
+ return routing.fallbackType;
+}
diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts
new file mode 100644
index 000000000..11a1177c6
--- /dev/null
+++ b/packages/astro/src/i18n/vite-plugin-i18n.ts
@@ -0,0 +1,55 @@
+import type * as vite from 'vite';
+import { AstroError } from '../core/errors/errors.js';
+import { AstroErrorData } from '../core/errors/index.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+
+const virtualModuleId = 'astro:i18n';
+
+type AstroInternationalization = {
+ settings: AstroSettings;
+};
+
+export interface I18nInternalConfig
+ extends Pick<AstroConfig, 'base' | 'site' | 'trailingSlash'>,
+ Pick<AstroConfig['build'], 'format'> {
+ i18n: AstroConfig['i18n'];
+ isBuild: boolean;
+}
+
+export default function astroInternationalization({
+ settings,
+}: AstroInternationalization): vite.Plugin {
+ const {
+ base,
+ build: { format },
+ i18n,
+ site,
+ trailingSlash,
+ } = settings.config;
+ return {
+ name: 'astro:i18n',
+ enforce: 'pre',
+ config(_config, { command }) {
+ const i18nConfig: I18nInternalConfig = {
+ base,
+ format,
+ site,
+ trailingSlash,
+ i18n,
+ isBuild: command === 'build',
+ };
+ return {
+ define: {
+ __ASTRO_INTERNAL_I18N_CONFIG__: JSON.stringify(i18nConfig),
+ },
+ };
+ },
+ resolveId(id) {
+ if (id === virtualModuleId) {
+ if (i18n === undefined) throw new AstroError(AstroErrorData.i18nNotEnabled);
+ return this.resolve('astro/virtual-modules/i18n.js');
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/integrations/features-validation.ts b/packages/astro/src/integrations/features-validation.ts
new file mode 100644
index 000000000..9383c76b2
--- /dev/null
+++ b/packages/astro/src/integrations/features-validation.ts
@@ -0,0 +1,182 @@
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+import type {
+ AdapterSupport,
+ AdapterSupportsKind,
+ AstroAdapterFeatureMap,
+} from '../types/public/integrations.js';
+
+export const AdapterFeatureStability = {
+ STABLE: 'stable',
+ DEPRECATED: 'deprecated',
+ UNSUPPORTED: 'unsupported',
+ EXPERIMENTAL: 'experimental',
+ LIMITED: 'limited',
+} as const;
+
+type ValidationResult = {
+ [Property in keyof AstroAdapterFeatureMap]: boolean;
+};
+
+/**
+ * Checks whether an adapter supports certain features that are enabled via Astro configuration.
+ *
+ * If a configuration is enabled and "unlocks" a feature, but the adapter doesn't support, the function
+ * will throw a runtime error.
+ *
+ */
+export function validateSupportedFeatures(
+ adapterName: string,
+ featureMap: AstroAdapterFeatureMap,
+ settings: AstroSettings,
+ logger: Logger,
+): ValidationResult {
+ const {
+ serverOutput = AdapterFeatureStability.UNSUPPORTED,
+ staticOutput = AdapterFeatureStability.UNSUPPORTED,
+ hybridOutput = AdapterFeatureStability.UNSUPPORTED,
+ i18nDomains = AdapterFeatureStability.UNSUPPORTED,
+ envGetSecret = AdapterFeatureStability.UNSUPPORTED,
+ sharpImageService = AdapterFeatureStability.UNSUPPORTED,
+ } = featureMap;
+ const validationResult: ValidationResult = {};
+
+ validationResult.staticOutput = validateSupportKind(
+ staticOutput,
+ adapterName,
+ logger,
+ 'staticOutput',
+ () => settings.buildOutput === 'static',
+ );
+
+ validationResult.hybridOutput = validateSupportKind(
+ hybridOutput,
+ adapterName,
+ logger,
+ 'hybridOutput',
+ () => settings.config.output == 'static' && settings.buildOutput === 'server',
+ );
+
+ validationResult.serverOutput = validateSupportKind(
+ serverOutput,
+ adapterName,
+ logger,
+ 'serverOutput',
+ () => settings.config?.output === 'server' || settings.buildOutput === 'server',
+ );
+
+ if (settings.config.i18n?.domains) {
+ validationResult.i18nDomains = validateSupportKind(
+ i18nDomains,
+ adapterName,
+ logger,
+ 'i18nDomains',
+ () => {
+ return settings.config?.output === 'server' && !settings.config?.site;
+ },
+ );
+ }
+
+ validationResult.envGetSecret = validateSupportKind(
+ envGetSecret,
+ adapterName,
+ logger,
+ 'astro:env getSecret',
+ () => Object.keys(settings.config?.env?.schema ?? {}).length !== 0,
+ );
+
+ validationResult.sharpImageService = validateSupportKind(
+ sharpImageService,
+ adapterName,
+ logger,
+ 'sharp',
+ () => settings.config?.image?.service?.entrypoint === 'astro/assets/services/sharp',
+ );
+
+ return validationResult;
+}
+
+export function unwrapSupportKind(supportKind?: AdapterSupport): AdapterSupportsKind | undefined {
+ if (!supportKind) {
+ return undefined;
+ }
+
+ return typeof supportKind === 'object' ? supportKind.support : supportKind;
+}
+
+export function getSupportMessage(supportKind: AdapterSupport): string | undefined {
+ return typeof supportKind === 'object' ? supportKind.message : undefined;
+}
+
+function validateSupportKind(
+ supportKind: AdapterSupport,
+ adapterName: string,
+ logger: Logger,
+ featureName: string,
+ hasCorrectConfig: () => boolean,
+): boolean {
+ const supportValue = unwrapSupportKind(supportKind);
+ const message = getSupportMessage(supportKind);
+
+ if (!supportValue) {
+ return false;
+ }
+
+ if (supportValue === AdapterFeatureStability.STABLE) {
+ return true;
+ } else if (hasCorrectConfig()) {
+ // If the user has the relevant configuration, but the adapter doesn't support it, warn the user
+ logFeatureSupport(adapterName, logger, featureName, supportValue, message);
+ }
+
+ return false;
+}
+
+function logFeatureSupport(
+ adapterName: string,
+ logger: Logger,
+ featureName: string,
+ supportKind: AdapterSupport,
+ adapterMessage?: string,
+) {
+ switch (supportKind) {
+ case AdapterFeatureStability.STABLE:
+ break;
+ case AdapterFeatureStability.DEPRECATED:
+ logger.warn(
+ 'config',
+ `The adapter ${adapterName} has deprecated its support for "${featureName}", and future compatibility is not guaranteed. The adapter may completely remove support for this feature without warning.`,
+ );
+ break;
+ case AdapterFeatureStability.EXPERIMENTAL:
+ logger.warn(
+ 'config',
+ `The adapter ${adapterName} provides experimental support for "${featureName}". You may experience issues or breaking changes until this feature is fully supported by the adapter.`,
+ );
+ break;
+ case AdapterFeatureStability.LIMITED:
+ logger.warn(
+ 'config',
+ `The adapter ${adapterName} has limited support for "${featureName}". Certain features may not work as expected.`,
+ );
+ break;
+ case AdapterFeatureStability.UNSUPPORTED:
+ logger.error(
+ 'config',
+ `The adapter ${adapterName} does not currently support the feature "${featureName}". Your project may not build correctly.`,
+ );
+ break;
+ }
+
+ // If the adapter specified a custom message, log it after the default message
+ if (adapterMessage) {
+ logger.warn('adapter', adapterMessage);
+ }
+}
+
+export function getAdapterStaticRecommendation(adapterName: string): string | undefined {
+ return {
+ '@astrojs/vercel/static':
+ 'Update your configuration to use `@astrojs/vercel/serverless` to unlock server-side rendering capabilities.',
+ }[adapterName];
+}
diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts
new file mode 100644
index 000000000..5a4b723b2
--- /dev/null
+++ b/packages/astro/src/integrations/hooks.ts
@@ -0,0 +1,712 @@
+import fsMod from 'node:fs';
+import type { AddressInfo } from 'node:net';
+import { fileURLToPath } from 'node:url';
+import { bold } from 'kleur/colors';
+import type { InlineConfig, ViteDevServer } from 'vite';
+import astroIntegrationActionsRouteHandler from '../actions/integration.js';
+import { isActionsFilePresent } from '../actions/utils.js';
+import { CONTENT_LAYER_TYPE } from '../content/consts.js';
+import { globalContentLayer } from '../content/content-layer.js';
+import { globalContentConfigObserver } from '../content/utils.js';
+import type { SerializedSSRManifest } from '../core/app/types.js';
+import type { PageBuildData } from '../core/build/types.js';
+import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
+import { mergeConfig } from '../core/config/index.js';
+import { validateSetAdapter } from '../core/dev/adapter-validation.js';
+import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+import type {
+ ContentEntryType,
+ DataEntryType,
+ RefreshContentOptions,
+} from '../types/public/content.js';
+import type {
+ AstroIntegration,
+ AstroRenderer,
+ BaseIntegrationHooks,
+ HookParameters,
+ IntegrationResolvedRoute,
+ IntegrationRouteData,
+ RouteOptions,
+} from '../types/public/integrations.js';
+import type { RouteData } from '../types/public/internal.js';
+import { validateSupportedFeatures } from './features-validation.js';
+
+async function withTakingALongTimeMsg<T>({
+ name,
+ hookName,
+ hookResult,
+ timeoutMs = 3000,
+ logger,
+}: {
+ name: string;
+ hookName: keyof BaseIntegrationHooks;
+ hookResult: T | Promise<T>;
+ timeoutMs?: number;
+ logger: Logger;
+}): Promise<T> {
+ const timeout = setTimeout(() => {
+ logger.info(
+ 'build',
+ `Waiting for integration ${bold(JSON.stringify(name))}, hook ${bold(
+ JSON.stringify(hookName),
+ )}...`,
+ );
+ }, timeoutMs);
+ const result = await hookResult;
+ clearTimeout(timeout);
+ return result;
+}
+
+// Used internally to store instances of loggers.
+const Loggers = new WeakMap<AstroIntegration, AstroIntegrationLogger>();
+
+function getLogger(integration: AstroIntegration, logger: Logger) {
+ if (Loggers.has(integration)) {
+ // SAFETY: we check the existence in the if block
+ return Loggers.get(integration)!;
+ }
+ const integrationLogger = logger.forkIntegrationLogger(integration.name);
+ Loggers.set(integration, integrationLogger);
+ return integrationLogger;
+}
+
+const serverEventPrefix = 'astro-dev-toolbar';
+
+export function getToolbarServerCommunicationHelpers(server: ViteDevServer) {
+ return {
+ /**
+ * Send a message to the dev toolbar that an app can listen for. The payload can be any serializable data.
+ * @param event - The event name
+ * @param payload - The payload to send
+ */
+ send: <T>(event: string, payload: T) => {
+ server.hot.send(event, payload);
+ },
+ /**
+ * Receive a message from a dev toolbar app.
+ * @param event
+ * @param callback
+ */
+ on: <T>(event: string, callback: (data: T) => void) => {
+ server.hot.on(event, callback);
+ },
+ /**
+ * Fired when an app is initialized.
+ * @param appId - The id of the app that was initialized
+ * @param callback - The callback to run when the app is initialized
+ */
+ onAppInitialized: (appId: string, callback: (data: Record<string, never>) => void) => {
+ server.hot.on(`${serverEventPrefix}:${appId}:initialized`, callback);
+ },
+ /**
+ * Fired when an app is toggled on or off.
+ * @param appId - The id of the app that was toggled
+ * @param callback - The callback to run when the app is toggled
+ */
+ onAppToggled: (appId: string, callback: (data: { state: boolean }) => void) => {
+ server.hot.on(`${serverEventPrefix}:${appId}:toggled`, callback);
+ },
+ };
+}
+
+// Will match any invalid characters (will be converted to _). We only allow a-zA-Z0-9.-_
+const SAFE_CHARS_RE = /[^\w.-]/g;
+
+export function normalizeCodegenDir(integrationName: string): string {
+ return `./integrations/${integrationName.replace(SAFE_CHARS_RE, '_')}/`;
+}
+
+export function normalizeInjectedTypeFilename(filename: string, integrationName: string): string {
+ if (!filename.endsWith('.d.ts')) {
+ throw new Error(
+ `Integration ${bold(integrationName)} is injecting a type that does not end with "${bold('.d.ts')}"`,
+ );
+ }
+ return `${normalizeCodegenDir(integrationName)}${filename.replace(SAFE_CHARS_RE, '_')}`;
+}
+
+export async function runHookConfigSetup({
+ settings,
+ command,
+ logger,
+ isRestart = false,
+ fs = fsMod,
+}: {
+ settings: AstroSettings;
+ command: 'dev' | 'build' | 'preview' | 'sync';
+ logger: Logger;
+ isRestart?: boolean;
+ fs?: typeof fsMod;
+}): Promise<AstroSettings> {
+ // An adapter is an integration, so if one is provided add it to the list of integrations.
+ if (settings.config.adapter) {
+ settings.config.integrations.unshift(settings.config.adapter);
+ }
+ if (await isActionsFilePresent(fs, settings.config.srcDir)) {
+ settings.config.integrations.push(astroIntegrationActionsRouteHandler({ settings }));
+ }
+
+ let updatedConfig: AstroConfig = { ...settings.config };
+ let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
+ let addedClientDirectives = new Map<string, Promise<string>>();
+ let astroJSXRenderer: AstroRenderer | null = null;
+
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of -- We need a for loop to be able to read integrations pushed while the loop is running.
+ for (let i = 0; i < updatedConfig.integrations.length; i++) {
+ const integration = updatedConfig.integrations[i];
+
+ /**
+ * By making integration hooks optional, Astro can now ignore null or undefined Integrations
+ * instead of giving an internal error most people can't read
+ *
+ * This also enables optional integrations, e.g.
+ * ```ts
+ * integration: [
+ * // Only run `compress` integration in production environments, etc...
+ * import.meta.env.production ? compress() : null
+ * ]
+ * ```
+ */
+ if (integration.hooks?.['astro:config:setup']) {
+ const integrationLogger = getLogger(integration, logger);
+
+ const hooks: HookParameters<'astro:config:setup'> = {
+ config: updatedConfig,
+ command,
+ isRestart,
+ addRenderer(renderer: AstroRenderer) {
+ if (!renderer.name) {
+ throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`);
+ }
+
+ if (!renderer.serverEntrypoint) {
+ throw new Error(`Renderer ${bold(renderer.name)} does not provide a serverEntrypoint.`);
+ }
+
+ if (renderer.name === 'astro:jsx') {
+ astroJSXRenderer = renderer;
+ } else {
+ updatedSettings.renderers.push(renderer);
+ }
+ },
+ injectScript: (stage, content) => {
+ updatedSettings.scripts.push({ stage, content });
+ },
+ updateConfig: (newConfig) => {
+ updatedConfig = mergeConfig(updatedConfig, newConfig) as AstroConfig;
+ return { ...updatedConfig };
+ },
+ injectRoute: (injectRoute) => {
+ if (injectRoute.entrypoint == null && 'entryPoint' in injectRoute) {
+ logger.warn(
+ null,
+ `The injected route "${injectRoute.pattern}" by ${integration.name} specifies the entry point with the "entryPoint" property. This property is deprecated, please use "entrypoint" instead.`,
+ );
+ injectRoute.entrypoint = injectRoute.entryPoint as string;
+ }
+ updatedSettings.injectedRoutes.push({ ...injectRoute, origin: 'external' });
+ },
+ addWatchFile: (path) => {
+ updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
+ },
+ addDevToolbarApp: (entrypoint) => {
+ updatedSettings.devToolbarApps.push(entrypoint);
+ },
+ addClientDirective: ({ name, entrypoint }) => {
+ if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
+ throw new Error(
+ `The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.`,
+ );
+ }
+ // TODO: this should be performed after astro:config:done
+ addedClientDirectives.set(
+ name,
+ buildClientDirectiveEntrypoint(name, entrypoint, settings.config.root),
+ );
+ },
+ addMiddleware: ({ order, entrypoint }) => {
+ if (typeof updatedSettings.middlewares[order] === 'undefined') {
+ throw new Error(
+ `The "${integration.name}" integration is trying to add middleware but did not specify an order.`,
+ );
+ }
+ logger.debug(
+ 'middleware',
+ `The integration ${integration.name} has added middleware that runs ${
+ order === 'pre' ? 'before' : 'after'
+ } any application middleware you define.`,
+ );
+ updatedSettings.middlewares[order].push(
+ typeof entrypoint === 'string' ? entrypoint : fileURLToPath(entrypoint),
+ );
+ },
+ createCodegenDir: () => {
+ const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir);
+ fs.mkdirSync(codegenDir, { recursive: true });
+ return codegenDir;
+ },
+ logger: integrationLogger,
+ };
+
+ // ---
+ // Public, intentionally undocumented hooks - not subject to semver.
+ // Intended for internal integrations (ex. `@astrojs/mdx`),
+ // though accessible to integration authors if discovered.
+
+ function addPageExtension(...input: (string | string[])[]) {
+ const exts = (input.flat(Infinity) as string[]).map((ext) => `.${ext.replace(/^\./, '')}`);
+ updatedSettings.pageExtensions.push(...exts);
+ }
+
+ function addContentEntryType(contentEntryType: ContentEntryType) {
+ updatedSettings.contentEntryTypes.push(contentEntryType);
+ }
+
+ function addDataEntryType(dataEntryType: DataEntryType) {
+ updatedSettings.dataEntryTypes.push(dataEntryType);
+ }
+
+ Object.defineProperty(hooks, 'addPageExtension', {
+ value: addPageExtension,
+ writable: false,
+ enumerable: false,
+ });
+ Object.defineProperty(hooks, 'addContentEntryType', {
+ value: addContentEntryType,
+ writable: false,
+ enumerable: false,
+ });
+ Object.defineProperty(hooks, 'addDataEntryType', {
+ value: addDataEntryType,
+ writable: false,
+ enumerable: false,
+ });
+ // ---
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:config:setup',
+ hookResult: integration.hooks['astro:config:setup'](hooks),
+ logger,
+ });
+
+ // Add custom client directives to settings, waiting for compiled code by esbuild
+ for (const [name, compiled] of addedClientDirectives) {
+ updatedSettings.clientDirectives.set(name, await compiled);
+ }
+ }
+ }
+
+ // The astro:jsx renderer should come last, to not interfere with others.
+ if (astroJSXRenderer) {
+ updatedSettings.renderers.push(astroJSXRenderer);
+ }
+
+ updatedSettings.config = updatedConfig;
+ return updatedSettings;
+}
+
+export async function runHookConfigDone({
+ settings,
+ logger,
+ command,
+}: {
+ settings: AstroSettings;
+ logger: Logger;
+ command?: 'dev' | 'build' | 'preview' | 'sync';
+}) {
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:config:done']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:config:done',
+ hookResult: integration.hooks['astro:config:done']({
+ config: settings.config,
+ setAdapter(adapter) {
+ validateSetAdapter(logger, settings, adapter, integration.name, command);
+
+ if (adapter.adapterFeatures?.buildOutput !== 'static') {
+ settings.buildOutput = 'server';
+ }
+
+ if (!adapter.supportedAstroFeatures) {
+ throw new Error(
+ `The adapter ${adapter.name} doesn't provide a feature map. It is required in Astro 4.0.`,
+ );
+ } else {
+ validateSupportedFeatures(
+ adapter.name,
+ adapter.supportedAstroFeatures,
+ settings,
+ logger,
+ );
+ }
+ settings.adapter = adapter;
+ },
+ injectTypes(injectedType) {
+ const normalizedFilename = normalizeInjectedTypeFilename(
+ injectedType.filename,
+ integration.name,
+ );
+
+ settings.injectedTypes.push({
+ filename: normalizedFilename,
+ content: injectedType.content,
+ });
+
+ // It must be relative to dotAstroDir here and not inside normalizeInjectedTypeFilename
+ // because injectedTypes are handled relatively to the dotAstroDir already
+ return new URL(normalizedFilename, settings.dotAstroDir);
+ },
+ logger: getLogger(integration, logger),
+ get buildOutput() {
+ return settings.buildOutput!; // settings.buildOutput is always set at this point
+ },
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+export async function runHookServerSetup({
+ config,
+ server,
+ logger,
+}: {
+ config: AstroConfig;
+ server: ViteDevServer;
+ logger: Logger;
+}) {
+ let refreshContent: undefined | ((options: RefreshContentOptions) => Promise<void>);
+ refreshContent = async (options: RefreshContentOptions) => {
+ const contentConfig = globalContentConfigObserver.get();
+ if (
+ contentConfig.status !== 'loaded' ||
+ !Object.values(contentConfig.config.collections).some(
+ (collection) => collection.type === CONTENT_LAYER_TYPE,
+ )
+ ) {
+ return;
+ }
+
+ const contentLayer = await globalContentLayer.get();
+ await contentLayer?.sync(options);
+ };
+
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:server:setup']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:server:setup',
+ hookResult: integration.hooks['astro:server:setup']({
+ server,
+ logger: getLogger(integration, logger),
+ toolbar: getToolbarServerCommunicationHelpers(server),
+ refreshContent,
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+export async function runHookServerStart({
+ config,
+ address,
+ logger,
+}: {
+ config: AstroConfig;
+ address: AddressInfo;
+ logger: Logger;
+}) {
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:server:start']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:server:start',
+ hookResult: integration.hooks['astro:server:start']({
+ address,
+ logger: getLogger(integration, logger),
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+export async function runHookServerDone({
+ config,
+ logger,
+}: {
+ config: AstroConfig;
+ logger: Logger;
+}) {
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:server:done']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:server:done',
+ hookResult: integration.hooks['astro:server:done']({
+ logger: getLogger(integration, logger),
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+export async function runHookBuildStart({
+ config,
+ logging,
+}: {
+ config: AstroConfig;
+ logging: Logger;
+}) {
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:build:start']) {
+ const logger = getLogger(integration, logging);
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:build:start',
+ hookResult: integration.hooks['astro:build:start']({ logger }),
+ logger: logging,
+ });
+ }
+ }
+}
+
+export async function runHookBuildSetup({
+ config,
+ vite,
+ pages,
+ target,
+ logger,
+}: {
+ config: AstroConfig;
+ vite: InlineConfig;
+ pages: Map<string, PageBuildData>;
+ target: 'server' | 'client';
+ logger: Logger;
+}): Promise<InlineConfig> {
+ let updatedConfig = vite;
+
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:build:setup']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:build:setup',
+ hookResult: integration.hooks['astro:build:setup']({
+ vite,
+ pages,
+ target,
+ updateConfig: (newConfig) => {
+ updatedConfig = mergeConfig(updatedConfig, newConfig);
+ return { ...updatedConfig };
+ },
+ logger: getLogger(integration, logger),
+ }),
+ logger,
+ });
+ }
+ }
+
+ return updatedConfig;
+}
+
+type RunHookBuildSsr = {
+ config: AstroConfig;
+ manifest: SerializedSSRManifest;
+ logger: Logger;
+ entryPoints: Map<RouteData, URL>;
+ middlewareEntryPoint: URL | undefined;
+};
+
+export async function runHookBuildSsr({
+ config,
+ manifest,
+ logger,
+ entryPoints,
+ middlewareEntryPoint,
+}: RunHookBuildSsr) {
+ const entryPointsMap = new Map();
+ for (const [key, value] of entryPoints) {
+ entryPointsMap.set(toIntegrationRouteData(key), value);
+ }
+ for (const integration of config.integrations) {
+ if (integration?.hooks?.['astro:build:ssr']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:build:ssr',
+ hookResult: integration.hooks['astro:build:ssr']({
+ manifest,
+ entryPoints: entryPointsMap,
+ middlewareEntryPoint,
+ logger: getLogger(integration, logger),
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+export async function runHookBuildGenerated({
+ settings,
+ logger,
+}: {
+ settings: AstroSettings;
+ logger: Logger;
+}) {
+ const dir =
+ settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
+
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:build:generated']) {
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:build:generated',
+ hookResult: integration.hooks['astro:build:generated']({
+ dir,
+ logger: getLogger(integration, logger),
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+type RunHookBuildDone = {
+ settings: AstroSettings;
+ pages: string[];
+ routes: RouteData[];
+ logging: Logger;
+};
+
+export async function runHookBuildDone({ settings, pages, routes, logging }: RunHookBuildDone) {
+ const dir =
+ settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
+ await fsMod.promises.mkdir(dir, { recursive: true });
+ const integrationRoutes = routes.map(toIntegrationRouteData);
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:build:done']) {
+ const logger = getLogger(integration, logging);
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:build:done',
+ hookResult: integration.hooks['astro:build:done']({
+ pages: pages.map((p) => ({ pathname: p })),
+ dir,
+ routes: integrationRoutes,
+ assets: new Map(
+ routes.filter((r) => r.distURL !== undefined).map((r) => [r.route, r.distURL!]),
+ ),
+ logger,
+ }),
+ logger: logging,
+ });
+ }
+ }
+}
+
+export async function runHookRouteSetup({
+ route,
+ settings,
+ logger,
+}: {
+ route: RouteOptions;
+ settings: AstroSettings;
+ logger: Logger;
+}) {
+ const prerenderChangeLogs: { integrationName: string; value: boolean | undefined }[] = [];
+
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:route:setup']) {
+ const originalRoute = { ...route };
+ const integrationLogger = getLogger(integration, logger);
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:route:setup',
+ hookResult: integration.hooks['astro:route:setup']({
+ route,
+ logger: integrationLogger,
+ }),
+ logger,
+ });
+
+ if (route.prerender !== originalRoute.prerender) {
+ prerenderChangeLogs.push({ integrationName: integration.name, value: route.prerender });
+ }
+ }
+ }
+
+ if (prerenderChangeLogs.length > 1) {
+ logger.debug(
+ 'router',
+ `The ${route.component} route's prerender option has been changed multiple times by integrations:\n` +
+ prerenderChangeLogs.map((log) => `- ${log.integrationName}: ${log.value}`).join('\n'),
+ );
+ }
+}
+
+export async function runHookRoutesResolved({
+ routes,
+ settings,
+ logger,
+}: { routes: Array<RouteData>; settings: AstroSettings; logger: Logger }) {
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:routes:resolved']) {
+ const integrationLogger = getLogger(integration, logger);
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:routes:resolved',
+ hookResult: integration.hooks['astro:routes:resolved']({
+ routes: routes.map((route) => toIntegrationResolvedRoute(route)),
+ logger: integrationLogger,
+ }),
+ logger,
+ });
+ }
+ }
+}
+
+function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute {
+ return {
+ isPrerendered: route.prerender,
+ entrypoint: route.component,
+ pattern: route.route,
+ params: route.params,
+ origin: route.origin,
+ generate: route.generate,
+ patternRegex: route.pattern,
+ segments: route.segments,
+ type: route.type,
+ pathname: route.pathname,
+ redirect: route.redirect,
+ redirectRoute: route.redirectRoute
+ ? toIntegrationResolvedRoute(route.redirectRoute)
+ : undefined,
+ };
+}
+
+function toIntegrationRouteData(route: RouteData): IntegrationRouteData {
+ return {
+ route: route.route,
+ component: route.component,
+ generate: route.generate,
+ params: route.params,
+ pathname: route.pathname,
+ segments: route.segments,
+ prerender: route.prerender,
+ redirect: route.redirect,
+ redirectRoute: route.redirectRoute ? toIntegrationRouteData(route.redirectRoute) : undefined,
+ type: route.type,
+ pattern: route.pattern,
+ distURL: route.distURL,
+ };
+}
diff --git a/packages/astro/src/jsx-runtime/index.ts b/packages/astro/src/jsx-runtime/index.ts
new file mode 100644
index 000000000..45093db14
--- /dev/null
+++ b/packages/astro/src/jsx-runtime/index.ts
@@ -0,0 +1,87 @@
+import { Fragment, Renderer, markHTMLString } from '../runtime/server/index.js';
+
+const AstroJSX = 'astro:jsx';
+const Empty = Symbol('empty');
+
+export interface AstroVNode {
+ [Renderer]: string;
+ [AstroJSX]: boolean;
+ type: string | ((...args: any) => any);
+ props: Record<string | symbol, any>;
+}
+
+const toSlotName = (slotAttr: string) => slotAttr;
+
+export function isVNode(vnode: any): vnode is AstroVNode {
+ return vnode && typeof vnode === 'object' && vnode[AstroJSX];
+}
+
+export function transformSlots(vnode: AstroVNode) {
+ if (typeof vnode.type === 'string') return vnode;
+ // Handle single child with slot attribute
+ const slots: Record<string, any> = {};
+ if (isVNode(vnode.props.children)) {
+ const child = vnode.props.children;
+ if (!isVNode(child)) return;
+ if (!('slot' in child.props)) return;
+ const name = toSlotName(child.props.slot);
+ slots[name] = [child];
+ slots[name]['$$slot'] = true;
+ delete child.props.slot;
+ delete vnode.props.children;
+ } else if (Array.isArray(vnode.props.children)) {
+ // Handle many children with slot attributes
+ vnode.props.children = vnode.props.children
+ .map((child) => {
+ if (!isVNode(child)) return child;
+ if (!('slot' in child.props)) return child;
+ const name = toSlotName(child.props.slot);
+ if (Array.isArray(slots[name])) {
+ slots[name].push(child);
+ } else {
+ slots[name] = [child];
+ slots[name]['$$slot'] = true;
+ }
+ delete child.props.slot;
+ return Empty;
+ })
+ .filter((v) => v !== Empty);
+ }
+ Object.assign(vnode.props, slots);
+}
+
+function markRawChildren(child: any): any {
+ if (typeof child === 'string') return markHTMLString(child);
+ if (Array.isArray(child)) return child.map((c) => markRawChildren(c));
+ return child;
+}
+
+function transformSetDirectives(vnode: AstroVNode) {
+ if (!('set:html' in vnode.props || 'set:text' in vnode.props)) return;
+ if ('set:html' in vnode.props) {
+ const children = markRawChildren(vnode.props['set:html']);
+ delete vnode.props['set:html'];
+ Object.assign(vnode.props, { children });
+ return;
+ }
+ if ('set:text' in vnode.props) {
+ const children = vnode.props['set:text'];
+ delete vnode.props['set:text'];
+ Object.assign(vnode.props, { children });
+ return;
+ }
+}
+
+function createVNode(type: any, props: Record<string, any>) {
+ const vnode: AstroVNode = {
+ [Renderer]: 'astro:jsx',
+ [AstroJSX]: true,
+ type,
+ props: props ?? {},
+ };
+ transformSetDirectives(vnode);
+ transformSlots(vnode);
+ return vnode;
+}
+
+export { AstroJSX, Fragment, createVNode as jsx, createVNode as jsxDEV, createVNode as jsxs };
diff --git a/packages/astro/src/jsx/rehype.ts b/packages/astro/src/jsx/rehype.ts
new file mode 100644
index 000000000..3db1a3070
--- /dev/null
+++ b/packages/astro/src/jsx/rehype.ts
@@ -0,0 +1,322 @@
+import type { RehypePlugin } from '@astrojs/markdown-remark';
+import type { RootContent } from 'hast';
+import type {
+ MdxJsxAttribute,
+ MdxJsxFlowElementHast,
+ MdxJsxTextElementHast,
+} from 'mdast-util-mdx-jsx';
+import { visit } from 'unist-util-visit';
+import type { VFile } from 'vfile';
+import { AstroError } from '../core/errors/errors.js';
+import { AstroErrorData } from '../core/errors/index.js';
+import { resolvePath } from '../core/viteUtils.js';
+import type { PluginMetadata } from '../vite-plugin-astro/types.js';
+
+// This import includes ambient types for hast to include mdx nodes
+import type {} from 'mdast-util-mdx';
+import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js';
+
+const ClientOnlyPlaceholder = 'astro-client-only';
+
+export const rehypeAnalyzeAstroMetadata: RehypePlugin = () => {
+ return (tree, file) => {
+ // Initial metadata for this MDX file, it will be mutated as we traverse the tree
+ const metadata = createDefaultAstroMetadata();
+
+ // Parse imports in this file. This is used to match components with their import source
+ const imports = parseImports(tree.children);
+
+ visit(tree, (node) => {
+ if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return;
+
+ const tagName = node.name;
+ if (!tagName || !isComponent(tagName) || !hasClientDirective(node)) return;
+
+ // From this point onwards, `node` is confirmed to be an island component
+
+ // Match this component with its import source
+ const matchedImport = findMatchingImport(tagName, imports);
+ if (!matchedImport) {
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingImport,
+ message: AstroErrorData.NoMatchingImport.message(node.name!),
+ });
+ }
+
+ // If this is an Astro component, that means the `client:` directive is misused as it doesn't
+ // work on Astro components as it's server-side only. Warn the user about this.
+ if (matchedImport.path.endsWith('.astro')) {
+ const clientAttribute = node.attributes.find(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:'),
+ ) as MdxJsxAttribute | undefined;
+ if (clientAttribute) {
+ console.warn(
+ `You are attempting to render <${node.name!} ${
+ clientAttribute.name
+ } />, but ${node.name!} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`,
+ );
+ }
+ }
+
+ const resolvedPath = resolvePath(matchedImport.path, file.path);
+
+ if (hasClientOnlyDirective(node)) {
+ // Add this component to the metadata
+ metadata.clientOnlyComponents.push({
+ exportName: matchedImport.name,
+ localName: '',
+ specifier: tagName,
+ resolvedPath,
+ });
+ // Mutate node with additional island attributes
+ addClientOnlyMetadata(node, matchedImport, resolvedPath);
+ } else {
+ // Add this component to the metadata
+ metadata.hydratedComponents.push({
+ exportName: '*',
+ localName: '',
+ specifier: tagName,
+ resolvedPath,
+ });
+ // Mutate node with additional island attributes
+ addClientMetadata(node, matchedImport, resolvedPath);
+ }
+ });
+
+ // Attach final metadata here, which can later be retrieved by `getAstroMetadata`
+ file.data.__astroMetadata = metadata;
+ };
+};
+
+export function getAstroMetadata(file: VFile) {
+ return file.data.__astroMetadata as PluginMetadata['astro'] | undefined;
+}
+
+type ImportSpecifier = { local: string; imported: string };
+
+/**
+ * ```
+ * import Foo from './Foo.jsx'
+ * import { Bar } from './Bar.jsx'
+ * import { Baz as Wiz } from './Bar.jsx'
+ * import * as Waz from './BaWazz.jsx'
+ *
+ * // => Map {
+ * // "./Foo.jsx" => Set { { local: "Foo", imported: "default" } },
+ * // "./Bar.jsx" => Set {
+ * // { local: "Bar", imported: "Bar" }
+ * // { local: "Wiz", imported: "Baz" },
+ * // },
+ * // "./Waz.jsx" => Set { { local: "Waz", imported: "*" } },
+ * // }
+ * ```
+ */
+function parseImports(children: RootContent[]) {
+ // Map of import source to its imported specifiers
+ const imports = new Map<string, Set<ImportSpecifier>>();
+
+ for (const child of children) {
+ if (child.type !== 'mdxjsEsm') continue;
+
+ const body = child.data?.estree?.body;
+ if (!body) continue;
+
+ for (const ast of body) {
+ if (ast.type !== 'ImportDeclaration') continue;
+
+ const source = ast.source.value as string;
+ const specs: ImportSpecifier[] = ast.specifiers.map((spec) => {
+ switch (spec.type) {
+ case 'ImportDefaultSpecifier':
+ return { local: spec.local.name, imported: 'default' };
+ case 'ImportNamespaceSpecifier':
+ return { local: spec.local.name, imported: '*' };
+ case 'ImportSpecifier': {
+ return {
+ local: spec.local.name,
+ imported:
+ spec.imported.type === 'Identifier'
+ ? spec.imported.name
+ : String(spec.imported.value),
+ };
+ }
+ default:
+ throw new Error('Unknown import declaration specifier: ' + spec);
+ }
+ });
+
+ // Get specifiers set from source or initialize a new one
+ let specSet = imports.get(source);
+ if (!specSet) {
+ specSet = new Set();
+ imports.set(source, specSet);
+ }
+
+ for (const spec of specs) {
+ specSet.add(spec);
+ }
+ }
+ }
+
+ return imports;
+}
+
+function isComponent(tagName: string) {
+ return (
+ (tagName[0] && tagName[0].toLowerCase() !== tagName[0]) ||
+ tagName.includes('.') ||
+ /[^a-zA-Z]/.test(tagName[0])
+ );
+}
+
+function hasClientDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) {
+ return node.attributes.some(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name.startsWith('client:'),
+ );
+}
+
+function hasClientOnlyDirective(node: MdxJsxFlowElementHast | MdxJsxTextElementHast) {
+ return node.attributes.some(
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'client:only',
+ );
+}
+
+type MatchedImport = { name: string; path: string };
+
+/**
+ * ```
+ * import Button from './Button.jsx'
+ * <Button />
+ * // => { name: "default", path: "./Button.jsx" }
+ *
+ * import { Button } from './Button.jsx'
+ * <Button />
+ * // => { name: "Button", path: "./Button.jsx" }
+ *
+ * import * as buttons from './Button.jsx'
+ * <buttons.Foo.Bar />
+ * // => { name: "Foo.Bar", path: "./Button.jsx" }
+ *
+ * import { buttons } from './Button.jsx'
+ * <buttons.Foo.Bar />
+ * // => { name: "buttons.Foo.Bar", path: "./Button.jsx" }
+ *
+ * import buttons from './Button.jsx'
+ * <buttons.Foo.Bar />
+ * // => { name: "default.Foo.Bar", path: "./Button.jsx" }
+ * ```
+ */
+function findMatchingImport(
+ tagName: string,
+ imports: Map<string, Set<ImportSpecifier>>,
+): MatchedImport | undefined {
+ const tagSpecifier = tagName.split('.')[0];
+ for (const [source, specs] of imports) {
+ for (const { imported, local } of specs) {
+ if (local === tagSpecifier) {
+ // If tagName access properties, we need to make sure the returned `name`
+ // properly access the properties from `path`
+ if (tagSpecifier !== tagName) {
+ switch (imported) {
+ // Namespace import: "<buttons.Foo.Bar />" => name: "Foo.Bar"
+ case '*': {
+ const accessPath = tagName.slice(tagSpecifier.length + 1);
+ return { name: accessPath, path: source };
+ }
+ // Default import: "<buttons.Foo.Bar />" => name: "default.Foo.Bar"
+ case 'default': {
+ // "buttons.Foo.Bar" => "Foo.Bar"
+ const accessPath = tagName.slice(tagSpecifier.length + 1);
+ return { name: `default.${accessPath}`, path: source };
+ }
+ // Named import: "<buttons.Foo.Bar />" => name: "buttons.Foo.Bar"
+ default: {
+ return { name: tagName, path: source };
+ }
+ }
+ }
+
+ return { name: imported, path: source };
+ }
+ }
+ }
+}
+
+function addClientMetadata(
+ node: MdxJsxFlowElementHast | MdxJsxTextElementHast,
+ meta: MatchedImport,
+ resolvedPath: string,
+) {
+ const attributeNames = node.attributes
+ .map((attr) => (attr.type === 'mdxJsxAttribute' ? attr.name : null))
+ .filter(Boolean);
+
+ if (!attributeNames.includes('client:component-path')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-path',
+ value: resolvedPath,
+ });
+ }
+ if (!attributeNames.includes('client:component-export')) {
+ if (meta.name === '*') {
+ meta.name = node.name!.split('.').slice(1).join('.')!;
+ }
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-export',
+ value: meta.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydration')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-hydration',
+ value: null,
+ });
+ }
+}
+
+function addClientOnlyMetadata(
+ node: MdxJsxFlowElementHast | MdxJsxTextElementHast,
+ meta: { path: string; name: string },
+ resolvedPath: string,
+) {
+ const attributeNames = node.attributes
+ .map((attr) => (attr.type === 'mdxJsxAttribute' ? attr.name : null))
+ .filter(Boolean);
+
+ if (!attributeNames.includes('client:display-name')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:display-name',
+ value: node.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydpathation')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-path',
+ value: resolvedPath,
+ });
+ }
+ if (!attributeNames.includes('client:component-export')) {
+ if (meta.name === '*') {
+ meta.name = node.name!.split('.').slice(1).join('.')!;
+ }
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-export',
+ value: meta.name,
+ });
+ }
+ if (!attributeNames.includes('client:component-hydration')) {
+ node.attributes.push({
+ type: 'mdxJsxAttribute',
+ name: 'client:component-hydration',
+ value: null,
+ });
+ }
+
+ node.name = ClientOnlyPlaceholder;
+}
diff --git a/packages/astro/src/manifest/virtual-module.ts b/packages/astro/src/manifest/virtual-module.ts
new file mode 100644
index 000000000..03b856265
--- /dev/null
+++ b/packages/astro/src/manifest/virtual-module.ts
@@ -0,0 +1,127 @@
+import type { Plugin } from 'vite';
+import { CantUseAstroConfigModuleError } from '../core/errors/errors-data.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import { fromRoutingStrategy } from '../i18n/utils.js';
+import type { AstroSettings } from '../types/astro.js';
+import type {
+ AstroConfig,
+ ClientDeserializedManifest,
+ SSRManifest,
+ ServerDeserializedManifest,
+} from '../types/public/index.js';
+
+const VIRTUAL_SERVER_ID = 'astro:config/server';
+const RESOLVED_VIRTUAL_SERVER_ID = '\0' + VIRTUAL_SERVER_ID;
+const VIRTUAL_CLIENT_ID = 'astro:config/client';
+const RESOLVED_VIRTUAL_CLIENT_ID = '\0' + VIRTUAL_CLIENT_ID;
+
+export default function virtualModulePlugin({
+ settings,
+ manifest,
+ logger: _logger,
+}: { settings: AstroSettings; manifest: SSRManifest; logger: Logger }): Plugin {
+ return {
+ enforce: 'pre',
+ name: 'astro-manifest-plugin',
+ resolveId(id) {
+ // Resolve the virtual module
+ if (VIRTUAL_SERVER_ID === id) {
+ return RESOLVED_VIRTUAL_SERVER_ID;
+ } else if (VIRTUAL_CLIENT_ID === id) {
+ return RESOLVED_VIRTUAL_CLIENT_ID;
+ }
+ },
+ load(id, opts) {
+ // client
+ if (id === RESOLVED_VIRTUAL_CLIENT_ID) {
+ if (!settings.config.experimental.serializeConfig) {
+ throw new AstroError({
+ ...CantUseAstroConfigModuleError,
+ message: CantUseAstroConfigModuleError.message(VIRTUAL_CLIENT_ID),
+ });
+ }
+ // There's nothing wrong about using `/client` on the server
+ return `${serializeClientConfig(manifest)};`;
+ }
+ // server
+ else if (id == RESOLVED_VIRTUAL_SERVER_ID) {
+ if (!settings.config.experimental.serializeConfig) {
+ throw new AstroError({
+ ...CantUseAstroConfigModuleError,
+ message: CantUseAstroConfigModuleError.message(VIRTUAL_SERVER_ID),
+ });
+ }
+ if (!opts?.ssr) {
+ throw new AstroError({
+ ...AstroErrorData.ServerOnlyModule,
+ message: AstroErrorData.ServerOnlyModule.message(VIRTUAL_SERVER_ID),
+ });
+ }
+ return `${serializeServerConfig(manifest)};`;
+ }
+ },
+ };
+}
+
+function serializeClientConfig(manifest: SSRManifest): string {
+ let i18n: AstroConfig['i18n'] | undefined = undefined;
+ if (manifest.i18n) {
+ i18n = {
+ defaultLocale: manifest.i18n.defaultLocale,
+ locales: manifest.i18n.locales,
+ routing: fromRoutingStrategy(manifest.i18n.strategy, manifest.i18n.fallbackType),
+ fallback: manifest.i18n.fallback,
+ };
+ }
+ const serClientConfig: ClientDeserializedManifest = {
+ base: manifest.base,
+ i18n,
+ build: {
+ format: manifest.buildFormat,
+ },
+ trailingSlash: manifest.trailingSlash,
+ compressHTML: manifest.compressHTML,
+ site: manifest.site,
+ };
+
+ const output = [];
+ for (const [key, value] of Object.entries(serClientConfig)) {
+ output.push(`export const ${key} = ${JSON.stringify(value)};`);
+ }
+ return output.join('\n') + '\n';
+}
+
+function serializeServerConfig(manifest: SSRManifest): string {
+ let i18n: AstroConfig['i18n'] | undefined = undefined;
+ if (manifest.i18n) {
+ i18n = {
+ defaultLocale: manifest.i18n.defaultLocale,
+ routing: fromRoutingStrategy(manifest.i18n.strategy, manifest.i18n.fallbackType),
+ locales: manifest.i18n.locales,
+ fallback: manifest.i18n.fallback,
+ };
+ }
+ const serverConfig: ServerDeserializedManifest = {
+ build: {
+ server: new URL(manifest.buildServerDir),
+ client: new URL(manifest.buildClientDir),
+ format: manifest.buildFormat,
+ },
+ cacheDir: new URL(manifest.cacheDir),
+ outDir: new URL(manifest.outDir),
+ publicDir: new URL(manifest.publicDir),
+ srcDir: new URL(manifest.srcDir),
+ root: new URL(manifest.hrefRoot),
+ base: manifest.base,
+ i18n,
+ trailingSlash: manifest.trailingSlash,
+ site: manifest.site,
+ compressHTML: manifest.compressHTML,
+ };
+ const output = [];
+ for (const [key, value] of Object.entries(serverConfig)) {
+ output.push(`export const ${key} = ${JSON.stringify(value)};`);
+ }
+ return output.join('\n') + '\n';
+}
diff --git a/packages/astro/src/preferences/README.md b/packages/astro/src/preferences/README.md
new file mode 100644
index 000000000..4234ebac1
--- /dev/null
+++ b/packages/astro/src/preferences/README.md
@@ -0,0 +1,33 @@
+# Preferences
+
+The preferences module implements global and local user preferences for controlling certain Astro behavior. Whereas the `astro.config.mjs` file controls project-specific behavior for every user of a project, preferences are user-specific.
+
+The design of Preferences is inspired by [Git](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration) and [Visual Studio Code](https://code.visualstudio.com/docs/getstarted/settings). Both systems implement similar layering approaches with project-specific and global settings.
+
+## `AstroPreferences`
+
+The `AstroPreferences` interface exposes both a `get` and `set` function.
+
+### Reading a preference
+
+`preferences.get("dot.separated.value")` will read a preference value from multiple sources if needed. Local project preferences are read from `.astro/settings.json`, if it exists. Next, global user preferences are read from `<homedir>/<os-specific-preferences-dir>/astro/settings.json`. If neither of those are found, the default preferences defined in [`./defaults.ts`](./defaults.ts) will apply.
+
+In order to read a preference from a specific location, you can pass the `location: "global" | "project"` option.
+
+```js
+await preferences.get('dot.separated.value', { location: 'global' });
+```
+
+### Writing a preference
+
+`preferences.set("dot.separated.value", true)` will store a preference value. By default, preferences are stored locally in a project.
+
+In order to set a global user preference, you can pass the `location: "global"` option.
+
+```js
+await preferences.set('dot.separated.value', 'value', { location: 'global' });
+```
+
+## Relation to Telemetry
+
+This module evolved from the existing `@astrojs/telemetry` package, but has been generalized for user-facing `astro` preferences. At some point, we'll need to merge the logic in `@astrojs/telemetry` and the logic in this module so that all preferences are stored in the same location.
diff --git a/packages/astro/src/preferences/constants.ts b/packages/astro/src/preferences/constants.ts
new file mode 100644
index 000000000..108787a28
--- /dev/null
+++ b/packages/astro/src/preferences/constants.ts
@@ -0,0 +1 @@
+export const SETTINGS_FILE = 'settings.json';
diff --git a/packages/astro/src/preferences/defaults.ts b/packages/astro/src/preferences/defaults.ts
new file mode 100644
index 000000000..8f643f99f
--- /dev/null
+++ b/packages/astro/src/preferences/defaults.ts
@@ -0,0 +1,18 @@
+export const DEFAULT_PREFERENCES = {
+ devToolbar: {
+ /** Specifies whether the user has the Dev Overlay enabled */
+ enabled: true,
+ },
+ checkUpdates: {
+ /** Specifies whether the user has the update check enabled */
+ enabled: true,
+ },
+ // Temporary variables that shouldn't be exposed to the users in the CLI, but are still useful to store in preferences
+ _variables: {
+ /** Time since last update check */
+ lastUpdateCheck: 0,
+ },
+};
+
+export type Preferences = typeof DEFAULT_PREFERENCES;
+export type PublicPreferences = Omit<Preferences, '_variables'>;
diff --git a/packages/astro/src/preferences/index.ts b/packages/astro/src/preferences/index.ts
new file mode 100644
index 000000000..e34486739
--- /dev/null
+++ b/packages/astro/src/preferences/index.ts
@@ -0,0 +1,155 @@
+import os from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+import dget from 'dlv';
+import type { AstroConfig } from '../types/public/config.js';
+import { DEFAULT_PREFERENCES, type Preferences, type PublicPreferences } from './defaults.js';
+import { PreferenceStore } from './store.js';
+
+type DotKeys<T> = T extends object
+ ? {
+ [K in keyof T]: `${Exclude<K, symbol>}${DotKeys<T[K]> extends never
+ ? ''
+ : `.${DotKeys<T[K]>}`}`;
+ }[keyof T]
+ : never;
+
+export type GetDotKey<
+ T extends Record<string | number, any>,
+ K extends string,
+> = K extends `${infer U}.${infer Rest}` ? GetDotKey<T[U], Rest> : T[K];
+
+export type PreferenceLocation = 'global' | 'project';
+export interface PreferenceOptions {
+ location?: PreferenceLocation;
+ /**
+ * If `true`, the server will be reloaded after setting the preference.
+ * If `false`, the server will not be reloaded after setting the preference.
+ *
+ * Defaults to `true`.
+ */
+ reloadServer?: boolean;
+}
+
+type DeepPartial<T> = T extends object
+ ? {
+ [P in keyof T]?: DeepPartial<T[P]>;
+ }
+ : T;
+
+export type PreferenceKey = DotKeys<Preferences>;
+export interface PreferenceList extends Record<PreferenceLocation, DeepPartial<PublicPreferences>> {
+ fromAstroConfig: DeepPartial<Preferences>;
+ defaults: PublicPreferences;
+}
+
+export interface AstroPreferences {
+ get<Key extends PreferenceKey>(
+ key: Key,
+ opts?: PreferenceOptions,
+ ): Promise<GetDotKey<Preferences, Key>>;
+ set<Key extends PreferenceKey>(
+ key: Key,
+ value: GetDotKey<Preferences, Key>,
+ opts?: PreferenceOptions,
+ ): Promise<void>;
+ getAll(): Promise<PublicPreferences>;
+ list(opts?: PreferenceOptions): Promise<PreferenceList>;
+ ignoreNextPreferenceReload: boolean;
+}
+
+export function isValidKey(key: string): key is PreferenceKey {
+ return dget(DEFAULT_PREFERENCES, key) !== undefined;
+}
+export function coerce(key: string, value: unknown) {
+ const type = typeof dget(DEFAULT_PREFERENCES, key);
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (type) {
+ case 'string':
+ return value;
+ case 'number':
+ return Number(value);
+ case 'boolean': {
+ if (value === 'true' || value === 1) return true;
+ if (value === 'false' || value === 0) return false;
+ break;
+ }
+ default:
+ throw new Error(`Incorrect value for ${key}`);
+ }
+ return value as any;
+}
+
+export default function createPreferences(config: AstroConfig, dotAstroDir: URL): AstroPreferences {
+ const global = new PreferenceStore(getGlobalPreferenceDir());
+ const project = new PreferenceStore(fileURLToPath(dotAstroDir));
+ const stores: Record<PreferenceLocation, PreferenceStore> = { global, project };
+
+ return {
+ async get(key, { location } = {}) {
+ if (!location) return project.get(key) ?? global.get(key) ?? dget(DEFAULT_PREFERENCES, key);
+ return stores[location].get(key);
+ },
+ async set(key, value, { location = 'project', reloadServer = true } = {}) {
+ stores[location].set(key, value);
+
+ if (!reloadServer) {
+ this.ignoreNextPreferenceReload = true;
+ }
+ },
+ async getAll() {
+ const allPrefs = Object.assign(
+ {},
+ DEFAULT_PREFERENCES,
+ stores['global'].getAll(),
+ stores['project'].getAll(),
+ );
+
+ const { _variables, ...prefs } = allPrefs;
+
+ return prefs;
+ },
+ async list() {
+ const { _variables, ...defaultPrefs } = DEFAULT_PREFERENCES;
+ return {
+ global: stores['global'].getAll(),
+ project: stores['project'].getAll(),
+ fromAstroConfig: mapFrom(DEFAULT_PREFERENCES, config),
+ defaults: defaultPrefs,
+ };
+
+ function mapFrom(defaults: Preferences, astroConfig: Record<string, any>) {
+ return Object.fromEntries(
+ Object.entries(defaults).map(([key, _]) => [key, astroConfig[key]]),
+ );
+ }
+ },
+ ignoreNextPreferenceReload: false,
+ };
+}
+
+// Adapted from https://github.com/sindresorhus/env-paths
+function getGlobalPreferenceDir() {
+ const name = 'astro';
+ const homedir = os.homedir();
+ const macos = () => path.join(homedir, 'Library', 'Preferences', name);
+ const win = () => {
+ const { APPDATA = path.join(homedir, 'AppData', 'Roaming') } = process.env;
+ return path.join(APPDATA, name, 'Config');
+ };
+ const linux = () => {
+ const { XDG_CONFIG_HOME = path.join(homedir, '.config') } = process.env;
+ return path.join(XDG_CONFIG_HOME, name);
+ };
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (process.platform) {
+ case 'darwin':
+ return macos();
+ case 'win32':
+ return win();
+ default:
+ return linux();
+ }
+}
diff --git a/packages/astro/src/preferences/store.ts b/packages/astro/src/preferences/store.ts
new file mode 100644
index 000000000..373ec88c1
--- /dev/null
+++ b/packages/astro/src/preferences/store.ts
@@ -0,0 +1,63 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import dget from 'dlv';
+import { dset } from 'dset';
+import { SETTINGS_FILE } from './constants.js';
+
+export class PreferenceStore {
+ private file: string;
+
+ constructor(
+ private dir: string,
+ filename = SETTINGS_FILE,
+ ) {
+ this.file = path.join(this.dir, filename);
+ }
+
+ private _store?: Record<string, any>;
+ private get store(): Record<string, any> {
+ if (this._store) return this._store;
+ if (fs.existsSync(this.file)) {
+ try {
+ this._store = JSON.parse(fs.readFileSync(this.file).toString());
+ } catch {}
+ }
+ if (!this._store) {
+ this._store = {};
+ this.write();
+ }
+ return this._store;
+ }
+ private set store(value: Record<string, any>) {
+ this._store = value;
+ this.write();
+ }
+ write() {
+ if (!this._store || Object.keys(this._store).length === 0) return;
+ fs.mkdirSync(this.dir, { recursive: true });
+ fs.writeFileSync(this.file, JSON.stringify(this.store, null, '\t'));
+ }
+ clear(): void {
+ this.store = {};
+ fs.rmSync(this.file, { recursive: true });
+ }
+ delete(key: string): boolean {
+ dset(this.store, key, undefined);
+ this.write();
+ return true;
+ }
+ get(key: string): any {
+ return dget(this.store, key);
+ }
+ has(key: string): boolean {
+ return typeof this.get(key) !== 'undefined';
+ }
+ set(key: string, value: any): void {
+ if (this.get(key) === value) return;
+ dset(this.store, key, value);
+ this.write();
+ }
+ getAll(): Record<string, any> {
+ return this.store;
+ }
+}
diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts
new file mode 100644
index 000000000..70f7052d3
--- /dev/null
+++ b/packages/astro/src/prefetch/index.ts
@@ -0,0 +1,349 @@
+/*
+ NOTE: Do not add any dependencies or imports in this file so that it can load quickly in dev.
+*/
+
+const debug = import.meta.env.DEV ? console.debug : undefined;
+const inBrowser = import.meta.env.SSR === false;
+// Track prefetched URLs so we don't prefetch twice
+const prefetchedUrls = new Set<string>();
+// Track listened anchors so we don't attach duplicated listeners
+const listenedAnchors = new WeakSet<HTMLAnchorElement>();
+
+// User-defined config for prefetch. The values are injected by vite-plugin-prefetch
+// and can be undefined if not configured. But it will be set a fallback value in `init()`.
+// @ts-expect-error injected global
+let prefetchAll: boolean = __PREFETCH_PREFETCH_ALL__;
+// @ts-expect-error injected global
+let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
+// @ts-expect-error injected global
+let clientPrerender: boolean = __EXPERIMENTAL_CLIENT_PRERENDER__;
+
+interface InitOptions {
+ defaultStrategy?: string;
+ prefetchAll?: boolean;
+}
+
+let inited = false;
+/**
+ * Initialize the prefetch script, only works once.
+ *
+ * @param defaultOpts Default options for prefetching if not already set by the user config.
+ */
+export function init(defaultOpts?: InitOptions) {
+ if (!inBrowser) return;
+
+ // Init only once
+ if (inited) return;
+ inited = true;
+
+ debug?.(`[astro] Initializing prefetch script`);
+
+ // Fallback default values if not set by user config
+ prefetchAll ??= defaultOpts?.prefetchAll ?? false;
+ defaultStrategy ??= defaultOpts?.defaultStrategy ?? 'hover';
+
+ // In the future, perhaps we can enable treeshaking specific unused strategies
+ initTapStrategy();
+ initHoverStrategy();
+ initViewportStrategy();
+ initLoadStrategy();
+}
+
+/**
+ * Prefetch links with higher priority when the user taps on them
+ */
+function initTapStrategy() {
+ for (const event of ['touchstart', 'mousedown']) {
+ document.body.addEventListener(
+ event,
+ (e) => {
+ if (elMatchesStrategy(e.target, 'tap')) {
+ prefetch(e.target.href, { ignoreSlowConnection: true });
+ }
+ },
+ { passive: true },
+ );
+ }
+}
+
+/**
+ * Prefetch links with higher priority when the user hovers over them
+ */
+function initHoverStrategy() {
+ let timeout: number;
+
+ // Handle focus listeners
+ document.body.addEventListener(
+ 'focusin',
+ (e) => {
+ if (elMatchesStrategy(e.target, 'hover')) {
+ handleHoverIn(e);
+ }
+ },
+ { passive: true },
+ );
+ document.body.addEventListener('focusout', handleHoverOut, { passive: true });
+
+ // Handle hover listeners. Re-run each time on page load.
+ onPageLoad(() => {
+ for (const anchor of document.getElementsByTagName('a')) {
+ // Skip if already listening
+ if (listenedAnchors.has(anchor)) continue;
+ // Add listeners for anchors matching the strategy
+ if (elMatchesStrategy(anchor, 'hover')) {
+ listenedAnchors.add(anchor);
+ anchor.addEventListener('mouseenter', handleHoverIn, { passive: true });
+ anchor.addEventListener('mouseleave', handleHoverOut, { passive: true });
+ }
+ }
+ });
+
+ function handleHoverIn(e: Event) {
+ const href = (e.target as HTMLAnchorElement).href;
+
+ // Debounce hover prefetches by 80ms
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeout = setTimeout(() => {
+ prefetch(href);
+ }, 80) as unknown as number;
+ }
+
+ // Cancel prefetch if the user hovers away
+ function handleHoverOut() {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = 0;
+ }
+ }
+}
+
+/**
+ * Prefetch links with lower priority as they enter the viewport
+ */
+function initViewportStrategy() {
+ let observer: IntersectionObserver;
+
+ onPageLoad(() => {
+ for (const anchor of document.getElementsByTagName('a')) {
+ // Skip if already listening
+ if (listenedAnchors.has(anchor)) continue;
+ // Observe for anchors matching the strategy
+ if (elMatchesStrategy(anchor, 'viewport')) {
+ listenedAnchors.add(anchor);
+ observer ??= createViewportIntersectionObserver();
+ observer.observe(anchor);
+ }
+ }
+ });
+}
+
+function createViewportIntersectionObserver() {
+ const timeouts = new WeakMap<HTMLAnchorElement, number>();
+
+ return new IntersectionObserver((entries, observer) => {
+ for (const entry of entries) {
+ const anchor = entry.target as HTMLAnchorElement;
+ const timeout = timeouts.get(anchor);
+ // Prefetch if intersecting
+ if (entry.isIntersecting) {
+ // Debounce viewport prefetches by 300ms
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeouts.set(
+ anchor,
+ setTimeout(() => {
+ observer.unobserve(anchor);
+ timeouts.delete(anchor);
+ prefetch(anchor.href);
+ }, 300) as unknown as number,
+ );
+ } else {
+ // If exited viewport but haven't prefetched, cancel it
+ if (timeout) {
+ clearTimeout(timeout);
+ timeouts.delete(anchor);
+ }
+ }
+ }
+ });
+}
+
+/**
+ * Prefetch links with lower priority when page load
+ */
+function initLoadStrategy() {
+ onPageLoad(() => {
+ for (const anchor of document.getElementsByTagName('a')) {
+ if (elMatchesStrategy(anchor, 'load')) {
+ // Prefetch every link in this page
+ prefetch(anchor.href);
+ }
+ }
+ });
+}
+
+export interface PrefetchOptions {
+ /**
+ * How the prefetch should prioritize the URL. (default `'link'`)
+ * - `'link'`: use `<link rel="prefetch">`.
+ * - `'fetch'`: use `fetch()`.
+ *
+ * @deprecated It is recommended to not use this option, and let prefetch use `'link'` whenever it's supported,
+ * or otherwise fall back to `'fetch'`. `'link'` works better if the URL doesn't set an appropriate cache header,
+ * as the browser will continue to cache it as long as it's used subsequently.
+ */
+ with?: 'link' | 'fetch';
+ /**
+ * Should prefetch even on data saver mode or slow connection. (default `false`)
+ */
+ ignoreSlowConnection?: boolean;
+}
+
+/**
+ * Prefetch a URL so it's cached when the user navigates to it.
+ *
+ * @param url A full or partial URL string based on the current `location.href`. They are only fetched if:
+ * - The user is online
+ * - The user is not in data saver mode
+ * - The URL is within the same origin
+ * - The URL is not the current page
+ * - The URL has not already been prefetched
+ * @param opts Additional options for prefetching.
+ */
+export function prefetch(url: string, opts?: PrefetchOptions) {
+ // Remove url hash to avoid prefetching the same URL multiple times
+ url = url.replace(/#.*/, '');
+
+ const ignoreSlowConnection = opts?.ignoreSlowConnection ?? false;
+ if (!canPrefetchUrl(url, ignoreSlowConnection)) return;
+ prefetchedUrls.add(url);
+
+ // Prefetch with speculationrules if `clientPrerender` is enabled and supported
+ // NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value
+ if (clientPrerender && HTMLScriptElement.supports?.('speculationrules')) {
+ debug?.(`[astro] Prefetching ${url} with <script type="speculationrules">`);
+ appendSpeculationRules(url);
+ }
+ // Prefetch with link if supported
+ else if (
+ document.createElement('link').relList?.supports?.('prefetch') &&
+ opts?.with !== 'fetch'
+ ) {
+ debug?.(`[astro] Prefetching ${url} with <link rel="prefetch">`);
+ const link = document.createElement('link');
+ link.rel = 'prefetch';
+ link.setAttribute('href', url);
+ document.head.append(link);
+ }
+ // Otherwise, fallback prefetch with fetch
+ else {
+ debug?.(`[astro] Prefetching ${url} with fetch`);
+ fetch(url, { priority: 'low' });
+ }
+}
+
+function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) {
+ // Skip prefetch if offline
+ if (!navigator.onLine) return false;
+ // Skip prefetch if using data saver mode or slow connection
+ if (!ignoreSlowConnection && isSlowConnection()) return false;
+ // Else check if URL is within the same origin, not the current page, and not already prefetched
+ try {
+ const urlObj = new URL(url, location.href);
+ return (
+ location.origin === urlObj.origin &&
+ (location.pathname !== urlObj.pathname || location.search !== urlObj.search) &&
+ !prefetchedUrls.has(url)
+ );
+ } catch {}
+ return false;
+}
+
+function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTMLAnchorElement {
+ // @ts-expect-error access unknown property this way as it's more performant
+ if (el?.tagName !== 'A') return false;
+ const attrValue = (el as HTMLElement).dataset.astroPrefetch;
+
+ // Out-out if `prefetchAll` is enabled
+ if (attrValue === 'false') {
+ return false;
+ }
+
+ // Fallback to tap strategy if using data saver mode or slow connection
+ if (strategy === 'tap' && (attrValue != null || prefetchAll) && isSlowConnection()) {
+ return true;
+ }
+
+ // If anchor has no dataset but we want to prefetch all, or has dataset but no value,
+ // check against fallback default strategy
+ if ((attrValue == null && prefetchAll) || attrValue === '') {
+ return strategy === defaultStrategy;
+ }
+ // Else if dataset is explicitly defined, check against it
+ if (attrValue === strategy) {
+ return true;
+ }
+ // Else, no match
+ return false;
+}
+
+function isSlowConnection() {
+ if ('connection' in navigator) {
+ // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
+ const conn = navigator.connection as any;
+ return conn.saveData || /2g/.test(conn.effectiveType);
+ }
+ return false;
+}
+
+/**
+ * Listen to page loads and handle Astro's View Transition specific events
+ */
+function onPageLoad(cb: () => void) {
+ cb();
+ // Ignore first call of `astro-page-load` as we already call `cb` above.
+ // We have to call `cb` eagerly as View Transitions may not be enabled.
+ let firstLoad = false;
+ document.addEventListener('astro:page-load', () => {
+ if (!firstLoad) {
+ firstLoad = true;
+ return;
+ }
+ cb();
+ });
+}
+
+/**
+ * Appends a `<script type="speculationrules">` tag to the head of the
+ * document that prerenders the `url` passed in.
+ *
+ * Modifying the script and appending a new link does not trigger the prerender.
+ * A new script must be added for each `url`.
+ *
+ * @param url The url of the page to prerender.
+ */
+function appendSpeculationRules(url: string) {
+ const script = document.createElement('script');
+ script.type = 'speculationrules';
+ script.textContent = JSON.stringify({
+ prerender: [
+ {
+ source: 'list',
+ urls: [url],
+ },
+ ],
+ // Currently, adding `prefetch` is required to fallback if `prerender` fails.
+ // Possibly will be automatic in the future, in which case it can be removed.
+ // https://github.com/WICG/nav-speculation/issues/162#issuecomment-1977818473
+ prefetch: [
+ {
+ source: 'list',
+ urls: [url],
+ },
+ ],
+ });
+ document.head.append(script);
+}
diff --git a/packages/astro/src/prefetch/vite-plugin-prefetch.ts b/packages/astro/src/prefetch/vite-plugin-prefetch.ts
new file mode 100644
index 000000000..c908e7cc2
--- /dev/null
+++ b/packages/astro/src/prefetch/vite-plugin-prefetch.ts
@@ -0,0 +1,70 @@
+import type * as vite from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+
+const virtualModuleId = 'astro:prefetch';
+const resolvedVirtualModuleId = '\0' + virtualModuleId;
+const prefetchInternalModuleFsSubpath = 'astro/dist/prefetch/index.js';
+const prefetchCode = `import { init } from 'astro/virtual-modules/prefetch.js';init()`;
+
+export default function astroPrefetch({ settings }: { settings: AstroSettings }): vite.Plugin {
+ const prefetchOption = settings.config.prefetch;
+ const prefetch = prefetchOption
+ ? typeof prefetchOption === 'object'
+ ? prefetchOption
+ : {}
+ : undefined;
+
+ // Check against existing scripts as this plugin could be called multiple times
+ if (prefetch && settings.scripts.every((s) => s.content !== prefetchCode)) {
+ // Inject prefetch script to all pages
+ settings.scripts.push({
+ stage: 'page',
+ content: `import { init } from 'astro/virtual-modules/prefetch.js';init()`,
+ });
+ }
+
+ // Throw a normal error instead of an AstroError as Vite captures this in the plugin lifecycle
+ // and would generate a different stack trace itself through esbuild.
+ const throwPrefetchNotEnabledError = () => {
+ throw new Error('You need to enable the `prefetch` Astro config to import `astro:prefetch`');
+ };
+
+ return {
+ name: 'astro:prefetch',
+ async resolveId(id) {
+ if (id === virtualModuleId) {
+ if (!prefetch) throwPrefetchNotEnabledError();
+ return resolvedVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedVirtualModuleId) {
+ if (!prefetch) throwPrefetchNotEnabledError();
+ return `export { prefetch } from "astro/virtual-modules/prefetch.js";`;
+ }
+ },
+ transform(code, id) {
+ // NOTE: Handle replacing the specifiers even if prefetch is disabled so View Transitions
+ // can import the internal module and not hit runtime issues.
+ if (id.includes(prefetchInternalModuleFsSubpath)) {
+ // We perform a simple replacement with padding so that the code offset is not changed and
+ // we don't have to generate a sourcemap. This has the assumption that the replaced string
+ // will always be shorter than the search string to work.
+ code = code
+ .replace(
+ '__PREFETCH_PREFETCH_ALL__', // length: 25
+ `${JSON.stringify(prefetch?.prefetchAll)}`.padEnd(25),
+ )
+ .replace(
+ '__PREFETCH_DEFAULT_STRATEGY__', // length: 29
+ `${JSON.stringify(prefetch?.defaultStrategy)}`.padEnd(29),
+ )
+ .replace(
+ '__EXPERIMENTAL_CLIENT_PRERENDER__', // length: 33
+ `${JSON.stringify(settings.config.experimental.clientPrerender)}`.padEnd(33),
+ );
+ return { code, map: null };
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/prerender/metadata.ts b/packages/astro/src/prerender/metadata.ts
new file mode 100644
index 000000000..a501cc46f
--- /dev/null
+++ b/packages/astro/src/prerender/metadata.ts
@@ -0,0 +1,22 @@
+import type { ModuleInfo, ModuleLoader } from '../core/module-loader/index.js';
+import { viteID } from '../core/util.js';
+
+type GetPrerenderStatusParams = {
+ filePath: URL;
+ loader: ModuleLoader;
+};
+
+export function getPrerenderStatus({
+ filePath,
+ loader,
+}: GetPrerenderStatusParams): boolean | undefined {
+ const fileID = viteID(filePath);
+ const moduleInfo = loader.getModuleInfo(fileID);
+ if (!moduleInfo) return;
+ const prerenderStatus = getPrerenderMetadata(moduleInfo);
+ return prerenderStatus;
+}
+
+export function getPrerenderMetadata(moduleInfo: ModuleInfo) {
+ return moduleInfo?.meta?.astro?.pageOptions?.prerender;
+}
diff --git a/packages/astro/src/prerender/routing.ts b/packages/astro/src/prerender/routing.ts
new file mode 100644
index 000000000..888b012e0
--- /dev/null
+++ b/packages/astro/src/prerender/routing.ts
@@ -0,0 +1,87 @@
+import { RedirectComponentInstance, routeIsRedirect } from '../core/redirects/index.js';
+import { routeComparator } from '../core/routing/priority.js';
+import type { AstroSettings, ComponentInstance } from '../types/astro.js';
+import type { RouteData } from '../types/public/internal.js';
+import type { DevPipeline } from '../vite-plugin-astro-server/pipeline.js';
+import { getPrerenderStatus } from './metadata.js';
+
+type GetSortedPreloadedMatchesParams = {
+ pipeline: DevPipeline;
+ matches: RouteData[];
+ settings: AstroSettings;
+};
+export async function getSortedPreloadedMatches({
+ pipeline,
+ matches,
+ settings,
+}: GetSortedPreloadedMatchesParams) {
+ return (
+ await preloadAndSetPrerenderStatus({
+ pipeline,
+ matches,
+ settings,
+ })
+ )
+ .sort((a, b) => routeComparator(a.route, b.route))
+ .sort((a, b) => prioritizePrerenderedMatchesComparator(a.route, b.route));
+}
+
+type PreloadAndSetPrerenderStatusParams = {
+ pipeline: DevPipeline;
+ matches: RouteData[];
+ settings: AstroSettings;
+};
+
+type PreloadAndSetPrerenderStatusResult = {
+ filePath: URL;
+ route: RouteData;
+ preloadedComponent: ComponentInstance;
+};
+
+async function preloadAndSetPrerenderStatus({
+ pipeline,
+ matches,
+ settings,
+}: PreloadAndSetPrerenderStatusParams): Promise<PreloadAndSetPrerenderStatusResult[]> {
+ const preloaded = new Array<PreloadAndSetPrerenderStatusResult>();
+ for (const route of matches) {
+ const filePath = new URL(`./${route.component}`, settings.config.root);
+ if (routeIsRedirect(route)) {
+ preloaded.push({
+ preloadedComponent: RedirectComponentInstance,
+ route,
+ filePath,
+ });
+ continue;
+ }
+
+ const preloadedComponent = await pipeline.preload(route, filePath);
+
+ // gets the prerender metadata set by the `astro:scanner` vite plugin
+ const prerenderStatus = getPrerenderStatus({
+ filePath,
+ loader: pipeline.loader,
+ });
+
+ if (prerenderStatus !== undefined) {
+ route.prerender = prerenderStatus;
+ }
+
+ preloaded.push({ preloadedComponent, route, filePath });
+ }
+ return preloaded;
+}
+
+function prioritizePrerenderedMatchesComparator(a: RouteData, b: RouteData): number {
+ if (areRegexesEqual(a.pattern, b.pattern)) {
+ if (a.prerender !== b.prerender) {
+ return a.prerender ? -1 : 1;
+ }
+ return a.component < b.component ? -1 : 1;
+ }
+ return 0;
+}
+
+function areRegexesEqual(regexp1: RegExp, regexp2: RegExp) {
+ return regexp1.source === regexp2.source && regexp1.global === regexp2.global;
+}
diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts
new file mode 100644
index 000000000..06ddc09ef
--- /dev/null
+++ b/packages/astro/src/prerender/utils.ts
@@ -0,0 +1,18 @@
+import { getOutDirWithinCwd } from '../core/build/common.js';
+import type { AstroSettings } from '../types/astro.js';
+import type { AstroConfig } from '../types/public/config.js';
+
+export function getPrerenderDefault(config: AstroConfig) {
+ return config.output !== 'server';
+}
+
+/**
+ * Returns the correct output directory of the SSR build based on the configuration
+ */
+export function getOutputDirectory(settings: AstroSettings): URL {
+ if (settings.buildOutput === 'server') {
+ return settings.config.build.server;
+ } else {
+ return getOutDirWithinCwd(settings.config.outDir);
+ }
+}
diff --git a/packages/astro/src/runtime/README.md b/packages/astro/src/runtime/README.md
new file mode 100644
index 000000000..68225fed1
--- /dev/null
+++ b/packages/astro/src/runtime/README.md
@@ -0,0 +1,9 @@
+# `runtime/`
+
+Code that executes within isolated contexts:
+
+- `client/`: executes within the browser. Astro’s client-side partial hydration code lives here, and only browser-compatible code can be used.
+- `server/`: executes inside Vite SSR. Though also a Node context, this is isolated from code in `core/`.
+- `compiler/`: same as `server/`, but only used by the Astro compiler `internalURL` option.
+
+[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts
new file mode 100644
index 000000000..7995d5209
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/astro.ts
@@ -0,0 +1,466 @@
+import type {
+ DevToolbarMetadata,
+ ResolvedDevToolbarApp,
+} from '../../../../types/public/toolbar.js';
+import { type Icon, isDefinedIcon } from '../ui-library/icons.js';
+import { colorForIntegration, iconForIntegration } from './utils/icons.js';
+import {
+ closeOnOutsideClick,
+ createWindowElement,
+ synchronizePlacementOnUpdate,
+} from './utils/window.js';
+
+const astroLogo =
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 99 26" width="100"><path fill="#fff" d="M6.70402 22.1453c-1.17459-1.0737-1.51748-3.3297-1.02811-4.9641.84853 1.0304 2.02424 1.3569 3.24204 1.5411 1.88005.2844 3.72635.178 5.47285-.6813.1998-.0984.3844-.2292.6027-.3617.1639.4755.2065.9554.1493 1.4439-.1392 1.1898-.7313 2.1088-1.673 2.8054-.3765.2787-.775.5278-1.1639.7905-1.1948.8075-1.518 1.7544-1.0691 3.1318.0107.0336.0202.0671.0444.149-.6101-.273-1.0557-.6705-1.39518-1.1931-.3586-.5517-.52921-1.1619-.53819-1.8221-.00449-.3213-.00449-.6455-.0477-.9623-.10551-.7722-.46804-1.118-1.15102-1.1379-.70094-.0205-1.2554.4129-1.40244 1.0953-.01122.0523-.02749.1041-.04377.1649l.00112.0006Z"/><path fill="url(#paint0_linear_386_2739)" d="M6.70402 22.1453c-1.17459-1.0737-1.51748-3.3297-1.02811-4.9641.84853 1.0304 2.02424 1.3569 3.24204 1.5411 1.88005.2844 3.72635.178 5.47285-.6813.1998-.0984.3844-.2292.6027-.3617.1639.4755.2065.9554.1493 1.4439-.1392 1.1898-.7313 2.1088-1.673 2.8054-.3765.2787-.775.5278-1.1639.7905-1.1948.8075-1.518 1.7544-1.0691 3.1318.0107.0336.0202.0671.0444.149-.6101-.273-1.0557-.6705-1.39518-1.1931-.3586-.5517-.52921-1.1619-.53819-1.8221-.00449-.3213-.00449-.6455-.0477-.9623-.10551-.7722-.46804-1.118-1.15102-1.1379-.70094-.0205-1.2554.4129-1.40244 1.0953-.01122.0523-.02749.1041-.04377.1649l.00112.0006Z"/><path fill="#fff" d="M0 16.909s3.47815-1.6944 6.96603-1.6944l2.62973-8.13858c.09846-.39359.38592-.66106.71044-.66106.3246 0 .612.26747.7105.66106l2.6297 8.13858c4.1309 0 6.966 1.6944 6.966 1.6944S14.7045.814589 14.693.782298C14.5234.306461 14.2371 0 13.8512 0H6.76183c-.38593 0-.66063.306461-.84174.782298C5.90733.81398 0 16.909 0 16.909ZM36.671 11.7318c0 1.4262-1.7739 2.2779-4.2302 2.2779-1.5985 0-2.1638-.3962-2.1638-1.2281 0-.8715.7018-1.2875 2.3003-1.2875 1.4426 0 2.6707.0198 4.0937.1981v.0396Zm.0195-1.7629c-.8772-.19808-2.2028-.31693-3.7818-.31693-4.6006 0-6.7644 1.08943-6.7644 3.62483 0 2.6344 1.4815 3.6446 4.9125 3.6446 2.9046 0 4.8735-.7328 5.5947-2.5354h.117c-.0195.4358-.039.8716-.039 1.2083 0 .931.156 1.0102.9162 1.0102h3.5869c-.1949-.5546-.3119-2.1194-.3119-3.4663 0-1.446.0585-2.5355.0585-4.00123 0-2.99098-1.7934-4.89253-7.4077-4.89253-2.4173 0-5.1074.41596-7.1543 1.03.1949.81213.4679 2.45617.6043 3.5258 1.774-.83193 4.2887-1.18847 6.2381-1.18847 2.6902 0 3.4309.61404 3.4309 1.86193v.4952ZM46.5325 12.5637c-.4874.0594-1.1502.0594-1.8325.0594-.7213 0-1.3841-.0198-1.8324-.0792 0 .1585-.0195.3367-.0195.4952 0 2.476 1.618 3.922 7.3102 3.922 5.3609 0 7.0958-1.4262 7.0958-3.9418 0-2.3769-1.1501-3.5456-6.238-3.8031-3.9573-.17827-4.3082-.61404-4.3082-1.10924 0-.57442.5068-.87154 3.158-.87154 2.7487 0 3.4894.37635 3.4894 1.16866v.17827c.3899-.01981 1.0917-.03961 1.813-.03961.6823 0 1.423.0198 1.8519.05942 0-.17827.0195-.33674.0195-.47539 0-2.91175-2.4172-3.86252-7.0958-3.86252-5.2634 0-7.0373 1.2875-7.0373 3.8031 0 2.25805 1.423 3.66445 6.472 3.88235 3.7233.1188 4.1327.5348 4.1327 1.1092 0 .6141-.6043.8914-3.2165.8914-3.0021 0-3.7623-.416-3.7623-1.2677v-.1189ZM63.6883 2.125c-1.423 1.32712-3.9768 2.65425-5.3998 3.01079.0195.73289.0195 2.07982.0195 2.81271l1.3061.01981c-.0195 1.40635-.039 3.10979-.039 4.23889 0 2.6344 1.3841 4.6152 5.6922 4.6152 1.813 0 3.0216-.1981 4.5226-.515-.1559-.9706-.3314-2.4562-.3898-3.5852-.8968.2971-2.0274.4556-3.275.4556-1.735 0-2.4368-.4754-2.4368-1.8422 0-1.1884 0-2.29767.0195-3.32768 2.2223.01981 4.4446.05943 5.7507.09904-.0195-1.03.0195-2.51559.078-3.50598-1.8909.03961-4.0157.05942-5.7702.05942.0195-.87154.039-1.70347.0585-2.5354h-.1365ZM75.3313 7.35427c.0195-1.03001.039-1.90156.0585-2.75329h-3.9183c.0585 1.70347.0585 3.44656.0585 6.00172 0 2.5553-.0195 4.3182-.0585 6.0018h4.4836c-.078-1.1885-.0975-3.189-.0975-4.8925 0-2.69388 1.0917-3.46638 3.5674-3.46638 1.1502 0 1.9689.13865 2.6902.39615.0195-1.01019.2144-2.97117.3314-3.84271-.7408-.21789-1.5595-.35655-2.5537-.35655-2.1249-.0198-3.6844.85174-4.4056 2.93156l-.156-.0198ZM94.8501 10.5235c0 2.1591-1.5595 3.1693-4.0157 3.1693-2.4368 0-3.9963-.9508-3.9963-3.1693 0-2.21846 1.579-3.05039 3.9963-3.05039 2.4367 0 4.0157.89135 4.0157 3.05039Zm4.0743-.099c0-4.29832-3.353-6.21968-8.09-6.21968-4.7566 0-7.9926 1.92136-7.9926 6.21968 0 4.2785 3.0216 6.5762 7.9731 6.5762 4.9904 0 8.1095-2.2977 8.1095-6.5762Z"/><defs><linearGradient id="paint0_linear_386_2739" x1="5.46011" x2="16.8017" y1="25.9999" y2="20.6412" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>';
+
+export interface Integration {
+ name: string;
+ title: string;
+ description: string;
+ image?: string;
+ categories: string[];
+ repoUrl: string;
+ npmUrl: string;
+ homepageUrl: string;
+ official: boolean;
+ featured: number;
+ downloads: number;
+}
+
+interface IntegrationData {
+ data: Integration[];
+}
+
+let integrationData: IntegrationData;
+
+export default {
+ id: 'astro:home',
+ name: 'Menu',
+ icon: 'astro:logo',
+ async init(canvas, eventTarget) {
+ createCanvas();
+
+ document.addEventListener('astro:after-swap', createCanvas);
+
+ eventTarget.addEventListener('app-toggled', async (event) => {
+ resetDebugButton();
+ if (!(event instanceof CustomEvent)) return;
+
+ if (event.detail.state === true) {
+ if (!integrationData) fetchIntegrationData();
+ }
+ });
+
+ closeOnOutsideClick(eventTarget);
+ synchronizePlacementOnUpdate(eventTarget, canvas);
+
+ function fetchIntegrationData() {
+ fetch('https://astro.build/api/v1/dev-overlay/', {
+ cache: 'no-cache',
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ integrationData = data;
+ integrationData.data = integrationData.data.map((integration) => {
+ return integration;
+ });
+ refreshIntegrationList();
+ });
+ }
+
+ function createCanvas() {
+ const links: { icon: Icon; name: string; link: string }[] = [
+ {
+ icon: 'bug',
+ name: 'Report a Bug',
+ link: 'https://github.com/withastro/astro/issues/new/choose',
+ },
+ {
+ icon: 'lightbulb',
+ name: 'Feedback',
+ link: 'https://github.com/withastro/roadmap/discussions/new/choose',
+ },
+ {
+ icon: 'file-search',
+ name: 'Documentation',
+ link: 'https://docs.astro.build',
+ },
+ {
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 14"><path fill="currentColor" d="M14.3451 1.9072c-1.0375-.47613-2.1323-.81595-3.257-1.010998-.0102-.001716-.0207-.000234-.03.004243s-.017.011728-.022.020757c-.141.249998-.297.576998-.406.832998-1.2124-.18399-2.44561-.18399-3.658 0-.12159-.28518-.25914-.56328-.412-.832998-.00513-.00893-.01285-.016098-.02213-.02056-.00928-.004462-.0197-.00601-.02987-.00444-1.125.193998-2.22.533998-3.257 1.010998-.00888.00339-.0163.00975-.021.018-2.074 3.099-2.643004 6.122-2.364004 9.107.001.014.01.028.021.037 1.207724.8946 2.558594 1.5777 3.995004 2.02.01014.0032.02103.0031.03111-.0003.01007-.0034.01878-.01.02489-.0187.308-.42.582-.863.818-1.329.00491-.0096.0066-.0205.0048-.0312-.00181-.0106-.007-.0204-.0148-.0278-.00517-.0049-.0113-.0086-.018-.011-.43084-.1656-.84811-.3645-1.248-.595-.01117-.0063-.01948-.0167-.0232-.029-.00373-.0123-.00258-.0255.0032-.037.0034-.0074.00854-.014.015-.019.084-.063.168-.129.248-.195.00706-.0057.01554-.0093.02453-.0106.00898-.0012.01813 0 .02647.0036 2.619 1.196 5.454 1.196 8.041 0 .0086-.0037.0181-.0051.0275-.0038.0093.0012.0181.0049.0255.0108.08.066.164.132.248.195.0068.005.0123.0116.0159.0192.0036.0076.0053.016.0049.0244-.0003.0084-.0028.0166-.0072.0238-.0043.0072-.0104.0133-.0176.0176-.399.2326-.8168.4313-1.249.594-.0069.0025-.0132.0065-.0183.0117-.0052.0051-.0092.0114-.0117.0183-.0023.0067-.0032.0138-.0027.0208.0005.0071.0024.0139.0057.0202.24.465.515.909.817 1.329.0061.0087.0148.0153.0249.0187.0101.0034.021.0035.0311.0003 1.4388-.441 2.7919-1.1241 4.001-2.02.0061-.0042.0111-.0097.0147-.0161.0037-.0064.0058-.0135.0063-.0209.334-3.451-.559-6.449-2.366-9.106-.0018-.00439-.0045-.00834-.008-.01162-.0034-.00327-.0075-.00578-.012-.00738Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z"/></svg>',
+ name: 'Community',
+ link: 'https://astro.build/chat',
+ },
+ ];
+
+ const hasNewerVersion = (window as DevToolbarMetadata).__astro_dev_toolbar__
+ .latestAstroVersion;
+
+ const windowComponent = createWindowElement(
+ `<style>
+ #buttons-container {
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+ }
+
+ #buttons-container astro-dev-toolbar-card {
+ flex: 1;
+ }
+
+ footer {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ }
+
+ footer a {
+ color: rgba(145, 152, 173, 1);
+ }
+
+ footer a:hover {
+ color: rgba(204, 206, 216, 1);
+ }
+
+ #main-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 24px;
+ }
+
+ p {
+ margin-top: 0;
+ }
+
+ header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ header section {
+ display: flex;
+ gap: 0.8em;
+ }
+
+ h2 {
+ color: white;
+ margin: 0;
+ font-size: 18px;
+ }
+
+ a {
+ color: rgba(224, 204, 250, 1);
+ }
+
+ a:hover {
+ color: #f4ecfd;
+ }
+
+ #integration-list-wrapper {
+ position: relative;
+ --offset: 24px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin-left: calc(var(--offset) * -1);
+ margin-right: calc(var(--offset) * -1);
+ padding-left: var(--offset);
+ padding-right: var(--offset);
+ height: 210px;
+ }
+
+ /* Pseudo-elements to fade cards as they scroll out of viewport */
+ #integration-list-wrapper::before,
+ #integration-list-wrapper::after {
+ content: '';
+ height: 192px;
+ display: block;
+ position: fixed;
+ width: var(--offset);
+ top: 106px;
+ background: red;
+ }
+
+ #integration-list-wrapper::before {
+ left: -1px;
+ border-left: 1px solid rgba(52, 56, 65, 1);
+ background: linear-gradient(to right, rgba(19, 21, 26, 1), rgba(19, 21, 26, 0));
+ }
+
+ #integration-list-wrapper::after {
+ right: -1px;
+ border-right: 1px solid rgba(52, 56, 65, 1);
+ background: linear-gradient(to left, rgba(19, 21, 26, 1), rgba(19, 21, 26, 0));
+ }
+
+ #integration-list-wrapper::-webkit-scrollbar {
+ width: 5px;
+ height: 8px;
+ background-color: rgba(255, 255, 255, 0.08); /* or add it to the track */
+ border-radius: 4px;
+ }
+
+ /* This is wild but gives us a gap on either side of the container */
+ #integration-list-wrapper::-webkit-scrollbar-button:start:decrement,
+ #integration-list-wrapper::-webkit-scrollbar-button:end:increment {
+ display: block;
+ width: 24px;
+ background-color: #13151A;
+ }
+
+ /* Removes arrows on both sides */
+ #integration-list-wrapper::-webkit-scrollbar-button:horizontal:start:increment,
+ #integration-list-wrapper::-webkit-scrollbar-button:horizontal:end:decrement {
+ display: none;
+ }
+
+ #integration-list-wrapper::-webkit-scrollbar-track-piece {
+ border-radius: 4px;
+ }
+
+ #integration-list-wrapper::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, 0.3);
+ border-radius: 4px;
+ }
+
+ #integration-list {
+ margin-top: 1em;
+ display: flex;
+ gap: 16px;
+ padding-bottom: 1em;
+ }
+
+ #integration-list::after {
+ content: " ";
+ display: inline-block;
+ white-space: pre;
+ width: 1px;
+ height: 1px;
+ }
+
+ #integration-list astro-dev-toolbar-card, .integration-skeleton {
+ min-width: 240px;
+ height: 160px;
+ }
+
+ .integration-skeleton {
+ animation: pulse 2s calc(var(--i, 0) * 250ms) cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ background-color: rgba(35, 38, 45, 1);
+ border-radius: 8px;
+ }
+
+ @keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: .5;
+ }
+ }
+
+ #integration-list astro-dev-toolbar-card .integration-image {
+ width: 40px;
+ height: 40px;
+ background-color: var(--integration-image-background, white);
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 8px;
+ }
+
+ #integration-list astro-dev-toolbar-card img {
+ width: 24px;
+ height: 24px;
+ }
+
+ #integration-list astro-dev-toolbar-card astro-dev-toolbar-icon {
+ width: 24px;
+ height: 24px;
+ color: #fff;
+ }
+
+ #links {
+ margin: auto 0;
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ }
+
+ #links a {
+ text-decoration: none;
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: 0.7em;
+ flex: 1;
+ white-space: nowrap;
+ font-weight: 600;
+ color: white;
+ }
+
+ #links a:hover {
+ color: rgba(145, 152, 173, 1);
+ }
+
+ #links astro-dev-toolbar-icon {
+ width: 1.5em;
+ height: 1.5em;
+ display: block;
+ }
+
+ #integration-list astro-dev-toolbar-card svg {
+ width: 24px;
+ height: 24px;
+ vertical-align: bottom;
+ }
+
+ #integration-list astro-dev-toolbar-card h3 {
+ margin: 0;
+ margin-bottom: 8px;
+ color: white;
+ white-space: nowrap;
+ }
+
+ #integration-list astro-dev-toolbar-card p {
+ font-size: 14px;
+ }
+
+ @media (forced-colors: active) {
+ svg path[fill="#fff"] {
+ fill: black;
+ }
+ }
+ </style>
+
+ <header>
+ <section>
+ ${astroLogo}
+ <astro-dev-toolbar-badge badge-style="gray" size="large">${
+ (window as DevToolbarMetadata).__astro_dev_toolbar__.version
+ }</astro-dev-toolbar-badge>
+ ${
+ hasNewerVersion
+ ? `<astro-dev-toolbar-badge badge-style="green" size="large">${
+ (window as DevToolbarMetadata).__astro_dev_toolbar__.latestAstroVersion
+ } available!</astro-dev-toolbar-badge>
+ `
+ : ''
+ }
+ </section>
+ <astro-dev-toolbar-button id="copy-debug-button">Copy debug info <astro-dev-toolbar-icon icon="copy" /></astro-dev-toolbar-button>
+ </header>
+ <hr />
+
+ <div id="main-container">
+ <div>
+ <header><h2>Featured integrations</h2><a href="https://astro.build/integrations/" target="_blank">View all</a></header>
+ <div id="integration-list-wrapper">
+ <section id="integration-list">
+ <div class="integration-skeleton" style="--i:0;"></div>
+ <div class="integration-skeleton" style="--i:1;"></div>
+ <div class="integration-skeleton" style="--i:2;"></div>
+ <div class="integration-skeleton" style="--i:3;"></div>
+ <div class="integration-skeleton" style="--i:4;"></div>
+ </section>
+ </div>
+ </div>
+ <section id="links">
+ ${links
+ .map(
+ (link) =>
+ `<a href="${link.link}" target="_blank"><astro-dev-toolbar-icon ${
+ isDefinedIcon(link.icon) ? `icon="${link.icon}">` : `>${link.icon}`
+ }</astro-dev-toolbar-icon>${link.name}</a>`,
+ )
+ .join('')}
+ </section>
+ </div>
+ `,
+ );
+
+ const copyDebugButton =
+ windowComponent.querySelector<HTMLButtonElement>('#copy-debug-button');
+
+ copyDebugButton?.addEventListener('click', () => {
+ navigator.clipboard.writeText(
+ '```\n' + (window as DevToolbarMetadata).__astro_dev_toolbar__.debugInfo + '\n```',
+ );
+ copyDebugButton.textContent = 'Copied to clipboard!';
+
+ setTimeout(() => {
+ resetDebugButton();
+ }, 3500);
+ });
+ canvas.append(windowComponent);
+
+ // If we have integration data, rebuild that part of the UI as well
+ // as it probably mean that the user had already open the app in this session (ex: view transitions)
+ if (integrationData) refreshIntegrationList();
+ }
+
+ function resetDebugButton() {
+ const copyDebugButton = canvas.querySelector<HTMLButtonElement>('#copy-debug-button');
+ if (!copyDebugButton) return;
+
+ copyDebugButton.innerHTML = 'Copy debug info <astro-dev-toolbar-icon icon="copy" />';
+ }
+
+ function refreshIntegrationList() {
+ const integrationList = canvas.querySelector<HTMLElement>('#integration-list');
+
+ if (!integrationList) return;
+ integrationList.innerHTML = '';
+
+ const fragment = document.createDocumentFragment();
+ for (const integration of integrationData.data) {
+ const integrationComponent = document.createElement('astro-dev-toolbar-card');
+ integrationComponent.link = integration.homepageUrl;
+
+ const integrationContainer = document.createElement('div');
+ integrationContainer.className = 'integration-container';
+
+ const integrationImage = document.createElement('div');
+ integrationImage.className = 'integration-image';
+
+ if (integration.image) {
+ const img = document.createElement('img');
+ img.src = integration.image;
+ img.alt = integration.title;
+ integrationImage.append(img);
+ } else {
+ const icon = document.createElement('astro-dev-toolbar-icon');
+ icon.icon = iconForIntegration(integration);
+ integrationImage.append(icon);
+ integrationImage.style.setProperty(
+ '--integration-image-background',
+ colorForIntegration(),
+ );
+ }
+
+ integrationContainer.append(integrationImage);
+
+ let integrationTitle = document.createElement('h3');
+ integrationTitle.textContent = integration.title;
+ if (integration.official || integration.categories.includes('official')) {
+ integrationTitle.innerHTML +=
+ ' <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 20"><rect width="19" height="19" x="1.16602" y=".5" fill="url(#paint0_linear_917_1096)" fill-opacity=".33" rx="9.5"/><path fill="#fff" d="M15.139 6.80657c-.062-.06248-.1357-.11208-.217-.14592-.0812-.03385-.1683-.05127-.2563-.05127-.0881 0-.1752.01742-.2564.05127-.0813.03384-.155.08344-.217.14592L9.22566 11.7799 7.13899 9.68657c-.06435-.06216-.14031-.11103-.22355-.14383-.08323-.03281-.17211-.04889-.26157-.04735-.08945.00155-.17773.0207-.25978.05637a.68120694.68120694 0 0 0-.21843.15148c-.06216.06435-.11104.14031-.14384.22355-.0328.08321-.04889.17211-.04734.26161.00154.0894.0207.1777.05636.2597.03566.0821.08714.1563.15148.2185l2.56 2.56c.06198.0625.13571.1121.21695.1459s.16838.0513.25639.0513c.088 0 .17514-.0175.25638-.0513s.15497-.0834.21695-.1459L15.139 7.78657c.0677-.06242.1217-.13819.1586-.22253.0369-.08433.056-.1754.056-.26747 0-.09206-.0191-.18313-.056-.26747-.0369-.08433-.0909-.1601-.1586-.22253Z"/><rect width="19" height="19" x="1.16602" y=".5" stroke="url(#paint1_linear_917_1096)" rx="9.5"/><defs><linearGradient id="paint0_linear_917_1096" x1="20.666" x2="-3.47548" y1=".00000136" y2="10.1345" gradientUnits="userSpaceOnUse"><stop stop-color="#4AF2C8"/><stop offset="1" stop-color="#2F4CB3"/></linearGradient><linearGradient id="paint1_linear_917_1096" x1="20.666" x2="-3.47548" y1=".00000136" y2="10.1345" gradientUnits="userSpaceOnUse"><stop stop-color="#4AF2C8"/><stop offset="1" stop-color="#2F4CB3"/></linearGradient></defs></svg>';
+ }
+ integrationContainer.append(integrationTitle);
+
+ const integrationDescription = document.createElement('p');
+ integrationDescription.textContent =
+ integration.description.length > 90
+ ? integration.description.slice(0, 90) + '…'
+ : integration.description;
+
+ integrationContainer.append(integrationDescription);
+ integrationComponent.append(integrationContainer);
+
+ fragment.append(integrationComponent);
+ }
+
+ integrationList.append(fragment);
+ }
+ },
+} satisfies ResolvedDevToolbarApp;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts
new file mode 100644
index 000000000..e6c7777cc
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts
@@ -0,0 +1,222 @@
+import type { ResolvedDevToolbarApp } from '../../../../../types/public/toolbar.js';
+import { settings } from '../../settings.js';
+import type { DevToolbarHighlight } from '../../ui-library/highlight.js';
+import { positionHighlight } from '../utils/highlight.js';
+import { closeOnOutsideClick } from '../utils/window.js';
+import { type AuditRule, rulesCategories } from './rules/index.js';
+import { DevToolbarAuditListItem } from './ui/audit-list-item.js';
+import { DevToolbarAuditListWindow } from './ui/audit-list-window.js';
+import { createAuditUI } from './ui/audit-ui.js';
+
+const icon =
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 1 20 16" aria-hidden="true"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>';
+
+export type Audit = {
+ auditedElement: HTMLElement;
+ rule: AuditRule;
+ highlight: DevToolbarHighlight | null;
+ card: HTMLElement | null;
+};
+
+try {
+ customElements.define('astro-dev-toolbar-audit-window', DevToolbarAuditListWindow);
+ customElements.define('astro-dev-toolbar-audit-list-item', DevToolbarAuditListItem);
+} catch {}
+
+let showState = false;
+
+export default {
+ id: 'astro:audit',
+ name: 'Audit',
+ icon: icon,
+ async init(canvas, eventTarget) {
+ let audits: Audit[] = [];
+ let auditWindow = document.createElement(
+ 'astro-dev-toolbar-audit-window',
+ ) as DevToolbarAuditListWindow;
+ let hasCreatedUI = false;
+
+ canvas.appendChild(auditWindow);
+
+ await lint();
+
+ let mutationDebounce: ReturnType<typeof setTimeout>;
+ const observer = new MutationObserver(() => {
+ // We don't want to rerun the audit lints on every single mutation, so we'll debounce it.
+ if (mutationDebounce) {
+ clearTimeout(mutationDebounce);
+ }
+
+ mutationDebounce = setTimeout(() => {
+ settings.logger.verboseLog('Rerunning audit lints because the DOM has been updated.');
+
+ // Even though we're ready to run the lints, we'll wait for the next idle period to do so, as it is less likely
+ // to interfere with any other work the browser is doing post-mutation. For instance, the page or the user might
+ // be interacting with the newly added elements, or the browser might be doing some work (layout, paint, etc.)
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(
+ async () => {
+ lint().then(() => {
+ if (showState) createAuditsUI();
+ });
+ },
+ { timeout: 300 },
+ );
+ } else {
+ // Fallback for old versions of Safari, we'll assume that things are less likely to be busy after 150ms.
+ setTimeout(async () => {
+ lint().then(() => {
+ if (showState) createAuditsUI();
+ });
+ }, 150);
+ }
+ }, 250);
+ });
+
+ setupObserver();
+
+ document.addEventListener('astro:before-preparation', () => {
+ observer.disconnect();
+ });
+ document.addEventListener('astro:after-swap', async () => {
+ lint();
+ });
+ document.addEventListener('astro:page-load', async () => {
+ refreshLintPositions();
+
+ // HACK: View transitions add a route announcer after this event, so we need to wait for it to be added
+ setTimeout(() => {
+ setupObserver();
+ }, 100);
+ });
+
+ eventTarget.addEventListener('app-toggled', (event: any) => {
+ if (event.detail.state === true) {
+ showState = true;
+ createAuditsUI();
+ } else {
+ showState = false;
+ }
+ });
+
+ closeOnOutsideClick(eventTarget, () => {
+ const activeAudits = audits.filter((audit) => audit.card?.hasAttribute('active'));
+
+ if (activeAudits.length > 0) {
+ activeAudits.forEach((audit) => {
+ audit.card?.toggleAttribute('active', false);
+ });
+ return true;
+ }
+
+ return false;
+ });
+
+ async function createAuditsUI() {
+ if (hasCreatedUI) return;
+
+ const fragment = document.createDocumentFragment();
+ for (const audit of audits) {
+ const { card, highlight } = createAuditUI(audit, audits);
+ audit.card = card;
+ audit.highlight = highlight;
+ fragment.appendChild(highlight);
+ }
+
+ auditWindow.audits = audits;
+ canvas.appendChild(fragment);
+
+ hasCreatedUI = true;
+ }
+
+ async function lint() {
+ // Clear the previous audits
+ if (audits.length > 0) {
+ audits.forEach((audit) => {
+ audit.highlight?.remove();
+ audit.card?.remove();
+ });
+ audits = [];
+ hasCreatedUI = false;
+ }
+
+ const selectorCache = new Map<string, NodeListOf<Element>>();
+ for (const ruleCategory of rulesCategories) {
+ for (const rule of ruleCategory.rules) {
+ const elements =
+ selectorCache.get(rule.selector) ?? document.querySelectorAll(rule.selector);
+ let matches: Element[] = [];
+ if (typeof rule.match === 'undefined') {
+ matches = Array.from(elements);
+ } else {
+ for (const element of elements) {
+ try {
+ if (await rule.match(element)) {
+ matches.push(element);
+ }
+ } catch (e) {
+ settings.logger.error(`Error while running audit's match function: ${e}`);
+ }
+ }
+ }
+ for (const element of matches) {
+ // Don't audit elements that already have an audit on them
+ // TODO: This is a naive implementation, it'd be good to show all the audits for an element at the same time.
+ if (audits.some((audit) => audit.auditedElement === element)) continue;
+
+ await createAuditProblem(rule, element);
+ }
+ }
+ }
+
+ eventTarget.dispatchEvent(
+ new CustomEvent('toggle-notification', {
+ detail: {
+ state: audits.length > 0,
+ },
+ }),
+ );
+ }
+
+ async function createAuditProblem(rule: AuditRule, originalElement: Element) {
+ const computedStyle = window.getComputedStyle(originalElement);
+ const targetedElement = (originalElement.children[0] as HTMLElement) || originalElement;
+
+ // If the element is hidden, don't do anything
+ if (targetedElement.offsetParent === null || computedStyle.display === 'none') {
+ return;
+ }
+
+ // If the element is an image but not yet loaded, ignore it
+ // TODO: We shouldn't ignore this, because it is valid for an image to not be loaded at start (e.g. lazy loading)
+ if (originalElement.nodeName === 'IMG' && !(originalElement as HTMLImageElement).complete) {
+ return;
+ }
+
+ audits.push({
+ auditedElement: originalElement as HTMLElement,
+ rule: rule,
+ card: null,
+ highlight: null,
+ });
+ }
+
+ function refreshLintPositions() {
+ audits.forEach(({ highlight, auditedElement }) => {
+ const rect = auditedElement.getBoundingClientRect();
+ if (highlight) positionHighlight(highlight, rect);
+ });
+ }
+
+ (['scroll', 'resize'] as const).forEach((event) => {
+ window.addEventListener(event, refreshLintPositions);
+ });
+
+ function setupObserver() {
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+ }
+ },
+} satisfies ResolvedDevToolbarApp;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts
new file mode 100644
index 000000000..b86e41b50
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts
@@ -0,0 +1,707 @@
+/**
+ * https://github.com/sveltejs/svelte/blob/61e5e53eee82e895c1a5b4fd36efb87eafa1fc2d/LICENSE.md
+ * @license MIT
+ *
+ * Copyright (c) 2016-23 [these people](https://github.com/sveltejs/svelte/graphs/contributors)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import type { ARIARoleDefinitionKey } from 'aria-query';
+import { aria, roles } from 'aria-query';
+// @ts-expect-error package does not provide types
+import { AXObjectRoles, elementAXObjects } from 'axobject-query';
+import type { AuditRuleWithSelector } from './index.js';
+
+const WHITESPACE_REGEX = /\s+/;
+
+const a11y_required_attributes = {
+ a: ['href'],
+ area: ['alt', 'aria-label', 'aria-labelledby'],
+ // html-has-lang
+ html: ['lang'],
+ // iframe-has-title
+ iframe: ['title'],
+ img: ['alt'],
+ object: ['title', 'aria-label', 'aria-labelledby'],
+};
+
+const MAYBE_INTERACTIVE = new Map([
+ ['a', 'href'],
+ ['input', 'type'],
+ ['audio', 'controls'],
+ ['img', 'usemap'],
+ ['object', 'usemap'],
+ ['video', 'controls'],
+]);
+
+const interactiveElements = [
+ 'button',
+ 'details',
+ 'embed',
+ 'iframe',
+ 'label',
+ 'select',
+ 'textarea',
+ ...MAYBE_INTERACTIVE.keys(),
+];
+
+const labellableElements = ['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea'];
+
+const aria_non_interactive_roles = [
+ 'alert',
+ 'alertdialog',
+ 'application',
+ 'article',
+ 'banner',
+ 'cell',
+ 'columnheader',
+ 'complementary',
+ 'contentinfo',
+ 'definition',
+ 'dialog',
+ 'directory',
+ 'document',
+ 'feed',
+ 'figure',
+ 'form',
+ 'group',
+ 'heading',
+ 'img',
+ 'list',
+ 'listitem',
+ 'log',
+ 'main',
+ 'marquee',
+ 'math',
+ 'menuitemradio',
+ 'navigation',
+ 'none',
+ 'note',
+ 'presentation',
+ 'region',
+ 'row',
+ 'rowgroup',
+ 'rowheader',
+ 'search',
+ 'status',
+ 'tabpanel',
+ 'term',
+ 'timer',
+ 'toolbar',
+ 'tooltip',
+];
+
+// These elements aren't interactive and aren't non-interactive. Their interaction changes based on the role assigned to them
+// https://www.w3.org/TR/html-aria/#docconformance -> look at the table, specification for the `div` and `span` elements.
+const roleless_elements = ['div', 'span'];
+
+const a11y_required_content = [
+ // anchor-has-content
+ 'a',
+ // heading-has-content
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+];
+
+const a11y_distracting_elements = ['blink', 'marquee'];
+
+const a11y_implicit_semantics = new Map([
+ ['a', 'link'],
+ ['area', 'link'],
+ ['article', 'article'],
+ ['aside', 'complementary'],
+ ['body', 'document'],
+ ['button', 'button'],
+ ['datalist', 'listbox'],
+ ['dd', 'definition'],
+ ['dfn', 'term'],
+ ['dialog', 'dialog'],
+ ['details', 'group'],
+ ['dt', 'term'],
+ ['fieldset', 'group'],
+ ['figure', 'figure'],
+ ['form', 'form'],
+ ['h1', 'heading'],
+ ['h2', 'heading'],
+ ['h3', 'heading'],
+ ['h4', 'heading'],
+ ['h5', 'heading'],
+ ['h6', 'heading'],
+ ['hr', 'separator'],
+ ['img', 'img'],
+ ['li', 'listitem'],
+ ['link', 'link'],
+ ['main', 'main'],
+ ['menu', 'list'],
+ ['meter', 'progressbar'],
+ ['nav', 'navigation'],
+ ['ol', 'list'],
+ ['option', 'option'],
+ ['optgroup', 'group'],
+ ['output', 'status'],
+ ['progress', 'progressbar'],
+ ['section', 'region'],
+ ['summary', 'button'],
+ ['table', 'table'],
+ ['tbody', 'rowgroup'],
+ ['textarea', 'textbox'],
+ ['tfoot', 'rowgroup'],
+ ['thead', 'rowgroup'],
+ ['tr', 'row'],
+ ['ul', 'list'],
+]);
+const menuitem_type_to_implicit_role = new Map([
+ ['command', 'menuitem'],
+ ['checkbox', 'menuitemcheckbox'],
+ ['radio', 'menuitemradio'],
+]);
+const input_type_to_implicit_role = new Map([
+ ['button', 'button'],
+ ['image', 'button'],
+ ['reset', 'button'],
+ ['submit', 'button'],
+ ['checkbox', 'checkbox'],
+ ['radio', 'radio'],
+ ['range', 'slider'],
+ ['number', 'spinbutton'],
+ ['email', 'textbox'],
+ ['search', 'searchbox'],
+ ['tel', 'textbox'],
+ ['text', 'textbox'],
+ ['url', 'textbox'],
+]);
+
+// All the WAI-ARIA 1.2 attributes from https://www.w3.org/TR/wai-aria-1.2/#state_prop_def
+const ariaAttributes = new Set(
+ 'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(
+ ' ',
+ ),
+);
+
+// All the WAI-ARIA 1.2 role attribute values from https://www.w3.org/TR/wai-aria-1.2/#role_definitions
+const ariaRoles = new Set(
+ 'alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox command complementary composite contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img input insertion landmark link list listbox listitem log main marquee math meter menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option paragraph presentation progressbar radio radiogroup range region roletype row rowgroup rowheader scrollbar search searchbox section sectionhead select separator slider spinbutton status strong structure subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem widget window'.split(
+ ' ',
+ ),
+);
+
+function isInteractive(element: Element): boolean {
+ const attribute = MAYBE_INTERACTIVE.get(element.localName);
+ if (attribute) {
+ return element.hasAttribute(attribute);
+ }
+
+ return true;
+}
+
+export const a11y: AuditRuleWithSelector[] = [
+ {
+ code: 'a11y-accesskey',
+ title: 'Avoid using `accesskey`',
+ message:
+ "The `accesskey` attribute can cause accessibility issues. The shortcuts can conflict with the browser's or operating system's shortcuts, and they are difficult for users to discover and use.",
+ selector: '[accesskey]',
+ },
+ {
+ code: 'a11y-aria-activedescendant-has-tabindex',
+ title: 'Elements with attribute `aria-activedescendant` must be tabbable',
+ message:
+ 'Element with the `aria-activedescendant` attribute must either have an inherent `tabindex` or declare `tabindex` as an attribute.',
+ selector: '[aria-activedescendant]',
+ match(element) {
+ if (!(element as HTMLElement).tabIndex && !element.hasAttribute('tabindex')) return true;
+ },
+ },
+ {
+ code: 'a11y-aria-attributes',
+ title: 'Element does not support ARIA roles.',
+ message: 'Elements like `meta`, `html`, `script`, `style` do not support having ARIA roles.',
+ selector: ':is(meta, html, script, style)[role]',
+ match(element) {
+ for (const attribute of element.attributes) {
+ if (attribute.name.startsWith('aria-')) return true;
+ }
+ },
+ },
+ {
+ code: 'a11y-autofocus',
+ title: 'Avoid using `autofocus`',
+ message:
+ 'The `autofocus` attribute can cause accessibility issues, as it can cause the focus to move around unexpectedly for screen reader users.',
+ selector: '[autofocus]',
+ },
+ {
+ code: 'a11y-distracting-elements',
+ title: 'Distracting elements should not be used',
+ message:
+ 'Elements that can be visually distracting like `<marquee>` or `<blink>` can cause accessibility issues for visually impaired users and should be avoided.',
+ selector: `:is(${a11y_distracting_elements.join(',')})`,
+ },
+ {
+ code: 'a11y-hidden',
+ title: 'Certain DOM elements are useful for screen reader navigation and should not be hidden',
+ message: (element) => `${element.localName} element should not be hidden.`,
+ selector: '[aria-hidden]:is(h1,h2,h3,h4,h5,h6)',
+ },
+ {
+ code: 'a11y-img-redundant-alt',
+ title: 'Redundant text in alt attribute',
+ message:
+ 'Screen readers already announce `img` elements as an image. There is no need to use words such as "image", "photo", and/or "picture".',
+ selector: 'img[alt]:not([aria-hidden])',
+ match: (img: HTMLImageElement) => /\b(?:image|picture|photo)\b/i.test(img.alt),
+ },
+ {
+ code: 'a11y-incorrect-aria-attribute-type',
+ title: 'Incorrect value for ARIA attribute.',
+ message: '`aria-hidden` should only receive a boolean.',
+ selector: '[aria-hidden]',
+ match(element) {
+ const value = element.getAttribute('aria-hidden');
+ if (!value) return true;
+ if (!['true', 'false'].includes(value)) return true;
+ },
+ },
+ {
+ code: 'a11y-invalid-href',
+ title: 'Invalid `href` attribute',
+ message: "`href` should not be empty, `'#'`, or `javascript:`.",
+ selector: 'a[href]:is([href=""], [href="#"], [href^="javascript:" i])',
+ },
+ {
+ code: 'a11y-invalid-label',
+ title: '`label` element should have an associated control and a text content.',
+ message:
+ 'The `label` element must be associated with a control either by using the `for` attribute or by containing a nested form element. Additionally, the `label` element must have text content.',
+ selector: 'label',
+ match(element: HTMLLabelElement) {
+ // Label must be associated with a control, either using `for` or having a nested valid element
+ const hasFor = element.hasAttribute('for');
+ const nestedLabellableElement = element.querySelector(`${labellableElements.join(', ')}`);
+ if (!hasFor && !nestedLabellableElement) return true;
+
+ // Label must have text content, using innerText to ignore hidden text
+ const innerText = element.innerText.trim();
+ if (innerText === '') return true;
+ },
+ },
+ {
+ code: 'a11y-media-has-caption',
+ title: 'Unmuted video elements should have captions',
+ message:
+ 'Videos without captions can be difficult for deaf and hard-of-hearing users to follow along with. If the video does not need captions, add the `muted` attribute.',
+ selector: 'video:not([muted])',
+ match(element) {
+ const tracks = element.querySelectorAll('track');
+ if (!tracks.length) return true;
+
+ const hasCaptionTrack = Array.from(tracks).some(
+ (track) => track.getAttribute('kind') === 'captions',
+ );
+
+ return !hasCaptionTrack;
+ },
+ },
+ {
+ code: 'a11y-misplaced-scope',
+ title: 'The `scope` attribute should only be used on `<th>` elements',
+ message:
+ 'The `scope` attribute tells the browser and screen readers how to navigate tables. In HTML5, it should only be used on `<th>` elements.',
+ selector: ':not(th)[scope]',
+ },
+ {
+ code: 'a11y-missing-attribute',
+ title: 'Required attributes missing.',
+ description:
+ 'Some HTML elements require additional attributes for accessibility. For example, an `img` element requires an `alt` attribute, this attribute is used to describe the content of the image for screen readers.',
+ message: (element) => {
+ const requiredAttributes =
+ a11y_required_attributes[element.localName as keyof typeof a11y_required_attributes];
+
+ const missingAttributes = requiredAttributes.filter(
+ (attribute) => !element.hasAttribute(attribute),
+ );
+
+ return `${
+ element.localName
+ } element is missing required attributes for accessibility: ${missingAttributes.join(', ')} `;
+ },
+ selector: Object.keys(a11y_required_attributes).join(','),
+ match(element) {
+ const requiredAttributes =
+ a11y_required_attributes[element.localName as keyof typeof a11y_required_attributes];
+
+ if (!requiredAttributes) return true;
+ for (const attribute of requiredAttributes) {
+ if (!element.hasAttribute(attribute)) return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ code: 'a11y-missing-content',
+ title: 'Missing content',
+ message:
+ 'Headings and anchors must have an accessible name, which can come from: inner text, aria-label, aria-labelledby, an img with alt property, or an svg with a tag <title></title>.',
+ selector: a11y_required_content.join(','),
+ match(element: HTMLElement) {
+ // innerText is used to ignore hidden text
+ const innerText = element.innerText?.trim();
+ if (innerText && innerText !== '') return false;
+
+ // Check for aria-label
+ const ariaLabel = element.getAttribute('aria-label')?.trim();
+ if (ariaLabel && ariaLabel !== '') return false;
+
+ // Check for valid aria-labelledby
+ const ariaLabelledby = element.getAttribute('aria-labelledby')?.trim();
+ if (ariaLabelledby) {
+ const ids = ariaLabelledby.split(' ');
+ for (const id of ids) {
+ const referencedElement = document.getElementById(id);
+ if (referencedElement && referencedElement.innerText.trim() !== '') return false;
+ }
+ }
+
+ // Check for <img> with valid alt attribute
+ const imgElements = element.querySelectorAll('img');
+ for (const img of imgElements) {
+ const altAttribute = img.getAttribute('alt');
+ if (altAttribute && altAttribute.trim() !== '') return false;
+ }
+
+ // Check for <svg> with valid title
+ const svgElements = element.querySelectorAll('svg');
+ for (const svg of svgElements) {
+ const titleText = svg.querySelector('title');
+ if (titleText && titleText.textContent && titleText.textContent.trim() !== '') return false;
+ }
+
+ const inputElements = element.querySelectorAll('input');
+ for (const input of inputElements) {
+ // Check for alt attribute if input type is image
+ if (input.type === 'image') {
+ const altAttribute = input.getAttribute('alt');
+ if (altAttribute && altAttribute.trim() !== '') return false;
+ }
+
+ // Check for aria-label
+ const inputAriaLabel = input.getAttribute('aria-label')?.trim();
+ if (inputAriaLabel && inputAriaLabel !== '') return false;
+
+ // Check for aria-labelledby
+ const inputAriaLabelledby = input.getAttribute('aria-labelledby')?.trim();
+ if (inputAriaLabelledby) {
+ const ids = inputAriaLabelledby.split(' ');
+ for (const id of ids) {
+ const referencedElement = document.getElementById(id);
+ if (referencedElement && referencedElement.innerText.trim() !== '') return false;
+ }
+ }
+
+ // Check for title
+ const title = input.getAttribute('title')?.trim();
+ if (title && title !== '') return false;
+ }
+
+ // If all checks fail, return true indicating missing content
+ return true;
+ },
+ },
+ {
+ code: 'a11y-no-redundant-roles',
+ title: 'HTML element has redundant ARIA roles',
+ message:
+ 'Giving these elements an ARIA role that is already set by the browser has no effect and is redundant.',
+ selector: [...a11y_implicit_semantics.keys()].join(','),
+ match(element) {
+ const role = element.getAttribute('role');
+
+ if (element.localName === 'input') {
+ const type = element.getAttribute('type');
+ if (!type) return true;
+
+ const implicitRoleForType = input_type_to_implicit_role.get(type);
+ if (!implicitRoleForType) return true;
+
+ if (role === implicitRoleForType) return false;
+ }
+
+ // TODO: Handle menuitem and elements that inherit their role from their parent
+
+ const implicitRole = a11y_implicit_semantics.get(element.localName);
+ if (!implicitRole) return true;
+
+ if (role === implicitRole) return false;
+ },
+ },
+ {
+ code: 'a11y-no-interactive-element-to-noninteractive-role',
+ title: 'Non-interactive ARIA role used on interactive HTML element.',
+ message:
+ 'Interactive HTML elements like `<a>` and `<button>` cannot use non-interactive roles like `heading`, `list`, `menu`, and `toolbar`.',
+ selector: `[role]:is(${interactiveElements.join(',')})`,
+ match(element) {
+ if (!isInteractive(element)) return false;
+ const role = element.getAttribute('role');
+ if (!role) return false;
+ if (!ariaRoles.has(role)) return false;
+ if (roleless_elements.includes(element.localName)) return false;
+
+ if (aria_non_interactive_roles.includes(role)) return true;
+ },
+ },
+ {
+ code: 'a11y-no-noninteractive-element-to-interactive-role',
+ title: 'Interactive ARIA role used on non-interactive HTML element.',
+ message:
+ 'Interactive roles should not be used to convert a non-interactive element to an interactive element',
+ selector: `[role]:not(${interactiveElements.join(',')})`,
+ match(element) {
+ if (!isInteractive(element)) return false;
+ const role = element.getAttribute('role');
+ if (!role) return false;
+ if (!ariaRoles.has(role)) return false;
+ const exceptions =
+ a11y_non_interactive_element_to_interactive_role_exceptions[
+ element.localName as keyof typeof a11y_non_interactive_element_to_interactive_role_exceptions
+ ];
+ if (exceptions?.includes(role)) return false;
+ if (roleless_elements.includes(element.localName)) return false;
+
+ if (!aria_non_interactive_roles.includes(role)) return true;
+ },
+ },
+ {
+ code: 'a11y-no-noninteractive-tabindex',
+ title: 'Invalid `tabindex` on non-interactive element',
+ description:
+ 'The `tabindex` attribute should only be used on interactive elements, as it can be confusing for keyboard-only users to navigate through non-interactive elements. If your element is only conditionally interactive, consider using `tabindex="-1"` to make it focusable only when it is actually interactive.',
+ message: (element) => `${element.localName} elements should not have \`tabindex\` attribute`,
+ selector: '[tabindex]:not([role="tabpanel"])',
+ match(element) {
+ // Scrollable elements are considered interactive
+ // See: https://www.w3.org/WAI/standards-guidelines/act/rules/0ssw9k/proposed/
+ const isScrollable =
+ element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
+ if (isScrollable) return false;
+
+ if (!isInteractive(element)) return false;
+
+ if (
+ !interactiveElements.includes(element.localName) &&
+ !roleless_elements.includes(element.localName)
+ )
+ return true;
+ },
+ },
+ {
+ code: 'a11y-positive-tabindex',
+ title: 'Avoid positive `tabindex` property values',
+ message:
+ 'This will move elements out of the expected tab order, creating a confusing experience for keyboard users.',
+ selector: '[tabindex]:not([tabindex="-1"]):not([tabindex="0"])',
+ },
+ {
+ code: 'a11y-role-has-required-aria-props',
+ title: 'Missing attributes required for ARIA role',
+ message: (element) => {
+ const { __astro_role: role, __astro_missing_attributes: required } = element as any;
+ return `${
+ element.localName
+ } element is missing required attributes for its role (${role}): ${required.join(', ')}`;
+ },
+ selector: '*',
+ match(element) {
+ const role = getRole(element);
+ if (!role) return false;
+ if (is_semantic_role_element(role, element.localName, getAttributeObject(element))) {
+ return;
+ }
+
+ const elementRoles = role.split(WHITESPACE_REGEX) as ARIARoleDefinitionKey[];
+ for (const elementRole of elementRoles) {
+ const { requiredProps } = roles.get(elementRole)!;
+ const required_role_props = Object.keys(requiredProps);
+ const missingProps = required_role_props.filter((prop) => !element.hasAttribute(prop));
+ if (missingProps.length > 0) {
+ (element as any).__astro_role = elementRole;
+ (element as any).__astro_missing_attributes = missingProps;
+ return true;
+ }
+ }
+ },
+ },
+
+ {
+ code: 'a11y-role-supports-aria-props',
+ title: 'Unsupported ARIA attribute',
+ message: (element) => {
+ const { __astro_role: role, __astro_unsupported_attributes: unsupported } = element as any;
+ return `${
+ element.localName
+ } element has ARIA attributes that are not supported by its role (${role}): ${unsupported.join(
+ ', ',
+ )}`;
+ },
+ selector: '*',
+ match(element) {
+ const role = getRole(element);
+ if (!role) return false;
+
+ const elementRoles = role.split(WHITESPACE_REGEX) as ARIARoleDefinitionKey[];
+ for (const elementRole of elementRoles) {
+ const { props } = roles.get(elementRole)!;
+ const attributes = getAttributeObject(element);
+ const unsupportedAttributes = aria.keys().filter((attribute) => !(attribute in props));
+ const invalidAttributes: string[] = Object.keys(attributes).filter(
+ (key) => key.startsWith('aria-') && unsupportedAttributes.includes(key as any),
+ );
+ if (invalidAttributes.length > 0) {
+ (element as any).__astro_role = elementRole;
+ (element as any).__astro_unsupported_attributes = invalidAttributes;
+ return true;
+ }
+ }
+ },
+ },
+ {
+ code: 'a11y-structure',
+ title: 'Invalid DOM structure',
+ message:
+ 'The DOM structure must be valid for accessibility of the page, for example `figcaption` must be a direct child of `figure`.',
+ selector: 'figcaption:not(figure > figcaption)',
+ },
+ {
+ code: 'a11y-unknown-aria-attribute',
+ title: 'Unknown ARIA attribute',
+ message: 'ARIA attributes prefixed with `aria-` must be valid, non-abstract ARIA attributes.',
+ selector: '*',
+ match(element) {
+ for (const attribute of element.attributes) {
+ if (attribute.name.startsWith('aria-')) {
+ if (!ariaAttributes.has(attribute.name.slice('aria-'.length))) return true;
+ }
+ }
+ },
+ },
+ {
+ code: 'a11y-unknown-role',
+ title: 'Unknown ARIA role',
+ message: 'ARIA roles must be valid, non-abstract ARIA roles.',
+ selector: '[role]',
+ match(element) {
+ const role = element.getAttribute('role');
+ if (!role) return true;
+ if (!ariaRoles.has(role)) return true;
+ },
+ },
+];
+
+/**
+ * Exceptions to the rule which follows common A11y conventions
+ * TODO make this configurable by the user
+ * @type {Record<string, string[]>}
+ */
+const a11y_non_interactive_element_to_interactive_role_exceptions = {
+ ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
+ ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
+ li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
+ table: ['grid'],
+ td: ['gridcell'],
+ fieldset: ['radiogroup', 'presentation'],
+};
+
+const combobox_if_list = ['email', 'search', 'tel', 'text', 'url'];
+function input_implicit_role(attributes: Record<string, string>) {
+ if (!('type' in attributes)) return;
+ const { type, list } = attributes;
+ if (!type) return;
+ if (list && combobox_if_list.includes(type)) {
+ return 'combobox';
+ }
+ return input_type_to_implicit_role.get(type);
+}
+
+function menuitem_implicit_role(attributes: Record<string, string>) {
+ if (!('type' in attributes)) return;
+ const { type } = attributes;
+ if (!type) return;
+ return menuitem_type_to_implicit_role.get(type);
+}
+
+function getRole(element: Element): ARIARoleDefinitionKey | undefined {
+ if (element.hasAttribute('role')) {
+ return element.getAttribute('role')! as ARIARoleDefinitionKey;
+ }
+ return getImplicitRole(element) as ARIARoleDefinitionKey;
+}
+
+function getImplicitRole(element: Element) {
+ const name = element.localName;
+ const attrs = getAttributeObject(element);
+ if (name === 'menuitem') {
+ return menuitem_implicit_role(attrs);
+ } else if (name === 'input') {
+ return input_implicit_role(attrs);
+ } else {
+ return a11y_implicit_semantics.get(name);
+ }
+}
+
+function getAttributeObject(element: Element): Record<string, string> {
+ let obj: Record<string, string> = {};
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attribute = element.attributes.item(i)!;
+ obj[attribute.name] = attribute.value;
+ }
+ return obj;
+}
+
+function is_semantic_role_element(
+ role: ARIARoleDefinitionKey,
+ tag_name: string,
+ attributes: Record<string, string>,
+) {
+ for (const [schema, ax_object] of elementAXObjects.entries()) {
+ if (
+ schema.name === tag_name &&
+ (!schema.attributes ||
+ schema.attributes.every((attr: any) => attributes[attr.name] === attr.value))
+ ) {
+ for (const name of ax_object) {
+ const axRoles = AXObjectRoles.get(name);
+ if (axRoles) {
+ for (const { name: _name } of axRoles) {
+ if (_name === role) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ }
+ return false;
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/index.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/index.ts
new file mode 100644
index 000000000..935f5376f
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/index.ts
@@ -0,0 +1,78 @@
+import { settings } from '../../../settings.js';
+import type { DefinedIcon } from '../../../ui-library/icons.js';
+import { a11y } from './a11y.js';
+import { perf } from './perf.js';
+
+type DynamicString = string | ((element: Element) => string);
+
+export interface AuditRule {
+ code: string;
+ title: DynamicString;
+ message: DynamicString;
+ description?: DynamicString;
+}
+
+export interface ResolvedAuditRule {
+ code: string;
+ title: string;
+ message: string;
+ description?: string;
+}
+
+export interface AuditRuleWithSelector extends AuditRule {
+ selector: string;
+ match?: (
+ element: Element,
+ ) =>
+ | boolean
+ | null
+ | undefined
+ | void
+ | Promise<boolean>
+ | Promise<void>
+ | Promise<null>
+ | Promise<undefined>;
+}
+
+interface RuleCategory {
+ code: string;
+ name: string;
+ icon: DefinedIcon;
+ rules: AuditRule[];
+}
+
+export const rulesCategories = [
+ { code: 'a11y', name: 'Accessibility', icon: 'person-arms-spread', rules: a11y },
+ { code: 'perf', name: 'Performance', icon: 'gauge', rules: perf },
+] satisfies RuleCategory[];
+
+const dynamicAuditRuleKeys: Array<keyof AuditRule> = ['title', 'message', 'description'];
+
+export function resolveAuditRule(rule: AuditRule, element: Element): ResolvedAuditRule {
+ let resolved: ResolvedAuditRule = { ...rule } as any;
+ for (const key of dynamicAuditRuleKeys) {
+ const value = rule[key];
+ if (typeof value === 'string') continue;
+ try {
+ if (!value) {
+ resolved[key] = '';
+ continue;
+ }
+
+ resolved[key] = value(element);
+ } catch (err) {
+ settings.logger.error(`Error resolving dynamic audit rule ${rule.code}'s ${key}: ${err}`);
+ resolved[key] = 'Error resolving dynamic rule';
+ }
+ }
+ return resolved;
+}
+
+export function getAuditCategory(rule: AuditRule): 'perf' | 'a11y' {
+ return rule.code.split('-')[0] as 'perf' | 'a11y';
+}
+
+export const categoryLabel = {
+ perf: 'performance',
+ a11y: 'accessibility',
+};
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/perf.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/perf.ts
new file mode 100644
index 000000000..18c0f7d35
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/perf.ts
@@ -0,0 +1,144 @@
+import type { AuditRuleWithSelector } from './index.js';
+
+// A regular expression to match external URLs
+const EXTERNAL_URL_REGEX = /^(?:[a-z+]+:)?\/\//i;
+
+export const perf: AuditRuleWithSelector[] = [
+ {
+ code: 'perf-use-image-component',
+ title: 'Use the Image component',
+ message: 'This image could be replaced with the Image component to improve performance.',
+ selector: 'img:not([data-image-component])',
+ async match(element) {
+ const src = element.getAttribute('src');
+ if (!src) return false;
+
+ // Don't match data URIs, they're typically used for specific use-cases that the image component doesn't help with
+ if (src.startsWith('data:')) return false;
+
+ // Ignore images that are smaller than 20KB, most of the time the image component won't really help with these, or they're used for specific use-cases (pixel tracking, etc.)
+ // Ignore this test for remote images for now, fetching them can be very slow and possibly dangerous
+ if (!EXTERNAL_URL_REGEX.test(src)) {
+ const imageData = await fetch(src).then((response) => response.blob());
+ if (imageData.size < 20480) return false;
+ }
+
+ return true;
+ },
+ },
+ {
+ code: 'perf-use-loading-lazy',
+ title: 'Unoptimized loading attribute',
+ message: (element) =>
+ `This ${element.nodeName} tag is below the fold and could be lazy-loaded to improve performance.`,
+ selector:
+ 'img:not([loading]), img[loading="eager"], iframe:not([loading]), iframe[loading="eager"]',
+ match(element) {
+ const htmlElement = element as HTMLImageElement | HTMLIFrameElement;
+
+ // Ignore elements that are above the fold, they should be loaded eagerly
+ let currentElement = element as HTMLElement;
+ let elementYPosition = 0;
+ while (currentElement) {
+ elementYPosition += currentElement.offsetTop;
+ currentElement = currentElement.offsetParent as HTMLElement;
+ }
+ if (elementYPosition < window.innerHeight) return false;
+
+ // Ignore elements using `data:` URI, the `loading` attribute doesn't do anything for these
+ if (htmlElement.src.startsWith('data:')) return false;
+
+ return true;
+ },
+ },
+ {
+ code: 'perf-use-loading-eager',
+ title: 'Unoptimized loading attribute',
+ message: (element) =>
+ `This ${element.nodeName} tag is above the fold and could be eagerly-loaded to improve performance.`,
+ selector: 'img[loading="lazy"], iframe[loading="lazy"]',
+ match(element) {
+ const htmlElement = element as HTMLImageElement | HTMLIFrameElement;
+
+ // Ignore elements that are below the fold, they should be loaded lazily
+ let currentElement = element as HTMLElement;
+ let elementYPosition = 0;
+ while (currentElement) {
+ elementYPosition += currentElement.offsetTop;
+ currentElement = currentElement.offsetParent as HTMLElement;
+ }
+ if (elementYPosition > window.innerHeight) return false;
+
+ // Ignore elements using `data:` URI, the `loading` attribute doesn't do anything for these
+ if (htmlElement.src.startsWith('data:')) return false;
+
+ return true;
+ },
+ },
+ {
+ code: 'perf-use-videos',
+ title: 'Use videos instead of GIFs for large animations',
+ message:
+ 'This GIF could be replaced with a video to reduce its file size and improve performance.',
+ selector: 'img[src$=".gif"]',
+ async match(element) {
+ const src = element.getAttribute('src');
+ if (!src) return false;
+
+ // Ignore remote URLs
+ if (EXTERNAL_URL_REGEX.test(src)) return false;
+
+ // Ignore GIFs that are smaller than 100KB, those are typically small enough to not be a problem
+ if (!EXTERNAL_URL_REGEX.test(src)) {
+ const imageData = await fetch(src).then((response) => response.blob());
+ if (imageData.size < 102400) return false;
+ }
+
+ return true;
+ },
+ },
+ {
+ code: 'perf-slow-component-server-render',
+ title: 'Server-rendered component took a long time to render',
+ message: (element) =>
+ `This component took an unusually long time to render on the server (${getCleanRenderingTime(
+ element.getAttribute('server-render-time'),
+ )}). This might be a sign that it's doing too much work on the server, or something is blocking rendering.`,
+ selector: 'astro-island[server-render-time]',
+ match(element) {
+ const serverRenderTime = element.getAttribute('server-render-time');
+ if (!serverRenderTime) return false;
+
+ const renderingTime = parseFloat(serverRenderTime);
+ if (Number.isNaN(renderingTime)) return false;
+
+ return renderingTime > 500;
+ },
+ },
+ {
+ code: 'perf-slow-component-client-hydration',
+ title: 'Client-rendered component took a long time to hydrate',
+ message: (element) =>
+ `This component took an unusually long time to render on the server (${getCleanRenderingTime(
+ element.getAttribute('client-render-time'),
+ )}). This could be a sign that something is blocking the main thread and preventing the component from hydrating quickly.`,
+ selector: 'astro-island[client-render-time]',
+ match(element) {
+ const clientRenderTime = element.getAttribute('client-render-time');
+ if (!clientRenderTime) return false;
+
+ const renderingTime = parseFloat(clientRenderTime);
+ if (Number.isNaN(renderingTime)) return false;
+
+ return renderingTime > 500;
+ },
+ },
+];
+
+function getCleanRenderingTime(time: string | null) {
+ if (!time) return 'unknown';
+ const renderingTime = parseFloat(time);
+ if (Number.isNaN(renderingTime)) return 'unknown';
+
+ return renderingTime.toFixed(2) + 's';
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-item.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-item.ts
new file mode 100644
index 000000000..421f76a17
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-item.ts
@@ -0,0 +1,146 @@
+export class DevToolbarAuditListItem extends HTMLElement {
+ clickAction?: () => void | (() => Promise<void>);
+ shadowRoot: ShadowRoot;
+ isManualFocus: boolean;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ this.isManualFocus = false;
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host>button, :host>div {
+ box-sizing: border-box;
+ padding: 16px;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid #1F2433;
+ text-decoration: none;
+ width: 100%;
+ height: 100%;
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ color: #fff;
+ font-weight: 600;
+ }
+
+ :host>button:hover, :host([hovered])>button {
+ background: #FFFFFF20;
+ }
+
+ svg {
+ display: block;
+ margin: 0 auto;
+ }
+
+ :host>button#astro-overlay-card {
+ text-align: left;
+ box-shadow: none;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ gap: 8px;
+ }
+
+ :host(:not([active]))>button:hover {
+ cursor: pointer;
+ }
+
+ .extended-info {
+ display: none;
+ color: white;
+ font-size: 14px;
+ }
+
+ .extended-info hr {
+ border: 1px solid rgba(27, 30, 36, 1);
+ }
+
+ :host([active]) .extended-info {
+ display: block;
+ position: absolute;
+ height: 100%;
+ top: 98px;
+ height: calc(100% - 98px);
+ background: #0d0e12;
+ user-select: text;
+ overflow: auto;
+ border: none;
+ z-index: 1000000000;
+ flex-direction: column;
+ line-height: 1.25rem;
+ }
+
+ :host([active])>button#astro-overlay-card {
+ display: none;
+ }
+
+ .audit-title {
+ margin: 0;
+ margin-bottom: 4px;
+ }
+
+ .extended-info .audit-selector {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid transparent;
+ user-select: none;
+ color: rgba(191, 193, 201, 1);
+ }
+
+ .extended-info .audit-selector:hover {
+ border-bottom: 1px solid rgba(255, 255, 255);
+ cursor: pointer;
+ color: #fff;
+ }
+
+ .audit-selector svg {
+ width: 16px;
+ height: 16px;
+ display: inline;
+ }
+
+ .extended-info .audit-description {
+ color: rgba(191, 193, 201, 1);
+ }
+
+ .extended-info code {
+ padding: 1px 3px;
+ border-radius: 3px;
+ background: #1F2433;
+ }
+
+ .reset-button {
+ text-align: left;
+ border: none;
+ margin: 0;
+ width: auto;
+ overflow: visible;
+ background: transparent;
+ font: inherit;
+ line-height: normal;
+ -webkit-font-smoothing: inherit;
+ -moz-osx-font-smoothing: inherit;
+ -webkit-appearance: none;
+ padding: 0;
+ color: white;
+ }
+ </style>
+
+ <button id="astro-overlay-card">
+ <slot />
+ </button>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.clickAction) {
+ this.shadowRoot
+ .getElementById('astro-overlay-card')
+ ?.addEventListener('click', this.clickAction);
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-window.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-window.ts
new file mode 100644
index 000000000..472e8d7c6
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-list-window.ts
@@ -0,0 +1,412 @@
+import type { Icon } from '../../../ui-library/icons.js';
+import type { Audit } from '../index.js';
+import { getAuditCategory, rulesCategories } from '../rules/index.js';
+
+export function createRoundedBadge(icon: Icon) {
+ const badge = document.createElement('astro-dev-toolbar-badge');
+
+ badge.shadowRoot.innerHTML += `
+ <style>
+ :host>div {
+ padding: 12px 8px;
+ font-size: 14px;
+ display: flex;
+ gap: 4px;
+ }
+ </style>
+ `;
+
+ badge.innerHTML = `<astro-dev-toolbar-icon icon="${icon}"></astro-dev-toolbar-icon>0`;
+
+ return {
+ badge,
+ updateCount: (count: number) => {
+ if (count === 0) {
+ badge.badgeStyle = 'green';
+ } else {
+ badge.badgeStyle = 'purple';
+ }
+
+ badge.innerHTML = `<astro-dev-toolbar-icon icon="${icon}"></astro-dev-toolbar-icon>${count}`;
+ },
+ };
+}
+
+export class DevToolbarAuditListWindow extends HTMLElement {
+ _audits: Audit[] = [];
+ shadowRoot: ShadowRoot;
+ badges: {
+ [key: string]: {
+ badge: HTMLElement;
+ updateCount: (count: number) => void;
+ };
+ } = {};
+
+ get audits() {
+ return this._audits;
+ }
+
+ set audits(value) {
+ this._audits = value;
+ this.render();
+ }
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.shadowRoot.innerHTML = `<style>
+ :host {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(0deg, #13151a, #13151a), linear-gradient(0deg, #343841, #343841);
+ border: 1px solid rgba(52, 56, 65, 1);
+ width: min(640px, 100%);
+ max-height: 480px;
+ border-radius: 12px;
+ padding: 24px;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ "Helvetica Neue",
+ Arial,
+ "Noto Sans",
+ sans-serif,
+ "Apple Color Emoji",
+ "Segoe UI Emoji",
+ "Segoe UI Symbol",
+ "Noto Color Emoji";
+ color: rgba(191, 193, 201, 1);
+ position: fixed;
+ z-index: 999999999;
+ bottom: 72px;
+ left: 50%;
+ transform: translateX(-50%);
+ box-shadow:
+ 0px 0px 0px 0px rgba(19, 21, 26, 0.3),
+ 0px 1px 2px 0px rgba(19, 21, 26, 0.29),
+ 0px 4px 4px 0px rgba(19, 21, 26, 0.26),
+ 0px 10px 6px 0px rgba(19, 21, 26, 0.15),
+ 0px 17px 7px 0px rgba(19, 21, 26, 0.04),
+ 0px 26px 7px 0px rgba(19, 21, 26, 0.01);
+ }
+
+ @media (forced-colors: active) {
+ :host {
+ background: white;
+ }
+ }
+
+ @media (max-width: 640px) {
+ :host {
+ border-radius: 0;
+ }
+ }
+
+ hr,
+ ::slotted(hr) {
+ border: 1px solid rgba(27, 30, 36, 1);
+ margin: 1em 0;
+ }
+
+ .reset-button {
+ text-align: left;
+ border: none;
+ margin: 0;
+ width: auto;
+ overflow: visible;
+ background: transparent;
+ font: inherit;
+ line-height: normal;
+ -webkit-font-smoothing: inherit;
+ -moz-osx-font-smoothing: inherit;
+ -webkit-appearance: none;
+ padding: 0;
+ }
+
+ :host {
+ left: initial;
+ top: 8px;
+ right: 8px;
+ transform: none;
+ width: 350px;
+ min-height: 350px;
+ max-height: 420px;
+ padding: 0;
+ overflow: hidden;
+ }
+
+ hr {
+ margin: 0;
+ }
+
+ header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ header > section {
+ display: flex;
+ align-items: center;
+ gap: 1em;
+ padding: 18px;
+ }
+
+ header.category-header {
+ background: rgba(27, 30, 36, 1);
+ padding: 10px 16px;
+ position: sticky;
+ top: 0;
+ }
+
+ header.category-header astro-dev-toolbar-icon {
+ opacity: 0.6;
+ }
+
+ #audit-counts {
+ display: flex;
+ gap: 0.5em;
+ }
+
+ #audit-counts > div {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ ul,
+ li {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ h1 {
+ font-size: 24px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ }
+
+ h2 {
+ font-weight: 600;
+ margin: 0;
+ color: white;
+ font-size: 14px;
+ }
+
+ h3 {
+ font-weight: normal;
+ margin: 0;
+ color: white;
+ font-size: 14px;
+ }
+
+ .audit-header {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .audit-selector {
+ color: white;
+ font-size: 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 4px 6px;
+ }
+
+ [active] .audit-selector:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ .selector-title-container {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ astro-dev-toolbar-icon {
+ color: white;
+ fill: white;
+ display: inline-block;
+ height: 16px;
+ width: 16px;
+ }
+
+ #audit-list {
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ overscroll-behavior: contain;
+ height: 100%;
+ }
+
+ #back-to-list {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(27, 30, 36, 1);
+ gap: 8px;
+ padding: 8px;
+ color: white;
+ font-size: 14px;
+ padding-right: 24px;
+ }
+
+ #back-to-list:hover {
+ cursor: pointer;
+ background: #313236;
+ }
+
+ #back-to-list:has(+ #audit-list astro-dev-toolbar-audit-list-item[active]) {
+ display: flex;
+ }
+
+ .no-audit-container {
+ display: flex;
+ flex-direction: column;
+ padding: 24px;
+ }
+
+ .no-audit-container h1 {
+ font-size: 20px;
+ }
+
+ .no-audit-container astro-dev-toolbar-icon {
+ width: auto;
+ height: auto;
+ margin: 0 auto;
+ }
+</style>
+
+<template id="category-template">
+ <div>
+ <header class="category-header">
+ </header>
+ <div class="category-content"></div>
+ </div>
+</template>
+
+<header>
+ <section id="header-left">
+ <h1>Audit</h1>
+ <section id="audit-counts"></section>
+ </section>
+</header>
+<hr />
+<button id="back-to-list" class="reset-button">
+ <astro-dev-toolbar-icon icon="arrow-left"></astro-dev-toolbar-icon>
+ Back to list
+</button>
+<div id="audit-list"></div>
+ `;
+
+ // Create badges
+ const auditCounts = this.shadowRoot.getElementById('audit-counts');
+ if (auditCounts) {
+ rulesCategories.forEach((category) => {
+ const headerEntryContainer = document.createElement('div');
+ const auditCount = this.audits.filter(
+ (audit) => getAuditCategory(audit.rule) === category.code,
+ ).length;
+
+ const categoryBadge = createRoundedBadge(category.icon);
+ categoryBadge.updateCount(auditCount);
+
+ headerEntryContainer.append(categoryBadge.badge);
+ auditCounts.append(headerEntryContainer);
+ this.badges[category.code] = categoryBadge;
+ });
+ }
+
+ // Back to list button
+ const backToListButton = this.shadowRoot.getElementById('back-to-list');
+ if (backToListButton) {
+ backToListButton.addEventListener('click', () => {
+ const activeAudit = this.shadowRoot.querySelector(
+ 'astro-dev-toolbar-audit-list-item[active]',
+ );
+ if (activeAudit) {
+ activeAudit.toggleAttribute('active', false);
+ }
+ });
+ }
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ updateAuditList() {
+ const auditListContainer = this.shadowRoot.getElementById('audit-list');
+ if (auditListContainer) {
+ auditListContainer.innerHTML = '';
+
+ if (this.audits.length > 0) {
+ for (const category of rulesCategories) {
+ const template = this.shadowRoot.getElementById(
+ 'category-template',
+ ) as HTMLTemplateElement;
+ if (!template) return;
+
+ const clone = document.importNode(template.content, true);
+ const categoryContainer = clone.querySelector('div')!;
+ const categoryHeader = clone.querySelector('.category-header')!;
+ categoryHeader.innerHTML = `<astro-dev-toolbar-icon icon="${category.icon}"></astro-dev-toolbar-icon><h2>${category.name}</h2>`;
+ categoryContainer.append(categoryHeader);
+
+ const categoryContent = clone.querySelector('.category-content')!;
+
+ const categoryAudits = this.audits.filter(
+ (audit) => getAuditCategory(audit.rule) === category.code,
+ );
+
+ for (const audit of categoryAudits) {
+ if (audit.card) categoryContent.append(audit.card);
+ }
+
+ categoryContainer.append(categoryContent);
+ auditListContainer.append(categoryContainer);
+ }
+ } else {
+ const noAuditContainer = document.createElement('div');
+ noAuditContainer.classList.add('no-audit-container');
+ noAuditContainer.innerHTML = `
+ <header>
+ <h1></astro-dev-toolbar-icon>No accessibility or performance issues detected.</h1>
+ </header>
+ <p>
+ Nice work! This app scans the page and highlights common accessibility and performance issues for you, like a missing "alt" attribute on an image, or a image not using performant attributes.
+ </p>
+ <astro-dev-toolbar-icon icon="houston-detective"></astro-dev-toolbar-icon>
+ `;
+
+ auditListContainer.append(noAuditContainer);
+ }
+ }
+ }
+
+ updateBadgeCounts() {
+ for (const category of rulesCategories) {
+ const auditCount = this.audits.filter(
+ (audit) => getAuditCategory(audit.rule) === category.code,
+ ).length;
+ this.badges[category.code].updateCount(auditCount);
+ }
+ }
+
+ render() {
+ this.updateAuditList();
+ this.updateBadgeCounts();
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-ui.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-ui.ts
new file mode 100644
index 000000000..34adf4f01
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/ui/audit-ui.ts
@@ -0,0 +1,178 @@
+import { escape as escapeHTML } from 'html-escaper';
+import type { DevToolbarMetadata } from '../../../../../../types/public/toolbar.js';
+import {
+ attachTooltipToHighlight,
+ createHighlight,
+ getElementsPositionInDocument,
+} from '../../utils/highlight.js';
+import type { Audit } from '../index.js';
+import { type ResolvedAuditRule, resolveAuditRule } from '../rules/index.js';
+import type { DevToolbarAuditListItem } from './audit-list-item.js';
+
+function truncate(val: string, maxLength: number): string {
+ return val.length > maxLength ? val.slice(0, maxLength - 1) + '&hellip;' : val;
+}
+
+export function createAuditUI(audit: Audit, audits: Audit[]) {
+ const rect = audit.auditedElement.getBoundingClientRect();
+ const highlight = createHighlight(rect, 'warning', { 'data-audit-code': audit.rule.code });
+
+ const resolvedAuditRule = resolveAuditRule(audit.rule, audit.auditedElement);
+ const tooltip = buildAuditTooltip(resolvedAuditRule, audit.auditedElement);
+ const card = buildAuditCard(resolvedAuditRule, highlight, audit.auditedElement, audits);
+
+ // If a highlight is hovered or focused, highlight the corresponding card for it
+ (['focus', 'mouseover'] as const).forEach((event) => {
+ const attribute = event === 'focus' ? 'active' : 'hovered';
+ highlight.addEventListener(event, () => {
+ if (event === 'focus') {
+ audits.forEach((adt) => {
+ if (adt.card) adt.card.toggleAttribute('active', false);
+ });
+ if (!card.isManualFocus) card.scrollIntoView();
+ card.toggleAttribute('active', true);
+ } else {
+ card.toggleAttribute(attribute, true);
+ }
+ });
+ });
+
+ highlight.addEventListener('mouseout', () => {
+ card.toggleAttribute('hovered', false);
+ });
+
+ // Set the highlight/tooltip as being fixed position the highlighted element
+ // is fixed. We do this so that we don't mistakenly take scroll position
+ // into account when setting the tooltip/highlight positioning.
+ //
+ // We only do this once due to how expensive computed styles are to calculate,
+ // and are unlikely to change. If that turns out to be wrong, reconsider this.
+ const { isFixed } = getElementsPositionInDocument(audit.auditedElement);
+ if (isFixed) {
+ tooltip.style.position = highlight.style.position = 'fixed';
+ }
+
+ attachTooltipToHighlight(highlight, tooltip, audit.auditedElement);
+
+ return { highlight, card };
+}
+
+function buildAuditTooltip(rule: ResolvedAuditRule, element: Element) {
+ const tooltip = document.createElement('astro-dev-toolbar-tooltip');
+ const { title, message } = rule;
+
+ tooltip.sections = [
+ {
+ icon: 'warning',
+ title: escapeHTML(title),
+ },
+ {
+ content: escapeHTML(message),
+ },
+ ];
+
+ const elementFile = element.getAttribute('data-astro-source-file');
+ const elementPosition = element.getAttribute('data-astro-source-loc');
+
+ if (elementFile) {
+ const elementFileWithPosition = elementFile + (elementPosition ? ':' + elementPosition : '');
+
+ tooltip.sections.push({
+ content: elementFileWithPosition.slice(
+ (window as DevToolbarMetadata).__astro_dev_toolbar__.root.length - 1, // We want to keep the final slash, so minus one.
+ ),
+ clickDescription: 'Click to go to file',
+ async clickAction() {
+ // NOTE: The path here has to be absolute and without any errors (no double slashes etc)
+ // or Vite will silently fail to open the file. Quite annoying.
+ await fetch('/__open-in-editor?file=' + encodeURIComponent(elementFileWithPosition));
+ },
+ });
+ }
+
+ return tooltip;
+}
+
+function buildAuditCard(
+ rule: ResolvedAuditRule,
+ highlightElement: HTMLElement,
+ auditedElement: Element,
+ audits: Audit[],
+) {
+ const card = document.createElement(
+ 'astro-dev-toolbar-audit-list-item',
+ ) as DevToolbarAuditListItem;
+
+ card.clickAction = () => {
+ if (card.hasAttribute('active')) return;
+
+ audits.forEach((audit) => {
+ audit.card?.toggleAttribute('active', false);
+ });
+ highlightElement.scrollIntoView();
+ card.isManualFocus = true;
+ highlightElement.focus();
+ card.isManualFocus = false;
+ };
+
+ const selectorTitleContainer = document.createElement('section');
+ selectorTitleContainer.classList.add('selector-title-container');
+ const selector = document.createElement('span');
+ const selectorName = truncate(auditedElement.tagName.toLowerCase(), 8);
+ selector.classList.add('audit-selector');
+ selector.innerHTML = escapeHTML(selectorName);
+
+ const title = document.createElement('h3');
+ title.classList.add('audit-title');
+ title.innerText = rule.title;
+
+ selectorTitleContainer.append(selector, title);
+ card.append(selectorTitleContainer);
+
+ const extendedInfo = document.createElement('div');
+ extendedInfo.classList.add('extended-info');
+
+ const selectorButton = document.createElement('button');
+ selectorButton.className = 'audit-selector reset-button';
+ selectorButton.innerHTML = `${selectorName} <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,136v64a8,8,0,0,1-16,0V155.32L45.66,221.66a8,8,0,0,1-11.32-11.32L100.68,144H56a8,8,0,0,1,0-16h64A8,8,0,0,1,128,136ZM208,32H80A16,16,0,0,0,64,48V96a8,8,0,0,0,16,0V48H208V176H160a8,8,0,0,0,0,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Z"></path></svg>`;
+
+ selectorButton.addEventListener('click', () => {
+ highlightElement.scrollIntoView();
+ highlightElement.focus();
+ });
+
+ extendedInfo.append(title.cloneNode(true));
+ extendedInfo.append(selectorButton);
+ extendedInfo.append(document.createElement('hr'));
+
+ const message = document.createElement('p');
+ message.classList.add('audit-message');
+ message.innerHTML = simpleRenderMarkdown(rule.message);
+ extendedInfo.appendChild(message);
+
+ const description = rule.description;
+ if (description) {
+ const descriptionElement = document.createElement('p');
+ descriptionElement.classList.add('audit-description');
+ descriptionElement.innerHTML = simpleRenderMarkdown(description);
+ extendedInfo.appendChild(descriptionElement);
+ }
+
+ card.shadowRoot.appendChild(extendedInfo);
+
+ return card;
+}
+
+const linkRegex = /\[([^[]+)\]\((.*)\)/g;
+const boldRegex = /\*\*(.+)\*\*/g;
+const codeRegex = /`([^`]+)`/g;
+
+/**
+ * Render a very small subset of Markdown to HTML or a CLI output
+ */
+function simpleRenderMarkdown(markdown: string) {
+ return escapeHTML(markdown)
+ .replace(linkRegex, `<a href="$2" target="_blank">$1</a>`)
+ .replace(boldRegex, '<b>$1</b>')
+ .replace(codeRegex, '<code>$1</code>');
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts
new file mode 100644
index 000000000..09fa8da26
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/settings.ts
@@ -0,0 +1,217 @@
+import type { ResolvedDevToolbarApp } from '../../../../types/public/toolbar.js';
+import { type Settings, settings } from '../settings.js';
+import { isValidPlacement, placements } from '../ui-library/window.js';
+import {
+ closeOnOutsideClick,
+ createWindowElement,
+ synchronizePlacementOnUpdate,
+} from './utils/window.js';
+
+interface SettingRow {
+ name: string;
+ description: string;
+ input: 'checkbox' | 'text' | 'number' | 'select';
+ settingKey: keyof Settings;
+ changeEvent: (evt: Event) => void;
+}
+
+const settingsRows = [
+ {
+ name: 'Disable notifications',
+ description: 'Hide notification badges in the toolbar.',
+ input: 'checkbox',
+ settingKey: 'disableAppNotification',
+ changeEvent: (evt: Event) => {
+ if (evt.currentTarget instanceof HTMLInputElement) {
+ const devToolbar = document.querySelector('astro-dev-toolbar');
+
+ if (devToolbar) {
+ devToolbar.setNotificationVisible(!evt.currentTarget.checked);
+ }
+
+ settings.updateSetting('disableAppNotification', evt.currentTarget.checked);
+ const action = evt.currentTarget.checked ? 'disabled' : 'enabled';
+ settings.logger.verboseLog(`App notification badges ${action}`);
+ }
+ },
+ },
+ {
+ name: 'Verbose logging',
+ description: 'Logs dev toolbar events in the browser console.',
+ input: 'checkbox',
+ settingKey: 'verbose',
+ changeEvent: (evt: Event) => {
+ if (evt.currentTarget instanceof HTMLInputElement) {
+ settings.updateSetting('verbose', evt.currentTarget.checked);
+ const action = evt.currentTarget.checked ? 'enabled' : 'disabled';
+ settings.logger.verboseLog(`Verbose logging ${action}`);
+ }
+ },
+ },
+ {
+ name: 'Placement',
+ description: 'Adjust the placement of the dev toolbar.',
+ input: 'select',
+ settingKey: 'placement',
+ changeEvent: (evt: Event) => {
+ if (evt.currentTarget instanceof HTMLSelectElement) {
+ const placement = evt.currentTarget.value;
+ if (isValidPlacement(placement)) {
+ document.querySelector('astro-dev-toolbar')?.setToolbarPlacement(placement);
+ settings.updateSetting('placement', placement);
+ settings.logger.verboseLog(`Placement set to ${placement}`);
+ }
+ }
+ },
+ },
+] satisfies SettingRow[];
+
+export default {
+ id: 'astro:settings',
+ name: 'Settings',
+ icon: 'gear',
+ init(canvas, eventTarget) {
+ createSettingsWindow();
+
+ document.addEventListener('astro:after-swap', createSettingsWindow);
+
+ closeOnOutsideClick(eventTarget);
+ synchronizePlacementOnUpdate(eventTarget, canvas);
+
+ function createSettingsWindow() {
+ const windowElement = createWindowElement(
+ `<style>
+ :host astro-dev-toolbar-window {
+ height: 480px;
+
+ --color-purple: rgba(224, 204, 250, 1);
+ }
+ header {
+ display: flex;
+ }
+
+ h2, h3 {
+ margin-top: 0;
+ }
+
+ .setting-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ h3 {
+ font-size: 16px;
+ font-weight: 400;
+ color: white;
+ margin-bottom: 4px;
+ }
+
+ label {
+ font-size: 14px;
+ line-height: 1.5rem;
+ }
+
+ h1 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ font-size: 22px;
+ }
+
+ astro-dev-toolbar-icon {
+ width: 1em;
+ height: 1em;
+ display: block;
+ }
+
+ code {
+ color: var(--color-purple);
+ border-color: #343841;
+ border-style: solid;
+ border-width: 1px;
+ border-radius: .4em;
+ background-color: #24262D;
+ padding: .3em;
+ }
+
+ label > section {
+ max-width: 67%;
+ }
+ p {
+ line-height: 1.5em;
+ }
+ a, a:visited {
+ color: var(--color-purple);
+ }
+ a:hover {
+ color: #f4ecfd;
+ }
+ </style>
+ <header>
+ <h1><astro-dev-toolbar-icon icon="gear"></astro-dev-toolbar-icon> Settings</h1>
+ </header>
+
+ <hr id="general"/>
+
+ <label class="setting-row">
+ <section>
+ <h3>Hide toolbar</h3>
+ Run <code>astro preferences disable devToolbar</code> in your terminal to disable the toolbar. <a href="https://docs.astro.build/en/reference/cli-reference/#astro-preferences" target="_blank">Learn more</a>.
+ </section>
+ </label>
+ `,
+ );
+ const general = windowElement.querySelector('#general')!;
+ for (const settingsRow of settingsRows) {
+ general.after(document.createElement('hr'));
+ general.after(getElementForSettingAsString(settingsRow));
+ }
+ canvas.append(windowElement);
+
+ function getElementForSettingAsString(setting: SettingRow) {
+ const label = document.createElement('label');
+ label.classList.add('setting-row');
+ const section = document.createElement('section');
+ section.innerHTML = `<h3>${setting.name}</h3>${setting.description}`;
+ label.append(section);
+
+ switch (setting.input) {
+ case 'checkbox': {
+ const astroToggle = document.createElement('astro-dev-toolbar-toggle');
+ astroToggle.input.addEventListener('change', setting.changeEvent);
+ astroToggle.input.checked = settings.config[setting.settingKey] as boolean;
+ label.append(astroToggle);
+ break;
+ }
+ case 'select': {
+ const astroSelect = document.createElement('astro-dev-toolbar-select');
+ placements.forEach((placement) => {
+ const option = document.createElement('option');
+ option.setAttribute('value', placement);
+ if (placement === settings.config[setting.settingKey]) {
+ option.selected = true;
+ }
+ option.textContent = `${placement.slice(0, 1).toUpperCase()}${placement.slice(
+ 1,
+ )}`.replace('-', ' ');
+ astroSelect.append(option);
+ });
+ astroSelect.element.addEventListener('change', setting.changeEvent);
+ label.append(astroSelect);
+ break;
+ }
+ case 'number':
+ case 'text':
+ default:
+ break;
+ }
+
+ return label;
+ }
+ }
+ },
+} satisfies ResolvedDevToolbarApp;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/utils/highlight.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/highlight.ts
new file mode 100644
index 000000000..50be27b0e
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/highlight.ts
@@ -0,0 +1,92 @@
+import type { DevToolbarHighlight } from '../../ui-library/highlight.js';
+import type { Icon } from '../../ui-library/icons.js';
+
+export function createHighlight(
+ rect: DOMRect,
+ icon?: Icon,
+ additionalAttributes?: Record<string, string>,
+) {
+ const highlight = document.createElement('astro-dev-toolbar-highlight');
+ if (icon) highlight.icon = icon;
+
+ if (additionalAttributes) {
+ for (const [key, value] of Object.entries(additionalAttributes)) {
+ highlight.setAttribute(key, value);
+ }
+ }
+
+ highlight.tabIndex = 0;
+
+ if (rect.width === 0 || rect.height === 0) {
+ highlight.style.display = 'none';
+ } else {
+ positionHighlight(highlight, rect);
+ }
+ return highlight;
+}
+
+// Figures out the element's position, based on it's parents.
+export function getElementsPositionInDocument(el: Element) {
+ let isFixed = false;
+ let current: Element | ParentNode | null = el;
+ while (current instanceof Element) {
+ // all the way up the tree. We are only doing so when the app initializes, so the cost is one-time
+ // If perf becomes an issue we'll want to refactor this somehow so that it reads this info in a rAF
+ let style = getComputedStyle(current);
+ if (style.position === 'fixed') {
+ isFixed = true;
+ }
+ current = current.parentNode;
+ }
+ return {
+ isFixed,
+ };
+}
+
+export function positionHighlight(highlight: DevToolbarHighlight, rect: DOMRect) {
+ highlight.style.display = 'block';
+ // If the highlight is fixed, don't position based on scroll
+ const scrollY = highlight.style.position === 'fixed' ? 0 : window.scrollY;
+ // Make an highlight that is 10px bigger than the element on all sides
+ highlight.style.top = `${Math.max(rect.top + scrollY - 10, 0)}px`;
+ highlight.style.left = `${Math.max(rect.left + window.scrollX - 10, 0)}px`;
+ highlight.style.width = `${rect.width + 15}px`;
+ highlight.style.height = `${rect.height + 15}px`;
+}
+
+export function attachTooltipToHighlight(
+ highlight: DevToolbarHighlight,
+ tooltip: HTMLElement,
+ originalElement: Element,
+) {
+ highlight.shadowRoot.append(tooltip);
+
+ (['mouseover', 'focus'] as const).forEach((event) => {
+ highlight.addEventListener(event, () => {
+ tooltip.dataset.show = 'true';
+ const originalRect = originalElement.getBoundingClientRect();
+ const dialogRect = tooltip.getBoundingClientRect();
+
+ // Prevent the tooltip from being off the screen
+ if (originalRect.top < dialogRect.height) {
+ // Not enough space above, show below
+ tooltip.style.top = `${originalRect.height + 15}px`;
+ } else {
+ tooltip.style.top = `-${tooltip.offsetHeight}px`;
+ }
+ if (dialogRect.right > document.documentElement.clientWidth) {
+ // Not enough space on the right, align to the right
+ tooltip.style.right = '0px';
+ } else if (dialogRect.left < 0) {
+ // Not enough space on the left, align to the left
+ tooltip.style.left = '0px';
+ }
+ });
+ });
+
+ (['mouseout', 'blur'] as const).forEach((event) => {
+ highlight.addEventListener(event, () => {
+ tooltip.dataset.show = 'false';
+ });
+ });
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/utils/icons.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/icons.ts
new file mode 100644
index 000000000..cddee0c26
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/icons.ts
@@ -0,0 +1,43 @@
+import type { Integration } from '../astro.js';
+
+function randomFromArray<T>(list: T[]) {
+ return list[Math.floor(Math.random() * list.length)];
+}
+
+const categoryIcons = new Map(
+ Object.entries({
+ frameworks: ['puzzle', 'grid'],
+ adapters: ['puzzle', 'grid', 'compress'],
+ 'css+ui': ['compress', 'grid', 'image', 'resizeImage', 'puzzle'],
+ 'performance+seo': ['approveUser', 'checkCircle', 'compress', 'robot', 'searchFile', 'sitemap'],
+ analytics: ['checkCircle', 'compress', 'searchFile'],
+ accessibility: ['approveUser', 'checkCircle'],
+ other: ['checkCircle', 'grid', 'puzzle', 'sitemap'],
+ }),
+);
+
+export function iconForIntegration(integration: Integration) {
+ const icons = integration.categories
+ .filter((category: string) => categoryIcons.has(category))
+ .map((category: string) => categoryIcons.get(category)!)
+ .flat();
+
+ return randomFromArray(icons);
+}
+
+const iconColors = [
+ '#BC52EE',
+ '#6D6AF0',
+ '#52EEBD',
+ '#52B7EE',
+ '#52EE55',
+ '#B7EE52',
+ '#EEBD52',
+ '#EE5552',
+ '#EE52B7',
+ '#858B98',
+];
+
+export function colorForIntegration() {
+ return randomFromArray(iconColors);
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/utils/window.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/window.ts
new file mode 100644
index 000000000..87dee93da
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/utils/window.ts
@@ -0,0 +1,50 @@
+import { settings } from '../../settings.js';
+import type { Placement } from '../../ui-library/window.js';
+
+export function createWindowElement(content: string, placement = settings.config.placement) {
+ const windowElement = document.createElement('astro-dev-toolbar-window');
+ windowElement.innerHTML = content;
+ windowElement.placement = placement;
+ return windowElement;
+}
+
+export function closeOnOutsideClick(
+ eventTarget: EventTarget,
+ additionalCheck?: (target: Element) => boolean,
+) {
+ function onPageClick(event: MouseEvent) {
+ const target = event.target as Element | null;
+ if (!target) return;
+ if (!target.closest) return;
+ if (target.closest('astro-dev-toolbar')) return;
+ if (additionalCheck && additionalCheck(target)) return;
+ eventTarget.dispatchEvent(
+ new CustomEvent('toggle-app', {
+ detail: {
+ state: false,
+ },
+ }),
+ );
+ }
+ eventTarget.addEventListener('app-toggled', (event: any) => {
+ if (event.detail.state === true) {
+ document.addEventListener('click', onPageClick, true);
+ } else {
+ document.removeEventListener('click', onPageClick, true);
+ }
+ });
+}
+
+export function synchronizePlacementOnUpdate(eventTarget: EventTarget, canvas: ShadowRoot) {
+ eventTarget.addEventListener('placement-updated', (evt) => {
+ if (!(evt instanceof CustomEvent)) {
+ return;
+ }
+ const windowElement = canvas.querySelector('astro-dev-toolbar-window');
+ if (!windowElement) {
+ return;
+ }
+ const event: CustomEvent<{ placement: Placement }> = evt;
+ windowElement.placement = event.detail.placement;
+ });
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts
new file mode 100644
index 000000000..6b1e14340
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts
@@ -0,0 +1,183 @@
+import { escape as escapeHTML } from 'html-escaper';
+import type {
+ DevToolbarMetadata,
+ ResolvedDevToolbarApp,
+} from '../../../../types/public/toolbar.js';
+import type { DevToolbarHighlight } from '../ui-library/highlight.js';
+import {
+ attachTooltipToHighlight,
+ createHighlight,
+ getElementsPositionInDocument,
+ positionHighlight,
+} from './utils/highlight.js';
+import {
+ closeOnOutsideClick,
+ createWindowElement,
+ synchronizePlacementOnUpdate,
+} from './utils/window.js';
+
+const icon =
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M7.9 1.5v-.4a1.1 1.1 0 0 1 2.2 0v.4a1.1 1.1 0 1 1-2.2 0Zm-6.4 8.6a1.1 1.1 0 1 0 0-2.2h-.4a1.1 1.1 0 0 0 0 2.2h.4ZM12 3.7a1.1 1.1 0 0 0 1.4-.7l.4-1.1a1.1 1.1 0 0 0-2.1-.8l-.4 1.2a1.1 1.1 0 0 0 .7 1.4Zm-9.7 7.6-1.2.4a1.1 1.1 0 1 0 .8 2.1l1-.4a1.1 1.1 0 1 0-.6-2ZM20.8 17a1.9 1.9 0 0 1 0 2.6l-1.2 1.2a1.9 1.9 0 0 1-2.6 0l-4.3-4.2-1.6 3.6a1.9 1.9 0 0 1-1.7 1.2A1.9 1.9 0 0 1 7.5 20L2.7 5a1.9 1.9 0 0 1 2.4-2.4l15 5a1.9 1.9 0 0 1 .2 3.4l-3.7 1.6 4.2 4.3ZM19 18.3 14.6 14a1.9 1.9 0 0 1 .6-3l3.2-1.5L5.1 5.1l4.3 13.3 1.5-3.2a1.9 1.9 0 0 1 3-.6l4.4 4.4.7-.7Z"/></svg>';
+
+export default {
+ id: 'astro:xray',
+ name: 'Inspect',
+ icon: icon,
+ init(canvas, eventTarget) {
+ let islandsOverlays: { highlightElement: DevToolbarHighlight; island: HTMLElement }[] = [];
+
+ addIslandsOverlay();
+
+ document.addEventListener('astro:after-swap', addIslandsOverlay);
+ document.addEventListener('astro:page-load', refreshIslandsOverlayPositions);
+
+ closeOnOutsideClick(eventTarget);
+ synchronizePlacementOnUpdate(eventTarget, canvas);
+
+ function addIslandsOverlay() {
+ islandsOverlays.forEach(({ highlightElement }) => {
+ highlightElement.remove();
+ });
+ islandsOverlays = [];
+
+ const islands = document.querySelectorAll<HTMLElement>('astro-island');
+
+ if (islands.length === 0) {
+ const window = createWindowElement(
+ `<style>
+ header {
+ display: flex;
+ }
+
+ h1 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ font-size: 22px;
+ }
+
+ astro-dev-toolbar-icon {
+ width: 1em;
+ height: 1em;
+ padding: 8px;
+ display: block;
+ background: #5f9ea0;
+ border-radius: 9999px;
+ }
+ </style>
+ <header>
+ <h1><astro-dev-toolbar-icon icon="lightbulb"></astro-dev-toolbar-icon>No islands detected.</h1>
+ </header>
+ <p>
+ It looks like there are no interactive component islands on this page. Did you forget to add a client directive to your interactive UI component?
+ </p>
+ `,
+ );
+
+ canvas.append(window);
+ return;
+ }
+
+ islands.forEach((island) => {
+ const computedStyle = window.getComputedStyle(island);
+ const islandElement = (island.children[0] as HTMLElement) || island;
+
+ // If the island is hidden, don't show an overlay on it
+ // TODO: For `client:only` islands, it might not have finished loading yet, so we should wait for that
+ if (islandElement.offsetParent === null || computedStyle.display === 'none') {
+ return;
+ }
+
+ const rect = islandElement.getBoundingClientRect();
+ const highlight = createHighlight(rect);
+ const tooltip = buildIslandTooltip(island);
+
+ // Set the highlight/tooltip as being fixed position the highlighted element
+ // is fixed. We do this so that we don't mistakenly take scroll position
+ // into account when setting the tooltip/highlight positioning.
+ //
+ // We only do this once due to how expensive computed styles are to calculate,
+ // and are unlikely to change. If that turns out to be wrong, reconsider this.
+ const { isFixed } = getElementsPositionInDocument(islandElement);
+ if (isFixed) {
+ tooltip.style.position = highlight.style.position = 'fixed';
+ }
+
+ attachTooltipToHighlight(highlight, tooltip, islandElement);
+ canvas.append(highlight);
+ islandsOverlays.push({ highlightElement: highlight, island: islandElement });
+ });
+
+ (['scroll', 'resize'] as const).forEach((event) => {
+ window.addEventListener(event, refreshIslandsOverlayPositions);
+ });
+ }
+
+ function refreshIslandsOverlayPositions() {
+ islandsOverlays.forEach(({ highlightElement, island: islandElement }) => {
+ const rect = islandElement.getBoundingClientRect();
+ positionHighlight(highlightElement, rect);
+ });
+ }
+
+ function buildIslandTooltip(island: HTMLElement) {
+ const tooltip = document.createElement('astro-dev-toolbar-tooltip');
+ tooltip.sections = [];
+
+ const islandProps = island.getAttribute('props')
+ ? JSON.parse(island.getAttribute('props')!)
+ : {};
+ const islandClientDirective = island.getAttribute('client');
+
+ // Add the component client's directive if we have one
+ if (islandClientDirective) {
+ tooltip.sections.push({
+ title: 'Client directive',
+ inlineTitle: `<code>client:${islandClientDirective}</code>`,
+ });
+ }
+
+ // Display the props if we have any
+ // Ignore the "data-astro-cid-XXXXXX" prop (internal)
+ const islandPropsEntries = Object.entries(islandProps).filter(
+ (prop: any) => !prop[0].startsWith('data-astro-cid-'),
+ );
+ if (islandPropsEntries.length > 0) {
+ const stringifiedProps = JSON.stringify(
+ Object.fromEntries(islandPropsEntries.map((prop: any) => [prop[0], prop[1][1]])),
+ undefined,
+ 2,
+ );
+ tooltip.sections.push({
+ title: 'Props',
+ content: `<pre><code>${escapeHTML(stringifiedProps)}</code></pre>`,
+ });
+ }
+
+ // Add a click action to go to the file
+ const islandComponentPath = island.getAttribute('component-url');
+ if (islandComponentPath) {
+ tooltip.sections.push({
+ content: islandComponentPath,
+ clickDescription: 'Click to go to file',
+ async clickAction() {
+ // NOTE: The path here has to be absolute and without any errors (no double slashes etc)
+ // or Vite will silently fail to open the file. Quite annoying.
+ await fetch(
+ '/__open-in-editor?file=' +
+ encodeURIComponent(
+ (window as DevToolbarMetadata).__astro_dev_toolbar__.root +
+ islandComponentPath.slice(1),
+ ),
+ );
+ },
+ });
+ }
+
+ return tooltip;
+ }
+ },
+} satisfies ResolvedDevToolbarApp;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts
new file mode 100644
index 000000000..6eca3e3bb
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/entrypoint.ts
@@ -0,0 +1,284 @@
+// @ts-expect-error - This module is private and untyped
+import { loadDevToolbarApps } from 'astro:toolbar:internal';
+import type { ResolvedDevToolbarApp as DevToolbarAppDefinition } from '../../../types/public/toolbar.js';
+import { ToolbarAppEventTarget } from './helpers.js';
+import { settings } from './settings.js';
+import type { AstroDevToolbar, DevToolbarApp } from './toolbar.js';
+
+let overlay: AstroDevToolbar;
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const [
+ customAppsDefinitions,
+ { default: astroDevToolApp },
+ { default: astroAuditApp },
+ { default: astroXrayApp },
+ { default: astroSettingsApp },
+ { AstroDevToolbar, DevToolbarCanvas, getAppIcon },
+ {
+ DevToolbarCard,
+ DevToolbarHighlight,
+ DevToolbarTooltip,
+ DevToolbarWindow,
+ DevToolbarToggle,
+ DevToolbarButton,
+ DevToolbarBadge,
+ DevToolbarIcon,
+ DevToolbarSelect,
+ DevToolbarRadioCheckbox,
+ },
+ ] = await Promise.all([
+ loadDevToolbarApps() as DevToolbarAppDefinition[],
+ import('./apps/astro.js'),
+ import('./apps/audit/index.js'),
+ import('./apps/xray.js'),
+ import('./apps/settings.js'),
+ import('./toolbar.js'),
+ import('./ui-library/index.js'),
+ ]);
+
+ // Register custom elements
+ customElements.define('astro-dev-toolbar', AstroDevToolbar);
+ customElements.define('astro-dev-toolbar-window', DevToolbarWindow);
+ customElements.define('astro-dev-toolbar-app-canvas', DevToolbarCanvas);
+ customElements.define('astro-dev-toolbar-tooltip', DevToolbarTooltip);
+ customElements.define('astro-dev-toolbar-highlight', DevToolbarHighlight);
+ customElements.define('astro-dev-toolbar-card', DevToolbarCard);
+ customElements.define('astro-dev-toolbar-toggle', DevToolbarToggle);
+ customElements.define('astro-dev-toolbar-button', DevToolbarButton);
+ customElements.define('astro-dev-toolbar-badge', DevToolbarBadge);
+ customElements.define('astro-dev-toolbar-icon', DevToolbarIcon);
+ customElements.define('astro-dev-toolbar-select', DevToolbarSelect);
+ customElements.define('astro-dev-toolbar-radio-checkbox', DevToolbarRadioCheckbox);
+
+ overlay = document.createElement('astro-dev-toolbar');
+
+ const notificationLevels = ['error', 'warning', 'info'] as const;
+ const notificationSVGs: Record<(typeof notificationLevels)[number], string> = {
+ error:
+ '<svg viewBox="0 0 10 10" style="--fill:var(--fill-default);--fill-default:#B33E66;--fill-hover:#E3AFC1;"><rect width="9" height="9" x=".5" y=".5" fill="var(--fill)" stroke="#13151A" stroke-width="2" rx="4.5"/></svg>',
+ warning:
+ '<svg width="12" height="10" fill="none" style="--fill:var(--fill-default);--fill-default:#B58A2D;--fill-hover:#D5B776;"><path fill="var(--fill)" stroke="#13151A" stroke-width="2" d="M7.29904 1.25c-.57735-1-2.02073-1-2.59808 0l-3.4641 6C.65951 8.25 1.3812 9.5 2.5359 9.5h6.9282c1.1547 0 1.8764-1.25 1.299-2.25l-3.46406-6Z"/></svg>',
+ info: '<svg viewBox="0 0 10 10" style="--fill:var(--fill-default);--fill-default:#3645D9;--fill-hover:#BDC3FF;"><rect width="9" height="9" x=".5" y=".5" fill="var(--fill)" stroke="#13151A" stroke-width="2" rx="1.5"/></svg>',
+ } as const;
+
+ const prepareApp = (appDefinition: DevToolbarAppDefinition, builtIn: boolean): DevToolbarApp => {
+ const eventTarget = new ToolbarAppEventTarget();
+ const app: DevToolbarApp = {
+ ...appDefinition,
+ builtIn: builtIn,
+ active: false,
+ status: 'loading',
+ notification: { state: false, level: undefined },
+ eventTarget: eventTarget,
+ };
+
+ // Events apps can send to the overlay to update their status
+ eventTarget.addEventListener('toggle-notification', (evt) => {
+ if (!(evt instanceof CustomEvent)) return;
+
+ const target = overlay.shadowRoot?.querySelector(`[data-app-id="${app.id}"]`);
+ if (!target) return;
+ const notificationElement = target.querySelector('.notification');
+ if (!notificationElement) return;
+
+ let newState = evt.detail.state ?? true;
+ let level = notificationLevels.includes(evt?.detail?.level)
+ ? (evt.detail.level as (typeof notificationLevels)[number])
+ : 'error';
+
+ app.notification.state = newState;
+ if (newState) app.notification.level = level;
+
+ notificationElement.toggleAttribute('data-active', newState);
+ if (newState) {
+ notificationElement.setAttribute('data-level', level);
+ notificationElement.innerHTML = notificationSVGs[level];
+ }
+ });
+
+ const onToggleApp = async (evt: Event) => {
+ let newState = undefined;
+ if (evt instanceof CustomEvent) {
+ newState = evt.detail.state ?? true;
+ }
+
+ await overlay.setAppStatus(app, newState);
+ };
+
+ eventTarget.addEventListener('toggle-app', onToggleApp);
+
+ return app;
+ };
+
+ const astroMoreApp = {
+ id: 'astro:more',
+ name: 'More',
+ icon: 'dots-three',
+ init(canvas, eventTarget) {
+ const hiddenApps = apps.filter((p) => !p.builtIn).slice(overlay.customAppsToShow);
+
+ createDropdown();
+
+ document.addEventListener('astro:after-swap', createDropdown);
+
+ function createDropdown() {
+ const style = document.createElement('style');
+ style.innerHTML = `
+ #dropdown {
+ background: rgba(19, 21, 26, 1);
+ border: 1px solid rgba(52, 56, 65, 1);
+ border-radius: 12px;
+ box-shadow: 0px 0px 0px 0px rgba(19, 21, 26, 0.30), 0px 1px 2px 0px rgba(19, 21, 26, 0.29), 0px 4px 4px 0px rgba(19, 21, 26, 0.26), 0px 10px 6px 0px rgba(19, 21, 26, 0.15), 0px 17px 7px 0px rgba(19, 21, 26, 0.04), 0px 26px 7px 0px rgba(19, 21, 26, 0.01);
+ width: 192px;
+ padding: 8px;
+ z-index: 2000000010;
+ transform: translate(-50%, 0%);
+ position: fixed;
+ bottom: 72px;
+ left: 50%;
+ }
+
+ .notification {
+ display: none;
+ position: absolute;
+ top: -4px;
+ right: -5px;
+ width: 12px;
+ height: 10px;
+ }
+
+ .notification svg {
+ display: block;
+ }
+
+ #dropdown:not([data-no-notification]) .notification[data-active] {
+ display: block;
+ }
+
+ #dropdown button {
+ border: 0;
+ background: transparent;
+ color: white;
+ font-family: system-ui, sans-serif;
+ font-size: 14px;
+ white-space: nowrap;
+ text-decoration: none;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 8px;
+ border-radius: 8px;
+ }
+
+ #dropdown button:hover, #dropdown button:focus-visible {
+ background: #FFFFFF20;
+ cursor: pointer;
+ }
+
+ #dropdown button.active {
+ background: rgba(71, 78, 94, 1);
+ }
+
+ #dropdown .icon {
+ position: relative;
+ height: 20px;
+ width: 20px;
+ padding: 1px;
+ margin-right: 0.5em;
+ }
+
+ #dropdown .icon svg {
+ max-height: 100%;
+ max-width: 100%;
+ }
+ `;
+ canvas.append(style);
+
+ const dropdown = document.createElement('div');
+ dropdown.id = 'dropdown';
+ dropdown.toggleAttribute('data-no-notification', settings.config.disableAppNotification);
+
+ for (const app of hiddenApps) {
+ const buttonContainer = document.createElement('div');
+ buttonContainer.classList.add('item');
+ const button = document.createElement('button');
+ button.setAttribute('data-app-id', app.id);
+
+ const iconContainer = document.createElement('div');
+ const iconElement = document.createElement('template');
+ iconElement.innerHTML = app.icon ? getAppIcon(app.icon) : '?';
+ iconContainer.append(iconElement.content.cloneNode(true));
+
+ const notification = document.createElement('div');
+ notification.classList.add('notification');
+ iconContainer.append(notification);
+ iconContainer.classList.add('icon');
+
+ button.append(iconContainer);
+ button.append(document.createTextNode(app.name));
+
+ button.addEventListener('click', () => {
+ overlay.toggleAppStatus(app);
+ });
+ buttonContainer.append(button);
+
+ dropdown.append(buttonContainer);
+
+ app.eventTarget.addEventListener('toggle-notification', (evt) => {
+ if (!(evt instanceof CustomEvent)) return;
+
+ let newState = evt.detail.state ?? true;
+ let level = notificationLevels.includes(evt?.detail?.level)
+ ? (evt.detail.level as (typeof notificationLevels)[number])
+ : 'error';
+
+ notification.toggleAttribute('data-active', newState);
+
+ if (newState) {
+ notification.setAttribute('data-level', level);
+ notification.innerHTML = notificationSVGs[level];
+ }
+
+ app.notification.state = newState;
+ if (newState) app.notification.level = level;
+
+ eventTarget.dispatchEvent(
+ new CustomEvent('toggle-notification', {
+ detail: {
+ state: hiddenApps.some((p) => p.notification.state === true),
+ level:
+ ['error', 'warning', 'info'].find((notificationLevel) =>
+ hiddenApps.some(
+ (p) =>
+ p.notification.state === true &&
+ p.notification.level === notificationLevel,
+ ),
+ ) ?? 'error',
+ },
+ }),
+ );
+ });
+ }
+
+ canvas.append(dropdown);
+ }
+ },
+ } satisfies DevToolbarAppDefinition;
+
+ const apps: DevToolbarApp[] = [
+ ...[astroDevToolApp, astroXrayApp, astroAuditApp, astroSettingsApp, astroMoreApp].map(
+ (appDef) => prepareApp(appDef, true),
+ ),
+ ...customAppsDefinitions.map((appDef) => prepareApp(appDef, false)),
+ ];
+
+ overlay.apps = apps;
+
+ document.body.append(overlay);
+
+ document.addEventListener('astro:after-swap', () => {
+ document.body.append(overlay);
+ });
+});
diff --git a/packages/astro/src/runtime/client/dev-toolbar/helpers.ts b/packages/astro/src/runtime/client/dev-toolbar/helpers.ts
new file mode 100644
index 000000000..a8250ed81
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/helpers.ts
@@ -0,0 +1,107 @@
+type NotificationPayload =
+ | {
+ state: true;
+ level?: 'error' | 'warning' | 'info';
+ }
+ | {
+ state: false;
+ };
+
+type AppStatePayload = {
+ state: boolean;
+};
+
+type AppToggledEvent = (opts: { state: boolean }) => void;
+
+type ToolbarPlacementUpdatedEvent = (opts: {
+ placement: 'bottom-left' | 'bottom-center' | 'bottom-right';
+}) => void;
+
+export class ToolbarAppEventTarget extends EventTarget {
+ constructor() {
+ super();
+ }
+
+ /**
+ * Toggle the notification state of the toolbar
+ * @param options - The notification options
+ * @param options.state - The state of the notification
+ * @param options.level - The level of the notification, optional when state is false
+ */
+ toggleNotification(options: NotificationPayload) {
+ this.dispatchEvent(
+ new CustomEvent('toggle-notification', {
+ detail: {
+ state: options.state,
+ level: options.state === true ? options.level : undefined,
+ } satisfies NotificationPayload,
+ }),
+ );
+ }
+
+ /**
+ * Toggle the app state on or off
+ * @param options - The app state options
+ * @param options.state - The new state of the app
+ */
+ toggleState(options: AppStatePayload) {
+ this.dispatchEvent(
+ new CustomEvent('toggle-app', {
+ detail: {
+ state: options.state,
+ } satisfies AppStatePayload,
+ }),
+ );
+ }
+
+ /**
+ * Fired when the app is toggled on or off
+ * @param callback - The callback to run when the event is fired, takes an object with the new state
+ */
+ onToggled(callback: AppToggledEvent) {
+ this.addEventListener('app-toggled', (evt) => {
+ if (!(evt instanceof CustomEvent)) return;
+ callback(evt.detail);
+ });
+ }
+
+ /**
+ * Fired when the toolbar placement is updated by the user
+ * @param callback - The callback to run when the event is fired, takes an object with the new placement
+ */
+ onToolbarPlacementUpdated(callback: ToolbarPlacementUpdatedEvent) {
+ this.addEventListener('placement-updated', (evt) => {
+ if (!(evt instanceof CustomEvent)) return;
+ callback(evt.detail);
+ });
+ }
+}
+
+export const serverHelpers = {
+ /**
+ * Send a message to the server, the payload can be any serializable data.
+ *
+ * The server can listen for this message in the `astro:server:config` hook of an Astro integration, using the `toolbar.on` method.
+ *
+ * @param event - The event name
+ * @param payload - The payload to send
+ */
+ send: <T>(event: string, payload: T) => {
+ if (import.meta.hot) {
+ import.meta.hot.send(event, payload);
+ }
+ },
+ /**
+ * Receive a message from the server.
+ * @param event - The event name
+ * @param callback - The callback to run when the event is received.
+ * The payload's content will be passed to the callback as an argument
+ */
+ on: <T>(event: string, callback: (data: T) => void) => {
+ if (import.meta.hot) {
+ import.meta.hot.on(event, callback);
+ }
+ },
+};
+
+export type ToolbarServerHelpers = typeof serverHelpers;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/settings.ts b/packages/astro/src/runtime/client/dev-toolbar/settings.ts
new file mode 100644
index 000000000..8441afc2f
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/settings.ts
@@ -0,0 +1,58 @@
+import type { Placement } from './ui-library/window.js';
+
+export interface Settings {
+ disableAppNotification: boolean;
+ verbose: boolean;
+ placement: Placement;
+}
+
+export const defaultSettings = {
+ disableAppNotification: false,
+ verbose: false,
+ placement: 'bottom-center',
+} satisfies Settings;
+
+export const settings = getSettings();
+
+function getSettings() {
+ let _settings: Settings = { ...defaultSettings };
+ const toolbarSettings = localStorage.getItem('astro:dev-toolbar:settings');
+
+ if (toolbarSettings) {
+ _settings = { ..._settings, ...JSON.parse(toolbarSettings) };
+ }
+
+ function updateSetting<Key extends keyof Settings>(key: Key, value: Settings[Key]) {
+ _settings[key] = value;
+ localStorage.setItem('astro:dev-toolbar:settings', JSON.stringify(_settings));
+ }
+
+ function log(message: string, level: 'log' | 'warn' | 'error' = 'log') {
+ console[level](
+ `%cAstro`,
+ 'background: linear-gradient(66.77deg, #D83333 0%, #F041FF 100%); color: white; padding-inline: 4px; border-radius: 2px; font-family: monospace;',
+ message,
+ );
+ }
+
+ return {
+ get config() {
+ return _settings;
+ },
+ updateSetting,
+ logger: {
+ log,
+ warn: (message: string) => {
+ log(message, 'warn');
+ },
+ error: (message: string) => {
+ log(message, 'error');
+ },
+ verboseLog: (message: string) => {
+ if (_settings.verbose) {
+ log(message);
+ }
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts
new file mode 100644
index 000000000..1ab7e9d09
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/toolbar.ts
@@ -0,0 +1,604 @@
+import type { ResolvedDevToolbarApp as DevToolbarAppDefinition } from '../../../types/public/toolbar.js';
+import { type ToolbarAppEventTarget, serverHelpers } from './helpers.js';
+import { settings } from './settings.js';
+import { type Icon, getIconElement, isDefinedIcon } from './ui-library/icons.js';
+import type { Placement } from './ui-library/window.js';
+
+export type DevToolbarApp = DevToolbarAppDefinition & {
+ builtIn: boolean;
+ active: boolean;
+ status: 'ready' | 'loading' | 'error';
+ notification: {
+ state: boolean;
+ level?: 'error' | 'warning' | 'info';
+ };
+ eventTarget: ToolbarAppEventTarget;
+};
+const WS_EVENT_NAME = 'astro-dev-toolbar';
+
+const HOVER_DELAY = 2 * 1000;
+const DEVBAR_HITBOX_ABOVE = 42;
+
+export class AstroDevToolbar extends HTMLElement {
+ shadowRoot: ShadowRoot;
+ delayedHideTimeout: number | undefined;
+ devToolbarContainer: HTMLDivElement | undefined;
+ apps: DevToolbarApp[] = [];
+ hasBeenInitialized = false;
+ // TODO: This should be dynamic based on the screen size or at least configurable, erika - 2023-11-29
+ customAppsToShow = 3;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ }
+
+ /**
+ * All one-time DOM setup runs through here. Only ever call this once,
+ * in connectedCallback(), and protect it from being called again.
+ */
+ init() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ /* Important! Reset all inherited styles to initial */
+ all: initial;
+ z-index: 999999;
+ view-transition-name: astro-dev-toolbar;
+ display: contents;
+
+ /* Hide the dev toolbar on window.print() (CTRL + P) */
+ @media print {
+ display: none;
+ }
+ }
+
+ ::view-transition-old(astro-dev-toolbar),
+ ::view-transition-new(astro-dev-toolbar) {
+ animation: none;
+ }
+
+ #dev-toolbar-root {
+ position: fixed;
+ bottom: 0px;
+ z-index: 2000000010;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transition: bottom 0.35s cubic-bezier(0.485, -0.050, 0.285, 1.505);
+ pointer-events: none;
+ }
+
+ #dev-toolbar-root[data-hidden] {
+ bottom: -40px;
+ }
+
+ #dev-toolbar-root[data-hidden] #dev-bar .item {
+ opacity: 0.2;
+ }
+
+ #dev-toolbar-root[data-placement="bottom-left"] {
+ left: 16px;
+ }
+ #dev-toolbar-root[data-placement="bottom-center"] {
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ #dev-toolbar-root[data-placement="bottom-right"] {
+ right: 16px;
+ }
+
+ #dev-bar-hitbox-above,
+ #dev-bar-hitbox-below {
+ width: 100%;
+ pointer-events: auto;
+ }
+ #dev-bar-hitbox-above {
+ height: ${DEVBAR_HITBOX_ABOVE}px;
+ }
+ #dev-bar-hitbox-below {
+ height: 16px;
+ }
+ #dev-bar {
+ height: 40px;
+ overflow: hidden;
+ pointer-events: auto;
+ background: linear-gradient(180deg, #13151A 0%, rgba(19, 21, 26, 0.88) 100%);
+ border: 1px solid #343841;
+ border-radius: 9999px;
+ box-shadow: 0px 0px 0px 0px rgba(19, 21, 26, 0.30), 0px 1px 2px 0px rgba(19, 21, 26, 0.29), 0px 4px 4px 0px rgba(19, 21, 26, 0.26), 0px 10px 6px 0px rgba(19, 21, 26, 0.15), 0px 17px 7px 0px rgba(19, 21, 26, 0.04), 0px 26px 7px 0px rgba(19, 21, 26, 0.01);
+ }
+
+ @media (forced-colors: active) {
+ #dev-bar {
+ background: white;
+ }
+ }
+
+ #dev-bar .item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 44px;
+ border: 0;
+ background: transparent;
+ color: white;
+ font-family: system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ transition: opacity 0.2s ease-out 0s;
+ }
+
+ #dev-bar #bar-container .item:hover, #dev-bar #bar-container .item:focus-visible {
+ background: #FFFFFF20;
+ cursor: pointer;
+ outline-offset: -3px;
+ }
+
+ #dev-bar #bar-container .item[data-app-error]:hover, #dev-bar #bar-container .item[data-app-error]:focus-visible {
+ cursor: not-allowed;
+ background: #ff252520;
+ }
+
+ #dev-bar .item:first-of-type {
+ border-top-left-radius: 9999px;
+ border-bottom-left-radius: 9999px;
+ width: 42px;
+ padding-left: 4px;
+ }
+
+ #dev-bar .item:last-of-type {
+ border-top-right-radius: 9999px;
+ border-bottom-right-radius: 9999px;
+ width: 42px;
+ padding-right: 4px;
+ }
+ #dev-bar #bar-container .item.active {
+ background: rgba(71, 78, 94, 1);
+ }
+
+ #dev-bar .item-tooltip {
+ background: linear-gradient(0deg, #13151A, #13151A), linear-gradient(0deg, #343841, #343841);
+ border: 1px solid rgba(52, 56, 65, 1);
+ border-radius: 4px;
+ padding: 4px 8px;
+ position: absolute;
+ top: ${4 - DEVBAR_HITBOX_ABOVE}px;
+ font-size: 14px;
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out 0s;
+ pointer-events: none;
+ user-select: none;
+ }
+
+ #dev-bar .item-tooltip::after{
+ content: '';
+ position: absolute;
+ left: calc(50% - 5px);
+ bottom: -6px;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 5px solid #343841;
+ }
+
+ #dev-bar .item[data-app-error] .icon {
+ opacity: 0.35;
+ }
+
+ #dev-bar .item:hover .item-tooltip, #dev-bar .item:not(.active):focus-visible .item-tooltip {
+ transition: opacity 0.2s ease-in-out 200ms;
+ opacity: 1;
+ }
+
+ @media (forced-colors: active) {
+ #dev-bar .item:hover .item-tooltip,
+ #dev-bar .item:not(.active):focus-visible .item-tooltip {
+ background: white;
+ }
+ }
+
+ #dev-bar #bar-container .item:hover .notification rect, #dev-bar #bar-container .item:hover .notification path {
+ stroke: #38393D;
+ --fill: var(--fill-hover);
+ }
+
+ #dev-bar #bar-container .item.active .notification rect, #dev-bar #bar-container .item.active .notification path {
+ stroke: #454C5C;
+ --fill: var(--fill-hover);
+ }
+
+ #dev-bar .item .icon {
+ position: relative;
+ max-width: 20px;
+ max-height: 20px;
+ user-select: none;
+ }
+
+ #dev-bar .item .icon>svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+ margin: auto;
+ }
+
+ @media (forced-colors: active) {
+ #dev-bar .item svg path[fill="#fff"] {
+ fill: black;
+ }
+ }
+
+ #dev-bar .item .notification {
+ display: none;
+ position: absolute;
+ top: -4px;
+ right: -6px;
+ width: 10px;
+ height: 10px;
+ }
+
+ #dev-bar .item .notification svg {
+ display: block;
+ }
+
+ #dev-toolbar-root:not([data-no-notification]) #dev-bar .item .notification[data-active] {
+ display: block;
+ }
+
+ #dev-bar #bar-container {
+ height: 100%;
+ display: flex;
+ }
+
+ #dev-bar .separator {
+ background: rgba(52, 56, 65, 1);
+ width: 1px;
+ }
+ </style>
+ <div id="dev-toolbar-root" data-hidden ${
+ settings.config.disableAppNotification ? 'data-no-notification' : ''
+ } data-placement="${settings.config.placement}">
+ <div id="dev-bar-hitbox-above"></div>
+ <div id="dev-bar">
+ <div id="bar-container">
+ ${this.apps
+ .filter((app) => app.builtIn && !['astro:settings', 'astro:more'].includes(app.id))
+ .map((app) => this.getAppTemplate(app))
+ .join('')}
+ ${
+ this.apps.filter((app) => !app.builtIn).length > 0
+ ? `<div class="separator"></div>${this.apps
+ .filter((app) => !app.builtIn)
+ .slice(0, this.customAppsToShow)
+ .map((app) => this.getAppTemplate(app))
+ .join('')}`
+ : ''
+ }
+ ${
+ this.apps.filter((app) => !app.builtIn).length > this.customAppsToShow
+ ? this.getAppTemplate(
+ this.apps.find((app) => app.builtIn && app.id === 'astro:more')!,
+ )
+ : ''
+ }
+ <div class="separator"></div>
+ ${this.getAppTemplate(
+ this.apps.find((app) => app.builtIn && app.id === 'astro:settings')!,
+ )}
+ </div>
+ </div>
+ <div id="dev-bar-hitbox-below"></div>
+ </div>`;
+
+ this.devToolbarContainer = this.shadowRoot.querySelector<HTMLDivElement>('#dev-toolbar-root')!;
+ this.attachEvents();
+
+ // Create app canvases
+ this.apps.forEach(async (app) => {
+ settings.logger.verboseLog(`Creating app canvas for ${app.id}`);
+ const appCanvas = document.createElement('astro-dev-toolbar-app-canvas');
+ appCanvas.dataset.appId = app.id;
+ this.shadowRoot?.append(appCanvas);
+ });
+
+ // Init app lazily, so that the page can load faster.
+ // Fallback to setTimeout for Safari (sad!)
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(
+ async () => {
+ this.apps.map((app) => this.initApp(app));
+ },
+ { timeout: 300 },
+ );
+ } else {
+ setTimeout(async () => {
+ this.apps.map((app) => this.initApp(app));
+ }, 300);
+ }
+ }
+
+ // This is called whenever the component is connected to the DOM.
+ // This happens on first page load, and on each page change when
+ // view transitions are used.
+ connectedCallback() {
+ if (!this.hasBeenInitialized) {
+ this.init();
+ this.hasBeenInitialized = true;
+ }
+
+ // Run this every time to make sure the correct app is open.
+ this.apps.forEach(async (app) => {
+ await this.setAppStatus(app, app.active);
+ });
+ }
+
+ attachEvents() {
+ const items = this.shadowRoot.querySelectorAll<HTMLDivElement>('.item');
+ items.forEach((item) => {
+ item.addEventListener('click', async (event) => {
+ const target = event.currentTarget;
+ if (!target || !(target instanceof HTMLElement)) return;
+ const id = target.dataset.appId;
+ if (!id) return;
+ const app = this.getAppById(id);
+ if (!app) return;
+ event.stopPropagation();
+ await this.toggleAppStatus(app);
+ });
+ });
+
+ (['mouseenter', 'focusin'] as const).forEach((event) => {
+ this.devToolbarContainer!.addEventListener(event, () => {
+ this.clearDelayedHide();
+ if (this.isHidden()) {
+ this.setToolbarVisible(true);
+ }
+ });
+ });
+
+ (['mouseleave', 'focusout'] as const).forEach((event) => {
+ this.devToolbarContainer!.addEventListener(event, () => {
+ this.clearDelayedHide();
+ if (this.getActiveApp() || this.isHidden()) {
+ return;
+ }
+ this.triggerDelayedHide();
+ });
+ });
+
+ document.addEventListener('keyup', (event) => {
+ if (event.key !== 'Escape') return;
+ if (this.isHidden()) return;
+ const activeApp = this.getActiveApp();
+ if (activeApp) {
+ this.toggleAppStatus(activeApp);
+ } else {
+ this.setToolbarVisible(false);
+ }
+ });
+ }
+
+ async initApp(app: DevToolbarApp) {
+ const shadowRoot = this.getAppCanvasById(app.id)!.shadowRoot!;
+ app.status = 'loading';
+ try {
+ settings.logger.verboseLog(`Initializing app ${app.id}`);
+
+ await app.init?.(shadowRoot, app.eventTarget, serverHelpers);
+ app.status = 'ready';
+
+ if (import.meta.hot) {
+ import.meta.hot.send(`${WS_EVENT_NAME}:${app.id}:initialized`);
+ }
+ } catch (e) {
+ console.error(`Failed to init app ${app.id}, error: ${e}`);
+ app.status = 'error';
+
+ if (import.meta.hot) {
+ import.meta.hot.send('astro:devtoolbar:error:init', {
+ app: app,
+ error: e instanceof Error ? e.stack : e,
+ });
+ }
+
+ const appButton = this.getAppButtonById(app.id);
+ const appTooltip = appButton?.querySelector<HTMLElement>('.item-tooltip');
+
+ if (appButton && appTooltip) {
+ appButton.toggleAttribute('data-app-error', true);
+ appTooltip.innerText = `Error initializing ${app.name}`;
+ }
+ }
+ }
+
+ getAppTemplate(app: DevToolbarApp) {
+ return `<button class="item" data-app-id="${app.id}">
+ <div class="icon">${app.icon ? getAppIcon(app.icon) : '?'}<div class="notification"></div></div>
+ <span class="item-tooltip">${app.name}</span>
+ </button>`;
+ }
+
+ getAppById(id: string) {
+ return this.apps.find((app) => app.id === id);
+ }
+
+ getAppCanvasById(id: string) {
+ return this.shadowRoot.querySelector<HTMLElement>(
+ `astro-dev-toolbar-app-canvas[data-app-id="${id}"]`,
+ );
+ }
+
+ getAppButtonById(id: string) {
+ return this.shadowRoot.querySelector<HTMLElement>(`[data-app-id="${id}"]`);
+ }
+
+ async toggleAppStatus(app: DevToolbarApp) {
+ const activeApp = this.getActiveApp();
+ if (activeApp) {
+ const closeApp = await this.setAppStatus(activeApp, false);
+
+ // If the app returned false, don't open the new app, the old app didn't want to close
+ if (!closeApp) return;
+ }
+
+ // TODO(fks): Handle a app that hasn't loaded yet.
+ // Currently, this will just do nothing.
+ if (app.status !== 'ready') return;
+
+ // Open the selected app. If the selected app was
+ // already the active app then the desired outcome
+ // was to close that app, so no action needed.
+ if (app !== activeApp) {
+ await this.setAppStatus(app, true);
+
+ if (import.meta.hot && app.id !== 'astro:more') {
+ import.meta.hot.send('astro:devtoolbar:app:toggled', {
+ app: app,
+ });
+ }
+ }
+ }
+
+ async setAppStatus(app: DevToolbarApp, newStatus: boolean) {
+ const appCanvas = this.getAppCanvasById(app.id);
+ if (!appCanvas) return false;
+
+ if (app.active && !newStatus && app.beforeTogglingOff) {
+ const shouldToggleOff = await app.beforeTogglingOff(appCanvas.shadowRoot!);
+
+ // If the app returned false, don't toggle it off, maybe the app showed a confirmation dialog or similar
+ if (!shouldToggleOff) return false;
+ }
+
+ app.active = newStatus ?? !app.active;
+ const mainBarButton = this.getAppButtonById(app.id);
+ const moreBarButton = this.getAppCanvasById('astro:more')?.shadowRoot?.querySelector(
+ `[data-app-id="${app.id}"]`,
+ );
+
+ if (mainBarButton) {
+ mainBarButton.classList.toggle('active', app.active);
+ }
+
+ if (moreBarButton) {
+ moreBarButton.classList.toggle('active', app.active);
+ }
+
+ if (app.active) {
+ appCanvas.style.display = 'block';
+ appCanvas.setAttribute('data-active', '');
+ } else {
+ appCanvas.style.display = 'none';
+ appCanvas.removeAttribute('data-active');
+ }
+
+ app.eventTarget.dispatchEvent(
+ new CustomEvent('app-toggled', {
+ detail: {
+ state: app.active,
+ app,
+ },
+ }),
+ );
+
+ import.meta.hot?.send(`${WS_EVENT_NAME}:${app.id}:toggled`, { state: app.active });
+
+ return true;
+ }
+
+ isHidden(): boolean {
+ return this.devToolbarContainer?.hasAttribute('data-hidden') ?? true;
+ }
+
+ getActiveApp(): DevToolbarApp | undefined {
+ return this.apps.find((app) => app.active);
+ }
+
+ clearDelayedHide() {
+ window.clearTimeout(this.delayedHideTimeout);
+ this.delayedHideTimeout = undefined;
+ }
+
+ triggerDelayedHide() {
+ this.clearDelayedHide();
+ this.delayedHideTimeout = window.setTimeout(() => {
+ this.setToolbarVisible(false);
+ this.delayedHideTimeout = undefined;
+ }, HOVER_DELAY);
+ }
+
+ setToolbarVisible(newStatus: boolean) {
+ const barContainer = this.shadowRoot.querySelector<HTMLDivElement>('#bar-container');
+ const devBar = this.shadowRoot.querySelector<HTMLDivElement>('#dev-bar');
+ const devBarHitboxAbove =
+ this.shadowRoot.querySelector<HTMLDivElement>('#dev-bar-hitbox-above');
+ if (newStatus === true) {
+ this.devToolbarContainer?.removeAttribute('data-hidden');
+ barContainer?.removeAttribute('inert');
+ devBar?.removeAttribute('tabindex');
+ if (devBarHitboxAbove) devBarHitboxAbove.style.height = '0';
+ return;
+ }
+ if (newStatus === false) {
+ this.devToolbarContainer?.setAttribute('data-hidden', '');
+ barContainer?.setAttribute('inert', '');
+ devBar?.setAttribute('tabindex', '0');
+ if (devBarHitboxAbove) devBarHitboxAbove.style.height = `${DEVBAR_HITBOX_ABOVE}px`;
+ return;
+ }
+ }
+
+ setNotificationVisible(newStatus: boolean) {
+ this.devToolbarContainer?.toggleAttribute('data-no-notification', !newStatus);
+
+ const moreCanvas = this.getAppCanvasById('astro:more');
+ moreCanvas?.shadowRoot
+ ?.querySelector('#dropdown')
+ ?.toggleAttribute('data-no-notification', !newStatus);
+ }
+
+ setToolbarPlacement(newPlacement: Placement) {
+ this.devToolbarContainer?.setAttribute('data-placement', newPlacement);
+ this.apps.forEach((app) => {
+ app.eventTarget.dispatchEvent(
+ new CustomEvent('placement-updated', {
+ detail: {
+ placement: newPlacement,
+ },
+ }),
+ );
+ });
+ }
+}
+
+export class DevToolbarCanvas extends HTMLElement {
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ </style>`;
+ }
+}
+
+export function getAppIcon(icon: Icon) {
+ if (isDefinedIcon(icon)) {
+ return getIconElement(icon).outerHTML;
+ }
+
+ return icon;
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/badge.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/badge.ts
new file mode 100644
index 000000000..f018e8f82
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/badge.ts
@@ -0,0 +1,120 @@
+import { settings } from '../settings.js';
+
+const sizes = ['small', 'large'] as const;
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type BadgeSize = (typeof sizes)[number];
+type BadgeStyle = (typeof styles)[number];
+
+export class DevToolbarBadge extends HTMLElement {
+ _size: BadgeSize = 'small';
+ _badgeStyle: BadgeStyle = 'purple';
+
+ get size() {
+ return this._size;
+ }
+
+ set size(value) {
+ if (!sizes.includes(value)) {
+ settings.logger.error(
+ `Invalid size: ${value}, expected one of ${sizes.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._size = value;
+ this.updateStyle();
+ }
+
+ get badgeStyle() {
+ return this._badgeStyle;
+ }
+
+ set badgeStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(
+ `Invalid style: ${value}, expected one of ${styles.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._badgeStyle = value;
+ this.updateStyle();
+ }
+
+ shadowRoot: ShadowRoot;
+
+ static observedAttributes = ['badge-style', 'size'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ .badge {
+ box-sizing: border-box;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ padding: 8px;
+ font-size: 12px;
+ color: var(--text-color);
+ height: var(--size);
+ border: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+
+ --purple-text: rgba(224, 204, 250, 1);
+ --purple-border: rgba(113, 24, 226, 1);
+
+ --gray-text: rgba(191, 193, 201, 1);
+ --gray-border:rgba(191, 193, 201, 1);
+
+ --red-text: rgba(249, 196, 215, 1);
+ --red-border: rgba(179, 62, 102, 1);
+
+ --green-text: rgba(213, 249, 196, 1);
+ --green-border: rgba(61, 125, 31, 1);
+
+ --yellow-text: rgba(249, 233, 196, 1);
+ --yellow-border: rgba(181, 138, 45, 1);
+
+ --blue-text: rgba(189, 195, 255, 1);
+ --blue-border: rgba(54, 69, 217, 1);
+
+ --large: 24px;
+ --small: 20px;
+ }
+ </style>
+ <style id="selected-style"></style>
+
+ <div class="badge">
+ <slot></slot>
+ </div>
+ `;
+ }
+
+ connectedCallback() {
+ this.updateStyle();
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('badge-style'))
+ this.badgeStyle = this.getAttribute('badge-style') as BadgeStyle;
+
+ if (this.hasAttribute('size')) this.size = this.getAttribute('size') as BadgeSize;
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+
+ if (style) {
+ style.innerHTML = `
+ .badge {
+ --text-color: var(--${this.badgeStyle}-text);
+ --border-color: var(--${this.badgeStyle}-border);
+ --size: var(--${this.size});
+ }`;
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/button.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/button.ts
new file mode 100644
index 000000000..224ad14c1
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/button.ts
@@ -0,0 +1,179 @@
+import { settings } from '../settings.js';
+
+const sizes = ['small', 'medium', 'large'] as const;
+const styles = ['ghost', 'outline', 'purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+const borderRadii = ['normal', 'rounded'] as const;
+
+type ButtonSize = (typeof sizes)[number];
+type ButtonStyle = (typeof styles)[number];
+type ButtonBorderRadius = (typeof borderRadii)[number];
+
+export class DevToolbarButton extends HTMLElement {
+ _size: ButtonSize = 'small';
+ _buttonStyle: ButtonStyle = 'purple';
+ _buttonBorderRadius: ButtonBorderRadius = 'normal';
+
+ get size() {
+ return this._size;
+ }
+
+ set size(value) {
+ if (!sizes.includes(value)) {
+ settings.logger.error(
+ `Invalid size: ${value}, expected one of ${sizes.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._size = value;
+ this.updateStyle();
+ }
+
+ get buttonStyle() {
+ return this._buttonStyle;
+ }
+
+ set buttonStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(
+ `Invalid style: ${value}, expected one of ${styles.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._buttonStyle = value;
+ this.updateStyle();
+ }
+
+ get buttonBorderRadius() {
+ return this._buttonBorderRadius;
+ }
+
+ set buttonBorderRadius(value) {
+ if (!borderRadii.includes(value)) {
+ settings.logger.error(
+ `Invalid border-radius: ${value}, expected one of ${borderRadii.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._buttonBorderRadius = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['button-style', 'size', 'button-border-radius'];
+
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ button {
+ --purple-background: rgba(113, 24, 226, 1);
+ --purple-border: rgba(224, 204, 250, 0.33);
+ --purple-text: #fff;
+
+ --gray-background: rgba(52, 56, 65, 1);
+ --gray-border: rgba(71, 78, 94, 1);
+ --gray-text: #fff;
+
+ --red-background: rgba(179, 62, 102, 1);
+ --red-border: rgba(249, 196, 215, 0.33);
+ --red-text: #fff;
+
+ --green-background: rgba(213, 249, 196, 1);
+ --green-border: rgba(61, 125, 31, 1);
+ --green-text: #000;
+
+ --yellow-background: rgba(255, 236, 179, 1);
+ --yellow-border: rgba(255, 191, 0, 1);
+ --yellow-text: #000;
+
+ --blue-background: rgba(54, 69, 217, 1);
+ --blue-border: rgba(189, 195, 255, 1);
+ --blue-text: #fff;
+
+ --outline-background: transparent;
+ --outline-border: #fff;
+ --outline-text: #fff;
+
+ --ghost-background: transparent;
+ --ghost-border: transparent;
+ --ghost-text: #fff;
+
+ --large-font-size: 16px;
+ --medium-font-size: 14px;
+ --small-font-size: 12px;
+
+ --large-padding: 12px 16px;
+ --large-rounded-padding: 12px 12px;
+ --medium-padding: 8px 12px;
+ --medium-rounded-padding: 8px 8px;
+ --small-padding: 4px 8px;
+ --small-rounded-padding: 4px 4px;
+
+ --normal-border-radius: 4px;
+ --rounded-border-radius: 9999px;
+
+ border: 1px solid var(--border);
+ padding: var(--padding);
+ font-size: var(--font-size);
+ background: var(--background);
+
+ color: var(--text-color);
+ border-radius: var(--border-radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ button:hover {
+ cursor: pointer;
+ }
+
+ ::slotted(astro-dev-toolbar-icon) {
+ display: inline-block;
+ height: 1em;
+ width: 1em;
+ margin-left: 0.5em;
+ }
+ </style>
+ <style id="selected-style"></style>
+
+ <button>
+ <slot></slot>
+ </button>
+ `;
+ }
+
+ connectedCallback() {
+ this.updateStyle();
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+
+ if (style) {
+ style.innerHTML = `
+ button {
+ --background: var(--${this.buttonStyle}-background);
+ --border: var(--${this.buttonStyle}-border);
+ --font-size: var(--${this.size}-font-size);
+ --text-color: var(--${this.buttonStyle}-text);
+ ${
+ this.buttonBorderRadius === 'normal'
+ ? '--padding: var(--' + this.size + '-padding);'
+ : '--padding: var(--' + this.size + '-rounded-padding);'
+ }
+ --border-radius: var(--${this.buttonBorderRadius}-border-radius);
+ }`;
+ }
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('size')) this.size = this.getAttribute('size') as ButtonSize;
+
+ if (this.hasAttribute('button-style'))
+ this.buttonStyle = this.getAttribute('button-style') as ButtonStyle;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/card.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/card.ts
new file mode 100644
index 000000000..abaf90efa
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/card.ts
@@ -0,0 +1,133 @@
+import { settings } from '../settings.js';
+
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type CardStyle = (typeof styles)[number];
+
+export class DevToolbarCard extends HTMLElement {
+ link?: string | undefined | null;
+ clickAction?: () => void | (() => Promise<void>);
+ shadowRoot: ShadowRoot;
+
+ _cardStyle: CardStyle = 'purple';
+
+ get cardStyle() {
+ return this._cardStyle;
+ }
+
+ set cardStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(
+ `Invalid style: ${value}, expected one of ${styles.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._cardStyle = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['card-style'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.link = this.getAttribute('link');
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('card-style'))
+ this.cardStyle = this.getAttribute('card-style') as CardStyle;
+
+ this.updateStyle();
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+
+ if (style) {
+ style.innerHTML = `
+ :host {
+ --hover-background: var(--${this.cardStyle}-hover-background);
+ --hover-border: var(--${this.cardStyle}-hover-border);
+ }
+ `;
+ }
+ }
+
+ connectedCallback() {
+ const element = this.link ? 'a' : this.clickAction ? 'button' : 'div';
+
+ this.shadowRoot.innerHTML += `
+ <style>
+ :host {
+ --purple-hover-background: rgba(136, 58, 234, 0.33);
+ --purple-hover-border: 1px solid rgba(113, 24, 226, 1);
+
+ --gray-hover-background: rgba(191, 193, 201, 0.33);
+ --gray-hover-border: 1px solid rgba(191, 193, 201, 1);
+
+ --red-hover-background: rgba(249, 196, 215, 0.33);
+ --red-hover-border: 1px solid rgba(179, 62, 102, 1);
+
+ --green-hover-background: rgba(213, 249, 196, 0.33);
+ --green-hover-border: 1px solid rgba(61, 125, 31, 1);
+
+ --yellow-hover-background: rgba(255, 236, 179, 0.33);
+ --yellow-hover-border: 1px solid rgba(255, 191, 0, 1);
+
+ --blue-hover-background: rgba(189, 195, 255, 0.33);
+ --blue-hover-border: 1px solid rgba(54, 69, 217, 1);
+ }
+
+ :host>a, :host>button, :host>div {
+ box-sizing: border-box;
+ padding: 16px;
+ display: block;
+ border-radius: 8px;
+ border: 1px solid rgba(35, 38, 45, 1);
+ color: rgba(191, 193, 201, 1);
+ text-decoration: none;
+ background-color: #13151A;
+ box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0.10), 0px 1px 2px 0px rgba(0, 0, 0, 0.10), 0px 4px 4px 0px rgba(0, 0, 0, 0.09), 0px 10px 6px 0px rgba(0, 0, 0, 0.05), 0px 17px 7px 0px rgba(0, 0, 0, 0.01), 0px 26px 7px 0px rgba(0, 0, 0, 0.00);
+ width: 100%;
+ height: 100%;
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ color: #fff;
+ font-weight: 600;
+ }
+
+ a:hover, button:hover {
+ background: var(--hover-background);
+ border: var(--hover-border);
+ }
+
+ svg {
+ display: block;
+ margin: 0 auto;
+ }
+
+ span {
+ margin-top: 8px;
+ display: block;
+ text-align: center;
+ }
+ </style>
+ <style id="selected-style"></style>
+
+ <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``} id="astro-overlay-card">
+ <slot />
+ </${element}>
+ `;
+
+ this.updateStyle();
+
+ if (this.clickAction) {
+ this.shadowRoot
+ .getElementById('astro-overlay-card')
+ ?.addEventListener('click', this.clickAction);
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/highlight.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/highlight.ts
new file mode 100644
index 000000000..c77d2103b
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/highlight.ts
@@ -0,0 +1,130 @@
+import { settings } from '../settings.js';
+import { type Icon, getIconElement, isDefinedIcon } from './icons.js';
+
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type HighlightStyle = (typeof styles)[number];
+
+export class DevToolbarHighlight extends HTMLElement {
+ icon?: Icon | undefined | null;
+ _highlightStyle: HighlightStyle = 'purple';
+
+ get highlightStyle() {
+ return this._highlightStyle;
+ }
+
+ set highlightStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(
+ `Invalid style: ${value}, expected one of ${styles.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._highlightStyle = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['highlight-style'];
+
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.icon = this.hasAttribute('icon') ? (this.getAttribute('icon') as Icon) : undefined;
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ --purple-background: linear-gradient(180deg, rgba(224, 204, 250, 0.33) 0%, rgba(224, 204, 250, 0.0825) 100%);
+ --purple-border: 1px solid rgba(113, 24, 226, 1);
+
+ --gray-background: linear-gradient(180deg, rgba(191, 193, 201, 0.33) 0%, rgba(191, 193, 201, 0.0825) 100%);
+ --gray-border: 1px solid rgba(191, 193, 201, 1);
+
+ --red-background: linear-gradient(180deg, rgba(249, 196, 215, 0.33) 0%, rgba(249, 196, 215, 0.0825) 100%);
+ --red-border: 1px solid rgba(179, 62, 102, 1);
+
+ --green-background: linear-gradient(180deg, rgba(213, 249, 196, 0.33) 0%, rgba(213, 249, 196, 0.0825) 100%);
+ --green-border: 1px solid rgba(61, 125, 31, 1);
+
+ --yellow-background: linear-gradient(180deg, rgba(255, 236, 179, 0.33) 0%, rgba(255, 236, 179, 0.0825) 100%);
+ --yellow-border: 1px solid rgba(181, 138, 45, 1);
+
+ --blue-background: linear-gradient(180deg, rgba(189, 195, 255, 0.33) 0%, rgba(189, 195, 255, 0.0825) 100%);
+ --blue-border: 1px solid rgba(54, 69, 217, 1);
+
+ border-radius: 4px;
+ display: block;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: 2000000000;
+
+ background: var(--background);
+ border: var(--border);
+ }
+
+ .icon {
+ width: 24px;
+ height: 24px;
+ color: white;
+ background: linear-gradient(0deg, #B33E66, #B33E66), linear-gradient(0deg, #351722, #351722);
+ border: 1px solid rgba(53, 23, 34, 1);
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ top: -15px;
+ right: -15px;
+ }
+ </style>
+ <style id="selected-style"></style>
+ `;
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+
+ if (style) {
+ style.innerHTML = `
+ :host {
+ --background: var(--${this.highlightStyle}-background);
+ --border: var(--${this.highlightStyle}-border);
+ }`;
+ }
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('highlight-style'))
+ this.highlightStyle = this.getAttribute('highlight-style') as HighlightStyle;
+ }
+
+ connectedCallback() {
+ this.updateStyle();
+
+ if (this.icon) {
+ let iconContainer = document.createElement('div');
+ iconContainer.classList.add('icon');
+
+ let iconElement;
+ if (isDefinedIcon(this.icon)) {
+ iconElement = getIconElement(this.icon);
+ } else {
+ iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = this.icon;
+ }
+
+ if (iconElement) {
+ iconElement?.style.setProperty('width', '16px');
+ iconElement?.style.setProperty('height', '16px');
+
+ iconContainer.append(iconElement);
+ this.shadowRoot.append(iconContainer);
+ }
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/icon.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/icon.ts
new file mode 100644
index 000000000..22d3f903f
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/icon.ts
@@ -0,0 +1,51 @@
+import { type Icon, getIconElement, isDefinedIcon } from './icons.js';
+
+export class DevToolbarIcon extends HTMLElement {
+ _icon: Icon | undefined = undefined;
+ shadowRoot: ShadowRoot;
+
+ get icon() {
+ return this._icon;
+ }
+ set icon(name: Icon | undefined) {
+ this._icon = name;
+ this.buildTemplate();
+ }
+
+ constructor() {
+ super();
+
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ if (this.hasAttribute('icon')) {
+ this.icon = this.getAttribute('icon') as Icon;
+ } else {
+ this.buildTemplate();
+ }
+ }
+
+ getIconHTML(icon: Icon | undefined) {
+ if (icon && isDefinedIcon(icon)) {
+ return getIconElement(icon)?.outerHTML ?? '';
+ }
+
+ // If the icon that was passed isn't one of the predefined one, assume that they're passing it in as a slot
+ return '<slot />';
+ }
+
+ buildTemplate() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ @media (forced-colors: active) {
+ svg path[fill="#fff"] {
+ fill: black;
+ }
+ }
+ </style>\n${this.getIconHTML(this._icon)}`;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/icons.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/icons.ts
new file mode 100644
index 000000000..f96660253
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/icons.ts
@@ -0,0 +1,72 @@
+export type DefinedIcon = keyof typeof icons;
+export type Icon = DefinedIcon | (string & NonNullable<unknown>);
+
+export function isDefinedIcon(icon: Icon): icon is DefinedIcon {
+ return icon in icons;
+}
+
+export function getIconElement(name: DefinedIcon): SVGElement;
+export function getIconElement(name: string & NonNullable<unknown>): undefined;
+export function getIconElement(
+ name: DefinedIcon | (string & NonNullable<unknown>),
+): SVGElement | undefined {
+ const icon = icons[name as DefinedIcon];
+
+ if (!icon) {
+ return undefined;
+ }
+
+ const svgFragment = new DocumentFragment();
+ svgFragment.append(document.createRange().createContextualFragment(icon));
+
+ return svgFragment.firstElementChild as SVGElement;
+}
+
+const icons = {
+ 'astro:logo': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 85 107" aria-hidden="true"><path fill="#fff" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="url(#a)" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="#fff" d="M0 69.6s14.3-7 28.7-7l10.8-33.5c.4-1.6 1.6-2.7 3-2.7 1.2 0 2.4 1.1 2.8 2.7l10.9 33.5c17 0 28.6 7 28.6 7L60.5 3.2c-.7-2-2-3.2-3.5-3.2H27.8c-1.6 0-2.7 1.3-3.4 3.2L0 69.6Z"/><defs><linearGradient id="a" x1="22.5" x2="69.1" y1="107" y2="84.9" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>`,
+ warning: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8 .40625c-1.5019 0-2.97007.445366-4.21886 1.27978C2.53236 2.52044 1.55905 3.70642.984293 5.094.40954 6.48157.259159 8.00842.552165 9.48147.845172 10.9545 1.56841 12.3076 2.63041 13.3696c1.06201 1.062 2.41508 1.7852 3.88813 2.0782 1.47304.293 2.99989.1427 4.38746-.4321 1.3876-.5747 2.5736-1.5481 3.408-2.7968.8344-1.2488 1.2798-2.717 1.2798-4.2189-.0023-2.0133-.8031-3.9435-2.2267-5.36713C11.9435 1.20925 10.0133.408483 8 .40625ZM8 13.9062c-1.16814 0-2.31006-.3463-3.28133-.9953-.97128-.649-1.7283-1.5715-2.17533-2.6507-.44703-1.0792-.56399-2.26675-.3361-3.41245.22789-1.1457.79041-2.1981 1.61641-3.0241.82601-.826 1.8784-1.38852 3.0241-1.61641 1.1457-.2279 2.33325-.11093 3.41245.3361 1.0793.44703 2.0017 1.20405 2.6507 2.17532.649.97128.9954 2.11319.9954 3.28134-.0017 1.56592-.6245 3.0672-1.7318 4.1745S9.56592 13.9046 8 13.9062Zm-.84375-5.62495V4.625c0-.22378.0889-.43839.24713-.59662.15824-.15824.37285-.24713.59662-.24713.22378 0 .43839.08889.59662.24713.15824.15823.24713.37284.24713.59662v3.65625c0 .22378-.08889.43839-.24713.59662C8.43839 9.03611 8.22378 9.125 8 9.125c-.22377 0-.43838-.08889-.59662-.24713-.15823-.15823-.24713-.37284-.24713-.59662ZM9.125 11.0938c0 .2225-.06598.44-.18959.625-.12362.185-.29932.3292-.50489.4143-.20556.0852-.43176.1074-.64999.064-.21823-.0434-.41869-.1505-.57602-.3079-.15734-.1573-.26448-.3577-.30789-.576-.04341-.2182-.02113-.4444.06402-.65.08515-.2055.22934-.3812.41435-.5049.185-.1236.40251-.18955.62501-.18955.29837 0 .58452.11855.7955.32955.21098.2109.3295.4971.3295.7955Z"/></svg>`,
+ 'arrow-down':
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 14" aria-hidden="true"><path fill="currentColor" d="m11.0306 8.53063-4.5 4.49997c-.06968.0699-.15247.1254-.24364.1633-.09116.0378-.1889.0573-.28761.0573-.09871 0-.19645-.0195-.28762-.0573-.09116-.0379-.17395-.0934-.24363-.1633L.968098 8.53063c-.140896-.1409-.220051-.332-.220051-.53125 0-.19926.079155-.39036.220051-.53125.140892-.1409.331992-.22006.531252-.22006.19926 0 .39035.07916.53125.22006l3.21937 3.21937V1.5c0-.19891.07902-.38968.21967-.53033C5.61029.829018 5.80106.75 5.99997.75c.19891 0 .38968.079018.53033.21967.14065.14065.21967.33142.21967.53033v9.1875l3.21938-3.22c.14085-.1409.33195-.22005.53125-.22005.1993 0 .3904.07915.5312.22005.1409.1409.2201.33199.2201.53125s-.0792.39035-.2201.53125l-.0012.00063Z"/></svg>',
+ bug: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 25 24" aria-hidden="true"><path fill="currentColor" d="M13.7916 8.25006c0-.29667.088-.58668.2528-.83335.1648-.24668.3991-.43893.6732-.55247.2741-.11353.5757-.14323.8667-.08536.2909.05788.5582.20074.768.41052s.3526.47706.4105.76803c.0579.29097.0282.59257-.0854.86666-.1135.27409-.3057.50836-.5524.67318-.2467.16482-.5367.25279-.8334.25279-.3978 0-.7793-.15803-1.0606-.43934-.2813-.2813-.4394-.66283-.4394-1.06066Zm-3.75-1.5c-.29665 0-.58666.08798-.83333.2528-.24667.16482-.43893.39909-.55246.67318-.11354.27409-.14324.57569-.08536.86666.05788.29097.20074.55824.41052.76802.20977.20978.47705.35264.76802.41052.29101.05788.59261.02817.86671-.08536.274-.11353.5083-.30579.6731-.55246.1649-.24668.2528-.53668.2528-.83336 0-.39782-.158-.77935-.4393-1.06066-.2813-.2813-.6628-.43934-1.0607-.43934Zm11.25 6.75004c.0003.6512-.0733 1.3003-.2193 1.935l1.7953.7837c.1354.0592.2578.1445.3603.2511.1024.1065.1829.2322.2368.3698.0539.1377.0801.2846.0772.4323-.0028.1478-.0348.2936-.094.429-.0592.1354-.1446.2579-.2511.3603-.1065.1025-.2322.1829-.3698.2368-.1377.0539-.2846.0802-.4323.0773-.1478-.0029-.2936-.0349-.429-.0941l-1.6875-.7359c-.7348 1.3818-1.8317 2.5377-3.1732 3.3437s-2.8771 1.2319-4.4421 1.2319c-1.5651 0-3.10061-.4259-4.44213-1.2319-1.34151-.806-2.43843-1.9619-3.17321-3.3437l-1.6875.7359c-.13542.0592-.28119.0912-.42896.0941-.14778.0029-.29468-.0234-.43232-.0773-.13763-.0539-.2633-.1343-.36984-.2368-.10653-.1024-.19185-.2249-.25106-.3603-.05922-.1354-.09119-.2812-.09407-.429-.00289-.1477.02336-.2946.07725-.4323.05389-.1376.13436-.2633.23681-.3698.10246-.1066.22489-.1919.36032-.2511l1.79531-.7837c-.14354-.635-.21462-1.2841-.21187-1.935v-.375h-1.875c-.29837 0-.58452-.1186-.7955-.3295-.21098-.211-.3295-.4972-.3295-.7955 0-.2984.11852-.5846.3295-.7955.21098-.211.49713-.3295.7955-.3295h1.875v-.375c-.00029-.65126.0733-1.30041.21937-1.93504l-1.79531-.78375c-.27351-.11959-.4883-.34294-.59713-.6209-.10883-.27797-.10278-.58778.01682-.86128.11959-.27351.34294-.4883.6209-.59713.27797-.10883.58778-.10278.86128.01681l1.6875.73594c.73478-1.38183 1.8317-2.53769 3.17321-3.34373 1.34152-.80604 2.87703-1.23187 4.44213-1.23187 1.565 0 3.1006.42583 4.4421 1.23187 1.3415.80604 2.4384 1.9619 3.1732 3.34373l1.6875-.73594c.1354-.05921.2812-.09118.429-.09406.1477-.00289.2946.02336.4323.07725.1376.05389.2633.13435.3698.23681.1065.10245.1919.22489.2511.36032.0592.13542.0912.28118.094.42896.0029.14778-.0233.29468-.0772.43232-.0539.13763-.1344.2633-.2368.36984-.1025.10653-.2249.19185-.3603.25106l-1.7953.78375c.1435.63492.2146 1.28407.2118 1.93504v.375h1.875c.2984 0 .5845.1185.7955.3295.211.2109.3295.4971.3295.7955 0 .2983-.1185.5845-.3295.7955-.211.2109-.4971.3295-.7955.3295h-1.875v.375Zm-14.99997-2.625H19.0416v-.375c0-1.69079-.6716-3.3123-1.8672-4.50784-1.1955-1.19555-2.817-1.8672-4.5078-1.8672-1.6907 0-3.31224.67165-4.50778 1.8672C6.96328 7.1878 6.29163 8.80931 6.29163 10.5001v.375Zm5.24997 8.8987v-6.6487H6.29163v.375c.00211 1.4949.52876 2.9417 1.48816 4.0882.95939 1.1464 2.29071 1.9199 3.76181 2.1855Zm7.5-6.2737v-.375h-5.25v6.6487c1.4712-.2656 2.8025-1.0391 3.7619-2.1855.9594-1.1465 1.486-2.5933 1.4881-4.0882Z"/></svg>',
+ '': '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 25 24"><path fill="currentColor" d="m20.6293 7.455-5.25-5.25c-.1045-.10461-.2285-.1876-.3651-.24422-.1366-.05662-.283-.08577-.4308-.08578H5.58337c-.49728 0-.97419.19754-1.32582.54917-.35163.35164-.54918.82855-.54918 1.32583v16.5c0 .4973.19755.9742.54918 1.3258.35163.3517.82854.5492 1.32582.5492H19.0834c.4973 0 .9742-.1975 1.3258-.5492.3516-.3516.5492-.8285.5492-1.3258v-12c0-.29813-.1184-.58407-.3291-.795Zm-3.1397.045h-2.1562V5.34375L17.4896 7.5ZM5.95837 19.875V4.125h7.12503v4.5c0 .29837.1185.58452.3295.7955.211.21097.4971.3295.7955.3295h4.5v10.125H5.95837Zm9.04503-4.5459c.3426-.7185.4202-1.5349.2192-2.3051-.2011-.7702-.6679-1.4445-1.3179-1.9038-.65-.4594-1.4415-.6742-2.2346-.6066-.7931.0677-1.5368.4135-2.0996.9763-.56283.5629-.90863 1.3065-.9763 2.0996-.06766.7931.14716 1.5846.60651 2.2346.45936.6501 1.13369 1.1169 1.90389 1.3179.7701.201 1.5866.1234 2.305-.2192l1.125 1.125c.2114.2114.498.3301.7969.3301.2989 0 .5855-.1187.7969-.3301.2113-.2113.3301-.498.3301-.7969 0-.2988-.1188-.5855-.3301-.7968l-1.125-1.125Zm-4.17-1.4541c0-.2225.066-.44.1896-.625.1236-.185.2993-.3292.5049-.4144.2055-.0851.4317-.1074.65-.064.2182.0434.4186.1506.576.3079.1573.1573.2644.3578.3079.576.0434.2183.0211.4445-.0641.65-.0851.2056-.2293.3813-.4143.5049-.185.1236-.4025.1896-.625.1896-.2984 0-.5845-.1185-.7955-.3295-.211-.211-.3295-.4971-.3295-.7955Z"/></svg>',
+ 'check-circle':
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"><path fill="currentColor" d="M10.0306 4.96938c.0699.06967.1254.15247.1633.24363.0378.09116.0573.1889.0573.28762 0 .09871-.0195.19645-.0573.28761-.0379.09116-.0934.17396-.1633.24364L6.53063 9.53187c-.06968.06992-.15247.1254-.24364.16326-.09116.03785-.1889.05734-.28761.05734-.09871 0-.19645-.01949-.28762-.05734-.09116-.03786-.17395-.09334-.24363-.16326l-1.5-1.5c-.06977-.06976-.12511-.15258-.16286-.24373-.03776-.09116-.05719-.18885-.05719-.28752 0-.09866.01943-.19635.05719-.28751.03775-.09115.09309-.17397.16286-.24373.06976-.06977.15259-.12511.24374-.16287.09115-.03775.18885-.05719.28751-.05719s.19636.01944.28751.05719c.09115.03776.17397.0931.24374.16287L6 7.9375l2.96938-2.97c.06978-.06961.15259-.12478.24371-.16237.09111-.03758.18874-.05683.2873-.05666.09856.00018.19612.01978.28711.05768.09098.0379.1736.09337.2431.16323ZM13.75 7c0 1.33502-.3959 2.64007-1.1376 3.7501-.7417 1.11-1.7959 1.9752-3.02928 2.4861-1.23341.5109-2.5906.6446-3.89998.3841-1.30937-.2605-2.5121-.9033-3.45611-1.8473-.944-.944-1.586877-2.14677-1.847328-3.45614-.26045-1.30937-.126777-2.66657.384114-3.89997C1.27471 3.18349 2.13987 2.12928 3.2499 1.38758 4.35994.645881 5.66498.25 7 .25c1.78961.001985 3.5053.713781 4.7708 1.97922C13.0362 3.49466 13.748 5.2104 13.75 7Zm-1.5 0c0-1.03835-.3079-2.05339-.8848-2.91674-.5769-.86336-1.3968-1.53627-2.35611-1.93363-.95931-.39736-2.01491-.50133-3.03331-.29875-1.0184.20257-1.95386.70258-2.68809 1.43681-.73422.73422-1.23424 1.66969-1.43681 2.68809-.20257 1.0184-.0986 2.074.29876 3.03331.39736.95931 1.07026 1.77921 1.93362 2.35611.86336.5769 1.87839.8848 2.91674.8848 1.39193-.0015 2.72643-.5551 3.7107-1.5393C11.6949 9.72642 12.2485 8.39193 12.25 7Z"/></svg>',
+ gear: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 22 22" aria-hidden="true"><path fill="currentColor" d="M11 6.12507c-.9642 0-1.90671.28592-2.7084.82159-.80169.53567-1.42653 1.29704-1.79551 2.18783-.36898.89081-.46552 1.87101-.27742 2.81661.18811.9457.6524 1.8143 1.33419 2.4961.68178.6818 1.55042 1.1461 2.49604 1.3342.9457.1881 1.9259.0916 2.8167-.2774s1.6521-.9938 2.1878-1.7955c.5357-.8017.8216-1.7442.8216-2.7084-.0015-1.2925-.5156-2.53161-1.4295-3.44553-.9139-.91392-2.153-1.42801-3.4455-1.4295Zm0 7.50003c-.5192 0-1.02669-.154-1.45837-.4424-.43168-.2885-.76813-.6984-.96681-1.1781-.19868-.4796-.25067-1.0074-.14938-1.5166.10129-.50924.35129-.97697.71841-1.34408.36711-.36712.83484-.61712 1.34405-.71841.5092-.10129 1.037-.0493 1.5166.14938.4797.19868.8897.53513 1.1781.96681.2884.43168.4424.9392.4424 1.4584 0 .6962-.2766 1.3638-.7688 1.8561-.4923.4923-1.16.7689-1.8562.7689Zm8.625-2.551v-.1481l1.3125-1.64155c.1102-.13755.1865-.29905.2228-.4715s.0316-.35102-.0137-.52131c-.2369-.89334-.5909-1.75142-1.0528-2.55188-.089-.15264-.2127-.28218-.3611-.37811-.1484-.09594-.3173-.15557-.493-.17408l-2.0888-.23437-.104-.10406-.2344-2.08969c-.0186-.17556-.0783-.34426-.1743-.49247-.0959-.1482-.2254-.27175-.3779-.36066-.8005-.46341-1.6589-.81869-2.5528-1.056559-.1704-.044683-.349-.048704-.5213-.01174-.1723.036965-.3335.113881-.4706.224549l-1.6415 1.3125h-.1482l-1.64152-1.3125C9.14683.9524 8.98532.87608 8.81288.839767c-.17245-.036314-.35102-.031606-.52132.013744-.89357.238319-1.75165.593909-2.55187 1.057499-.15205.08854-.28121.2115-.37712.35901-.0959.14752-.15586.31547-.17507.49037l-.23437 2.08875-.10407.10406-2.08968.23437c-.17556.01865-.34426.07835-.49247.17428-.14821.09593-.27176.22539-.36066.37791-.46211.80072-.81613 1.65912-1.052812 2.55281-.045195.17016-.049823.34855-.013512.52082.03631.17227.112546.33362.222574.47106L2.375 10.926v.1481l-1.3125 1.6416c-.110173.1375-.186492.299-.222806.4715-.036313.1724-.031605.351.013744.5213.238622.8936.594522 1.7517 1.058442 2.5519.08844.1519.21126.281.3586.3769.14734.0959.3151.1559.48983.1753l2.08875.2325.10407.104.23437 2.0916c.01865.1756.07835.3443.17428.4925.09592.1482.22538.2717.37791.3606.80052.4634 1.65893.8187 2.55281 1.0566.17045.0447.349.0487.52129.0117.17228-.0369.33347-.1139.47059-.2245l1.64152-1.3125h.1482l1.6415 1.3125c.1376.1101.2991.1865.4715.2228.1725.0363.351.0316.5213-.0138.8934-.2368 1.7514-.5908 2.5519-1.0528.1524-.0883.2819-.2112.3782-.3587.0962-.1475.1565-.3156.1759-.4907l.2325-2.0887.104-.1041 2.0897-.239c.1751-.0194.3432-.0797.4907-.1759.1475-.0963.2704-.2258.3587-.3782.4634-.8005.8187-1.6589 1.0566-2.5528.0448-.1699.0493-.3479.013-.5198-.0363-.172-.1124-.333-.2221-.4702l-1.3125-1.6416Zm-2.2612-.4584c.015.256.015.5127 0 .7687-.0168.2784.0704.553.2446.7707l1.2038 1.5047c-.1136.3363-.2492.6648-.406.9834l-1.9153.2128c-.2773.0317-.5329.1654-.7171.375-.1704.1919-.3519.3735-.5438.5438-.2096.1842-.3433.4398-.375.7171l-.2119 1.9144c-.3185.1574-.647.2936-.9834.4078l-1.5047-1.2047c-.1997-.1593-.4477-.2459-.7031-.2456h-.0675c-.2561.015-.5127.015-.7688 0-.2781-.0165-.5525.0703-.7706.2438l-1.50469 1.2047c-.33634-.1137-.66486-.2493-.98343-.406l-.21282-1.9153c-.0317-.2773-.16536-.5329-.375-.7172-.19187-.1703-.37344-.3519-.54375-.5437-.18426-.2097-.43988-.3433-.71718-.375l-1.91438-.2119c-.15734-.3185-.29357-.647-.40781-.9834l1.20375-1.5047c.17424-.2177.26144-.4923.24469-.7707-.01501-.256-.01501-.5127 0-.7687.01675-.2783-.07045-.553-.24469-.77063L3.18781 8.34038c.11364-.33634.24924-.66486.40594-.98343l1.91531-.21281c.27731-.03171.53292-.16537.71719-.375.17031-.19188.35188-.37345.54375-.54375.20964-.18427.3433-.43989.375-.71719l.21188-1.91438c.31852-.15734.64704-.29357.98343-.40781L9.845 4.3907c.2181.17343.4925.26023.7706.24375.2561-.015.5127-.015.7688 0 .2782.01701.5528-.06985.7706-.24375l1.5047-1.20469c.3364.11424.6649.25047.9834.40781l.2128 1.91532c.0317.2773.1654.53292.375.71718.1919.17031.3735.35188.5438.54375.1843.20964.4399.3433.7172.375l1.9143.21188c.1574.31852.2936.64704.4079.98343l-1.2038 1.50469c-.1749.21743-.2628.49203-.2465.77063Z"/></svg>',
+ lightbulb:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 13 16"><path fill="currentColor" d="M9.84994 14.5002c0 .1989-.07902.3897-.21967.5303-.14066.1407-.33142.2197-.53033.2197h-5c-.19891 0-.38968-.079-.53033-.2197-.14065-.1406-.21967-.3314-.21967-.5303 0-.1989.07902-.3897.21967-.5303.14065-.1407.33142-.2197.53033-.2197h5c.19891 0 .38967.079.53033.2197.14065.1406.21967.3314.21967.5303Zm2.49996-8.00001c.0023.87138-.1945 1.73175-.5755 2.51544-.381.78368-.9359 1.46997-1.6226 2.00647-.093.0708-.16853.162-.22085.2665-.05233.1046-.08004.2197-.08101.3366v.125c0 .3315-.1317.6495-.36612.8839-.23442.2344-.55236.3661-.88388.3661h-4c-.33152 0-.64947-.1317-.88389-.3661-.23442-.2344-.36611-.5524-.36611-.8839v-.125c-.00014-.115-.0267-.2284-.07763-.3314-.05094-.1031-.12488-.193-.21612-.263-.68477-.5334-1.23925-1.2155-1.62148-1.9948-.38223-.77929-.582201-1.63532-.584772-2.50331C.833063 3.41832 3.34994.825195 6.46181.750195c.76665-.018422 1.52923.116696 2.24287.397405.71365.2807 1.36392.70132 1.91262 1.23711.5486.53578.9846 1.1759 1.2821 1.88268.2976.70678.4508 1.46594.4505 2.2328Zm-1.5 0c.0002-.5669-.113-1.12811-.3331-1.65058-.22-.52247-.54226-.99565-.9479-1.39168-.40563-.39602-.8864-.70689-1.414-.91431-.52759-.20741-1.09135-.30718-1.65809-.29343-2.29937.055-4.15937 1.97188-4.14687 4.27375.00214.64152.15011 1.27416.43271 1.85009.2826.57592.69244 1.08006 1.19854 1.47429.25496.19678.46453.44618.61444.73128.14992.285.23665.599.25431.9206h3.50625c.018-.3222.10486-.6368.25472-.9226.14986-.2859.35924-.5362.61403-.73428.50754-.39672.91776-.90412 1.19936-1.4835.2817-.57938.4272-1.21543.4256-1.85963Zm-1.25434-.3325c-.06636-.56119-.28826-1.09265-.64067-1.53441-.35241-.44175-.82128-.7762-1.3537-.96559-.1861-.0608-.38859-.04643-.56423.04006-.17565.08648-.31051.23821-.37579.42278-.06527.18458-.05579.38736.02642.56504.08222.17767.23065.31616.4136.38587.26755.09379.50353.26056.68124.48146.17771.2209.29008.48712.32438.76854.02188.19776.12142.37872.27673.50308.0769.06157.16517.1074.25978.13486.09461.02747.1937.03602.29162.02519.09791-.01083.19274-.04085.27905-.08833.08632-.04748.16244-.1115.22402-.1884.06158-.07689.1074-.16517.13487-.25978.02746-.09461.03602-.1937.02518-.29162l-.0025.00125Z"/></svg>',
+ 'file-search':
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 14"><path fill="currentColor" d="M11.5306 3.97 8.03063.47C7.96097.400261 7.87826.344936 7.78721.307186 7.69616.269437 7.59856.250005 7.5.25h-6C1.16848.25.850537.381696.616117.616117.381696.850537.25 1.16848.25 1.5v11c0 .3315.131696.6495.366117.8839.23442.2344.552363.3661.883883.3661h9c.3315 0 .6495-.1317.8839-.3661.2344-.2344.3661-.5524.3661-.8839v-8c0-.19876-.0789-.38938-.2194-.53ZM9.4375 4H8V2.5625L9.4375 4ZM1.75 12.25V1.75H6.5v3c0 .19891.07902.38968.21967.53033.14065.14065.33142.21967.53033.21967h3v6.75h-8.5Zm6.03-3.03063c.2284-.47897.28015-1.02326.14613-1.53671-.13403-.51344-.44521-.96299-.87858-1.26923-.43337-.30623-.96102-.44945-1.48975-.40433-.52872.04511-1.02449.27564-1.39971.65086-.37523.37522-.60576.87099-.65087 1.39972-.04511.52872.0981 1.05638.40434 1.48975.30624.43336.75579.74457 1.26923.87857.51344.134 1.05773.0823 1.53671-.1461l.75.75c.1409.1409.33199.22.53125.22s.39035-.0791.53125-.22c.1409-.1409.22005-.332.22005-.5313 0-.1992-.07915-.3903-.22005-.53123l-.75-.75ZM5 8.25c0-.14834.04399-.29334.1264-.41668.08241-.12333.19954-.21946.33659-.27623.13704-.05676.28784-.07162.43333-.04268.14548.02894.27912.10037.38401.20526.10489.10489.17632.23853.20526.38401.02894.14549.01408.29629-.04268.43333-.05677.13705-.1529.25418-.27623.33659C6.04334 8.95601 5.89834 9 5.75 9c-.19891 0-.38968-.07902-.53033-.21967C5.07902 8.63968 5 8.44891 5 8.25Z"/></svg>',
+ star: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 15 15"><path fill="currentColor" d="M14.5873 6.00333c-.0759-.23292-.2187-.43838-.4105-.59083-.1918-.15245-.4241-.24519-.6682-.26667L9.9461 4.8377 8.55235 1.51645c-.09588-.22586-.25611-.4185-.46072-.553929-.2046-.135425-.44454-.207638-.68991-.207638-.24537 0-.48531.072213-.68991.207638-.20461.135429-.36484.328069-.46071.553929L4.85547 4.8377l-3.5625.30813c-.24538.02032-.479299.11265-.6724.26542-.193101.15276-.336784.35916-.413023.59328-.076238.23412-.081634.48554-.015512.72272s.200817.44954.387185.61045l2.7075 2.3625-.8125 3.515c-.05572.2394-.03965.4898.04619.7201.08585.2303.23767.4301.43648.5746.19881.1444.4358.2271.68132.2376.24553.0105.48871-.0516.69914-.1785l3.0625-1.86 3.06245 1.86c.2105.1267.4536.1886.699.178.2454-.0106.4822-.0933.6809-.2377.1987-.1444.3505-.3442.4363-.5743.0858-.2302.102-.4805.0463-.7198l-.8125-3.515 2.7075-2.3625c.1859-.16149.32-.37429.3853-.61168.0654-.23739.0592-.4888-.0178-.72269Zm-4.1718 2.66375c-.1714.14913-.299.34215-.3689.55831-.0699.21617-.07959.44731-.028.66857l.7119 3.08254-2.68378-1.63c-.1949-.1187-.41869-.1815-.64687-.1815-.22819 0-.45198.0628-.64688.1815l-2.68375 1.63.71188-3.08254c.05158-.22126.04189-.4524-.02803-.66857-.06993-.21616-.19745-.40918-.36885-.55831L2.00359 6.5902l3.13376-.27125c.22692-.01943.44417-.10073.62809-.23507.18393-.13433.32748-.31654.41503-.5268l1.21938-2.90563 1.21937 2.90563c.08755.21026.2311.39247.41503.5268.18392.13434.40117.21564.62809.23507l3.13376.27125-2.3806 2.07688Z"/></svg>',
+ checkmark:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 8"><path fill="#fff" d="M9.47334.806574C9.41136.744088 9.33763.694492 9.25639.660646S9.08802.609375 9.00001.609375 8.82486.6268 8.74362.660646s-.15497.083442-.21695.145928L3.56001 5.77991 1.47334 3.68657c-.06435-.06216-.14031-.11103-.22354-.14383-.08324-.03281-.17212-.04889-.261578-.04735-.089454.00155-.177727.0207-.259779.05637-.082052.03566-.156277.08713-.218436.15148-.062159.06435-.111035.14031-.143837.22355-.032803.08323-.04889.17212-.047342.26157.001547.08945.020699.17773.056361.25978.035663.08205.087137.15627.151485.21843l2.559996 2.56c.06198.06249.13571.11209.21695.14593.08124.03385.16838.05127.25639.05127s.17514-.01742.25638-.05127c.08124-.03384.15498-.08344.21695-.14593l5.44-5.44c.06767-.06242.12168-.13819.15861-.22253.03694-.08433.05601-.1754.05601-.26747 0-.09206-.01907-.18313-.05601-.26747-.03693-.08433-.09094-.160098-.15861-.222526Z"/></svg>',
+ 'dots-three':
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 4"><path fill="#fff" d="M9.5 2c0 .29667-.08797.58668-.2528.83336-.16482.24667-.39908.43893-.67317.55246-.27409.11353-.57569.14324-.86666.08536-.29098-.05788-.55825-.20074-.76803-.41052-.20978-.20978-.35264-.47705-.41052-.76802-.05788-.29098-.02817-.59258.08536-.86666.11353-.27409.30579-.508362.55247-.673184C7.41332.587974 7.70333.5 8 .5c.39783 0 .77936.158036 1.06066.43934C9.34196 1.22064 9.5 1.60218 9.5 2ZM1.625.5c-.29667 0-.58668.087974-.833354.252796-.246674.164822-.438933.399094-.552465.673184-.113531.27408-.1432361.57568-.085358.86666.057878.29097.200739.55824.410518.76802.209778.20978.477049.35264.768029.41052.29097.05788.59257.02817.86666-.08536.27408-.11353.50835-.30579.67318-.55246C3.03703 2.58668 3.125 2.29667 3.125 2c0-.39782-.15803-.77936-.43934-1.06066C2.40436.658036 2.02283.5 1.625.5Zm12.75 0c-.2967 0-.5867.087974-.8334.252796-.2466.164822-.4389.399094-.5524.673184-.1135.27408-.1433.57568-.0854.86666.0579.29097.2008.55824.4105.76802.2098.20978.4771.35264.7681.41052.2909.05788.5925.02817.8666-.08536s.5084-.30579.6732-.55246c.1648-.24668.2528-.53669.2528-.83336 0-.39782-.158-.77936-.4393-1.06066C15.1544.658036 14.7728.5 14.375.5Z"/></svg>',
+ copy: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 11"><path fill="#fff" d="M9.125.8125h-6c-.14918 0-.29226.059263-.39775.164752-.10549.105488-.16475.248568-.16475.397748v1.6875H.875c-.149184 0-.292258.05926-.397748.16475C.371763 3.33274.3125 3.47582.3125 3.625v6c0 .14918.059263.29226.164752.3977.10549.1055.248564.1648.397748.1648h6c.14918 0 .29226-.0593.39775-.1648.10549-.10544.16475-.24852.16475-.3977V7.9375H9.125c.14918 0 .29226-.05926.39775-.16475.10549-.10549.16475-.24857.16475-.39775v-6c0-.14918-.05926-.29226-.16475-.397748C9.41726.871763 9.27418.8125 9.125.8125Zm-2.8125 8.25h-4.875v-4.875h4.875v4.875Zm2.25-2.25h-1.125V3.625c0-.14918-.05926-.29226-.16475-.39775-.10549-.10549-.24857-.16475-.39775-.16475H3.6875v-1.125h4.875v4.875Z"/></svg>',
+ compress:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32"><path fill="currentColor" d="M13.84 17.44c-.16-.07-.33-.1-.5-.1H8A1.33 1.33 0 1 0 8 20h2.12l-7.07 7.05a1.33 1.33 0 0 0 .44 2.19 1.33 1.33 0 0 0 1.46-.3L12 21.89V24a1.33 1.33 0 1 0 2.67 0v-5.33a1.37 1.37 0 0 0-.83-1.23Zm-.5-10.77A1.33 1.33 0 0 0 12 8v2.12L4.95 3.05a1.34 1.34 0 1 0-1.9 1.9L10.12 12H8a1.33 1.33 0 0 0 0 2.67h5.33c.18 0 .35-.04.5-.11a1.33 1.33 0 0 0 .84-1.23V8a1.33 1.33 0 0 0-1.34-1.33Zm4.82 7.89c.16.07.33.1.5.1H24A1.33 1.33 0 0 0 24 12h-2.12l7.07-7.05a1.34 1.34 0 1 0-1.9-1.9L20 10.12V8a1.33 1.33 0 0 0-2.67 0v5.33a1.33 1.33 0 0 0 .83 1.23ZM21.88 20H24a1.33 1.33 0 0 0 0-2.67h-5.33c-.18 0-.35.04-.51.11a1.33 1.33 0 0 0-.83 1.23V24A1.33 1.33 0 0 0 20 24v-2.12l7.05 7.07a1.33 1.33 0 0 0 2.19-.44 1.33 1.33 0 0 0-.3-1.46L21.89 20Z"/></svg>',
+ grid: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32"><path fill="currentColor" d="M13.33 17.33H4a1.33 1.33 0 0 0-1.33 1.34V28A1.33 1.33 0 0 0 4 29.33h9.33A1.33 1.33 0 0 0 14.67 28v-9.33a1.33 1.33 0 0 0-1.34-1.34ZM12 26.67H5.33V20H12v6.67Zm16-24h-9.33A1.33 1.33 0 0 0 17.33 4v9.33a1.33 1.33 0 0 0 1.34 1.34H28a1.33 1.33 0 0 0 1.33-1.34V4A1.33 1.33 0 0 0 28 2.67ZM26.67 12H20V5.33h6.67V12ZM28 17.33h-9.33a1.33 1.33 0 0 0-1.34 1.34V28a1.33 1.33 0 0 0 1.34 1.33H28A1.33 1.33 0 0 0 29.33 28v-9.33A1.33 1.33 0 0 0 28 17.33Zm-1.33 9.34H20V20h6.67v6.67Zm-13.34-24H4A1.33 1.33 0 0 0 2.67 4v9.33A1.33 1.33 0 0 0 4 14.67h9.33a1.33 1.33 0 0 0 1.34-1.34V4a1.33 1.33 0 0 0-1.34-1.33ZM12 12H5.33V5.33H12V12Z"/></svg>',
+ puzzle:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M23 29.33H7a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4h1.33A5.33 5.33 0 0 1 19 8h4a1.33 1.33 0 0 1 1.33 1.33v4a5.33 5.33 0 0 1 0 10.67v4A1.33 1.33 0 0 1 23 29.33ZM7 10.67A1.33 1.33 0 0 0 5.67 12v13.33A1.33 1.33 0 0 0 7 26.67h14.67v-4.24a1.33 1.33 0 0 1 1.77-1.27 2.36 2.36 0 0 0 2.32-.3A2.67 2.67 0 0 0 27 19.02a2.67 2.67 0 0 0-.64-2.12 2.52 2.52 0 0 0-2.9-.74 1.33 1.33 0 0 1-1.77-1.26v-4.24h-4.26a1.33 1.33 0 0 1-1.34-1.78 2.36 2.36 0 0 0-.3-2.32 2.59 2.59 0 0 0-4-.57A2.67 2.67 0 0 0 11 8c0 .3.06.6.17.9a1.33 1.33 0 0 1-1.26 1.77H7Z"/></svg>',
+ approveUser:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M18.4 16.3a6.56 6.56 0 0 0 2.27-4.97 6.67 6.67 0 1 0-12.74 2.73c.39.86.96 1.62 1.67 2.23A10.67 10.67 0 0 0 3.33 26 1.33 1.33 0 0 0 6 26a8 8 0 0 1 16 0 1.33 1.33 0 0 0 2.67 0 10.67 10.67 0 0 0-6.27-9.7Zm-4.4-.97a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm15.61-3.16a1.33 1.33 0 0 0-1.9 0l-2.66 2.67-.82-.84a1.33 1.33 0 1 0-1.9 1.88l1.79 1.79a1.33 1.33 0 0 0 1.88 0l3.56-3.56a1.33 1.33 0 0 0 .05-1.94Z"/></svg>',
+ checkCircle:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="m20.3 11.72-5.73 5.73-2.2-2.2a1.33 1.33 0 1 0-1.88 1.88l3.14 3.15a1.33 1.33 0 0 0 1.88 0l6.66-6.67a1.33 1.33 0 1 0-1.88-1.89Zm-3.63-9.05a13.33 13.33 0 1 0 0 26.66 13.33 13.33 0 0 0 0-26.66Zm0 24a10.67 10.67 0 1 1 0-21.34 10.67 10.67 0 0 1 0 21.34Z"/></svg>',
+ resizeImage:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M17.67 14.67H3A1.33 1.33 0 0 0 1.67 16v12A1.33 1.33 0 0 0 3 29.33h14.67A1.33 1.33 0 0 0 19 28V16a1.33 1.33 0 0 0-1.33-1.33Zm-7.42 12 2.58-2.58a.4.4 0 0 1 .66 0l2.56 2.58h-5.8Zm6.08-3.5-.96-.94a3.21 3.21 0 0 0-4.44 0l-4.45 4.44H4.33v-9.34h12v5.84ZM3 5.48a1.33 1.33 0 0 0 1.15-.65 1.4 1.4 0 0 0-.16-1.8A1.33 1.33 0 0 0 3 2.67 1.33 1.33 0 0 0 1.67 4v.13A1.33 1.33 0 0 0 3 5.48Zm10.55-.15h.25a1.33 1.33 0 0 0 0-2.66h-.25a1.33 1.33 0 0 0 0 2.66ZM3 11.71a1.33 1.33 0 0 0 1.33-1.34v-.29a1.33 1.33 0 0 0-2.66 0v.3A1.33 1.33 0 0 0 3 11.7Zm16.12-9.04h-.25a1.33 1.33 0 0 0 0 2.66h.25a1.33 1.33 0 0 0 0-2.66ZM8.22 5.33h.25a1.33 1.33 0 1 0 0-2.66H8.2a1.33 1.33 0 1 0 0 2.66Zm21.45 3.2a1.33 1.33 0 0 0-1.34 1.34v.28a1.33 1.33 0 0 0 2.67 0v-.28a1.33 1.33 0 0 0-1.33-1.34Zm-6.5 18.14h-.33a1.33 1.33 0 1 0 0 2.66h.32a1.33 1.33 0 0 0 0-2.66Zm6.36-24a1.33 1.33 0 1 0 .36 2.64A1.33 1.33 0 0 0 31 4.15V4a1.45 1.45 0 0 0-1.47-1.33Zm.14 11.86a1.33 1.33 0 0 0-1.34 1.34v.29a1.33 1.33 0 1 0 2.67 0v-.3a1.33 1.33 0 0 0-1.33-1.33ZM24.45 2.67h-.25a1.33 1.33 0 1 0 0 2.66h.25a1.33 1.33 0 1 0 0-2.66Zm5.22 24A1.33 1.33 0 0 0 28.34 28a1.33 1.33 0 0 0 1.33 1.33A1.45 1.45 0 0 0 31 27.87a1.33 1.33 0 0 0-1.33-1.2Zm0-6.08a1.33 1.33 0 0 0-1.34 1.33v.3a1.33 1.33 0 0 0 2.67 0v-.35a1.33 1.33 0 0 0-1.33-1.34v.06Z"/></svg>',
+ searchFile:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32"><path fill="currentColor" d="M16 26.67H6.67a1.33 1.33 0 0 1-1.34-1.34V6.67a1.33 1.33 0 0 1 1.34-1.34h6.66v4a4 4 0 0 0 4 4h4v1.34a1.33 1.33 0 0 0 2.67 0v-2.75a1.75 1.75 0 0 0-.08-.36v-.12a1.43 1.43 0 0 0-.25-.37l-8-8c-.11-.1-.24-.2-.38-.26h-.12a1.17 1.17 0 0 0-.44-.14H6.67a4 4 0 0 0-4 4v18.66a4 4 0 0 0 4 4H16a1.33 1.33 0 0 0 0-2.66ZM16 7.2l3.45 3.46h-2.12A1.33 1.33 0 0 1 16 9.33V7.21Zm-6.67 3.46a1.33 1.33 0 0 0 0 2.66h1.34a1.33 1.33 0 0 0 0-2.66H9.33Zm19.62 16.38-1.56-1.54a4.59 4.59 0 0 0-.72-5.51 4.65 4.65 0 0 0-8 3.32 4.61 4.61 0 0 0 6.84 4.07l1.54 1.56a1.33 1.33 0 0 0 2.19-.44 1.33 1.33 0 0 0-.3-1.46Zm-4.23-2.33a2.05 2.05 0 0 1-2.81 0 2 2 0 0 1 0-2.81 2 2 0 0 1 1.34-.58 1.96 1.96 0 0 1 1.47 3.39ZM17.33 16h-8a1.33 1.33 0 0 0 0 2.67h8a1.33 1.33 0 1 0 0-2.67Zm-2.66 8a1.33 1.33 0 0 0 0-2.67H9.33a1.33 1.33 0 0 0 0 2.67h5.34Z"/></svg>',
+ image:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M26 2.67H7.33a4 4 0 0 0-4 4v18.66a4 4 0 0 0 4 4H26c.22 0 .44-.02.65-.06l.4-.1h.16l.5-.18.17-.1c.13-.08.28-.14.41-.24.18-.13.35-.27.5-.42l.1-.12c.13-.14.25-.28.36-.43l.12-.17c.1-.15.18-.3.24-.47l.1-.2.16-.5v-.2c.07-.27.12-.54.13-.8V6.66a4 4 0 0 0-4-4Zm-18.67 24A1.33 1.33 0 0 1 6 25.33V19.6l4.39-4.4a1.33 1.33 0 0 1 1.89 0l11.47 11.48H7.33Zm20-1.34c0 .17-.03.33-.1.48a1.33 1.33 0 0 1-.22.35l-7.13-7.13 1.17-1.18a1.33 1.33 0 0 1 1.9 0l4.38 4.4v3.08Zm0-6.85L24.83 16a4.1 4.1 0 0 0-5.66 0L18 17.17l-3.84-3.84a4.1 4.1 0 0 0-5.65 0L6 15.81V6.67a1.33 1.33 0 0 1 1.33-1.34H26a1.33 1.33 0 0 1 1.33 1.34v11.81Z"/></svg>',
+ robot:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M12.67 20a1.33 1.33 0 1 0 0 2.67 1.33 1.33 0 0 0 0-2.67Zm-9.34-1.33A1.33 1.33 0 0 0 2 20v2.67a1.33 1.33 0 1 0 2.67 0V20a1.33 1.33 0 0 0-1.34-1.33Zm26.67 0A1.33 1.33 0 0 0 28.67 20v2.67a1.33 1.33 0 1 0 2.66 0V20A1.33 1.33 0 0 0 30 18.67Zm-6.67-9.34H18v-1.7a2.67 2.67 0 0 0 .55-4.18 2.67 2.67 0 0 0-4.19 3.2c.24.41.57.74.97.98v1.7H10a4 4 0 0 0-4 4v12a4 4 0 0 0 4 4h13.33a4 4 0 0 0 4-4v-12a4 4 0 0 0-4-4ZM18.96 12l-.67 2.67h-3.25L14.37 12h4.59Zm5.7 13.33a1.33 1.33 0 0 1-1.33 1.34H10a1.33 1.33 0 0 1-1.33-1.34v-12A1.33 1.33 0 0 1 10 12h1.63l1.04 4.32A1.33 1.33 0 0 0 14 17.33h5.33a1.33 1.33 0 0 0 1.34-1.01L21.7 12h1.62a1.33 1.33 0 0 1 1.34 1.33v12Zm-4-5.33a1.33 1.33 0 1 0 0 2.67 1.33 1.33 0 0 0 0-2.67Z"/></svg>',
+ sitemap:
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 33 32"><path fill="currentColor" d="M29.67 20H27v-4a1.33 1.33 0 0 0-1.33-1.33h-8V12h2.66a1.33 1.33 0 0 0 1.34-1.33v-8a1.33 1.33 0 0 0-1.34-1.34h-8A1.33 1.33 0 0 0 11 2.67v8A1.33 1.33 0 0 0 12.33 12H15v2.67H7A1.33 1.33 0 0 0 5.67 16v4H3a1.33 1.33 0 0 0-1.33 1.33v8A1.33 1.33 0 0 0 3 30.67h8a1.33 1.33 0 0 0 1.33-1.34v-8A1.33 1.33 0 0 0 11 20H8.33v-2.67h16V20h-2.66a1.33 1.33 0 0 0-1.34 1.33v8a1.33 1.33 0 0 0 1.34 1.34h8A1.33 1.33 0 0 0 31 29.33v-8A1.33 1.33 0 0 0 29.67 20Zm-20 2.67V28H4.33v-5.33h5.34Zm4-13.34V4H19v5.33h-5.33ZM28.33 28H23v-5.33h5.33V28Z"/></svg>',
+ gauge:
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M207 80.7A111.2 111.2 0 0 0 128 48h-.4C66.1 48.2 16 99 16 161.1V184a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16v-24a111.3 111.3 0 0 0-33-79.3ZM224 184H119.7l54.8-75.3a8 8 0 0 0-13-9.4L100 184H32v-22.9c0-3 .1-6 .4-9.1H56a8 8 0 0 0 0-16H35.3A96.7 96.7 0 0 1 120 64.3V88a8 8 0 0 0 16 0V64.3a96.1 96.1 0 0 1 85 71.7h-21a8 8 0 0 0 0 16h23.7c.2 2.6.3 5.3.3 8Z"/></svg>',
+ 'person-arms-spread':
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M160 40a32 32 0 1 0-32 32 32 32 0 0 0 32-32Zm-32 16a16 16 0 1 1 16-16 16 16 0 0 1-16 16Zm103.5 31.7A19.6 19.6 0 0 0 212 72H44a20 20 0 0 0-8.4 38.2h.1l50.8 22.3-21 79.7a20 20 0 0 0 36.5 16.6l26-44.9 26 44.9a20 20 0 0 0 36.4-16.5l-21-79.7 50.8-22.4a19.6 19.6 0 0 0 11.3-22.5Zm-17.8 8-57 25a8 8 0 0 0-4.4 9.3l22.8 87a7 7 0 0 0 .5 1.4 4 4 0 0 1-5 5.4 4 4 0 0 1-2.2-2 6.3 6.3 0 0 0-.4-.7L135 164a8 8 0 0 0-14 0l-33 57a6.3 6.3 0 0 0-.3.7 4 4 0 0 1-2.3 2 4 4 0 0 1-5-5.4 7 7 0 0 0 .5-1.4l22.8-86.9a8 8 0 0 0-4.5-9.4l-56.9-25A4 4 0 0 1 44 88h168a4 4 0 0 1 1.7 7.6Z"/></svg>',
+ 'arrow-left':
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8Z"/></svg>',
+ 'houston-detective':
+ '<svg xmlns="http://www.w3.org/2000/svg" width="80.73885" height="73.61774" fill="none"><path fill="url(#a)" d="M4.36139 21.7987c0-4.93847 4.00624-8.9419 8.94818-8.9419h49.21475c4.9424 0 8.9486 4.00343 8.9486 8.9419v32.4145c0 4.93841-4.0062 8.94186-8.9486 8.94186H13.30957c-4.94194 0-8.94818-4.00345-8.94818-8.94187z"/><path fill="url(#b)" d="M6.98283 22.62008c0-3.94523 3.2151-7.14348 7.18112-7.14348h47.50578c3.9662 0 7.18144 3.19825 7.18144 7.14348V53.3917c0 3.94545-3.21523 7.14387-7.18144 7.14387H14.16395c-3.96602 0-7.18112-3.19842-7.18112-7.14387z"/><path fill="#fff" d="M25.47554 43.24548c0 3.0745-2.4941 5.56686-5.57076 5.56686-3.0766 0-5.57077-2.49236-5.57077-5.56686s2.49416-5.56686 5.57077-5.56686c3.07666 0 5.57076 2.49236 5.57076 5.56686zm12.25545 3.34c1.84596 0 3.34243-1.49538 3.34243-3.3401h2.22833c0 3.0745-2.49415 5.56686-5.57076 5.56686-3.07666 0-5.57077-2.49236-5.57077-5.56686h2.22828c0 1.84472 1.49647 3.3401 3.34249 3.3401zm23.39735-3.34c0 3.0745-2.49419 5.56686-5.57082 5.56686-3.07658 0-5.57064-2.49236-5.57064-5.56686s2.49406-5.56686 5.57064-5.56686c3.07664 0 5.57082 2.49236 5.57082 5.56686z"/><g filter="url(#c)" opacity=".5" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#000" d="M104.83 26.2341c3.182 2.7053-1.591 12.5125-28.6385 12.5125-27.0471 0-31.8199-9.807-28.6381-12.5125 3.1818-2.7056 12.8217 1.6907 28.6381 1.6907 15.8164 0 25.4555-4.3961 28.6385-1.6907z"/></g><g filter="url(#d)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#663c0d" d="M79.1496 5.30492c-.7721.51031-1.7701.50914-2.5512.01272-4.5064-2.86385-11.2943-6.50075-14.3451-2.91332-4.1955 4.93349-6.3656 17.86268-6.3656 20.24438 0 1.9053 14.7566 5.784 21.9902 7.4852 7.2336-1.7012 21.9902-5.5799 21.9902-7.4852 0-2.3817-3.3275-15.31089-7.523-20.24438-3.0463-3.58212-8.8653.03873-13.1955 2.9006z"/></g><g filter="url(#e)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#421913" d="M56.4531 17.9819h42.5617l2.5902 11.2845H54.1523Z"/></g><g filter="url(#f)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#663c0d" fill-rule="evenodd" d="M51.2044 21.7432c-16.6972 0-13.3879 5.5266-6.2519 8.7939-.0399.0582-.0601.117-.0601.1761 0 1.598 14.768 2.8934 32.9853 2.8934 18.2172 0 32.9853-1.2954 32.9853-2.8934 0-.0479-.013-.0955-.04-.1429 7.075-3.2629 10.412-8.8271-6.083-8.8271-11.1883 0-20.1017 1.3613-26.5593 2.8804-6.559-1.5191-15.6124-2.8804-26.9763-2.8804z" clip-rule="evenodd"/></g><g filter="url(#g)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#d2a460" d="M11.479 82.4367c2.6687 3.4228 6.7106 11.7506 9.859 13.5839 15.5288 9.0424 37.7225 24.2464 56.4741 24.5724 18.7516-.326 36.9159-14.482 52.4449-23.5245 3.148-1.8333 7.19-7.0175 9.859-10.4403 1.45-1.8596 2.77-3.1993 2.77-3.1993s6.091 36.1791-1.328 39.8061c-28.151 13.763-103.3705 13.763-131.5215 0-7.41893-3.627-2.11069-40.854-1.32762-45.0455.27567 0 2.30452 3.65 2.77012 4.2472z"/></g><g filter="url(#h)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#d2a460" d="m142.911 68.731-65.1786 51.865v9.447c21.7262-9.397 66.3766-34.1088 71.1716-39.8496 4.795-9.6363 1.249-21.4624-5.993-21.4624z"/></g><g filter="url(#i)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#d2a460" d="m8.67377 68.731 69.05863 51.865v9.447C54.7129 120.646 7.40376 95.9342 2.32368 90.1934-2.75639 80.5571 1.00072 68.731 8.67377 68.731Z"/></g><g filter="url(#j)" transform="matrix(.5 0 0 .5 0 .1236)"><path fill="#421913" d="m127.123 109.203 7.779 1.798-5.636 33.161c-.657 2.993-12.371.06-11.121-2.774z"/></g><g filter="url(#k)" transform="matrix(.5 0 0 .5 0 .1236)"><rect width="10.9039" height="5.2351" x="126.291" y="106.322" fill="url(#l)" rx=".52395" transform="rotate(13 126.291 106.322)"/></g><g filter="url(#m)" transform="matrix(.5 0 0 .5 0 .1236)"><circle cx="135.744" cy="88.0296" r="22.2183" fill="url(#n)" transform="rotate(43 135.744 88.0296)"/></g><ellipse cx="79.76932" cy="-14.01218" fill="url(#o)" rx="9.16844" ry="9.16799" transform="rotate(42.97994) skewX(-.04011)"/><defs><filter id="c" width="80.5643" height="34.955" x="35.9092" y="14.5728" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_847_1831" stdDeviation="5.3906"/></filter><filter id="d" width="46.8739" height="31.8277" x="54.1516" y="-.24722" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="-1.73607" dy="-1.15738"/><feGaussianBlur stdDeviation="1.15738"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="1.15738" dy="1.44672"/><feGaussianBlur stdDeviation="1.15738"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.579167 0 0 0 0 0.436815 0 0 0 0 0.277517 0 0 0 0.8 0"/><feBlend in2="effect1_innerShadow_847_1831" result="effect2_innerShadow_847_1831"/></filter><filter id="e" width="49.0455" height="13.9403" x="52.559" y="17.9819" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="-1.59338" dy="2.65563"/><feGaussianBlur stdDeviation="1.59338"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/></filter><filter id="f" width="80.4374" height="14.4674" x="37.3704" y="20.5858" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="-1.73607" dy="-1.15738"/><feGaussianBlur stdDeviation="1.15738"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="1.15738" dy="1.44672"/><feGaussianBlur stdDeviation="1.15738"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.579167 0 0 0 0 0.436815 0 0 0 0 0.277517 0 0 0 0.8 0"/><feBlend in2="effect1_innerShadow_847_1831" result="effect2_innerShadow_847_1831"/></filter><filter id="g" width="139.101" height="61.6551" x="6.13867" y="76.0937" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-4.19159"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2.09579"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/><feBlend in2="effect1_innerShadow_847_1831" result="effect2_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="4.19159"/><feGaussianBlur stdDeviation="4.19159"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feBlend in2="effect2_innerShadow_847_1831" result="effect3_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1.0479"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/><feBlend in2="effect3_innerShadow_847_1831" mode="overlay" result="effect4_innerShadow_847_1831"/></filter><filter id="h" width="73.3652" height="67.5994" x="77.7324" y="66.6352" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-4.19159"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2.09579"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/><feBlend in2="effect1_innerShadow_847_1831" result="effect2_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="4.19159"/><feGaussianBlur stdDeviation="4.19159"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feBlend in2="effect2_innerShadow_847_1831" result="effect3_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1.0479"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/><feBlend in2="effect3_innerShadow_847_1831" mode="overlay" result="effect4_innerShadow_847_1831"/></filter><filter id="i" width="77.7324" height="67.5994" x="0" y="66.6352" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-4.19159"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2.09579"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/><feBlend in2="effect1_innerShadow_847_1831" result="effect2_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="4.19159"/><feGaussianBlur stdDeviation="4.19159"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feBlend in2="effect2_innerShadow_847_1831" result="effect3_innerShadow_847_1831"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1.0479"/><feGaussianBlur stdDeviation="1.0479"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/><feBlend in2="effect3_innerShadow_847_1831" mode="overlay" result="effect4_innerShadow_847_1831"/></filter><filter id="j" width="18.4224" height="37.7877" x="116.48" y="109.203" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="-2.6407" dy="3.69907"/><feGaussianBlur stdDeviation=".78592"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect1_innerShadow_847_1831"/></filter><filter id="k" width="18.4043" height="14.1577" x="121.811" y="103.873" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".8512"/><feGaussianBlur stdDeviation="1.70239"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_847_1831"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_847_1831" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx=".8512" dy="-2.55359"/><feGaussianBlur stdDeviation="1.70239"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect2_innerShadow_847_1831"/></filter><filter id="m" width="51.2461" height="51.2466" x="110.121" y="63.2574" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy=".8512"/><feGaussianBlur stdDeviation="1.70239"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_847_1831"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_847_1831" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx=".8512" dy="-2.55359"/><feGaussianBlur stdDeviation="1.70239"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="shape" result="effect2_innerShadow_847_1831"/></filter><radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(0 -25.15 33.555 0 37.91706 38.00588)" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset=".28646" stop-color="#BC52EE"/><stop offset=".41434" stop-color="#4AF2C8"/><stop offset=".55466" stop-color="#4AF2C8"/><stop offset="1" stop-color="#3245FF"/></radialGradient><radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="matrix(0 36.673 -50.354 0 37.91691 32.37368)" gradientUnits="userSpaceOnUse"><stop offset=".36659" stop-color="#191C24"/><stop offset=".66145" stop-color="#111218"/><stop offset="1" stop-color="#040506"/></radialGradient><radialGradient id="l" cx="0" cy="0" r="1" gradientTransform="matrix(6.044 5.658 -9.328 9.963 130.134 106.85)" gradientUnits="userSpaceOnUse"><stop stop-color="#D5D7DB"/><stop offset=".17494" stop-color="#BABFCF"/><stop offset=".32731" stop-color="#A7AAB6"/><stop offset=".45345" stop-color="#9CA0AE"/><stop offset=".60938" stop-color="#7F818A"/><stop offset=".79688" stop-color="#636673"/><stop offset="1" stop-color="#47474A"/></radialGradient><radialGradient id="n" cx="0" cy="0" r="1" gradientTransform="matrix(24.63 48.03 -64.437 33.045 129.187 70.2917)" gradientUnits="userSpaceOnUse"><stop stop-color="#D5D7DB"/><stop offset=".17494" stop-color="#BABFCF"/><stop offset=".32731" stop-color="#A7AAB6"/><stop offset=".45345" stop-color="#9CA0AE"/><stop offset=".60938" stop-color="#7F818A"/><stop offset=".79688" stop-color="#636673"/><stop offset="1" stop-color="#47474A"/></radialGradient><linearGradient id="o" x1="154.075" x2="109.822" y1="69.6993" y2="88.276" gradientTransform="matrix(.5 0 0 .5 11.87299 -58.04057)" gradientUnits="userSpaceOnUse"><stop stop-color="#4AF2C8"/><stop offset="1" stop-color="#2F4CB3"/></linearGradient></defs></svg>',
+} as const;
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/index.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/index.ts
new file mode 100644
index 000000000..56765c4ca
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/index.ts
@@ -0,0 +1,10 @@
+export { DevToolbarBadge } from './badge.js';
+export { DevToolbarButton } from './button.js';
+export { DevToolbarCard } from './card.js';
+export { DevToolbarHighlight } from './highlight.js';
+export { DevToolbarIcon } from './icon.js';
+export { DevToolbarSelect } from './select.js';
+export { DevToolbarToggle } from './toggle.js';
+export { DevToolbarTooltip } from './tooltip.js';
+export { DevToolbarWindow } from './window.js';
+export { DevToolbarRadioCheckbox } from './radio-checkbox.js';
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/radio-checkbox.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/radio-checkbox.ts
new file mode 100644
index 000000000..a223bf1a8
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/radio-checkbox.ts
@@ -0,0 +1,121 @@
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type RadioStyle = (typeof styles)[number];
+
+export class DevToolbarRadioCheckbox extends HTMLElement {
+ private _radioStyle: RadioStyle = 'purple';
+ input: HTMLInputElement;
+
+ shadowRoot: ShadowRoot;
+
+ get radioStyle() {
+ return this._radioStyle;
+ }
+
+ set radioStyle(value) {
+ if (!styles.includes(value)) {
+ console.error(`Invalid style: ${value}, expected one of ${styles.join(', ')}.`);
+ return;
+ }
+ this._radioStyle = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['radio-style', 'checked', 'disabled', 'name', 'value'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ --purple-unchecked: rgba(224, 204, 250, 0.33);
+ --purple-checked: rgba(224, 204, 250, 1);
+
+ --gray-unchecked: rgba(191, 193, 201, 0.33);
+ --gray-checked: rgba(191, 193, 201, 1);
+
+ --red-unchecked: rgba(249, 196, 215, 0.33);
+ --red-checked: rgba(179, 62, 102, 1);
+
+ --green-unchecked: rgba(213, 249, 196, 0.33);
+ --green-checked: rgba(61, 125, 31, 1);
+
+ --yellow-unchecked: rgba(255, 236, 179, 0.33);
+ --yellow-checked: rgba(181, 138, 45, 1);
+
+ --blue-unchecked: rgba(189, 195, 255, 0.33);
+ --blue-checked: rgba(54, 69, 217, 1);
+ }
+
+ input[type="radio"] {
+ appearance: none;
+ -webkit-appearance: none;
+ display: flex;
+ align-content: center;
+ justify-content: center;
+ border: 2px solid var(--unchecked-color);
+ border-radius: 9999px;
+ width: 16px;
+ height: 16px;
+ }
+
+ input[type="radio"]::before {
+ content: "";
+ background-color: var(--checked-color);
+ width: 8px;
+ height: 8px;
+ border-radius: 9999px;
+ visibility: hidden;
+ margin: 2px;
+ }
+
+ input[type="radio"]:checked {
+ border-color: var(--checked-color);
+ }
+
+ input[type="radio"]:checked::before {
+ visibility: visible;
+ }
+ </style>
+ <style id="selected-style"></style>
+ `;
+ this.input = document.createElement('input');
+ this.input.type = 'radio';
+ this.shadowRoot.append(this.input);
+ }
+
+ connectedCallback() {
+ this.updateInputState();
+ this.updateStyle();
+ }
+
+ updateStyle() {
+ const styleElement = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+
+ if (styleElement) {
+ styleElement.innerHTML = `
+ :host {
+ --unchecked-color: var(--${this._radioStyle}-unchecked);
+ --checked-color: var(--${this._radioStyle}-checked);
+ }
+ `;
+ }
+ }
+
+ updateInputState() {
+ this.input.checked = this.hasAttribute('checked');
+ this.input.disabled = this.hasAttribute('disabled');
+ this.input.name = this.getAttribute('name') || '';
+ this.input.value = this.getAttribute('value') || '';
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('radio-style')) {
+ this.radioStyle = this.getAttribute('radio-style') as RadioStyle;
+ }
+
+ this.updateInputState();
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/select.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/select.ts
new file mode 100644
index 000000000..341bfdd07
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/select.ts
@@ -0,0 +1,109 @@
+import { settings } from '../settings.js';
+
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type SelectStyle = (typeof styles)[number];
+
+export class DevToolbarSelect extends HTMLElement {
+ shadowRoot: ShadowRoot;
+ element: HTMLSelectElement;
+ _selectStyle: SelectStyle = 'gray';
+
+ get selectStyle() {
+ return this._selectStyle;
+ }
+ set selectStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(`Invalid style: ${value}, expected one of ${styles.join(', ')}.`);
+ return;
+ }
+ this._selectStyle = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['select-style'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ --purple-text: rgba(224, 204, 250, 1);
+ --purple-border: rgba(113, 24, 226, 1);
+
+ --gray-text: rgba(191, 193, 201, 1);
+ --gray-border:rgba(191, 193, 201, 1);
+
+ --red-text: rgba(249, 196, 215, 1);
+ --red-border: rgba(179, 62, 102, 1);
+
+ --green-text: rgba(213, 249, 196, 1);
+ --green-border: rgba(61, 125, 31, 1);
+
+ --yellow-text: rgba(249, 233, 196, 1);
+ --yellow-border: rgba(181, 138, 45, 1);
+
+ --blue-text: rgba(189, 195, 255, 1);
+ --blue-border: rgba(54, 69, 217, 1);
+
+ --text-color: var(--gray-text);
+ --border-color: var(--gray-border);
+ }
+ select {
+ appearance: none;
+ text-align-last: center;
+ display: inline-block;
+ font-family: system-ui, sans-serif;
+ font-size: 14px;
+ padding: 4px 24px 4px 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-color);
+ background-color: transparent;
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--text-color) 50%),
+ linear-gradient(135deg, var(--text-color) 50%, transparent 50%);
+ background-position:
+ calc(100% - 12px) calc(1em - 2px),
+ calc(100% - 8px) calc(1em - 2px);
+ background-size: 4px 4px;
+ background-repeat: no-repeat;
+ }
+ </style>
+ <style id="selected-style"></style>
+ <slot></slot>
+ `;
+ this.element = document.createElement('select');
+ this.shadowRoot.addEventListener('slotchange', (event) => {
+ if (event.target instanceof HTMLSlotElement) {
+ // Manually add slotted elements to <select> because it only accepts <option> as children and escapes other elements including <slot>
+ this.element.append(...event.target.assignedNodes());
+ }
+ });
+ }
+
+ connectedCallback() {
+ this.element.name = 'dev-toolbar-select';
+ this.shadowRoot.append(this.element);
+ this.updateStyle();
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('select-style')) {
+ this.selectStyle = this.getAttribute('select-style') as SelectStyle;
+ }
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+ if (style) {
+ style.innerHTML = `
+ :host {
+ --text-color: var(--${this.selectStyle}-text);
+ --border-color: var(--${this.selectStyle}-border);
+ }
+ `;
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/toggle.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/toggle.ts
new file mode 100644
index 000000000..9a0b775b8
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/toggle.ts
@@ -0,0 +1,137 @@
+import { settings } from '../settings.js';
+
+const styles = ['purple', 'gray', 'red', 'green', 'yellow', 'blue'] as const;
+
+type ToggleStyle = (typeof styles)[number];
+
+export class DevToolbarToggle extends HTMLElement {
+ shadowRoot: ShadowRoot;
+ input: HTMLInputElement;
+ _toggleStyle: ToggleStyle = 'gray';
+
+ get toggleStyle() {
+ return this._toggleStyle;
+ }
+
+ set toggleStyle(value) {
+ if (!styles.includes(value)) {
+ settings.logger.error(`Invalid style: ${value}, expected one of ${styles.join(', ')}.`);
+ return;
+ }
+ this._toggleStyle = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['toggle-style'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ --purple-bg-on: rgba(113, 24, 226, 1);
+ --purple-border-off: rgba(113, 24, 226, 1);
+ --purple-border-on: rgba(224, 204, 250, 1);
+
+ --gray-bg-on: rgba(61, 125, 31, 1);
+ --gray-border-off: rgba(145, 152, 173, 1);
+ --gray-border-on: rgba(213, 249, 196, 1);
+
+ --red-bg-on: rgba(179, 62, 102, 1);
+ --red-border-off: rgba(179, 62, 102, 1);
+ --red-border-on: rgba(249, 196, 215, 1);
+
+ --green-bg-on: rgba(61, 125, 31, 1);
+ --green-border-off: rgba(61, 125, 31, 1);
+ --green-border-on: rgba(213, 249, 196, 1);
+
+ --yellow-bg-on: rgba(181, 138, 45, 1);
+ --yellow-border-off: rgba(181, 138, 45, 1);
+ --yellow-border-on: rgba(255, 236, 179, 1);
+
+ --blue-bg-on: rgba(54, 69, 217, 1);
+ --blue-border-off: rgba(54, 69, 217, 1);
+ --blue-border-on: rgba(189, 195, 255, 1);
+ }
+
+ input {
+ appearance: none;
+ width: 32px;
+ height: 20px;
+ border: 1px solid var(--border-off);
+ transition: background-color 0.2s ease, border-color 0.2s ease;
+ border-radius: 9999px;
+ }
+
+ input::after {
+ content: '';
+ width: 16px;
+ display: inline-block;
+ height: 16px;
+ background-color: var(--border-off);
+ border-radius: 9999px;
+ transition: transform 0.2s ease, background-color 0.2s ease;
+ top: 1px;
+ left: 1px;
+ position: relative;
+ }
+
+ @media (forced-colors: active) {
+ input::after {
+ border: 1px solid black;
+ top: 0px;
+ left: 0px;
+ }
+ }
+
+ input:checked {
+ border: 1px solid var(--border-on);
+ background-color: var(--bg-on);
+ }
+
+ input:checked::after {
+ transform: translateX(12px);
+ background: var(--border-on);
+ }
+ </style>
+ <style id="selected-style"></style>
+ `;
+
+ this.input = document.createElement('input');
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('toggle-style'))
+ this.toggleStyle = this.getAttribute('toggle-style') as ToggleStyle;
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+ if (style) {
+ style.innerHTML = `
+ :host {
+ --bg-on: var(--${this.toggleStyle}-bg-on);
+ --border-off: var(--${this.toggleStyle}-border-off);
+ --border-on: var(--${this.toggleStyle}-border-on);
+ }
+ `;
+ }
+ }
+
+ connectedCallback() {
+ this.input.type = 'checkbox';
+ this.input.name = 'dev-toolbar-toggle';
+ this.shadowRoot.append(this.input);
+ this.updateStyle();
+ }
+
+ get value() {
+ return this.input.value;
+ }
+
+ set value(val) {
+ this.input.value = val;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/tooltip.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/tooltip.ts
new file mode 100644
index 000000000..b54524752
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/tooltip.ts
@@ -0,0 +1,172 @@
+import { type Icon, getIconElement, isDefinedIcon } from './icons.js';
+
+export interface DevToolbarTooltipSection {
+ title?: string;
+ inlineTitle?: string;
+ icon?: Icon;
+ content?: string;
+ clickAction?: () => void | Promise<void>;
+ clickDescription?: string;
+}
+
+export class DevToolbarTooltip extends HTMLElement {
+ sections: DevToolbarTooltipSection[] = [];
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ position: absolute;
+ display: none;
+ color: white;
+ background: linear-gradient(0deg, #310A65, #310A65), linear-gradient(0deg, #7118E2, #7118E2);
+ border: 1px solid rgba(113, 24, 226, 1);
+ border-radius: 4px;
+ padding: 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 14px;
+ margin: 0;
+ z-index: 2000000001;
+ max-width: 45ch;
+ width: fit-content;
+ min-width: 30ch;
+ box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0.30), 0px 1px 2px 0px rgba(0, 0, 0, 0.29), 0px 4px 4px 0px rgba(0, 0, 0, 0.26), 0px 10px 6px 0px rgba(0, 0, 0, 0.15), 0px 17px 7px 0px rgba(0, 0, 0, 0.04), 0px 26px 7px 0px rgba(0, 0, 0, 0.01);
+ }
+
+ :host([data-show="true"]) {
+ display: block;
+ }
+
+ svg {
+ vertical-align: bottom;
+ margin-inline-end: 4px;
+ }
+
+ hr {
+ border: 1px solid rgba(136, 58, 234, 0.33);
+ padding: 0;
+ margin: 0;
+ }
+
+ section {
+ padding: 8px;
+ }
+
+ .section-content {
+ max-height: 250px;
+ overflow-y: auto;
+ }
+
+ .modal-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .modal-main-title {
+ font-weight: bold;
+ }
+
+ .modal-title + div {
+ margin-top: 8px;
+ }
+
+ .modal-cta {
+ display: block;
+ font-weight: bold;
+ font-size: 0.9em;
+ }
+
+ .clickable-section {
+ background: rgba(113, 24, 226, 1);
+ padding: 8px;
+ border: 0;
+ color: white;
+ font-family: system-ui, sans-serif;
+ text-align: left;
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ margin: 0;
+ width: 100%;
+ }
+
+ .clickable-section:hover {
+ cursor: pointer;
+ }
+
+ pre, code {
+ background: rgb(78, 27, 145);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ border-radius: 2px;
+ font-size: 14px;
+ padding: 2px;
+ }
+ pre {
+ padding: 1em;
+ margin: 0 0;
+ overflow: auto;
+ }
+ `;
+
+ const fragment = new DocumentFragment();
+ this.sections.forEach((section, index) => {
+ const sectionElement = section.clickAction
+ ? document.createElement('button')
+ : document.createElement('section');
+
+ if (section.clickAction) {
+ sectionElement.classList.add('clickable-section');
+ sectionElement.addEventListener('click', async () => {
+ await section.clickAction!();
+ });
+ }
+
+ sectionElement.innerHTML = `
+ ${
+ section.title
+ ? `<div class="modal-title"><span class="modal-main-title">
+ ${section.icon ? this.getElementForIcon(section.icon) : ''}${section.title}</span>${
+ section.inlineTitle ?? ''
+ }</div>`
+ : ''
+ }
+ ${section.content ? `<div class="section-content">${section.content}</div>` : ''}
+ ${
+ section.clickDescription
+ ? `<span class="modal-cta">${section.clickDescription}</span>`
+ : ''
+ }
+ `;
+ fragment.append(sectionElement);
+
+ if (index < this.sections.length - 1) {
+ fragment.append(document.createElement('hr'));
+ }
+ });
+
+ this.shadowRoot.append(fragment);
+ }
+
+ getElementForIcon(icon: Icon | (string & NonNullable<unknown>)) {
+ let iconElement;
+ if (isDefinedIcon(icon)) {
+ iconElement = getIconElement(icon);
+ } else {
+ iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ iconElement.setAttribute('viewBox', '0 0 16 16');
+ iconElement.innerHTML = icon;
+ }
+
+ iconElement?.style.setProperty('width', '16px');
+ iconElement?.style.setProperty('height', '16px');
+
+ return iconElement?.outerHTML ?? '';
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-toolbar/ui-library/window.ts b/packages/astro/src/runtime/client/dev-toolbar/ui-library/window.ts
new file mode 100644
index 000000000..8b040ab44
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-toolbar/ui-library/window.ts
@@ -0,0 +1,140 @@
+import { defaultSettings, settings } from '../settings.js';
+
+export const placements = ['bottom-left', 'bottom-center', 'bottom-right'] as const;
+
+export type Placement = (typeof placements)[number];
+
+export function isValidPlacement(value: string): value is Placement {
+ return placements.map(String).includes(value);
+}
+
+export class DevToolbarWindow extends HTMLElement {
+ shadowRoot: ShadowRoot;
+ _placement: Placement = defaultSettings.placement;
+
+ get placement() {
+ return this._placement;
+ }
+ set placement(value) {
+ if (!isValidPlacement(value)) {
+ settings.logger.error(
+ `Invalid placement: ${value}, expected one of ${placements.join(', ')}, got ${value}.`,
+ );
+ return;
+ }
+ this._placement = value;
+ this.updateStyle();
+ }
+
+ static observedAttributes = ['placement'];
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+ }
+
+ async connectedCallback() {
+ this.shadowRoot.innerHTML = `
+ <style>
+ :host {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ background: linear-gradient(0deg, #13151A, #13151A), linear-gradient(0deg, #343841, #343841);
+ border: 1px solid rgba(52, 56, 65, 1);
+ width: min(640px, 100%);
+ max-height: 480px;
+ border-radius: 12px;
+ padding: 24px;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ color: rgba(191, 193, 201, 1);
+ position: fixed;
+ z-index: 999999999;
+ bottom: 72px;
+ box-shadow: 0px 0px 0px 0px rgba(19, 21, 26, 0.30), 0px 1px 2px 0px rgba(19, 21, 26, 0.29), 0px 4px 4px 0px rgba(19, 21, 26, 0.26), 0px 10px 6px 0px rgba(19, 21, 26, 0.15), 0px 17px 7px 0px rgba(19, 21, 26, 0.04), 0px 26px 7px 0px rgba(19, 21, 26, 0.01);
+ }
+
+ @media (forced-colors: active) {
+ :host {
+ background: white;
+ }
+ }
+
+ @media (max-width: 640px) {
+ :host {
+ border-radius: 0;
+ }
+ }
+
+ ::slotted(h1), ::slotted(h2), ::slotted(h3), ::slotted(h4), ::slotted(h5) {
+ font-weight: 600;
+ color: #fff;
+ }
+
+ ::slotted(h1) {
+ font-size: 22px;
+ }
+
+ ::slotted(h2) {
+ font-size: 20px;
+ }
+
+ ::slotted(h3) {
+ font-size: 18px;
+ }
+
+ ::slotted(h4) {
+ font-size: 16px;
+ }
+
+ ::slotted(h5) {
+ font-size: 14px;
+ }
+
+ hr, ::slotted(hr) {
+ border: 1px solid rgba(27, 30, 36, 1);
+ margin: 1em 0;
+ }
+
+ p, ::slotted(p) {
+ line-height: 1.5em;
+ }
+ </style>
+ <style id="selected-style"></style>
+
+ <slot />
+ `;
+
+ this.updateStyle();
+ }
+
+ attributeChangedCallback() {
+ if (this.hasAttribute('placement'))
+ this.placement = this.getAttribute('placement') as Placement;
+ }
+
+ updateStyle() {
+ const style = this.shadowRoot.querySelector<HTMLStyleElement>('#selected-style');
+ if (style) {
+ const styleMap: Record<Placement, string> = {
+ 'bottom-left': `
+ :host {
+ left: 16px;
+ }
+ `,
+ 'bottom-center': `
+ :host {
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ `,
+ 'bottom-right': `
+ :host {
+ right: 16px;
+ }
+ `,
+ };
+ style.innerHTML = styleMap[this.placement];
+ }
+ }
+}
diff --git a/packages/astro/src/runtime/client/hmr.ts b/packages/astro/src/runtime/client/hmr.ts
new file mode 100644
index 000000000..5b6eddc23
--- /dev/null
+++ b/packages/astro/src/runtime/client/hmr.ts
@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+if (import.meta.hot) {
+ // HMR temporarily not needed for now, but kept here in case we need it again.
+ // To re-instate this module again, update `vite-plugin-astro-server/route.ts`
+ // to add this module as a script similar to `/@vite/client`
+}
diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts
new file mode 100644
index 000000000..801e1c983
--- /dev/null
+++ b/packages/astro/src/runtime/client/idle.ts
@@ -0,0 +1,23 @@
+import type { ClientDirective } from '../../types/public/integrations.js';
+
+const idleDirective: ClientDirective = (load, options) => {
+ const cb = async () => {
+ const hydrate = await load();
+ await hydrate();
+ };
+
+ const rawOptions =
+ typeof options.value === 'object' ? (options.value as IdleRequestOptions) : undefined;
+
+ const idleOptions: IdleRequestOptions = {
+ timeout: rawOptions?.timeout,
+ };
+
+ if ('requestIdleCallback' in window) {
+ (window as any).requestIdleCallback(cb, idleOptions);
+ } else {
+ setTimeout(cb, idleOptions.timeout || 200);
+ }
+};
+
+export default idleDirective;
diff --git a/packages/astro/src/runtime/client/load.ts b/packages/astro/src/runtime/client/load.ts
new file mode 100644
index 000000000..98521181c
--- /dev/null
+++ b/packages/astro/src/runtime/client/load.ts
@@ -0,0 +1,8 @@
+import type { ClientDirective } from '../../types/public/integrations.js';
+
+const loadDirective: ClientDirective = async (load) => {
+ const hydrate = await load();
+ await hydrate();
+};
+
+export default loadDirective;
diff --git a/packages/astro/src/runtime/client/media.ts b/packages/astro/src/runtime/client/media.ts
new file mode 100644
index 000000000..0c6e497e3
--- /dev/null
+++ b/packages/astro/src/runtime/client/media.ts
@@ -0,0 +1,22 @@
+import type { ClientDirective } from '../../types/public/integrations.js';
+
+/**
+ * Hydrate this component when a matching media query is found
+ */
+const mediaDirective: ClientDirective = (load, options) => {
+ const cb = async () => {
+ const hydrate = await load();
+ await hydrate();
+ };
+
+ if (options.value) {
+ const mql = matchMedia(options.value);
+ if (mql.matches) {
+ cb();
+ } else {
+ mql.addEventListener('change', cb, { once: true });
+ }
+ }
+};
+
+export default mediaDirective;
diff --git a/packages/astro/src/runtime/client/only.ts b/packages/astro/src/runtime/client/only.ts
new file mode 100644
index 000000000..c5fc488d4
--- /dev/null
+++ b/packages/astro/src/runtime/client/only.ts
@@ -0,0 +1,11 @@
+import type { ClientDirective } from '../../types/public/integrations.js';
+
+/**
+ * Hydrate this component only on the client
+ */
+const onlyDirective: ClientDirective = async (load) => {
+ const hydrate = await load();
+ await hydrate();
+};
+
+export default onlyDirective;
diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts
new file mode 100644
index 000000000..c7d858ffb
--- /dev/null
+++ b/packages/astro/src/runtime/client/visible.ts
@@ -0,0 +1,37 @@
+import type { ClientVisibleOptions } from '../../types/public/elements.js';
+import type { ClientDirective } from '../../types/public/integrations.js';
+
+/**
+ * Hydrate this component when one of it's children becomes visible
+ * We target the children because `astro-island` is set to `display: contents`
+ * which doesn't work with IntersectionObserver
+ */
+const visibleDirective: ClientDirective = (load, options, el) => {
+ const cb = async () => {
+ const hydrate = await load();
+ await hydrate();
+ };
+
+ const rawOptions =
+ typeof options.value === 'object' ? (options.value as ClientVisibleOptions) : undefined;
+
+ const ioOptions: IntersectionObserverInit = {
+ rootMargin: rawOptions?.rootMargin,
+ };
+
+ const io = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ // As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island`
+ io.disconnect();
+ cb();
+ break; // break loop on first match
+ }
+ }, ioOptions);
+
+ for (const child of el.children) {
+ io.observe(child);
+ }
+};
+
+export default visibleDirective;
diff --git a/packages/astro/src/runtime/compiler/index.ts b/packages/astro/src/runtime/compiler/index.ts
new file mode 100644
index 000000000..86ff982d2
--- /dev/null
+++ b/packages/astro/src/runtime/compiler/index.ts
@@ -0,0 +1,21 @@
+// NOTE: Although this entrypoint is exported, it is internal API and may change at any time.
+
+export {
+ Fragment,
+ addAttribute,
+ createAstro,
+ createComponent,
+ createTransitionScope,
+ defineScriptVars,
+ defineStyleVars,
+ maybeRenderHead,
+ mergeSlots,
+ render,
+ renderComponent,
+ renderHead,
+ renderScript,
+ renderSlot,
+ renderTransition,
+ spreadAttributes,
+ unescapeHTML,
+} from '../server/index.js';
diff --git a/packages/astro/src/runtime/server/astro-component.ts b/packages/astro/src/runtime/server/astro-component.ts
new file mode 100644
index 000000000..20649b64d
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-component.ts
@@ -0,0 +1,54 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { PropagationHint } from '../../types/public/internal.js';
+import type { AstroComponentFactory } from './render/index.js';
+
+function validateArgs(args: unknown[]): args is Parameters<AstroComponentFactory> {
+ if (args.length !== 3) return false;
+ if (!args[0] || typeof args[0] !== 'object') return false;
+ return true;
+}
+function baseCreateComponent(
+ cb: AstroComponentFactory,
+ moduleId?: string,
+ propagation?: PropagationHint,
+): AstroComponentFactory {
+ const name = moduleId?.split('/').pop()?.replace('.astro', '') ?? '';
+ const fn = (...args: Parameters<AstroComponentFactory>) => {
+ if (!validateArgs(args)) {
+ throw new AstroError({
+ ...AstroErrorData.InvalidComponentArgs,
+ message: AstroErrorData.InvalidComponentArgs.message(name),
+ });
+ }
+ return cb(...args);
+ };
+ Object.defineProperty(fn, 'name', { value: name, writable: false });
+ // Add a flag to this callback to mark it as an Astro component
+ fn.isAstroComponentFactory = true;
+ fn.moduleId = moduleId;
+ fn.propagation = propagation;
+ return fn;
+}
+
+interface CreateComponentOptions {
+ factory: AstroComponentFactory;
+ moduleId?: string;
+ propagation?: PropagationHint;
+}
+
+function createComponentWithOptions(opts: CreateComponentOptions) {
+ const cb = baseCreateComponent(opts.factory, opts.moduleId, opts.propagation);
+ return cb;
+}
+// Used in creating the component. aka the main export.
+export function createComponent(
+ arg1: AstroComponentFactory | CreateComponentOptions,
+ moduleId?: string,
+ propagation?: PropagationHint,
+) {
+ if (typeof arg1 === 'function') {
+ return baseCreateComponent(arg1, moduleId, propagation);
+ } else {
+ return createComponentWithOptions(arg1);
+ }
+}
diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts
new file mode 100644
index 000000000..0602302cc
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-global.ts
@@ -0,0 +1,43 @@
+import { ASTRO_VERSION } from '../../core/constants.js';
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { AstroGlobalPartial } from '../../types/public/context.js';
+
+/** Create the Astro.glob() runtime function. */
+function createAstroGlobFn() {
+ const globHandler = (importMetaGlobResult: Record<string, any>) => {
+ // This is created inside of the runtime so we don't have access to the Astro logger.
+ console.warn(`Astro.glob is deprecated and will be removed in a future major version of Astro.
+Use import.meta.glob instead: https://vitejs.dev/guide/features.html#glob-import`);
+
+ if (typeof importMetaGlobResult === 'string') {
+ throw new AstroError({
+ ...AstroErrorData.AstroGlobUsedOutside,
+ message: AstroErrorData.AstroGlobUsedOutside.message(JSON.stringify(importMetaGlobResult)),
+ });
+ }
+ let allEntries = [...Object.values(importMetaGlobResult)];
+ if (allEntries.length === 0) {
+ throw new AstroError({
+ ...AstroErrorData.AstroGlobNoMatch,
+ message: AstroErrorData.AstroGlobNoMatch.message(JSON.stringify(importMetaGlobResult)),
+ });
+ }
+ // Map over the `import()` promises, calling to load them.
+ return Promise.all(allEntries.map((fn) => fn()));
+ };
+ // Cast the return type because the argument that the user sees (string) is different from the argument
+ // that the runtime sees post-compiler (Record<string, Module>).
+ return globHandler as unknown as AstroGlobalPartial['glob'];
+}
+
+// This is used to create the top-level Astro global; the one that you can use
+// inside of getStaticPaths. See the `astroGlobalArgs` option for parameter type.
+export function createAstro(site: string | undefined): AstroGlobalPartial {
+ return {
+ // TODO: this is no longer necessary for `Astro.site`
+ // but it somehow allows working around caching issues in content collections for some tests
+ site: site ? new URL(site) : undefined,
+ generator: `Astro v${ASTRO_VERSION}`,
+ glob: createAstroGlobFn(),
+ };
+}
diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts
new file mode 100644
index 000000000..d9a13146e
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-island.ts
@@ -0,0 +1,216 @@
+// Note that this file is prebuilt to astro-island.prebuilt.ts
+// Do not import this file directly, instead import the prebuilt one instead.
+// pnpm --filter astro run prebuild
+
+type directiveAstroKeys = 'load' | 'idle' | 'visible' | 'media' | 'only';
+
+declare const Astro: {
+ [k in directiveAstroKeys]?: (
+ fn: () => Promise<() => void>,
+ opts: Record<string, any>,
+ root: HTMLElement,
+ ) => unknown;
+};
+
+{
+ interface PropTypeSelector {
+ [k: string]: (value: any) => any;
+ }
+
+ const propTypes: PropTypeSelector = {
+ 0: (value) => reviveObject(value),
+ 1: (value) => reviveArray(value),
+ 2: (value) => new RegExp(value),
+ 3: (value) => new Date(value),
+ 4: (value) => new Map(reviveArray(value)),
+ 5: (value) => new Set(reviveArray(value)),
+ 6: (value) => BigInt(value),
+ 7: (value) => new URL(value),
+ 8: (value) => new Uint8Array(value),
+ 9: (value) => new Uint16Array(value),
+ 10: (value) => new Uint32Array(value),
+ 11: (value) => Infinity * value,
+ };
+
+ // Not using JSON.parse reviver because it's bottom-up but we want top-down
+ const reviveTuple = (raw: any): any => {
+ const [type, value] = raw;
+ return type in propTypes ? propTypes[type](value) : undefined;
+ };
+
+ const reviveArray = (raw: any): any => (raw as Array<any>).map(reviveTuple);
+
+ const reviveObject = (raw: any): any => {
+ if (typeof raw !== 'object' || raw === null) return raw;
+ return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveTuple(value)]));
+ };
+
+ // 🌊🏝🌴
+ class AstroIsland extends HTMLElement {
+ public Component: any;
+ public hydrator: any;
+ static observedAttributes = ['props'];
+
+ disconnectedCallback() {
+ document.removeEventListener('astro:after-swap', this.unmount);
+ document.addEventListener('astro:after-swap', this.unmount, { once: true });
+ }
+
+ connectedCallback() {
+ if (
+ !this.hasAttribute('await-children') ||
+ document.readyState === 'interactive' ||
+ document.readyState === 'complete'
+ ) {
+ this.childrenConnectedCallback();
+ } else {
+ // connectedCallback may run *before* children are rendered (ex. HTML streaming)
+ // If SSR children are expected, but not yet rendered, wait with a mutation observer
+ // for a special marker inserted when rendering islands that signals the end of the island
+ const onConnected = () => {
+ document.removeEventListener('DOMContentLoaded', onConnected);
+ mo.disconnect();
+ this.childrenConnectedCallback();
+ };
+ const mo = new MutationObserver(() => {
+ if (
+ this.lastChild?.nodeType === Node.COMMENT_NODE &&
+ this.lastChild.nodeValue === 'astro:end'
+ ) {
+ this.lastChild.remove();
+ onConnected();
+ }
+ });
+ mo.observe(this, { childList: true });
+ // in case the marker comment got stripped and the mutation observer waited indefinitely,
+ // also wait for DOMContentLoaded as a last resort
+ document.addEventListener('DOMContentLoaded', onConnected);
+ }
+ }
+
+ async childrenConnectedCallback() {
+ let beforeHydrationUrl = this.getAttribute('before-hydration-url');
+ if (beforeHydrationUrl) {
+ await import(beforeHydrationUrl);
+ }
+ this.start();
+ }
+
+ async start() {
+ const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
+ const directive = this.getAttribute('client') as directiveAstroKeys;
+ if (Astro[directive] === undefined) {
+ window.addEventListener(`astro:${directive}`, () => this.start(), { once: true });
+ return;
+ }
+ try {
+ await Astro[directive]!(
+ async () => {
+ const rendererUrl = this.getAttribute('renderer-url');
+ const [componentModule, { default: hydrator }] = await Promise.all([
+ import(this.getAttribute('component-url')!),
+ rendererUrl ? import(rendererUrl) : () => () => {},
+ ]);
+ const componentExport = this.getAttribute('component-export') || 'default';
+ if (!componentExport.includes('.')) {
+ this.Component = componentModule[componentExport];
+ } else {
+ this.Component = componentModule;
+ for (const part of componentExport.split('.')) {
+ this.Component = this.Component[part];
+ }
+ }
+ this.hydrator = hydrator;
+ return this.hydrate;
+ },
+ opts,
+ this,
+ );
+ } catch (e) {
+ console.error(`[astro-island] Error hydrating ${this.getAttribute('component-url')}`, e);
+ }
+ }
+
+ hydrate = async () => {
+ // The client directive needs to load the hydrator code before it can hydrate
+ if (!this.hydrator) return;
+
+ // Make sure the island is mounted on the DOM before hydrating. It could be unmounted
+ // when the parent island hydrates and re-creates this island.
+ if (!this.isConnected) return;
+
+ // Wait for parent island to hydrate first so we hydrate top-down. The `ssr` attribute
+ // represents that it has not completed hydration yet.
+ const parentSsrIsland = this.parentElement?.closest('astro-island[ssr]');
+ if (parentSsrIsland) {
+ parentSsrIsland.addEventListener('astro:hydrate', this.hydrate, { once: true });
+ return;
+ }
+
+ const slotted = this.querySelectorAll('astro-slot');
+ const slots: Record<string, string> = {};
+ // Always check to see if there are templates.
+ // This happens if slots were passed but the client component did not render them.
+ const templates = this.querySelectorAll('template[data-astro-template]');
+ for (const template of templates) {
+ const closest = template.closest(this.tagName);
+ if (!closest?.isSameNode(this)) continue;
+ slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML;
+ template.remove();
+ }
+ for (const slot of slotted) {
+ const closest = slot.closest(this.tagName);
+ if (!closest?.isSameNode(this)) continue;
+ slots[slot.getAttribute('name') || 'default'] = slot.innerHTML;
+ }
+
+ let props: Record<string, unknown>;
+
+ try {
+ props = this.hasAttribute('props')
+ ? reviveObject(JSON.parse(this.getAttribute('props')!))
+ : {};
+ } catch (e) {
+ let componentName: string = this.getAttribute('component-url') || '<unknown>';
+ const componentExport = this.getAttribute('component-export');
+
+ if (componentExport) {
+ componentName += ` (export ${componentExport})`;
+ }
+
+ console.error(
+ `[hydrate] Error parsing props for component ${componentName}`,
+ this.getAttribute('props'),
+ e,
+ );
+ throw e;
+ }
+ let hydrationTimeStart;
+ const hydrator = this.hydrator(this);
+ if (process.env.NODE_ENV === 'development') hydrationTimeStart = performance.now();
+ await hydrator(this.Component, props, slots, {
+ client: this.getAttribute('client'),
+ });
+ if (process.env.NODE_ENV === 'development' && hydrationTimeStart)
+ this.setAttribute(
+ 'client-render-time',
+ (performance.now() - hydrationTimeStart).toString(),
+ );
+ this.removeAttribute('ssr');
+ this.dispatchEvent(new CustomEvent('astro:hydrate'));
+ };
+
+ attributeChangedCallback() {
+ this.hydrate();
+ }
+
+ unmount = () => {
+ // If element wasn't persisted, fire unmount event
+ if (!this.isConnected) this.dispatchEvent(new CustomEvent('astro:unmount'));
+ };
+ }
+
+ if (!customElements.get('astro-island')) {
+ customElements.define('astro-island', AstroIsland);
+ }
+}
diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts
new file mode 100644
index 000000000..1a6bbc08e
--- /dev/null
+++ b/packages/astro/src/runtime/server/endpoint.ts
@@ -0,0 +1,82 @@
+import { bold } from 'kleur/colors';
+import { REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER } from '../../core/constants.js';
+import { EndpointDidNotReturnAResponse } from '../../core/errors/errors-data.js';
+import { AstroError } from '../../core/errors/errors.js';
+import type { Logger } from '../../core/logger/core.js';
+import type { APIRoute } from '../../types/public/common.js';
+import type { APIContext } from '../../types/public/context.js';
+
+/** Renders an endpoint request to completion, returning the body. */
+export async function renderEndpoint(
+ mod: {
+ [method: string]: APIRoute;
+ },
+ context: APIContext,
+ isPrerendered: boolean,
+ logger: Logger,
+) {
+ const { request, url } = context;
+
+ const method = request.method.toUpperCase();
+ // use the exact match on `method`, fallback to ALL
+ const handler = mod[method] ?? mod['ALL'];
+ if (isPrerendered && method !== 'GET') {
+ logger.warn(
+ 'router',
+ `${url.pathname} ${bold(
+ method,
+ )} requests are not available in static endpoints. Mark this page as server-rendered (\`export const prerender = false;\`) or update your config to \`output: 'server'\` to make all your pages server-rendered by default.`,
+ );
+ }
+ if (handler === undefined) {
+ logger.warn(
+ 'router',
+ `No API Route handler exists for the method "${method}" for the route "${url.pathname}".\n` +
+ `Found handlers: ${Object.keys(mod)
+ .map((exp) => JSON.stringify(exp))
+ .join(', ')}\n` +
+ ('all' in mod
+ ? `One of the exported handlers is "all" (lowercase), did you mean to export 'ALL'?\n`
+ : ''),
+ );
+ // No handler matching the verb found, so this should be a
+ // 404. Should be handled by 404.astro route if possible.
+ return new Response(null, { status: 404 });
+ }
+ if (typeof handler !== 'function') {
+ logger.error(
+ 'router',
+ `The route "${
+ url.pathname
+ }" exports a value for the method "${method}", but it is of the type ${typeof handler} instead of a function.`,
+ );
+ return new Response(null, { status: 500 });
+ }
+
+ let response = await handler.call(mod, context);
+
+ if (!response || response instanceof Response === false) {
+ throw new AstroError(EndpointDidNotReturnAResponse);
+ }
+
+ // Endpoints explicitly returning 404 or 500 response status should
+ // NOT be subject to rerouting to 404.astro or 500.astro.
+ if (REROUTABLE_STATUS_CODES.includes(response.status)) {
+ try {
+ response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
+ } catch (err) {
+ // In some cases the response may have immutable headers
+ // This is the case if, for example, the user directly returns a `fetch` response
+ // There's no clean way to check if the headers are immutable, so we just catch the error
+ // Note that response.clone() still has immutable headers!
+ if ((err as Error).message?.includes('immutable')) {
+ response = new Response(response.body, response);
+ response.headers.set(REROUTE_DIRECTIVE_HEADER, 'no');
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ return response;
+}
diff --git a/packages/astro/src/runtime/server/escape.ts b/packages/astro/src/runtime/server/escape.ts
new file mode 100644
index 000000000..1dbfd3725
--- /dev/null
+++ b/packages/astro/src/runtime/server/escape.ts
@@ -0,0 +1,113 @@
+import { escape } from 'html-escaper';
+import { streamAsyncIterator } from './util.js';
+
+// Leverage the battle-tested `html-escaper` npm package.
+export const escapeHTML = escape;
+
+export class HTMLBytes extends Uint8Array {}
+
+// TypeScript won't let us define this in the class body so have to do it
+// this way. Boo.
+Object.defineProperty(HTMLBytes.prototype, Symbol.toStringTag, {
+ get() {
+ return 'HTMLBytes';
+ },
+});
+
+/**
+ * A "blessed" extension of String that tells Astro that the string
+ * has already been escaped. This helps prevent double-escaping of HTML.
+ */
+export class HTMLString extends String {
+ get [Symbol.toStringTag]() {
+ return 'HTMLString';
+ }
+}
+
+type BlessedType = string | HTMLBytes;
+
+/**
+ * markHTMLString marks a string as raw or "already escaped" by returning
+ * a `HTMLString` instance. This is meant for internal use, and should not
+ * be returned through any public JS API.
+ */
+export const markHTMLString = (value: any) => {
+ // If value is already marked as an HTML string, there is nothing to do.
+ if (value instanceof HTMLString) {
+ return value;
+ }
+ // Cast to `HTMLString` to mark the string as valid HTML. Any HTML escaping
+ // and sanitization should have already happened to the `value` argument.
+ // NOTE: `unknown as string` is necessary for TypeScript to treat this as `string`
+ if (typeof value === 'string') {
+ return new HTMLString(value) as unknown as string;
+ }
+ // Return all other values (`number`, `null`, `undefined`) as-is.
+ // The compiler will recursively stringify these correctly at a later stage.
+ return value;
+};
+
+export function isHTMLString(value: any): value is HTMLString {
+ return Object.prototype.toString.call(value) === '[object HTMLString]';
+}
+
+function markHTMLBytes(bytes: Uint8Array) {
+ return new HTMLBytes(bytes);
+}
+
+export function isHTMLBytes(value: any): value is HTMLBytes {
+ return Object.prototype.toString.call(value) === '[object HTMLBytes]';
+}
+
+function hasGetReader(obj: unknown): obj is ReadableStream {
+ return typeof (obj as any).getReader === 'function';
+}
+
+async function* unescapeChunksAsync(iterable: ReadableStream | string): any {
+ if (hasGetReader(iterable)) {
+ for await (const chunk of streamAsyncIterator(iterable)) {
+ yield unescapeHTML(chunk as BlessedType);
+ }
+ } else {
+ for await (const chunk of iterable) {
+ yield unescapeHTML(chunk as BlessedType);
+ }
+ }
+}
+
+function* unescapeChunks(iterable: Iterable<any>): any {
+ for (const chunk of iterable) {
+ yield unescapeHTML(chunk);
+ }
+}
+
+export function unescapeHTML(
+ str: any,
+):
+ | BlessedType
+ | Promise<BlessedType | AsyncGenerator<BlessedType, void, unknown>>
+ | AsyncGenerator<BlessedType, void, unknown> {
+ if (!!str && typeof str === 'object') {
+ if (str instanceof Uint8Array) {
+ return markHTMLBytes(str);
+ }
+ // If a response, stream out the chunks
+ else if (str instanceof Response && str.body) {
+ const body = str.body;
+ return unescapeChunksAsync(body);
+ }
+ // If a promise, await the result and mark that.
+ else if (typeof str.then === 'function') {
+ return Promise.resolve(str).then((value) => {
+ return unescapeHTML(value);
+ });
+ } else if (str[Symbol.for('astro:slot-string')]) {
+ return str;
+ } else if (Symbol.iterator in str) {
+ return unescapeChunks(str);
+ } else if (Symbol.asyncIterator in str || hasGetReader(str)) {
+ return unescapeChunksAsync(str);
+ }
+ }
+ return markHTMLString(str);
+}
diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts
new file mode 100644
index 000000000..1a678a81a
--- /dev/null
+++ b/packages/astro/src/runtime/server/hydration.ts
@@ -0,0 +1,187 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type {
+ AstroComponentMetadata,
+ SSRElement,
+ SSRLoadedRenderer,
+ SSRResult,
+} from '../../types/public/internal.js';
+import { escapeHTML } from './escape.js';
+import { serializeProps } from './serialize.js';
+
+export interface HydrationMetadata {
+ directive: string;
+ value: string;
+ componentUrl: string;
+ componentExport: { value: string };
+}
+
+type Props = Record<string | number | symbol, any>;
+
+interface ExtractedProps {
+ isPage: boolean;
+ hydration: HydrationMetadata | null;
+ props: Props;
+ propsWithoutTransitionAttributes: Props;
+}
+
+const transitionDirectivesToCopyOnIsland = Object.freeze([
+ 'data-astro-transition-scope',
+ 'data-astro-transition-persist',
+ 'data-astro-transition-persist-props',
+]);
+
+// Used to extract the directives, aka `client:load` information about a component.
+// Finds these special props and removes them from what gets passed into the component.
+export function extractDirectives(
+ inputProps: Props,
+ clientDirectives: SSRResult['clientDirectives'],
+): ExtractedProps {
+ let extracted: ExtractedProps = {
+ isPage: false,
+ hydration: null,
+ props: {},
+ propsWithoutTransitionAttributes: {},
+ };
+ for (const [key, value] of Object.entries(inputProps)) {
+ if (key.startsWith('server:')) {
+ if (key === 'server:root') {
+ extracted.isPage = true;
+ }
+ }
+ if (key.startsWith('client:')) {
+ if (!extracted.hydration) {
+ extracted.hydration = {
+ directive: '',
+ value: '',
+ componentUrl: '',
+ componentExport: { value: '' },
+ };
+ }
+ switch (key) {
+ case 'client:component-path': {
+ extracted.hydration.componentUrl = value;
+ break;
+ }
+ case 'client:component-export': {
+ extracted.hydration.componentExport.value = value;
+ break;
+ }
+ // This is a special prop added to prove that the client hydration method
+ // was added statically.
+ case 'client:component-hydration': {
+ break;
+ }
+ case 'client:display-name': {
+ break;
+ }
+ default: {
+ extracted.hydration.directive = key.split(':')[1];
+ extracted.hydration.value = value;
+
+ // throw an error if an invalid hydration directive was provided
+ if (!clientDirectives.has(extracted.hydration.directive)) {
+ const hydrationMethods = Array.from(clientDirectives.keys())
+ .map((d) => `client:${d}`)
+ .join(', ');
+ throw new Error(
+ `Error: invalid hydration directive "${key}". Supported hydration methods: ${hydrationMethods}`,
+ );
+ }
+
+ // throw an error if the query wasn't provided for client:media
+ if (
+ extracted.hydration.directive === 'media' &&
+ typeof extracted.hydration.value !== 'string'
+ ) {
+ throw new AstroError(AstroErrorData.MissingMediaQueryDirective);
+ }
+
+ break;
+ }
+ }
+ } else {
+ extracted.props[key] = value;
+ if (!transitionDirectivesToCopyOnIsland.includes(key)) {
+ extracted.propsWithoutTransitionAttributes[key] = value;
+ }
+ }
+ }
+ for (const sym of Object.getOwnPropertySymbols(inputProps)) {
+ extracted.props[sym] = inputProps[sym];
+ extracted.propsWithoutTransitionAttributes[sym] = inputProps[sym];
+ }
+
+ return extracted;
+}
+
+interface HydrateScriptOptions {
+ renderer: SSRLoadedRenderer;
+ result: SSRResult;
+ astroId: string;
+ props: Record<string | number, any>;
+ attrs: Record<string, string> | undefined;
+}
+
+/** For hydrated components, generate a <script type="module"> to load the component */
+export async function generateHydrateScript(
+ scriptOptions: HydrateScriptOptions,
+ metadata: Required<AstroComponentMetadata>,
+): Promise<SSRElement> {
+ const { renderer, result, astroId, props, attrs } = scriptOptions;
+ const { hydrate, componentUrl, componentExport } = metadata;
+
+ if (!componentExport.value) {
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingImport,
+ message: AstroErrorData.NoMatchingImport.message(metadata.displayName),
+ });
+ }
+
+ const island: SSRElement = {
+ children: '',
+ props: {
+ // This is for HMR, probably can avoid it in prod
+ uid: astroId,
+ },
+ };
+
+ // Attach renderer-provided attributes
+ if (attrs) {
+ for (const [key, value] of Object.entries(attrs)) {
+ island.props[key] = escapeHTML(value);
+ }
+ }
+
+ // Add component url
+ island.props['component-url'] = await result.resolve(decodeURI(componentUrl));
+
+ // Add renderer url
+ if (renderer.clientEntrypoint) {
+ island.props['component-export'] = componentExport.value;
+ island.props['renderer-url'] = await result.resolve(
+ decodeURI(renderer.clientEntrypoint.toString()),
+ );
+ island.props['props'] = escapeHTML(serializeProps(props, metadata));
+ }
+
+ island.props['ssr'] = '';
+ island.props['client'] = hydrate;
+ let beforeHydrationUrl = await result.resolve('astro:scripts/before-hydration.js');
+ if (beforeHydrationUrl.length) {
+ island.props['before-hydration-url'] = beforeHydrationUrl;
+ }
+ island.props['opts'] = escapeHTML(
+ JSON.stringify({
+ name: metadata.displayName,
+ value: metadata.hydrateArgs || '',
+ }),
+ );
+
+ transitionDirectivesToCopyOnIsland.forEach((name) => {
+ if (typeof props[name] !== 'undefined') {
+ island.props[name] = props[name];
+ }
+ });
+
+ return island;
+}
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
new file mode 100644
index 000000000..508ece984
--- /dev/null
+++ b/packages/astro/src/runtime/server/index.ts
@@ -0,0 +1,106 @@
+// NOTE: Although this entrypoint is exported, it is internal API and may change at any time.
+
+export { createComponent } from './astro-component.js';
+export { createAstro } from './astro-global.js';
+export { renderEndpoint } from './endpoint.js';
+export {
+ escapeHTML,
+ HTMLBytes,
+ HTMLString,
+ isHTMLString,
+ markHTMLString,
+ unescapeHTML,
+} from './escape.js';
+export { renderJSX } from './jsx.js';
+export {
+ addAttribute,
+ createHeadAndContent,
+ defineScriptVars,
+ Fragment,
+ maybeRenderHead,
+ renderTemplate as render,
+ renderComponent,
+ Renderer as Renderer,
+ renderHead,
+ renderHTMLElement,
+ renderPage,
+ renderScript,
+ renderScriptElement,
+ renderSlot,
+ renderSlotToString,
+ renderTemplate,
+ renderToString,
+ renderUniqueStylesheet,
+ voidElementNames,
+} from './render/index.js';
+export type {
+ AstroComponentFactory,
+ AstroComponentInstance,
+ ComponentSlots,
+ RenderInstruction,
+} from './render/index.js';
+export { createTransitionScope, renderTransition } from './transition.js';
+
+import { markHTMLString } from './escape.js';
+import { Renderer, addAttribute } from './render/index.js';
+
+export function mergeSlots(...slotted: unknown[]) {
+ const slots: Record<string, () => any> = {};
+ for (const slot of slotted) {
+ if (!slot) continue;
+ if (typeof slot === 'object') {
+ Object.assign(slots, slot);
+ } else if (typeof slot === 'function') {
+ Object.assign(slots, mergeSlots(slot()));
+ }
+ }
+ return slots;
+}
+
+/** @internal Associate JSX components with a specific renderer (see /packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts) */
+export function __astro_tag_component__(Component: unknown, rendererName: string) {
+ if (!Component) return;
+ if (typeof Component !== 'function') return;
+ Object.defineProperty(Component, Renderer, {
+ value: rendererName,
+ enumerable: false,
+ writable: false,
+ });
+}
+
+// Adds support for `<Component {...value} />
+export function spreadAttributes(
+ values: Record<any, any> = {},
+ _name?: string,
+ { class: scopedClassName }: { class?: string } = {},
+) {
+ let output = '';
+ // If the compiler passes along a scoped class, merge with existing props or inject it
+ if (scopedClassName) {
+ if (typeof values.class !== 'undefined') {
+ values.class += ` ${scopedClassName}`;
+ } else if (typeof values['class:list'] !== 'undefined') {
+ values['class:list'] = [values['class:list'], scopedClassName];
+ } else {
+ values.class = scopedClassName;
+ }
+ }
+ for (const [key, value] of Object.entries(values)) {
+ output += addAttribute(value, key, true);
+ }
+ return markHTMLString(output);
+}
+
+// Adds CSS variables to an inline style tag
+export function defineStyleVars(defs: Record<any, any> | Record<any, any>[]) {
+ let output = '';
+ let arr = !Array.isArray(defs) ? [defs] : defs;
+ for (const vars of arr) {
+ for (const [key, value] of Object.entries(vars)) {
+ if (value || value === 0) {
+ output += `--${key}: ${value};`;
+ }
+ }
+ }
+ return markHTMLString(output);
+}
diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts
new file mode 100644
index 000000000..7280e216c
--- /dev/null
+++ b/packages/astro/src/runtime/server/jsx.ts
@@ -0,0 +1,191 @@
+import { AstroJSX, type AstroVNode, isVNode } from '../../jsx-runtime/index.js';
+import type { SSRResult } from '../../types/public/internal.js';
+import {
+ HTMLString,
+ escapeHTML,
+ markHTMLString,
+ renderToString,
+ spreadAttributes,
+ voidElementNames,
+} from './index.js';
+import { renderComponentToString } from './render/component.js';
+
+const ClientOnlyPlaceholder = 'astro-client-only';
+
+// If the `vnode.type` is a function, we could render it as JSX or as framework components.
+// Inside `renderJSXNode`, we first try to render as framework components, and if `renderJSXNode`
+// is called again while rendering the component, it's likely that the `astro:jsx` is invoking
+// `renderJSXNode` again (loop). In this case, we try to render as JSX instead.
+//
+// This Symbol is assigned to `vnode.props` to track if it had tried to render as framework components.
+// It mutates `vnode.props` to be able to scope to the current render call.
+const hasTriedRenderComponentSymbol = Symbol('hasTriedRenderComponent');
+
+export async function renderJSX(result: SSRResult, vnode: any): Promise<any> {
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (true) {
+ case vnode instanceof HTMLString:
+ if (vnode.toString().trim() === '') {
+ return '';
+ }
+ return vnode;
+ case typeof vnode === 'string':
+ return markHTMLString(escapeHTML(vnode));
+ case typeof vnode === 'function':
+ return vnode;
+ case !vnode && vnode !== 0:
+ return '';
+ case Array.isArray(vnode):
+ return markHTMLString(
+ (await Promise.all(vnode.map((v: any) => renderJSX(result, v)))).join(''),
+ );
+ }
+
+ return renderJSXVNode(result, vnode);
+}
+
+async function renderJSXVNode(result: SSRResult, vnode: AstroVNode): Promise<any> {
+ if (isVNode(vnode)) {
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (true) {
+ case !vnode.type: {
+ throw new Error(`Unable to render ${result.pathname} because it contains an undefined Component!
+Did you forget to import the component or is it possible there is a typo?`);
+ }
+ case (vnode.type as any) === Symbol.for('astro:fragment'):
+ return renderJSX(result, vnode.props.children);
+ case (vnode.type as any).isAstroComponentFactory: {
+ let props: Record<string, any> = {};
+ let slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(vnode.props ?? {})) {
+ if (key === 'children' || (value && typeof value === 'object' && value['$$slot'])) {
+ slots[key === 'children' ? 'default' : key] = () => renderJSX(result, value);
+ } else {
+ props[key] = value;
+ }
+ }
+ const str = await renderToString(result, vnode.type as any, props, slots);
+ if (str instanceof Response) {
+ throw str;
+ }
+ const html = markHTMLString(str);
+ return html;
+ }
+ case !vnode.type && (vnode.type as any) !== 0:
+ return '';
+ case typeof vnode.type === 'string' && vnode.type !== ClientOnlyPlaceholder:
+ return markHTMLString(await renderElement(result, vnode.type as string, vnode.props ?? {}));
+ }
+
+ if (vnode.type) {
+ if (typeof vnode.type === 'function' && vnode.props['server:root']) {
+ const output = await vnode.type(vnode.props ?? {});
+ return await renderJSX(result, output);
+ }
+ if (typeof vnode.type === 'function') {
+ if (vnode.props[hasTriedRenderComponentSymbol]) {
+ // omitting compiler-internals from user components
+ delete vnode.props[hasTriedRenderComponentSymbol];
+ const output = await vnode.type(vnode.props ?? {});
+ if (output?.[AstroJSX] || !output) {
+ return await renderJSXVNode(result, output);
+ } else {
+ return;
+ }
+ } else {
+ vnode.props[hasTriedRenderComponentSymbol] = true;
+ }
+ }
+
+ const { children = null, ...props } = vnode.props ?? {};
+ const _slots: Record<string, any> = {
+ default: [],
+ };
+ function extractSlots(child: any): any {
+ if (Array.isArray(child)) {
+ return child.map((c) => extractSlots(c));
+ }
+ if (!isVNode(child)) {
+ _slots.default.push(child);
+ return;
+ }
+ if ('slot' in child.props) {
+ _slots[child.props.slot] = [...(_slots[child.props.slot] ?? []), child];
+ delete child.props.slot;
+ return;
+ }
+ _slots.default.push(child);
+ }
+ extractSlots(children);
+ for (const [key, value] of Object.entries(props)) {
+ if (value?.['$$slot']) {
+ _slots[key] = value;
+ delete props[key];
+ }
+ }
+ const slotPromises = [];
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(_slots)) {
+ slotPromises.push(
+ renderJSX(result, value).then((output) => {
+ if (output.toString().trim().length === 0) return;
+ slots[key] = () => output;
+ }),
+ );
+ }
+ await Promise.all(slotPromises);
+
+ let output: string;
+ if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
+ output = await renderComponentToString(
+ result,
+ vnode.props['client:display-name'] ?? '',
+ null,
+ props,
+ slots,
+ );
+ } else {
+ output = await renderComponentToString(
+ result,
+ typeof vnode.type === 'function' ? vnode.type.name : vnode.type,
+ vnode.type,
+ props,
+ slots,
+ );
+ }
+ return markHTMLString(output);
+ }
+ }
+ // numbers, plain objects, etc
+ return markHTMLString(`${vnode}`);
+}
+
+async function renderElement(
+ result: any,
+ tag: string,
+ { children, ...props }: Record<string, any>,
+) {
+ return markHTMLString(
+ `<${tag}${spreadAttributes(props)}${markHTMLString(
+ (children == null || children == '') && voidElementNames.test(tag)
+ ? `/>`
+ : `>${
+ children == null ? '' : await renderJSX(result, prerenderElementChildren(tag, children))
+ }</${tag}>`,
+ )}`,
+ );
+}
+
+/**
+ * Pre-render the children with the given `tag` information
+ */
+function prerenderElementChildren(tag: string, children: any) {
+ // For content within <style> and <script> tags that are plain strings, e.g. injected
+ // by remark/rehype plugins, or if a user explicitly does `<script>{'...'}</script>`,
+ // we mark it as an HTML string to prevent the content from being HTML-escaped.
+ if (typeof children === 'string' && (tag === 'style' || tag === 'script')) {
+ return markHTMLString(children);
+ } else {
+ return children;
+ }
+}
diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts
new file mode 100644
index 000000000..e7e9f0b56
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/any.ts
@@ -0,0 +1,54 @@
+import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
+import { isPromise } from '../util.js';
+import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
+import { type RenderDestination, isRenderInstance } from './common.js';
+import { SlotString } from './slot.js';
+import { renderToBufferDestination } from './util.js';
+
+export async function renderChild(destination: RenderDestination, child: any) {
+ if (isPromise(child)) {
+ child = await child;
+ }
+ if (child instanceof SlotString) {
+ destination.write(child);
+ } else if (isHTMLString(child)) {
+ destination.write(child);
+ } else if (Array.isArray(child)) {
+ // Render all children eagerly and in parallel
+ const childRenders = child.map((c) => {
+ return renderToBufferDestination((bufferDestination) => {
+ return renderChild(bufferDestination, c);
+ });
+ });
+ for (const childRender of childRenders) {
+ if (!childRender) continue;
+ await childRender.renderToFinalDestination(destination);
+ }
+ } else if (typeof child === 'function') {
+ // Special: If a child is a function, call it automatically.
+ // This lets you do {() => ...} without the extra boilerplate
+ // of wrapping it in a function and calling it.
+ await renderChild(destination, child());
+ } else if (typeof child === 'string') {
+ destination.write(markHTMLString(escapeHTML(child)));
+ } else if (!child && child !== 0) {
+ // do nothing, safe to ignore falsey values.
+ } else if (isRenderInstance(child)) {
+ await child.render(destination);
+ } else if (isRenderTemplateResult(child)) {
+ await child.render(destination);
+ } else if (isAstroComponentInstance(child)) {
+ await child.render(destination);
+ } else if (ArrayBuffer.isView(child)) {
+ destination.write(child);
+ } else if (
+ typeof child === 'object' &&
+ (Symbol.asyncIterator in child || Symbol.iterator in child)
+ ) {
+ for await (const value of child) {
+ await renderChild(destination, value);
+ }
+ } else {
+ destination.write(child);
+ }
+}
diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts
new file mode 100644
index 000000000..dab33a031
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/factory.ts
@@ -0,0 +1,28 @@
+import type { PropagationHint, SSRResult } from '../../../../types/public/internal.js';
+import type { HeadAndContent } from './head-and-content.js';
+import type { RenderTemplateResult } from './render-template.js';
+
+export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent;
+
+// The callback passed to to $$createComponent
+export interface AstroComponentFactory {
+ (result: any, props: any, slots: any): AstroFactoryReturnValue | Promise<AstroFactoryReturnValue>;
+ isAstroComponentFactory?: boolean;
+ moduleId?: string | undefined;
+ propagation?: PropagationHint;
+}
+
+export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {
+ return obj == null ? false : obj.isAstroComponentFactory === true;
+}
+
+export function isAPropagatingComponent(
+ result: SSRResult,
+ factory: AstroComponentFactory,
+): boolean {
+ let hint: PropagationHint = factory.propagation || 'none';
+ if (factory.moduleId && result.componentMetadata.has(factory.moduleId) && hint === 'none') {
+ hint = result.componentMetadata.get(factory.moduleId)!.propagation;
+ }
+ return hint === 'in-tree' || hint === 'self';
+}
diff --git a/packages/astro/src/runtime/server/render/astro/head-and-content.ts b/packages/astro/src/runtime/server/render/astro/head-and-content.ts
new file mode 100644
index 000000000..e0b566882
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/head-and-content.ts
@@ -0,0 +1,21 @@
+import type { RenderTemplateResult } from './render-template.js';
+
+const headAndContentSym = Symbol.for('astro.headAndContent');
+
+export type HeadAndContent = {
+ [headAndContentSym]: true;
+ head: string;
+ content: RenderTemplateResult;
+};
+
+export function isHeadAndContent(obj: unknown): obj is HeadAndContent {
+ return typeof obj === 'object' && obj !== null && !!(obj as any)[headAndContentSym];
+}
+
+export function createHeadAndContent(head: string, content: RenderTemplateResult): HeadAndContent {
+ return {
+ [headAndContentSym]: true,
+ head,
+ content,
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/astro/index.ts b/packages/astro/src/runtime/server/render/astro/index.ts
new file mode 100644
index 000000000..9510c0cbb
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/index.ts
@@ -0,0 +1,7 @@
+export { isAstroComponentFactory } from './factory.js';
+export type { AstroComponentFactory } from './factory.js';
+export { createHeadAndContent, isHeadAndContent } from './head-and-content.js';
+export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js';
+export type { AstroComponentInstance } from './instance.js';
+export { isRenderTemplateResult, renderTemplate } from './render-template.js';
+export { renderToReadableStream, renderToString } from './render.js';
diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts
new file mode 100644
index 000000000..aae13ca9e
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/instance.ts
@@ -0,0 +1,105 @@
+import type { ComponentSlots } from '../slot.js';
+import type { AstroComponentFactory } from './factory.js';
+
+import type { SSRResult } from '../../../../types/public/internal.js';
+import { isPromise } from '../../util.js';
+import { renderChild } from '../any.js';
+import type { RenderDestination } from '../common.js';
+import { isAPropagatingComponent } from './factory.js';
+import { isHeadAndContent } from './head-and-content.js';
+
+type ComponentProps = Record<string | number, any>;
+
+const astroComponentInstanceSym = Symbol.for('astro.componentInstance');
+
+export class AstroComponentInstance {
+ [astroComponentInstanceSym] = true;
+
+ private readonly result: SSRResult;
+ private readonly props: ComponentProps;
+ private readonly slotValues: ComponentSlots;
+ private readonly factory: AstroComponentFactory;
+ private returnValue: ReturnType<AstroComponentFactory> | undefined;
+ constructor(
+ result: SSRResult,
+ props: ComponentProps,
+ slots: ComponentSlots,
+ factory: AstroComponentFactory,
+ ) {
+ this.result = result;
+ this.props = props;
+ this.factory = factory;
+ this.slotValues = {};
+ for (const name in slots) {
+ // prerender the slots eagerly to make collection entries propagate styles and scripts
+ let didRender = false;
+ let value = slots[name](result);
+ this.slotValues[name] = () => {
+ // use prerendered value only once
+ if (!didRender) {
+ didRender = true;
+ return value;
+ }
+ // render afresh for the advanced use-case where the same slot is rendered multiple times
+ return slots[name](result);
+ };
+ }
+ }
+
+ async init(result: SSRResult) {
+ if (this.returnValue !== undefined) return this.returnValue;
+ this.returnValue = this.factory(result, this.props, this.slotValues);
+ // Save the resolved value after promise is resolved for optimization
+ if (isPromise(this.returnValue)) {
+ this.returnValue
+ .then((resolved) => {
+ this.returnValue = resolved;
+ })
+ .catch(() => {
+ // Ignore errors and appease unhandledrejection error
+ });
+ }
+ return this.returnValue;
+ }
+
+ async render(destination: RenderDestination) {
+ const returnValue = await this.init(this.result);
+ if (isHeadAndContent(returnValue)) {
+ await returnValue.content.render(destination);
+ } else {
+ await renderChild(destination, returnValue);
+ }
+ }
+}
+
+// Issue warnings for invalid props for Astro components
+function validateComponentProps(props: any, displayName: string) {
+ if (props != null) {
+ for (const prop of Object.keys(props)) {
+ if (prop.startsWith('client:')) {
+ console.warn(
+ `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`,
+ );
+ }
+ }
+ }
+}
+
+export function createAstroComponentInstance(
+ result: SSRResult,
+ displayName: string,
+ factory: AstroComponentFactory,
+ props: ComponentProps,
+ slots: any = {},
+) {
+ validateComponentProps(props, displayName);
+ const instance = new AstroComponentInstance(result, props, slots, factory);
+ if (isAPropagatingComponent(result, factory)) {
+ result._metadata.propagators.add(instance);
+ }
+ return instance;
+}
+
+export function isAstroComponentInstance(obj: unknown): obj is AstroComponentInstance {
+ return typeof obj === 'object' && obj !== null && !!(obj as any)[astroComponentInstanceSym];
+}
diff --git a/packages/astro/src/runtime/server/render/astro/render-template.ts b/packages/astro/src/runtime/server/render/astro/render-template.ts
new file mode 100644
index 000000000..90d57fe01
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/render-template.ts
@@ -0,0 +1,65 @@
+import { markHTMLString } from '../../escape.js';
+import { isPromise } from '../../util.js';
+import { renderChild } from '../any.js';
+import type { RenderDestination } from '../common.js';
+import { renderToBufferDestination } from '../util.js';
+
+const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
+
+// The return value when rendering a component.
+// This is the result of calling render(), should this be named to RenderResult or...?
+export class RenderTemplateResult {
+ public [renderTemplateResultSym] = true;
+ private htmlParts: TemplateStringsArray;
+ public expressions: any[];
+ private error: Error | undefined;
+ constructor(htmlParts: TemplateStringsArray, expressions: unknown[]) {
+ this.htmlParts = htmlParts;
+ this.error = undefined;
+ this.expressions = expressions.map((expression) => {
+ // Wrap Promise expressions so we can catch errors
+ // There can only be 1 error that we rethrow from an Astro component,
+ // so this keeps track of whether or not we have already done so.
+ if (isPromise(expression)) {
+ return Promise.resolve(expression).catch((err) => {
+ if (!this.error) {
+ this.error = err;
+ throw err;
+ }
+ });
+ }
+ return expression;
+ });
+ }
+
+ async render(destination: RenderDestination) {
+ // Render all expressions eagerly and in parallel
+ const expRenders = this.expressions.map((exp) => {
+ return renderToBufferDestination((bufferDestination) => {
+ // Skip render if falsy, except the number 0
+ if (exp || exp === 0) {
+ return renderChild(bufferDestination, exp);
+ }
+ });
+ });
+
+ for (let i = 0; i < this.htmlParts.length; i++) {
+ const html = this.htmlParts[i];
+ const expRender = expRenders[i];
+
+ destination.write(markHTMLString(html));
+ if (expRender) {
+ await expRender.renderToFinalDestination(destination);
+ }
+ }
+ }
+}
+
+// Determines if a component is an .astro component
+export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResult {
+ return typeof obj === 'object' && obj !== null && !!(obj as any)[renderTemplateResultSym];
+}
+
+export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
+ return new RenderTemplateResult(htmlParts, expressions);
+}
diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts
new file mode 100644
index 000000000..adc335495
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/render.ts
@@ -0,0 +1,341 @@
+import { AstroError, AstroErrorData } from '../../../../core/errors/index.js';
+import type { RouteData, SSRResult } from '../../../../types/public/internal.js';
+import { type RenderDestination, chunkToByteArray, chunkToString, encoder } from '../common.js';
+import { promiseWithResolvers } from '../util.js';
+import type { AstroComponentFactory } from './factory.js';
+import { isHeadAndContent } from './head-and-content.js';
+import { isRenderTemplateResult } from './render-template.js';
+
+const DOCTYPE_EXP = /<!doctype html/i;
+
+// Calls a component and renders it into a string of HTML
+export async function renderToString(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory,
+ props: any,
+ children: any,
+ isPage = false,
+ route?: RouteData,
+): Promise<string | Response> {
+ const templateResult = await callComponentAsTemplateResultOrResponse(
+ result,
+ componentFactory,
+ props,
+ children,
+ route,
+ );
+
+ // If the Astro component returns a Response on init, return that response
+ if (templateResult instanceof Response) return templateResult;
+
+ let str = '';
+ let renderedFirstPageChunk = false;
+
+ if (isPage) {
+ await bufferHeadContent(result);
+ }
+
+ const destination: RenderDestination = {
+ write(chunk) {
+ // Automatic doctype insertion for pages
+ if (isPage && !renderedFirstPageChunk) {
+ renderedFirstPageChunk = true;
+ if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
+ const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
+ str += doctype;
+ }
+ }
+
+ // `renderToString` doesn't work with emitting responses, so ignore here
+ if (chunk instanceof Response) return;
+
+ str += chunkToString(result, chunk);
+ },
+ };
+
+ await templateResult.render(destination);
+
+ return str;
+}
+
+// Calls a component and renders it into a readable stream
+export async function renderToReadableStream(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory,
+ props: any,
+ children: any,
+ isPage = false,
+ route?: RouteData,
+): Promise<ReadableStream | Response> {
+ const templateResult = await callComponentAsTemplateResultOrResponse(
+ result,
+ componentFactory,
+ props,
+ children,
+ route,
+ );
+
+ // If the Astro component returns a Response on init, return that response
+ if (templateResult instanceof Response) return templateResult;
+
+ let renderedFirstPageChunk = false;
+
+ if (isPage) {
+ await bufferHeadContent(result);
+ }
+
+ return new ReadableStream({
+ start(controller) {
+ const destination: RenderDestination = {
+ write(chunk) {
+ // Automatic doctype insertion for pages
+ if (isPage && !renderedFirstPageChunk) {
+ renderedFirstPageChunk = true;
+ if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
+ const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
+ controller.enqueue(encoder.encode(doctype));
+ }
+ }
+
+ // `chunk` might be a Response that contains a redirect,
+ // that was rendered eagerly and therefore bypassed the early check
+ // whether headers can still be modified. In that case, throw an error
+ if (chunk instanceof Response) {
+ throw new AstroError({
+ ...AstroErrorData.ResponseSentError,
+ });
+ }
+
+ const bytes = chunkToByteArray(result, chunk);
+ controller.enqueue(bytes);
+ },
+ };
+
+ (async () => {
+ try {
+ await templateResult.render(destination);
+ controller.close();
+ } catch (e) {
+ // We don't have a lot of information downstream, and upstream we can't catch the error properly
+ // So let's add the location here
+ if (AstroError.is(e) && !e.loc) {
+ e.setLocation({
+ file: route?.component,
+ });
+ }
+
+ // Queue error on next microtask to flush the remaining chunks written synchronously
+ setTimeout(() => controller.error(e), 0);
+ }
+ })();
+ },
+ cancel() {
+ // If the client disconnects,
+ // we signal to ignore the results of existing renders and avoid kicking off more of them.
+ result.cancelled = true;
+ },
+ });
+}
+
+async function callComponentAsTemplateResultOrResponse(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory,
+ props: any,
+ children: any,
+ route?: RouteData,
+) {
+ const factoryResult = await componentFactory(result, props, children);
+
+ if (factoryResult instanceof Response) {
+ return factoryResult;
+ }
+ // we check if the component we attempt to render is a head+content
+ else if (isHeadAndContent(factoryResult)) {
+ // we make sure that content is valid template result
+ if (!isRenderTemplateResult(factoryResult.content)) {
+ throw new AstroError({
+ ...AstroErrorData.OnlyResponseCanBeReturned,
+ message: AstroErrorData.OnlyResponseCanBeReturned.message(
+ route?.route,
+ typeof factoryResult,
+ ),
+ location: {
+ file: route?.component,
+ },
+ });
+ }
+
+ // return the content
+ return factoryResult.content;
+ } else if (!isRenderTemplateResult(factoryResult)) {
+ throw new AstroError({
+ ...AstroErrorData.OnlyResponseCanBeReturned,
+ message: AstroErrorData.OnlyResponseCanBeReturned.message(route?.route, typeof factoryResult),
+ location: {
+ file: route?.component,
+ },
+ });
+ }
+
+ return factoryResult;
+}
+
+// Recursively calls component instances that might have head content
+// to be propagated up.
+async function bufferHeadContent(result: SSRResult) {
+ const iterator = result._metadata.propagators.values();
+ while (true) {
+ const { value, done } = iterator.next();
+ if (done) {
+ break;
+ }
+ // Call component instances that might have head content to be propagated up.
+ const returnValue = await value.init(result);
+ if (isHeadAndContent(returnValue)) {
+ result._metadata.extraHead.push(returnValue.head);
+ }
+ }
+}
+
+export async function renderToAsyncIterable(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory,
+ props: any,
+ children: any,
+ isPage = false,
+ route?: RouteData,
+): Promise<AsyncIterable<Uint8Array> | Response> {
+ const templateResult = await callComponentAsTemplateResultOrResponse(
+ result,
+ componentFactory,
+ props,
+ children,
+ route,
+ );
+ if (templateResult instanceof Response) return templateResult;
+ let renderedFirstPageChunk = false;
+ if (isPage) {
+ await bufferHeadContent(result);
+ }
+
+ // This implements the iterator protocol:
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols
+ // The `iterator` is passed to the Response as a stream-like thing.
+ // The `buffer` array acts like a buffer. During render the `destination` pushes
+ // chunks of Uint8Arrays into the buffer. The response calls `next()` and we combine
+ // all of the chunks into one Uint8Array and then empty it.
+
+ let error: Error | null = null;
+ // The `next` is an object `{ promise, resolve, reject }` that we use to wait
+ // for chunks to be pushed into the buffer.
+ let next: ReturnType<typeof promiseWithResolvers<void>> | null = null;
+ const buffer: Uint8Array[] = []; // []Uint8Array
+ let renderingComplete = false;
+
+ const iterator: AsyncIterator<Uint8Array> = {
+ async next() {
+ if (result.cancelled) return { done: true, value: undefined };
+
+ if (next !== null) {
+ await next.promise;
+ }
+ // Buffer is empty so there's nothing to receive, wait for the next resolve.
+ else if (!renderingComplete && !buffer.length) {
+ next = promiseWithResolvers();
+ await next.promise;
+ }
+
+ // Only create a new promise if rendering is still ongoing. Otherwise
+ // there will be a dangling promises that breaks tests (probably not an actual app)
+ if (!renderingComplete) {
+ next = promiseWithResolvers();
+ }
+
+ // If an error occurs during rendering, throw the error as we cannot proceed.
+ if (error) {
+ throw error;
+ }
+
+ // Get the total length of all arrays.
+ let length = 0;
+ for (let i = 0, len = buffer.length; i < len; i++) {
+ length += buffer[i].length;
+ }
+
+ // Create a new array with total length and merge all source arrays.
+ let mergedArray = new Uint8Array(length);
+ let offset = 0;
+ for (let i = 0, len = buffer.length; i < len; i++) {
+ const item = buffer[i];
+ mergedArray.set(item, offset);
+ offset += item.length;
+ }
+
+ // Empty the array. We do this so that we can reuse the same array.
+ buffer.length = 0;
+
+ const returnValue = {
+ // The iterator is done when rendering has finished
+ // and there are no more chunks to return.
+ done: length === 0 && renderingComplete,
+ value: mergedArray,
+ };
+
+ return returnValue;
+ },
+ async return() {
+ // If the client disconnects,
+ // we signal to the rest of the internals to ignore the results of existing renders and avoid kicking off more of them.
+ result.cancelled = true;
+ return { done: true, value: undefined };
+ },
+ };
+
+ const destination: RenderDestination = {
+ write(chunk) {
+ if (isPage && !renderedFirstPageChunk) {
+ renderedFirstPageChunk = true;
+ if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
+ const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
+ buffer.push(encoder.encode(doctype));
+ }
+ }
+ if (chunk instanceof Response) {
+ throw new AstroError(AstroErrorData.ResponseSentError);
+ }
+ const bytes = chunkToByteArray(result, chunk);
+ // It might be possible that we rendered a chunk with no content, in which
+ // case we don't want to resolve the promise.
+ if (bytes.length > 0) {
+ // Push the chunks into the buffer and resolve the promise so that next()
+ // will run.
+ buffer.push(bytes);
+ next?.resolve();
+ } else if (buffer.length > 0) {
+ next?.resolve();
+ }
+ },
+ };
+
+ const renderPromise = templateResult.render(destination);
+ renderPromise
+ .then(() => {
+ // Once rendering is complete, calling resolve() allows the iterator to finish running.
+ renderingComplete = true;
+ next?.resolve();
+ })
+ .catch((err) => {
+ // If an error occurs, save it in the scope so that we throw it when next() is called.
+ error = err;
+ renderingComplete = true;
+ next?.resolve();
+ });
+
+ // This is the Iterator protocol, an object with a `Symbol.asyncIterator`
+ // function that returns an object like `{ next(): Promise<{ done: boolean; value: any }> }`
+ return {
+ [Symbol.asyncIterator]() {
+ return iterator;
+ },
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts
new file mode 100644
index 000000000..77f05dfcc
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/common.ts
@@ -0,0 +1,146 @@
+import type { RenderInstruction } from './instruction.js';
+
+import type { SSRResult } from '../../../types/public/internal.js';
+import type { HTMLBytes, HTMLString } from '../escape.js';
+import { markHTMLString } from '../escape.js';
+import {
+ type PrescriptType,
+ determineIfNeedsHydrationScript,
+ determinesIfNeedsDirectiveScript,
+ getPrescripts,
+} from '../scripts.js';
+import { renderAllHeadContent } from './head.js';
+import { isRenderInstruction } from './instruction.js';
+import { type SlotString, isSlotString } from './slot.js';
+
+/**
+ * Possible chunk types to be written to the destination, and it'll
+ * handle stringifying them at the end.
+ *
+ * NOTE: Try to reduce adding new types here. If possible, serialize
+ * the custom types to a string in `renderChild` in `any.ts`.
+ */
+export type RenderDestinationChunk =
+ | string
+ | HTMLBytes
+ | HTMLString
+ | SlotString
+ | ArrayBufferView
+ | RenderInstruction
+ | Response;
+
+export interface RenderDestination {
+ /**
+ * Any rendering logic should call this to construct the HTML output.
+ * See the `chunk` parameter for possible writable values.
+ */
+ write(chunk: RenderDestinationChunk): void;
+}
+
+export interface RenderInstance {
+ render: RenderFunction;
+}
+
+export type RenderFunction = (destination: RenderDestination) => Promise<void> | void;
+
+export const Fragment = Symbol.for('astro:fragment');
+export const Renderer = Symbol.for('astro:renderer');
+
+export const encoder = new TextEncoder();
+export const decoder = new TextDecoder();
+
+// Rendering produces either marked strings of HTML or instructions for hydration.
+// These directive instructions bubble all the way up to renderPage so that we
+// can ensure they are added only once, and as soon as possible.
+function stringifyChunk(
+ result: SSRResult,
+ chunk: string | HTMLString | SlotString | RenderInstruction,
+): string {
+ if (isRenderInstruction(chunk)) {
+ const instruction = chunk;
+ switch (instruction.type) {
+ case 'directive': {
+ const { hydration } = instruction;
+ let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
+ let needsDirectiveScript =
+ hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
+
+ let prescriptType: PrescriptType = needsHydrationScript
+ ? 'both'
+ : needsDirectiveScript
+ ? 'directive'
+ : null;
+ if (prescriptType) {
+ let prescripts = getPrescripts(result, prescriptType, hydration.directive);
+ return markHTMLString(prescripts);
+ } else {
+ return '';
+ }
+ }
+ case 'head': {
+ if (result._metadata.hasRenderedHead || result.partial) {
+ return '';
+ }
+ return renderAllHeadContent(result);
+ }
+ case 'maybe-head': {
+ if (result._metadata.hasRenderedHead || result._metadata.headInTree || result.partial) {
+ return '';
+ }
+ return renderAllHeadContent(result);
+ }
+ case 'renderer-hydration-script': {
+ const { rendererSpecificHydrationScripts } = result._metadata;
+ const { rendererName } = instruction;
+
+ if (!rendererSpecificHydrationScripts.has(rendererName)) {
+ rendererSpecificHydrationScripts.add(rendererName);
+ return instruction.render();
+ }
+ return '';
+ }
+ default: {
+ throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
+ }
+ }
+ } else if (chunk instanceof Response) {
+ return '';
+ } else if (isSlotString(chunk as string)) {
+ let out = '';
+ const c = chunk as SlotString;
+ if (c.instructions) {
+ for (const instr of c.instructions) {
+ out += stringifyChunk(result, instr);
+ }
+ }
+ out += chunk.toString();
+ return out;
+ }
+
+ return chunk.toString();
+}
+
+export function chunkToString(result: SSRResult, chunk: Exclude<RenderDestinationChunk, Response>) {
+ if (ArrayBuffer.isView(chunk)) {
+ return decoder.decode(chunk);
+ } else {
+ return stringifyChunk(result, chunk);
+ }
+}
+
+export function chunkToByteArray(
+ result: SSRResult,
+ chunk: Exclude<RenderDestinationChunk, Response>,
+): Uint8Array {
+ if (ArrayBuffer.isView(chunk)) {
+ return chunk as Uint8Array;
+ } else {
+ // `stringifyChunk` might return a HTMLString, call `.toString()` to really ensure it's a string
+ const stringified = stringifyChunk(result, chunk);
+ return encoder.encode(stringified.toString());
+ }
+}
+
+export function isRenderInstance(obj: unknown): obj is RenderInstance {
+ return !!obj && typeof obj === 'object' && 'render' in obj && typeof obj.render === 'function';
+}
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
new file mode 100644
index 000000000..b3979f831
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -0,0 +1,575 @@
+import { createRenderInstruction } from './instruction.js';
+
+import { clsx } from 'clsx';
+import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
+import { markHTMLString } from '../escape.js';
+import { extractDirectives, generateHydrateScript } from '../hydration.js';
+import { serializeProps } from '../serialize.js';
+import { shorthash } from '../shorthash.js';
+import { isPromise } from '../util.js';
+import { type AstroComponentFactory, isAstroComponentFactory } from './astro/factory.js';
+import { renderTemplate } from './astro/index.js';
+import { createAstroComponentInstance } from './astro/instance.js';
+
+import type {
+ AstroComponentMetadata,
+ RouteData,
+ SSRLoadedRenderer,
+ SSRResult,
+} from '../../../types/public/internal.js';
+import {
+ Fragment,
+ type RenderDestination,
+ type RenderInstance,
+ Renderer,
+ chunkToString,
+} from './common.js';
+import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
+import { maybeRenderHead } from './head.js';
+import { containsServerDirective, renderServerIsland } from './server-islands.js';
+import { type ComponentSlots, renderSlotToString, renderSlots } from './slot.js';
+import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
+
+const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
+const rendererAliases = new Map([['solid', 'solid-js']]);
+const clientOnlyValues = new Set(['solid-js', 'react', 'preact', 'vue', 'svelte']);
+
+function guessRenderers(componentUrl?: string): string[] {
+ const extname = componentUrl?.split('.').pop();
+ switch (extname) {
+ case 'svelte':
+ return ['@astrojs/svelte'];
+ case 'vue':
+ return ['@astrojs/vue'];
+ case 'jsx':
+ case 'tsx':
+ return ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js', '@astrojs/vue (jsx)'];
+ case undefined:
+ default:
+ return [
+ '@astrojs/react',
+ '@astrojs/preact',
+ '@astrojs/solid-js',
+ '@astrojs/vue',
+ '@astrojs/svelte',
+ ];
+ }
+}
+
+function isFragmentComponent(Component: unknown) {
+ return Component === Fragment;
+}
+
+function isHTMLComponent(Component: unknown) {
+ return Component && (Component as any)['astro:html'] === true;
+}
+
+const ASTRO_SLOT_EXP = /<\/?astro-slot\b[^>]*>/g;
+const ASTRO_STATIC_SLOT_EXP = /<\/?astro-static-slot\b[^>]*>/g;
+
+function removeStaticAstroSlot(html: string, supportsAstroStaticSlot = true) {
+ const exp = supportsAstroStaticSlot ? ASTRO_STATIC_SLOT_EXP : ASTRO_SLOT_EXP;
+ return html.replace(exp, '');
+}
+
+async function renderFrameworkComponent(
+ result: SSRResult,
+ displayName: string,
+ Component: unknown,
+ _props: Record<string | number, any>,
+ slots: any = {},
+): Promise<RenderInstance> {
+ if (!Component && 'client:only' in _props === false) {
+ throw new Error(
+ `Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`,
+ );
+ }
+
+ const { renderers, clientDirectives } = result;
+ const metadata: AstroComponentMetadata = {
+ astroStaticSlot: true,
+ displayName,
+ };
+
+ const { hydration, isPage, props, propsWithoutTransitionAttributes } = extractDirectives(
+ _props,
+ clientDirectives,
+ );
+ let html = '';
+ let attrs: Record<string, string> | undefined = undefined;
+
+ if (hydration) {
+ metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
+ metadata.hydrateArgs = hydration.value;
+ metadata.componentExport = hydration.componentExport;
+ metadata.componentUrl = hydration.componentUrl;
+ }
+
+ const probableRendererNames = guessRenderers(metadata.componentUrl);
+ const validRenderers = renderers.filter((r) => r.name !== 'astro:jsx');
+ const { children, slotInstructions } = await renderSlots(result, slots);
+
+ // Call the renderers `check` hook to see if any claim this component.
+ let renderer: SSRLoadedRenderer | undefined;
+ if (metadata.hydrate !== 'only') {
+ // If this component ran through `__astro_tag_component__`, we already know
+ // which renderer to match to and can skip the usual `check` calls.
+ // This will help us throw most relevant error message for modules with runtime errors
+ let isTagged = false;
+ try {
+ isTagged = Component && (Component as any)[Renderer];
+ } catch {
+ // Accessing `Component[Renderer]` may throw if `Component` is a Proxy that doesn't
+ // return the actual read-only value. In this case, ignore.
+ }
+ if (isTagged) {
+ const rendererName = (Component as any)[Renderer];
+ renderer = renderers.find(({ name }) => name === rendererName);
+ }
+
+ if (!renderer) {
+ let error;
+ for (const r of renderers) {
+ try {
+ if (await r.ssr.check.call({ result }, Component, props, children)) {
+ renderer = r;
+ break;
+ }
+ } catch (e) {
+ error ??= e;
+ }
+ }
+
+ // If no renderer is found and there is an error, throw that error because
+ // it is likely a problem with the component code.
+ if (!renderer && error) {
+ throw error;
+ }
+ }
+
+ if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) {
+ const output = await renderHTMLElement(
+ result,
+ Component as typeof HTMLElement,
+ _props,
+ slots,
+ );
+ return {
+ render(destination) {
+ destination.write(output);
+ },
+ };
+ }
+ } else {
+ // Attempt: use explicitly passed renderer name
+ if (metadata.hydrateArgs) {
+ const rendererName = rendererAliases.has(metadata.hydrateArgs)
+ ? rendererAliases.get(metadata.hydrateArgs)
+ : metadata.hydrateArgs;
+ if (clientOnlyValues.has(rendererName)) {
+ renderer = renderers.find(
+ ({ name }) => name === `@astrojs/${rendererName}` || name === rendererName,
+ );
+ }
+ }
+ // Attempt: user only has a single renderer, default to that
+ if (!renderer && validRenderers.length === 1) {
+ renderer = validRenderers[0];
+ }
+ // Attempt: can we guess the renderer from the export extension?
+ if (!renderer) {
+ const extname = metadata.componentUrl?.split('.').pop();
+ renderer = renderers.find(({ name }) => name === `@astrojs/${extname}` || name === extname);
+ }
+ }
+
+ let componentServerRenderEndTime;
+ // If no one claimed the renderer
+ if (!renderer) {
+ if (metadata.hydrate === 'only') {
+ const rendererName = rendererAliases.has(metadata.hydrateArgs)
+ ? rendererAliases.get(metadata.hydrateArgs)
+ : metadata.hydrateArgs;
+ if (clientOnlyValues.has(rendererName)) {
+ // throw an error if provide correct client:only directive but not find the renderer
+ const plural = validRenderers.length > 1;
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingRenderer,
+ message: AstroErrorData.NoMatchingRenderer.message(
+ metadata.displayName,
+ metadata?.componentUrl?.split('.').pop(),
+ plural,
+ validRenderers.length,
+ ),
+ hint: AstroErrorData.NoMatchingRenderer.hint(
+ formatList(probableRendererNames.map((r) => '`' + r + '`')),
+ ),
+ });
+ } else {
+ // throw an error if an invalid hydration directive was provided
+ throw new AstroError({
+ ...AstroErrorData.NoClientOnlyHint,
+ message: AstroErrorData.NoClientOnlyHint.message(metadata.displayName),
+ hint: AstroErrorData.NoClientOnlyHint.hint(
+ probableRendererNames.map((r) => r.replace('@astrojs/', '')).join('|'),
+ ),
+ });
+ }
+ } else if (typeof Component !== 'string') {
+ const matchingRenderers = validRenderers.filter((r) =>
+ probableRendererNames.includes(r.name),
+ );
+ const plural = validRenderers.length > 1;
+ if (matchingRenderers.length === 0) {
+ throw new AstroError({
+ ...AstroErrorData.NoMatchingRenderer,
+ message: AstroErrorData.NoMatchingRenderer.message(
+ metadata.displayName,
+ metadata?.componentUrl?.split('.').pop(),
+ plural,
+ validRenderers.length,
+ ),
+ hint: AstroErrorData.NoMatchingRenderer.hint(
+ formatList(probableRendererNames.map((r) => '`' + r + '`')),
+ ),
+ });
+ } else if (matchingRenderers.length === 1) {
+ // We already know that renderer.ssr.check() has failed
+ // but this will throw a much more descriptive error!
+ renderer = matchingRenderers[0];
+ ({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
+ { result },
+ Component,
+ propsWithoutTransitionAttributes,
+ children,
+ metadata,
+ ));
+ } else {
+ throw new Error(`Unable to render ${metadata.displayName}!
+
+This component likely uses ${formatList(probableRendererNames)},
+but Astro encountered an error during server-side rendering.
+
+Please ensure that ${metadata.displayName}:
+1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`.
+ If this is unavoidable, use the \`client:only\` hydration directive.
+2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server.
+
+If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`);
+ }
+ }
+ } else {
+ if (metadata.hydrate === 'only') {
+ html = await renderSlotToString(result, slots?.fallback);
+ } else {
+ const componentRenderStartTime = performance.now();
+ ({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
+ { result },
+ Component,
+ propsWithoutTransitionAttributes,
+ children,
+ metadata,
+ ));
+ if (process.env.NODE_ENV === 'development')
+ componentServerRenderEndTime = performance.now() - componentRenderStartTime;
+ }
+ }
+
+ // This is a custom element without a renderer. Because of that, render it
+ // as a string and the user is responsible for adding a script tag for the component definition.
+ if (!html && typeof Component === 'string') {
+ // Sanitize tag name because some people might try to inject attributes 🙄
+ const Tag = sanitizeElementName(Component);
+ const childSlots = Object.values(children).join('');
+
+ const renderTemplateResult = renderTemplate`<${Tag}${internalSpreadAttributes(
+ props,
+ )}${markHTMLString(
+ childSlots === '' && voidElementNames.test(Tag) ? `/>` : `>${childSlots}</${Tag}>`,
+ )}`;
+
+ html = '';
+ const destination: RenderDestination = {
+ write(chunk) {
+ if (chunk instanceof Response) return;
+ html += chunkToString(result, chunk);
+ },
+ };
+ await renderTemplateResult.render(destination);
+ }
+
+ if (!hydration) {
+ return {
+ render(destination) {
+ // If no hydration is needed, start rendering the html and return
+ if (slotInstructions) {
+ for (const instruction of slotInstructions) {
+ destination.write(instruction);
+ }
+ }
+ if (isPage || renderer?.name === 'astro:jsx') {
+ destination.write(html);
+ } else if (html && html.length > 0) {
+ destination.write(
+ markHTMLString(removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot)),
+ );
+ }
+ },
+ };
+ }
+
+ // Include componentExport name, componentUrl, and props in hash to dedupe identical islands
+ const astroId = shorthash(
+ `<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps(
+ props,
+ metadata,
+ )}`,
+ );
+
+ const island = await generateHydrateScript(
+ { renderer: renderer!, result, astroId, props, attrs },
+ metadata as Required<AstroComponentMetadata>,
+ );
+
+ if (componentServerRenderEndTime && process.env.NODE_ENV === 'development')
+ island.props['server-render-time'] = componentServerRenderEndTime;
+
+ // Render template if not all astro fragments are provided.
+ let unrenderedSlots: string[] = [];
+ if (html) {
+ if (Object.keys(children).length > 0) {
+ for (const key of Object.keys(children)) {
+ let tagName = renderer?.ssr?.supportsAstroStaticSlot
+ ? !!metadata.hydrate
+ ? 'astro-slot'
+ : 'astro-static-slot'
+ : 'astro-slot';
+ let expectedHTML = key === 'default' ? `<${tagName}>` : `<${tagName} name="${key}">`;
+ if (!html.includes(expectedHTML)) {
+ unrenderedSlots.push(key);
+ }
+ }
+ }
+ } else {
+ unrenderedSlots = Object.keys(children);
+ }
+ const template =
+ unrenderedSlots.length > 0
+ ? unrenderedSlots
+ .map(
+ (key) =>
+ `<template data-astro-template${key !== 'default' ? `="${key}"` : ''}>${
+ children[key]
+ }</template>`,
+ )
+ .join('')
+ : '';
+
+ island.children = `${html ?? ''}${template}`;
+
+ if (island.children) {
+ island.props['await-children'] = '';
+ // Marker to signal that Astro island children is completed while streaming
+ island.children += `<!--astro:end-->`;
+ }
+
+ return {
+ render(destination) {
+ // Render the html
+ if (slotInstructions) {
+ for (const instruction of slotInstructions) {
+ destination.write(instruction);
+ }
+ }
+ destination.write(createRenderInstruction({ type: 'directive', hydration }));
+ if (hydration.directive !== 'only' && renderer?.ssr.renderHydrationScript) {
+ destination.write(
+ createRenderInstruction({
+ type: 'renderer-hydration-script',
+ rendererName: renderer.name,
+ render: renderer.ssr.renderHydrationScript,
+ }),
+ );
+ }
+ const renderedElement = renderElement('astro-island', island, false);
+ destination.write(markHTMLString(renderedElement));
+ },
+ };
+}
+
+function sanitizeElementName(tag: string) {
+ const unsafe = /[&<>'"\s]+/;
+ if (!unsafe.test(tag)) return tag;
+ return tag.trim().split(unsafe)[0].trim();
+}
+
+async function renderFragmentComponent(
+ result: SSRResult,
+ slots: ComponentSlots = {},
+): Promise<RenderInstance> {
+ const children = await renderSlotToString(result, slots?.default);
+ return {
+ render(destination) {
+ if (children == null) return;
+ destination.write(children);
+ },
+ };
+}
+
+async function renderHTMLComponent(
+ result: SSRResult,
+ Component: unknown,
+ _props: Record<string | number, any>,
+ slots: any = {},
+): Promise<RenderInstance> {
+ const { slotInstructions, children } = await renderSlots(result, slots);
+ const html = (Component as any)({ slots: children });
+ const hydrationHtml = slotInstructions
+ ? slotInstructions.map((instr) => chunkToString(result, instr)).join('')
+ : '';
+ return {
+ render(destination) {
+ destination.write(markHTMLString(hydrationHtml + html));
+ },
+ };
+}
+
+function renderAstroComponent(
+ result: SSRResult,
+ displayName: string,
+ Component: AstroComponentFactory,
+ props: Record<string | number, any>,
+ slots: any = {},
+): RenderInstance {
+ if (containsServerDirective(props)) {
+ return renderServerIsland(result, displayName, props, slots);
+ }
+
+ const instance = createAstroComponentInstance(result, displayName, Component, props, slots);
+ return {
+ async render(destination) {
+ // NOTE: This render call can't be pre-invoked outside of this function as it'll also initialize the slots
+ // recursively, which causes each Astro components in the tree to be called bottom-up, and is incorrect.
+ // The slots are initialized eagerly for head propagation.
+ await instance.render(destination);
+ },
+ };
+}
+
+export async function renderComponent(
+ result: SSRResult,
+ displayName: string,
+ Component: unknown,
+ props: Record<string | number, any>,
+ slots: ComponentSlots = {},
+): Promise<RenderInstance> {
+ if (isPromise(Component)) {
+ Component = await Component.catch(handleCancellation);
+ }
+
+ if (isFragmentComponent(Component)) {
+ return await renderFragmentComponent(result, slots).catch(handleCancellation);
+ }
+
+ // Ensure directives (`class:list`) are processed
+ props = normalizeProps(props);
+
+ // .html components
+ if (isHTMLComponent(Component)) {
+ return await renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
+ }
+
+ if (isAstroComponentFactory(Component)) {
+ return renderAstroComponent(result, displayName, Component, props, slots);
+ }
+
+ return await renderFrameworkComponent(result, displayName, Component, props, slots).catch(
+ handleCancellation,
+ );
+
+ function handleCancellation(e: unknown) {
+ if (result.cancelled)
+ return {
+ render() {},
+ };
+ throw e;
+ }
+}
+
+function normalizeProps(props: Record<string, any>): Record<string, any> {
+ if (props['class:list'] !== undefined) {
+ const value = props['class:list'];
+ delete props['class:list'];
+ props['class'] = clsx(props['class'], value);
+ if (props['class'] === '') {
+ delete props['class'];
+ }
+ }
+ return props;
+}
+
+export async function renderComponentToString(
+ result: SSRResult,
+ displayName: string,
+ Component: unknown,
+ props: Record<string | number, any>,
+ slots: any = {},
+ isPage = false,
+ route?: RouteData,
+): Promise<string> {
+ let str = '';
+ let renderedFirstPageChunk = false;
+
+ // Handle head injection if required. Note that this needs to run early so
+ // we can ensure getting a value for `head`.
+ let head = '';
+ if (isPage && !result.partial && nonAstroPageNeedsHeadInjection(Component)) {
+ head += chunkToString(result, maybeRenderHead());
+ }
+
+ try {
+ const destination: RenderDestination = {
+ write(chunk) {
+ // Automatic doctype and head insertion for pages
+ if (isPage && !result.partial && !renderedFirstPageChunk) {
+ renderedFirstPageChunk = true;
+ if (!/<!doctype html/i.test(String(chunk))) {
+ const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
+ str += doctype + head;
+ }
+ }
+
+ // `renderToString` doesn't work with emitting responses, so ignore here
+ if (chunk instanceof Response) return;
+
+ str += chunkToString(result, chunk);
+ },
+ };
+
+ const renderInstance = await renderComponent(result, displayName, Component, props, slots);
+ await renderInstance.render(destination);
+ } catch (e) {
+ // We don't have a lot of information downstream, and upstream we can't catch the error properly
+ // So let's add the location here
+ if (AstroError.is(e) && !e.loc) {
+ e.setLocation({
+ file: route?.component,
+ });
+ }
+
+ throw e;
+ }
+
+ return str;
+}
+
+export type NonAstroPageComponent = {
+ name: string;
+ [needsHeadRenderingSymbol]: boolean;
+};
+
+function nonAstroPageNeedsHeadInjection(
+ pageComponent: any,
+): pageComponent is NonAstroPageComponent {
+ return !!pageComponent?.[needsHeadRenderingSymbol];
+}
diff --git a/packages/astro/src/runtime/server/render/dom.ts b/packages/astro/src/runtime/server/render/dom.ts
new file mode 100644
index 000000000..f92b4889a
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/dom.ts
@@ -0,0 +1,41 @@
+import type { SSRResult } from '../../../types/public/internal.js';
+import { markHTMLString } from '../escape.js';
+import { renderSlotToString } from './slot.js';
+import { toAttributeString } from './util.js';
+
+export function componentIsHTMLElement(Component: unknown) {
+ return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object);
+}
+
+export async function renderHTMLElement(
+ result: SSRResult,
+ constructor: typeof HTMLElement,
+ props: any,
+ slots: any,
+): Promise<string> {
+ const name = getHTMLElementName(constructor);
+
+ let attrHTML = '';
+
+ for (const attr in props) {
+ attrHTML += ` ${attr}="${toAttributeString(await props[attr])}"`;
+ }
+
+ return markHTMLString(
+ `<${name}${attrHTML}>${await renderSlotToString(result, slots?.default)}</${name}>`,
+ );
+}
+
+function getHTMLElementName(constructor: typeof HTMLElement) {
+ const definedName = (
+ customElements as CustomElementRegistry & { getName(_constructor: typeof HTMLElement): string }
+ ).getName(constructor);
+ if (definedName) return definedName;
+
+ const assignedName = constructor.name
+ .replace(/^HTML|Element$/g, '')
+ .replace(/[A-Z]/g, '-$&')
+ .toLowerCase()
+ .replace(/^-/, 'html-');
+ return assignedName;
+}
diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts
new file mode 100644
index 000000000..01f11f21e
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/head.ts
@@ -0,0 +1,65 @@
+import type { SSRResult } from '../../../types/public/internal.js';
+import { markHTMLString } from '../escape.js';
+import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instruction.js';
+import { createRenderInstruction } from './instruction.js';
+import { renderElement } from './util.js';
+
+// Filter out duplicate elements in our set
+const uniqueElements = (item: any, index: number, all: any[]) => {
+ const props = JSON.stringify(item.props);
+ const children = item.children;
+ return (
+ index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children)
+ );
+};
+
+export function renderAllHeadContent(result: SSRResult) {
+ result._metadata.hasRenderedHead = true;
+ const styles = Array.from(result.styles)
+ .filter(uniqueElements)
+ .map((style) =>
+ style.props.rel === 'stylesheet'
+ ? renderElement('link', style)
+ : renderElement('style', style),
+ );
+ // Clear result.styles so that any new styles added will be inlined.
+ result.styles.clear();
+ const scripts = Array.from(result.scripts)
+ .filter(uniqueElements)
+ .map((script) => {
+ return renderElement('script', script, false);
+ });
+ const links = Array.from(result.links)
+ .filter(uniqueElements)
+ .map((link) => renderElement('link', link, false));
+
+ // Order styles -> links -> scripts similar to src/content/runtime.ts
+ // The order is usually fine as the ordering between these groups are mutually exclusive,
+ // except for CSS styles and CSS stylesheet links. However CSS stylesheet links usually
+ // consist of CSS modules which should naturally take precedence over CSS styles, so the
+ // order will still work. In prod, all CSS are stylesheet links.
+ // In the future, it may be better to have only an array of head elements to avoid these assumptions.
+ let content = styles.join('\n') + links.join('\n') + scripts.join('\n');
+
+ if (result._metadata.extraHead.length > 0) {
+ for (const part of result._metadata.extraHead) {
+ content += part;
+ }
+ }
+
+ return markHTMLString(content);
+}
+
+export function renderHead(): RenderHeadInstruction {
+ return createRenderInstruction({ type: 'head' });
+}
+
+// This function is called by Astro components that do not contain a <head> component
+// This accommodates the fact that using a <head> is optional in Astro, so this
+// is called before a component's first non-head HTML element. If the head was
+// already injected it is a noop.
+export function maybeRenderHead(): MaybeRenderHeadInstruction {
+ // This is an instruction informing the page rendering that head might need rendering.
+ // This allows the page to deduplicate head injections.
+ return createRenderInstruction({ type: 'maybe-head' });
+}
diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts
new file mode 100644
index 000000000..c996a3fa5
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/index.ts
@@ -0,0 +1,12 @@
+export { createHeadAndContent, renderTemplate, renderToString } from './astro/index.js';
+export type { AstroComponentFactory, AstroComponentInstance } from './astro/index.js';
+export { Fragment, Renderer, chunkToByteArray, chunkToString } from './common.js';
+export { renderComponent, renderComponentToString } from './component.js';
+export { renderScript } from './script.js';
+export { renderHTMLElement } from './dom.js';
+export { maybeRenderHead, renderHead } from './head.js';
+export type { RenderInstruction } from './instruction.js';
+export { renderPage } from './page.js';
+export { renderSlot, renderSlotToString, type ComponentSlots } from './slot.js';
+export { renderScriptElement, renderUniqueStylesheet } from './tags.js';
+export { addAttribute, defineScriptVars, voidElementNames } from './util.js';
diff --git a/packages/astro/src/runtime/server/render/instruction.ts b/packages/astro/src/runtime/server/render/instruction.ts
new file mode 100644
index 000000000..c1761312f
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/instruction.ts
@@ -0,0 +1,52 @@
+import type { HydrationMetadata } from '../hydration.js';
+
+const RenderInstructionSymbol = Symbol.for('astro:render');
+
+export type RenderDirectiveInstruction = {
+ type: 'directive';
+ hydration: HydrationMetadata;
+};
+
+export type RenderHeadInstruction = {
+ type: 'head';
+};
+
+/**
+ * Render a renderer-specific hydration script before the first component of that
+ * framework
+ */
+export type RendererHydrationScriptInstruction = {
+ type: 'renderer-hydration-script';
+ rendererName: string;
+ render: () => string;
+};
+
+export type MaybeRenderHeadInstruction = {
+ type: 'maybe-head';
+};
+
+export type RenderInstruction =
+ | RenderDirectiveInstruction
+ | RenderHeadInstruction
+ | MaybeRenderHeadInstruction
+ | RendererHydrationScriptInstruction;
+
+export function createRenderInstruction(
+ instruction: RenderDirectiveInstruction,
+): RenderDirectiveInstruction;
+export function createRenderInstruction(
+ instruction: RendererHydrationScriptInstruction,
+): RendererHydrationScriptInstruction;
+export function createRenderInstruction(instruction: RenderHeadInstruction): RenderHeadInstruction;
+export function createRenderInstruction(
+ instruction: MaybeRenderHeadInstruction,
+): MaybeRenderHeadInstruction;
+export function createRenderInstruction(instruction: { type: string }): RenderInstruction {
+ return Object.defineProperty(instruction as RenderInstruction, RenderInstructionSymbol, {
+ value: true,
+ });
+}
+
+export function isRenderInstruction(chunk: any): chunk is RenderInstruction {
+ return chunk && typeof chunk === 'object' && chunk[RenderInstructionSymbol];
+}
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
new file mode 100644
index 000000000..5ac05f741
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -0,0 +1,95 @@
+import { type NonAstroPageComponent, renderComponentToString } from './component.js';
+import type { AstroComponentFactory } from './index.js';
+
+import type { RouteData, SSRResult } from '../../../types/public/internal.js';
+import { isAstroComponentFactory } from './astro/index.js';
+import { renderToAsyncIterable, renderToReadableStream, renderToString } from './astro/render.js';
+import { encoder } from './common.js';
+import { isDeno, isNode } from './util.js';
+
+export async function renderPage(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory | NonAstroPageComponent,
+ props: any,
+ children: any,
+ streaming: boolean,
+ route?: RouteData,
+): Promise<Response> {
+ if (!isAstroComponentFactory(componentFactory)) {
+ result._metadata.headInTree =
+ result.componentMetadata.get((componentFactory as any).moduleId)?.containsHead ?? false;
+
+ const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
+
+ const str = await renderComponentToString(
+ result,
+ componentFactory.name,
+ componentFactory,
+ pageProps,
+ {},
+ true,
+ route,
+ );
+
+ const bytes = encoder.encode(str);
+
+ return new Response(bytes, {
+ headers: new Headers([
+ ['Content-Type', 'text/html'],
+ ['Content-Length', bytes.byteLength.toString()],
+ ]),
+ });
+ }
+
+ // Mark if this page component contains a <head> within its tree. If it does
+ // We avoid implicit head injection entirely.
+ result._metadata.headInTree =
+ result.componentMetadata.get(componentFactory.moduleId!)?.containsHead ?? false;
+
+ let body: BodyInit | Response;
+ if (streaming) {
+ // isNode is true in Deno node-compat mode but response construction from
+ // async iterables is not supported, so we fallback to ReadableStream if isDeno is true.
+ if (isNode && !isDeno) {
+ const nodeBody = await renderToAsyncIterable(
+ result,
+ componentFactory,
+ props,
+ children,
+ true,
+ route,
+ );
+ // Node.js allows passing in an AsyncIterable to the Response constructor.
+ // This is non-standard so using `any` here to preserve types everywhere else.
+ body = nodeBody as any;
+ } else {
+ body = await renderToReadableStream(result, componentFactory, props, children, true, route);
+ }
+ } else {
+ body = await renderToString(result, componentFactory, props, children, true, route);
+ }
+
+ // If the Astro component returns a Response on init, return that response
+ if (body instanceof Response) return body;
+
+ // Create final response from body
+ const init = result.response;
+ const headers = new Headers(init.headers);
+ // For non-streaming, convert string to byte array to calculate Content-Length
+ if (!streaming && typeof body === 'string') {
+ body = encoder.encode(body);
+ headers.set('Content-Length', body.byteLength.toString());
+ }
+ let status = init.status;
+ // Custom 404.astro and 500.astro are particular routes that must return a fixed status code
+ if (route?.route === '/404') {
+ status = 404;
+ } else if (route?.route === '/500') {
+ status = 500;
+ }
+ if (status) {
+ return new Response(body, { ...init, headers, status });
+ } else {
+ return new Response(body, { ...init, headers });
+ }
+}
diff --git a/packages/astro/src/runtime/server/render/script.ts b/packages/astro/src/runtime/server/render/script.ts
new file mode 100644
index 000000000..6d9283790
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/script.ts
@@ -0,0 +1,24 @@
+import type { SSRResult } from '../../../types/public/internal.js';
+import { markHTMLString } from '../escape.js';
+
+/**
+ * Relies on the `renderScript: true` compiler option
+ * @experimental
+ */
+export async function renderScript(result: SSRResult, id: string) {
+ if (result._metadata.renderedScripts.has(id)) return;
+ result._metadata.renderedScripts.add(id);
+
+ const inlined = result.inlinedScripts.get(id);
+ if (inlined != null) {
+ // The inlined script may actually be empty, so skip rendering it altogether if so
+ if (inlined) {
+ return markHTMLString(`<script type="module">${inlined}</script>`);
+ } else {
+ return '';
+ }
+ }
+
+ const resolved = await result.resolve(id);
+ return markHTMLString(`<script type="module" src="${resolved}"></script>`);
+}
diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts
new file mode 100644
index 000000000..093254cd3
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/server-islands.ts
@@ -0,0 +1,152 @@
+import { encryptString } from '../../../core/encryption.js';
+import type { SSRResult } from '../../../types/public/internal.js';
+import { renderChild } from './any.js';
+import type { RenderInstance } from './common.js';
+import { type ComponentSlots, renderSlotToString } from './slot.js';
+
+const internalProps = new Set([
+ 'server:component-path',
+ 'server:component-export',
+ 'server:component-directive',
+ 'server:defer',
+]);
+
+export function containsServerDirective(props: Record<string | number, any>) {
+ return 'server:component-directive' in props;
+}
+
+const SCRIPT_RE = /<\/script/giu;
+const COMMENT_RE = /<!--/gu;
+const SCRIPT_REPLACER = '<\\/script';
+const COMMENT_REPLACER = '\\u003C!--';
+
+/**
+ * Encodes the script end-tag open (ETAGO) delimiter and opening HTML comment syntax for JSON inside a `<script>` tag.
+ * @see https://mathiasbynens.be/notes/etago
+ */
+function safeJsonStringify(obj: any) {
+ return JSON.stringify(obj)
+ .replace(SCRIPT_RE, SCRIPT_REPLACER)
+ .replace(COMMENT_RE, COMMENT_REPLACER);
+}
+
+function createSearchParams(componentExport: string, encryptedProps: string, slots: string) {
+ const params = new URLSearchParams();
+ params.set('e', componentExport);
+ params.set('p', encryptedProps);
+ params.set('s', slots);
+ return params;
+}
+
+function isWithinURLLimit(pathname: string, params: URLSearchParams) {
+ const url = pathname + '?' + params.toString();
+ const chars = url.length;
+ // https://chromium.googlesource.com/chromium/src/+/master/docs/security/url_display_guidelines/url_display_guidelines.md#url-length
+ return chars < 2048;
+}
+
+export function renderServerIsland(
+ result: SSRResult,
+ _displayName: string,
+ props: Record<string | number, any>,
+ slots: ComponentSlots,
+): RenderInstance {
+ return {
+ async render(destination) {
+ const componentPath = props['server:component-path'];
+ const componentExport = props['server:component-export'];
+ const componentId = result.serverIslandNameMap.get(componentPath);
+
+ if (!componentId) {
+ throw new Error(`Could not find server component name`);
+ }
+
+ // Remove internal props
+ for (const key of Object.keys(props)) {
+ if (internalProps.has(key)) {
+ delete props[key];
+ }
+ }
+
+ destination.write('<!--[if astro]>server-island-start<![endif]-->');
+
+ // Render the slots
+ const renderedSlots: Record<string, string> = {};
+ for (const name in slots) {
+ if (name !== 'fallback') {
+ const content = await renderSlotToString(result, slots[name]);
+ renderedSlots[name] = content.toString();
+ } else {
+ await renderChild(destination, slots.fallback(result));
+ }
+ }
+
+ const key = await result.key;
+ const propsEncrypted =
+ Object.keys(props).length === 0 ? '' : await encryptString(key, JSON.stringify(props));
+
+ const hostId = crypto.randomUUID();
+
+ const slash = result.base.endsWith('/') ? '' : '/';
+ let serverIslandUrl = `${result.base}${slash}_server-islands/${componentId}${result.trailingSlash === 'always' ? '/' : ''}`;
+
+ // Determine if its safe to use a GET request
+ const potentialSearchParams = createSearchParams(
+ componentExport,
+ propsEncrypted,
+ safeJsonStringify(renderedSlots),
+ );
+ const useGETRequest = isWithinURLLimit(serverIslandUrl, potentialSearchParams);
+
+ if (useGETRequest) {
+ serverIslandUrl += '?' + potentialSearchParams.toString();
+ destination.write(
+ `<link rel="preload" as="fetch" href="${serverIslandUrl}" crossorigin="anonymous">`,
+ );
+ }
+
+ destination.write(`<script async type="module" data-island-id="${hostId}">
+let script = document.querySelector('script[data-island-id="${hostId}"]');
+
+${
+ useGETRequest
+ ? // GET request
+ `let response = await fetch('${serverIslandUrl}');
+`
+ : // POST request
+ `let data = {
+ componentExport: ${safeJsonStringify(componentExport)},
+ encryptedProps: ${safeJsonStringify(propsEncrypted)},
+ slots: ${safeJsonStringify(renderedSlots)},
+};
+
+let response = await fetch('${serverIslandUrl}', {
+ method: 'POST',
+ body: JSON.stringify(data),
+});
+`
+}
+if (script) {
+ if(
+ response.status === 200
+ && response.headers.has('content-type')
+ && response.headers.get('content-type').split(";")[0].trim() === 'text/html') {
+ let html = await response.text();
+
+ // Swap!
+ while(script.previousSibling &&
+ script.previousSibling.nodeType !== 8 &&
+ script.previousSibling.data !== '[if astro]>server-island-start<![endif]') {
+ script.previousSibling.remove();
+ }
+ script.previousSibling?.remove();
+
+ let frag = document.createRange().createContextualFragment(html);
+ script.before(frag);
+ }
+ script.remove();
+}
+</script>`);
+ },
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/slot.ts b/packages/astro/src/runtime/server/render/slot.ts
new file mode 100644
index 000000000..0df374406
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/slot.ts
@@ -0,0 +1,111 @@
+import { renderTemplate } from './astro/render-template.js';
+import type { RenderInstruction } from './instruction.js';
+
+import type { SSRResult } from '../../../types/public/internal.js';
+import { HTMLString, markHTMLString, unescapeHTML } from '../escape.js';
+import { renderChild } from './any.js';
+import { type RenderDestination, type RenderInstance, chunkToString } from './common.js';
+
+type RenderTemplateResult = ReturnType<typeof renderTemplate>;
+export type ComponentSlots = Record<string, ComponentSlotValue>;
+export type ComponentSlotValue = (
+ result: SSRResult,
+) => RenderTemplateResult | Promise<RenderTemplateResult>;
+
+const slotString = Symbol.for('astro:slot-string');
+
+export class SlotString extends HTMLString {
+ public instructions: null | RenderInstruction[];
+ public [slotString]: boolean;
+ constructor(content: string, instructions: null | RenderInstruction[]) {
+ super(content);
+ this.instructions = instructions;
+ this[slotString] = true;
+ }
+}
+
+export function isSlotString(str: string): str is any {
+ return !!(str as any)[slotString];
+}
+
+export function renderSlot(
+ result: SSRResult,
+ slotted: ComponentSlotValue | RenderTemplateResult,
+ fallback?: ComponentSlotValue | RenderTemplateResult,
+): RenderInstance {
+ if (!slotted && fallback) {
+ return renderSlot(result, fallback);
+ }
+ return {
+ async render(destination) {
+ await renderChild(destination, typeof slotted === 'function' ? slotted(result) : slotted);
+ },
+ };
+}
+
+export async function renderSlotToString(
+ result: SSRResult,
+ slotted: ComponentSlotValue | RenderTemplateResult,
+ fallback?: ComponentSlotValue | RenderTemplateResult,
+): Promise<string> {
+ let content = '';
+ let instructions: null | RenderInstruction[] = null;
+ const temporaryDestination: RenderDestination = {
+ write(chunk) {
+ // if the chunk is already a SlotString, we concatenate
+ if (chunk instanceof SlotString) {
+ content += chunk;
+ if (chunk.instructions) {
+ instructions ??= [];
+ instructions.push(...chunk.instructions);
+ }
+ } else if (chunk instanceof Response) return;
+ else if (typeof chunk === 'object' && 'type' in chunk && typeof chunk.type === 'string') {
+ if (instructions === null) {
+ instructions = [];
+ }
+ instructions.push(chunk);
+ } else {
+ content += chunkToString(result, chunk);
+ }
+ },
+ };
+ const renderInstance = renderSlot(result, slotted, fallback);
+ await renderInstance.render(temporaryDestination);
+ return markHTMLString(new SlotString(content, instructions));
+}
+
+interface RenderSlotsResult {
+ slotInstructions: null | RenderInstruction[];
+ children: Record<string, string>;
+}
+
+export async function renderSlots(
+ result: SSRResult,
+ slots: ComponentSlots = {},
+): Promise<RenderSlotsResult> {
+ let slotInstructions: RenderSlotsResult['slotInstructions'] = null;
+ let children: RenderSlotsResult['children'] = {};
+ if (slots) {
+ await Promise.all(
+ Object.entries(slots).map(([key, value]) =>
+ renderSlotToString(result, value).then((output: any) => {
+ if (output.instructions) {
+ if (slotInstructions === null) {
+ slotInstructions = [];
+ }
+ slotInstructions.push(...output.instructions);
+ }
+ children[key] = output;
+ }),
+ ),
+ );
+ }
+ return { slotInstructions, children };
+}
+
+export function createSlotValueFromString(content: string): ComponentSlotValue {
+ return function () {
+ return renderTemplate`${unescapeHTML(content)}`;
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/tags.ts b/packages/astro/src/runtime/server/render/tags.ts
new file mode 100644
index 000000000..baba11a5b
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/tags.ts
@@ -0,0 +1,22 @@
+import type { StylesheetAsset } from '../../../core/app/types.js';
+import type { SSRElement, SSRResult } from '../../../types/public/internal.js';
+import { renderElement } from './util.js';
+
+export function renderScriptElement({ props, children }: SSRElement) {
+ return renderElement('script', {
+ props,
+ children,
+ });
+}
+
+export function renderUniqueStylesheet(result: SSRResult, sheet: StylesheetAsset) {
+ if (sheet.type === 'external') {
+ if (Array.from(result.styles).some((s) => s.props.href === sheet.src)) return '';
+ return renderElement('link', { props: { rel: 'stylesheet', href: sheet.src }, children: '' });
+ }
+
+ if (sheet.type === 'inline') {
+ if (Array.from(result.styles).some((s) => s.children.includes(sheet.content))) return '';
+ return renderElement('style', { props: {}, children: sheet.content });
+ }
+}
diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts
new file mode 100644
index 000000000..d693ad070
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/util.ts
@@ -0,0 +1,258 @@
+import type { RenderDestination, RenderDestinationChunk, RenderFunction } from './common.js';
+
+import { clsx } from 'clsx';
+import type { SSRElement } from '../../../types/public/internal.js';
+import { HTMLString, markHTMLString } from '../escape.js';
+
+export const voidElementNames =
+ /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
+const htmlBooleanAttributes =
+ /^(?:allowfullscreen|async|autofocus|autoplay|checked|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|inert|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|selected|itemscope)$/i;
+
+const AMPERSAND_REGEX = /&/g;
+const DOUBLE_QUOTE_REGEX = /"/g;
+
+const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);
+
+// converts (most) arbitrary strings to valid JS identifiers
+const toIdent = (k: string) =>
+ k.trim().replace(/(?!^)\b\w|\s+|\W+/g, (match, index) => {
+ if (/\W/.test(match)) return '';
+ return index === 0 ? match : match.toUpperCase();
+ });
+
+export const toAttributeString = (value: any, shouldEscape = true) =>
+ shouldEscape
+ ? String(value).replace(AMPERSAND_REGEX, '&#38;').replace(DOUBLE_QUOTE_REGEX, '&#34;')
+ : value;
+
+const kebab = (k: string) =>
+ k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
+
+export const toStyleString = (obj: Record<string, any>) =>
+ Object.entries(obj)
+ .filter(([_, v]) => (typeof v === 'string' && v.trim()) || typeof v === 'number')
+ .map(([k, v]) => {
+ if (k[0] !== '-' && k[1] !== '-') return `${kebab(k)}:${v}`;
+ return `${k}:${v}`;
+ })
+ .join(';');
+
+// Adds variables to an inline script.
+export function defineScriptVars(vars: Record<any, any>) {
+ let output = '';
+ for (const [key, value] of Object.entries(vars)) {
+ // Use const instead of let as let global unsupported with Safari
+ // https://stackoverflow.com/questions/29194024/cant-use-let-keyword-in-safari-javascript
+ output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
+ /<\/script>/g,
+ '\\x3C/script>',
+ )};\n`;
+ }
+ return markHTMLString(output);
+}
+
+export function formatList(values: string[]): string {
+ if (values.length === 1) {
+ return values[0];
+ }
+ return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
+}
+
+// A helper used to turn expressions into attribute key/value
+export function addAttribute(value: any, key: string, shouldEscape = true) {
+ if (value == null) {
+ 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.
+
+Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the dynamic spread syntax (\`{...{ "${key}": value }}\`).`);
+ return '';
+ }
+
+ // support "class" from an expression passed into an element (#782)
+ if (key === 'class:list') {
+ const listValue = toAttributeString(clsx(value), shouldEscape);
+ if (listValue === '') {
+ return '';
+ }
+ return markHTMLString(` ${key.slice(0, -5)}="${listValue}"`);
+ }
+
+ // support object styles for better JSX compat
+ if (key === 'style' && !(value instanceof HTMLString)) {
+ if (Array.isArray(value) && value.length === 2) {
+ return markHTMLString(
+ ` ${key}="${toAttributeString(`${toStyleString(value[0])};${value[1]}`, shouldEscape)}"`,
+ );
+ }
+ if (typeof value === 'object') {
+ return markHTMLString(` ${key}="${toAttributeString(toStyleString(value), shouldEscape)}"`);
+ }
+ }
+
+ // support `className` for better JSX compat
+ if (key === 'className') {
+ return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`);
+ }
+
+ // Prevents URLs in attributes from being escaped in static builds
+ if (typeof value === 'string' && value.includes('&') && isHttpUrl(value)) {
+ return markHTMLString(` ${key}="${toAttributeString(value, false)}"`);
+ }
+
+ // Boolean values only need the key
+ if (htmlBooleanAttributes.test(key)) {
+ return markHTMLString(value ? ` ${key}` : '');
+ }
+
+ // Other attributes with an empty string value can omit rendering the value
+ if (value === '') {
+ return markHTMLString(` ${key}`);
+ }
+
+ return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
+}
+
+// Adds support for `<Component {...value} />
+export function internalSpreadAttributes(values: Record<any, any>, shouldEscape = true) {
+ let output = '';
+ for (const [key, value] of Object.entries(values)) {
+ output += addAttribute(value, key, shouldEscape);
+ }
+ return markHTMLString(output);
+}
+
+export function renderElement(
+ name: string,
+ { props: _props, children = '' }: SSRElement,
+ shouldEscape = true,
+) {
+ // Do not print `hoist`, `lang`, `is:global`
+ const { lang: _, 'data-astro-id': astroId, 'define:vars': defineVars, ...props } = _props;
+ if (defineVars) {
+ if (name === 'style') {
+ delete props['is:global'];
+ delete props['is:scoped'];
+ }
+ if (name === 'script') {
+ delete props.hoist;
+ children = defineScriptVars(defineVars) + '\n' + children;
+ }
+ }
+ if ((children == null || children == '') && voidElementNames.test(name)) {
+ return `<${name}${internalSpreadAttributes(props, shouldEscape)}>`;
+ }
+ return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`;
+}
+
+const noop = () => {};
+
+/**
+ * Renders into a buffer until `renderToFinalDestination` is called (which
+ * flushes the buffer)
+ */
+class BufferedRenderer implements RenderDestination {
+ private chunks: RenderDestinationChunk[] = [];
+ private renderPromise: Promise<void> | void;
+ private destination?: RenderDestination;
+
+ public constructor(bufferRenderFunction: RenderFunction) {
+ this.renderPromise = bufferRenderFunction(this);
+ // Catch here in case it throws before `renderToFinalDestination` is called,
+ // to prevent an unhandled rejection.
+ Promise.resolve(this.renderPromise).catch(noop);
+ }
+
+ public write(chunk: RenderDestinationChunk): void {
+ if (this.destination) {
+ this.destination.write(chunk);
+ } else {
+ this.chunks.push(chunk);
+ }
+ }
+
+ public async renderToFinalDestination(destination: RenderDestination) {
+ // Write the buffered chunks to the real destination
+ for (const chunk of this.chunks) {
+ destination.write(chunk);
+ }
+
+ // NOTE: We don't empty `this.chunks` after it's written as benchmarks show
+ // that it causes poorer performance, likely due to forced memory re-allocation,
+ // instead of letting the garbage collector handle it automatically.
+ // (Unsure how this affects on limited memory machines)
+
+ // Re-assign the real destination so `instance.render` will continue and write to the new destination
+ this.destination = destination;
+
+ // Wait for render to finish entirely
+ await this.renderPromise;
+ }
+}
+
+/**
+ * Executes the `bufferRenderFunction` to prerender it into a buffer destination, and return a promise
+ * with an object containing the `renderToFinalDestination` function to flush the buffer to the final
+ * destination.
+ *
+ * @example
+ * ```ts
+ * // Render components in parallel ahead of time
+ * const finalRenders = [ComponentA, ComponentB].map((comp) => {
+ * return renderToBufferDestination(async (bufferDestination) => {
+ * await renderComponentToDestination(bufferDestination);
+ * });
+ * });
+ * // Render array of components serially
+ * for (const finalRender of finalRenders) {
+ * await finalRender.renderToFinalDestination(finalDestination);
+ * }
+ * ```
+ */
+export function renderToBufferDestination(bufferRenderFunction: RenderFunction): {
+ renderToFinalDestination: RenderFunction;
+} {
+ const renderer = new BufferedRenderer(bufferRenderFunction);
+ return renderer;
+}
+
+export const isNode =
+ typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]';
+// @ts-expect-error: Deno is not part of the types.
+export const isDeno = typeof Deno !== 'undefined';
+
+// We can get rid of this when Promise.withResolvers() is ready
+export type PromiseWithResolvers<T> = {
+ promise: Promise<T>;
+ resolve: (value: T) => void;
+ reject: (reason?: any) => void;
+};
+
+// This is an implementation of Promise.withResolvers(), which we can't yet rely on.
+// We can remove this once the native function is available in Node.js
+export function promiseWithResolvers<T = any>(): PromiseWithResolvers<T> {
+ let resolve: any, reject: any;
+ const promise = new Promise<T>((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+ return {
+ promise,
+ resolve,
+ reject,
+ };
+}
+
+const VALID_PROTOCOLS = ['http:', 'https:'];
+function isHttpUrl(url: string) {
+ try {
+ const parsedUrl = new URL(url);
+ return VALID_PROTOCOLS.includes(parsedUrl.protocol);
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts
new file mode 100644
index 000000000..ca9cad1fb
--- /dev/null
+++ b/packages/astro/src/runtime/server/scripts.ts
@@ -0,0 +1,49 @@
+import type { SSRResult } from '../../types/public/internal.js';
+import islandScriptDev from './astro-island.prebuilt-dev.js';
+import islandScript from './astro-island.prebuilt.js';
+
+const ISLAND_STYLES = `<style>astro-island,astro-slot,astro-static-slot{display:contents}</style>`;
+
+export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
+ if (result._metadata.hasHydrationScript) {
+ return false;
+ }
+ return (result._metadata.hasHydrationScript = true);
+}
+
+export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: string): boolean {
+ if (result._metadata.hasDirectives.has(directive)) {
+ return false;
+ }
+ result._metadata.hasDirectives.add(directive);
+ return true;
+}
+
+export type PrescriptType = null | 'both' | 'directive';
+
+function getDirectiveScriptText(result: SSRResult, directive: string): string {
+ const clientDirectives = result.clientDirectives;
+ const clientDirective = clientDirectives.get(directive);
+ if (!clientDirective) {
+ throw new Error(`Unknown directive: ${directive}`);
+ }
+ return clientDirective;
+}
+
+export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string {
+ // Note that this is a classic script, not a module script.
+ // This is so that it executes immediate, and when the browser encounters
+ // an astro-island element the callbacks will fire immediately, causing the JS
+ // deps to be loaded immediately.
+ switch (type) {
+ case 'both':
+ return `${ISLAND_STYLES}<script>${getDirectiveScriptText(result, directive)};${
+ process.env.NODE_ENV === 'development' ? islandScriptDev : islandScript
+ }</script>`;
+ case 'directive':
+ return `<script>${getDirectiveScriptText(result, directive)}</script>`;
+ case null:
+ break;
+ }
+ return '';
+}
diff --git a/packages/astro/src/runtime/server/serialize.ts b/packages/astro/src/runtime/server/serialize.ts
new file mode 100644
index 000000000..b7cc96666
--- /dev/null
+++ b/packages/astro/src/runtime/server/serialize.ts
@@ -0,0 +1,115 @@
+import type { ValueOf } from '../../type-utils.js';
+import type { AstroComponentMetadata } from '../../types/public/internal.js';
+
+const PROP_TYPE = {
+ Value: 0,
+ JSON: 1, // Actually means Array
+ RegExp: 2,
+ Date: 3,
+ Map: 4,
+ Set: 5,
+ BigInt: 6,
+ URL: 7,
+ Uint8Array: 8,
+ Uint16Array: 9,
+ Uint32Array: 10,
+ Infinity: 11,
+};
+
+function serializeArray(
+ value: any[],
+ metadata: AstroComponentMetadata | Record<string, any> = {},
+ parents = new WeakSet<any>(),
+): any[] {
+ if (parents.has(value)) {
+ throw new Error(`Cyclic reference detected while serializing props for <${metadata.displayName} client:${metadata.hydrate}>!
+
+Cyclic references cannot be safely serialized for client-side usage. Please remove the cyclic reference.`);
+ }
+ parents.add(value);
+ const serialized = value.map((v) => {
+ return convertToSerializedForm(v, metadata, parents);
+ });
+ parents.delete(value);
+ return serialized;
+}
+
+function serializeObject(
+ value: Record<any, any>,
+ metadata: AstroComponentMetadata | Record<string, any> = {},
+ parents = new WeakSet<any>(),
+): Record<any, any> {
+ if (parents.has(value)) {
+ throw new Error(`Cyclic reference detected while serializing props for <${metadata.displayName} client:${metadata.hydrate}>!
+
+Cyclic references cannot be safely serialized for client-side usage. Please remove the cyclic reference.`);
+ }
+ parents.add(value);
+ const serialized = Object.fromEntries(
+ Object.entries(value).map(([k, v]) => {
+ return [k, convertToSerializedForm(v, metadata, parents)];
+ }),
+ );
+ parents.delete(value);
+ return serialized;
+}
+
+function convertToSerializedForm(
+ value: any,
+ metadata: AstroComponentMetadata | Record<string, any> = {},
+ parents = new WeakSet<any>(),
+): [ValueOf<typeof PROP_TYPE>, any] | [ValueOf<typeof PROP_TYPE>] {
+ const tag = Object.prototype.toString.call(value);
+ switch (tag) {
+ case '[object Date]': {
+ return [PROP_TYPE.Date, (value as Date).toISOString()];
+ }
+ case '[object RegExp]': {
+ return [PROP_TYPE.RegExp, (value as RegExp).source];
+ }
+ case '[object Map]': {
+ return [PROP_TYPE.Map, serializeArray(Array.from(value as Map<any, any>), metadata, parents)];
+ }
+ case '[object Set]': {
+ return [PROP_TYPE.Set, serializeArray(Array.from(value as Set<any>), metadata, parents)];
+ }
+ case '[object BigInt]': {
+ return [PROP_TYPE.BigInt, (value as bigint).toString()];
+ }
+ case '[object URL]': {
+ return [PROP_TYPE.URL, (value as URL).toString()];
+ }
+ case '[object Array]': {
+ return [PROP_TYPE.JSON, serializeArray(value, metadata, parents)];
+ }
+ case '[object Uint8Array]': {
+ return [PROP_TYPE.Uint8Array, Array.from(value as Uint8Array)];
+ }
+ case '[object Uint16Array]': {
+ return [PROP_TYPE.Uint16Array, Array.from(value as Uint16Array)];
+ }
+ case '[object Uint32Array]': {
+ return [PROP_TYPE.Uint32Array, Array.from(value as Uint32Array)];
+ }
+ default: {
+ if (value !== null && typeof value === 'object') {
+ return [PROP_TYPE.Value, serializeObject(value, metadata, parents)];
+ }
+ if (value === Infinity) {
+ return [PROP_TYPE.Infinity, 1];
+ }
+ if (value === -Infinity) {
+ return [PROP_TYPE.Infinity, -1];
+ }
+ if (value === undefined) {
+ return [PROP_TYPE.Value];
+ }
+ return [PROP_TYPE.Value, value];
+ }
+ }
+}
+
+export function serializeProps(props: any, metadata: AstroComponentMetadata) {
+ const serialized = JSON.stringify(serializeObject(props, metadata));
+ return serialized;
+}
diff --git a/packages/astro/src/runtime/server/shorthash.ts b/packages/astro/src/runtime/server/shorthash.ts
new file mode 100644
index 000000000..1a5575f72
--- /dev/null
+++ b/packages/astro/src/runtime/server/shorthash.ts
@@ -0,0 +1,67 @@
+/**
+ * shortdash - https://github.com/bibig/node-shorthash
+ *
+ * @license
+ *
+ * (The MIT License)
+ *
+ * Copyright (c) 2013 Bibig <bibig@me.com>
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+const dictionary = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY';
+const binary = dictionary.length;
+
+// refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
+function bitwise(str: string) {
+ let hash = 0;
+ if (str.length === 0) return hash;
+ for (let i = 0; i < str.length; i++) {
+ const ch = str.charCodeAt(i);
+ hash = (hash << 5) - hash + ch;
+ hash = hash & hash; // Convert to 32bit integer
+ }
+ return hash;
+}
+
+export function shorthash(text: string) {
+ let num: number;
+ let result = '';
+
+ let integer = bitwise(text);
+ const sign = integer < 0 ? 'Z' : ''; // If it's negative, start with Z, which isn't in the dictionary
+
+ integer = Math.abs(integer);
+
+ while (integer >= binary) {
+ num = integer % binary;
+ integer = Math.floor(integer / binary);
+ result = dictionary[num] + result;
+ }
+
+ if (integer > 0) {
+ result = dictionary[integer] + result;
+ }
+
+ return sign + result;
+}
diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts
new file mode 100644
index 000000000..6f1cd5ba4
--- /dev/null
+++ b/packages/astro/src/runtime/server/transition.ts
@@ -0,0 +1,263 @@
+import cssesc from 'cssesc';
+import { fade, slide } from '../../transitions/index.js';
+import type { SSRResult } from '../../types/public/internal.js';
+import type {
+ TransitionAnimation,
+ TransitionAnimationPair,
+ TransitionAnimationValue,
+ TransitionDirectionalAnimations,
+} from '../../types/public/view-transitions.js';
+import { markHTMLString } from './escape.js';
+
+const transitionNameMap = new WeakMap<SSRResult, number>();
+function incrementTransitionNumber(result: SSRResult) {
+ let num = 1;
+ if (transitionNameMap.has(result)) {
+ num = transitionNameMap.get(result)! + 1;
+ }
+ transitionNameMap.set(result, num);
+ return num;
+}
+
+export function createTransitionScope(result: SSRResult, hash: string) {
+ const num = incrementTransitionNumber(result);
+ return `astro-${hash}-${num}`;
+}
+
+type Entries<T extends Record<string, any>> = Iterable<[keyof T, T[keyof T]]>;
+
+const getAnimations = (name: TransitionAnimationValue) => {
+ if (name === 'fade') return fade();
+ if (name === 'slide') return slide();
+ if (typeof name === 'object') return name;
+};
+
+const addPairs = (
+ animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>,
+ stylesheet: ViewTransitionStyleSheet,
+) => {
+ for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
+ for (const [image, rules] of Object.entries(images) as Entries<
+ (typeof animations)[typeof direction]
+ >) {
+ stylesheet.addAnimationPair(direction, image, rules);
+ }
+ }
+};
+
+// Chrome (121) accepts custom-idents for view-transition-names as generated by cssesc,
+// but it just ignores them during view transitions if they contain escaped 7-bit ASCII characters
+// like \<space> or \. A special case are digits and minus at the beginning of the string,
+// which cssesc also encodes as \xx
+const reEncodeValidChars: string[] =
+ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
+ .split('')
+ .reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);
+const reEncodeInValidStart: string[] = '-0123456789_'
+ .split('')
+ .reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);
+
+function reEncode(s: string) {
+ let result = '';
+ let codepoint;
+ // we work on codepoints that might use more than 16bit, not character codes.
+ // so the index will often step by 1 as usual or by 2 if the codepoint is greater than 0xFFFF
+ for (let i = 0; i < s.length; i += (codepoint ?? 0) > 0xffff ? 2 : 1) {
+ codepoint = s.codePointAt(i);
+ if (codepoint !== undefined) {
+ // this should never happen, they said!
+
+ // If we find a character in the range \x00 - \x7f that is not one of the reEncodeValidChars,
+ // we replace it with its hex value escaped by an underscore for decodability (and better readability,
+ // because most of them are punctuations like ,'"":;_..., and '_' might be a better choice than '-')
+ // The underscore itself (code 95) is also escaped and encoded as two underscores to avoid
+ // collisions between original and encoded strings.
+ // All other values are just copied over
+ result +=
+ codepoint < 0x80
+ ? codepoint === 95
+ ? '__'
+ : (reEncodeValidChars[codepoint] ?? '_' + codepoint.toString(16).padStart(2, '0'))
+ : String.fromCodePoint(codepoint);
+ }
+ }
+ // Digits and minus sign at the beginning of the string are special, so we simply prepend an underscore
+ return reEncodeInValidStart[result.codePointAt(0) ?? 0] ? '_' + result : result;
+}
+
+export function renderTransition(
+ result: SSRResult,
+ hash: string,
+ animationName: TransitionAnimationValue | undefined,
+ transitionName: string,
+) {
+ if (typeof (transitionName ?? '') !== 'string') {
+ throw new Error(`Invalid transition name {${transitionName}}`);
+ }
+ // Default to `fade` (similar to `initial`, but snappier)
+ if (!animationName) animationName = 'fade';
+ const scope = createTransitionScope(result, hash);
+ const name = transitionName ? cssesc(reEncode(transitionName), { isIdentifier: true }) : scope;
+ const sheet = new ViewTransitionStyleSheet(scope, name);
+
+ const animations = getAnimations(animationName);
+ if (animations) {
+ addPairs(animations, sheet);
+ } else if (animationName === 'none') {
+ sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;');
+ sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;');
+ sheet.addAnimationRaw('new', 'animation: none; mix-blend-mode: normal;');
+ sheet.addModern('group', 'animation: none');
+ }
+
+ result._metadata.extraHead.push(markHTMLString(`<style>${sheet.toString()}</style>`));
+ return scope;
+}
+
+export function createAnimationScope(
+ transitionName: string,
+ animations: Record<string, TransitionAnimationPair>,
+) {
+ const hash = Math.random().toString(36).slice(2, 8);
+ const scope = `astro-${hash}`;
+ const sheet = new ViewTransitionStyleSheet(scope, transitionName);
+
+ addPairs(animations, sheet);
+
+ return { scope, styles: sheet.toString().replaceAll('"', '') };
+}
+
+class ViewTransitionStyleSheet {
+ private modern: string[] = [];
+ private fallback: string[] = [];
+
+ constructor(
+ private scope: string,
+ private name: string,
+ ) {}
+
+ toString() {
+ const { scope, name } = this;
+ const [modern, fallback] = [this.modern, this.fallback].map((rules) => rules.join(''));
+ return [
+ `[data-astro-transition-scope="${scope}"] { view-transition-name: ${name}; }`,
+ this.layer(modern),
+ fallback,
+ ].join('');
+ }
+
+ private layer(cssText: string) {
+ return cssText ? `@layer astro { ${cssText} }` : '';
+ }
+
+ private addRule(target: 'modern' | 'fallback', cssText: string) {
+ this[target].push(cssText);
+ }
+
+ addAnimationRaw(image: 'old' | 'new' | 'group', animation: string) {
+ this.addModern(image, animation);
+ this.addFallback(image, animation);
+ }
+
+ addModern(image: 'old' | 'new' | 'group', animation: string) {
+ const { name } = this;
+ this.addRule('modern', `::view-transition-${image}(${name}) { ${animation} }`);
+ }
+
+ addFallback(image: 'old' | 'new' | 'group', animation: string) {
+ const { scope } = this;
+ this.addRule(
+ 'fallback',
+ // Two selectors here, the second in case there is an animation on the root.
+ `[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
+ [data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
+ );
+ }
+
+ addAnimationPair(
+ direction: 'forwards' | 'backwards' | string,
+ image: 'old' | 'new',
+ rules: TransitionAnimation | TransitionAnimation[],
+ ) {
+ const { scope, name } = this;
+ const animation = stringifyAnimation(rules);
+ const prefix =
+ direction === 'backwards'
+ ? `[data-astro-transition=back]`
+ : direction === 'forwards'
+ ? ''
+ : `[data-astro-transition=${direction}]`;
+ this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
+ this.addRule(
+ 'fallback',
+ `${prefix}[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
+ ${prefix}[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
+ );
+ }
+}
+
+type AnimationBuilder = {
+ toString(): string;
+ [key: string]: string[] | ((k: string) => string);
+};
+
+function addAnimationProperty(builder: AnimationBuilder, prop: string, value: string | number) {
+ let arr = builder[prop];
+ if (Array.isArray(arr)) {
+ arr.push(value.toString());
+ } else {
+ builder[prop] = [value.toString()];
+ }
+}
+
+function animationBuilder(): AnimationBuilder {
+ return {
+ toString() {
+ let out = '';
+ for (let k in this) {
+ let value = this[k];
+ if (Array.isArray(value)) {
+ out += `\n\t${k}: ${value.join(', ')};`;
+ }
+ }
+ return out;
+ },
+ };
+}
+
+function stringifyAnimation(anim: TransitionAnimation | TransitionAnimation[]): string {
+ if (Array.isArray(anim)) {
+ return stringifyAnimations(anim);
+ } else {
+ return stringifyAnimations([anim]);
+ }
+}
+
+function stringifyAnimations(anims: TransitionAnimation[]): string {
+ const builder = animationBuilder();
+
+ for (const anim of anims) {
+ if (anim.duration) {
+ addAnimationProperty(builder, 'animation-duration', toTimeValue(anim.duration));
+ }
+ if (anim.easing) {
+ addAnimationProperty(builder, 'animation-timing-function', anim.easing);
+ }
+ if (anim.direction) {
+ addAnimationProperty(builder, 'animation-direction', anim.direction);
+ }
+ if (anim.delay) {
+ addAnimationProperty(builder, 'animation-delay', anim.delay);
+ }
+ if (anim.fillMode) {
+ addAnimationProperty(builder, 'animation-fill-mode', anim.fillMode);
+ }
+ addAnimationProperty(builder, 'animation-name', anim.name);
+ }
+
+ return builder.toString();
+}
+
+function toTimeValue(num: number | string) {
+ return typeof num === 'number' ? num + 'ms' : num;
+}
diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts
new file mode 100644
index 000000000..abf02643e
--- /dev/null
+++ b/packages/astro/src/runtime/server/util.ts
@@ -0,0 +1,19 @@
+export function isPromise<T = any>(value: any): value is Promise<T> {
+ return (
+ !!value && typeof value === 'object' && 'then' in value && typeof value.then === 'function'
+ );
+}
+
+export async function* streamAsyncIterator(stream: ReadableStream) {
+ const reader = stream.getReader();
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) return;
+ yield value;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
diff --git a/packages/astro/src/template/4xx.ts b/packages/astro/src/template/4xx.ts
new file mode 100644
index 000000000..4df016405
--- /dev/null
+++ b/packages/astro/src/template/4xx.ts
@@ -0,0 +1,158 @@
+import { appendForwardSlash, removeTrailingForwardSlash } from '@astrojs/internal-helpers/path';
+import { escape } from 'html-escaper';
+
+interface ErrorTemplateOptions {
+ /** a short description of the error */
+ pathname: string;
+ /** HTTP error code */
+ statusCode?: number;
+ /** HTML <title> */
+ tabTitle: string;
+ /** page title */
+ title: string;
+ /** The body of the message, if one is provided */
+ body?: string;
+}
+
+/** Display all errors */
+export default function template({
+ title,
+ pathname,
+ statusCode = 404,
+ tabTitle,
+ body,
+}: ErrorTemplateOptions): string {
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>${tabTitle}</title>
+ <style>
+ :root {
+ --gray-10: hsl(258, 7%, 10%);
+ --gray-20: hsl(258, 7%, 20%);
+ --gray-30: hsl(258, 7%, 30%);
+ --gray-40: hsl(258, 7%, 40%);
+ --gray-50: hsl(258, 7%, 50%);
+ --gray-60: hsl(258, 7%, 60%);
+ --gray-70: hsl(258, 7%, 70%);
+ --gray-80: hsl(258, 7%, 80%);
+ --gray-90: hsl(258, 7%, 90%);
+ --black: #13151A;
+ --accent-light: #E0CCFA;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ html {
+ background: var(--black);
+ color-scheme: dark;
+ accent-color: var(--accent-light);
+ }
+
+ body {
+ background-color: var(--gray-10);
+ color: var(--gray-80);
+ font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
+ line-height: 1.5;
+ margin: 0;
+ }
+
+ a {
+ color: var(--accent-light);
+ }
+
+ .center {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ width: 100vw;
+ }
+
+ h1 {
+ margin-bottom: 8px;
+ color: white;
+ font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ font-weight: 700;
+ margin-top: 1rem;
+ margin-bottom: 0;
+ }
+
+ .statusCode {
+ color: var(--accent-light);
+ }
+
+ .astro-icon {
+ height: 124px;
+ width: 124px;
+ }
+
+ pre, code {
+ padding: 2px 8px;
+ background: rgba(0,0,0, 0.25);
+ border: 1px solid rgba(255,255,255, 0.25);
+ border-radius: 4px;
+ font-size: 1.2em;
+ margin-top: 0;
+ max-width: 60em;
+ }
+ </style>
+ </head>
+ <body>
+ <main class="center">
+ <svg class="astro-icon" xmlns="http://www.w3.org/2000/svg" width="64" height="80" viewBox="0 0 64 80" fill="none"> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="white"/> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="url(#paint0_linear_738_686)"/> <path d="M0 51.6401C0 51.6401 10.6488 46.4654 21.3274 46.4654L29.3786 21.6102C29.6801 20.4082 30.5602 19.5913 31.5538 19.5913C32.5474 19.5913 33.4275 20.4082 33.7289 21.6102L41.7802 46.4654C54.4274 46.4654 63.1076 51.6401 63.1076 51.6401C63.1076 51.6401 45.0197 2.48776 44.9843 2.38914C44.4652 0.935933 43.5888 0 42.4073 0H20.7022C19.5206 0 18.6796 0.935933 18.1251 2.38914C18.086 2.4859 0 51.6401 0 51.6401Z" fill="white"/> <defs> <linearGradient id="paint0_linear_738_686" x1="31.554" y1="75.4423" x2="39.7462" y2="48.376" gradientUnits="userSpaceOnUse"> <stop stop-color="#D83333"/> <stop offset="1" stop-color="#F041FF"/> </linearGradient> </defs> </svg>
+ <h1>${
+ statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ''
+ }<span class="statusMessage">${title}</span></h1>
+ ${
+ body ||
+ `
+ <pre>Path: ${escape(pathname)}</pre>
+ `
+ }
+ </main>
+ </body>
+</html>`;
+}
+
+export function subpathNotUsedTemplate(base: string, pathname: string) {
+ return template({
+ pathname,
+ statusCode: 404,
+ title: 'Not found',
+ tabTitle: '404: Not Found',
+ body: `<p>In your <code>site</code> you have your base path set to <a href="${base}">${base}</a>. Do you want to go there instead?</p>
+<p>Come to our <a href="https://astro.build/chat">Discord</a> if you need help.</p>`,
+ });
+}
+
+export function trailingSlashMismatchTemplate(
+ pathname: string,
+ trailingSlash: 'always' | 'never' | 'ignore',
+) {
+ const corrected =
+ trailingSlash === 'always'
+ ? appendForwardSlash(pathname)
+ : removeTrailingForwardSlash(pathname);
+ return template({
+ pathname,
+ statusCode: 404,
+ title: 'Not found',
+ tabTitle: '404: Not Found',
+ body: `<p>Your site is configured with <code>trailingSlash</code> set to <code>${trailingSlash}</code>. Do you want to go to <a href="${corrected}">${corrected}</a> instead?</p>
+<p>See <a href=https://docs.astro.build/en/reference/configuration-reference/#trailingslash">the documentation for <code>trailingSlash</code></a> if you need help.</p>`,
+ });
+}
+
+export function notFoundTemplate(pathname: string, message = 'Not found') {
+ return template({
+ pathname,
+ statusCode: 404,
+ title: message,
+ tabTitle: `404: ${message}`,
+ });
+}
diff --git a/packages/astro/src/toolbar/index.ts b/packages/astro/src/toolbar/index.ts
new file mode 100644
index 000000000..78d7158b2
--- /dev/null
+++ b/packages/astro/src/toolbar/index.ts
@@ -0,0 +1,5 @@
+import type { DevToolbarApp } from '../types/public/toolbar.js';
+
+export function defineToolbarApp(app: DevToolbarApp) {
+ return app;
+}
diff --git a/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts
new file mode 100644
index 000000000..e26df2c55
--- /dev/null
+++ b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts
@@ -0,0 +1,119 @@
+import type * as vite from 'vite';
+import { telemetry } from '../events/index.js';
+import { eventAppToggled } from '../events/toolbar.js';
+import type { AstroPluginOptions } from '../types/astro.js';
+
+const PRIVATE_VIRTUAL_MODULE_ID = 'astro:toolbar:internal';
+const resolvedPrivateVirtualModuleId = '\0' + PRIVATE_VIRTUAL_MODULE_ID;
+
+export default function astroDevToolbar({ settings, logger }: AstroPluginOptions): vite.Plugin {
+ let telemetryTimeout: ReturnType<typeof setTimeout>;
+
+ return {
+ name: 'astro:dev-toolbar',
+ config() {
+ return {
+ optimizeDeps: {
+ // Optimize CJS dependencies used by the dev toolbar
+ include: ['astro > aria-query', 'astro > axobject-query'],
+ },
+ };
+ },
+ resolveId(id) {
+ if (id === PRIVATE_VIRTUAL_MODULE_ID) {
+ return resolvedPrivateVirtualModuleId;
+ }
+ },
+ configureServer(server) {
+ server.hot.on('astro:devtoolbar:error:load', (args) => {
+ logger.error(
+ 'toolbar',
+ `Failed to load dev toolbar app from ${args.entrypoint}: ${args.error}`,
+ );
+ });
+
+ server.hot.on('astro:devtoolbar:error:init', (args) => {
+ logger.error(
+ 'toolbar',
+ `Failed to initialize dev toolbar app ${args.app.name} (${args.app.id}):\n${args.error}`,
+ );
+ });
+
+ server.hot.on('astro:devtoolbar:app:toggled', (args) => {
+ // Debounce telemetry to avoid recording events when the user is rapidly toggling apps for debugging
+ clearTimeout(telemetryTimeout);
+ telemetryTimeout = setTimeout(() => {
+ let nameToRecord = args?.app?.id;
+ // Only record apps names for apps that are built-in
+ if (!nameToRecord || !nameToRecord.startsWith('astro:')) {
+ nameToRecord = 'other';
+ }
+ telemetry.record(
+ eventAppToggled({
+ appName: nameToRecord,
+ }),
+ );
+ }, 200);
+ });
+ },
+ async load(id) {
+ if (id === resolvedPrivateVirtualModuleId) {
+ return `
+ export const loadDevToolbarApps = async () => {
+ return (await Promise.all([${settings.devToolbarApps
+ .map(
+ (plugin) =>
+ `safeLoadPlugin(${JSON.stringify(
+ plugin,
+ )}, async () => (await import(${JSON.stringify(
+ typeof plugin === 'string' ? plugin : plugin.entrypoint.toString(),
+ )})).default, ${JSON.stringify(
+ typeof plugin === 'string' ? plugin : plugin.entrypoint.toString(),
+ )})`,
+ )
+ .join(',')}]));
+ };
+
+ async function safeLoadPlugin(appDefinition, importEntrypoint, entrypoint) {
+ try {
+ let app;
+ if (typeof appDefinition === 'string') {
+ app = await importEntrypoint();
+
+ if (typeof app !== 'object' || !app.id || !app.name) {
+ throw new Error("Apps must default export an object with an id, and a name.");
+ }
+ } else {
+ app = appDefinition;
+
+ if (typeof app !== 'object' || !app.id || !app.name || !app.entrypoint) {
+ throw new Error("Apps must be an object with an id, a name and an entrypoint.");
+ }
+
+ const loadedApp = await importEntrypoint();
+
+ if (typeof loadedApp !== 'object') {
+ throw new Error("App entrypoint must default export an object.");
+ }
+
+ app = { ...app, ...loadedApp };
+ }
+
+ return app;
+ } catch (err) {
+ console.error(\`Failed to load dev toolbar app from \${entrypoint}: \${err.message}\`);
+
+ if (import.meta.hot) {
+ import.meta.hot.send('astro:devtoolbar:error:load', { entrypoint: entrypoint, error: err.message })
+ }
+
+ return undefined;
+ }
+
+ return undefined;
+ }
+ `;
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/transitions/events.ts b/packages/astro/src/transitions/events.ts
new file mode 100644
index 000000000..39abd46bb
--- /dev/null
+++ b/packages/astro/src/transitions/events.ts
@@ -0,0 +1,186 @@
+import { updateScrollPosition } from './router.js';
+import { swap } from './swap-functions.js';
+import type { Direction, NavigationTypeString } from './types.js';
+
+export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation';
+export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation';
+export const TRANSITION_BEFORE_SWAP = 'astro:before-swap';
+export const TRANSITION_AFTER_SWAP = 'astro:after-swap';
+export const TRANSITION_PAGE_LOAD = 'astro:page-load';
+
+type Events =
+ | typeof TRANSITION_AFTER_PREPARATION
+ | typeof TRANSITION_AFTER_SWAP
+ | typeof TRANSITION_PAGE_LOAD;
+export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
+export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD);
+
+/*
+ * Common stuff
+ */
+class BeforeEvent extends Event {
+ readonly from: URL;
+ to: URL;
+ direction: Direction | string;
+ readonly navigationType: NavigationTypeString;
+ readonly sourceElement: Element | undefined;
+ readonly info: any;
+ newDocument: Document;
+ readonly signal: AbortSignal;
+
+ constructor(
+ type: string,
+ eventInitDict: EventInit | undefined,
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ newDocument: Document,
+ signal: AbortSignal,
+ ) {
+ super(type, eventInitDict);
+ this.from = from;
+ this.to = to;
+ this.direction = direction;
+ this.navigationType = navigationType;
+ this.sourceElement = sourceElement;
+ this.info = info;
+ this.newDocument = newDocument;
+ this.signal = signal;
+
+ Object.defineProperties(this, {
+ from: { enumerable: true },
+ to: { enumerable: true, writable: true },
+ direction: { enumerable: true, writable: true },
+ navigationType: { enumerable: true },
+ sourceElement: { enumerable: true },
+ info: { enumerable: true },
+ newDocument: { enumerable: true, writable: true },
+ signal: { enumerable: true },
+ });
+ }
+}
+
+/*
+ * TransitionBeforePreparationEvent
+
+ */
+export const isTransitionBeforePreparationEvent = (
+ value: any,
+): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION;
+export class TransitionBeforePreparationEvent extends BeforeEvent {
+ formData: FormData | undefined;
+ loader: () => Promise<void>;
+ constructor(
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ newDocument: Document,
+ signal: AbortSignal,
+ formData: FormData | undefined,
+ loader: (event: TransitionBeforePreparationEvent) => Promise<void>,
+ ) {
+ super(
+ TRANSITION_BEFORE_PREPARATION,
+ { cancelable: true },
+ from,
+ to,
+ direction,
+ navigationType,
+ sourceElement,
+ info,
+ newDocument,
+ signal,
+ );
+ this.formData = formData;
+ this.loader = loader.bind(this, this);
+ Object.defineProperties(this, {
+ formData: { enumerable: true },
+ loader: { enumerable: true, writable: true },
+ });
+ }
+}
+
+/*
+ * TransitionBeforeSwapEvent
+ */
+
+export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent =>
+ value.type === TRANSITION_BEFORE_SWAP;
+export class TransitionBeforeSwapEvent extends BeforeEvent {
+ readonly direction: Direction | string;
+ readonly viewTransition: ViewTransition;
+ swap: () => void;
+
+ constructor(afterPreparation: BeforeEvent, viewTransition: ViewTransition) {
+ super(
+ TRANSITION_BEFORE_SWAP,
+ undefined,
+ afterPreparation.from,
+ afterPreparation.to,
+ afterPreparation.direction,
+ afterPreparation.navigationType,
+ afterPreparation.sourceElement,
+ afterPreparation.info,
+ afterPreparation.newDocument,
+ afterPreparation.signal,
+ );
+ this.direction = afterPreparation.direction;
+ this.viewTransition = viewTransition;
+ this.swap = () => swap(this.newDocument);
+
+ Object.defineProperties(this, {
+ direction: { enumerable: true },
+ viewTransition: { enumerable: true },
+ swap: { enumerable: true, writable: true },
+ });
+ }
+}
+
+export async function doPreparation(
+ from: URL,
+ to: URL,
+ direction: Direction | string,
+ navigationType: NavigationTypeString,
+ sourceElement: Element | undefined,
+ info: any,
+ signal: AbortSignal,
+ formData: FormData | undefined,
+ defaultLoader: (event: TransitionBeforePreparationEvent) => Promise<void>,
+) {
+ const event = new TransitionBeforePreparationEvent(
+ from,
+ to,
+ direction,
+ navigationType,
+ sourceElement,
+ info,
+ window.document,
+ signal,
+ formData,
+ defaultLoader,
+ );
+ if (document.dispatchEvent(event)) {
+ await event.loader();
+ if (!event.defaultPrevented) {
+ triggerEvent(TRANSITION_AFTER_PREPARATION);
+ if (event.navigationType !== 'traverse') {
+ // save the current scroll position before we change the DOM and transition to the new page
+ updateScrollPosition({ scrollX, scrollY });
+ }
+ }
+ }
+ return event;
+}
+
+export function doSwap(afterPreparation: BeforeEvent, viewTransition: ViewTransition) {
+ const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition);
+ document.dispatchEvent(event);
+ event.swap();
+ return event;
+}
diff --git a/packages/astro/src/transitions/index.ts b/packages/astro/src/transitions/index.ts
new file mode 100644
index 000000000..05bfb0972
--- /dev/null
+++ b/packages/astro/src/transitions/index.ts
@@ -0,0 +1,78 @@
+import type {
+ TransitionAnimationPair,
+ TransitionDirectionalAnimations,
+} from '../types/public/view-transitions.js';
+
+export { createAnimationScope } from '../runtime/server/transition.js';
+
+const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)';
+
+export function slide({
+ duration,
+}: {
+ duration?: string | number;
+} = {}): TransitionDirectionalAnimations {
+ return {
+ forwards: {
+ old: [
+ {
+ name: 'astroFadeOut',
+ duration: duration ?? '90ms',
+ easing: EASE_IN_OUT_QUART,
+ fillMode: 'both',
+ },
+ {
+ name: 'astroSlideToLeft',
+ duration: duration ?? '220ms',
+ easing: EASE_IN_OUT_QUART,
+ fillMode: 'both',
+ },
+ ],
+ new: [
+ {
+ name: 'astroFadeIn',
+ duration: duration ?? '210ms',
+ easing: EASE_IN_OUT_QUART,
+ delay: duration ? undefined : '30ms',
+ fillMode: 'both',
+ },
+ {
+ name: 'astroSlideFromRight',
+ duration: duration ?? '220ms',
+ easing: EASE_IN_OUT_QUART,
+ fillMode: 'both',
+ },
+ ],
+ },
+ backwards: {
+ old: [{ name: 'astroFadeOut' }, { name: 'astroSlideToRight' }],
+ new: [{ name: 'astroFadeIn' }, { name: 'astroSlideFromLeft' }],
+ },
+ };
+}
+
+export function fade({
+ duration,
+}: {
+ duration?: string | number;
+} = {}): TransitionDirectionalAnimations {
+ const anim = {
+ old: {
+ name: 'astroFadeOut',
+ duration: duration ?? 180,
+ easing: EASE_IN_OUT_QUART,
+ fillMode: 'both',
+ },
+ new: {
+ name: 'astroFadeIn',
+ duration: duration ?? 180,
+ easing: EASE_IN_OUT_QUART,
+ fillMode: 'both',
+ },
+ } satisfies TransitionAnimationPair;
+
+ return {
+ forwards: anim,
+ backwards: anim,
+ };
+}
diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts
new file mode 100644
index 000000000..925aab0a6
--- /dev/null
+++ b/packages/astro/src/transitions/router.ts
@@ -0,0 +1,706 @@
+import type { TransitionBeforePreparationEvent } from './events.js';
+import { TRANSITION_AFTER_SWAP, doPreparation, doSwap } from './events.js';
+import type { Direction, Fallback, Options } from './types.js';
+
+type State = {
+ index: number;
+ scrollX: number;
+ scrollY: number;
+};
+type Events = 'astro:page-load' | 'astro:after-swap';
+type Navigation = { controller: AbortController };
+type Transition = {
+ // The view transitions object (API and simulation)
+ viewTransition?: ViewTransition;
+ // Simulation: Whether transition was skipped
+ transitionSkipped: boolean;
+ // Simulation: The resolve function of the finished promise
+ viewTransitionFinished?: () => void;
+};
+
+// Create bound versions of pushState/replaceState so that Partytown doesn't hijack them,
+// which breaks Firefox.
+const inBrowser = import.meta.env.SSR === false;
+const pushState = (inBrowser && history.pushState.bind(history)) as typeof history.pushState;
+const replaceState = (inBrowser &&
+ history.replaceState.bind(history)) as typeof history.replaceState;
+
+// only update history entries that are managed by us
+// leave other entries alone and do not accidently add state.
+export const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => {
+ if (history.state) {
+ history.scrollRestoration = 'manual';
+ replaceState({ ...history.state, ...positions }, '');
+ }
+};
+
+export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
+
+export const transitionEnabledOnThisPage = () =>
+ inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]');
+
+const samePage = (thisLocation: URL, otherLocation: URL) =>
+ thisLocation.pathname === otherLocation.pathname && thisLocation.search === otherLocation.search;
+
+// The previous navigation that might still be in processing
+let mostRecentNavigation: Navigation | undefined;
+// The previous transition that might still be in processing
+let mostRecentTransition: Transition | undefined;
+// When we traverse the history, the window.location is already set to the new location.
+// This variable tells us where we came from
+let originalLocation: URL;
+
+const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
+const onPageLoad = () => triggerEvent('astro:page-load');
+const announce = () => {
+ let div = document.createElement('div');
+ div.setAttribute('aria-live', 'assertive');
+ div.setAttribute('aria-atomic', 'true');
+ div.className = 'astro-route-announcer';
+ document.body.append(div);
+ setTimeout(
+ () => {
+ let title = document.title || document.querySelector('h1')?.textContent || location.pathname;
+ div.textContent = title;
+ },
+ // Much thought went into this magic number; the gist is that screen readers
+ // need to see that the element changed and might not do so if it happens
+ // too quickly.
+ 60,
+ );
+};
+
+const PERSIST_ATTR = 'data-astro-transition-persist';
+const DIRECTION_ATTR = 'data-astro-transition';
+const OLD_NEW_ATTR = 'data-astro-transition-fallback';
+
+const VITE_ID = 'data-vite-dev-id';
+
+let parser: DOMParser;
+
+// The History API does not tell you if navigation is forward or back, so
+// you can figure it using an index. On pushState the index is incremented so you
+// can use that to determine popstate if going forward or back.
+let currentHistoryIndex = 0;
+
+if (inBrowser) {
+ if (history.state) {
+ // Here we reloaded a page with history state
+ // (e.g. history navigation from non-transition page or browser reload)
+ currentHistoryIndex = history.state.index;
+ scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
+ } else if (transitionEnabledOnThisPage()) {
+ // This page is loaded from the browser address bar or via a link from extern,
+ // it needs a state in the history
+ replaceState({ index: currentHistoryIndex, scrollX, scrollY }, '');
+ history.scrollRestoration = 'manual';
+ }
+}
+
+// returns the contents of the page or null if the router can't deal with it.
+async function fetchHTML(
+ href: string,
+ init?: RequestInit,
+): Promise<null | { html: string; redirected?: string; mediaType: DOMParserSupportedType }> {
+ try {
+ const res = await fetch(href, init);
+ const contentType = res.headers.get('content-type') ?? '';
+ // drop potential charset (+ other name/value pairs) as parser needs the mediaType
+ const mediaType = contentType.split(';', 1)[0].trim();
+ // the DOMParser can handle two types of HTML
+ if (mediaType !== 'text/html' && mediaType !== 'application/xhtml+xml') {
+ // everything else (e.g. audio/mp3) will be handled by the browser but not by us
+ return null;
+ }
+ const html = await res.text();
+ return {
+ html,
+ redirected: res.redirected ? res.url : undefined,
+ mediaType,
+ };
+ } catch {
+ // can't fetch, let someone else deal with it.
+ return null;
+ }
+}
+
+export function getFallback(): Fallback {
+ const el = document.querySelector('[name="astro-view-transitions-fallback"]');
+ if (el) {
+ return el.getAttribute('content') as Fallback;
+ }
+ return 'animate';
+}
+
+function runScripts() {
+ let wait = Promise.resolve();
+ for (const script of document.getElementsByTagName('script')) {
+ if (script.dataset.astroExec === '') continue;
+ const type = script.getAttribute('type');
+ if (type && type !== 'module' && type !== 'text/javascript') continue;
+ const newScript = document.createElement('script');
+ newScript.innerHTML = script.innerHTML;
+ for (const attr of script.attributes) {
+ if (attr.name === 'src') {
+ const p = new Promise((r) => {
+ newScript.onload = newScript.onerror = r;
+ });
+ wait = wait.then(() => p as any);
+ }
+ newScript.setAttribute(attr.name, attr.value);
+ }
+ newScript.dataset.astroExec = '';
+ script.replaceWith(newScript);
+ }
+ return wait;
+}
+
+// Add a new entry to the browser history. This also sets the new page in the browser address bar.
+// Sets the scroll position according to the hash fragment of the new location.
+const moveToLocation = (
+ to: URL,
+ from: URL,
+ options: Options,
+ pageTitleForBrowserHistory: string,
+ historyState?: State,
+) => {
+ const intraPage = samePage(from, to);
+
+ const targetPageTitle = document.title;
+ document.title = pageTitleForBrowserHistory;
+
+ let scrolledToTop = false;
+ if (to.href !== location.href && !historyState) {
+ if (options.history === 'replace') {
+ const current = history.state;
+ replaceState(
+ {
+ ...options.state,
+ index: current.index,
+ scrollX: current.scrollX,
+ scrollY: current.scrollY,
+ },
+ '',
+ to.href,
+ );
+ } else {
+ pushState(
+ { ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 },
+ '',
+ to.href,
+ );
+ }
+ }
+ document.title = targetPageTitle;
+ // now we are on the new page for non-history navigation!
+ // (with history navigation page change happens before popstate is fired)
+ originalLocation = to;
+
+ // freshly loaded pages start from the top
+ if (!intraPage) {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ scrolledToTop = true;
+ }
+
+ if (historyState) {
+ scrollTo(historyState.scrollX, historyState.scrollY);
+ } else {
+ if (to.hash) {
+ // because we are already on the target page ...
+ // ... what comes next is a intra-page navigation
+ // that won't reload the page but instead scroll to the fragment
+ history.scrollRestoration = 'auto';
+ const savedState = history.state;
+ location.href = to.href; // this kills the history state on Firefox
+ if (!history.state) {
+ replaceState(savedState, ''); // this restores the history state
+ if (intraPage) {
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ }
+ }
+ } else {
+ if (!scrolledToTop) {
+ scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ }
+ }
+ history.scrollRestoration = 'manual';
+ }
+};
+
+function preloadStyleLinks(newDocument: Document) {
+ const links: Promise<any>[] = [];
+ for (const el of newDocument.querySelectorAll('head link[rel=stylesheet]')) {
+ // Do not preload links that are already on the page.
+ if (
+ !document.querySelector(
+ `[${PERSIST_ATTR}="${el.getAttribute(
+ PERSIST_ATTR,
+ )}"], link[rel=stylesheet][href="${el.getAttribute('href')}"]`,
+ )
+ ) {
+ const c = document.createElement('link');
+ c.setAttribute('rel', 'preload');
+ c.setAttribute('as', 'style');
+ c.setAttribute('href', el.getAttribute('href')!);
+ links.push(
+ new Promise<any>((resolve) => {
+ ['load', 'error'].forEach((evName) => c.addEventListener(evName, resolve));
+ document.head.append(c);
+ }),
+ );
+ }
+ }
+ return links;
+}
+
+// replace head and body of the windows document with contents from newDocument
+// if !popstate, update the history entry and scroll position according to toLocation
+// if popState is given, this holds the scroll position for history navigation
+// if fallback === "animate" then simulate view transitions
+async function updateDOM(
+ preparationEvent: TransitionBeforePreparationEvent,
+ options: Options,
+ currentTransition: Transition,
+ historyState?: State,
+ fallback?: Fallback,
+) {
+ async function animate(phase: string) {
+ function isInfinite(animation: Animation) {
+ const effect = animation.effect;
+ if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false;
+ const style = window.getComputedStyle(effect.target, effect.pseudoElement);
+ return style.animationIterationCount === 'infinite';
+ }
+ const currentAnimations = document.getAnimations();
+ // Trigger view transition animations waiting for data-astro-transition-fallback
+ document.documentElement.setAttribute(OLD_NEW_ATTR, phase);
+ const nextAnimations = document.getAnimations();
+ const newAnimations = nextAnimations.filter(
+ (a) => !currentAnimations.includes(a) && !isInfinite(a),
+ );
+ // Wait for all new animations to finish (resolved or rejected).
+ // Do not reject on canceled ones.
+ return Promise.allSettled(newAnimations.map((a) => a.finished));
+ }
+
+ if (
+ fallback === 'animate' &&
+ !currentTransition.transitionSkipped &&
+ !preparationEvent.signal.aborted
+ ) {
+ try {
+ await animate('old');
+ } catch {
+ // animate might reject as a consequence of a call to skipTransition()
+ // ignored on purpose
+ }
+ }
+
+ const pageTitleForBrowserHistory = document.title; // document.title will be overridden by swap()
+ const swapEvent = doSwap(preparationEvent, currentTransition.viewTransition!);
+ moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
+ triggerEvent(TRANSITION_AFTER_SWAP);
+
+ if (fallback === 'animate') {
+ if (!currentTransition.transitionSkipped && !swapEvent.signal.aborted) {
+ animate('new').finally(() => currentTransition.viewTransitionFinished!());
+ } else {
+ currentTransition.viewTransitionFinished!();
+ }
+ }
+}
+
+function abortAndRecreateMostRecentNavigation(): Navigation {
+ mostRecentNavigation?.controller.abort();
+ return (mostRecentNavigation = {
+ controller: new AbortController(),
+ });
+}
+
+async function transition(
+ direction: Direction,
+ from: URL,
+ to: URL,
+ options: Options,
+ historyState?: State,
+) {
+ // The most recent navigation always has precedence
+ // Yes, there can be several navigation instances as the user can click links
+ // while we fetch content or simulate view transitions. Even synchronous creations are possible
+ // e.g. by calling navigate() from an transition event.
+ // Invariant: all but the most recent navigation are already aborted.
+
+ const currentNavigation = abortAndRecreateMostRecentNavigation();
+
+ // not ours
+ if (!transitionEnabledOnThisPage() || location.origin !== to.origin) {
+ if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
+ location.href = to.href;
+ return;
+ }
+
+ const navigationType = historyState
+ ? 'traverse'
+ : options.history === 'replace'
+ ? 'replace'
+ : 'push';
+
+ if (navigationType !== 'traverse') {
+ updateScrollPosition({ scrollX, scrollY });
+ }
+ if (samePage(from, to)) {
+ if ((direction !== 'back' && to.hash) || (direction === 'back' && from.hash)) {
+ moveToLocation(to, from, options, document.title, historyState);
+ if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
+ return;
+ }
+ }
+
+ const prepEvent = await doPreparation(
+ from,
+ to,
+ direction,
+ navigationType,
+ options.sourceElement,
+ options.info,
+ currentNavigation!.controller.signal,
+ options.formData,
+ defaultLoader,
+ );
+ if (prepEvent.defaultPrevented || prepEvent.signal.aborted) {
+ if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
+ if (!prepEvent.signal.aborted) {
+ // not aborted -> delegate to browser
+ location.href = to.href;
+ }
+ // and / or exit
+ return;
+ }
+
+ async function defaultLoader(preparationEvent: TransitionBeforePreparationEvent) {
+ const href = preparationEvent.to.href;
+ const init: RequestInit = { signal: preparationEvent.signal };
+ if (preparationEvent.formData) {
+ init.method = 'POST';
+ const form =
+ preparationEvent.sourceElement instanceof HTMLFormElement
+ ? preparationEvent.sourceElement
+ : preparationEvent.sourceElement instanceof HTMLElement &&
+ 'form' in preparationEvent.sourceElement
+ ? (preparationEvent.sourceElement.form as HTMLFormElement)
+ : preparationEvent.sourceElement?.closest('form');
+ // Form elements without enctype explicitly set default to application/x-www-form-urlencoded.
+ // In order to maintain compatibility with Astro 4.x, we need to check the value of enctype
+ // on the attributes property rather than accessing .enctype directly. Astro 5.x may
+ // introduce defaulting to application/x-www-form-urlencoded as a breaking change, and then
+ // we can access .enctype directly.
+ //
+ // Note: getNamedItem can return null in real life, even if TypeScript doesn't think so, hence
+ // the ?.
+ init.body =
+ form?.attributes.getNamedItem('enctype')?.value === 'application/x-www-form-urlencoded'
+ ? new URLSearchParams(preparationEvent.formData as any)
+ : preparationEvent.formData;
+ }
+ const response = await fetchHTML(href, init);
+ // If there is a problem fetching the new page, just do an MPA navigation to it.
+ if (response === null) {
+ preparationEvent.preventDefault();
+ return;
+ }
+ // if there was a redirection, show the final URL in the browser's address bar
+ if (response.redirected) {
+ const redirectedTo = new URL(response.redirected);
+ // but do not redirect cross origin
+ if (redirectedTo.origin !== preparationEvent.to.origin) {
+ preparationEvent.preventDefault();
+ return;
+ }
+ preparationEvent.to = redirectedTo;
+ }
+
+ parser ??= new DOMParser();
+
+ preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
+ // The next line might look like a hack,
+ // but it is actually necessary as noscript elements
+ // and their contents are returned as markup by the parser,
+ // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
+ preparationEvent.newDocument.querySelectorAll('noscript').forEach((el) => el.remove());
+
+ // If ClientRouter is not enabled on the incoming page, do a full page load to it.
+ // Unless this was a form submission, in which case we do not want to trigger another mutation.
+ if (
+ !preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') &&
+ !preparationEvent.formData
+ ) {
+ preparationEvent.preventDefault();
+ return;
+ }
+
+ const links = preloadStyleLinks(preparationEvent.newDocument);
+ links.length && !preparationEvent.signal.aborted && (await Promise.all(links));
+
+ if (import.meta.env.DEV && !preparationEvent.signal.aborted)
+ await prepareForClientOnlyComponents(
+ preparationEvent.newDocument,
+ preparationEvent.to,
+ preparationEvent.signal,
+ );
+ }
+ async function abortAndRecreateMostRecentTransition(): Promise<Transition> {
+ if (mostRecentTransition) {
+ if (mostRecentTransition.viewTransition) {
+ try {
+ mostRecentTransition.viewTransition.skipTransition();
+ } catch {
+ // might throw AbortError DOMException. Ignored on purpose.
+ }
+ try {
+ // UpdateCallbackDone might already been settled, i.e. if the previous transition finished updating the DOM.
+ // Could not take long, we wait for it to avoid parallel updates
+ // (which are very unlikely as long as swap() is not async).
+ await mostRecentTransition.viewTransition.updateCallbackDone;
+ } catch {
+ // There was an error in the update callback of the transition which we cancel.
+ // Ignored on purpose
+ }
+ }
+ }
+ return (mostRecentTransition = { transitionSkipped: false });
+ }
+
+ const currentTransition = await abortAndRecreateMostRecentTransition();
+
+ if (prepEvent.signal.aborted) {
+ if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
+ return;
+ }
+
+ document.documentElement.setAttribute(DIRECTION_ATTR, prepEvent.direction);
+ if (supportsViewTransitions) {
+ // This automatically cancels any previous transition
+ // We also already took care that the earlier update callback got through
+ currentTransition.viewTransition = document.startViewTransition(
+ async () => await updateDOM(prepEvent, options, currentTransition, historyState),
+ );
+ } else {
+ // Simulation mode requires a bit more manual work
+ const updateDone = (async () => {
+ // Immediately paused to setup the ViewTransition object for Fallback mode
+ await Promise.resolve(); // hop through the micro task queue
+ await updateDOM(prepEvent, options, currentTransition, historyState, getFallback());
+ return undefined;
+ })();
+
+ // When the updateDone promise is settled,
+ // we have run and awaited all swap functions and the after-swap event
+ // This qualifies for "updateCallbackDone".
+ //
+ // For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone",
+ // i.e. after all pseudo elements are created and the animation is about to start.
+ // In simulation mode the "old" animation starts before swap,
+ // the "new" animation starts after swap. That is not really comparable.
+ // Thus we go with "very, very shortly after updateCallbackDone" and make both equal.
+ //
+ // "finished" resolves after all animations are done.
+
+ currentTransition.viewTransition = {
+ updateCallbackDone: updateDone, // this is about correct
+ ready: updateDone, // good enough
+ // Finished promise could have been done better: finished rejects iff updateDone does.
+ // Our simulation always resolves, never rejects.
+ finished: new Promise((r) => (currentTransition.viewTransitionFinished = r as () => void)), // see end of updateDOM
+ skipTransition: () => {
+ currentTransition.transitionSkipped = true;
+ // This cancels all animations of the simulation
+ document.documentElement.removeAttribute(OLD_NEW_ATTR);
+ },
+ };
+ }
+ // In earlier versions was then'ed on viewTransition.ready which would not execute
+ // if the visual part of the transition has errors or was skipped
+ currentTransition.viewTransition?.updateCallbackDone.finally(async () => {
+ await runScripts();
+ onPageLoad();
+ announce();
+ });
+ // finished.ready and finished.finally are the same for the simulation but not
+ // necessarily for native view transition, where finished rejects when updateCallbackDone does.
+ currentTransition.viewTransition?.finished.finally(() => {
+ currentTransition.viewTransition = undefined;
+ if (currentTransition === mostRecentTransition) mostRecentTransition = undefined;
+ if (currentNavigation === mostRecentNavigation) mostRecentNavigation = undefined;
+ document.documentElement.removeAttribute(DIRECTION_ATTR);
+ document.documentElement.removeAttribute(OLD_NEW_ATTR);
+ });
+ try {
+ // Compatibility:
+ // In an earlier version we awaited viewTransition.ready, which includes animation setup.
+ // Scripts that depend on the view transition pseudo elements should hook on viewTransition.ready.
+ await currentTransition.viewTransition?.updateCallbackDone;
+ } catch (e) {
+ // This log doesn't make it worse than before, where we got error messages about uncaught exceptions, which can't be caught when the trigger was a click or history traversal.
+ // Needs more investigation on root causes if errors still occur sporadically
+ const err = e as Error;
+ // biome-ignore lint/suspicious/noConsoleLog: allowed
+ console.log('[astro]', err.name, err.message, err.stack);
+ }
+}
+
+let navigateOnServerWarned = false;
+
+export async function navigate(href: string, options?: Options) {
+ if (inBrowser === false) {
+ if (!navigateOnServerWarned) {
+ // instantiate an error for the stacktrace to show to user.
+ const warning = new Error(
+ 'The view transitions client API was called during a server side render. This may be unintentional as the navigate() function is expected to be called in response to user interactions. Please make sure that your usage is correct.',
+ );
+ warning.name = 'Warning';
+ console.warn(warning);
+ navigateOnServerWarned = true;
+ }
+ return;
+ }
+ await transition('forward', originalLocation, new URL(href, location.href), options ?? {});
+}
+
+function onPopState(ev: PopStateEvent) {
+ if (!transitionEnabledOnThisPage() && ev.state) {
+ // The current page doesn't have View Transitions enabled
+ // but the page we navigate to does (because it set the state).
+ // Do a full page refresh to reload the client-side router from the new page.
+ location.reload();
+ return;
+ }
+
+ // History entries without state are created by the browser (e.g. for hash links)
+ // Our view transition entries always have state.
+ // Just ignore stateless entries.
+ // The browser will handle navigation fine without our help
+ if (ev.state === null) {
+ return;
+ }
+ const state: State = history.state;
+ const nextIndex = state.index;
+ const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
+ currentHistoryIndex = nextIndex;
+ transition(direction, originalLocation, new URL(location.href), {}, state);
+}
+
+const onScrollEnd = () => {
+ // NOTE: our "popstate" event handler may call `pushState()` or
+ // `replaceState()` and then `scrollTo()`, which will fire "scroll" and
+ // "scrollend" events. To avoid redundant work and expensive calls to
+ // `replaceState()`, we simply check that the values are different before
+ // updating.
+ if (history.state && (scrollX !== history.state.scrollX || scrollY !== history.state.scrollY)) {
+ updateScrollPosition({ scrollX, scrollY });
+ }
+};
+
+// initialization
+if (inBrowser) {
+ if (supportsViewTransitions || getFallback() !== 'none') {
+ originalLocation = new URL(location.href);
+ addEventListener('popstate', onPopState);
+ addEventListener('load', onPageLoad);
+ // There's not a good way to record scroll position before a history back
+ // navigation, so we will record it when the user has stopped scrolling.
+ if ('onscrollend' in window) addEventListener('scrollend', onScrollEnd);
+ else {
+ // Keep track of state between intervals
+ let intervalId: number | undefined, lastY: number, lastX: number, lastIndex: State['index'];
+ const scrollInterval = () => {
+ // Check the index to see if a popstate event was fired
+ if (lastIndex !== history.state?.index) {
+ clearInterval(intervalId);
+ intervalId = undefined;
+ return;
+ }
+ // Check if the user stopped scrolling
+ if (lastY === scrollY && lastX === scrollX) {
+ // Cancel the interval and update scroll positions
+ clearInterval(intervalId);
+ intervalId = undefined;
+ onScrollEnd();
+ return;
+ } else {
+ // Update vars with current positions
+ (lastY = scrollY), (lastX = scrollX);
+ }
+ };
+ // We can't know when or how often scroll events fire, so we'll just use them to start intervals
+ addEventListener(
+ 'scroll',
+ () => {
+ if (intervalId !== undefined) return;
+ (lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX);
+ intervalId = window.setInterval(scrollInterval, 50);
+ },
+ { passive: true },
+ );
+ }
+ }
+ for (const script of document.getElementsByTagName('script')) {
+ script.dataset.astroExec = '';
+ }
+}
+
+// Keep all styles that are potentially created by client:only components
+// and required on the next page
+async function prepareForClientOnlyComponents(
+ newDocument: Document,
+ toLocation: URL,
+ signal: AbortSignal,
+) {
+ // Any client:only component on the next page?
+ if (newDocument.body.querySelector(`astro-island[client='only']`)) {
+ // Load the next page with an empty module loader cache
+ const nextPage = document.createElement('iframe');
+ // with srcdoc resolving imports does not work on webkit browsers
+ nextPage.src = toLocation.href;
+ nextPage.style.display = 'none';
+ document.body.append(nextPage);
+ // silence the iframe's console
+ // @ts-ignore
+ nextPage.contentWindow!.console = Object.keys(console).reduce((acc: any, key) => {
+ acc[key] = () => {};
+ return acc;
+ }, {});
+ await hydrationDone(nextPage);
+
+ const nextHead = nextPage.contentDocument?.head;
+ if (nextHead) {
+ // Collect the vite ids of all styles present in the next head
+ const viteIds = [...nextHead.querySelectorAll(`style[${VITE_ID}]`)].map((style) =>
+ style.getAttribute(VITE_ID),
+ );
+ // Copy required styles to the new document if they are from hydration.
+ viteIds.forEach((id) => {
+ const style = nextHead.querySelector(`style[${VITE_ID}="${id}"]`);
+ if (style && !newDocument.head.querySelector(`style[${VITE_ID}="${id}"]`)) {
+ newDocument.head.appendChild(style.cloneNode(true));
+ }
+ });
+ }
+
+ // return a promise that resolves when all astro-islands are hydrated
+ async function hydrationDone(loadingPage: HTMLIFrameElement) {
+ if (!signal.aborted) {
+ await new Promise((r) =>
+ loadingPage.contentWindow?.addEventListener('load', r, { once: true }),
+ );
+ }
+ return new Promise<void>(async (r) => {
+ for (let count = 0; count <= 20; ++count) {
+ if (signal.aborted) break;
+ if (!loadingPage.contentDocument!.body.querySelector('astro-island[ssr]')) break;
+ await new Promise((r2) => setTimeout(r2, 50));
+ }
+ r();
+ });
+ }
+ }
+}
diff --git a/packages/astro/src/transitions/swap-functions.ts b/packages/astro/src/transitions/swap-functions.ts
new file mode 100644
index 000000000..c99de8d3d
--- /dev/null
+++ b/packages/astro/src/transitions/swap-functions.ts
@@ -0,0 +1,159 @@
+export type SavedFocus = {
+ activeElement: HTMLElement | null;
+ start?: number | null;
+ end?: number | null;
+};
+
+const PERSIST_ATTR = 'data-astro-transition-persist';
+
+/*
+ * Mark new scripts that should not execute
+ */
+export function deselectScripts(doc: Document) {
+ for (const s1 of document.scripts) {
+ for (const s2 of doc.scripts) {
+ if (
+ // Check if the script should be rerun regardless of it being the same
+ !s2.hasAttribute('data-astro-rerun') &&
+ // Inline
+ ((!s1.src && s1.textContent === s2.textContent) ||
+ // External
+ (s1.src && s1.type === s2.type && s1.src === s2.src))
+ ) {
+ // the old script is in the new document and doesn't have the rerun attribute
+ // we mark it as executed to prevent re-execution
+ s2.dataset.astroExec = '';
+ break;
+ }
+ }
+ }
+}
+
+/*
+ * swap attributes of the html element
+ * delete all attributes from the current document
+ * insert all attributes from doc
+ * reinsert all original attributes that are named 'data-astro-*'
+ */
+export function swapRootAttributes(doc: Document) {
+ const html = document.documentElement;
+ const astroAttributes = [...html.attributes].filter(
+ ({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-')),
+ );
+ [...doc.documentElement.attributes, ...astroAttributes].forEach(({ name, value }) =>
+ html.setAttribute(name, value),
+ );
+}
+
+/*
+ * make the old head look like the new one
+ */
+export function swapHeadElements(doc: Document) {
+ for (const el of Array.from(document.head.children)) {
+ const newEl = persistedHeadElement(el as HTMLElement, doc);
+ // If the element exists in the document already, remove it
+ // from the new document and leave the current node alone
+ if (newEl) {
+ newEl.remove();
+ } else {
+ // Otherwise remove the element in the head. It doesn't exist in the new page.
+ el.remove();
+ }
+ }
+
+ // Everything left in the new head is new, append it all.
+ document.head.append(...doc.head.children);
+}
+
+export function swapBodyElement(newElement: Element, oldElement: Element) {
+ // this will reset scroll Position
+ oldElement.replaceWith(newElement);
+
+ for (const el of oldElement.querySelectorAll(`[${PERSIST_ATTR}]`)) {
+ const id = el.getAttribute(PERSIST_ATTR);
+ const newEl = newElement.querySelector(`[${PERSIST_ATTR}="${id}"]`);
+ if (newEl) {
+ // The element exists in the new page, replace it with the element
+ // from the old page so that state is preserved.
+ newEl.replaceWith(el);
+ // For islands, copy over the props to allow them to re-render
+ if (
+ newEl.localName === 'astro-island' &&
+ shouldCopyProps(el as HTMLElement) &&
+ !isSameProps(el, newEl)
+ ) {
+ el.setAttribute('ssr', '');
+ el.setAttribute('props', newEl.getAttribute('props')!);
+ }
+ }
+ }
+}
+
+export const saveFocus = (): (() => void) => {
+ const activeElement = document.activeElement as HTMLElement;
+ // The element that currently has the focus is part of a DOM tree
+ // that will survive the transition to the new document.
+ // Save the element and the cursor position
+ if (activeElement?.closest(`[${PERSIST_ATTR}]`)) {
+ if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
+ const start = activeElement.selectionStart;
+ const end = activeElement.selectionEnd;
+ return () => restoreFocus({ activeElement, start, end });
+ }
+ return () => restoreFocus({ activeElement });
+ } else {
+ return () => restoreFocus({ activeElement: null });
+ }
+};
+
+export const restoreFocus = ({ activeElement, start, end }: SavedFocus) => {
+ if (activeElement) {
+ activeElement.focus();
+ if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
+ if (typeof start === 'number') activeElement.selectionStart = start;
+ if (typeof end === 'number') activeElement.selectionEnd = end;
+ }
+ }
+};
+
+// Check for a head element that should persist and returns it,
+// either because it has the data attribute or is a link el.
+// Returns null if the element is not part of the new head, undefined if it should be left alone.
+const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null => {
+ const id = el.getAttribute(PERSIST_ATTR);
+ const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`);
+ if (newEl) {
+ return newEl;
+ }
+ if (el.matches('link[rel=stylesheet]')) {
+ const href = el.getAttribute('href');
+ return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
+ }
+ return null;
+};
+
+const shouldCopyProps = (el: HTMLElement): boolean => {
+ const persistProps = el.dataset.astroTransitionPersistProps;
+ return persistProps == null || persistProps === 'false';
+};
+
+const isSameProps = (oldEl: Element, newEl: Element) => {
+ return oldEl.getAttribute('props') === newEl.getAttribute('props');
+};
+
+export const swapFunctions = {
+ deselectScripts,
+ swapRootAttributes,
+ swapHeadElements,
+ swapBodyElement,
+ saveFocus,
+};
+
+export const swap = (doc: Document) => {
+ deselectScripts(doc);
+ swapRootAttributes(doc);
+ swapHeadElements(doc);
+ const restoreFocusFunction = saveFocus();
+ swapBodyElement(doc.body, document.body);
+ restoreFocusFunction();
+};
diff --git a/packages/astro/src/transitions/types.ts b/packages/astro/src/transitions/types.ts
new file mode 100644
index 000000000..0e70825e5
--- /dev/null
+++ b/packages/astro/src/transitions/types.ts
@@ -0,0 +1,10 @@
+export type Fallback = 'none' | 'animate' | 'swap';
+export type Direction = 'forward' | 'back';
+export type NavigationTypeString = 'push' | 'replace' | 'traverse';
+export type Options = {
+ history?: 'auto' | 'push' | 'replace';
+ info?: any;
+ state?: any;
+ formData?: FormData;
+ sourceElement?: Element; // more than HTMLElement, e.g. SVGAElement
+};
diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts
new file mode 100644
index 000000000..ea0576477
--- /dev/null
+++ b/packages/astro/src/transitions/vite-plugin-transitions.ts
@@ -0,0 +1,59 @@
+import type * as vite from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+
+const virtualModuleId = 'astro:transitions';
+const resolvedVirtualModuleId = '\0' + virtualModuleId;
+const virtualClientModuleId = 'astro:transitions/client';
+const resolvedVirtualClientModuleId = '\0' + virtualClientModuleId;
+
+// The virtual module for the astro:transitions namespace
+export default function astroTransitions({ settings }: { settings: AstroSettings }): vite.Plugin {
+ return {
+ name: 'astro:transitions',
+ config() {
+ return {
+ optimizeDeps: {
+ include: ['astro > cssesc'],
+ },
+ };
+ },
+ async resolveId(id) {
+ if (id === virtualModuleId) {
+ return resolvedVirtualModuleId;
+ }
+ if (id === virtualClientModuleId) {
+ return resolvedVirtualClientModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedVirtualModuleId) {
+ return `
+ export * from "astro/virtual-modules/transitions.js";
+ export {
+ default as ViewTransitions,
+ default as ClientRouter
+ } from "astro/components/ClientRouter.astro";
+ `;
+ }
+ if (id === resolvedVirtualClientModuleId) {
+ return `
+ export { navigate, supportsViewTransitions, transitionEnabledOnThisPage } from "astro/virtual-modules/transitions-router.js";
+ export * from "astro/virtual-modules/transitions-types.js";
+ export {
+ TRANSITION_BEFORE_PREPARATION, isTransitionBeforePreparationEvent, TransitionBeforePreparationEvent,
+ TRANSITION_AFTER_PREPARATION,
+ TRANSITION_BEFORE_SWAP, isTransitionBeforeSwapEvent, TransitionBeforeSwapEvent,
+ TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD
+ } from "astro/virtual-modules/transitions-events.js";
+ export { swapFunctions } from "astro/virtual-modules/transitions-swap-functions.js";
+ `;
+ }
+ },
+ transform(code, id) {
+ if (id.includes('ClientRouter.astro') && id.endsWith('.ts')) {
+ const prefetchDisabled = settings.config.prefetch === false;
+ return code.replace('__PREFETCH_DISABLED__', JSON.stringify(prefetchDisabled));
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/type-utils.ts b/packages/astro/src/type-utils.ts
new file mode 100644
index 000000000..1aa816aad
--- /dev/null
+++ b/packages/astro/src/type-utils.ts
@@ -0,0 +1,46 @@
+// Q: Why is this not in @types?
+// A: `@types` is for types that are part of the public API. This is just a bunch of utilities we use throughout the codebase. (Mostly by Erika)
+
+// Merge all the intersection of a type into one type. This is useful for making tooltips better in the editor for complex types
+// Ex: The Image component props are a merge of all the properties that can be on an `img` tag and our props, in the editor
+// this results in a very opaque type that just says `ImgAttributes & ImageComponentProps`. With this, all the props shows.
+export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
+
+// Mark certain properties of a type as required. Think of it like "This type, with those specific properties required"
+export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
+
+// Name is pretty self descriptive, but it removes the index signature of an object
+export type OmitIndexSignature<ObjectType> = {
+ [KeyType in keyof ObjectType as object extends Record<KeyType, unknown>
+ ? never
+ : KeyType]: ObjectType[KeyType];
+};
+
+// This is an alternative `Omit<T, K>` implementation that _doesn't_ remove the index signature of an object.
+export type OmitPreservingIndexSignature<T, K extends PropertyKey> = {
+ [P in keyof T as Exclude<P, K>]: T[P];
+};
+
+// Transform a string into its kebab case equivalent (camelCase -> kebab-case). Useful for CSS-in-JS to CSS.
+export type Kebab<T extends string, A extends string = ''> = T extends `${infer F}${infer R}`
+ ? Kebab<R, `${A}${F extends Lowercase<F> ? '' : '-'}${Lowercase<F>}`>
+ : A;
+
+// Transform every key of an object to its kebab case equivalent using the above utility
+export type KebabKeys<T> = { [K in keyof T as K extends string ? Kebab<K> : K]: T[K] };
+
+// Similar to `keyof`, gets the type of all the values of an object
+export type ValueOf<T> = T[keyof T];
+
+// Gets the type of the values of a Map
+export type MapValue<T> = T extends Map<any, infer V> ? V : never;
+
+// Allow the user to create a type where all keys are optional.
+// Useful for functions where props are merged.
+export type DeepPartial<T> = {
+ [P in keyof T]?: T[P] extends (infer U)[]
+ ? DeepPartial<U>[]
+ : T[P] extends object | undefined
+ ? DeepPartial<T[P]>
+ : T[P];
+};
diff --git a/packages/astro/src/types/README.md b/packages/astro/src/types/README.md
new file mode 100644
index 000000000..103312106
--- /dev/null
+++ b/packages/astro/src/types/README.md
@@ -0,0 +1,5 @@
+# `types/`
+
+In this folder rest the types that are used throughout Astro. Typically folders for corresponding features will have a corresponding `types.ts` file in their folder. For example, the `src/assets/types.ts` contain the types for `astro:assets`. However this folder can be useful for types that are used across multiple features, or generally don't fit in any other folder.
+
+This folder additionally contain a `public` folder, which contains types that are exposed to users one way or another. Remember that these types are part of the public API, and as such follow the same semver contract as the rest of Astro.
diff --git a/packages/astro/src/types/astro.ts b/packages/astro/src/types/astro.ts
new file mode 100644
index 000000000..890c936e8
--- /dev/null
+++ b/packages/astro/src/types/astro.ts
@@ -0,0 +1,92 @@
+import type { SSRManifest } from '../core/app/types.js';
+import type { AstroTimer } from '../core/config/timer.js';
+import type { TSConfig } from '../core/config/tsconfig.js';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroPreferences } from '../preferences/index.js';
+import type { AstroComponentFactory } from '../runtime/server/index.js';
+import type { GetStaticPathsOptions, GetStaticPathsResult } from './public/common.js';
+import type { AstroConfig } from './public/config.js';
+import type { ContentEntryType, DataEntryType } from './public/content.js';
+import type {
+ AstroAdapter,
+ AstroRenderer,
+ InjectedScriptStage,
+ InjectedType,
+} from './public/integrations.js';
+import type { InternalInjectedRoute, ResolvedInjectedRoute, RouteData } from './public/internal.js';
+import type { DevToolbarAppEntry } from './public/toolbar.js';
+
+export type SerializedRouteData = Omit<
+ RouteData,
+ 'generate' | 'pattern' | 'redirectRoute' | 'fallbackRoutes'
+> & {
+ generate: undefined;
+ pattern: string;
+ redirectRoute: SerializedRouteData | undefined;
+ fallbackRoutes: SerializedRouteData[];
+ _meta: {
+ trailingSlash: AstroConfig['trailingSlash'];
+ };
+};
+
+export interface AstroSettings {
+ config: AstroConfig;
+ adapter: AstroAdapter | undefined;
+ preferences: AstroPreferences;
+ injectedRoutes: InternalInjectedRoute[];
+ resolvedInjectedRoutes: ResolvedInjectedRoute[];
+ pageExtensions: string[];
+ contentEntryTypes: ContentEntryType[];
+ dataEntryTypes: DataEntryType[];
+ renderers: AstroRenderer[];
+ scripts: {
+ stage: InjectedScriptStage;
+ content: string;
+ }[];
+ /**
+ * Map of directive name (e.g. `load`) to the directive script code
+ */
+ clientDirectives: Map<string, string>;
+ devToolbarApps: (DevToolbarAppEntry | string)[];
+ middlewares: { pre: string[]; post: string[] };
+ tsConfig: TSConfig | undefined;
+ tsConfigPath: string | undefined;
+ watchFiles: string[];
+ timer: AstroTimer;
+ dotAstroDir: URL;
+ /**
+ * Latest version of Astro, will be undefined if:
+ * - unable to check
+ * - the user has disabled the check
+ * - the check has not completed yet
+ * - the user is on the latest version already
+ */
+ latestAstroVersion: string | undefined;
+ serverIslandMap: NonNullable<SSRManifest['serverIslandMap']>;
+ serverIslandNameMap: NonNullable<SSRManifest['serverIslandNameMap']>;
+ // This makes content optional. Internal only so it's not optional on InjectedType
+ injectedTypes: Array<Omit<InjectedType, 'content'> & Partial<Pick<InjectedType, 'content'>>>;
+ /**
+ * Determine if the build output should be a static, dist folder or a adapter-based server output
+ * undefined when unknown
+ */
+ buildOutput: undefined | 'static' | 'server';
+}
+
+/** Generic interface for a component (Astro, Svelte, React, etc.) */
+export interface ComponentInstance {
+ default: AstroComponentFactory;
+ css?: string[];
+ partial?: boolean;
+ prerender?: boolean;
+ getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult;
+}
+
+export interface RoutesList {
+ routes: RouteData[];
+}
+
+export interface AstroPluginOptions {
+ settings: AstroSettings;
+ logger: Logger;
+}
diff --git a/packages/astro/src/types/public/common.ts b/packages/astro/src/types/public/common.ts
new file mode 100644
index 000000000..47202a1ef
--- /dev/null
+++ b/packages/astro/src/types/public/common.ts
@@ -0,0 +1,182 @@
+import type { OmitIndexSignature, Simplify } from '../../type-utils.js';
+import type { APIContext } from './context.js';
+
+/**
+ * getStaticPaths() options
+ *
+ * [Astro Reference](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
+ */
+export interface GetStaticPathsOptions {
+ paginate: PaginateFunction;
+}
+
+export type GetStaticPathsItem = {
+ params: { [K in keyof Params]: Params[K] | number };
+ props?: Props;
+};
+export type GetStaticPathsResult = GetStaticPathsItem[];
+export type GetStaticPathsResultKeyed = GetStaticPathsResult & {
+ keyed: Map<string, GetStaticPathsItem>;
+};
+
+/**
+ * Return an array of pages to generate for a [dynamic route](https://docs.astro.build/en/guides/routing/#dynamic-routes). (**SSG Only**)
+ *
+ * [Astro Reference](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
+ */
+export type GetStaticPaths = (
+ options: GetStaticPathsOptions,
+) => Promise<GetStaticPathsResult> | GetStaticPathsResult;
+
+/**
+ * paginate() Options
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#paginate)
+ */
+export interface PaginateOptions<PaginateProps extends Props, PaginateParams extends Params> {
+ /** the number of items per-page (default: `10`) */
+ pageSize?: number;
+ /** key: value object of page params (ex: `{ tag: 'javascript' }`) */
+ params?: PaginateParams;
+ /** object of props to forward to `page` result */
+ props?: PaginateProps;
+}
+
+/**
+ * Represents a single page of data in a paginated collection
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#the-pagination-page-prop)
+ */
+export interface Page<T = any> {
+ /** result */
+ data: T[];
+ /** metadata */
+ /** the count of the first item on the page, starting from 0 */
+ start: number;
+ /** the count of the last item on the page, starting from 0 */
+ end: number;
+ /** total number of results */
+ total: number;
+ /** the current page number, starting from 1 */
+ currentPage: number;
+ /** number of items per page (default: 10) */
+ size: number;
+ /** number of last page */
+ lastPage: number;
+ url: {
+ /** url of the current page */
+ current: string;
+ /** url of the previous page (if there is one) */
+ prev: string | undefined;
+ /** url of the next page (if there is one) */
+ next: string | undefined;
+ /** url of the first page (if the current page is not the first page) */
+ first: string | undefined;
+ /** url of the last page (if the current page is not the last page) */
+ last: string | undefined;
+ };
+}
+
+export type PaginateFunction = <
+ PaginateData,
+ AdditionalPaginateProps extends Props,
+ AdditionalPaginateParams extends Params,
+>(
+ data: PaginateData[],
+ args?: PaginateOptions<AdditionalPaginateProps, AdditionalPaginateParams>,
+) => {
+ params: Simplify<
+ {
+ page: string | undefined;
+ } & OmitIndexSignature<AdditionalPaginateParams>
+ >;
+ props: Simplify<
+ {
+ page: Page<PaginateData>;
+ } & OmitIndexSignature<AdditionalPaginateProps>
+ >;
+}[];
+
+export type APIRoute<
+ APIProps extends Record<string, any> = Record<string, any>,
+ APIParams extends Record<string, string | undefined> = Record<string, string | undefined>,
+> = (context: APIContext<APIProps, APIParams>) => Response | Promise<Response>;
+
+export type RewritePayload = string | URL | Request;
+
+export type MiddlewareNext = (rewritePayload?: RewritePayload) => Promise<Response>;
+export type MiddlewareHandler = (
+ context: APIContext,
+ next: MiddlewareNext,
+) => Promise<Response> | Response | Promise<void> | void;
+
+// NOTE: when updating this file with other functions,
+// remember to update `plugin-page.ts` too, to add that function as a no-op function.
+export type AstroMiddlewareInstance = {
+ onRequest?: MiddlewareHandler;
+};
+
+/**
+ * Infers the shape of the `params` property returned by `getStaticPaths()`.
+ *
+ * @example
+ * ```ts
+ * import type { GetStaticPaths } from 'astro';
+ *
+ * export const getStaticPaths = (() => {
+ * return results.map((entry) => ({
+ * params: { slug: entry.slug },
+ * }));
+ * }) satisfies GetStaticPaths;
+ *
+ * type Params = InferGetStaticParamsType<typeof getStaticPaths>;
+ * // ^? { slug: string; }
+ *
+ * const { slug } = Astro.params as Params;
+ * ```
+ */
+export type InferGetStaticParamsType<T> = T extends (
+ opts?: GetStaticPathsOptions,
+) => infer R | Promise<infer R>
+ ? R extends Array<infer U>
+ ? U extends { params: infer P }
+ ? P
+ : never
+ : never
+ : never;
+
+/**
+ * Infers the shape of the `props` property returned by `getStaticPaths()`.
+ *
+ * @example
+ * ```ts
+ * import type { GetStaticPaths } from 'astro';
+ *
+ * export const getStaticPaths = (() => {
+ * return results.map((entry) => ({
+ * params: { slug: entry.slug },
+ * props: {
+ * propA: true,
+ * propB: 42
+ * },
+ * }));
+ * }) satisfies GetStaticPaths;
+ *
+ * type Props = InferGetStaticPropsType<typeof getStaticPaths>;
+ * // ^? { propA: boolean; propB: number; }
+ *
+ * const { propA, propB } = Astro.props;
+ * ```
+ */
+export type InferGetStaticPropsType<T> = T extends (
+ opts: GetStaticPathsOptions,
+) => infer R | Promise<infer R>
+ ? R extends Array<infer U>
+ ? U extends { props: infer P }
+ ? P
+ : never
+ : never
+ : never;
+
+export type Params = Record<string, string | undefined>;
+export type Props = Record<string, unknown>;
diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts
new file mode 100644
index 000000000..a2b8ab899
--- /dev/null
+++ b/packages/astro/src/types/public/config.ts
@@ -0,0 +1,2148 @@
+import type { OutgoingHttpHeaders } from 'node:http';
+import type {
+ RehypePlugins,
+ RemarkPlugins,
+ RemarkRehype,
+ ShikiConfig,
+} from '@astrojs/markdown-remark';
+import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
+import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
+import type { ImageFit, ImageLayout } from '../../assets/types.js';
+import type { RemotePattern } from '../../assets/utils/remotePattern.js';
+import type { SvgRenderMode } from '../../assets/utils/svg.js';
+import type { AssetsPrefix } from '../../core/app/types.js';
+import type { AstroConfigType } from '../../core/config/schema.js';
+import type { REDIRECT_STATUS_CODES } from '../../core/constants.js';
+import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js';
+import type { Logger, LoggerLevel } from '../../core/logger/core.js';
+import type { EnvSchema } from '../../env/schema.js';
+import type { AstroIntegration } from './integrations.js';
+export type Locales = (string | { codes: [string, ...string[]]; path: string })[];
+
+type NormalizeLocales<T extends Locales> = {
+ [K in keyof T]: T[K] extends string
+ ? T[K]
+ : T[K] extends { codes: Array<string> }
+ ? T[K]['codes'][number]
+ : never;
+}[number];
+
+export interface ImageServiceConfig<T extends Record<string, any> = Record<string, any>> {
+ entrypoint: 'astro/assets/services/sharp' | (string & {});
+ config?: T;
+}
+
+export type RuntimeMode = 'development' | 'production';
+
+export type ValidRedirectStatus = (typeof REDIRECT_STATUS_CODES)[number];
+
+export type RedirectConfig =
+ | string
+ | {
+ status: ValidRedirectStatus;
+ destination: string;
+ };
+
+export type ServerConfig = {
+ /**
+ * @name server.host
+ * @type {string | boolean}
+ * @default `false`
+ * @version 0.24.0
+ * @description
+ * Set which network IP addresses the dev server should listen on (i.e. non-localhost IPs).
+ * - `false` - do not expose on a network IP address
+ * - `true` - listen on all addresses, including LAN and public addresses
+ * - `[custom-address]` - expose on a network IP address at `[custom-address]`
+ */
+ host?: string | boolean;
+
+ /**
+ * @name server.port
+ * @type {number}
+ * @default `4321`
+ * @description
+ * Set which port the dev server should listen on.
+ *
+ * If the given port is already in use, Astro will automatically try the next available port.
+ */
+ port?: number;
+
+ /**
+ * @name server.headers
+ * @typeraw {OutgoingHttpHeaders}
+ * @default `{}`
+ * @version 1.7.0
+ * @description
+ * Set custom HTTP response headers to be sent in `astro dev` and `astro preview`.
+ */
+ headers?: OutgoingHttpHeaders;
+
+ /**
+ * @name server.open
+ * @type {string | boolean}
+ * @default `false`
+ * @version 4.1.0
+ * @description
+ * Controls whether the dev server should open in your browser window on startup.
+ *
+ * Pass a full URL string (e.g. "http://example.com") or a pathname (e.g. "/about") to specify the URL to open.
+ *
+ * ```js
+ * {
+ * server: { open: "/about" }
+ * }
+ * ```
+ */
+ open?: string | boolean;
+};
+
+export type SessionDriverName = BuiltinDriverName | 'custom' | 'test';
+
+interface CommonSessionConfig {
+ /**
+ * Configures the session cookie. If set to a string, it will be used as the cookie name.
+ * Alternatively, you can pass an object with additional options.
+ */
+ cookie?:
+ | string
+ | (Omit<AstroCookieSetOptions, 'httpOnly' | 'expires' | 'encode'> & { name?: string });
+
+ /**
+ * Default session duration in seconds. If not set, the session will be stored until deleted, or until the cookie expires.
+ */
+ ttl?: number;
+}
+
+interface BuiltinSessionConfig<TDriver extends keyof BuiltinDriverOptions>
+ extends CommonSessionConfig {
+ driver: TDriver;
+ options?: BuiltinDriverOptions[TDriver];
+}
+
+interface CustomSessionConfig extends CommonSessionConfig {
+ /** Entrypoint for a custom session driver */
+ driver: string;
+ options?: Record<string, unknown>;
+}
+
+interface TestSessionConfig extends CommonSessionConfig {
+ driver: 'test';
+ options: {
+ mockStorage: Storage;
+ };
+}
+
+export type SessionConfig<TDriver extends SessionDriverName> =
+ TDriver extends keyof BuiltinDriverOptions
+ ? BuiltinSessionConfig<TDriver>
+ : TDriver extends 'test'
+ ? TestSessionConfig
+ : CustomSessionConfig;
+
+export type ResolvedSessionConfig<TDriver extends SessionDriverName> = SessionConfig<TDriver> & {
+ driverModule?: () => Promise<{ default: () => Driver }>;
+};
+
+export interface ViteUserConfig extends OriginalViteUserConfig {
+ ssr?: ViteSSROptions;
+}
+
+// NOTE(fks): We choose to keep our hand-generated AstroUserConfig interface so that
+// we can add JSDoc-style documentation and link to the definition file in our repo.
+// However, Zod comes with the ability to auto-generate AstroConfig from the schema
+// above. If we ever get to the point where we no longer need the dedicated type,
+// consider replacing it with the following lines:
+// export interface AstroUserConfig extends z.input<typeof AstroConfigSchema> {
+// }
+
+/**
+ * Astro User Config
+ * Docs: https://docs.astro.build/reference/configuration-reference/
+ *
+ * Generics do not follow semver and may change at any time.
+ */ export interface AstroUserConfig<
+ TLocales extends Locales = never,
+ TSession extends SessionDriverName = never,
+> {
+ /**
+ * @docs
+ * @kind heading
+ * @name Top-Level Options
+ */
+
+ /**
+ * @docs
+ * @name site
+ * @type {string}
+ * @description
+ * Your final, deployed URL. Astro uses this full URL to generate your sitemap and canonical URLs in your final build. It is strongly recommended that you set this configuration to get the most out of Astro.
+ *
+ * ```js
+ * {
+ * site: 'https://www.my-site.dev'
+ * }
+ * ```
+ */
+ site?: string;
+
+ /**
+ * @docs
+ * @name base
+ * @type {string}
+ * @description
+ * The base path to deploy to. Astro will use this path as the root for your pages and assets both in development and in production build.
+ *
+ * In the example below, `astro dev` will start your server at `/docs`.
+ *
+ * ```js
+ * {
+ * base: '/docs'
+ * }
+ * ```
+ *
+ * When using this option, all of your static asset imports and URLs should add the base as a prefix. You can access this value via `import.meta.env.BASE_URL`.
+ *
+ * The value of `import.meta.env.BASE_URL` will be determined by your `trailingSlash` config, no matter what value you have set for `base`.
+ *
+ * A trailing slash is always included if `trailingSlash: "always"` is set. If `trailingSlash: "never"` is set, `BASE_URL` will not include a trailing slash, even if `base` includes one.
+ *
+ * Additionally, Astro will internally manipulate the configured value of `config.base` before making it available to integrations. The value of `config.base` as read by integrations will also be determined by your `trailingSlash` configuration in the same way.
+ *
+ * In the example below, the values of `import.meta.env.BASE_URL` and `config.base` when processed will both be `/docs`:
+ * ```js
+ * {
+ * base: '/docs/',
+ * trailingSlash: "never"
+ * }
+ * ```
+ *
+ * In the example below, the values of `import.meta.env.BASE_URL` and `config.base` when processed will both be `/docs/`:
+ *
+ * ```js
+ * {
+ * base: '/docs',
+ * trailingSlash: "always"
+ * }
+ * ```
+ */
+ base?: string;
+
+ /**
+ * @docs
+ * @name trailingSlash
+ * @type {('always' | 'never' | 'ignore')}
+ * @default `'ignore'`
+ * @see build.format
+ * @description
+ *
+ * Set the route matching behavior for trailing slashes in the dev server and on-demand rendered pages. Choose from the following options:
+ * - `'ignore'` - Match URLs regardless of whether a trailing "/" exists. Requests for "/about" and "/about/" will both match the same route.
+ * - `'always'` - Only match URLs that include a trailing slash (e.g: "/about/"). In production, requests for on-demand rendered URLs without a trailing slash will be redirected to the correct URL for your convenience. However, in development, they will display a warning page reminding you that you have `always` configured.
+ * - `'never'` - Only match URLs that do not include a trailing slash (e.g: "/about"). In production, requests for on-demand rendered URLs with a trailing slash will be redirected to the correct URL for your convenience. However, in development, they will display a warning page reminding you that you have `never` configured.
+ *
+ * When redirects occur in production for GET requests, the redirect will be a 301 (permanent) redirect. For all other request methods, it will be a 308 (permanent, and preserve the request method) redirect.
+ *
+ * Trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration.
+ * See your hosting platform's documentation for more information.
+ *
+ * ```js
+ * {
+ * // Example: Require a trailing slash during development
+ * trailingSlash: 'always'
+ * }
+ * ```
+ */
+ trailingSlash?: 'always' | 'never' | 'ignore';
+
+ /**
+ * @docs
+ * @name redirects
+ * @type {Record<string, RedirectConfig>}
+ * @default `{}`
+ * @version 2.9.0
+ * @description Specify a mapping of redirects where the key is the route to match
+ * and the value is the path to redirect to.
+ *
+ * You can redirect both static and dynamic routes, but only to the same kind of route.
+ * For example, you cannot have a `'/article': '/blog/[...slug]'` redirect.
+ *
+ *
+ * ```js
+ * export default defineConfig({
+ * redirects: {
+ * '/old': '/new',
+ * '/blog/[...slug]': '/articles/[...slug]',
+ * '/about': 'https://example.com/about',
+ * '/news': {
+ * status: 302,
+ * destination: 'https://example.com/news'
+ * }
+ * }
+ * })
+ * ```
+ *
+ *
+ * For statically-generated sites with no adapter installed, this will produce a client redirect using a [`<meta http-equiv="refresh">` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) and does not support status codes.
+ *
+ * When using SSR or with a static adapter in `output: static`
+ * mode, status codes are supported.
+ * Astro will serve redirected GET requests with a status of `301`
+ * and use a status of `308` for any other request method.
+ *
+ * You can customize the [redirection status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages) using an object in the redirect config:
+ *
+ * ```js
+ * export default defineConfig({
+ * redirects: {
+ * '/other': {
+ * status: 302,
+ * destination: '/place',
+ * },
+ * }
+ * })
+ * ```
+ */
+ redirects?: Record<string, RedirectConfig>;
+
+ /**
+ * @docs
+ * @name output
+ * @type {('static' | 'server')}
+ * @default `'static'`
+ * @see adapter
+ * @description
+ *
+ * Specifies the output target for builds.
+ *
+ * - `'static'` - Prerender all your pages by default, outputting a completely static site if none of your pages opt out of prerendering.
+ * - `'server'` - Use server-side rendering (SSR) for all pages by default, always outputting a server-rendered site.
+ *
+ * ```js
+ * import { defineConfig } from 'astro/config';
+ *
+ * export default defineConfig({
+ * output: 'static'
+ * })
+ * ```
+ */
+ output?: 'static' | 'server';
+
+ /**
+ * @docs
+ * @name adapter
+ * @typeraw {AstroIntegration}
+ * @see output
+ * @description
+ *
+ * Deploy to your favorite server, serverless, or edge host with build adapters. Import one of our first-party adapters for [Netlify](https://docs.astro.build/en/guides/deploy/netlify/#adapter-for-ssr), [Vercel](https://docs.astro.build/en/guides/deploy/vercel/#adapter-for-ssr), and more to engage Astro SSR.
+ *
+ * [See our On-demand Rendering guide](https://docs.astro.build/en/guides/on-demand-rendering/) for more on SSR, and [our deployment guides](https://docs.astro.build/en/guides/deploy/) for a complete list of hosts.
+ *
+ * ```js
+ * import netlify from '@astrojs/netlify';
+ * {
+ * // Example: Build for Netlify serverless deployment
+ * adapter: netlify(),
+ * }
+ * ```
+ */
+ adapter?: AstroIntegration;
+
+ /**
+ * @docs
+ * @name integrations
+ * @typeraw {AstroIntegration[]}
+ * @description
+ *
+ * Extend Astro with custom integrations. Integrations are your one-stop-shop for adding framework support (like Solid.js), new features (like sitemaps), and new libraries (like Partytown).
+ *
+ * Read our [Integrations Guide](https://docs.astro.build/en/guides/integrations-guide/) for help getting started with Astro Integrations.
+ *
+ * ```js
+ * import react from '@astrojs/react';
+ * import mdx from '@astrojs/mdx';
+ * {
+ * // Example: Add React + MDX support to Astro
+ * integrations: [react(), mdx()]
+ * }
+ * ```
+ */
+ integrations?: Array<
+ AstroIntegration | (AstroIntegration | false | undefined | null)[] | false | undefined | null
+ >;
+
+ /**
+ * @docs
+ * @name root
+ * @cli --root
+ * @type {string}
+ * @default `"."` (current working directory)
+ * @summary Set the project root. The project root is the directory where your Astro project (and all `src`, `public` and `package.json` files) live.
+ * @description You should only provide this option if you run the `astro` CLI commands in a directory other than the project root directory. Usually, this option is provided via the CLI instead of the Astro config file, since Astro needs to know your project root before it can locate your config file.
+ *
+ * If you provide a relative path (ex: `--root: './my-project'`) Astro will resolve it against your current working directory.
+ *
+ * #### Examples
+ *
+ * ```js
+ * {
+ * root: './my-project-directory'
+ * }
+ * ```
+ * ```bash
+ * $ astro build --root ./my-project-directory
+ * ```
+ */
+ root?: string;
+
+ /**
+ * @docs
+ * @name srcDir
+ * @type {string}
+ * @default `"./src"`
+ * @description Set the directory that Astro will read your site from.
+ *
+ * The value can be either an absolute file system path or a path relative to the project root.
+ *
+ * ```js
+ * {
+ * srcDir: './www'
+ * }
+ * ```
+ */
+ srcDir?: string;
+
+ /**
+ * @docs
+ * @name publicDir
+ * @type {string}
+ * @default `"./public"`
+ * @description
+ * Set the directory for your static assets. Files in this directory are served at `/` during dev and copied to your build directory during build. These files are always served or copied as-is, without transform or bundling.
+ *
+ * The value can be either an absolute file system path or a path relative to the project root.
+ *
+ * ```js
+ * {
+ * publicDir: './my-custom-publicDir-directory'
+ * }
+ * ```
+ */
+ publicDir?: string;
+
+ /**
+ * @docs
+ * @name outDir
+ * @type {string}
+ * @default `"./dist"`
+ * @see build.server
+ * @description Set the directory that `astro build` writes your final build to.
+ *
+ * The value can be either an absolute file system path or a path relative to the project root.
+ *
+ * ```js
+ * {
+ * outDir: './my-custom-build-directory'
+ * }
+ * ```
+ */
+ outDir?: string;
+
+ /**
+ * @docs
+ * @name cacheDir
+ * @type {string}
+ * @default `"./node_modules/.astro"`
+ * @description Set the directory for caching build artifacts. Files in this directory will be used in subsequent builds to speed up the build time.
+ *
+ * The value can be either an absolute file system path or a path relative to the project root.
+ *
+ * ```js
+ * {
+ * cacheDir: './my-custom-cache-directory'
+ * }
+ * ```
+ */
+ cacheDir?: string;
+
+ /**
+ * @docs
+ * @name compressHTML
+ * @type {boolean}
+ * @default `true`
+ * @description
+ *
+ * This is an option to minify your HTML output and reduce the size of your HTML files.
+ *
+ * By default, Astro removes whitespace from your HTML, including line breaks, from `.astro` components in a lossless manner.
+ * Some whitespace may be kept as needed to preserve the visual rendering of your HTML. This occurs both in development mode and in the final build.
+ *
+ * To disable HTML compression, set `compressHTML` to false.
+ *
+ * ```js
+ * {
+ * compressHTML: false
+ * }
+ * ```
+ */
+ compressHTML?: boolean;
+
+ /**
+ * @docs
+ * @name scopedStyleStrategy
+ * @type {('where' | 'class' | 'attribute')}
+ * @default `'attribute'`
+ * @version 2.4
+ * @description
+ *
+ * Specify the strategy used for scoping styles within Astro components. Choose from:
+ * - `'where'` - Use `:where` selectors, causing no specificity increase.
+ * - `'class'` - Use class-based selectors, causing a +1 specificity increase.
+ * - `'attribute'` - Use `data-` attributes, causing a +1 specificity increase.
+ *
+ * Using `'class'` is helpful when you want to ensure that element selectors within an Astro component override global style defaults (e.g. from a global stylesheet).
+ * Using `'where'` gives you more control over specificity, but requires that you use higher-specificity selectors, layers, and other tools to control which selectors are applied.
+ * Using `'attribute'` is useful when you are manipulating the `class` attribute of elements and need to avoid conflicts between your own styling logic and Astro's application of styles.
+ */
+ scopedStyleStrategy?: 'where' | 'class' | 'attribute';
+
+ /**
+ * @docs
+ * @name security
+ * @type {Record<"checkOrigin", boolean> | undefined}
+ * @default `{checkOrigin: true}`
+ * @version 4.9.0
+ * @description
+ *
+ * Enables security measures for an Astro website.
+ *
+ * These features only exist for pages rendered on demand (SSR) using `server` mode or pages that opt out of prerendering in `static` mode.
+ *
+ * By default, Astro will automatically check that the “origin” header
+ * matches the URL sent by each request in on-demand rendered pages. You can
+ * disable this behavior by setting `checkOrigin` to `false`:
+ *
+ * ```js
+ * // astro.config.mjs
+ * export default defineConfig({
+ * output: "server",
+ * security: {
+ * checkOrigin: false
+ * }
+ * })
+ * ```
+ */
+ security?: {
+ /**
+ * @docs
+ * @name security.checkOrigin
+ * @kind h4
+ * @type {boolean}
+ * @default `true`
+ * @version 4.9.0
+ * @description
+ *
+ * Performs a check that the "origin" header, automatically passed by all modern browsers, matches the URL sent by each `Request`. This is used to provide Cross-Site Request Forgery (CSRF) protection.
+ *
+ * The "origin" check is executed only for pages rendered on demand, and only for the requests `POST`, `PATCH`, `DELETE` and `PUT` with
+ * one of the following `content-type` headers: `'application/x-www-form-urlencoded'`, `'multipart/form-data'`, `'text/plain'`.
+ *
+ * If the "origin" header doesn't match the `pathname` of the request, Astro will return a 403 status code and will not render the page.
+ */
+
+ checkOrigin?: boolean;
+ };
+
+ /**
+ * @docs
+ * @name vite
+ * @typeraw {ViteUserConfig}
+ * @description
+ *
+ * Pass additional configuration options to Vite. Useful when Astro doesn't support some advanced configuration that you may need.
+ *
+ * View the full `vite` configuration object documentation on [vite.dev](https://vite.dev/config/).
+ *
+ * #### Examples
+ *
+ * ```js
+ * {
+ * vite: {
+ * ssr: {
+ * // Example: Force a broken package to skip SSR processing, if needed
+ * external: ['broken-npm-package'],
+ * }
+ * }
+ * }
+ * ```
+ *
+ * ```js
+ * {
+ * vite: {
+ * // Example: Add custom vite plugins directly to your Astro project
+ * plugins: [myPlugin()],
+ * }
+ * }
+ * ```
+ */
+ vite?: ViteUserConfig;
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Build Options
+ */
+ build?: {
+ /**
+ * @docs
+ * @name build.format
+ * @typeraw {('file' | 'directory' | 'preserve')}
+ * @default `'directory'`
+ * @description
+ * Control the output file format of each page. This value may be set by an adapter for you.
+ * - `'file'`: Astro will generate an HTML file named for each page route. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about.html`)
+ * - `'directory'`: Astro will generate a directory with a nested `index.html` file for each page. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about/index.html`)
+ * - `'preserve'`: Astro will generate HTML files exactly as they appear in your source folder. (e.g. `src/pages/about.astro` builds `/about.html` and `src/pages/about/index.astro` builds the file `/about/index.html`)
+ *
+ * ```js
+ * {
+ * build: {
+ * // Example: Generate `page.html` instead of `page/index.html` during build.
+ * format: 'file'
+ * }
+ * }
+ * ```
+ *
+ *
+ *
+ * #### Effect on Astro.url
+ * Setting `build.format` controls what `Astro.url` is set to during the build. When it is:
+ * - `directory` - The `Astro.url.pathname` will include a trailing slash to mimic folder behavior. (e.g. `/foo/`)
+ * - `file` - The `Astro.url.pathname` will include `.html`. (e.g. `/foo.html`)
+ *
+ * This means that when you create relative URLs using `new URL('./relative', Astro.url)`, you will get consistent behavior between dev and build.
+ *
+ * To prevent inconsistencies with trailing slash behaviour in dev, you can restrict the [`trailingSlash` option](#trailingslash) to `'always'` or `'never'` depending on your build format:
+ * - `directory` - Set `trailingSlash: 'always'`
+ * - `file` - Set `trailingSlash: 'never'`
+ */
+ format?: 'file' | 'directory' | 'preserve';
+ /**
+ * @docs
+ * @name build.client
+ * @type {string}
+ * @default `'./client'`
+ * @description
+ * Controls the output directory of your client-side CSS and JavaScript when building a website with server-rendered pages.
+ * `outDir` controls where the code is built to.
+ *
+ * This value is relative to the `outDir`.
+ *
+ * ```js
+ * {
+ * output: 'server',
+ * build: {
+ * client: './client'
+ * }
+ * }
+ * ```
+ */
+ client?: string;
+ /**
+ * @docs
+ * @name build.server
+ * @type {string}
+ * @default `'./server'`
+ * @description
+ * Controls the output directory of server JavaScript when building to SSR.
+ *
+ * This value is relative to the `outDir`.
+ *
+ * ```js
+ * {
+ * build: {
+ * server: './server'
+ * }
+ * }
+ * ```
+ */
+ server?: string;
+ /**
+ * @docs
+ * @name build.assets
+ * @type {string}
+ * @default `'_astro'`
+ * @see outDir
+ * @version 2.0.0
+ * @description
+ * Specifies the directory in the build output where Astro-generated assets (bundled JS and CSS for example) should live.
+ *
+ * ```js
+ * {
+ * build: {
+ * assets: '_custom'
+ * }
+ * }
+ * ```
+ */
+ assets?: string;
+ /**
+ * @docs
+ * @name build.assetsPrefix
+ * @type {string | Record<string, string>}
+ * @default `undefined`
+ * @version 2.2.0
+ * @description
+ * Specifies the prefix for Astro-generated asset links. This can be used if assets are served from a different domain than the current site.
+ *
+ * This requires uploading the assets in your local `./dist/_astro` folder to a corresponding `/_astro/` folder on the remote domain.
+ * To rename the `_astro` path, specify a new directory in `build.assets`.
+ *
+ * To fetch all assets uploaded to the same domain (e.g. `https://cdn.example.com/_astro/...`), set `assetsPrefix` to the root domain as a string (regardless of your `base` configuration):
+ *
+ * ```js
+ * {
+ * build: {
+ * assetsPrefix: 'https://cdn.example.com'
+ * }
+ * }
+ * ```
+ *
+ * **Added in:** `astro@4.5.0`
+ *
+ * You can also pass an object to `assetsPrefix` to specify a different domain for each file type.
+ * In this case, a `fallback` property is required and will be used by default for any other files.
+ *
+ * ```js
+ * {
+ * build: {
+ * assetsPrefix: {
+ * 'js': 'https://js.cdn.example.com',
+ * 'mjs': 'https://js.cdn.example.com',
+ * 'css': 'https://css.cdn.example.com',
+ * 'fallback': 'https://cdn.example.com'
+ * }
+ * }
+ * }
+ * ```
+ *
+ */
+ assetsPrefix?: AssetsPrefix;
+ /**
+ * @docs
+ * @name build.serverEntry
+ * @type {string}
+ * @default `'entry.mjs'`
+ * @description
+ * Specifies the file name of the server entrypoint when building to SSR.
+ * This entrypoint is usually dependent on which host you are deploying to and
+ * will be set by your adapter for you.
+ *
+ * Note that it is recommended that this file ends with `.mjs` so that the runtime
+ * detects that the file is a JavaScript module.
+ *
+ * ```js
+ * {
+ * build: {
+ * serverEntry: 'main.mjs'
+ * }
+ * }
+ * ```
+ */
+ serverEntry?: string;
+ /**
+ * @docs
+ * @name build.redirects
+ * @type {boolean}
+ * @default `true`
+ * @version 2.6.0
+ * @description
+ * Specifies whether redirects will be output to HTML during the build.
+ * This option only applies to `output: 'static'` mode; in SSR redirects
+ * are treated the same as all responses.
+ *
+ * This option is mostly meant to be used by adapters that have special
+ * configuration files for redirects and do not need/want HTML based redirects.
+ *
+ * ```js
+ * {
+ * build: {
+ * redirects: false
+ * }
+ * }
+ * ```
+ */
+ redirects?: boolean;
+ /**
+ * @docs
+ * @name build.inlineStylesheets
+ * @type {('always' | 'auto' | 'never')}
+ * @default `auto`
+ * @version 2.6.0
+ * @description
+ * Control whether project styles are sent to the browser in a separate css file or inlined into `<style>` tags. Choose from the following options:
+ * - `'always'` - project styles are inlined into `<style>` tags
+ * - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, project styles are sent in external stylesheets.
+ * - `'never'` - project styles are sent in external stylesheets
+ *
+ * ```js
+ * {
+ * build: {
+ * inlineStylesheets: `never`,
+ * },
+ * }
+ * ```
+ */
+ inlineStylesheets?: 'always' | 'auto' | 'never';
+ /**
+ * @docs
+ * @name build.concurrency
+ * @type { number }
+ * @default `1`
+ * @version 4.16.0
+ * @description
+ * The number of pages to build in parallel.
+ *
+ * **In most cases, you should not change the default value of `1`.**
+ *
+ * Use this option only when other attempts to reduce the overall rendering time (e.g. batch or cache long running tasks like fetch calls or data access) are not possible or are insufficient.
+ * If the number is set too high, page rendering may slow down due to insufficient memory resources and because JS is single-threaded.
+ *
+ * ```js
+ * {
+ * build: {
+ * concurrency: 2
+ * }
+ * }
+ * ```
+ *
+ * :::caution[Breaking changes possible]
+ * This feature is stable and is not considered experimental. However, this feature is only intended to address difficult performance issues, and breaking changes may occur in a [minor release](https://docs.astro.build/en/upgrade-astro/#semantic-versioning) to keep this option as performant as possible. Please check the [Astro CHANGELOG](https://github.com/withastro/astro/blob/refs/heads/next/packages/astro/CHANGELOG.md) for every minor release if you are using this feature.
+ * :::
+ */
+ concurrency?: number;
+ };
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Server Options
+ * @description
+ *
+ * Customize the Astro dev server, used by both `astro dev` and `astro preview`.
+ *
+ * ```js
+ * {
+ * server: { port: 1234, host: true}
+ * }
+ * ```
+ *
+ * To set different configuration based on the command run ("dev", "preview") a function can also be passed to this configuration option.
+ *
+ * ```js
+ * {
+ * // Example: Use the function syntax to customize based on command
+ * server: ({ command }) => ({ port: command === 'dev' ? 4321 : 4000 })
+ * }
+ * ```
+ */
+
+ /**
+ * @docs
+ * @name server.host
+ * @type {string | boolean}
+ * @default `false`
+ * @version 0.24.0
+ * @description
+ * Set which network IP addresses the server should listen on (i.e. non-localhost IPs).
+ * - `false` - do not expose on a network IP address
+ * - `true` - listen on all addresses, including LAN and public addresses
+ * - `[custom-address]` - expose on a network IP address at `[custom-address]` (ex: `192.168.0.1`)
+ */
+
+ /**
+ * @docs
+ * @name server.port
+ * @type {number}
+ * @default `4321`
+ * @description
+ * Set which port the server should listen on.
+ *
+ * If the given port is already in use, Astro will automatically try the next available port.
+ *
+ * ```js
+ * {
+ * server: { port: 8080 }
+ * }
+ * ```
+ */
+
+ /**
+ * @docs
+ * @name server.open
+ * @type {string | boolean}
+ * @default `false`
+ * @version 4.1.0
+ * @description
+ * Controls whether the dev server should open in your browser window on startup.
+ *
+ * Pass a full URL string (e.g. "http://example.com") or a pathname (e.g. "/about") to specify the URL to open.
+ *
+ * ```js
+ * {
+ * server: { open: "/about" }
+ * }
+ * ```
+ */
+
+ /**
+ * @docs
+ * @name server.headers
+ * @typeraw {OutgoingHttpHeaders}
+ * @default `{}`
+ * @version 1.7.0
+ * @description
+ * Set custom HTTP response headers to be sent in `astro dev` and `astro preview`.
+ */
+
+ server?: ServerConfig | ((options: { command: 'dev' | 'preview' }) => ServerConfig);
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Dev Toolbar Options
+ */
+ devToolbar?: {
+ /**
+ * @docs
+ * @name devToolbar.enabled
+ * @type {boolean}
+ * @default `true`
+ * @description
+ * Whether to enable the Astro Dev Toolbar. This toolbar allows you to inspect your page islands, see helpful audits on performance and accessibility, and more.
+ *
+ * This option is scoped to the entire project, to only disable the toolbar for yourself, run `npm run astro preferences disable devToolbar`. To disable the toolbar for all your Astro projects, run `npm run astro preferences disable devToolbar --global`.
+ */
+ enabled: boolean;
+ };
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Prefetch Options
+ * @type {boolean | object}
+ * @description
+ * Enable prefetching for links on your site to provide faster page transitions.
+ * (Enabled by default on pages using the `<ClientRouter />` router. Set `prefetch: false` to opt out of this behaviour.)
+ *
+ * This configuration automatically adds a prefetch script to every page in the project
+ * giving you access to the `data-astro-prefetch` attribute.
+ * Add this attribute to any `<a />` link on your page to enable prefetching for that page.
+ *
+ * ```html
+ * <a href="/about" data-astro-prefetch>About</a>
+ * ```
+ * Further customize the default prefetching behavior using the [`prefetch.defaultStrategy`](#prefetchdefaultstrategy) and [`prefetch.prefetchAll`](#prefetchprefetchall) options.
+ *
+ * See the [Prefetch guide](https://docs.astro.build/en/guides/prefetch/) for more information.
+ */
+ prefetch?:
+ | boolean
+ | {
+ /**
+ * @docs
+ * @name prefetch.prefetchAll
+ * @type {boolean}
+ * @description
+ * Enable prefetching for all links, including those without the `data-astro-prefetch` attribute.
+ * This value defaults to `true` when using the `<ClientRouter />` router. Otherwise, the default value is `false`.
+ *
+ * ```js
+ * prefetch: {
+ * prefetchAll: true
+ * }
+ * ```
+ *
+ * When set to `true`, you can disable prefetching individually by setting `data-astro-prefetch="false"` on any individual links.
+ *
+ * ```html
+ * <a href="/about" data-astro-prefetch="false">About</a>
+ *```
+ */
+ prefetchAll?: boolean;
+
+ /**
+ * @docs
+ * @name prefetch.defaultStrategy
+ * @type {'tap' | 'hover' | 'viewport' | 'load'}
+ * @default `'hover'`
+ * @description
+ * The default prefetch strategy to use when the `data-astro-prefetch` attribute is set on a link with no value.
+ *
+ * - `'tap'`: Prefetch just before you click on the link.
+ * - `'hover'`: Prefetch when you hover over or focus on the link. (default)
+ * - `'viewport'`: Prefetch as the links enter the viewport.
+ * - `'load'`: Prefetch all links on the page after the page is loaded.
+ *
+ * You can override this default value and select a different strategy for any individual link by setting a value on the attribute.
+ *
+ * ```html
+ * <a href="/about" data-astro-prefetch="viewport">About</a>
+ * ```
+ */
+ defaultStrategy?: 'tap' | 'hover' | 'viewport' | 'load';
+ };
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Image Options
+ */
+ image?: {
+ /**
+ * @docs
+ * @name image.endpoint
+ * @type {{route: string, entrypoint: undefined | string}}
+ * @default `{route: '/_image', entrypoint: undefined}`
+ * @version 3.1.0
+ * @description
+ * Set the endpoint to use for image optimization in dev and SSR. The `entrypoint` property can be set to `undefined` to use the default image endpoint.
+ *
+ * ```js
+ * {
+ * image: {
+ * // Example: Use a custom image endpoint at `/custom_endpoint`
+ * endpoint: {
+ * route: '/custom_endpoint',
+ * entrypoint: 'src/my_endpoint.ts',
+ * },
+ * },
+ * }
+ * ```
+ */
+ endpoint?: {
+ route: '/_image' | (string & {});
+ entrypoint: undefined | string;
+ };
+
+ /**
+ * @docs
+ * @name image.service
+ * @type {{entrypoint: 'astro/assets/services/sharp' | string, config: Record<string, any>}}
+ * @default `{entrypoint: 'astro/assets/services/sharp', config?: {}}`
+ * @version 2.1.0
+ * @description
+ * Set which image service is used for Astro’s assets support.
+ *
+ * The value should be an object with an entrypoint for the image service to use and optionally, a config object to pass to the service.
+ *
+ * The service entrypoint can be either one of the included services, or a third-party package.
+ *
+ * ```js
+ * {
+ * image: {
+ * // Example: Enable the Sharp-based image service with a custom config
+ * service: {
+ * entrypoint: 'astro/assets/services/sharp',
+ * config: {
+ * limitInputPixels: false,
+ * },
+ * },
+ * },
+ * }
+ * ```
+ */
+ service?: ImageServiceConfig;
+ /**
+ * @docs
+ * @name image.service.config.limitInputPixels
+ * @kind h4
+ * @type {number | boolean}
+ * @default `true`
+ * @version 4.1.0
+ * @description
+ *
+ * Whether or not to limit the size of images that the Sharp image service will process.
+ *
+ * Set `false` to bypass the default image size limit for the Sharp image service and process large images.
+ */
+
+ /**
+ * @docs
+ * @name image.domains
+ * @type {string[]}
+ * @default `{domains: []}`
+ * @version 2.10.10
+ * @description
+ * Defines a list of permitted image source domains for remote image optimization. No other remote images will be optimized by Astro.
+ *
+ * This option requires an array of individual domain names as strings. Wildcards are not permitted. Instead, use [`image.remotePatterns`](#imageremotepatterns) to define a list of allowed source URL patterns.
+ *
+ * ```js
+ * // astro.config.mjs
+ * {
+ * image: {
+ * // Example: Allow remote image optimization from a single domain
+ * domains: ['astro.build'],
+ * },
+ * }
+ * ```
+ */
+ domains?: string[];
+
+ /**
+ * @docs
+ * @name image.remotePatterns
+ * @type {RemotePattern[]}
+ * @default `{remotePatterns: []}`
+ * @version 2.10.10
+ * @description
+ * Defines a list of permitted image source URL patterns for remote image optimization.
+ *
+ * `remotePatterns` can be configured with four properties:
+ * 1. protocol
+ * 2. hostname
+ * 3. port
+ * 4. pathname
+ *
+ * ```js
+ * {
+ * image: {
+ * // Example: allow processing all images from your aws s3 bucket
+ * remotePatterns: [{
+ * protocol: 'https',
+ * hostname: '**.amazonaws.com',
+ * }],
+ * },
+ * }
+ * ```
+ *
+ * You can use wildcards to define the permitted `hostname` and `pathname` values as described below. Otherwise, only the exact values provided will be configured:
+ * `hostname`:
+ * - Start with '**.' to allow all subdomains ('endsWith').
+ * - Start with '*.' to allow only one level of subdomain.
+ *
+ * `pathname`:
+ * - End with '/**' to allow all sub-routes ('startsWith').
+ * - End with '/*' to allow only one level of sub-route.
+
+ */
+ remotePatterns?: Partial<RemotePattern>[];
+
+ /**
+ * @docs
+ * @name image.experimentalLayout
+ * @type {ImageLayout}
+ * @default `undefined`
+ * @description
+ * The default layout type for responsive images. Can be overridden by the `layout` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ * - `responsive` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
+ * - `fixed` - The image will maintain its original dimensions.
+ * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio.
+ */
+ experimentalLayout?: ImageLayout | undefined;
+ /**
+ * @docs
+ * @name image.experimentalObjectFit
+ * @type {ImageFit}
+ * @default `"cover"`
+ * @description
+ * The default object-fit value for responsive images. Can be overridden by the `fit` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ */
+ experimentalObjectFit?: ImageFit;
+ /**
+ * @docs
+ * @name image.experimentalObjectPosition
+ * @type {string}
+ * @default `"center"`
+ * @description
+ * The default object-position value for responsive images. Can be overridden by the `position` prop on the image component.
+ * Requires the `experimental.responsiveImages` flag to be enabled.
+ */
+ experimentalObjectPosition?: string;
+ /**
+ * @docs
+ * @name image.experimentalBreakpoints
+ * @type {number[]}
+ * @default `[640, 750, 828, 1080, 1280, 1668, 2048, 2560] | [640, 750, 828, 960, 1080, 1280, 1668, 1920, 2048, 2560, 3200, 3840, 4480, 5120, 6016]`
+ * @description
+ * The breakpoints used to generate responsive images. Requires the `experimental.responsiveImages` flag to be enabled. The full list is not normally used,
+ * but is filtered according to the source and output size. The defaults used depend on whether a local or remote image service is used. For remote services
+ * the more comprehensive list is used, because only the required sizes are generated. For local services, the list is shorter to reduce the number of images generated.
+ */
+ experimentalBreakpoints?: number[];
+ };
+
+ /**
+ * @docs
+ * @kind heading
+ * @name Markdown Options
+ */
+ markdown?: {
+ /**
+ * @docs
+ * @name markdown.shikiConfig
+ * @typeraw {Partial<ShikiConfig>}
+ * @description
+ *
+ * Shiki is our default syntax highlighter. You can configure all options via the `markdown.shikiConfig` object:
+ *
+ * ```js title="astro.config.mjs"
+ * import { defineConfig } from 'astro/config';
+ *
+ * export default defineConfig({
+ * markdown: {
+ * shikiConfig: {
+ * // Choose from Shiki's built-in themes (or add your own)
+ * // https://shiki.style/themes
+ * theme: 'dracula',
+ * // Alternatively, provide multiple themes
+ * // See note below for using dual light/dark themes
+ * themes: {
+ * light: 'github-light',
+ * dark: 'github-dark',
+ * },
+ * // Disable the default colors
+ * // https://shiki.style/guide/dual-themes#without-default-color
+ * // (Added in v4.12.0)
+ * defaultColor: false,
+ * // Add custom languages
+ * // Note: Shiki has countless langs built-in, including .astro!
+ * // https://shiki.style/languages
+ * langs: [],
+ * // Add custom aliases for languages
+ * // Map an alias to a Shiki language ID: https://shiki.style/languages#bundled-languages
+ * // https://shiki.style/guide/load-lang#custom-language-aliases
+ * langAlias: {
+ * cjs: "javascript"
+ * },
+ * // Enable word wrap to prevent horizontal scrolling
+ * wrap: true,
+ * // Add custom transformers: https://shiki.style/guide/transformers
+ * // Find common transformers: https://shiki.style/packages/transformers
+ * transformers: [],
+ * },
+ * },
+ * });
+ * ```
+ *
+ * See the [code syntax highlighting guide](/en/guides/syntax-highlighting/) for usage and examples.
+ */
+ shikiConfig?: Partial<ShikiConfig>;
+
+ /**
+ * @docs
+ * @name markdown.syntaxHighlight
+ * @type {'shiki' | 'prism' | false}
+ * @default `shiki`
+ * @description
+ * Which syntax highlighter to use for Markdown code blocks (\`\`\`), if any. This determines the CSS classes that Astro will apply to your Markdown code blocks.
+ * - `shiki` - use the [Shiki](https://shiki.style) highlighter (`github-dark` theme configured by default)
+ * - `prism` - use the [Prism](https://prismjs.com/) highlighter and [provide your own Prism stylesheet](/en/guides/syntax-highlighting/#add-a-prism-stylesheet)
+ * - `false` - do not apply syntax highlighting.
+ *
+ * ```js
+ * {
+ * markdown: {
+ * // Example: Switch to use prism for syntax highlighting in Markdown
+ * syntaxHighlight: 'prism',
+ * }
+ * }
+ * ```
+ */
+ syntaxHighlight?: 'shiki' | 'prism' | false;
+
+ /**
+ * @docs
+ * @name markdown.remarkPlugins
+ * @type {RemarkPlugins}
+ * @description
+ * Pass [remark plugins](https://github.com/remarkjs/remark) to customize how your Markdown is built. You can import and apply the plugin function (recommended), or pass the plugin name as a string.
+ *
+ * ```js
+ * import remarkToc from 'remark-toc';
+ * {
+ * markdown: {
+ * remarkPlugins: [ [remarkToc, { heading: "contents"} ] ]
+ * }
+ * }
+ * ```
+ */
+ remarkPlugins?: RemarkPlugins;
+ /**
+ * @docs
+ * @name markdown.rehypePlugins
+ * @type {RehypePlugins}
+ * @description
+ * Pass [rehype plugins](https://github.com/remarkjs/remark-rehype) to customize how your Markdown's output HTML is processed. You can import and apply the plugin function (recommended), or pass the plugin name as a string.
+ *
+ * ```js
+ * import { rehypeAccessibleEmojis } from 'rehype-accessible-emojis';
+ * {
+ * markdown: {
+ * rehypePlugins: [rehypeAccessibleEmojis]
+ * }
+ * }
+ * ```
+ */
+ rehypePlugins?: RehypePlugins;
+ /**
+ * @docs
+ * @name markdown.gfm
+ * @type {boolean}
+ * @default `true`
+ * @version 2.0.0
+ * @description
+ * Astro uses [GitHub-flavored Markdown](https://github.com/remarkjs/remark-gfm) by default. To disable this, set the `gfm` flag to `false`:
+ *
+ * ```js
+ * {
+ * markdown: {
+ * gfm: false,
+ * }
+ * }
+ * ```
+ */
+ gfm?: boolean;
+ /**
+ * @docs
+ * @name markdown.smartypants
+ * @type {boolean}
+ * @default `true`
+ * @version 2.0.0
+ * @description
+ * Astro uses the [SmartyPants formatter](https://daringfireball.net/projects/smartypants/) by default. To disable this, set the `smartypants` flag to `false`:
+ *
+ * ```js
+ * {
+ * markdown: {
+ * smartypants: false,
+ * }
+ * }
+ * ```
+ */
+ smartypants?: boolean;
+ /**
+ * @docs
+ * @name markdown.remarkRehype
+ * @type {RemarkRehype}
+ * @description
+ * Pass options to [remark-rehype](https://github.com/remarkjs/remark-rehype#api).
+ *
+ * ```js
+ * {
+ * markdown: {
+ * // Example: Translate the footnotes text to another language, here are the default English values
+ * remarkRehype: { footnoteLabel: "Footnotes", footnoteBackLabel: "Back to reference 1"},
+ * },
+ * };
+ * ```
+ */
+ remarkRehype?: RemarkRehype;
+ };
+
+ /**
+ * @docs
+ * @kind heading
+ * @name i18n
+ * @type {object}
+ * @version 3.5.0
+ * @type {object}
+ * @description
+ *
+ * Configures i18n routing and allows you to specify some customization options.
+ *
+ * See our guide for more information on [internationalization in Astro](/en/guides/internationalization/)
+ */
+ i18n?: {
+ /**
+ * @docs
+ * @name i18n.locales
+ * @type {Locales}
+ * @version 3.5.0
+ * @description
+ *
+ * A list of all locales supported by the website. This is a required field.
+ *
+ * Languages can be listed either as individual codes (e.g. `['en', 'es', 'pt-br']`) or mapped to a shared `path` of codes (e.g. `{ path: "english", codes: ["en", "en-US"]}`). These codes will be used to determine the URL structure of your deployed site.
+ *
+ * No particular language code format or syntax is enforced, but your project folders containing your content files must match exactly the `locales` items in the list. In the case of multiple `codes` pointing to a custom URL path prefix, store your content files in a folder with the same name as the `path` configured.
+ */
+ locales: [TLocales] extends [never] ? Locales : TLocales;
+
+ /**
+ * @docs
+ * @name i18n.defaultLocale
+ * @type {string}
+ * @version 3.5.0
+ * @description
+ *
+ * The default locale of your website/application, that is one of the specified `locales`. This is a required field.
+ *
+ * No particular language format or syntax is enforced, but we suggest using lower-case and hyphens as needed (e.g. "es", "pt-br") for greatest compatibility.
+ */
+ defaultLocale: [TLocales] extends [never] ? string : NormalizeLocales<NoInfer<TLocales>>;
+
+ /**
+ * @docs
+ * @name i18n.fallback
+ * @type {Record<string, string>}
+ * @version 3.5.0
+ * @description
+ *
+ * The fallback strategy when navigating to pages that do not exist (e.g. a translated page has not been created).
+ *
+ * Use this object to declare a fallback `locale` route for each language you support. If no fallback is specified, then unavailable pages will return a 404.
+ *
+ * ##### Example
+ *
+ * The following example configures your content fallback strategy to redirect unavailable pages in `/pt-br/` to their `es` version, and unavailable pages in `/fr/` to their `en` version. Unavailable `/es/` pages will return a 404.
+ *
+ * ```js
+ * export default defineConfig({
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr", "pt-br", "es"],
+ * fallback: {
+ * pt: "es",
+ * fr: "en"
+ * }
+ * }
+ * })
+ * ```
+ */
+ fallback?: [TLocales] extends [never]
+ ? Record<string, string>
+ : {
+ [Locale in NormalizeLocales<NoInfer<TLocales>>]?: Exclude<
+ NormalizeLocales<NoInfer<TLocales>>,
+ Locale
+ >;
+ };
+
+ /**
+ * @docs
+ * @name i18n.routing
+ * @type {Routing}
+ * @version 3.7.0
+ * @description
+ *
+ * Controls the routing strategy to determine your site URLs. Set this based on your folder/URL path configuration for your default language.
+ *
+ */
+ routing?:
+ | {
+ /**
+ * @docs
+ * @name i18n.routing.prefixDefaultLocale
+ * @kind h4
+ * @type {boolean}
+ * @default `false`
+ * @version 3.7.0
+ * @description
+ *
+ * When `false`, only non-default languages will display a language prefix.
+ * The `defaultLocale` will not show a language prefix and content files do not exist in a localized folder.
+ * URLs will be of the form `example.com/[locale]/content/` for all non-default languages, but `example.com/content/` for the default locale.
+ *
+ * When `true`, all URLs will display a language prefix.
+ * URLs will be of the form `example.com/[locale]/content/` for every route, including the default language.
+ * Localized folders are used for every language, including the default.
+ *
+ * ```js
+ * export default defineConfig({
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr", "pt-br", "es"],
+ * routing: {
+ * prefixDefaultLocale: true,
+ * }
+ * }
+ * })
+ * ```
+ */
+ prefixDefaultLocale?: boolean;
+
+ /**
+ * @docs
+ * @name i18n.routing.redirectToDefaultLocale
+ * @kind h4
+ * @type {boolean}
+ * @default `true`
+ * @version 4.2.0
+ * @description
+ *
+ * Configures whether or not the home URL (`/`) generated by `src/pages/index.astro`
+ * will redirect to `/[defaultLocale]` when `prefixDefaultLocale: true` is set.
+ *
+ * Set `redirectToDefaultLocale: false` to disable this automatic redirection at the root of your site:
+ * ```js
+ * // astro.config.mjs
+ * export default defineConfig({
+ * i18n:{
+ * defaultLocale: "en",
+ * locales: ["en", "fr"],
+ * routing: {
+ * prefixDefaultLocale: true,
+ * redirectToDefaultLocale: false
+ * }
+ * }
+ * })
+ *```
+ * */
+ redirectToDefaultLocale?: boolean;
+
+ /**
+ * @docs
+ * @name i18n.routing.fallbackType
+ * @kind h4
+ * @type {"redirect" | "rewrite"}
+ * @default `"redirect"`
+ * @version 4.15.0
+ * @description
+ *
+ * When [`i18n.fallback`](#i18nfallback) is configured to avoid showing a 404 page for missing page routes, this option controls whether to [redirect](https://docs.astro.build/en/guides/routing/#redirects) to the fallback page, or to [rewrite](https://docs.astro.build/en/guides/routing/#rewrites) the fallback page's content in place.
+ *
+ * By default, Astro's i18n routing creates pages that redirect your visitors to a new destination based on your fallback configuration. The browser will refresh and show the destination address in the URL bar.
+ *
+ * When `i18n.routing.fallback: "rewrite"` is configured, Astro will create pages that render the contents of the fallback page on the original, requested URL.
+ *
+ * With the following configuration, if you have the file `src/pages/en/about.astro` but not `src/pages/fr/about.astro`, the `astro build` command will generate `dist/fr/about.html` with the same content as the `dist/en/about.html` page.
+ * Your site visitor will see the English version of the page at `https://example.com/fr/about/` and will not be redirected.
+ *
+ * ```js
+ * //astro.config.mjs
+ * export default defineConfig({
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr"],
+ * routing: {
+ * prefixDefaultLocale: false,
+ * fallbackType: "rewrite",
+ * },
+ * fallback: {
+ * fr: "en",
+ * }
+ * },
+ * })
+ * ```
+ */
+ fallbackType?: 'redirect' | 'rewrite';
+
+ /**
+ * @name i18n.routing.strategy
+ * @type {"pathname"}
+ * @default `"pathname"`
+ * @version 3.7.0
+ * @description
+ *
+ * - `"pathname": The strategy is applied to the pathname of the URLs
+ */
+ strategy?: 'pathname';
+ }
+ /**
+ *
+ * @docs
+ * @name i18n.routing.manual
+ * @kind h4
+ * @type {string}
+ * @version 4.6.0
+ * @description
+ * When this option is enabled, Astro will **disable** its i18n middleware so that you can implement your own custom logic. No other `routing` options (e.g. `prefixDefaultLocale`) may be configured with `routing: "manual"`.
+ *
+ * You will be responsible for writing your own routing logic, or executing Astro's i18n middleware manually alongside your own.
+ *
+ * ```js
+ * export default defineConfig({
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr", "pt-br", "es"],
+ * routing: {
+ * prefixDefaultLocale: true,
+ * }
+ * }
+ * })
+ * ```
+ */
+ | 'manual';
+
+ /**
+ * @name i18n.domains
+ * @type {Record<string, string> }
+ * @default '{}'
+ * @version 4.3.0
+ * @description
+ *
+ * Configures the URL pattern of one or more supported languages to use a custom domain (or sub-domain).
+ *
+ * When a locale is mapped to a domain, a `/[locale]/` path prefix will not be used.
+ * However, localized folders within `src/pages/` are still required, including for your configured `defaultLocale`.
+ *
+ * Any other locale not configured will default to a localized path-based URL according to your `prefixDefaultLocale` strategy (e.g. `https://example.com/[locale]/blog`).
+ *
+ * ```js
+ * //astro.config.mjs
+ * export default defineConfig({
+ * site: "https://example.com",
+ * output: "server", // required, with no prerendered pages
+ * adapter: node({
+ * mode: 'standalone',
+ * }),
+ * i18n: {
+ * defaultLocale: "en",
+ * locales: ["en", "fr", "pt-br", "es"],
+ * prefixDefaultLocale: false,
+ * domains: {
+ * fr: "https://fr.example.com",
+ * es: "https://example.es"
+ * }
+ * },
+ * })
+ * ```
+ *
+ * Both page routes built and URLs returned by the `astro:i18n` helper functions [`getAbsoluteLocaleUrl()`](https://docs.astro.build/en/reference/api-reference/#getabsolutelocaleurl) and [`getAbsoluteLocaleUrlList()`](https://docs.astro.build/en/reference/api-reference/#getabsolutelocaleurllist) will use the options set in `i18n.domains`.
+ *
+ * See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains) for more details, including the limitations of this feature.
+ */
+ domains?: [TLocales] extends [never]
+ ? Record<string, string>
+ : Partial<Record<NormalizeLocales<NoInfer<TLocales>>, string>>;
+ };
+
+ /** ! WARNING: SUBJECT TO CHANGE */
+ db?: Config.Database;
+
+ /**
+ * @docs
+ * @kind heading
+ * @name env
+ * @type {object}
+ * @default `{}`
+ * @version 5.0.0
+ * @description
+ *
+ * Configuration options for type-safe environment variables.
+ *
+ * See our guide for more information on [environment variables in Astro](/en/guides/environment-variables/).
+ */
+ env?: {
+ /**
+ * @docs
+ * @name env.schema
+ * @type {EnvSchema}
+ * @default `{}`
+ * @version 5.0.0
+ * @description
+ *
+ * An object that uses `envField` to define the data type and properties of your environment variables: `context` (client or server), `access` (public or secret), a `default` value to use, and whether or not this environment variable is `optional` (defaults to `false`).
+ * ```js
+ * // astro.config.mjs
+ * import { defineConfig, envField } from "astro/config"
+ *
+ * export default defineConfig({
+ * env: {
+ * schema: {
+ * API_URL: envField.string({ context: "client", access: "public", optional: true }),
+ * PORT: envField.number({ context: "server", access: "public", default: 4321 }),
+ * API_SECRET: envField.string({ context: "server", access: "secret" }),
+ * }
+ * }
+ * })
+ * ```
+ *
+ * `envField` supports four data types: string, number, enum, and boolean. `context` and `access` are required properties for all data types. The following shows the complete list of properties available for each data type:
+ *
+ * ```js
+ * import { envField } from "astro/config"
+ *
+ * envField.string({
+ * // context & access
+ * optional: true,
+ * default: "foo",
+ * max: 20,
+ * min: 1,
+ * length: 13,
+ * url: true,
+ * includes: "oo",
+ * startsWith: "f",
+ * endsWith: "o",
+ * })
+ * envField.number({
+ * // context & access
+ * optional: true,
+ * default: 15,
+ * gt: 2,
+ * min: 1,
+ * lt: 3,
+ * max: 4,
+ * int: true,
+ * })
+ * envField.boolean({
+ * // context & access
+ * optional: true,
+ * default: true,
+ * })
+ * envField.enum({
+ * // context & access
+ * values: ['foo', 'bar', 'baz'], // required
+ * optional: true,
+ * default: 'baz',
+ * })
+ * ```
+ */
+ schema?: EnvSchema;
+
+ /**
+ * @docs
+ * @name env.validateSecrets
+ * @type {boolean}
+ * @default `false`
+ * @version 5.0.0
+ * @description
+ *
+ * Whether or not to validate secrets on the server when starting the dev server or running a build.
+ *
+ * By default, only public variables are validated on the server when starting the dev server or a build, and private variables are validated at runtime only. If enabled, private variables will also be checked on start. This is useful in some continuous integration (CI) pipelines to make sure all your secrets are correctly set before deploying.
+ *
+ * ```js
+ * // astro.config.mjs
+ * import { defineConfig, envField } from "astro/config"
+ *
+ * export default defineConfig({
+ * env: {
+ * schema: {
+ * // ...
+ * },
+ * validateSecrets: true
+ * }
+ * })
+ * ```
+ */
+ validateSecrets?: boolean;
+ };
+
+ /**
+ *
+ * @kind heading
+ * @name Legacy Flags
+ * @description
+ * To help some users migrate between versions of Astro, we occasionally introduce `legacy` flags.
+ * These flags allow you to opt in to some deprecated or otherwise outdated behavior of Astro
+ * in the latest version, so that you can continue to upgrade and take advantage of new Astro releases.
+ */
+ legacy?: {
+ /**
+ *
+ * @name legacy.collections
+ * @type {boolean}
+ * @default `false`
+ * @version 5.0.0
+ * @description
+ * Enable legacy behavior for content collections.
+ *
+ * ```js
+ * // astro.config.mjs
+ * import { defineConfig } from 'astro/config';
+ * export default defineConfig({
+ * legacy: {
+ * collections: true
+ * }
+ * });
+ * ```
+ *
+ * If enabled, `data` and `content` collections (only) are handled using the legacy content collections implementation. Collections with a `loader` (only) will continue to use the Content Layer API instead. Both kinds of collections may exist in the same project, each using their respective implementations.
+ *
+ * The following limitations continue to exist:
+ *
+ * - Any legacy (`type: 'content'` or `type: 'data'`) collections must continue to be located in the `src/content/` directory.
+ * - These legacy collections will not be transformed to implicitly use the `glob()` loader, and will instead be handled by legacy code.
+ * - Collections using the Content Layer API (with a `loader` defined) are forbidden in `src/content/`, but may exist anywhere else in your project.
+ *
+ * When you are ready to remove this flag and migrate to the new Content Layer API for your legacy collections, you must define a collection for any directories in `src/content/` that you want to continue to use as a collection. It is sufficient to declare an empty collection, and Astro will implicitly generate an appropriate definition for your legacy collections:
+ *
+ * ```js
+ * // src/content.config.ts
+ * import { defineCollection, z } from 'astro:content';
+ *
+ * const blog = defineCollection({ })
+ *
+ * export const collections = { blog };
+ * ```
+ *
+ */
+ collections?: boolean;
+ };
+
+ /**
+ *
+ * @kind heading
+ * @name Experimental Flags
+ * @description
+ * Astro offers experimental flags to give users early access to new features.
+ * These flags are not guaranteed to be stable.
+ */
+ experimental?: {
+ /**
+ *
+ * @name experimental.clientPrerender
+ * @type {boolean}
+ * @default `false`
+ * @version 4.2.0
+ * @description
+ * Enables pre-rendering your prefetched pages on the client in supported browsers.
+ *
+ * This feature uses the experimental [Speculation Rules Web API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) and enhances the default `prefetch` behavior globally to prerender links on the client.
+ * You may wish to review the [possible risks when prerendering on the client](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API#unsafe_prefetching) before enabling this feature.
+ *
+ * Enable client side prerendering in your `astro.config.mjs` along with any desired `prefetch` configuration options:
+ *
+ * ```js
+ * // astro.config.mjs
+ * {
+ * prefetch: {
+ * prefetchAll: true,
+ * defaultStrategy: 'viewport',
+ * },
+ * experimental: {
+ * clientPrerender: true,
+ * },
+ * }
+ * ```
+ *
+ * Continue to use the `data-astro-prefetch` attribute on any `<a />` link on your site to opt in to prefetching.
+ * Instead of appending a `<link>` tag to the head of the document or fetching the page with JavaScript, a `<script>` tag will be appended with the corresponding speculation rules.
+ *
+ * Client side prerendering requires browser support. If the Speculation Rules API is not supported, `prefetch` will fallback to the supported strategy.
+ *
+ * See the [Prefetch Guide](https://docs.astro.build/en/guides/prefetch/) for more `prefetch` options and usage.
+ */
+ clientPrerender?: boolean;
+
+ /**
+ *
+ * @name experimental.contentIntellisense
+ * @type {boolean}
+ * @default `false`
+ * @version 5.x
+ * @description
+ *
+ * Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors.
+ *
+ * When enabled, this feature will generate and add JSON schemas to the `.astro` directory in your project. These files can be used by the Astro language server to provide Intellisense inside content files (`.md`, `.mdx`, `.mdoc`).
+ *
+ * ```js
+ * {
+ * experimental: {
+ * contentIntellisense: true,
+ * },
+ * }
+ * ```
+ *
+ * To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature.
+ */
+ contentIntellisense?: boolean;
+
+ /**
+ *
+ * @name experimental.responsiveImages
+ * @type {boolean}
+ * @default `undefined`
+ * @version 5.0.0
+ * @description
+ *
+ * Enables automatic responsive images in your project.
+ *
+ * ```js title=astro.config.mjs
+ * {
+ * experimental: {
+ * responsiveImages: true,
+ * },
+ * }
+ * ```
+ *
+ * When enabled, you can pass a `layout` props to any `<Image />` or `<Picture />` component to create a responsive image. When a layout is set, images have automatically generated `srcset` and `sizes` attributes based on the image's dimensions and the layout type. Images with `responsive` and `full-width` layouts will have styles applied to ensure they resize according to their container.
+ *
+ * ```astro title=MyComponent.astro
+ * ---
+ * import { Image, Picture } from 'astro:assets';
+ * import myImage from '../assets/my_image.png';
+ * ---
+ * <Image src={myImage} alt="A description of my image." layout='responsive' width={800} height={600} />
+ * <Picture src={myImage} alt="A description of my image." layout='full-width' formats={['avif', 'webp', 'jpeg']} />
+ * ```
+ * This `<Image />` component will generate the following HTML output:
+ * ```html title=Output
+ *
+ * <img
+ * src="/_astro/my_image.hash3.webp"
+ * srcset="/_astro/my_image.hash1.webp 640w,
+ * /_astro/my_image.hash2.webp 750w,
+ * /_astro/my_image.hash3.webp 800w,
+ * /_astro/my_image.hash4.webp 828w,
+ * /_astro/my_image.hash5.webp 1080w,
+ * /_astro/my_image.hash6.webp 1280w,
+ * /_astro/my_image.hash7.webp 1600w"
+ * alt="A description of my image"
+ * sizes="(min-width: 800px) 800px, 100vw"
+ * loading="lazy"
+ * decoding="async"
+ * fetchpriority="auto"
+ * width="800"
+ * height="600"
+ * style="--w: 800; --h: 600; --fit: cover; --pos: center;"
+ * data-astro-image="responsive"
+ * >
+ * ```
+ *
+ * The following styles are applied to ensure the images resize correctly:
+ *
+ * ```css title="Responsive Image Styles"
+ * [data-astro-image] {
+ * width: 100%;
+ * height: auto;
+ * object-fit: var(--fit);
+ * object-position: var(--pos);
+ * aspect-ratio: var(--w) / var(--h)
+ * }
+ *
+ * [data-astro-image=responsive] {
+ * max-width: calc(var(--w) * 1px);
+ * max-height: calc(var(--h) * 1px)
+ * }
+ *
+ * [data-astro-image=fixed] {
+ * width: calc(var(--w) * 1px);
+ * height: calc(var(--h) * 1px)
+ * }
+ * ```
+ * You can enable responsive images for all `<Image />` and `<Picture />` components by setting `image.experimentalLayout` with a default value. This can be overridden by the `layout` prop on each component.
+ *
+ * **Example:**
+ * ```js title=astro.config.mjs
+ * {
+ * image: {
+ * // Used for all `<Image />` and `<Picture />` components unless overridden
+ * experimentalLayout: 'responsive',
+ * },
+ * experimental: {
+ * responsiveImages: true,
+ * },
+ * }
+ * ```
+ *
+ * ```astro title=MyComponent.astro
+ * ---
+ * import { Image } from 'astro:assets';
+ * import myImage from '../assets/my_image.png';
+ * ---
+ *
+ * <Image src={myImage} alt="This will use responsive layout" width={800} height={600} />
+ *
+ * <Image src={myImage} alt="This will use full-width layout" layout="full-width" />
+ *
+ * <Image src={myImage} alt="This will disable responsive images" layout="none" />
+ * ```
+ *
+ * #### Responsive image properties
+ *
+ * These are additional properties available to the `<Image />` and `<Picture />` components when responsive images are enabled:
+ *
+ * - `layout`: The layout type for the image. Can be `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
+ * - `fit`: Defines how the image should be cropped if the aspect ratio is changed. Values match those of CSS `object-fit`. Defaults to `cover`, or the value of `image.experimentalObjectFit` if set.
+ * - `position`: Defines the position of the image crop if the aspect ratio is changed. Values match those of CSS `object-position`. Defaults to `center`, or the value of `image.experimentalObjectPosition` if set.
+ * - `priority`: If set, eagerly loads the image. Otherwise images will be lazy-loaded. Use this for your largest above-the-fold image. Defaults to `false`.
+ *
+ * The `widths` and `sizes` attributes are automatically generated based on the image's dimensions and the layout type, and in most cases should not be set manually. The generated `sizes` attribute for `responsive` and `full-width` images
+ * is based on the assumption that the image is displayed at close to the full width of the screen when the viewport is smaller than the image's width. If it is significantly different (e.g. if it's in a multi-column layout on small screens) you may need to adjust the `sizes` attribute manually for best results.
+ *
+ * The `densities` attribute is not compatible with responsive images and will be ignored if set.
+ */
+
+ responsiveImages?: boolean;
+
+ /**
+ *
+ * @name experimental.session
+ * @type {SessionConfig}
+ * @version 5.0.0
+ * @description
+ *
+ * Enables support for sessions in Astro. Sessions are used to store user data across requests, such as user authentication state.
+ *
+ * When enabled you can access the `Astro.session` object to read and write data that persists across requests. You can configure the session driver using the [`session` option](#session), or use the default provided by your adapter.
+ *
+ * ```astro title=src/components/CartButton.astro
+ * ---
+ * export const prerender = false; // Not needed in 'server' mode
+ * const cart = await Astro.session.get('cart');
+ * ---
+ *
+ * <a href="/checkout">🛒 {cart?.length ?? 0} items</a>
+ *
+ * ```
+ * The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage.
+ *
+ * You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default.
+ *
+ * ```js title="astro.config.mjs"
+ * {
+ * experimental: {
+ * session: {
+ * // Required: the name of the Unstorage driver
+ * driver: "redis",
+ * // The required options depend on the driver
+ * options: {
+ * url: process.env.REDIS_URL,
+ * }
+ * }
+ * },
+ * }
+ * ```
+ *
+ * For more details, see [the Sessions RFC](https://github.com/withastro/roadmap/blob/sessions/proposals/0054-sessions.md).
+ *
+ */
+
+ session?: SessionConfig<TSession>;
+ /**
+ *
+ * @name experimental.svg
+ * @type {boolean|object}
+ * @default `undefined`
+ * @version 5.x
+ * @description
+ *
+ * This feature allows you to import SVG files directly into your Astro project. By default, Astro will inline the SVG content into your HTML output.
+ *
+ * To enable this feature, set `experimental.svg` to `true` in your Astro config:
+ *
+ * ```js
+ * {
+ * experimental: {
+ * svg: true,
+ * },
+ * }
+ * ```
+ *
+ * To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component.
+ * Astro also provides a `size` attribute to set equal `height` and `width` properties:
+ *
+ * ```astro
+ * ---
+ * import Logo from './path/to/svg/file.svg';
+ * ---
+ *
+ * <Logo size={24} />
+ * ```
+ *
+ * For a complete overview, and to give feedback on this experimental API,
+ * see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035).
+ */
+ svg?:
+ | boolean
+ | {
+ /**
+ *
+ * @name experimental.svg.mode
+ * @type {string}
+ * @default 'inline'
+ *
+ * The default technique for handling imported SVG files. Astro will inline the SVG content into your HTML output if not specified.
+ *
+ * - `inline`: Astro will inline the SVG content into your HTML output.
+ * - `sprite`: Astro will generate a sprite sheet with all imported SVG files.
+ *
+ * ```astro
+ * ---
+ * import Logo from './path/to/svg/file.svg';
+ * ---
+ *
+ * <Logo size={24} mode="sprite" />
+ * ```
+ */
+ mode: SvgRenderMode;
+ };
+
+ /**
+ * @name experimental.serializeConfig
+ * @type {boolean}
+ * @default `false`
+ * @version 5.x
+ * @description
+ *
+ * Enables the use of the experimental virtual modules `astro:config/server` and `astro:config/client`.
+ *
+ * These two virtual modules contain a serializable subset of the Astro configuration.
+ */
+ serializeConfig?: boolean;
+ };
+}
+
+/**
+ * Resolved Astro Config
+ *
+ * Config with user settings along with all defaults filled in.
+ */
+export interface AstroConfig extends AstroConfigType {
+ // Public:
+ // This is a more detailed type than zod validation gives us.
+ // TypeScript still confirms zod validation matches this type.
+ integrations: AstroIntegration[];
+}
+/**
+ * An inline Astro config that takes highest priority when merging with the user config,
+ * and includes inline-specific options to configure how Astro runs.
+ */
+export interface AstroInlineConfig extends AstroUserConfig, AstroInlineOnlyConfig {}
+export interface AstroInlineOnlyConfig {
+ /**
+ * A custom path to the Astro config file. If relative, it'll resolve based on the current working directory.
+ * Set to false to disable loading any config files.
+ *
+ * If this value is undefined or unset, Astro will search for an `astro.config.(js,mjs,ts)` file relative to
+ * the `root` and load the config file if found.
+ *
+ * The inline config passed in this object will take the highest priority when merging with the loaded user config.
+ */
+ configFile?: string | false;
+ /**
+ * The mode used when developing or building your site. It's passed to Vite that affects the value of `import.meta.env.MODE`
+ * and how `.env` files are loaded, which also affects the values of `astro:env`. See the
+ * [environment variables documentation](https://docs.astro.build/en/guides/environment-variables/) for more details.
+ *
+ * To output a development-based build, you can run `astro build` with the `--devOutput` flag.
+ *
+ * @default "development" for `astro dev`, "production" for `astro build`
+ */
+ mode?: string;
+ /**
+ * The logging level to filter messages logged by Astro.
+ * - "debug": Log everything, including noisy debugging diagnostics.
+ * - "info": Log informational messages, warnings, and errors.
+ * - "warn": Log warnings and errors.
+ * - "error": Log errors only.
+ * - "silent": No logging.
+ *
+ * @default "info"
+ */
+ logLevel?: LoggerLevel;
+ /**
+ * Clear the content layer cache, forcing a rebuild of all content entries.
+ */
+ force?: boolean;
+ /**
+ * @internal for testing only, use `logLevel` instead.
+ */
+ logger?: Logger;
+}
+
+// HACK! astro:db augment this type that is used in the config
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Config {
+ type Database = Record<string, any>;
+ }
+}
diff --git a/packages/astro/src/types/public/content.ts b/packages/astro/src/types/public/content.ts
new file mode 100644
index 000000000..171c8d550
--- /dev/null
+++ b/packages/astro/src/types/public/content.ts
@@ -0,0 +1,126 @@
+import type { MarkdownHeading } from '@astrojs/markdown-remark';
+import type * as rollup from 'rollup';
+import type { DataEntry, RenderedContent } from '../../content/data-store.js';
+import type { AstroComponentFactory } from '../../runtime/server/index.js';
+import type { AstroConfig } from './config.js';
+
+export interface AstroInstance {
+ file: string;
+ url: string | undefined;
+ default: AstroComponentFactory;
+}
+
+export interface MarkdownInstance<T extends Record<string, any>> {
+ frontmatter: T;
+ /** Absolute file path (e.g. `/home/user/projects/.../file.md`) */
+ file: string;
+ /** Browser URL for files under `/src/pages` (e.g. `/en/guides/markdown-content`) */
+ url: string | undefined;
+ /** Component to render content in `.astro` files. Usage: `<Content />` */
+ Content: AstroComponentFactory;
+ /** raw Markdown file content, excluding layout HTML and YAML frontmatter */
+ rawContent(): string;
+ /** Markdown file compiled to HTML, excluding layout HTML */
+ compiledContent(): Promise<string>;
+ /** List of headings (h1 -> h6) with associated metadata */
+ getHeadings(): MarkdownHeading[];
+ default: AstroComponentFactory;
+}
+
+export interface MDXInstance<T extends Record<string, any>>
+ extends Omit<MarkdownInstance<T>, 'rawContent' | 'compiledContent'> {
+ components: Record<string, AstroComponentFactory> | undefined;
+}
+
+export interface MarkdownLayoutProps<T extends Record<string, any>> {
+ frontmatter: {
+ file: MarkdownInstance<T>['file'];
+ url: MarkdownInstance<T>['url'];
+ } & T;
+ file: MarkdownInstance<T>['file'];
+ url: MarkdownInstance<T>['url'];
+ headings: MarkdownHeading[];
+ rawContent: MarkdownInstance<T>['rawContent'];
+ compiledContent: MarkdownInstance<T>['compiledContent'];
+}
+
+export interface MDXLayoutProps<T extends Record<string, any>>
+ extends Omit<MarkdownLayoutProps<T>, 'rawContent' | 'compiledContent'> {
+ components: MDXInstance<T>['components'];
+}
+
+export type ContentEntryModule = {
+ id: string;
+ collection: string;
+ slug: string;
+ body: string;
+ data: Record<string, unknown>;
+ _internal: {
+ rawData: string;
+ filePath: string;
+ };
+};
+
+export type DataEntryModule = {
+ id: string;
+ collection: string;
+ data: Record<string, unknown>;
+ _internal: {
+ rawData: string;
+ filePath: string;
+ };
+};
+
+export type ContentEntryRenderFunction = (entry: DataEntry) => Promise<RenderedContent>;
+
+export interface ContentEntryType {
+ extensions: string[];
+ getEntryInfo(params: {
+ fileUrl: URL;
+ contents: string;
+ }): GetContentEntryInfoReturnType | Promise<GetContentEntryInfoReturnType>;
+ getRenderModule?(
+ this: rollup.PluginContext,
+ params: {
+ contents: string;
+ fileUrl: URL;
+ viteId: string;
+ },
+ ): rollup.LoadResult | Promise<rollup.LoadResult>;
+ contentModuleTypes?: string;
+ getRenderFunction?(config: AstroConfig): Promise<ContentEntryRenderFunction>;
+
+ /**
+ * Handle asset propagation for rendered content to avoid bleed.
+ * Ex. MDX content can import styles and scripts, so `handlePropagation` should be true.
+ * @default true
+ */
+ handlePropagation?: boolean;
+}
+
+export interface RefreshContentOptions {
+ loaders?: Array<string>;
+ context?: Record<string, any>;
+}
+
+type GetContentEntryInfoReturnType = {
+ data: Record<string, unknown>;
+ /**
+ * Used for error hints to point to correct line and location
+ * Should be the untouched data as read from the file,
+ * including newlines
+ */
+ rawData: string;
+ body: string;
+ slug: string;
+};
+
+export interface DataEntryType {
+ extensions: string[];
+ getEntryInfo(params: {
+ fileUrl: URL;
+ contents: string;
+ }): GetDataEntryInfoReturnType | Promise<GetDataEntryInfoReturnType>;
+}
+
+export type GetDataEntryInfoReturnType = { data: Record<string, unknown>; rawData?: string };
diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts
new file mode 100644
index 000000000..594376dee
--- /dev/null
+++ b/packages/astro/src/types/public/context.ts
@@ -0,0 +1,537 @@
+import type { z } from 'zod';
+import type {
+ ActionAccept,
+ ActionClient,
+ ActionReturnType,
+} from '../../actions/runtime/virtual/server.js';
+import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../core/constants.js';
+import type { AstroCookies } from '../../core/cookies/cookies.js';
+import type { AstroSession } from '../../core/session.js';
+import type { AstroComponentFactory } from '../../runtime/server/index.js';
+import type { Params, RewritePayload } from './common.js';
+import type { ValidRedirectStatus } from './config.js';
+import type { AstroInstance, MDXInstance, MarkdownInstance } from './content.js';
+
+/**
+ * Astro global available in all contexts in .astro files
+ *
+ * [Astro reference](https://docs.astro.build/reference/api-reference/#astro-global)
+ */
+export interface AstroGlobal<
+ Props extends Record<string, any> = Record<string, any>,
+ Self = AstroComponentFactory,
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ Params extends Record<string, string | undefined> = Record<string, string | undefined>,
+> extends AstroGlobalPartial,
+ AstroSharedContext<Props, Params> {
+ /**
+ * A full URL object of the request URL.
+ * Equivalent to: `new URL(Astro.request.url)`
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#url)
+ */
+ url: AstroSharedContext['url'];
+ /** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
+ *
+ * Example usage:
+ * ```astro
+ * ---
+ * export async function getStaticPaths() {
+ * return [
+ * { params: { id: '1' } },
+ * ];
+ * }
+ *
+ * const { id } = Astro.params;
+ * ---
+ * <h1>{id}</h1>
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroparams)
+ */
+ params: AstroSharedContext<Props, Params>['params'];
+ /** List of props passed to this component
+ *
+ * A common way to get specific props is through [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), ex:
+ * ```typescript
+ * const { name } = Astro.props
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/basics/astro-components/#component-props)
+ */
+ props: AstroSharedContext<Props, Params>['props'];
+ /** Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object
+ *
+ * For example, to get a URL object of the current URL, you can use:
+ * ```typescript
+ * const url = new URL(Astro.request.url);
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astrorequest)
+ */
+ request: Request;
+ /** Information about the outgoing response. This is a standard [ResponseInit](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#init) object
+ *
+ * For example, to change the status code you can set a different status on this object:
+ * ```typescript
+ * Astro.response.status = 404;
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroresponse)
+ */
+ response: ResponseInit & {
+ readonly headers: Headers;
+ };
+ /**
+ * Get an action result on the server when using a form POST.
+ * Expects the action function as a parameter.
+ * Returns a type-safe result with the action data when
+ * a matching POST request is received
+ * and `undefined` otherwise.
+ *
+ * Example usage:
+ *
+ * ```typescript
+ * import { actions } from 'astro:actions';
+ *
+ * const result = await Astro.getActionResult(actions.myAction);
+ * ```
+ */
+ getActionResult: AstroSharedContext['getActionResult'];
+ /**
+ * Call an Action directly from an Astro page or API endpoint.
+ * Expects the action function as the first parameter,
+ * and the type-safe action input as the second parameter.
+ * Returns a Promise with the action result.
+ *
+ * Example usage:
+ *
+ * ```typescript
+ * import { actions } from 'astro:actions';
+ *
+ * const result = await Astro.callAction(actions.getPost, { postId: 'test' });
+ * ```
+ */
+ callAction: AstroSharedContext['callAction'];
+ /** Redirect to another page
+ *
+ * Example usage:
+ * ```typescript
+ * if(!isLoggedIn) {
+ * return Astro.redirect('/login');
+ * }
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroredirect)
+ */
+ redirect: AstroSharedContext['redirect'];
+ /**
+ * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted
+ * by the rewritten URL passed as argument.
+ *
+ * ## Example
+ *
+ * ```js
+ * if (pageIsNotEnabled) {
+ * return Astro.rewrite('/fallback-page')
+ * }
+ * ```
+ */
+ rewrite: AstroSharedContext['rewrite'];
+
+ /**
+ * The route currently rendered. It's stripped of the `srcDir` and the `pages` folder, and it doesn't contain the extension.
+ *
+ * ## Example
+ * - The value when rendering `src/pages/index.astro` will `index`.
+ * - The value when rendering `src/pages/blog/[slug].astro` will `blog/[slug]`.
+ * - The value when rendering `src/pages/[...path].astro` will `[...path]`.
+ */
+ routePattern: string;
+ /**
+ * The <Astro.self /> element allows a component to reference itself recursively.
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroself)
+ */
+ self: Self;
+ /** Utility functions for modifying an Astro component’s slotted children
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroslots)
+ */
+ slots: Record<string, true | undefined> & {
+ /**
+ * Check whether content for this slot name exists
+ *
+ * Example usage:
+ * ```typescript
+ * if (Astro.slots.has('default')) {
+ * // Do something...
+ * }
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroslots)
+ */
+ has(slotName: string): boolean;
+ /**
+ * Asynchronously renders this slot and returns a string
+ *
+ * Example usage:
+ * ```astro
+ * ---
+ * let html: string = '';
+ * if (Astro.slots.has('default')) {
+ * html = await Astro.slots.render('default')
+ * }
+ * ---
+ * <Fragment set:html={html} />
+ * ```
+ *
+ * A second parameter can be used to pass arguments to a slotted callback
+ *
+ * Example usage:
+ * ```astro
+ * ---
+ * html = await Astro.slots.render('default', ["Hello", "World"])
+ * ---
+ * ```
+ * Each item in the array will be passed as an argument that you can use like so:
+ * ```astro
+ * <Component>
+ * {(hello, world) => <div>{hello}, {world}!</div>}
+ * </Component>
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroslots)
+ */
+ render(slotName: string, args?: any[]): Promise<string>;
+ };
+}
+
+/** Union type of supported markdown file extensions */
+type MarkdownFileExtension = (typeof SUPPORTED_MARKDOWN_FILE_EXTENSIONS)[number];
+
+export interface AstroGlobalPartial {
+ /**
+ * Fetch local files into your static site setup
+ *
+ * Example usage:
+ * ```typescript
+ * const posts = await Astro.glob('../pages/post/*.md');
+ * ```
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroglob)
+ * @deprecated Astro.glob is deprecated and will be removed in the next major version of Astro. Use `import.meta.glob` instead: https://vitejs.dev/guide/features.html#glob-import
+ */
+ glob(globStr: `${any}.astro`): Promise<AstroInstance[]>;
+ glob<T extends Record<string, any>>(
+ globStr: `${any}${MarkdownFileExtension}`,
+ ): Promise<MarkdownInstance<T>[]>;
+ glob<T extends Record<string, any>>(globStr: `${any}.mdx`): Promise<MDXInstance<T>[]>;
+ glob<T extends Record<string, any>>(globStr: string): Promise<T[]>;
+ /**
+ * Returns a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object built from the [site](https://docs.astro.build/en/reference/configuration-reference/#site) config option
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astrosite)
+ */
+ site: URL | undefined;
+ /**
+ * Returns a string with the current version of Astro.
+ *
+ * Useful for using `<meta name="generator" content={Astro.generator} />` or crediting Astro in a site footer.
+ *
+ * [HTML Specification for `generator`](https://html.spec.whatwg.org/multipage/semantics.html#meta-generator)
+ *
+ * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astrogenerator)
+ */
+ generator: string;
+}
+
+// Shared types between `Astro` global and API context object
+interface AstroSharedContext<
+ Props extends Record<string, any> = Record<string, any>,
+ RouteParams extends Record<string, string | undefined> = Record<string, string | undefined>,
+> {
+ /**
+ * The address (usually IP address) of the user.
+ *
+ * Throws an error if used within a static site, or within a prerendered page.
+ */
+ clientAddress: string;
+ /**
+ * Utility for getting and setting the values of cookies.
+ */
+ cookies: AstroCookies;
+ /**
+ * Utility for handling sessions.
+ */
+ session?: AstroSession;
+ /**
+ * Information about the current request. This is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object
+ */
+ request: Request;
+ /**
+ * A full URL object of the request URL.
+ */
+ url: URL;
+ /**
+ * The origin pathname of the request URL.
+ * Useful to track the original URL before rewrites were applied.
+ */
+ originPathname: string;
+ /**
+ * Get action result on the server when using a form POST.
+ */
+ getActionResult: <
+ TAccept extends ActionAccept,
+ TInputSchema extends z.ZodType,
+ TAction extends ActionClient<unknown, TAccept, TInputSchema>,
+ >(
+ action: TAction,
+ ) => ActionReturnType<TAction> | undefined;
+ /**
+ * Call action handler from the server.
+ */
+ callAction: <
+ TAccept extends ActionAccept,
+ TInputSchema extends z.ZodType,
+ TOutput,
+ TAction extends
+ | ActionClient<TOutput, TAccept, TInputSchema>
+ | ActionClient<TOutput, TAccept, TInputSchema>['orThrow'],
+ >(
+ action: TAction,
+ input: Parameters<TAction>[0],
+ ) => Promise<ActionReturnType<TAction>>;
+ /**
+ * Route parameters for this request if this is a dynamic route.
+ */
+ params: RouteParams;
+ /**
+ * List of props returned for this path by `getStaticPaths` (**Static Only**).
+ */
+ props: Props;
+ /**
+ * Redirect to another page (**SSR Only**).
+ */
+ redirect(path: string, status?: ValidRedirectStatus): Response;
+
+ /**
+ * It rewrites to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted
+ * by the rerouted URL passed as argument.
+ *
+ * ## Example
+ *
+ * ```js
+ * if (pageIsNotEnabled) {
+ * return Astro.rewrite('/fallback-page')
+ * }
+ * ```
+ */
+ rewrite(rewritePayload: RewritePayload): Promise<Response>;
+
+ /**
+ * Object accessed via Astro middleware
+ */
+ locals: App.Locals;
+
+ /**
+ * The current locale that is computed from the `Accept-Language` header of the browser (**SSR Only**).
+ */
+ preferredLocale: string | undefined;
+
+ /**
+ * The list of locales computed from the `Accept-Language` header of the browser, sorted by quality value (**SSR Only**).
+ */
+
+ preferredLocaleList: string[] | undefined;
+
+ /**
+ * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise.
+ */
+ currentLocale: string | undefined;
+
+ /**
+ * Whether the current route is prerendered or not.
+ */
+ isPrerendered: boolean;
+}
+
+/**
+ * The `APIContext` is the object made available to endpoints and middleware.
+ * It is a subset of the `Astro` global object available in pages.
+ *
+ * [Reference](https://docs.astro.build/en/reference/api-reference/#endpoint-context)
+ */
+export interface APIContext<
+ Props extends Record<string, any> = Record<string, any>,
+ APIParams extends Record<string, string | undefined> = Record<string, string | undefined>,
+> extends AstroSharedContext<Props, Params> {
+ /**
+ * The site provided in the astro config, parsed as an instance of `URL`, without base.
+ * `undefined` if the site is not provided in the config.
+ */
+ site: URL | undefined;
+ /**
+ * A human-readable string representing the Astro version used to create the project.
+ * For example, `"Astro v1.1.1"`.
+ */
+ generator: string;
+ /**
+ * The url of the current request, parsed as an instance of `URL`.
+ *
+ * Equivalent to:
+ * ```ts
+ * new URL(context.request.url)
+ * ```
+ */
+ url: AstroSharedContext['url'];
+ /**
+ * Parameters matching the page’s dynamic route pattern.
+ * In static builds, this will be the `params` generated by `getStaticPaths`.
+ * In SSR builds, this can be any path segments matching the dynamic route pattern.
+ *
+ * Example usage:
+ * ```ts
+ * import type { APIContext } from "astro"
+ *
+ * export function getStaticPaths() {
+ * return [
+ * { params: { id: '0' }, props: { name: 'Sarah' } },
+ * { params: { id: '1' }, props: { name: 'Chris' } },
+ * { params: { id: '2' }, props: { name: 'Fuzzy' } },
+ * ];
+ * }
+ *
+ * export async function GET({ params }: APIContext) {
+ * return new Response(`Hello user ${params.id}!`)
+ * }
+ * ```
+ *
+ * [Reference](https://docs.astro.build/en/reference/api-reference/#contextparams)
+ */
+ params: AstroSharedContext<Props, APIParams>['params'];
+ /**
+ * List of props passed from `getStaticPaths`. Only available to static builds.
+ *
+ * Example usage:
+ * ```ts
+ * import type { APIContext } from "astro"
+ *
+ * export function getStaticPaths() {
+ * return [
+ * { params: { id: '0' }, props: { name: 'Sarah' } },
+ * { params: { id: '1' }, props: { name: 'Chris' } },
+ * { params: { id: '2' }, props: { name: 'Fuzzy' } },
+ * ];
+ * }
+ *
+ * export function GET({ props }: APIContext): Response {
+ * return new Response(`Hello ${props.name}!`);
+ * }
+ * ```
+ *
+ * [Reference](https://docs.astro.build/en/reference/api-reference/#contextprops)
+ */
+ props: AstroSharedContext<Props, APIParams>['props'];
+ /**
+ * Create a response that redirects to another page.
+ *
+ * Example usage:
+ * ```ts
+ * // src/pages/secret.ts
+ * export function GET({ redirect }) {
+ * return redirect('/login');
+ * }
+ * ```
+ *
+ * [Reference](https://docs.astro.build/en/guides/api-reference/#contextredirect)
+ */
+ redirect: AstroSharedContext['redirect'];
+
+ /**
+ * It reroutes to another page. As opposed to redirects, the URL won't change, and Astro will render the HTML emitted
+ * by the rerouted URL passed as argument.
+ *
+ * ## Example
+ *
+ * ```ts
+ * // src/pages/secret.ts
+ * export function GET(ctx) {
+ * return ctx.rewrite(new URL("../"), ctx.url);
+ * }
+ * ```
+ */
+ rewrite: AstroSharedContext['rewrite'];
+
+ /**
+ * An object that middlewares can use to store extra information related to the request.
+ *
+ * It will be made available to pages as `Astro.locals`, and to endpoints as `context.locals`.
+ *
+ * Example usage:
+ *
+ * ```ts
+ * // src/middleware.ts
+ * import { defineMiddleware } from "astro:middleware";
+ *
+ * export const onRequest = defineMiddleware((context, next) => {
+ * context.locals.greeting = "Hello!";
+ * return next();
+ * });
+ * ```
+ * Inside a `.astro` file:
+ * ```astro
+ * ---
+ * // src/pages/index.astro
+ * const greeting = Astro.locals.greeting;
+ * ---
+ * <h1>{greeting}</h1>
+ * ```
+ *
+ * [Reference](https://docs.astro.build/en/reference/api-reference/#contextlocals)
+ */
+ locals: App.Locals;
+
+ /**
+ * Available only when `i18n` configured and in SSR.
+ *
+ * It represents the preferred locale of the user. It's computed by checking the supported locales in `i18n.locales`
+ * and locales supported by the users's browser via the header `Accept-Language`
+ *
+ * For example, given `i18n.locales` equals to `['fr', 'de']`, and the `Accept-Language` value equals to `en, de;q=0.2, fr;q=0.6`, the
+ * `Astro.preferredLanguage` will be `fr` because `en` is not supported, its [quality value] is the highest.
+ *
+ * [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
+ */
+ preferredLocale: string | undefined;
+
+ /**
+ * Available only when `i18n` configured and in SSR.
+ *
+ * It represents the list of the preferred locales that are supported by the application. The list is sorted via [quality value].
+ *
+ * For example, given `i18n.locales` equals to `['fr', 'pt', 'de']`, and the `Accept-Language` value equals to `en, de;q=0.2, fr;q=0.6`, the
+ * `Astro.preferredLocaleList` will be equal to `['fs', 'de']` because `en` isn't supported, and `pt` isn't part of the locales contained in the
+ * header.
+ *
+ * When the `Accept-Header` is `*`, the original `i18n.locales` are returned. The value `*` means no preferences, so Astro returns all the supported locales.
+ *
+ * [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
+ */
+ preferredLocaleList: string[] | undefined;
+
+ /**
+ * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise.
+ */
+ currentLocale: string | undefined;
+
+ /**
+ * The route currently rendered. It's stripped of the `srcDir` and the `pages` folder, and it doesn't contain the extension.
+ *
+ * ## Example
+ * - The value when rendering `src/pages/index.astro` will `index`.
+ * - The value when rendering `src/pages/blog/[slug].astro` will `blog/[slug]`.
+ * - The value when rendering `src/pages/[...path].astro` will `[...path]`.
+ */
+ routePattern: string;
+}
diff --git a/packages/astro/src/types/public/elements.ts b/packages/astro/src/types/public/elements.ts
new file mode 100644
index 000000000..518b133ca
--- /dev/null
+++ b/packages/astro/src/types/public/elements.ts
@@ -0,0 +1,47 @@
+import type { TransitionAnimationValue } from './view-transitions.js';
+
+export interface AstroComponentDirectives extends Astro.ClientDirectives {
+ 'server:defer'?: boolean;
+}
+
+export interface AstroClientDirectives {
+ 'client:load'?: boolean;
+ 'client:idle'?: IdleRequestOptions | boolean;
+ 'client:media'?: string;
+ 'client:visible'?: ClientVisibleOptions | boolean;
+ 'client:only'?: boolean | string;
+}
+
+export type ClientVisibleOptions = Pick<IntersectionObserverInit, 'rootMargin'>;
+
+export interface AstroBuiltinAttributes {
+ 'class:list'?:
+ | Record<string, boolean>
+ | Record<any, any>
+ | Iterable<string>
+ | Iterable<any>
+ | string;
+ 'set:html'?: any;
+ 'set:text'?: any;
+ 'is:raw'?: boolean;
+ 'transition:animate'?: TransitionAnimationValue;
+ 'transition:name'?: string;
+ 'transition:persist'?: boolean | string;
+}
+
+export interface AstroDefineVarsAttribute {
+ 'define:vars'?: any;
+}
+
+export interface AstroStyleAttributes {
+ 'is:global'?: boolean;
+ 'is:inline'?: boolean;
+}
+
+export interface AstroScriptAttributes {
+ 'is:inline'?: boolean;
+}
+
+export interface AstroSlotAttributes {
+ 'is:inline'?: boolean;
+}
diff --git a/packages/astro/src/types/public/extendables.ts b/packages/astro/src/types/public/extendables.ts
new file mode 100644
index 000000000..1592eda19
--- /dev/null
+++ b/packages/astro/src/types/public/extendables.ts
@@ -0,0 +1,21 @@
+/* eslint-disable @typescript-eslint/no-namespace */
+/* eslint-disable @typescript-eslint/no-empty-object-type */
+import type { AstroClientDirectives } from './elements.js';
+import type { BaseIntegrationHooks } from './integrations.js';
+
+// The interfaces in this file can be extended by users
+declare global {
+ namespace App {
+ /**
+ * Used by middlewares to store information, that can be read by the user via the global `Astro.locals`
+ */
+ export interface Locals {}
+ }
+
+ namespace Astro {
+ export interface IntegrationHooks extends BaseIntegrationHooks {}
+ export interface ClientDirectives extends AstroClientDirectives {}
+ }
+}
+
+export {};
diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts
new file mode 100644
index 000000000..fae134bbe
--- /dev/null
+++ b/packages/astro/src/types/public/index.ts
@@ -0,0 +1,45 @@
+export type * from './config.js';
+export type * from './elements.js';
+export type * from './extendables.js';
+export type * from './toolbar.js';
+export type * from './view-transitions.js';
+export type * from './integrations.js';
+export type * from './internal.js';
+export type * from './context.js';
+export type * from './preview.js';
+export type * from './content.js';
+export type * from './common.js';
+export type * from './manifest.js';
+
+export type { AstroIntegrationLogger } from '../../core/logger/core.js';
+export type { ToolbarServerHelpers } from '../../runtime/client/dev-toolbar/helpers.js';
+
+export type {
+ MarkdownHeading,
+ RehypePlugins,
+ RemarkPlugins,
+ ShikiConfig,
+} from '@astrojs/markdown-remark';
+export type {
+ ExternalImageService,
+ ImageService,
+ LocalImageService,
+} from '../../assets/services/service.js';
+export type {
+ GetImageResult,
+ ImageInputFormat,
+ ImageMetadata,
+ ImageOutputFormat,
+ ImageQuality,
+ ImageQualityPreset,
+ ImageTransform,
+ UnresolvedImageTransform,
+} from '../../assets/types.js';
+export type { RemotePattern } from '../../assets/utils/remotePattern.js';
+export type { AssetsPrefix, SSRManifest } from '../../core/app/types.js';
+export type {
+ AstroCookieGetOptions,
+ AstroCookieSetOptions,
+ AstroCookies,
+} from '../../core/cookies/index.js';
+export type { ContainerRenderer } from '../../container/index.js';
diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts
new file mode 100644
index 000000000..2d0b13e96
--- /dev/null
+++ b/packages/astro/src/types/public/integrations.ts
@@ -0,0 +1,287 @@
+import type { AddressInfo } from 'node:net';
+import type { ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite';
+import type { SerializedSSRManifest } from '../../core/app/types.js';
+import type { PageBuildData } from '../../core/build/types.js';
+import type { AstroIntegrationLogger } from '../../core/logger/core.js';
+import type { AdapterFeatureStability } from '../../integrations/features-validation.js';
+import type { getToolbarServerCommunicationHelpers } from '../../integrations/hooks.js';
+import type { DeepPartial } from '../../type-utils.js';
+import type { AstroConfig } from './config.js';
+import type { RefreshContentOptions } from './content.js';
+import type { InternalInjectedRoute, RouteData } from './internal.js';
+import type { DevToolbarAppEntry } from './toolbar.js';
+
+export interface RouteOptions {
+ /**
+ * The path to this route relative to the project root. The slash is normalized as forward slash
+ * across all OS.
+ * @example "src/pages/blog/[...slug].astro"
+ */
+ readonly component: string;
+ /**
+ * Whether this route should be prerendered. If the route has an explicit `prerender` export,
+ * the value will be passed here. Otherwise, it's undefined and will fallback to a prerender
+ * default depending on the `output` option.
+ */
+ prerender?: boolean;
+}
+
+/* Client Directives */
+type DirectiveHydrate = () => Promise<void>;
+type DirectiveLoad = () => Promise<DirectiveHydrate>;
+
+type DirectiveOptions = {
+ /**
+ * The component displayName
+ */
+ name: string;
+ /**
+ * The attribute value provided
+ */
+ value: string;
+};
+
+export type ClientDirective = (
+ load: DirectiveLoad,
+ options: DirectiveOptions,
+ el: HTMLElement,
+) => void;
+
+export interface ClientDirectiveConfig {
+ name: string;
+ entrypoint: string | URL;
+}
+
+export interface AstroRenderer {
+ /** Name of the renderer. */
+ name: string;
+ /** Import entrypoint for the client/browser renderer. */
+ clientEntrypoint?: string | URL;
+ /** Import entrypoint for the server/build/ssr renderer. */
+ serverEntrypoint: string | URL;
+}
+
+export type AdapterSupportsKind =
+ (typeof AdapterFeatureStability)[keyof typeof AdapterFeatureStability];
+
+export type AdapterSupportWithMessage = {
+ support: Exclude<AdapterSupportsKind, 'stable'>;
+ message: string;
+};
+
+export type AdapterSupport = AdapterSupportsKind | AdapterSupportWithMessage;
+
+export interface AstroAdapterFeatures {
+ /**
+ * Creates an edge function that will communicate with the Astro middleware
+ */
+ edgeMiddleware: boolean;
+ /**
+ * Determine the type of build output the adapter is intended for. Defaults to `server`;
+ */
+ buildOutput?: 'static' | 'server';
+}
+
+export interface AstroAdapter {
+ name: string;
+ serverEntrypoint?: string | URL;
+ previewEntrypoint?: string | URL;
+ exports?: string[];
+ args?: any;
+ adapterFeatures?: AstroAdapterFeatures;
+ /**
+ * List of features supported by an adapter.
+ *
+ * If the adapter is not able to handle certain configurations, Astro will throw an error.
+ */
+ supportedAstroFeatures: AstroAdapterFeatureMap;
+}
+
+export type AstroAdapterFeatureMap = {
+ /**
+ * The adapter is able serve static pages
+ */
+ staticOutput?: AdapterSupport;
+
+ /**
+ * The adapter is able to serve pages that are static or rendered via server
+ */
+ hybridOutput?: AdapterSupport;
+
+ /**
+ * The adapter is able to serve SSR pages
+ */
+ serverOutput?: AdapterSupport;
+
+ /**
+ * The adapter is able to support i18n domains
+ */
+ i18nDomains?: AdapterSupport;
+
+ /**
+ * The adapter is able to support `getSecret` exported from `astro:env/server`
+ */
+ envGetSecret?: AdapterSupport;
+
+ /**
+ * The adapter supports image transformation using the built-in Sharp image service
+ */
+ sharpImageService?: AdapterSupport;
+};
+
+/**
+ * IDs for different stages of JS script injection:
+ * - "before-hydration": Imported client-side, before the hydration script runs. Processed & resolved by Vite.
+ * - "head-inline": Injected into a script tag in the `<head>` of every page. Not processed or resolved by Vite.
+ * - "page": Injected into the JavaScript bundle of every page. Processed & resolved by Vite.
+ * - "page-ssr": Injected into the frontmatter of every Astro page. Processed & resolved by Vite.
+ */
+export type InjectedScriptStage = 'before-hydration' | 'head-inline' | 'page' | 'page-ssr';
+
+export type InjectedRoute = Omit<InternalInjectedRoute, 'origin'>;
+
+export interface InjectedType {
+ filename: string;
+ content: string;
+}
+
+export type AstroIntegrationMiddleware = {
+ order: 'pre' | 'post';
+ entrypoint: string | URL;
+};
+
+export type HookParameters<
+ Hook extends keyof AstroIntegration['hooks'],
+ Fn = AstroIntegration['hooks'][Hook],
+> = Fn extends (...args: any) => any ? Parameters<Fn>[0] : never;
+
+export interface BaseIntegrationHooks {
+ 'astro:config:setup': (options: {
+ config: AstroConfig;
+ command: 'dev' | 'build' | 'preview' | 'sync';
+ isRestart: boolean;
+ updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig;
+ addRenderer: (renderer: AstroRenderer) => void;
+ addWatchFile: (path: URL | string) => void;
+ injectScript: (stage: InjectedScriptStage, content: string) => void;
+ injectRoute: (injectRoute: InjectedRoute) => void;
+ addClientDirective: (directive: ClientDirectiveConfig) => void;
+ addDevToolbarApp: (entrypoint: DevToolbarAppEntry) => void;
+ addMiddleware: (mid: AstroIntegrationMiddleware) => void;
+ createCodegenDir: () => URL;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:config:done': (options: {
+ config: AstroConfig;
+ setAdapter: (adapter: AstroAdapter) => void;
+ injectTypes: (injectedType: InjectedType) => URL;
+ logger: AstroIntegrationLogger;
+ buildOutput: 'static' | 'server';
+ }) => void | Promise<void>;
+ 'astro:server:setup': (options: {
+ server: ViteDevServer;
+ logger: AstroIntegrationLogger;
+ toolbar: ReturnType<typeof getToolbarServerCommunicationHelpers>;
+ refreshContent?: (options: RefreshContentOptions) => Promise<void>;
+ }) => void | Promise<void>;
+ 'astro:server:start': (options: {
+ address: AddressInfo;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:server:done': (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
+ 'astro:build:ssr': (options: {
+ manifest: SerializedSSRManifest;
+ /**
+ * This maps a {@link RouteData} to an {@link URL}, this URL represents
+ * the physical file you should import.
+ */
+ entryPoints: Map<IntegrationRouteData, URL>;
+ /**
+ * File path of the emitted middleware
+ */
+ middlewareEntryPoint: URL | undefined;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:build:start': (options: { logger: AstroIntegrationLogger }) => void | Promise<void>;
+ 'astro:build:setup': (options: {
+ vite: ViteInlineConfig;
+ pages: Map<string, PageBuildData>;
+ target: 'client' | 'server';
+ updateConfig: (newConfig: ViteInlineConfig) => void;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:build:generated': (options: {
+ dir: URL;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:build:done': (options: {
+ pages: { pathname: string }[];
+ dir: URL;
+ /** @deprecated Use the `assets` map and the new `astro:routes:resolved` hook */
+ routes: IntegrationRouteData[];
+ assets: Map<string, URL[]>;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:route:setup': (options: {
+ route: RouteOptions;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ 'astro:routes:resolved': (options: {
+ routes: IntegrationResolvedRoute[];
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+}
+
+export interface AstroIntegration {
+ /** The name of the integration. */
+ name: string;
+ /** The different hooks available to extend. */
+ hooks: {
+ [K in keyof Astro.IntegrationHooks]?: Astro.IntegrationHooks[K];
+ } & Partial<Record<string, unknown>>;
+}
+
+/**
+ * A smaller version of the {@link RouteData} that is used in the integrations.
+ * @deprecated Use {@link IntegrationResolvedRoute}
+ */
+export type IntegrationRouteData = Omit<
+ RouteData,
+ 'isIndex' | 'fallbackRoutes' | 'redirectRoute' | 'origin'
+> & {
+ /**
+ * {@link RouteData.redirectRoute}
+ */
+ redirectRoute?: IntegrationRouteData;
+};
+
+export interface IntegrationResolvedRoute
+ extends Pick<
+ RouteData,
+ 'generate' | 'params' | 'pathname' | 'segments' | 'type' | 'redirect' | 'origin'
+ > {
+ /**
+ * {@link RouteData.route}
+ */
+ pattern: RouteData['route'];
+
+ /**
+ * {@link RouteData.pattern}
+ */
+ patternRegex: RouteData['pattern'];
+
+ /**
+ * {@link RouteData.component}
+ */
+ entrypoint: RouteData['component'];
+
+ /**
+ * {@link RouteData.prerender}
+ */
+ isPrerendered: RouteData['prerender'];
+
+ /**
+ * {@link RouteData.redirectRoute}
+ */
+ redirectRoute?: IntegrationResolvedRoute;
+}
diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts
new file mode 100644
index 000000000..a17ee7650
--- /dev/null
+++ b/packages/astro/src/types/public/internal.ts
@@ -0,0 +1,304 @@
+// TODO: Should the types here really be public?
+
+import type { ErrorPayload as ViteErrorPayload } from 'vite';
+import type { AstroCookies } from '../../core/cookies/cookies.js';
+import type { AstroComponentInstance } from '../../runtime/server/index.js';
+import type { Params } from './common.js';
+import type { AstroConfig, RedirectConfig } from './config.js';
+import type { AstroGlobal, AstroGlobalPartial } from './context.js';
+import type { AstroRenderer } from './integrations.js';
+
+export type { SSRManifest } from '../../core/app/types.js';
+
+export interface NamedSSRLoadedRendererValue extends SSRLoadedRendererValue {
+ name: string;
+}
+
+export interface SSRLoadedRendererValue {
+ name?: string;
+ check: AsyncRendererComponentFn<boolean>;
+ renderToStaticMarkup: AsyncRendererComponentFn<{
+ html: string;
+ attrs?: Record<string, string>;
+ }>;
+ supportsAstroStaticSlot?: boolean;
+ /**
+ * If provided, Astro will call this function and inject the returned
+ * script in the HTML before the first component handled by this renderer.
+ *
+ * This feature is needed by some renderers (in particular, by Solid). The
+ * Solid official hydration script sets up a page-level data structure.
+ * It is mainly used to transfer data between the server side render phase
+ * and the browser application state. Solid Components rendered later in
+ * the HTML may inject tiny scripts into the HTML that call into this
+ * page-level data structure.
+ */
+ renderHydrationScript?: () => string;
+}
+
+/**
+ * It contains the information about a route
+ */
+export interface RouteData {
+ /**
+ * The current **pattern** of the route. For example:
+ * - `src/pages/index.astro` has a pattern of `/`
+ * - `src/pages/blog/[...slug].astro` has a pattern of `/blog/[...slug]`
+ * - `src/pages/site/[blog]/[...slug].astro` has a pattern of `/site/[blog]/[...slug]`
+ */
+ route: string;
+ /**
+ * Source component URL
+ */
+ component: string;
+ /**
+ * @param {any} data The optional parameters of the route
+ *
+ * @description
+ * A function that accepts a list of params, interpolates them with the route pattern, and returns the path name of the route.
+ *
+ * ## Example
+ *
+ * For a route such as `/blog/[...id].astro`, the `generate` function would return something like this:
+ *
+ * ```js
+ * console.log(generate({ id: 'presentation' })) // will log `/blog/presentation`
+ * ```
+ */
+ generate: (data?: any) => string;
+ /**
+ * Dynamic and spread route params
+ * ex. "/pages/[lang]/[...slug].astro" will output the params ['lang', '...slug']
+ */
+ params: string[];
+ /**
+ * Output URL pathname where this route will be served
+ * note: will be undefined for [dynamic] and [...spread] routes
+ */
+ pathname?: string;
+ /**
+ * The paths of the physical files emitted by this route. When a route **isn't** prerendered, the value is either `undefined` or an empty array.
+ */
+ distURL?: URL[];
+ /**
+ *
+ * regex used for matching an input URL against a requested route
+ * ex. "[fruit]/about.astro" will generate the pattern: /^\/([^/]+?)\/about\/?$/
+ * where pattern.test("banana/about") is "true"
+ *
+ * ## Example
+ *
+ * ```js
+ * if (route.pattern.test('/blog')) {
+ * // do something
+ * }
+ * ```
+ */
+ pattern: RegExp;
+ /**
+ * Similar to the "params" field, but with more associated metadata. For example, for `/site/[blog]/[...slug].astro`, the segments are:
+ *
+ * 1. `{ content: 'site', dynamic: false, spread: false }`
+ * 2. `{ content: 'blog', dynamic: true, spread: false }`
+ * 3. `{ content: '...slug', dynamic: true, spread: true }`
+ */
+ segments: RoutePart[][];
+ /**
+ *
+ * The type of the route. It can be:
+ * - `page`: a route that lives in the file system, usually an Astro component
+ * - `endpoint`: a route that lives in the file system, usually a JS file that exposes endpoints methods
+ * - `redirect`: a route points to another route that lives in the file system
+ * - `fallback`: a route that doesn't exist in the file system that needs to be handled with other means, usually the middleware
+ */
+ type: RouteType;
+ /**
+ * Whether the route is prerendered or not
+ */
+ prerender: boolean;
+ /**
+ * The route to redirect to. It holds information regarding the status code and its destination.
+ */
+ redirect?: RedirectConfig;
+ /**
+ * The {@link RouteData} to redirect to. It's present when `RouteData.type` is `redirect`.
+ */
+ redirectRoute?: RouteData;
+ /**
+ * A list of {@link RouteData} to fallback to. They are present when `i18n.fallback` has a list of locales.
+ */
+ fallbackRoutes: RouteData[];
+
+ /**
+ * If this route is a directory index
+ * For example:
+ * - src/pages/index.astro
+ * - src/pages/blog/index.astro
+ */
+ isIndex: boolean;
+
+ /**
+ * Whether the route comes from Astro core, an integration or the user's project
+ */
+ origin: 'internal' | 'external' | 'project';
+}
+
+/**
+ * - page: a route that lives in the file system, usually an Astro component
+ * - endpoint: a route that lives in the file system, usually a JS file that exposes endpoints methods
+ * - redirect: a route points to another route that lives in the file system
+ * - fallback: a route that doesn't exist in the file system that needs to be handled with other means, usually the middleware
+ */
+export type RouteType = 'page' | 'endpoint' | 'redirect' | 'fallback';
+
+export interface RoutePart {
+ content: string;
+ dynamic: boolean;
+ spread: boolean;
+}
+
+export interface AstroComponentMetadata {
+ displayName: string;
+ hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only';
+ hydrateArgs?: any;
+ componentUrl?: string;
+ componentExport?: { value: string; namespace?: boolean };
+ astroStaticSlot: true;
+}
+
+export type AsyncRendererComponentFn<U> = (
+ Component: any,
+ props: any,
+ slots: Record<string, string>,
+ metadata?: AstroComponentMetadata,
+) => Promise<U>;
+
+export interface NamedSSRLoadedRendererValue extends SSRLoadedRendererValue {
+ name: string;
+}
+
+export interface SSRLoadedRendererValue {
+ name?: string;
+ check: AsyncRendererComponentFn<boolean>;
+ renderToStaticMarkup: AsyncRendererComponentFn<{
+ html: string;
+ attrs?: Record<string, string>;
+ }>;
+ supportsAstroStaticSlot?: boolean;
+ /**
+ * If provided, Astro will call this function and inject the returned
+ * script in the HTML before the first component handled by this renderer.
+ *
+ * This feature is needed by some renderers (in particular, by Solid). The
+ * Solid official hydration script sets up a page-level data structure.
+ * It is mainly used to transfer data between the server side render phase
+ * and the browser application state. Solid Components rendered later in
+ * the HTML may inject tiny scripts into the HTML that call into this
+ * page-level data structure.
+ */
+ renderHydrationScript?: () => string;
+}
+
+export interface SSRLoadedRenderer extends Pick<AstroRenderer, 'name' | 'clientEntrypoint'> {
+ ssr: SSRLoadedRendererValue;
+}
+
+export interface SSRElement {
+ props: Record<string, any>;
+ children: string;
+}
+
+export interface SSRResult {
+ /**
+ * Whether the page has failed with a non-recoverable error, or the client disconnected.
+ */
+ cancelled: boolean;
+ base: string;
+ styles: Set<SSRElement>;
+ scripts: Set<SSRElement>;
+ links: Set<SSRElement>;
+ componentMetadata: Map<string, SSRComponentMetadata>;
+ inlinedScripts: Map<string, string>;
+ createAstro(
+ Astro: AstroGlobalPartial,
+ props: Record<string, any>,
+ slots: Record<string, any> | null,
+ ): AstroGlobal;
+ params: Params;
+ resolve: (s: string) => Promise<string>;
+ response: AstroGlobal['response'];
+ request: AstroGlobal['request'];
+ actionResult?: ReturnType<AstroGlobal['getActionResult']>;
+ renderers: SSRLoadedRenderer[];
+ /**
+ * Map of directive name (e.g. `load`) to the directive script code
+ */
+ clientDirectives: Map<string, string>;
+ compressHTML: boolean;
+ partial: boolean;
+ /**
+ * Only used for logging
+ */
+ pathname: string;
+ cookies: AstroCookies | undefined;
+ serverIslandNameMap: Map<string, string>;
+ trailingSlash: AstroConfig['trailingSlash'];
+ key: Promise<CryptoKey>;
+ _metadata: SSRMetadata;
+}
+
+/**
+ * A hint on whether the Astro runtime needs to wait on a component to render head
+ * content. The meanings:
+ *
+ * - __none__ (default) The component does not propagation head content.
+ * - __self__ The component appends head content.
+ * - __in-tree__ Another component within this component's dependency tree appends head content.
+ *
+ * These are used within the runtime to know whether or not a component should be waited on.
+ */
+export type PropagationHint = 'none' | 'self' | 'in-tree';
+
+export type SSRComponentMetadata = {
+ propagation: PropagationHint;
+ containsHead: boolean;
+};
+
+/**
+ * Ephemeral and mutable state during rendering that doesn't rely
+ * on external configuration
+ */
+export interface SSRMetadata {
+ hasHydrationScript: boolean;
+ /**
+ * Names of renderers that have injected their hydration scripts
+ * into the current page. For example, Solid SSR needs a hydration
+ * script in the page HTML before the first Solid component.
+ */
+ rendererSpecificHydrationScripts: Set<string>;
+ /**
+ * Used by `renderScript` to track script ids that have been rendered,
+ * so we only render each once.
+ */
+ renderedScripts: Set<string>;
+ hasDirectives: Set<string>;
+ hasRenderedHead: boolean;
+ headInTree: boolean;
+ extraHead: string[];
+ propagators: Set<AstroComponentInstance>;
+}
+
+export type SSRError = Error & ViteErrorPayload['err'];
+
+// `origin` is set within the hook, but the user doesn't have access to this property. That's why
+// we need an intermediary interface
+export interface InternalInjectedRoute {
+ pattern: string;
+ entrypoint: string | URL;
+ prerender?: boolean;
+ origin: RouteData['origin'];
+}
+
+export interface ResolvedInjectedRoute extends InternalInjectedRoute {
+ resolvedEntryPoint?: URL;
+}
diff --git a/packages/astro/src/types/public/manifest.ts b/packages/astro/src/types/public/manifest.ts
new file mode 100644
index 000000000..83cbb6a09
--- /dev/null
+++ b/packages/astro/src/types/public/manifest.ts
@@ -0,0 +1,38 @@
+/**
+ * **IMPORTANT**: use the `Pick` interface to select only the properties that we want to expose
+ * to the users. Using blanket types could expose properties that we don't want. So if we decide to expose
+ * properties, we need to be good at justifying them. For example: why you need this config? can't you use an integration?
+ * why do you need access to the shiki config? (very low-level confiig)
+ */
+
+import type { SSRManifest } from '../../core/app/types.js';
+import type { AstroConfig } from './config.js';
+
+// do not export
+type Extend<T, U> = { [K in keyof T]: T[K] | U };
+
+// do not export
+type Dirs = Pick<SSRManifest, 'cacheDir' | 'outDir' | 'publicDir' | 'srcDir'>;
+
+// do not export
+type DeserializedDirs = Extend<Dirs, URL>;
+
+// Export types after this comment
+
+export type ServerDeserializedManifest = Pick<
+ SSRManifest,
+ 'base' | 'trailingSlash' | 'compressHTML' | 'site'
+> &
+ DeserializedDirs & {
+ i18n: AstroConfig['i18n'];
+ build: Pick<AstroConfig['build'], 'server' | 'client' | 'format'>;
+ root: URL;
+ };
+
+export type ClientDeserializedManifest = Pick<
+ SSRManifest,
+ 'base' | 'trailingSlash' | 'compressHTML' | 'site'
+> & {
+ i18n: AstroConfig['i18n'];
+ build: Pick<AstroConfig['build'], 'format'>;
+};
diff --git a/packages/astro/src/types/public/preview.ts b/packages/astro/src/types/public/preview.ts
new file mode 100644
index 000000000..e2794303f
--- /dev/null
+++ b/packages/astro/src/types/public/preview.ts
@@ -0,0 +1,28 @@
+import type { OutgoingHttpHeaders } from 'node:http';
+import type { AstroIntegrationLogger } from '../../core/logger/core.js';
+
+export interface PreviewServer {
+ host?: string;
+ port: number;
+ closed(): Promise<void>;
+ stop(): Promise<void>;
+}
+
+export interface PreviewServerParams {
+ outDir: URL;
+ client: URL;
+ serverEntrypoint: URL;
+ host: string | undefined;
+ port: number;
+ base: string;
+ logger: AstroIntegrationLogger;
+ headers?: OutgoingHttpHeaders;
+}
+
+export type CreatePreviewServer = (
+ params: PreviewServerParams,
+) => PreviewServer | Promise<PreviewServer>;
+
+export interface PreviewModule {
+ default: CreatePreviewServer;
+}
diff --git a/packages/astro/src/types/public/toolbar.ts b/packages/astro/src/types/public/toolbar.ts
new file mode 100644
index 000000000..21b1db6b7
--- /dev/null
+++ b/packages/astro/src/types/public/toolbar.ts
@@ -0,0 +1,72 @@
+import type {
+ ToolbarAppEventTarget,
+ ToolbarServerHelpers,
+} from '../../runtime/client/dev-toolbar/helpers.js';
+import type {
+ AstroDevToolbar,
+ DevToolbarCanvas,
+} from '../../runtime/client/dev-toolbar/toolbar.js';
+import type { Icon } from '../../runtime/client/dev-toolbar/ui-library/icons.js';
+import type {
+ DevToolbarBadge,
+ DevToolbarButton,
+ DevToolbarCard,
+ DevToolbarHighlight,
+ DevToolbarIcon,
+ DevToolbarRadioCheckbox,
+ DevToolbarSelect,
+ DevToolbarToggle,
+ DevToolbarTooltip,
+ DevToolbarWindow,
+} from '../../runtime/client/dev-toolbar/ui-library/index.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'astro-dev-toolbar': AstroDevToolbar;
+ 'astro-dev-toolbar-window': DevToolbarWindow;
+ 'astro-dev-toolbar-app-canvas': DevToolbarCanvas;
+ 'astro-dev-toolbar-tooltip': DevToolbarTooltip;
+ 'astro-dev-toolbar-highlight': DevToolbarHighlight;
+ 'astro-dev-toolbar-toggle': DevToolbarToggle;
+ 'astro-dev-toolbar-badge': DevToolbarBadge;
+ 'astro-dev-toolbar-button': DevToolbarButton;
+ 'astro-dev-toolbar-icon': DevToolbarIcon;
+ 'astro-dev-toolbar-card': DevToolbarCard;
+ 'astro-dev-toolbar-select': DevToolbarSelect;
+ 'astro-dev-toolbar-radio-checkbox': DevToolbarRadioCheckbox;
+ }
+}
+
+type DevToolbarAppMeta = {
+ id: string;
+ name: string;
+ icon?: Icon;
+};
+
+// The param passed to `addDevToolbarApp` in the integration
+export type DevToolbarAppEntry = DevToolbarAppMeta & {
+ entrypoint: string | URL;
+};
+
+// Public API for the dev toolbar
+export type DevToolbarApp = {
+ init?(
+ canvas: ShadowRoot,
+ app: ToolbarAppEventTarget,
+ server: ToolbarServerHelpers,
+ ): void | Promise<void>;
+ beforeTogglingOff?(canvas: ShadowRoot): boolean | Promise<boolean>;
+};
+
+// An app that has been loaded and as such contain all of its properties
+export type ResolvedDevToolbarApp = DevToolbarAppMeta & DevToolbarApp;
+
+export type DevToolbarMetadata = Window &
+ typeof globalThis & {
+ __astro_dev_toolbar__: {
+ root: string;
+ version: string;
+ latestAstroVersion: string | undefined;
+ debugInfo: string;
+ };
+ };
diff --git a/packages/astro/src/types/public/view-transitions.ts b/packages/astro/src/types/public/view-transitions.ts
new file mode 100644
index 000000000..1acd5003b
--- /dev/null
+++ b/packages/astro/src/types/public/view-transitions.ts
@@ -0,0 +1,40 @@
+import type {
+ TransitionBeforePreparationEvent,
+ TransitionBeforeSwapEvent,
+} from '../../transitions/events.js';
+
+export interface TransitionAnimation {
+ name: string; // The name of the keyframe
+ delay?: number | string;
+ duration?: number | string;
+ easing?: string;
+ fillMode?: string;
+ direction?: string;
+}
+
+export interface TransitionAnimationPair {
+ old: TransitionAnimation | TransitionAnimation[];
+ new: TransitionAnimation | TransitionAnimation[];
+}
+
+export interface TransitionDirectionalAnimations {
+ forwards: TransitionAnimationPair;
+ backwards: TransitionAnimationPair;
+}
+
+export type TransitionAnimationValue =
+ | 'initial'
+ | 'slide'
+ | 'fade'
+ | 'none'
+ | TransitionDirectionalAnimations;
+
+declare global {
+ interface DocumentEventMap {
+ 'astro:before-preparation': TransitionBeforePreparationEvent;
+ 'astro:after-preparation': Event;
+ 'astro:before-swap': TransitionBeforeSwapEvent;
+ 'astro:after-swap': Event;
+ 'astro:page-load': Event;
+ }
+}
diff --git a/packages/astro/src/types/typed-emitter.ts b/packages/astro/src/types/typed-emitter.ts
new file mode 100644
index 000000000..43139bd4e
--- /dev/null
+++ b/packages/astro/src/types/typed-emitter.ts
@@ -0,0 +1,47 @@
+/**
+ * The MIT License (MIT)
+ * Copyright (c) 2018 Andy Wermke
+ * https://github.com/andywer/typed-emitter/blob/9a139b6fa0ec6b0db6141b5b756b784e4f7ef4e4/LICENSE
+ */
+
+export type EventMap = {
+ [key: string]: (...args: any[]) => void;
+};
+
+/**
+ * Type-safe event emitter.
+ *
+ * Use it like this:
+ *
+ * ```typescript
+ * type MyEvents = {
+ * error: (error: Error) => void;
+ * message: (from: string, content: string) => void;
+ * }
+ *
+ * const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
+ *
+ * myEmitter.emit("error", "x") // <- Will catch this type error;
+ * ```
+ */
+export interface TypedEventEmitter<Events extends EventMap> {
+ addListener<E extends keyof Events>(event: E, listener: Events[E]): this;
+ on<E extends keyof Events>(event: E, listener: Events[E]): this;
+ once<E extends keyof Events>(event: E, listener: Events[E]): this;
+ prependListener<E extends keyof Events>(event: E, listener: Events[E]): this;
+ prependOnceListener<E extends keyof Events>(event: E, listener: Events[E]): this;
+
+ off<E extends keyof Events>(event: E, listener: Events[E]): this;
+ removeAllListeners<E extends keyof Events>(event?: E): this;
+ removeListener<E extends keyof Events>(event: E, listener: Events[E]): this;
+
+ emit<E extends keyof Events>(event: E, ...args: Parameters<Events[E]>): boolean;
+ // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
+ eventNames(): (keyof Events | string | symbol)[];
+ rawListeners<E extends keyof Events>(event: E): Events[E][];
+ listeners<E extends keyof Events>(event: E): Events[E][];
+ listenerCount<E extends keyof Events>(event: E): number;
+
+ getMaxListeners(): number;
+ setMaxListeners(maxListeners: number): this;
+}
diff --git a/packages/astro/src/virtual-modules/README.md b/packages/astro/src/virtual-modules/README.md
new file mode 100644
index 000000000..137e2e16f
--- /dev/null
+++ b/packages/astro/src/virtual-modules/README.md
@@ -0,0 +1,3 @@
+# virtual-modules
+
+This directory contains the entry points for Astro virtual modules. For example, `astro:foobar` would re-export or use `astro/virtual-modules/foobar.js` which maps to the internal file `astro/dist/virtual-modules/foobar.js`.
diff --git a/packages/astro/src/virtual-modules/container.ts b/packages/astro/src/virtual-modules/container.ts
new file mode 100644
index 000000000..4908f0607
--- /dev/null
+++ b/packages/astro/src/virtual-modules/container.ts
@@ -0,0 +1,33 @@
+import type { AstroRenderer } from '../types/public/integrations.js';
+import type { SSRLoadedRenderer } from '../types/public/internal.js';
+
+/**
+ * Use this function to provide renderers to the `AstroContainer`:
+ *
+ * ```js
+ * import { getContainerRenderer } from "@astrojs/react";
+ * import { experimental_AstroContainer as AstroContainer } from "astro/container";
+ * import { loadRenderers } from "astro:container"; // use this only when using vite/vitest
+ *
+ * const renderers = await loadRenderers([ getContainerRenderer ]);
+ * const container = await AstroContainer.create({ renderers });
+ *
+ * ```
+ * @param renderers
+ */
+export async function loadRenderers(renderers: AstroRenderer[]) {
+ const loadedRenderers = await Promise.all(
+ renderers.map(async (renderer) => {
+ const mod = await import(renderer.serverEntrypoint.toString());
+ if (typeof mod.default !== 'undefined') {
+ return {
+ ...renderer,
+ ssr: mod.default,
+ } as SSRLoadedRenderer;
+ }
+ return undefined;
+ }),
+ );
+
+ return loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r));
+}
diff --git a/packages/astro/src/virtual-modules/i18n.ts b/packages/astro/src/virtual-modules/i18n.ts
new file mode 100644
index 000000000..d2e193fd7
--- /dev/null
+++ b/packages/astro/src/virtual-modules/i18n.ts
@@ -0,0 +1,390 @@
+import type { SSRManifest } from '../core/app/types.js';
+import { IncorrectStrategyForI18n } from '../core/errors/errors-data.js';
+import { AstroError } from '../core/errors/index.js';
+import * as I18nInternals from '../i18n/index.js';
+import type { RedirectToFallback } from '../i18n/index.js';
+import { toFallbackType, toRoutingStrategy } from '../i18n/utils.js';
+import type { I18nInternalConfig } from '../i18n/vite-plugin-i18n.js';
+import type { MiddlewareHandler } from '../types/public/common.js';
+import type { AstroConfig, ValidRedirectStatus } from '../types/public/config.js';
+import type { APIContext } from '../types/public/context.js';
+
+export { normalizeTheLocale, toCodes, toPaths } from '../i18n/index.js';
+
+const { trailingSlash, format, site, i18n, isBuild } =
+ // @ts-expect-error
+ __ASTRO_INTERNAL_I18N_CONFIG__ as I18nInternalConfig;
+const { defaultLocale, locales, domains, fallback, routing } = i18n!;
+const base = import.meta.env.BASE_URL;
+
+let strategy = toRoutingStrategy(routing, domains);
+let fallbackType = toFallbackType(routing);
+
+export type GetLocaleOptions = I18nInternals.GetLocaleOptions;
+
+const noop = (method: string) =>
+ function () {
+ throw new AstroError({
+ ...IncorrectStrategyForI18n,
+ message: IncorrectStrategyForI18n.message(method),
+ });
+ };
+
+/**
+ * @param locale A locale
+ * @param path An optional path to add after the `locale`.
+ * @param options Customise the generated path
+ *
+ * Returns a _relative_ path with passed locale.
+ *
+ * ## Errors
+ *
+ * Throws an error if the locale doesn't exist in the list of locales defined in the configuration.
+ *
+ * ## Examples
+ *
+ * ```js
+ * import { getRelativeLocaleUrl } from "astro:i18n";
+ * getRelativeLocaleUrl("es"); // /es
+ * getRelativeLocaleUrl("es", "getting-started"); // /es/getting-started
+ * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // /blog/es-us/getting-started
+ * getRelativeLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // /blog/es_US/getting-started
+ * ```
+ */
+export const getRelativeLocaleUrl = (locale: string, path?: string, options?: GetLocaleOptions) =>
+ I18nInternals.getLocaleRelativeUrl({
+ locale,
+ path,
+ base,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ ...options,
+ });
+
+/**
+ *
+ * @param locale A locale
+ * @param path An optional path to add after the `locale`.
+ * @param options Customise the generated path
+ *
+ * Returns an absolute path with the passed locale. The behaviour is subject to change based on `site` configuration.
+ * If _not_ provided, the function will return a _relative_ URL.
+ *
+ * ## Errors
+ *
+ * Throws an error if the locale doesn't exist in the list of locales defined in the configuration.
+ *
+ * ## Examples
+ *
+ * If `site` is `https://example.com`:
+ *
+ * ```js
+ * import { getAbsoluteLocaleUrl } from "astro:i18n";
+ * getAbsoluteLocaleUrl("es"); // https://example.com/es
+ * getAbsoluteLocaleUrl("es", "getting-started"); // https://example.com/es/getting-started
+ * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog" }); // https://example.com/blog/es-us/getting-started
+ * getAbsoluteLocaleUrl("es_US", "getting-started", { prependWith: "blog", normalizeLocale: false }); // https://example.com/blog/es_US/getting-started
+ * ```
+ */
+export const getAbsoluteLocaleUrl = (locale: string, path?: string, options?: GetLocaleOptions) =>
+ I18nInternals.getLocaleAbsoluteUrl({
+ locale,
+ path,
+ base,
+ trailingSlash,
+ format,
+ site,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ isBuild,
+ ...options,
+ });
+
+/**
+ * @param path An optional path to add after the `locale`.
+ * @param options Customise the generated path
+ *
+ * Works like `getRelativeLocaleUrl` but it emits the relative URLs for ALL locales:
+ */
+export const getRelativeLocaleUrlList = (path?: string, options?: GetLocaleOptions) =>
+ I18nInternals.getLocaleRelativeUrlList({
+ base,
+ path,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ ...options,
+ });
+
+/**
+ * @param path An optional path to add after the `locale`.
+ * @param options Customise the generated path
+ *
+ * Works like `getAbsoluteLocaleUrl` but it emits the absolute URLs for ALL locales:
+ */
+export const getAbsoluteLocaleUrlList = (path?: string, options?: GetLocaleOptions) =>
+ I18nInternals.getLocaleAbsoluteUrlList({
+ site,
+ base,
+ path,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ isBuild,
+ ...options,
+ });
+
+/**
+ * A function that return the `path` associated to a locale (defined as code). It's particularly useful in case you decide
+ * to use locales that are broken down in paths and codes.
+ *
+ * @param locale The code of the locale
+ * @returns The path associated to the locale
+ *
+ * ## Example
+ *
+ * ```js
+ * // astro.config.mjs
+ *
+ * export default defineConfig({
+ * i18n: {
+ * locales: [
+ * { codes: ["it", "it-VT"], path: "italiano" },
+ * "es"
+ * ]
+ * }
+ * })
+ * ```
+ *
+ * ```js
+ * import { getPathByLocale } from "astro:i18n";
+ * getPathByLocale("it"); // returns "italiano"
+ * getPathByLocale("it-VT"); // returns "italiano"
+ * getPathByLocale("es"); // returns "es"
+ * ```
+ */
+export const getPathByLocale = (locale: string) => I18nInternals.getPathByLocale(locale, locales);
+
+/**
+ * A function that returns the preferred locale given a certain path. This is particularly useful if you configure a locale using
+ * `path` and `codes`. When you define multiple `code`, this function will return the first code of the array.
+ *
+ * Astro will treat the first code as the one that the user prefers.
+ *
+ * @param path The path that maps to a locale
+ * @returns The path associated to the locale
+ *
+ * ## Example
+ *
+ * ```js
+ * // astro.config.mjs
+ *
+ * export default defineConfig({
+ * i18n: {
+ * locales: [
+ * { codes: ["it-VT", "it"], path: "italiano" },
+ * "es"
+ * ]
+ * }
+ * })
+ * ```
+ *
+ * ```js
+ * import { getLocaleByPath } from "astro:i18n";
+ * getLocaleByPath("italiano"); // returns "it-VT" because that's the first code configured
+ * getLocaleByPath("es"); // returns "es"
+ * ```
+ */
+export const getLocaleByPath = (path: string) => I18nInternals.getLocaleByPath(path, locales);
+
+/**
+ * A function that can be used to check if the current path contains a configured locale.
+ *
+ * @param path The path that maps to a locale
+ * @returns Whether the `path` has the locale
+ *
+ * ## Example
+ *
+ * Given the following configuration:
+ *
+ * ```js
+ * // astro.config.mjs
+ *
+ * export default defineConfig({
+ * i18n: {
+ * locales: [
+ * { codes: ["it-VT", "it"], path: "italiano" },
+ * "es"
+ * ]
+ * }
+ * })
+ * ```
+ *
+ * Here's some use cases:
+ *
+ * ```js
+ * import { pathHasLocale } from "astro:i18n";
+ * getLocaleByPath("italiano"); // returns `true`
+ * getLocaleByPath("es"); // returns `true`
+ * getLocaleByPath("it-VT"); // returns `false`
+ * ```
+ */
+export const pathHasLocale = (path: string) => I18nInternals.pathHasLocale(path, locales);
+
+/**
+ *
+ * This function returns a redirect to the default locale configured in the
+ *
+ * @param {APIContext} context The context passed to the middleware
+ * @param {ValidRedirectStatus?} statusCode An optional status code for the redirect.
+ */
+export let redirectToDefaultLocale: (
+ context: APIContext,
+ statusCode?: ValidRedirectStatus,
+) => Response | undefined;
+
+if (i18n?.routing === 'manual') {
+ redirectToDefaultLocale = I18nInternals.redirectToDefaultLocale({
+ base,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ fallback,
+ fallbackType,
+ });
+} else {
+ redirectToDefaultLocale = noop('redirectToDefaultLocale');
+}
+/**
+ *
+ * Use this function to return a 404 when:
+ * - the current path isn't a root. e.g. / or /<base>
+ * - the URL doesn't contain a locale
+ *
+ * When a `Response` is passed, the new `Response` emitted by this function will contain the same headers of the original response.
+ *
+ * @param {APIContext} context The context passed to the middleware
+ * @param {Response?} response An optional `Response` in case you're handling a `Response` coming from the `next` function.
+ *
+ */
+export let notFound: (context: APIContext, response?: Response) => Response | undefined;
+
+if (i18n?.routing === 'manual') {
+ notFound = I18nInternals.notFound({
+ base,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ fallback,
+ fallbackType,
+ });
+} else {
+ notFound = noop('notFound');
+}
+
+/**
+ * Checks whether the current URL contains a configured locale. Internally, this function will use `APIContext#url.pathname`
+ *
+ * @param {APIContext} context The context passed to the middleware
+ */
+export let requestHasLocale: (context: APIContext) => boolean;
+
+if (i18n?.routing === 'manual') {
+ requestHasLocale = I18nInternals.requestHasLocale(locales);
+} else {
+ requestHasLocale = noop('requestHasLocale');
+}
+
+/**
+ * Allows to use the build-in fallback system of Astro
+ *
+ * @param {APIContext} context The context passed to the middleware
+ * @param {Promise<Response>} response An optional `Response` in case you're handling a `Response` coming from the `next` function.
+ */
+export let redirectToFallback: RedirectToFallback;
+
+if (i18n?.routing === 'manual') {
+ redirectToFallback = I18nInternals.redirectToFallback({
+ base,
+ trailingSlash,
+ format,
+ defaultLocale,
+ locales,
+ strategy,
+ domains,
+ fallback,
+ fallbackType,
+ });
+} else {
+ redirectToFallback = noop('useFallback');
+}
+
+type OnlyObject<T> = T extends object ? T : never;
+type NewAstroRoutingConfigWithoutManual = OnlyObject<NonNullable<AstroConfig['i18n']>['routing']>;
+
+/**
+ * @param {AstroConfig['i18n']['routing']} customOptions
+ *
+ * A function that allows to programmatically create the Astro i18n middleware.
+ *
+ * This is use useful when you still want to use the default i18n logic, but add only few exceptions to your website.
+ *
+ * ## Examples
+ *
+ * ```js
+ * // middleware.js
+ * import { middleware } from "astro:i18n";
+ * import { sequence, defineMiddleware } from "astro:middleware";
+ *
+ * const customLogic = defineMiddleware(async (context, next) => {
+ * const response = await next();
+ *
+ * // Custom logic after resolving the response.
+ * // It's possible to catch the response coming from Astro i18n middleware.
+ *
+ * return response;
+ * });
+ *
+ * export const onRequest = sequence(customLogic, middleware({
+ * prefixDefaultLocale: true,
+ * redirectToDefaultLocale: false
+ * }))
+ *
+ * ```
+ */
+export let middleware: (customOptions: NewAstroRoutingConfigWithoutManual) => MiddlewareHandler;
+
+if (i18n?.routing === 'manual') {
+ middleware = (customOptions: NewAstroRoutingConfigWithoutManual) => {
+ strategy = toRoutingStrategy(customOptions, {});
+ fallbackType = toFallbackType(customOptions);
+ const manifest: SSRManifest['i18n'] = {
+ ...i18n,
+ strategy,
+ domainLookupTable: {},
+ fallbackType,
+ fallback: i18n.fallback,
+ };
+ return I18nInternals.createMiddleware(manifest, base, trailingSlash, format);
+ };
+} else {
+ middleware = noop('middleware');
+}
diff --git a/packages/astro/src/virtual-modules/middleware.ts b/packages/astro/src/virtual-modules/middleware.ts
new file mode 100644
index 000000000..4874c88d0
--- /dev/null
+++ b/packages/astro/src/virtual-modules/middleware.ts
@@ -0,0 +1 @@
+export { defineMiddleware, sequence } from '../core/middleware/index.js';
diff --git a/packages/astro/src/virtual-modules/prefetch.ts b/packages/astro/src/virtual-modules/prefetch.ts
new file mode 100644
index 000000000..72bc23e2d
--- /dev/null
+++ b/packages/astro/src/virtual-modules/prefetch.ts
@@ -0,0 +1 @@
+export * from '../prefetch/index.js';
diff --git a/packages/astro/src/virtual-modules/transitions-events.ts b/packages/astro/src/virtual-modules/transitions-events.ts
new file mode 100644
index 000000000..35ecaf64f
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-events.ts
@@ -0,0 +1 @@
+export * from '../transitions/events.js';
diff --git a/packages/astro/src/virtual-modules/transitions-router.ts b/packages/astro/src/virtual-modules/transitions-router.ts
new file mode 100644
index 000000000..666089f3f
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-router.ts
@@ -0,0 +1 @@
+export * from '../transitions/router.js';
diff --git a/packages/astro/src/virtual-modules/transitions-swap-functions.ts b/packages/astro/src/virtual-modules/transitions-swap-functions.ts
new file mode 100644
index 000000000..5947533e3
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-swap-functions.ts
@@ -0,0 +1 @@
+export * from '../transitions/swap-functions.js';
diff --git a/packages/astro/src/virtual-modules/transitions-types.ts b/packages/astro/src/virtual-modules/transitions-types.ts
new file mode 100644
index 000000000..66dfb1d0e
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions-types.ts
@@ -0,0 +1 @@
+export * from '../transitions/types.js';
diff --git a/packages/astro/src/virtual-modules/transitions.ts b/packages/astro/src/virtual-modules/transitions.ts
new file mode 100644
index 000000000..84aeb3a2c
--- /dev/null
+++ b/packages/astro/src/virtual-modules/transitions.ts
@@ -0,0 +1 @@
+export * from '../transitions/index.js';
diff --git a/packages/astro/src/vite-plugin-astro-postprocess/README.md b/packages/astro/src/vite-plugin-astro-postprocess/README.md
new file mode 100644
index 000000000..f4cc5fd6c
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-postprocess/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-astro-postprocess
+
+Adds last-minute transforms to `.astro` files
diff --git a/packages/astro/src/vite-plugin-astro-postprocess/index.ts b/packages/astro/src/vite-plugin-astro-postprocess/index.ts
new file mode 100644
index 000000000..0e48d8a66
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-postprocess/index.ts
@@ -0,0 +1,65 @@
+import { parse } from 'acorn';
+import type { Node as ESTreeNode } from 'estree-walker';
+import { walk } from 'estree-walker';
+import MagicString from 'magic-string';
+import type { Plugin } from 'vite';
+import { isMarkdownFile } from '../core/util.js';
+
+// Check for `Astro.glob()`. Be very forgiving of whitespace. False positives are okay.
+const ASTRO_GLOB_REGEX = /Astro2?\s*\.\s*glob\s*\(/;
+
+export default function astro(): Plugin {
+ return {
+ name: 'astro:postprocess',
+ async transform(code, id) {
+ // Currently only supported in ".astro" and ".md" (or any alternative markdown file extension like `.markdown`) files
+ if (!id.endsWith('.astro') && !isMarkdownFile(id)) {
+ return null;
+ }
+
+ // Optimization: Detect usage with a quick string match.
+ // Only perform the transform if this function is found
+ if (!ASTRO_GLOB_REGEX.test(code)) {
+ return null;
+ }
+
+ let s: MagicString | undefined;
+ const ast = parse(code, {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ });
+
+ walk(ast as ESTreeNode, {
+ enter(node: any) {
+ // Transform `Astro.glob("./pages/*.astro")` to `Astro.glob(import.meta.glob("./pages/*.astro"), () => "./pages/*.astro")`
+ // Also handle for `Astro2.glob()`
+ if (
+ node.type === 'CallExpression' &&
+ node.callee.type === 'MemberExpression' &&
+ node.callee.property.name === 'glob' &&
+ (node.callee.object.name === 'Astro' || node.callee.object.name === 'Astro2') &&
+ node.arguments.length
+ ) {
+ const firstArgStart = node.arguments[0].start;
+ const firstArgEnd = node.arguments[0].end;
+ const lastArgEnd = node.arguments[node.arguments.length - 1].end;
+ const firstArg = code.slice(firstArgStart, firstArgEnd);
+ s ??= new MagicString(code);
+ s.overwrite(
+ firstArgStart,
+ lastArgEnd,
+ `import.meta.glob(${firstArg}), () => ${firstArg}`,
+ );
+ }
+ },
+ });
+
+ if (s) {
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary' }),
+ };
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/base.ts b/packages/astro/src/vite-plugin-astro-server/base.ts
new file mode 100644
index 000000000..8820f611b
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/base.ts
@@ -0,0 +1,69 @@
+import type * as vite from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+
+import * as fs from 'node:fs';
+import path from 'node:path';
+import { appendForwardSlash } from '@astrojs/internal-helpers/path';
+import { bold } from 'kleur/colors';
+import type { Logger } from '../core/logger/core.js';
+import { notFoundTemplate, subpathNotUsedTemplate } from '../template/4xx.js';
+import { writeHtmlResponse } from './response.js';
+
+export function baseMiddleware(
+ settings: AstroSettings,
+ logger: Logger,
+): vite.Connect.NextHandleFunction {
+ const { config } = settings;
+ const site = config.site ? new URL(config.base, config.site) : undefined;
+ const devRootURL = new URL(config.base, 'http://localhost');
+ const devRoot = site ? site.pathname : devRootURL.pathname;
+ const devRootReplacement = devRoot.endsWith('/') ? '/' : '';
+
+ return function devBaseMiddleware(req, res, next) {
+ const url = req.url!;
+ let pathname: string;
+ try {
+ pathname = decodeURI(new URL(url, 'http://localhost').pathname);
+ } catch (e) {
+ /* malformed uri */
+ return next(e);
+ }
+
+ if (pathname.startsWith(devRoot)) {
+ req.url = url.replace(devRoot, devRootReplacement);
+ return next();
+ }
+
+ if (pathname === '/' || pathname === '/index.html') {
+ const html = subpathNotUsedTemplate(devRoot, pathname);
+ return writeHtmlResponse(res, 404, html);
+ }
+
+ if (req.headers.accept?.includes('text/html')) {
+ const html = notFoundTemplate(pathname);
+ return writeHtmlResponse(res, 404, html);
+ }
+
+ // Check to see if it's in public and if so 404
+ const publicPath = new URL('.' + req.url, config.publicDir);
+ fs.stat(publicPath, (_err, stats) => {
+ if (stats) {
+ const publicDir = appendForwardSlash(
+ path.posix.relative(config.root.pathname, config.publicDir.pathname),
+ );
+ const expectedLocation = new URL(devRootURL.pathname + url, devRootURL).pathname;
+
+ logger.error(
+ 'router',
+ `Request URLs for ${bold(
+ publicDir,
+ )} assets must also include your base. "${expectedLocation}" expected, but received "${url}".`,
+ );
+ const html = subpathNotUsedTemplate(devRoot, pathname);
+ return writeHtmlResponse(res, 404, html);
+ } else {
+ next();
+ }
+ });
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/controller.ts b/packages/astro/src/vite-plugin-astro-server/controller.ts
new file mode 100644
index 000000000..9ba345d69
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/controller.ts
@@ -0,0 +1,107 @@
+import type { LoaderEvents, ModuleLoader } from '../core/module-loader/index.js';
+import type { ServerState } from './server-state.js';
+
+import {
+ clearRouteError,
+ createServerState,
+ setRouteError,
+ setServerError,
+} from './server-state.js';
+
+type ReloadFn = () => void;
+
+export interface DevServerController {
+ state: ServerState;
+ onFileChange: LoaderEvents['file-change'];
+ onHMRError: LoaderEvents['hmr-error'];
+}
+
+export type CreateControllerParams =
+ | {
+ loader: ModuleLoader;
+ }
+ | {
+ reload: ReloadFn;
+ };
+
+export function createController(params: CreateControllerParams): DevServerController {
+ if ('loader' in params) {
+ return createLoaderController(params.loader);
+ } else {
+ return createBaseController(params);
+ }
+}
+
+function createBaseController({ reload }: { reload: ReloadFn }): DevServerController {
+ const serverState = createServerState();
+
+ const onFileChange: LoaderEvents['file-change'] = () => {
+ if (serverState.state === 'error') {
+ reload();
+ }
+ };
+
+ const onHMRError: LoaderEvents['hmr-error'] = (payload) => {
+ let msg = payload?.err?.message ?? 'Unknown error';
+ let stack = payload?.err?.stack ?? 'Unknown stack';
+ let error = new Error(msg);
+ Object.defineProperty(error, 'stack', {
+ value: stack,
+ });
+ setServerError(serverState, error);
+ };
+
+ return {
+ state: serverState,
+ onFileChange,
+ onHMRError,
+ };
+}
+
+function createLoaderController(loader: ModuleLoader): DevServerController {
+ const controller = createBaseController({
+ reload() {
+ loader.clientReload();
+ },
+ });
+ const baseOnFileChange = controller.onFileChange;
+ controller.onFileChange = (...args) => {
+ if (controller.state.state === 'error') {
+ // If we are in an error state, check if there are any modules with errors
+ // and if so invalidate them so that they will be updated on refresh.
+ loader.eachModule((mod) => {
+ if (mod.ssrError) {
+ loader.invalidateModule(mod);
+ }
+ });
+ }
+ baseOnFileChange(...args);
+ };
+
+ loader.events.on('file-change', controller.onFileChange);
+ loader.events.on('hmr-error', controller.onHMRError);
+
+ return controller;
+}
+
+export interface RunWithErrorHandlingParams {
+ controller: DevServerController;
+ pathname: string;
+ run: () => Promise<any>;
+ onError: (error: unknown) => Error;
+}
+
+export async function runWithErrorHandling({
+ controller: { state },
+ pathname,
+ run,
+ onError,
+}: RunWithErrorHandlingParams) {
+ try {
+ await run();
+ clearRouteError(state, pathname);
+ } catch (err) {
+ const error = onError(err);
+ setRouteError(state, pathname, error);
+ }
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/css.ts b/packages/astro/src/vite-plugin-astro-server/css.ts
new file mode 100644
index 000000000..af147048a
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/css.ts
@@ -0,0 +1,70 @@
+import type { ModuleLoader } from '../core/module-loader/index.js';
+import { viteID } from '../core/util.js';
+import { isBuildableCSSRequest } from './util.js';
+import { crawlGraph } from './vite.js';
+
+interface ImportedStyle {
+ id: string;
+ url: string;
+ content: string;
+}
+
+const inlineQueryRE = /(?:\?|&)inline(?:$|&)/;
+
+/** Given a filePath URL, crawl Vite’s module graph to find all style imports. */
+export async function getStylesForURL(
+ filePath: URL,
+ loader: ModuleLoader,
+): Promise<{ urls: Set<string>; styles: ImportedStyle[]; crawledFiles: Set<string> }> {
+ const importedCssUrls = new Set<string>();
+ // Map of url to injected style object. Use a `url` key to deduplicate styles
+ const importedStylesMap = new Map<string, ImportedStyle>();
+ const crawledFiles = new Set<string>();
+
+ for await (const importedModule of crawlGraph(loader, viteID(filePath), true)) {
+ if (importedModule.file) {
+ crawledFiles.add(importedModule.file);
+ }
+ if (isBuildableCSSRequest(importedModule.url)) {
+ // In dev, we inline all styles if possible
+ let css = '';
+ // If this is a plain CSS module, the default export should be a string
+ if (typeof importedModule.ssrModule?.default === 'string') {
+ css = importedModule.ssrModule.default;
+ }
+ // Else try to load it
+ else {
+ let modId = importedModule.url;
+ // Mark url with ?inline so Vite will return the CSS as plain string, even for CSS modules
+ if (!inlineQueryRE.test(importedModule.url)) {
+ if (importedModule.url.includes('?')) {
+ modId = importedModule.url.replace('?', '?inline&');
+ } else {
+ modId += '?inline';
+ }
+ }
+ try {
+ // The SSR module is possibly not loaded. Load it if it's null.
+ const ssrModule = await loader.import(modId);
+ css = ssrModule.default;
+ } catch {
+ // The module may not be inline-able, e.g. SCSS partials. Skip it as it may already
+ // be inlined into other modules if it happens to be in the graph.
+ continue;
+ }
+ }
+
+ importedStylesMap.set(importedModule.url, {
+ id: importedModule.id ?? importedModule.url,
+ url: importedModule.url,
+ content: css,
+ });
+ }
+ }
+
+ return {
+ urls: importedCssUrls,
+ styles: [...importedStylesMap.values()],
+ crawledFiles,
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/error.ts b/packages/astro/src/vite-plugin-astro-server/error.ts
new file mode 100644
index 000000000..25a237cc2
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/error.ts
@@ -0,0 +1,32 @@
+import type { ModuleLoader } from '../core/module-loader/index.js';
+import type { DevPipeline } from './pipeline.js';
+
+import { collectErrorMetadata } from '../core/errors/dev/index.js';
+import { createSafeError } from '../core/errors/index.js';
+import { formatErrorMessage } from '../core/messages.js';
+import type { AstroConfig } from '../types/public/config.js';
+
+export function recordServerError(
+ loader: ModuleLoader,
+ config: AstroConfig,
+ { logger }: DevPipeline,
+ _err: unknown,
+) {
+ const err = createSafeError(_err);
+
+ // This could be a runtime error from Vite's SSR module, so try to fix it here
+ try {
+ loader.fixStacktrace(err);
+ } catch {}
+
+ // This is our last line of defense regarding errors where we still might have some information about the request
+ // Our error should already be complete, but let's try to add a bit more through some guesswork
+ const errorWithMetadata = collectErrorMetadata(err, config.root);
+
+ logger.error(null, formatErrorMessage(errorWithMetadata, logger.level() === 'debug'));
+
+ return {
+ error: err,
+ errorWithMetadata,
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts
new file mode 100644
index 000000000..14172e8ae
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/index.ts
@@ -0,0 +1,3 @@
+export { createController, runWithErrorHandling } from './controller.js';
+export { default as vitePluginAstroServer } from './plugin.js';
+export { handleRequest } from './request.js';
diff --git a/packages/astro/src/vite-plugin-astro-server/metadata.ts b/packages/astro/src/vite-plugin-astro-server/metadata.ts
new file mode 100644
index 000000000..7f358ade0
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/metadata.ts
@@ -0,0 +1,42 @@
+import type { ModuleInfo, ModuleLoader } from '../core/module-loader/index.js';
+import { viteID } from '../core/util.js';
+import type { SSRComponentMetadata, SSRResult } from '../types/public/internal.js';
+import { getAstroMetadata } from '../vite-plugin-astro/index.js';
+import { crawlGraph } from './vite.js';
+
+export async function getComponentMetadata(
+ filePath: URL,
+ loader: ModuleLoader,
+): Promise<SSRResult['componentMetadata']> {
+ const map: SSRResult['componentMetadata'] = new Map();
+
+ const rootID = viteID(filePath);
+ addMetadata(map, loader.getModuleInfo(rootID));
+ for await (const moduleNode of crawlGraph(loader, rootID, true)) {
+ const id = moduleNode.id;
+ if (id) {
+ addMetadata(map, loader.getModuleInfo(id));
+ }
+ }
+
+ return map;
+}
+
+function addMetadata(map: SSRResult['componentMetadata'], modInfo: ModuleInfo | null) {
+ if (modInfo) {
+ const astro = getAstroMetadata(modInfo);
+ if (astro) {
+ let metadata: SSRComponentMetadata = {
+ containsHead: false,
+ propagation: 'none',
+ };
+ if (astro.propagation) {
+ metadata.propagation = astro.propagation;
+ }
+ if (astro.containsHead) {
+ metadata.containsHead = astro.containsHead;
+ }
+ map.set(modInfo.id, metadata);
+ }
+ }
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/pipeline.ts b/packages/astro/src/vite-plugin-astro-server/pipeline.ts
new file mode 100644
index 000000000..c1eee575d
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/pipeline.ts
@@ -0,0 +1,227 @@
+import { fileURLToPath } from 'node:url';
+import { getInfoOutput } from '../cli/info/index.js';
+import type { HeadElements, TryRewriteResult } from '../core/base-pipeline.js';
+import { ASTRO_VERSION } from '../core/constants.js';
+import { enhanceViteSSRError } from '../core/errors/dev/index.js';
+import { AggregateError, CSSError, MarkdownError } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import type { ModuleLoader } from '../core/module-loader/index.js';
+import { Pipeline, loadRenderer } from '../core/render/index.js';
+import { createDefaultRoutes } from '../core/routing/default.js';
+import { findRouteToRewrite } from '../core/routing/rewrite.js';
+import { isPage, viteID } from '../core/util.js';
+import { resolveIdToUrl } from '../core/viteUtils.js';
+import type { AstroSettings, ComponentInstance, RoutesList } from '../types/astro.js';
+import type { RewritePayload } from '../types/public/common.js';
+import type {
+ RouteData,
+ SSRElement,
+ SSRLoadedRenderer,
+ SSRManifest,
+} from '../types/public/internal.js';
+import type { DevToolbarMetadata } from '../types/public/toolbar.js';
+import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
+import { getStylesForURL } from './css.js';
+import { getComponentMetadata } from './metadata.js';
+import { createResolve } from './resolve.js';
+
+export class DevPipeline extends Pipeline {
+ // renderers are loaded on every request,
+ // so it needs to be mutable here unlike in other environments
+ override renderers = new Array<SSRLoadedRenderer>();
+
+ routesList: RoutesList | undefined;
+
+ componentInterner: WeakMap<RouteData, ComponentInstance> = new WeakMap<
+ RouteData,
+ ComponentInstance
+ >();
+
+ private constructor(
+ readonly loader: ModuleLoader,
+ readonly logger: Logger,
+ readonly manifest: SSRManifest,
+ readonly settings: AstroSettings,
+ readonly config = settings.config,
+ readonly defaultRoutes = createDefaultRoutes(manifest),
+ ) {
+ const resolve = createResolve(loader, config.root);
+ const serverLike = settings.buildOutput === 'server';
+ const streaming = true;
+ super(logger, manifest, 'development', [], resolve, serverLike, streaming);
+ manifest.serverIslandMap = settings.serverIslandMap;
+ manifest.serverIslandNameMap = settings.serverIslandNameMap;
+ }
+
+ static create(
+ manifestData: RoutesList,
+ {
+ loader,
+ logger,
+ manifest,
+ settings,
+ }: Pick<DevPipeline, 'loader' | 'logger' | 'manifest' | 'settings'>,
+ ) {
+ const pipeline = new DevPipeline(loader, logger, manifest, settings);
+ pipeline.routesList = manifestData;
+ return pipeline;
+ }
+
+ async headElements(routeData: RouteData): Promise<HeadElements> {
+ const {
+ config: { root },
+ loader,
+ runtimeMode,
+ settings,
+ } = this;
+ const filePath = new URL(`${routeData.component}`, root);
+ const scripts = new Set<SSRElement>();
+
+ // Inject HMR scripts
+ if (isPage(filePath, settings) && runtimeMode === 'development') {
+ scripts.add({
+ props: { type: 'module', src: '/@vite/client' },
+ children: '',
+ });
+
+ if (
+ settings.config.devToolbar.enabled &&
+ (await settings.preferences.get('devToolbar.enabled'))
+ ) {
+ const src = await resolveIdToUrl(loader, 'astro/runtime/client/dev-toolbar/entrypoint.js');
+ scripts.add({ props: { type: 'module', src }, children: '' });
+
+ const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = {
+ root: fileURLToPath(settings.config.root),
+ version: ASTRO_VERSION,
+ latestAstroVersion: settings.latestAstroVersion,
+ debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }),
+ };
+
+ // Additional data for the dev overlay
+ const children = `window.__astro_dev_toolbar__ = ${JSON.stringify(additionalMetadata)}`;
+ scripts.add({ props: {}, children });
+ }
+ }
+
+ // TODO: We should allow adding generic HTML elements to the head, not just scripts
+ for (const script of settings.scripts) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.content,
+ });
+ } else if (script.stage === 'page' && isPage(filePath, settings)) {
+ scripts.add({
+ props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` },
+ children: '',
+ });
+ }
+ }
+
+ // Pass framework CSS in as style tags to be appended to the page.
+ const links = new Set<SSRElement>();
+ const { urls, styles: _styles } = await getStylesForURL(filePath, loader);
+ for (const href of urls) {
+ links.add({ props: { rel: 'stylesheet', href }, children: '' });
+ }
+
+ const styles = new Set<SSRElement>();
+ for (const { id, url: src, content } of _styles) {
+ // Vite handles HMR for styles injected as scripts
+ scripts.add({ props: { type: 'module', src }, children: '' });
+ // But we still want to inject the styles to avoid FOUC. The style tags
+ // should emulate what Vite injects so further HMR works as expected.
+ styles.add({ props: { 'data-vite-dev-id': id }, children: content });
+ }
+
+ return { scripts, styles, links };
+ }
+
+ componentMetadata(routeData: RouteData) {
+ const {
+ config: { root },
+ loader,
+ } = this;
+ const filePath = new URL(`${routeData.component}`, root);
+ return getComponentMetadata(filePath, loader);
+ }
+
+ async preload(routeData: RouteData, filePath: URL) {
+ const { loader } = this;
+
+ // First check built-in routes
+ for (const route of this.defaultRoutes) {
+ if (route.matchesComponent(filePath)) {
+ return route.instance;
+ }
+ }
+
+ // Important: This needs to happen first, in case a renderer provides polyfills.
+ const renderers__ = this.settings.renderers.map((r) => loadRenderer(r, loader));
+ const renderers_ = await Promise.all(renderers__);
+ this.renderers = renderers_.filter((r): r is SSRLoadedRenderer => Boolean(r));
+
+ try {
+ // Load the module from the Vite SSR Runtime.
+ const componentInstance = (await loader.import(viteID(filePath))) as ComponentInstance;
+ this.componentInterner.set(routeData, componentInstance);
+ return componentInstance;
+ } catch (error) {
+ // If the error came from Markdown or CSS, we already handled it and there's no need to enhance it
+ if (MarkdownError.is(error) || CSSError.is(error) || AggregateError.is(error)) {
+ throw error;
+ }
+
+ throw enhanceViteSSRError({ error, filePath, loader });
+ }
+ }
+
+ clearRouteCache() {
+ this.routeCache.clearAll();
+ this.componentInterner = new WeakMap<RouteData, ComponentInstance>();
+ }
+
+ async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
+ const component = this.componentInterner.get(routeData);
+ if (component) {
+ return component;
+ } else {
+ const filePath = new URL(`${routeData.component}`, this.config.root);
+ return await this.preload(routeData, filePath);
+ }
+ }
+
+ async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
+ if (!this.routesList) {
+ throw new Error('Missing manifest data. This is an internal error, please file an issue.');
+ }
+ const { routeData, pathname, newUrl } = findRouteToRewrite({
+ payload,
+ request,
+ routes: this.routesList?.routes,
+ trailingSlash: this.config.trailingSlash,
+ buildFormat: this.config.build.format,
+ base: this.config.base,
+ });
+
+ const componentInstance = await this.getComponentByRoute(routeData);
+ return { newUrl, pathname, componentInstance, routeData };
+ }
+
+ setManifestData(manifestData: RoutesList) {
+ this.routesList = manifestData;
+ }
+
+ rewriteKnownRoute(route: string, sourceRoute: RouteData): ComponentInstance {
+ if (this.serverLike && sourceRoute.prerender) {
+ for (let def of this.defaultRoutes) {
+ if (route === def.route) {
+ return def.instance;
+ }
+ }
+ }
+
+ throw new Error('Unknown route');
+ }
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts
new file mode 100644
index 000000000..113a85804
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts
@@ -0,0 +1,208 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+import type fs from 'node:fs';
+import { IncomingMessage } from 'node:http';
+import { fileURLToPath } from 'node:url';
+import type * as vite from 'vite';
+import { normalizePath } from 'vite';
+import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
+import { warnMissingAdapter } from '../core/dev/adapter-validation.js';
+import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../core/encryption.js';
+import { getViteErrorPayload } from '../core/errors/dev/index.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { patchOverlay } from '../core/errors/overlay.js';
+import type { Logger } from '../core/logger/core.js';
+import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js';
+import { createViteLoader } from '../core/module-loader/index.js';
+import { createRoutesList } from '../core/routing/index.js';
+import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js';
+import { toFallbackType, toRoutingStrategy } from '../i18n/utils.js';
+import { runHookRoutesResolved } from '../integrations/hooks.js';
+import type { AstroSettings, RoutesList } from '../types/astro.js';
+import { baseMiddleware } from './base.js';
+import { createController } from './controller.js';
+import { recordServerError } from './error.js';
+import { DevPipeline } from './pipeline.js';
+import { handleRequest } from './request.js';
+import { setRouteError } from './server-state.js';
+import { trailingSlashMiddleware } from './trailing-slash.js';
+
+export interface AstroPluginOptions {
+ settings: AstroSettings;
+ logger: Logger;
+ fs: typeof fs;
+ routesList: RoutesList;
+ manifest: SSRManifest;
+}
+
+export default function createVitePluginAstroServer({
+ settings,
+ logger,
+ fs: fsMod,
+ routesList,
+ manifest,
+}: AstroPluginOptions): vite.Plugin {
+ return {
+ name: 'astro:server',
+ configureServer(viteServer) {
+ const loader = createViteLoader(viteServer);
+ const pipeline = DevPipeline.create(routesList, {
+ loader,
+ logger,
+ manifest,
+ settings,
+ });
+ const controller = createController({ loader });
+ const localStorage = new AsyncLocalStorage();
+
+ /** rebuild the route cache + manifest */
+ async function rebuildManifest(path: string | null = null) {
+ pipeline.clearRouteCache();
+
+ // If a route changes, we check if it's part of the manifest and check for its prerender value
+ if (path !== null) {
+ const route = routesList.routes.find(
+ (r) =>
+ normalizePath(path) ===
+ normalizePath(fileURLToPath(new URL(r.component, settings.config.root))),
+ );
+ if (!route) {
+ return;
+ }
+ if (route.type !== 'page' && route.type !== 'endpoint') return;
+
+ const routePath = fileURLToPath(new URL(route.component, settings.config.root));
+ try {
+ const content = await fsMod.promises.readFile(routePath, 'utf-8');
+ await getRoutePrerenderOption(content, route, settings, logger);
+ await runHookRoutesResolved({ routes: routesList.routes, settings, logger });
+ } catch (_) {}
+ } else {
+ routesList = await createRoutesList({ settings, fsMod }, logger, { dev: true });
+ }
+
+ warnMissingAdapter(logger, settings);
+ pipeline.manifest.checkOrigin =
+ settings.config.security.checkOrigin && settings.buildOutput === 'server';
+ pipeline.setManifestData(routesList);
+ }
+
+ // Rebuild route manifest on file change
+ viteServer.watcher.on('add', rebuildManifest.bind(null, null));
+ viteServer.watcher.on('unlink', rebuildManifest.bind(null, null));
+ viteServer.watcher.on('change', rebuildManifest);
+
+ function handleUnhandledRejection(rejection: any) {
+ const error = new AstroError({
+ ...AstroErrorData.UnhandledRejection,
+ message: AstroErrorData.UnhandledRejection.message(rejection?.stack || rejection),
+ });
+ const store = localStorage.getStore();
+ if (store instanceof IncomingMessage) {
+ const request = store;
+ setRouteError(controller.state, request.url!, error);
+ }
+ const { errorWithMetadata } = recordServerError(loader, settings.config, pipeline, error);
+ setTimeout(
+ async () => loader.webSocketSend(await getViteErrorPayload(errorWithMetadata)),
+ 200,
+ );
+ }
+
+ process.on('unhandledRejection', handleUnhandledRejection);
+ viteServer.httpServer?.on('close', () => {
+ process.off('unhandledRejection', handleUnhandledRejection);
+ });
+
+ return () => {
+ // Push this middleware to the front of the stack so that it can intercept responses.
+ // fix(#6067): always inject this to ensure zombie base handling is killed after restarts
+ viteServer.middlewares.stack.unshift({
+ route: '',
+ handle: baseMiddleware(settings, logger),
+ });
+ viteServer.middlewares.stack.unshift({
+ route: '',
+ handle: trailingSlashMiddleware(settings),
+ });
+ // Note that this function has a name so other middleware can find it.
+ viteServer.middlewares.use(async function astroDevHandler(request, response) {
+ if (request.url === undefined || !request.method) {
+ response.writeHead(500, 'Incomplete request');
+ response.end();
+ return;
+ }
+ localStorage.run(request, () => {
+ handleRequest({
+ pipeline,
+ routesList,
+ controller,
+ incomingRequest: request,
+ incomingResponse: response,
+ });
+ });
+ });
+ };
+ },
+ transform(code, id, opts = {}) {
+ if (opts.ssr) return;
+ if (!id.includes('vite/dist/client/client.mjs')) return;
+
+ // Replace the Vite overlay with ours
+ return patchOverlay(code);
+ },
+ };
+}
+
+/**
+ * It creates a `SSRManifest` from the `AstroSettings`.
+ *
+ * Renderers needs to be pulled out from the page module emitted during the build.
+ * @param settings
+ */
+export function createDevelopmentManifest(settings: AstroSettings): SSRManifest {
+ let i18nManifest: SSRManifestI18n | undefined = undefined;
+ if (settings.config.i18n) {
+ i18nManifest = {
+ fallback: settings.config.i18n.fallback,
+ strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
+ defaultLocale: settings.config.i18n.defaultLocale,
+ locales: settings.config.i18n.locales,
+ domainLookupTable: {},
+ fallbackType: toFallbackType(settings.config.i18n.routing),
+ };
+ }
+
+ return {
+ hrefRoot: settings.config.root.toString(),
+ srcDir: settings.config.srcDir,
+ cacheDir: settings.config.cacheDir,
+ outDir: settings.config.outDir,
+ buildServerDir: settings.config.build.server,
+ buildClientDir: settings.config.build.client,
+ publicDir: settings.config.publicDir,
+ trailingSlash: settings.config.trailingSlash,
+ buildFormat: settings.config.build.format,
+ compressHTML: settings.config.compressHTML,
+ assets: new Set(),
+ entryModules: {},
+ routes: [],
+ adapterName: settings?.adapter?.name || '',
+ clientDirectives: settings.clientDirectives,
+ renderers: [],
+ base: settings.config.base,
+ assetsPrefix: settings.config.build.assetsPrefix,
+ site: settings.config.site,
+ componentMetadata: new Map(),
+ inlinedScripts: new Map(),
+ i18n: i18nManifest,
+ checkOrigin:
+ (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
+ key: hasEnvironmentKey() ? getEnvironmentKey() : createKey(),
+ middleware() {
+ return {
+ onRequest: NOOP_MIDDLEWARE_FN,
+ };
+ },
+ sessionConfig: settings.config.experimental.session,
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/request.ts b/packages/astro/src/vite-plugin-astro-server/request.ts
new file mode 100644
index 000000000..b72835eeb
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/request.ts
@@ -0,0 +1,80 @@
+import type http from 'node:http';
+import { removeTrailingForwardSlash } from '../core/path.js';
+import type { RoutesList } from '../types/astro.js';
+import type { DevServerController } from './controller.js';
+import { runWithErrorHandling } from './controller.js';
+import { recordServerError } from './error.js';
+import type { DevPipeline } from './pipeline.js';
+import { handle500Response } from './response.js';
+import { handleRoute, matchRoute } from './route.js';
+
+type HandleRequest = {
+ pipeline: DevPipeline;
+ routesList: RoutesList;
+ controller: DevServerController;
+ incomingRequest: http.IncomingMessage;
+ incomingResponse: http.ServerResponse;
+};
+
+/** The main logic to route dev server requests to pages in Astro. */
+export async function handleRequest({
+ pipeline,
+ routesList,
+ controller,
+ incomingRequest,
+ incomingResponse,
+}: HandleRequest) {
+ const { config, loader } = pipeline;
+ const origin = `${loader.isHttps() ? 'https' : 'http'}://${
+ incomingRequest.headers[':authority'] ?? incomingRequest.headers.host
+ }`;
+
+ const url = new URL(origin + incomingRequest.url);
+ let pathname: string;
+ if (config.trailingSlash === 'never' && !incomingRequest.url) {
+ pathname = '';
+ } else {
+ // We already have a middleware that checks if there's an incoming URL that has invalid URI, so it's safe
+ // to not handle the error: packages/astro/src/vite-plugin-astro-server/base.ts
+ pathname = decodeURI(url.pathname);
+ }
+
+ // Add config.base back to url before passing it to SSR
+ url.pathname = removeTrailingForwardSlash(config.base) + url.pathname;
+
+ let body: ArrayBuffer | undefined = undefined;
+ if (!(incomingRequest.method === 'GET' || incomingRequest.method === 'HEAD')) {
+ let bytes: Uint8Array[] = [];
+ await new Promise((resolve) => {
+ incomingRequest.on('data', (part) => {
+ bytes.push(part);
+ });
+ incomingRequest.on('end', resolve);
+ });
+ body = Buffer.concat(bytes);
+ }
+
+ await runWithErrorHandling({
+ controller,
+ pathname,
+ async run() {
+ const matchedRoute = await matchRoute(pathname, routesList, pipeline);
+ const resolvedPathname = matchedRoute?.resolvedPathname ?? pathname;
+ return await handleRoute({
+ matchedRoute,
+ url,
+ pathname: resolvedPathname,
+ body,
+ pipeline,
+ routesList,
+ incomingRequest: incomingRequest,
+ incomingResponse: incomingResponse,
+ });
+ },
+ onError(_err) {
+ const { error, errorWithMetadata } = recordServerError(loader, config, pipeline, _err);
+ handle500Response(loader, incomingResponse, errorWithMetadata);
+ return error;
+ },
+ });
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/resolve.ts b/packages/astro/src/vite-plugin-astro-server/resolve.ts
new file mode 100644
index 000000000..03b516c95
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/resolve.ts
@@ -0,0 +1,13 @@
+import type { ModuleLoader } from '../core/module-loader/index.js';
+import { resolveIdToUrl } from '../core/viteUtils.js';
+
+export function createResolve(loader: ModuleLoader, root: URL) {
+ // Resolves specifiers in the inline hydrated scripts, such as:
+ // - @astrojs/preact/client.js
+ // - @/components/Foo.vue
+ // - /Users/macos/project/src/Foo.vue
+ // - C:/Windows/project/src/Foo.vue (normalized slash)
+ return async function (s: string) {
+ return await resolveIdToUrl(loader, s, root);
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/response.ts b/packages/astro/src/vite-plugin-astro-server/response.ts
new file mode 100644
index 000000000..03b25be1a
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/response.ts
@@ -0,0 +1,129 @@
+import type http from 'node:http';
+import { Http2ServerResponse } from 'node:http2';
+import type { ErrorWithMetadata } from '../core/errors/index.js';
+import type { ModuleLoader } from '../core/module-loader/index.js';
+
+import { Readable } from 'node:stream';
+import { getSetCookiesFromResponse } from '../core/cookies/index.js';
+import { getViteErrorPayload } from '../core/errors/dev/index.js';
+import { redirectTemplate } from '../core/routing/3xx.js';
+import notFoundTemplate from '../template/4xx.js';
+
+export async function handle404Response(
+ origin: string,
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+) {
+ const pathname = decodeURI(new URL(origin + req.url).pathname);
+
+ const html = notFoundTemplate({
+ statusCode: 404,
+ title: 'Not found',
+ tabTitle: '404: Not Found',
+ pathname,
+ });
+ writeHtmlResponse(res, 404, html);
+}
+
+export async function handle500Response(
+ loader: ModuleLoader,
+ res: http.ServerResponse,
+ err: ErrorWithMetadata,
+) {
+ res.on('close', async () =>
+ setTimeout(async () => loader.webSocketSend(await getViteErrorPayload(err)), 200),
+ );
+ if (res.headersSent) {
+ res.write(`<script type="module" src="/@vite/client"></script>`);
+ res.end();
+ } else {
+ writeHtmlResponse(
+ res,
+ 500,
+ `<title>${err.name}</title><script type="module" src="/@vite/client"></script>`,
+ );
+ }
+}
+
+export function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) {
+ res.writeHead(statusCode, {
+ 'Content-Type': 'text/html',
+ 'Content-Length': Buffer.byteLength(html, 'utf-8'),
+ });
+ res.write(html);
+ res.end();
+}
+
+export function writeRedirectResponse(
+ res: http.ServerResponse,
+ statusCode: number,
+ location: string,
+) {
+ const html = redirectTemplate({ status: statusCode, location });
+ res.writeHead(statusCode, {
+ Location: location,
+ 'Content-Type': 'text/html',
+ 'Content-Length': Buffer.byteLength(html, 'utf-8'),
+ });
+ res.write(html);
+ res.end();
+}
+
+export async function writeWebResponse(res: http.ServerResponse, webResponse: Response) {
+ const { status, headers, body, statusText } = webResponse;
+
+ // Attach any set-cookie headers added via Astro.cookies.set()
+ const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse));
+ if (setCookieHeaders.length) {
+ // Always use `res.setHeader` because headers.append causes them to be concatenated.
+ res.setHeader('set-cookie', setCookieHeaders);
+ }
+
+ const _headers: http.OutgoingHttpHeaders = Object.fromEntries(headers.entries());
+
+ if (headers.has('set-cookie')) {
+ _headers['set-cookie'] = headers.getSetCookie();
+ }
+ // HTTP/2 doesn't support statusMessage
+ if (!(res instanceof Http2ServerResponse)) {
+ res.statusMessage = statusText;
+ }
+ res.writeHead(status, _headers);
+ if (body) {
+ if (Symbol.for('astro.responseBody') in webResponse) {
+ let stream = (webResponse as any)[Symbol.for('astro.responseBody')];
+ for await (const chunk of stream) {
+ res.write(chunk.toString());
+ }
+ } else if (body instanceof Readable) {
+ body.pipe(res);
+ return;
+ } else if (typeof body === 'string') {
+ res.write(body);
+ } else {
+ const reader = body.getReader();
+ res.on('close', () => {
+ reader.cancel().catch(() => {
+ // Don't log here, or errors will get logged twice in most cases
+ });
+ });
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) {
+ res.write(value);
+ }
+ }
+ }
+ }
+ res.end();
+}
+
+export async function writeSSRResult(
+ webRequest: Request,
+ webResponse: Response,
+ res: http.ServerResponse,
+) {
+ Reflect.set(webRequest, Symbol.for('astro.responseSent'), true);
+ return writeWebResponse(res, webResponse);
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
new file mode 100644
index 000000000..eb7c43b3a
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -0,0 +1,340 @@
+import type http from 'node:http';
+import {
+ DEFAULT_404_COMPONENT,
+ NOOP_MIDDLEWARE_HEADER,
+ REROUTE_DIRECTIVE_HEADER,
+ REWRITE_DIRECTIVE_HEADER_KEY,
+ clientLocalsSymbol,
+} from '../core/constants.js';
+import { AstroErrorData, isAstroError } from '../core/errors/index.js';
+import { req } from '../core/messages.js';
+import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
+import { routeIsRedirect } from '../core/redirects/index.js';
+import { RenderContext } from '../core/render-context.js';
+import { getProps } from '../core/render/index.js';
+import { createRequest } from '../core/request.js';
+import { redirectTemplate } from '../core/routing/3xx.js';
+import { matchAllRoutes } from '../core/routing/index.js';
+import { isRoute404, isRoute500 } from '../core/routing/match.js';
+import { PERSIST_SYMBOL } from '../core/session.js';
+import { getSortedPreloadedMatches } from '../prerender/routing.js';
+import type { ComponentInstance, RoutesList } from '../types/astro.js';
+import type { RouteData } from '../types/public/internal.js';
+import type { DevPipeline } from './pipeline.js';
+import { writeSSRResult, writeWebResponse } from './response.js';
+
+type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
+ ...args: any
+) => Promise<infer R>
+ ? R
+ : any;
+
+export interface MatchedRoute {
+ route: RouteData;
+ filePath: URL;
+ resolvedPathname: string;
+ preloadedComponent: ComponentInstance;
+ mod: ComponentInstance;
+}
+
+function isLoggedRequest(url: string) {
+ return url !== '/favicon.ico';
+}
+
+function getCustom404Route(manifestData: RoutesList): RouteData | undefined {
+ return manifestData.routes.find((r) => isRoute404(r.route));
+}
+
+function getCustom500Route(manifestData: RoutesList): RouteData | undefined {
+ return manifestData.routes.find((r) => isRoute500(r.route));
+}
+
+export async function matchRoute(
+ pathname: string,
+ routesList: RoutesList,
+ pipeline: DevPipeline,
+): Promise<MatchedRoute | undefined> {
+ const { config, logger, routeCache, serverLike, settings } = pipeline;
+ const matches = matchAllRoutes(pathname, routesList);
+
+ const preloadedMatches = await getSortedPreloadedMatches({ pipeline, matches, settings });
+
+ for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
+ // attempt to get static paths
+ // if this fails, we have a bad URL match!
+ try {
+ await getProps({
+ mod: preloadedComponent,
+ routeData: maybeRoute,
+ routeCache,
+ pathname: pathname,
+ logger,
+ serverLike,
+ base: config.base,
+ });
+ return {
+ route: maybeRoute,
+ filePath,
+ resolvedPathname: pathname,
+ preloadedComponent,
+ mod: preloadedComponent,
+ };
+ } catch (e) {
+ // Ignore error for no matching static paths
+ if (isAstroError(e) && e.title === AstroErrorData.NoMatchingStaticPathFound.title) {
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ // Try without `.html` extensions or `index.html` in request URLs to mimic
+ // routing behavior in production builds. This supports both file and directory
+ // build formats, and is necessary based on how the manifest tracks build targets.
+ const altPathname = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
+
+ if (altPathname !== pathname) {
+ return await matchRoute(altPathname, routesList, pipeline);
+ }
+
+ if (matches.length) {
+ const possibleRoutes = matches.flatMap((route) => route.component);
+
+ logger.warn(
+ 'router',
+ `${AstroErrorData.NoMatchingStaticPathFound.message(
+ pathname,
+ )}\n\n${AstroErrorData.NoMatchingStaticPathFound.hint(possibleRoutes)}`,
+ );
+ }
+
+ const custom404 = getCustom404Route(routesList);
+
+ if (custom404) {
+ const filePath = new URL(`./${custom404.component}`, config.root);
+ const preloadedComponent = await pipeline.preload(custom404, filePath);
+
+ return {
+ route: custom404,
+ filePath,
+ resolvedPathname: pathname,
+ preloadedComponent,
+ mod: preloadedComponent,
+ };
+ }
+
+ return undefined;
+}
+
+type HandleRoute = {
+ matchedRoute: AsyncReturnType<typeof matchRoute>;
+ url: URL;
+ pathname: string;
+ body: ArrayBuffer | undefined;
+ routesList: RoutesList;
+ incomingRequest: http.IncomingMessage;
+ incomingResponse: http.ServerResponse;
+ pipeline: DevPipeline;
+};
+
+export async function handleRoute({
+ matchedRoute,
+ url,
+ pathname,
+ body,
+ pipeline,
+ routesList,
+ incomingRequest,
+ incomingResponse,
+}: HandleRoute): Promise<void> {
+ const timeStart = performance.now();
+ const { config, loader, logger } = pipeline;
+
+ if (!matchedRoute) {
+ // This should never happen, because ensure404Route will add a 404 route if none exists.
+ throw new Error('No route matched, and default 404 route was not found.');
+ }
+
+ let request: Request;
+ let renderContext: RenderContext;
+ let mod: ComponentInstance | undefined = undefined;
+ let route: RouteData;
+ const middleware = (await loadMiddleware(loader)).onRequest;
+ // This is required for adapters to set locals in dev mode. They use a dev server middleware to inject locals to the `http.IncomingRequest` object.
+ const locals = Reflect.get(incomingRequest, clientLocalsSymbol);
+
+ const { preloadedComponent } = matchedRoute;
+ route = matchedRoute.route;
+
+ // Allows adapters to pass in locals in dev mode.
+ request = createRequest({
+ url,
+ headers: incomingRequest.headers,
+ method: incomingRequest.method,
+ body,
+ logger,
+ isPrerendered: route.prerender,
+ routePattern: route.component,
+ });
+
+ // Set user specified headers to response object.
+ for (const [name, value] of Object.entries(config.server.headers ?? {})) {
+ if (value) incomingResponse.setHeader(name, value);
+ }
+
+ mod = preloadedComponent;
+
+ renderContext = await RenderContext.create({
+ locals,
+ pipeline,
+ pathname,
+ middleware: isDefaultPrerendered404(matchedRoute.route) ? undefined : middleware,
+ request,
+ routeData: route,
+ clientAddress: incomingRequest.socket.remoteAddress,
+ });
+
+ let response;
+ let statusCode = 200;
+ let isReroute = false;
+ let isRewrite = false;
+ try {
+ response = await renderContext.render(mod);
+ isReroute = response.headers.has(REROUTE_DIRECTIVE_HEADER);
+ isRewrite = response.headers.has(REWRITE_DIRECTIVE_HEADER_KEY);
+ const statusCodedMatched = getStatusByMatchedRoute(matchedRoute);
+ statusCode = isRewrite
+ ? // Ignore `matchedRoute` status for rewrites
+ response.status
+ : // Our internal noop middleware sets a particular header. If the header isn't present, it means that the user have
+ // their own middleware, so we need to return what the user returns.
+ !response.headers.has(NOOP_MIDDLEWARE_HEADER) && !isReroute
+ ? response.status
+ : (statusCodedMatched ?? response.status);
+ } catch (err: any) {
+ const custom500 = getCustom500Route(routesList);
+ if (!custom500) {
+ throw err;
+ }
+ // Log useful information that the custom 500 page may not display unlike the default error overlay
+ logger.error('router', err.stack || err.message);
+ const filePath500 = new URL(`./${custom500.component}`, config.root);
+ const preloaded500Component = await pipeline.preload(custom500, filePath500);
+ renderContext.props.error = err;
+ response = await renderContext.render(preloaded500Component);
+ statusCode = 500;
+ } finally {
+ renderContext.session?.[PERSIST_SYMBOL]();
+ }
+
+ if (isLoggedRequest(pathname)) {
+ const timeEnd = performance.now();
+ logger.info(
+ null,
+ req({
+ url: pathname,
+ method: incomingRequest.method,
+ statusCode,
+ isRewrite,
+ reqTime: timeEnd - timeStart,
+ }),
+ );
+ }
+
+ if (
+ statusCode === 404 &&
+ // If the body isn't null, that means the user sets the 404 status
+ // but uses the current route to handle the 404
+ response.body === null &&
+ response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
+ ) {
+ const fourOhFourRoute = await matchRoute('/404', routesList, pipeline);
+ if (fourOhFourRoute) {
+ renderContext = await RenderContext.create({
+ locals,
+ pipeline,
+ pathname,
+ middleware: isDefaultPrerendered404(fourOhFourRoute.route) ? undefined : middleware,
+ request,
+ routeData: fourOhFourRoute.route,
+ clientAddress: incomingRequest.socket.remoteAddress,
+ });
+ response = await renderContext.render(fourOhFourRoute.preloadedComponent);
+ }
+ }
+
+ // We remove the internally-used header before we send the response to the user agent.
+ if (isReroute) {
+ response.headers.delete(REROUTE_DIRECTIVE_HEADER);
+ }
+ if (isRewrite) {
+ response.headers.delete(REROUTE_DIRECTIVE_HEADER);
+ }
+
+ if (route.type === 'endpoint') {
+ await writeWebResponse(incomingResponse, response);
+ return;
+ }
+
+ // This check is important in case of rewrites.
+ // A route can start with a 404 code, then the rewrite kicks in and can return a 200 status code
+ if (isRewrite) {
+ await writeSSRResult(request, response, incomingResponse);
+ return;
+ }
+
+ // We are in a recursion, and it's possible that this function is called itself with a status code
+ // By default, the status code passed via parameters is computed by the matched route.
+ //
+ // By default, we should give priority to the status code passed, although it's possible that
+ // the `Response` emitted by the user is a redirect. If so, then return the returned response.
+ if (response.status < 400 && response.status >= 300) {
+ if (
+ response.status >= 300 &&
+ response.status < 400 &&
+ routeIsRedirect(route) &&
+ !config.build.redirects &&
+ pipeline.settings.buildOutput === 'static'
+ ) {
+ // If we're here, it means that the calling static redirect that was configured by the user
+ // We try to replicate the same behaviour that we provide during a static build
+ response = new Response(
+ redirectTemplate({
+ status: response.status,
+ location: response.headers.get('location')!,
+ from: pathname,
+ }),
+ {
+ status: 200,
+ headers: {
+ ...response.headers,
+ 'content-type': 'text/html',
+ },
+ },
+ );
+ }
+ await writeSSRResult(request, response, incomingResponse);
+ return;
+ }
+
+ // Apply the `status` override to the response object before responding.
+ // Response.status is read-only, so a clone is required to override.
+ if (response.status !== statusCode) {
+ response = new Response(response.body, {
+ status: statusCode,
+ headers: response.headers,
+ });
+ }
+ await writeSSRResult(request, response, incomingResponse);
+}
+
+/** Check for /404 and /500 custom routes to compute status code */
+function getStatusByMatchedRoute(matchedRoute?: MatchedRoute) {
+ if (matchedRoute?.route.route === '/404') return 404;
+ if (matchedRoute?.route.route === '/500') return 500;
+ return undefined;
+}
+
+function isDefaultPrerendered404(route: RouteData) {
+ return route.route === '/404' && route.prerender && route.component === DEFAULT_404_COMPONENT;
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/server-state.ts b/packages/astro/src/vite-plugin-astro-server/server-state.ts
new file mode 100644
index 000000000..94f1fe8a5
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/server-state.ts
@@ -0,0 +1,52 @@
+export type ErrorState = 'fresh' | 'error';
+
+export interface RouteState {
+ state: ErrorState;
+ error?: Error;
+}
+
+export interface ServerState {
+ routes: Map<string, RouteState>;
+ state: ErrorState;
+ error?: Error;
+}
+
+export function createServerState(): ServerState {
+ return {
+ routes: new Map(),
+ state: 'fresh',
+ };
+}
+
+export function hasAnyFailureState(serverState: ServerState) {
+ return serverState.state !== 'fresh';
+}
+
+export function setRouteError(serverState: ServerState, pathname: string, error: Error) {
+ if (serverState.routes.has(pathname)) {
+ const routeState = serverState.routes.get(pathname)!;
+ routeState.state = 'error';
+ routeState.error = error;
+ } else {
+ const routeState: RouteState = {
+ state: 'error',
+ error: error,
+ };
+ serverState.routes.set(pathname, routeState);
+ }
+ serverState.state = 'error';
+ serverState.error = error;
+}
+
+export function setServerError(serverState: ServerState, error: Error) {
+ serverState.state = 'error';
+ serverState.error = error;
+}
+
+export function clearRouteError(serverState: ServerState, pathname: string) {
+ if (serverState.routes.has(pathname)) {
+ serverState.routes.delete(pathname);
+ }
+ serverState.state = 'fresh';
+ serverState.error = undefined;
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts b/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts
new file mode 100644
index 000000000..00f71b784
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/trailing-slash.ts
@@ -0,0 +1,38 @@
+import type * as vite from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+
+import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
+import { trailingSlashMismatchTemplate } from '../template/4xx.js';
+import { writeHtmlResponse, writeRedirectResponse } from './response.js';
+
+export function trailingSlashMiddleware(settings: AstroSettings): vite.Connect.NextHandleFunction {
+ const { trailingSlash } = settings.config;
+
+ return function devTrailingSlash(req, res, next) {
+ const url = new URL(`http://localhost${req.url}`);
+ let pathname: string;
+ try {
+ pathname = decodeURI(url.pathname);
+ } catch (e) {
+ /* malformed uri */
+ return next(e);
+ }
+ if (pathname.startsWith('/_') || pathname.startsWith('/@')) {
+ return next();
+ }
+
+ const destination = collapseDuplicateTrailingSlashes(pathname, true);
+ if (pathname && destination !== pathname) {
+ return writeRedirectResponse(res, 301, `${destination}${url.search}`);
+ }
+
+ if (
+ (trailingSlash === 'never' && pathname.endsWith('/') && pathname !== '/') ||
+ (trailingSlash === 'always' && !pathname.endsWith('/') && !hasFileExtension(pathname))
+ ) {
+ const html = trailingSlashMismatchTemplate(pathname, trailingSlash);
+ return writeHtmlResponse(res, 404, html);
+ }
+ return next();
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro-server/util.ts b/packages/astro/src/vite-plugin-astro-server/util.ts
new file mode 100644
index 000000000..fe4b29294
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/util.ts
@@ -0,0 +1,9 @@
+import { isCSSRequest } from 'vite';
+
+const rawRE = /(?:\?|&)raw(?:&|$)/;
+const inlineRE = /(?:\?|&)inline\b/;
+
+export { isCSSRequest };
+
+export const isBuildableCSSRequest = (request: string): boolean =>
+ isCSSRequest(request) && !rawRE.test(request) && !inlineRE.test(request);
diff --git a/packages/astro/src/vite-plugin-astro-server/vite.ts b/packages/astro/src/vite-plugin-astro-server/vite.ts
new file mode 100644
index 000000000..697174571
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro-server/vite.ts
@@ -0,0 +1,127 @@
+import npath from 'node:path';
+import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../core/constants.js';
+import type { ModuleLoader, ModuleNode } from '../core/module-loader/index.js';
+import { unwrapId } from '../core/util.js';
+import { hasSpecialQueries } from '../vite-plugin-utils/index.js';
+import { isCSSRequest } from './util.js';
+
+/**
+ * List of file extensions signalling we can (and should) SSR ahead-of-time
+ * See usage below
+ */
+const fileExtensionsToSSR = new Set(['.astro', '.mdoc', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS]);
+
+const STRIP_QUERY_PARAMS_REGEX = /\?.*$/;
+
+/** recursively crawl the module graph to get all style files imported by parent id */
+export async function* crawlGraph(
+ loader: ModuleLoader,
+ _id: string,
+ isRootFile: boolean,
+ scanned = new Set<string>(),
+): AsyncGenerator<ModuleNode, void, unknown> {
+ const id = unwrapId(_id);
+ const importedModules = new Set<ModuleNode>();
+
+ const moduleEntriesForId = isRootFile
+ ? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail),
+ // So we can get up-to-date info on initial server load.
+ // Needed for slower CSS preprocessing like Tailwind
+ (loader.getModulesByFile(id) ?? new Set())
+ : // For non-root files, we're safe to pull from "getModuleById" based on testing.
+ // TODO: Find better invalidation strategy to use "getModuleById" in all cases!
+ new Set([loader.getModuleById(id)]);
+
+ // Collect all imported modules for the module(s).
+ for (const entry of moduleEntriesForId) {
+ // Handle this in case an module entries weren't found for ID
+ // This seems possible with some virtual IDs (ex: `astro:markdown/*.md`)
+ if (!entry) {
+ continue;
+ }
+ if (id === entry.id) {
+ scanned.add(id);
+
+ // NOTE: It may be worth revisiting if we can crawl direct imports of the module since
+ // `.importedModules` would also include modules that are dynamically watched, not imported.
+ // That way we no longer need the below `continue` skips.
+
+ // CSS requests `importedModules` are usually from `@import`, but we don't really need
+ // to crawl into those as the `@import` code are already inlined into this `id`.
+ // If CSS requests `importedModules` contain non-CSS files, e.g. Tailwind might add HMR
+ // dependencies as `importedModules`, we should also skip them as they aren't really
+ // imported. Without this, every hoisted script in the project is added to every page!
+ if (isCSSRequest(id)) {
+ continue;
+ }
+ // Some special Vite queries like `?url` or `?raw` are known to be a simple default export
+ // and doesn't have any imports to crawl. However, since they would `this.addWatchFile` the
+ // underlying module, our logic would crawl into them anyways which is incorrect as they
+ // don't take part in the final rendering, so we skip it here.
+ if (hasSpecialQueries(id)) {
+ continue;
+ }
+
+ for (const importedModule of entry.importedModules) {
+ if (!importedModule.id) continue;
+
+ // some dynamically imported modules are *not* server rendered in time
+ // to only SSR modules that we can safely transform, we check against
+ // a list of file extensions based on our built-in vite plugins
+
+ // Strip special query params like "?content".
+ // NOTE: Cannot use `new URL()` here because not all IDs will be valid paths.
+ // For example, `virtual:image-loader` if you don't have the plugin installed.
+ const importedModulePathname = importedModule.id.replace(STRIP_QUERY_PARAMS_REGEX, '');
+
+ const isFileTypeNeedingSSR = fileExtensionsToSSR.has(npath.extname(importedModulePathname));
+ // A propagation stopping point is a module with the ?astroPropagatedAssets flag.
+ // When we encounter one of these modules we don't want to continue traversing.
+ const isPropagationStoppingPoint = importedModule.id.includes('?astroPropagatedAssets');
+ if (
+ isFileTypeNeedingSSR &&
+ // Should not SSR a module with ?astroPropagatedAssets
+ !isPropagationStoppingPoint
+ ) {
+ const mod = loader.getModuleById(importedModule.id);
+ if (!mod?.ssrModule) {
+ try {
+ await loader.import(importedModule.id);
+ } catch {
+ /** Likely an out-of-date module entry! Silently continue. */
+ }
+ }
+ }
+
+ // Make sure the `importedModule` traversed is explicitly imported by the user, and not by HMR
+ // TODO: This isn't very performant. Maybe look into using `ssrTransformResult` but make sure it
+ // doesn't regress UnoCSS. https://github.com/withastro/astro/issues/7529
+ if (isImportedBy(id, importedModule) && !isPropagationStoppingPoint) {
+ importedModules.add(importedModule);
+ }
+ }
+ }
+ }
+
+ // scan imported modules for CSS imports & add them to our collection.
+ // Then, crawl that file to follow and scan all deep imports as well.
+ for (const importedModule of importedModules) {
+ if (!importedModule.id || scanned.has(importedModule.id)) {
+ continue;
+ }
+
+ yield importedModule;
+ yield* crawlGraph(loader, importedModule.id, false, scanned);
+ }
+}
+
+// Verify true imports. If the child module has the parent as an importers, it's
+// a real import.
+function isImportedBy(parent: string, entry: ModuleNode) {
+ for (const importer of entry.importers) {
+ if (importer.id === parent) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/packages/astro/src/vite-plugin-astro/README.md b/packages/astro/src/vite-plugin-astro/README.md
new file mode 100644
index 000000000..014a8baf9
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-astro
+
+Adds `.astro` support to Vite
diff --git a/packages/astro/src/vite-plugin-astro/compile.ts b/packages/astro/src/vite-plugin-astro/compile.ts
new file mode 100644
index 000000000..5000b42b7
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/compile.ts
@@ -0,0 +1,137 @@
+import { type ESBuildTransformResult, transformWithEsbuild } from 'vite';
+import { type CompileProps, type CompileResult, compile } from '../core/compile/index.js';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroConfig } from '../types/public/config.js';
+import { getFileInfo } from '../vite-plugin-utils/index.js';
+import type { CompileMetadata } from './types.js';
+import { frontmatterRE } from './utils.js';
+
+interface CompileAstroOption {
+ compileProps: CompileProps;
+ astroFileToCompileMetadata: Map<string, CompileMetadata>;
+ logger: Logger;
+}
+
+export interface CompileAstroResult extends Omit<CompileResult, 'map'> {
+ map: ESBuildTransformResult['map'];
+}
+
+interface EnhanceCompilerErrorOptions {
+ err: Error;
+ id: string;
+ source: string;
+ config: AstroConfig;
+ logger: Logger;
+}
+
+export async function compileAstro({
+ compileProps,
+ astroFileToCompileMetadata,
+ logger,
+}: CompileAstroOption): Promise<CompileAstroResult> {
+ let transformResult: CompileResult;
+ let esbuildResult: ESBuildTransformResult;
+
+ try {
+ transformResult = await compile(compileProps);
+ // Compile all TypeScript to JavaScript.
+ // Also, catches invalid JS/TS in the compiled output before returning.
+ esbuildResult = await transformWithEsbuild(transformResult.code, compileProps.filename, {
+ ...compileProps.viteConfig.esbuild,
+ loader: 'ts',
+ sourcemap: 'external',
+ tsconfigRaw: {
+ compilerOptions: {
+ // Ensure client:only imports are treeshaken
+ verbatimModuleSyntax: false,
+ importsNotUsedAsValues: 'remove',
+ },
+ },
+ });
+ } catch (err: any) {
+ await enhanceCompileError({
+ err,
+ id: compileProps.filename,
+ source: compileProps.source,
+ config: compileProps.astroConfig,
+ logger: logger,
+ });
+ throw err;
+ }
+
+ const { fileId: file, fileUrl: url } = getFileInfo(
+ compileProps.filename,
+ compileProps.astroConfig,
+ );
+
+ let SUFFIX = '';
+ SUFFIX += `\nconst $$file = ${JSON.stringify(file)};\nconst $$url = ${JSON.stringify(
+ url,
+ )};export { $$file as file, $$url as url };\n`;
+
+ // Add HMR handling in dev mode.
+ if (!compileProps.viteConfig.isProduction) {
+ let i = 0;
+ while (i < transformResult.scripts.length) {
+ SUFFIX += `import "${compileProps.filename}?astro&type=script&index=${i}&lang.ts";`;
+ i++;
+ }
+ }
+
+ // Attach compile metadata to map for use by virtual modules
+ astroFileToCompileMetadata.set(compileProps.filename, {
+ originalCode: compileProps.source,
+ css: transformResult.css,
+ scripts: transformResult.scripts,
+ });
+
+ return {
+ ...transformResult,
+ code: esbuildResult.code + SUFFIX,
+ map: esbuildResult.map,
+ };
+}
+
+async function enhanceCompileError({
+ err,
+ id,
+ source,
+}: EnhanceCompilerErrorOptions): Promise<void> {
+ const lineText = (err as any).loc?.lineText;
+ // Verify frontmatter: a common reason that this plugin fails is that
+ // the user provided invalid JS/TS in the component frontmatter.
+ // If the frontmatter is invalid, the `err` object may be a compiler
+ // panic or some other vague/confusing compiled error message.
+ //
+ // Before throwing, it is better to verify the frontmatter here, and
+ // let esbuild throw a more specific exception if the code is invalid.
+ // If frontmatter is valid or cannot be parsed, then continue.
+ const scannedFrontmatter = frontmatterRE.exec(source);
+ if (scannedFrontmatter) {
+ // Top-level return is not supported, so replace `return` with throw
+ const frontmatter = scannedFrontmatter[1].replace(/\breturn\b/g, 'throw');
+
+ // If frontmatter does not actually include the offending line, skip
+ if (lineText && !frontmatter.includes(lineText)) throw err;
+
+ try {
+ await transformWithEsbuild(frontmatter, id, {
+ loader: 'ts',
+ target: 'esnext',
+ sourcemap: false,
+ });
+ } catch (frontmatterErr: any) {
+ // Improve the error by replacing the phrase "unexpected end of file"
+ // with "unexpected end of frontmatter" in the esbuild error message.
+ if (frontmatterErr?.message) {
+ frontmatterErr.message = frontmatterErr.message.replace(
+ 'end of file',
+ 'end of frontmatter',
+ );
+ }
+ throw frontmatterErr;
+ }
+ }
+
+ throw err;
+}
diff --git a/packages/astro/src/vite-plugin-astro/hmr.ts b/packages/astro/src/vite-plugin-astro/hmr.ts
new file mode 100644
index 000000000..93fecc7aa
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/hmr.ts
@@ -0,0 +1,97 @@
+import type { HmrContext } from 'vite';
+import type { Logger } from '../core/logger/core.js';
+import type { CompileMetadata } from './types.js';
+import { frontmatterRE } from './utils.js';
+
+export interface HandleHotUpdateOptions {
+ logger: Logger;
+ astroFileToCompileMetadata: Map<string, CompileMetadata>;
+}
+
+export async function handleHotUpdate(
+ ctx: HmrContext,
+ { logger, astroFileToCompileMetadata }: HandleHotUpdateOptions,
+) {
+ // HANDLING 1: Invalidate compile metadata if CSS dependency updated
+ //
+ // If any `ctx.file` is part of a CSS dependency of any Astro file, invalidate its `astroFileToCompileMetadata`
+ // so the next transform of the Astro file or Astro script/style virtual module will re-generate it
+ for (const [astroFile, compileData] of astroFileToCompileMetadata) {
+ const isUpdatedFileCssDep = compileData.css.some((css) => css.dependencies?.includes(ctx.file));
+ if (isUpdatedFileCssDep) {
+ astroFileToCompileMetadata.delete(astroFile);
+ }
+ }
+
+ // HANDLING 2: Only invalidate Astro style virtual module if only style tags changed
+ //
+ // If only the style code has changed, e.g. editing the `color`, then we can directly invalidate
+ // the Astro CSS virtual modules only. The main Astro module's JS result will be the same and doesn't
+ // need to be invalidated.
+ const oldCode = astroFileToCompileMetadata.get(ctx.file)?.originalCode;
+ if (oldCode == null) return;
+ const newCode = await ctx.read();
+
+ if (isStyleOnlyChanged(oldCode, newCode)) {
+ logger.debug('watch', 'style-only change');
+ // Invalidate its `astroFileToCompileMetadata` so that the next transform of Astro style virtual module
+ // will re-generate it
+ astroFileToCompileMetadata.delete(ctx.file);
+ // Only return the Astro styles that have changed!
+ return ctx.modules.filter((mod) => mod.id?.includes('astro&type=style'));
+ }
+}
+
+// Disable eslint as we're not sure how to improve this regex yet
+// eslint-disable-next-line regexp/no-super-linear-backtracking
+const scriptRE = /<script(?:\s.*?)?>.*?<\/script>/gs;
+// eslint-disable-next-line regexp/no-super-linear-backtracking
+const styleRE = /<style(?:\s.*?)?>.*?<\/style>/gs;
+
+export function isStyleOnlyChanged(oldCode: string, newCode: string) {
+ if (oldCode === newCode) return false;
+
+ // Before we can regex-capture style tags, we remove the frontmatter and scripts
+ // first as they could contain false-positive style tag matches. At the same time,
+ // we can also compare if they have changed and early out.
+
+ // Strip off and compare frontmatter
+ let oldFrontmatter = '';
+ let newFrontmatter = '';
+ oldCode = oldCode.replace(frontmatterRE, (m) => ((oldFrontmatter = m), ''));
+ newCode = newCode.replace(frontmatterRE, (m) => ((newFrontmatter = m), ''));
+ if (oldFrontmatter !== newFrontmatter) return false;
+
+ // Strip off and compare scripts
+ const oldScripts: string[] = [];
+ const newScripts: string[] = [];
+ oldCode = oldCode.replace(scriptRE, (m) => (oldScripts.push(m), ''));
+ newCode = newCode.replace(scriptRE, (m) => (newScripts.push(m), ''));
+ if (!isArrayEqual(oldScripts, newScripts)) return false;
+
+ // Finally, we can compare styles
+ const oldStyles: string[] = [];
+ const newStyles: string[] = [];
+ oldCode = oldCode.replace(styleRE, (m) => (oldStyles.push(m), ''));
+ newCode = newCode.replace(styleRE, (m) => (newStyles.push(m), ''));
+
+ // Remaining of `oldCode` and `newCode` is the markup, return false if they're different
+ if (oldCode !== newCode) return false;
+
+ // Finally, check if only the style changed.
+ // The length must also be the same for style only change. If style tags are added/removed,
+ // we need to regenerate the main Astro file so that its CSS imports are also added/removed
+ return oldStyles.length === newStyles.length && !isArrayEqual(oldStyles, newStyles);
+}
+
+function isArrayEqual(a: any[], b: any[]) {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts
new file mode 100644
index 000000000..21d9dcfb1
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/index.ts
@@ -0,0 +1,283 @@
+import type { SourceDescription } from 'rollup';
+import type * as vite from 'vite';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+import type {
+ PluginCssMetadata as AstroPluginCssMetadata,
+ PluginMetadata as AstroPluginMetadata,
+ CompileMetadata,
+} from './types.js';
+
+import { defaultClientConditions, defaultServerConditions, normalizePath } from 'vite';
+import type { AstroConfig } from '../types/public/config.js';
+import { hasSpecialQueries, normalizeFilename } from '../vite-plugin-utils/index.js';
+import { type CompileAstroResult, compileAstro } from './compile.js';
+import { handleHotUpdate } from './hmr.js';
+import { parseAstroRequest } from './query.js';
+import { loadId } from './utils.js';
+export { getAstroMetadata } from './metadata.js';
+export type { AstroPluginMetadata, AstroPluginCssMetadata };
+
+interface AstroPluginOptions {
+ settings: AstroSettings;
+ logger: Logger;
+}
+
+const astroFileToCompileMetadataWeakMap = new WeakMap<AstroConfig, Map<string, CompileMetadata>>();
+
+/** Transform .astro files for Vite */
+export default function astro({ settings, logger }: AstroPluginOptions): vite.Plugin[] {
+ const { config } = settings;
+ let server: vite.ViteDevServer | undefined;
+ let compile: (code: string, filename: string) => Promise<CompileAstroResult>;
+ // Each Astro file has its own compile metadata so that its scripts and styles virtual module
+ // can retrieve their code from here.
+ // NOTE: We need to initialize a map here and in `buildStart` because our unit tests don't
+ // call `buildStart` (test bug)
+ let astroFileToCompileMetadata = new Map<string, CompileMetadata>();
+
+ // Variables for determining if an id starts with /src...
+ const srcRootWeb = config.srcDir.pathname.slice(config.root.pathname.length - 1);
+ const isBrowserPath = (path: string) => path.startsWith(srcRootWeb) && srcRootWeb !== '/';
+
+ const prePlugin: vite.Plugin = {
+ name: 'astro:build',
+ enforce: 'pre', // run transforms before other plugins can
+ async configEnvironment(name, viteConfig, opts) {
+ viteConfig.resolve ??= {};
+ // Emulate Vite default fallback for `resolve.conditions` if not set
+ if (viteConfig.resolve.conditions == null) {
+ if (viteConfig.consumer === 'client' || name === 'client' || opts.isSsrTargetWebworker) {
+ viteConfig.resolve.conditions = [...defaultClientConditions];
+ } else {
+ viteConfig.resolve.conditions = [...defaultServerConditions];
+ }
+ }
+ viteConfig.resolve.conditions.push('astro');
+ },
+ configResolved(viteConfig) {
+ // Initialize `compile` function to simplify usage later
+ compile = (code, filename) => {
+ return compileAstro({
+ compileProps: {
+ astroConfig: config,
+ viteConfig,
+ preferences: settings.preferences,
+ filename,
+ source: code,
+ },
+ astroFileToCompileMetadata,
+ logger,
+ });
+ };
+ },
+ configureServer(_server) {
+ server = _server;
+ // Make sure deleted files are removed from the compile metadata to save memory
+ server.watcher.on('unlink', (filename) => {
+ astroFileToCompileMetadata.delete(filename);
+ });
+ },
+ buildStart() {
+ astroFileToCompileMetadata = new Map();
+
+ // Share the `astroFileToCompileMetadata` across the same Astro config as Astro performs
+ // multiple builds and its hoisted scripts analyzer requires the compile metadata from
+ // previous builds. Ideally this should not be needed when we refactor hoisted scripts analysis.
+ if (astroFileToCompileMetadataWeakMap.has(config)) {
+ astroFileToCompileMetadata = astroFileToCompileMetadataWeakMap.get(config)!;
+ } else {
+ astroFileToCompileMetadataWeakMap.set(config, astroFileToCompileMetadata);
+ }
+ },
+ async load(id, opts) {
+ const parsedId = parseAstroRequest(id);
+ const query = parsedId.query;
+ if (!query.astro) {
+ return null;
+ }
+
+ // Astro scripts and styles virtual module code comes from the main Astro compilation
+ // through the metadata from `astroFileToCompileMetadata`. It should always exist as Astro
+ // modules are compiled first, then its virtual modules.
+ const filename = normalizePath(normalizeFilename(parsedId.filename, config.root));
+ let compileMetadata = astroFileToCompileMetadata.get(filename);
+ if (!compileMetadata) {
+ // If `compileMetadata` doesn't exist in dev, that means the virtual module may have been invalidated.
+ // We try to re-compile the main Astro module (`filename`) first before retrieving the metadata again.
+ if (server) {
+ const code = await loadId(server.pluginContainer, filename);
+ // `compile` should re-set `filename` in `astroFileToCompileMetadata`
+ if (code != null) await compile(code, filename);
+ }
+
+ compileMetadata = astroFileToCompileMetadata.get(filename);
+ }
+ // If the metadata still doesn't exist, that means the virtual modules are somehow compiled first,
+ // throw an error and we should investigate it.
+ if (!compileMetadata) {
+ throw new Error(
+ `No cached compile metadata found for "${id}". The main Astro module "${filename}" should have ` +
+ `compiled and filled the metadata first, before its virtual modules can be requested.`,
+ );
+ }
+
+ switch (query.type) {
+ case 'style': {
+ if (typeof query.index === 'undefined') {
+ throw new Error(`Requests for Astro CSS must include an index.`);
+ }
+
+ const result = compileMetadata.css[query.index];
+ if (!result) {
+ throw new Error(`No Astro CSS at index ${query.index}`);
+ }
+
+ // Register dependencies from preprocessing this style
+ result.dependencies?.forEach((dep) => this.addWatchFile(dep));
+
+ return {
+ code: result.code,
+ // This metadata is used by `cssScopeToPlugin` to remove this module from the bundle
+ // if the `filename` default export (the Astro component) is unused.
+ meta: result.isGlobal
+ ? undefined
+ : ({
+ astroCss: {
+ cssScopeTo: {
+ [filename]: ['default'],
+ },
+ },
+ } satisfies AstroPluginCssMetadata),
+ };
+ }
+ case 'script': {
+ if (typeof query.index === 'undefined') {
+ throw new Error(`Requests for scripts must include an index`);
+ }
+ // SSR script only exists to make them appear in the module graph.
+ if (opts?.ssr) {
+ return {
+ code: `/* client script, empty in SSR: ${id} */`,
+ };
+ }
+
+ const script = compileMetadata.scripts[query.index];
+ if (!script) {
+ throw new Error(`No script at index ${query.index}`);
+ }
+
+ if (script.type === 'external') {
+ const src = script.src;
+ if (src.startsWith('/') && !isBrowserPath(src)) {
+ const publicDir = config.publicDir.pathname.replace(/\/$/, '').split('/').pop() + '/';
+ throw new Error(
+ `\n\n<script src="${src}"> references an asset in the "${publicDir}" directory. Please add the "is:inline" directive to keep this asset from being bundled.\n\nFile: ${id}`,
+ );
+ }
+ }
+
+ const result: SourceDescription = {
+ code: '',
+ meta: {
+ vite: {
+ lang: 'ts',
+ },
+ },
+ };
+
+ switch (script.type) {
+ case 'inline': {
+ const { code, map } = script;
+ result.code = appendSourceMap(code, map);
+ break;
+ }
+ case 'external': {
+ const { src } = script;
+ result.code = `import "${src}"`;
+ break;
+ }
+ }
+
+ return result;
+ }
+ case 'custom':
+ case 'template':
+ case undefined:
+ default:
+ return null;
+ }
+ },
+ async transform(source, id) {
+ if (hasSpecialQueries(id)) return;
+
+ const parsedId = parseAstroRequest(id);
+ // ignore astro file sub-requests, e.g. Foo.astro?astro&type=script&index=0&lang.ts
+ if (!parsedId.filename.endsWith('.astro') || parsedId.query.astro) {
+ // Special edge case handling for Vite 6 beta, the style dependencies need to be registered here take affect
+ // TODO: Remove this when Vite fixes it (https://github.com/vitejs/vite/pull/18103)
+ if (this.environment.name === 'client') {
+ const astroFilename = normalizePath(normalizeFilename(parsedId.filename, config.root));
+ const compileMetadata = astroFileToCompileMetadata.get(astroFilename);
+ if (compileMetadata && parsedId.query.type === 'style' && parsedId.query.index != null) {
+ const result = compileMetadata.css[parsedId.query.index];
+
+ // Register dependencies from preprocessing this style
+ result.dependencies?.forEach((dep) => this.addWatchFile(dep));
+ }
+ }
+
+ return;
+ }
+
+ const filename = normalizePath(parsedId.filename);
+ const transformResult = await compile(source, filename);
+
+ const astroMetadata: AstroPluginMetadata['astro'] = {
+ clientOnlyComponents: transformResult.clientOnlyComponents,
+ hydratedComponents: transformResult.hydratedComponents,
+ serverComponents: transformResult.serverComponents,
+ scripts: transformResult.scripts,
+ containsHead: transformResult.containsHead,
+ propagation: transformResult.propagation ? 'self' : 'none',
+ pageOptions: {},
+ };
+
+ return {
+ code: transformResult.code,
+ map: transformResult.map,
+ meta: {
+ astro: astroMetadata,
+ vite: {
+ // Setting this vite metadata to `ts` causes Vite to resolve .js
+ // extensions to .ts files.
+ lang: 'ts',
+ },
+ },
+ };
+ },
+ async handleHotUpdate(ctx) {
+ return handleHotUpdate(ctx, { logger, astroFileToCompileMetadata });
+ },
+ };
+
+ const normalPlugin: vite.Plugin = {
+ name: 'astro:build:normal',
+ resolveId(id) {
+ // If Vite resolver can't resolve the Astro request, it's likely a virtual Astro file, fallback here instead
+ const parsedId = parseAstroRequest(id);
+ if (parsedId.query.astro) {
+ return id;
+ }
+ },
+ };
+
+ return [prePlugin, normalPlugin];
+}
+
+function appendSourceMap(content: string, map?: string) {
+ if (!map) return content;
+ return `${content}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(
+ map,
+ ).toString('base64')}`;
+}
diff --git a/packages/astro/src/vite-plugin-astro/metadata.ts b/packages/astro/src/vite-plugin-astro/metadata.ts
new file mode 100644
index 000000000..3fa0068a5
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/metadata.ts
@@ -0,0 +1,21 @@
+import type { ModuleInfo } from '../core/module-loader/index.js';
+import type { PluginMetadata } from './types.js';
+
+export function getAstroMetadata(modInfo: ModuleInfo): PluginMetadata['astro'] | undefined {
+ if (modInfo.meta?.astro) {
+ return modInfo.meta.astro as PluginMetadata['astro'];
+ }
+ return undefined;
+}
+
+export function createDefaultAstroMetadata(): PluginMetadata['astro'] {
+ return {
+ hydratedComponents: [],
+ clientOnlyComponents: [],
+ serverComponents: [],
+ scripts: [],
+ propagation: 'none',
+ containsHead: false,
+ pageOptions: {},
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro/query.ts b/packages/astro/src/vite-plugin-astro/query.ts
new file mode 100644
index 000000000..c9829de3f
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/query.ts
@@ -0,0 +1,37 @@
+export interface AstroQuery {
+ astro?: boolean;
+ src?: boolean;
+ type?: 'script' | 'template' | 'style' | 'custom';
+ index?: number;
+ lang?: string;
+ raw?: boolean;
+}
+
+export interface ParsedRequestResult {
+ filename: string;
+ query: AstroQuery;
+}
+
+// Parses an id to check if it's an Astro request.
+// CSS is imported like `import '/src/pages/index.astro?astro&type=style&index=0&lang.css';
+// This parses those ids and returns an object representing what it found.
+export function parseAstroRequest(id: string): ParsedRequestResult {
+ const [filename, rawQuery] = id.split(`?`, 2);
+ const query = Object.fromEntries(new URLSearchParams(rawQuery).entries()) as AstroQuery;
+ if (query.astro != null) {
+ query.astro = true;
+ }
+ if (query.src != null) {
+ query.src = true;
+ }
+ if (query.index != null) {
+ query.index = Number(query.index);
+ }
+ if (query.raw != null) {
+ query.raw = true;
+ }
+ return {
+ filename,
+ query,
+ };
+}
diff --git a/packages/astro/src/vite-plugin-astro/types.ts b/packages/astro/src/vite-plugin-astro/types.ts
new file mode 100644
index 000000000..d85fd6483
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/types.ts
@@ -0,0 +1,49 @@
+import type { HoistedScript, TransformResult } from '@astrojs/compiler';
+import type { CompileCssResult } from '../core/compile/types.js';
+import type { PropagationHint } from '../types/public/internal.js';
+
+export interface PageOptions {
+ prerender?: boolean;
+}
+
+export interface PluginMetadata {
+ astro: {
+ hydratedComponents: TransformResult['hydratedComponents'];
+ clientOnlyComponents: TransformResult['clientOnlyComponents'];
+ serverComponents: TransformResult['serverComponents'];
+ scripts: TransformResult['scripts'];
+ containsHead: TransformResult['containsHead'];
+ propagation: PropagationHint;
+ pageOptions: PageOptions;
+ };
+}
+
+export interface PluginCssMetadata {
+ astroCss: {
+ /**
+ * For Astro CSS virtual modules, it can scope to the main Astro module's default export
+ * so that if those exports are treeshaken away, the CSS module will also be treeshaken.
+ *
+ * Example config if the CSS id is `/src/Foo.astro?astro&type=style&lang.css`:
+ * ```js
+ * cssScopeTo: {
+ * '/src/Foo.astro': ['default']
+ * }
+ * ```
+ *
+ * The above is the only config we use today, but we're exposing as a `Record` to follow the
+ * upstream Vite implementation: https://github.com/vitejs/vite/pull/16058. When/If that lands,
+ * we can also remove our custom implementation.
+ */
+ cssScopeTo: Record<string, string[]>;
+ };
+}
+
+export interface CompileMetadata {
+ /** Used for HMR to compare code changes */
+ originalCode: string;
+ /** For Astro CSS virtual module */
+ css: CompileCssResult[];
+ /** For Astro scripts virtual module */
+ scripts: HoistedScript[];
+}
diff --git a/packages/astro/src/vite-plugin-astro/utils.ts b/packages/astro/src/vite-plugin-astro/utils.ts
new file mode 100644
index 000000000..f1a592340
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/utils.ts
@@ -0,0 +1,21 @@
+import fs from 'node:fs/promises';
+import type { PluginContainer } from 'vite';
+
+export const frontmatterRE = /^---(.*?)^---/ms;
+
+export async function loadId(pluginContainer: PluginContainer, id: string) {
+ const result = await pluginContainer.load(id, { ssr: true });
+
+ if (result) {
+ if (typeof result === 'string') {
+ return result;
+ } else {
+ return result.code;
+ }
+ }
+
+ // Fallback to reading from fs (Vite doesn't add this by default)
+ try {
+ return await fs.readFile(id, 'utf-8');
+ } catch {}
+}
diff --git a/packages/astro/src/vite-plugin-config-alias/README.md b/packages/astro/src/vite-plugin-config-alias/README.md
new file mode 100644
index 000000000..3b470aded
--- /dev/null
+++ b/packages/astro/src/vite-plugin-config-alias/README.md
@@ -0,0 +1,26 @@
+# vite-plugin-config-alias
+
+This adds aliasing support to Vite from `tsconfig.json` or `jsconfig.json` files.
+
+Consider the following example configuration:
+
+```
+{
+ "compilerOptions": {
+ "baseUrl": "src",
+ "paths": {
+ "components:*": ["components/*.astro"]
+ }
+ }
+}
+```
+
+With this configuration, the following imports would map to the same location.
+
+```js
+import Test from '../components/Test.astro';
+
+import Test from 'components/Test.astro';
+
+import Test from 'components:Test';
+```
diff --git a/packages/astro/src/vite-plugin-config-alias/index.ts b/packages/astro/src/vite-plugin-config-alias/index.ts
new file mode 100644
index 000000000..d30d806a6
--- /dev/null
+++ b/packages/astro/src/vite-plugin-config-alias/index.ts
@@ -0,0 +1,152 @@
+import path from 'node:path';
+import type { CompilerOptions } from 'typescript';
+import { type ResolvedConfig, type Plugin as VitePlugin, normalizePath } from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+
+type Alias = {
+ find: RegExp;
+ replacement: string;
+};
+
+/** Returns a list of compiled aliases. */
+const getConfigAlias = (settings: AstroSettings): Alias[] | null => {
+ const { tsConfig, tsConfigPath } = settings;
+ if (!tsConfig || !tsConfigPath || !tsConfig.compilerOptions) return null;
+
+ const { baseUrl, paths } = tsConfig.compilerOptions as CompilerOptions;
+ if (!baseUrl) return null;
+
+ // resolve the base url from the configuration file directory
+ const resolvedBaseUrl = path.resolve(path.dirname(tsConfigPath), baseUrl);
+
+ const aliases: Alias[] = [];
+
+ // compile any alias expressions and push them to the list
+ if (paths) {
+ for (const [alias, values] of Object.entries(paths)) {
+ /** Regular Expression used to match a given path. */
+ const find = new RegExp(
+ `^${[...alias]
+ .map((segment) =>
+ segment === '*' ? '(.+)' : segment.replace(/[\\^$*+?.()|[\]{}]/, '\\$&'),
+ )
+ .join('')}$`,
+ );
+
+ for (const value of values) {
+ /** Internal index used to calculate the matching id in a replacement. */
+ let matchId = 0;
+ /** String used to replace a matched path. */
+ const replacement = [...normalizePath(path.resolve(resolvedBaseUrl, value))]
+ .map((segment) => (segment === '*' ? `$${++matchId}` : segment === '$' ? '$$' : segment))
+ .join('');
+
+ aliases.push({ find, replacement });
+ }
+ }
+ }
+
+ // compile the baseUrl expression and push it to the list
+ // - `baseUrl` changes the way non-relative specifiers are resolved
+ // - if `baseUrl` exists then all non-relative specifiers are resolved relative to it
+ aliases.push({
+ find: /^(?!\.*\/|\.*$|\w:)(.+)$/,
+ replacement: `${[...normalizePath(resolvedBaseUrl)]
+ .map((segment) => (segment === '$' ? '$$' : segment))
+ .join('')}/$1`,
+ });
+
+ return aliases;
+};
+
+/** Returns a Vite plugin used to alias paths from tsconfig.json and jsconfig.json. */
+export default function configAliasVitePlugin({
+ settings,
+}: {
+ settings: AstroSettings;
+}): VitePlugin | null {
+ const configAlias = getConfigAlias(settings);
+ if (!configAlias) return null;
+
+ const plugin: VitePlugin = {
+ name: 'astro:tsconfig-alias',
+ // use post to only resolve ids that all other plugins before it can't
+ enforce: 'post',
+ configResolved(config) {
+ patchCreateResolver(config, plugin);
+ },
+ async resolveId(id, importer, options) {
+ if (isVirtualId(id)) return;
+
+ // Handle aliases found from `compilerOptions.paths`. Unlike Vite aliases, tsconfig aliases
+ // are best effort only, so we have to manually replace them here, instead of using `vite.resolve.alias`
+ for (const alias of configAlias) {
+ if (alias.find.test(id)) {
+ const updatedId = id.replace(alias.find, alias.replacement);
+
+ // Vite may pass an id with "*" when resolving glob import paths
+ // Returning early allows Vite to handle the final resolution
+ // See https://github.com/withastro/astro/issues/9258#issuecomment-1838806157
+ if (updatedId.includes('*')) {
+ return updatedId;
+ }
+
+ const resolved = await this.resolve(updatedId, importer, { skipSelf: true, ...options });
+ if (resolved) return resolved;
+ }
+ }
+ },
+ };
+
+ return plugin;
+}
+
+/**
+ * Vite's `createResolver` is used to resolve various things, including CSS `@import`.
+ * However, there's no way to extend this resolver, besides patching it. This function
+ * patches and adds a Vite plugin whose `resolveId` will be used to resolve before the
+ * internal plugins in `createResolver`.
+ *
+ * Vite may simplify this soon: https://github.com/vitejs/vite/pull/10555
+ */
+function patchCreateResolver(config: ResolvedConfig, postPlugin: VitePlugin) {
+ const _createResolver = config.createResolver;
+ // @ts-expect-error override readonly property intentionally
+ config.createResolver = function (...args1: any) {
+ const resolver = _createResolver.apply(config, args1);
+ return async function (...args2: any) {
+ const id: string = args2[0];
+ const importer: string | undefined = args2[1];
+ const ssr: boolean | undefined = args2[3];
+
+ // fast path so we don't run this extensive logic in prebundling
+ if (importer?.includes('node_modules')) {
+ return resolver.apply(_createResolver, args2);
+ }
+
+ const fakePluginContext = {
+ resolve: (_id: string, _importer?: string) => resolver(_id, _importer, false, ssr),
+ };
+ const fakeResolveIdOpts = {
+ assertions: {},
+ isEntry: false,
+ ssr,
+ };
+
+ const result = await resolver.apply(_createResolver, args2);
+ if (result) return result;
+
+ // @ts-expect-error resolveId exists
+ const resolved = await postPlugin.resolveId.apply(fakePluginContext, [
+ id,
+ importer,
+ fakeResolveIdOpts,
+ ]);
+ if (resolved) return resolved;
+ };
+ };
+}
+
+function isVirtualId(id: string) {
+ return id.includes('\0') || id.startsWith('virtual:') || id.startsWith('astro:');
+}
diff --git a/packages/astro/src/vite-plugin-fileurl/index.ts b/packages/astro/src/vite-plugin-fileurl/index.ts
new file mode 100644
index 000000000..73132f3af
--- /dev/null
+++ b/packages/astro/src/vite-plugin-fileurl/index.ts
@@ -0,0 +1,14 @@
+import type { Plugin as VitePlugin } from 'vite';
+
+export default function vitePluginFileURL(): VitePlugin {
+ return {
+ name: 'astro:vite-plugin-file-url',
+ enforce: 'pre',
+ resolveId(source, importer) {
+ if (source.startsWith('file://')) {
+ const rest = source.slice(7);
+ return this.resolve(rest, importer);
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts
new file mode 100644
index 000000000..c9ec2460d
--- /dev/null
+++ b/packages/astro/src/vite-plugin-head/index.ts
@@ -0,0 +1,162 @@
+import type { ModuleInfo } from 'rollup';
+import type * as vite from 'vite';
+import type { AstroBuildPlugin } from '../core/build/plugin.js';
+import type { PluginMetadata } from '../vite-plugin-astro/types.js';
+
+import { getParentModuleInfos, getTopLevelPageModuleInfos } from '../core/build/graph.js';
+import type { BuildInternals } from '../core/build/internal.js';
+import type { SSRComponentMetadata, SSRResult } from '../types/public/internal.js';
+import { getAstroMetadata } from '../vite-plugin-astro/index.js';
+
+// Detect this in comments, both in .astro components and in js/ts files.
+const injectExp = /(?:^\/\/|\/\/!)\s*astro-head-inject/;
+
+export default function configHeadVitePlugin(): vite.Plugin {
+ let server: vite.ViteDevServer;
+
+ function propagateMetadata<
+ P extends keyof PluginMetadata['astro'],
+ V extends PluginMetadata['astro'][P],
+ >(
+ this: { getModuleInfo(id: string): ModuleInfo | null },
+ id: string,
+ prop: P,
+ value: V,
+ seen = new Set<string>(),
+ ) {
+ if (seen.has(id)) return;
+ seen.add(id);
+ const mod = server.moduleGraph.getModuleById(id);
+ const info = this.getModuleInfo(id);
+
+ if (info?.meta.astro) {
+ const astroMetadata = getAstroMetadata(info);
+ if (astroMetadata) {
+ Reflect.set(astroMetadata, prop, value);
+ }
+ }
+
+ for (const parent of mod?.importers || []) {
+ if (parent.id) {
+ propagateMetadata.call(this, parent.id, prop, value, seen);
+ }
+ }
+ }
+
+ return {
+ name: 'astro:head-metadata',
+ enforce: 'pre',
+ apply: 'serve',
+ configureServer(_server) {
+ server = _server;
+ },
+ resolveId(source, importer) {
+ 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 (!server) {
+ return;
+ }
+
+ // TODO This could probably be removed now that this is handled in resolveId
+ let info = this.getModuleInfo(id);
+ if (info && getAstroMetadata(info)?.containsHead) {
+ propagateMetadata.call(this, id, 'containsHead', true);
+ }
+
+ // TODO This could probably be removed now that this is handled in resolveId
+ if (info && getAstroMetadata(info)?.propagation === 'self') {
+ const mod = server.moduleGraph.getModuleById(id);
+ for (const parent of mod?.importers ?? []) {
+ if (parent.id) {
+ propagateMetadata.call(this, parent.id, 'propagation', 'in-tree');
+ }
+ }
+ }
+
+ if (injectExp.test(source)) {
+ propagateMetadata.call(this, id, 'propagation', 'in-tree');
+ }
+ },
+ };
+}
+
+export function astroHeadBuildPlugin(internals: BuildInternals): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before'() {
+ return {
+ vitePlugin: {
+ name: 'astro:head-metadata-build',
+ generateBundle(_opts, bundle) {
+ const map: SSRResult['componentMetadata'] = internals.componentMetadata;
+ function getOrCreateMetadata(id: string): SSRComponentMetadata {
+ if (map.has(id)) return map.get(id)!;
+ const metadata: SSRComponentMetadata = {
+ 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, mod] of Object.entries(output.modules)) {
+ 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') {
+ for (const info of getParentModuleInfos(id, this)) {
+ let metadata = getOrCreateMetadata(info.id);
+ if (metadata.propagation !== 'self') {
+ metadata.propagation = 'in-tree';
+ }
+ }
+ }
+ }
+
+ // Head propagation (aka bubbling)
+ if (mod.code && injectExp.test(mod.code)) {
+ for (const info of getParentModuleInfos(id, this)) {
+ getOrCreateMetadata(info.id).propagation = 'in-tree';
+ }
+ }
+ }
+ }
+ },
+ },
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-hmr-reload/index.ts b/packages/astro/src/vite-plugin-hmr-reload/index.ts
new file mode 100644
index 000000000..cf151eef6
--- /dev/null
+++ b/packages/astro/src/vite-plugin-hmr-reload/index.ts
@@ -0,0 +1,36 @@
+import type { EnvironmentModuleNode, Plugin } from 'vite';
+
+/**
+ * The very last Vite plugin to reload the browser if any SSR-only module are updated
+ * which will require a full page reload. This mimics the behaviour of Vite 5 where
+ * it used to unconditionally reload for us.
+ */
+export default function hmrReload(): Plugin {
+ return {
+ name: 'astro:hmr-reload',
+ enforce: 'post',
+ hotUpdate: {
+ order: 'post',
+ handler({ modules, server, timestamp }) {
+ if (this.environment.name !== 'ssr') return;
+
+ let hasSsrOnlyModules = false;
+
+ const invalidatedModules = new Set<EnvironmentModuleNode>();
+ for (const mod of modules) {
+ if (mod.id == null) continue;
+ const clientModule = server.environments.client.moduleGraph.getModuleById(mod.id);
+ if (clientModule != null) continue;
+
+ this.environment.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true);
+ hasSsrOnlyModules = true;
+ }
+
+ if (hasSsrOnlyModules) {
+ server.ws.send({ type: 'full-reload' });
+ return [];
+ }
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-html/README.md b/packages/astro/src/vite-plugin-html/README.md
new file mode 100644
index 000000000..d10d43754
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-html
+
+Transforms `.html` files as JS to be rendered by Astro.
diff --git a/packages/astro/src/vite-plugin-html/index.ts b/packages/astro/src/vite-plugin-html/index.ts
new file mode 100644
index 000000000..b44a28ace
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/index.ts
@@ -0,0 +1,14 @@
+import { transform } from './transform/index.js';
+
+export default function html() {
+ return {
+ name: 'astro:html',
+ options(options: any) {
+ options.plugins = options.plugins?.filter((p: any) => p.name !== 'vite:build-html');
+ },
+ async transform(source: string, id: string) {
+ if (!id.endsWith('.html')) return;
+ return await transform(source, id);
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-html/transform/escape.ts b/packages/astro/src/vite-plugin-html/transform/escape.ts
new file mode 100644
index 000000000..c367e834a
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/transform/escape.ts
@@ -0,0 +1,33 @@
+import type { Root, RootContent } from 'hast';
+import type MagicString from 'magic-string';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { escapeTemplateLiteralCharacters, needsEscape, replaceAttribute } from './utils.js';
+
+const rehypeEscape: Plugin<[{ s: MagicString }], Root> = ({ s }) => {
+ return (tree) => {
+ visit(tree, (node: Root | RootContent) => {
+ if (node.type === 'text' || node.type === 'comment') {
+ if (needsEscape(node.value)) {
+ s.overwrite(
+ node.position!.start.offset!,
+ node.position!.end.offset!,
+ escapeTemplateLiteralCharacters(node.value),
+ );
+ }
+ } else if (node.type === 'element') {
+ if (!node.properties) return;
+ for (let [key, value] of Object.entries(node.properties)) {
+ key = key.replace(/([A-Z])/g, '-$1').toLowerCase();
+ const newKey = needsEscape(key) ? escapeTemplateLiteralCharacters(key) : key;
+ const newValue = needsEscape(value) ? escapeTemplateLiteralCharacters(value) : value;
+ if (newKey === key && newValue === value) continue;
+ replaceAttribute(s, node, key, value === '' ? newKey : `${newKey}="${newValue}"`);
+ }
+ }
+ });
+ };
+};
+
+export default rehypeEscape;
diff --git a/packages/astro/src/vite-plugin-html/transform/index.ts b/packages/astro/src/vite-plugin-html/transform/index.ts
new file mode 100644
index 000000000..d5be96762
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/transform/index.ts
@@ -0,0 +1,20 @@
+import MagicString from 'magic-string';
+import { rehype } from 'rehype';
+import { VFile } from 'vfile';
+import escape from './escape.js';
+import slots, { SLOT_PREFIX } from './slots.js';
+
+export async function transform(code: string, id: string) {
+ const s = new MagicString(code, { filename: id });
+ const parser = rehype().data('settings', { fragment: true }).use(escape, { s }).use(slots, { s });
+
+ const vfile = new VFile({ value: code, path: id });
+ await parser.process(vfile);
+ s.prepend(`function render({ slots: ${SLOT_PREFIX} }) {\n\t\treturn \``);
+ s.append('`\n\t}\nrender["astro:html"] = true;\nexport default render;');
+
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary' }),
+ };
+}
diff --git a/packages/astro/src/vite-plugin-html/transform/slots.ts b/packages/astro/src/vite-plugin-html/transform/slots.ts
new file mode 100644
index 000000000..82046dd33
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/transform/slots.ts
@@ -0,0 +1,33 @@
+import type { Root, RootContent } from 'hast';
+import type { Plugin } from 'unified';
+
+import type MagicString from 'magic-string';
+import { visit } from 'unist-util-visit';
+import { escapeTemplateLiteralCharacters } from './utils.js';
+
+const rehypeSlots: Plugin<[{ s: MagicString }], Root> = ({ s }) => {
+ return (tree, file) => {
+ visit(tree, (node: Root | RootContent) => {
+ if (node.type === 'element' && node.tagName === 'slot') {
+ if (typeof node.properties?.['is:inline'] !== 'undefined') return;
+ const name = node.properties?.['name'] ?? 'default';
+ const start = node.position?.start.offset ?? 0;
+ const end = node.position?.end.offset ?? 0;
+ const first = node.children.at(0) ?? node;
+ const last = node.children.at(-1) ?? node;
+ const text = file.value
+ .slice(first.position?.start.offset ?? 0, last.position?.end.offset ?? 0)
+ .toString();
+ s.overwrite(
+ start,
+ end,
+ `\${${SLOT_PREFIX}["${name}"] ?? \`${escapeTemplateLiteralCharacters(text).trim()}\`}`,
+ );
+ }
+ });
+ };
+};
+
+export default rehypeSlots;
+
+export const SLOT_PREFIX = `___SLOTS___`;
diff --git a/packages/astro/src/vite-plugin-html/transform/utils.ts b/packages/astro/src/vite-plugin-html/transform/utils.ts
new file mode 100644
index 000000000..dd0ebcd14
--- /dev/null
+++ b/packages/astro/src/vite-plugin-html/transform/utils.ts
@@ -0,0 +1,60 @@
+import type { Element } from 'hast';
+import type MagicString from 'magic-string';
+
+const splitAttrsTokenizer = /([${}@\w:\-]*)\s*=\s*?(['"]?)(.*?)\2\s+/g;
+
+export function replaceAttribute(s: MagicString, node: Element, key: string, newValue: string) {
+ splitAttrsTokenizer.lastIndex = 0;
+ const text = s.original
+ .slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0)
+ .toString();
+ const offset = text.indexOf(key);
+ if (offset === -1) return;
+ const start = node.position!.start.offset! + offset;
+ const tokens = text.slice(offset).split(splitAttrsTokenizer);
+ const token = tokens[0].replace(/([^>])>[\s\S]*$/gm, '$1');
+ if (token.trim() === key) {
+ const end = start + key.length;
+ return s.overwrite(start, end, newValue, { contentOnly: true });
+ } else {
+ const length = token.length;
+ const end = start + length;
+ return s.overwrite(start, end, newValue, { contentOnly: true });
+ }
+}
+
+// Embedding in our own template literal expression requires escaping
+// any meaningful template literal characters in the user's code!
+const NEEDS_ESCAPE_RE = /[`\\]|\$\{/g;
+
+export function needsEscape(value: any): value is string {
+ // Reset the RegExp's global state
+ NEEDS_ESCAPE_RE.lastIndex = 0;
+ return typeof value === 'string' && NEEDS_ESCAPE_RE.test(value);
+}
+
+export function escapeTemplateLiteralCharacters(value: string) {
+ // Reset the RegExp's global state
+ NEEDS_ESCAPE_RE.lastIndex = 0;
+
+ let char: string | undefined;
+ let startIndex = 0;
+ let segment = '';
+ let text = '';
+
+ // Rather than a naive `String.replace()`, we have to iterate through
+ // the raw contents to properly handle existing backslashes
+ while (([char] = NEEDS_ESCAPE_RE.exec(value) ?? [])) {
+ // Final loop when char === undefined, append trailing content
+ if (!char) {
+ text += value.slice(startIndex);
+ break;
+ }
+ const endIndex = NEEDS_ESCAPE_RE.lastIndex - char.length;
+ const prefix = segment === '\\' ? '' : '\\';
+ segment = prefix + char;
+ text += value.slice(startIndex, endIndex) + segment;
+ startIndex = NEEDS_ESCAPE_RE.lastIndex;
+ }
+ return text;
+}
diff --git a/packages/astro/src/vite-plugin-integrations-container/index.ts b/packages/astro/src/vite-plugin-integrations-container/index.ts
new file mode 100644
index 000000000..81b720aef
--- /dev/null
+++ b/packages/astro/src/vite-plugin-integrations-container/index.ts
@@ -0,0 +1,45 @@
+import type { PluginContext } from 'rollup';
+import type { Plugin as VitePlugin } from 'vite';
+import type { Logger } from '../core/logger/core.js';
+import type { AstroSettings } from '../types/astro.js';
+
+import { normalizePath } from 'vite';
+import { runHookServerSetup } from '../integrations/hooks.js';
+import type { InternalInjectedRoute, ResolvedInjectedRoute } from '../types/public/internal.js';
+
+/** Connect Astro integrations into Vite, as needed. */
+export default function astroIntegrationsContainerPlugin({
+ settings,
+ logger,
+}: {
+ settings: AstroSettings;
+ logger: Logger;
+}): VitePlugin {
+ return {
+ name: 'astro:integration-container',
+ async configureServer(server) {
+ if (server.config.isProduction) return;
+ await runHookServerSetup({ config: settings.config, server, logger });
+ },
+ async buildStart() {
+ if (settings.injectedRoutes.length === settings.resolvedInjectedRoutes.length) return;
+ // Ensure the injectedRoutes are all resolved to their final paths through Rollup
+ settings.resolvedInjectedRoutes = await Promise.all(
+ settings.injectedRoutes.map((route) => resolveEntryPoint.call(this, route)),
+ );
+ },
+ };
+}
+
+async function resolveEntryPoint(
+ this: PluginContext,
+ route: InternalInjectedRoute,
+): Promise<ResolvedInjectedRoute> {
+ const resolvedId = await this.resolve(route.entrypoint.toString())
+ .then((res) => res?.id)
+ .catch(() => undefined);
+ if (!resolvedId) return route;
+
+ const resolvedEntryPoint = new URL(`file://${normalizePath(resolvedId)}`);
+ return { ...route, resolvedEntryPoint };
+}
diff --git a/packages/astro/src/vite-plugin-load-fallback/README.md b/packages/astro/src/vite-plugin-load-fallback/README.md
new file mode 100644
index 000000000..6ff8eac5f
--- /dev/null
+++ b/packages/astro/src/vite-plugin-load-fallback/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-load-fallback
+
+Handle fallback loading using Astro's internal module loader before falling back to Vite's.
diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts
new file mode 100644
index 000000000..199f15c43
--- /dev/null
+++ b/packages/astro/src/vite-plugin-load-fallback/index.ts
@@ -0,0 +1,80 @@
+import nodeFs from 'node:fs';
+import npath from 'node:path';
+import type * as vite from 'vite';
+import { slash } from '../core/path.js';
+import { cleanUrl } from '../vite-plugin-utils/index.js';
+
+type NodeFileSystemModule = typeof nodeFs;
+
+export interface LoadFallbackPluginParams {
+ fs?: NodeFileSystemModule;
+ root: URL;
+}
+
+export default function loadFallbackPlugin({
+ fs,
+ root,
+}: LoadFallbackPluginParams): vite.Plugin[] | false {
+ // Only add this plugin if a custom fs implementation is provided.
+ // Also check for `fs.default` because `import * as fs from 'node:fs'` will
+ // export as so, which only it's `.default` would === `nodeFs`.
+ // @ts-expect-error check default
+ if (!fs || fs === nodeFs || fs.default === nodeFs) {
+ return false;
+ }
+
+ const tryLoadModule = async (id: string) => {
+ try {
+ // await is necessary for the catch
+ return await fs.promises.readFile(cleanUrl(id), 'utf-8');
+ } catch {
+ try {
+ return await fs.promises.readFile(id, 'utf-8');
+ } catch {
+ try {
+ const fullpath = new URL('.' + id, root);
+ return await fs.promises.readFile(fullpath, 'utf-8');
+ } catch {
+ // Let fall through to the next
+ }
+ }
+ }
+ };
+
+ return [
+ {
+ name: 'astro:load-fallback',
+ enforce: 'post',
+ async resolveId(id, parent) {
+ // See if this can be loaded from our fs
+ if (parent) {
+ const candidateId = npath.posix.join(npath.posix.dirname(slash(parent)), id);
+ try {
+ // Check to see if this file exists and is not a directory.
+ const stats = await fs.promises.stat(candidateId);
+ if (!stats.isDirectory()) {
+ return candidateId;
+ }
+ } catch {}
+ }
+ },
+ async load(id) {
+ const source = await tryLoadModule(id);
+ return source;
+ },
+ },
+ {
+ name: 'astro:load-fallback-hmr',
+ enforce: 'pre',
+ handleHotUpdate(context) {
+ // Wrap context.read so it checks our filesystem first.
+ const read = context.read;
+ context.read = async () => {
+ const source = await tryLoadModule(context.file);
+ if (source) return source;
+ return read.call(context);
+ };
+ },
+ },
+ ];
+}
diff --git a/packages/astro/src/vite-plugin-markdown/README.md b/packages/astro/src/vite-plugin-markdown/README.md
new file mode 100644
index 000000000..b0c432a42
--- /dev/null
+++ b/packages/astro/src/vite-plugin-markdown/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-markdown
+
+Adds Markdown support to Vite for `.md` files (See `SUPPORTED_MARKDOWN_FILE_EXTENSIONS` for all supported extensions).
diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts
new file mode 100644
index 000000000..6f248853f
--- /dev/null
+++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts
@@ -0,0 +1,35 @@
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { createMarkdownProcessor } from '@astrojs/markdown-remark';
+import { safeParseFrontmatter } from '../content/utils.js';
+import type { ContentEntryType } from '../types/public/content.js';
+
+export const markdownContentEntryType: ContentEntryType = {
+ extensions: ['.md'],
+ async getEntryInfo({ contents, fileUrl }: { contents: string; fileUrl: URL }) {
+ const parsed = safeParseFrontmatter(contents, fileURLToPath(fileUrl));
+ return {
+ data: parsed.frontmatter,
+ body: parsed.content.trim(),
+ slug: parsed.frontmatter.slug,
+ rawData: parsed.rawFrontmatter,
+ };
+ },
+ // We need to handle propagation for Markdown because they support layouts which will bring in styles.
+ handlePropagation: true,
+
+ async getRenderFunction(config) {
+ const processor = await createMarkdownProcessor(config.markdown);
+ return async function renderToString(entry) {
+ // Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically
+ const result = await processor.render(entry.body ?? '', {
+ frontmatter: entry.data,
+ // @ts-expect-error Internal API
+ fileURL: entry.filePath ? pathToFileURL(entry.filePath) : undefined,
+ });
+ return {
+ html: result.code,
+ metadata: result.metadata,
+ };
+ };
+ },
+};
diff --git a/packages/astro/src/vite-plugin-markdown/images.ts b/packages/astro/src/vite-plugin-markdown/images.ts
new file mode 100644
index 000000000..d0ed62535
--- /dev/null
+++ b/packages/astro/src/vite-plugin-markdown/images.ts
@@ -0,0 +1,59 @@
+export type MarkdownImagePath = { raw: string; safeName: string };
+
+export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: string) {
+ return `
+ import { getImage } from "astro:assets";
+ ${imagePaths
+ .map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`)
+ .join('\n')}
+
+ const images = async function(html) {
+ const imageSources = {};
+ ${imagePaths
+ .map((entry) => {
+ const rawUrl = JSON.stringify(entry.raw);
+ return `{
+ const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ '\\\\$&',
+ )} + '[^"]*)"', 'g');
+ let match;
+ let occurrenceCounter = 0;
+ while ((match = regex.exec(html)) !== null) {
+ const matchKey = ${rawUrl} + '_' + occurrenceCounter;
+ const imageProps = JSON.parse(match[1].replace(/&#x22;/g, '"'));
+ const { src, ...props } = imageProps;
+ imageSources[matchKey] = await getImage({src: Astro__${entry.safeName}, ...props});
+ occurrenceCounter++;
+ }
+ }`;
+ })
+ .join('\n')}
+ return imageSources;
+ };
+
+ async function updateImageReferences(html) {
+ const imageSources = await images(html);
+
+ return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) => {
+ const decodedImagePath = JSON.parse(imagePath.replace(/&#x22;/g, '"'));
+
+ // Use the 'index' property for each image occurrence
+ const srcKey = decodedImagePath.src + '_' + decodedImagePath.index;
+
+ if (imageSources[srcKey].srcSet && imageSources[srcKey].srcSet.values.length > 0) {
+ imageSources[srcKey].attributes.srcset = imageSources[srcKey].srcSet.attribute;
+ }
+
+ const { index, ...attributesWithoutIndex } = imageSources[srcKey].attributes;
+
+ return spreadAttributes({
+ src: imageSources[srcKey].src,
+ ...attributesWithoutIndex,
+ });
+ });
+ }
+
+ const html = async () => await updateImageReferences(${JSON.stringify(html)});
+ `;
+}
diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts
new file mode 100644
index 000000000..8876250f9
--- /dev/null
+++ b/packages/astro/src/vite-plugin-markdown/index.ts
@@ -0,0 +1,166 @@
+import fs from 'node:fs';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ type MarkdownProcessor,
+ createMarkdownProcessor,
+ isFrontmatterValid,
+} from '@astrojs/markdown-remark';
+import type { Plugin } from 'vite';
+import { safeParseFrontmatter } from '../content/utils.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import { isMarkdownFile, isPage } from '../core/util.js';
+import { normalizePath } from '../core/viteUtils.js';
+import { shorthash } from '../runtime/server/shorthash.js';
+import type { AstroSettings } from '../types/astro.js';
+import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js';
+import { getFileInfo } from '../vite-plugin-utils/index.js';
+import { type MarkdownImagePath, getMarkdownCodeForImages } from './images.js';
+
+interface AstroPluginOptions {
+ settings: AstroSettings;
+ logger: Logger;
+}
+
+const astroServerRuntimeModulePath = normalizePath(
+ fileURLToPath(new URL('../runtime/server/index.js', import.meta.url)),
+);
+
+const astroErrorModulePath = normalizePath(
+ fileURLToPath(new URL('../core/errors/index.js', import.meta.url)),
+);
+
+export default function markdown({ settings, logger }: AstroPluginOptions): Plugin {
+ let processor: Promise<MarkdownProcessor> | undefined;
+
+ return {
+ enforce: 'pre',
+ name: 'astro:markdown',
+ buildEnd() {
+ processor = undefined;
+ },
+ async resolveId(source, importer, options) {
+ if (importer?.endsWith('.md') && source[0] !== '/') {
+ let resolved = await this.resolve(source, importer, options);
+ if (!resolved) resolved = await this.resolve('./' + source, importer, options);
+ return resolved;
+ }
+ },
+ // Why not the "transform" hook instead of "load" + readFile?
+ // A: Vite transforms all "import.meta.env" references to their values before
+ // passing to the transform hook. This lets us get the truly raw value
+ // to escape "import.meta.env" ourselves.
+ async load(id) {
+ if (isMarkdownFile(id)) {
+ const { fileId, fileUrl } = getFileInfo(id, settings.config);
+ const rawFile = await fs.promises.readFile(fileId, 'utf-8');
+ const raw = safeParseFrontmatter(rawFile, id);
+
+ const fileURL = pathToFileURL(fileId);
+
+ // Lazily initialize the Markdown processor
+ if (!processor) {
+ processor = createMarkdownProcessor(settings.config.markdown);
+ }
+
+ const renderResult = await (await processor).render(raw.content, {
+ // @ts-expect-error passing internal prop
+ fileURL,
+ frontmatter: raw.frontmatter,
+ });
+
+ // Improve error message for invalid astro frontmatter
+ if (!isFrontmatterValid(renderResult.metadata.frontmatter)) {
+ throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
+ }
+
+ let html = renderResult.code;
+ const { headings, imagePaths: rawImagePaths, frontmatter } = renderResult.metadata;
+
+ // Add default charset for markdown pages
+ const isMarkdownPage = isPage(fileURL, settings);
+ const charset = isMarkdownPage ? '<meta charset="utf-8">' : '';
+
+ // Resolve all the extracted images from the content
+ const imagePaths: MarkdownImagePath[] = [];
+ for (const imagePath of rawImagePaths) {
+ imagePaths.push({
+ raw: imagePath,
+ safeName: shorthash(imagePath),
+ });
+ }
+
+ const { layout } = frontmatter;
+
+ if (frontmatter.setup) {
+ logger.warn(
+ 'markdown',
+ `[${id}] Astro now supports MDX! Support for components in ".md" (or alternative extensions like ".markdown") files using the "setup" frontmatter is no longer enabled by default. Migrate this file to MDX.`,
+ );
+ }
+
+ const code = `
+ import { unescapeHTML, spreadAttributes, createComponent, render, renderComponent, maybeRenderHead } from ${JSON.stringify(
+ astroServerRuntimeModulePath,
+ )};
+ import { AstroError, AstroErrorData } from ${JSON.stringify(astroErrorModulePath)};
+ ${layout ? `import Layout from ${JSON.stringify(layout)};` : ''}
+
+ ${
+ // Only include the code relevant to `astro:assets` if there's images in the file
+ imagePaths.length > 0
+ ? getMarkdownCodeForImages(imagePaths, html)
+ : `const html = () => ${JSON.stringify(html)};`
+ }
+
+ export const frontmatter = ${JSON.stringify(frontmatter)};
+ export const file = ${JSON.stringify(fileId)};
+ export const url = ${JSON.stringify(fileUrl)};
+ export function rawContent() {
+ return ${JSON.stringify(raw.content)};
+ }
+ export async function compiledContent() {
+ return await html();
+ }
+ export function getHeadings() {
+ return ${JSON.stringify(headings)};
+ }
+
+ export const Content = createComponent((result, _props, slots) => {
+ const { layout, ...content } = frontmatter;
+ content.file = file;
+ content.url = url;
+
+ return ${
+ layout
+ ? `render\`\${renderComponent(result, 'Layout', Layout, {
+ file,
+ url,
+ content,
+ frontmatter: content,
+ headings: getHeadings(),
+ rawContent,
+ compiledContent,
+ 'server:root': true,
+ }, {
+ 'default': () => render\`\${unescapeHTML(html())}\`
+ })}\`;`
+ : `render\`${charset}\${maybeRenderHead(result)}\${unescapeHTML(html())}\`;`
+ }
+ });
+ export default Content;
+ `;
+
+ return {
+ code,
+ meta: {
+ astro: createDefaultAstroMetadata(),
+ vite: {
+ lang: 'ts',
+ },
+ },
+ };
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts
new file mode 100644
index 000000000..a86205484
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scanner/index.ts
@@ -0,0 +1,113 @@
+import { extname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bold } from 'kleur/colors';
+import type { Plugin as VitePlugin } from 'vite';
+import { warnMissingAdapter } from '../core/dev/adapter-validation.js';
+import type { Logger } from '../core/logger/core.js';
+import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js';
+import { isEndpoint, isPage } from '../core/util.js';
+import { normalizePath, rootRelativePath } from '../core/viteUtils.js';
+import type { AstroSettings, RoutesList } from '../types/astro.js';
+
+export interface AstroPluginScannerOptions {
+ settings: AstroSettings;
+ logger: Logger;
+ routesList: RoutesList;
+}
+
+const KNOWN_FILE_EXTENSIONS = ['.astro', '.js', '.ts'];
+
+export default function astroScannerPlugin({
+ settings,
+ logger,
+ routesList,
+}: AstroPluginScannerOptions): VitePlugin {
+ return {
+ name: 'astro:scanner',
+ enforce: 'post',
+
+ async transform(this, code, id, options) {
+ if (!options?.ssr) return;
+
+ const filename = normalizePath(id);
+ let fileURL: URL;
+ try {
+ fileURL = new URL(`file://${filename}`);
+ } catch {
+ // If we can't construct a valid URL, exit early
+ return;
+ }
+
+ const fileIsPage = isPage(fileURL, settings);
+ const fileIsEndpoint = isEndpoint(fileURL, settings);
+ if (!(fileIsPage || fileIsEndpoint)) return;
+
+ const route = routesList.routes.find((r) => {
+ const filePath = new URL(`./${r.component}`, settings.config.root);
+ return normalizePath(fileURLToPath(filePath)) === filename;
+ });
+
+ if (!route) {
+ return;
+ }
+
+ // `getStaticPaths` warning is just a string check, should be good enough for most cases
+ if (
+ !route.prerender &&
+ code.includes('getStaticPaths') &&
+ // this should only be valid for `.astro`, `.js` and `.ts` files
+ KNOWN_FILE_EXTENSIONS.includes(extname(filename))
+ ) {
+ logger.warn(
+ 'router',
+ `getStaticPaths() ignored in dynamic page ${bold(
+ rootRelativePath(settings.config.root, fileURL, true),
+ )}. Add \`export const prerender = true;\` to prerender the page as static HTML during the build process.`,
+ );
+ }
+
+ const { meta = {} } = this.getModuleInfo(id) ?? {};
+ return {
+ code,
+ map: null,
+ meta: {
+ ...meta,
+ astro: {
+ ...(meta.astro ?? { hydratedComponents: [], clientOnlyComponents: [], scripts: [] }),
+ pageOptions: {
+ prerender: route.prerender,
+ },
+ },
+ },
+ };
+ },
+
+ // Handle hot updates to update the prerender option
+ async handleHotUpdate(ctx) {
+ const filename = normalizePath(ctx.file);
+ let fileURL: URL;
+ try {
+ fileURL = new URL(`file://${filename}`);
+ } catch {
+ // If we can't construct a valid URL, exit early
+ return;
+ }
+
+ const fileIsPage = isPage(fileURL, settings);
+ const fileIsEndpoint = isEndpoint(fileURL, settings);
+ if (!(fileIsPage || fileIsEndpoint)) return;
+
+ const route = routesList.routes.find((r) => {
+ const filePath = new URL(`./${r.component}`, settings.config.root);
+ return normalizePath(fileURLToPath(filePath)) === filename;
+ });
+
+ if (!route) {
+ return;
+ }
+
+ await getRoutePrerenderOption(await ctx.read(), route, settings, logger);
+ warnMissingAdapter(logger, settings);
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-scripts/README.md b/packages/astro/src/vite-plugin-scripts/README.md
new file mode 100644
index 000000000..dcb1cdd35
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scripts/README.md
@@ -0,0 +1,3 @@
+# vite-plugin-scripts
+
+Resolves and loads custom scripts by Astro or injected by integrations.
diff --git a/packages/astro/src/vite-plugin-scripts/index.ts b/packages/astro/src/vite-plugin-scripts/index.ts
new file mode 100644
index 000000000..f87f6a381
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scripts/index.ts
@@ -0,0 +1,64 @@
+import type { ConfigEnv, Plugin as VitePlugin } from 'vite';
+import type { AstroSettings } from '../types/astro.js';
+import type { InjectedScriptStage } from '../types/public/integrations.js';
+
+// NOTE: We can't use the virtual "\0" ID convention because we need to
+// inject these as ESM imports into actual code, where they would not
+// resolve correctly.
+const SCRIPT_ID_PREFIX = `astro:scripts/`;
+export const BEFORE_HYDRATION_SCRIPT_ID = `${SCRIPT_ID_PREFIX}${
+ 'before-hydration' as InjectedScriptStage
+}.js`;
+export const PAGE_SCRIPT_ID = `${SCRIPT_ID_PREFIX}${'page' as InjectedScriptStage}.js`;
+export const PAGE_SSR_SCRIPT_ID = `${SCRIPT_ID_PREFIX}${'page-ssr' as InjectedScriptStage}.js`;
+
+export default function astroScriptsPlugin({ settings }: { settings: AstroSettings }): VitePlugin {
+ let env: ConfigEnv | undefined = undefined;
+ return {
+ name: 'astro:scripts',
+
+ config(_config, _env) {
+ env = _env;
+ },
+
+ async resolveId(id) {
+ if (id.startsWith(SCRIPT_ID_PREFIX)) {
+ return id;
+ }
+ return undefined;
+ },
+
+ async load(id) {
+ if (id === BEFORE_HYDRATION_SCRIPT_ID) {
+ return settings.scripts
+ .filter((s) => s.stage === 'before-hydration')
+ .map((s) => s.content)
+ .join('\n');
+ }
+ if (id === PAGE_SCRIPT_ID) {
+ return settings.scripts
+ .filter((s) => s.stage === 'page')
+ .map((s) => s.content)
+ .join('\n');
+ }
+ if (id === PAGE_SSR_SCRIPT_ID) {
+ return settings.scripts
+ .filter((s) => s.stage === 'page-ssr')
+ .map((s) => s.content)
+ .join('\n');
+ }
+ return null;
+ },
+ buildStart() {
+ const hasHydrationScripts = settings.scripts.some((s) => s.stage === 'before-hydration');
+ const isSsrBuild = env?.isSsrBuild;
+ if (hasHydrationScripts && env?.command === 'build' && !isSsrBuild) {
+ this.emitFile({
+ type: 'chunk',
+ id: BEFORE_HYDRATION_SCRIPT_ID,
+ name: BEFORE_HYDRATION_SCRIPT_ID,
+ });
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-scripts/page-ssr.ts b/packages/astro/src/vite-plugin-scripts/page-ssr.ts
new file mode 100644
index 000000000..05d8be18d
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scripts/page-ssr.ts
@@ -0,0 +1,42 @@
+import MagicString from 'magic-string';
+import { type Plugin as VitePlugin, normalizePath } from 'vite';
+import { isPage } from '../core/util.js';
+import type { AstroSettings } from '../types/astro.js';
+import { PAGE_SSR_SCRIPT_ID } from './index.js';
+
+export default function astroScriptsPostPlugin({
+ settings,
+}: {
+ settings: AstroSettings;
+}): VitePlugin {
+ return {
+ name: 'astro:scripts:page-ssr',
+ enforce: 'post',
+ transform(this, code, id, options) {
+ if (!options?.ssr) return;
+
+ const hasInjectedScript = settings.scripts.some((s) => s.stage === 'page-ssr');
+ if (!hasInjectedScript) return;
+
+ const filename = normalizePath(id);
+ let fileURL: URL;
+ try {
+ fileURL = new URL(`file://${filename}`);
+ } catch {
+ // If we can't construct a valid URL, exit early
+ return;
+ }
+
+ const fileIsPage = isPage(fileURL, settings);
+ if (!fileIsPage) return;
+
+ const s = new MagicString(code, { filename });
+ s.prepend(`import '${PAGE_SSR_SCRIPT_ID}';\n`);
+
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: 'boundary' }),
+ };
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-ssr-manifest/index.ts b/packages/astro/src/vite-plugin-ssr-manifest/index.ts
new file mode 100644
index 000000000..05e1f54be
--- /dev/null
+++ b/packages/astro/src/vite-plugin-ssr-manifest/index.ts
@@ -0,0 +1,25 @@
+import type { Plugin as VitePlugin } from 'vite';
+
+const manifestVirtualModuleId = 'astro:ssr-manifest';
+const resolvedManifestVirtualModuleId = '\0' + manifestVirtualModuleId;
+
+export function vitePluginSSRManifest(): VitePlugin {
+ return {
+ name: '@astrojs/vite-plugin-astro-ssr-manifest',
+ enforce: 'post',
+ resolveId(id) {
+ if (id === manifestVirtualModuleId) {
+ return resolvedManifestVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedManifestVirtualModuleId) {
+ return `export let manifest = {};
+export function _privateSetManifestDontUseThis(ssrManifest) {
+ manifest = ssrManifest;
+}`;
+ }
+ return void 0;
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-utils/index.ts b/packages/astro/src/vite-plugin-utils/index.ts
new file mode 100644
index 000000000..2e7948bdd
--- /dev/null
+++ b/packages/astro/src/vite-plugin-utils/index.ts
@@ -0,0 +1,61 @@
+import { fileURLToPath } from 'node:url';
+import ancestor from 'common-ancestor-path';
+import {
+ appendExtension,
+ appendForwardSlash,
+ removeLeadingForwardSlashWindows,
+} from '../core/path.js';
+import { viteID } from '../core/util.js';
+import type { AstroConfig } from '../types/public/config.js';
+
+export function getFileInfo(id: string, config: AstroConfig) {
+ const sitePathname = appendForwardSlash(
+ config.site ? new URL(config.base, config.site).pathname : config.base,
+ );
+
+ const fileId = id.split('?')[0];
+ let fileUrl = fileId.includes('/pages/')
+ ? fileId
+ .replace(/^.*?\/pages\//, sitePathname)
+ .replace(/(?:\/index)?\.(?:md|markdown|mdown|mkdn|mkd|mdwn|astro)$/, '')
+ : undefined;
+ if (fileUrl && config.trailingSlash === 'always') {
+ fileUrl = appendForwardSlash(fileUrl);
+ }
+ if (fileUrl && config.build.format === 'file') {
+ fileUrl = appendExtension(fileUrl, 'html');
+ }
+ return { fileId, fileUrl };
+}
+
+/**
+ * Normalizes different file names like:
+ *
+ * - /@fs/home/user/project/src/pages/index.astro
+ * - /src/pages/index.astro
+ *
+ * as absolute file paths with forward slashes.
+ */
+export function normalizeFilename(filename: string, root: URL) {
+ if (filename.startsWith('/@fs')) {
+ filename = filename.slice('/@fs'.length);
+ } else if (filename.startsWith('/') && !ancestor(filename, fileURLToPath(root))) {
+ const url = new URL('.' + filename, root);
+ filename = viteID(url);
+ }
+ return removeLeadingForwardSlashWindows(filename);
+}
+
+const postfixRE = /[?#].*$/s;
+export function cleanUrl(url: string): string {
+ return url.replace(postfixRE, '');
+}
+
+const specialQueriesRE = /(?:\?|&)(?:url|raw|direct)(?:&|$)/;
+/**
+ * Detect `?url`, `?raw`, and `?direct`, in which case we usually want to skip
+ * transforming any code with this queries as Vite will handle it directly.
+ */
+export function hasSpecialQueries(id: string): boolean {
+ return specialQueriesRE.test(id);
+}