diff --git a/docs/src/pages/reference/renderer-reference.md b/docs/src/pages/reference/renderer-reference.md index 15c90a3c2..cf0b37fd9 100644 --- a/docs/src/pages/reference/renderer-reference.md +++ b/docs/src/pages/reference/renderer-reference.md @@ -38,8 +38,8 @@ A renderer should include any framework dependencies as package dependencies. Fo // package.json "name": "@astrojs/renderer-react", "dependencies": { - "react": "^17.0.0", - "react-dom": "^17.0.0" + "react": "^17.0.2", + "react-dom": "^17.0.2" } ``` @@ -56,8 +56,6 @@ export default { name: '@astrojs/renderer-xxx', // the renderer name client: './client.js', // relative path to the client entrypoint server: './server.js', // optional, relative path to the server entrypoint - snowpackPlugin: '@snowpack/plugin-xxx', // optional, the name of a snowpack plugin to inject - snowpackPluginOptions: { example: true }, // optional, any options to be forwarded to the snowpack plugin knownEntrypoint: ['framework'], // optional, entrypoint modules that will be used by compiled source external: ['dep'], // optional, dependencies that should not be built by snowpack polyfills: ['./shadow-dom-polyfill.js'], // optional, module scripts that should be loaded before client hydration. @@ -72,6 +70,7 @@ export default { plugins: [jsx({}, { runtime: 'automatic', importSource: 'preact' })], }; }, + vitePlugins: [], // optional, inject Vite plugins here (https://vitejs.dev/plugins/#plugins) }; ``` diff --git a/examples/blog-multiple-authors/src/components/MainHead.astro b/examples/blog-multiple-authors/src/components/MainHead.astro index 0392f6d94..3e56774e0 100644 --- a/examples/blog-multiple-authors/src/components/MainHead.astro +++ b/examples/blog-multiple-authors/src/components/MainHead.astro @@ -18,7 +18,7 @@ const { title, description, image, type, next, prev, canonicalURL } = Astro.prop - + diff --git a/examples/blog/src/pages/posts/introducing-astro.md b/examples/blog/src/pages/posts/introducing-astro.md deleted file mode 100644 index 114161cd9..000000000 --- a/examples/blog/src/pages/posts/introducing-astro.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: 'Introducing Astro: Ship Less JavaScript' -description: "We're excited to announce Astro as a new way to build static websites and deliver lightning-fast performance without sacrificing a modern developer experience." -publishDate: 'Tuesday, June 8 2021' -author: 'fred' -heroImage: '/social.jpg' -alt: 'Astro' -layout: '../../layouts/BlogPost.astro' ---- - -There's a simple secret to building a faster website — _just ship less_. - -Unfortunately, modern web development has been trending in the opposite direction—towards _more._ More JavaScript, more features, more moving parts, and ultimately more complexity needed to keep it all running smoothly. - -Today I'm excited to publicly share Astro: a new kind of static site builder that delivers lightning-fast performance with a modern developer experience. To design Astro, we borrowed the best parts of our favorite tools and then added a few innovations of our own, including: - -- **Bring Your Own Framework (BYOF):** Build your site using React, Svelte, Vue, Preact, web components, or just plain ol' HTML + JavaScript. -- **100% Static HTML, No JS:** Astro renders your entire page to static HTML, removing all JavaScript from your final build by default. -- **On-Demand Components:** Need some JS? Astro can automatically hydrate interactive components when they become visible on the page. If the user never sees it, they never load it. -- **Fully-Featured:** Astro supports TypeScript, Scoped CSS, CSS Modules, Sass, Tailwind, Markdown, MDX, and any of your favorite npm packages. -- **SEO Enabled:** Automatic sitemaps, RSS feeds, pagination and collections take the pain out of SEO and syndication. - -This post marks the first public beta release of Astro. **Missing features and bugs are still to be expected at this early stage.** There are still some months to go before an official 1.0 release, but there are already several fast sites built with Astro in production today. We would love your early feedback as we move towards a v1.0 release later this year. - -> To learn more about Astro and start building your first site, check out [the project README.](https://github.com/snowpackjs/astro#-guides). - -## Getting Started - -Starting a new project in Astro is easy: - -```shell -# create your project -mkdir new-project-directory -cd new-project-directory -npm init astro - -# install your dependencies -npm install - -# start the dev server and open your browser -npm run dev -``` - -> To learn more about Astro and start building your first site, check out [the project README.](https://github.com/snowpackjs/astro#-guides). - -## How Astro Works - -Astro works a lot like a static site generator. If you have ever used Eleventy, Hugo, or Jekyll (or even a server-side web framework like Rails, Laravel, or Django) then you should feel right at home with Astro. - -In Astro, you compose your website using UI components from your favorite JavaScript web framework (React, Svelte, Vue, etc). Astro renders your entire site to static HTML during the build. The result is a fully static website with all JavaScript removed from the final page. No monolithic JavaScript application required, just static HTML that loads as fast as possible in the browser regardless of how many UI components you used to generate it. - -Of course, sometimes client-side JavaScript is inevitable. Image carousels, shopping carts, and auto-complete search bars are just a few examples of things that require some JavaScript to run in the browser. This is where Astro really shines: When a component needs some JavaScript, Astro only loads that one component (and any dependencies). The rest of your site continues to exist as static, lightweight HTML. - -In other full-stack web frameworks this level of per-component optimization would be impossible without loading the entire page in JavaScript, delaying interactivity. In Astro, this kind of [partial hydration](https://addyosmani.com/blog/rehydration/) is built into the tool itself. - -You can even [automatically defer components](https://codepen.io/jonneal/full/ZELvMvw) to only load once they become visible on the page with the `client:visible` directive. - -This new approach to web architecture is called [islands architecture](https://jasonformat.com/islands-architecture/). We didn't coin the term, but Astro may have perfected the technique. We are confident that an HTML-first, JavaScript-only-as-needed approach is the best solution for the majority of content-based websites. - -> To learn more about Astro and start building your first site, check out [the project README.](https://github.com/snowpackjs/astro#-guides) - -## Embracing the Pit of Success - -> A well-designed system makes it easy to do the right things and annoying (but not impossible) to do the wrong things

– Jeff Atwood

[Falling Into The Pit of Success](https://blog.codinghorror.com/falling-into-the-pit-of-success/)
- -Poor performance is often framed as a failure of the developer, but we respectfully disagree. In many cases, poor performance is a failure of tooling. It should be difficult to build a slow website. - -Astro's main design principle is to lead developers into what [Rico Mariani](https://twitter.com/ricomariani) dubbed "the pit of success". It is our goal to build every site "fast by default" while also delivering a familiar, modern developer experience. - -By building your site to static HTML by default, Astro makes it difficult (but never impossible 😉) to build a slow site. - -## Long-Term Sustainability - -Astro is built by the team of open source developers behind [Snowpack](https://snowpack.dev) and [Skypack](https://skypack.dev), with additional contributions from the community. - -**Astro is and always will be free.** It is an open source project released under the [MIT license](https://github.com/snowpackjs/astro/blob/main/LICENSE). - -We care deeply about building a more sustainable future for open source software. At the same time, we need to support Astro's development long-term. This requires money (donations alone aren't enough.) - -We're inspired by the early success of projects like [Tailwind](https://tailwindcss.com/), [Rome](https://rome.tools/), [Remix](https://remix.run/), [Ionic](https://ionicframework.com/), and others who are experimenting with long-term financial sustainability on top of Open Source. Over the next year we'll be exploring how we can create a sustainable business to support a 100% free, open source Astro for years to come. - -If your company is as excited about Astro as we are, [we'd love to hear from you.](https://astro.build/chat) - -Finally, I'd like to give a **HUGE** thanks to the 300+ developers who joined our earliest private beta. Your feedback has been essential in shaping Astro into the tool it is today. If you're interested in getting involved (or just following along with development) please [join us on Discord.](https://astro.build/chat) - -> To learn more about Astro and start building your first site, check out [the project README.](https://github.com/snowpackjs/astro#-guides) diff --git a/package.json b/package.json index 63c408091..cc71dc224 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:templates": "lerna run test --scope create-astro --stream" }, "workspaces": [ + "compiled/*", "packages/renderers/*", "packages/*", "examples/*", @@ -44,7 +45,6 @@ "devDependencies": { "@changesets/cli": "^2.16.0", "@octokit/action": "^3.15.4", - "@snowpack/plugin-postcss": "^1.4.3", "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.18.0", "autoprefixer": "^10.2.6", @@ -57,12 +57,9 @@ "eslint-plugin-prettier": "^3.4.0", "execa": "^5.0.0", "lerna": "^4.0.0", - "postcss": "^8.2.15", - "postcss-icss-keyframes": "^0.2.1", - "prettier": "^2.2.1", - "svelte": "^3.38.0", + "prettier": "^2.3.2", "tiny-glob": "^0.2.8", - "typescript": "^4.2.4", + "typescript": "^4.4.2", "uvu": "^0.5.1" }, "engines": { diff --git a/packages/astro/astro.js b/packages/astro/astro.js index c5dec5f66..7725c1366 100755 --- a/packages/astro/astro.js +++ b/packages/astro/astro.js @@ -13,6 +13,7 @@ const CI_INTRUCTIONS = { VERCEL: 'https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version', }; +/** `astro *` */ async function main() { // Check for ESM support. // Load the "supports-esm" package in an way that works in both ESM & CJS. @@ -29,7 +30,7 @@ async function main() { // Preflight check complete. Enjoy! ✨ if (supportsESM) { - return import('./dist/cli.js') + return import('./dist/cli/index.js') .then(({ cli }) => cli(process.argv)) .catch((error) => { console.error(error); diff --git a/packages/astro/components/Markdown.astro b/packages/astro/components/Markdown.astro index b2639804d..609d015d4 100644 --- a/packages/astro/components/Markdown.astro +++ b/packages/astro/components/Markdown.astro @@ -1,5 +1,24 @@ ---- + +import fetch from 'node-fetch'; import { renderMarkdown } from '@astrojs/markdown-support'; +import { __astro_slot } from 'astro/runtime/__astro_slot.js'; + +if(!('fetch' in globalThis)) { + globalThis.fetch = fetch; +} + + +const __TopLevelAstro = { + site: new URL("http://localhost:3000"), + fetchContent: (globResult) => fetchContent(globResult, import.meta.url), + resolve(...segments) { + return segments.reduce( + (url, segment) => new URL(segment, url), + new URL("http://localhost:3000/packages/astro/components/Markdown.astro") + ).pathname + }, +}; +const Astro = __TopLevelAstro; export interface Props { content?: string; @@ -13,10 +32,43 @@ interface InternalProps extends Props { const { content, $scope } = Astro.props as InternalProps; let html = null; -// This flow is only triggered if a user passes `` + + + +// `__render()`: Render the contents of the Astro module. +import { h, Fragment } from 'astro/runtime/h.js'; +const __astroInternal = Symbol('astro.internal'); +const __astroContext = Symbol.for('astro.context'); +async function __render(props, ...children) { + const Astro = Object.create(__TopLevelAstro, { + props: { + value: props, + enumerable: true + }, + pageCSS: { + value: (props[__astroContext] && props[__astroContext].pageCSS) || [], + enumerable: true + }, + isPage: { + value: (props[__astroInternal] && props[__astroInternal].isPage) || false, + enumerable: true + }, + request: { + value: (props[__astroContext] && props[__astroContext].request) || {}, + enumerable: true + }, + }); + + const { + content, + $scope +} = Astro.props; +let html = null; if (content) { - const { content: htmlContent } = await renderMarkdown(content, { - mode: 'md', + const { + content: htmlContent + } = await renderMarkdown(content, { + mode: "md", $: { scopedClassName: $scope } @@ -24,9 +76,55 @@ if (content) { html = htmlContent; } -/* - If we have rendered `html` for `content`, render that - Otherwise, just render the slotted content -*/ ---- -{html ? html : } + return h(Fragment, null, h(Fragment, null,(html ? html : h(Fragment, null, h(__astro_slot, { [__astroContext]: props[__astroContext] }, children))))); +} +export default { isAstroComponent: true, __render }; + +// `__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}) { + const currentChild = { + isAstroComponent: true, + layout: typeof __layout === 'undefined' ? undefined : __layout, + content: typeof __content === 'undefined' ? undefined : __content, + __render, + }; + + const isLayout = (__astroContext in props); + if(!isLayout) { + let astroRootUIDCounter = 0; + Object.defineProperty(props, __astroContext, { + value: { + pageCSS: css, + request, + createAstroRootUID(seed) { return seed + astroRootUIDCounter++; }, + }, + writable: false, + enumerable: false + }); + } + + Object.defineProperty(props, __astroInternal, { + value: { + isPage: !isLayout + }, + writable: false, + enumerable: false + }); + + const childBodyResult = await currentChild.__render(props, children); + + // find layout, if one was given. + if (currentChild.layout) { + return currentChild.layout({ + request, + props: {content: currentChild.content, [__astroContext]: props[__astroContext]}, + children: [childBodyResult], + }); + } + + return childBodyResult; +}; + + + diff --git a/packages/astro/components/Prism.astro b/packages/astro/components/Prism.astro index ba4b22ec3..5942a0428 100644 --- a/packages/astro/components/Prism.astro +++ b/packages/astro/components/Prism.astro @@ -1,7 +1,12 @@ ---- -import Prism from 'prismjs'; -import { addAstro } from '@astrojs/prism'; + +import fetch from 'node-fetch'; import loadLanguages from 'prismjs/components/index.js'; +import { addAstro } from '@astrojs/prism'; +import Prism from 'prismjs'; + +if(!('fetch' in globalThis)) { + globalThis.fetch = fetch; +} export interface Props { class?: string; @@ -13,40 +18,128 @@ const { class: className, lang, code } = Astro.props as Props; let classLanguage = `language-${lang}` -const languageMap = new Map([ - ['ts', 'typescript'] -]); +const __TopLevelAstro = { + site: new URL("http://localhost:3000"), + fetchContent: (globResult) => fetchContent(globResult, import.meta.url), + resolve(...segments) { + return segments.reduce( + (url, segment) => new URL(segment, url), + new URL("http://localhost:3000/packages/astro/components/Prism.astro") + ).pathname + }, +}; +const Astro = __TopLevelAstro; + + + + +// `__render()`: Render the contents of the Astro module. +import { h, Fragment } from 'astro/runtime/h.js'; +const __astroInternal = Symbol('astro.internal'); +const __astroContext = Symbol.for('astro.context'); +async function __render(props, ...children) { + const Astro = Object.create(__TopLevelAstro, { + props: { + value: props, + enumerable: true + }, + pageCSS: { + value: (props[__astroContext] && props[__astroContext].pageCSS) || [], + enumerable: true + }, + isPage: { + value: (props[__astroInternal] && props[__astroInternal].isPage) || false, + enumerable: true + }, + request: { + value: (props[__astroContext] && props[__astroContext].request) || {}, + enumerable: true + }, + }); + + const { + class: className, + lang, + code +} = Astro.props; +let classLanguage = `language-${lang}`; +const languageMap = new Map([["ts", "typescript"]]); if (lang == null) { - console.warn('Prism.astro: No language provided.'); + console.warn("Prism.astro: No language provided."); } - -const ensureLoaded = lang => { - if(lang && !Prism.languages[lang]) { - loadLanguages([lang]); +const ensureLoaded = (lang2) => { + if (lang2 && !Prism.languages[lang2]) { + loadLanguages([lang2]); } }; - -if(languageMap.has(lang)) { +if (languageMap.has(lang)) { ensureLoaded(languageMap.get(lang)); -} else if(lang === 'astro') { - ensureLoaded('typescript'); +} else if (lang === "astro") { + ensureLoaded("typescript"); addAstro(Prism); } else { - ensureLoaded('markup-templating'); // Prism expects this to exist for a number of other langs + ensureLoaded("markup-templating"); ensureLoaded(lang); } - -if(lang && !Prism.languages[lang]) { +if (lang && !Prism.languages[lang]) { console.warn(`Unable to load the language: ${lang}`); } - const grammar = Prism.languages[lang]; let html = code; if (grammar) { html = Prism.highlight(code, grammar, lang); } ---- + return h(Fragment, null, h(Fragment, null,h("pre", {"class":([className, classLanguage].join(" ")),[__astroContext]:props[__astroContext]},h("code", {"class":(classLanguage),[__astroContext]:props[__astroContext]},(html))))); +} +export default { isAstroComponent: true, __render }; + +// `__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}) { + const currentChild = { + isAstroComponent: true, + layout: typeof __layout === 'undefined' ? undefined : __layout, + content: typeof __content === 'undefined' ? undefined : __content, + __render, + }; + + const isLayout = (__astroContext in props); + if(!isLayout) { + let astroRootUIDCounter = 0; + Object.defineProperty(props, __astroContext, { + value: { + pageCSS: css, + request, + createAstroRootUID(seed) { return seed + astroRootUIDCounter++; }, + }, + writable: false, + enumerable: false + }); + } + + Object.defineProperty(props, __astroInternal, { + value: { + isPage: !isLayout + }, + writable: false, + enumerable: false + }); + + const childBodyResult = await currentChild.__render(props, children); + + // find layout, if one was given. + if (currentChild.layout) { + return currentChild.layout({ + request, + props: {content: currentChild.content, [__astroContext]: props[__astroContext]}, + children: [childBodyResult], + }); + } + + return childBodyResult; +}; + + -
{html}
diff --git a/packages/astro/internal.d.ts b/packages/astro/internal.d.ts deleted file mode 100644 index 40b65ffb3..000000000 --- a/packages/astro/internal.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module '#astro/compiler' { - export * from 'astro/dist/types/compiler'; -} -declare module '#astro/ast' { - export * from 'astro/dist/types/ast'; -} -declare module '#astro/build' { - export * from 'astro/dist/types/build'; -} -declare module '#astro/cli' { - export * from 'astro/dist/types/cli'; -} -declare module '#astro/config' { - export * from 'astro/dist/types/config'; -} -declare module '#astro/dev' { - export * from 'astro/dist/types/dev'; -} -declare module '#astro/logger' { - export * from 'astro/dist/types/logger'; -} -declare module '#astro/runtime' { - export * from 'astro/dist/types/runtime'; -} -declare module '#astro/search' { - export * from 'astro/dist/types/search'; -} diff --git a/packages/astro/package.json b/packages/astro/package.json index 718cd938f..77b1499dc 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -12,18 +12,14 @@ }, "exports": { ".": "./astro.js", - "./package.json": "./package.json", - "./snowpack-plugin": "./snowpack-plugin.cjs", - "./snowpack-plugin-jsx": "./snowpack-plugin-jsx.cjs", + "./client/*": "./dist/client/*", "./components": "./components/index.js", "./debug": "./components/Debug.astro", "./components/*": "./components/*", - "./runtime/svelte": "./dist/frontend/runtime/svelte.js", - "./internal/*": "./dist/internal/*", - "./dist/internal/*": "./dist/internal/*" + "./package.json": "./package.json", + "./runtime/*": "./dist/runtime/*" }, "imports": { - "#astro/compiler": "./dist/compiler/index.js", "#astro/*": "./dist/*.js" }, "bin": { @@ -32,8 +28,6 @@ "files": [ "components", "dist", - "snowpack-plugin-jsx.cjs", - "snowpack-plugin.cjs", "astro.js" ], "scripts": { @@ -44,81 +38,46 @@ "test": "uvu test -i fixtures -i benchmark -i test-utils.js" }, "dependencies": { - "@astrojs/markdown-support": "0.3.1", - "@astrojs/parser": "0.20.2", - "@astrojs/prism": "0.2.2", - "@astrojs/renderer-preact": "0.2.2", - "@astrojs/renderer-react": "0.2.1", - "@astrojs/renderer-svelte": "0.1.2", - "@astrojs/renderer-vue": "0.1.8", - "@babel/code-frame": "^7.12.13", - "@babel/core": "^7.14.6", - "@babel/generator": "^7.13.9", - "@babel/parser": "^7.13.15", - "@babel/traverse": "^7.13.15", - "@snowpack/plugin-postcss": "^1.4.3", - "@snowpack/plugin-sass": "^1.4.0", - "@types/send": "^0.17.1", - "acorn": "^7.4.0", - "astring": "^1.7.4", - "autoprefixer": "^10.2.5", - "babel-plugin-module-resolver": "^4.1.0", + "@astrojs/markdown-support": "^0.3.1", + "@babel/core": "^7.15.0", + "@web/rollup-plugin-html": "^1.9.1", + "astring": "^1.7.5", "camel-case": "^4.1.2", - "cheerio": "^1.0.0-rc.6", + "chokidar": "^3.5.2", "ci-info": "^3.2.0", + "connect": "^3.7.0", "del": "^6.0.0", - "es-module-lexer": "^0.4.1", - "esbuild": "^0.12.12", + "es-module-lexer": "^0.7.1", + "esbuild": "^0.12.23", "estree-util-value-to-estree": "^1.2.0", - "estree-walker": "^3.0.0", "fast-xml-parser": "^3.19.0", - "fdir": "^5.0.0", - "find-up": "^5.0.0", - "get-port": "^5.1.1", - "gzip-size": "^6.0.0", + "fdir": "^5.1.0", "kleur": "^4.1.4", - "magic-string": "^0.25.3", "mime": "^2.5.2", - "moize": "^6.0.1", - "nanoid": "^3.1.23", "node-fetch": "^2.6.1", "path-to-regexp": "^6.2.0", - "picomatch": "^2.2.3", - "postcss": "^8.2.15", - "postcss-icss-keyframes": "^0.2.1", - "pretty-bytes": "^5.6.0", - "prismjs": "^1.23.0", - "resolve": "^1.20.0", - "rollup": "^2.43.1", - "rollup-plugin-terser": "^7.0.2", - "sass": "^1.32.13", + "picomatch": "^2.3.0", + "sass": "^1.38.1", "semver": "^7.3.5", "send": "^0.17.1", "shiki": "^0.9.10", "shorthash": "^0.0.2", "slash": "^4.0.0", - "snowpack": "^3.8.6", "srcset-parse": "^1.1.0", + "source-map": "^0.7.3", "string-width": "^5.0.0", "supports-esm": "^1.0.0", - "tiny-glob": "^0.2.8", - "yargs-parser": "^20.2.7", + "vite": "^2.5.1", + "yargs-parser": "^20.2.9", "zod": "^3.8.1" }, "devDependencies": { - "@babel/types": "^7.14.0", - "@types/babel__code-frame": "^7.0.2", - "@types/babel__generator": "^7.6.2", - "@types/babel__parser": "^7.1.1", - "@types/babel__traverse": "^7.11.1", - "@types/estree": "0.0.46", + "@types/babel__core": "^7.1.15", + "@types/connect": "^3.4.35", "@types/mime": "^2.0.3", - "@types/node": "^14.14.31", - "@types/sass": "^1.16.0", - "@types/yargs-parser": "^20.2.0", - "astro-scripts": "0.0.1", - "is-windows": "^1.0.2", - "strip-ansi": "^7.0.0" + "@types/node-fetch": "^2.5.12", + "@types/send": "^0.17.1", + "@types/yargs-parser": "^20.2.1" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0", diff --git a/packages/astro/snowpack-plugin-jsx.cjs b/packages/astro/snowpack-plugin-jsx.cjs deleted file mode 100644 index da373e131..000000000 --- a/packages/astro/snowpack-plugin-jsx.cjs +++ /dev/null @@ -1,190 +0,0 @@ -const esbuild = require('esbuild'); -const colors = require('kleur/colors'); -const loggerPromise = import('./dist/logger.js'); -const { promises: fs } = require('fs'); - -const babel = require('@babel/core'); -const eslexer = require('es-module-lexer'); -let error = (...args) => {}; - -/** - * @typedef {Object} PluginOptions - creates a new type named 'SpecialType' - * @prop {import('./src/config_manager').ConfigManager} configManager - * @prop {'development' | 'production'} mode - */ - -/** - * Returns esbuild loader for a given file - * @param filePath {string} - * @returns {import('esbuild').Loader} - */ -function getLoader(fileExt) { - /** @type {any} */ - return fileExt.substr(1); -} - -// The `tsx` loader in esbuild will remove unused imports, so we need to -// be careful about esbuild not treating h, React, Fragment, etc. as unused. -const PREVENT_UNUSED_IMPORTS = ';;(React,Fragment,h);'; - -/** - * @type {import('snowpack').SnowpackPluginFactory} - */ -module.exports = function jsxPlugin(config, options = {}) { - const { configManager, logging } = options; - - let didInit = false; - return { - name: '@astrojs/snowpack-plugin-jsx', - resolve: { - input: ['.jsx', '.tsx'], - output: ['.js'], - }, - async load({ filePath, fileExt, ...transformContext }) { - if (!didInit) { - const logger = await loggerPromise; - error = logger.error; - await eslexer.init; - didInit = true; - } - - const contents = await fs.readFile(filePath, 'utf8'); - const loader = getLoader(fileExt); - - const { code, warnings } = await esbuild.transform(contents, { - loader, - jsx: 'preserve', - sourcefile: filePath, - sourcemap: config.buildOptions.sourcemap ? 'inline' : undefined, - charset: 'utf8', - sourcesContent: config.mode !== 'production', - }); - for (const warning of warnings) { - error( - logging, - 'renderer', - `${colors.bold('!')} ${filePath} - ${warning.text}` - ); - } - - let renderers = await configManager.getRenderers(); - const importSources = new Set(renderers.map(({ jsxImportSource }) => jsxImportSource).filter((i) => i)); - const getRenderer = (importSource) => renderers.find(({ jsxImportSource }) => jsxImportSource === importSource); - const getTransformOptions = async (importSource) => { - const { name } = getRenderer(importSource); - const { default: renderer } = await import(name); - return renderer.jsxTransformOptions(transformContext); - }; - - if (importSources.size === 0) { - throw new Error(`${colors.yellow(filePath)} -Unable to resolve a renderer that handles JSX transforms! Please include a \`renderer\` plugin which supports JSX in your \`astro.config.mjs\` file.`); - } - - // If we only have a single renderer, we can skip a bunch of work! - if (importSources.size === 1) { - const result = transform(code, filePath, await getTransformOptions(Array.from(importSources)[0])); - - return { - '.js': { - code: result.code || '', - }, - }; - } - - // we need valid JS here, so we can use `h` and `Fragment` as placeholders - // NOTE(fks, matthewp): Make sure that you're transforming the original contents here. - const { code: codeToScan } = await esbuild.transform(contents + PREVENT_UNUSED_IMPORTS, { - loader, - jsx: 'transform', - jsxFactory: 'h', - jsxFragment: 'Fragment', - }); - - let imports = []; - if (/import/.test(codeToScan)) { - let [i] = eslexer.parse(codeToScan); - // @ts-ignore - imports = i; - } - - let importSource; - - if (imports.length > 0) { - for (let { n: name } of imports) { - if (name.indexOf('/') > -1) name = name.split('/')[0]; - if (importSources.has(name)) { - importSource = name; - break; - } - } - } - - if (!importSource) { - const multiline = contents.match(/\/\*\*[\S\s]*\*\//gm) || []; - - for (const comment of multiline) { - const [_, lib] = comment.match(/@jsxImportSource\s*(\S+)/) || []; - if (lib) { - importSource = lib; - break; - } - } - } - - if (!importSource) { - const importStatements = { - react: "import React from 'react'", - preact: "import { h } from 'preact'", - 'solid-js': "import 'solid-js/web'", - }; - if (importSources.size > 1) { - const defaultRenderer = Array.from(importSources)[0]; - error( - logging, - 'renderer', - `${colors.yellow(filePath)} -Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment. -Add ${colors.cyan(importStatements[defaultRenderer] || `import '${defaultRenderer}';`)} or ${colors.cyan(`/* jsxImportSource: ${defaultRenderer} */`)} to this file. -` - ); - } - - return { - '.js': { - code: contents, - }, - }; - } - - const result = transform(code, filePath, await getTransformOptions(importSource)); - - return { - '.js': { - code: result.code || '', - }, - }; - }, - cleanup() {}, - }; -}; - -/** - * - * @param code {string} - * @param id {string} - * @param opts {{ plugins?: import('@babel/core').PluginItem[], presets?: import('@babel/core').PluginItem[] }|undefined} - */ -const transform = (code, id, { alias, plugins = [], presets = [] } = {}) => - babel.transformSync(code, { - presets, - plugins: [...plugins, alias ? ['babel-plugin-module-resolver', { root: process.cwd(), alias }] : undefined].filter((v) => v), - cwd: process.cwd(), - filename: id, - ast: false, - compact: false, - sourceMaps: false, - configFile: false, - babelrc: false, - }); diff --git a/packages/astro/snowpack-plugin.cjs b/packages/astro/snowpack-plugin.cjs deleted file mode 100644 index eb3c00281..000000000 --- a/packages/astro/snowpack-plugin.cjs +++ /dev/null @@ -1,69 +0,0 @@ -const { readFile } = require('fs').promises; -const getPort = require('get-port'); -// Snowpack plugins must be CommonJS :( -const transformPromise = import('./dist/compiler/index.js'); - -const DEFAULT_HMR_PORT = 12321; - -/** - * @typedef {Object} PluginOptions - creates a new type named 'SpecialType' - * @prop {import('./src/config_manager').ConfigManager} configManager - * @prop {'development' | 'production'} mode - */ - -/** - * @type {import('snowpack').SnowpackPluginFactory} - */ -module.exports = (snowpackConfig, options = {}) => { - const { resolvePackageUrl, astroConfig, configManager, mode } = options; - let hmrPort = DEFAULT_HMR_PORT; - return { - name: 'snowpack-astro', - knownEntrypoints: ['astro/dist/internal/h.js', 'astro/components/Prism.astro', 'shorthash', 'estree-util-value-to-estree', 'astring'], - resolve: { - input: ['.astro', '.md'], - output: ['.js', '.css'], - }, - async transform({ contents, id, fileExt }) { - if (configManager.isConfigModule(fileExt, id)) { - configManager.configModuleId = id; - const source = await configManager.buildSource(contents); - return source; - } - }, - onChange({ filePath }) { - // If the astro.config.mjs file changes, mark the generated config module as changed. - if (configManager.isAstroConfig(filePath) && configManager.configModuleId) { - this.markChanged(configManager.configModuleId); - configManager.markDirty(); - } - }, - async config(snowpackConfig) { - if (!isNaN(snowpackConfig.devOptions.hmrPort)) { - hmrPort = snowpackConfig.devOptions.hmrPort; - } else { - hmrPort = await getPort({ port: DEFAULT_HMR_PORT, host: snowpackConfig.devOptions.hostname }); - snowpackConfig.devOptions.hmrPort = hmrPort; - } - }, - async load({ filePath }) { - const { compileComponent } = await transformPromise; - const projectRoot = snowpackConfig.root; - const contents = await readFile(filePath, 'utf-8'); - - /** @type {import('./src/@types/compiler').CompileOptions} */ - const compileOptions = { - astroConfig, - hmrPort, - mode, - resolvePackageUrl, - }; - const result = await compileComponent(contents, { compileOptions, filename: filePath, projectRoot }); - const output = { - '.js': { code: result.contents }, - }; - if (result.css) output['.css'] = result.css; - return output; - }, - }; -}; diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0d1f1c0de..07fbfdebd 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1,92 +1,286 @@ -import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types'; -import type { AstroUserConfig, AstroConfig } from './config'; +import type { AstroMarkdownOptions } from '@astrojs/markdown-support'; +import type babel from '@babel/core'; +import type vite from 'vite'; +import type { z } from 'zod'; +import type { AstroConfigSchema } from '../config'; -export { AstroUserConfig, AstroConfig }; -export interface RouteData { - type: 'page'; - pattern: RegExp; - params: string[]; - path: string | null; - component: string; - generate: (data?: any) => string; +export { AstroMarkdownOptions }; + +export interface AstroComponentMetadata { + displayName: string; + hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only'; + componentUrl?: string; + componentExport?: { value: string; namespace?: boolean }; + value?: undefined | string; } +/** + * The Astro User Config Format: + * This is the type interface for your astro.config.mjs default export. + */ +export interface AstroUserConfig { + /** + * Where to resolve all URLs relative to. Useful if you have a monorepo project. + * Default: '.' (current working directory) + */ + projectRoot?: string; + /** + * Path to the `astro build` output. + * Default: './dist' + */ + dist?: string; + /** + * Path to all of your Astro components, pages, and data. + * Default: './src' + */ + src?: string; + /** + * Path to your Astro/Markdown pages. Each file in this directory + * becomes a page in your final build. + * Default: './src/pages' + */ + pages?: string; + /** + * Path to your public files. These are copied over into your build directory, untouched. + * Useful for favicons, images, and other files that don't need processing. + * Default: './public' + */ + public?: string; + /** + * Framework component renderers enable UI framework rendering (static and dynamic). + * When you define this in your configuration, all other defaults are disabled. + * Default: [ + * '@astrojs/renderer-svelte', + * '@astrojs/renderer-vue', + * '@astrojs/renderer-react', + * '@astrojs/renderer-preact', + * ], + */ + renderers?: string[]; + /** Options for rendering markdown content */ + markdownOptions?: Partial; + /** Options specific to `astro build` */ + buildOptions?: { + /** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */ + site?: string; + /** Generate an automatically-generated sitemap for your build. + * Default: true + */ + sitemap?: boolean; + /** + * Control the output file URL format of each page. + * If 'file', Astro will generate a matching HTML file (ex: "/foo.html") instead of a directory. + * If 'directory', Astro will generate a directory with a nested index.html (ex: "/foo/index.html") for each page. + * Default: 'directory' + */ + pageUrlFormat?: 'file' | 'directory'; + }; + /** Options for the development server run with `astro dev`. */ + devOptions?: { + hostname?: string; + /** The port to run the dev server on. */ + port?: number; + /** Path to tailwind.config.js, if used */ + tailwindConfig?: string; + /** + * Configure The trailing slash behavior of URL route matching: + * 'always' - Only match URLs that include a trailing slash (ex: "/foo/") + * 'never' - Never match URLs that include a trailing slash (ex: "/foo") + * 'ignore' - Match URLs regardless of whether a trailing "/" exists + * Default: 'always' + */ + trailingSlash?: 'always' | 'never' | 'ignore'; + }; +} + +// NOTE(fks): We choose to keep our hand-generated AstroUserConfig interface so that +// we can add JSDoc-style documentation and link to the definition file in our repo. +// However, Zod comes with the ability to auto-generate AstroConfig from the schema +// above. If we ever get to the point where we no longer need the dedicated +// @types/config.ts file, consider replacing it with the following lines: +// +// export interface AstroUserConfig extends z.input { +// markdownOptions?: Partial; +// } +export interface AstroConfig extends z.output { + markdownOptions: Partial; +} + +export type AsyncRendererComponentFn = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise; + +export interface CollectionRSS { + /** (required) Title of the RSS Feed */ + title: string; + /** (required) Description of the RSS Feed */ + description: string; + /** Specify arbitrary metadata on opening tag */ + xmlns?: Record; + /** Specify custom data in opening of file */ + customData?: string; + /** + * Specify where the RSS xml file should be written. + * Relative to final build directory. Example: '/foo/bar.xml' + * Defaults to '/rss.xml'. + */ + dest?: string; + /** Return data about each item */ + items: { + /** (required) Title of item */ + title: string; + /** (required) Link to item */ + link: string; + /** Publication date of item */ + pubDate?: Date; + /** Item description */ + description?: string; + /** Append some other XML-valid data to this item */ + customData?: string; + }[]; +} + +/** Generic interface for a component (Astro, Svelte, React, etc.) */ +export interface ComponentInstance { + default: { + isAstroComponent: boolean; + __render?(props: Props, ...children: any[]): string; + __renderer?: Renderer; + }; + __renderPage?: (options: RenderPageOptions) => string; + css?: string[]; + getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult; +} + +export type GetStaticPathsArgs = { paginate: PaginateFunction; rss: RSSFunction }; + +export interface GetStaticPathsOptions { + paginate?: PaginateFunction; + rss?: (...args: any[]) => any; +} + +export type GetStaticPathsResult = { params: Params; props?: Props }[] | { params: Params; props?: Props }[]; + +export interface JSXTransformConfig { + /** Babel presets */ + presets?: babel.PluginItem[]; + /** Babel plugins */ + plugins?: babel.PluginItem[]; +} + +export type JSXTransformFn = (options: { isSSR: boolean }) => Promise; + export interface ManifestData { routes: RouteData[]; } -export interface JsxItem { +export interface PaginatedCollectionProp { + /** result */ + data: T[]; + /** metadata */ + /** the count of the first item on the page, starting from 0 */ + start: number; + /** the count of the last item on the page, starting from 0 */ + end: number; + /** total number of results */ + total: number; + /** the current page number, starting from 1 */ + currentPage: number; + /** number of items per page (default: 25) */ + size: number; + /** number of last page */ + lastPage: number; + url: { + /** url of the current page */ + current: string; + /** url of the previous page (if there is one) */ + prev: string | undefined; + /** url of the next page (if there is one) */ + next: string | undefined; + }; +} + +export interface PaginatedCollectionResult { + /** result */ + data: T[]; + /** metadata */ + /** the count of the first item on the page, starting from 0 */ + start: number; + /** the count of the last item on the page, starting from 0 */ + end: number; + /** total number of results */ + total: number; + /** the current page number, starting from 1 */ + currentPage: number; + /** number of items per page (default: 25) */ + size: number; + /** number of last page */ + lastPage: number; + url: { + /** url of the current page */ + current: string; + /** url of the previous page (if there is one) */ + prev: string | undefined; + /** url of the next page (if there is one) */ + next: string | undefined; + }; +} + +export type PaginateFunction = (data: [], args?: { pageSize?: number; params?: Params; props?: Props }) => GetStaticPathsResult; + +export type Params = Record; + +export type Props = Record; + +export interface RenderPageOptions { + request: { + params?: Params; + url: URL; + canonicalURL: URL; + }; + children: any[]; + props: Props; + css?: string[]; +} + +export interface Renderer { + /** Name of the renderer (required) */ name: string; - jsx: string; + hydrationPolyfills?: string[]; + /** Don’t try and build these dependencies for client */ + external?: string[]; + /** Clientside requirements */ + knownEntrypoints?: string[]; + polyfills?: string[]; + /** Import statement for renderer */ + source?: string; + /** JSX identifier (e.g. 'react' or 'solid-js') */ + jsxImportSource?: string; + /** Babel transform options */ + jsxTransformOptions?: JSXTransformFn; + /** Utilies for server-side rendering */ + ssr: { + check: AsyncRendererComponentFn; + renderToStaticMarkup: AsyncRendererComponentFn<{ + html: string; + }>; + }; + /** Add plugins to Vite, if any */ + vitePlugins?: vite.Plugin[]; } -export interface InlineScriptInfo { - content: string; +export interface RouteData { + component: string; + generate: (data?: any) => string; + params: string[]; + pathname?: string; + pattern: RegExp; + type: 'page'; } -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; -} - -export interface CompileResult { - result: TransformResult; - contents: string; - css?: string; -} +export type RouteCache = Record; export type RuntimeMode = 'development' | 'production'; -export type Params = Record; -export type Props = Record; - -/** Entire output of `astro build`, stored in memory */ -export interface BuildOutput { - [dist: string]: BuildFile; -} - -export interface BuildFile { - /** The original location. Needed for code frame errors. */ - srcPath: URL; - /** File contents */ - contents: string | Buffer; - /** File content type (to determine encoding, etc) */ - 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. */ -export type BundleMap = { - [pageUrl: string]: PageDependencies; -}; - -export interface PageDependencies { - /** JavaScript files needed for page. No distinction between blocking/non-blocking or sync/async. */ - js: Set; - /** CSS needed for page, whether imported via , JS, or Astro component. */ - css: Set; - /** Images needed for page. Can be loaded via CSS, , or otherwise. */ - images: Set; - /** Async hoisted Javascript */ - hoistedJS: Map; -} +export type RSSFunction = (args: RSSFunctionArgs) => void; export interface RSSFunctionArgs { /** (required) Title of the RSS Feed */ @@ -118,57 +312,14 @@ export interface RSSFunctionArgs { }[]; } -export interface PaginatedCollectionProp { - /** result */ - data: T[]; - /** metadata */ - /** the count of the first item on the page, starting from 0 */ - start: number; - /** the count of the last item on the page, starting from 0 */ - end: number; - /** total number of results */ - total: number; - /** the current page number, starting from 1 */ - currentPage: number; - /** number of items per page (default: 25) */ - size: number; - /** number of last page */ - lastPage: number; - url: { - /** url of the current page */ - current: string; - /** url of the previous page (if there is one) */ - prev: string | undefined; - /** url of the next page (if there is one) */ - next: string | undefined; - }; +export type RSSResult = { url: string; xml?: string }; + +export type ScriptInfo = ScriptInfoInline | ScriptInfoExternal; + +export interface ScriptInfoInline { + content: string; } -export type RSSFunction = (args: RSSFunctionArgs) => void; -export type PaginateFunction = (data: [], args?: { pageSize?: number; params?: Params; props?: Props }) => GetStaticPathsResult; -export type GetStaticPathsArgs = { paginate: PaginateFunction; rss: RSSFunction }; -export type GetStaticPathsResult = { params: Params; props?: Props }[] | { params: Params; props?: Props }[]; - -export interface ComponentInfo { - url: string; - importSpecifier: ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier; -} - -export type Components = Map; - -export interface AstroComponentMetadata { - displayName: string; - hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only'; - componentUrl?: string; - componentExport?: { value: string; namespace?: boolean }; - value?: undefined | string; -} - -type AsyncRendererComponentFn = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise; - -export interface Renderer { - check: AsyncRendererComponentFn; - renderToStaticMarkup: AsyncRendererComponentFn<{ - html: string; - }>; +export interface ScriptInfoExternal { + src: string; } diff --git a/packages/astro/src/@types/compiler.ts b/packages/astro/src/@types/compiler.ts deleted file mode 100644 index d35dcfd09..000000000 --- a/packages/astro/src/@types/compiler.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { LogOptions } from '../logger'; -import type { AstroConfig, RuntimeMode } from './astro'; - -export interface CompileOptions { - logging: LogOptions; - resolvePackageUrl: (p: string) => Promise; - astroConfig: AstroConfig; - hmrPort?: number; - mode: RuntimeMode; -} diff --git a/packages/astro/src/@types/estree-walker.d.ts b/packages/astro/src/@types/estree-walker.d.ts deleted file mode 100644 index a3b7da859..000000000 --- a/packages/astro/src/@types/estree-walker.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BaseNode } from 'estree-walker'; - -declare module 'estree-walker' { - export function walk( - ast: T, - { - enter, - leave, - }: { - enter?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; - leave?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; - } - ): T; - - export function asyncWalk( - ast: T, - { - enter, - leave, - }: { - enter?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; - leave?: (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, key: string, index: number) => void; - } - ): T; -} diff --git a/packages/astro/src/@types/micromark-extension-gfm.d.ts b/packages/astro/src/@types/micromark-extension-gfm.d.ts deleted file mode 100644 index ebdfe3b3a..000000000 --- a/packages/astro/src/@types/micromark-extension-gfm.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// TODO: add types (if helpful) -declare module 'micromark-extension-gfm'; -declare module 'micromark-extension-gfm/html.js'; diff --git a/packages/astro/src/@types/micromark.ts b/packages/astro/src/@types/micromark.ts deleted file mode 100644 index 5060ab468..000000000 --- a/packages/astro/src/@types/micromark.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface MicromarkExtensionContext { - sliceSerialize(node: any): string; - raw(value: string): void; - tag(value: string): void; - data(value: string): void; - resume(): any; -} - -export type MicromarkExtensionCallback = (this: MicromarkExtensionContext, node: any) => void; - -export interface MicromarkExtension { - enter?: Record; - exit?: Record; -} diff --git a/packages/astro/src/@types/postcss-icss-keyframes.d.ts b/packages/astro/src/@types/postcss-icss-keyframes.d.ts deleted file mode 100644 index 14c330b6e..000000000 --- a/packages/astro/src/@types/postcss-icss-keyframes.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'postcss-icss-keyframes' { - import type { Plugin } from 'postcss'; - - export default function (options: { generateScopedName(keyframesName: string, filepath: string, css: string): string }): Plugin; -} diff --git a/packages/astro/src/@types/resolve.d.ts b/packages/astro/src/@types/resolve.d.ts deleted file mode 100644 index a4cc7d062..000000000 --- a/packages/astro/src/@types/resolve.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'resolve'; diff --git a/packages/astro/src/@types/tailwind.d.ts b/packages/astro/src/@types/tailwind.d.ts deleted file mode 100644 index d25eaae2f..000000000 --- a/packages/astro/src/@types/tailwind.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -// we shouldn‘t have this as a dependency for Astro, but we may dynamically import it if a user requests it, so let TS know about it -declare module 'tailwindcss'; diff --git a/packages/astro/src/@types/transformer.ts b/packages/astro/src/@types/transformer.ts deleted file mode 100644 index 6bb453c1d..000000000 --- a/packages/astro/src/@types/transformer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { TemplateNode } from '@astrojs/parser'; -import type { CompileOptions } from './compiler'; - -export type VisitorFn = (this: { skip: () => void; remove: () => void; replace: (node: T) => void }, node: T, parent: T, type: string, index: number) => void; - -export interface NodeVisitor { - enter?: VisitorFn; - leave?: VisitorFn; -} - -export interface Transformer { - visitors?: { - html?: Record; - css?: Record; - }; - finalize: () => Promise; -} - -export interface TransformOptions { - compileOptions: CompileOptions; - filename: string; - fileID: string; -} diff --git a/packages/astro/src/ast.ts b/packages/astro/src/ast.ts deleted file mode 100644 index bf73c8508..000000000 --- a/packages/astro/src/ast.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Attribute } from '@astrojs/parser'; - -// AST utility functions - -/** Get TemplateNode attribute from name */ -export function getAttr(attributes: Attribute[], name: string): Attribute | undefined { - const attr = attributes.find((a) => a.name === name); - return attr; -} - -/** Get TemplateNode attribute by value */ -export function getAttrValue(attributes: Attribute[], name: string): string | undefined { - if (attributes.length === 0) return ''; - const attr = getAttr(attributes, name); - if (attr) { - return attr.value[0]?.data; - } -} - -/** Set TemplateNode attribute value */ -export function setAttrValue(attributes: Attribute[], name: string, value: string): void { - const attr = attributes.find((a) => a.name === name); - if (attr && attr.value[0]) { - attr.value[0].data = value; - attr.value[0].raw = value; - } -} diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts deleted file mode 100644 index ea3b03b77..000000000 --- a/packages/astro/src/build.ts +++ /dev/null @@ -1,365 +0,0 @@ -import cheerio from 'cheerio'; -import del from 'del'; -import eslexer from 'es-module-lexer'; -import fs from 'fs'; -import { bold, green, red, underline, yellow } from 'kleur/colors'; -import mime from 'mime'; -import path from 'path'; -import { performance } from 'perf_hooks'; -import glob from 'tiny-glob'; -import hash from 'shorthash'; -import srcsetParse from 'srcset-parse'; -import { fileURLToPath } from 'url'; -import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode, ScriptInfo } from './@types/astro'; -import { bundleCSS } from './build/bundle/css.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'; -import { getDistPath, stopTimer } from './build/util.js'; -import type { LogOptions } from './logger'; -import { debug, defaultLogDestination, defaultLogLevel, error, info, warn } from './logger.js'; -import { createRuntime, LoadResult } from './runtime.js'; - -// This package isn't real ESM, so have to coerce it -const matchSrcset: typeof srcsetParse = (srcsetParse as any).default; - -const defaultLogging: LogOptions = { - level: defaultLogLevel, - dest: defaultLogDestination, -}; - -/** Is this URL remote or embedded? */ -function isRemoteOrEmbedded(url: string) { - return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('data:'); -} - -/** The primary build action */ -export async function build(astroConfig: AstroConfig, logging: LogOptions = defaultLogging): Promise<0 | 1> { - const { projectRoot } = astroConfig; - const buildState: BuildOutput = {}; - const depTree: BundleMap = {}; - const timer: Record = {}; - - const runtimeLogging: LogOptions = { - level: 'error', - dest: defaultLogDestination, - }; - - // warn users if missing config item in build that may result in broken SEO (can’t disable, as they should provide this) - if (!astroConfig.buildOptions.site) { - warn(logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`); - } - - const mode: RuntimeMode = 'production'; - const astroRuntime = await createRuntime(astroConfig, { mode, logging: runtimeLogging }); - const { runtimeConfig } = astroRuntime; - const { snowpackRuntime } = runtimeConfig; - - try { - // 0. erase build directory - await del(fileURLToPath(astroConfig.dist)); - - /** - * 1. Build Pages - * Source files are built in parallel and stored in memory. Most assets are also gathered here, too. - */ - timer.build = performance.now(); - info(logging, 'build', yellow('! building pages...')); - const allRoutesAndPaths = await Promise.all( - runtimeConfig.manifest.routes.map(async (route): Promise<[RouteData, string[]]> => { - if (route.path) { - return [route, [route.path]]; - } else { - const result = await getStaticPathsForPage({ - astroConfig, - astroRuntime, - route, - snowpackRuntime, - logging, - }); - if (result.rss.xml) { - if (buildState[result.rss.url]) { - throw new Error(`[getStaticPaths] RSS feed ${result.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})`); - } - buildState[result.rss.url] = { - srcPath: new URL(result.rss.url, projectRoot), - contents: result.rss.xml, - contentType: 'text/xml', - encoding: 'utf8', - }; - } - return [route, result.paths]; - } - }) - ); - try { - await Promise.all( - allRoutesAndPaths.map(async ([route, paths]: [RouteData, string[]]) => { - for (const p of paths) { - await buildStaticPage({ - astroConfig, - buildState, - route, - path: p, - astroRuntime, - }); - } - }) - ); - } catch (e) { - if (e.filename) { - let stack = e.stack - .replace(/Object\.__render \(/gm, '') - .replace(/\/_astro\/(.+)\.astro\.js\:\d+\:\d+\)/gm, (_: string, $1: string) => 'file://' + fileURLToPath(projectRoot) + $1 + '.astro') - .split('\n'); - stack.splice(1, 0, ` at file://${e.filename}`); - stack = stack.join('\n'); - error( - logging, - 'build', - `${red(`Unable to render ${underline(e.filename.replace(fileURLToPath(projectRoot), ''))}`)} - -${stack} -` - ); - } else { - error(logging, 'build', e.message); - } - error(logging, 'build', red('✕ building pages failed!')); - - await astroRuntime.shutdown(); - return 1; - } - info(logging, 'build', green('✔'), 'pages built.'); - debug(logging, 'build', `built pages [${stopTimer(timer.build)}]`); - - // after pages are built, build depTree - timer.deps = performance.now(); - const scanPromises: Promise[] = []; - - await eslexer.init; - for (const id of Object.keys(buildState)) { - if (buildState[id].contentType !== 'text/html') continue; // only scan HTML files - const pageDeps = findDeps(buildState[id].contents as string, { - astroConfig, - srcPath: buildState[id].srcPath, - id, - }); - depTree[id] = pageDeps; - - // while scanning we will find some unbuilt files; make sure those are all built while scanning - for (const url of [...pageDeps.js, ...pageDeps.css, ...pageDeps.images]) { - if (!buildState[url]) - scanPromises.push( - astroRuntime.load(url).then((result: LoadResult) => { - if (result.statusCode === 404) { - if (url.startsWith('/_astro/')) { - throw new Error(`${buildState[id].srcPath.href}: could not find file "${url}".`); - } - warn(logging, 'build', `${buildState[id].srcPath.href}: could not find file "${url}". Marked as external.`); - return; - } - if (result.statusCode !== 200) { - // there shouldn’t be a build error here - throw (result as any).error || new Error(`unexpected ${result.statusCode} response from "${url}".`); - } - buildState[url] = { - srcPath: new URL(url, projectRoot), - contents: result.contents, - contentType: result.contentType || mime.getType(url) || '', - }; - }) - ); - } - } - await Promise.all(scanPromises); - debug(logging, 'build', `scanned deps [${stopTimer(timer.deps)}]`); - - /** - * 2. Bundling 1st Pass: In-memory - * 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.prebundleCSS = performance.now(); - await Promise.all([ - bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => { - debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundleCSS)}]`); - }), - bundleHoistedJS({ buildState, astroConfig, logging, depTree, runtime: astroRuntime, dist: astroConfig.dist }), - // TODO: optimize images? - ]); - // TODO: minify HTML? - info(logging, 'build', green('✔'), 'css optimized.'); - - /** - * 3. Write to disk - * Also clear in-memory bundle - */ - // collect stats output - const urlStats = await collectBundleStats(buildState, depTree); - - // collect JS imports for bundling - const jsImports = await collectJSImports(buildState); - - // write sitemap - if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) { - timer.sitemap = performance.now(); - info(logging, 'build', yellow('! creating sitemap...')); - const sitemap = generateSitemap(buildState, astroConfig.buildOptions.site); - const sitemapPath = new URL('sitemap.xml', astroConfig.dist); - await fs.promises.mkdir(path.dirname(fileURLToPath(sitemapPath)), { recursive: true }); - await fs.promises.writeFile(sitemapPath, sitemap, 'utf8'); - info(logging, 'build', green('✔'), 'sitemap built.'); - debug(logging, 'build', `built sitemap [${stopTimer(timer.sitemap)}]`); - } - - // write to disk and free up memory - timer.write = performance.now(); - for (const id of Object.keys(buildState)) { - const outPath = new URL(`.${id}`, astroConfig.dist); - const parentDir = path.dirname(fileURLToPath(outPath)); - await fs.promises.mkdir(parentDir, { recursive: true }); - const handle = await fs.promises.open(outPath, 'w'); - await fs.promises.writeFile(handle, buildState[id].contents, buildState[id].encoding); - - // Ensure the file handle is not left hanging which will - // result in the garbage collector loggin errors in the console - // when it eventually has to close them. - await handle.close(); - - delete buildState[id]; - delete depTree[id]; - } - debug(logging, 'build', `wrote files to disk [${stopTimer(timer.write)}]`); - - /** - * 4. Copy Public Assets - */ - if (fs.existsSync(astroConfig.public)) { - info(logging, 'build', yellow(`! copying public folder...`)); - timer.public = performance.now(); - const cwd = fileURLToPath(astroConfig.public); - const publicFiles = await glob('**/*', { cwd, filesOnly: true }); - await Promise.all( - publicFiles.map(async (filepath) => { - const srcPath = new URL(filepath, astroConfig.public); - const distPath = new URL(filepath, astroConfig.dist); - await fs.promises.mkdir(path.dirname(fileURLToPath(distPath)), { recursive: true }); - await fs.promises.copyFile(srcPath, distPath); - }) - ); - debug(logging, 'build', `copied public folder [${stopTimer(timer.public)}]`); - info(logging, 'build', green('✔'), 'public folder copied.'); - } else { - if (path.basename(astroConfig.public.toString()) !== 'public') { - info(logging, 'tip', yellow(`! no public folder ${astroConfig.public} found...`)); - } - } - - /** - * 5. Bundling 2nd Pass: On disk - * Bundle JS, which requires hard files to optimize - */ - info(logging, 'build', yellow(`! bundling...`)); - if (jsImports.size > 0) { - timer.bundleJS = performance.now(); - const jsStats = await bundleJS(jsImports, { dist: astroConfig.dist, astroRuntime }); - mapBundleStatsToURLStats({ urlStats, depTree, bundleStats: jsStats }); - debug(logging, 'build', `bundled JS [${stopTimer(timer.bundleJS)}]`); - info(logging, 'build', green(`✔`), 'bundling complete.'); - } - - /** - * 6. Print stats - */ - logURLStats(logging, urlStats); - await astroRuntime.shutdown(); - info(logging, 'build', bold(green('▶ Build Complete!'))); - return 0; - } catch (err) { - error(logging, 'build', err.message); - await astroRuntime.shutdown(); - return 1; - } -} - -/** Given an HTML string, collect and tags */ -export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL; id: string }): PageDependencies { - const pageDeps: PageDependencies = { - js: new Set(), - css: new Set(), - images: new Set(), - hoistedJS: new Map(), - }; - - const $ = cheerio.load(html); - - $('script').each((_i, el) => { - const src = $(el).attr('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 { - const text = $(el).html(); - if (!text) return; - const [imports] = eslexer.parse(text); - for (const spec of imports) { - const importSrc = spec.n; - if (importSrc && !isRemoteOrEmbedded(importSrc)) { - pageDeps.js.add(getDistPath(importSrc, { astroConfig, srcPath })); - } - } - } - }); - - $('link[href]').each((_i, el) => { - const href = $(el).attr('href'); - if (href && !isRemoteOrEmbedded(href) && ($(el).attr('rel') === 'stylesheet' || $(el).attr('type') === 'text/css' || href.endsWith('.css'))) { - const dist = getDistPath(href, { astroConfig, srcPath }); - pageDeps.css.add(dist); - } - }); - - $('img[src]').each((_i, el) => { - const src = $(el).attr('src'); - if (src && !isRemoteOrEmbedded(src)) { - pageDeps.images.add(getDistPath(src, { astroConfig, srcPath })); - } - }); - - $('img[srcset]').each((_i, el) => { - const srcset = $(el).attr('srcset') || ''; - for (const src of matchSrcset(srcset)) { - if (!isRemoteOrEmbedded(src.url)) { - pageDeps.images.add(getDistPath(src.url, { astroConfig, srcPath })); - } - } - }); - - // Add in srcset check for - $('source[srcset]').each((_i, el) => { - const srcset = $(el).attr('srcset') || ''; - for (const src of matchSrcset(srcset)) { - if (!isRemoteOrEmbedded(src.url)) { - pageDeps.images.add(getDistPath(src.url, { astroConfig, srcPath })); - } - } - }); - - // important: preserve the scan order of deps! order matters on pages - - return pageDeps; -} diff --git a/packages/astro/src/build/bundle/css.ts b/packages/astro/src/build/bundle/css.ts deleted file mode 100644 index e2911f94b..000000000 --- a/packages/astro/src/build/bundle/css.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { AstroConfig, BuildOutput, BundleMap } from '../../@types/astro'; -import type { LogOptions } from '../../logger.js'; - -import { performance } from 'perf_hooks'; -import shorthash from 'shorthash'; -import cheerio from 'cheerio'; -import esbuild from 'esbuild'; -import { getDistPath, getSrcPath, IS_ASTRO_FILE_URL, stopTimer } from '../util.js'; -import { debug } from '../../logger.js'; - -// config -const COMMON_URL = `/_astro/common-[HASH].css`; // [HASH] will be replaced - -/** - * Bundle CSS - * For files within dep tree, find ways to combine them. - * Current logic: - * - If CSS appears across multiple pages, combine into `/_astro/common.css` bundle - * - Otherwise, combine page CSS into one request as `/_astro/[page].css` bundle - * - * This operation _should_ be relatively-safe to do in parallel with other bundling, - * assuming other bundling steps don’t touch CSS. While this step does modify HTML, - * it doesn’t keep anything in local memory so other processes may modify HTML too. - * - * This operation mutates the original references of the buildOutput not only for - * safety (prevents possible conflicts), but for efficiency. - */ -export async function bundleCSS({ - astroConfig, - buildState, - logging, - depTree, -}: { - astroConfig: AstroConfig; - buildState: BuildOutput; - logging: LogOptions; - depTree: BundleMap; -}): Promise { - const timer: Record = {}; - const cssMap = new Map(); - - // 1. organize CSS into common or page-specific CSS - timer.bundle = performance.now(); - 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 })); - for (const pageUrl of sortedPages) { - const { css } = depTree[pageUrl]; - for (const cssUrl of css.keys()) { - if (!IS_ASTRO_FILE_URL.test(cssUrl)) { - // do not add to cssMap, leave as-is. - } else if (cssMap.has(cssUrl)) { - // scenario 1: if multiple URLs require this CSS, upgrade to common chunk - cssMap.set(cssUrl, COMMON_URL); - } else { - // scenario 2: otherwise, assume this CSS is page-specific - cssMap.set(cssUrl, '/_astro' + pageUrl.replace(/.html$/, '').replace(/^\./, '') + '-[HASH].css'); - } - } - } - - // 2. bundle (note: assume cssMap keys are in specific, correct order; assume buildState[] keys are in different order each time) - timer.bundle = performance.now(); - // note: don’t parallelize here otherwise CSS may end up in random order - for (const id of cssMap.keys()) { - const newUrl = cssMap.get(id) as string; - - // if new bundle, create - if (!buildState[newUrl]) { - buildState[newUrl] = { - srcPath: getSrcPath(id, { astroConfig }), // this isn’t accurate, but we can at least reference a file in the bundle - contents: '', - contentType: 'text/css', - encoding: 'utf8', - }; - } - - // append to bundle, delete old file - (buildState[newUrl] as any).contents += Buffer.isBuffer(buildState[id].contents) ? buildState[id].contents.toString('utf8') : buildState[id].contents; - delete buildState[id]; - } - debug(logging, 'css', `bundled [${stopTimer(timer.bundle)}]`); - - // 3. minify - timer.minify = performance.now(); - await Promise.all( - Object.keys(buildState).map(async (id) => { - if (buildState[id].contentType !== 'text/css') return; - const { code } = await esbuild.transform(buildState[id].contents.toString(), { - loader: 'css', - minify: true, - }); - buildState[id].contents = code; - }) - ); - debug(logging, 'css', `minified [${stopTimer(timer.minify)}]`); - - // 4. determine hashes based on CSS content (deterministic), and update HTML tags with final hashed URLs - timer.hashes = performance.now(); - const cssHashes = new Map(); - for (const id of Object.keys(buildState)) { - if (!id.includes('[HASH].css')) continue; // iterate through buildState, looking to replace [HASH] - - const hash = shorthash.unique(buildState[id].contents as string); - const newID = id.replace(/\[HASH\]/, hash); - cssHashes.set(id, newID); - buildState[newID] = buildState[id]; // copy ref without cloning to save memory - delete buildState[id]; // delete old ref - } - debug(logging, 'css', `built hashes [${stopTimer(timer.hashes)}]`); - - // 5. update HTML tags with final hashed URLs - timer.html = performance.now(); - await Promise.all( - Object.keys(buildState).map(async (id) => { - if (buildState[id].contentType !== 'text/html') return; - - const $ = cheerio.load(buildState[id].contents); - const stylesheets = new Set(); // keep track of page-specific CSS so we remove dupes - const preloads = new Set(); // list of stylesheets preloads, to remove dupes - - $('link[href]').each((i, el) => { - const srcPath = getSrcPath(id, { astroConfig }); - const oldHref = getDistPath($(el).attr('href') || '', { astroConfig, srcPath }); // note: this may be a relative URL; transform to absolute to find a buildOutput match - const newHref = cssMap.get(oldHref); - - if (!newHref) { - return; - } - - if (el.attribs?.rel === 'preload') { - if (preloads.has(newHref)) { - $(el).remove(); - } else { - $(el).attr('href', cssHashes.get(newHref) || ''); - preloads.add(newHref); - } - return; - } - - if (stylesheets.has(newHref)) { - $(el).remove(); // this is a dupe; remove - } else { - $(el).attr('href', cssHashes.get(newHref) || ''); // new CSS; update href (important! use cssHashes, not cssMap) - - // bonus: add [rel] and [type]. not necessary, but why not? - $(el).attr('rel', 'stylesheet'); - $(el).attr('type', 'text/css'); - - stylesheets.add(newHref); - } - }); - (buildState[id] as any).contents = $.html(); // save updated HTML in global buildState - }) - ); - debug(logging, 'css', `parsed html [${stopTimer(timer.html)}]`); -} diff --git a/packages/astro/src/build/bundle/js.ts b/packages/astro/src/build/bundle/js.ts deleted file mode 100644 index 61b55b735..000000000 --- a/packages/astro/src/build/bundle/js.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { InputOptions, OutputOptions, OutputChunk } from 'rollup'; -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 { IS_ASTRO_FILE_URL } from '../util.js'; -import cheerio from 'cheerio'; -import path from 'path'; - -interface BundleOptions { - dist: URL; - astroRuntime: AstroRuntime; -} - -/** Collect JS imports from build output */ -export function collectJSImports(buildState: BuildOutput): Set { - const imports = new Set(); - for (const id of Object.keys(buildState)) { - if (buildState[id].contentType === 'application/javascript') imports.add(id); - } - 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(); - const pageToEntryMap = new Map(); - - 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(); - 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, { astroRuntime, dist }: BundleOptions): Promise { - const ROOT = 'astro:root'; - const validImports = [...imports].filter((url) => IS_ASTRO_FILE_URL.test(url)); - const root = ` - ${validImports.map((url) => `import '${url}';`).join('\n')} -`; - - const inputOptions: InputOptions = { - input: validImports, - plugins: [ - { - name: 'astro:build', - resolveId(source: string, imported?: string) { - if (source === ROOT) { - 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 (id === ROOT) { - return root; - } - - const result = await astroRuntime.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(1); - }, - 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 stats = createBundleStats(); - const { output } = await build.write(outputOptions); - await Promise.all( - output.map(async (chunk) => { - const code = (chunk as OutputChunk).code || ''; - await addBundleStats(stats, code, chunk.fileName); - }) - ); - - return stats; -} diff --git a/packages/astro/src/build/index.ts b/packages/astro/src/build/index.ts new file mode 100644 index 000000000..3dac614d8 --- /dev/null +++ b/packages/astro/src/build/index.ts @@ -0,0 +1,201 @@ +import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, RouteCache, RouteData, RSSResult } from '../@types/astro'; +import type { LogOptions } from '../logger'; + +import { rollupPluginHTML } from '@web/rollup-plugin-html'; +import fs from 'fs'; +import { bold, cyan, green, dim } from 'kleur/colors'; +import { performance } from 'perf_hooks'; +import vite, { ViteDevServer } from 'vite'; +import { fileURLToPath } from 'url'; +import { pad } from '../dev/util.js'; +import { defaultLogOptions, warn } from '../logger.js'; +import { generatePaginateFunction } from '../runtime/paginate.js'; +import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../runtime/routing.js'; +import { generateRssFunction } from '../runtime/rss.js'; +import { ssr } from '../runtime/ssr.js'; +import { loadViteConfig } from '../runtime/vite/config.js'; +import { kb, profileHTML, profileJS } from './stats.js'; +import { generateSitemap } from '../runtime/sitemap.js'; + +export interface BuildOptions { + logging: LogOptions; +} + +/** `astro build` */ +export default async function build(config: AstroConfig, options: BuildOptions = { logging: defaultLogOptions }): Promise { + const builder = new AstroBuilder(config, options); + await builder.build(); +} + +class AstroBuilder { + private config: AstroConfig; + private logging: LogOptions; + private origin: string; + private routeCache: RouteCache = {}; + private manifest: ManifestData; + + constructor(config: AstroConfig, options: BuildOptions) { + if (!config.buildOptions.site) { + warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`); + } + + this.config = config; + const port = config.devOptions.port; // no need to save this (don’t rely on port in builder) + this.logging = options.logging; + this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${port}`; + this.manifest = createRouteManifest({ config }); + } + + /** Build all pages */ + async build() { + const start = performance.now(); + + // 1. initialize fresh Vite instance + const { config, logging, origin } = this; + const viteConfig = await loadViteConfig( + { + mode: 'production', + server: { + hmr: { overlay: false }, + middlewareMode: 'ssr', + }, + }, + { astroConfig: this.config, logging } + ); + const viteServer = await vite.createServer(viteConfig); + + // 2. get all routes + const outDir = new URL('./dist/', this.config.projectRoot); + const allPages: Promise<{ html: string; name: string }>[] = []; + const assets: Record = {}; // additional assets to be written + await Promise.all( + this.manifest.routes.map(async (route) => { + const { pathname } = route; + const filePath = new URL(`./${route.component}`, this.config.projectRoot); + // static pages + if (pathname) { + allPages.push( + ssr({ filePath, logging, mode: 'production', origin, route, routeCache: this.routeCache, pathname, viteServer }).then((html) => ({ + html, + name: pathname.replace(/\/?$/, '/index.html').replace(/^\//, ''), + })) + ); + } + // dynamic pages + else { + const staticPaths = await this.getStaticPathsForRoute(route, viteServer); + // handle RSS (TODO: improve this?) + if (staticPaths.rss && staticPaths.rss.xml) { + const rssFile = new URL(staticPaths.rss.url.replace(/^\/?/, './'), outDir); + if (assets[fileURLToPath(rssFile)]) { + throw new Error( + `[getStaticPaths] RSS feed ${staticPaths.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})` + ); + } + assets[fileURLToPath(rssFile)] = staticPaths.rss.xml; + } + // TODO: throw error if conflict + staticPaths.paths.forEach((staticPath) => { + allPages.push( + ssr({ filePath, logging, mode: 'production', origin, route, routeCache: this.routeCache, pathname: staticPath, viteServer }).then((html) => ({ + html, + name: staticPath.replace(/\/?$/, '/index.html').replace(/^\//, ''), + })) + ); + }); + } + }) + ); + const input = await Promise.all(allPages); + + // 3. build with Vite + await vite.build({ + logLevel: 'error', + mode: 'production', + build: { + emptyOutDir: true, + minify: 'esbuild', // significantly faster than "terser" but may produce slightly-bigger bundles + outDir: fileURLToPath(outDir), + rollupOptions: { + input: [], + output: { format: 'esm' }, + }, + target: 'es2020', // must match an esbuild target + }, + root: fileURLToPath(config.projectRoot), + server: viteConfig.server, + plugins: [ + rollupPluginHTML({ + input, + extractAssets: false, + }), + ...(viteConfig.plugins || []), + ], + }); + + // 4. write assets to disk + await Promise.all( + Object.keys(assets).map(async (k) => { + if (!assets[k]) return; + const filePath = new URL(`file://${k}`); + await fs.promises.mkdir(new URL('./', filePath), { recursive: true }); + await fs.promises.writeFile(filePath, assets[k], 'utf8'); + delete assets[k]; // free up memory + }) + ); + + // 5. build sitemap + let sitemapTime = 0; + if (this.config.buildOptions.sitemap && this.config.buildOptions.site) { + const sitemapStart = performance.now(); + const sitemap = generateSitemap(input.map(({ name }) => new URL(`/${name}`, this.config.buildOptions.site).href)); + const sitemapPath = new URL('sitemap.xml', outDir); + await fs.promises.mkdir(new URL('./', sitemapPath), { recursive: true }); + await fs.promises.writeFile(sitemapPath, sitemap, 'utf8'); + sitemapTime = performance.now() - sitemapStart; + } + + // 6. log output + await this.printStats({ + cwd: outDir, + pageCount: input.length, + pageTime: Math.round(performance.now() - start), + sitemapTime, + }); + } + + /** Extract all static paths from a dynamic route */ + private async getStaticPathsForRoute(route: RouteData, viteServer: ViteDevServer): Promise<{ paths: string[]; rss?: RSSResult }> { + const filePath = new URL(`./${route.component}`, this.config.projectRoot); + const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + validateGetStaticPathsModule(mod); + const rss = generateRssFunction(this.config.buildOptions.site, route); + const staticPaths: GetStaticPathsResult = (await mod.getStaticPaths!({ paginate: generatePaginateFunction(route), rss: rss.generator })).flat(); + validateGetStaticPathsResult(staticPaths, this.logging); + return { + paths: staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean), + rss: rss.rss, + }; + } + + /** Stats */ + private async printStats({ cwd, pageTime, pageCount, sitemapTime }: { cwd: URL; pageTime: number; pageCount: number; sitemapTime: number }) { + const end = Math.round(performance.now() - pageTime); + const [js, html] = await Promise.all([profileJS({ cwd, entryHTML: new URL('./index.html', cwd) }), profileHTML({ cwd })]); + + /* eslint-disable no-console */ + console.log(`${pad(bold(cyan('Done')), 70)}${dim(` ${pad(`${end}ms`, 8, 'left')}`)} +Pages (${pageCount} total) + ${green(`✔ All pages under ${kb(html.maxSize)}`)} +JS + ${pad('initial load', 50)}${pad(kb(js.entryHTML || 0), 8, 'left')} + ${pad('total size', 50)}${pad(kb(js.total), 8, 'left')} +CSS + ${pad('initial load', 50)}${pad('0 kB', 8, 'left')} + ${pad('total size', 50)}${pad('0 kB', 8, 'left')} +Images + ${green(`✔ All images under 50 kB`)} +`); + if (sitemapTime > 0) console.log(`Sitemap\n ${green(`✔ Built in ${sitemapTime}`)}`); + } +} diff --git a/packages/astro/src/build/page.ts b/packages/astro/src/build/page.ts deleted file mode 100644 index 6708d55c0..000000000 --- a/packages/astro/src/build/page.ts +++ /dev/null @@ -1,77 +0,0 @@ -import _path from 'path'; -import type { ServerRuntime as SnowpackServerRuntime } from 'snowpack'; -import { fileURLToPath } from 'url'; -import type { AstroConfig, BuildOutput, RouteData } from '../@types/astro'; -import { LogOptions } from '../logger'; -import type { AstroRuntime } from '../runtime.js'; -import { convertMatchToLocation, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../util.js'; -import { generatePaginateFunction } from './paginate.js'; -import { generateRssFunction } from './rss.js'; - -interface PageBuildOptions { - astroConfig: AstroConfig; - buildState: BuildOutput; - path: string; - route: RouteData; - astroRuntime: AstroRuntime; -} - -/** Build dynamic page */ -export async function getStaticPathsForPage({ - astroConfig, - astroRuntime, - snowpackRuntime, - route, - logging, -}: { - astroConfig: AstroConfig; - astroRuntime: AstroRuntime; - route: RouteData; - snowpackRuntime: SnowpackServerRuntime; - logging: LogOptions; -}): Promise<{ paths: string[]; rss: any }> { - const location = convertMatchToLocation(route, astroConfig); - const mod = await snowpackRuntime.importModule(location.snowpackURL); - validateGetStaticPathsModule(mod); - const [rssFunction, rssResult] = generateRssFunction(astroConfig.buildOptions.site, route); - const staticPaths = await astroRuntime.getStaticPaths(route.component, mod, { - paginate: generatePaginateFunction(route), - rss: rssFunction, - }); - validateGetStaticPathsResult(staticPaths, logging); - return { - paths: staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean), - rss: rssResult, - }; -} - -function formatOutFile(path: string, pageUrlFormat: AstroConfig['buildOptions']['pageUrlFormat']) { - if (path === '/404') { - return '/404.html'; - } - if (path === '/') { - return '/index.html'; - } - if (pageUrlFormat === 'directory') { - return _path.posix.join(path, '/index.html'); - } - return `${path}.html`; -} -/** Build static page */ -export async function buildStaticPage({ astroConfig, buildState, path, route, astroRuntime }: PageBuildOptions): Promise { - const location = convertMatchToLocation(route, astroConfig); - const normalizedPath = astroConfig.devOptions.trailingSlash === 'never' ? path : path.endsWith('/') ? path : `${path}/`; - const result = await astroRuntime.load(normalizedPath); - if (result.statusCode !== 200) { - let err = (result as any).error; - if (!(err instanceof Error)) err = new Error(err); - err.filename = fileURLToPath(location.fileURL); - throw err; - } - buildState[formatOutFile(path, astroConfig.buildOptions.pageUrlFormat)] = { - srcPath: location.fileURL, - contents: result.contents, - contentType: 'text/html', - encoding: 'utf8', - }; -} diff --git a/packages/astro/src/build/sitemap.ts b/packages/astro/src/build/sitemap.ts deleted file mode 100644 index d1e15636f..000000000 --- a/packages/astro/src/build/sitemap.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { BuildOutput } from '../@types/astro'; -import { canonicalURL } from './util.js'; - -/** Construct sitemap.xml given a set of URLs */ -export function generateSitemap(buildState: BuildOutput, site: string): string { - const uniqueURLs = new Set(); - - // TODO: find way to respect URLs here - // TODO: find way to exclude pages from sitemap (currently only skips 404 pages) - - // look through built pages, only add HTML - for (const id of Object.keys(buildState)) { - if (buildState[id].contentType !== 'text/html') continue; - if (id === '/404.html') continue; - uniqueURLs.add(canonicalURL(id, site).href); - } - - const pages = [...uniqueURLs]; - pages.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time - - let sitemap = ``; - for (const page of pages) { - sitemap += `${page}`; - } - sitemap += `\n`; - return sitemap; -} diff --git a/packages/astro/src/build/stats.ts b/packages/astro/src/build/stats.ts index eeff0af73..c93df6ff8 100644 --- a/packages/astro/src/build/stats.ts +++ b/packages/astro/src/build/stats.ts @@ -1,92 +1,140 @@ -import type { BuildOutput, BundleMap } from '../@types/astro'; -import type { LogOptions } from '../logger'; +import cheerio from 'cheerio'; +import * as eslexer from 'es-module-lexer'; +import { fdir } from 'fdir'; +import fetch from 'node-fetch'; +import fs from 'fs'; +import slash from 'slash'; +import { fileURLToPath } from 'url'; -import { info, table } from '../logger.js'; -import { underline, bold } from 'kleur/colors'; -import gzipSize from 'gzip-size'; -import prettyBytes from 'pretty-bytes'; +type FileSizes = { [file: string]: number }; -interface BundleStats { - size: number; - gzipSize: number; +// Feel free to modify output to whatever’s needed in display. If it’s not needed, kill it and improve stat speeds! + +/** JS: prioritize entry HTML, but also show total */ +interface JSOutput { + /** breakdown of JS per-file */ + js: FileSizes; + /** weight of index.html */ + entryHTML?: number; + /** total bytes of [js], added for convenience */ + total: number; } -interface URLStats { - dynamicImports: Set; - stats: BundleStats[]; +/** HTML: total isn’t important, because those are broken up requests. However, surface any anomalies / bloated HTML */ +interface HTMLOutput { + /** breakdown of HTML per-file */ + html: FileSizes; + /** biggest HTML file */ + maxSize: number; } -export type BundleStatsMap = Map; -export type URLStatsMap = Map; - -export function createURLStats(): URLStatsMap { - return new Map(); +/** Scan any directory */ +async function scan(cwd: URL, pattern: string): Promise { + const results: string[] = (await new fdir().glob(pattern).withFullPaths().crawl(fileURLToPath(cwd)).withPromise()) as any; + return results.map((filepath) => new URL(`file://${slash(filepath)}`)); } -export function createBundleStats(): BundleStatsMap { - return new Map(); -} - -export async function addBundleStats(bundleStatsMap: BundleStatsMap, code: string, filename: string) { - const gzsize = await gzipSize(code); - - bundleStatsMap.set(filename, { - size: Buffer.byteLength(code), - gzipSize: gzsize, - }); -} - -export function mapBundleStatsToURLStats({ urlStats, depTree, bundleStats }: { urlStats: URLStatsMap; depTree: BundleMap; bundleStats: BundleStatsMap }) { - for (let [srcPath, stats] of bundleStats) { - for (let url of urlStats.keys()) { - if (depTree[url] && depTree[url].js.has('/' + srcPath)) { - urlStats.get(url)?.stats.push(stats); - } - } - } -} - -export async function collectBundleStats(buildState: BuildOutput, depTree: BundleMap): Promise { - const urlStats = createURLStats(); - +/** get total HTML size */ +export async function profileHTML({ cwd }: { cwd: URL }): Promise { + const sizes: FileSizes = {}; + const html = await scan(cwd, '**/*.html'); + let maxSize = 0; await Promise.all( - Object.keys(buildState).map(async (id) => { - if (!depTree[id]) return; - const stats = await Promise.all( - [...depTree[id].js, ...depTree[id].css, ...depTree[id].images].map(async (url) => { - if (!buildState[url]) return undefined; - const stat = { - size: Buffer.byteLength(buildState[url].contents), - gzipSize: await gzipSize(buildState[url].contents), - }; - return stat; - }) - ); - urlStats.set(id, { - dynamicImports: new Set(), - stats: stats.filter((s) => !!s) as any, - }); + html.map(async (file) => { + const relPath = file.pathname.replace(cwd.pathname, ''); + const size = (await fs.promises.stat(file)).size; + sizes[relPath] = size; + if (size > maxSize) maxSize = size; + }) + ); + return { + html: sizes, + maxSize, + }; +} + +/** get total JS size (note: .wasm counts as JS!) */ +export async function profileJS({ cwd, entryHTML }: { cwd: URL; entryHTML?: URL }): Promise { + const sizes: FileSizes = {}; + let htmlSize = 0; + + // profile HTML entry (do this first, before all JS in a project is scanned) + if (entryHTML) { + let $ = cheerio.load(await fs.promises.readFile(entryHTML)); + let entryScripts: URL[] = []; + let visitedEntry = false; // note: a quirk of Vite is that the entry file is async-loaded. Count that, but don’t count subsequent async loads + + // scan can't be anywhere inside of a JS string, otherwise the HTML parser fails. - // Break it up here so that the HTML parser won't detect it. - const stringifiedSetupContext = JSON.stringify(contentData).replace(/\<\/script\>/g, ``); - - return `--- -${layout ? `import {__renderPage as __layout} from '${layout}';` : 'const __layout = undefined;'} -export const __content = ${stringifiedSetupContext}; ---- -${content}`; -} - -/** - * .md -> .jsx - * Core function processing Markdown, but along the way also calls convertAstroToJsx(). - */ -async function convertMdToJsx( - contents: string, - { compileOptions, filename, fileID }: { compileOptions: CompileOptions; filename: string; fileID: string } -): Promise { - const raw = await convertMdToAstroSource(contents, { filename }, compileOptions.astroConfig.markdownOptions); - const convertOptions = { compileOptions, filename, fileID }; - return await convertAstroToJsx(raw, convertOptions); -} - -/** Given a file, process it either as .astro, .md */ -async function transformFromSource( - contents: string, - { compileOptions, filename, projectRoot }: { compileOptions: CompileOptions; filename: string; projectRoot: string } -): Promise { - const fileID = path.relative(projectRoot, filename); - switch (true) { - case filename.slice(-6) === '.astro': - return await convertAstroToJsx(contents, { compileOptions, filename, fileID }); - - case filename.slice(-3) === '.md': - return await convertMdToJsx(contents, { compileOptions, filename, fileID }); - - default: - throw new Error('Not Supported!'); - } -} - -/** Return internal code that gets processed in Snowpack */ -interface CompileComponentOptions { - compileOptions: CompileOptions; - filename: string; - projectRoot: string; - isPage?: boolean; -} -/** Compiles an Astro component */ -export async function compileComponent(source: string, { compileOptions, filename, projectRoot }: CompileComponentOptions): Promise { - const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); - const { mode } = compileOptions; - const { hostname, port } = compileOptions.astroConfig.devOptions; - const devSite = `http://${hostname}:${port}`; - const site = compileOptions.astroConfig.buildOptions.site || devSite; - - const fileID = path.join('/_astro', path.relative(projectRoot, filename)); - const fileURL = new URL('.' + fileID, mode === 'production' ? site : devSite); - - // return template - let moduleJavaScript = ` -import fetch from 'node-fetch'; -${result.imports.join('\n')} - -if(!('fetch' in globalThis)) { - globalThis.fetch = fetch; -} - -${/* Global Astro Namespace (shadowed & extended by the scoped namespace inside of __render()) */ ''} -const __TopLevelAstro = { - site: new URL(${JSON.stringify(site)}), - fetchContent: (globResult) => fetchContent(globResult, import.meta.url), - resolve(...segments) { - return segments.reduce( - (url, segment) => new URL(segment, url), - new URL(${JSON.stringify(fileURL)}) - ).pathname - }, -}; -const Astro = __TopLevelAstro; - -${ - result.hasCustomElements - ? ` -const __astro_element_registry = new AstroElementRegistry({ - candidates: new Map([${Array.from(result.customElementCandidates) - .map(([identifier, url]) => `[${identifier}, '${url}']`) - .join(', ')}]) -}); -`.trim() - : '' -} - -${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'); -const __astroSlotted = Symbol.for('astro.slotted'); -async function __render($$props, ...children) { - const Astro = Object.create(__TopLevelAstro, { - props: { - value: $$props, - enumerable: true - }, - slots: { - value: children.reduce( - (slots, child) => { - for (let name in child.$slots) { - slots[name] = Boolean(child.$slots[name]) - } - return slots - }, - {} - ), - enumerable: true - }, - pageCSS: { - 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 - }, - request: { - value: ($$props[__astroContext] && $$props[__astroContext].request) || {}, - enumerable: true - }, - }); - - ${result.script} - return h(Fragment, null, ${result.html}); -} -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: $$props, css, scripts}) { - const currentChild = { - isAstroComponent: true, - layout: typeof __layout === 'undefined' ? undefined : __layout, - content: typeof __content === 'undefined' ? undefined : __content, - __render, - }; - - const isLayout = (__astroContext in $$props); - if(!isLayout) { - let astroRootUIDCounter = 0; - Object.defineProperty($$props, __astroContext, { - value: { - pageCSS: css, - request, - createAstroRootUID(seed) { return seed + astroRootUIDCounter++; }, - pageScripts: scripts, - }, - writable: false, - enumerable: false - }); - } - - Object.defineProperty($$props, __astroInternal, { - value: { - isPage: !isLayout - }, - writable: false, - enumerable: false - }); - - const childBodyResult = await currentChild.__render($$props, children); - - // find layout, if one was given. - if (currentChild.layout) { - return currentChild.layout({ - request, - props: {content: currentChild.content, [__astroContext]: $$props[__astroContext]}, - children: [childBodyResult], - }); - } - - return childBodyResult; -}; - -${result.exports.join('\n')} -`; - - return { - result, - contents: moduleJavaScript, - css: result.css, - }; -} diff --git a/packages/astro/src/compiler/transform/doctype.ts b/packages/astro/src/compiler/transform/doctype.ts deleted file mode 100644 index 7647c205e..000000000 --- a/packages/astro/src/compiler/transform/doctype.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Transformer } from '../../@types/transformer'; - -/** Transform tg */ -export default function (_opts: { filename: string; fileID: string }): Transformer { - let hasDoctype = false; - - return { - visitors: { - html: { - Element: { - enter(node, parent, _key, index) { - if (node.name.toLowerCase() === '!doctype') { - hasDoctype = true; - } - if (node.name === 'html' && !hasDoctype) { - const dtNode = { - start: 0, - end: 0, - attributes: [{ type: 'Attribute', name: 'html', value: true, start: 0, end: 0 }], - children: [], - name: '!doctype', - type: 'Element', - }; - (parent.children || []).splice(index, 0, dtNode); - hasDoctype = true; - } - }, - }, - }, - }, - async finalize() { - // Nothing happening here. - }, - }; -} diff --git a/packages/astro/src/compiler/transform/head.ts b/packages/astro/src/compiler/transform/head.ts deleted file mode 100644 index 27b6bed3e..000000000 --- a/packages/astro/src/compiler/transform/head.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { Transformer, TransformOptions } from '../../@types/transformer'; -import type { TemplateNode } from '@astrojs/parser'; -import { EndOfHead } from './util/end-of-head.js'; - -/** If there are hydrated components, inject styles for [data-astro-root] and [data-astro-children] */ -export default function (opts: TransformOptions): Transformer { - let hasComponents = false; - let isHmrEnabled = typeof opts.compileOptions.hmrPort !== 'undefined' && opts.compileOptions.mode === 'development'; - const eoh = new EndOfHead(); - - return { - visitors: { - html: { - Fragment: { - enter(node) { - eoh.enter(node); - }, - leave(node) { - eoh.leave(node); - }, - }, - InlineComponent: { - enter(node) { - if (hasComponents) { - return; - } - // Initialize eoh if there are no elements - eoh.enter(node); - if (node.attributes && node.attributes.some(({ name }: any) => name?.startsWith('client:'))) { - hasComponents = true; - return; - } - - /** Check for legacy hydration */ - const [_name, kind] = node.name.split(':'); - if (kind) { - hasComponents = true; - } - }, - leave(node) { - eoh.leave(node); - }, - }, - Element: { - enter(node) { - eoh.enter(node); - }, - leave(node) { - eoh.leave(node); - }, - }, - }, - }, - async finalize() { - const children = []; - - /** - * Injects an expression that adds link tags for provided css. - * Turns into: - * ``` - * { Astro.css.map(css => ( - * - * ))} - * ``` - */ - - children.push({ - start: 0, - end: 0, - type: 'Fragment', - children: [ - { - start: 0, - end: 0, - type: 'Expression', - codeChunks: ['Astro.pageCSS.map(css => (', '))'], - children: [ - { - type: 'Element', - name: 'link', - attributes: [ - { - name: 'rel', - type: 'Attribute', - value: [ - { - type: 'Text', - raw: 'stylesheet', - data: 'stylesheet', - }, - ], - }, - { - name: 'href', - type: 'Attribute', - value: [ - { - start: 0, - end: 0, - type: 'MustacheTag', - expression: { - start: 0, - end: 0, - type: 'Expression', - codeChunks: ['css'], - children: [], - }, - }, - ], - }, - ], - start: 0, - end: 0, - children: [], - }, - ], - }, - { - 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: [], - }, - }, - ], - }, - ], - }, - ], - }, - ], - }); - - if (hasComponents) { - children.push({ - type: 'Element', - name: 'style', - attributes: [{ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] }], - start: 0, - end: 0, - children: [ - { - start: 0, - end: 0, - type: 'Text', - data: 'astro-root, astro-fragment { display: contents; }', - raw: 'astro-root, astro-fragment { display: contents; }', - }, - ], - }); - } - - if (isHmrEnabled) { - const { hmrPort } = opts.compileOptions; - children.push( - { - type: 'Element', - name: 'script', - attributes: [], - children: [{ type: 'Text', data: `window.HMR_WEBSOCKET_PORT = ${hmrPort};`, start: 0, end: 0 }], - start: 0, - end: 0, - }, - { - type: 'Element', - name: 'script', - attributes: [ - { type: 'Attribute', name: 'type', value: [{ type: 'Text', data: 'module', start: 0, end: 0 }], start: 0, end: 0 }, - { type: 'Attribute', name: 'src', value: [{ type: 'Text', data: '/_snowpack/hmr-client.js', start: 0, end: 0 }], start: 0, end: 0 }, - ], - children: [], - start: 0, - end: 0, - } - ); - } - - if (eoh.foundHeadOrHtmlElement || eoh.foundHeadAndBodyContent) { - const topLevelFragment = { - start: 0, - end: 0, - type: 'Fragment', - children, - }; - eoh.append(topLevelFragment); - } - }, - }; -} diff --git a/packages/astro/src/compiler/transform/index.ts b/packages/astro/src/compiler/transform/index.ts deleted file mode 100644 index c321c9f81..000000000 --- a/packages/astro/src/compiler/transform/index.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Ast, TemplateNode } from '@astrojs/parser'; -import type { NodeVisitor, TransformOptions, Transformer, VisitorFn } from '../../@types/transformer'; - -import { walk } from 'estree-walker'; - -// Transformers -import transformStyles from './styles.js'; -import transformDoctype from './doctype.js'; -import transformModuleScripts from './module-scripts.js'; -import transformCodeBlocks from './prism.js'; -import transformHead from './head.js'; - -interface VisitorCollection { - enter: Map; - leave: Map; -} - -/** Add visitors to given collection */ -function addVisitor(visitor: NodeVisitor, collection: VisitorCollection, nodeName: string, event: 'enter' | 'leave') { - if (typeof visitor[event] !== 'function') return; - if (!collection[event]) collection[event] = new Map(); - - const visitors = collection[event].get(nodeName) || []; - visitors.push(visitor[event] as any); - collection[event].set(nodeName, visitors); -} - -/** Compile visitor actions from transformer */ -function collectVisitors(transformer: Transformer, htmlVisitors: VisitorCollection, cssVisitors: VisitorCollection, finalizers: Array<() => Promise>) { - if (transformer.visitors) { - if (transformer.visitors.html) { - for (const [nodeName, visitor] of Object.entries(transformer.visitors.html)) { - addVisitor(visitor, htmlVisitors, nodeName, 'enter'); - addVisitor(visitor, htmlVisitors, nodeName, 'leave'); - } - } - if (transformer.visitors.css) { - for (const [nodeName, visitor] of Object.entries(transformer.visitors.css)) { - addVisitor(visitor, cssVisitors, nodeName, 'enter'); - addVisitor(visitor, cssVisitors, nodeName, 'leave'); - } - } - } - finalizers.push(transformer.finalize); -} - -/** Utility for formatting visitors */ -function createVisitorCollection() { - return { - enter: new Map(), - leave: new Map(), - }; -} - -/** Walk AST with collected visitors */ -function walkAstWithVisitors(tmpl: TemplateNode, collection: VisitorCollection) { - walk(tmpl, { - enter(node, parent, key, index) { - if (collection.enter.has(node.type)) { - const fns = collection.enter.get(node.type) || []; - for (let fn of fns) { - fn.call(this, node, parent, key, index); - } - } - }, - leave(node, parent, key, index) { - if (collection.leave.has(node.type)) { - const fns = collection.leave.get(node.type) || []; - for (let fn of fns) { - fn.call(this, node, parent, key, index); - } - } - }, - }); -} - -/** - * Transform - * Step 2/3 in Astro SSR. - * Transform is the point at which we mutate the AST before sending off to - * Codegen, and then to Snowpack. In some ways, it‘s a preprocessor. - */ -export async function transform(ast: Ast, opts: TransformOptions) { - const htmlVisitors = createVisitorCollection(); - const cssVisitors = createVisitorCollection(); - const finalizers: Array<() => Promise> = []; - - const optimizers = [transformHead(opts), transformStyles(opts), transformDoctype(opts), transformModuleScripts(opts), transformCodeBlocks(ast.module)]; - - for (const optimizer of optimizers) { - collectVisitors(optimizer, htmlVisitors, cssVisitors, finalizers); - } - - (ast.css || []).map((css) => walkAstWithVisitors(css, cssVisitors)); - walkAstWithVisitors(ast.html, htmlVisitors); - - // Run all of the finalizer functions in parallel because why not. - await Promise.all(finalizers.map((fn) => fn())); -} diff --git a/packages/astro/src/compiler/transform/module-scripts.ts b/packages/astro/src/compiler/transform/module-scripts.ts deleted file mode 100644 index 98385b105..000000000 --- a/packages/astro/src/compiler/transform/module-scripts.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Transformer } from '../../@types/transformer'; -import type { CompileOptions } from '../../@types/compiler'; - -import { getAttrValue, setAttrValue } from '../../ast.js'; - -/** Transform `; + +`; return hydrationScript; } @@ -198,7 +185,7 @@ const removeSlottedChildren = (_children: string | Record[]) => { }; /** The main wrapper for any components in Astro files */ -export function __astro_component(Component: any, metadata: AstroComponentMetadata = {} as any) { +export function __astro_component(Component: ComponentInstance['default'], metadata: AstroComponentMetadata = {} as any) { if (Component == null) { throw new Error(`Unable to render ${metadata.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`); } else if (typeof Component === 'string' && !isCustomElementTag(Component)) { @@ -206,22 +193,22 @@ export function __astro_component(Component: any, metadata: AstroComponentMetada } return async function __astro_component_internal(props: any, ..._children: any[]) { - if (Component.isAstroComponent) { + if (Component.isAstroComponent && Component.__render) { return Component.__render(props, prepareSlottedChildren(_children)); } const children = removeSlottedChildren(_children); - let instance = await resolveRenderer(Component, props, children, metadata); + let renderer = await resolveRenderer(Component, props, children, metadata); - if (!instance) { - if (isCustomElementTag(Component)) { - instance = astroHtmlRendererInstance; + if (!renderer) { + if (isCustomElementTag(Component as any)) { + renderer = astroHtmlRendererInstance; } else { // If the user only specifies a single renderer, but the check failed // for some reason... just default to their preferred renderer. - instance = rendererInstances.length === 2 ? rendererInstances[1] : undefined; + renderer = rendererInstances.length === 2 ? rendererInstances[1] : undefined; } - if (!instance) { + if (!renderer) { const name = getComponentName(Component, metadata); throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`); } @@ -230,12 +217,12 @@ export function __astro_component(Component: any, metadata: AstroComponentMetada let html = ''; // Skip SSR for components using client:only hydration if (metadata.hydrate !== 'only') { - const rendered = await instance.renderer.renderToStaticMarkup(Component, props, children, metadata); + const rendered = await renderer.ssr.renderToStaticMarkup(Component, props, children, metadata); html = rendered.html; } - if (instance.polyfills.length) { - let polyfillScripts = instance.polyfills.map((src) => ``).join(''); + if (renderer.polyfills) { + let polyfillScripts = renderer.polyfills.map((src: string) => ``).join(''); html = html + polyfillScripts; } @@ -248,7 +235,7 @@ export function __astro_component(Component: any, metadata: AstroComponentMetada // If we ARE hydrating this component, let's generate the hydration script const uniqueId = props[Symbol.for('astro.context')].createAstroRootUID(html); const uniqueIdHashed = hash.unique(uniqueId); - const script = await generateHydrateScript({ instance, astroId: uniqueIdHashed, props }, metadata as Required); + const script = await generateHydrateScript({ renderer, astroId: uniqueIdHashed, props }, metadata as Required); const astroRoot = `${html}`; return [astroRoot, script].join('\n'); }; diff --git a/packages/astro/src/internal/__astro_hoisted_scripts.ts b/packages/astro/src/runtime/__astro_hoisted_scripts.ts similarity index 100% rename from packages/astro/src/internal/__astro_hoisted_scripts.ts rename to packages/astro/src/runtime/__astro_hoisted_scripts.ts diff --git a/packages/astro/src/internal/__astro_slot.ts b/packages/astro/src/runtime/__astro_slot.ts similarity index 93% rename from packages/astro/src/internal/__astro_slot.ts rename to packages/astro/src/runtime/__astro_slot.ts index 99f9872d5..a719fa297 100644 --- a/packages/astro/src/internal/__astro_slot.ts +++ b/packages/astro/src/runtime/__astro_slot.ts @@ -7,7 +7,7 @@ export const __astro_slot = ({ name = 'default' }: { name: string }, _children: if (name === 'default' && typeof _children === 'string') { return _children ? _children : fallback; } - if (!_children || !_children.$slots) { + if (!_children.$slots) { throw new Error(`__astro_slot encountered an unexpected child:\n${JSON.stringify(_children)}`); } const children = _children.$slots[name]; diff --git a/packages/astro/src/internal/element-registry.ts b/packages/astro/src/runtime/element-registry.ts similarity index 100% rename from packages/astro/src/internal/element-registry.ts rename to packages/astro/src/runtime/element-registry.ts diff --git a/packages/astro/src/internal/fetch-content.ts b/packages/astro/src/runtime/fetch-content.ts similarity index 65% rename from packages/astro/src/internal/fetch-content.ts rename to packages/astro/src/runtime/fetch-content.ts index 262a6b6e7..54af6681c 100644 --- a/packages/astro/src/internal/fetch-content.ts +++ b/packages/astro/src/runtime/fetch-content.ts @@ -10,11 +10,13 @@ export function fetchContent(importMetaGlobResult: Record, url: str if (!mod.__content) { return; } - const urlSpec = new URL(spec, url).pathname.replace(/[\\/\\\\]/, '/'); + const urlSpec = new URL(spec, url.replace(/^(file:\/\/)?/, 'file://')).href; // note: "href" will always be forward-slashed ("pathname" may not be) + if (!urlSpec.includes('/pages/')) { + return mod.__content; + } return { ...mod.__content, - url: urlSpec.includes('/pages/') && urlSpec.replace(/^.*\/pages\//, '/').replace(/\.md$/, ''), - file: new URL(spec, url), + url: urlSpec.replace(/^.*\/pages\//, '/').replace(/\.md$/, ''), }; }) .filter(Boolean); diff --git a/packages/astro/src/internal/h.ts b/packages/astro/src/runtime/h.ts similarity index 100% rename from packages/astro/src/internal/h.ts rename to packages/astro/src/runtime/h.ts diff --git a/packages/astro/src/internal/renderer-html.ts b/packages/astro/src/runtime/html.ts similarity index 51% rename from packages/astro/src/internal/renderer-html.ts rename to packages/astro/src/runtime/html.ts index 66c98197e..a0e8119a6 100644 --- a/packages/astro/src/internal/renderer-html.ts +++ b/packages/astro/src/runtime/html.ts @@ -1,8 +1,11 @@ -import { h } from './h'; +import type { AstroComponentMetadata } from '../@types/astro.js'; -async function renderToStaticMarkup(tag: string, props: Record, children: string) { +import { h } from './h.js'; + +async function renderToStaticMarkup(tag: string, props: Record, children: string | undefined) { const html = await h(tag, props, Promise.resolve(children)); return { + check: (...args: any[]) => true, html, }; } diff --git a/packages/astro/src/runtime/markdown.ts b/packages/astro/src/runtime/markdown.ts new file mode 100644 index 000000000..e88cf0d0a --- /dev/null +++ b/packages/astro/src/runtime/markdown.ts @@ -0,0 +1,15 @@ +import type { SourceDescription } from 'rollup'; + +import { renderMarkdownWithFrontmatter } from '@astrojs/markdown-support'; +import astroParser from '@astrojs/parser'; +import { SourceMapGenerator } from 'source-map'; + +/** transform .md contents into Astro h() function */ +export async function markdownToH(filename: string, contents: string): Promise { + const { astro, content } = await renderMarkdownWithFrontmatter(contents); + const map = new SourceMapGenerator(); + return { + code: content, + map: null, + }; +} diff --git a/packages/astro/src/build/paginate.ts b/packages/astro/src/runtime/paginate.ts similarity index 100% rename from packages/astro/src/build/paginate.ts rename to packages/astro/src/runtime/paginate.ts diff --git a/packages/astro/src/manifest/create.ts b/packages/astro/src/runtime/routing.ts similarity index 75% rename from packages/astro/src/manifest/create.ts rename to packages/astro/src/runtime/routing.ts index c945586e2..a25045a42 100644 --- a/packages/astro/src/manifest/create.ts +++ b/packages/astro/src/runtime/routing.ts @@ -1,9 +1,70 @@ +import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, Params, RouteData } from '../@types/astro'; +import type { LogOptions } from '../logger'; + import fs from 'fs'; import path from 'path'; import { compile } from 'path-to-regexp'; import slash from 'slash'; import { fileURLToPath } from 'url'; -import { AstroConfig, ManifestData, RouteData } from '../@types/astro'; +import { warn } from '../logger.js'; + +/** + * given an array of params like `['x', 'y', 'z']` for + * src/routes/[x]/[y]/[z]/svelte, create a function + * that turns a RegExpExecArray into ({ x, y, z }) + */ +export function getParams(array: string[]) { + const fn = (match: RegExpExecArray) => { + const params: Params = {}; + array.forEach((key, i) => { + if (key.startsWith('...')) { + params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined; + } else { + params[key] = decodeURIComponent(match[i + 1]); + } + }); + return params; + }; + + return fn; +} + +/** Find matching route from pathname */ +export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { + return manifest.routes.find((route) => route.pattern.test(pathname)); +} + +/** Throw error for deprecated/malformed APIs */ +export function validateGetStaticPathsModule(mod: ComponentInstance) { + if ((mod as any).createCollection) { + throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`); + } + if (!mod.getStaticPaths) { + throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`); + } +} + +/** Throw error for malformed getStaticPaths() response */ +export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) { + if (!Array.isArray(result)) { + throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`); + } + result.forEach((pathObject) => { + if (!pathObject.params) { + warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`); + return; + } + for (const [key, val] of Object.entries(pathObject.params)) { + if (!(typeof val === 'undefined' || typeof val === 'string')) { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`); + } + if (val === '') { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`); + } + } + }); +} + interface Part { content: string; dynamic: boolean; @@ -21,10 +82,8 @@ interface Item { routeSuffix: string; } -// Needed? -// const specials = new Set([]); - -export function createManifest({ config, cwd }: { config: AstroConfig; cwd?: string }): ManifestData { +/** Create manifest of all static routes */ +export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?: string }): ManifestData { const components: string[] = []; const routes: RouteData[] = []; @@ -129,7 +188,7 @@ export function createManifest({ config, cwd }: { config: AstroConfig; cwd?: str params, component, generate, - path: pathname, + pathname: pathname || undefined, }); } }); diff --git a/packages/astro/src/build/rss.ts b/packages/astro/src/runtime/rss.ts similarity index 82% rename from packages/astro/src/build/rss.ts rename to packages/astro/src/runtime/rss.ts index b058970aa..d46a2a076 100644 --- a/packages/astro/src/build/rss.ts +++ b/packages/astro/src/runtime/rss.ts @@ -1,4 +1,4 @@ -import type { RSSFunctionArgs, RouteData } from '../@types/astro'; +import type { RSSFunction, RSSFunctionArgs, RSSResult, RouteData } from '../@types/astro'; import parser from 'fast-xml-parser'; import { canonicalURL } from './util.js'; @@ -70,16 +70,19 @@ export function generateRSS(args: GenerateRSSArgs): string { return xml; } -export function generateRssFunction(site: string | undefined, routeMatch: RouteData): [(args: any) => void, { url?: string; xml?: string }] { - let result: { url?: string; xml?: string } = {}; - function rssUtility(args: any) { - if (!site) { - throw new Error(`[${routeMatch.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); - } - const { dest, ...rssData } = args; - const feedURL = dest || '/rss.xml'; - result.url = feedURL; - result.xml = generateRSS({ rssData, site, srcFile: routeMatch.component, feedURL }); - } - return [rssUtility, result]; +/** Generated function to be run */ +export function generateRssFunction(site: string | undefined, route: RouteData): { generator: RSSFunction; rss?: RSSResult } { + let result: RSSResult = {} as any; + return { + generator: function rssUtility(args: any) { + if (!site) { + throw new Error(`[${route.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`); + } + const { dest, ...rssData } = args; + const feedURL = dest || '/feed.xml'; + result.url = feedURL; + result.xml = generateRSS({ rssData, site, srcFile: route.component, feedURL }); + }, + rss: result, + }; } diff --git a/packages/astro/src/runtime/sitemap.ts b/packages/astro/src/runtime/sitemap.ts new file mode 100644 index 000000000..2c337bc8b --- /dev/null +++ b/packages/astro/src/runtime/sitemap.ts @@ -0,0 +1,14 @@ +/** Construct sitemap.xml given a set of URLs */ +export function generateSitemap(pages: string[]): string { + // TODO: find way to respect URLs here + // TODO: find way to exclude pages from sitemap + + const urls = [...pages]; // copy just in case original copy is needed + urls.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time + let sitemap = ``; + for (const url of urls) { + sitemap += `${url}`; + } + sitemap += `\n`; + return sitemap; +} diff --git a/packages/astro/src/runtime/ssr.ts b/packages/astro/src/runtime/ssr.ts new file mode 100644 index 000000000..af576ac77 --- /dev/null +++ b/packages/astro/src/runtime/ssr.ts @@ -0,0 +1,139 @@ +import cheerio from 'cheerio'; +import * as eslexer from 'es-module-lexer'; +import type { ViteDevServer } from 'vite'; +import type { ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode } from '../@types/astro'; +import type { LogOptions } from '../logger'; + +import { fileURLToPath } from 'url'; +import { generatePaginateFunction } from './paginate.js'; +import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; +import { canonicalURL, parseNpmName } from './util.js'; + +interface SSROptions { + /** location of file on disk */ + filePath: URL; + /** logging options */ + logging: LogOptions; + /** "development" or "production" */ + mode: RuntimeMode; + /** production website, needed for some RSS & Sitemap functions */ + origin: string; + /** the web request (needed for dynamic routes) */ + pathname: string; + /** optional, in case we need to render something outside of a dev server */ + route?: RouteData; + /** pass in route cache because SSR can’t manage cache-busting */ + routeCache: RouteCache; + /** Vite instance */ + viteServer: ViteDevServer; +} + +// note: not every request has a Vite browserHash. if we ever receive one, hang onto it +// this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom) +let browserHash: string | undefined; + +/** use Vite to SSR */ +export async function ssr({ filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise { + // 1. load module + const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + + // 2. handle dynamic routes + let params: Params = {}; + let pageProps: Props = {}; + if (route && !route.pathname) { + if (route.params.length) { + const paramsMatch = route.pattern.exec(pathname)!; + params = getParams(route.params)(paramsMatch); + } + validateGetStaticPathsModule(mod); + routeCache[route.component] = + routeCache[route.component] || + ( + await mod.getStaticPaths!({ + paginate: generatePaginateFunction(route), + rss: () => { + /* noop */ + }, + }) + ).flat(); + validateGetStaticPathsResult(routeCache[route.component], logging); + const routePathParams: GetStaticPathsResult = routeCache[route.component]; + const matchedStaticPath = routePathParams.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params)); + if (!matchedStaticPath) { + throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); + } + pageProps = { ...matchedStaticPath.props } || {}; + } + + // 3. render page + if (!browserHash && (viteServer as any)._optimizeDepsMetadata?.browserHash) browserHash = (viteServer as any)._optimizeDepsMetadata.browserHash; // note: this is "private" and may change over time + const fullURL = new URL(pathname, origin); + if (!mod.__renderPage) throw new Error(`__renderPage() undefined (${route?.component})`); + let html = await mod.__renderPage({ + request: { + params, + url: fullURL, + canonicalURL: canonicalURL(fullURL.pathname, fullURL.origin), + }, + children: [], + props: pageProps, + css: mod.css || [], + }); + + // 4. modify response + // inject Vite HMR code (dev only) + if (mode === 'development') html = injectViteClient(html); + + // replace client hydration scripts + if (mode === 'development') html = resolveNpmImports(html); + + // 5. finish + return html; +} + +/** Injects Vite client code */ +function injectViteClient(html: string): string { + return html.replace('', ``); +} + +/** Convert npm specifier into Vite URL */ +function resolveViteNpmPackage(spec: string): string { + const pkg = parseNpmName(spec); + if (!pkg) return spec; + let viteURL = '/node_modules/.vite/'; // start with /node_modules/.vite + viteURL += `${pkg.name}${pkg.subpath ? pkg.subpath.substr(1) : ''}`.replace(/[\/\.]/g, '_'); // flatten package name by replacing slashes (and dots) with underscores + viteURL += '.js'; // add .js + if (browserHash) viteURL += `?v=${browserHash}`; // add browserHash (if provided) + return viteURL; +} + +/** Replaces npm imports with Vite-friendly paths */ +function resolveNpmImports(html: string): string { + const $ = cheerio.load(html); + + // find all