This commit is contained in:
wuls 2023-05-04 18:54:18 +08:00
commit a5369ae504
239 changed files with 5185 additions and 3143 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/sitemap': minor
---
Adds support to SSR routes to sitemap generation.

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Implement Inline Stylesheets RFC as experimental

View file

@ -0,0 +1,21 @@
---
'astro': minor
---
Implements a new class-based scoping strategy
This implements the [Scoping RFC](https://github.com/withastro/roadmap/pull/543), providing a way to opt in to increased style specificity for Astro component styles.
This prevents bugs where global styles override Astro component styles due to CSS ordering and the use of element selectors.
To enable class-based scoping, you can set it in your config:
```js
import { defineConfig } from 'astro/config';
export default defineConfig({
scopedStyleStrategy: 'class'
});
```
Note that the 0-specificity `:where` pseudo-selector is still the default strategy. The intent is to change `'class'` to be the default in 3.0.

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Support `<Code inline />` to output inline code HTML (no `pre` tag)

View file

@ -1,6 +0,0 @@
---
'@astrojs/telemetry': patch
'@astrojs/webapi': patch
---
Upgrade undici to v5.22.0

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
New middleware API

View file

@ -0,0 +1,8 @@
---
'@astrojs/markdoc': patch
'@astrojs/mdx': patch
'@astrojs/markdown-remark': minor
'astro': minor
---
Upgrade shiki to v0.14.1. This updates the shiki theme colors and adds the theme name to the `pre` tag, e.g. `<pre class="astro-code github-dark">`.

View file

@ -1,5 +0,0 @@
---
'astro': patch
---
Update `experimental.assets`'s `image.service` configuration to allow for a config option in addition to an entrypoint

View file

@ -1,5 +0,0 @@
---
"@astrojs/cloudflare": patch
---
Fix missing code language in Cloudflare README

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Ensure multiple cookies set in dev result in multiple set-cookie headers

View file

@ -1,6 +0,0 @@
---
'@astrojs/tailwind': patch
'@astrojs/svelte': patch
---
Update dependencies

View file

@ -34,7 +34,7 @@ jobs:
- name: Get changed files in the .changeset folder
id: changed-files
uses: tj-actions/changed-files@v29
uses: tj-actions/changed-files@v35
if: steps.set-blocks.outputs.blocks == ''
with:
files: |
@ -87,5 +87,5 @@ jobs:
--url https://api.github.com/repos/${{github.repository}}/pulls/${{github.event.number}}/reviews \
--header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
--header 'content-type: application/json' \
-d '{"event":"REQUEST_CHANGES"}'
-d '{"event":"REQUEST_CHANGES", body: ""}'

View file

@ -21,12 +21,15 @@ import { MyCounter } from '../components/my-counter.js';
<Lorem />
{
/**
* Our VS Code extension does not currently properly typecheck attributes on Lit components
* As such, the following code will result in a TypeScript error inside the editor, nonetheless, it works in Astro!
*/
(
/**
* Our editor tooling does not currently properly typecheck attributes on imported Lit components. As such, without a
* pragma directive telling TypeScript to ignore the error, the line below will result in an error in the editor.
* Nonetheless, this code works in Astro itself!
*/
// @ts-expect-error
<CalcAdd num={0} />
)
}
{/** @ts-expect-error */}
<CalcAdd num={0} />
</body>
</html>

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
experimental: {
middleware: true,
},
});

View file

@ -0,0 +1,23 @@
{
"name": "@example/middleware",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"server": "node dist/server/entry.mjs"
},
"dependencies": {
"astro": "workspace:*",
"svelte": "^3.48.0",
"@astrojs/node": "workspace:*",
"concurrently": "^7.2.1",
"unocss": "^0.15.6",
"vite-imagetools": "^4.0.4",
"html-minifier": "^4.0.0"
}
}

View file

@ -0,0 +1,63 @@
---
export interface Props {
title: string;
body: string;
href: string;
}
const { href, title, body } = Astro.props;
---
<li class="link-card">
<a href={href}>
<h2>
{title}
<span>&rarr;</span>
</h2>
<p>
{body}
</p>
</a>
</li>
<style>
.link-card {
list-style: none;
display: flex;
padding: 0.25rem;
background-color: white;
background-image: none;
background-size: 400%;
border-radius: 0.6rem;
background-position: 100%;
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.link-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
padding: 1rem 1.3rem;
border-radius: 0.35rem;
color: #111;
background-color: white;
opacity: 0.8;
}
h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
p {
margin-top: 0.5rem;
margin-bottom: 0;
color: #444;
}
.link-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.link-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent));
}
</style>

13
examples/middleware/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
/// <reference types="astro/client" />
declare global {
namespace AstroMiddleware {
interface Locals {
user: {
name: string;
surname: string;
};
}
}
}
export {};

View file

@ -0,0 +1,35 @@
---
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
:root {
--accent: 124, 58, 237;
--accent-gradient: linear-gradient(45deg, rgb(var(--accent)), #da62c4 30%, white 60%);
}
html {
font-family: system-ui, sans-serif;
background-color: #f6f6f6;
}
code {
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
</style>

View file

@ -0,0 +1,71 @@
import { defineMiddleware, sequence } from 'astro/middleware';
import htmlMinifier from 'html-minifier';
const limit = 50;
const loginInfo = {
token: undefined,
currentTime: undefined,
};
export const minifier = defineMiddleware(async (context, next) => {
const response = await next();
// check if the response is returning some HTML
if (response.headers.get('content-type') === 'text/html') {
let headers = response.headers;
let html = await response.text();
let newHtml = htmlMinifier.minify(html, {
removeAttributeQuotes: true,
collapseWhitespace: true,
});
return new Response(newHtml, {
status: 200,
headers,
});
}
return response;
});
const validation = defineMiddleware(async (context, next) => {
if (context.request.url.endsWith('/admin')) {
if (loginInfo.currentTime) {
const difference = new Date().getTime() - loginInfo.currentTime;
if (difference > limit) {
console.log('hit threshold');
loginInfo.token = undefined;
loginInfo.currentTime = undefined;
return context.redirect('/login');
}
}
// we naively check if we have a token
if (loginInfo.token && loginInfo.token === 'loggedIn') {
// we fill the locals with user-facing information
context.locals.user = {
name: 'AstroUser',
surname: 'AstroSurname',
};
return await next();
} else {
loginInfo.token = undefined;
loginInfo.currentTime = undefined;
return context.redirect('/login');
}
} else if (context.request.url.endsWith('/api/login')) {
const response = await next();
// the login endpoint will return to us a JSON with username and password
const data = await response.json();
// we naively check if username and password are equals to some string
if (data.username === 'astro' && data.password === 'astro') {
// we store the token somewhere outside of locals because the `locals` object is attached to the request
// and when doing a redirect, we lose that information
loginInfo.token = 'loggedIn';
loginInfo.currentTime = new Date().getTime();
return context.redirect('/admin');
}
}
// we don't really care about awaiting the response in this case
next();
return;
});
export const onRequest = sequence(validation, minifier);

View file

@ -0,0 +1,55 @@
---
import Layout from '../layouts/Layout.astro';
const user = Astro.locals.user;
---
<Layout title="Welcome back!!">
<main>
<h1>Welcome back <span class="text-gradient">{user.name} {user.surname}</span></h1>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,18 @@
import { APIRoute } from 'astro';
export const post: APIRoute = async ({ request }) => {
const data = await request.formData();
const username = data.get('username');
const password = data.get('password');
return new Response(
JSON.stringify({
username,
password,
}),
{
headers: {
'content-type': 'application/json',
},
}
);
};

View file

@ -0,0 +1,63 @@
---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---
<Layout title="Welcome to Astro.">
<main>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
{}
<ul role="list" class="link-card-grid">
<Card href="/login" title="Login" body="Try the login" />
</ul>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,75 @@
---
import Layout from '../layouts/Layout.astro';
const status = Astro.response.status;
let redirectMessage;
if (status === 301) {
redirectMessage = 'Your session is finished, please login again';
}
---
<Layout title="Welcome to Astro.">
<main>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
{redirectMessage}
<form action="/api/login" method="POST">
<label>
Username: <input type="text" minlength="1" id="username" name="username" />
</label>
<label>
Password: <input type="password" minlength="1" id="password" name="password" />
</label>
<button>Submit</button>
</form>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}

View file

@ -88,7 +88,7 @@
"eslint-plugin-prettier": "^4.2.1",
"only-allow": "^1.1.1",
"organize-imports-cli": "^0.10.0",
"prettier": "^2.8.7",
"prettier": "^2.8.8",
"prettier-plugin-astro": "^0.8.0",
"tiny-glob": "^0.2.9",
"turbo": "^1.9.3",

View file

@ -1,5 +1,11 @@
# @astrojs/rss
## 2.4.1
### Patch Changes
- [#6970](https://github.com/withastro/astro/pull/6970) [`b5482cee2`](https://github.com/withastro/astro/commit/b5482cee2387149ff397447e546130ba3dea58db) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix: remove accidental stripping of trailing `/1/` on canonical URLs
## 2.4.0
### Minor Changes

View file

@ -1,7 +1,7 @@
{
"name": "@astrojs/rss",
"description": "Add RSS feeds to your Astro projects",
"version": "2.4.0",
"version": "2.4.1",
"type": "module",
"types": "./dist/index.d.ts",
"author": "withastro",

View file

@ -8,7 +8,6 @@ export function createCanonicalURL(
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 (trailingSlash === false) {
// remove the trailing slash
pathname = pathname.replace(/(\/+)?$/, '');

View file

@ -1,5 +1,39 @@
# astro
## 2.3.4
### Patch Changes
- [#6967](https://github.com/withastro/astro/pull/6967) [`a8a319aef`](https://github.com/withastro/astro/commit/a8a319aef744a64647ee16c7d558d74de6864c6c) Thanks [@bluwy](https://github.com/bluwy)! - Fix `astro-entry` error on build with multiple JSX frameworks
- [#6961](https://github.com/withastro/astro/pull/6961) [`a695e44ae`](https://github.com/withastro/astro/commit/a695e44aed6e2f5d32cb950d4237be6e5657ba98) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Fix getImage type
- [#6956](https://github.com/withastro/astro/pull/6956) [`367e61776`](https://github.com/withastro/astro/commit/367e61776196a17d61c28daa4dfbabb6244e040c) Thanks [@lilnasy](https://github.com/lilnasy)! - Changed where various parts of the build pipeline look to decide if a page should be prerendered. They now exclusively consider PageBuildData, allowing integrations to participate in the decision.
- [#6969](https://github.com/withastro/astro/pull/6969) [`77270cc2c`](https://github.com/withastro/astro/commit/77270cc2cd06c942d7abf1d882e36d9163edafa5) Thanks [@bluwy](https://github.com/bluwy)! - Avoid removing leading slash for `build.assetsPrefix` value in the build output
- [#6910](https://github.com/withastro/astro/pull/6910) [`895fa07d8`](https://github.com/withastro/astro/commit/895fa07d8b4b8359984e048daca5437e40f44390) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Inline `process.env` boolean values (`0`, `1`, `true`, `false`) during the build. This helps with DCE and allows for better `export const prerender` detection.
- [#6958](https://github.com/withastro/astro/pull/6958) [`72c6bf01f`](https://github.com/withastro/astro/commit/72c6bf01fe49b331ca8ad9206a7506b15caf5b8d) Thanks [@bluwy](https://github.com/bluwy)! - Fix content render imports flow
- [#6952](https://github.com/withastro/astro/pull/6952) [`e5bd084c0`](https://github.com/withastro/astro/commit/e5bd084c01e4f60a157969b50c05ce002f7b63d2) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Update allowed Sharp versions to support 0.32.0
## 2.3.3
### Patch Changes
- [#6940](https://github.com/withastro/astro/pull/6940) [`a98df9374`](https://github.com/withastro/astro/commit/a98df9374dec65c678fa47319cb1481b1af123e2) Thanks [@delucis](https://github.com/delucis)! - Support custom 404s added via `injectRoute` or as `src/pages/404.html`
- [#6948](https://github.com/withastro/astro/pull/6948) [`50975f2ea`](https://github.com/withastro/astro/commit/50975f2ea3a59f9e023cc631a9372c0c7986eec9) Thanks [@imchell](https://github.com/imchell)! - Placeholders for slots are cleaned in HTML String that is rendered
- [#6848](https://github.com/withastro/astro/pull/6848) [`ebae1eaf8`](https://github.com/withastro/astro/commit/ebae1eaf87f49399036033c673b513338f7d9c42) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Update `experimental.assets`'s `image.service` configuration to allow for a config option in addition to an entrypoint
- [#6953](https://github.com/withastro/astro/pull/6953) [`dc062f669`](https://github.com/withastro/astro/commit/dc062f6695ce577dc569781fc0678c903012c336) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Update `astro check` to use version 1.0.0 of the Astro language server
- Updated dependencies [[`ac57b5549`](https://github.com/withastro/astro/commit/ac57b5549f828a17bdbebdaca7ace075307a3c9d)]:
- @astrojs/telemetry@2.1.1
- @astrojs/webapi@2.1.1
## 2.3.2
### Patch Changes

View file

@ -3,7 +3,26 @@
declare module 'astro:assets' {
// Exporting things one by one is a bit cumbersome, not sure if there's a better way - erika, 2023-02-03
type AstroAssets = {
getImage: typeof import('./dist/assets/index.js').getImage;
// getImage's type here is different from the internal function since the Vite module implicitly pass the service config
/**
* Get an optimized image and the necessary attributes to render it.
*
* **Example**
* ```astro
* ---
* import { getImage } from 'astro:assets';
* import originalImage from '../assets/image.png';
*
* const optimizedImage = await getImage({src: originalImage, width: 1280 });
* ---
* <img src={optimizedImage.src} {...optimizedImage.attributes} />
* ```
*
* This is functionally equivalent to using the `<Image />` component, as the component calls this function internally.
*/
getImage: (
options: import('./dist/assets/types.js').ImageTransform
) => Promise<import('./dist/assets/types.js').GetImageResult>;
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
Image: typeof import('./components/Image.astro').default;
};
@ -368,3 +387,9 @@ declare module '*?inline' {
const src: string;
export default src;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace App {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Locals {}
}

View file

@ -1,5 +1,6 @@
---
import type * as shiki from 'shiki';
import { renderToHtml } from 'shiki';
import { getHighlighter } from './Shiki.js';
export interface Props {
@ -30,36 +31,60 @@ export interface Props {
* @default false
*/
wrap?: boolean | null;
/**
* Generate inline code element only, without the pre element wrapper.
*
* @default false
*/
inline?: boolean;
}
const { code, lang = 'plaintext', theme = 'github-dark', wrap = false } = Astro.props;
/** Replace the shiki class name with a custom astro class name. */
function repairShikiTheme(html: string): string {
// Replace "shiki" class naming with "astro"
html = html.replace(/<pre class="(.*?)shiki(.*?)"/, '<pre class="$1astro-code$2"');
// Handle code wrapping
// if wrap=null, do nothing.
if (wrap === false) {
html = html.replace(/style="(.*?)"/, 'style="$1; overflow-x: auto;"');
} else if (wrap === true) {
html = html.replace(
/style="(.*?)"/,
'style="$1; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"'
);
}
return html;
}
const {
code,
lang = 'plaintext',
theme = 'github-dark',
wrap = false,
inline = false,
} = Astro.props;
// 1. Get the shiki syntax highlighter
const highlighter = await getHighlighter({
theme,
// Load custom lang if passed an object, otherwise load the default
langs: typeof lang !== 'string' ? [lang] : undefined,
});
const _html = highlighter.codeToHtml(code, {
lang: typeof lang === 'string' ? lang : lang.id,
// 2. Turn code into shiki theme tokens
const tokens = highlighter.codeToThemedTokens(code, typeof lang === 'string' ? lang : lang.id);
// 3. Get shiki theme object
const _theme = highlighter.getTheme();
// 4. Render the theme tokens as html
const html = renderToHtml(tokens, {
themeName: _theme.name,
fg: _theme.fg,
bg: _theme.bg,
elements: {
pre({ className, style, children }) {
// Swap to `code` tag if inline
const tag = inline ? 'code' : 'pre';
// Replace "shiki" class naming with "astro-code"
className = className.replace(/shiki/g, 'astro-code');
// Handle code wrapping
// if wrap=null, do nothing.
if (wrap === false) {
style += '; overflow-x: auto;"';
} else if (wrap === true) {
style += '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"';
}
return `<${tag} class="${className}" style="${style}" tabindex="0">${children}</${tag}>`;
},
code({ children }) {
return inline ? children : `<code>${children}</code>`;
},
},
});
const html = repairShikiTheme(_html);
---
<Fragment set:html={html} />

File diff suppressed because it is too large Load diff

View file

@ -1,33 +1,43 @@
/**
* This file is prebuilt from packages/astro/scripts/shiki-gen-themes.mjs
* Do not edit this directly, but instead edit that file and rerun it to generate this file.
*/
// prettier-ignore
export const themes = {
'css-variables': () => import('shiki/themes/css-variables.json').then((mod) => mod.default),
'dark-plus': () => import('shiki/themes/dark-plus.json').then((mod) => mod.default),
'dracula-soft': () => import('shiki/themes/dracula-soft.json').then((mod) => mod.default),
dracula: () => import('shiki/themes/dracula.json').then((mod) => mod.default),
'github-dark-dimmed': () =>
import('shiki/themes/github-dark-dimmed.json').then((mod) => mod.default),
'github-dark': () => import('shiki/themes/github-dark.json').then((mod) => mod.default),
'github-light': () => import('shiki/themes/github-light.json').then((mod) => mod.default),
hc_light: () => import('shiki/themes/hc_light.json').then((mod) => mod.default),
'light-plus': () => import('shiki/themes/light-plus.json').then((mod) => mod.default),
'material-darker': () => import('shiki/themes/material-darker.json').then((mod) => mod.default),
'material-default': () => import('shiki/themes/material-default.json').then((mod) => mod.default),
'material-lighter': () => import('shiki/themes/material-lighter.json').then((mod) => mod.default),
'material-ocean': () => import('shiki/themes/material-ocean.json').then((mod) => mod.default),
'material-palenight': () =>
import('shiki/themes/material-palenight.json').then((mod) => mod.default),
'min-dark': () => import('shiki/themes/min-dark.json').then((mod) => mod.default),
'min-light': () => import('shiki/themes/min-light.json').then((mod) => mod.default),
monokai: () => import('shiki/themes/monokai.json').then((mod) => mod.default),
nord: () => import('shiki/themes/nord.json').then((mod) => mod.default),
'one-dark-pro': () => import('shiki/themes/one-dark-pro.json').then((mod) => mod.default),
poimandres: () => import('shiki/themes/poimandres.json').then((mod) => mod.default),
'rose-pine-dawn': () => import('shiki/themes/rose-pine-dawn.json').then((mod) => mod.default),
'rose-pine-moon': () => import('shiki/themes/rose-pine-moon.json').then((mod) => mod.default),
'rose-pine': () => import('shiki/themes/rose-pine.json').then((mod) => mod.default),
'slack-dark': () => import('shiki/themes/slack-dark.json').then((mod) => mod.default),
'slack-ochin': () => import('shiki/themes/slack-ochin.json').then((mod) => mod.default),
'solarized-dark': () => import('shiki/themes/solarized-dark.json').then((mod) => mod.default),
'solarized-light': () => import('shiki/themes/solarized-light.json').then((mod) => mod.default),
'vitesse-dark': () => import('shiki/themes/vitesse-dark.json').then((mod) => mod.default),
'vitesse-light': () => import('shiki/themes/vitesse-light.json').then((mod) => mod.default),
'css-variables': () => import('shiki/themes/css-variables.json').then(mod => mod.default),
'dark-plus': () => import('shiki/themes/dark-plus.json').then(mod => mod.default),
'dracula-soft': () => import('shiki/themes/dracula-soft.json').then(mod => mod.default),
'dracula': () => import('shiki/themes/dracula.json').then(mod => mod.default),
'github-dark-dimmed': () => import('shiki/themes/github-dark-dimmed.json').then(mod => mod.default),
'github-dark': () => import('shiki/themes/github-dark.json').then(mod => mod.default),
'github-light': () => import('shiki/themes/github-light.json').then(mod => mod.default),
'hc_light': () => import('shiki/themes/hc_light.json').then(mod => mod.default),
'light-plus': () => import('shiki/themes/light-plus.json').then(mod => mod.default),
'material-theme-darker': () => import('shiki/themes/material-theme-darker.json').then(mod => mod.default),
'material-theme-lighter': () => import('shiki/themes/material-theme-lighter.json').then(mod => mod.default),
'material-theme-ocean': () => import('shiki/themes/material-theme-ocean.json').then(mod => mod.default),
'material-theme-palenight': () => import('shiki/themes/material-theme-palenight.json').then(mod => mod.default),
'material-theme': () => import('shiki/themes/material-theme.json').then(mod => mod.default),
'min-dark': () => import('shiki/themes/min-dark.json').then(mod => mod.default),
'min-light': () => import('shiki/themes/min-light.json').then(mod => mod.default),
'monokai': () => import('shiki/themes/monokai.json').then(mod => mod.default),
'nord': () => import('shiki/themes/nord.json').then(mod => mod.default),
'one-dark-pro': () => import('shiki/themes/one-dark-pro.json').then(mod => mod.default),
'poimandres': () => import('shiki/themes/poimandres.json').then(mod => mod.default),
'rose-pine-dawn': () => import('shiki/themes/rose-pine-dawn.json').then(mod => mod.default),
'rose-pine-moon': () => import('shiki/themes/rose-pine-moon.json').then(mod => mod.default),
'rose-pine': () => import('shiki/themes/rose-pine.json').then(mod => mod.default),
'slack-dark': () => import('shiki/themes/slack-dark.json').then(mod => mod.default),
'slack-ochin': () => import('shiki/themes/slack-ochin.json').then(mod => mod.default),
'solarized-dark': () => import('shiki/themes/solarized-dark.json').then(mod => mod.default),
'solarized-light': () => import('shiki/themes/solarized-light.json').then(mod => mod.default),
'vitesse-dark': () => import('shiki/themes/vitesse-dark.json').then(mod => mod.default),
'vitesse-light': () => import('shiki/themes/vitesse-light.json').then(mod => mod.default),
// old theme names for compat
'material-darker': () => import('shiki/themes/material-theme-darker').then(mod => mod.default),
'material-default': () => import('shiki/themes/material-theme').then(mod => mod.default),
'material-lighter': () => import('shiki/themes/material-theme-lighter').then(mod => mod.default),
'material-ocean': () => import('shiki/themes/material-theme-ocean').then(mod => mod.default),
'material-palenight': () => import('shiki/themes/material-theme-palenight').then(mod => mod.default),
};

View file

@ -1,6 +1,6 @@
{
"name": "astro",
"version": "2.3.2",
"version": "2.3.4",
"description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.",
"type": "module",
"author": "withastro",
@ -64,6 +64,10 @@
"./zod": {
"types": "./zod.d.ts",
"default": "./zod.mjs"
},
"./middleware": {
"types": "./dist/core/middleware/index.d.ts",
"default": "./dist/core/middleware/index.js"
}
},
"imports": {
@ -106,11 +110,11 @@
"test:e2e:match": "playwright test -g"
},
"dependencies": {
"@astrojs/compiler": "^1.3.1",
"@astrojs/language-server": "^0.28.3",
"@astrojs/compiler": "^1.4.0",
"@astrojs/language-server": "^1.0.0",
"@astrojs/markdown-remark": "^2.1.4",
"@astrojs/telemetry": "^2.1.0",
"@astrojs/webapi": "^2.1.0",
"@astrojs/telemetry": "^2.1.1",
"@astrojs/webapi": "^2.1.1",
"@babel/core": "^7.18.2",
"@babel/generator": "^7.18.2",
"@babel/parser": "^7.18.4",
@ -119,7 +123,7 @@
"@babel/types": "^7.18.4",
"@types/babel__core": "^7.1.19",
"@types/yargs-parser": "^21.0.0",
"acorn": "^8.8.1",
"acorn": "^8.8.2",
"boxen": "^6.2.1",
"chokidar": "^3.5.3",
"ci-info": "^3.3.1",
@ -130,7 +134,7 @@
"devalue": "^4.2.0",
"diff": "^5.1.0",
"es-module-lexer": "^1.1.0",
"estree-walker": "^3.0.1",
"estree-walker": "3.0.0",
"execa": "^6.1.0",
"fast-glob": "^3.2.11",
"github-slugger": "^2.0.0",
@ -146,7 +150,7 @@
"rehype": "^12.0.1",
"semver": "^7.3.8",
"server-destroy": "^1.0.1",
"shiki": "^0.11.1",
"shiki": "^0.14.1",
"slash": "^4.0.0",
"string-width": "^5.1.2",
"strip-ansi": "^7.0.1",
@ -181,7 +185,6 @@
"@types/rimraf": "^3.0.2",
"@types/send": "^0.17.1",
"@types/server-destroy": "^1.0.1",
"@types/sharp": "^0.31.1",
"@types/unist": "^2.0.6",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
@ -196,13 +199,13 @@
"remark-code-titles": "^0.1.2",
"rollup": "^3.9.0",
"sass": "^1.52.2",
"sharp": "^0.31.3",
"sharp": "^0.32.1",
"srcset-parse": "^1.1.0",
"undici": "^5.22.0",
"unified": "^10.1.2"
},
"peerDependencies": {
"sharp": "^0.31.3"
"sharp": ">=0.31.0"
},
"peerDependenciesMeta": {
"sharp": {

View file

@ -4,31 +4,34 @@ const dir = await fs.promises.readdir('packages/astro/node_modules/shiki/languag
const langImports = dir.map((f) => {
const key = f.slice(0, f.indexOf('.tmLanguage.json'));
return [
key,
`import('shiki/languages/${f}').then(mod => mod.default).then(grammar => {
const lang = BUNDLED_LANGUAGES.find(l => l.id === '${key}');
if(lang) {
return [key, `import('shiki/languages/${f}').then((mod) => handleLang(mod.default, '${key}'))`];
});
let code = `\
/**
* This file is prebuilt from packages/astro/scripts/shiki-gen-languages.mjs
* Do not edit this directly, but instead edit that file and rerun it to generate this file.
*/
import { BUNDLED_LANGUAGES } from 'shiki';
function handleLang(grammar, language) {
const lang = BUNDLED_LANGUAGES.find((l) => l.id === language);
if (lang) {
return {
...lang,
grammar
grammar,
};
} else {
return undefined;
}
})`,
];
});
let code = `import { BUNDLED_LANGUAGES } from 'shiki';
}
// prettier-ignore
export const languages = {`;
let i = 0;
for (const [key, imp] of langImports) {
if (i > 0) {
code += ',';
}
code += `\n\t'${key}': () => ${imp}`;
i++;
code += `\n\t'${key}': () => ${imp},`;
}
code += '\n};';

View file

@ -2,18 +2,36 @@ import fs from 'fs';
const dir = await fs.promises.readdir('packages/astro/node_modules/shiki/themes/');
const toThemeImport = (theme) => `import('shiki/themes/${theme}').then(mod => mod.default)`;
const themeImports = dir.map((f) => {
return [f.slice(0, f.indexOf('.json')), `import('shiki/themes/${f}').then(mod => mod.default)`];
return [f.slice(0, f.indexOf('.json')), toThemeImport(f)];
});
let code = `export const themes = {`;
let i = 0;
// Map of old theme names to new names to preserve compatibility when we upgrade shiki
const compatThemes = {
'material-darker': 'material-theme-darker',
'material-default': 'material-theme',
'material-lighter': 'material-theme-lighter',
'material-ocean': 'material-theme-ocean',
'material-palenight': 'material-theme-palenight',
};
let code = `\
/**
* This file is prebuilt from packages/astro/scripts/shiki-gen-themes.mjs
* Do not edit this directly, but instead edit that file and rerun it to generate this file.
*/
// prettier-ignore
export const themes = {`;
for (const [key, imp] of themeImports) {
if (i > 0) {
code += ',';
}
code += `\n\t'${key}': () => ${imp}`;
i++;
code += `\n\t'${key}': () => ${imp},`;
}
code += `\n\t// old theme names for compat`;
for (const oldName in compatThemes) {
code += `\n\t'${oldName}': () => ${toThemeImport(compatThemes[oldName])},`;
}
code += '\n};';

9
packages/astro/src/@types/app.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/**
* Shared interfaces throughout the application that can be overridden by the user.
*/
declare namespace App {
/**
* Used by middlewares to store information, that can be read by the user via the global `Astro.locals`
*/
interface Locals {}
}

View file

@ -103,6 +103,7 @@ export interface CLIFlags {
drafts?: boolean;
open?: boolean;
experimentalAssets?: boolean;
experimentalMiddleware?: boolean;
}
export interface BuildConfig {
@ -511,6 +512,23 @@ export interface AstroUserConfig {
*/
trailingSlash?: 'always' | 'never' | 'ignore';
/**
* @docs
* @name scopedStyleStrategy
* @type {('where' | 'class')}
* @default `'where'`
* @description
* @version 2.4
*
* Specify the strategy used for scoping styles within Astro components. Choose from:
* - `'where'` - Use `:where` selectors, causing no specifity increase.
* - `'class'` - Use class-based selectors, causing a +1 specifity increase.
*
* Using `'class'` is helpful when you want to ensure that element selectors within an Astro component override global style defaults (e.g. from a global stylesheet).
* Using `'where'` gives you more control over specifity, but requires that you use higher-specifity selectors, layers, and other tools to control which selectors are applied.
*/
scopedStyleStrategy?: 'where' | 'class';
/**
* @docs
* @name adapter
@ -1034,6 +1052,46 @@ export interface AstroUserConfig {
* }
*/
assets?: boolean;
/**
* @docs
* @name experimental.inlineStylesheets
* @type {('always' | 'auto' | 'never')}
* @default `never`
* @description
* Control whether styles are sent to the browser in a separate css file or inlined into <style> tags. Choose from the following options:
* - `'always'` - all styles are inlined into <style> tags
* - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, styles are sent in external stylesheets.
* - `'never'` - all styles are sent in external stylesheets
*
* ```js
* {
* experimental: {
* inlineStylesheets: `auto`,
* },
* }
*/
inlineStylesheets?: 'always' | 'auto' | 'never';
/**
* @docs
* @name experimental.middleware
* @type {boolean}
* @default `false`
* @version 2.4.0
* @description
* Enable experimental support for Astro middleware.
*
* To enable this feature, set `experimental.middleware` to `true` in your Astro config:
*
* ```js
* {
* experimental: {
* middleware: true,
* },
* }
*/
middleware?: boolean;
};
// Legacy options to be removed
@ -1431,6 +1489,11 @@ interface AstroSharedContext<Props extends Record<string, any> = Record<string,
* Redirect to another page (**SSR Only**).
*/
redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response;
/**
* Object accessed via Astro middleware
*/
locals: App.Locals;
}
export interface APIContext<Props extends Record<string, any> = Record<string, any>>
@ -1464,7 +1527,7 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a
* }
* ```
*
* [context reference](https://docs.astro.build/en/guides/api-reference/#contextparams)
* [context reference](https://docs.astro.build/en/reference/api-reference/#contextparams)
*/
params: AstroSharedContext['params'];
/**
@ -1504,6 +1567,31 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a
* [context reference](https://docs.astro.build/en/guides/api-reference/#contextredirect)
*/
redirect: AstroSharedContext['redirect'];
/**
* Object accessed via Astro middleware.
*
* Example usage:
*
* ```ts
* // src/middleware.ts
* import {defineMiddleware} from "astro/middleware";
*
* export const onRequest = defineMiddleware((context, next) => {
* context.locals.greeting = "Hello!";
* next();
* });
* ```
* Inside a `.astro` file:
* ```astro
* ---
* // src/pages/index.astro
* const greeting = Astro.locals.greeting;
* ---
* <h1>{greeting}</h1>
* ```
*/
locals: App.Locals;
}
export type Props = Record<string, unknown>;
@ -1592,6 +1680,22 @@ export interface AstroIntegration {
};
}
export type MiddlewareNext<R> = () => Promise<R>;
export type MiddlewareHandler<R> = (
context: APIContext,
next: MiddlewareNext<R>
) => Promise<R> | Promise<void> | void;
export type MiddlewareResponseHandler = MiddlewareHandler<Response>;
export type MiddlewareEndpointHandler = MiddlewareHandler<Response | EndpointOutput>;
export type MiddlewareNextResponse = MiddlewareNext<Response>;
// NOTE: when updating this file with other functions,
// remember to update `plugin-page.ts` too, to add that function as a no-op function.
export type AstroMiddlewareInstance<R> = {
onRequest?: MiddlewareHandler<R>;
};
export interface AstroPluginOptions {
settings: AstroSettings;
logging: LogOptions;

View file

@ -29,22 +29,6 @@ export async function getConfiguredImageService(): Promise<ImageService> {
return globalThis.astroAsset.imageService;
}
/**
* Get an optimized image and the necessary attributes to render it.
*
* **Example**
* ```astro
* ---
* import { getImage } from 'astro:assets';
* import originalImage from '../assets/image.png';
*
* const optimizedImage = await getImage({src: originalImage, width: 1280 });
* ---
* <img src={optimizedImage.src} {...optimizedImage.attributes} />
* ```
*
* This is functionally equivalent to using the `<Image />` component, as the component calls this function internally.
*/
export async function getImage(
options: ImageTransform,
serviceConfig: Record<string, any>

View file

@ -6,7 +6,6 @@ import {
createHeadAndContent,
renderComponent,
renderScriptElement,
renderStyleElement,
renderTemplate,
renderUniqueStylesheet,
unescapeHTML,
@ -152,13 +151,21 @@ async function render({
links = '',
scripts = '';
if (Array.isArray(collectedStyles)) {
styles = collectedStyles.map((style: any) => renderStyleElement(style)).join('');
styles = collectedStyles
.map((style: any) => {
return renderUniqueStylesheet(result, {
type: 'inline',
content: style,
});
})
.join('');
}
if (Array.isArray(collectedLinks)) {
links = collectedLinks
.map((link: any) => {
return renderUniqueStylesheet(result, {
href: prependForwardSlash(link),
type: 'external',
src: prependForwardSlash(link),
});
})
.join('');

View file

@ -123,7 +123,8 @@ export function astroConfigBuildPlugin(
chunk.type === 'chunk' &&
(chunk.code.includes(LINKS_PLACEHOLDER) || chunk.code.includes(SCRIPTS_PLACEHOLDER))
) {
let entryCSS = new Set<string>();
let entryStyles = new Set<string>();
let entryLinks = new Set<string>();
let entryScripts = new Set<string>();
for (const id of Object.keys(chunk.modules)) {
@ -137,7 +138,8 @@ export function astroConfigBuildPlugin(
const _entryScripts = pageData.propagatedScripts?.get(id);
if (_entryCss) {
for (const value of _entryCss) {
entryCSS.add(value);
if (value.type === 'inline') entryStyles.add(value.content);
if (value.type === 'external') entryLinks.add(value.src);
}
}
if (_entryScripts) {
@ -150,10 +152,16 @@ export function astroConfigBuildPlugin(
}
let newCode = chunk.code;
if (entryCSS.size) {
if (entryStyles.size) {
newCode = newCode.replace(
JSON.stringify(STYLES_PLACEHOLDER),
JSON.stringify(Array.from(entryStyles))
);
}
if (entryLinks.size) {
newCode = newCode.replace(
JSON.stringify(LINKS_PLACEHOLDER),
JSON.stringify(Array.from(entryCSS).map(prependBase))
JSON.stringify(Array.from(entryLinks).map(prependBase))
);
}
if (entryScripts.size) {

View file

@ -119,7 +119,7 @@ export function astroContentImportPlugin({
if (settings.contentEntryTypes.some((t) => t.getRenderModule)) {
plugins.push({
name: 'astro:content-render-imports',
async load(viteId) {
async transform(_, viteId) {
const contentRenderer = getContentRendererByViteId(viteId, settings);
if (!contentRenderer) return;

View file

@ -2,6 +2,7 @@ import type {
ComponentInstance,
EndpointHandler,
ManifestData,
MiddlewareResponseHandler,
RouteData,
SSRElement,
} from '../../@types/astro';
@ -9,9 +10,10 @@ import type { RouteInfo, SSRManifest as Manifest } from './types';
import mime from 'mime';
import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js';
import { call as callEndpoint } from '../endpoint/index.js';
import { call as callEndpoint, createAPIContext } from '../endpoint/index.js';
import { consoleLogDestination } from '../logger/console.js';
import { error, type LogOptions } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { removeTrailingForwardSlash } from '../path.js';
import {
createEnvironment,
@ -22,12 +24,14 @@ import {
import { RouteCache } from '../render/route-cache.js';
import {
createAssetLink,
createLinkStylesheetElementSet,
createModuleScriptElement,
createStylesheetElementSet,
} from '../render/ssr-element.js';
import { matchRoute } from '../routing/match.js';
export { deserializeManifest } from './common.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;
const responseSentSymbol = Symbol.for('astro.responseSent');
@ -127,6 +131,8 @@ export class App {
}
}
Reflect.set(request, clientLocalsSymbol, {});
// Use the 404 status code for 404.astro components
if (routeData.route === '/404') {
defaultStatus = 404;
@ -174,7 +180,9 @@ export class App {
const url = new URL(request.url);
const pathname = '/' + this.removeBase(url.pathname);
const info = this.#routeDataToRouteInfo.get(routeData!)!;
const links = createLinkStylesheetElementSet(info.links);
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links = new Set<never>();
const styles = createStylesheetElementSet(info.styles);
let scripts = new Set<SSRElement>();
for (const script of info.scripts) {
@ -191,18 +199,45 @@ export class App {
}
try {
const ctx = createRenderContext({
const renderContext = await createRenderContext({
request,
origin: url.origin,
pathname,
componentMetadata: this.#manifest.componentMetadata,
scripts,
styles,
links,
route: routeData,
status,
mod: mod as any,
env: this.#env,
});
const response = await renderPage(mod, ctx, this.#env);
const apiContext = createAPIContext({
request: renderContext.request,
params: renderContext.params,
props: renderContext.props,
site: this.#env.site,
adapterName: this.#env.adapterName,
});
const onRequest = this.#manifest.middleware?.onRequest;
let response;
if (onRequest) {
response = await callMiddleware<Response>(
onRequest as MiddlewareResponseHandler,
apiContext,
() => {
return renderPage({ mod, renderContext, env: this.#env, apiContext });
}
);
} else {
response = await renderPage({
mod,
renderContext,
env: this.#env,
apiContext,
});
}
Reflect.set(request, responseSentSymbol, true);
return response;
} catch (err: any) {
@ -224,15 +259,23 @@ export class App {
const pathname = '/' + this.removeBase(url.pathname);
const handler = mod as unknown as EndpointHandler;
const ctx = createRenderContext({
const ctx = await createRenderContext({
request,
origin: url.origin,
pathname,
route: routeData,
status,
env: this.#env,
mod: handler as any,
});
const result = await callEndpoint(handler, this.#env, ctx, this.#logging);
const result = await callEndpoint(
handler,
this.#env,
ctx,
this.#logging,
this.#manifest.middleware
);
if (result.type === 'response') {
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {

View file

@ -1,5 +1,6 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type {
AstroMiddlewareInstance,
ComponentInstance,
RouteData,
SerializedRouteData,
@ -10,6 +11,10 @@ import type {
export type ComponentPath = string;
export type StylesheetAsset =
| { type: 'inline'; content: string }
| { type: 'external'; src: string };
export interface RouteInfo {
routeData: RouteData;
file: string;
@ -20,6 +25,7 @@ export interface RouteInfo {
// Hoisted
| { type: 'inline' | 'external'; value: string }
)[];
styles: StylesheetAsset[];
}
export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
@ -38,6 +44,7 @@ export interface SSRManifest {
entryModules: Record<string, string>;
assets: Set<string>;
componentMetadata: SSRResult['componentMetadata'];
middleware?: AstroMiddlewareInstance<unknown>;
}
export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'componentMetadata'> & {

View file

@ -5,10 +5,13 @@ import type { OutputAsset, OutputChunk } from 'rollup';
import { fileURLToPath } from 'url';
import type {
AstroConfig,
AstroMiddlewareInstance,
AstroSettings,
ComponentInstance,
EndpointHandler,
EndpointOutput,
ImageTransform,
MiddlewareResponseHandler,
RouteType,
SSRError,
SSRLoadedRenderer,
@ -25,27 +28,32 @@ import {
} from '../../core/path.js';
import { runHookBuildGenerated } from '../../integrations/index.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { call as callEndpoint, throwIfRedirectNotAllowed } from '../endpoint/index.js';
import {
call as callEndpoint,
createAPIContext,
throwIfRedirectNotAllowed,
} from '../endpoint/index.js';
import { AstroError } from '../errors/index.js';
import { debug, info } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js';
import {
createAssetLink,
createLinkStylesheetElementSet,
createModuleScriptsSet,
createStylesheetElementSet,
} from '../render/ssr-element.js';
import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
import {
eachPageData,
eachPrerenderedPageData,
getPageDataByComponent,
sortedCSS,
} from './internal.js';
import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types';
import { cssOrder, eachPageData, getPageDataByComponent, mergeInlineCss } from './internal.js';
import type {
PageBuildData,
SingleFileBuiltModule,
StaticBuildOptions,
StylesheetAsset,
} from './types';
import { getTimeStat } from './util.js';
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
@ -99,8 +107,9 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
const builtPaths = new Set<string>();
if (ssr) {
for (const pageData of eachPrerenderedPageData(internals)) {
await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender)
await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
}
} else {
for (const pageData of eachPageData(internals)) {
@ -157,10 +166,17 @@ async function generatePage(
const renderers = ssrEntry.renderers;
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
const linkIds: string[] = sortedCSS(pageData);
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const linkIds: [] = [];
const scripts = pageInfo?.hoistedScript ?? null;
const styles = pageData.styles
.sort(cssOrder)
.map(({ sheet }) => sheet)
.reduce(mergeInlineCss, []);
const pageModule = ssrEntry.pageMap?.get(pageData.component);
const middleware = ssrEntry.middleware;
if (!pageModule) {
throw new Error(
@ -178,6 +194,7 @@ async function generatePage(
internals,
linkIds,
scripts,
styles,
mod: pageModule,
renderers,
};
@ -190,7 +207,7 @@ async function generatePage(
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
await generatePath(path, opts, generationOptions);
await generatePath(path, opts, generationOptions, middleware);
const timeEnd = performance.now();
const timeChange = getTimeStat(timeStart, timeEnd);
const timeIncrease = `(+${timeChange})`;
@ -268,6 +285,7 @@ interface GeneratePathOptions {
internals: BuildInternals;
linkIds: string[];
scripts: { type: 'inline' | 'external'; value: string } | null;
styles: StylesheetAsset[];
mod: ComponentInstance;
renderers: SSRLoadedRenderer[];
}
@ -332,10 +350,19 @@ function getUrlForPath(
async function generatePath(
pathname: string,
opts: StaticBuildOptions,
gopts: GeneratePathOptions
gopts: GeneratePathOptions,
middleware?: AstroMiddlewareInstance<unknown>
) {
const { settings, logging, origin, routeCache } = opts;
const { mod, internals, linkIds, scripts: hoistedScripts, pageData, renderers } = gopts;
const {
mod,
internals,
linkIds,
scripts: hoistedScripts,
styles: _styles,
pageData,
renderers,
} = gopts;
// This adds the page name to the array so it can be shown as part of stats.
if (pageData.route.type === 'page') {
@ -344,13 +371,15 @@ async function generatePath(
debug('build', `Generating: ${pathname}`);
const links = createLinkStylesheetElementSet(
linkIds,
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links = new Set<never>();
const scripts = createModuleScriptsSet(
hoistedScripts ? [hoistedScripts] : [],
settings.config.base,
settings.config.build.assetsPrefix
);
const scripts = createModuleScriptsSet(
hoistedScripts ? [hoistedScripts] : [],
const styles = createStylesheetElementSet(
_styles,
settings.config.base,
settings.config.build.assetsPrefix
);
@ -418,21 +447,32 @@ async function generatePath(
ssr,
streaming: true,
});
const ctx = createRenderContext({
const renderContext = await createRenderContext({
origin,
pathname,
request: createRequest({ url, headers: new Headers(), logging, ssr }),
componentMetadata: internals.componentMetadata,
scripts,
styles,
links,
route: pageData.route,
env,
mod,
});
let body: string | Uint8Array;
let encoding: BufferEncoding | undefined;
if (pageData.route.type === 'endpoint') {
const endpointHandler = mod as unknown as EndpointHandler;
const result = await callEndpoint(endpointHandler, env, ctx, logging);
const result = await callEndpoint(
endpointHandler,
env,
renderContext,
logging,
middleware as AstroMiddlewareInstance<Response | EndpointOutput>
);
if (result.type === 'response') {
throwIfRedirectNotAllowed(result.response, opts.settings.config);
@ -447,7 +487,26 @@ async function generatePath(
} else {
let response: Response;
try {
response = await renderPage(mod, ctx, env);
const apiContext = createAPIContext({
request: renderContext.request,
params: renderContext.params,
props: renderContext.props,
site: env.site,
adapterName: env.adapterName,
});
const onRequest = middleware?.onRequest;
if (onRequest) {
response = await callMiddleware<Response>(
onRequest as MiddlewareResponseHandler,
apiContext,
() => {
return renderPage({ mod, renderContext, env, apiContext });
}
);
} else {
response = await renderPage({ mod, renderContext, env, apiContext });
}
} catch (err) {
if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
(err as SSRError).id = pageData.component;

View file

@ -1,5 +1,5 @@
import type { Rollup } from 'vite';
import type { PageBuildData, ViteID } from './types';
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
import type { SSRResult } from '../../@types/astro';
import type { PageOptions } from '../../vite-plugin-astro/types';
@ -216,63 +216,64 @@ export function* eachPageData(internals: BuildInternals) {
}
export function hasPrerenderedPages(internals: BuildInternals) {
for (const id of internals.pagesByViteID.keys()) {
if (internals.pageOptionsByPage.get(id)?.prerender) {
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender) {
return true;
}
}
return false;
}
export function* eachPrerenderedPageData(internals: BuildInternals) {
for (const [id, pageData] of internals.pagesByViteID.entries()) {
if (internals.pageOptionsByPage.get(id)?.prerender) {
yield pageData;
}
}
}
export function* eachServerPageData(internals: BuildInternals) {
for (const [id, pageData] of internals.pagesByViteID.entries()) {
if (!internals.pageOptionsByPage.get(id)?.prerender) {
yield pageData;
}
}
interface OrderInfo {
depth: number;
order: number;
}
/**
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
* A lower depth means it comes directly from the top-level page.
* The return of this function is an array of CSS paths, with shared CSS on top
* and page-level CSS on bottom.
* Can be used to sort stylesheets so that shared rules come first
* and page-specific rules come after.
*/
export function sortedCSS(pageData: PageBuildData) {
return Array.from(pageData.css)
.sort((a, b) => {
let depthA = a[1].depth,
depthB = b[1].depth,
orderA = a[1].order,
orderB = b[1].order;
export function cssOrder(a: OrderInfo, b: OrderInfo) {
let depthA = a.depth,
depthB = b.depth,
orderA = a.order,
orderB = b.order;
if (orderA === -1 && orderB >= 0) {
return 1;
} else if (orderB === -1 && orderA >= 0) {
return -1;
} else if (orderA > orderB) {
return 1;
} else if (orderA < orderB) {
return -1;
} else {
if (depthA === -1) {
return -1;
} else if (depthB === -1) {
return 1;
} else {
return depthA > depthB ? -1 : 1;
}
}
})
.map(([id]) => id);
if (orderA === -1 && orderB >= 0) {
return 1;
} else if (orderB === -1 && orderA >= 0) {
return -1;
} else if (orderA > orderB) {
return 1;
} else if (orderA < orderB) {
return -1;
} else {
if (depthA === -1) {
return -1;
} else if (depthB === -1) {
return 1;
} else {
return depthA > depthB ? -1 : 1;
}
}
}
export function mergeInlineCss(
acc: Array<StylesheetAsset>,
current: StylesheetAsset
): Array<StylesheetAsset> {
const lastAdded = acc.at(acc.length - 1);
const lastWasInline = lastAdded?.type === 'inline';
const currentIsInline = current?.type === 'inline';
if (lastWasInline && currentIsInline) {
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
acc[acc.length - 1] = merged;
return acc;
}
acc.push(current);
return acc;
}
export function isHoistedScript(internals: BuildInternals, id: string): boolean {

View file

@ -53,7 +53,7 @@ export async function collectPagesData(
component: route.component,
route,
moduleSpecifier: '',
css: new Map(),
styles: [],
propagatedStyles: new Map(),
propagatedScripts: new Map(),
hoistedScript: undefined,
@ -76,7 +76,7 @@ export async function collectPagesData(
component: route.component,
route,
moduleSpecifier: '',
css: new Map(),
styles: [],
propagatedStyles: new Map(),
propagatedScripts: new Map(),
hoistedScript: undefined,

View file

@ -2,7 +2,7 @@ import type { Plugin as VitePlugin } from 'vite';
import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
const astroEntryPrefix = '\0astro-entry:';
export const astroEntryPrefix = '\0astro-entry:';
/**
* When adding hydrated or client:only components as Rollup inputs, sometimes we're not using all

View file

@ -5,7 +5,7 @@ import { type Plugin as VitePlugin, type ResolvedConfig } from 'vite';
import { isBuildableCSSRequest } from '../../render/dev/util.js';
import type { BuildInternals } from '../internal';
import type { AstroBuildPlugin } from '../plugin';
import type { PageBuildData, StaticBuildOptions } from '../types';
import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types';
import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
import * as assetName from '../css-asset-name.js';
@ -25,217 +25,7 @@ interface PluginOptions {
target: 'client' | 'server';
}
export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
const { internals, buildOptions } = options;
const { settings } = buildOptions;
let resolvedConfig: ResolvedConfig;
function createNameHash(baseId: string, hashIds: string[]): string {
const baseName = baseId ? npath.parse(baseId).name : 'index';
const hash = crypto.createHash('sha256');
for (const id of hashIds) {
hash.update(id, 'utf-8');
}
const h = hash.digest('hex').slice(0, 8);
const proposedName = baseName + '.' + h;
return proposedName;
}
function* getParentClientOnlys(
id: string,
ctx: { getModuleInfo: GetModuleInfo }
): Generator<PageBuildData, void, unknown> {
for (const [info] of walkParentInfos(id, ctx)) {
yield* getPageDatasByClientOnlyID(internals, info.id);
}
}
return [
{
name: 'astro:rollup-plugin-build-css',
transform(_, id) {
// In the SSR build, styles that are bundled are tracked in `internals.cssChunkModuleIds`.
// In the client build, if we're also bundling the same style, return an empty string to
// deduplicate the final CSS output.
if (options.target === 'client' && internals.cssChunkModuleIds.has(id)) {
return '';
}
},
outputOptions(outputOptions) {
// Skip in client builds as its module graph doesn't have reference to Astro pages
// to be able to chunk based on it's related top-level pages.
if (options.target === 'client') return;
const assetFileNames = outputOptions.assetFileNames;
const namingIncludesHash = assetFileNames?.toString().includes('[hash]');
const createNameForParentPages = namingIncludesHash
? assetName.shortHashedName
: assetName.createSlugger(settings);
extendManualChunks(outputOptions, {
after(id, meta) {
// For CSS, create a hash of all of the pages that use it.
// This causes CSS to be built into shared chunks when used by multiple pages.
if (isBuildableCSSRequest(id)) {
for (const [pageInfo] of walkParentInfos(id, {
getModuleInfo: meta.getModuleInfo,
})) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
// Split delayed assets to separate modules
// so they can be injected where needed
return createNameHash(id, [id]);
}
}
return createNameForParentPages(id, meta);
}
},
});
},
async generateBundle(_outputOptions, bundle) {
type ViteMetadata = {
importedAssets: Set<string>;
importedCss: Set<string>;
};
const appendCSSToPage = (
pageData: PageBuildData,
meta: ViteMetadata,
depth: number,
order: number
) => {
for (const importedCssImport of meta.importedCss) {
// CSS is prioritized based on depth. Shared CSS has a higher depth due to being imported by multiple pages.
// Depth info is used when sorting the links on the page.
if (pageData?.css.has(importedCssImport)) {
// eslint-disable-next-line
const cssInfo = pageData?.css.get(importedCssImport)!;
if (depth < cssInfo.depth) {
cssInfo.depth = depth;
}
// Update the order, preferring the lowest order we have.
if (cssInfo.order === -1) {
cssInfo.order = order;
} else if (order < cssInfo.order && order > -1) {
cssInfo.order = order;
}
} else {
pageData?.css.set(importedCssImport, { depth, order });
}
}
};
for (const [_, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
const c = chunk;
if ('viteMetadata' in chunk) {
const meta = chunk['viteMetadata'] as ViteMetadata;
// Chunks that have the viteMetadata.importedCss are CSS chunks
if (meta.importedCss.size) {
// In the SSR build, keep track of all CSS chunks' modules as the client build may
// duplicate them, e.g. for `client:load` components that render in SSR and client
// for hydation.
if (options.target === 'server') {
for (const id of Object.keys(c.modules)) {
internals.cssChunkModuleIds.add(id);
}
}
// For the client build, client:only styles need to be mapped
// over to their page. For this chunk, determine if it's a child of a
// client:only component and if so, add its CSS to the page it belongs to.
if (options.target === 'client') {
for (const id of Object.keys(c.modules)) {
for (const pageData of getParentClientOnlys(id, this)) {
for (const importedCssImport of meta.importedCss) {
pageData.css.set(importedCssImport, { depth: -1, order: -1 });
}
}
}
}
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
for (const id of Object.keys(c.modules)) {
for (const [pageInfo, depth, order] of walkParentInfos(
id,
this,
function until(importer) {
return new URL(importer, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG);
}
)) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
for (const parent of walkParentInfos(id, this)) {
const parentInfo = parent[0];
if (moduleIsTopLevelPage(parentInfo)) {
const pageViteID = parentInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) {
for (const css of meta.importedCss) {
const existingCss =
pageData.propagatedStyles.get(pageInfo.id) ?? new Set();
pageData.propagatedStyles.set(
pageInfo.id,
new Set([...existingCss, css])
);
}
}
}
}
} else if (moduleIsTopLevelPage(pageInfo)) {
const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) {
appendCSSToPage(pageData, meta, depth, order);
}
} else if (
options.target === 'client' &&
isHoistedScript(internals, pageInfo.id)
) {
for (const pageData of getPageDatasByHoistedScriptId(
internals,
pageInfo.id
)) {
appendCSSToPage(pageData, meta, -1, order);
}
}
}
}
}
}
}
}
},
},
{
name: 'astro:rollup-plugin-single-css',
enforce: 'post',
configResolved(config) {
resolvedConfig = config;
},
generateBundle(_, bundle) {
// If user disable css code-splitting, search for Vite's hardcoded
// `style.css` and add it as css for each page.
// Ref: https://github.com/vitejs/vite/blob/b2c0ee04d4db4a0ef5a084c50f49782c5f88587c/packages/vite/src/node/plugins/html.ts#L690-L705
if (!resolvedConfig.build.cssCodeSplit) {
const cssChunk = Object.values(bundle).find(
(chunk) => chunk.type === 'asset' && chunk.name === 'style.css'
);
if (cssChunk) {
for (const pageData of eachPageData(internals)) {
pageData.css.set(cssChunk.fileName, { depth: -1, order: -1 });
}
}
}
},
},
];
}
/***** ASTRO PLUGIN *****/
export function pluginCSS(
options: StaticBuildOptions,
@ -258,3 +48,272 @@ export function pluginCSS(
},
};
}
/***** ROLLUP SUB-PLUGINS *****/
function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
const { internals, buildOptions } = options;
const { settings } = buildOptions;
let resolvedConfig: ResolvedConfig;
// stylesheet filenames are kept in here until "post", when they are rendered and ready to be inlined
const pagesToCss: Record<string, Record<string, { order: number; depth: number }>> = {};
const pagesToPropagatedCss: Record<string, Record<string, Set<string>>> = {};
const cssBuildPlugin: VitePlugin = {
name: 'astro:rollup-plugin-build-css',
transform(_, id) {
// In the SSR build, styles that are bundled are tracked in `internals.cssChunkModuleIds`.
// In the client build, if we're also bundling the same style, return an empty string to
// deduplicate the final CSS output.
if (options.target === 'client' && internals.cssChunkModuleIds.has(id)) {
return '';
}
},
outputOptions(outputOptions) {
// Skip in client builds as its module graph doesn't have reference to Astro pages
// to be able to chunk based on it's related top-level pages.
if (options.target === 'client') return;
const assetFileNames = outputOptions.assetFileNames;
const namingIncludesHash = assetFileNames?.toString().includes('[hash]');
const createNameForParentPages = namingIncludesHash
? assetName.shortHashedName
: assetName.createSlugger(settings);
extendManualChunks(outputOptions, {
after(id, meta) {
// For CSS, create a hash of all of the pages that use it.
// This causes CSS to be built into shared chunks when used by multiple pages.
if (isBuildableCSSRequest(id)) {
for (const [pageInfo] of walkParentInfos(id, {
getModuleInfo: meta.getModuleInfo,
})) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
// Split delayed assets to separate modules
// so they can be injected where needed
return createNameHash(id, [id]);
}
}
return createNameForParentPages(id, meta);
}
},
});
},
async generateBundle(_outputOptions, bundle) {
for (const [_, chunk] of Object.entries(bundle)) {
if (chunk.type !== 'chunk') continue;
if ('viteMetadata' in chunk === false) continue;
const meta = chunk.viteMetadata as ViteMetadata;
// Skip if the chunk has no CSS, we want to handle CSS chunks only
if (meta.importedCss.size < 1) continue;
// In the SSR build, keep track of all CSS chunks' modules as the client build may
// duplicate them, e.g. for `client:load` components that render in SSR and client
// for hydation.
if (options.target === 'server') {
for (const id of Object.keys(chunk.modules)) {
internals.cssChunkModuleIds.add(id);
}
}
// For the client build, client:only styles need to be mapped
// over to their page. For this chunk, determine if it's a child of a
// client:only component and if so, add its CSS to the page it belongs to.
if (options.target === 'client') {
for (const id of Object.keys(chunk.modules)) {
for (const pageData of getParentClientOnlys(id, this, internals)) {
for (const importedCssImport of meta.importedCss) {
const cssToInfoRecord = (pagesToCss[pageData.moduleSpecifier] ??= {});
cssToInfoRecord[importedCssImport] = { depth: -1, order: -1 };
}
}
}
}
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
for (const id of Object.keys(chunk.modules)) {
for (const [pageInfo, depth, order] of walkParentInfos(
id,
this,
function until(importer) {
return new URL(importer, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG);
}
)) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
for (const parent of walkParentInfos(id, this)) {
const parentInfo = parent[0];
if (moduleIsTopLevelPage(parentInfo) === false) continue;
const pageViteID = parentInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData === undefined) continue;
for (const css of meta.importedCss) {
const propagatedStyles = (pagesToPropagatedCss[pageData.moduleSpecifier] ??= {});
const existingCss = (propagatedStyles[pageInfo.id] ??= new Set());
existingCss.add(css);
}
}
} else if (moduleIsTopLevelPage(pageInfo)) {
const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) {
appendCSSToPage(pageData, meta, pagesToCss, depth, order);
}
} else if (options.target === 'client' && isHoistedScript(internals, pageInfo.id)) {
for (const pageData of getPageDatasByHoistedScriptId(internals, pageInfo.id)) {
appendCSSToPage(pageData, meta, pagesToCss, -1, order);
}
}
}
}
}
},
};
const singleCssPlugin: VitePlugin = {
name: 'astro:rollup-plugin-single-css',
enforce: 'post',
configResolved(config) {
resolvedConfig = config;
},
generateBundle(_, bundle) {
// If user disable css code-splitting, search for Vite's hardcoded
// `style.css` and add it as css for each page.
// Ref: https://github.com/vitejs/vite/blob/b2c0ee04d4db4a0ef5a084c50f49782c5f88587c/packages/vite/src/node/plugins/html.ts#L690-L705
if (resolvedConfig.build.cssCodeSplit) return;
const cssChunk = Object.values(bundle).find(
(chunk) => chunk.type === 'asset' && chunk.name === 'style.css'
);
if (cssChunk === undefined) return;
for (const pageData of eachPageData(internals)) {
const cssToInfoMap = (pagesToCss[pageData.moduleSpecifier] ??= {});
cssToInfoMap[cssChunk.fileName] = { depth: -1, order: -1 };
}
},
};
const inlineStylesheetsPlugin: VitePlugin = {
name: 'astro:rollup-plugin-inline-stylesheets',
enforce: 'post',
async generateBundle(_outputOptions, bundle) {
const inlineConfig = settings.config.experimental.inlineStylesheets;
const { assetsInlineLimit = 4096 } = settings.config.vite?.build ?? {};
Object.entries(bundle).forEach(([id, stylesheet]) => {
if (
stylesheet.type !== 'asset' ||
stylesheet.name?.endsWith('.css') !== true ||
typeof stylesheet.source !== 'string'
)
return;
const assetSize = new TextEncoder().encode(stylesheet.source).byteLength;
const toBeInlined =
inlineConfig === 'always'
? true
: inlineConfig === 'never'
? false
: assetSize <= assetsInlineLimit;
if (toBeInlined) delete bundle[id];
// there should be a single js object for each stylesheet,
// allowing the single reference to be shared and checked for duplicates
const sheet: StylesheetAsset = toBeInlined
? { type: 'inline', content: stylesheet.source }
: { type: 'external', src: stylesheet.fileName };
const pages = Array.from(eachPageData(internals));
pages.forEach((pageData) => {
const orderingInfo = pagesToCss[pageData.moduleSpecifier]?.[stylesheet.fileName];
if (orderingInfo !== undefined) return pageData.styles.push({ ...orderingInfo, sheet });
const propagatedPaths = pagesToPropagatedCss[pageData.moduleSpecifier];
if (propagatedPaths === undefined) return;
Object.entries(propagatedPaths).forEach(([pageInfoId, css]) => {
// return early if sheet does not need to be propagated
if (css.has(stylesheet.fileName) !== true) return;
// return early if the stylesheet needing propagation has already been included
if (pageData.styles.some((s) => s.sheet === sheet)) return;
const propagatedStyles =
pageData.propagatedStyles.get(pageInfoId) ??
pageData.propagatedStyles.set(pageInfoId, new Set()).get(pageInfoId)!;
propagatedStyles.add(sheet);
});
});
});
},
};
return [cssBuildPlugin, singleCssPlugin, inlineStylesheetsPlugin];
}
/***** UTILITY FUNCTIONS *****/
function createNameHash(baseId: string, hashIds: string[]): string {
const baseName = baseId ? npath.parse(baseId).name : 'index';
const hash = crypto.createHash('sha256');
for (const id of hashIds) {
hash.update(id, 'utf-8');
}
const h = hash.digest('hex').slice(0, 8);
const proposedName = baseName + '.' + h;
return proposedName;
}
function* getParentClientOnlys(
id: string,
ctx: { getModuleInfo: GetModuleInfo },
internals: BuildInternals
): Generator<PageBuildData, void, unknown> {
for (const [info] of walkParentInfos(id, ctx)) {
yield* getPageDatasByClientOnlyID(internals, info.id);
}
}
type ViteMetadata = {
importedAssets: Set<string>;
importedCss: Set<string>;
};
function appendCSSToPage(
pageData: PageBuildData,
meta: ViteMetadata,
pagesToCss: Record<string, Record<string, { order: number; depth: number }>>,
depth: number,
order: number
) {
for (const importedCssImport of meta.importedCss) {
// CSS is prioritized based on depth. Shared CSS has a higher depth due to being imported by multiple pages.
// Depth info is used when sorting the links on the page.
const cssInfo = pagesToCss[pageData.moduleSpecifier]?.[importedCssImport];
if (cssInfo !== undefined) {
if (depth < cssInfo.depth) {
cssInfo.depth = depth;
}
// Update the order, preferring the lowest order we have.
if (cssInfo.order === -1) {
cssInfo.order = order;
} else if (order < cssInfo.order && order > -1) {
cssInfo.order = order;
}
} else {
const cssToInfoRecord = (pagesToCss[pageData.moduleSpecifier] ??= {});
cssToInfoRecord[importedCssImport] = { depth, order };
}
}
}

View file

@ -1,10 +1,10 @@
import type { Plugin as VitePlugin } from 'vite';
import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../../app/index.js';
import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js';
import { addRollupInput } from '../add-rollup-input.js';
import { eachPageData, hasPrerenderedPages, type BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return {
@ -22,8 +22,15 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern
}
},
load(id) {
async load(id) {
if (id === resolvedPagesVirtualModuleId) {
let middlewareId = null;
if (opts.settings.config.experimental.middleware) {
middlewareId = await this.resolve(
`${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}`
);
}
let importMap = '';
let imports = [];
let i = 0;
@ -47,8 +54,12 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern
const def = `${imports.join('\n')}
${middlewareId ? `import * as _middleware from "${middlewareId.id}";` : ''}
export const pageMap = new Map([${importMap}]);
export const renderers = [${rendererItems}];`;
export const renderers = [${rendererItems}];
${middlewareId ? `export const middleware = _middleware;` : ''}
`;
return def;
}

View file

@ -1,5 +1,5 @@
import type { Plugin as VitePlugin } from 'vite';
import type { AstroAdapter } from '../../../@types/astro';
import type { AstroAdapter, AstroConfig } from '../../../@types/astro';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types';
import type { BuildInternals } from '../internal.js';
import type { StaticBuildOptions } from '../types';
@ -13,7 +13,7 @@ import { joinPaths, prependForwardSlash } from '../../path.js';
import { serializeRouteData } from '../../routing/index.js';
import { addRollupInput } from '../add-rollup-input.js';
import { getOutFile, getOutFolder } from '../common.js';
import { eachPrerenderedPageData, eachServerPageData, sortedCSS } from '../internal.js';
import { cssOrder, eachPageData, mergeInlineCss } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin';
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
@ -21,7 +21,11 @@ const resolvedVirtualModuleId = '\0' + virtualModuleId;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g');
export function vitePluginSSR(internals: BuildInternals, adapter: AstroAdapter): VitePlugin {
export function vitePluginSSR(
internals: BuildInternals,
adapter: AstroAdapter,
config: AstroConfig
): VitePlugin {
return {
name: '@astrojs/vite-plugin-astro-ssr',
enforce: 'post',
@ -35,13 +39,18 @@ export function vitePluginSSR(internals: BuildInternals, adapter: AstroAdapter):
},
load(id) {
if (id === resolvedVirtualModuleId) {
let middleware = '';
if (config.experimental?.middleware === true) {
middleware = 'middleware: _main.middleware';
}
return `import * as adapter from '${adapter.serverEntrypoint}';
import * as _main from '${pagesVirtualModuleId}';
import { deserializeManifest as _deserializeManifest } from 'astro/app';
import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest';
const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), {
pageMap: _main.pageMap,
renderers: _main.renderers
renderers: _main.renderers,
${middleware}
});
_privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
@ -142,7 +151,8 @@ function buildManifest(
}
};
for (const pageData of eachPrerenderedPageData(internals)) {
for (const pageData of eachPageData(internals)) {
if (!pageData.route.prerender) continue;
if (!pageData.route.pathname) continue;
const outFolder = getOutFolder(
@ -161,12 +171,14 @@ function buildManifest(
file,
links: [],
scripts: [],
styles: [],
routeData: serializeRouteData(pageData.route, settings.config.trailingSlash),
});
staticFiles.push(file);
}
for (const pageData of eachServerPageData(internals)) {
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender) continue;
const scripts: SerializedRouteInfo['scripts'] = [];
if (pageData.hoistedScript) {
const hoistedValue = pageData.hoistedScript.value;
@ -186,7 +198,14 @@ function buildManifest(
});
}
const links = sortedCSS(pageData).map((pth) => prefixAssetPath(pth));
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links: [] = [];
const styles = pageData.styles
.sort(cssOrder)
.map(({ sheet }) => sheet)
.map((s) => (s.type === 'external' ? { ...s, src: prefixAssetPath(s.src) } : s))
.reduce(mergeInlineCss, []);
routes.push({
file: '',
@ -197,6 +216,7 @@ function buildManifest(
.filter((script) => script.stage === 'head-inline')
.map(({ stage, content }) => ({ stage, children: content })),
],
styles,
routeData: serializeRouteData(pageData.route, settings.config.trailingSlash),
});
}
@ -233,7 +253,9 @@ export function pluginSSR(
build: 'ssr',
hooks: {
'build:before': () => {
let vitePlugin = ssr ? vitePluginSSR(internals, options.settings.adapter!) : undefined;
let vitePlugin = ssr
? vitePluginSSR(internals, options.settings.adapter!, options.settings.config)
: undefined;
return {
enforce: 'after-user-plugins',

View file

@ -8,7 +8,7 @@ import { fileURLToPath } from 'url';
import * as vite from 'vite';
import {
createBuildInternals,
eachPrerenderedPageData,
eachPageData,
type BuildInternals,
} from '../../core/build/internal.js';
import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js';
@ -290,8 +290,9 @@ async function runPostBuildHooks(
*/
async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) {
const allStaticFiles = new Set();
for (const pageData of eachPrerenderedPageData(internals)) {
allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier));
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender)
allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier));
}
const ssr = opts.settings.config.output === 'server';
const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);

View file

@ -1,6 +1,7 @@
import type { default as vite, InlineConfig } from 'vite';
import type {
AstroConfig,
AstroMiddlewareInstance,
AstroSettings,
BuildConfig,
ComponentInstance,
@ -16,14 +17,18 @@ export type ComponentPath = string;
export type ViteID = string;
export type PageOutput = AstroConfig['output'];
export type StylesheetAsset =
| { type: 'inline'; content: string }
| { type: 'external'; src: string };
export interface PageBuildData {
component: ComponentPath;
route: RouteData;
moduleSpecifier: string;
css: Map<string, { depth: number; order: number }>;
propagatedStyles: Map<string, Set<string>>;
propagatedStyles: Map<string, Set<StylesheetAsset>>;
propagatedScripts: Map<string, Set<string>>;
hoistedScript: { type: 'inline' | 'external'; value: string } | undefined;
styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>;
}
export type AllPagesData = Record<ComponentPath, PageBuildData>;
@ -44,6 +49,7 @@ export interface StaticBuildOptions {
export interface SingleFileBuiltModule {
pageMap: Map<ComponentPath, ComponentInstance>;
middleware: AstroMiddlewareInstance<unknown>;
renderers: SSRLoadedRenderer[];
}

View file

@ -43,6 +43,7 @@ export async function compile({
sourcemap: 'both',
internalURL: 'astro/server/index.js',
astroGlobalArgs: JSON.stringify(astroConfig.site),
scopedStyleStrategy: astroConfig.scopedStyleStrategy,
resultScopedSlot: true,
preprocessStyle: createStylePreprocessor({
filename,

View file

@ -103,6 +103,8 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined,
experimentalAssets:
typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined,
experimentalMiddleware:
typeof flags.experimentalMiddleware === 'boolean' ? flags.experimentalMiddleware : undefined,
};
}
@ -136,6 +138,9 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags) {
// TODO: Come back here and refactor to remove this expected error.
astroConfig.server.open = flags.open;
}
if (typeof flags.experimentalMiddleware === 'boolean') {
astroConfig.experimental.middleware = true;
}
return astroConfig;
}

View file

@ -38,6 +38,8 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
legacy: {},
experimental: {
assets: false,
inlineStylesheets: 'never',
middleware: false,
},
};
@ -73,6 +75,10 @@ export const AstroConfigSchema = z.object({
.union([z.literal('static'), z.literal('server')])
.optional()
.default('static'),
scopedStyleStrategy: z
.union([z.literal('where'), z.literal('class')])
.optional()
.default('where'),
adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(),
integrations: z.preprocess(
// preprocess
@ -185,6 +191,11 @@ export const AstroConfigSchema = z.object({
experimental: z
.object({
assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets),
inlineStylesheets: z
.enum(['always', 'auto', 'never'])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
})
.optional()
.default({}),

View file

@ -10,3 +10,6 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
'.mdwn',
'.md',
] as const;
// The folder name where to find the middleware
export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';

View file

@ -8,15 +8,18 @@ export async function call(options: SSROptions, logging: LogOptions) {
const {
env,
preload: [, mod],
middleware,
} = options;
const endpointHandler = mod as unknown as EndpointHandler;
const ctx = createRenderContext({
const ctx = await createRenderContext({
request: options.request,
origin: options.origin,
pathname: options.pathname,
route: options.route,
env,
mod: endpointHandler as any,
});
return await callEndpoint(endpointHandler, env, ctx, logging);
return await callEndpoint(endpointHandler, env, ctx, logging, middleware);
}

View file

@ -1,4 +1,12 @@
import type { APIContext, AstroConfig, EndpointHandler, Params } from '../../@types/astro';
import type {
APIContext,
AstroConfig,
AstroMiddlewareInstance,
EndpointHandler,
EndpointOutput,
MiddlewareEndpointHandler,
Params,
} from '../../@types/astro';
import type { Environment, RenderContext } from '../render/index';
import { renderEndpoint } from '../../runtime/server/index.js';
@ -6,9 +14,11 @@ import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { warn, type LogOptions } from '../logger/core.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { isValueSerializable } from '../render/core.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
type EndpointCallResult =
| {
@ -22,7 +32,7 @@ type EndpointCallResult =
response: Response;
};
function createAPIContext({
export function createAPIContext({
request,
params,
site,
@ -35,7 +45,7 @@ function createAPIContext({
props: Record<string, any>;
adapterName?: string;
}): APIContext {
return {
const context = {
cookies: new AstroCookies(request),
request,
params,
@ -51,7 +61,6 @@ function createAPIContext({
});
},
url: new URL(request.url),
// @ts-expect-error
get clientAddress() {
if (!(clientAddressSymbol in request)) {
if (adapterName) {
@ -66,44 +75,60 @@ function createAPIContext({
return Reflect.get(request, clientAddressSymbol);
},
};
} as APIContext;
// We define a custom property, so we can check the value passed to locals
Object.defineProperty(context, 'locals', {
get() {
return Reflect.get(request, clientLocalsSymbol);
},
set(val) {
if (typeof val !== 'object') {
throw new AstroError(AstroErrorData.LocalsNotAnObject);
} else {
Reflect.set(request, clientLocalsSymbol, val);
}
},
});
return context;
}
export async function call(
export async function call<MiddlewareResult = Response | EndpointOutput>(
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
logging: LogOptions
logging: LogOptions,
middleware?: AstroMiddlewareInstance<MiddlewareResult> | undefined
): Promise<EndpointCallResult> {
const paramsAndPropsResp = await getParamsAndProps({
mod: mod as any,
route: ctx.route,
routeCache: env.routeCache,
pathname: ctx.pathname,
logging: env.logging,
ssr: env.ssr,
});
if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname),
hint: ctx.route?.component
? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component])
: '',
});
}
const [params, props] = paramsAndPropsResp;
const context = createAPIContext({
request: ctx.request,
params,
props,
params: ctx.params,
props: ctx.props,
site: env.site,
adapterName: env.adapterName,
});
const response = await renderEndpoint(mod, context, env.ssr);
let response = await renderEndpoint(mod, context, env.ssr);
if (middleware && middleware.onRequest) {
if (response.body === null) {
const onRequest = middleware.onRequest as MiddlewareEndpointHandler;
response = await callMiddleware<Response | EndpointOutput>(onRequest, context, async () => {
if (env.mode === 'development' && !isValueSerializable(context.locals)) {
throw new AstroError({
...AstroErrorData.LocalsNotSerializable,
message: AstroErrorData.LocalsNotSerializable.message(ctx.pathname),
});
}
return response;
});
} else {
warn(
env.logging,
'middleware',
"Middleware doesn't work for endpoints that return a simple body. The middleware will be disabled for this page."
);
}
}
if (response instanceof Response) {
attachToResponse(response, context.cookies);

View file

@ -628,6 +628,95 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
code: 3030,
message: 'The response has already been sent to the browser and cannot be altered.',
},
/**
* @docs
* @description
* Thrown when the middleware does not return any data or call the `next` function.
*
* For example:
* ```ts
* import {defineMiddleware} from "astro/middleware";
* export const onRequest = defineMiddleware((context, _) => {
* // doesn't return anything or call `next`
* context.locals.someData = false;
* });
* ```
*/
MiddlewareNoDataOrNextCalled: {
title: "The middleware didn't return a response or call `next`",
code: 3031,
message:
'The middleware needs to either return a `Response` object or call the `next` function.',
},
/**
* @docs
* @description
* Thrown in development mode when middleware returns something that is not a `Response` object.
*
* For example:
* ```ts
* import {defineMiddleware} from "astro/middleware";
* export const onRequest = defineMiddleware(() => {
* return "string"
* });
* ```
*/
MiddlewareNotAResponse: {
title: 'The middleware returned something that is not a `Response` object',
code: 3032,
message: 'Any data returned from middleware must be a valid `Response` object.',
},
/**
* @docs
* @description
*
* Thrown in development mode when `locals` is overwritten with something that is not an object
*
* For example:
* ```ts
* import {defineMiddleware} from "astro/middleware";
* export const onRequest = defineMiddleware((context, next) => {
* context.locals = 1541;
* return next();
* });
* ```
*/
LocalsNotAnObject: {
title: 'Value assigned to `locals` is not accepted',
code: 3033,
message:
'`locals` can only be assigned to an object. Other values like numbers, strings, etc. are not accepted.',
hint: 'If you tried to remove some information from the `locals` object, try to use `delete` or set the property to `undefined`.',
},
/**
* @docs
* @description
* Thrown in development mode when a user attempts to store something that is not serializable in `locals`.
*
* For example:
* ```ts
* import {defineMiddleware} from "astro/middleware";
* export const onRequest = defineMiddleware((context, next) => {
* context.locals = {
* foo() {
* alert("Hello world!")
* }
* };
* return next();
* });
* ```
*/
LocalsNotSerializable: {
title: '`Astro.locals` is not serializable',
code: 3034,
message: (href: string) => {
return `The information stored in \`Astro.locals\` for the path "${href}" is not serializable.\nMake sure you store only serializable data.`;
},
},
// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx
/**

View file

@ -0,0 +1,99 @@
import type { APIContext, MiddlewareHandler, MiddlewareNext } from '../../@types/astro';
import { AstroError, AstroErrorData } from '../errors/index.js';
/**
* Utility function that is in charge of calling the middleware.
*
* It accepts a `R` generic, which usually is the `Response` returned.
* It is a generic because endpoints can return a different payload.
*
* When calling a middleware, we provide a `next` function, this function might or
* might not be called.
*
* A middleware, to behave correctly, can:
* - return a `Response`;
* - call `next`;
*
* Failing doing so will result an error. A middleware can call `next` and do not return a
* response. A middleware can not call `next` and return a new `Response` from scratch (maybe with a redirect).
*
* ```js
* const onRequest = async (context, next) => {
* const response = await next(context);
* return response;
* }
* ```
*
* ```js
* const onRequest = async (context, next) => {
* context.locals = "foo";
* next();
* }
* ```
*
* @param onRequest The function called which accepts a `context` and a `resolve` function
* @param apiContext The API context
* @param responseFunction A callback function that should return a promise with the response
*/
export async function callMiddleware<R>(
onRequest: MiddlewareHandler<R>,
apiContext: APIContext,
responseFunction: () => Promise<R>
): Promise<Response | R> {
let resolveResolve: any;
new Promise((resolve) => {
resolveResolve = resolve;
});
let nextCalled = false;
const next: MiddlewareNext<R> = async () => {
nextCalled = true;
return await responseFunction();
};
let middlewarePromise = onRequest(apiContext, next);
return await Promise.resolve(middlewarePromise).then(async (value) => {
// first we check if `next` was called
if (nextCalled) {
/**
* Then we check if a value is returned. If so, we need to return the value returned by the
* middleware.
* e.g.
* ```js
* const response = await next();
* const new Response(null, { status: 500, headers: response.headers });
* ```
*/
if (typeof value !== 'undefined') {
if (value instanceof Response === false) {
throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
}
return value as R;
} else {
/**
* Here we handle the case where `next` was called and returned nothing.
*/
const responseResult = await responseFunction();
return responseResult;
}
} else if (typeof value === 'undefined') {
/**
* There might be cases where `next` isn't called and the middleware **must** return
* something.
*
* If not thing is returned, then we raise an Astro error.
*/
throw new AstroError(AstroErrorData.MiddlewareNoDataOrNextCalled);
} else if (value instanceof Response === false) {
throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
} else {
// Middleware did not call resolve and returned a value
return value as R;
}
});
}
function isEndpointResult(response: any): boolean {
return response && typeof response.body !== 'undefined';
}

View file

@ -0,0 +1,9 @@
import type { MiddlewareResponseHandler } from '../../@types/astro';
import { sequence } from './sequence.js';
function defineMiddleware(fn: MiddlewareResponseHandler) {
return fn;
}
// NOTE: this export must export only the functions that will be exposed to user-land as officials APIs
export { sequence, defineMiddleware };

View file

@ -0,0 +1,22 @@
import type { AstroSettings } from '../../@types/astro';
import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js';
import type { ModuleLoader } from '../module-loader';
/**
* It accepts a module loader and the astro settings, and it attempts to load the middlewares defined in the configuration.
*
* If not middlewares were not set, the function returns an empty array.
*/
export async function loadMiddleware(
moduleLoader: ModuleLoader,
srcDir: AstroSettings['config']['srcDir']
) {
// can't use node Node.js builtins
let middlewarePath = srcDir.pathname + '/' + MIDDLEWARE_PATH_SEGMENT_NAME;
try {
const module = await moduleLoader.import(middlewarePath);
return module;
} catch {
return void 0;
}
}

View file

@ -0,0 +1,36 @@
import type { APIContext, MiddlewareResponseHandler } from '../../@types/astro';
import { defineMiddleware } from './index.js';
// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js
/**
*
* It accepts one or more middleware handlers and makes sure that they are run in sequence.
*/
export function sequence(...handlers: MiddlewareResponseHandler[]): MiddlewareResponseHandler {
const length = handlers.length;
if (!length) {
const handler: MiddlewareResponseHandler = defineMiddleware((context, next) => {
return next();
});
return handler;
}
return defineMiddleware((context, next) => {
return applyHandle(0, context);
function applyHandle(i: number, handleContext: APIContext) {
const handle = handlers[i];
// @ts-expect-error
// SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually
// doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`.
const result = handle(handleContext, async () => {
if (i < length - 1) {
return applyHandle(i + 1, handleContext);
} else {
return next();
}
});
return result;
}
});
}

View file

@ -52,7 +52,18 @@ function isString(path: unknown): path is string {
}
export function joinPaths(...paths: (string | undefined)[]) {
return paths.filter(isString).map(trimSlashes).join('/');
return paths
.filter(isString)
.map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
})
.join('/');
}
export function removeFileExtension(path: string) {

View file

@ -1,4 +1,13 @@
import type { RouteData, SSRElement, SSRResult } from '../../@types/astro';
import type {
ComponentInstance,
Params,
Props,
RouteData,
SSRElement,
SSRResult,
} from '../../@types/astro';
import { getParamsAndPropsOrThrow } from './core.js';
import type { Environment } from './environment';
/**
* The RenderContext represents the parts of rendering that are specific to one request.
@ -14,22 +23,38 @@ export interface RenderContext {
componentMetadata?: SSRResult['componentMetadata'];
route?: RouteData;
status?: number;
params: Params;
props: Props;
}
export type CreateRenderContextArgs = Partial<RenderContext> & {
origin?: string;
request: RenderContext['request'];
mod: ComponentInstance;
env: Environment;
};
export function createRenderContext(options: CreateRenderContextArgs): RenderContext {
export async function createRenderContext(
options: CreateRenderContextArgs
): Promise<RenderContext> {
const request = options.request;
const url = new URL(request.url);
const origin = options.origin ?? url.origin;
const pathname = options.pathname ?? url.pathname;
const [params, props] = await getParamsAndPropsOrThrow({
mod: options.mod as any,
route: options.route,
routeCache: options.env.routeCache,
pathname: pathname,
logging: options.env.logging,
ssr: options.env.ssr,
});
return {
...options,
origin,
pathname,
url,
params,
props,
};
}

View file

@ -1,12 +1,11 @@
import type { ComponentInstance, Params, Props, RouteData } from '../../@types/astro';
import type { LogOptions } from '../logger/core.js';
import type { RenderContext } from './context.js';
import type { Environment } from './environment.js';
import type { APIContext, ComponentInstance, Params, Props, RouteData } from '../../@types/astro';
import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { LogOptions } from '../logger/core.js';
import { getParams } from '../routing/params.js';
import type { RenderContext } from './context.js';
import type { Environment } from './environment.js';
import { createResult } from './result.js';
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
@ -23,6 +22,26 @@ export const enum GetParamsAndPropsError {
NoMatchingStaticPath,
}
/**
* It retrieves `Params` and `Props`, or throws an error
* if they are not correctly retrieved.
*/
export async function getParamsAndPropsOrThrow(
options: GetParamsAndPropsOptions
): Promise<[Params, Props]> {
let paramsAndPropsResp = await getParamsAndProps(options);
if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(options.pathname),
hint: options.route?.component
? AstroErrorData.NoMatchingStaticPathFound.hint([options.route?.component])
: '',
});
}
return paramsAndPropsResp;
}
export async function getParamsAndProps(
opts: GetParamsAndPropsOptions
): Promise<[Params, Props] | GetParamsAndPropsError> {
@ -84,65 +103,63 @@ export async function getParamsAndProps(
return [params, pageProps];
}
export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env: Environment) {
const paramsAndPropsRes = await getParamsAndProps({
logging: env.logging,
mod,
route: ctx.route,
routeCache: env.routeCache,
pathname: ctx.pathname,
ssr: env.ssr,
});
if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname),
hint: ctx.route?.component
? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component])
: '',
});
}
const [params, pageProps] = paramsAndPropsRes;
export type RenderPage = {
mod: ComponentInstance;
renderContext: RenderContext;
env: Environment;
apiContext?: APIContext;
};
export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) {
// Validate the page component before rendering the page
const Component = mod.default;
if (!Component)
throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
let locals = {};
if (apiContext) {
if (env.mode === 'development' && !isValueSerializable(apiContext.locals)) {
throw new AstroError({
...AstroErrorData.LocalsNotSerializable,
message: AstroErrorData.LocalsNotSerializable.message(renderContext.pathname),
});
}
locals = apiContext.locals;
}
const result = createResult({
adapterName: env.adapterName,
links: ctx.links,
styles: ctx.styles,
links: renderContext.links,
styles: renderContext.styles,
logging: env.logging,
markdown: env.markdown,
mode: env.mode,
origin: ctx.origin,
params,
props: pageProps,
pathname: ctx.pathname,
componentMetadata: ctx.componentMetadata,
origin: renderContext.origin,
params: renderContext.params,
props: renderContext.props,
pathname: renderContext.pathname,
componentMetadata: renderContext.componentMetadata,
resolve: env.resolve,
renderers: env.renderers,
request: ctx.request,
request: renderContext.request,
site: env.site,
scripts: ctx.scripts,
scripts: renderContext.scripts,
ssr: env.ssr,
status: ctx.status ?? 200,
status: renderContext.status ?? 200,
locals,
});
// Support `export const components` for `MDX` pages
if (typeof (mod as any).components === 'object') {
Object.assign(pageProps, { components: (mod as any).components });
Object.assign(renderContext.props, { components: (mod as any).components });
}
const response = await runtimeRenderPage(
let response = await runtimeRenderPage(
result,
Component,
pageProps,
renderContext.props,
null,
env.streaming,
ctx.route
renderContext.route
);
// If there is an Astro.cookies instance, attach it to the response so that
@ -153,3 +170,57 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
return response;
}
/**
* Checks whether any value can is serializable.
*
* A serializable value contains plain values. For example, `Proxy`, `Set`, `Map`, functions, etc.
* are not serializable objects.
*
* @param object
*/
export function isValueSerializable(value: unknown): boolean {
let type = typeof value;
let plainObject = true;
if (type === 'object' && isPlainObject(value)) {
for (const [, nestedValue] of Object.entries(value)) {
if (!isValueSerializable(nestedValue)) {
plainObject = false;
break;
}
}
} else {
plainObject = false;
}
let result =
value === null ||
type === 'string' ||
type === 'number' ||
type === 'boolean' ||
Array.isArray(value) ||
plainObject;
return result;
}
/**
*
* From [redux-toolkit](https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/isPlainObject.ts)
*
* Returns true if the passed value is "plain" object, i.e. an object whose
* prototype is the root `Object.prototype`. This includes objects created
* using object literals, but not for instance for class instances.
*/
function isPlainObject(value: unknown): value is object {
if (typeof value !== 'object' || value === null) return false;
let proto = Object.getPrototypeOf(value);
if (proto === null) return true;
let baseProto = proto;
while (Object.getPrototypeOf(baseProto) !== null) {
baseProto = Object.getPrototypeOf(baseProto);
}
return proto === baseProto;
}

View file

@ -1,14 +1,18 @@
import { fileURLToPath } from 'url';
import type {
AstroMiddlewareInstance,
AstroSettings,
ComponentInstance,
MiddlewareResponseHandler,
RouteData,
SSRElement,
SSRLoadedRenderer,
} from '../../../@types/astro';
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { createAPIContext } from '../../endpoint/index.js';
import { enhanceViteSSRError } from '../../errors/dev/index.js';
import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js';
import { callMiddleware } from '../../middleware/callMiddleware.js';
import type { ModuleLoader } from '../../module-loader/index';
import { isPage, resolveIdToUrl, viteID } from '../../util.js';
import { createRenderContext, renderPage as coreRenderPage } from '../index.js';
@ -35,6 +39,10 @@ export interface SSROptions {
request: Request;
/** optional, in case we need to render something outside of a dev server */
route?: RouteData;
/**
* Optional middlewares
*/
middleware?: AstroMiddlewareInstance<unknown>;
}
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];
@ -158,8 +166,9 @@ export async function renderPage(options: SSROptions): Promise<Response> {
env: options.env,
filePath: options.filePath,
});
const { env } = options;
const ctx = createRenderContext({
const renderContext = await createRenderContext({
request: options.request,
origin: options.origin,
pathname: options.pathname,
@ -168,7 +177,25 @@ export async function renderPage(options: SSROptions): Promise<Response> {
styles,
componentMetadata: metadata,
route: options.route,
mod,
env,
});
if (options.middleware) {
if (options.middleware && options.middleware.onRequest) {
const apiContext = createAPIContext({
request: options.request,
params: renderContext.params,
props: renderContext.props,
adapterName: options.env.adapterName,
});
return await coreRenderPage(mod, ctx, options.env); // NOTE: without "await", errors wont get caught below
const onRequest = options.middleware.onRequest as MiddlewareResponseHandler;
const response = await callMiddleware<Response>(onRequest, apiContext, () => {
return coreRenderPage({ mod, renderContext, env: options.env, apiContext });
});
return response;
}
}
return await coreRenderPage({ mod, renderContext, env: options.env }); // NOTE: without "await", errors wont get caught below
}

View file

@ -1,6 +1,11 @@
export { createRenderContext } from './context.js';
export type { RenderContext } from './context.js';
export { getParamsAndProps, GetParamsAndPropsError, renderPage } from './core.js';
export {
getParamsAndProps,
GetParamsAndPropsError,
getParamsAndPropsOrThrow,
renderPage,
} from './core.js';
export type { Environment } from './environment';
export { createBasicEnvironment, createEnvironment } from './environment.js';
export { loadRenderer } from './renderer.js';

View file

@ -50,6 +50,7 @@ export interface CreateResultArgs {
componentMetadata?: SSRResult['componentMetadata'];
request: Request;
status: number;
locals: App.Locals;
}
function getFunctionExpression(slot: any) {
@ -131,7 +132,7 @@ class Slots {
let renderMarkdown: any = null;
export function createResult(args: CreateResultArgs): SSRResult {
const { markdown, params, pathname, renderers, request, resolve } = args;
const { markdown, params, pathname, renderers, request, resolve, locals } = args;
const url = new URL(request.url);
const headers = new Headers();
@ -200,6 +201,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
},
params,
props,
locals,
request,
url,
redirect: args.ssr

View file

@ -1,6 +1,7 @@
import slashify from 'slash';
import type { SSRElement } from '../../@types/astro';
import { joinPaths, prependForwardSlash } from '../../core/path.js';
import type { StylesheetAsset } from '../app/types';
export function createAssetLink(href: string, base?: string, assetsPrefix?: string): string {
if (assetsPrefix) {
@ -12,28 +13,35 @@ export function createAssetLink(href: string, base?: string, assetsPrefix?: stri
}
}
export function createLinkStylesheetElement(
href: string,
export function createStylesheetElement(
stylesheet: StylesheetAsset,
base?: string,
assetsPrefix?: string
): SSRElement {
return {
props: {
rel: 'stylesheet',
href: createAssetLink(href, base, assetsPrefix),
},
children: '',
};
if (stylesheet.type === 'inline') {
return {
props: {
type: 'text/css',
},
children: stylesheet.content,
};
} else {
return {
props: {
rel: 'stylesheet',
href: createAssetLink(stylesheet.src, base, assetsPrefix),
},
children: '',
};
}
}
export function createLinkStylesheetElementSet(
hrefs: string[],
export function createStylesheetElementSet(
stylesheets: StylesheetAsset[],
base?: string,
assetsPrefix?: string
) {
return new Set<SSRElement>(
hrefs.map((href) => createLinkStylesheetElement(href, base, assetsPrefix))
);
): Set<SSRElement> {
return new Set(stylesheets.map((s) => createStylesheetElement(s, base, assetsPrefix)));
}
export function createModuleScriptElement(

View file

@ -16,6 +16,7 @@ export interface CreateRequestOptions {
}
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
export function createRequest({
url,
@ -65,5 +66,7 @@ export function createRequest({
Reflect.set(request, clientAddressSymbol, clientAddress);
}
Reflect.set(request, clientLocalsSymbol, {});
return request;
}

View file

@ -55,6 +55,7 @@ export async function runHookConfigSetup({
let updatedConfig: AstroConfig = { ...settings.config };
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
for (const integration of settings.config.integrations) {
/**
* By making integration hooks optional, Astro can now ignore null or undefined Integrations
@ -68,7 +69,7 @@ export async function runHookConfigSetup({
* ]
* ```
*/
if (integration?.hooks?.['astro:config:setup']) {
if (integration.hooks?.['astro:config:setup']) {
const hooks: HookParameters<'astro:config:setup'> = {
config: updatedConfig,
command,

View file

@ -19,7 +19,7 @@ function getHandlerFromModule(mod: EndpointHandler, method: string) {
/** Renders an endpoint request to completion, returning the body. */
export async function renderEndpoint(mod: EndpointHandler, context: APIContext, ssr: boolean) {
const { request, params } = context;
const { request, params, locals } = context;
const chosenMethod = request.method?.toLowerCase();
const handler = getHandlerFromModule(mod, chosenMethod);
if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') {

View file

@ -19,7 +19,6 @@ export {
renderScriptElement,
renderSlot,
renderSlotToString,
renderStyleElement,
renderTemplate as render,
renderTemplate,
renderToString,

View file

@ -263,7 +263,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
if (isPage || renderer?.name === 'astro:jsx') {
yield html;
} else if (html && html.length > 0) {
yield markHTMLString(html.replace(/\<\/?astro-slot\>/g, ''));
yield markHTMLString(html.replace(/\<\/?astro-slot\b[^>]*>/g, ''));
} else {
yield '';
}

View file

@ -16,7 +16,11 @@ export function renderAllHeadContent(result: SSRResult) {
result._metadata.hasRenderedHead = true;
const styles = Array.from(result.styles)
.filter(uniqueElements)
.map((style) => renderElement('style', style));
.map((style) =>
style.props.rel === 'stylesheet'
? renderElement('link', style)
: renderElement('style', style)
);
// Clear result.styles so that any new styles added will be inlined.
result.styles.clear();
const scripts = Array.from(result.scripts)

View file

@ -11,6 +11,6 @@ export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js';
export { renderSlot, renderSlotToString, type ComponentSlots } from './slot.js';
export { renderScriptElement, renderStyleElement, renderUniqueStylesheet } from './tags.js';
export { renderScriptElement, renderUniqueStylesheet } from './tags.js';
export type { RenderInstruction } from './types';
export { addAttribute, defineScriptVars, voidElementNames } from './util.js';

View file

@ -1,15 +1,7 @@
import type { SSRElement, SSRResult } from '../../../@types/astro';
import type { StylesheetAsset } from '../../../core/app/types';
import { renderElement } from './util.js';
const stylesheetRel = 'stylesheet';
export function renderStyleElement(children: string) {
return renderElement('style', {
props: {},
children,
});
}
export function renderScriptElement({ props, children }: SSRElement) {
return renderElement('script', {
props,
@ -17,26 +9,14 @@ export function renderScriptElement({ props, children }: SSRElement) {
});
}
export function renderStylesheet({ href }: { href: string }) {
return renderElement(
'link',
{
props: {
rel: stylesheetRel,
href,
},
children: '',
},
false
);
}
export function renderUniqueStylesheet(result: SSRResult, link: { href: string }) {
for (const existingLink of result.links) {
if (existingLink.props.rel === stylesheetRel && existingLink.props.href === link.href) {
return '';
}
export function renderUniqueStylesheet(result: SSRResult, sheet: StylesheetAsset) {
if (sheet.type === 'external') {
if (Array.from(result.styles).some((s) => s.props.href === sheet.src)) return '';
return renderElement('link', { props: { rel: 'stylesheet', href: sheet.src }, children: '' });
}
return renderStylesheet(link);
if (sheet.type === 'inline') {
if (Array.from(result.styles).some((s) => s.children.includes(sheet.content))) return '';
return renderElement('style', { props: { type: 'text/css' }, children: sheet.content });
}
}

View file

@ -57,9 +57,10 @@ export async function writeWebResponse(res: http.ServerResponse, webResponse: Re
// Attach any set-cookie headers added via Astro.cookies.set()
const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse));
setCookieHeaders.forEach((cookie) => {
headers.append('set-cookie', cookie);
});
if (setCookieHeaders.length) {
// Always use `res.setHeader` because headers.append causes them to be concatenated.
res.setHeader('set-cookie', setCookieHeaders);
}
const _headers = Object.fromEntries(headers.entries());

View file

@ -1,23 +1,21 @@
import type http from 'http';
import mime from 'mime';
import type { AstroSettings, ComponentInstance, ManifestData, RouteData } from '../@types/astro';
import type {
ComponentPreload,
DevelopmentEnvironment,
SSROptions,
} from '../core/render/dev/index';
import type { ComponentInstance, ManifestData, RouteData } from '../@types/astro';
import { attachToResponse } from '../core/cookies/index.js';
import { call as callEndpoint } from '../core/endpoint/dev/index.js';
import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js';
import { AstroErrorData } from '../core/errors/index.js';
import { warn } from '../core/logger/core.js';
import { appendForwardSlash } from '../core/path.js';
import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
import type {
ComponentPreload,
DevelopmentEnvironment,
SSROptions,
} from '../core/render/dev/index';
import { preload, renderPage } from '../core/render/dev/index.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js';
import { resolvePages } from '../core/util.js';
import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
@ -35,11 +33,9 @@ interface MatchedRoute {
mod: ComponentInstance;
}
function getCustom404Route({ config }: AstroSettings, manifest: ManifestData) {
// For Windows compat, use relative page paths to match the 404 route
const relPages = resolvePages(config).href.replace(config.root.href, '');
const pattern = new RegExp(`${appendForwardSlash(relPages)}404.(astro|md)`);
return manifest.routes.find((r) => r.component.match(pattern));
function getCustom404Route(manifest: ManifestData): RouteData | undefined {
const route404 = /^\/404\/?$/;
return manifest.routes.find((r) => route404.test(r.route));
}
export async function matchRoute(
@ -97,7 +93,7 @@ export async function matchRoute(
}
log404(logging, pathname);
const custom404 = getCustom404Route(settings, manifest);
const custom404 = getCustom404Route(manifest);
if (custom404) {
const filePath = new URL(`./${custom404.component}`, settings.config.root);
@ -173,7 +169,12 @@ export async function handleRoute(
request,
route,
};
if (env.settings.config.experimental.middleware) {
const middleware = await loadMiddleware(env.loader, env.settings.config.srcDir);
if (middleware) {
options.middleware = middleware;
}
}
// Route successfully matched! Render it.
if (route.type === 'endpoint') {
const result = await callEndpoint(options, logging);

View file

@ -31,7 +31,14 @@ function getPrivateEnv(
// Ignore public env var
if (envPrefixes.every((prefix) => !key.startsWith(prefix))) {
if (typeof process.env[key] !== 'undefined') {
privateEnv[key] = `process.env.${key}`;
const value = process.env[key];
// Boolean values should be inlined to support `export const prerender`
// We already know that these are NOT sensitive values, so inlining is safe
if (value === '0' || value === '1' || value === 'true' || value === 'false') {
privateEnv[key] = value;
} else {
privateEnv[key] = `process.env.${key}`;
}
} else {
privateEnv[key] = JSON.stringify(fullEnv[key]);
}

View file

@ -13,6 +13,7 @@ import babel from '@babel/core';
import * as colors from 'kleur/colors';
import path from 'path';
import { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from '../content/index.js';
import { astroEntryPrefix } from '../core/build/plugins/plugin-component-entry.js';
import { error } from '../core/logger/core.js';
import { removeQueryString } from '../core/path.js';
import { detectImportSource } from './import-source.js';
@ -139,7 +140,9 @@ export default function jsx({ settings, logging }: AstroPluginJSXOptions): Plugi
},
async transform(code, id, opts) {
const ssr = Boolean(opts?.ssr);
if (SPECIAL_QUERY_REGEX.test(id)) {
// Skip special queries and astro entries. We skip astro entries here as we know it doesn't contain
// JSX code, and also because we can't detect the import source to apply JSX transforms.
if (SPECIAL_QUERY_REGEX.test(id) || id.startsWith(astroEntryPrefix)) {
return null;
}
id = removeQueryString(id);

View file

@ -63,6 +63,29 @@ describe('Assets Prefix - Static', () => {
});
});
describe('Assets Prefix - Static with path prefix', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-assets-prefix/',
build: {
assetsPrefix: '/starting-slash',
},
});
await fixture.build();
});
it('all stylesheets should start with assetPrefix', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const stylesheets = $('link[rel="stylesheet"]');
stylesheets.each((i, el) => {
expect(el.attribs.href).to.match(/^\/starting-slash\/.*/);
});
});
});
describe('Assets Prefix - Server', () => {
let app;
@ -119,3 +142,32 @@ describe('Assets Prefix - Server', () => {
expect(imgAsset.attr('src')).to.match(assetsPrefixRegex);
});
});
describe('Assets Prefix - Server with path prefix', () => {
let app;
before(async () => {
const fixture = await loadFixture({
root: './fixtures/astro-assets-prefix/',
output: 'server',
adapter: testAdapter(),
build: {
assetsPrefix: '/starting-slash',
},
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
});
it('all stylesheets should start with assetPrefix', async () => {
const request = new Request('http://example.com/custom-base/');
const response = await app.render(request);
expect(response.status).to.equal(200);
const html = await response.text();
const $ = cheerio.load(html);
const stylesheets = $('link[rel="stylesheet"]');
stylesheets.each((i, el) => {
expect(el.attribs.href).to.match(/^\/starting-slash\/.*/);
});
});
});

View file

@ -15,7 +15,7 @@ describe('<Code>', () => {
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
expect($('pre').attr('style')).to.equal(
'background-color: #0d1117; overflow-x: auto;',
'background-color: #24292e; overflow-x: auto;',
'applies default and overflow'
);
expect($('pre > code')).to.have.lengthOf(1);
@ -28,7 +28,7 @@ describe('<Code>', () => {
let html = await fixture.readFile('/basic/index.html');
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
expect($('pre').attr('class'), 'astro-code');
expect($('pre').attr('class'), 'astro-code nord');
expect($('pre > code')).to.have.lengthOf(1);
// test: contains many generated spans
expect($('pre > code span').length).to.be.greaterThanOrEqual(6);
@ -38,7 +38,7 @@ describe('<Code>', () => {
let html = await fixture.readFile('/custom-theme/index.html');
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
expect($('pre').attr('class')).to.equal('astro-code');
expect($('pre').attr('class')).to.equal('astro-code nord');
expect($('pre').attr('style')).to.equal(
'background-color: #2e3440ff; overflow-x: auto;',
'applies custom theme'
@ -52,7 +52,7 @@ describe('<Code>', () => {
expect($('pre')).to.have.lengthOf(1);
// test: applies wrap overflow
expect($('pre').attr('style')).to.equal(
'background-color: #0d1117; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'
'background-color: #24292e; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'
);
}
{
@ -60,14 +60,14 @@ describe('<Code>', () => {
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
// test: applies wrap overflow
expect($('pre').attr('style')).to.equal('background-color: #0d1117; overflow-x: auto;');
expect($('pre').attr('style')).to.equal('background-color: #24292e; overflow-x: auto;');
}
{
let html = await fixture.readFile('/wrap-null/index.html');
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
// test: applies wrap overflow
expect($('pre').attr('style')).to.equal('background-color: #0d1117');
expect($('pre').attr('style')).to.equal('background-color: #24292e');
}
});
@ -75,7 +75,7 @@ describe('<Code>', () => {
let html = await fixture.readFile('/css-theme/index.html');
const $ = cheerio.load(html);
expect($('pre')).to.have.lengthOf(1);
expect($('pre').attr('class')).to.equal('astro-code');
expect($('pre').attr('class')).to.equal('astro-code css-variables');
expect(
$('pre, pre span')
.map((i, f) => (f.attribs ? f.attribs.style : 'no style found'))
@ -100,4 +100,14 @@ describe('<Code>', () => {
expect($('#lang > pre')).to.have.lengthOf(1);
expect($('#lang > pre > code span').length).to.equal(3);
});
it('<Code inline> has no pre tag', async () => {
let html = await fixture.readFile('/inline/index.html');
const $ = cheerio.load(html);
const codeEl = $('.astro-code');
expect(codeEl.prop('tagName')).to.eq('CODE');
expect(codeEl.attr('style')).to.include('background-color:');
expect($('pre')).to.have.lengthOf(0);
});
});

View file

@ -3,11 +3,11 @@ import { loadFixture } from './test-utils.js';
import * as cheerio from 'cheerio';
describe('getStaticPaths - build calls', () => {
before(async () => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
/** @type {import('./test-utils').Fixture} */
let fixture;
const fixture = await loadFixture({
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-get-static-paths/',
site: 'https://mysite.dev/',
base: '/blog',
@ -15,10 +15,22 @@ describe('getStaticPaths - build calls', () => {
await fixture.build();
});
afterEach(() => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
});
it('is only called once during build', () => {
// useless expect; if build() throws in setup then this test fails
expect(true).to.equal(true);
});
it('Astro.url sets the current pathname', async () => {
const html = await fixture.readFile('/food/tacos/index.html');
const $ = cheerio.load(html);
expect($('#url').text()).to.equal('/blog/food/tacos/');
});
});
describe('getStaticPaths - dev calls', () => {
@ -26,11 +38,16 @@ describe('getStaticPaths - dev calls', () => {
let devServer;
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-get-static-paths/',
site: 'https://mysite.dev/',
});
devServer = await fixture.startDevServer();
});
afterEach(() => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
fixture = await loadFixture({ root: './fixtures/astro-get-static-paths/' });
devServer = await fixture.startDevServer();
});
after(async () => {
@ -47,95 +64,46 @@ describe('getStaticPaths - dev calls', () => {
res = await fixture.fetch('/c');
expect(res.status).to.equal(200);
});
});
describe('getStaticPaths - 404 behavior', () => {
let fixture;
let devServer;
before(async () => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
fixture = await loadFixture({ root: './fixtures/astro-get-static-paths/' });
devServer = await fixture.startDevServer();
});
after(async () => {
devServer.stop();
});
it('resolves 200 on matching static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-sausage');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-pineapple');
expect(res.status).to.equal(404);
});
it('resolves 200 on matching static path - rest params', async () => {
const res = await fixture.fetch('/pizza/grimaldis/new-york');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - rest params', async () => {
const res = await fixture.fetch('/pizza/pizza-hut');
expect(res.status).to.equal(404);
});
});
describe('getStaticPaths - route params type validation', () => {
let fixture, devServer;
before(async () => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
fixture = await loadFixture({ root: './fixtures/astro-get-static-paths/' });
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('resolves 200 on nested array parameters', async () => {
const res = await fixture.fetch('/nested-arrays/slug1');
expect(res.status).to.equal(200);
});
it('resolves 200 on matching static path - string params', async () => {
// route provided with { params: { year: "2022", slug: "post-2" }}
const res = await fixture.fetch('/blog/2022/post-1');
expect(res.status).to.equal(200);
});
it('resolves 200 on matching static path - numeric params', async () => {
// route provided with { params: { year: 2022, slug: "post-2" }}
const res = await fixture.fetch('/blog/2022/post-2');
expect(res.status).to.equal(200);
});
});
describe('getStaticPaths - numeric route params', () => {
let fixture;
let devServer;
before(async () => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
fixture = await loadFixture({
root: './fixtures/astro-get-static-paths/',
site: 'https://mysite.dev/',
describe('404 behavior', () => {
it('resolves 200 on matching static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-sausage');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-pineapple');
expect(res.status).to.equal(404);
});
it('resolves 200 on matching static path - rest params', async () => {
const res = await fixture.fetch('/pizza/grimaldis/new-york');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - rest params', async () => {
const res = await fixture.fetch('/pizza/pizza-hut');
expect(res.status).to.equal(404);
});
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
describe('route params type validation', () => {
it('resolves 200 on nested array parameters', async () => {
const res = await fixture.fetch('/nested-arrays/slug1');
expect(res.status).to.equal(200);
});
it('resolves 200 on matching static path - string params', async () => {
// route provided with { params: { year: "2022", slug: "post-2" }}
const res = await fixture.fetch('/blog/2022/post-1');
expect(res.status).to.equal(200);
});
it('resolves 200 on matching static path - numeric params', async () => {
// route provided with { params: { year: 2022, slug: "post-2" }}
const res = await fixture.fetch('/blog/2022/post-2');
expect(res.status).to.equal(200);
});
});
it('resolves 200 on matching static paths', async () => {
@ -155,25 +123,3 @@ describe('getStaticPaths - numeric route params', () => {
}
});
});
describe('getStaticPaths - Astro.url', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
// reset the flag used by [...calledTwiceTest].astro between each test
globalThis.isCalledOnce = false;
fixture = await loadFixture({
root: './fixtures/astro-get-static-paths/',
site: 'https://mysite.dev/',
});
await fixture.build();
});
it('Sets the current pathname', async () => {
const html = await fixture.readFile('/food/tacos/index.html');
const $ = cheerio.load(html);
expect($('#url').text()).to.equal('/food/tacos/');
});
});

View file

@ -20,7 +20,7 @@ describe('Astro Markdown Shiki', () => {
expect($('pre')).to.have.lengthOf(2);
expect($('pre').hasClass('astro-code')).to.equal(true);
expect($('pre').attr().style).to.equal('background-color: #0d1117; overflow-x: auto;');
expect($('pre').attr().style).to.equal('background-color: #24292e; overflow-x: auto;');
});
it('Can render diff syntax with "user-select: none"', async () => {
@ -47,7 +47,7 @@ describe('Astro Markdown Shiki', () => {
expect($('pre')).to.have.lengthOf(1);
expect($('pre').hasClass('astro-code')).to.equal(true);
expect($('pre').attr().style).to.equal('background-color: #ffffff; overflow-x: auto;');
expect($('pre').attr().style).to.equal('background-color: #fff; overflow-x: auto;');
});
});
@ -84,13 +84,13 @@ describe('Astro Markdown Shiki', () => {
const segments = $('.line').get(6).children;
expect(segments).to.have.lengthOf(3);
expect(segments[0].attribs.style).to.be.equal('color: #C9D1D9');
expect(segments[1].attribs.style).to.be.equal('color: #79C0FF');
expect(segments[2].attribs.style).to.be.equal('color: #C9D1D9');
expect(segments[0].attribs.style).to.be.equal('color: #E1E4E8');
expect(segments[1].attribs.style).to.be.equal('color: #79B8FF');
expect(segments[2].attribs.style).to.be.equal('color: #E1E4E8');
const unknownLang = $('.line').last().html();
expect(unknownLang).to.be.equal(
'<span style="color: #c9d1d9">This language does not exist</span>'
'<span style="color: #e1e4e8">This language does not exist</span>'
);
});
});
@ -98,7 +98,7 @@ describe('Astro Markdown Shiki', () => {
describe('Wrap', () => {
describe('wrap = true', () => {
const style =
'background-color: #0d1117; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;';
'background-color: #24292e; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;';
let fixture;
before(async () => {
@ -117,7 +117,7 @@ describe('Astro Markdown Shiki', () => {
});
describe('wrap = false', () => {
const style = 'background-color: #0d1117; overflow-x: auto;';
const style = 'background-color: #24292e; overflow-x: auto;';
let fixture;
before(async () => {
@ -135,7 +135,7 @@ describe('Astro Markdown Shiki', () => {
});
describe('wrap = null', () => {
const style = 'background-color: #0d1117';
const style = 'background-color: #24292e';
let fixture;
before(async () => {

View file

@ -16,4 +16,10 @@ describe('Slots with client: directives', () => {
const $ = cheerio.load(html);
expect($('script')).to.have.a.lengthOf(1);
});
it('Astro slot tags are cleaned', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('astro-slot')).to.have.a.lengthOf(0);
});
});

View file

@ -36,6 +36,25 @@ describe('Content Collections - render()', () => {
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
it('De-duplicates CSS used both in layout and directly in target page', async () => {
const html = await fixture.readFile('/with-layout-prop/index.html');
const $ = cheerio.load(html);
const set = new Set();
$('link[rel=stylesheet]').each((_, linkEl) => {
const href = linkEl.attribs.href;
expect(set).to.not.contain(href);
set.add(href);
});
$('style').each((_, styleEl) => {
const textContent = styleEl.children[0].data;
expect(set).to.not.contain(textContent);
set.add(textContent);
});
});
it('Includes component scripts for rendered entry', async () => {
const html = await fixture.readFile('/launch-week-component-scripts/index.html');
const $ = cheerio.load(html);
@ -116,6 +135,28 @@ describe('Content Collections - render()', () => {
expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
});
it('De-duplicates CSS used both in layout and directly in target page', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/with-layout-prop/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
const set = new Set();
$('link[rel=stylesheet]').each((_, linkEl) => {
const href = linkEl.attribs.href;
expect(set).to.not.contain(href);
set.add(href);
});
$('style').each((_, styleEl) => {
const textContent = styleEl.children[0].data;
expect(set).to.not.contain(textContent);
set.add(textContent);
});
});
it('Applies MDX components export', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/launch-week-components-export');

View file

@ -6,7 +6,7 @@ describe('Importing raw/inlined CSS', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/css-inline/',
root: './fixtures/css-import-as-inline/',
});
});
describe('Build', () => {

View file

@ -0,0 +1,285 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
describe('Setting inlineStylesheets to never in static output', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/never/',
output: 'static',
experimental: {
inlineStylesheets: 'never',
},
});
await fixture.build();
});
it('Does not render any <style> tags', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('style').toArray()).to.be.empty;
});
describe('Inspect linked stylesheets', () => {
// object, so it can be passed by reference
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromStaticOutput(fixture);
});
commonExpectations(allStyles);
});
});
describe('Setting inlineStylesheets to never in server output', () => {
let app;
before(async () => {
const fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/never/',
output: 'server',
adapter: testAdapter(),
experimental: {
inlineStylesheets: 'never',
},
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
});
it('Does not render any <style> tags', async () => {
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
expect($('style').toArray()).to.be.empty;
});
describe('Inspect linked stylesheets', () => {
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromServer(app);
});
commonExpectations(allStyles);
});
});
describe('Setting inlineStylesheets to auto in static output', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/auto/',
output: 'static',
experimental: {
inlineStylesheets: 'auto',
},
vite: {
build: {
assetsInlineLimit: 512,
},
},
});
await fixture.build();
});
it('Renders some <style> and some <link> tags', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
// the count of style/link tags depends on our css chunking logic
// this test should be updated if it changes
expect($('style')).to.have.lengthOf(3);
expect($('link[rel=stylesheet]')).to.have.lengthOf(1);
});
describe('Inspect linked and inlined stylesheets', () => {
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromStaticOutput(fixture);
});
commonExpectations(allStyles);
});
});
describe('Setting inlineStylesheets to auto in server output', () => {
let app;
before(async () => {
const fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/auto/',
output: 'server',
adapter: testAdapter(),
experimental: {
inlineStylesheets: 'auto',
},
vite: {
build: {
assetsInlineLimit: 512,
},
},
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
});
it('Renders some <style> and some <link> tags', async () => {
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
// the count of style/link tags depends on our css chunking logic
// this test should be updated if it changes
expect($('style')).to.have.lengthOf(3);
expect($('link[rel=stylesheet]')).to.have.lengthOf(1);
});
describe('Inspect linked and inlined stylesheets', () => {
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromServer(app);
});
commonExpectations(allStyles);
});
});
describe('Setting inlineStylesheets to always in static output', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/always/',
output: 'static',
experimental: {
inlineStylesheets: 'always',
},
});
await fixture.build();
});
it('Does not render any <link> tags', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('link[rel=stylesheet]').toArray()).to.be.empty;
});
describe('Inspect inlined stylesheets', () => {
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromStaticOutput(fixture);
});
commonExpectations(allStyles);
});
});
describe('Setting inlineStylesheets to always in server output', () => {
let app;
before(async () => {
const fixture = await loadFixture({
root: './fixtures/css-inline-stylesheets/always/',
output: 'server',
adapter: testAdapter(),
experimental: {
inlineStylesheets: 'always',
},
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
});
it('Does not render any <link> tags', async () => {
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
expect($('link[rel=stylesheet]').toArray()).to.be.empty;
});
describe('Inspect inlined stylesheets', () => {
const allStyles = {};
before(async () => {
allStyles.value = await stylesFromServer(app);
});
commonExpectations(allStyles);
});
});
async function stylesFromStaticOutput(fixture) {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const links = $('link[rel=stylesheet]');
const hrefs = links.map((_, linkEl) => linkEl.attribs.href).toArray();
const allLinkedStylesheets = await Promise.all(hrefs.map((href) => fixture.readFile(href)));
const allLinkedStyles = allLinkedStylesheets.join('');
const styles = $('style');
const allInlinedStylesheets = styles.map((_, styleEl) => styleEl.children[0].data).toArray();
const allInlinedStyles = allInlinedStylesheets.join('');
return allLinkedStyles + allInlinedStyles;
}
async function stylesFromServer(app) {
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
const links = $('link[rel=stylesheet]');
const hrefs = links.map((_, linkEl) => linkEl.attribs.href).toArray();
const allLinkedStylesheets = await Promise.all(
hrefs.map(async (href) => {
const cssRequest = new Request(`http://example.com${href}`);
const cssResponse = await app.render(cssRequest);
return await cssResponse.text();
})
);
const allLinkedStyles = allLinkedStylesheets.join('');
const styles = $('style');
const allInlinedStylesheets = styles.map((_, styleEl) => styleEl.children[0].data).toArray();
const allInlinedStyles = allInlinedStylesheets.join('');
return allLinkedStyles + allInlinedStyles;
}
function commonExpectations(allStyles) {
it('Includes all authored css', () => {
// authored in imported.css
expect(allStyles.value).to.include('.bg-lightcoral');
// authored in index.astro
expect(allStyles.value).to.include('#welcome');
// authored in components/Button.astro
expect(allStyles.value).to.include('.variant-outline');
// authored in layouts/Layout.astro
expect(allStyles.value).to.include('Menlo');
});
it('Styles used both in content layout and directly in page are included only once', () => {
// authored in components/Button.astro
expect(allStyles.value.match(/cubic-bezier/g)).to.have.lengthOf(1);
});
}

View file

@ -0,0 +1,42 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('Custom 404.html', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/custom-404-html/',
site: 'http://example.com',
});
});
describe('dev', () => {
let devServer;
let $;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('renders /', async () => {
const html = await fixture.fetch('/').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('Home');
});
it('renders 404 for /a', async () => {
const html = await fixture.fetch('/a').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('Page not found');
expect($('p').text()).to.equal('This 404 is a static HTML file.');
});
});
});

View file

@ -0,0 +1,42 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('Custom 404 with injectRoute', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/custom-404-injected/',
site: 'http://example.com',
});
});
describe('dev', () => {
let devServer;
let $;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('renders /', async () => {
const html = await fixture.fetch('/').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('Home');
});
it('renders 404 for /a', async () => {
const html = await fixture.fetch('/a').then((res) => res.text());
$ = cheerio.load(html);
expect($('h1').text()).to.equal('Page not found');
expect($('p').text()).to.equal('/a');
});
});
});

View file

@ -0,0 +1,10 @@
---
import {Code} from 'astro/components';
---
<html>
<head><title>Code component</title></head>
<body>
Simple:
<Code code="console.log('inline code');" lang="js" inline />
</body>
</html>

View file

@ -0,0 +1,2 @@
<style>h3 { margin: 1rem }</style>
<h3><slot /></h3>

View file

@ -1,6 +1,6 @@
---
import { CollectionEntry, getCollection } from 'astro:content';
import H3 from './H3.astro'
// Test for recursive `getCollection()` calls
const blog = await getCollection('blog');
@ -23,6 +23,7 @@ const {
</head>
<body data-layout-prop="true">
<h1>{title}</h1>
<H3>H3 inserted in the layout</H3>
<nav>
<ul>
{blog.map((post) => (

View file

@ -1,7 +1,9 @@
---
import { getEntryBySlug } from 'astro:content';
import H3 from '../components/H3.astro';
const entry = await getEntryBySlug('blog', 'with-layout-prop');
const { Content } = await entry.render();
---
<H3>H3 directly inserted to the page</H3>
<Content />

View file

@ -0,0 +1,8 @@
{
"name": "@test/css-import-as-inline",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

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