summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/young-trainers-rule.md14
-rw-r--r--docs/src/pages/core-concepts/astro-components.md33
-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
-rw-r--r--packages/astro/test/astro-scripts.test.js62
-rw-r--r--packages/astro/test/fixtures/astro-scripts/public/another_external.js2
-rw-r--r--packages/astro/test/fixtures/astro-scripts/public/regular_script.js1
-rw-r--r--packages/astro/test/fixtures/astro-scripts/public/something.js1
-rw-r--r--packages/astro/test/fixtures/astro-scripts/snowpack.config.json3
-rw-r--r--packages/astro/test/fixtures/astro-scripts/src/components/Inline.astro3
-rw-r--r--packages/astro/test/fixtures/astro-scripts/src/components/Widget.astro1
-rw-r--r--packages/astro/test/fixtures/astro-scripts/src/components/Widget2.astro1
-rw-r--r--packages/astro/test/fixtures/astro-scripts/src/pages/external.astro19
-rw-r--r--packages/astro/test/fixtures/astro-scripts/src/pages/inline.astro16
20 files changed, 530 insertions, 12 deletions
diff --git a/.changeset/young-trainers-rule.md b/.changeset/young-trainers-rule.md
new file mode 100644
index 000000000..fb248edeb
--- /dev/null
+++ b/.changeset/young-trainers-rule.md
@@ -0,0 +1,14 @@
+---
+'docs': patch
+'astro': patch
+---
+
+# Hoisted scripts
+
+This change adds support for hoisted scripts, allowing you to bundle scripts together for a page and hoist them to the top (in the head):
+
+```astro
+<script hoist>
+ // Anything goes here!
+</script>
+``` \ No newline at end of file
diff --git a/docs/src/pages/core-concepts/astro-components.md b/docs/src/pages/core-concepts/astro-components.md
index 1faa65d0f..2747b33cb 100644
--- a/docs/src/pages/core-concepts/astro-components.md
+++ b/docs/src/pages/core-concepts/astro-components.md
@@ -275,6 +275,39 @@ const items = ["Dog", "Cat", "Platipus"];
</ul>
```
+### Hoisted scripts
+
+By default Astro does not make any assumptions on how you want scripts to be served, so if you add a `<script>` tag in a page or a component it will be left alone.
+
+However if you'd like all of your scripts to be hoisted out of components and moved to the top of the page, and then later bundled together in production, you can achieve this with hoisted scripts.
+
+A __hoisted script__ looks like this:
+
+```astro
+<script hoist>
+ // An inline script
+</script>
+```
+
+Or it can link to an external JavaScript file:
+
+```astro
+<script src={Astro.resolve('./my-component.js')} hoist></script>
+```
+
+A hoisted script can be within a page or a component, and no matter how many times the component is used, the script will only be added once:
+
+```astro
+---
+import TwitterTimeline from '../components/TwitterTimeline.astro';
+---
+
+<-- The script will only be injected into the head once. -->
+<TwitterTimeline />
+<TwitterTimeline />
+<TwitterTimeline />
+```
+
## Comparing `.astro` versus `.jsx`
`.astro` files can end up looking very similar to `.jsx` files, but there are a few key differences. Here's a comparison between the two formats.
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 {
diff --git a/packages/astro/test/astro-scripts.test.js b/packages/astro/test/astro-scripts.test.js
new file mode 100644
index 000000000..4bc1118df
--- /dev/null
+++ b/packages/astro/test/astro-scripts.test.js
@@ -0,0 +1,62 @@
+import { suite } from 'uvu';
+import * as assert from 'uvu/assert';
+import { setup, setupBuild } from './helpers.js';
+import { doc } from './test-utils.js';
+import path from 'path';
+
+const Scripts = suite('Hoisted scripts');
+
+setup(Scripts, './fixtures/astro-scripts');
+setupBuild(Scripts, './fixtures/astro-scripts');
+
+Scripts('Moves external scripts up', async ({ runtime }) => {
+ const result = await runtime.load('/external');
+ if (result.error) throw new Error(result.error);
+ assert.equal(result.statusCode, 200);
+ const html = result.contents;
+
+ const $ = doc(html);
+ assert.equal($('head script[type="module"][data-astro="hoist"]').length, 2);
+ assert.equal($('body script').length, 0);
+});
+
+Scripts('Moves inline scripts up', async ({ runtime }) => {
+ const result = await runtime.load('/inline');
+ if (result.error) throw new Error(result.error);
+ assert.equal(result.statusCode, 200);
+ const html = result.contents;
+
+ const $ = doc(html);
+ assert.equal($('head script[type="module"][data-astro="hoist"]').length, 1);
+ assert.equal($('body script').length, 0);
+});
+
+Scripts('Builds the scripts to a single bundle', async({ build, readFile }) => {
+ try {
+ await build();
+ } catch(err) {
+ console.error(err.stack);
+ assert.ok(!err);
+ return;
+ }
+
+ /* Inline page */
+ let inline = await readFile('/inline/index.html');
+ let $ = doc(inline);
+ assert.equal($('script').length, 1, 'Just one entry module');
+ assert.equal($('script').attr('data-astro'), undefined, 'attr removed');
+ let entryURL = path.join('inline', $('script').attr('src'));
+ let inlineEntryJS = await readFile(entryURL);
+ assert.ok(inlineEntryJS, 'The JS exists');
+
+ /* External page */
+ let external = await readFile('/external/index.html');
+ $ = doc(external);
+ assert.equal($('script').length, 2, 'There are two scripts');
+ let el = $('script').get(1);
+ entryURL = path.join('external', $(el).attr('src'));
+ let externalEntryJS = await readFile(entryURL);
+ assert.ok(externalEntryJS, 'got JS');
+});
+
+Scripts.run();
diff --git a/packages/astro/test/fixtures/astro-scripts/public/another_external.js b/packages/astro/test/fixtures/astro-scripts/public/another_external.js
new file mode 100644
index 000000000..d0665036b
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/public/another_external.js
@@ -0,0 +1,2 @@
+let variable = 'foo';
+console.log(`${variable} bar`); \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/public/regular_script.js b/packages/astro/test/fixtures/astro-scripts/public/regular_script.js
new file mode 100644
index 000000000..7c457fb56
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/public/regular_script.js
@@ -0,0 +1 @@
+console.log('here i am'); \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/public/something.js b/packages/astro/test/fixtures/astro-scripts/public/something.js
new file mode 100644
index 000000000..f79e0a992
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/public/something.js
@@ -0,0 +1 @@
+console.log('this is a widget'); \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/snowpack.config.json b/packages/astro/test/fixtures/astro-scripts/snowpack.config.json
new file mode 100644
index 000000000..8f034781d
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/snowpack.config.json
@@ -0,0 +1,3 @@
+{
+ "workspaceRoot": "../../../../../"
+}
diff --git a/packages/astro/test/fixtures/astro-scripts/src/components/Inline.astro b/packages/astro/test/fixtures/astro-scripts/src/components/Inline.astro
new file mode 100644
index 000000000..3dac7f270
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/src/components/Inline.astro
@@ -0,0 +1,3 @@
+<script hoist type="module">
+ console.log('some content here.');
+</script> \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/src/components/Widget.astro b/packages/astro/test/fixtures/astro-scripts/src/components/Widget.astro
new file mode 100644
index 000000000..56fff46c4
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/src/components/Widget.astro
@@ -0,0 +1 @@
+<script hoist type="module" src="/something.js"></script> \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/src/components/Widget2.astro b/packages/astro/test/fixtures/astro-scripts/src/components/Widget2.astro
new file mode 100644
index 000000000..a87763ef2
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/src/components/Widget2.astro
@@ -0,0 +1 @@
+<script hoist type="module" src="/another_external.js"></script> \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/src/pages/external.astro b/packages/astro/test/fixtures/astro-scripts/src/pages/external.astro
new file mode 100644
index 000000000..2fbdc02b3
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/src/pages/external.astro
@@ -0,0 +1,19 @@
+---
+import Widget from '../components/Widget.astro';
+import Widget2 from '../components/Widget2.astro';
+---
+
+<html lang="en">
+<head>
+ <title>My Page</title>
+ <script type="module" src="/regular_script.js"></script>
+</head>
+<body>
+ <Widget />
+ <Widget />
+ <Widget />
+ <Widget />
+ <Widget />
+ <Widget2 />
+</body>
+</html> \ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-scripts/src/pages/inline.astro b/packages/astro/test/fixtures/astro-scripts/src/pages/inline.astro
new file mode 100644
index 000000000..e3de6198a
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-scripts/src/pages/inline.astro
@@ -0,0 +1,16 @@
+---
+import Inline from '../components/Inline.astro';
+---
+
+<html lang="en">
+<head>
+ <title>My Page</title>
+</head>
+<body>
+ <Inline />
+ <Inline />
+ <Inline />
+ <Inline />
+ <Inline />
+</body>
+</html> \ No newline at end of file