Compare commits
38 commits
Author | SHA1 | Date | |
---|---|---|---|
|
a0639b34f0 | ||
|
8145dc042c | ||
|
e878fc3026 | ||
|
986f028714 | ||
|
19d45496e7 | ||
|
5fbd05be15 | ||
|
f199c69eab | ||
|
b418ae31b8 | ||
|
215f46aa01 | ||
|
190fba394d | ||
|
dc7260bea2 | ||
|
8c30740442 | ||
|
d7152acb85 | ||
|
09038cd457 | ||
|
21294f7e0b | ||
|
3ddc3646d9 | ||
|
d818c9bb92 | ||
|
b99eacfa54 | ||
|
73eb8edfe3 | ||
|
7e42b006f9 | ||
|
ccf3b22069 | ||
|
f454b25c91 | ||
|
47ac7875ac | ||
|
d7e3faf49a | ||
|
68749c7df2 | ||
|
17cebebbee | ||
|
5f6eaf24fe | ||
|
480b7dbafc | ||
|
34e016aeb3 | ||
|
75fc9f2c7a | ||
|
2cf885ee73 | ||
|
561fecd03b | ||
|
bf38f9ea93 | ||
|
00389e3fc0 | ||
|
7ae9a09186 | ||
|
c6a5b518ab | ||
|
7eaf14ce15 | ||
|
a195e3155a |
356 changed files with 186830 additions and 10970 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
|
10
.changeset/ninety-terms-raise.md
Normal file
10
.changeset/ninety-terms-raise.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
'@astrojs/renderer-lit': patch
|
||||
'@astrojs/renderer-preact': patch
|
||||
'@astrojs/renderer-react': patch
|
||||
'@astrojs/renderer-solid': patch
|
||||
'@astrojs/renderer-svelte': patch
|
||||
'@astrojs/renderer-vue': patch
|
||||
---
|
||||
|
||||
Point renderer entrypoints to resolved JS files
|
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
|
|
@ -8,6 +8,7 @@ module.exports = {
|
|||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
|
|
59
.github/workflows/ci.yml
vendored
59
.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
|
||||
|
@ -72,33 +74,34 @@ 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
|
||||
cache: 'yarn'
|
||||
# - name: Set node version to 14
|
||||
# uses: actions/setup-node@v2
|
||||
# with:
|
||||
# node-version: 14
|
||||
# cache: 'yarn'
|
||||
|
||||
- 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
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -12,3 +12,5 @@ package-lock.json
|
|||
|
||||
# do not commit .env files or any files that end with `.env`
|
||||
*.env
|
||||
|
||||
!packages/astro/vite/dist
|
|
@ -5,7 +5,9 @@ We welcome contributions of any size and skill level. As an open source project,
|
|||
> **Tip for new contributors:**
|
||||
> Take a look at [https://github.com/firstcontributions/first-contributions](https://github.com/firstcontributions/first-contributions) for helpful information on contributing
|
||||
|
||||
## Prerequisite
|
||||
## Quick Guide
|
||||
|
||||
### Prerequisite
|
||||
|
||||
```shell
|
||||
node: "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
|
@ -13,7 +15,7 @@ yarn: "^1.22.10"
|
|||
# otherwise, your build will fail
|
||||
```
|
||||
|
||||
## Setting up your local repo
|
||||
### Setting up your local repo
|
||||
|
||||
Astro uses yarn workspaces, so you should **always run `yarn install` from the top-level project directory.** running `yarn install` in the top-level project root will install dependencies for `astro`, `www`, `docs`, and every package in the repo.
|
||||
|
||||
|
@ -23,7 +25,7 @@ yarn install
|
|||
yarn build:all
|
||||
```
|
||||
|
||||
## Development
|
||||
### Development
|
||||
|
||||
```shell
|
||||
# starts a file-watching, live-reloading dev script for active development
|
||||
|
@ -32,17 +34,17 @@ yarn dev
|
|||
yarn build
|
||||
```
|
||||
|
||||
## Running tests
|
||||
### Running tests
|
||||
|
||||
```shell
|
||||
# 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
|
||||
### Other useful commands
|
||||
|
||||
```shell
|
||||
# auto-format the entire project
|
||||
|
@ -56,7 +58,7 @@ yarn format
|
|||
yarn lint
|
||||
```
|
||||
|
||||
## Making a Pull Request
|
||||
### Making a Pull Request
|
||||
|
||||
When making a pull request, be sure to add a changeset when something has changed with Astro. Non-packages (`examples/*`, `docs/*`, and `www/*`) do not need changesets.
|
||||
|
||||
|
@ -64,7 +66,7 @@ When making a pull request, be sure to add a changeset when something has change
|
|||
yarn changeset
|
||||
```
|
||||
|
||||
## Running benchmarks
|
||||
### Running benchmarks
|
||||
|
||||
We have benchmarks to keep performance under control. You can run these by running (from the project root):
|
||||
|
||||
|
@ -83,7 +85,31 @@ node test/benchmark/dev.bench.js --save
|
|||
|
||||
Which will update the build and dev benchmarks.
|
||||
|
||||
# Releasing Astro
|
||||
## Code Structure
|
||||
|
||||
Server-side rendering (SSR) can be complicated. The Astro package (`packages/astro`) is structured in a way to help think about the different systems.
|
||||
|
||||
- `components/`: Built-in components to use in your project (e.g. `import Code from 'astro/components/Code.astro'`)
|
||||
- `src/`: Astro source
|
||||
- `@types/`: TypeScript types. These are centralized to cut down on circular dependencies
|
||||
- `cli/`: Code that powers the `astro` CLI command
|
||||
- `core/`: Code that executes **in the top-level scope** (in Node). Within, you’ll find code that powers the `astro build` and `astro dev` commands, as well as top-level SSR code.
|
||||
- `runtime/`: Code that executes **in different scopes** (i.e. not in a pure Node context). You’ll have to think about code differently here.
|
||||
- `client/`: Code that executes **in the browser.** Astro’s partial hydration code lives here, and only browser-compatible code can be used.
|
||||
- `server/`: Code that executes **inside Vite’s SSR.** Though this is a Node environment inside, this will be executed independently from `core/` and may have to be structured differently.
|
||||
- `vite-plugin-*/`: Any Vite plugins that Astro needs to run. For the most part, these also execute within Vite similar to `src/runtime/server/`, but it’s also helpful to think about them as independent modules. _Note: at the moment these are internal while they’re in development_
|
||||
|
||||
### Thinking about SSR
|
||||
|
||||
There are 3 contexts in which code executes:
|
||||
|
||||
- **Node.js**: this code lives in `src/core/`.
|
||||
- **Inside Vite**: this code lives in `src/runtime/server/`.
|
||||
- **In the browser**: this code lives in `src/runtime/client/`.
|
||||
|
||||
Understanding in which environment code runs, and at which stage in the process, can help clarify thinking about what Astro is doing. It also helps with debugging, for instance, if you’re working within `src/core/`, you know that your code isn’t executing within Vite, so you don’t have to debug Vite’s setup. But you will have to debug vite inside `runtime/server/`.
|
||||
|
||||
## Releasing Astro
|
||||
|
||||
_Note: Only priviledged contributors (L3+) can release new versions of Astro._
|
||||
|
||||
|
@ -91,7 +117,7 @@ The repo is set up with automatic releases, using the changeset GitHub action &
|
|||
|
||||
To release a new version of Astro, find the `Version Packages` PR, read it over, and merge it.
|
||||
|
||||
## Releasing PR preview snapshots
|
||||
### Releasing PR preview snapshots
|
||||
|
||||
Our release tool `changeset` has a feature for releasing "snapshot" releases from a PR or custom branch. These are npm package publishes that live temporarily, so that you can give users a way to test a PR before merging. This can be a great way to get early user feedback while still in the PR review process.
|
||||
|
||||
|
@ -112,7 +138,7 @@ git reset --hard
|
|||
|
||||
Full documentation: https://github.com/atlassian/changesets/blob/main/docs/snapshot-releases.md
|
||||
|
||||
## Releasing `astro@next` (aka "prerelease mode")
|
||||
### Releasing `astro@next` (aka "prerelease mode")
|
||||
|
||||
Sometimes, the repo will enter into "prerelease mode". In prerelease mode, our normal release process will publish npm versions under the `next` dist-tag, instead of the default `latest` tag. We do this from time-to-time to test large features before sharing them with the larger Astro audience.
|
||||
|
||||
|
@ -154,7 +180,7 @@ When in prerelease mode, the automatic PR release process will no longer release
|
|||
1. Go to https://github.com/snowpackjs/astro/releases/new and create a new release. Copy the new changelog entry from https://github.com/snowpackjs/astro/blob/latest/packages/astro/CHANGELOG.md.
|
||||
1. Post in Discord #announcements channel, if needed!
|
||||
|
||||
# Translations
|
||||
## Translations
|
||||
|
||||
Help us translate [docs.astro.build](https://docs.astro.build/) into as many languages as possible! This can be a great way to get involved with open source development without having to code.
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"broken-link-checker": "^0.7.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pa11y-ci": "^2.4.2",
|
||||
"prettier": "^2.3.2",
|
||||
"prettier": "^2.4.1",
|
||||
"start-server-and-test": "^1.12.6"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
@ -32,11 +32,10 @@ console.log(data);
|
|||
|
||||
## Using `fetch()` outside of Astro Components
|
||||
|
||||
If you want to use `fetch()` in a non-astro component, use the [`node-fetch`](https://github.com/node-fetch/node-fetch) library:
|
||||
If you want to use `fetch()` in a non-astro component, it is also globally available:
|
||||
|
||||
```tsx
|
||||
// Movies.tsx
|
||||
import fetch from 'node-fetch';
|
||||
import type { FunctionalComponent } from 'preact';
|
||||
import { h } from 'preact';
|
||||
|
||||
|
@ -55,11 +54,3 @@ const Movies: FunctionalComponent = () => {
|
|||
|
||||
export default Movies;
|
||||
```
|
||||
|
||||
If you load a component using `node-fetch` [interactively](/core-concepts/component-hydration), with `client:load`, `client:visible`, etc., you'll need to either not use `node-fetch` or switch to an [isomorphic](https://en.wikipedia.org/wiki/Isomorphic_JavaScript) library that will run both at build time and on the client, as the [`node-fetch` README.md](https://github.com/node-fetch/node-fetch#motivation) recommends:
|
||||
|
||||
> Instead of implementing XMLHttpRequest in Node.js to run browser-specific [Fetch polyfill](https://github.com/github/fetch), why not go from native http to fetch API directly? Hence, node-fetch, minimal code for a window.fetch compatible API on Node.js runtime.
|
||||
>
|
||||
> See Jason Miller's [isomorphic-unfetch](https://www.npmjs.com/package/isomorphic-unfetch) or Leonardo Quixada's [cross-fetch](https://github.com/lquixada/cross-fetch) for isomorphic usage (exports node-fetch for server-side, whatwg-fetch for client-side).
|
||||
|
||||
> Quoted from https://github.com/node-fetch/node-fetch#motivation
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -72,6 +72,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>
|
||||
|
|
|
@ -38,7 +38,7 @@ import { Markdown } from 'astro/components';
|
|||
<h2>👀 Want to learn more?</h2>
|
||||
<p>Feel free to check <a href="https://github.com/snowpackjs/astro">our documentation</a> or jump into our <a href="https://astro.build/chat">Discord server</a>.</p>
|
||||
</section>
|
||||
|
||||
|
||||
</article>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
{
|
||||
"ignoreChanges": ["**/test/**", "**/*.md"],
|
||||
"useWorkspaces": true,
|
||||
"version": "4.0.0"
|
||||
"version": "4.0.0",
|
||||
"command": {
|
||||
"run": {
|
||||
"npmClient": "yarn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
34
package.json
34
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/*",
|
||||
|
@ -34,36 +36,26 @@
|
|||
"packages/astro/test/fixtures/custom-elements/my-component-lib"
|
||||
],
|
||||
"volta": {
|
||||
"node": "14.16.1",
|
||||
"node": "14.17.0",
|
||||
"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": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"del": "^6.0.0",
|
||||
"esbuild": "^0.11.17",
|
||||
"eslint": "^7.25.0",
|
||||
"esbuild": "^0.13.7",
|
||||
"eslint": "^8.0.1",
|
||||
"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"
|
||||
|
|
|
@ -13,6 +13,7 @@ const CI_INSTRUCTIONS = {
|
|||
VERCEL: 'https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version',
|
||||
};
|
||||
|
||||
/** `astro *` */
|
||||
async function main() {
|
||||
// Check for ESM support.
|
||||
// Load the "supports-esm" package in an way that works in both ESM & CJS.
|
||||
|
@ -29,7 +30,7 @@ async function main() {
|
|||
|
||||
// Preflight check complete. Enjoy! ✨
|
||||
if (supportsESM) {
|
||||
return import('./dist/cli.js')
|
||||
return import('./dist/cli/index.js')
|
||||
.then(({ cli }) => cli(process.argv))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
|
|
@ -4,14 +4,14 @@ import shiki from 'shiki';
|
|||
export interface Props {
|
||||
/** The code to highlight. Required. */
|
||||
code: string;
|
||||
/**
|
||||
* The language of your code. Defaults to "plaintext".
|
||||
* Supports all languages listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
|
||||
/**
|
||||
* The language of your code. Defaults to "plaintext".
|
||||
* Supports all languages listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
|
||||
*/
|
||||
lang?: string;
|
||||
/**
|
||||
/**
|
||||
* The styling theme. Defaults to "github-dark".
|
||||
* Supports all themes listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
|
||||
* Supports all themes listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md
|
||||
* Instructions for loading a custom theme: https://github.com/shikijs/shiki/blob/main/docs/themes.md#loading-theme
|
||||
*/
|
||||
theme?: string;
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
"author": "Skypack",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"types": "./dist/types/@types/public.d.ts",
|
||||
"types": "./dist/types/@types/astro-public.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/snowpackjs/astro.git",
|
||||
|
@ -12,18 +12,27 @@
|
|||
},
|
||||
"exports": {
|
||||
".": "./astro.js",
|
||||
"./package.json": "./package.json",
|
||||
"./snowpack-plugin": "./snowpack-plugin.cjs",
|
||||
"./snowpack-plugin-jsx": "./snowpack-plugin-jsx.cjs",
|
||||
"./client/*": "./dist/runtime/client/*",
|
||||
"./components": "./components/index.js",
|
||||
"./debug": "./components/Debug.astro",
|
||||
"./components/*": "./components/*",
|
||||
"./runtime/svelte": "./dist/frontend/runtime/svelte.js",
|
||||
"./internal/*": "./dist/internal/*",
|
||||
"./dist/internal/*": "./dist/internal/*"
|
||||
"./debug": "./components/Debug.astro",
|
||||
"./internal": "./dist/runtime/server/index.js",
|
||||
"./internal/*": "./dist/runtime/server/*",
|
||||
"./package.json": "./package.json",
|
||||
"./runtime/*": "./dist/runtime/*",
|
||||
"./server/*": "./dist/runtime/server/*",
|
||||
"./vite-plugin-astro": "./dist/vite-plugin-astro/index.js",
|
||||
"./vite-plugin-astro/*": "./dist/vite-plugin-astro/*",
|
||||
"./vite-plugin-astro-postprocess": "./dist/vite-plugin-astro-postprocess/index.js",
|
||||
"./vite-plugin-astro-postprocess/*": "./dist/vite-plugin-astro-postprocess/*",
|
||||
"./vite-plugin-fetch": "./dist/vite-plugin-fetch/index.js",
|
||||
"./vite-plugin-fetch/*": "./dist/vite-plugin-fetch/*",
|
||||
"./vite-plugin-jsx/*": "./dist/vite-plugin-jsx/*",
|
||||
"./vite-plugin-jsx": "./dist/vite-plugin-jsx/index.js",
|
||||
"./vite-plugin-markdown": "./dist/vite-plugin-markdown/index.js",
|
||||
"./vite-plugin-markdown/*": "./dist/vite-plugin-markdown/*"
|
||||
},
|
||||
"imports": {
|
||||
"#astro/compiler": "./dist/compiler/index.js",
|
||||
"#astro/*": "./dist/*.js"
|
||||
},
|
||||
"bin": {
|
||||
|
@ -32,93 +41,74 @@
|
|||
"files": [
|
||||
"components",
|
||||
"dist",
|
||||
"snowpack-plugin-jsx.cjs",
|
||||
"snowpack-plugin.cjs",
|
||||
"astro.js"
|
||||
"astro.js",
|
||||
"README.md",
|
||||
"vite"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^0.1.15",
|
||||
"@astrojs/language-server": "^0.7.16",
|
||||
"@astrojs/markdown-remark": "^0.3.1",
|
||||
"@astrojs/markdown-support": "0.3.1",
|
||||
"@astrojs/parser": "0.20.2",
|
||||
"@astrojs/prism": "0.2.2",
|
||||
"@astrojs/renderer-preact": "0.2.2",
|
||||
"@astrojs/renderer-preact": "^0.2.2",
|
||||
"@astrojs/renderer-react": "0.2.2",
|
||||
"@astrojs/renderer-svelte": "0.1.2",
|
||||
"@astrojs/renderer-vue": "0.1.9",
|
||||
"@babel/code-frame": "^7.12.13",
|
||||
"@babel/core": "^7.14.6",
|
||||
"@babel/generator": "^7.13.9",
|
||||
"@babel/parser": "^7.13.15",
|
||||
"@babel/traverse": "^7.13.15",
|
||||
"@snowpack/plugin-postcss": "^1.4.3",
|
||||
"@snowpack/plugin-sass": "^1.4.0",
|
||||
"@types/send": "^0.17.1",
|
||||
"acorn": "^7.4.0",
|
||||
"astring": "^1.7.4",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"camel-case": "^4.1.2",
|
||||
"cheerio": "^1.0.0-rc.6",
|
||||
"@babel/core": "^7.15.8",
|
||||
"@babel/traverse": "^7.15.4",
|
||||
"@web/rollup-plugin-html": "^1.10.1",
|
||||
"astring": "^1.7.5",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"ci-info": "^3.2.0",
|
||||
"del": "^6.0.0",
|
||||
"es-module-lexer": "^0.4.1",
|
||||
"esbuild": "^0.12.12",
|
||||
"connect": "^3.7.0",
|
||||
"es-module-lexer": "^0.7.1",
|
||||
"esbuild": "^0.13.6",
|
||||
"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",
|
||||
"node-fetch": "^2.6.1",
|
||||
"morphdom": "^2.6.1",
|
||||
"node-fetch": "^2.6.5",
|
||||
"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.43.2",
|
||||
"semver": "^7.3.5",
|
||||
"send": "^0.17.1",
|
||||
"shiki": "^0.9.10",
|
||||
"shorthash": "^0.0.2",
|
||||
"slash": "^4.0.0",
|
||||
"snowpack": "^3.8.6",
|
||||
"srcset-parse": "^1.1.0",
|
||||
"sourcemap-codec": "^1.4.8",
|
||||
"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",
|
||||
"vite": "^2.6.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",
|
||||
"@astrojs/parser": "^0.20.2",
|
||||
"@babel/types": "^7.15.6",
|
||||
"@types/babel__core": "^7.1.15",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/connect": "^3.4.35",
|
||||
"@types/mime": "^2.0.3",
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/sass": "^1.16.0",
|
||||
"@types/yargs-parser": "^20.2.0",
|
||||
"astro-scripts": "0.0.1",
|
||||
"is-windows": "^1.0.2",
|
||||
"strip-ansi": "^7.0.0"
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node-fetch": "^2.5.12",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/send": "^0.17.1",
|
||||
"@types/yargs-parser": "^20.2.1",
|
||||
"chai": "^4.3.4",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"mocha": "^9.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.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;
|
||||
},
|
||||
};
|
||||
};
|
5
packages/astro/src/@types/README.md
Normal file
5
packages/astro/src/@types/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# `@types/`
|
||||
|
||||
TypeScript definitions and types for untyped modules.
|
||||
|
||||
[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
|
321
packages/astro/src/@types/astro-core.ts
Normal file
321
packages/astro/src/@types/astro-core.ts
Normal file
|
@ -0,0 +1,321 @@
|
|||
import type babel from '@babel/core';
|
||||
import type vite from 'vite';
|
||||
import type { z } from 'zod';
|
||||
import type { AstroConfigSchema } from '../core/config';
|
||||
import type { AstroComponentFactory } from '../runtime/server';
|
||||
|
||||
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 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;
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
/** Add plugins to Vite, if any */
|
||||
vitePlugins?: vite.Plugin[];
|
||||
}
|
||||
|
||||
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 RSSFunction = (args: RSSFunctionArgs) => void;
|
||||
|
||||
export interface RSSFunctionArgs {
|
||||
/** (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;
|
||||
}[];
|
||||
}
|
||||
|
||||
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 interface ScriptInfoExternal {
|
||||
src: string;
|
||||
}
|
1
packages/astro/src/@types/astro-public.ts
Normal file
1
packages/astro/src/@types/astro-public.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AstroConfig, AstroUserConfig } from './astro-core';
|
59
packages/astro/src/@types/astro-runtime.ts
Normal file
59
packages/astro/src/@types/astro-runtime.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import type { Renderer } from './astro-core';
|
||||
|
||||
export interface AstroBuiltinProps {
|
||||
'client:load'?: boolean;
|
||||
'client:idle'?: boolean;
|
||||
'client:media'?: string;
|
||||
'client:visible'?: boolean;
|
||||
}
|
||||
|
||||
export interface AstroGlobal extends TopLevelAstro {
|
||||
props: Record<string, number | string | any>;
|
||||
request: AstroPageRequest;
|
||||
slots: Record<string, true | undefined>;
|
||||
}
|
||||
|
||||
interface AstroPageRequest {
|
||||
url: URL;
|
||||
canonicalURL: URL;
|
||||
params: Params;
|
||||
}
|
||||
|
||||
type AstroRenderedHTML = string;
|
||||
|
||||
export type FetchContentResultBase = {
|
||||
astro: {
|
||||
headers: string[];
|
||||
source: string;
|
||||
html: AstroRenderedHTML;
|
||||
};
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type FetchContentResult<T> = FetchContentResultBase & T;
|
||||
|
||||
export interface HydrateOptions {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;
|
||||
|
||||
export type Params = Record<string, string | undefined>;
|
||||
|
||||
export interface TopLevelAstro {
|
||||
isPage: boolean;
|
||||
fetchContent<T = any>(globStr: string): Promise<FetchContentResult<T>[]>;
|
||||
resolve: (path: string) => string;
|
||||
site: URL;
|
||||
}
|
||||
|
||||
export interface SSRMetadata {
|
||||
renderers: Renderer[];
|
||||
}
|
||||
|
||||
export interface SSRResult {
|
||||
styles: Set<string>;
|
||||
scripts: Set<string>;
|
||||
createAstro(Astro: TopLevelAstro, props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal;
|
||||
_metadata: SSRMetadata;
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types';
|
||||
import type { AstroUserConfig, AstroConfig } from './config';
|
||||
|
||||
export { AstroUserConfig, AstroConfig };
|
||||
export interface RouteData {
|
||||
type: 'page';
|
||||
pattern: RegExp;
|
||||
params: string[];
|
||||
path: string | null;
|
||||
component: string;
|
||||
generate: (data?: any) => string;
|
||||
}
|
||||
|
||||
export interface ManifestData {
|
||||
routes: RouteData[];
|
||||
}
|
||||
|
||||
export interface JsxItem {
|
||||
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[];
|
||||
html: string;
|
||||
css?: string;
|
||||
hoistedScripts: ScriptInfo[];
|
||||
getStaticPaths?: string;
|
||||
hasCustomElements: boolean;
|
||||
customElementCandidates: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
result: TransformResult;
|
||||
contents: string;
|
||||
css?: string;
|
||||
}
|
||||
|
||||
export type 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 interface RSSFunctionArgs {
|
||||
/** (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;
|
||||
}[];
|
||||
}
|
||||
|
||||
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 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;
|
||||
}>;
|
||||
}
|
|
@ -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,5 +0,0 @@
|
|||
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;
|
||||
|
||||
export interface HydrateOptions {
|
||||
value?: string;
|
||||
}
|
|
@ -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 +0,0 @@
|
|||
export { AstroConfig, AstroUserConfig } from './config';
|
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;
|
||||
}
|
|
@ -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 +0,0 @@
|
|||
import type { BuildOutput, BundleMap } from '../@types/astro';
|
||||
import type { LogOptions } from '../logger';
|
||||
|
||||
import { info, table } from '../logger.js';
|
||||
import { underline, bold } from 'kleur/colors';
|
||||
import gzipSize from 'gzip-size';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
interface BundleStats {
|
||||
size: number;
|
||||
gzipSize: number;
|
||||
}
|
||||
|
||||
interface URLStats {
|
||||
dynamicImports: Set<string>;
|
||||
stats: BundleStats[];
|
||||
}
|
||||
|
||||
export type BundleStatsMap = Map<string, BundleStats>;
|
||||
export type URLStatsMap = Map<string, URLStats>;
|
||||
|
||||
export function createURLStats(): URLStatsMap {
|
||||
return new Map<string, URLStats>();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
await Promise.all(
|
||||
Object.keys(buildState).map(async (id) => {
|
||||
if (!depTree[id]) return;
|
||||
const stats = await Promise.all(
|
||||
[...depTree[id].js, ...depTree[id].css, ...depTree[id].images].map(async (url) => {
|
||||
if (!buildState[url]) return undefined;
|
||||
const stat = {
|
||||
size: Buffer.byteLength(buildState[url].contents),
|
||||
gzipSize: await gzipSize(buildState[url].contents),
|
||||
};
|
||||
return stat;
|
||||
})
|
||||
);
|
||||
urlStats.set(id, {
|
||||
dynamicImports: new Set<string>(),
|
||||
stats: stats.filter((s) => !!s) as any,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return urlStats;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
|
@ -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`;
|
||||
}
|
5
packages/astro/src/cli/README.md
Normal file
5
packages/astro/src/cli/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# `cli/`
|
||||
|
||||
Code that controls Astro’s binfile and is responsible for `astro *` CLI commands.
|
||||
|
||||
[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
|
|
@ -1,7 +1,8 @@
|
|||
/* eslint-disable no-console */
|
||||
import { AstroCheck, DiagnosticSeverity } from '@astrojs/language-server';
|
||||
import type { AstroConfig } from './@types/astro';
|
||||
import { bold, blue, black, bgWhite, red, cyan, yellow } from 'kleur/colors';
|
||||
import type { AstroConfig } from '../@types/astro-core';
|
||||
|
||||
import { bold, black, bgWhite, red, cyan, yellow } from 'kleur/colors';
|
||||
import glob from 'fast-glob';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
|
@ -1,29 +1,18 @@
|
|||
/* eslint-disable no-console */
|
||||
import { promises as fsPromises } from 'fs';
|
||||
|
||||
import type { AstroConfig } from '../@types/astro-core';
|
||||
import { LogOptions } from '../core/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 { reload } from './reload.js';
|
||||
import { defaultLogDestination } from '../core/logger.js';
|
||||
import build from '../core/build/index.js';
|
||||
import devServer from '../core/dev/index.js';
|
||||
import preview from '../core/preview/index.js';
|
||||
import { check } from './check.js';
|
||||
|
||||
const { readFile } = fsPromises;
|
||||
const buildAndExit = async (astroConfig: AstroConfig) => {
|
||||
const ret = await build(astroConfig);
|
||||
process.exit(ret);
|
||||
};
|
||||
const reloadAndExit = async () => {
|
||||
const ret = await reload();
|
||||
process.exit(ret);
|
||||
};
|
||||
const checkAndExit = async (astroConfig: AstroConfig) => {
|
||||
const ret = await check(astroConfig);
|
||||
process.exit(ret);
|
||||
};
|
||||
import { formatConfigError, loadConfig } from '../core/config.js';
|
||||
|
||||
type Arguments = yargs.Arguments;
|
||||
type cliCommand = 'help' | 'version' | 'dev' | 'build' | 'preview' | 'reload' | 'check';
|
||||
|
@ -36,7 +25,6 @@ interface CLIState {
|
|||
hostname?: string;
|
||||
port?: number;
|
||||
config?: string;
|
||||
reload?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -67,10 +55,6 @@ function resolveArgs(flags: Arguments): CLIState {
|
|||
case 'check':
|
||||
return { cmd: 'check', options };
|
||||
default:
|
||||
if (flags.reload) {
|
||||
return { cmd: 'reload', options };
|
||||
}
|
||||
|
||||
return { cmd: 'help', options };
|
||||
}
|
||||
}
|
||||
|
@ -78,7 +62,6 @@ function resolveArgs(flags: Arguments): CLIState {
|
|||
/** Display --help flag */
|
||||
function printHelp() {
|
||||
console.error(` ${colors.bold('astro')} - Futuristic web development tool.
|
||||
|
||||
${colors.bold('Commands:')}
|
||||
astro dev Run Astro in development mode.
|
||||
astro build Build a pre-compiled production version of your site.
|
||||
|
@ -89,7 +72,6 @@ function printHelp() {
|
|||
--config <path> Specify the path to the Astro config file.
|
||||
--project-root <path> Specify the path to the project root folder.
|
||||
--no-sitemap Disable sitemap generation (build only).
|
||||
--reload Clean the cache, reinstalling dependencies.
|
||||
--verbose Enable verbose logging
|
||||
--silent Disable logging
|
||||
--version Show the version number and exit.
|
||||
|
@ -99,7 +81,7 @@ function printHelp() {
|
|||
|
||||
/** Display --version flag */
|
||||
async function printVersion() {
|
||||
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'));
|
||||
const pkg = JSON.parse(await fs.promises.readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
console.error(pkg.version);
|
||||
}
|
||||
|
||||
|
@ -111,63 +93,83 @@ function mergeCLIFlags(astroConfig: AstroConfig, flags: CLIState['options']) {
|
|||
if (typeof flags.hostname === 'string') astroConfig.devOptions.hostname = flags.hostname;
|
||||
}
|
||||
|
||||
/** Handle `astro run` command */
|
||||
async function runCommand(rawRoot: string, cmd: (a: AstroConfig, opts: any) => Promise<void>, options: CLIState['options']) {
|
||||
try {
|
||||
const projectRoot = options.projectRoot || rawRoot;
|
||||
const astroConfig = await loadConfig(projectRoot, options.config);
|
||||
mergeCLIFlags(astroConfig, options);
|
||||
|
||||
return cmd(astroConfig, options);
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) {
|
||||
console.log(formatConfigError(err));
|
||||
} else {
|
||||
console.error(colors.red(err.toString()));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const cmdMap = new Map<string, (a: AstroConfig, opts?: any) => Promise<any>>([
|
||||
['build', buildAndExit],
|
||||
['dev', devServer],
|
||||
['preview', preview],
|
||||
['reload', reloadAndExit],
|
||||
['check', checkAndExit],
|
||||
]);
|
||||
|
||||
/** The primary CLI action */
|
||||
export async function cli(args: string[]) {
|
||||
const flags = yargs(args);
|
||||
const state = resolveArgs(flags);
|
||||
const options = { ...state.options };
|
||||
const projectRoot = options.projectRoot || flags._[3];
|
||||
|
||||
// logLevel
|
||||
let logging: LogOptions = {
|
||||
dest: defaultLogDestination,
|
||||
level: 'info',
|
||||
};
|
||||
if (flags.verbose) logging.level = 'debug';
|
||||
if (flags.silent) logging.level = 'silent';
|
||||
let config: AstroConfig;
|
||||
try {
|
||||
config = await loadConfig({ cwd: projectRoot, filename: options.config });
|
||||
mergeCLIFlags(config, options);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
console.log(formatConfigError(err));
|
||||
} else {
|
||||
console.error(colors.red((err as any).toString() || err));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
switch (state.cmd) {
|
||||
case 'help': {
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
break;
|
||||
return;
|
||||
}
|
||||
case 'version': {
|
||||
await printVersion();
|
||||
process.exit(0);
|
||||
break;
|
||||
return;
|
||||
}
|
||||
case 'reload': {
|
||||
await reloadAndExit();
|
||||
break;
|
||||
}
|
||||
case 'build':
|
||||
case 'preview':
|
||||
case 'dev':
|
||||
case 'check': {
|
||||
if (flags.reload) {
|
||||
await reload();
|
||||
case 'dev': {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
case 'build': {
|
||||
try {
|
||||
await build(config, { logging });
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
throwAndExit(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'check': {
|
||||
const ret = await check(config);
|
||||
process.exit(ret);
|
||||
return;
|
||||
}
|
||||
case 'preview': {
|
||||
try {
|
||||
await preview(config, { logging }); // this will keep running
|
||||
} catch (err) {
|
||||
throwAndExit(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Error running ${state.cmd}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Display error and exit */
|
||||
function throwAndExit(err: any) {
|
||||
console.error(colors.red(err.toString() || err));
|
||||
process.exit(1);
|
||||
}
|
|
@ -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,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);
|
||||
}
|
||||
}
|
5
packages/astro/src/core/README.md
Normal file
5
packages/astro/src/core/README.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# `core/`
|
||||
|
||||
Code that executes within the top-level Node context. Contains the main Astro logic for the `build` and `dev` commands, and also manages the Vite server and SSR.
|
||||
|
||||
[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
|
207
packages/astro/src/core/build/index.ts
Normal file
207
packages/astro/src/core/build/index.ts
Normal file
|
@ -0,0 +1,207 @@
|
|||
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, RouteCache, RouteData, RSSResult } from '../../@types/astro-core';
|
||||
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.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import { pad } from '../dev/util.js';
|
||||
import { defaultLogOptions, levels, warn } from '../logger.js';
|
||||
import { ssr } from '../ssr/index.js';
|
||||
import { generatePaginateFunction } from '../ssr/paginate.js';
|
||||
import { createRouteManifest, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../ssr/routing.js';
|
||||
import { generateRssFunction } from '../ssr/rss.js';
|
||||
import { generateSitemap } from '../ssr/sitemap.js';
|
||||
import { kb, profileHTML, profileJS } from './stats.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 createVite(
|
||||
{
|
||||
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, // "any" needed for CI; also we don’t need typedefs for this anyway
|
||||
...(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}`)}`);
|
||||
}
|
||||
}
|
140
packages/astro/src/core/build/stats.ts
Normal file
140
packages/astro/src/core/build/stats.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
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';
|
||||
|
||||
type FileSizes = { [file: string]: 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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
/** 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(
|
||||
html.map(async (file) => {
|
||||
const relPath = file.pathname.replace(cwd.pathname, '');
|
||||
const size = (await fs.promises.stat(file)).size;
|
||||
sizes[relPath] = size;
|
||||
if (size > maxSize) maxSize = size;
|
||||
})
|
||||
);
|
||||
return {
|
||||
html: sizes,
|
||||
maxSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** get total JS size (note: .wasm counts as JS!) */
|
||||
export async function profileJS({ cwd, entryHTML }: { cwd: URL; entryHTML?: URL }): Promise<JSOutput> {
|
||||
const sizes: FileSizes = {};
|
||||
let htmlSize = 0;
|
||||
|
||||
// profile HTML entry (do this first, before all JS in a project is scanned)
|
||||
if (entryHTML) {
|
||||
let $ = cheerio.load(await fs.promises.readFile(entryHTML));
|
||||
let entryScripts: URL[] = [];
|
||||
let visitedEntry = false; // note: a quirk of Vite is that the entry file is async-loaded. Count that, but 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.href).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 {
|
||||
js: sizes,
|
||||
entryHTML: htmlSize || undefined,
|
||||
total: Object.values(sizes).reduce((sum, acc) => sum + acc, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,11 +1,10 @@
|
|||
import type { AstroConfig, AstroUserConfig } from '../@types/astro-core';
|
||||
|
||||
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)) {
|
201
packages/astro/src/core/create-vite.ts
Normal file
201
packages/astro/src/core/create-vite.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
import type { AstroConfig } from '../@types/astro-core';
|
||||
import type { AstroDevServer } from './dev';
|
||||
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.js';
|
||||
import astroVitePlugin from '../vite-plugin-astro/index.js';
|
||||
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
|
||||
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
||||
import jsxVitePlugin from '../vite-plugin-jsx/index.js';
|
||||
import fetchVitePlugin from '../vite-plugin-fetch/index.js';
|
||||
import { getPackageJSON, parseNpmName } from './util.js';
|
||||
|
||||
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 createVite(
|
||||
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('../runtime/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: [
|
||||
astroVitePlugin({ config: astroConfig, devServer }),
|
||||
markdownVitePlugin({ config: astroConfig, devServer }),
|
||||
jsxVitePlugin({ config: astroConfig, logging }),
|
||||
astroPostprocessVitePlugin({ config: astroConfig, devServer }),
|
||||
fetchVitePlugin(),
|
||||
...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],
|
||||
},
|
||||
},
|
||||
vite.mergeConfig(viteConfig, astroConfig.vite || {}) // merge in user vite settings
|
||||
);
|
||||
}
|
||||
|
||||
/** 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]),
|
||||
};
|
||||
}
|
307
packages/astro/src/core/dev/index.ts
Normal file
307
packages/astro/src/core/dev/index.ts
Normal file
|
@ -0,0 +1,307 @@
|
|||
import type { NextFunction } from 'connect';
|
||||
import type http from 'http';
|
||||
import type { AstroConfig, ManifestData, RouteCache, RouteData } from '../../@types/astro-core';
|
||||
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 stripAnsi from 'strip-ansi';
|
||||
import vite from '../vite.js';
|
||||
import { defaultLogOptions, error, info } from '../logger.js';
|
||||
import { ssr } from '../ssr/index.js';
|
||||
import { createRouteManifest, matchRoute } from '../ssr/routing.js';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import * as msg from './messages.js';
|
||||
import { errorTemplate } from './template/error.js';
|
||||
|
||||
export interface DevOptions {
|
||||
logging: LogOptions;
|
||||
}
|
||||
|
||||
interface DevServer {
|
||||
hostname: string;
|
||||
port: number;
|
||||
server: connect.Server;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `astro dev` */
|
||||
export default async function dev(config: AstroConfig, options: DevOptions = { logging: defaultLogOptions }): Promise<DevServer> {
|
||||
// start dev server
|
||||
const server = new AstroDevServer(config, options);
|
||||
await server.start();
|
||||
|
||||
// attempt shutdown
|
||||
process.on('SIGTERM', () => server.stop());
|
||||
return {
|
||||
hostname: server.hostname,
|
||||
port: server.port,
|
||||
server: server.app,
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dev server */
|
||||
export class AstroDevServer {
|
||||
app = connect();
|
||||
httpServer: http.Server | undefined;
|
||||
hostname: string;
|
||||
port: number;
|
||||
|
||||
private config: AstroConfig;
|
||||
private logging: LogOptions;
|
||||
private manifest: ManifestData;
|
||||
private mostRecentRoute?: RouteData;
|
||||
private origin: string;
|
||||
private routeCache: RouteCache = {};
|
||||
private viteServer: vite.ViteDevServer | undefined;
|
||||
|
||||
constructor(config: AstroConfig, options: DevOptions) {
|
||||
this.config = config;
|
||||
this.hostname = config.devOptions.hostname || 'localhost';
|
||||
this.logging = options.logging;
|
||||
this.port = config.devOptions.port;
|
||||
this.origin = `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.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 createVite(
|
||||
{
|
||||
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/core/dev/messages.ts
Normal file
37
packages/astro/src/core/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/core/dev/template/error.ts
Normal file
53
packages/astro/src/core/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/core/dev/util.ts
Normal file
8
packages/astro/src/core/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,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,
|
||||
|
@ -133,11 +153,8 @@ export function parseError(opts: LogOptions, err: CompileError) {
|
|||
opts,
|
||||
'parse-error',
|
||||
`
|
||||
|
||||
${underline(bold(grey(`${err.filename || ''}:${err.start.line}:${err.start.column}`)))}
|
||||
|
||||
${bold(red(`𝘅 ${err.message}`))}
|
||||
|
||||
${frame}
|
||||
`
|
||||
);
|
67
packages/astro/src/core/preview/index.ts
Normal file
67
packages/astro/src/core/preview/index.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import type { AstroConfig } from '../../@types/astro-core';
|
||||
import type { LogOptions } from '../logger';
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
240
packages/astro/src/core/ssr/index.ts
Normal file
240
packages/astro/src/core/ssr/index.ts
Normal file
|
@ -0,0 +1,240 @@
|
|||
import type { BuildResult } from 'esbuild';
|
||||
import type { ViteDevServer } from '../../../vite';
|
||||
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRError } from '../../@types/astro-core';
|
||||
import type { AstroGlobal, TopLevelAstro, SSRResult } from '../../@types/astro-runtime';
|
||||
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 { renderPage } from '../../runtime/server/index.js';
|
||||
import { parseNpmName, canonicalURL as getCanonicalURL, codeFrame } from '../util.js';
|
||||
import { generatePaginateFunction } from './paginate.js';
|
||||
import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.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<string, Promise<Renderer>>();
|
||||
|
||||
// TODO: improve validation and error handling here.
|
||||
async function resolveRenderer(viteServer: ViteDevServer, renderer: string) {
|
||||
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;
|
||||
|
||||
const completedRenderer: Renderer = resolvedRenderer;
|
||||
return completedRenderer;
|
||||
}
|
||||
|
||||
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]): Promise<Renderer[]> {
|
||||
const renderers = await Promise.all(
|
||||
ids.map((renderer) => {
|
||||
if (cache.has(renderer)) return cache.get(renderer)!;
|
||||
let promise = resolveRenderer(viteServer, renderer);
|
||||
cache.set(renderer, promise);
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
|
||||
return renderers;
|
||||
}
|
||||
|
||||
/** use Vite to SSR */
|
||||
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
|
||||
try {
|
||||
// 1. resolve renderers
|
||||
// Important this happens before load module in case a renderer provides polyfills.
|
||||
const renderers = await resolveRenderers(viteServer, astroConfig.renderers);
|
||||
|
||||
// 1.5. load module
|
||||
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
|
||||
|
||||
// 2. handle dynamic routes
|
||||
let params: Params = {};
|
||||
let pageProps: Props = {};
|
||||
if (route && !route.pathname) {
|
||||
if (route.params.length) {
|
||||
const paramsMatch = route.pattern.exec(pathname);
|
||||
if (paramsMatch) {
|
||||
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: SSRResult = {
|
||||
styles: new Set(),
|
||||
scripts: new Set(),
|
||||
/** This function returns the `Astro` faux-global */
|
||||
createAstro(astroGlobal: TopLevelAstro, props: Record<string, any>, slots: Record<string, any> | null) {
|
||||
const site = new URL(origin);
|
||||
const url = new URL('.' + pathname, site);
|
||||
const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin);
|
||||
|
||||
return {
|
||||
__proto__: astroGlobal,
|
||||
props,
|
||||
request: {
|
||||
canonicalURL,
|
||||
params: {},
|
||||
url,
|
||||
},
|
||||
slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])),
|
||||
} as unknown as AstroGlobal;
|
||||
},
|
||||
_metadata: { renderers },
|
||||
};
|
||||
|
||||
let html = await renderPage(result, Component, pageProps, 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) {
|
||||
viteServer.ssrFixStacktrace(e);
|
||||
// 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/client/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();
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { GetStaticPathsResult, PaginatedCollectionProp, PaginateFunction, Params, Props, RouteData } from '../@types/astro';
|
||||
import { GetStaticPathsResult, PaginatedCollectionProp, PaginateFunction, Params, Props, RouteData } from '../../@types/astro-core';
|
||||
|
||||
// return filters.map((filter) => {
|
||||
// const filteredRecipes = allRecipes.filter((recipe) =>
|
|
@ -1,9 +1,70 @@
|
|||
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, Params, RouteData } from '../../@types/astro-core';
|
||||
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,6 +1,7 @@
|
|||
import type { RSSFunctionArgs, RouteData } from '../@types/astro';
|
||||
import type { RSSFunction, RSSFunctionArgs, RSSResult, RouteData } from '../../@types/astro-core';
|
||||
|
||||
import parser from 'fast-xml-parser';
|
||||
import { canonicalURL } from './util.js';
|
||||
import { canonicalURL } from '../util.js';
|
||||
|
||||
/** Validates getStaticPaths.rss */
|
||||
export function validateRSS(args: GenerateRSSArgs): void {
|
||||
|
@ -70,16 +71,19 @@ export function generateRSS(args: GenerateRSSArgs): string {
|
|||
return xml;
|
||||
}
|
||||
|
||||
export function generateRssFunction(site: string | undefined, routeMatch: RouteData): [(args: any) => void, { url?: string; xml?: string }] {
|
||||
let result: { url?: string; xml?: string } = {};
|
||||
function rssUtility(args: any) {
|
||||
if (!site) {
|
||||
throw new Error(`[${routeMatch.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
|
||||
}
|
||||
const { dest, ...rssData } = args;
|
||||
const feedURL = dest || '/rss.xml';
|
||||
result.url = feedURL;
|
||||
result.xml = generateRSS({ rssData, site, srcFile: routeMatch.component, feedURL });
|
||||
}
|
||||
return [rssUtility, result];
|
||||
/** Generated function to be run */
|
||||
export function generateRssFunction(site: string | undefined, route: RouteData): { generator: RSSFunction; rss?: RSSResult } {
|
||||
let result: RSSResult = {} as any;
|
||||
return {
|
||||
generator: function rssUtility(args: any) {
|
||||
if (!site) {
|
||||
throw new Error(`[${route.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
|
||||
}
|
||||
const { dest, ...rssData } = args;
|
||||
const feedURL = dest || '/feed.xml';
|
||||
result.url = feedURL;
|
||||
result.xml = generateRSS({ rssData, site, srcFile: route.component, feedURL });
|
||||
},
|
||||
rss: result,
|
||||
};
|
||||
}
|
14
packages/astro/src/core/ssr/sitemap.ts
Normal file
14
packages/astro/src/core/ssr/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;
|
||||
}
|
77
packages/astro/src/core/util.ts
Normal file
77
packages/astro/src/core/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;
|
||||
}
|
2
packages/astro/src/core/vite.ts
Normal file
2
packages/astro/src/core/vite.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from '../../vite/dist/node/index.js';
|
||||
export { default } from '../../vite/dist/node/index.js';
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
};
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import type { ScriptInfo } from '../@types/astro';
|
||||
|
||||
const sym = Symbol.for('astro.hoistedScripts');
|
||||
|
||||
interface ComponentThatMaybeHasHoistedScripts {
|
||||
[sym]: ScriptInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes all of the components this component uses and combines them with its
|
||||
* own scripts and flattens it to a deduped list.
|
||||
* The page component will have an array of all scripts used by all child components and itself.
|
||||
*/
|
||||
function hoistedScripts(Components: ComponentThatMaybeHasHoistedScripts[], scripts: ScriptInfo[]) {
|
||||
const flatScripts = [];
|
||||
|
||||
const allScripts: ScriptInfo[] = Components.map((c) => c && c[sym])
|
||||
.filter((a) => a)
|
||||
.concat(scripts)
|
||||
.flatMap((a) => a);
|
||||
|
||||
const visitedSource = new Set();
|
||||
for (let script of allScripts) {
|
||||
if (!('src' in script)) {
|
||||
flatScripts.push(script);
|
||||
} else if (!visitedSource.has(script.src)) {
|
||||
flatScripts.push(script);
|
||||
visitedSource.add(script.src);
|
||||
}
|
||||
}
|
||||
|
||||
return flatScripts;
|
||||
}
|
||||
|
||||
export { hoistedScripts as __astro_hoisted_scripts };
|
|
@ -1,15 +0,0 @@
|
|||
/** */
|
||||
export function __astro_slot_content({ name }: { name: string }, ...children: any[]) {
|
||||
return { $slot: name, children };
|
||||
}
|
||||
|
||||
export const __astro_slot = ({ name = 'default' }: { name: string }, _children: any, ...fallback: string[]) => {
|
||||
if (name === 'default' && typeof _children === 'string') {
|
||||
return _children ? _children : fallback;
|
||||
}
|
||||
if (!_children || !_children.$slots) {
|
||||
throw new Error(`__astro_slot encountered an unexpected child:\n${JSON.stringify(_children)}`);
|
||||
}
|
||||
const children = _children.$slots[name];
|
||||
return children ? children : fallback;
|
||||
};
|
|
@ -1,48 +0,0 @@
|
|||
import type { AstroComponentMetadata } from '../@types/astro';
|
||||
|
||||
type ModuleCandidates = Map<any, string>;
|
||||
|
||||
interface RegistryOptions {
|
||||
candidates: ModuleCandidates;
|
||||
}
|
||||
class AstroElementRegistry {
|
||||
private candidates: ModuleCandidates;
|
||||
private cache: Map<string, string> = new Map();
|
||||
|
||||
constructor(options: RegistryOptions) {
|
||||
this.candidates = options.candidates;
|
||||
}
|
||||
|
||||
find(tagName: string) {
|
||||
for (let [module, importSpecifier] of this.candidates) {
|
||||
if (module && typeof module.tagName === 'string') {
|
||||
if (module.tagName === tagName) {
|
||||
// Found!
|
||||
return importSpecifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findCached(tagName: string) {
|
||||
if (this.cache.has(tagName)) {
|
||||
return this.cache.get(tagName);
|
||||
}
|
||||
let specifier = this.find(tagName);
|
||||
if (specifier) {
|
||||
this.cache.set(tagName, specifier);
|
||||
}
|
||||
return specifier;
|
||||
}
|
||||
|
||||
astroComponentArgs(tagName: string, metadata: AstroComponentMetadata) {
|
||||
const specifier = this.findCached(tagName);
|
||||
const outMeta: AstroComponentMetadata = {
|
||||
...metadata,
|
||||
componentUrl: specifier || metadata.componentUrl,
|
||||
};
|
||||
return [tagName, outMeta];
|
||||
}
|
||||
}
|
||||
|
||||
export { AstroElementRegistry };
|
|
@ -1,21 +0,0 @@
|
|||
/**
|
||||
* Convert the result of an `import.meta.globEager()` call to an array of processed
|
||||
* Markdown content objects. Filter out any non-Markdown files matched in the glob
|
||||
* result, by default.
|
||||
*/
|
||||
export function fetchContent(importMetaGlobResult: Record<string, any>, url: string) {
|
||||
return [...Object.entries(importMetaGlobResult)]
|
||||
.map(([spec, mod]) => {
|
||||
// Only return Markdown files, which export the __content object.
|
||||
if (!mod.__content) {
|
||||
return;
|
||||
}
|
||||
const urlSpec = new URL(spec, url).pathname.replace(/[\\/\\\\]/, '/');
|
||||
return {
|
||||
...mod.__content,
|
||||
url: urlSpec.includes('/pages/') && urlSpec.replace(/^.*\/pages\//, '/').replace(/\.md$/, ''),
|
||||
file: new URL(spec, url),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue