diff options
author | 2022-01-04 09:35:07 -0500 | |
---|---|---|
committer | 2022-01-04 09:35:07 -0500 | |
commit | 9db22b97b604e2ab1908b28e3461aefb222dcf97 (patch) | |
tree | 3dc843c263aedf5569222f807626507bb5660a11 | |
parent | 523f93a3d89115b23e198a1cde35fe984b46b316 (diff) | |
download | astro-9db22b97b604e2ab1908b28e3461aefb222dcf97.tar.gz astro-9db22b97b604e2ab1908b28e3461aefb222dcf97.tar.zst astro-9db22b97b604e2ab1908b28e3461aefb222dcf97.zip |
Only resolve inline script specifiers in the static build (#2302)
* Revert "Revert "Implement hydrated components in the static build (#2260)""
This reverts commit 17ac18e88c2b5a916c23ff7abc630fb98e313906.
* Only resolve specifiers in the static build
* Adding a changeset
* Fix the client-only test
-rw-r--r-- | .changeset/soft-sloths-impress.md | 5 | ||||
-rw-r--r-- | examples/fast-build/package.json | 1 | ||||
-rw-r--r-- | examples/fast-build/src/components/Counter.vue | 24 | ||||
-rw-r--r-- | examples/fast-build/src/pages/[pokemon].astro | 20 | ||||
-rw-r--r-- | examples/fast-build/src/pages/index.astro | 18 | ||||
-rw-r--r-- | packages/astro/package.json | 2 | ||||
-rw-r--r-- | packages/astro/src/@types/astro.ts | 1 | ||||
-rw-r--r-- | packages/astro/src/core/build/internal.ts | 5 | ||||
-rw-r--r-- | packages/astro/src/core/build/static-build.ts | 152 | ||||
-rw-r--r-- | packages/astro/src/core/ssr/index.ts | 157 | ||||
-rw-r--r-- | packages/astro/src/core/ssr/result.ts | 75 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/hydration.ts | 19 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/index.ts | 2 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/metadata.ts | 79 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/util.ts | 16 | ||||
-rw-r--r-- | packages/astro/src/vite-plugin-astro/compile.ts | 5 | ||||
-rw-r--r-- | packages/astro/src/vite-plugin-build-html/index.ts | 2 | ||||
-rw-r--r-- | packages/astro/src/vite-plugin-markdown/index.ts | 4 | ||||
-rw-r--r-- | yarn.lock | 8 |
19 files changed, 394 insertions, 201 deletions
diff --git a/.changeset/soft-sloths-impress.md b/.changeset/soft-sloths-impress.md new file mode 100644 index 000000000..ff6fb7c35 --- /dev/null +++ b/.changeset/soft-sloths-impress.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix to allow the static build to build hydrated components diff --git a/examples/fast-build/package.json b/examples/fast-build/package.json index afbedca11..730734679 100644 --- a/examples/fast-build/package.json +++ b/examples/fast-build/package.json @@ -6,6 +6,7 @@ "dev": "astro dev --experimental-static-build", "start": "astro dev", "build": "astro build --experimental-static-build", + "scan-build": "astro build", "preview": "astro preview" }, "devDependencies": { diff --git a/examples/fast-build/src/components/Counter.vue b/examples/fast-build/src/components/Counter.vue new file mode 100644 index 000000000..599bcf615 --- /dev/null +++ b/examples/fast-build/src/components/Counter.vue @@ -0,0 +1,24 @@ +<template> + <div id="vue" class="counter"> + <button @click="subtract()">-</button> + <pre>{{ count }}</pre> + <button @click="add()">+</button> + </div> +</template> + +<script> +import { ref } from 'vue'; +export default { + setup() { + const count = ref(0); + const add = () => (count.value = count.value + 1); + const subtract = () => (count.value = count.value - 1); + + return { + count, + add, + subtract, + }; + }, +}; +</script> diff --git a/examples/fast-build/src/pages/[pokemon].astro b/examples/fast-build/src/pages/[pokemon].astro new file mode 100644 index 000000000..ea01cc4f7 --- /dev/null +++ b/examples/fast-build/src/pages/[pokemon].astro @@ -0,0 +1,20 @@ +--- +import Greeting from '../components/Greeting.vue'; + +export async function getStaticPaths() { + const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=2000`); + const result = await response.json(); + const allPokemon = result.results; + return allPokemon.map(pokemon => ({params: {pokemon: pokemon.name}, props: {pokemon}})); +} +--- +<html lang="en"> + <head> + <title>Hello</title> + </head> + + <body> + <h1>{Astro.props.pokemon.name}</h1> + <Greeting client:load /> + </body> +</html>
\ No newline at end of file diff --git a/examples/fast-build/src/pages/index.astro b/examples/fast-build/src/pages/index.astro index 2bdadbf5b..ee228aa19 100644 --- a/examples/fast-build/src/pages/index.astro +++ b/examples/fast-build/src/pages/index.astro @@ -2,6 +2,7 @@ import imgUrl from '../images/penguin.jpg'; import grayscaleUrl from '../images/random.jpg?grayscale=true'; import Greeting from '../components/Greeting.vue'; +import Counter from '../components/Counter.vue'; --- <html> @@ -26,9 +27,14 @@ import Greeting from '../components/Greeting.vue'; <Greeting /> </section> - <section> - <h1>ImageTools</h1> - <img src={grayscaleUrl} /> - </section> - </body> -</html> + <section> + <h1>ImageTools</h1> + <img src={grayscaleUrl} /> + </section> + + <section> + <h1>Hydrated component</h1> + <Counter client:idle /> + </section> +</body> +</html>
\ No newline at end of file diff --git a/packages/astro/package.json b/packages/astro/package.json index 45c24c3d8..b069bc4ce 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -56,7 +56,7 @@ "test": "mocha --parallel --timeout 15000" }, "dependencies": { - "@astrojs/compiler": "^0.6.0", + "@astrojs/compiler": "^0.7.0", "@astrojs/language-server": "^0.8.2", "@astrojs/markdown-remark": "^0.6.0", "@astrojs/prism": "0.4.0", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index f6f47cd98..2e60da3b7 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -371,5 +371,6 @@ export interface SSRResult { scripts: Set<SSRElement>; links: Set<SSRElement>; createAstro(Astro: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal; + resolve: (s: string) => Promise<string>; _metadata: SSRMetadata; } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index b30ea7ddf..ee379f4e3 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -14,6 +14,10 @@ export interface BuildInternals { // A mapping to entrypoints (facadeId) to assets (styles) that are added. facadeIdToAssetsMap: 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>; } /** @@ -41,5 +45,6 @@ export function createBuildInternals(): BuildInternals { astroStyleMap, astroPageStyleMap, facadeIdToAssetsMap, + entrySpecifierToBundleMap: new Map<string, string>(), }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 191cdb987..07e97c7ca 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -1,6 +1,6 @@ import type { OutputChunk, PreRenderedChunk, RollupOutput } from 'rollup'; import type { Plugin as VitePlugin } from '../vite'; -import type { AstroConfig, RouteCache } from '../../@types/astro'; +import type { AstroConfig, RouteCache, SSRElement } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteConfigWithSSR } from '../create-vite'; @@ -9,12 +9,16 @@ import type { BuildInternals } from '../../core/build/internal.js'; import type { AstroComponentFactory } from '../../runtime/server'; import fs from 'fs'; +import npath from 'path'; import { fileURLToPath } from 'url'; +import glob from 'fast-glob'; import vite from '../vite.js'; import { debug, info, error } from '../../core/logger.js'; import { createBuildInternals } from '../../core/build/internal.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import { renderComponent, getParamsAndProps } from '../ssr/index.js'; +import { getParamsAndProps } from '../ssr/index.js'; +import { createResult } from '../ssr/result.js'; +import { renderPage } from '../../runtime/server/index.js'; export interface StaticBuildOptions { allPages: AllPagesData; @@ -28,8 +32,11 @@ export interface StaticBuildOptions { export async function staticBuild(opts: StaticBuildOptions) { const { allPages, astroConfig } = opts; + // The pages to be built for rendering purposes. + const pageInput = new Set<string>(); + // The JavaScript entrypoints. - const jsInput: Set<string> = new Set(); + const jsInput = new Set<string>(); // A map of each page .astro file, to the PageBuildData which contains information // about that page, such as its paths. @@ -37,26 +44,35 @@ export async function staticBuild(opts: StaticBuildOptions) { for (const [component, pageData] of Object.entries(allPages)) { const [renderers, mod] = pageData.preload; + const metadata = mod.$$metadata; + + const topLevelImports = new Set([ + // Any component that gets hydrated + ...metadata.hydratedComponentPaths(), + // Any hydration directive like astro/client/idle.js + ...metadata.hydrationDirectiveSpecifiers(), + // The client path for each renderer + ...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!), + ]); - // Hydrated components are statically identified. - for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { - // Note that this part is not yet implemented in the static build. - //jsInput.add(path); + for (const specifier of topLevelImports) { + jsInput.add(specifier); } let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname; - jsInput.add(astroModuleId); + pageInput.add(astroModuleId); facadeIdToPageDataMap.set(astroModuleId, pageData); } // Build internals needed by the CSS plugin const internals = createBuildInternals(); - // Perform the SSR build - const result = (await ssrBuild(opts, internals, jsInput)) as RollupOutput; + // Run the SSR build and client build in parallel + const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[]; // Generate each of the pages. - await generatePages(result, opts, internals, facadeIdToPageDataMap); + await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); + await cleanSsrOutput(opts); } async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) { @@ -67,7 +83,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp mode: 'production', build: { emptyOutDir: true, - minify: false, // 'esbuild', // significantly faster than "terser" but may produce slightly-bigger bundles + minify: false, outDir: fileURLToPath(astroConfig.dist), ssr: true, rollupOptions: { @@ -79,7 +95,41 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp target: 'es2020', // must match an esbuild target }, plugins: [ - vitePluginNewBuild(), + vitePluginNewBuild(input, internals, 'mjs'), + rollupPluginAstroBuildCSS({ + internals, + }), + ...(viteConfig.plugins || []), + ], + publicDir: viteConfig.publicDir, + root: viteConfig.root, + envPrefix: 'PUBLIC_', + server: viteConfig.server, + base: astroConfig.buildOptions.site ? new URL(astroConfig.buildOptions.site).pathname : '/', + }); +} + +async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) { + const { astroConfig, viteConfig } = opts; + + return await vite.build({ + logLevel: 'error', + mode: 'production', + build: { + emptyOutDir: false, + minify: 'esbuild', + outDir: fileURLToPath(astroConfig.dist), + rollupOptions: { + input: Array.from(input), + output: { + format: 'esm', + }, + preserveEntrySignatures: 'exports-only', + }, + target: 'es2020', // must match an esbuild target + }, + plugins: [ + vitePluginNewBuild(input, internals, 'js'), rollupPluginAstroBuildCSS({ internals, }), @@ -124,6 +174,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter const generationOptions: Readonly<GeneratePathOptions> = { pageData, + internals, linkIds, Component, }; @@ -136,13 +187,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter interface GeneratePathOptions { pageData: PageBuildData; + internals: BuildInternals; linkIds: string[]; Component: AstroComponentFactory; } -async function generatePath(path: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { +async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { const { astroConfig, logging, origin, routeCache } = opts; - const { Component, linkIds, pageData } = gopts; + const { Component, internals, linkIds, pageData } = gopts; const [renderers, mod] = pageData.preload; @@ -151,14 +203,36 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener route: pageData.route, routeCache, logging, - pathname: path, + pathname, mod, }); - info(logging, 'generate', `Generating: ${path}`); + debug(logging, 'generate', `Generating: ${pathname}`); - const html = await renderComponent(renderers, Component, astroConfig, path, origin, params, pageProps, linkIds); - const outFolder = new URL('.' + path + '/', astroConfig.dist); + const result = createResult({ astroConfig, origin, params, pathname, renderers }); + result.links = new Set<SSRElement>( + linkIds.map((href) => ({ + props: { + rel: 'stylesheet', + href, + }, + children: '', + })) + ); + // Override the `resolve` method so that hydrated components are given the + // hashed filepath to the component. + result.resolve = async (specifier: string) => { + const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); + if (typeof hashedFilePath !== 'string') { + throw new Error(`Cannot find the built path for ${specifier}`); + } + const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); + const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; + return fullyRelativePath; + }; + + let html = await renderPage(result, Component, pageProps, null); + const outFolder = new URL('.' + pathname + '/', astroConfig.dist); const outFile = new URL('./index.html', outFolder); await fs.promises.mkdir(outFolder, { recursive: true }); await fs.promises.writeFile(outFile, html, 'utf-8'); @@ -167,7 +241,20 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener } } -export function vitePluginNewBuild(): VitePlugin { +async function cleanSsrOutput(opts: StaticBuildOptions) { + // The SSR output is all .mjs files, the client output is not. + const files = await glob('**/*.mjs', { + cwd: opts.astroConfig.dist.pathname, + }); + await Promise.all( + files.map(async (filename) => { + const url = new URL(filename, opts.astroConfig.dist); + await fs.promises.rm(url); + }) + ); +} + +export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin { return { name: '@astro/rollup-plugin-new-build', @@ -183,13 +270,34 @@ export function vitePluginNewBuild(): VitePlugin { outputOptions(outputOptions) { Object.assign(outputOptions, { entryFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].mjs'; + return 'assets/[name].[hash].' + ext; }, chunkFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].mjs'; + return 'assets/[name].[hash].' + ext; }, }); return outputOptions; }, + + async generateBundle(_options, bundle) { + const promises = []; + const mapping = new Map<string, string>(); + for (const specifier of input) { + promises.push( + this.resolve(specifier).then((result) => { + if (result) { + mapping.set(result.id, specifier); + } + }) + ); + } + await Promise.all(promises); + for (const [, chunk] of Object.entries(bundle)) { + if (chunk.type === 'chunk' && chunk.facadeModuleId && mapping.has(chunk.facadeModuleId)) { + const specifier = mapping.get(chunk.facadeModuleId)!; + internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName); + } + } + }, }; } diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts index b53411b91..a739d0175 100644 --- a/packages/astro/src/core/ssr/index.ts +++ b/packages/astro/src/core/ssr/index.ts @@ -1,34 +1,19 @@ import type { BuildResult } from 'esbuild'; import type vite from '../vite'; -import type { - AstroConfig, - AstroGlobal, - AstroGlobalPartial, - ComponentInstance, - GetStaticPathsResult, - Params, - Props, - Renderer, - RouteCache, - RouteData, - RuntimeMode, - SSRElement, - SSRError, - SSRResult, -} from '../../@types/astro'; +import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRElement, SSRError } from '../../@types/astro'; import type { LogOptions } from '../logger'; -import type { AstroComponentFactory } from '../../runtime/server/index'; import eol from 'eol'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { renderPage, renderSlot } from '../../runtime/server/index.js'; -import { canonicalURL as getCanonicalURL, codeFrame, resolveDependency } from '../util.js'; +import { renderPage } from '../../runtime/server/index.js'; +import { codeFrame, resolveDependency } from '../util.js'; import { getStylesForURL } from './css.js'; import { injectTags } from './html.js'; import { generatePaginateFunction } from './paginate.js'; import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; +import { createResult } from './result.js'; const svelteStylesRE = /svelte\?svelte&type=style/; @@ -139,75 +124,6 @@ export async function preload({ astroConfig, filePath, viteServer }: SSROptions) return [renderers, mod]; } -export async function renderComponent( - renderers: Renderer[], - Component: AstroComponentFactory, - astroConfig: AstroConfig, - pathname: string, - origin: string, - params: Params, - pageProps: Props, - links: string[] = [] -): Promise<string> { - const _links = new Set<SSRElement>( - links.map((href) => ({ - props: { - rel: 'stylesheet', - href, - }, - children: '', - })) - ); - const result: SSRResult = { - styles: new Set<SSRElement>(), - scripts: new Set<SSRElement>(), - links: _links, - /** This function returns the `Astro` faux-global */ - createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { - const site = new URL(origin); - const url = new URL('.' + pathname, site); - const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin); - return { - __proto__: astroGlobal, - props, - request: { - canonicalURL, - params, - url, - }, - slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])), - // This is used for <Markdown> but shouldn't be used publicly - privateRenderSlotDoNotUse(slotName: string) { - return renderSlot(result, slots ? slots[slotName] : null); - }, - // <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages - async privateRenderMarkdownDoNotUse(content: string, opts: any) { - let mdRender = astroConfig.markdownOptions.render; - let renderOpts = {}; - if (Array.isArray(mdRender)) { - renderOpts = mdRender[1]; - mdRender = mdRender[0]; - } - if (typeof mdRender === 'string') { - ({ default: mdRender } = await import(mdRender)); - } - const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) }); - return code; - }, - } as unknown as AstroGlobal; - }, - _metadata: { - renderers, - pathname, - experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, - }, - }; - - let html = await renderPage(result, Component, pageProps, null); - - return html; -} - export async function getParamsAndProps({ route, routeCache, @@ -292,57 +208,18 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - // Create the result object that will be passed into the render 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 = { - styles: new Set<SSRElement>(), - scripts: new Set<SSRElement>(), - links: new Set<SSRElement>(), - /** This function returns the `Astro` faux-global */ - createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { - const site = new URL(origin); - const url = new URL('.' + pathname, site); - const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin); - return { - __proto__: astroGlobal, - props, - request: { - canonicalURL, - params, - url, - }, - slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])), - // This is used for <Markdown> but shouldn't be used publicly - privateRenderSlotDoNotUse(slotName: string) { - return renderSlot(result, slots ? slots[slotName] : null); - }, - // <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages - async privateRenderMarkdownDoNotUse(content: string, opts: any) { - let mdRender = astroConfig.markdownOptions.render; - let renderOpts = {}; - if (Array.isArray(mdRender)) { - renderOpts = mdRender[1]; - mdRender = mdRender[0]; - } - // ['rehype-toc', opts] - if (typeof mdRender === 'string') { - ({ default: mdRender } = await import(mdRender)); - } - // [import('rehype-toc'), opts] - else if (mdRender instanceof Promise) { - ({ default: mdRender } = await mdRender); - } - const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) }); - return code; - }, - } as unknown as AstroGlobal; - }, - _metadata: { - renderers, - pathname, - experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, - }, + const result = createResult({ astroConfig, origin, params, pathname, renderers }); + // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" + result.resolve = async (s: string) => { + // The legacy build needs these to remain unresolved so that vite HTML + // Can do the resolution. Without this condition the build output will be + // broken in the legacy build. This can be removed once the legacy build is removed. + if(astroConfig.buildOptions.experimentalStaticBuild) { + const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s); + return resolvedPath; + } else { + return s; + } }; let html = await renderPage(result, Component, pageProps, null); @@ -387,7 +264,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO html = injectTags(html, tags); // run transformIndexHtml() in dev to run Vite dev transformations - if (mode === 'development') { + if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); html = await viteServer.transformIndexHtml(relativeURL, html, pathname); } diff --git a/packages/astro/src/core/ssr/result.ts b/packages/astro/src/core/ssr/result.ts new file mode 100644 index 000000000..32694dd05 --- /dev/null +++ b/packages/astro/src/core/ssr/result.ts @@ -0,0 +1,75 @@ +import type { AstroConfig, AstroGlobal, AstroGlobalPartial, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro'; + +import { canonicalURL as getCanonicalURL } from '../util.js'; +import { renderSlot } from '../../runtime/server/index.js'; + +export interface CreateResultArgs { + astroConfig: AstroConfig; + origin: string; + params: Params; + pathname: string; + renderers: Renderer[]; +} + +export function createResult(args: CreateResultArgs): SSRResult { + const { astroConfig, origin, params, pathname, renderers } = args; + + // Create the result object that will be passed into the render 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 = { + styles: new Set<SSRElement>(), + scripts: new Set<SSRElement>(), + links: new Set<SSRElement>(), + /** This function returns the `Astro` faux-global */ + createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { + const site = new URL(origin); + const url = new URL('.' + pathname, site); + const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin); + return { + __proto__: astroGlobal, + props, + request: { + canonicalURL, + params, + url, + }, + slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])), + // This is used for <Markdown> but shouldn't be used publicly + privateRenderSlotDoNotUse(slotName: string) { + return renderSlot(result, slots ? slots[slotName] : null); + }, + // <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages + async privateRenderMarkdownDoNotUse(content: string, opts: any) { + let mdRender = astroConfig.markdownOptions.render; + let renderOpts = {}; + if (Array.isArray(mdRender)) { + renderOpts = mdRender[1]; + mdRender = mdRender[0]; + } + // ['rehype-toc', opts] + if (typeof mdRender === 'string') { + ({ default: mdRender } = await import(mdRender)); + } + // [import('rehype-toc'), opts] + else if (mdRender instanceof Promise) { + ({ default: mdRender } = await mdRender); + } + const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) }); + return code; + }, + } as unknown as AstroGlobal; + }, + // This is a stub and will be implemented by dev and build. + async resolve(s: string): Promise<string> { + return ''; + }, + _metadata: { + renderers, + pathname, + experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, + }, + }; + + return result; +} diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index fd71287dd..f83632bdd 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -1,8 +1,8 @@ import type { AstroComponentMetadata } from '../../@types/astro'; -import type { SSRElement } from '../../@types/astro'; +import type { SSRElement, SSRResult } from '../../@types/astro'; import { valueToEstree } from 'estree-util-value-to-estree'; import * as astring from 'astring'; -import { serializeListValue } from './util.js'; +import { hydrationSpecifier, serializeListValue } from './util.js'; const { generate, GENERATOR } = astring; @@ -69,6 +69,11 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext 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; + } default: { extracted.hydration.directive = key.split(':')[1]; extracted.hydration.value = value; @@ -93,18 +98,20 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext extracted.props[key] = value; } } + return extracted; } interface HydrateScriptOptions { renderer: any; + result: SSRResult; astroId: string; props: Record<string | number, any>; } /** 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, astroId, props } = scriptOptions; + const { renderer, result, astroId, props } = scriptOptions; const { hydrate, componentUrl, componentExport } = metadata; if (!componentExport) { @@ -117,7 +124,9 @@ export async function generateHydrateScript(scriptOptions: HydrateScriptOptions, } hydrationSource += renderer.source - ? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${renderer.source}")]); + ? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${await result.resolve(componentUrl)}"), import("${await result.resolve( + renderer.source + )}")]); return (el, children) => hydrate(el)(Component, ${serializeProps(props)}, children); ` : `await import("${componentUrl}"); @@ -126,7 +135,7 @@ export async function generateHydrateScript(scriptOptions: HydrateScriptOptions, const hydrationScript = { props: { type: 'module', 'data-astro-component-hydration': true }, - children: `import setup from 'astro/client/${hydrate}.js'; + children: `import setup from '${await result.resolve(hydrationSpecifier(hydrate))}'; setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => { ${hydrationSource} }); diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 7bbba1a4b..7fe0a174e 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -249,7 +249,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr // Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head. // INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point. - result.scripts.add(await generateHydrateScript({ renderer, astroId, props }, metadata as Required<AstroComponentMetadata>)); + result.scripts.add(await generateHydrateScript({ renderer, result, astroId, props }, metadata as Required<AstroComponentMetadata>)); return `<astro-root uid="${astroId}">${html ?? ''}</astro-root>`; } diff --git a/packages/astro/src/runtime/server/metadata.ts b/packages/astro/src/runtime/server/metadata.ts index b4454e16f..f012105b1 100644 --- a/packages/astro/src/runtime/server/metadata.ts +++ b/packages/astro/src/runtime/server/metadata.ts @@ -1,3 +1,5 @@ +import { hydrationSpecifier } from './util.js'; + interface ModuleInfo { module: Record<string, any>; specifier: string; @@ -8,11 +10,28 @@ interface ComponentMetadata { componentUrl: string; } +interface CreateMetadataOptions { + modules: ModuleInfo[]; + hydratedComponents: any[]; + hydrationDirectives: Set<string>; + hoisted: any[]; +} + export class Metadata { public fileURL: URL; + public modules: ModuleInfo[]; + public hoisted: any[]; + public hydratedComponents: any[]; + public hydrationDirectives: Set<string>; + private metadataCache: Map<any, ComponentMetadata | null>; - constructor(fileURL: string, public modules: ModuleInfo[], public hydratedComponents: any[], public hoisted: any[]) { - this.fileURL = new URL(fileURL); + + constructor(filePathname: string, opts: CreateMetadataOptions) { + this.modules = opts.modules; + this.hoisted = opts.hoisted; + this.hydratedComponents = opts.hydratedComponents; + this.hydrationDirectives = opts.hydrationDirectives; + this.fileURL = new URL(filePathname, 'http://example.com'); this.metadataCache = new Map<any, ComponentMetadata | null>(); } @@ -30,24 +49,50 @@ export class Metadata { return metadata?.componentExport || null; } - // Recursively collect all of the hydrated components' paths. - getAllHydratedComponentPaths(): Set<string> { - const paths = new Set<string>(); - for (const component of this.hydratedComponents) { - const path = this.getPath(component); - if (path) { - paths.add(path); + /** + * Gets the paths of all hydrated components within this component + * and children components. + */ + *hydratedComponentPaths() { + const found = new Set<string>(); + for (const metadata of this.deepMetadata()) { + for (const component of metadata.hydratedComponents) { + const path = this.getPath(component); + if (path && !found.has(path)) { + found.add(path); + yield path; + } } } + } + /** + * Gets all of the hydration specifiers used within this component. + */ + *hydrationDirectiveSpecifiers() { + for (const directive of this.hydrationDirectives) { + yield hydrationSpecifier(directive); + } + } + + private *deepMetadata(): Generator<Metadata, void, unknown> { + // Yield self + yield this; + // Keep a Set of metadata objects so we only yield them out once. + const seen = new Set<Metadata>(); for (const { module: mod } of this.modules) { if (typeof mod.$$metadata !== 'undefined') { - for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { - paths.add(path); + const md = mod.$$metadata as Metadata; + // Call children deepMetadata() which will yield the child metadata + // and any of its children metadatas + for (const childMetdata of md.deepMetadata()) { + if (!seen.has(childMetdata)) { + seen.add(childMetdata); + yield childMetdata; + } } } } - return paths; } private getComponentMetadata(Component: any): ComponentMetadata | null { @@ -83,12 +128,6 @@ export class Metadata { } } -interface CreateMetadataOptions { - modules: ModuleInfo[]; - hydratedComponents: any[]; - hoisted: any[]; -} - -export function createMetadata(fileURL: string, options: CreateMetadataOptions) { - return new Metadata(fileURL, options.modules, options.hydratedComponents, options.hoisted); +export function createMetadata(filePathname: string, options: CreateMetadataOptions) { + return new Metadata(filePathname, options); } diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts index 62a0da388..22f38f970 100644 --- a/packages/astro/src/runtime/server/util.ts +++ b/packages/astro/src/runtime/server/util.ts @@ -1,3 +1,10 @@ +function formatList(values: string[]): string { + if (values.length === 1) { + return values[0]; + } + return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; +} + export function serializeListValue(value: any) { const hash: Record<string, any> = {}; @@ -27,3 +34,12 @@ export function serializeListValue(value: any) { } } } + +/** + * Get the import specifier for a given hydration directive. + * @param hydrate The hydration directive such as `idle` or `visible` + * @returns + */ +export function hydrationSpecifier(hydrate: string) { + return `astro/client/${hydrate}.js`; +} diff --git a/packages/astro/src/vite-plugin-astro/compile.ts b/packages/astro/src/vite-plugin-astro/compile.ts index 868230037..4c6ceccd7 100644 --- a/packages/astro/src/vite-plugin-astro/compile.ts +++ b/packages/astro/src/vite-plugin-astro/compile.ts @@ -29,8 +29,10 @@ function isSSR(options: undefined | boolean | { ssr: boolean }): boolean { async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: boolean | undefined) { // pages and layouts should be transformed as full documents (implicit <head> <body> etc) // everything else is treated as a fragment - const normalizedID = fileURLToPath(new URL(`file://${filename}`)); + const filenameURL = new URL(`file://${filename}`); + const normalizedID = fileURLToPath(filenameURL); const isPage = normalizedID.startsWith(fileURLToPath(config.pages)) || normalizedID.startsWith(fileURLToPath(config.layouts)); + const pathname = filenameURL.pathname.substr(config.projectRoot.pathname.length - 1); let cssTransformError: Error | undefined; @@ -39,6 +41,7 @@ async function compile(config: AstroConfig, filename: string, source: string, vi // result passed to esbuild, but also available in the catch handler. const transformResult = await transform(source, { as: isPage ? 'document' : 'fragment', + pathname, projectRoot: config.projectRoot.toString(), site: config.buildOptions.site, sourcefile: filename, diff --git a/packages/astro/src/vite-plugin-build-html/index.ts b/packages/astro/src/vite-plugin-build-html/index.ts index c4d998191..0c92d5a44 100644 --- a/packages/astro/src/vite-plugin-build-html/index.ts +++ b/packages/astro/src/vite-plugin-build-html/index.ts @@ -69,7 +69,7 @@ export function rollupPluginAstroBuildHTML(options: PluginOptions): VitePlugin { const [renderers, mod] = pageData.preload; // Hydrated components are statically identified. - for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { + for (const path of mod.$$metadata.hydratedComponentPaths()) { jsInput.add(path); } diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index e3d977066..9b3ccaca8 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -51,8 +51,12 @@ ${setup}`.trim(); astroResult = `${prelude}\n${astroResult}`; } + const filenameURL = new URL(`file://${id}`); + const pathname = filenameURL.pathname.substr(config.projectRoot.pathname.length - 1); + // Transform from `.astro` to valid `.ts` let { code: tsResult } = await transform(astroResult, { + pathname, projectRoot: config.projectRoot.toString(), site: config.buildOptions.site, sourcefile: id, @@ -122,10 +122,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@astrojs/compiler@^0.6.0": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.6.2.tgz#f9f6d2bfabc70921fa2be9da49767f878a1bc1e4" - integrity sha512-okzco1cwAPC1Fs1EovCckQpZFLAkuysTM+0qVXQ41fE6mLxmq/4i7fFR7l0Wy/0JapgcRQbK5xN4Y08ku4EPQg== +"@astrojs/compiler@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.7.0.tgz#b2c93c0df6cfe84360043918bb581f8cec1fac31" + integrity sha512-lnEdXsGSCMbUfVfXewvYKzTOyHYxsiZHmePNc5YBGjNUvJsFljr4ttMAE958gvwRWltOuaVqc4HuNSx6ylL/hQ== dependencies: typescript "^4.3.5" |