Compare commits
31 commits
Author | SHA1 | Date | |
---|---|---|---|
|
d3ab66356e | ||
|
b89fffc2ed | ||
|
b9ea9284e7 | ||
|
876569d97a | ||
|
584947e862 | ||
|
8a47544480 | ||
|
a2e0fc27d7 | ||
|
f0839c62df | ||
|
650b9b12c1 | ||
|
863bf58c46 | ||
|
7292c2eb04 | ||
|
36f06044ef | ||
|
aa07cb4339 | ||
|
800a701c16 | ||
|
7d494b2df2 | ||
|
2d83bb2c79 | ||
|
dc4be6c618 | ||
|
15bef35226 | ||
|
91cb38f451 | ||
|
8126686878 | ||
|
cf2e3b31cb | ||
|
3c1fcc237d | ||
|
ec746089df | ||
|
5909f27b19 | ||
|
3df506a219 | ||
|
95b3f254d8 | ||
|
d5bee9039d | ||
|
d34dec859b | ||
|
f9c5f7ad3c | ||
|
58b94197f7 | ||
|
2c07089957 |
280 changed files with 6799 additions and 10517 deletions
5
.changeset/hungry-wolves-shave.md
Normal file
5
.changeset/hungry-wolves-shave.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Fix logger locale parsing
|
8
.changeset/orange-swans-smoke.md
Normal file
8
.changeset/orange-swans-smoke.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
'astro': patch
|
||||
'@astrojs/prism': patch
|
||||
'create-astro': patch
|
||||
'@astrojs/markdown-remark': patch
|
||||
---
|
||||
|
||||
Update dependency bundling to include more deps
|
5
.changeset/pink-trainers-learn.md
Normal file
5
.changeset/pink-trainers-learn.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Use new compiler, move Astro to Vite
|
9
.changeset/tiny-bulldogs-lie.md
Normal file
9
.changeset/tiny-bulldogs-lie.md
Normal 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
|
80
.github/workflows/ci.yml
vendored
80
.github/workflows/ci.yml
vendored
|
@ -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
30
.github/workflows/release-next.yml
vendored
Normal 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 don’t 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
|
||||
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
```
|
||||
|
||||
|
|
|
@ -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 -->
|
||||
|
|
9
examples/blog/src/components/Heading.astro
Normal file
9
examples/blog/src/components/Heading.astro
Normal file
|
@ -0,0 +1,9 @@
|
|||
<h1>
|
||||
<slot/>
|
||||
</h1>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
15
examples/blog/src/pages/posts/index.md
Normal file
15
examples/blog/src/pages/posts/index.md
Normal 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}?
|
|
@ -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)
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
// Component Imports
|
||||
import Counter from '../components/Counter.jsx'
|
||||
import Counter from '../components/Counter.tsx'
|
||||
|
||||
|
||||
// Full Astro Component Syntax:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
{
|
||||
"ignoreChanges": ["**/test/**", "**/*.md"],
|
||||
"useWorkspaces": true,
|
||||
"version": "4.0.0"
|
||||
"version": "4.0.0",
|
||||
"command": {
|
||||
"run": {
|
||||
"npmClient": "yarn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
32
package.json
32
package.json
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { renderMarkdown } from '@astrojs/markdown-support';
|
||||
import { renderMarkdown } from '@astrojs/markdown-remark';
|
||||
|
||||
export interface Props {
|
||||
content?: string;
|
||||
|
|
|
@ -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!`)
|
||||
}
|
||||
|
|
27
packages/astro/internal.d.ts
vendored
27
packages/astro/internal.d.ts
vendored
|
@ -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';
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
||||
export interface InlineScriptInfo {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ExternalScriptInfo {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export type ScriptInfo = InlineScriptInfo | ExternalScriptInfo;
|
||||
|
||||
export interface TransformResult {
|
||||
script: string;
|
||||
imports: string[];
|
||||
exports: string[];
|
||||
components: string[];
|
||||
hydrationPolyfills?: string[];
|
||||
/** Don’t try and build these dependencies for client */
|
||||
external?: string[];
|
||||
/** Clientside requirements */
|
||||
knownEntrypoints?: string[];
|
||||
polyfills?: string[];
|
||||
/** Import statement for renderer */
|
||||
source?: string;
|
||||
/** JSX identifier (e.g. 'react' or 'solid-js') */
|
||||
jsxImportSource?: string;
|
||||
/** Babel transform options */
|
||||
jsxTransformOptions?: JSXTransformFn;
|
||||
/** Utilies for server-side rendering */
|
||||
ssr: {
|
||||
check: AsyncRendererComponentFn<boolean>;
|
||||
renderToStaticMarkup: AsyncRendererComponentFn<{
|
||||
html: string;
|
||||
css?: string;
|
||||
hoistedScripts: ScriptInfo[];
|
||||
getStaticPaths?: string;
|
||||
hasCustomElements: boolean;
|
||||
customElementCandidates: Map<string, string>;
|
||||
}>;
|
||||
};
|
||||
/** Add plugins to Vite, if any */
|
||||
vitePlugins?: vite.Plugin[];
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
result: TransformResult;
|
||||
contents: string;
|
||||
css?: string;
|
||||
export interface RouteData {
|
||||
component: string;
|
||||
generate: (data?: any) => string;
|
||||
params: string[];
|
||||
pathname?: string;
|
||||
pattern: RegExp;
|
||||
type: 'page';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
}
|
25
packages/astro/src/@types/estree-walker.d.ts
vendored
25
packages/astro/src/@types/estree-walker.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
// TODO: add types (if helpful)
|
||||
declare module 'micromark-extension-gfm';
|
||||
declare module 'micromark-extension-gfm/html.js';
|
|
@ -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>;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1 +1 @@
|
|||
export { AstroConfig, AstroUserConfig } from './config';
|
||||
export { AstroConfig, AstroUserConfig } from './astro';
|
||||
|
|
1
packages/astro/src/@types/resolve.d.ts
vendored
1
packages/astro/src/@types/resolve.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
declare module 'resolve';
|
2
packages/astro/src/@types/tailwind.d.ts
vendored
2
packages/astro/src/@types/tailwind.d.ts
vendored
|
@ -1,2 +0,0 @@
|
|||
// we shouldn‘t have this as a dependency for Astro, but we may dynamically import it if a user requests it, so let TS know about it
|
||||
declare module 'tailwindcss';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 (can’t disable, as they should provide this)
|
||||
if (!astroConfig.buildOptions.site) {
|
||||
warn(logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
|
||||
}
|
||||
|
||||
const mode: RuntimeMode = 'production';
|
||||
const astroRuntime = await createRuntime(astroConfig, { mode, logging: runtimeLogging });
|
||||
const { runtimeConfig } = astroRuntime;
|
||||
const { snowpackRuntime } = runtimeConfig;
|
||||
|
||||
try {
|
||||
// 0. erase build directory
|
||||
await del(fileURLToPath(astroConfig.dist));
|
||||
|
||||
/**
|
||||
* 1. Build Pages
|
||||
* Source files are built in parallel and stored in memory. Most assets are also gathered here, too.
|
||||
*/
|
||||
timer.build = performance.now();
|
||||
info(logging, 'build', yellow('! building pages...'));
|
||||
const allRoutesAndPaths = await Promise.all(
|
||||
runtimeConfig.manifest.routes.map(async (route): Promise<[RouteData, string[]]> => {
|
||||
if (route.path) {
|
||||
return [route, [route.path]];
|
||||
} else {
|
||||
const result = await getStaticPathsForPage({
|
||||
astroConfig,
|
||||
astroRuntime,
|
||||
route,
|
||||
snowpackRuntime,
|
||||
logging,
|
||||
});
|
||||
if (result.rss.xml) {
|
||||
if (buildState[result.rss.url]) {
|
||||
throw new Error(`[getStaticPaths] RSS feed ${result.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})`);
|
||||
}
|
||||
buildState[result.rss.url] = {
|
||||
srcPath: new URL(result.rss.url, projectRoot),
|
||||
contents: result.rss.xml,
|
||||
contentType: 'text/xml',
|
||||
encoding: 'utf8',
|
||||
};
|
||||
}
|
||||
return [route, result.paths];
|
||||
}
|
||||
})
|
||||
);
|
||||
try {
|
||||
await Promise.all(
|
||||
allRoutesAndPaths.map(async ([route, paths]: [RouteData, string[]]) => {
|
||||
for (const p of paths) {
|
||||
await buildStaticPage({
|
||||
astroConfig,
|
||||
buildState,
|
||||
route,
|
||||
path: p,
|
||||
astroRuntime,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.filename) {
|
||||
let stack = e.stack
|
||||
.replace(/Object\.__render \(/gm, '')
|
||||
.replace(/\/_astro\/(.+)\.astro\.js\:\d+\:\d+\)/gm, (_: string, $1: string) => 'file://' + fileURLToPath(projectRoot) + $1 + '.astro')
|
||||
.split('\n');
|
||||
stack.splice(1, 0, ` at file://${e.filename}`);
|
||||
stack = stack.join('\n');
|
||||
error(
|
||||
logging,
|
||||
'build',
|
||||
`${red(`Unable to render ${underline(e.filename.replace(fileURLToPath(projectRoot), ''))}`)}
|
||||
|
||||
${stack}
|
||||
`
|
||||
);
|
||||
} else {
|
||||
error(logging, 'build', e.message);
|
||||
}
|
||||
error(logging, 'build', red('✕ building pages failed!'));
|
||||
|
||||
await astroRuntime.shutdown();
|
||||
return 1;
|
||||
}
|
||||
info(logging, 'build', green('✔'), 'pages built.');
|
||||
debug(logging, 'build', `built pages [${stopTimer(timer.build)}]`);
|
||||
|
||||
// after pages are built, build depTree
|
||||
timer.deps = performance.now();
|
||||
const scanPromises: Promise<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 shouldn’t be a build error here
|
||||
throw (result as any).error || new Error(`unexpected ${result.statusCode} response from "${url}".`);
|
||||
}
|
||||
buildState[url] = {
|
||||
srcPath: new URL(url, projectRoot),
|
||||
contents: result.contents,
|
||||
contentType: result.contentType || mime.getType(url) || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(scanPromises);
|
||||
debug(logging, 'build', `scanned deps [${stopTimer(timer.deps)}]`);
|
||||
|
||||
/**
|
||||
* 2. Bundling 1st Pass: In-memory
|
||||
* Bundle CSS, and anything else that can happen in memory (for now, JS bundling happens after writing to disk)
|
||||
*/
|
||||
info(logging, 'build', yellow('! optimizing css...'));
|
||||
timer.prebundleCSS = performance.now();
|
||||
await Promise.all([
|
||||
bundleCSS({ buildState, astroConfig, logging, depTree }).then(() => {
|
||||
debug(logging, 'build', `bundled CSS [${stopTimer(timer.prebundleCSS)}]`);
|
||||
}),
|
||||
bundleHoistedJS({ buildState, astroConfig, logging, depTree, runtime: astroRuntime, dist: astroConfig.dist }),
|
||||
// TODO: optimize images?
|
||||
]);
|
||||
// TODO: minify HTML?
|
||||
info(logging, 'build', green('✔'), 'css optimized.');
|
||||
|
||||
/**
|
||||
* 3. Write to disk
|
||||
* Also clear in-memory bundle
|
||||
*/
|
||||
// collect stats output
|
||||
const urlStats = await collectBundleStats(buildState, depTree);
|
||||
|
||||
// collect JS imports for bundling
|
||||
const jsImports = await collectJSImports(buildState);
|
||||
|
||||
// write sitemap
|
||||
if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) {
|
||||
timer.sitemap = performance.now();
|
||||
info(logging, 'build', yellow('! creating sitemap...'));
|
||||
const sitemap = generateSitemap(buildState, astroConfig.buildOptions.site);
|
||||
const sitemapPath = new URL('sitemap.xml', astroConfig.dist);
|
||||
await fs.promises.mkdir(path.dirname(fileURLToPath(sitemapPath)), { recursive: true });
|
||||
await fs.promises.writeFile(sitemapPath, sitemap, 'utf8');
|
||||
info(logging, 'build', green('✔'), 'sitemap built.');
|
||||
debug(logging, 'build', `built sitemap [${stopTimer(timer.sitemap)}]`);
|
||||
}
|
||||
|
||||
// write to disk and free up memory
|
||||
timer.write = performance.now();
|
||||
for (const id of Object.keys(buildState)) {
|
||||
const outPath = new URL(`.${id}`, astroConfig.dist);
|
||||
const parentDir = path.dirname(fileURLToPath(outPath));
|
||||
await fs.promises.mkdir(parentDir, { recursive: true });
|
||||
const handle = await fs.promises.open(outPath, 'w');
|
||||
await fs.promises.writeFile(handle, buildState[id].contents, buildState[id].encoding);
|
||||
|
||||
// Ensure the file handle is not left hanging which will
|
||||
// result in the garbage collector loggin errors in the console
|
||||
// when it eventually has to close them.
|
||||
await handle.close();
|
||||
|
||||
delete buildState[id];
|
||||
delete depTree[id];
|
||||
}
|
||||
debug(logging, 'build', `wrote files to disk [${stopTimer(timer.write)}]`);
|
||||
|
||||
/**
|
||||
* 4. Copy Public Assets
|
||||
*/
|
||||
if (fs.existsSync(astroConfig.public)) {
|
||||
info(logging, 'build', yellow(`! copying public folder...`));
|
||||
timer.public = performance.now();
|
||||
const cwd = fileURLToPath(astroConfig.public);
|
||||
const publicFiles = await glob('**/*', { cwd, filesOnly: true });
|
||||
await Promise.all(
|
||||
publicFiles.map(async (filepath) => {
|
||||
const srcPath = new URL(filepath, astroConfig.public);
|
||||
const distPath = new URL(filepath, astroConfig.dist);
|
||||
await fs.promises.mkdir(path.dirname(fileURLToPath(distPath)), { recursive: true });
|
||||
await fs.promises.copyFile(srcPath, distPath);
|
||||
})
|
||||
);
|
||||
debug(logging, 'build', `copied public folder [${stopTimer(timer.public)}]`);
|
||||
info(logging, 'build', green('✔'), 'public folder copied.');
|
||||
} else {
|
||||
if (path.basename(astroConfig.public.toString()) !== 'public') {
|
||||
info(logging, 'tip', yellow(`! no public folder ${astroConfig.public} found...`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Bundling 2nd Pass: On disk
|
||||
* Bundle JS, which requires hard files to optimize
|
||||
*/
|
||||
info(logging, 'build', yellow(`! bundling...`));
|
||||
if (jsImports.size > 0) {
|
||||
timer.bundleJS = performance.now();
|
||||
const jsStats = await bundleJS(jsImports, { dist: astroConfig.dist, astroRuntime });
|
||||
mapBundleStatsToURLStats({ urlStats, depTree, bundleStats: jsStats });
|
||||
debug(logging, 'build', `bundled JS [${stopTimer(timer.bundleJS)}]`);
|
||||
info(logging, 'build', green(`✔`), 'bundling complete.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. Print stats
|
||||
*/
|
||||
logURLStats(logging, urlStats);
|
||||
await astroRuntime.shutdown();
|
||||
info(logging, 'build', bold(green('▶ Build Complete!')));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
error(logging, 'build', err.message);
|
||||
await astroRuntime.shutdown();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Given an HTML string, collect <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;
|
||||
}
|
|
@ -1,156 +0,0 @@
|
|||
import type { AstroConfig, BuildOutput, BundleMap } from '../../@types/astro';
|
||||
import type { LogOptions } from '../../logger.js';
|
||||
|
||||
import { performance } from 'perf_hooks';
|
||||
import shorthash from 'shorthash';
|
||||
import cheerio from 'cheerio';
|
||||
import esbuild from 'esbuild';
|
||||
import { getDistPath, getSrcPath, IS_ASTRO_FILE_URL, stopTimer } from '../util.js';
|
||||
import { debug } from '../../logger.js';
|
||||
|
||||
// config
|
||||
const COMMON_URL = `/_astro/common-[HASH].css`; // [HASH] will be replaced
|
||||
|
||||
/**
|
||||
* Bundle CSS
|
||||
* For files within dep tree, find ways to combine them.
|
||||
* Current logic:
|
||||
* - If CSS appears across multiple pages, combine into `/_astro/common.css` bundle
|
||||
* - Otherwise, combine page CSS into one request as `/_astro/[page].css` bundle
|
||||
*
|
||||
* This operation _should_ be relatively-safe to do in parallel with other bundling,
|
||||
* assuming other bundling steps don’t touch CSS. While this step does modify HTML,
|
||||
* it doesn’t keep anything in local memory so other processes may modify HTML too.
|
||||
*
|
||||
* This operation mutates the original references of the buildOutput not only for
|
||||
* safety (prevents possible conflicts), but for efficiency.
|
||||
*/
|
||||
export async function bundleCSS({
|
||||
astroConfig,
|
||||
buildState,
|
||||
logging,
|
||||
depTree,
|
||||
}: {
|
||||
astroConfig: AstroConfig;
|
||||
buildState: BuildOutput;
|
||||
logging: LogOptions;
|
||||
depTree: BundleMap;
|
||||
}): Promise<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: don’t parallelize here otherwise CSS may end up in random order
|
||||
for (const id of cssMap.keys()) {
|
||||
const newUrl = cssMap.get(id) as string;
|
||||
|
||||
// if new bundle, create
|
||||
if (!buildState[newUrl]) {
|
||||
buildState[newUrl] = {
|
||||
srcPath: getSrcPath(id, { astroConfig }), // this isn’t accurate, but we can at least reference a file in the bundle
|
||||
contents: '',
|
||||
contentType: 'text/css',
|
||||
encoding: 'utf8',
|
||||
};
|
||||
}
|
||||
|
||||
// append to bundle, delete old file
|
||||
(buildState[newUrl] as any).contents += Buffer.isBuffer(buildState[id].contents) ? buildState[id].contents.toString('utf8') : buildState[id].contents;
|
||||
delete buildState[id];
|
||||
}
|
||||
debug(logging, 'css', `bundled [${stopTimer(timer.bundle)}]`);
|
||||
|
||||
// 3. minify
|
||||
timer.minify = performance.now();
|
||||
await Promise.all(
|
||||
Object.keys(buildState).map(async (id) => {
|
||||
if (buildState[id].contentType !== 'text/css') return;
|
||||
const { code } = await esbuild.transform(buildState[id].contents.toString(), {
|
||||
loader: 'css',
|
||||
minify: true,
|
||||
});
|
||||
buildState[id].contents = code;
|
||||
})
|
||||
);
|
||||
debug(logging, 'css', `minified [${stopTimer(timer.minify)}]`);
|
||||
|
||||
// 4. determine hashes based on CSS content (deterministic), and update HTML <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)}]`);
|
||||
}
|
|
@ -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;
|
||||
}
|
210
packages/astro/src/build/index.ts
Normal file
210
packages/astro/src/build/index.ts
Normal 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 (don’t rely on port in builder)
|
||||
this.logging = options.logging;
|
||||
this.origin = config.buildOptions.site ? new URL(config.buildOptions.site).origin : `http://localhost:${port}`;
|
||||
this.manifest = createRouteManifest({ config });
|
||||
}
|
||||
|
||||
/** Build all pages */
|
||||
async build() {
|
||||
const start = performance.now();
|
||||
|
||||
// 1. initialize fresh Vite instance
|
||||
const { 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}`)}`);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 whatever’s needed in display. If it’s not needed, kill it and improve stat speeds!
|
||||
|
||||
/** JS: prioritize entry HTML, but also show total */
|
||||
interface JSOutput {
|
||||
/** breakdown of JS per-file */
|
||||
js: FileSizes;
|
||||
/** weight of index.html */
|
||||
entryHTML?: number;
|
||||
/** total bytes of [js], added for convenience */
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface URLStats {
|
||||
dynamicImports: Set<string>;
|
||||
stats: BundleStats[];
|
||||
/** HTML: total isn’t important, because those are broken up requests. However, surface any anomalies / bloated HTML */
|
||||
interface HTMLOutput {
|
||||
/** breakdown of HTML per-file */
|
||||
html: FileSizes;
|
||||
/** biggest HTML file */
|
||||
maxSize: number;
|
||||
}
|
||||
|
||||
export type BundleStatsMap = Map<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),
|
||||
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,
|
||||
};
|
||||
return stat;
|
||||
})
|
||||
);
|
||||
urlStats.set(id, {
|
||||
dynamicImports: new Set<string>(),
|
||||
stats: stats.filter((s) => !!s) as any,
|
||||
}
|
||||
|
||||
/** 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 don’t 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 we’re on the entry file, count async imports, too
|
||||
return d === -1; // subsequent runs: don’t 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.1–0.4, round up to 1
|
||||
}
|
||||
|
|
|
@ -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 there’s no extension
|
||||
pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() won’t)
|
||||
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 isn’t 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`;
|
||||
}
|
|
@ -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();
|
||||
break;
|
||||
}
|
||||
case 'build':
|
||||
case 'preview':
|
||||
case 'dev': {
|
||||
if (flags.reload) {
|
||||
await reload();
|
||||
if (flags.reload) await reload(projectRoot);
|
||||
try {
|
||||
const server = await devServer(config, { logging });
|
||||
await new Promise(() => {}); // don’t close dev server
|
||||
} catch (err) {
|
||||
throwAndExit(err);
|
||||
}
|
||||
|
||||
const cmd = cmdMap.get(state.cmd);
|
||||
if (!cmd) throw new Error(`Error running ${state.cmd}`);
|
||||
runCommand(flags._[3], cmd, state.options);
|
||||
break;
|
||||
}
|
||||
case 'build': {
|
||||
if (flags.reload) await reload(projectRoot);
|
||||
try {
|
||||
await build(config, { logging });
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
throwAndExit(err);
|
||||
}
|
||||
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);
|
||||
}
|
20
packages/astro/src/cli/reload.ts
Normal file
20
packages/astro/src/cli/reload.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
|
||||
|
||||
/**
|
||||
* Hydrate this component immediately
|
|
@ -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
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||
import type { GetHydrateCallback, HydrateOptions } from '../@types/hydrate';
|
||||
|
||||
/**
|
||||
* Hydrate this component immediately
|
|
@ -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.
|
|
@ -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 shouldn’t 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 it‘s 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 it‘s 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 it‘s 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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.
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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, it‘s 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()));
|
||||
}
|
|
@ -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() {},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
// don’t 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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') {
|
||||
// don‘t 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]) {
|
||||
// don‘t 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 we‘re 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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 don’t 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 doesn’t 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)) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
313
packages/astro/src/dev/index.ts
Normal file
313
packages/astro/src/dev/index.ts
Normal 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();
|
||||
}
|
||||
}
|
37
packages/astro/src/dev/messages.ts
Normal file
37
packages/astro/src/dev/messages.ts
Normal 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…`;
|
||||
}
|
53
packages/astro/src/dev/template/error.ts
Normal file
53
packages/astro/src/dev/template/error.ts
Normal 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 didn’t 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>
|
||||
`;
|
||||
}
|
8
packages/astro/src/dev/util.ts
Normal file
8
packages/astro/src/dev/util.ts
Normal 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;
|
||||
}
|
|
@ -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();
|
|
@ -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>
|
|
@ -1,6 +0,0 @@
|
|||
import type { RendererInstance } from '../internal/__astro_component';
|
||||
|
||||
declare function setRenderers(instances: RendererInstance[]): void;
|
||||
declare let rendererInstances: RendererInstance[];
|
||||
|
||||
setRenderers(rendererInstances);
|
|
@ -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');
|
||||
};
|
||||
}
|
275
packages/astro/src/internal/index.ts
Normal file
275
packages/astro/src/internal/index.ts
Normal 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}>`;
|
||||
}
|
|
@ -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}
|
||||
`
|
||||
);
|
||||
|
|
|
@ -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]));
|
|
@ -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);
|
||||
});
|
||||
}
|
67
packages/astro/src/preview/index.ts
Normal file
67
packages/astro/src/preview/index.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 don’t 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(),
|
||||
};
|
||||
}
|
|
@ -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];
|
|
@ -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);
|
33
packages/astro/src/runtime/hmr.ts
Normal file
33
packages/astro/src/runtime/hmr.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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) {
|
||||
/** 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(`[${routeMatch.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
|
||||
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 || '/rss.xml';
|
||||
const feedURL = dest || '/feed.xml';
|
||||
result.url = feedURL;
|
||||
result.xml = generateRSS({ rssData, site, srcFile: routeMatch.component, feedURL });
|
||||
}
|
||||
return [rssUtility, result];
|
||||
result.xml = generateRSS({ rssData, site, srcFile: route.component, feedURL });
|
||||
},
|
||||
rss: result,
|
||||
};
|
||||
}
|
14
packages/astro/src/runtime/sitemap.ts
Normal file
14
packages/astro/src/runtime/sitemap.ts
Normal 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;
|
||||
}
|
293
packages/astro/src/runtime/ssr.ts
Normal file
293
packages/astro/src/runtime/ssr.ts
Normal 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 can’t manage cache-busting */
|
||||
routeCache: RouteCache;
|
||||
/** Vite instance */
|
||||
viteServer: ViteDevServer;
|
||||
}
|
||||
|
||||
// note: not every request has a Vite browserHash. if we ever receive one, hang onto it
|
||||
// this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom)
|
||||
let browserHash: string | undefined;
|
||||
|
||||
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();
|
||||
}
|
77
packages/astro/src/runtime/util.ts
Normal file
77
packages/astro/src/runtime/util.ts
Normal 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 there’s no extension
|
||||
pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() won’t)
|
||||
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;
|
||||
}
|
192
packages/astro/src/runtime/vite/config.ts
Normal file
192
packages/astro/src/runtime/vite/config.ts
Normal file
|
@ -0,0 +1,192 @@
|
|||
import type { AstroConfig } from '../../@types/astro';
|
||||
import type { LogOptions } from '../../logger';
|
||||
|
||||
import fs from 'fs';
|
||||
import slash from 'slash';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import vite from 'vite';
|
||||
import { getPackageJSON, parseNpmName } from '../util.js';
|
||||
import astro from './plugin-astro.js';
|
||||
import markdown from './plugin-markdown.js';
|
||||
import jsx from './plugin-jsx.js';
|
||||
import { AstroDevServer } from '../../dev';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// note: ssr is still an experimental API hence the type omission
|
||||
type ViteConfigWithSSR = vite.InlineConfig & { ssr?: { external?: string[]; noExternal?: string[] } };
|
||||
|
||||
/** Return a common starting point for all Vite actions */
|
||||
export async function loadViteConfig(
|
||||
viteConfig: ViteConfigWithSSR,
|
||||
{ astroConfig, logging, devServer }: { astroConfig: AstroConfig; logging: LogOptions; devServer?: AstroDevServer }
|
||||
): Promise<ViteConfigWithSSR> {
|
||||
const optimizedDeps = new Set<string>(); // dependencies that must be bundled for the client (Vite may not detect all of these)
|
||||
const dedupe = new Set<string>(); // dependencies that can’t be duplicated (e.g. React & SolidJS)
|
||||
const plugins: Plugin[] = []; // Vite plugins
|
||||
|
||||
// load project deps
|
||||
const packageJSON = (await getPackageJSON(astroConfig.projectRoot)) || {};
|
||||
const userDeps = Object.keys(packageJSON?.dependencies || {});
|
||||
userDeps.forEach((dep) => {
|
||||
optimizedDeps.add(dep); // prepare all user deps for client ahead of time
|
||||
});
|
||||
const userDevDeps = Object.keys(packageJSON?.devDependencies || {});
|
||||
const { external, noExternal } = await viteSSRDeps([...userDeps, ...userDevDeps]);
|
||||
// console.log(external.has('tiny-glob'), noExternal.has('tiny-glob'));
|
||||
|
||||
// load Astro renderers
|
||||
await Promise.all(
|
||||
astroConfig.renderers.map(async (name) => {
|
||||
const { default: renderer } = await import(name);
|
||||
// 1. prepare client-side hydration code for browser
|
||||
if (renderer.client) {
|
||||
optimizedDeps.add(name + renderer.client.substr(1));
|
||||
}
|
||||
// 2. knownEntrypoints and polyfills need to be added to the client
|
||||
for (let dep of [...(renderer.knownEntrypoints || []), ...(renderer.polyfills || [])]) {
|
||||
if (dep[0] === '.') dep = name + dep.substr(1); // if local polyfill, use full path
|
||||
optimizedDeps.add(dep);
|
||||
dedupe.add(dep); // we can try and dedupe renderers by default
|
||||
}
|
||||
// 3. let renderer inject Vite plugins
|
||||
if (renderer.vitePlugins) {
|
||||
plugins.push(...renderer.vitePlugins);
|
||||
}
|
||||
// 4. mark external packages as external to Vite
|
||||
if (renderer.external) {
|
||||
for (const dep of renderer.external) {
|
||||
external.add(dep);
|
||||
noExternal.delete(dep);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// load client-side hydrations
|
||||
fs.readdirSync(new URL('../../client', import.meta.url)).forEach((hydrator) => {
|
||||
optimizedDeps.add(`astro/client/${hydrator}`); // always prepare these for client
|
||||
});
|
||||
|
||||
return vite.mergeConfig(
|
||||
{
|
||||
cacheDir: fileURLToPath(new URL('./node_modules/.vite/', astroConfig.projectRoot)), // using local caches allows Astro to be used in monorepos, etc.
|
||||
clearScreen: false,
|
||||
logLevel: 'error',
|
||||
optimizeDeps: {
|
||||
/** Try and scan a user’s project (won’t catch everything) */
|
||||
entries: ['src/**/*'],
|
||||
/** Always include these dependencies for optimization */
|
||||
include: [...optimizedDeps],
|
||||
},
|
||||
plugins: [astro({ config: astroConfig, devServer }), markdown({ config: astroConfig, devServer }), jsx({ config: astroConfig, logging }), ...plugins],
|
||||
publicDir: fileURLToPath(astroConfig.public),
|
||||
resolve: {
|
||||
dedupe: [...dedupe],
|
||||
},
|
||||
root: fileURLToPath(astroConfig.projectRoot),
|
||||
server: {
|
||||
/** prevent serving outside of project root (will become new default soon) */
|
||||
fs: { strict: true },
|
||||
/** disable HMR for test */
|
||||
hmr: process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'production' ? false : undefined,
|
||||
/** handle Vite URLs */
|
||||
proxy: {
|
||||
// add proxies here
|
||||
},
|
||||
},
|
||||
/** Note: SSR API is in beta (https://vitejs.dev/guide/ssr.html) */
|
||||
ssr: {
|
||||
external: [...external],
|
||||
noExternal: [...noExternal],
|
||||
},
|
||||
},
|
||||
viteConfig
|
||||
);
|
||||
}
|
||||
|
||||
/** Try and automatically figure out Vite external & noExternal */
|
||||
async function viteSSRDeps(deps: string[]): Promise<{ external: Set<string>; noExternal: Set<string> }> {
|
||||
const skip = new Set<string>();
|
||||
const external = new Set<string>();
|
||||
const noExternal = new Set<string>();
|
||||
|
||||
/** categorize package as ESM or CJS */
|
||||
async function sortPkg(spec: string): Promise<void> {
|
||||
// already sorted; skip
|
||||
if (external.has(spec) || noExternal.has(spec) || skip.has(spec)) return;
|
||||
|
||||
// not an npm package: ignore
|
||||
const pkg = parseNpmName(spec);
|
||||
if (!pkg) {
|
||||
skip.add(spec);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleLoc = require.resolve(spec);
|
||||
|
||||
// node can’t find this: skip
|
||||
if (!moduleLoc) {
|
||||
skip.add(spec);
|
||||
return;
|
||||
}
|
||||
|
||||
// load module’s package.json
|
||||
let cwd = new URL('../', `file://${slash(moduleLoc)}/`);
|
||||
let packageJSON = await getPackageJSON(cwd);
|
||||
while (!packageJSON) {
|
||||
const next = new URL('../', cwd);
|
||||
if (next.href === cwd.href) return; // we’re at root; skip
|
||||
cwd = next;
|
||||
packageJSON = await getPackageJSON(cwd);
|
||||
}
|
||||
|
||||
// couldn’t locate: skip
|
||||
if (!packageJSON) {
|
||||
skip.add(spec);
|
||||
return;
|
||||
}
|
||||
|
||||
// sort this package
|
||||
let isExternal = true; // external by default
|
||||
|
||||
// ESM gets noExternal
|
||||
if (packageJSON.type === 'module') isExternal = false;
|
||||
// TODO: manual bugfixes for Vite
|
||||
if (pkg.name === '@sveltejs/vite-plugin-svelte') isExternal = true;
|
||||
if (pkg.name === 'micromark-util-events-to-acorn') isExternal = true;
|
||||
if (pkg.name === 'unified') isExternal = true;
|
||||
// TODO: add more checks here if needed
|
||||
|
||||
// add to list
|
||||
if (isExternal === true) {
|
||||
external.add(spec);
|
||||
} else {
|
||||
noExternal.add(spec);
|
||||
}
|
||||
|
||||
// recursively load dependencies for package (but not devDeps)
|
||||
await Promise.all(Object.keys(packageJSON.dependencies || {}).map(sortPkg));
|
||||
} catch (err) {
|
||||
// can’t load package: skip
|
||||
skip.add(spec);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// for top-level, load deps and devDeps (just in case)
|
||||
await Promise.all(deps.map(sortPkg));
|
||||
|
||||
// sort (when debugging, makes packages easier to find)
|
||||
const externalSorted = [...external];
|
||||
externalSorted.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
|
||||
const noExternalSorted = [...noExternal];
|
||||
noExternalSorted.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
|
||||
|
||||
return {
|
||||
external: new Set([...externalSorted]),
|
||||
noExternal: new Set([...noExternalSorted]),
|
||||
};
|
||||
}
|
61
packages/astro/src/runtime/vite/plugin-astro.ts
Normal file
61
packages/astro/src/runtime/vite/plugin-astro.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import type { TransformResult } from '@astrojs/compiler';
|
||||
import type { Plugin } from 'vite';
|
||||
import type { AstroConfig, Renderer } from '../../@types/astro.js';
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import fs from 'fs';
|
||||
import { transform } from '@astrojs/compiler';
|
||||
import { decode } from 'sourcemap-codec';
|
||||
import { AstroDevServer } from '../../dev/index.js';
|
||||
|
||||
interface AstroPluginOptions {
|
||||
config: AstroConfig;
|
||||
devServer?: AstroDevServer;
|
||||
}
|
||||
|
||||
/** Transform .astro files for Vite */
|
||||
export default function astro({ config, devServer }: AstroPluginOptions): Plugin {
|
||||
return {
|
||||
name: '@astrojs/vite-plugin-astro',
|
||||
enforce: 'pre', // run transforms before other plugins can
|
||||
// note: don’t claim .astro files with resolveId() — it prevents Vite from transpiling the final JS (import.meta.globEager, etc.)
|
||||
async load(id) {
|
||||
if (id.endsWith('.astro')) {
|
||||
// const isPage = id.startsWith(fileURLToPath(config.pages));
|
||||
let source = await fs.promises.readFile(id, 'utf8');
|
||||
let tsResult: TransformResult | undefined;
|
||||
|
||||
try {
|
||||
// 1. Transform from `.astro` to valid `.ts`
|
||||
// use `sourcemap: "inline"` so that the sourcemap is included in the "code" result that we pass to esbuild.
|
||||
tsResult = await transform(source, { sourcefile: id, sourcemap: 'inline', internalURL: 'astro/internal' });
|
||||
// 2. Compile `.ts` to `.js`
|
||||
const { code, map } = await esbuild.transform(tsResult.code, { loader: 'ts', sourcemap: 'inline', sourcefile: id });
|
||||
|
||||
return {
|
||||
code,
|
||||
map,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// if esbuild threw the error, find original code source to display
|
||||
if (err.errors) {
|
||||
const sourcemapb64 = (tsResult?.code.match(/^\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.*)/m) || [])[1];
|
||||
if (!sourcemapb64) throw err;
|
||||
const json = JSON.parse(new Buffer(sourcemapb64, 'base64').toString());
|
||||
const mappings = decode(json.mappings);
|
||||
const focusMapping = mappings[err.errors[0].location.line + 1];
|
||||
err.sourceLoc = { file: id, line: (focusMapping[0][2] || 0) + 1, column: (focusMapping[0][3] || 0) + 1 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
async handleHotUpdate(context) {
|
||||
if (devServer) {
|
||||
return devServer.handleHotUpdate(context);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue