Compare commits

...

1 commit

Author SHA1 Message Date
Nate Moore
80011fb303 feat: add support for incremental builds 2021-11-29 05:25:52 -06:00
5 changed files with 182 additions and 21 deletions

View file

@ -0,0 +1,34 @@
---
'astro': minor
---
👋 Say goodbye to full site rebuilds for small copy changes! Astro now supports **incremental builds**.
`astro build` now accepts a set of output paths that Astro should rebuild.
```bash
# static: rebuild `src/pages/index.astro`
astro build "/"
# dynamic: rebuild `/authors/nate` from `src/pages/authors/[author].astro`
astro build "/authors/nate"
# dynamic: rebuild all `/authors/...` paths from `src/pages/authors/[author].astro`
astro build "/authors/*"
# combined: rebuild `src/pages/index.astro` and `src/pages/authors/[author].astro`
astro build "/" "/authors/*"
```
You can also specify source files that have been updated! Astro can trace these inputs to determine exactly which pages need to be rebuilt. 🎉
```bash
# single page source, builds any paths generated by this file
astro build "src/pages/authors/[author].astro"
# shared component, builds any page that uses this component
astro build "src/components/Navigation.astro"
# multiple files
astro build "src/pages/index.astro" "src/components/Navigation.astro" "src/components/Counter.svelte"
```

View file

@ -196,6 +196,11 @@ export interface ManifestData {
routes: RouteData[]; routes: RouteData[];
} }
export interface BuildManifestData {
routes: RouteData[];
urls: Set<string>;
}
export type MarkdownParser = (contents: string, options?: Record<string, any>) => MarkdownParserResponse | PromiseLike<MarkdownParserResponse>; export type MarkdownParser = (contents: string, options?: Record<string, any>) => MarkdownParserResponse | PromiseLike<MarkdownParserResponse>;
export interface MarkdownParserResponse { export interface MarkdownParserResponse {

View file

@ -99,7 +99,7 @@ export async function cli(args: string[]) {
const flags = yargs(args); const flags = yargs(args);
const state = resolveArgs(flags); const state = resolveArgs(flags);
const options = { ...state.options }; const options = { ...state.options };
const projectRoot = options.projectRoot || flags._[3]; const projectRoot = options.projectRoot;
// logLevel // logLevel
let logging: LogOptions = { let logging: LogOptions = {
@ -143,7 +143,8 @@ export async function cli(args: string[]) {
} }
case 'build': { case 'build': {
try { try {
await build(config, { logging }); const input = flags._.slice(3);
await build(config, { logging, input });
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
throwAndExit(err); throwAndExit(err);

View file

@ -1,4 +1,4 @@
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, RouteCache, RouteData, RSSResult } from '../../@types/astro'; import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, BuildManifestData, RouteCache, RouteData, RSSResult } from '../../@types/astro';
import type { LogOptions } from '../logger'; import type { LogOptions } from '../logger';
import type { AllPagesData } from './types'; import type { AllPagesData } from './types';
import type { RenderedChunk } from 'rollup'; import type { RenderedChunk } from 'rollup';
@ -9,18 +9,20 @@ import fs from 'fs';
import * as colors from 'kleur/colors'; import * as colors from 'kleur/colors';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import vite, { ViteDevServer } from '../vite.js'; import vite, { ViteDevServer } from '../vite.js';
import { fileURLToPath } from 'url'; import { fileURLToPath, pathToFileURL } from 'url';
import { createVite, ViteConfigWithSSR } from '../create-vite.js'; import { createVite, ViteConfigWithSSR } from '../create-vite.js';
import { debug, defaultLogOptions, info, levels, timerMessage, warn } from '../logger.js'; import { debug, defaultLogOptions, info, levels, timerMessage, warn, error } from '../logger.js';
import { preload as ssrPreload } from '../ssr/index.js'; import { preload as ssrPreload } from '../ssr/index.js';
import { generatePaginateFunction } from '../ssr/paginate.js'; import { generatePaginateFunction } from '../ssr/paginate.js';
import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js'; import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js';
import { generateRssFunction } from '../ssr/rss.js'; import { generateRssFunction } from '../ssr/rss.js';
import { generateSitemap } from '../ssr/sitemap.js'; import { generateSitemap } from '../ssr/sitemap.js';
import { viteifyURL, isFile } from '../util.js';
export interface BuildOptions { export interface BuildOptions {
mode?: string; mode?: string;
logging: LogOptions; logging: LogOptions;
input?: string[];
} }
/** `astro build` */ /** `astro build` */
@ -33,6 +35,8 @@ class AstroBuilder {
private config: AstroConfig; private config: AstroConfig;
private logging: LogOptions; private logging: LogOptions;
private mode = 'production'; private mode = 'production';
private input: string[] = [];
private isIncremental: boolean;
private origin: string; private origin: string;
private routeCache: RouteCache = {}; private routeCache: RouteCache = {};
private manifest: ManifestData; private manifest: ManifestData;
@ -40,16 +44,91 @@ class AstroBuilder {
private viteConfig?: ViteConfigWithSSR; private viteConfig?: ViteConfigWithSSR;
constructor(config: AstroConfig, options: BuildOptions) { constructor(config: AstroConfig, options: BuildOptions) {
if (!config.buildOptions.site && config.buildOptions.sitemap !== false) {
warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
}
if (options.mode) this.mode = options.mode; if (options.mode) this.mode = options.mode;
this.config = config; this.config = config;
const port = config.devOptions.port; // no need to save this (dont rely on port in builder) const port = config.devOptions.port; // no need to save this (dont rely on port in builder)
this.logging = options.logging; this.logging = options.logging;
this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${port}`; this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${port}`;
this.manifest = createRouteManifest({ config }, this.logging); this.manifest = createRouteManifest({ config }, this.logging);
this.input = options.input ?? [];
this.isIncremental = this.input.length > 0;
if (!this.isIncremental && !config.buildOptions.site && config.buildOptions.sitemap !== false) {
warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
}
}
private isPage(fileUrl: string|URL): boolean {
const normalizedID = fileURLToPath(fileUrl);
return normalizedID.startsWith(fileURLToPath(this.config.pages));
}
// Build Manifest is a sparse version of Route Manifest.
// It only contains the routes that should be rebuilt for incremental builds.
async getBuildManifest(): Promise<BuildManifestData> {
if (!this.isIncremental) {
return { ...this.manifest, urls: new Set() };
}
const manifest: BuildManifestData = { routes: [], urls: new Set() };
const pages = new Set<string>();
const nonPages = new Set<string>();
await Promise.all(this.input.map(async (input) => {
// Attempt: does this URL pathname match a known route?
const match = this.manifest.routes.find(route => route.pattern.test(input));
if (match) {
pages.add(match.component);
manifest.urls.add(input);
return;
}
// Attempt: input is a likely pathname, let's also try to match /index
if (input.startsWith('/') && input.indexOf('.') === -1) {
const matchIndex = this.manifest.routes.find(route => route.pattern.test(`${input}/index`));
if (matchIndex) {
pages.add(matchIndex.component);
manifest.urls.add(`${input}/`);
return;
}
error(this.logging, '404', `${input}`);
return;
}
// Attempt: this input is a file URL relative to the site root
const fileUrl = new URL(`./${input.replace(/^\.?\//, '')}`, this.config.projectRoot);
if (!isFile(fileUrl)) {
error(this.logging, '404', `${input}`);
return;
}
if (this.isPage(fileUrl)) {
pages.add(fileUrl.toString().replace(this.config.projectRoot.toString(), ''));
} else {
nonPages.add(fileUrl.toString().replace(this.config.projectRoot.toString(), ''))
}
}))
// Run ssrLoadModule for any pages we don't know we have to rebuild
// This will populate the moduleGraph
if (nonPages.size > 0) {
// TODO: use `ssrPreload` instead of calling `ssrLoadModule` directly
await Promise.all(this.manifest.routes.map(route => pages.has(route.component) ? null : this.viteServer?.ssrLoadModule(viteifyURL(new URL(route.component, this.config.projectRoot)))));
await Promise.all(Array.from(nonPages.values()).map(file => {
const fileUrl = new URL(`./${file.replace(/^\.\//, '')}`, this.config.projectRoot);
const module = this.viteServer?.moduleGraph.getModuleById(viteifyURL(fileUrl));
const importers = module?.importers ?? new Set();
for (const importer of importers) {
if (importer.file && this.isPage(pathToFileURL(importer.file))) {
pages.add(pathToFileURL(importer.file).toString().replace(this.config.projectRoot.toString(), ''));
}
}
}))
}
manifest.routes = this.manifest.routes.filter(route => pages.has(route.component))
return manifest;
} }
async build() { async build() {
@ -78,12 +157,20 @@ class AstroBuilder {
timer.loadStart = performance.now(); timer.loadStart = performance.now();
const assets: Record<string, string> = {}; const assets: Record<string, string> = {};
const allPages: AllPagesData = {}; const allPages: AllPagesData = {};
timer.manifestStart = performance.now();
const manifest = await this.getBuildManifest();
debug(logging, 'build', timerMessage('Generated Build Manifest', timer.manifestStart));
if (this.isIncremental && manifest.routes.length === 0) {
process.exit(1);
}
// Collect all routes ahead-of-time, before we start the build. // Collect all routes ahead-of-time, before we start the build.
// NOTE: This enforces that `getStaticPaths()` is only called once per route, // 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 // 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. // with parallelized builds without guaranteeing that this is called first.
await Promise.all( await Promise.all(
this.manifest.routes.map(async (route) => { manifest.routes.map(async (route) => {
// static route: // static route:
if (route.pathname) { if (route.pathname) {
allPages[route.component] = { allPages[route.component] = {
@ -130,9 +217,28 @@ class AstroBuilder {
} }
assets[fileURLToPath(rssFile)] = result.rss.xml; assets[fileURLToPath(rssFile)] = result.rss.xml;
} }
let paths: string[] = [];
if (manifest.urls.size > 0) {
const urls = Array.from(manifest.urls);
paths = result.paths.filter(p => urls.find(url => p.replace(/\/$/, '').startsWith(url.replace(/\/\*?$/, ''))));
} else {
paths = result.paths;
}
if (paths.length === 0) {
if (manifest.urls.size === 1) {
error(this.logging, '404', `${route.component} was matched but \`getStaticPaths\` did not return "${manifest.urls.values().next().value}"`);
} else {
error(this.logging, '404', `${route.component} was matched but \`getStaticPaths\` did not return any of the specified URLs'`);
}
return;
}
allPages[route.component] = { allPages[route.component] = {
route, route,
paths: result.paths, paths,
preload: await ssrPreload({ preload: await ssrPreload({
astroConfig: this.config, astroConfig: this.config,
filePath: new URL(`./${route.component}`, this.config.projectRoot), filePath: new URL(`./${route.component}`, this.config.projectRoot),
@ -149,6 +255,10 @@ class AstroBuilder {
); );
debug(logging, 'build', timerMessage('All pages loaded', timer.loadStart)); debug(logging, 'build', timerMessage('All pages loaded', timer.loadStart));
if (this.isIncremental && Object.keys(allPages).length === 0) {
process.exit(1);
}
// Pure CSS chunks are chunks that only contain CSS. // Pure CSS chunks are chunks that only contain CSS.
// This is all of them, and chunkToReferenceIdMap maps them to a hash id used to find the final file. // This is all of them, and chunkToReferenceIdMap maps them to a hash id used to find the final file.
const pureCSSChunks = new Set<RenderedChunk>(); const pureCSSChunks = new Set<RenderedChunk>();
@ -201,7 +311,7 @@ class AstroBuilder {
}), }),
...(viteConfig.plugins || []), ...(viteConfig.plugins || []),
], ],
publicDir: viteConfig.publicDir, publicDir: this.isIncremental ? false : viteConfig.publicDir,
root: viteConfig.root, root: viteConfig.root,
server: viteConfig.server, server: viteConfig.server,
base: this.config.buildOptions.site ? new URL(this.config.buildOptions.site).pathname : '/', base: this.config.buildOptions.site ? new URL(this.config.buildOptions.site).pathname : '/',
@ -219,6 +329,8 @@ class AstroBuilder {
}); });
debug(logging, 'build', timerMessage('Additional assets copied', timer.assetsStart)); debug(logging, 'build', timerMessage('Additional assets copied', timer.assetsStart));
// TODO: should this be updated incrementally rather than skipped?
if (!this.isIncremental) {
// Build your final sitemap. // Build your final sitemap.
timer.sitemapStart = performance.now(); timer.sitemapStart = performance.now();
if (this.config.buildOptions.sitemap && this.config.buildOptions.site) { if (this.config.buildOptions.sitemap && this.config.buildOptions.site) {
@ -228,6 +340,7 @@ class AstroBuilder {
await fs.promises.writeFile(sitemapPath, sitemap, 'utf8'); await fs.promises.writeFile(sitemapPath, sitemap, 'utf8');
} }
debug(logging, 'build', timerMessage('Sitemap built', timer.sitemapStart)); debug(logging, 'build', timerMessage('Sitemap built', timer.sitemapStart));
}
// You're done! Time to clean up. // You're done! Time to clean up.
await viteServer.close(); await viteServer.close();
@ -259,7 +372,7 @@ class AstroBuilder {
const buildTime = performance.now() - timeStart; const buildTime = performance.now() - timeStart;
const total = buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`; const total = buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`;
const perPage = `${Math.round(buildTime / pageCount)}ms`; const perPage = `${Math.round(buildTime / pageCount)}ms`;
info(logging, 'build', `${pageCount} pages built in ${colors.bold(total)} ${colors.dim(`(${perPage}/page)`)}`); info(logging, 'build', `${pageCount} ${pageCount === 1 ? 'page' : 'pages'} ${this.isIncremental ? 'rebuilt' : 'built'} in ${colors.bold(total)} ${pageCount > 1 ? colors.dim(`(${perPage}/page)`) : ''}`.trim());
info(logging, 'build', `🚀 ${colors.cyan(colors.bold('Done'))}`); info(logging, 'build', `🚀 ${colors.cyan(colors.bold('Done'))}`);
} }
} }

View file

@ -2,6 +2,7 @@ import type { AstroConfig } from '../@types/astro';
import type { ErrorPayload } from 'vite'; import type { ErrorPayload } from 'vite';
import eol from 'eol'; import eol from 'eol';
import path from 'path'; import path from 'path';
import fs from 'fs';
import slash from 'slash'; import slash from 'slash';
import { fileURLToPath, pathToFileURL } from 'url'; import { fileURLToPath, pathToFileURL } from 'url';
import resolve from 'resolve'; import resolve from 'resolve';
@ -83,3 +84,10 @@ export function resolveDependency(dep: string, astroConfig: AstroConfig) {
export function viteifyURL(filePath: URL): string { export function viteifyURL(filePath: URL): string {
return slash(fileURLToPath(filePath)); return slash(fileURLToPath(filePath));
} }
export function isFile(fileUrl: URL): boolean {
try {
return fs.statSync(fileURLToPath(fileUrl)).isFile();
} catch (e) {}
return false;
}