summaryrefslogtreecommitdiff
path: root/packages/astro/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src')
-rw-r--r--packages/astro/src/@types/astro.ts16
-rw-r--r--packages/astro/src/build.ts28
-rw-r--r--packages/astro/src/build/bundle/js.ts163
-rw-r--r--packages/astro/src/compiler/codegen/index.ts19
-rw-r--r--packages/astro/src/compiler/index.ts13
-rw-r--r--packages/astro/src/compiler/transform/head.ts109
-rw-r--r--packages/astro/src/internal/__astro_hoisted_scripts.ts37
-rw-r--r--packages/astro/src/runtime.ts1
8 files changed, 374 insertions, 12 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 2e4e018c0..0d1f1c0de 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -20,12 +20,24 @@ export interface JsxItem {
jsx: string;
}
+export interface InlineScriptInfo {
+ content: string;
+}
+
+export interface ExternalScriptInfo {
+ src: string;
+}
+
+export type ScriptInfo = InlineScriptInfo | ExternalScriptInfo;
+
export interface TransformResult {
script: string;
imports: string[];
exports: string[];
+ components: string[];
html: string;
css?: string;
+ hoistedScripts: ScriptInfo[];
getStaticPaths?: string;
hasCustomElements: boolean;
customElementCandidates: Map<string, string>;
@@ -56,6 +68,8 @@ export interface BuildFile {
contentType: string;
/** Encoding */
encoding?: 'utf8';
+ /** Extracted scripts */
+ hoistedScripts?: ScriptInfo[];
}
/** Mapping of every URL and its required assets. All URLs are absolute relative to the project. */
@@ -70,6 +84,8 @@ export interface PageDependencies {
css: Set<string>;
/** Images needed for page. Can be loaded via CSS, <link>, or otherwise. */
images: Set<string>;
+ /** Async hoisted Javascript */
+ hoistedJS: Map<string, ScriptInfo>;
}
export interface RSSFunctionArgs {
diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts
index 28c9c90b7..64a7d8d86 100644
--- a/packages/astro/src/build.ts
+++ b/packages/astro/src/build.ts
@@ -7,10 +7,11 @@ import mime from 'mime';
import path from 'path';
import { performance } from 'perf_hooks';
import glob from 'tiny-glob';
+import hash from 'shorthash';
import { fileURLToPath } from 'url';
-import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode } from './@types/astro';
+import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode, ScriptInfo } from './@types/astro';
import { bundleCSS } from './build/bundle/css.js';
-import { bundleJS, collectJSImports } from './build/bundle/js.js';
+import { bundleJS, bundleHoistedJS, collectJSImports } from './build/bundle/js.js';
import { buildStaticPage, getStaticPathsForPage } from './build/page.js';
import { generateSitemap } from './build/sitemap.js';
import { collectBundleStats, logURLStats, mapBundleStatsToURLStats } from './build/stats.js';
@@ -139,6 +140,7 @@ ${stack}
const pageDeps = findDeps(buildState[id].contents as string, {
astroConfig,
srcPath: buildState[id].srcPath,
+ id
});
depTree[id] = pageDeps;
@@ -171,11 +173,12 @@ ${stack}
* Bundle CSS, and anything else that can happen in memory (for now, JS bundling happens after writing to disk)
*/
info(logging, 'build', yellow('! optimizing css...'));
- timer.prebundle = performance.now();
+ timer.prebundleCSS = performance.now();
await Promise.all([
bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => {
- debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundle)}]`);
+ debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundleCSS)}]`);
}),
+ bundleHoistedJS({ buildState, astroConfig, logging, depTree, runtime: astroRuntime, dist: astroConfig.dist })
// TODO: optimize images?
]);
// TODO: minify HTML?
@@ -269,18 +272,31 @@ ${stack}
}
/** Given an HTML string, collect <link> and <img> tags */
-export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL }): PageDependencies {
+export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL, id: string }): PageDependencies {
const pageDeps: PageDependencies = {
js: new Set<string>(),
css: new Set<string>(),
images: new Set<string>(),
+ hoistedJS: new Map<string, ScriptInfo>(),
};
const $ = cheerio.load(html);
$('script').each((_i, el) => {
const src = $(el).attr('src');
- if (src) {
+ const hoist = $(el).attr('data-astro') === 'hoist';
+ if(hoist) {
+ if(src) {
+ pageDeps.hoistedJS.set(src, {
+ src
+ });
+ } else {
+ let content = $(el).html() || '';
+ pageDeps.hoistedJS.set(`astro-virtual:${hash.unique(content)}`, {
+ content
+ });
+ }
+ } else if (src) {
if (isRemoteOrEmbedded(src)) return;
pageDeps.js.add(getDistPath(src, { astroConfig, srcPath }));
} else {
diff --git a/packages/astro/src/build/bundle/js.ts b/packages/astro/src/build/bundle/js.ts
index 4deecb30a..dfab05b1d 100644
--- a/packages/astro/src/build/bundle/js.ts
+++ b/packages/astro/src/build/bundle/js.ts
@@ -1,12 +1,15 @@
import type { InputOptions, OutputOptions, OutputChunk } from 'rollup';
-import type { BuildOutput } from '../../@types/astro';
+import type { AstroConfig, BundleMap, BuildOutput, ScriptInfo, InlineScriptInfo } from '../../@types/astro';
import type { AstroRuntime } from '../../runtime';
+import type { LogOptions } from '../../logger.js';
import { fileURLToPath } from 'url';
import { rollup } from 'rollup';
import { terser } from 'rollup-plugin-terser';
-import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js';
+import { createBundleStats, addBundleStats, BundleStatsMap } from '../stats.js'
import { IS_ASTRO_FILE_URL } from '../util.js';
+import cheerio from 'cheerio';
+import path from 'path';
interface BundleOptions {
dist: URL;
@@ -22,6 +25,161 @@ export function collectJSImports(buildState: BuildOutput): Set<string> {
return imports;
}
+function pageUrlToVirtualJSEntry(pageUrl: string) {
+ return 'astro-virtual:' + pageUrl.replace(/.html$/, '').replace(/^\./, '') + '.js';
+}
+
+export async function bundleHoistedJS({
+ buildState,
+ astroConfig,
+ logging,
+ depTree,
+ dist,
+ runtime
+}: {
+ astroConfig: AstroConfig;
+ buildState: BuildOutput;
+ logging: LogOptions;
+ depTree: BundleMap;
+ dist: URL;
+ runtime: AstroRuntime;
+}) {
+ const sortedPages = Object.keys(depTree); // these were scanned in parallel; sort to create somewhat deterministic order
+ sortedPages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
+
+ /**
+ * 1. Go over sorted pages and create a virtual module for all of its dependencies
+ */
+ const entryImports: string[] = [];
+ const virtualScripts = new Map<string, ScriptInfo>();
+ const pageToEntryMap = new Map<string, string>();
+
+ for(let pageUrl of sortedPages) {
+ const hoistedJS = depTree[pageUrl].hoistedJS;
+ if(hoistedJS.size) {
+ for(let [url, scriptInfo] of hoistedJS) {
+ if(virtualScripts.has(url) || !url.startsWith('astro-virtual:')) continue;
+ virtualScripts.set(url, scriptInfo);
+ }
+ const entryURL = pageUrlToVirtualJSEntry(pageUrl);
+ const entryJS = Array.from(hoistedJS.keys()).map(url => `import '${url}';`).join('\n');
+ virtualScripts.set(entryURL, {
+ content: entryJS
+ });
+ entryImports.push(entryURL);
+ pageToEntryMap.set(pageUrl, entryURL);
+ }
+ }
+
+ if(!entryImports.length) {
+ // There are no hoisted scripts, bail
+ return;
+ }
+
+ /**
+ * 2. Run the bundle to bundle each pages JS into a single bundle (with shared content)
+ */
+ const inputOptions: InputOptions = {
+ input: entryImports,
+ plugins: [
+ {
+ name: 'astro:build',
+ resolveId(source: string, imported?: string) {
+ if(virtualScripts.has(source)) {
+ return source;
+ }
+ if (source.startsWith('/')) {
+ return source;
+ }
+
+
+ if (imported) {
+ const outUrl = new URL(source, 'http://example.com' + imported);
+ return outUrl.pathname;
+ }
+
+ return null;
+ },
+ async load(id: string) {
+ if(virtualScripts.has(id)) {
+ let info = virtualScripts.get(id) as InlineScriptInfo;
+ return info.content;
+ }
+
+
+ const result = await runtime.load(id);
+
+ if (result.statusCode !== 200) {
+ return null;
+ }
+
+ return result.contents.toString('utf-8');
+ },
+ },
+ ],
+ };
+
+ const build = await rollup(inputOptions);
+
+ const outputOptions: OutputOptions = {
+ dir: fileURLToPath(dist),
+ format: 'esm',
+ exports: 'named',
+ entryFileNames(chunk) {
+ const { facadeModuleId } = chunk;
+ if (!facadeModuleId) throw new Error(`facadeModuleId missing: ${chunk.name}`);
+ return facadeModuleId.substr('astro-virtual:/'.length, facadeModuleId.length - 'astro-virtual:/'.length - 3 /* .js */)
+ + '-[hash].js';
+ },
+ plugins: [
+ // We are using terser for the demo, but might switch to something else long term
+ // Look into that rather than adding options here.
+ terser(),
+ ],
+ };
+
+ const { output } = await build.write(outputOptions);
+
+ /**
+ * 3. Get a mapping of the virtual filename to the chunk file name
+ */
+ const entryToChunkFileName = new Map<string, string>();
+ output.forEach((chunk) => {
+ const { fileName, facadeModuleId, isEntry } = chunk as OutputChunk;
+ if(!facadeModuleId || !isEntry) return;
+ entryToChunkFileName.set(facadeModuleId, fileName);
+ });
+
+ /**
+ * 4. Update the original HTML with the new chunk scripts
+ */
+ Object.keys(buildState).forEach((id) => {
+ if (buildState[id].contentType !== 'text/html') return;
+
+ const entryVirtualURL = pageUrlToVirtualJSEntry(id);
+ let hasHoisted = false;
+ const $ = cheerio.load(buildState[id].contents);
+ $('script[data-astro="hoist"]').each((i, el) => {
+ hasHoisted = true;
+ if(i === 0) {
+ let chunkName = entryToChunkFileName.get(entryVirtualURL);
+ if (!chunkName) return;
+ let chunkPathname = '/' + chunkName;
+ let relLink = path.relative(path.dirname(id), chunkPathname);
+ $(el).attr('src', relLink.startsWith('.') ? relLink : './' + relLink);
+ $(el).removeAttr('data-astro');
+ $(el).html('');
+ } else {
+ $(el).remove();
+ }
+ });
+
+ if(hasHoisted) {
+ (buildState[id] as any).contents = $.html(); // save updated HTML in global buildState
+ }
+ });
+}
+
/** Bundle JS action */
export async function bundleJS(imports: Set<string>, { astroRuntime, dist }: BundleOptions): Promise<BundleStatsMap> {
const ROOT = 'astro:root';
@@ -42,6 +200,7 @@ export async function bundleJS(imports: Set<string>, { astroRuntime, dist }: Bun
if (source.startsWith('/')) {
return source;
}
+
if (imported) {
const outUrl = new URL(source, 'http://example.com' + imported);
diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts
index 446fa760b..f915a39bd 100644
--- a/packages/astro/src/compiler/codegen/index.ts
+++ b/packages/astro/src/compiler/codegen/index.ts
@@ -1,6 +1,6 @@
import type { Ast, Script, Style, TemplateNode, Expression } from '@astrojs/parser';
import type { CompileOptions } from '../../@types/compiler';
-import type { AstroConfig, TransformResult, ComponentInfo, Components } from '../../@types/astro';
+import type { AstroConfig, TransformResult, ComponentInfo, Components, ScriptInfo } from '../../@types/astro';
import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types';
import type { Attribute } from './interfaces';
import eslexer from 'es-module-lexer';
@@ -316,6 +316,7 @@ interface CompileResult {
interface CodegenState {
components: Components;
css: string[];
+ hoistedScripts: ScriptInfo[];
filename: string;
fileID: string;
markers: {
@@ -672,6 +673,19 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
+ if(attributes.hoist) {
+ if(attributes.src) {
+ state.hoistedScripts.push({
+ src: attributes.src.substr(1, attributes.src.length - 2)
+ });
+ } else if(node.children && node.children.length === 1 && node.children[0].type === 'Text') {
+ state.hoistedScripts.push({
+ content: node.children[0].data
+ });
+ }
+ this.skip();
+ return;
+ }
buffers[curr] += `h("${name}", ${generateAttributes(attributes)},`;
paren++;
return;
@@ -887,6 +901,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
fileID,
components: new Map(),
css: [],
+ hoistedScripts: [],
markers: {
insideMarkdown: false,
},
@@ -909,6 +924,8 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
exports: Array.from(state.exportStatements),
html,
css: state.css.length ? state.css.join('\n\n') : undefined,
+ hoistedScripts: state.hoistedScripts,
+ components: Array.from(state.components.keys()),
getStaticPaths,
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
customElementCandidates: state.customElementCandidates,
diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts
index ede2a62f2..6409c5825 100644
--- a/packages/astro/src/compiler/index.ts
+++ b/packages/astro/src/compiler/index.ts
@@ -153,6 +153,9 @@ ${result.getStaticPaths || ''}
// \`__render()\`: Render the contents of the Astro module.
import { h, Fragment } from 'astro/dist/internal/h.js';
+import { __astro_hoisted_scripts } from 'astro/dist/internal/__astro_hoisted_scripts.js';
+
+const __astroScripts = __astro_hoisted_scripts([${result.components.map(n => `typeof ${n} !== 'undefined' && ${n}`)}], ${JSON.stringify(result.hoistedScripts)});
const __astroInternal = Symbol('astro.internal');
const __astroContext = Symbol.for('astro.context');
async function __render(props, ...children) {
@@ -165,6 +168,10 @@ async function __render(props, ...children) {
value: (props[__astroContext] && props[__astroContext].pageCSS) || [],
enumerable: true
},
+ pageScripts: {
+ value: (props[__astroContext] && props[__astroContext].pageScripts) || [],
+ enumerable: true
+ },
isPage: {
value: (props[__astroInternal] && props[__astroInternal].isPage) || false,
enumerable: true
@@ -178,11 +185,11 @@ async function __render(props, ...children) {
${result.script}
return h(Fragment, null, ${result.html});
}
-export default { isAstroComponent: true, __render };
+export default { isAstroComponent: true, __render, [Symbol.for('astro.hoistedScripts')]: __astroScripts };
// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow,
// triggered by loading a component directly by URL.
-export async function __renderPage({request, children, props, css}) {
+export async function __renderPage({request, children, props, css, scripts}) {
const currentChild = {
isAstroComponent: true,
layout: typeof __layout === 'undefined' ? undefined : __layout,
@@ -198,6 +205,7 @@ export async function __renderPage({request, children, props, css}) {
pageCSS: css,
request,
createAstroRootUID(seed) { return seed + astroRootUIDCounter++; },
+ pageScripts: scripts,
},
writable: false,
enumerable: false
@@ -227,7 +235,6 @@ export async function __renderPage({request, children, props, css}) {
};
${result.exports.join('\n')}
-
`;
return {
diff --git a/packages/astro/src/compiler/transform/head.ts b/packages/astro/src/compiler/transform/head.ts
index f277b56f1..ff707547a 100644
--- a/packages/astro/src/compiler/transform/head.ts
+++ b/packages/astro/src/compiler/transform/head.ts
@@ -115,6 +115,115 @@ export default function (opts: TransformOptions): Transformer {
},
],
},
+ {
+ start: 0,
+ end: 0,
+ type: 'Expression',
+ codeChunks: ['Astro.pageScripts.map(script => (', '))'],
+ children: [
+ {
+ start: 0,
+ end: 0,
+ type: 'Expression',
+ codeChunks: ['script.src ? (', ') : (', ')'],
+ children: [
+ {
+ type: 'Element',
+ name: 'script',
+ attributes: [
+ {
+ type: 'Attribute',
+ name: 'type',
+ value: [
+ {
+ type: 'Text',
+ raw: 'module',
+ data: 'module'
+ }
+ ]
+ },
+ {
+ type: 'Attribute',
+ name: 'src',
+ value: [
+ {
+ start: 0,
+ end: 0,
+ type: 'MustacheTag',
+ expression: {
+ start: 0,
+ end: 0,
+ type: 'Expression',
+ codeChunks: ['script.src'],
+ children: [],
+ },
+ }
+ ]
+ },
+ {
+ type: 'Attribute',
+ name: 'data-astro',
+ value: [
+ {
+ type: 'Text',
+ raw: 'hoist',
+ data: 'hoist'
+ }
+ ]
+ }
+ ],
+ start: 0,
+ end: 0,
+ children: [],
+ },
+ {
+ type: 'Element',
+ name: 'script',
+ attributes: [
+ {
+ type: 'Attribute',
+ name: 'type',
+ value: [
+ {
+ type: 'Text',
+ raw: 'module',
+ data: 'module'
+ }
+ ]
+ },
+ {
+ type: 'Attribute',
+ name: 'data-astro',
+ value: [
+ {
+ type: 'Text',
+ raw: 'hoist',
+ data: 'hoist'
+ }
+ ]
+ }
+ ],
+ start: 0,
+ end: 0,
+ children: [
+ {
+ start: 0,
+ end: 0,
+ type: 'MustacheTag',
+ expression: {
+ start: 0,
+ end: 0,
+ type: 'Expression',
+ codeChunks: ['script.content'],
+ children: [],
+ },
+ }
+ ],
+ },
+ ]
+ }
+ ],
+ },
],
});
diff --git a/packages/astro/src/internal/__astro_hoisted_scripts.ts b/packages/astro/src/internal/__astro_hoisted_scripts.ts
new file mode 100644
index 000000000..4899ca60b
--- /dev/null
+++ b/packages/astro/src/internal/__astro_hoisted_scripts.ts
@@ -0,0 +1,37 @@
+import type { ScriptInfo } from '../@types/astro';
+
+const sym = Symbol.for('astro.hoistedScripts');
+
+interface ComponentThatMaybeHasHoistedScripts {
+ [sym]: ScriptInfo[]
+}
+
+/**
+ * Takes all of the components this component uses and combines them with its
+ * own scripts and flattens it to a deduped list.
+ * The page component will have an array of all scripts used by all child components and itself.
+ */
+function hoistedScripts(Components: ComponentThatMaybeHasHoistedScripts[], scripts: ScriptInfo[]) {
+ const flatScripts = [];
+
+ const allScripts: ScriptInfo[] = Components.map(c => c && c[sym])
+ .filter(a => a)
+ .concat(scripts)
+ .flatMap(a => a);
+
+ const visitedSource = new Set();
+ for(let script of allScripts) {
+ if(!('src' in script)) {
+ flatScripts.push(script);
+ } else if(!visitedSource.has(script.src)) {
+ flatScripts.push(script);
+ visitedSource.add(script.src);
+ }
+ }
+
+ return flatScripts;
+}
+
+export {
+ hoistedScripts as __astro_hoisted_scripts
+}; \ No newline at end of file
diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts
index 4145753ed..ed8f96e9e 100644
--- a/packages/astro/src/runtime.ts
+++ b/packages/astro/src/runtime.ts
@@ -163,6 +163,7 @@ async function load(config: AstroRuntimeConfig, rawPathname: string | undefined)
children: [],
props: pageProps,
css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : [],
+ scripts: mod.exports.default[Symbol.for('astro.hoistedScripts')]
})) as string;
return {