Move Astro to Vite

This commit is contained in:
Drew Powers 2021-08-06 15:56:22 -06:00
parent 94f36749a0
commit ddca8c4dea
110 changed files with 3289 additions and 6466 deletions

View file

@ -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"
}
```
@ -72,6 +72,7 @@ export default {
plugins: [jsx({}, { runtime: 'automatic', importSource: 'preact' })],
};
},
vitePlugins: [], // optional, inject Vite plugins here (https://vitejs.dev/plugins/#plugins)
};
```

View file

@ -18,7 +18,7 @@ const { title, description, image, type, next, prev, canonicalURL } = Astro.prop
<meta name="description" content={description}>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/global.css">
<link rel="stylesheet" href="/src/global.scss">
<!-- Sitemap -->
<link rel="sitemap" href="/sitemap.xml">
<!-- RSS -->

View file

@ -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<div class="source"><p> Jeff Atwood</p>[Falling Into The Pit of Success](https://blog.codinghorror.com/falling-into-the-pit-of-success/)</div>
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)

View file

@ -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": {

View file

@ -13,6 +13,7 @@ const CI_INSTRUCTIONS = {
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);

View file

@ -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 `<Markdown content={content} />`
// `__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 : <slot />}
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;
};

View file

@ -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;
};
<pre class={[className, classLanguage].join(' ')}><code class={classLanguage}>{html}</code></pre>

View file

@ -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';
}

View file

@ -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": {
@ -53,73 +47,56 @@
"@astrojs/renderer-svelte": "0.1.2",
"@astrojs/renderer-vue": "0.1.9",
"@babel/code-frame": "^7.12.13",
"@babel/core": "^7.14.6",
"@babel/core": "^7.15.0",
"@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",
"@web/rollup-plugin-html": "^1.9.1",
"acorn": "^7.4.0",
"astring": "^1.7.4",
"astring": "^1.7.5",
"autoprefixer": "^10.2.5",
"babel-plugin-module-resolver": "^4.1.0",
"camel-case": "^4.1.2",
"cheerio": "^1.0.0-rc.6",
"chokidar": "^3.5.2",
"ci-info": "^3.2.0",
"connect": "^3.7.0",
"deepmerge": "^4.2.2",
"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",
"source-map": "^0.7.3",
"srcset-parse": "^1.1.0",
"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",
"@astrojs/parser": "^0.20.2",
"@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",

View file

@ -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<PluginOptions>}
*/
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,
});

View file

@ -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<PluginOptions>}
*/
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;
},
};
};

View file

@ -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<AstroMarkdownOptions>;
/** 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<typeof AstroConfigSchema> {
// markdownOptions?: Partial<AstroMarkdownOptions>;
// }
export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
markdownOptions: Partial<AstroMarkdownOptions>;
}
export type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise<U>;
export interface CollectionRSS {
/** (required) Title of the RSS Feed */
title: string;
/** (required) Description of the RSS Feed */
description: string;
/** Specify arbitrary metadata on opening <xml> tag */
xmlns?: Record<string, string>;
/** 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<JSXTransformConfig>;
export interface ManifestData {
routes: RouteData[];
}
export interface JsxItem {
export interface PaginatedCollectionProp<T = any> {
/** 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<T = any> {
/** 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<string, string | undefined>;
export type Props = Record<string, unknown>;
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[];
/** Dont 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<boolean>;
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<string, string>;
}
export interface CompileResult {
result: TransformResult;
contents: string;
css?: string;
}
export type RouteCache = Record<string, GetStaticPathsResult>;
export type RuntimeMode = 'development' | 'production';
export type Params = Record<string, string | undefined>;
export type Props = Record<string, any>;
/** 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<string>;
/** CSS needed for page, whether imported via <link>, JS, or Astro component. */
css: Set<string>;
/** Images needed for page. Can be loaded via CSS, <link>, or otherwise. */
images: Set<string>;
/** Async hoisted Javascript */
hoistedJS: Map<string, ScriptInfo>;
}
export type RSSFunction = (args: RSSFunctionArgs) => void;
export interface RSSFunctionArgs {
/** (required) Title of the RSS Feed */
@ -118,57 +312,14 @@ export interface RSSFunctionArgs {
}[];
}
export interface PaginatedCollectionProp<T = any> {
/** 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<string, ComponentInfo>;
export interface AstroComponentMetadata {
displayName: string;
hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only';
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
value?: undefined | string;
}
type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise<U>;
export interface Renderer {
check: AsyncRendererComponentFn<boolean>;
renderToStaticMarkup: AsyncRendererComponentFn<{
html: string;
}>;
export interface ScriptInfoExternal {
src: string;
}

View file

@ -1,10 +0,0 @@
import type { LogOptions } from '../logger';
import type { AstroConfig, RuntimeMode } from './astro';
export interface CompileOptions {
logging: LogOptions;
resolvePackageUrl: (p: string) => Promise<string>;
astroConfig: AstroConfig;
hmrPort?: number;
mode: RuntimeMode;
}

View file

@ -1,25 +0,0 @@
import { BaseNode } from 'estree-walker';
declare module 'estree-walker' {
export function walk<T = BaseNode>(
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<T = BaseNode>(
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;
}

View file

@ -1,3 +0,0 @@
// TODO: add types (if helpful)
declare module 'micromark-extension-gfm';
declare module 'micromark-extension-gfm/html.js';

View file

@ -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<string, MicromarkExtensionCallback>;
exit?: Record<string, MicromarkExtensionCallback>;
}

View file

@ -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;
}

View file

@ -1 +0,0 @@
declare module 'resolve';

View file

@ -1,2 +0,0 @@
// we shouldnt 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';

View file

@ -1,23 +0,0 @@
import type { TemplateNode } from '@astrojs/parser';
import type { CompileOptions } from './compiler';
export type VisitorFn<T = TemplateNode> = (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<string, NodeVisitor>;
css?: Record<string, NodeVisitor>;
};
finalize: () => Promise<void>;
}
export interface TransformOptions {
compileOptions: CompileOptions;
filename: string;
fileID: string;
}

View file

@ -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;
}
}

View file

@ -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<string, number> = {};
const runtimeLogging: LogOptions = {
level: 'error',
dest: defaultLogDestination,
};
// warn users if missing config item in build that may result in broken SEO (cant 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<void>[] = [];
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 shouldnt 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 <link> and <img> tags */
export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL; id: string }): PageDependencies {
const pageDeps: PageDependencies = {
js: new Set<string>(),
css: new Set<string>(),
images: new Set<string>(),
hoistedJS: new Map<string, ScriptInfo>(),
};
const $ = cheerio.load(html);
$('script').each((_i, el) => {
const src = $(el).attr('src');
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>
$('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;
}

View file

@ -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 dont touch CSS. While this step does modify HTML,
* it doesnt 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<void> {
const timer: Record<string, number> = {};
const cssMap = new Map<string, string>();
// 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: dont 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 isnt 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 <link> tags with final hashed URLs
timer.hashes = performance.now();
const cssHashes = new Map<string, string>();
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 <link> 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<string>(); // keep track of page-specific CSS so we remove dupes
const preloads = new Set<string>(); // 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)}]`);
}

View file

@ -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<string> {
const imports = new Set<string>();
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<string, ScriptInfo>();
const pageToEntryMap = new Map<string, string>();
for (let pageUrl of sortedPages) {
const hoistedJS = depTree[pageUrl].hoistedJS;
if (hoistedJS.size) {
for (let [url, scriptInfo] of hoistedJS) {
if (virtualScripts.has(url) || !url.startsWith('astro-virtual:')) continue;
virtualScripts.set(url, scriptInfo);
}
const entryURL = pageUrlToVirtualJSEntry(pageUrl);
const entryJS = Array.from(hoistedJS.keys())
.map((url) => `import '${url}';`)
.join('\n');
virtualScripts.set(entryURL, {
content: entryJS,
});
entryImports.push(entryURL);
pageToEntryMap.set(pageUrl, entryURL);
}
}
if (!entryImports.length) {
// There are no hoisted scripts, bail
return;
}
/**
* 2. Run the bundle to bundle each pages JS into a single bundle (with shared content)
*/
const inputOptions: InputOptions = {
input: entryImports,
plugins: [
{
name: 'astro:build',
resolveId(source: string, imported?: string) {
if (virtualScripts.has(source)) {
return source;
}
if (source.startsWith('/')) {
return source;
}
if (imported) {
const outUrl = new URL(source, 'http://example.com' + imported);
return outUrl.pathname;
}
return null;
},
async load(id: string) {
if (virtualScripts.has(id)) {
let info = virtualScripts.get(id) as InlineScriptInfo;
return info.content;
}
const result = await runtime.load(id);
if (result.statusCode !== 200) {
return null;
}
return result.contents.toString('utf-8');
},
},
],
};
const build = await rollup(inputOptions);
const outputOptions: OutputOptions = {
dir: fileURLToPath(dist),
format: 'esm',
exports: 'named',
entryFileNames(chunk) {
const { facadeModuleId } = chunk;
if (!facadeModuleId) throw new Error(`facadeModuleId missing: ${chunk.name}`);
return facadeModuleId.substr('astro-virtual:/'.length, facadeModuleId.length - 'astro-virtual:/'.length - 3 /* .js */) + '-[hash].js';
},
plugins: [
// We are using terser for the demo, but might switch to something else long term
// Look into that rather than adding options here.
terser(),
],
};
const { output } = await build.write(outputOptions);
/**
* 3. Get a mapping of the virtual filename to the chunk file name
*/
const entryToChunkFileName = new Map<string, string>();
output.forEach((chunk) => {
const { fileName, facadeModuleId, isEntry } = chunk as OutputChunk;
if (!facadeModuleId || !isEntry) return;
entryToChunkFileName.set(facadeModuleId, fileName);
});
/**
* 4. Update the original HTML with the new chunk scripts
*/
Object.keys(buildState).forEach((id) => {
if (buildState[id].contentType !== 'text/html') return;
const entryVirtualURL = pageUrlToVirtualJSEntry(id);
let hasHoisted = false;
const $ = cheerio.load(buildState[id].contents);
$('script[data-astro="hoist"]').each((i, el) => {
hasHoisted = true;
if (i === 0) {
let chunkName = entryToChunkFileName.get(entryVirtualURL);
if (!chunkName) return;
let chunkPathname = '/' + chunkName;
let relLink = path.relative(path.dirname(id), chunkPathname);
$(el).attr('src', relLink.startsWith('.') ? relLink : './' + relLink);
$(el).removeAttr('data-astro');
$(el).html('');
} else {
$(el).remove();
}
});
if (hasHoisted) {
(buildState[id] as any).contents = $.html(); // save updated HTML in global buildState
}
});
}
/** Bundle JS action */
export async function bundleJS(imports: Set<string>, { astroRuntime, dist }: BundleOptions): Promise<BundleStatsMap> {
const ROOT = 'astro:root';
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;
}

View file

@ -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<void> {
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 (dont 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<string, string> = {}; // 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}`)}`);
}
}

View file

@ -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<void> {
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',
};
}

View file

@ -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<string>();
// TODO: find way to respect <link rel="canonical"> 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 = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
for (const page of pages) {
sitemap += `<url><loc>${page}</loc></url>`;
}
sitemap += `</urlset>\n`;
return sitemap;
}

View file

@ -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 whatevers needed in display. If its 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<string>;
stats: BundleStats[];
/** HTML: total isnt 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<string, BundleStats>;
export type URLStatsMap = Map<string, URLStats>;
export function createURLStats(): URLStatsMap {
return new Map<string, URLStats>();
/** Scan any directory */
async function scan(cwd: URL, pattern: string): Promise<URL[]> {
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<string, BundleStats>();
}
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<URLStatsMap> {
const urlStats = createURLStats();
/** get total HTML size */
export async function profileHTML({ cwd }: { cwd: URL }): Promise<HTMLOutput> {
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<string>(),
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<JSOutput> {
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 dont count subsequent async loads
// scan <script> files, keep adding to total until done
$('script').each((n, el) => {
const src = $(el).attr('src');
const innerHTML = $(el).html();
// if inline script, add to overall JS weight
if (innerHTML) {
htmlSize += Buffer.byteLength(innerHTML);
}
// otherwise if external script, load & scan it
if (src) {
entryScripts.push(new URL(src, entryHTML));
}
});
let scanPromises: Promise<void>[] = [];
await Promise.all(entryScripts.map(parseJS));
/** parse JS for imports, and add to total size */
async function parseJS(url: URL): Promise<void> {
const relPath = url.pathname.replace(cwd.pathname, '');
if (sizes[relPath]) return;
try {
let code = url.protocol === 'file:' ? await fs.promises.readFile(url, 'utf8') : await fetch(url).then((body) => body.text());
sizes[relPath] = Buffer.byteLength(code);
const staticImports = eslexer.parse(code)[0].filter(({ d }) => {
if (!visitedEntry) return true; // if were on the entry file, count async imports, too
return d === -1; // subsequent runs: dont count deferred code toward total
});
for (const { n } of staticImports) {
if (!n) continue;
let nextURL: URL | undefined;
// external import
if (n.startsWith('http://') || n.startsWith('https://') || n.startsWith('//')) nextURL = new URL(n);
// relative import
else if (n[0] === '.') nextURL = new URL(n, url);
// absolute import (note: make sure "//" is already handled!)
else if (n[0] === '/') nextURL = new URL(`.${n}`, cwd);
if (!nextURL) continue; // unknown format: skip
if (sizes[nextURL.pathname.replace(cwd.pathname, '')]) continue; // already scanned: skip
scanPromises.push(parseJS(nextURL));
}
} catch (err) {
console.warn(`Could not access ${url.href} to include in bundle size`); // eslint-disable-line no-console
}
visitedEntry = true; // after first run, stop counting async imports toward total
}
await Promise.all(scanPromises);
htmlSize = Object.values(sizes).reduce((sum, next) => sum + next, 0);
}
// collect size of all JS in project (note: some may have already been scanned; skip when possible)
const js = await scan(cwd, '**/*.(js|mjs|wasm)');
await Promise.all(
js.map(async (file) => {
const relPath = file.pathname.replace(cwd.pathname, '');
if (!sizes[relPath]) sizes[relPath] = (await fs.promises.stat(file)).size; // only scan if new
})
);
return urlStats;
return {
js: sizes,
entryHTML: htmlSize || undefined,
total: Object.values(sizes).reduce((sum, acc) => sum + acc, 0),
};
}
export function logURLStats(logging: LogOptions, urlStats: URLStatsMap) {
const builtURLs = [...urlStats.keys()].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
info(logging, null, '');
const log = table(logging, [60, 20]);
log(info, ' ' + bold(underline('Pages')), bold(underline('Page Weight (GZip)')));
const lastIndex = builtURLs.length - 1;
builtURLs.forEach((url, index) => {
const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├';
const urlPart = ' ' + sep + ' ' + url;
const bytes =
urlStats
.get(url)
?.stats.map((s) => s.gzipSize)
.reduce((a, b) => a + b, 0) || 0;
const sizePart = prettyBytes(bytes);
log(info, urlPart, sizePart);
});
/** b -> kB */
export function kb(bytes: number): string {
if (bytes === 0) return `0 kB`;
return (Math.round(bytes / 1000) || 1) + ' kB'; // if this is between 0.10.4, round up to 1
}

View file

@ -1,81 +0,0 @@
import type { AstroConfig } from '../@types/astro';
import { performance } from 'perf_hooks';
import fs from 'fs';
import path from 'path';
import { URL } from 'url';
/**
* Only Astro-handled imports need bundling. Any other imports are considered
* a part of `public/`, and should not be touched.
*/
export const IS_ASTRO_FILE_URL = /^\/(_astro|_astro_frontend|_snowpack)\//;
/** Normalize URL to its canonical form */
export function canonicalURL(url: string, base?: string): URL {
let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
pathname = pathname.replace(/\/1\/?$/, ''); // neither is a trailing /1/ (impl. detail of collections)
if (!path.extname(pathname)) pathname = pathname.replace(/(\/+)?$/, '/'); // add trailing slash if theres no extension
pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() wont)
if (base) {
return new URL('.' + pathname, base);
} else {
return new URL(pathname, base);
}
}
/** Resolve final output URL */
export function getDistPath(specifier: string, { astroConfig, srcPath }: { astroConfig: AstroConfig; srcPath: URL }): string {
if (specifier[0] === '/') return specifier; // assume absolute URLs are correct
const { pages: pagesRoot, projectRoot } = astroConfig;
const fileLoc = new URL(specifier, srcPath);
const projectLoc = fileLoc.pathname.replace(projectRoot.pathname, '');
const ext = path.extname(fileLoc.pathname);
const isPage = fileLoc.pathname.includes(pagesRoot.pathname) && (ext === '.astro' || ext === '.md');
// if this lives in src/pages, return that URL
if (isPage) {
const [, publicURL] = projectLoc.split(pagesRoot.pathname);
return publicURL || '/index.html'; // if this is missing, this is the root
}
// if this is in public/, use that as final URL
const isPublicAsset = fileLoc.pathname.includes(astroConfig.public.pathname);
if (isPublicAsset) {
return fileLoc.pathname.replace(astroConfig.public.pathname, '/');
}
// otherwise, return /_astro/* url
return '/_astro/' + projectLoc;
}
/** Given a final output URL, guess at src path (may be inaccurate; only for non-pages) */
export function getSrcPath(distURL: string, { astroConfig }: { astroConfig: AstroConfig }): URL {
if (distURL.startsWith('/_astro/')) {
return new URL('.' + distURL.replace(/^\/_astro\//, ''), astroConfig.projectRoot);
} else if (distURL === '/index.html') {
return new URL('./index.astro', astroConfig.pages);
}
const possibleURLs = [
new URL('.' + distURL, astroConfig.public), // public asset
new URL('.' + distURL.replace(/([^\/])+\/d+\/index.html/, '$$1.astro'), astroConfig.pages), // collection page
new URL('.' + distURL.replace(/\/index\.html$/, '.astro'), astroConfig.pages), // page
// TODO: Astro pages (this isnt currently used for that lookup)
];
// if this is in public/ or pages/, return that
for (const possibleURL of possibleURLs) {
if (fs.existsSync(possibleURL)) return possibleURL;
}
// otherwise resolve relative to project
return new URL('.' + distURL, astroConfig.projectRoot);
}
/** Stop timer & format time for profiling */
export function stopTimer(start: number): string {
const diff = performance.now() - start;
return diff < 750 ? `${Math.round(diff)}ms` : `${(diff / 1000).toFixed(1)}s`;
}

View file

@ -1,7 +1,7 @@
/* eslint-disable no-console */
import { AstroCheck, DiagnosticSeverity } from '@astrojs/language-server';
import type { AstroConfig } from './@types/astro';
import { bold, blue, black, bgWhite, red, cyan, yellow } from 'kleur/colors';
import type { AstroConfig } from '../@types/astro';
import { bold, black, bgWhite, red, cyan, yellow } from 'kleur/colors';
import glob from 'fast-glob';
import * as path from 'path';
import { pathToFileURL } from 'url';

View file

@ -1,29 +1,19 @@
/* eslint-disable no-console */
import { promises as fsPromises } from 'fs';
import type { AstroConfig } from '../@types/astro';
import { LogOptions } from '../logger.js';
import * as colors from 'kleur/colors';
import fs from 'fs';
import yargs from 'yargs-parser';
import { z } from 'zod';
import type { AstroConfig } from './@types/astro';
import { build } from './build.js';
import { formatConfigError, loadConfig } from './config.js';
import devServer from './dev.js';
import { preview } from './preview.js';
import { defaultLogDestination } from '../logger.js';
import build from '../build/index.js';
import devServer from '../dev/index.js';
import { preview } from '../preview/index.js';
import { reload } from './reload.js';
import { check } from './check.js';
const { readFile } = fsPromises;
const buildAndExit = async (astroConfig: AstroConfig) => {
const ret = await build(astroConfig);
process.exit(ret);
};
const reloadAndExit = async () => {
const ret = await reload();
process.exit(ret);
};
const checkAndExit = async (astroConfig: AstroConfig) => {
const ret = await check(astroConfig);
process.exit(ret);
};
import { formatConfigError, loadConfig } from '../config.js';
type Arguments = yargs.Arguments;
type cliCommand = 'help' | 'version' | 'dev' | 'build' | 'preview' | 'reload' | 'check';
@ -36,7 +26,6 @@ interface CLIState {
hostname?: string;
port?: number;
config?: string;
reload?: boolean;
};
}
@ -67,10 +56,6 @@ function resolveArgs(flags: Arguments): CLIState {
case 'check':
return { cmd: 'check', options };
default:
if (flags.reload) {
return { cmd: 'reload', options };
}
return { cmd: 'help', options };
}
}
@ -78,7 +63,6 @@ function resolveArgs(flags: Arguments): CLIState {
/** Display --help flag */
function printHelp() {
console.error(` ${colors.bold('astro')} - Futuristic web development tool.
${colors.bold('Commands:')}
astro dev Run Astro in development mode.
astro build Build a pre-compiled production version of your site.
@ -89,7 +73,6 @@ function printHelp() {
--config <path> Specify the path to the Astro config file.
--project-root <path> Specify the path to the project root folder.
--no-sitemap Disable sitemap generation (build only).
--reload Clean the cache, reinstalling dependencies.
--verbose Enable verbose logging
--silent Disable logging
--version Show the version number and exit.
@ -99,7 +82,7 @@ function printHelp() {
/** Display --version flag */
async function printVersion() {
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'));
const pkg = JSON.parse(await fs.promises.readFile(new URL('../package.json', import.meta.url), 'utf8'));
console.error(pkg.version);
}
@ -111,63 +94,86 @@ function mergeCLIFlags(astroConfig: AstroConfig, flags: CLIState['options']) {
if (typeof flags.hostname === 'string') astroConfig.devOptions.hostname = flags.hostname;
}
/** Handle `astro run` command */
async function runCommand(rawRoot: string, cmd: (a: AstroConfig, opts: any) => Promise<void>, options: CLIState['options']) {
try {
const projectRoot = options.projectRoot || rawRoot;
const astroConfig = await loadConfig(projectRoot, options.config);
mergeCLIFlags(astroConfig, options);
return cmd(astroConfig, options);
} catch (err: any) {
if (err instanceof z.ZodError) {
console.log(formatConfigError(err));
} else {
console.error(colors.red(err.toString()));
}
process.exit(1);
}
}
const cmdMap = new Map<string, (a: AstroConfig, opts?: any) => Promise<any>>([
['build', buildAndExit],
['dev', devServer],
['preview', preview],
['reload', reloadAndExit],
['check', checkAndExit],
]);
/** The primary CLI action */
export async function cli(args: string[]) {
const flags = yargs(args);
const state = resolveArgs(flags);
const options = { ...state.options };
const projectRoot = options.projectRoot || flags._[3];
// logLevel
let logging: LogOptions = {
dest: defaultLogDestination,
level: 'info',
};
if (flags.verbose) logging.level = 'debug';
if (flags.silent) logging.level = 'silent';
let config: AstroConfig;
try {
config = await loadConfig({ cwd: projectRoot, filename: options.config });
mergeCLIFlags(config, options);
} catch (err) {
if (err instanceof z.ZodError) {
console.log(formatConfigError(err));
} else {
console.error(colors.red((err as any).toString() || err));
}
process.exit(1);
}
switch (state.cmd) {
case 'help': {
printHelp();
process.exit(1);
break;
return;
}
case 'version': {
await printVersion();
process.exit(0);
break;
return;
}
case 'reload': {
await reloadAndExit();
break;
}
case 'build':
case 'preview':
case 'dev':
case 'check': {
if (flags.reload) {
await reload();
case 'dev': {
if (flags.reload) await reload(projectRoot);
try {
const server = await devServer(config, { logging });
await new Promise(() => {}); // dont close dev server
} catch (err) {
throwAndExit(err);
}
const cmd = cmdMap.get(state.cmd);
if (!cmd) throw new Error(`Error running ${state.cmd}`);
runCommand(flags._[3], cmd, state.options);
break;
return;
}
case 'build': {
if (flags.reload) await reload(projectRoot);
try {
await build(config, { logging });
process.exit(0);
} catch (err) {
throwAndExit(err);
}
return;
}
case 'check': {
const ret = await check(config);
process.exit(ret);
return;
}
case 'preview': {
if (flags.reload) await reload(projectRoot);
try {
await preview(config, { logging }); // this will keep running
} catch (err) {
throwAndExit(err);
}
return;
}
default: {
throw new Error(`Error running ${state.cmd}`);
}
}
}
/** Display error and exit */
function throwAndExit(err: any) {
console.error(colors.red(err.toString() || err));
process.exit(1);
}

View file

@ -0,0 +1,21 @@
import type { LogOptions } from '../logger';
import del from 'del';
import { fileURLToPath } from 'url';
import { defaultLogDestination, defaultLogLevel, info } from '../logger.js';
const logging: LogOptions = {
level: defaultLogLevel,
dest: defaultLogDestination,
};
export async function reload(cwd: string) {
try {
info(logging, 'reload', `Clearing the cache...`);
const viteCache = new URL('node_modules/.vite/', `file://${cwd}/`);
await del(fileURLToPath(viteCache));
return 0;
} catch {
return 1;
}
}

View file

@ -1,4 +1,4 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
/**
* Hydrate this component as soon as the main thread is free

View file

@ -1,4 +1,4 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
/**
* Hydrate this component immediately

View file

@ -1,4 +1,4 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
/**
* Hydrate this component when a matching media query is found

View file

@ -1,4 +1,4 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
/**
* Hydrate this component immediately

View file

@ -1,4 +1,4 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
/**
* Hydrate this component when one of it's children becomes visible.

View file

@ -1,934 +0,0 @@
import type { Ast, Script, Style, TemplateNode, Expression } from '@astrojs/parser';
import type { CompileOptions } from '../../@types/compiler';
import type { AstroConfig, TransformResult, ComponentInfo, Components, ScriptInfo } from '../../@types/astro';
import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types';
import type { Attribute } from './interfaces';
import eslexer from 'es-module-lexer';
import esbuild from 'esbuild';
import path from 'path';
import astroParser from '@astrojs/parser';
import { walk, asyncWalk } from 'estree-walker';
import _babelGenerator from '@babel/generator';
import babelParser from '@babel/parser';
import { codeFrameColumns } from '@babel/code-frame';
import * as babelTraverse from '@babel/traverse';
import { error, warn, parseError } from '../../logger.js';
import { yellow } from 'kleur/colors';
import { isComponentTag, isCustomElementTag, positionAt } from '../utils.js';
import { warnIfRelativeStringLiteral } from './utils.js';
import { renderMarkdown } from '@astrojs/markdown-support';
import { camelCase } from 'camel-case';
import { transform } from '../transform/index.js';
import { PRISM_IMPORT } from '../transform/prism.js';
import { nodeBuiltinsSet } from '../../node_builtins.js';
import { readFileSync } from 'fs';
import { fileURLToPath, pathToFileURL } from 'url';
const { parse, FEATURE_CUSTOM_ELEMENT } = astroParser;
const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default;
// @ts-ignore
const babelGenerator: typeof _babelGenerator = _babelGenerator.default;
const { transformSync } = esbuild;
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media', 'client:only']);
interface CodeGenOptions {
compileOptions: CompileOptions;
filename: string;
fileID: string;
}
interface HydrationAttributes {
method?: 'load' | 'idle' | 'visible' | 'media' | 'only';
value?: undefined | string;
}
/** Searches through attributes to extract hydration-rlated attributes */
function findHydrationAttributes(attrs: Record<string, string>): HydrationAttributes {
let method: HydrationAttributes['method'];
let value: undefined | string;
for (const [key, val] of Object.entries(attrs)) {
if (hydrationDirectives.has(key)) {
method = key.slice(7) as HydrationAttributes['method'];
value = val === 'true' ? undefined : val;
}
}
return { method, value };
}
/** Retrieve attributes from TemplateNode */
async function getAttributes(nodeName: string, attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise<Record<string, string>> {
const isPage = state.filename.startsWith(fileURLToPath(compileOptions.astroConfig.pages));
let result: Record<string, string> = {};
for (const attr of attrs) {
if (attr.type === 'Spread') {
const code = await compileExpression(attr.expression as Expression, state, compileOptions);
if (code) {
result[`...(${code})`] = '';
}
continue;
}
if (attr.value === true) {
result[attr.name] = JSON.stringify(attr.value);
continue;
}
if (attr.value === false || attr.value === undefined) {
// note: attr.value shouldnt be `undefined`, but a bad transform would cause a compile error here, so prevent that
continue;
}
if (attr.value.length === 0) {
result[attr.name] = '""';
continue;
}
if (attr.value.length > 1) {
result[attr.name] =
'(' +
attr.value
.map((v: TemplateNode) => {
if (v.content) {
return v.content;
} else {
return JSON.stringify(getTextFromAttribute(v));
}
})
.join('+') +
')';
continue;
}
const val = attr.value[0];
if (!val) {
result[attr.name] = '(' + val + ')';
continue;
}
switch (val.type) {
case 'MustacheTag': {
const code = await compileExpression(val.expression, state, compileOptions);
if (code) {
result[attr.name] = '(' + code + ')';
}
continue;
}
case 'Text': {
let text = getTextFromAttribute(val);
if (!isPage) {
warnIfRelativeStringLiteral(compileOptions.logging, nodeName, attr, text);
}
result[attr.name] = JSON.stringify(text);
continue;
}
case 'AttributeShorthand':
result[attr.name] = '(' + attr.name + ')';
continue;
default:
throw new Error(`UNKNOWN: ${val.type}`);
}
}
return result;
}
/** Get value from a TemplateNode Attribute (text attributes only!) */
function getTextFromAttribute(attr: any): string {
switch (attr.type) {
case 'Text': {
if (attr.raw !== undefined) {
return attr.raw;
}
if (attr.data !== undefined) {
return attr.data;
}
break;
}
case 'MustacheTag': {
// FIXME: this won't work when JSX element can appear in attributes (rare but possible).
return attr.expression.codeChunks[0];
}
}
throw new Error(`Unknown attribute type ${attr.type}`);
}
/** Convert TemplateNode attributes to string */
function generateAttributes(attrs: Record<string, string>): string {
let result = '{';
for (const [key, val] of Object.entries(attrs)) {
if (hydrationDirectives.has(key)) {
continue;
} else if (key.startsWith('...')) {
result += key + ',';
} else {
result += JSON.stringify(key) + ':' + val + ',';
}
}
result += `[__astroContext]:$$props[__astroContext]`;
return result + '}';
}
function getComponentUrl(astroConfig: AstroConfig, url: string, parentUrl: string | URL) {
const componentExt = path.extname(url);
const ext = PlainExtensions.has(componentExt) ? '.js' : `${componentExt}.js`;
const outUrl = new URL(url, parentUrl);
return '/_astro/' + outUrl.href.replace(astroConfig.projectRoot.href, '').replace(/\.[^.]+$/, ext);
}
interface GetComponentWrapperOptions {
filename: string;
astroConfig: AstroConfig;
compileOptions: CompileOptions;
}
const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
/** Generate Astro-friendly component import */
function getComponentWrapper(_name: string, hydration: HydrationAttributes, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
const { astroConfig, filename, compileOptions } = opts;
let name = _name;
let method = hydration.method;
/** Legacy support for original hydration syntax */
if (name.indexOf(':') > 0) {
const [legacyName, legacyMethod] = _name.split(':');
name = legacyName;
method = legacyMethod as HydrationAttributes['method'];
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, filename);
warn(compileOptions.logging, shortname, yellow(`Deprecation warning: Partial hydration now uses a directive syntax. Please update to "<${name} client:${method} />"`));
}
// Special flow for custom elements
if (isCustomElementTag(_name)) {
return {
wrapper: `__astro_component(...__astro_element_registry.astroComponentArgs("${name}", ${JSON.stringify({ hydrate: method, displayName: _name })}))`,
wrapperImports: [
`import {AstroElementRegistry} from 'astro/dist/internal/element-registry.js';`,
`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`,
],
};
} else {
const getComponentExport = () => {
switch (importSpecifier.type) {
case 'ImportDefaultSpecifier':
return { value: 'default' };
case 'ImportSpecifier': {
if (importSpecifier.imported.type === 'Identifier') {
return { value: importSpecifier.imported.name };
}
return { value: importSpecifier.imported.value };
}
case 'ImportNamespaceSpecifier': {
const [_, value] = _name.split('.');
return { value };
}
}
};
let metadata = '';
if (method) {
const componentUrl = getComponentUrl(astroConfig, url, pathToFileURL(filename));
const componentExport = getComponentExport();
metadata = `{ hydrate: "${method}", displayName: "${name}", componentUrl: "${componentUrl}", componentExport: ${JSON.stringify(componentExport)}, value: ${
hydration.value || 'null'
} }`;
// for client:only components, only render a Fragment on the server
if (method === 'only') {
name = 'Fragment';
}
} else {
metadata = `{ hydrate: undefined, displayName: "${name}", value: ${hydration.value || 'null'} }`;
}
return {
wrapper: `__astro_component(${name}, ${metadata})`,
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
};
}
}
/**
* Convert an Expression Node to a string
*
* @param expression Expression Node to compile
* @param state CodegenState
* @param compileOptions CompileOptions
*/
async function compileExpression(node: Expression, state: CodegenState, compileOptions: CompileOptions) {
const children: string[] = await Promise.all((node.children ?? []).map((child) => compileHtml(child, state, compileOptions)));
let raw = '';
let nextChildIndex = 0;
for (const chunk of node.codeChunks) {
raw += chunk;
if (nextChildIndex < children.length) {
raw += children[nextChildIndex++];
}
}
const location = { start: node.start, end: node.end };
let code = transpileExpressionSafe('(' + raw + ')', { state, compileOptions, location });
if (code === null) throw new Error(`Unable to compile expression`);
code = code.trim().replace(/\;$/, '');
return code;
}
/** Evaluate expression (safely) */
function transpileExpressionSafe(
raw: string,
{ state, compileOptions, location }: { state: CodegenState; compileOptions: CompileOptions; location: { start: number; end: number } }
): string | null {
try {
let { code } = transformSync(raw, {
loader: 'tsx',
jsxFactory: 'h',
jsxFragment: 'Fragment',
charset: 'utf8',
});
return code;
} catch ({ errors }) {
const err = new Error() as any;
const e = errors[0];
err.filename = state.filename;
const text = readFileSync(state.filename).toString();
const start = positionAt(location.start, text);
start.line += e.location.line;
start.character += e.location.column + 1;
err.start = { line: start.line, column: start.character };
const end = { ...start };
end.character += e.location.length;
const frame = codeFrameColumns(text, {
start: { line: start.line, column: start.character },
end: { line: end.line, column: end.character },
});
err.frame = frame;
err.message = e.text;
parseError(compileOptions.logging, err);
return null;
}
}
interface CompileResult {
script: string;
getStaticPaths?: string;
}
interface CodegenState {
components: Components;
css: string[];
hoistedScripts: ScriptInfo[];
filename: string;
fileID: string;
markers: {
insideMarkdown: boolean | Record<string, any>;
};
declarations: Set<string>;
exportStatements: Set<string>;
importStatements: Set<string>;
componentImports: Map<string, string[]>;
customElementCandidates: Map<string, string>;
}
/** Compile/prepare Astro frontmatter scripts */
function compileModule(ast: Ast, module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult {
const { astroConfig } = compileOptions;
const { filename } = state;
const componentImports: ImportDeclaration[] = [];
const componentProps: VariableDeclarator[] = [];
const componentExports: ExportNamedDeclaration[] = [];
let script = '';
let propsStatement = '';
let getStaticPaths = ''; // function for executing collection
if (module) {
const parseOptions: babelParser.ParserOptions = {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'topLevelAwait', 'throwExpressions'],
};
let parseResult;
try {
parseResult = babelParser.parse(module.content, parseOptions);
} catch (err) {
const location = { start: err.loc };
const frame = codeFrameColumns(module.content, location);
err.frame = frame;
err.filename = state.filename;
err.start = err.loc;
throw err;
}
// Convert Astro.fetchContent() to use import.meta.glob
if (/Astro\s*\.\s*fetchContent/.test(module.content)) {
state.importStatements.add(`import {fetchContent} from 'astro/dist/internal/fetch-content.js';\n`);
traverse(parseResult, {
enter({ node }) {
if (
node.type !== 'CallExpression' ||
node.callee.type !== 'MemberExpression' ||
(node.callee.object as any).name !== 'Astro' ||
(node.callee.property as any).name !== 'fetchContent'
) {
return;
}
if (node.arguments[0].type !== 'StringLiteral') {
throw new Error(`[Astro.fetchContent] Only string literals allowed, ex: \`Astro.fetchContent('./post/*.md')\`\n ${state.filename}`);
}
// Replace `Astro.fetchContent(str)` with `Astro.fetchContent(import.meta.globEager(str))`
node.arguments = [
{
type: 'CallExpression',
callee: {
type: 'MemberExpression',
object: { type: 'MetaProperty', meta: { type: 'Identifier', name: 'import' }, property: { type: 'Identifier', name: 'meta' } },
property: { type: 'Identifier', name: 'globEager' },
computed: false,
},
arguments: node.arguments,
},
] as any;
},
});
}
const program = parseResult.program;
const { body } = program;
let i = body.length;
while (--i >= 0) {
const node = body[i];
switch (node.type) {
// case 'ExportAllDeclaration':
// case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration': {
if (!node.declaration) break;
if (node.declaration.type === 'VariableDeclaration') {
// case 1: prop (export let title)
const declaration = node.declaration.declarations[0];
if ((declaration.id as Identifier).name === '__layout' || (declaration.id as Identifier).name === '__content') {
componentExports.push(node);
} else {
componentProps.push(declaration);
}
} else if (node.declaration.type === 'FunctionDeclaration') {
// case 2: getStaticPaths (export async function)
if (!node.declaration.id || node.declaration.id.name !== 'getStaticPaths') break;
getStaticPaths = babelGenerator(node).code;
}
body.splice(i, 1);
break;
}
case 'FunctionDeclaration': {
if (node.id?.name) {
state.declarations.add(node.id?.name);
}
break;
}
case 'ImportDeclaration': {
componentImports.push(node);
body.splice(i, 1); // remove node
break;
}
case 'VariableDeclaration': {
// Support frontmatter-defined components
for (const declaration of node.declarations) {
if (declaration.id.type === 'Identifier') {
state.declarations.add(declaration.id.name);
}
}
break;
}
}
}
for (const componentImport of componentImports) {
const importUrl = componentImport.source.value;
if (nodeBuiltinsSet.has(importUrl)) {
throw new Error(`Node builtins must be prefixed with 'node:'. Use node:${importUrl} instead.`);
}
for (const specifier of componentImport.specifiers) {
const componentName = specifier.local.name;
state.components.set(componentName, {
importSpecifier: specifier,
url: importUrl,
});
if (!state.componentImports.has(componentName)) {
state.componentImports.set(componentName, []);
}
// Track component imports to be used for server-rendered components
const { start, end } = componentImport;
state.componentImports.get(componentName)?.push(module.content.slice(start || undefined, end || undefined));
}
const { start, end } = componentImport;
if (ast.meta.features & FEATURE_CUSTOM_ELEMENT && componentImport.specifiers.length === 0) {
// Add possible custom element, but only if the AST says there are custom elements.
const moduleImportName = camelCase(importUrl + 'Module');
state.importStatements.add(`import * as ${moduleImportName} from '${importUrl}';\n`);
state.customElementCandidates.set(moduleImportName, getComponentUrl(astroConfig, importUrl, pathToFileURL(filename)));
} else {
state.importStatements.add(module.content.slice(start || undefined, end || undefined));
}
}
// TODO: actually expose componentExports other than __layout and __content
for (const componentImport of componentExports) {
const { start, end } = componentImport;
state.exportStatements.add(module.content.slice(start || undefined, end || undefined));
}
if (componentProps.length > 0) {
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
const props = componentProps.map((prop) => (prop.id as Identifier)?.name).filter((v) => v);
warn(
compileOptions.logging,
shortname,
yellow(`\nDefining props with "export" has been removed! Please see https://github.com/snowpackjs/astro/blob/main/packages/astro/CHANGELOG.md#0150
Please update your code to use:
const { ${props.join(', ')} } = Astro.props;\n`)
);
}
script = propsStatement + babelGenerator(program).code;
const location = { start: module.start, end: module.end };
let transpiledScript = transpileExpressionSafe(script, { state, compileOptions, location });
if (transpiledScript === null) throw new Error(`Unable to compile script`);
script = transpiledScript;
}
return {
script,
getStaticPaths: getStaticPaths || undefined,
};
}
/** Compile styles */
function compileCss(style: Style, state: CodegenState) {
walk(style, {
enter(node: TemplateNode) {
if (node.type === 'Style') {
state.css.push(node.content.styles); // if multiple <style> tags, combine together
this.skip();
}
},
leave(node: TemplateNode) {
if (node.type === 'Style') {
this.remove(); // this will be optimized in a global CSS file; remove so its not accidentally inlined
}
},
});
}
/** dedent markdown */
function dedent(str: string) {
let arr = str.match(/^[ \t]*(?=\S)/gm);
let first = !!arr && arr.find((x) => x.length > 0)?.length;
return !arr || !first ? str : str.replace(new RegExp(`^[ \\t]{0,${first}}`, 'gm'), '');
}
const FALSY_EXPRESSIONS = new Set(['false', 'null', 'undefined', 'void 0']);
function isFrontmatterDefinedComponent(componentName: string, componentInfo: ComponentInfo | undefined, state: CodegenState) {
let hasVariableDeclaration = state.declarations.has(componentName);
let isNotImported = !componentInfo;
return hasVariableDeclaration && isNotImported;
}
function isFragmentComponent(componentName: string) {
return componentName === 'Fragment';
}
/** Compile page markup */
async function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOptions: CompileOptions): Promise<string> {
return new Promise((resolve) => {
const { components, css, importStatements, exportStatements, filename, fileID } = state;
const { astroConfig } = compileOptions;
let paren = -1;
let buffers = {
out: '',
markdown: '',
};
let curr: keyof typeof buffers = 'out';
/** renders markdown stored in `buffers.markdown` to JSX and pushes that to `buffers.out` */
async function pushMarkdownToBuffer() {
const md = buffers.markdown;
const { markdownOptions = {} } = astroConfig;
if (!md.trim()) {
buffers.out += ',' + md;
buffers.markdown = '';
curr = 'out';
return;
}
const { $scope: scopedClassName } = state.markers.insideMarkdown as Record<'$scope', any>;
let { content: rendered } = await renderMarkdown(dedent(md), {
...markdownOptions,
$: { scopedClassName: scopedClassName && scopedClassName.slice(1, -1) },
});
// 1. Parse
const ast = parse(rendered);
// 2. Transform the AST
await transform(ast, {
compileOptions,
filename,
fileID,
});
// 3. Codegen
const result = await compileHtml(ast.html, { ...state, markers: { insideMarkdown: false } }, compileOptions);
buffers.out += ',' + result;
buffers.markdown = '';
curr = 'out';
}
asyncWalk(enterNode, {
async enter(node: TemplateNode, parent: TemplateNode) {
switch (node.type) {
case 'Expression': {
let code = await compileExpression(node as Expression, state, compileOptions);
if (FALSY_EXPRESSIONS.has(code)) {
this.skip();
break;
}
if (code !== '') {
if (state.markers.insideMarkdown) {
buffers[curr] += `{${code}}`;
} else {
buffers[curr] += `,(${code})`;
}
}
this.skip();
break;
}
case 'MustacheTag':
if (state.markers.insideMarkdown) {
if (curr === 'out') curr = 'markdown';
}
return;
case 'Comment':
return;
case 'Fragment': {
buffers[curr] += `h(Fragment, null,`;
break;
}
case 'SlotTemplate': {
buffers[curr] += `h(Fragment, null, children`;
paren++;
return;
}
case 'Slot':
case 'Head':
case 'InlineComponent': {
switch (node.name) {
case 'Prism': {
if (!importStatements.has(PRISM_IMPORT)) {
importStatements.add(PRISM_IMPORT);
}
if (!components.has('Prism')) {
components.set('Prism', {
importSpecifier: {
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: 'Prism' } as Identifier,
} as ImportDefaultSpecifier,
url: 'astro/components/Prism.astro',
});
}
break;
}
}
// Do not break.
}
case 'Title':
case 'Element': {
const name: string = node.name;
if (!name) {
throw new Error('AHHHH');
}
try {
const attributes = await getAttributes(name, node.attributes, state, compileOptions);
const hydrationAttributes = findHydrationAttributes(attributes);
buffers.out += buffers.out === '' ? '' : ',';
if (node.type === 'Slot') {
state.importStatements.add(`import { __astro_slot } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot, ${generateAttributes(attributes)}, children`;
paren++;
return;
}
if (!isComponentTag(name)) {
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
if (attributes.hoist) {
if (attributes.src) {
state.hoistedScripts.push({
src: attributes.src.substr(1, attributes.src.length - 2),
});
} else if (node.children && node.children.length === 1 && node.children[0].type === 'Text') {
state.hoistedScripts.push({
content: node.children[0].data,
});
}
this.skip();
return;
}
buffers[curr] += `h("${name}", ${generateAttributes(attributes)},`;
paren++;
return;
}
const [componentName, _componentKind] = name.split(':');
let componentInfo = components.get(componentName);
if (/\./.test(componentName)) {
const [componentNamespace] = componentName.split('.');
componentInfo = components.get(componentNamespace);
}
if ((isFrontmatterDefinedComponent(componentName, componentInfo, state) && !isCustomElementTag(componentName)) || isFragmentComponent(componentName)) {
if (hydrationAttributes.method) {
throw new Error(
`Unable to hydrate "${componentName}" because it is statically defined in the frontmatter script. Hydration directives may only be used on imported components.`
);
}
// Previously we would throw here, but this is valid!
// If the frontmatter script defines `const Element = 'h1'`,
// you should be able to statically render `<Element>`
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
buffers[curr] += `h(${componentName}, ${generateAttributes(attributes)},`;
paren++;
return;
} else if (!componentInfo && !isCustomElementTag(componentName)) {
throw new Error(`Unable to render "${componentName}" because it is undefined\n ${state.filename}`);
}
if (componentName === 'Markdown') {
const { $scope } = attributes ?? {};
state.markers.insideMarkdown = typeof state.markers.insideMarkdown === 'object' ? { $scope, count: state.markers.insideMarkdown.count + 1 } : { $scope, count: 1 };
const keys = Object.keys(attributes).filter((attr) => attr !== '$scope');
if (keys.length > 0) {
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
buffers[curr] += `,${componentName}.__render(${generateAttributes(attributes)}),`;
}
curr = 'markdown';
return;
}
const { wrapper, wrapperImports } = getComponentWrapper(name, hydrationAttributes, componentInfo ?? ({} as any), { astroConfig, filename, compileOptions });
if (wrapperImports) {
for (let wrapperImport of wrapperImports) {
importStatements.add(wrapperImport);
}
}
if (hydrationAttributes.method === 'only') {
// Remove component imports for client-only components
const componentImports = state.componentImports.get(componentName) || [];
componentImports.map((componentImport) => state.importStatements.delete(componentImport));
}
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
paren++;
buffers[curr] += `h(${wrapper}, ${generateAttributes(attributes)}`;
} catch (err) {
paren--;
// handle errors in scope with filename
const rel = filename.replace(astroConfig.projectRoot.pathname, '');
// TODO: return actual codeframe here
error(compileOptions.logging, rel, err.toString());
}
return;
}
case 'Attribute':
case 'Spread': {
this.skip();
return;
}
case 'Style': {
css.push(node.content.styles); // if multiple <style> tags, combine together
this.skip();
return;
}
case 'CodeSpan':
case 'CodeFence': {
if (state.markers.insideMarkdown) {
if (curr === 'out') curr = 'markdown';
buffers[curr] += node.raw;
return;
}
buffers[curr] += ',' + JSON.stringify(node.data);
return;
}
case 'Text': {
let text = getTextFromAttribute(node);
if (state.markers.insideMarkdown) {
if (curr === 'out') curr = 'markdown';
buffers[curr] += text;
return;
}
if (parent.name !== 'Markdown' && !text.trim()) {
return;
}
if (parent.name === 'code') {
// Special case, escaped { characters from markdown content
text = node.raw.replace(/ASTRO_ESCAPED_LEFT_CURLY_BRACKET\0/g, '{');
}
buffers[curr] += ',' + JSON.stringify(text);
return;
}
default:
throw new Error('Unexpected (enter) node type: ' + node.type);
}
},
async leave(node, parent, prop, index) {
switch (node.type) {
case 'Fragment': {
if (curr === 'markdown') curr = 'out';
buffers[curr] += `)`;
break;
}
case 'Text':
case 'Attribute':
case 'Spread':
case 'Comment':
case 'Expression':
case 'MustacheTag':
case 'CodeSpan':
case 'CodeFence':
return;
case 'SlotTemplate':
case 'Slot':
case 'Head':
case 'Body':
case 'Title':
case 'Element': {
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) {
buffers.out += ')';
paren--;
}
return;
}
case 'InlineComponent': {
if (node.name === 'Markdown') {
(state.markers.insideMarkdown as Record<string, any>).count--;
if ((state.markers.insideMarkdown as Record<string, any>).count <= 0) {
state.markers.insideMarkdown = false;
}
const hasAttrs = node.attributes.filter(({ name }: Attribute) => name !== '$scope').length > 0;
if (hasAttrs) {
return;
}
}
if (curr === 'markdown' && buffers.markdown !== '') {
await pushMarkdownToBuffer();
if (!state.markers.insideMarkdown) {
return;
}
}
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) {
buffers.out += ')';
paren--;
}
return;
}
case 'Style': {
this.remove(); // this will be optimized in a global CSS file; remove so its not accidentally inlined
return;
}
default:
throw new Error('Unexpected (leave) node type: ' + node.type);
}
},
}).then(() => {
const content = buffers.out.replace(/^\,/, '').replace(/\,\)/g, ')').replace(/\,+/g, ',').replace(/\)h/g, '),h');
buffers.out = '';
buffers.markdown = '';
return resolve(content);
});
});
}
/**
* Codegen
* Step 3/3 in Astro SSR.
* This is the final pass over a document AST before its converted to an h() function
* and handed off to Snowpack to build.
* @param {Ast} AST The parsed AST to crawl
* @param {object} CodeGenOptions
*/
export async function codegen(ast: Ast, { compileOptions, filename, fileID }: CodeGenOptions): Promise<TransformResult> {
await eslexer.init;
const state: CodegenState = {
filename,
fileID,
components: new Map(),
css: [],
hoistedScripts: [],
markers: {
insideMarkdown: false,
},
declarations: new Set(),
importStatements: new Set(),
exportStatements: new Set(),
componentImports: new Map(),
customElementCandidates: new Map(),
};
const { script, getStaticPaths } = compileModule(ast, ast.module, state, compileOptions);
(ast.css || []).map((css) => compileCss(css, state));
const html = await compileHtml(ast.html, state, compileOptions);
return {
script: script,
imports: Array.from(state.importStatements),
exports: Array.from(state.exportStatements),
html,
css: state.css.length ? state.css.join('\n\n') : undefined,
hoistedScripts: state.hoistedScripts,
components: Array.from(state.components.keys()),
getStaticPaths,
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
customElementCandidates: state.customElementCandidates,
};
}

View file

@ -1,10 +0,0 @@
import type { Expression, TemplateNode } from '@astrojs/parser';
export interface Attribute {
start: number;
end: number;
type: 'Attribute' | 'Spread';
name: string;
value: TemplateNode[] | boolean;
expression?: Expression;
}

View file

@ -1,35 +0,0 @@
/**
* Codegen utils
*/
import type { VariableDeclarator, CallExpression } from '@babel/types';
import type { Attribute } from './interfaces';
import type { LogOptions } from '../../logger';
import { warn } from '../../logger.js';
/** Is this an import.meta.* built-in? You can pass an optional 2nd param to see if the name matches as well. */
export function isImportMetaDeclaration(declaration: VariableDeclarator, metaName?: string): boolean {
let { init } = declaration;
if (!init) return false; // definitely not import.meta
// this could be `await import.meta`; if so, evaluate that:
if (init.type === 'AwaitExpression') {
init = init.argument;
}
// continue evaluating
if (init.type !== 'CallExpression' || init.callee.type !== 'MemberExpression' || init.callee.object.type !== 'MetaProperty') return false;
// optional: if metaName specified, match that
if (metaName && (init.callee.property.type !== 'Identifier' || init.callee.property.name !== metaName)) return false;
return true;
}
const warnableRelativeValues = new Set(['img+src', 'a+href', 'script+src', 'link+href', 'source+srcset']);
const matchesRelative = /^(?![A-Za-z][+-.0-9A-Za-z]*:|\/|#)/;
export function warnIfRelativeStringLiteral(logging: LogOptions, nodeName: string, attr: Attribute, value: string) {
let key = nodeName + '+' + attr.name;
if (warnableRelativeValues.has(key) && matchesRelative.test(value)) {
let message = `This value will be resolved relative to the page: <${nodeName} ${attr.name}="${value}">`;
warn(logging, 'relative-link', message);
}
}

View file

@ -1,258 +0,0 @@
import type { CompileResult, TransformResult } from '../@types/astro';
import type { CompileOptions } from '../@types/compiler.js';
import path from 'path';
import { MarkdownRenderingOptions, renderMarkdownWithFrontmatter } from '@astrojs/markdown-support';
import { parse } from '@astrojs/parser';
import { transform } from './transform/index.js';
import { codegen } from './codegen/index.js';
export { scopeRule } from './transform/postcss-scoped-styles/index.js';
interface ConvertAstroOptions {
compileOptions: CompileOptions;
filename: string;
fileID: string;
}
/**
* .astro -> .jsx
* Core function processing .astro files. Initiates all 3 phases of compilation:
* 1. Parse
* 2. Transform
* 3. Codegen
*/
export async function convertAstroToJsx(template: string, opts: ConvertAstroOptions): Promise<TransformResult> {
const { filename } = opts;
// 1. Parse
const ast = parse(template, {
filename,
});
// 2. Transform the AST
await transform(ast, opts);
// 3. Turn AST into JSX
return await codegen(ast, opts);
}
/**
* .md -> .astro source
*/
export async function convertMdToAstroSource(contents: string, { filename }: { filename: string }, opts?: MarkdownRenderingOptions): Promise<string> {
let {
content,
frontmatter: { layout, ...frontmatter },
...data
} = await renderMarkdownWithFrontmatter(contents, opts);
if (frontmatter['astro'] !== undefined) {
throw new Error(`"astro" is a reserved word but was used as a frontmatter value!\n\tat ${filename}`);
}
const contentData: any = {
...frontmatter,
...data,
};
// </script> 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, `</scrip" + "t>`);
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<TransformResult> {
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<TransformResult> {
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<CompileResult> {
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,
};
}

View file

@ -1,35 +0,0 @@
import { Transformer } from '../../@types/transformer';
/** Transform <!doctype> 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.
},
};
}

View file

@ -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 => (
* <link rel="stylesheet" href={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);
}
},
};
}

View file

@ -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<string, VisitorFn[]>;
leave: Map<string, VisitorFn[]>;
}
/** 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<string, VisitorFn[]>();
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<void>>) {
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<string, VisitorFn[]>(),
leave: new Map<string, VisitorFn[]>(),
};
}
/** 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, its a preprocessor.
*/
export async function transform(ast: Ast, opts: TransformOptions) {
const htmlVisitors = createVisitorCollection();
const cssVisitors = createVisitorCollection();
const finalizers: Array<() => Promise<void>> = [];
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()));
}

View file

@ -1,55 +0,0 @@
import type { Transformer } from '../../@types/transformer';
import type { CompileOptions } from '../../@types/compiler';
import { getAttrValue, setAttrValue } from '../../ast.js';
/** Transform <script type="module"> */
export default function ({ compileOptions, filename }: { compileOptions: CompileOptions; filename: string; fileID: string }): Transformer {
const { astroConfig } = compileOptions;
const fileUrl = new URL(`file://${filename}`);
return {
visitors: {
html: {
Element: {
enter(node) {
let name = node.name;
if (name !== 'script') {
return;
}
let type = getAttrValue(node.attributes, 'type');
if (type !== 'module') {
return;
}
let src = getAttrValue(node.attributes, 'src');
// scenario 1: if missing "src", ignore
if (!src) {
return;
}
// scenario 2: if absolute path, ignore
if (src.startsWith('/')) {
return;
}
// scenario 3: if remote URL, ignore
try {
new URL(src); // if this succeeds, this is a complete, valid URL
return;
} catch (err) {
// do nothing
}
const srcUrl = new URL(src, fileUrl);
const absoluteUrl = `/_astro/${srcUrl.href.replace(astroConfig.projectRoot.href, '')}`;
setAttrValue(node.attributes, 'src', absoluteUrl);
},
},
},
},
async finalize() {},
};
}

View file

@ -1,140 +0,0 @@
import { Plugin } from 'postcss';
interface AstroScopedOptions {
className: string;
}
interface Selector {
start: number;
end: number;
value: string;
}
const GLOBAL = ':global('; // custom function to prevent scoping
const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
const KEYFRAME_PERCENT = /\d+\.?\d*%/;
/** minify selector CSS */
function minifySelector(selector: string): string {
return selector.replace(/(\r?\n|\s)+/g, ' ').replace(/\s*(,|\+|>|~|\(|\))\s*/g, '$1');
}
/** find matching paren */
function matchParen(search: string, start: number): number {
if (search[start] !== '(') return -1;
let parenCount = 0;
for (let n = start + 1; n < search.length; n++) {
if (search[n] === ')' && parenCount === 0) return n;
if (search[n] === '(') parenCount += 1;
if (search[n] === ')') parenCount -= 1;
}
return -1;
}
/** HTML tags that should never get scoped classes */
export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']);
/**
* Scope Rules
* Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`)
* @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax).
* @param {string} className The CSS class to apply.
*/
export function scopeRule(selector: string, className: string) {
// if this is a keyframe keyword, return original selector
if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) {
return selector;
}
// sanitize & normalize
const input = minifySelector(selector);
// For everything else, parse & scope
const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
const selectors: Selector[] = [];
let ss = input; // sanitize
// Pass 1: parse selector string; extract top-level selectors
{
let start = 0;
let lastValue = '';
let parenCount = 0;
for (let n = 0; n < ss.length; n++) {
const isEnd = n === input.length - 1;
if (input[n] === '(') parenCount += 1;
if (input[n] === ')') parenCount -= 1;
if (isEnd || (parenCount === 0 && CSS_SEPARATORS.has(input[n]))) {
lastValue = input.substring(start, isEnd ? undefined : n);
if (!lastValue) continue;
selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
start = n + 1;
}
}
}
// Pass 2: starting from end, transform selectors w/ scoped class
for (let i = selectors.length - 1; i >= 0; i--) {
const { start, end } = selectors[i];
let value = selectors[i].value;
const head = ss.substring(0, start);
const tail = ss.substring(end);
// leave :global() alone!
if (value.includes(GLOBAL)) {
let withoutGlobal = value;
// :global() may appear multiple times; if so, extract contents of each and combine
while (withoutGlobal.includes(GLOBAL)) {
const globalStart = withoutGlobal.indexOf(GLOBAL);
const globalParenOpen = globalStart + GLOBAL.length - 1;
const globalEnd = matchParen(withoutGlobal, globalParenOpen);
const globalContents = withoutGlobal.substring(globalParenOpen + 1, globalEnd);
withoutGlobal = withoutGlobal.substring(0, globalStart) + globalContents + withoutGlobal.substring(globalEnd + 1);
}
ss = head + withoutGlobal + tail;
continue;
}
// replace '*' with scoped class
if (value.includes('*')) {
ss = head + value.replace(/\*/g, c) + tail;
continue;
}
// dont scope body, title, etc.
if (CSS_SEPARATORS.has(value) || NEVER_SCOPED_TAGS.has(value)) {
ss = head + value + tail;
continue;
}
// scope everything else (place class just before any pseudoclasses)
let pseudoclassStart = -1;
for (let n = 0; n < value.length; n++) {
// note: CSS may allow backslash-escaped colons, which does not count as a pseudoclass
if (value[n] === ':' && value[n - 1] !== '\\') {
pseudoclassStart = n;
break;
}
}
if (pseudoclassStart !== -1) {
ss = head + value.substring(0, pseudoclassStart) + c + value.substring(pseudoclassStart) + tail;
} else {
ss = head + value + c + tail;
}
}
return ss;
}
/** PostCSS Scope plugin */
export default function astroScopedStyles(options: AstroScopedOptions): Plugin {
const rulesScopedCache = new WeakSet();
return {
postcssPlugin: '@astrojs/postcss-scoped-styles',
Rule(rule) {
if (!rulesScopedCache.has(rule)) {
rule.selector = scopeRule(rule.selector, options.className);
rulesScopedCache.add(rule);
}
},
};
}

View file

@ -1,123 +0,0 @@
import type { Transformer } from '../../@types/transformer';
import type { Script, TemplateNode } from '@astrojs/parser';
import { getAttrValue } from '../../ast.js';
export const PRISM_IMPORT = `import Prism from 'astro/components/Prism.astro';`;
const prismImportExp = /import Prism from ['"]astro\/components\/Prism.astro['"]/;
/** escaping code samples that contain template string replacement parts, ${foo} or example. */
function escape(code: string) {
return code
.replace(/[`$]/g, (match) => {
return '\\' + match;
})
.replace(/ASTRO_ESCAPED_LEFT_CURLY_BRACKET\0/g, '{');
}
/** Unescape { characters transformed by Markdown generation */
function unescapeCode(code: TemplateNode) {
code.children = code.children?.map((child) => {
if (child.type === 'Text') {
return { ...child, raw: child.raw.replace(/ASTRO_ESCAPED_LEFT_CURLY_BRACKET\0/g, '{') };
}
return child;
});
}
/** default export - Transform prism */
export default function (module: Script): Transformer {
let usesPrism = false;
return {
visitors: {
html: {
Element: {
enter(node) {
if (node.name === 'code') {
unescapeCode(node);
return;
}
if (node.name !== 'pre') return;
const codeEl = node.children && node.children[0];
if (!codeEl || codeEl.name !== 'code') return;
const className = getAttrValue(codeEl.attributes, 'class') || '';
const classes = className.split(' ');
let lang: string | undefined;
for (let cn of classes) {
const matches = /language-(.+)/.exec(cn);
if (matches) {
lang = matches[1];
break;
}
}
if (!lang) return;
let classesWithoutLang = classes.filter((cn) => cn !== `language-${lang}`);
let codeData = codeEl.children && codeEl.children[0];
if (!codeData) return;
let code = codeData.data as string;
const repl = {
start: 0,
end: 0,
type: 'InlineComponent',
name: 'Prism',
attributes: [
{
type: 'Attribute',
name: 'lang',
value: [
{
type: 'Text',
raw: lang,
data: lang,
},
],
},
{
type: 'Attribute',
name: 'class',
value: [
{
type: 'Text',
raw: classesWithoutLang.join(' '),
data: classesWithoutLang.join(' '),
},
],
},
{
type: 'Attribute',
name: 'code',
value: [
{
type: 'MustacheTag',
expression: {
type: 'Expression',
codeChunks: ['`' + escape(code) + '`'],
children: [],
},
},
],
},
],
children: [],
};
this.replace(repl);
usesPrism = true;
},
},
},
},
async finalize() {
// Add the Prism import if needed.
if (usesPrism && module && !prismImportExp.test(module.content)) {
module.content = PRISM_IMPORT + '\n' + module.content;
}
},
};
}

View file

@ -1,309 +0,0 @@
import type { TransformOptions, Transformer } from '../../@types/transformer';
import type { TemplateNode } from '@astrojs/parser';
import crypto from 'crypto';
import { createRequire } from 'module';
import path from 'path';
import autoprefixer from 'autoprefixer';
import postcss, { Plugin } from 'postcss';
import postcssKeyframes from 'postcss-icss-keyframes';
import findUp from 'find-up';
import sass from 'sass';
import { error, LogOptions } from '../../logger.js';
import astroScopedStyles, { NEVER_SCOPED_TAGS } from './postcss-scoped-styles/index.js';
import slash from 'slash';
type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
declare global {
interface ImportMeta {
/** https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent */
resolve(specifier: string, parent?: string): Promise<any>;
}
}
const getStyleType: Map<string, StyleType> = new Map([
['.css', 'css'],
['.pcss', 'postcss'],
['.sass', 'sass'],
['.scss', 'scss'],
['css', 'css'],
['sass', 'sass'],
['scss', 'scss'],
['text/css', 'css'],
['text/sass', 'sass'],
['text/scss', 'scss'],
]);
/** Should be deterministic, given a unique filename */
function hashFromFilename(filename: string): string {
const hash = crypto.createHash('sha256');
return hash
.update(slash(filename))
.digest('base64')
.toString()
.replace(/[^A-Za-z0-9-]/g, '')
.substr(0, 8);
}
export interface StyleTransformResult {
css: string;
type: StyleType;
}
interface StylesMiniCache {
nodeModules: Map<string, string>; // filename: node_modules location
}
/** Simple cache that only exists in memory per-run. Prevents the same lookups from happening over and over again within the same build or dev server session. */
const miniCache: StylesMiniCache = {
nodeModules: new Map<string, string>(),
};
export interface TransformStyleOptions {
logging: LogOptions;
type?: string;
filename: string;
scopedClass: string;
tailwindConfig?: string;
global?: boolean;
}
/** given a class="" string, does it contain a given class? */
function hasClass(classList: string, className: string): boolean {
if (!className) return false;
for (const c of classList.split(' ')) {
if (className === c.trim()) return true;
}
return false;
}
/** Convert styles to scoped CSS */
async function transformStyle(code: string, { logging, type, filename, scopedClass, tailwindConfig, global }: TransformStyleOptions): Promise<StyleTransformResult> {
let styleType: StyleType = 'css'; // important: assume CSS as default
if (type) {
styleType = getStyleType.get(type) || styleType;
}
// add file path to includePaths
let includePaths: string[] = [path.dirname(filename)];
// include node_modules to includePaths (allows @use-ing node modules, if it can be located)
const cachedNodeModulesDir = miniCache.nodeModules.get(filename);
if (cachedNodeModulesDir) {
includePaths.push(cachedNodeModulesDir);
} else {
const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) });
if (nodeModulesDir) {
miniCache.nodeModules.set(filename, nodeModulesDir);
includePaths.push(nodeModulesDir);
}
}
// 1. Preprocess (currently only Sass supported)
let css = '';
switch (styleType) {
case 'css': {
css = code;
break;
}
case 'sass':
case 'scss': {
css = sass.renderSync({ data: code, includePaths, indentedSyntax: styleType === 'sass' }).css.toString('utf8');
break;
}
default: {
throw new Error(`Unsupported: <style lang="${styleType}">`);
}
}
// 2. Post-process (PostCSS)
const postcssPlugins: Plugin[] = [];
// 2a. Tailwind (only if project uses Tailwind)
if (tailwindConfig) {
try {
const require = createRequire(import.meta.url);
const tw = require.resolve('tailwindcss', { paths: [import.meta.url, process.cwd()] });
postcssPlugins.push(require(tw)(tailwindConfig) as any);
} catch (err) {
error(logging, 'transform', err);
throw new Error(`tailwindcss not installed. Try running \`npm install tailwindcss\` and trying again.`);
}
}
if (!global) {
// 2b. Astro scoped styles (skip for global style blocks)
postcssPlugins.push(astroScopedStyles({ className: scopedClass }));
// 2c. Scoped @keyframes
postcssPlugins.push(
postcssKeyframes({
generateScopedName(keyframesName) {
return `${keyframesName}-${scopedClass}`;
},
})
);
}
// 2d. Autoprefixer (always on)
postcssPlugins.push(autoprefixer());
// 2e. Run PostCSS
css = await postcss(postcssPlugins)
.process(css, { from: filename, to: undefined })
.then((result) => result.css);
return { css, type: styleType };
}
/** For a given node, inject or append a `scopedClass` to its `class` attribute */
function injectScopedClassAttribute(node: TemplateNode, scopedClass: string, attribute = 'class') {
if (!node.attributes) node.attributes = [];
const classIndex = node.attributes.findIndex(({ name }: any) => name === attribute);
if (classIndex === -1) {
// 3a. element has no class="" attribute; add one and append scopedClass
node.attributes.push({ start: -1, end: -1, type: 'Attribute', name: attribute, value: [{ type: 'Text', raw: scopedClass, data: scopedClass }] });
} else {
// 3b. element has class=""; append scopedClass
const attr = node.attributes[classIndex];
for (let k = 0; k < attr.value.length; k++) {
if (attr.value[k].type === 'Text') {
// dont add same scopedClass twice
if (!hasClass(attr.value[k].data, scopedClass)) {
// string literal
attr.value[k].raw += ' ' + scopedClass;
attr.value[k].data += ' ' + scopedClass;
}
} else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) {
// dont add same scopedClass twice (this check is a little more basic, but should suffice)
if (!attr.value[k].expression.codeChunks[0].includes(`' ${scopedClass}'`)) {
// MustacheTag
// FIXME: this won't work when JSX element can appear in attributes (rare but possible).
attr.value[k].expression.codeChunks[0] = `(${attr.value[k].expression.codeChunks[0]}) + ' ${scopedClass}'`;
}
}
}
}
}
/** Transform <style> tags */
export default function transformStyles({ compileOptions, filename, fileID }: TransformOptions): Transformer {
const styleNodes: TemplateNode[] = []; // <style> tags to be updated
const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize();
const scopedClass = `astro-${hashFromFilename(fileID)}`; // this *should* generate same hash from fileID every time
const nodesToScope = new Set<TemplateNode>();
return {
visitors: {
html: {
InlineComponent: {
enter(node) {
if (node.name === 'Markdown') {
injectScopedClassAttribute(node, scopedClass, '$scope');
}
for (let attr of node.attributes) {
if (attr.name === 'class') {
injectScopedClassAttribute(node, scopedClass, 'class');
break;
}
}
},
},
Element: {
enter(node) {
// 1. if <style> tag, transform it and continue to next node
if (node.name === 'style') {
// Same as ast.css (below)
const code = Array.isArray(node.children) ? node.children.map(({ data }: any) => data).join('\n') : '';
if (!code) return;
const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
const globalAttr = (node.attributes || []).find(({ name }: any) => name === 'global');
styleNodes.push(node);
styleTransformPromises.push(
transformStyle(code, {
logging: compileOptions.logging,
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
scopedClass,
tailwindConfig: compileOptions.astroConfig.devOptions.tailwindConfig,
global: globalAttr && globalAttr.value,
})
);
return;
}
// 2. add scoped HTML classes
if (NEVER_SCOPED_TAGS.has(node.name) || node.name.toLowerCase() === '!doctype') {
return; // only continue if this is NOT a <script> tag, etc.
}
// Note: currently we _do_ scope web components/custom elements. This seems correct?
nodesToScope.add(node);
},
},
},
// CSS: compile styles, apply CSS Modules scoping
css: {
Style: {
enter(node) {
// Same as ast.html (above)
// Note: this is duplicated from html because of the compiler were using; in a future version we should combine these
if (!node.content || !node.content.styles) return;
const code = node.content.styles;
const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
const globalAttr = (node.attributes || []).find(({ name }: any) => name === 'global');
styleNodes.push(node);
styleTransformPromises.push(
transformStyle(code, {
logging: compileOptions.logging,
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
scopedClass,
global: globalAttr && globalAttr.value,
})
);
},
},
},
},
async finalize() {
const styleTransforms = await Promise.all(styleTransformPromises);
// If we DO have styles, let's inject the scoped `class` attribute
// Otherwise, our final optimization is easier if we skip this
if (styleTransforms.length > 0) {
for (const node of nodesToScope.values()) {
injectScopedClassAttribute(node, scopedClass);
}
}
styleTransforms.forEach((result, n) => {
if (styleNodes[n].attributes) {
// 1. Replace with final CSS
const isHeadStyle = !styleNodes[n].content;
if (isHeadStyle) {
// Note: <style> tags in <head> have different attributes/rules, because of the parser. Unknown why
(styleNodes[n].children as any) = [{ ...(styleNodes[n].children as any)[0], data: result.css }];
} else {
styleNodes[n].content.styles = result.css;
}
// 2. Update <style> attributes
const styleTypeIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'type');
// add type="text/css"
if (styleTypeIndex !== -1) {
styleNodes[n].attributes[styleTypeIndex].value[0].raw = 'text/css';
styleNodes[n].attributes[styleTypeIndex].value[0].data = 'text/css';
} else {
styleNodes[n].attributes.push({ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] });
}
// remove lang="*"
const styleLangIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'lang');
if (styleLangIndex !== -1) styleNodes[n].attributes.splice(styleLangIndex, 1);
// TODO: add data-astro for later
// styleNodes[n].attributes.push({ name: 'data-astro', type: 'Attribute', value: true });
}
});
},
};
}

View file

@ -1,96 +0,0 @@
import type { TemplateNode } from '@astrojs/parser';
const beforeHeadElements = new Set(['!doctype', 'html']);
const validHeadElements = new Set(['title', 'meta', 'link', 'style', 'script', 'noscript', 'base']);
export class EndOfHead {
private html: TemplateNode | null = null;
private head: TemplateNode | null = null;
private firstNonHead: TemplateNode | null = null;
private parent: TemplateNode | null = null;
private stack: TemplateNode[] = [];
public foundHeadElements = false;
public foundBodyElements = false;
public append: (...node: TemplateNode[]) => void = () => void 0;
get found(): boolean {
return !!(this.head || this.firstNonHead);
}
get foundHeadContent(): boolean {
return !!this.head || this.foundHeadElements;
}
get foundHeadAndBodyContent(): boolean {
return this.foundHeadContent && this.foundBodyElements;
}
get foundHeadOrHtmlElement(): boolean {
return !!(this.html || this.head);
}
enter(node: TemplateNode) {
const name = node.name ? node.name.toLowerCase() : null;
if (this.found) {
if (!validHeadElements.has(name)) {
if (node.type === 'Element') {
this.foundBodyElements = true;
}
}
return;
}
this.stack.push(node);
// Fragment has no name
if (!node.name) {
return;
}
if (name === 'head') {
this.head = node;
this.parent = this.stack[this.stack.length - 2];
this.append = this.appendToHead;
return;
}
// Skip !doctype and html elements
if (beforeHeadElements.has(name)) {
if (name === 'html') {
this.html = node;
}
return;
}
if (!validHeadElements.has(name)) {
if (node.type === 'Element') {
this.foundBodyElements = true;
}
this.firstNonHead = node;
this.parent = this.stack[this.stack.length - 2];
this.append = this.prependToFirstNonHead;
return;
} else {
this.foundHeadElements = true;
}
}
leave(_node: TemplateNode) {
this.stack.pop();
}
private appendToHead(...nodes: TemplateNode[]) {
if (this.head) {
const head = this.head;
head.children = head.children ?? [];
head.children.push(...nodes);
}
}
private prependToFirstNonHead(...nodes: TemplateNode[]) {
let idx: number = (this.firstNonHead && this.parent?.children?.indexOf(this.firstNonHead)) || 0;
this.parent?.children?.splice(idx, 0, ...nodes);
}
}

View file

@ -1,93 +0,0 @@
/** Is the given string a custom-element tag? */
export function isCustomElementTag(tag: string) {
return /[-]/.test(tag);
}
/** Is the given string a valid component tag */
export function isComponentTag(tag: string) {
return /^[A-Z]/.test(tag) || /^[a-z]+\./.test(tag) || isCustomElementTag(tag);
}
export interface Position {
line: number;
character: number;
}
/** Clamps a number between min and max */
export function clamp(num: number, min: number, max: number): number {
return Math.max(min, Math.min(max, num));
}
/**
* Get the line and character based on the offset
* @param offset The index of the position
* @param text The text for which the position should be retrived
*/
export function positionAt(offset: number, text: string): Position {
offset = clamp(offset, 0, text.length);
const lineOffsets = getLineOffsets(text);
let low = 0;
let high = lineOffsets.length;
if (high === 0) {
return { line: 0, character: offset };
}
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (lineOffsets[mid] > offset) {
high = mid;
} else {
low = mid + 1;
}
}
// low is the least x for which the line offset is larger than the current offset
// or array.length if no line offset is larger than the current offset
const line = low - 1;
return { line, character: offset - lineOffsets[line] };
}
/**
* Get the offset of the line and character position
* @param position Line and character position
* @param text The text for which the offset should be retrived
*/
export function offsetAt(position: Position, text: string): number {
const lineOffsets = getLineOffsets(text);
if (position.line >= lineOffsets.length) {
return text.length;
} else if (position.line < 0) {
return 0;
}
const lineOffset = lineOffsets[position.line];
const nextLineOffset = position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : text.length;
return clamp(nextLineOffset, lineOffset, lineOffset + position.character);
}
/** Get the offset of all lines */
function getLineOffsets(text: string) {
const lineOffsets = [];
let isLineStart = true;
for (let i = 0; i < text.length; i++) {
if (isLineStart) {
lineOffsets.push(i);
isLineStart = false;
}
const ch = text.charAt(i);
isLineStart = ch === '\r' || ch === '\n';
if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') {
i++;
}
}
if (isLineStart && text.length > 0) {
lineOffsets.push(text.length);
}
return lineOffsets;
}

View file

@ -1,11 +1,12 @@
import type { AstroConfig } from './@types/astro';
import { existsSync } from 'fs';
import getPort from 'get-port';
import * as colors from 'kleur/colors';
import path from 'path';
import { pathToFileURL } from 'url';
import { z } from 'zod';
import { AstroConfig, AstroUserConfig } from './@types/astro';
import { addTrailingSlash } from './util.js';
import { AstroUserConfig } from './@types/config';
export const AstroConfigSchema = z.object({
projectRoot: z
@ -101,10 +102,25 @@ export async function validateConfig(userConfig: any, root: string): Promise<Ast
return AstroConfigRelativeSchema.parseAsync(userConfig);
}
interface LoadConfigOptions {
cwd?: string;
filename?: string;
}
/** Adds '/' to end of string but doesnt double-up */
function addTrailingSlash(str: string): string {
return str.replace(/\/+?$/, '/');
}
interface LoadConfigOptions {
cwd?: string;
filename?: string;
}
/** Attempt to load an `astro.config.mjs` file */
export async function loadConfig(rawRoot: string | undefined, configFileName = 'astro.config.mjs'): Promise<AstroConfig> {
const root = rawRoot ? path.resolve(rawRoot) : process.cwd();
const astroConfigPath = new URL(`./${configFileName}`, `file://${root}/`);
export async function loadConfig(options: LoadConfigOptions): Promise<AstroConfig> {
const root = options.cwd ? path.resolve(options.cwd) : process.cwd();
const astroConfigPath = new URL(`./${options.filename || 'astro.config.mjs'}`, `file://${root}/`);
let userConfig: AstroUserConfig = {};
// Load a user-config, if one exists and is provided
if (existsSync(astroConfigPath)) {

View file

@ -1,180 +0,0 @@
import type { ServerRuntime as SnowpackServerRuntime, PluginLoadOptions } from 'snowpack';
import type { AstroConfig } from './@types/astro';
import { posix as path } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import resolve from 'resolve';
import { loadConfig } from './config.js';
type RendererSnowpackPlugin = string | [string, any] | undefined;
interface RendererInstance {
name: string;
options: any;
snowpackPlugin: RendererSnowpackPlugin;
client: string | null;
server: string;
knownEntrypoints: string[] | undefined;
external: string[] | undefined;
polyfills: string[];
hydrationPolyfills: string[];
jsxImportSource?: string;
jsxTransformOptions?: (
transformContext: Omit<PluginLoadOptions, 'filePath' | 'fileExt'>
) => undefined | { plugins?: any[]; presets?: any[] } | Promise<{ plugins?: any[]; presets?: any[] }>;
}
const CONFIG_MODULE_BASE_NAME = '__astro_config.js';
const CONFIG_MODULE_URL = `/_astro_frontend/${CONFIG_MODULE_BASE_NAME}`;
const DEFAULT_RENDERERS = ['@astrojs/renderer-vue', '@astrojs/renderer-svelte', '@astrojs/renderer-react', '@astrojs/renderer-preact'];
export class ConfigManager {
private state: 'initial' | 'dirty' | 'clean' = 'initial';
public snowpackRuntime: SnowpackServerRuntime | null = null;
public configModuleId: string | null = null;
private rendererNames!: string[];
private version = 1;
constructor(private astroConfig: AstroConfig, private resolvePackageUrl: (pkgName: string) => Promise<string>) {
this.setRendererNames(this.astroConfig);
}
markDirty() {
this.state = 'dirty';
}
async update() {
if (this.needsUpdate() && this.snowpackRuntime) {
// astro.config.mjs has changed, reload it.
if (this.state === 'dirty') {
const version = this.version++;
const astroConfig = await loadConfig(this.astroConfig.projectRoot.pathname, `astro.config.mjs?version=${version}`);
this.setRendererNames(astroConfig);
}
await this.importModule(this.snowpackRuntime);
this.state = 'clean';
}
}
isConfigModule(fileExt: string, filename: string) {
return fileExt === '.js' && filename.endsWith(CONFIG_MODULE_BASE_NAME);
}
isAstroConfig(filename: string) {
const { projectRoot } = this.astroConfig;
return new URL('./astro.config.mjs', projectRoot).pathname === filename;
}
async buildRendererInstances(): Promise<RendererInstance[]> {
const { projectRoot } = this.astroConfig;
const rendererNames = this.rendererNames;
const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) });
const rendererInstances = (
await Promise.all(
rendererNames.map(async (rendererName) => {
let _options: any = null;
if (Array.isArray(rendererName)) {
_options = rendererName[1];
rendererName = rendererName[0];
}
const entrypoint = pathToFileURL(resolveDependency(rendererName)).toString();
const r = await import(entrypoint);
return {
raw: r.default,
options: _options,
};
})
)
).map(({ raw, options }, i) => {
const { name = rendererNames[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw;
if (typeof client !== 'string' && client != null) {
throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`);
}
if (typeof server !== 'string') {
throw new Error(`Expected "server" from ${name} to be a relative path to the server-side renderer!`);
}
let snowpackPlugin: RendererSnowpackPlugin;
if (typeof snowpackPluginName === 'string') {
if (snowpackPluginOptions) {
snowpackPlugin = [resolveDependency(snowpackPluginName), snowpackPluginOptions];
} else {
snowpackPlugin = resolveDependency(snowpackPluginName);
}
} else if (snowpackPluginName) {
throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`);
}
const polyfillsNormalized = (raw.polyfills || []).map((p: string) => (p.startsWith('.') ? path.join(name, p) : p));
const hydrationPolyfillsNormalized = (raw.hydrationPolyfills || []).map((p: string) => (p.startsWith('.') ? path.join(name, p) : p));
return {
name,
options,
snowpackPlugin,
client: raw.client ? path.join(name, raw.client) : null,
server: path.join(name, raw.server),
knownEntrypoints: raw.knownEntrypoints,
external: raw.external,
polyfills: polyfillsNormalized,
hydrationPolyfills: hydrationPolyfillsNormalized,
jsxImportSource: raw.jsxImportSource,
};
});
return rendererInstances;
}
async getRenderers(): Promise<RendererInstance[]> {
const renderers = await this.buildRendererInstances();
return renderers;
}
async buildSource(contents: string): Promise<string> {
const renderers = await this.buildRendererInstances();
const rendererServerPackages = renderers.map(({ server }) => server);
const rendererClientPackages = await Promise.all(
renderers.filter((instance): instance is RendererInstance & { client: string } => !!instance.client).map(({ client }) => this.resolvePackageUrl(client))
);
const rendererPolyfills = await Promise.all(renderers.map(({ polyfills }) => Promise.all(polyfills.map((src) => this.resolvePackageUrl(src)))));
const rendererHydrationPolyfills = await Promise.all(renderers.map(({ hydrationPolyfills }) => Promise.all(hydrationPolyfills.map((src) => this.resolvePackageUrl(src)))));
const result = /* js */ `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')}
import { setRenderers } from 'astro/dist/internal/__astro_component.js';
let rendererInstances = [${renderers
.map(
(r, i) => `{
name: "${r.name}",
source: ${rendererClientPackages[i] ? `"${rendererClientPackages[i]}"` : 'null'},
renderer: typeof __renderer_${i} === 'function' ? __renderer_${i}(${r.options ? JSON.stringify(r.options) : 'null'}) : __renderer_${i},
polyfills: ${JSON.stringify(rendererPolyfills[i])},
hydrationPolyfills: ${JSON.stringify(rendererHydrationPolyfills[i])}
}`
)
.join(', ')}];
${contents}
`;
return result;
}
needsUpdate(): boolean {
return this.state === 'initial' || this.state === 'dirty';
}
private setRendererNames(astroConfig: AstroConfig) {
this.rendererNames = astroConfig.renderers || DEFAULT_RENDERERS;
}
private async importModule(snowpackRuntime: SnowpackServerRuntime): Promise<void> {
await snowpackRuntime.importModule(CONFIG_MODULE_URL);
}
}

View file

@ -1,109 +0,0 @@
import type { AstroConfig } from './@types/astro';
import type { LogOptions } from './logger.js';
import { green } from 'kleur/colors';
import http from 'http';
import path from 'path';
import { performance } from 'perf_hooks';
import { defaultLogDestination, defaultLogLevel, debug, error, info, parseError } from './logger.js';
import { createRuntime } from './runtime.js';
import { stopTimer } from './build/util.js';
const logging: LogOptions = {
level: defaultLogLevel,
dest: defaultLogDestination,
};
/** The primary dev action */
export default async function dev(astroConfig: AstroConfig) {
const startServerTime = performance.now();
const { projectRoot } = astroConfig;
const timer: Record<string, number> = {};
timer.runtime = performance.now();
const runtime = await createRuntime(astroConfig, { mode: 'development', logging });
debug(logging, 'dev', `runtime created [${stopTimer(timer.runtime)}]`);
const server = http.createServer(async (req, res) => {
timer.load = performance.now();
const result = await runtime.load(req.url);
debug(logging, 'dev', `loaded ${req.url} [${stopTimer(timer.load)}]`);
switch (result.statusCode) {
case 200: {
if (result.contentType) {
res.setHeader('Content-Type', result.contentType);
}
res.statusCode = 200;
res.write(result.contents);
res.end();
break;
}
case 404: {
const { hostname, port } = astroConfig.devOptions;
const fullurl = new URL(req.url || '/', astroConfig.buildOptions.site || `http://${hostname}:${port}`);
const reqPath = decodeURI(fullurl.pathname);
error(logging, 'access', 'Not Found:', reqPath);
res.statusCode = 404;
const fourOhFourResult = await runtime.load('/404');
if (fourOhFourResult.statusCode === 200) {
if (fourOhFourResult.contentType) {
res.setHeader('Content-Type', fourOhFourResult.contentType);
}
res.write(fourOhFourResult.contents);
} else {
res.setHeader('Content-Type', 'text/plain');
res.write('Not Found');
}
res.end();
break;
}
case 500: {
res.setHeader('Content-Type', 'text/html;charset=utf-8');
switch (result.type) {
case 'parse-error': {
const err = result.error;
if (err.filename) err.filename = path.posix.relative(projectRoot.pathname, err.filename);
parseError(logging, err);
break;
}
default: {
error(logging, 'executing astro', result.error);
break;
}
}
res.statusCode = 500;
let errorResult = await runtime.load(`/500?error=${encodeURIComponent(result.error.stack || result.error.toString())}`);
if (errorResult.statusCode === 200) {
if (errorResult.contentType) {
res.setHeader('Content-Type', errorResult.contentType);
}
res.write(errorResult.contents);
} else {
res.write(result.error.toString());
}
res.end();
break;
}
}
});
const { hostname, port } = astroConfig.devOptions;
server
.listen(port, hostname, () => {
const endServerTime = performance.now();
info(logging, 'dev server', green(`Server started in ${Math.floor(endServerTime - startServerTime)}ms.`));
info(logging, 'dev server', `${green('Local:')} http://${hostname}:${port}/`);
})
.on('error', (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {
error(logging, 'dev server', `Address ${hostname}:${port} already in use. Try changing devOptions.port in your config file`);
} else {
error(logging, 'dev server', err.stack);
}
process.exit(1);
});
}

View file

@ -0,0 +1,219 @@
import type { NextFunction } from 'connect';
import type http from 'http';
import type { AstroConfig, ManifestData, RouteCache } from '../@types/astro';
import type { LogOptions } from '../logger';
import chokidar from 'chokidar';
import connect from 'connect';
import mime from 'mime';
import { performance } from 'perf_hooks';
import { fileURLToPath } from 'url';
import vite from 'vite';
import { defaultLogOptions, error, info } from '../logger.js';
import { createRouteManifest, matchRoute } from '../runtime/routing.js';
import { ssr } from '../runtime/ssr.js';
import { loadViteConfig } from '../runtime/vite/config.js';
import * as msg from './messages.js';
import { errorTemplate } from './template/error.js';
export interface DevOptions {
logging: LogOptions;
}
interface DevServer {
hostname: string;
port: number;
server: connect.Server;
stop(): Promise<void>;
}
/** `astro dev` */
export default async function dev(config: AstroConfig, options: DevOptions = { logging: defaultLogOptions }): Promise<DevServer> {
// start dev server
const server = new AstroDevServer(config, options);
await server.start();
// attempt shutdown
process.on('SIGTERM', () => server.stop());
return {
hostname: server.hostname,
port: server.port,
server: server.app,
stop: server.stop,
};
}
/** Dev server */
class AstroDevServer {
app = connect();
hostname: string;
port: number;
private config: AstroConfig;
private logging: LogOptions;
private manifest: ManifestData;
private origin: string;
private routeCache: RouteCache = {};
private viteServer: vite.ViteDevServer | undefined;
private watcher: chokidar.FSWatcher;
constructor(config: AstroConfig, options: DevOptions) {
this.config = config;
this.hostname = config.devOptions.hostname || 'localhost';
this.logging = options.logging;
this.port = config.devOptions.port;
this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${this.port}`;
this.manifest = createRouteManifest({ config });
// rebuild manifest on change (watch all events, but only rebuild if .astro or .md files are touched)
this.watcher = chokidar.watch(fileURLToPath(config.pages), { ignored: ['!**/*.astro', '!**/*.md', '**'] }); // ignore everything but .astro & .md
this.watcher.on('add', () => {
this.routeCache = {};
this.manifest = createRouteManifest({ config: this.config });
});
this.watcher.on('unlink', () => {
this.routeCache = {};
this.manifest = createRouteManifest({ config: this.config });
});
this.watcher.on('change', () => {
this.routeCache = {}; // note: manifests dont need to be rebuilt on file content changes
});
}
/** Start dev server */
async start() {
// 1. profile startup time
const devStart = performance.now();
// 2. create Vite instance
const viteConfig = await loadViteConfig(
{
mode: 'development',
server: {
middlewareMode: 'ssr',
host: this.hostname,
},
},
{ astroConfig: this.config, logging: this.logging }
);
this.viteServer = await vite.createServer(viteConfig);
// 3. add middlewares
this.app.use((req, res, next) => this.handleRequest(req, res, next));
this.app.use(this.viteServer.middlewares);
this.app.use((req, res, next) => this.renderError(req, res, next));
// 4. listen on port
await new Promise<void>((resolve, reject) => {
this.app
.listen(this.port, this.hostname, () => {
info(this.logging, 'astro', msg.devStart({ startupTime: performance.now() - devStart }));
info(this.logging, 'astro', msg.devHost({ host: `http://${this.hostname}:${this.port}` }));
resolve();
})
.on('error', (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {
error(this.logging, 'astro', `Address ${this.hostname}:${this.port} already in use. Try changing devOptions.port in your config file`);
} else {
error(this.logging, 'astro', err.stack);
}
reject();
process.exit(1);
});
});
}
/** Stop dev server */
async stop() {
await Promise.all([
...(this.viteServer ? [this.viteServer.close()] : []), // close Vite server
this.watcher.close(), // close chokidar
]);
}
/** The primary router (runs before Vite, in case we need to modify or intercept anything) */
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) {
if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`);
let pathname = req.url || '/'; // original request
const reqStart = performance.now();
try {
const route = matchRoute(pathname, this.manifest);
// 404: continue to Vite
if (!route) {
next();
return;
}
// handle .astro and .md pages
const html = await ssr({
filePath: new URL(`./${route.component}`, this.config.projectRoot),
logging: this.logging,
mode: 'development',
origin: this.origin,
pathname,
route,
routeCache: this.routeCache,
viteServer: this.viteServer,
});
info(this.logging, 'astro', msg.req({ url: pathname, statusCode: 200, reqTime: performance.now() - reqStart }));
res.writeHead(200, {
'Content-Type': mime.getType('.html') as string,
'Content-Length': Buffer.byteLength(html, 'utf8'),
});
res.write(html);
res.end();
} catch (e) {
const err = e as Error;
this.viteServer.ssrFixStacktrace(err);
console.error(err.stack);
const statusCode = 500;
const html = errorTemplate({ statusCode, title: 'Internal Error', tabTitle: '500: Error', message: err.message });
info(this.logging, 'astro', msg.req({ url: pathname, statusCode: 500, reqTime: performance.now() - reqStart }));
res.writeHead(statusCode, {
'Content-Type': mime.getType('.html') as string,
'Content-Length': Buffer.byteLength(html, 'utf8'),
});
res.write(html);
res.end();
}
}
/** Render error page */
private async renderError(req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) {
if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`);
const pathname = req.url || '/';
const reqStart = performance.now();
let html = '';
const statusCode = 404;
// attempt to load user-given page
const relPages = this.config.pages.href.replace(this.config.projectRoot.href, '');
const userDefined404 = this.manifest.routes.find((route) => route.component === relPages + '404.astro');
if (userDefined404) {
html = await ssr({
filePath: new URL(`./${userDefined404.component}`, this.config.projectRoot),
logging: this.logging,
mode: 'development',
pathname: `/${userDefined404.component}`,
origin: this.origin,
routeCache: this.routeCache,
viteServer: this.viteServer,
});
}
// if not found, fall back to default template
else {
html = errorTemplate({ statusCode, title: 'Not found', tabTitle: '404: Not Found', message: pathname });
}
info(this.logging, 'astro', msg.req({ url: pathname, statusCode, reqTime: performance.now() - reqStart }));
res.writeHead(statusCode, {
'Content-Type': mime.getType('.html') as string,
'Content-Length': Buffer.byteLength(html, 'utf8'),
});
res.write(html);
res.end();
}
}

View file

@ -0,0 +1,26 @@
/**
* Dev server messages (organized here to prevent clutter)
*/
import { bold, dim, green, magenta, yellow } from 'kleur/colors';
import { pad } from './util.js';
/** Display */
export function req({ url, statusCode, reqTime }: { url: string; statusCode: number; reqTime: number }): string {
let color = dim;
if (statusCode >= 500) color = magenta;
else if (statusCode >= 400) color = yellow;
else if (statusCode >= 300) color = dim;
else if (statusCode >= 200) color = green;
return `${color(statusCode)} ${pad(url, 40)} ${dim(Math.round(reqTime) + 'ms')}`;
}
/** Display dev server host and startup time */
export function devStart({ startupTime }: { startupTime: number }): string {
return `${pad(`Server started`, 44)} ${dim(`${Math.round(startupTime)}ms`)}`;
}
/** Display dev server host */
export function devHost({ host }: { host: string }): string {
return `Local: ${bold(magenta(host))}`;
}

View file

@ -0,0 +1,51 @@
interface ErrorTemplateOptions {
statusCode?: number;
tabTitle: string;
title: string;
message: string;
}
/** Display internal 404 page (if user didnt provide one) */
export function errorTemplate({ title, message, statusCode, tabTitle }: ErrorTemplateOptions): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${tabTitle}</title>
<style>
body {
background-color: #101010;
color: #d0d0d0;
font-family: monospace;
line-height: 1.6;
margin: 0;
}
.wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
h1 {
font-weight: 800;
margin-top: 1rem;
margin-bottom: 0;
}
p {
color: #999;
font-size: 1.4em;
margin-top: 0;
max-width: 60em;
}
.status {
opacity: 0.7;
}
</style>
</head>
<body>
<main class="wrapper">
<h1>${statusCode ? `<span class="statusCode">${statusCode}</span> ` : ''}${title}</h1>
<p>${message.replace(/\n/g, '<br>')}</p>
</main>
</body>
</html>
`;
}

View file

@ -0,0 +1,8 @@
/** Pad string () */
export function pad(input: string, minLength: number, dir?: 'left' | 'right'): string {
let output = input;
while (output.length < minLength) {
output = dir === 'left' ? ' ' + output : output + ' ';
}
return output;
}

View file

@ -1,35 +0,0 @@
import { createRequire } from 'module';
import { nodeBuiltinsMap } from './node_builtins.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
/**
* This file allows us to automatically exclude
* particular packages from Snowpack's `esinstall`
* step.
*/
// These packages SHOULD be built by `esinstall`
const allowList = new Set(['astring', '@astrojs/prism', 'estree-util-value-to-estree', 'prismjs', 'shorthash']);
const isAstroRenderer = (name: string) => {
return name.startsWith(`@astrojs/renderer-`);
};
// These packages should NOT be built by `esinstall`
// But might not be explicit dependencies of `astro`
const denyList = ['prismjs/components/index.js', '@astrojs/markdown-support', 'node:fs/promises', ...nodeBuiltinsMap.values()];
export default Object.keys(pkg.dependencies)
// Filter out packages that should be loaded through Snowpack
.filter((name) => {
// Explicitly allowed packages should NOT be external
if (allowList.has(name)) return false;
// Astro renderers should NOT be external
if (isAstroRenderer(name)) return false;
// Everything else SHOULD be external
return true;
})
// Add extras
.concat(denyList)
.sort();

View file

@ -1,128 +0,0 @@
---
import { Prism } from 'astro/components';
let title = 'Uh oh...';
const error = Astro.request.url.searchParams.get('error');
---
<!doctype html>
<html lang="en">
<head>
<title>Error 500</title>
<link rel="preconnect"href="https://fonts.gstatic.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=IBM+Plex+Sans:wght@400;700&display=swap">
<link rel="stylesheet" href="http://cdn.skypack.dev/prism-themes/themes/prism-material-dark.css">
<style>
* {
box-sizing: border-box;
margin: 0;
}
:global(:root) {
--font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--font-mono: "IBM Plex Mono", Consolas, "Andale Mono WT", "Andale Mono",
"Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono",
"Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco,
"Courier New", Courier, monospace;
--color-gray-800: #1F2937;
--color-gray-500: #6B7280;
--color-gray-400: #9CA3AF;
--color-gray-100: #F3F4F6;
--color-red: #FF1639;
}
html, body {
width: 100vw;
height: 100%;
min-height: 100vh;
font-family: var(--font-sans);
font-weight: 400;
background: var(--color-gray-100);
text-align: center;
}
body {
display: grid;
place-content: center;
}
header {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-sans);
font-size: 2.5rem;
font-size: clamp(24px, calc(2vw + 1rem), 2.5rem);
}
header h1 {
margin: 0.25em;
margin-right: 0;
font-weight: 400;
letter-spacing: -2px;
line-height: 1;
}
header h1 .title {
color: var(--color-gray-400);
white-space: nowrap;
}
header svg {
margin-bottom: -0.125em;
color: var(--color-red);
}
p {
font-size: 1.75rem;
font-size: clamp(14px, calc(2vw + 0.5rem), 1.75rem);
flex: 1;
}
.error-message {
display: grid;
justify-content: center;
margin-top: 4rem;
}
.error-message :global(code[class*="language-"]) {
background: var(--color-gray-800);
}
.error-message :global(pre) {
margin: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
background: var(--color-gray-800);
border-radius: 8px;
}
.error-message :global(.token.punctuation) {
color: var(--color-gray-400);
}
.error-message :global(.token.operator) {
color: var(--color-gray-400);
}
</style>
</head>
<body>
<main>
<header>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" width="1.75em" height="1.75em">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h1><span class="error">500 Error </span><span class="title">{title}</span></h1>
</header>
<article>
<p>Astro had some trouble loading this page.</p>
<div class="error-message">
<Prism lang="shell" code={error} />
</div>
</article>
</main>
</body>
</html>

View file

@ -1,6 +0,0 @@
import type { RendererInstance } from '../internal/__astro_component';
declare function setRenderers(instances: RendererInstance[]): void;
declare let rendererInstances: RendererInstance[];
setRenderers(rendererInstances);

View file

@ -1,13 +1,16 @@
import type { CompileError } from '@astrojs/parser';
import { bold, blue, red, grey, underline, yellow } from 'kleur/colors';
import { bold, blue, dim, red, grey, underline, yellow } from 'kleur/colors';
import { Writable } from 'stream';
import { format as utilFormat } from 'util';
import stringWidth from 'string-width';
import { format as utilFormat } from 'util';
type ConsoleStream = Writable & {
fd: 1 | 2;
};
const dt = new Intl.DateTimeFormat(process.env.LANG ? process.env.LANG.split('.')[0].replace(/_/g, '-') : 'en-US', { hour: '2-digit', minute: '2-digit' });
export const defaultLogDestination = new Writable({
objectMode: true,
write(event: LogMessage, _, callback) {
@ -15,6 +18,9 @@ export const defaultLogDestination = new Writable({
if (levels[event.level] < levels['error']) {
dest = process.stdout;
}
dest.write(dim(dt.format(new Date()) + ' '));
let type = event.type;
if (type !== null) {
if (event.level === 'info') {
@ -133,11 +139,8 @@ export function parseError(opts: LogOptions, err: CompileError) {
opts,
'parse-error',
`
${underline(bold(grey(`${err.filename || ''}:${err.start.line}:${err.start.column}`)))}
${bold(red(`𝘅 ${err.message}`))}
${frame}
`
);

View file

@ -1,4 +0,0 @@
import { builtinModules } from 'module';
export const nodeBuiltinsSet = new Set(builtinModules);
export const nodeBuiltinsMap = new Map(builtinModules.map((bareName) => [bareName, 'node:' + bareName]));

View file

@ -1,31 +1,38 @@
import type { AstroConfig } from '../@types/astro';
import type { LogOptions } from '../logger.js';
import http from 'http';
import { green } from 'kleur/colors';
import { performance } from 'perf_hooks';
import send from 'send';
import { fileURLToPath } from 'url';
import type { AstroConfig } from './@types/astro';
import type { LogOptions } from './logger.js';
import { defaultLogDestination, defaultLogLevel, error, info } from './logger.js';
import * as msg from '../dev/messages.js';
import { error, info } from '../logger.js';
const logging: LogOptions = {
level: defaultLogLevel,
dest: defaultLogDestination,
};
interface PreviewOptions {
logging: LogOptions;
}
/** The primary dev action */
export async function preview(astroConfig: AstroConfig) {
export async function preview(config: AstroConfig, { logging }: PreviewOptions) {
const {
dist,
devOptions: { hostname, port },
} = config;
const startServerTime = performance.now();
const { hostname, port } = astroConfig.devOptions;
// Create the preview server, send static files out of the `dist/` directory.
const server = http.createServer((req, res) => {
send(req, req.url!, { root: fileURLToPath(astroConfig.dist) }).pipe(res);
send(req, req.url!, {
root: fileURLToPath(dist),
}).pipe(res);
});
// Start listening on `hostname:port`.
return server
.listen(port, hostname, () => {
const endServerTime = performance.now();
info(logging, 'preview', green(`Preview server started in ${Math.floor(endServerTime - startServerTime)}ms.`));
info(logging, 'preview', `${green('Local:')} http://${hostname}:${port}/`);
info(logging, 'preview', msg.devStart({ startupTime: performance.now() - startServerTime }));
info(logging, 'preview', msg.devHost({ host: `http://${hostname}:${port}/` }));
})
.on('error', (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {

View file

@ -1,18 +0,0 @@
import type { LogOptions } from './logger';
import { clearCache } from 'snowpack';
import { defaultLogDestination, defaultLogLevel, info } from './logger.js';
const logging: LogOptions = {
level: defaultLogLevel,
dest: defaultLogDestination,
};
export async function reload() {
try {
info(logging, 'reload', `Clearing the cache...`);
await clearCache();
return 0;
} catch {
return 1;
}
}

View file

@ -1,443 +0,0 @@
import type { CompileError as ICompileError } from '@astrojs/parser';
import parser from '@astrojs/parser';
import { existsSync, promises as fs } from 'fs';
import { posix as path } from 'path';
import { performance } from 'perf_hooks';
import resolve from 'resolve';
import {
loadConfiguration,
logger as snowpackLogger,
NotFoundError,
ServerRuntime as SnowpackServerRuntime,
SnowpackConfig,
SnowpackDevServer,
startServer as startSnowpackServer,
} from 'snowpack';
import { fileURLToPath } from 'url';
import { z } from 'zod';
import type { AstroConfig, GetStaticPathsArgs, GetStaticPathsResult, ManifestData, Params, RSSFunctionArgs, RuntimeMode } from './@types/astro';
import { generatePaginateFunction } from './build/paginate.js';
import { canonicalURL, getSrcPath, stopTimer } from './build/util.js';
import { formatConfigError } from './config.js';
import { ConfigManager } from './config_manager.js';
import snowpackExternals from './external.js';
import { debug, info, LogOptions } from './logger.js';
import { createManifest } from './manifest/create.js';
import { nodeBuiltinsMap } from './node_builtins.js';
import { configureSnowpackLogger } from './snowpack-logger.js';
import { convertMatchToLocation, validateGetStaticPathsModule, validateGetStaticPathsResult } from './util.js';
const { CompileError } = parser;
export interface AstroRuntimeConfig {
astroConfig: AstroConfig;
cache: { staticPaths: Record<string, Promise<GetStaticPathsResult>> };
logging: LogOptions;
mode: RuntimeMode;
snowpack: SnowpackDevServer;
snowpackRuntime: SnowpackServerRuntime;
snowpackConfig: SnowpackConfig;
configManager: ConfigManager;
manifest: ManifestData;
}
type LoadResultSuccess = {
statusCode: 200;
contents: string | Buffer;
contentType?: string | false;
rss?: { data: any[] & RSSFunctionArgs };
};
type LoadResultNotFound = { statusCode: 404; error: Error };
type LoadResultError = { statusCode: 500 } & (
| { type: 'parse-error'; error: ICompileError }
| { type: 'config-error'; error: z.ZodError }
| { type: 'ssr'; error: Error }
| { type: 'not-found'; error: ICompileError }
| { type: 'unknown'; error: Error }
);
export type LoadResult = LoadResultSuccess | LoadResultNotFound | LoadResultError;
// Disable snowpack from writing to stdout/err.
configureSnowpackLogger(snowpackLogger);
function getParams(array: string[]) {
// 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 })
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;
}
async function getStaticPathsMemoized(runtimeConfig: AstroRuntimeConfig, component: string, mod: any, args: GetStaticPathsArgs): Promise<GetStaticPathsResult> {
runtimeConfig.cache.staticPaths[component] = runtimeConfig.cache.staticPaths[component] || mod.exports.getStaticPaths(args);
return (await runtimeConfig.cache.staticPaths[component]).flat();
}
/** Pass a URL to Astro to resolve and build */
async function load(config: AstroRuntimeConfig, rawPathname: string | undefined): Promise<LoadResult> {
const { logging, snowpackRuntime, snowpack, configManager } = config;
const { buildOptions, devOptions } = config.astroConfig;
const site = new URL(buildOptions.site || `http://${devOptions.hostname}:${devOptions.port}`);
const fullurl = new URL(rawPathname || '/', site.origin);
const reqPath = decodeURI(fullurl.pathname);
try {
const result = await snowpack.loadUrl(reqPath);
if (!result) throw new Error(`Unable to load ${reqPath}`);
// success
debug(logging, 'access', reqPath);
return {
statusCode: 200,
...result,
};
} catch (err) {
// build error
if (err.failed) {
return { statusCode: 500, type: 'unknown', error: err };
}
// not found, load a page instead
// continue...
}
info(logging, 'access', reqPath);
const routeMatch = config.manifest.routes.find((route) => route.pattern.test(reqPath));
if (!routeMatch) {
return { statusCode: 404, error: new Error('No matching route found.') };
}
const paramsMatch = routeMatch.pattern.exec(reqPath);
const routeLocation = convertMatchToLocation(routeMatch, config.astroConfig);
const params = paramsMatch ? getParams(routeMatch.params)(paramsMatch) : {};
let pageProps = {} as Record<string, any>;
try {
if (configManager.needsUpdate()) {
await configManager.update();
}
const mod = await snowpackRuntime.importModule(routeLocation.snowpackURL);
debug(logging, 'resolve', `${reqPath} -> ${routeLocation.snowpackURL}`);
// if path isn't static, we need to generate the valid paths first and check against them
// this helps us to prevent incorrect matches in dev that wouldn't exist in build.
if (!routeMatch.path) {
validateGetStaticPathsModule(mod);
const staticPaths = await getStaticPathsMemoized(config, routeMatch.component, mod, {
paginate: generatePaginateFunction(routeMatch),
rss: () => {
/* noop */
},
});
validateGetStaticPathsResult(staticPaths, logging);
const matchedStaticPath = staticPaths.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params));
if (!matchedStaticPath) {
return { statusCode: 404, error: new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${reqPath})`) };
}
pageProps = { ...matchedStaticPath.props } || {};
}
const requestURL = new URL(fullurl.toString());
// For first release query params are not passed to components.
// An exception is made for dev server specific routes.
if (reqPath !== '/500') {
requestURL.search = '';
}
let html = (await mod.exports.__renderPage({
request: {
params,
url: requestURL,
canonicalURL: canonicalURL(requestURL.pathname, site.toString()),
},
children: [],
props: pageProps,
css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : [],
scripts: mod.exports.default[Symbol.for('astro.hoistedScripts')],
})) as string;
return {
statusCode: 200,
contentType: 'text/html; charset=utf-8',
contents: html,
rss: undefined, // TODO: Add back rss support
};
} catch (err) {
if (err instanceof z.ZodError) {
console.log(formatConfigError(err));
return {
statusCode: 500,
type: 'config-error',
error: err,
};
}
if (err.code === 'parse-error' || err instanceof SyntaxError) {
return {
statusCode: 500,
type: 'parse-error',
error: err,
};
}
if (err instanceof ReferenceError && err.toString().includes('window is not defined')) {
return {
statusCode: 500,
type: 'ssr',
error: new Error(
`[${reqPath}]
The window object is not available during server-side rendering (SSR).
Try using \`import.meta.env.SSR\` to write SSR-friendly code.
https://docs.astro.build/reference/api-reference/#importmeta`
),
};
}
if (err instanceof NotFoundError && rawPathname) {
const fileMatch = err.toString().match(/\(([^\)]+)\)/);
const missingFile: string | undefined = (fileMatch && fileMatch[1].replace(/^\/_astro/, '').replace(/\.proxy\.js$/, '')) || undefined;
const distPath = path.extname(rawPathname) ? rawPathname : rawPathname.replace(/\/?$/, '/index.html');
const srcFile = getSrcPath(distPath, { astroConfig: config.astroConfig });
const code = existsSync(srcFile) ? await fs.readFile(srcFile, 'utf8') : '';
// try and find the import statement within the module. this is a bit hacky, as we dont know the line, but
// given that we know this is for sure a “not found” error, and we know what file is erring,
// we can make some safe assumptions about how to locate the line in question
let start = 0;
const segments = missingFile ? missingFile.split('/').filter((segment) => !!segment) : [];
while (segments.length) {
const importMatch = code.indexOf(segments.join('/'));
if (importMatch >= 0) {
start = importMatch;
break;
}
segments.shift();
}
return {
statusCode: 500,
type: 'not-found',
error: new CompileError({
code,
filename: srcFile.pathname,
start,
// TODO: why did I need to add this?
end: 1,
message: `Could not find${missingFile ? ` "${missingFile}"` : ' file'}`,
}),
};
}
return {
statusCode: 500,
type: 'unknown',
error: err,
};
}
}
export interface AstroRuntime {
runtimeConfig: AstroRuntimeConfig;
getStaticPaths: (component: string, mod: any, args: GetStaticPathsArgs) => Promise<GetStaticPathsResult>;
load: (rawPathname: string | undefined) => Promise<LoadResult>;
shutdown: () => Promise<void>;
}
export interface RuntimeOptions {
mode: RuntimeMode;
logging: LogOptions;
}
interface CreateSnowpackOptions {
logging: LogOptions;
mode: RuntimeMode;
resolvePackageUrl: (pkgName: string) => Promise<string>;
}
/** Create a new Snowpack instance to power Astro */
async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) {
const { projectRoot, src } = astroConfig;
const { mode, logging, resolvePackageUrl } = options;
const frontendPath = new URL('./frontend/', import.meta.url);
const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) });
const isHmrEnabled = mode === 'development';
// The config manager takes care of the runtime config module (that handles setting renderers, mostly)
const configManager = new ConfigManager(astroConfig, resolvePackageUrl);
let snowpack: SnowpackDevServer;
let astroPluginOptions: {
resolvePackageUrl?: (s: string) => Promise<string>;
astroConfig: AstroConfig;
hmrPort?: number;
mode: RuntimeMode;
logging: LogOptions;
configManager: ConfigManager;
} = {
astroConfig,
mode,
logging,
resolvePackageUrl,
configManager,
};
const mountOptions = {
...(existsSync(astroConfig.public) ? { [fileURLToPath(astroConfig.public)]: { url: '/', static: true, resolve: false } } : {}),
[fileURLToPath(frontendPath)]: '/_astro_frontend',
[fileURLToPath(src)]: '/_astro/src', // must be last (greediest)
};
// Tailwind: IDK what this does but it makes JIT work 🤷‍♂️
if (astroConfig.devOptions.tailwindConfig) {
(process.env as any).TAILWIND_DISABLE_TOUCH = true;
}
// Make sure that Snowpack builds our renderer plugins
const rendererInstances = await configManager.buildRendererInstances();
const knownEntrypoints: string[] = [
'astro/dist/internal/__astro_component.js',
'astro/dist/internal/element-registry.js',
'astro/dist/internal/fetch-content.js',
'astro/dist/internal/__astro_slot.js',
'astro/dist/internal/__astro_hoisted_scripts.js',
'prismjs',
];
for (const renderer of rendererInstances) {
knownEntrypoints.push(renderer.server);
if (renderer.client) {
knownEntrypoints.push(renderer.client);
}
if (renderer.knownEntrypoints) {
knownEntrypoints.push(...renderer.knownEntrypoints);
}
knownEntrypoints.push(...renderer.polyfills);
knownEntrypoints.push(...renderer.hydrationPolyfills);
}
const external = snowpackExternals.concat([]);
for (const renderer of rendererInstances) {
if (renderer.external) {
external.push(...renderer.external);
}
}
const rendererSnowpackPlugins = rendererInstances.filter((renderer) => renderer.snowpackPlugin).map((renderer) => renderer.snowpackPlugin) as string | [string, any];
const snowpackConfig = await loadConfiguration({
root: fileURLToPath(projectRoot),
mount: mountOptions,
mode,
plugins: [
[fileURLToPath(new URL('../snowpack-plugin-jsx.cjs', import.meta.url)), astroPluginOptions],
[fileURLToPath(new URL('../snowpack-plugin.cjs', import.meta.url)), astroPluginOptions],
...rendererSnowpackPlugins,
resolveDependency('@snowpack/plugin-sass'),
[
resolveDependency('@snowpack/plugin-postcss'),
{
config: {
plugins: {
[resolveDependency('autoprefixer')]: {},
...(astroConfig.devOptions.tailwindConfig ? { [resolveDependency('tailwindcss')]: astroConfig.devOptions.tailwindConfig } : {}),
},
},
},
],
],
devOptions: {
open: 'none',
output: 'stream',
port: 0,
hmr: isHmrEnabled,
tailwindConfig: astroConfig.devOptions.tailwindConfig,
},
buildOptions: {
baseUrl: astroConfig.buildOptions.site || '/', // note: Snowpack needs this fallback
out: fileURLToPath(astroConfig.dist),
},
packageOptions: {
knownEntrypoints,
external,
},
});
const polyfillNode = (snowpackConfig.packageOptions as any).polyfillNode as boolean;
if (!polyfillNode) {
snowpackConfig.alias = Object.assign({}, Object.fromEntries(nodeBuiltinsMap), snowpackConfig.alias ?? {});
}
snowpack = await startSnowpackServer(
{
config: snowpackConfig,
lockfile: null,
},
{
isWatch: mode === 'development',
}
);
const snowpackRuntime = snowpack.getServerRuntime();
astroPluginOptions.configManager.snowpackRuntime = snowpackRuntime;
return { snowpack, snowpackRuntime, snowpackConfig, configManager };
}
/** Core Astro runtime */
export async function createRuntime(astroConfig: AstroConfig, { mode, logging }: RuntimeOptions): Promise<AstroRuntime> {
let snowpack: SnowpackDevServer;
const timer: Record<string, number> = {};
const resolvePackageUrl = async (pkgName: string) => snowpack.getUrlForPackage(pkgName);
timer.backend = performance.now();
const {
snowpack: snowpackInstance,
snowpackRuntime,
snowpackConfig,
configManager,
} = await createSnowpack(astroConfig, {
logging,
mode,
resolvePackageUrl,
});
snowpack = snowpackInstance;
debug(logging, 'core', `snowpack created [${stopTimer(timer.backend)}]`);
const runtimeConfig: AstroRuntimeConfig = {
astroConfig,
cache: { staticPaths: {} },
logging,
mode,
snowpack,
snowpackRuntime,
snowpackConfig,
configManager,
manifest: createManifest({ config: astroConfig }),
};
snowpack.onFileChange(({ filePath }: { filePath: string }) => {
// Clear out any cached getStaticPaths() data.
runtimeConfig.cache.staticPaths = {};
// Rebuild the manifest, if needed
if (filePath.includes(fileURLToPath(astroConfig.pages))) {
runtimeConfig.manifest = createManifest({ config: astroConfig });
}
});
return {
runtimeConfig,
load: load.bind(null, runtimeConfig),
getStaticPaths: getStaticPathsMemoized.bind(null, runtimeConfig),
shutdown: () => snowpack.shutdown(),
};
}

View file

@ -1,12 +1,15 @@
import type { Renderer, AstroComponentMetadata } from '../@types/astro';
import type { AstroComponentMetadata, ComponentInstance, Renderer } from '../@types/astro';
import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate, GENERATOR, Generator } from 'astring';
import * as astroHtml from './renderer-html';
import * as astring from 'astring';
import * as astroHtml from './html.js';
const { generate, GENERATOR } = astring;
// A more robust version alternative to `JSON.stringify` that can handle most values
// see https://github.com/remcohaszing/estree-util-value-to-estree#readme
const customGenerator: Generator = {
const customGenerator: astring.Generator = {
...GENERATOR,
Literal(node, state) {
if (node.raw != null) {
@ -23,33 +26,13 @@ const serialize = (value: Value) =>
generator: customGenerator,
});
export interface RendererInstance {
name: string | null;
source: string | null;
renderer: Renderer;
polyfills: string[];
hydrationPolyfills: string[];
}
const astroHtmlRendererInstance: RendererInstance = {
name: null,
source: '',
renderer: astroHtml as Renderer,
polyfills: [],
hydrationPolyfills: [],
};
let rendererInstances: RendererInstance[] = [];
export function setRenderers(_rendererInstances: RendererInstance[]) {
rendererInstances = ([] as RendererInstance[]).concat(_rendererInstances);
}
declare let rendererInstances: Renderer[];
function isCustomElementTag(name: unknown) {
return typeof name === 'string' && /-/.test(name);
}
const rendererCache = new Map<any, RendererInstance>();
const rendererCache = new Map<any, Renderer>();
/** For client:only components, attempt to infer the required renderer. */
function inferClientRenderer(metadata: Partial<AstroComponentMetadata>) {
@ -82,7 +65,7 @@ function inferClientRenderer(metadata: Partial<AstroComponentMetadata>) {
}
/** For a given component, resolve the renderer. Results are cached if this instance is encountered again */
async function resolveRenderer(Component: any, props: any = {}, children?: string, metadata: Partial<AstroComponentMetadata> = {}): Promise<RendererInstance | undefined> {
async function resolveRenderer(Component: any, props: any = {}, children?: string, metadata: Partial<AstroComponentMetadata> = {}): Promise<Renderer | undefined> {
// For client:only components, the component can't be imported
// during SSR. We need to infer the required renderer.
if (metadata.hydrate === 'only') {
@ -94,21 +77,19 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin
}
const errors: Error[] = [];
for (const instance of rendererInstances) {
const { renderer } = instance;
for (const renderer of rendererInstances) {
// Yes, we do want to `await` inside of this loop!
// __renderer.check can't be run in parallel, it
// returns the first match and skips any subsequent checks
try {
const shouldUse: boolean = await renderer.check(Component, props, children);
const shouldUse: boolean = await renderer.ssr.check(Component, props, children);
if (shouldUse) {
rendererCache.set(Component, instance);
return instance;
rendererCache.set(Component, renderer);
return renderer;
}
} catch (err) {
errors.push(err);
errors.push(err as any);
}
}
@ -119,38 +100,44 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin
}
interface HydrateScriptOptions {
instance: RendererInstance;
renderer: Renderer;
astroId: string;
props: any;
}
const astroHtmlRendererInstance: Renderer = {
name: '@astrojs/renderer-html',
source: '',
ssr: astroHtml as any,
polyfills: [],
hydrationPolyfills: [],
};
/** For hydrated components, generate a <script type="module"> to load the component */
async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>) {
const { instance, astroId, props } = scriptOptions;
const { source } = instance;
const { renderer, astroId, props } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata;
let hydrationSource = '';
if (instance.hydrationPolyfills.length) {
hydrationSource += `await Promise.all([${instance.hydrationPolyfills.map((src) => `import("${src}")`).join(', ')}]);\n`;
if (renderer.hydrationPolyfills) {
hydrationSource += `await Promise.all([${renderer.hydrationPolyfills.map((src) => `\n import("${src}")`).join(', ')}]);\n`;
}
hydrationSource += source
? `
const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${source}")]);
hydrationSource += renderer.source
? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${renderer.source}")]);
return (el, children) => hydrate(el)(Component, ${serialize(props)}, children);
`
: `
await import("${componentUrl}");
: `await import("${componentUrl}");
return () => {};
`;
const hydrationScript = `<script type="module">
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
import setup from 'astro/client/${hydrate}.js';
setup("${astroId}", {${metadata.value ? `value: "${metadata.value}"` : ''}}, async () => {
${hydrationSource}
});
</script>`;
</script>
`;
return hydrationScript;
}
@ -198,7 +185,7 @@ const removeSlottedChildren = (_children: string | Record<any, any>[]) => {
};
/** 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) => `<script type="module" src="${src}"></script>`).join('');
if (renderer.polyfills) {
let polyfillScripts = renderer.polyfills.map((src: string) => `<script type="module" src="${src}"></script>`).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<AstroComponentMetadata>);
const script = await generateHydrateScript({ renderer, astroId: uniqueIdHashed, props }, metadata as Required<AstroComponentMetadata>);
const astroRoot = `<astro-root uid="${uniqueIdHashed}">${html}</astro-root>`;
return [astroRoot, script].join('\n');
};

View file

@ -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];

View file

@ -10,11 +10,13 @@ export function fetchContent(importMetaGlobResult: Record<string, any>, 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);

View file

@ -1,8 +1,11 @@
import { h } from './h';
import type { AstroComponentMetadata } from '../@types/astro.js';
async function renderToStaticMarkup(tag: string, props: Record<string, any>, children: string) {
import { h } from './h.js';
async function renderToStaticMarkup(tag: string, props: Record<string, any>, children: string | undefined) {
const html = await h(tag, props, Promise.resolve(children));
return {
check: (...args: any[]) => true,
html,
};
}

View file

@ -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<SourceDescription> {
const { astro, content } = await renderMarkdownWithFrontmatter(contents);
const map = new SourceMapGenerator();
return {
code: content,
map: null,
};
}

View file

@ -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,
});
}
});

View file

@ -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,
};
}

View file

@ -0,0 +1,14 @@
/** Construct sitemap.xml given a set of URLs */
export function generateSitemap(pages: string[]): string {
// TODO: find way to respect <link rel="canonical"> 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 = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
for (const url of urls) {
sitemap += `<url><loc>${url}</loc></url>`;
}
sitemap += `</urlset>\n`;
return sitemap;
}

View file

@ -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 cant 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<string> {
// 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('<head>', `<head><script type="module" src="/@vite/client"></script>`);
}
/** 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 <script type="module">
const moduleScripts = $('script[type="module"]');
if (!moduleScripts.length) return html; // if none, return
// for each <script>, update all npm imports with Vite-friendly imports
moduleScripts.each((_, el) => {
let code = $(el).html() || '';
if (!code || $(el).attr('src')) return;
try {
const scan = () => eslexer.parse(code)[0].filter(({ n }) => n && parseNpmName(n));
let specs = scan();
while (specs.length) {
const next = specs[0];
let pkgName = resolveViteNpmPackage(next.n as string);
if (next.d !== -1) pkgName = JSON.stringify(pkgName); // if dynamic import, stringify
code = code.substring(0, next.s) + pkgName + code.substring(next.e);
specs = scan();
}
$(el).html(code);
} catch (err) {
// if invalid JS, ignore (error will be thrown elsewhere)
}
});
return $.html();
}

View file

@ -0,0 +1,46 @@
import fs from 'fs';
import path from 'path';
/** Normalize URL to its canonical form */
export function canonicalURL(url: string, base?: string): URL {
let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
pathname = pathname.replace(/\/1\/?$/, ''); // neither is a trailing /1/ (impl. detail of collections)
if (!path.extname(pathname)) pathname = pathname.replace(/(\/+)?$/, '/'); // add trailing slash if theres no extension
pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() wont)
return new URL(pathname, base);
}
/** get user dependency list for Vite */
export async function getPackageJSON(projectRoot: URL): Promise<Record<string, any> | undefined> {
const possibleLocs = new Set(['./package.json']);
for (const possibleLoc of possibleLocs) {
const packageJSONLoc = new URL(possibleLoc, projectRoot);
if (fs.existsSync(packageJSONLoc)) {
return JSON.parse(await fs.promises.readFile(packageJSONLoc, 'utf8'));
}
}
}
/** is a specifier an npm package? */
export function parseNpmName(spec: string): { scope?: string; name: string; subpath?: string } | undefined {
// not an npm package
if (!spec || spec[0] === '.' || spec[0] === '/') return undefined;
let scope: string | undefined;
let name = '';
let parts = spec.split('/');
if (parts[0][0] === '@') {
scope = parts[0];
name = parts.shift() + '/';
}
name += parts.shift();
let subpath = parts.length ? `./${parts.join('/')}` : undefined;
return {
scope,
name,
subpath,
};
}

View file

@ -0,0 +1,183 @@
import type { InlineConfig } from 'vite';
import type { AstroConfig } from '../../@types/astro';
import type { LogOptions } from '../../logger';
import fs from 'fs';
import slash from 'slash';
import deepmerge from 'deepmerge';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { getPackageJSON, parseNpmName } from '../util.js';
import astro from './plugin-astro.js';
import jsx from './plugin-jsx.js';
const require = createRequire(import.meta.url);
// note: ssr is still an experimental API hence the type omission
type ViteConfigWithSSR = InlineConfig & { ssr?: { external?: string[]; noExternal?: string[] } };
/** Return a common starting point for all Vite actions */
export async function loadViteConfig(viteConfig: ViteConfigWithSSR, { astroConfig, logging }: { astroConfig: AstroConfig; logging: LogOptions }): Promise<ViteConfigWithSSR> {
const optimizedDeps = new Set<string>(); // dependencies that must be bundled for the client (Vite may not detect all of these)
const dedupe = new Set<string>(); // dependencies that cant be duplicated (e.g. React & SolidJS)
const plugins: Plugin[] = []; // Vite plugins
// load project deps
const packageJSON = (await getPackageJSON(astroConfig.projectRoot)) || {};
const userDeps = Object.keys(packageJSON?.dependencies || {});
userDeps.forEach((dep) => {
optimizedDeps.add(dep); // prepare all user deps for client ahead of time
});
const userDevDeps = Object.keys(packageJSON?.devDependencies || {});
const { external, noExternal } = await viteSSRDeps([...userDeps, ...userDevDeps]);
// load Astro renderers
await Promise.all(
astroConfig.renderers.map(async (name) => {
const { default: renderer } = await import(name);
// 1. prepare client-side hydration code for browser
if (renderer.client) {
optimizedDeps.add(name + renderer.client.substr(1));
}
// 2. knownEntrypoints and polyfills need to be added to the client
for (const dep of [...(renderer.knownEntrypoints || []), ...(renderer.polyfills || [])]) {
optimizedDeps.add(dep);
dedupe.add(dep); // we can try and dedupe renderers by default
}
// 3. let renderer inject Vite plugins
if (renderer.vitePlugins) {
plugins.push(...renderer.vitePlugins);
}
// 4. mark external packages as external to Vite
if (renderer.external) {
for (const dep of renderer.external) {
external.add(dep);
noExternal.delete(dep);
}
}
})
);
// load client-side hydrations
(await fs.promises.readdir(new URL('../../client', import.meta.url))).forEach((hydrator) => {
optimizedDeps.add(`astro/client/${hydrator}`); // always prepare these for client
});
return deepmerge(
{
clearScreen: false,
logLevel: 'error',
optimizeDeps: {
/** Try and scan a users project (wont catch everything) */
entries: ['src/**/*'],
/** Always include these dependencies for optimization */
include: [...optimizedDeps],
},
plugins: [astro(astroConfig), jsx({ config: astroConfig, logging }), ...plugins],
publicDir: fileURLToPath(astroConfig.public),
resolve: {
dedupe: [...dedupe],
},
root: fileURLToPath(astroConfig.projectRoot),
server: {
/** prevent serving outside of project root (will become new default soon) */
fs: { strict: true },
/** handle Vite URLs */
proxy: {
// add proxies here
},
},
/** Note: SSR API is in beta (https://vitejs.dev/guide/ssr.html) */
ssr: {
external: [...external],
noExternal: [...noExternal],
},
},
viteConfig
);
}
/** Try and automatically figure out Vite external & noExternal */
async function viteSSRDeps(deps: string[]): Promise<{ external: Set<string>; noExternal: Set<string> }> {
const skip = new Set<string>();
const external = new Set<string>();
const noExternal = new Set<string>();
/** categorize package as ESM or CJS */
async function sortPkg(spec: string): Promise<void> {
// already sorted; skip
if (external.has(spec) || noExternal.has(spec) || skip.has(spec)) return;
// not an npm package: ignore
const pkg = parseNpmName(spec);
if (!pkg) {
skip.add(spec);
return;
}
try {
const moduleLoc = require.resolve(spec);
// node cant find this: skip
if (!moduleLoc) {
skip.add(spec);
return;
}
// load modules package.json
let cwd = new URL('../', `file://${slash(moduleLoc)}/`);
let packageJSON = await getPackageJSON(cwd);
while (!packageJSON) {
const next = new URL('../', cwd);
if (next.href === cwd.href) return; // were at root; skip
cwd = next;
packageJSON = await getPackageJSON(cwd);
}
// couldnt locate: skip
if (!packageJSON) {
skip.add(spec);
return;
}
// sort this package
let isExternal = true; // external by default
// ESM gets noExternal
if (packageJSON.type === 'module') isExternal = false;
// TODO: manual bugfixes for Vite
if (pkg.name === '@sveltejs/vite-plugin-svelte') isExternal = true;
if (pkg.name === 'micromark-util-events-to-acorn') isExternal = true;
if (pkg.name === 'unified') isExternal = true;
// TODO: add more checks here if needed
// add to list
if (isExternal === true) {
external.add(spec);
} else {
noExternal.add(spec);
}
// recursively load dependencies for package (but not devDeps)
await Promise.all(Object.keys(packageJSON.dependencies || {}).map(sortPkg));
} catch (err) {
// cant load package: skip
skip.add(spec);
return;
}
}
// for top-level, load deps and devDeps (just in case)
await Promise.all(deps.map(sortPkg));
// sort (when debugging, makes packages easier to find)
const externalSorted = [...external];
externalSorted.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
const noExternalSorted = [...noExternal];
noExternalSorted.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
return {
external: new Set([...externalSorted]),
noExternal: new Set([...noExternalSorted]),
};
}

View file

@ -0,0 +1,97 @@
import type { Plugin } from 'vite';
import type { AstroConfig, Renderer } from '../../@types/astro.js';
import { camelCase } from 'camel-case';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { markdownToH } from '../markdown.js';
/** Transform .astro files for Vite */
export default function astro(config: AstroConfig): Plugin {
return {
name: '@astrojs/vite-plugin-astro',
enforce: 'pre', // run transforms before other plugins can
// note: dont claim .astro files with resolveId() — it prevents Vite from transpiling the final JS (import.meta.globEager, etc.)
async load(id) {
if (id.endsWith('.astro') || id.endsWith('.md')) {
// TODO: replace with compiler
let code = await fs.promises.readFile(id, 'utf8');
return {
code: code,
map: null,
};
}
// inject renderers (TODO: improve this?)
if (id.endsWith('runtime/__astro_component.js')) {
let code = await fs.promises.readFile(id, 'utf8');
let rendererCode = '';
// add imports
config.renderers.forEach((name) => {
rendererCode += `import ${jsRef(name)} from '${name}';
import ${jsRef(name, '_ssr')} from '${name}/server';
`;
});
// initialize renderers
rendererCode += `
function initRenderer(name, entry, ssr) {
const join = (...parts) => parts.map((part) => part.replace(/^\\./, '')).join('');
const renderer = {};
renderer.name = name;
renderer.ssr = ssr;
if (entry.client) renderer.source = join(name, entry.client);
if (Array.isArray(entry.hydrationPolyfills)) renderer.hydrationPolyfills = entry.hydrationPolyfills.map((src) => join(name, src));
if (Array.isArray(entry.polyfills)) renderer.polyfills = entry.polyfills.map((src) => join(name, src));
return renderer;
}
let rendererInstances = [
${config.renderers.map((name) => `initRenderer('${name}', ${jsRef(name)}, ${jsRef(name, '_ssr')})`).join(',\n')}
];
`;
return {
code: rendererCode + code,
map: null,
};
}
// UNCOMMENT WHEN MARKDOWN SUPPORT LANDS
// } else if (id.endsWith('.md')) {
// let contents = await fs.promises.readFile(id, 'utf8');
// const filename = slash(id.replace(fileURLToPath(config.projectRoot), ''));
// return markdownToH(filename, contents);
// }
return null;
},
async handleHotUpdate({ file, modules, timestamp, server, read }) {
// invalidate module
const module = server.moduleGraph.getModuleById(file);
if (module) server.moduleGraph.invalidateModule(module);
try {
const {
default: { __render: render },
} = await server.ssrLoadModule(file);
const html = await render();
server.ws.send({
type: 'custom',
event: 'astro:reload',
data: {
html,
},
});
} catch (e) {
server.ws.send({
type: 'full-reload',
});
}
return [];
},
};
}
/** Given any string (e.g. npm package name), generate a JS-friendly ref */
function jsRef(name: string, suffix = ''): string {
return `__${camelCase(name)}${suffix}`;
}

View file

@ -0,0 +1,185 @@
import type { Plugin } from 'vite';
import type { TransformResult } from 'rollup';
import type { AstroConfig, Renderer } from '../../@types/astro';
import type { LogOptions } from '../../logger';
import babel from '@babel/core';
import esbuild from 'esbuild';
import * as colors from 'kleur/colors';
import * as eslexer from 'es-module-lexer';
import path from 'path';
import { error } from '../../logger.js';
import { parseNpmName } from '../util.js';
const JSX_RENDERERS = new Map<string, Renderer>();
const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']);
const IMPORT_STATEMENTS: Record<string, string> = {
react: "import React from 'react'",
preact: "import { h } from 'preact'",
'solid-js': "import 'solid-js/web'",
};
// 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);';
interface AstroPluginJSXOptions {
config: AstroConfig;
logging: LogOptions;
}
/** Use Astro config to allow for alternate or multiple JSX renderers (by default Vite will assume React) */
export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin {
return {
name: '@astrojs/vite-plugin-jsx',
enforce: 'pre', // run transforms before other plugins
async transform(code, id, ssr) {
if (!JSX_EXTENSIONS.has(path.extname(id))) {
return null;
}
// load renderers (on first run only)
if (JSX_RENDERERS.size === 0) {
const jsxRenderers = await loadJSXRenderers(config.renderers);
if (jsxRenderers.size === 0) {
// note: we have filtered out all non-JSX files, so this error should only show if a JSX file is loaded with no matching renderers
throw new Error(
`${colors.yellow(
id
)}\nUnable to resolve a renderer that handles JSX transforms! Please include a \`renderer\` plugin which supports JSX in your \`astro.config.mjs\` file.`
);
}
for (const [importSource, renderer] of jsxRenderers) {
JSX_RENDERERS.set(importSource, renderer);
}
}
// attempt 0: if we only have one renderer, we can skip a bunch of work!
if (JSX_RENDERERS.size === 1) {
return transformJSX({ code, id, renderer: [...JSX_RENDERERS.values()][0], ssr: ssr || false });
}
// attempt 1: try and guess framework from imports (file cant import React and Preact)
// 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(code + PREVENT_UNUSED_IMPORTS, {
loader: getLoader(path.extname(id)),
jsx: 'transform',
jsxFactory: 'h',
jsxFragment: 'Fragment',
});
let imports: eslexer.ImportSpecifier[] = [];
if (/import/.test(codeToScan)) {
let [i] = eslexer.parse(codeToScan);
imports = i as any;
}
let importSource: string | undefined;
if (imports.length > 0) {
for (let { n: spec } of imports) {
const pkg = spec && parseNpmName(spec);
if (!pkg) continue;
if (JSX_RENDERERS.has(pkg.name)) {
importSource = pkg.name;
break;
}
}
}
// attempt 2: look for @jsxImportSource comment
if (!importSource) {
const multiline = code.match(/\/\*\*[\S\s]*\*\//gm) || [];
for (const comment of multiline) {
const [_, lib] = comment.match(/@jsxImportSource\s*(\S+)/) || [];
if (lib) {
importSource = lib;
break;
}
}
}
// if JSX renderer found, then use that
if (importSource) {
const jsxRenderer = JSX_RENDERERS.get(importSource);
// if renderer not installed for this JSX source, throw error
if (!jsxRenderer) {
error(logging, 'renderer', `${colors.yellow(id)} No renderer installed for ${importSource}. Try adding \`@astrojs/renderer-${importSource}\` to your dependencies.`);
return null;
}
return transformJSX({ code, id, renderer: JSX_RENDERERS.get(importSource) as Renderer, ssr: ssr || false });
}
// if we still cant tell, throw error
const defaultRenderer = [...JSX_RENDERERS.keys()][0];
error(
logging,
'renderer',
`${colors.yellow(id)}
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(IMPORT_STATEMENTS[defaultRenderer] || `import '${defaultRenderer}';`)} or ${colors.cyan(`/* jsxImportSource: ${defaultRenderer} */`)} to this file.
`
);
return null;
},
};
}
/** Returns esbuild loader for a given file */
function getLoader(fileExt: string): esbuild.Loader {
return fileExt.substr(1) as any;
}
/** Load JSX renderers from config */
async function loadJSXRenderers(rendererNames: string[]): Promise<Map<string, Renderer>> {
const renderers = new Map<string, Renderer>();
await Promise.all(
rendererNames.map((name) =>
import(name).then(({ default: renderer }) => {
if (!renderer.jsxImportSource) return;
renderers.set(renderer.jsxImportSource, renderer);
})
)
);
return renderers;
}
interface TransformJSXOptions {
code: string;
id: string;
ssr: boolean;
renderer: Renderer; // note MUST check for JSX beforehand!
}
/** Transform JSX with Babel */
async function transformJSX({ code, id, ssr, renderer }: TransformJSXOptions): Promise<TransformResult> {
const { jsxTransformOptions } = renderer;
const options = await jsxTransformOptions!({ isSSR: ssr || false }); // must filter for this beforehand
const result = await new Promise<babel.BabelFileResult | null>((resolve, reject) => {
const plugins = [...(options.plugins || [])];
babel.transform(
code,
{
presets: options.presets,
plugins,
cwd: process.cwd(),
filename: id,
ast: false,
compact: false,
sourceMaps: true,
configFile: false,
babelrc: false,
},
(err, res) => {
if (err) {
reject(err);
} else {
return resolve(res);
}
}
);
});
if (!result) return null;
return {
code: result.code || '',
map: result.map,
};
}

View file

@ -1,10 +0,0 @@
import { logger as snowpackLogger } from 'snowpack';
import { defaultLogLevel } from './logger.js';
export function configureSnowpackLogger(logger: typeof snowpackLogger) {
if (defaultLogLevel === 'debug') {
logger.level = 'debug';
} else if (defaultLogLevel === 'silent') {
logger.level = 'silent';
}
}

View file

@ -1,55 +0,0 @@
import { AstroConfig, GetStaticPathsResult, RouteData } from './@types/astro';
import { LogOptions, warn } from './logger.js';
interface PageLocation {
fileURL: URL;
snowpackURL: string;
}
/** convertMatchToLocation and return the _astro candidate for snowpack */
export function convertMatchToLocation(routeMatch: RouteData, astroConfig: AstroConfig): PageLocation {
const url = new URL(`./${routeMatch.component}`, astroConfig.projectRoot);
return {
fileURL: url,
snowpackURL: `/_astro/${routeMatch.component}.js`,
};
}
export function validateGetStaticPathsModule(mod: any) {
if (mod.exports.createCollection) {
throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`);
}
if (!mod.exports.getStaticPaths) {
throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`);
}
}
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.`);
}
}
});
}
/** Add / to beginning of string (but dont double-up) */
export function addLeadingSlash(path: string) {
return path.replace(/^\/?/, '/');
}
/** Add / to the end of string (but dont double-up) */
export function addTrailingSlash(path: string) {
return path.replace(/\/?$/, '/');
}

View file

@ -1,25 +1,20 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { setup, setupBuild } from './helpers.js';
import build from '../dist/build/index.js';
const Assets = suite('Assets');
setup(Assets, './fixtures/astro-assets');
setupBuild(Assets, './fixtures/astro-assets');
await build({ projectRoot: 'test/fixtures/astro-assets/' });
Assets('srcset is copied in the build', async ({ build, readFile }) => {
await build().catch((err) => {
assert.ok(!err, 'Error during the build');
});
Assets('build the base image', () => {});
let oneX = await readFile('/_astro/src/images/twitter.png');
assert.ok(oneX, 'built the base image');
// let oneX = await readFile('/_astro/src/images/twitter.png');
// assert.ok(oneX, 'built the base image');
let twoX = await readFile('/_astro/src/images/twitter@2x.png');
assert.ok(twoX, 'built the 2x image');
// let twoX = await readFile('/_astro/src/images/twitter@2x.png');
// assert.ok(twoX, 'built the 2x image');
let threeX = await readFile('/_astro/src/images/twitter@3x.png');
assert.ok(threeX, 'build the 3x image');
});
// let threeX = await readFile('/_astro/src/images/twitter@3x.png');
// assert.ok(threeX, 'build the 3x image');
Assets.run();

View file

@ -1,3 +0,0 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -3,9 +3,9 @@
"include": ["src", "index.d.ts"],
"compilerOptions": {
"allowJs": true,
"target": "ES2019",
"declarationDir": "./dist/types",
"module": "ES2020",
"outDir": "./dist",
"declarationDir": "./dist/types"
"target": "ES2019"
}
}

View file

@ -14,12 +14,13 @@
"scripts": {
"prepublish": "yarn build",
"build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json",
"postbuild": "astro-scripts copy \"src/**/*.js\"",
"dev": "astro-scripts dev \"src/**/*.ts\""
},
"dependencies": {
"@silvenon/remark-smartypants": "^1.0.0",
"assert": "^2.0.0",
"github-slugger": "^1.3.0",
"gray-matter": "^4.0.3",
"mdast-util-mdx-expression": "^1.1.0",
"micromark-extension-mdx-expression": "^1.0.0",
"rehype-raw": "^6.0.0",

View file

@ -0,0 +1,229 @@
/**
* The MIT License (MIT)
*
* Copyright (c) 2014-2018, Jon Schlinkert.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
'use strict';
import sections from 'section-matter';
import defaults from './lib/defaults.js';
import stringify from './lib/stringify.js';
import excerpt from './lib/excerpt.js';
import engines from './lib/engines.js';
import toFile from './lib/to-file.js';
import parse from './lib/parse.js';
import * as utils from './lib/utils.js';
/**
* Takes a string or object with `content` property, extracts
* and parses front-matter from the string, then returns an object
* with `data`, `content` and other [useful properties](#returned-object).
*
* ```js
* const matter = require('gray-matter');
* console.log(matter('---\ntitle: Home\n---\nOther stuff'));
* //=> { data: { title: 'Home'}, content: 'Other stuff' }
* ```
* @param {Object|String} `input` String, or object with `content` string
* @param {Object=} `options`
* @return {{content: string, data: Record<string, string>}}
* @api public
*/
function matter(input, options) {
if (input === '') {
return { data: {}, content: input, excerpt: '', orig: input };
}
let file = toFile(input);
const cached = matter.cache[file.content];
if (!options) {
if (cached) {
file = Object.assign({}, cached);
file.orig = cached.orig;
return file;
}
// only cache if there are no options passed. if we cache when options
// are passed, we would need to also cache options values, which would
// negate any performance benefits of caching
matter.cache[file.content] = file;
}
return parseMatter(file, options);
}
/**
* Parse front matter
*/
function parseMatter(file, options) {
const opts = defaults(options);
const open = opts.delimiters[0];
const close = '\n' + opts.delimiters[1];
let str = file.content;
if (opts.language) {
file.language = opts.language;
}
// get the length of the opening delimiter
const openLen = open.length;
if (!utils.startsWith(str, open, openLen)) {
excerpt(file, opts);
return file;
}
// if the next character after the opening delimiter is
// a character from the delimiter, then it's not a front-
// matter delimiter
if (str.charAt(openLen) === open.slice(-1)) {
return file;
}
// strip the opening delimiter
str = str.slice(openLen);
const len = str.length;
// use the language defined after first delimiter, if it exists
const language = matter.language(str, opts);
if (language.name) {
file.language = language.name;
str = str.slice(language.raw.length);
}
// get the index of the closing delimiter
let closeIndex = str.indexOf(close);
if (closeIndex === -1) {
closeIndex = len;
}
// get the raw front-matter block
file.matter = str.slice(0, closeIndex);
const block = file.matter.replace(/^\s*#[^\n]+/gm, '').trim();
if (block === '') {
file.isEmpty = true;
file.empty = file.content;
file.data = {};
} else {
// create file.data by parsing the raw file.matter block
file.data = parse(file.language, file.matter, opts);
}
// update file.content
if (closeIndex === len) {
file.content = '';
} else {
file.content = str.slice(closeIndex + close.length);
if (file.content[0] === '\r') {
file.content = file.content.slice(1);
}
if (file.content[0] === '\n') {
file.content = file.content.slice(1);
}
}
excerpt(file, opts);
if (opts.sections === true || typeof opts.section === 'function') {
sections(file, opts.section);
}
return file;
}
/**
* Expose engines
*/
matter.engines = engines;
/**
* Stringify an object to YAML or the specified language, and
* append it to the given string. By default, only YAML and JSON
* can be stringified. See the [engines](#engines) section to learn
* how to stringify other languages.
*
* ```js
* console.log(matter.stringify('foo bar baz', {title: 'Home'}));
* // results in:
* // ---
* // title: Home
* // ---
* // foo bar baz
* ```
* @param {String|Object} `file` The content string to append to stringified front-matter, or a file object with `file.content` string.
* @param {Object} `data` Front matter to stringify.
* @param {Object} `options` [Options](#options) to pass to gray-matter and [js-yaml].
* @return {String} Returns a string created by wrapping stringified yaml with delimiters, and appending that to the given string.
* @api public
*/
matter.stringify = function (file, data, options) {
if (typeof file === 'string') file = matter(file, options);
return stringify(file, data, options);
};
/**
* Returns true if the given `string` has front matter.
* @param {String} `string`
* @param {Object} `options`
* @return {Boolean} True if front matter exists.
* @api public
*/
matter.test = function (str, options) {
return utils.startsWith(str, defaults(options).delimiters[0]);
};
/**
* Detect the language to use, if one is defined after the
* first front-matter delimiter.
* @param {String} `string`
* @param {Object} `options`
* @return {Object} Object with `raw` (actual language string), and `name`, the language with whitespace trimmed
*/
matter.language = function (str, options) {
const opts = defaults(options);
const open = opts.delimiters[0];
if (matter.test(str)) {
str = str.slice(open.length);
}
const language = str.slice(0, str.search(/\r?\n/));
return {
raw: language,
name: language ? language.trim() : '',
};
};
/**
* Expose `matter`
*/
matter.cache = {};
matter.clearCache = function () {
matter.cache = {};
};
export default matter;

View file

@ -0,0 +1,18 @@
'use strict';
import engines from './engines.js';
import * as utils from './utils.js';
export default function (options) {
const opts = Object.assign({}, options);
// ensure that delimiters are an array
opts.delimiters = utils.arrayify(opts.delims || opts.delimiters || '---');
if (opts.delimiters.length === 1) {
opts.delimiters.push(opts.delimiters[0]);
}
opts.language = (opts.language || opts.lang || 'yaml').toLowerCase();
opts.engines = Object.assign({}, engines, opts.parsers, opts.engines);
return opts;
}

View file

@ -0,0 +1,30 @@
'use strict';
export default function (name, options) {
let engine = options.engines[name] || options.engines[aliase(name)];
if (typeof engine === 'undefined') {
throw new Error('gray-matter engine "' + name + '" is not registered');
}
if (typeof engine === 'function') {
engine = { parse: engine };
}
return engine;
}
function aliase(name) {
switch (name.toLowerCase()) {
case 'js':
case 'javascript':
return 'javascript';
case 'coffee':
case 'coffeescript':
case 'cson':
return 'coffee';
case 'yaml':
case 'yml':
return 'yaml';
default: {
return name;
}
}
}

View file

@ -0,0 +1,56 @@
'use strict';
import yaml from 'js-yaml';
/**
* Default engines
*/
const engines = {};
/**
* YAML
*/
engines.yaml = {
parse: yaml.safeLoad.bind(yaml),
stringify: yaml.safeDump.bind(yaml),
};
/**
* JSON
*/
engines.json = {
parse: JSON.parse.bind(JSON),
stringify: function (obj, options) {
const opts = Object.assign({ replacer: null, space: 2 }, options);
return JSON.stringify(obj, opts.replacer, opts.space);
},
};
/**
* JavaScript
*/
engines.javascript = {
parse: function parse(str, options, wrap) {
/* eslint no-eval: 0 */
try {
if (wrap !== false) {
str = '(function() {\nreturn ' + str.trim() + ';\n}());';
}
return eval(str) || {};
} catch (err) {
if (wrap !== false && /(unexpected|identifier)/i.test(err.message)) {
return parse(str, options, false);
}
throw new SyntaxError(err);
}
},
stringify: function () {
throw new Error('stringifying JavaScript is not supported');
},
};
export default engines;

View file

@ -0,0 +1,30 @@
'use strict';
import defaults from './defaults.js';
export default function (file, options) {
const opts = defaults(options);
if (file.data == null) {
file.data = {};
}
if (typeof opts.excerpt === 'function') {
return opts.excerpt(file, opts);
}
const sep = file.data.excerpt_separator || opts.excerpt_separator;
if (sep == null && (opts.excerpt === false || opts.excerpt == null)) {
return file;
}
const delimiter = typeof opts.excerpt === 'string' ? opts.excerpt : sep || opts.delimiters[0];
// if enabled, get the excerpt defined after front-matter
const idx = file.content.indexOf(delimiter);
if (idx !== -1) {
file.excerpt = file.content.slice(0, idx);
}
return file;
}

View file

@ -0,0 +1,13 @@
'use strict';
import getEngine from './engine.js';
import defaults from './defaults.js';
export default function (language, str, options) {
const opts = defaults(options);
const engine = getEngine(language, opts);
if (typeof engine.parse !== 'function') {
throw new TypeError('expected "' + language + '.parse" to be a function');
}
return engine.parse(str, opts);
}

View file

@ -0,0 +1,56 @@
'use strict';
import typeOf from 'kind-of';
import getEngine from './engine.js';
import defaults from './defaults.js';
export default function (file, data, options) {
if (data == null && options == null) {
switch (typeOf(file)) {
case 'object':
data = file.data;
options = {};
break;
case 'string':
return file;
default: {
throw new TypeError('expected file to be a string or object');
}
}
}
const str = file.content;
const opts = defaults(options);
if (data == null) {
if (!opts.data) return file;
data = opts.data;
}
const language = file.language || opts.language;
const engine = getEngine(language, opts);
if (typeof engine.stringify !== 'function') {
throw new TypeError('expected "' + language + '.stringify" to be a function');
}
data = Object.assign({}, file.data, data);
const open = opts.delimiters[0];
const close = opts.delimiters[1];
const matter = engine.stringify(data, options).trim();
let buf = '';
if (matter !== '{}') {
buf = newline(open) + newline(matter) + newline(close);
}
if (typeof file.excerpt === 'string' && file.excerpt !== '') {
if (str.indexOf(file.excerpt.trim()) === -1) {
buf += newline(file.excerpt) + newline(close);
}
}
return buf + newline(str);
}
function newline(str) {
return str.slice(-1) !== '\n' ? str + '\n' : str;
}

View file

@ -0,0 +1,43 @@
'use strict';
import typeOf from 'kind-of';
import stringify from './stringify.js';
import * as utils from './utils.js';
/**
* Normalize the given value to ensure an object is returned
* with the expected properties.
*/
export default function (file) {
if (typeOf(file) !== 'object') {
file = { content: file };
}
if (typeOf(file.data) !== 'object') {
file.data = {};
}
// if file was passed as an object, ensure that
// "file.content" is set
if (file.contents && file.content == null) {
file.content = file.contents;
}
// set non-enumerable properties on the file object
utils.define(file, 'orig', utils.toBuffer(file.content));
utils.define(file, 'language', file.language || '');
utils.define(file, 'matter', file.matter || '');
utils.define(file, 'stringify', function (data, options) {
if (options && options.language) {
file.language = options.language;
}
return stringify(file, data, options);
});
// strip BOM and ensure that "file.content" is a string
file.content = utils.toString(file.content);
file.isEmpty = false;
file.excerpt = '';
return file;
}

View file

@ -0,0 +1,66 @@
'use strict';
import stripBom from 'strip-bom-string';
import typeOf from 'kind-of';
export function define(obj, key, val) {
Reflect.defineProperty(obj, key, {
enumerable: false,
configurable: true,
writable: true,
value: val,
});
}
/**
* Returns true if `val` is a buffer
*/
export function isBuffer(val) {
return typeOf(val) === 'buffer';
}
/**
* Returns true if `val` is an object
*/
export function isObject(val) {
return typeOf(val) === 'object';
}
/**
* Cast `input` to a buffer
*/
export function toBuffer(input) {
return typeof input === 'string' ? Buffer.from(input) : input;
}
/**
* Cast `val` to a string.
*/
export function toString(input) {
if (isBuffer(input)) return stripBom(String(input));
if (typeof input !== 'string') {
throw new TypeError('expected input to be a string or buffer');
}
return stripBom(input);
}
/**
* Cast `val` to an array.
*/
export function arrayify(val) {
return val ? (Array.isArray(val) ? val : [val]) : [];
}
/**
* Returns true if `str` starts with `substr`.
*/
export function startsWith(str, substr, len) {
if (typeof len !== 'number') len = substr.length;
return str.slice(0, len) === substr;
}

View file

@ -2,7 +2,7 @@ import type { AstroMarkdownOptions, MarkdownRenderingOptions } from './types';
import createCollectHeaders from './rehype-collect-headers.js';
import scopedStyles from './remark-scoped-styles.js';
import remarkExpressions from './remark-expressions.js';
import { remarkExpressions, loadRemarkExpressions } from './remark-expressions.js';
import rehypeExpressions from './rehype-expressions.js';
import { remarkCodeBlock, rehypeCodeBlock } from './codeblock.js';
import { loadPlugins } from './load-plugins.js';
@ -13,13 +13,12 @@ import markdown from 'remark-parse';
import markdownToHtml from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import remarkSlug from 'remark-slug';
import matter from './gray-matter/index.js';
export { AstroMarkdownOptions, MarkdownRenderingOptions };
/** Internal utility for rendering a full markdown file and extracting Frontmatter data */
export async function renderMarkdownWithFrontmatter(contents: string, opts?: MarkdownRenderingOptions | null) {
// Dynamic import to ensure that "gray-matter" isn't built by Snowpack
const { default: matter } = await import('gray-matter');
const { data: frontmatter, content } = matter(contents);
const value = await renderMarkdown(content, opts);
return { ...value, frontmatter };
@ -29,6 +28,9 @@ export async function renderMarkdownWithFrontmatter(contents: string, opts?: Mar
export async function renderMarkdown(content: string, opts?: MarkdownRenderingOptions | null) {
const { $: { scopedClassName = null } = {}, footnotes: useFootnotes = true, gfm: useGfm = true, remarkPlugins = [], rehypePlugins = [] } = opts ?? {};
const { headers, rehypeCollectHeaders } = createCollectHeaders();
await loadRemarkExpressions(); // Vite bug: dynamically import() these because of CJS interop (this will cache)
let parser = unified()
.use(markdown)
.use(remarkSlug)

View file

@ -1,7 +1,9 @@
import { mdxExpression } from 'micromark-extension-mdx-expression';
import { mdxExpressionFromMarkdown, mdxExpressionToMarkdown } from 'mdast-util-mdx-expression';
// Vite bug: dynamically import() modules needed for CJS. Cache in memory to keep side effects
let mdxExpression: any;
let mdxExpressionFromMarkdown: any;
let mdxExpressionToMarkdown: any;
function remarkExpressions(this: any, options: any) {
export function remarkExpressions(this: any, options: any) {
let settings = options || {};
let data = this.data();
@ -16,4 +18,14 @@ function remarkExpressions(this: any, options: any) {
}
}
export default remarkExpressions;
export async function loadRemarkExpressions() {
if (!mdxExpression) {
const micromarkMdxExpression = await import('micromark-extension-mdx-expression');
mdxExpression = micromarkMdxExpression.mdxExpression;
}
if (!mdxExpressionFromMarkdown || !mdxExpressionToMarkdown) {
const mdastUtilMdxExpression = await import('mdast-util-mdx-expression');
mdxExpressionFromMarkdown = mdastUtilMdxExpression.mdxExpressionFromMarkdown;
mdxExpressionToMarkdown = mdastUtilMdxExpression.mdxExpressionToMarkdown;
}
}

View file

@ -9,9 +9,9 @@
"./package.json": "./package.json"
},
"dependencies": {
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.18",
"@babel/plugin-transform-react-jsx": "^7.14.5"
"@babel/plugin-transform-react-jsx": "^7.14.5",
"preact": "^10.5.14",
"preact-render-to-string": "^5.1.19"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"

Some files were not shown because too many files have changed in this diff Show more