diff --git a/.changeset/soft-sloths-impress.md b/.changeset/soft-sloths-impress.md new file mode 100644 index 000000000000..ff6fb7c3574c --- /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 afbedca119c8..7307346793a4 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 000000000000..599bcf615f51 --- /dev/null +++ b/examples/fast-build/src/components/Counter.vue @@ -0,0 +1,24 @@ + + + diff --git a/examples/fast-build/src/pages/[pokemon].astro b/examples/fast-build/src/pages/[pokemon].astro new file mode 100644 index 000000000000..ea01cc4f75c4 --- /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}})); +} +--- + + + Hello + + + +

{Astro.props.pokemon.name}

+ + + \ 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 2bdadbf5b273..ee228aa193b2 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'; --- @@ -26,9 +27,14 @@ import Greeting from '../components/Greeting.vue'; -
-

ImageTools

- -
- - +
+

ImageTools

+ +
+ +
+

Hydrated component

+ +
+ + \ No newline at end of file diff --git a/packages/astro/package.json b/packages/astro/package.json index 45c24c3d81fa..b069bc4ce86f 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 f6f47cd98b56..2e60da3b7703 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -371,5 +371,6 @@ export interface SSRResult { scripts: Set; links: Set; createAstro(Astro: AstroGlobalPartial, props: Record, slots: Record | null): AstroGlobal; + resolve: (s: string) => Promise; _metadata: SSRMetadata; } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index b30ea7ddf884..ee379f4e3181 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; + + // A mapping of specifiers like astro/client/idle.js to the hashed bundled name. + // Used to render pages with the correct specifiers. + entrySpecifierToBundleMap: Map; } /** @@ -41,5 +45,6 @@ export function createBuildInternals(): BuildInternals { astroStyleMap, astroPageStyleMap, facadeIdToAssetsMap, + entrySpecifierToBundleMap: new Map(), }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 191cdb9870cd..07e97c7caddc 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(); + // The JavaScript entrypoints. - const jsInput: Set = new Set(); + const jsInput = new Set(); // 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) { @@ -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) { + 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 = { 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( + 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, 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(); + 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 b53411b912f5..a739d0175b07 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 { - const _links = new Set( - links.map((href) => ({ - props: { - rel: 'stylesheet', - href, - }, - children: '', - })) - ); - const result: SSRResult = { - styles: new Set(), - scripts: new Set(), - links: _links, - /** This function returns the `Astro` faux-global */ - createAstro(astroGlobal: AstroGlobalPartial, props: Record, slots: Record | 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 but shouldn't be used publicly - privateRenderSlotDoNotUse(slotName: string) { - return renderSlot(result, slots ? slots[slotName] : null); - }, - // 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(), - scripts: new Set(), - links: new Set(), - /** This function returns the `Astro` faux-global */ - createAstro(astroGlobal: AstroGlobalPartial, props: Record, slots: Record | 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 but shouldn't be used publicly - privateRenderSlotDoNotUse(slotName: string) { - return renderSlot(result, slots ? slots[slotName] : null); - }, - // 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 000000000000..32694dd05330 --- /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(), + scripts: new Set(), + links: new Set(), + /** This function returns the `Astro` faux-global */ + createAstro(astroGlobal: AstroGlobalPartial, props: Record, slots: Record | 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 but shouldn't be used publicly + privateRenderSlotDoNotUse(slotName: string) { + return renderSlot(result, slots ? slots[slotName] : null); + }, + // 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 { + 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 fd71287ddb75..f83632bddf7c 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): 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): Ext extracted.props[key] = value; } } + return extracted; } interface HydrateScriptOptions { renderer: any; + result: SSRResult; astroId: string; props: Record; } /** For hydrated components, generate a