Compare commits

...

31 commits

Author SHA1 Message Date
Nate Moore
d3ab66356e fix: update dependencies to bundle more deps 2021-10-07 17:16:37 -05:00
Nate Moore
b89fffc2ed chore(build): update build script to not bundle "dependencies" 2021-10-07 17:15:22 -05:00
Nate Moore
b9ea9284e7 docs: add note to CONTRIBUTING 2021-10-07 17:07:56 -05:00
Matthew Phillips
876569d97a
Add hydration to Solid renderer (#1479) (#1495)
* feat: add hydration to Solid renderer

* fix: intersection observer, move script to the end

Co-authored-by: Ryan Carniato <ryansolid@gmail.com>
2021-10-05 16:57:51 -04:00
Matthew Phillips
584947e862
Enables most slot tests (#1494)
* Enables most slot tests

* Use spreadAttributes
2021-10-05 16:50:32 -04:00
Matthew Phillips
8a47544480 Check in latest yarn.lock 2021-10-05 13:53:44 -04:00
Matthew Phillips
a2e0fc27d7
Add back in support for children (#1486)
* Add back in support for children

* Be more careful
2021-10-05 13:50:53 -04:00
Matthew Phillips
f0839c62df Apply changes from #1387 2021-09-30 10:04:44 -04:00
Pranav Karawale
650b9b12c1 Fix logger locale parsing (#1439)
* fix(logger): locale parsing
* Fixed issue of compiler crash when "c" locale was encountered
* Return default locale if parsed locale is less than 2 chars long

* chore: add changeset
2021-09-30 10:04:44 -04:00
Drew Powers
863bf58c46 Fix CI release on merge to next (#1427) 2021-09-30 10:04:44 -04:00
Drew Powers
7292c2eb04 Try mocha/chai test runners (#1418)
* Try mocha/chai test runners

* Disable failing smoke test for now

Will revert when next can build docs

* Enable mocha in parallel mode

* Remove warning

* Update docs

* Fix Windows bug

* Fix internal imports

* Fix styles
2021-09-30 10:04:44 -04:00
Nate Moore
36f06044ef feat: update compiler (#1421) 2021-09-30 10:04:44 -04:00
Drew Powers
aa07cb4339 Use new renderers (#1412) 2021-09-30 10:04:44 -04:00
Drew Powers
800a701c16 Clean up astro deps (#1411) 2021-09-30 10:04:44 -04:00
Drew Powers
7d494b2df2 Create .npmrc 2021-09-30 10:04:44 -04:00
Drew Powers
2d83bb2c79 Add NPM_TOKEN to publish script (#1409) 2021-09-30 10:04:44 -04:00
Drew Powers
dc4be6c618 Automate publish on merge (#1408) 2021-09-30 10:04:43 -04:00
Drew Powers
15bef35226 Fix npm install (#1407) 2021-09-30 10:04:43 -04:00
Drew Powers
91cb38f451 Allow users to pass config to Vite 2021-09-30 10:04:43 -04:00
Drew Powers
8126686878 Improve testing suite 2021-09-30 10:04:42 -04:00
Nate Moore
cf2e3b31cb Throw Error for WIP Features 2021-09-30 10:04:13 -04:00
Fred K. Schott
3c1fcc237d Fred fixes part 2 2021-09-30 10:04:12 -04:00
Nate Moore
ec746089df Tycho fixes 2021-09-30 10:03:40 -04:00
Nate Moore
5909f27b19 Add Markdown support 2021-09-30 10:03:40 -04:00
Drew Powers
3df506a219 Fix runtime, improve code frame 2021-09-30 10:03:40 -04:00
Drew Powers
95b3f254d8 Update compiler, improve tests 2021-09-30 10:03:40 -04:00
Fred K. Schott
d5bee9039d fred fixes 2021-09-30 10:03:40 -04:00
Drew Powers
d34dec859b More test improvements 2021-09-30 10:03:39 -04:00
Nate Moore
f9c5f7ad3c Update tests 2021-09-30 10:03:04 -04:00
Drew Powers
58b94197f7 Move Astro to Vite 2021-09-30 10:01:12 -04:00
Fred K. Schott
2c07089957 make astro-root uids unique 2021-09-30 10:00:01 -04:00
280 changed files with 6799 additions and 10517 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix logger locale parsing

View file

@ -0,0 +1,8 @@
---
'astro': patch
'@astrojs/prism': patch
'create-astro': patch
'@astrojs/markdown-remark': patch
---
Update dependency bundling to include more deps

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Use new compiler, move Astro to Vite

View file

@ -0,0 +1,9 @@
---
'@astrojs/renderer-preact': minor
'@astrojs/renderer-react': minor
'@astrojs/renderer-solid': minor
'@astrojs/renderer-svelte': minor
'@astrojs/renderer-vue': minor
---
Update renderers for Vite

View file

@ -18,11 +18,13 @@ jobs:
matrix:
os: [ubuntu-latest]
node_version: [12, 14, 16]
include:
- os: windows-latest
node_version: 14
# TODO: uncomment this (Vite has trouble resolving imports on Windows)
# include:
# - os: windows-latest
# node_version: 14
fail-fast: false
env:
LANG: en-us
name: 'Test: node-${{ matrix.node_version }}, ${{ matrix.os }}'
steps:
- name: Checkout
@ -96,45 +98,47 @@ jobs:
- name: Lint
run: yarn lint
smoke:
runs-on: ubuntu-latest
name: 'Smoke: node-14, ubuntu-latest'
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
# NOTE: temporarily disabled until `next` branch can build docs again
#
# smoke:
# runs-on: ubuntu-latest
# name: 'Smoke: node-14, ubuntu-latest'
# steps:
# - uses: actions/checkout@v2
# with:
# fetch-depth: 0
- name: Set node version to 14
uses: actions/setup-node@v2
with:
node-version: 14
# - name: Set node version to 14
# uses: actions/setup-node@v2
# with:
# node-version: 14
- name: Get yarn cache directory
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
# - name: Get yarn cache directory
# id: yarn-cache
# run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Set dependencies cache
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('yarn.lock') }}
${{ runner.os }}-${{ matrix.node_version }}-
# - name: Set dependencies cache
# uses: actions/cache@v2
# with:
# path: ${{ steps.yarn-cache.outputs.dir }}
# key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('yarn.lock') }}
# restore-keys: |
# ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('yarn.lock') }}
# ${{ runner.os }}-${{ matrix.node_version }}-
- name: Debug
run: yarn versions
# - name: Debug
# run: yarn versions
- name: Install dependencies
run: yarn install --frozen-lockfile --ignore-engines
# - name: Install dependencies
# run: yarn install --frozen-lockfile --ignore-engines
- name: Build
run: yarn build:all
# - name: Build
# run: yarn build:all
- name: "Smoke Test: Build 'docs'"
run: yarn build
working-directory: ./docs
# - name: "Smoke Test: Build 'docs'"
# run: yarn build
# working-directory: ./docs
- name: "Smoke Test: Build 'www'"
run: yarn build
working-directory: ./www
# - name: "Smoke Test: Build 'www'"
# run: yarn build
# working-directory: ./www

30
.github/workflows/release-next.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: release astro@next--compiler
on:
push:
branches:
- next
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- # 1. create new snapshot version
run: yarn changeset version --snapshot compiler
- # 2. discard examples/docs/www changes (just in case)
run: git checkout -- examples/ docs/ www/
- # 3: use compiler--next renderers (but dont commit)
run: |
cd packages/astro
yarn add @astrojs/renderer-preact@next--compiler @astrojs/renderer-react@next--compiler @astrojs/renderer-svelte@next--compiler @astrojs/renderer-vue@next--compiler
cd ../..
- # 4: auth
run: echo '//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}' > ${{ github.workspace }}/.npmrc
- # 5: publish!
run: yarn release --tag next--compiler

View file

@ -35,8 +35,8 @@ yarn build
# run this in the top-level project root to run all tests
yarn test
# run only a few tests, great for working on a single feature
# (example - `yarn test rss` runs `astro-rss.test.js` tests)
yarn test $STRING_MATCH
# (example - `yarn test -g "RSS"` runs `astro-rss.test.js`)
yarn test -g "$STRING_MATCH"
```
## Other useful commands
@ -61,6 +61,10 @@ When making a pull request, be sure to add a changeset when something has change
yarn changeset
```
## Dependencies
Astro uses `esbuild` to pre-bundle most dependencies before publishing! Therefore most dependencies, even used in `src/` code, should be added under `devDependencies` by default.
## Running benchmarks
We have benchmarks to keep performance under control. You can run these by running (from the project root):

View file

@ -22,7 +22,3 @@ export default /** @type {import('astro').AstroUserConfig} */ (
}
);
```
## Snowpack Config
Astro is powered internally by Snowpack. You can configure Snowpack directly by creating a `snowpack.config.mjs` file. See [snowpack.dev](https://www.snowpack.dev/reference/configuration) for full documentation on this file.

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"
}
```
@ -56,8 +56,6 @@ export default {
name: '@astrojs/renderer-xxx', // the renderer name
client: './client.js', // relative path to the client entrypoint
server: './server.js', // optional, relative path to the server entrypoint
snowpackPlugin: '@snowpack/plugin-xxx', // optional, the name of a snowpack plugin to inject
snowpackPluginOptions: { example: true }, // optional, any options to be forwarded to the snowpack plugin
knownEntrypoint: ['framework'], // optional, entrypoint modules that will be used by compiled source
external: ['dep'], // optional, dependencies that should not be built by snowpack
polyfills: ['./shadow-dom-polyfill.js'], // optional, module scripts that should be loaded before client hydration.
@ -72,6 +70,7 @@ export default {
plugins: [jsx({}, { runtime: 'automatic', importSource: 'preact' })],
};
},
vitePlugins: [], // optional, inject Vite plugins here (https://vitejs.dev/plugins/#plugins)
};
```

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

@ -0,0 +1,9 @@
<h1>
<slot/>
</h1>
<style>
h1 {
color: red;
}
</style>

View file

@ -1,12 +1,12 @@
---
import { Markdown } from 'astro/components';
import BaseHead from '../components/BaseHead.astro';
import BlogHeader from '../components/BlogHeader.astro';
import BlogPost from '../components/BlogPost.astro';
// import BlogHeader from '../components/BlogHeader.astro';
// import BlogPost from '../components/BlogPost.astro';
const {content} = Astro.props;
const {title, description, publishDate, author, heroImage, permalink, alt} = content;
---
<html lang={ content.lang || 'en' }>
<head>
<BaseHead title={title} description={description} permalink={permalink} />
@ -14,10 +14,13 @@ const {title, description, publishDate, author, heroImage, permalink, alt} = con
</head>
<body>
<BlogHeader />
<BlogPost title={title} author={author} heroImage={heroImage} publishDate={publishDate} alt={alt}>
<h1>Hello world!</h1>
<div class="container">
<slot />
</BlogPost>
</div>
<!-- <BlogHeader /> -->
<!-- <BlogPost title={title} author={author} heroImage={heroImage} publishDate={publishDate} alt={alt}> -->
<!-- </BlogPost> -->
</body>
</html>

View file

@ -18,7 +18,7 @@ let permalink = 'https://example.com/';
// Data Fetching: List all Markdown posts in the repo.
let allPosts = Astro.fetchContent<MarkdownFrontmatter>('./posts/*.md');
let allPosts = await Astro.fetchContent('./posts/*.md');
allPosts = allPosts.sort((a, b) => new Date(b.publishDate).valueOf() - new Date(a.publishDate).valueOf());
// Full Astro Component Syntax:

View file

@ -0,0 +1,15 @@
---
setup: |
import Layout from '../../layouts/BlogPost.astro'
import Cool from '../../components/Author.astro'
name: Nate Moore
value: 128
---
# Hello world!
<Cool name={frontmatter.name} href="https://twitter.com/n_moore" client:load />
This is so cool!
Do variables work {frontmatter.value * 2}?

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

@ -1,6 +1,6 @@
---
// Component Imports
import Counter from '../components/Counter.jsx'
import Counter from '../components/Counter.tsx'
// Full Astro Component Syntax:

View file

@ -1,5 +1,6 @@
---
---
<html lang="en">
<head>
@ -7,6 +8,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<title>Welcome to Astro</title>
</head>
<body>

View file

@ -38,7 +38,7 @@ import { Markdown } from 'astro/components';
<h2>👀 Want to learn more?</h2>
<p>Feel free to check <a href="https://github.com/snowpackjs/astro">our documentation</a> or jump into our <a href="https://astro.build/chat">Discord server</a>.</p>
</section>
</article>
<style>

View file

@ -1,5 +1,10 @@
{
"ignoreChanges": ["**/test/**", "**/*.md"],
"useWorkspaces": true,
"version": "4.0.0"
"version": "4.0.0",
"command": {
"run": {
"npmClient": "yarn"
}
}
}

View file

@ -12,17 +12,19 @@
"build": "yarn build:core",
"build:one": "lerna run build --scope",
"build:all": "lerna run build --scope \"{astro,@astrojs/*}\"",
"build:core": "lerna run build --scope \"{astro,@astrojs/parser,@astrojs/markdown-support}\"",
"build:core": "lerna run build --scope \"{astro,@astrojs/parser,@astrojs/markdown-remark}\"",
"dev": "yarn dev:core --parallel --stream",
"dev:one": "lerna run dev --scope --parallel --stream",
"dev:all": "lerna run dev --scope \"{astro,@astrojs/*}\" --parallel --stream",
"dev:core": "lerna run dev --scope \"{astro,@astrojs/parser,@astrojs/markdown-support}\" --parallel --stream",
"dev:core": "lerna run dev --scope \"{astro,@astrojs/parser,@astrojs/markdown-remark}\" --parallel --stream",
"format": "prettier -w .",
"lint": "eslint \"packages/**/*.ts\"",
"test": "yarn workspace astro run test",
"test:templates": "lerna run test --scope create-astro --stream"
},
"workspaces": [
"compiled/*",
"packages/markdown/*",
"packages/renderers/*",
"packages/*",
"examples/*",
@ -38,32 +40,22 @@
"npm": "7.11.2",
"yarn": "1.22.10"
},
"dependencies": {
"camel-case": "^4.1.2"
},
"dependencies": {},
"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",
"cheerio": "^1.0.0-rc.6",
"cheerio-select-tmp": "^0.1.1",
"@typescript-eslint/eslint-plugin": "^4.31.2",
"@typescript-eslint/parser": "^4.31.2",
"del": "^6.0.0",
"esbuild": "^0.11.17",
"eslint": "^7.25.0",
"esbuild": "^0.12.28",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-prettier": "^4.0.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.4.1",
"tiny-glob": "^0.2.8",
"typescript": "^4.2.4",
"uvu": "^0.5.1"
"typescript": "^4.4.3"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"

View file

@ -18,6 +18,8 @@
"keywords": [],
"author": "Skypack",
"license": "MIT",
"//": "Default to 'devDependencies' (bundled with src/) rather than 'dependencies' (unbundled).",
"//": "See CONTRIBUTING.md for more info!",
"devDependencies": {
"prismjs": "^1.23.0"
},

View file

@ -13,6 +13,7 @@ const CI_INTRUCTIONS = {
VERCEL: 'https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version',
};
/** `astro *` */
async function main() {
// Check for ESM support.
// Load the "supports-esm" package in an way that works in both ESM & CJS.
@ -29,7 +30,7 @@ async function main() {
// Preflight check complete. Enjoy! ✨
if (supportsESM) {
return import('./dist/cli.js')
return import('./dist/cli/index.js')
.then(({ cli }) => cli(process.argv))
.catch((error) => {
console.error(error);

View file

@ -4,14 +4,14 @@ import shiki from 'shiki';
export interface Props {
/** The code to highlight. Required. */
code: string;
/**
* The language of your code. Defaults to "plaintext".
* Supports all languages listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
/**
* The language of your code. Defaults to "plaintext".
* Supports all languages listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
*/
lang?: string;
/**
/**
* The styling theme. Defaults to "github-dark".
* Supports all themes listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
* Supports all themes listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
* Instructions for loading a custom theme: https://github.com/shikijs/shiki/blob/main/docs/themes.md#loading-theme
*/
theme?: string;

View file

@ -1,5 +1,5 @@
---
import { renderMarkdown } from '@astrojs/markdown-support';
import { renderMarkdown } from '@astrojs/markdown-remark';
export interface Props {
content?: string;

View file

@ -1,4 +1,17 @@
export { default as Code } from './Code.astro';
export { default as Debug } from './Debug.astro';
export { default as Markdown } from './Markdown.astro';
export { default as Prism } from './Prism.astro';
// export { default as Code } from './Code.astro';
// export { default as Debug } from './Debug.astro';
// export { default as Markdown } from './Markdown.astro';
// export { default as Prism } from './Prism.astro';
export const Code = () => {
throw new Error(`Cannot render <Code />. "astro/components" are still WIP!`)
}
export const Debug = () => {
throw new Error(`Cannot render <Debug />. "astro/components" are still WIP!`)
}
export const Markdown = () => {
throw new Error(`Cannot render <Markdown />. "astro/components" are still WIP!`)
}
export const Prism = () => {
throw new Error(`Cannot render <Prism />. "astro/components" are still WIP!`)
}

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,16 @@
},
"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",
"./debug": "./components/Debug.astro",
"./internal": "./dist/internal/index.js",
"./internal/*": "./dist/internal/*",
"./dist/internal/*": "./dist/internal/*"
"./runtime/*": "./dist/runtime/*.js",
"./package.json": "./package.json"
},
"imports": {
"#astro/compiler": "./dist/compiler/index.js",
"#astro/*": "./dist/*.js"
},
"bin": {
@ -32,8 +30,6 @@
"files": [
"components",
"dist",
"snowpack-plugin-jsx.cjs",
"snowpack-plugin.cjs",
"astro.js"
],
"scripts": {
@ -41,84 +37,63 @@
"dev": "astro-scripts dev \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.astro\"",
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
"test": "uvu test -i fixtures -i benchmark -i test-utils.js"
"test": "mocha --parallel --timeout 15000"
},
"//": "Default to 'devDependencies' (bundled with src/) rather than 'dependencies' (unbundled).",
"//": "See CONTRIBUTING.md for more info!",
"dependencies": {
"@astrojs/markdown-support": "0.3.1",
"@astrojs/parser": "0.20.2",
"@astrojs/prism": "0.2.2",
"@astrojs/renderer-preact": "0.2.2",
"@astrojs/renderer-react": "0.2.1",
"@astrojs/renderer-svelte": "0.1.2",
"@astrojs/renderer-vue": "0.1.8",
"@babel/code-frame": "^7.12.13",
"@babel/core": "^7.14.6",
"@babel/generator": "^7.13.9",
"@babel/parser": "^7.13.15",
"@babel/traverse": "^7.13.15",
"@snowpack/plugin-postcss": "^1.4.3",
"@snowpack/plugin-sass": "^1.4.0",
"@astrojs/compiler": "^0.1.0-canary.47",
"@astrojs/markdown-remark": "^0.3.1",
"@astrojs/renderer-preact": "^0.2.2",
"@astrojs/renderer-react": "^0.2.1",
"@astrojs/renderer-svelte": "^0.1.2",
"@astrojs/renderer-vue": "^0.1.8",
"@babel/core": "^7.15.5",
"@web/rollup-plugin-html": "^1.10.1",
"vite": "^2.5.10"
},
"devDependencies": {
"@types/babel__core": "^7.1.15",
"@types/chai": "^4.2.22",
"@types/connect": "^3.4.35",
"@types/mime": "^2.0.3",
"@types/mocha": "^9.0.0",
"@types/node-fetch": "^2.5.12",
"@types/send": "^0.17.1",
"acorn": "^7.4.0",
"astring": "^1.7.4",
"autoprefixer": "^10.2.5",
"babel-plugin-module-resolver": "^4.1.0",
"camel-case": "^4.1.2",
"cheerio": "^1.0.0-rc.6",
"@types/yargs-parser": "^20.2.1",
"astring": "^1.7.5",
"chai": "^4.3.4",
"cheerio": "^1.0.0-rc.10",
"ci-info": "^3.2.0",
"connect": "^3.7.0",
"del": "^6.0.0",
"es-module-lexer": "^0.4.1",
"esbuild": "^0.12.12",
"es-module-lexer": "^0.7.1",
"esbuild": "^0.12.28",
"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",
"html-entities": "^2.3.2",
"kleur": "^4.1.4",
"magic-string": "^0.25.3",
"mime": "^2.5.2",
"moize": "^6.0.1",
"mocha": "^9.1.1",
"morphdom": "^2.6.1",
"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",
"sass": "^1.42.0",
"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",
"sourcemap-codec": "^1.4.8",
"srcset-parse": "^1.1.0",
"string-width": "^5.0.0",
"strip-ansi": "^7.0.1",
"supports-esm": "^1.0.0",
"tiny-glob": "^0.2.8",
"yargs-parser": "^20.2.7",
"yargs-parser": "^20.2.9",
"zod": "^3.8.1"
},
"devDependencies": {
"@babel/types": "^7.14.0",
"@types/babel__code-frame": "^7.0.2",
"@types/babel__generator": "^7.6.2",
"@types/babel__parser": "^7.1.1",
"@types/babel__traverse": "^7.11.1",
"@types/estree": "0.0.46",
"@types/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"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0",
"npm": ">=6.14.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,280 @@
import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types';
import type { AstroUserConfig, AstroConfig } from './config';
import type babel from '@babel/core';
import type vite from 'vite';
import type { z } from 'zod';
import type { AstroConfigSchema } from '../config';
import type { AstroComponentFactory } from '../internal';
export { AstroUserConfig, AstroConfig };
export interface RouteData {
type: 'page';
pattern: RegExp;
params: string[];
path: string | null;
component: string;
generate: (data?: any) => string;
export interface AstroComponentMetadata {
displayName: string;
hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only';
hydrateArgs?: any;
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
}
/**
* 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?: {
render?: [string, Record<string, any>];
};
/** 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';
};
/** Pass configuration options to Vite */
vite?: vite.InlineConfig;
}
// 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> {
// }
export type AstroConfig = z.output<typeof AstroConfigSchema>;
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: AstroComponentFactory;
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 +306,16 @@ 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 type SSRError = Error & vite.ErrorPayload['err'];
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,95 +0,0 @@
import type { AstroMarkdownOptions } from '@astrojs/markdown-support';
import type { AstroConfigSchema } from '../config';
import type { z } from 'zod';
/**
* 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>;
}

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 +1 @@
export { AstroConfig, AstroUserConfig } from './config';
export { AstroConfig, AstroUserConfig } from './astro';

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,210 @@
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, levels, 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 {
mode?: string;
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 mode = 'production';
private origin: string;
private routeCache: RouteCache = {};
private manifest: ManifestData;
constructor(config: AstroConfig, options: BuildOptions) {
if (!config.buildOptions.site && config.buildOptions.sitemap !== false) {
warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
}
if (options.mode) this.mode = options.mode;
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 { logging, origin } = this;
const viteConfig = await loadViteConfig(
{
mode: this.mode,
server: {
hmr: { overlay: false },
middlewareMode: 'ssr',
},
...(this.config.vite || {}),
},
{ astroConfig: this.config, logging }
);
const viteServer = await vite.createServer(viteConfig);
// 2. get all routes
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({ astroConfig: this.config, 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(/^\/?/, './'), this.config.dist);
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({ astroConfig: this.config, 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(this.config.dist),
rollupOptions: {
input: [],
output: { format: 'esm' },
},
target: 'es2020', // must match an esbuild target
},
plugins: [
rollupPluginHTML({
input,
extractAssets: false,
}) as any, // CI fix: ignore typing of this plugin
...(viteConfig.plugins || []),
],
publicDir: viteConfig.publicDir,
root: viteConfig.root,
server: viteConfig.server,
});
// 4. write assets to disk
Object.keys(assets).map((k) => {
if (!assets[k]) return;
const filePath = new URL(`file://${k}`);
fs.mkdirSync(new URL('./', filePath), { recursive: true });
fs.writeFileSync(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', this.config.dist);
await fs.promises.mkdir(new URL('./', sitemapPath), { recursive: true });
await fs.promises.writeFile(sitemapPath, sitemap, 'utf8');
sitemapTime = performance.now() - sitemapStart;
}
// 6. clean up
await viteServer.close();
// 7. log output
if (logging.level && levels[logging.level] <= levels['info']) {
await this.printStats({
cwd: this.config.dist,
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 fetch from 'node-fetch';
import fs from 'fs';
import slash from 'slash';
import glob from 'tiny-glob';
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 = await glob(pattern, { cwd: fileURLToPath(cwd) });
return results.map((filepath) => new URL(slash(filepath), cwd));
}
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,24 +1,18 @@
/* 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';
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);
};
import { formatConfigError, loadConfig } from '../config.js';
type Arguments = yargs.Arguments;
type cliCommand = 'help' | 'version' | 'dev' | 'build' | 'preview' | 'reload';
@ -31,7 +25,6 @@ interface CLIState {
hostname?: string;
port?: number;
config?: string;
reload?: boolean;
};
}
@ -60,10 +53,6 @@ function resolveArgs(flags: Arguments): CLIState {
case 'preview':
return { cmd: 'preview', options };
default:
if (flags.reload) {
return { cmd: 'reload', options };
}
return { cmd: 'help', options };
}
}
@ -71,7 +60,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.
@ -81,7 +69,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.
@ -91,7 +78,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);
}
@ -103,35 +90,33 @@ 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) {
if (err instanceof z.ZodError) {
console.log(formatConfigError(err));
} else {
console.error(colors.red(err.toString() || err));
}
process.exit(1);
}
}
const cmdMap = new Map<string, (a: AstroConfig, opts?: any) => Promise<any>>([
['build', buildAndExit],
['dev', devServer],
['preview', preview],
['reload', reloadAndExit],
]);
/** 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();
@ -143,21 +128,43 @@ export async function cli(args: string[]) {
process.exit(0);
break;
}
case 'reload': {
await reloadAndExit();
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);
}
break;
}
case 'build':
case 'preview':
case 'dev': {
if (flags.reload) {
await reload();
case 'build': {
if (flags.reload) await reload(projectRoot);
try {
await build(config, { logging });
process.exit(0);
} 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;
}
case 'preview': {
if (flags.reload) await reload(projectRoot);
try {
await preview(config, { logging }); // this will keep running
} catch (err) {
throwAndExit(err);
}
break;
}
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,20 @@
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}/`);
del.sync(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,10 @@
import type { AstroConfig, AstroUserConfig } 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';
export const AstroConfigSchema = z.object({
projectRoot: z
@ -40,6 +39,7 @@ export const AstroConfigSchema = z.object({
gfm: z.boolean().optional(),
remarkPlugins: z.array(z.any()).optional(),
rehypePlugins: z.array(z.any()).optional(),
render: z.any().optional().default(['@astrojs/markdown-remark', {}]),
})
.optional()
.default({}),
@ -57,10 +57,7 @@ export const AstroConfigSchema = z.object({
devOptions: z
.object({
hostname: z.string().optional().default('localhost'),
port: z
.number()
.optional()
.transform((val) => val || getPort({ port: getPort.makeRange(3000, 3050) })),
port: z.number().optional().default(3000),
tailwindConfig: z.string().optional(),
trailingSlash: z
.union([z.literal('always'), z.literal('never'), z.literal('ignore')])
@ -69,6 +66,7 @@ export const AstroConfigSchema = z.object({
})
.optional()
.default({}),
vite: z.any().optional().default({}), // TODO: we dont need validation, but can we get better type inference?
});
/** Turn raw config values into normalized values */
@ -101,10 +99,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,313 @@
import type { NextFunction } from 'connect';
import type http from 'http';
import type { AstroConfig, ManifestData, RouteCache, RouteData, SSRError } from '../@types/astro';
import type { LogOptions } from '../logger';
import type { HmrContext, ModuleNode } from 'vite';
import { fileURLToPath } from 'url';
import connect from 'connect';
import mime from 'mime';
import { performance } from 'perf_hooks';
import { createRequire } from 'module';
import stripAnsi from 'strip-ansi';
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';
const require = createRequire(import.meta.url);
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 */
export class AstroDevServer {
app = connect();
httpServer: http.Server | undefined;
hostname: string;
port: number;
private internalCache: Map<string, string>;
private config: AstroConfig;
private logging: LogOptions;
private manifest: ManifestData;
private origin: string;
private routeCache: RouteCache = {};
private viteServer: vite.ViteDevServer | undefined;
private mostRecentRoute?: RouteData;
constructor(config: AstroConfig, options: DevOptions) {
this.internalCache = new Map();
this.config = config;
this.hostname = config.devOptions.hostname || 'localhost';
this.logging = options.logging;
this.port = config.devOptions.port;
this.origin = `http://localhost:${this.port}`;
this.manifest = createRouteManifest({ config });
}
/** Start dev server */
async start() {
// 1. profile startup time
const devStart = performance.now();
// 2. create Vite instance
this.viteServer = await this.createViteServer();
// 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 (and retry if taken)
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {
info(this.logging, 'astro', msg.portInUse({ port: this.port }));
this.port++;
} else {
error(this.logging, 'astro', err.stack);
this.httpServer?.removeListener('error', onError);
reject(err);
}
};
this.httpServer = 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();
});
this.httpServer.on('error', onError);
});
}
/** Stop dev server */
async stop() {
this.internalCache = new Map();
this.httpServer?.close(); // close HTTP server
if (this.viteServer) await this.viteServer.close(); // close Vite server
}
/** Handle HMR */
public async handleHotUpdate({ file, modules }: HmrContext): Promise<void | ModuleNode[]> {
if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`);
for (const module of modules) {
this.viteServer.moduleGraph.invalidateModule(module);
}
const route = this.mostRecentRoute;
const pathname = route?.pathname ?? '/';
if (!route) {
this.viteServer.ws.send({
type: 'full-reload',
});
return [];
}
try {
// try to update the most recent route
const html = await ssr({
astroConfig: this.config,
filePath: new URL(`./${route.component}`, this.config.projectRoot),
logging: this.logging,
mode: 'development',
origin: this.origin,
pathname,
route,
routeCache: this.routeCache,
viteServer: this.viteServer,
});
// TODO: log update
this.viteServer.ws.send({
type: 'custom',
event: 'astro:reload',
data: { html },
});
return [];
} catch (e) {
const err = e as Error;
this.viteServer.ssrFixStacktrace(err);
console.log(err.stack);
this.viteServer.ws.send({
type: 'full-reload',
});
return [];
}
}
/** Set up Vite server */
private async createViteServer() {
const viteConfig = await loadViteConfig(
{
mode: 'development',
server: {
middlewareMode: 'ssr',
host: this.hostname,
},
...(this.config.vite || {}),
},
{ astroConfig: this.config, logging: this.logging, devServer: this }
);
const viteServer = await vite.createServer(viteConfig);
const pagesDirectory = fileURLToPath(this.config.pages);
viteServer.watcher.on('add', (file) => {
// Only rebuild routes if new file is a page.
if (!file.startsWith(pagesDirectory)) {
return;
}
this.routeCache = {};
this.manifest = createRouteManifest({ config: this.config });
});
viteServer.watcher.on('unlink', (file) => {
// Only rebuild routes if deleted file is a page.
if (!file.startsWith(pagesDirectory)) {
return;
}
this.routeCache = {};
this.manifest = createRouteManifest({ config: this.config });
});
viteServer.watcher.on('change', () => {
// No need to rebuild routes on file content changes.
// However, we DO want to clear the cache in case
// the change caused a getStaticPaths() return to change.
this.routeCache = {};
});
return viteServer;
}
/** 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();
if (pathname.startsWith('/@astro')) {
const spec = pathname.slice(2);
const url = await this.viteServer.moduleGraph.resolveUrl(spec);
req.url = url[1];
return this.viteServer.middlewares.handle(req, res, next);
}
let filePath: URL | undefined;
try {
const route = matchRoute(pathname, this.manifest);
// 404: continue to Vite
if (!route) {
next();
return;
}
this.mostRecentRoute = route;
// handle .astro and .md pages
filePath = new URL(`./${route.component}`, this.config.projectRoot);
const html = await ssr({
astroConfig: this.config,
filePath,
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 (err: any) {
this.viteServer.ssrFixStacktrace(err);
this.viteServer.ws.send({ type: 'error', err });
const statusCode = 500;
const html = errorTemplate({
statusCode,
title: 'Internal Error',
tabTitle: '500: Error',
message: stripAnsi(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({
astroConfig: this.config,
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,37 @@
/**
* 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 */
export function reload({ url, reqTime }: { url: string; reqTime: number }): string {
let color = yellow;
return `${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))}`;
}
/** Display port in use */
export function portInUse({ port }: { port: number }): string {
return `Port ${port} in use. Trying a new one…`;
}

View file

@ -0,0 +1,53 @@
import { encode } from 'html-entities';
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;
}
pre {
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>
<pre><code>${encode(message)}</code></pre>
</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,255 +0,0 @@
import type { Renderer, AstroComponentMetadata } 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';
// 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 = {
...GENERATOR,
Literal(node, state) {
if (node.raw != null) {
// escape closing script tags in strings so browsers wouldn't interpret them as
// closing the actual end tag in HTML
state.write(node.raw.replace('</script>', '<\\/script>'));
} else {
GENERATOR.Literal(node, state);
}
},
};
const serialize = (value: Value) =>
generate(valueToEstree(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);
}
function isCustomElementTag(name: unknown) {
return typeof name === 'string' && /-/.test(name);
}
const rendererCache = new Map<any, RendererInstance>();
/** For client:only components, attempt to infer the required renderer. */
function inferClientRenderer(metadata: Partial<AstroComponentMetadata>) {
// If there's only one renderer, assume it's the required renderer
if (rendererInstances.length === 1) {
return rendererInstances[0];
} else if (metadata.value) {
// Attempt to find the renderer by matching the hydration value
const hint = metadata.value;
let match = rendererInstances.find((instance) => instance.name === hint);
if (!match) {
// Didn't find an exact match, try shorthand hints for the internal renderers
const fullHintName = `@astrojs/renderer-${hint}`;
match = rendererInstances.find((instance) => instance.name === fullHintName);
}
if (!match) {
throw new Error(
`Couldn't find a renderer for <${metadata.displayName} client:only="${metadata.value}" />. Is there a renderer that matches the "${metadata.value}" hint in your Astro config?`
);
}
return match;
} else {
// Multiple renderers included but no hint was provided
throw new Error(
`Can't determine the renderer for ${metadata.displayName}. Include a hint similar to <${metadata.displayName} client:only="react" /> when multiple renderers are included in your Astro config.`
);
}
}
/** 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> {
// For client:only components, the component can't be imported
// during SSR. We need to infer the required renderer.
if (metadata.hydrate === 'only') {
return inferClientRenderer(metadata);
}
if (rendererCache.has(Component)) {
return rendererCache.get(Component);
}
const errors: Error[] = [];
for (const instance of rendererInstances) {
const { renderer } = instance;
// 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);
if (shouldUse) {
rendererCache.set(Component, instance);
return instance;
}
} catch (err) {
errors.push(err);
}
}
if (errors.length) {
// For now just throw the first error we encounter.
throw errors[0];
}
}
interface HydrateScriptOptions {
instance: RendererInstance;
astroId: string;
props: any;
}
/** 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 { hydrate, componentUrl, componentExport } = metadata;
let hydrationSource = '';
if (instance.hydrationPolyfills.length) {
hydrationSource += `await Promise.all([${instance.hydrationPolyfills.map((src) => `import("${src}")`).join(', ')}]);\n`;
}
hydrationSource += source
? `
const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${source}")]);
return (el, children) => hydrate(el)(Component, ${serialize(props)}, children);
`
: `
await import("${componentUrl}");
return () => {};
`;
const hydrationScript = `<script type="module">
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
setup("${astroId}", {${metadata.value ? `value: "${metadata.value}"` : ''}}, async () => {
${hydrationSource}
});
</script>`;
return hydrationScript;
}
const getComponentName = (Component: any, componentProps: any) => {
if (componentProps.displayName) return componentProps.displayName;
switch (typeof Component) {
case 'function':
return Component.displayName ?? Component.name;
case 'string':
return Component;
default: {
return Component;
}
}
};
const prepareSlottedChildren = (children: string | Record<any, any>[]) => {
const $slots: Record<string, string> = {
default: '',
};
for (const child of children) {
if (typeof child === 'string') {
$slots.default += child;
} else if (typeof child === 'object' && child['$slot']) {
if (!$slots[child['$slot']]) $slots[child['$slot']] = '';
$slots[child['$slot']] += child.children.join('').replace(new RegExp(`slot="${child['$slot']}"\s*`, ''));
}
}
return { $slots };
};
const removeSlottedChildren = (_children: string | Record<any, any>[]) => {
let children = '';
for (const child of _children) {
if (typeof child === 'string') {
children += child;
} else if (typeof child === 'object' && child['$slot']) {
children += child.children.join('');
}
}
return children;
};
/** The main wrapper for any components in Astro files */
export function __astro_component(Component: any, 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)) {
throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
}
return async function __astro_component_internal(props: any, ..._children: any[]) {
if (Component.isAstroComponent) {
return Component.__render(props, prepareSlottedChildren(_children));
}
const children = removeSlottedChildren(_children);
let instance = await resolveRenderer(Component, props, children, metadata);
if (!instance) {
if (isCustomElementTag(Component)) {
instance = 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;
}
if (!instance) {
const name = getComponentName(Component, metadata);
throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`);
}
}
let html = '';
// Skip SSR for components using client:only hydration
if (metadata.hydrate !== 'only') {
const rendered = await instance.renderer.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('');
html = html + polyfillScripts;
}
// If we're NOT hydrating this component, just return the HTML
if (!metadata.hydrate) {
// It's safe to remove <astro-fragment>, static content doesn't need the wrapper
return html.replace(/\<\/?astro-fragment\>/g, '');
}
// 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 astroRoot = `<astro-root uid="${uniqueIdHashed}">${html}</astro-root>`;
return [astroRoot, script].join('\n');
};
}

View file

@ -0,0 +1,275 @@
import type { AstroComponentMetadata } from '../@types/astro';
import { valueToEstree } from 'estree-util-value-to-estree';
import * as astring from 'astring';
import shorthash from 'shorthash';
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: astring.Generator = {
...GENERATOR,
Literal(node, state) {
if (node.raw != null) {
// escape closing script tags in strings so browsers wouldn't interpret them as
// closing the actual end tag in HTML
state.write(node.raw.replace('</script>', '<\\/script>'));
} else {
GENERATOR.Literal(node, state);
}
},
};
const serialize = (value: any) =>
generate(valueToEstree(value), {
generator: customGenerator,
});
async function _render(child: any): Promise<any> {
child = await child;
if (Array.isArray(child)) {
return (await Promise.all(child.map((value) => _render(value)))).join('\n');
} else if (typeof child === 'function') {
// Special: If a child is a function, call it automatically.
// This lets you do {() => ...} without the extra boilerplate
// of wrapping it in a function and calling it.
return _render(child());
} else if (typeof child === 'string') {
return child;
} else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values.
} else if (child instanceof AstroComponent) {
return await renderAstroComponent(child);
} else {
return child;
}
}
export class AstroComponent {
private htmlParts: TemplateStringsArray;
private expressions: any[];
constructor(htmlParts: TemplateStringsArray, expressions: any[]) {
this.htmlParts = htmlParts;
this.expressions = expressions;
}
*[Symbol.iterator]() {
const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
yield _render(html);
yield _render(expression);
}
}
}
export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new AstroComponent(htmlParts, expressions);
}
export interface AstroComponentFactory {
(result: any, props: any, slots: any): ReturnType<typeof render>;
isAstroComponentFactory?: boolean;
}
export function createComponent(cb: AstroComponentFactory) {
// Add a flag to this callback to mark it as an Astro component
(cb as any).isAstroComponentFactory = true;
return cb;
}
function extractHydrationDirectives(inputProps: Record<string | number, any>): { hydrationDirective: [string, any] | null; props: Record<string | number, any> } {
let props: Record<string | number, any> = {};
let hydrationDirective: [string, any] | null = null;
for (const [key, value] of Object.entries(inputProps)) {
if (key.startsWith('client:')) {
hydrationDirective = [key.split(':')[1], value];
} else {
props[key] = value;
}
}
return { hydrationDirective, props };
}
interface HydrateScriptOptions {
renderer: any;
astroId: string;
props: any;
}
/** For hydrated components, generate a <script type="module"> to load the component */
async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>) {
const { renderer, astroId, props } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata;
if (!componentExport) {
throw new Error(`Unable to resolve a componentExport for "${metadata.displayName}"! Please open an issue.`);
}
let hydrationSource = '';
if (renderer.hydrationPolyfills) {
hydrationSource += `await Promise.all([${renderer.hydrationPolyfills.map((src: string) => `\n import("${src}")`).join(', ')}]);\n`;
}
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}");
return () => {};
`;
const hydrationScript = `<script type="module">
import setup from 'astro/client/${hydrate}.js';
setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => {
${hydrationSource}
});
</script>
`;
return hydrationScript;
}
export async function renderSlot(result: any, slotted: string, fallback?: any) {
if (slotted) {
return _render(slotted);
}
return fallback;
}
export async function renderComponent(result: any, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
Component = await Component;
const children = await renderSlot(result, slots?.default);
const { renderers } = result._metadata;
if (Component && (Component as any).isAstroComponentFactory) {
const output = await renderToString(result, Component as any, _props, slots);
return output;
}
let metadata: AstroComponentMetadata = { displayName };
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)) {
// throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
// }
const { hydrationDirective, props } = extractHydrationDirectives(_props);
let html = '';
if (hydrationDirective) {
metadata.hydrate = hydrationDirective[0] as AstroComponentMetadata['hydrate'];
metadata.hydrateArgs = hydrationDirective[1];
}
for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
for (const [key, value] of Object.entries(exported as any)) {
if (Component === value) {
metadata.componentExport = { value: key };
metadata.componentUrl = url;
break;
}
}
}
let renderer = null;
for (const r of renderers) {
if (await r.ssr.check(Component, props, children)) {
renderer = r;
}
}
if(renderer === null) {
if(typeof Component === 'string') {
html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}</${Component}>`);
} else {
throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
}
} else {
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
}
if (!hydrationDirective) {
return html.replace(/\<\/?astro-fragment\>/g, '');
}
const astroId = shorthash.unique(html);
result.scripts.add(await generateHydrateScript({ renderer, astroId, props }, metadata as Required<AstroComponentMetadata>));
return `<astro-root uid="${astroId}">${html}</astro-root>`;
}
export function addAttribute(value: any, key: string) {
if (value == null || value === false) {
return '';
}
return ` ${key}="${value}"`;
}
export function spreadAttributes(values: Record<any, any>) {
let output = '';
for (const [key, value] of Object.entries(values)) {
output += addAttribute(value, key);
}
return output;
}
export function defineStyleVars(astroId: string, vars: Record<any, any>) {
let output = '\n';
for (const [key, value] of Object.entries(vars)) {
output += ` --${key}: ${value};\n`;
}
return `.${astroId} {${output}}`;
}
export function defineScriptVars(vars: Record<any, any>) {
let output = '';
for (const [key, value] of Object.entries(vars)) {
output += `let ${key} = ${JSON.stringify(value)};\n`;
}
return output;
}
export async function renderToString(result: any, componentFactory: AstroComponentFactory, props: any, children: any) {
const Component = await componentFactory(result, props, children);
let template = await renderAstroComponent(Component);
return template;
}
export async function renderPage(result: any, Component: AstroComponentFactory, props: any, children: any) {
const template = await renderToString(result, Component, props, children);
const styles = Array.from(result.styles).map((style: any) => renderElement('style', style));
const scripts = Array.from(result.scripts);
return template.replace('</head>', styles.join('\n') + scripts.join('\n') + '</head>');
}
export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) {
let template = '';
for await (const value of component) {
if (value || value === 0) {
template += value;
}
}
return template;
}
function renderElement(name: string, { props: _props, children = '' }: { props: Record<any, any>; children?: string }) {
const { hoist: _, 'data-astro-id': astroId, 'define:vars': defineVars, ...props } = _props;
if (defineVars) {
if (name === 'style') {
children = defineStyleVars(astroId, defineVars) + '\n' + children;
}
if (name === 'script') {
children = defineScriptVars(defineVars) + '\n' + children;
}
}
return `<${name}${spreadAttributes(props)}>${children}</${name}>`;
}

View file

@ -1,13 +1,30 @@
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;
};
function getLoggerLocale(): string {
const defaultLocale = 'en-US';
if (process.env.LANG) {
const extractedLocale = process.env.LANG.split('.')[0].replace(/_/g, '-');
// Check if language code is atleast two characters long (ie. en, es).
// NOTE: if "c" locale is encountered, the default locale will be returned.
if (extractedLocale.length < 2) return defaultLocale;
else return extractedLocale;
} else return defaultLocale;
}
const dt = new Intl.DateTimeFormat(getLoggerLocale(), {
hour: '2-digit',
minute: '2-digit',
});
export const defaultLogDestination = new Writable({
objectMode: true,
write(event: LogMessage, _, callback) {
@ -15,6 +32,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') {
@ -59,7 +79,7 @@ export interface LogMessage {
args: Array<any>;
}
const levels: Record<LoggerLevel, number> = {
export const levels: Record<LoggerLevel, number> = {
debug: 20,
info: 30,
warn: 40,
@ -68,7 +88,12 @@ const levels: Record<LoggerLevel, number> = {
};
/** Full logging API */
export function log(opts: LogOptions = {}, level: LoggerLevel, type: string | null, ...args: Array<any>) {
export function log(
opts: LogOptions = {},
level: LoggerLevel,
type: string | null,
...args: Array<any>
) {
const logLevel = opts.level ?? defaultLogOptions.level;
const dest = opts.dest ?? defaultLogOptions.dest;
const event: LogMessage = {
@ -87,22 +112,38 @@ export function log(opts: LogOptions = {}, level: LoggerLevel, type: string | nu
}
/** Emit a message only shown in debug mode */
export function debug(opts: LogOptions, type: string | null, ...messages: Array<any>) {
export function debug(
opts: LogOptions,
type: string | null,
...messages: Array<any>
) {
return log(opts, 'debug', type, ...messages);
}
/** Emit a general info message (be careful using this too much!) */
export function info(opts: LogOptions, type: string | null, ...messages: Array<any>) {
export function info(
opts: LogOptions,
type: string | null,
...messages: Array<any>
) {
return log(opts, 'info', type, ...messages);
}
/** Emit a warning a user should be aware of */
export function warn(opts: LogOptions, type: string | null, ...messages: Array<any>) {
export function warn(
opts: LogOptions,
type: string | null,
...messages: Array<any>
) {
return log(opts, 'warn', type, ...messages);
}
/** Emit a fatal error message the user should address. */
export function error(opts: LogOptions, type: string | null, ...messages: Array<any>) {
export function error(
opts: LogOptions,
type: string | null,
...messages: Array<any>
) {
return log(opts, 'error', type, ...messages);
}
@ -133,11 +174,10 @@ export function parseError(opts: LogOptions, err: CompileError) {
opts,
'parse-error',
`
${underline(bold(grey(`${err.filename || ''}:${err.start.line}:${err.start.column}`)))}
${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,38 +0,0 @@
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';
const logging: LogOptions = {
level: defaultLogLevel,
dest: defaultLogDestination,
};
/** The primary dev action */
export async function preview(astroConfig: AstroConfig) {
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);
});
// 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}/`);
})
.on('error', (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {
error(logging, 'preview', `Address ${hostname}:${port} already in use. Try changing devOptions.port in your config file`);
} else {
error(logging, 'preview', err.stack);
}
process.exit(1);
});
}

View file

@ -0,0 +1,67 @@
import type { AstroConfig } from '../@types/astro';
import type { LogOptions } from '../logger.js';
import http from 'http';
import { performance } from 'perf_hooks';
import send from 'send';
import { fileURLToPath } from 'url';
import * as msg from '../dev/messages.js';
import { error, info } from '../logger.js';
interface PreviewOptions {
logging: LogOptions;
}
interface PreviewServer {
hostname: string;
port: number;
server: http.Server;
stop(): Promise<void>;
}
/** The primary dev action */
export default async function preview(config: AstroConfig, { logging }: PreviewOptions): Promise<PreviewServer> {
const startServerTime = performance.now();
// Create the preview server, send static files out of the `dist/` directory.
const server = http.createServer((req, res) => {
send(req, req.url!, {
root: fileURLToPath(config.dist),
}).pipe(res);
});
// Start listening on `hostname:port`.
let port = config.devOptions.port;
const { hostname } = config.devOptions;
await new Promise<http.Server>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
if (err.code && err.code === 'EADDRINUSE') {
info(logging, 'astro', msg.portInUse({ port }));
port++;
} else {
error(logging, 'preview', err.stack);
server.removeListener('error', onError);
reject(err);
}
};
server
.listen(port, hostname, () => {
info(logging, 'preview', msg.devStart({ startupTime: performance.now() - startServerTime }));
info(logging, 'preview', msg.devHost({ host: `http://${hostname}:${port}/` }));
resolve(server);
})
.on('error', (err: NodeJS.ErrnoException) => {
process.exit(1);
});
});
return {
hostname,
port,
server,
stop: async () => {
server.close();
},
};
}

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, 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] || (await mod.exports.getStaticPaths(args)).flat();
return runtimeConfig.cache.staticPaths[component];
}
/** 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

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

@ -0,0 +1,33 @@
import '@vite/client';
if (import.meta.hot) {
const parser = new DOMParser();
import.meta.hot.on('astro:reload', async ({ html }: { html: string }) => {
const { default: morphdom } = await import('morphdom');
const doc = parser.parseFromString(html, 'text/html');
morphdom(document.head, doc.head, {
onBeforeElUpdated: function (fromEl, toEl) {
if (fromEl.isEqualNode(toEl)) {
return false;
}
return true;
},
});
morphdom(document.body, doc.body, {
onBeforeElUpdated: function (fromEl, toEl) {
if (fromEl.localName === 'astro-root') {
return fromEl.getAttribute('uid') !== toEl.getAttribute('uid');
}
if (fromEl.isEqualNode(toEl)) {
return false;
}
return true;
},
});
});
}

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

@ -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,293 @@
import type { BuildResult } from 'esbuild';
import type { ViteDevServer } from 'vite';
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro';
import type { LogOptions } from '../logger';
import cheerio from 'cheerio';
import * as eslexer from 'es-module-lexer';
import { fileURLToPath } from 'url';
import fs from 'fs';
import path from 'path';
import slash from 'slash';
import glob from 'tiny-glob';
import { renderPage } from '../internal/index.js';
import { generatePaginateFunction } from './paginate.js';
import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js';
import { parseNpmName, canonicalURL as getCanonicalURL, codeFrame } from './util.js';
interface SSROptions {
/** an instance of the AstroConfig */
astroConfig: AstroConfig;
/** 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;
const cache = new Map();
// TODO: improve validation and error handling here.
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]) {
const renderers = await Promise.all(
ids.map(async (renderer) => {
if (cache.has(renderer)) return cache.get(renderer);
const resolvedRenderer: any = {};
// We can dynamically import the renderer by itself because it shouldn't have
// any non-standard imports, the index is just meta info.
// The other entrypoints need to be loaded through Vite.
const {
default: { name, client, polyfills, hydrationPolyfills, server },
} = await import(renderer);
resolvedRenderer.name = name;
if (client) resolvedRenderer.source = path.posix.join(renderer, client);
if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src));
if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src));
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server));
const { default: rendererSSR } = await viteServer.ssrLoadModule(url);
resolvedRenderer.ssr = rendererSSR;
cache.set(renderer, resolvedRenderer);
return resolvedRenderer;
})
);
return renderers;
}
async function resolveImportedModules(viteServer: ViteDevServer, file: URL) {
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(slash(fileURLToPath(file))); // note: for some reason Vite expects forward slashes here for Windows, which `slash()` helps resolve
const modulesByFile = viteServer.moduleGraph.getModulesByFile(url);
if (!modulesByFile) {
return {};
}
let importedModules: Record<string, any> = {};
const moduleNodes = Array.from(modulesByFile);
// Loop over the importedModules and grab the exports from each one.
// We'll pass these to the shared $$result so renderers can match
// components to their exported identifier and URL
// NOTE: Important that this is parallelized as much as possible!
await Promise.all(
moduleNodes.map((moduleNode) => {
const entries = Array.from(moduleNode.importedModules);
return Promise.all(
entries.map((entry) => {
// Skip our internal import that every module will have
if (entry.id?.endsWith('astro/dist/internal/index.js')) {
return;
}
return viteServer.moduleGraph.ensureEntryFromUrl(entry.url).then((mod) => {
if (mod.ssrModule) {
importedModules[mod.url] = mod.ssrModule;
return;
} else {
return viteServer.ssrLoadModule(mod.url).then((result) => {
importedModules[mod.url] = result.ssrModule;
return;
});
}
});
})
);
})
);
return importedModules;
}
/** use Vite to SSR */
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
try {
// 1. load module
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
// 1.5. resolve renderers and imported modules.
// important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty
const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, filePath)]);
// 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);
const Component = await mod.default;
const ext = path.posix.extname(filePath.pathname);
if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
const result = {
styles: new Set(),
scripts: new Set(),
/** This function returns the `Astro` faux-global */
createAstro: (props: any) => {
const site = new URL(origin);
const url = new URL('.' + pathname, site);
const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin);
const fetchContent = createFetchContent(fileURLToPath(filePath));
return {
isPage: true,
site,
request: { url, canonicalURL },
props,
fetchContent,
};
},
_metadata: { importedModules, renderers },
};
const createFetchContent = (currentFilePath: string) => {
const fetchContentCache = new Map<string, any>();
return async (pattern: string) => {
const cwd = path.dirname(currentFilePath);
const cacheKey = `${cwd}:${pattern}`;
if (fetchContentCache.has(cacheKey)) {
return fetchContentCache.get(cacheKey);
}
const files = await glob(pattern, { cwd, absolute: true });
const contents = await Promise.all(
files.map(async (file) => {
const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any;
return { ...frontmatter, astro };
})
);
fetchContentCache.set(cacheKey, contents);
return contents;
};
};
let html = await renderPage(result, Component, {}, null);
// 4. modify response
if (mode === 'development') {
// inject Astro HMR code
html = injectAstroHMR(html);
// inject Vite HMR code
html = injectViteClient(html);
// replace client hydration scripts
html = resolveNpmImports(html);
}
// 5. finish
return html;
} catch (e: any) {
// Astro error (thrown by esbuild so it needs to be formatted for Vite)
if (e.errors) {
const { location, pluginName, text } = (e as BuildResult).errors[0];
const err = new Error(text) as SSRError;
if (location) err.loc = { file: location.file, line: location.line, column: location.column };
const frame = codeFrame(await fs.promises.readFile(filePath, 'utf8'), err.loc);
err.frame = frame;
err.id = location?.file;
err.message = `${location?.file}: ${text}
${frame}
`;
err.stack = e.stack;
if (pluginName) err.plugin = pluginName;
throw err;
}
// Vite error (already formatted)
throw e;
}
}
/** Injects Vite client code */
function injectViteClient(html: string): string {
return html.replace('<head>', `<head><script type="module" src="/@vite/client"></script>`);
}
/** Injects Astro HMR client code */
function injectAstroHMR(html: string): string {
return html.replace('<head>', `<head><script type="module" src="/@astro/runtime/hmr"></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,77 @@
import type { ErrorPayload } from 'vite';
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,
};
}
/** generate code frame from esbuild error */
export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string {
if (!loc) return '';
const lines = src.replace(/\r\n/g, '\n').split('\n');
// 1. grab 2 lines before, and 3 lines after focused line
const visibleLines = [];
for (let n = -2; n <= 2; n++) {
if (lines[loc.line + n]) visibleLines.push(loc.line + n);
}
// 2. figure out gutter width
let gutterWidth = 0;
for (const lineNo of visibleLines) {
let w = `> ${lineNo}`;
if (w.length > gutterWidth) gutterWidth = w.length;
}
// 3. print lines
let output = '';
for (const lineNo of visibleLines) {
const isFocusedLine = lineNo === loc.line - 1;
output += isFocusedLine ? '> ' : ' ';
output += `${lineNo + 1} | ${lines[lineNo]}\n`;
if (isFocusedLine) output += `${[...new Array(gutterWidth)].join(' ')} | ${[...new Array(loc.column)].join(' ')}^\n`;
}
return output;
}

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