Merge branch 'main' into redirects-ssg

This commit is contained in:
Matthew Phillips 2023-05-30 17:23:32 -04:00
commit 02a8506e22
105 changed files with 2518 additions and 605 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/sitemap': patch
---
exported enum type to support typescript > 5.0

View file

@ -0,0 +1,5 @@
---
'@astrojs/partytown': patch
---
fix typescript type for partytown options

View file

@ -0,0 +1,5 @@
---
'@astrojs/mdx': patch
---
Add `optimize` option for faster builds and rendering

View file

@ -0,0 +1,6 @@
---
'astro': patch
---
Refactor how pages are emitted during the internal bundling. Now each
page is emitted as a separate entry point.

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix CSS deduping and missing chunks

View file

@ -0,0 +1,19 @@
---
'@astrojs/markdoc': patch
---
Add support for syntax highlighting with Shiki. Apply to your Markdoc config using the `extends` property:
```js
// markdoc.config.mjs
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import shiki from '@astrojs/markdoc/shiki';
export default defineMarkdocConfig({
extends: [
shiki({ /** Shiki config options */ }),
],
})
```
Learn more in the [`@astrojs/markdoc` README.](https://docs.astro.build/en/guides/integrations-guide/markdoc/#syntax-highlighting)

View file

@ -0,0 +1,7 @@
---
'@astrojs/preact': patch
'@astrojs/react': patch
'@astrojs/vue': patch
---
Fix `astro-static-slot` hydration mismatch error

View file

@ -0,0 +1,5 @@
---
'@astrojs/webapi': minor
---
Add polyfill for `crypto`

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Prioritize dynamic prerendered routes over dynamic server routes

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Add error message if `Astro.glob` is called outside of an Astro file

View file

@ -0,0 +1,5 @@
---
'@astrojs/vercel': patch
---
Fix `imagesConfig` being wrongly spelt as `imageConfig` in the README

View file

@ -0,0 +1,17 @@
---
'@astrojs/markdoc': patch
---
Add a built-in extension for syntax highlighting with Prism. Apply to your Markdoc config using the `extends` property:
```js
// markdoc.config.mjs
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import prism from '@astrojs/markdoc/prism';
export default defineMarkdocConfig({
extends: [prism()],
})
```
Learn more in the [`@astrojs/markdoc` README.](https://docs.astro.build/en/guides/integrations-guide/markdoc/#syntax-highlighting)

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
fix miss a head when the templaterender has a promise

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Use `AstroError` for `Astro.glob` errors

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
The `src` property returned by ESM importing images with `astro:assets` is now an absolute path, unlocking support for importing images outside the project.

View file

@ -1,14 +1,21 @@
import { expect } from '@playwright/test';
import { getErrorOverlayContent, testFactory } from './test-utils.js';
import { getErrorOverlayContent, silentLogging, testFactory } from './test-utils.js';
const test = testFactory({
root: './fixtures/errors/',
// Only test the error overlay, don't print to console
vite: {
logLevel: 'silent',
},
});
let devServer;
test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
devServer = await astro.startDevServer({
// Only test the error overlay, don't print to console
logging: silentLogging,
});
});
test.afterAll(async ({ astro }) => {
@ -89,4 +96,16 @@ test.describe('Error display', () => {
expect(await page.locator('vite-error-overlay').count()).toEqual(0);
});
test('astro glob no match error', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/astro-glob-no-match'), { waitUntil: 'networkidle' });
const message = (await getErrorOverlayContent(page)).message;
expect(message).toMatch('did not return any matching files');
});
test('astro glob used outside of an astro file', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/astro-glob-outside-astro'), { waitUntil: 'networkidle' });
const message = (await getErrorOverlayContent(page)).message;
expect(message).toMatch('can only be used in');
});
});

View file

@ -0,0 +1,3 @@
export function globSomething(Astro) {
return Astro.glob('./*.lua')
}

View file

@ -0,0 +1,3 @@
---
Astro.glob('./*.lua')
---

View file

@ -0,0 +1,5 @@
---
import { globSomething } from '../components/AstroGlobOutsideAstro'
globSomething(Astro)
---

View file

@ -14,6 +14,22 @@ test.afterAll(async () => {
});
test.describe('Nested Frameworks in React', () => {
test('No hydration mismatch', async ({ page, astro }) => {
// Get browser logs
const logs = [];
page.on('console', (msg) => logs.push(msg.text()));
await page.goto(astro.resolveUrl('/'));
// wait for root island to hydrate
const counter = page.locator('#react-counter');
await waitForHydrate(page, counter);
for (const log of logs) {
expect(log, 'React hydration mismatch').not.toMatch('An error occurred during hydration');
}
});
test('React counter', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

View file

@ -14,6 +14,22 @@ test.afterAll(async () => {
});
test.describe('Nested Frameworks in Vue', () => {
test('no hydration mismatch', async ({ page, astro }) => {
// Get browser logs
const logs = [];
page.on('console', (msg) => logs.push(msg.text()));
await page.goto(astro.resolveUrl('/'));
// wait for root island to hydrate
const counter = page.locator('#vue-counter');
await waitForHydrate(page, counter);
for (const log of logs) {
expect(log, 'Vue hydration mismatch').not.toMatch('Hydration node mismatch');
}
});
test('React counter', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

View file

@ -5,6 +5,8 @@ import { loadFixture as baseLoadFixture } from '../test/test-utils.js';
export const isWindows = process.platform === 'win32';
export { silentLogging } from '../test/test-utils.js';
// Get all test files in directory, assign unique port for each of them so they don't conflict
const testFiles = await fs.readdir(new URL('.', import.meta.url));
const testFileToPort = new Map();

View file

@ -118,12 +118,12 @@
"@astrojs/markdown-remark": "^2.2.1",
"@astrojs/telemetry": "^2.1.1",
"@astrojs/webapi": "^2.1.1",
"@babel/core": "^7.18.2",
"@babel/core": "^7.21.8",
"@babel/generator": "^7.18.2",
"@babel/parser": "^7.18.4",
"@babel/plugin-transform-react-jsx": "^7.17.12",
"@babel/traverse": "^7.18.2",
"@babel/types": "^7.18.4",
"@babel/types": "^7.21.5",
"@types/babel__core": "^7.1.19",
"@types/yargs-parser": "^21.0.0",
"acorn": "^8.8.2",

View file

@ -1698,7 +1698,7 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a
*
* export const onRequest = defineMiddleware((context, next) => {
* context.locals.greeting = "Hello!";
* next();
* return next();
* });
* ```
* Inside a `.astro` file:

View file

@ -109,6 +109,7 @@ export type BaseServiceTransform = {
*/
export const baseService: Omit<LocalImageService, 'transform'> = {
validateOptions(options) {
// `src` is missing or is `undefined`.
if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
throw new AstroError({
...AstroErrorData.ExpectedImage,
@ -117,6 +118,14 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
}
if (!isESMImportedImage(options.src)) {
// User passed an `/@fs/` path instead of the full image.
if (options.src.startsWith('/@fs/')) {
throw new AstroError({
...AstroErrorData.LocalImageUsedWrongly,
message: AstroErrorData.LocalImageUsedWrongly.message(options.src),
});
}
// For remote images, width and height are explicitly required as we can't infer them from the file
let missingDimension: 'width' | 'height' | 'both' | undefined;
if (!options.width && !options.height) {

View file

@ -2,14 +2,13 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import slash from 'slash';
import type { AstroConfig, AstroSettings } from '../../@types/astro';
import { prependForwardSlash } from '../../core/path.js';
import { imageMetadata, type Metadata } from './metadata.js';
export async function emitESMImage(
id: string | undefined,
watchMode: boolean,
fileEmitter: any,
settings: Pick<AstroSettings, 'config'>
fileEmitter: any
): Promise<Metadata | undefined> {
if (!id) {
return undefined;
@ -40,34 +39,14 @@ export async function emitESMImage(
url.searchParams.append('origHeight', meta.height.toString());
url.searchParams.append('origFormat', meta.format);
meta.src = rootRelativePath(settings.config, url);
meta.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
}
return meta;
}
/**
* Utilities inlined from `packages/astro/src/core/util.ts`
* Avoids ESM / CJS bundling failures when accessed from integrations
* due to Vite dependencies in core.
*/
function rootRelativePath(config: Pick<AstroConfig, 'root'>, url: URL): string {
const basePath = fileURLToNormalizedPath(url);
const rootPath = fileURLToNormalizedPath(config.root);
return prependForwardSlash(basePath.slice(rootPath.length));
}
function prependForwardSlash(filePath: string): string {
return filePath[0] === '/' ? filePath : '/' + filePath;
}
function fileURLToNormalizedPath(filePath: URL): string {
// Uses `slash` package instead of Vite's `normalizePath`
// to avoid CJS bundling issues.
return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/');
}
export function emoji(char: string, fallback: string): string {
return process.platform !== 'win32' ? char : fallback;
}

View file

@ -107,13 +107,12 @@ export default function assets({
}
const url = new URL(req.url, 'file:');
const filePath = url.searchParams.get('href');
if (!filePath) {
if (!url.searchParams.has('href')) {
return next();
}
const filePathURL = new URL('.' + filePath, settings.config.root);
const filePath = url.searchParams.get('href')?.slice('/@fs'.length);
const filePathURL = new URL('.' + filePath, 'file:');
const file = await fs.readFile(filePathURL);
// Get the file's metadata from the URL
@ -243,7 +242,7 @@ export default function assets({
const cleanedUrl = removeQueryString(id);
if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(cleanedUrl)) {
const meta = await emitESMImage(id, this.meta.watchMode, this.emitFile, settings);
const meta = await emitESMImage(id, this.meta.watchMode, this.emitFile);
return `export default ${JSON.stringify(meta)}`;
}
},

View file

@ -1,21 +1,15 @@
import type { PluginContext } from 'rollup';
import { z } from 'zod';
import type { AstroSettings } from '../@types/astro.js';
import { emitESMImage } from '../assets/index.js';
export function createImage(
settings: Pick<AstroSettings, 'config'>,
pluginContext: PluginContext,
entryFilePath: string
) {
export function createImage(pluginContext: PluginContext, entryFilePath: string) {
return () => {
return z.string().transform(async (imagePath, ctx) => {
const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id;
const metadata = await emitESMImage(
resolvedFilePath,
pluginContext.meta.watchMode,
pluginContext.emitFile,
settings
pluginContext.emitFile
);
if (!metadata) {

View file

@ -111,7 +111,7 @@ export async function getEntryData(
}
schema = schema({
image: createImage({ config }, pluginContext, entry._internal.filePath),
image: createImage(pluginContext, entry._internal.filePath),
});
}

View file

@ -33,8 +33,6 @@ 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');
export interface MatchOptions {
@ -177,7 +175,12 @@ export class App {
if(route.type === 'redirect') {
return RedirectComponentInstance;
} else {
return await this.#manifest.pageMap.get(route.component)!();
const importComponentInstance = this.#manifest.pageMap.get(route.component);
if(!importComponentInstance) {
throw new Error(`Unexpected unable to find a component instance for route ${route.route}`);
}
const built = await importComponentInstance();
return built.page();
}
}

View file

@ -1,13 +1,13 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type {
AstroMiddlewareInstance,
ComponentInstance,
RouteData,
SerializedRouteData,
SSRComponentMetadata,
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import type { SinglePageBuiltModule } from '../build/types';
export type ComponentPath = string;
@ -31,7 +31,7 @@ export interface RouteInfo {
export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
routeData: SerializedRouteData;
};
type ImportComponentInstance = () => Promise<ComponentInstance>;
type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
export interface SSRManifest {
adapterName: string;

View file

@ -12,6 +12,8 @@ import type {
EndpointOutput,
ImageTransform,
MiddlewareResponseHandler,
RedirectRouteData,
RouteData,
RouteType,
SSRError,
SSRLoadedRenderer,
@ -20,7 +22,12 @@ import {
generateImage as generateImageInternal,
getStaticImageList,
} from '../../assets/generate.js';
import { hasPrerenderedPages, type BuildInternals } from '../../core/build/internal.js';
import {
eachPageDataFromEntryPoint,
hasPrerenderedPages,
type BuildInternals,
eachRedirectPageData,
} from '../../core/build/internal.js';
import {
prependForwardSlash,
removeLeadingForwardSlash,
@ -35,7 +42,7 @@ 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 { getRedirectLocationOrThrow, routeIsRedirect, RedirectComponentInstance } from '../redirects/index.js';
import { getRedirectLocationOrThrow, RedirectComponentInstance } from '../redirects/index.js';
import {
createAssetLink,
createModuleScriptsSet,
@ -45,15 +52,47 @@ import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
import { cssOrder, eachPageData, getPageDataByComponent, mergeInlineCss } from './internal.js';
import { cssOrder, getPageDataByComponent, mergeInlineCss, getEntryFilePathFromComponentPath } from './internal.js';
import type {
PageBuildData,
SingleFileBuiltModule,
SinglePageBuiltModule,
StaticBuildOptions,
StylesheetAsset,
} from './types';
import { getTimeStat } from './util.js';
const StaticMiddlewareInstance: AstroMiddlewareInstance<unknown> = {
onRequest: (ctx, next) => next()
};
function createEntryURL(filePath: string, outFolder: URL) {
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
}
async function getEntryForRedirectRoute(
route: RouteData,
internals: BuildInternals,
outFolder: URL
): Promise<SinglePageBuiltModule> {
if(route.type !== 'redirect') {
throw new Error(`Expected a redirect route.`);
}
if(route.redirectRoute) {
const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component);
if(filePath) {
const url = createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
return ssrEntryPage;
}
}
return {
page: () => Promise.resolve(RedirectComponentInstance),
middleware: StaticMiddlewareInstance,
renderers: []
}
}
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
return (
// Drafts are disabled
@ -92,7 +131,6 @@ export function chunkIsPage(
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
const timer = performance.now();
const ssr = opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config); // hybrid mode is essentially SSR with prerender by default
const serverEntry = opts.buildConfig.serverEntry;
const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
if (ssr && !hasPrerenderedPages(internals)) return;
@ -100,18 +138,43 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
const verb = ssr ? 'prerendering' : 'generating';
info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`);
const ssrEntryURL = new URL('./' + serverEntry + `?time=${Date.now()}`, outFolder);
const ssrEntry = await import(ssrEntryURL.toString());
const builtPaths = new Set<string>();
if (ssr) {
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender)
await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
if (pageData.route.prerender) {
const ssrEntryURLPage =createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
}
}
for(const pageData of eachRedirectPageData(internals)) {
// TODO MOVE
await generatePage(opts, internals, pageData, {
page: () => Promise.resolve(RedirectComponentInstance),
middleware: StaticMiddlewareInstance,
renderers: []
}, builtPaths);
}
} else {
for (const pageData of eachPageData(internals)) {
await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
const ssrEntryURLPage =createEntryURL(filePath, outFolder);
const ssrEntryPage: SinglePageBuiltModule = await import(ssrEntryURLPage.toString());
await generatePage(opts, internals, pageData, ssrEntryPage, builtPaths);
}
for(const pageData of eachRedirectPageData(internals)) {
const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder);
if(pageData.route.redirectRoute) {
const filePath = getEntryFilePathFromComponentPath(internals, pageData.route.redirectRoute.component);
}
await generatePage(opts, internals, pageData, {
page: () => Promise.resolve(RedirectComponentInstance),
middleware: StaticMiddlewareInstance,
renderers: []
}, builtPaths);
}
}
@ -154,11 +217,11 @@ async function generatePage(
opts: StaticBuildOptions,
internals: BuildInternals,
pageData: PageBuildData,
ssrEntry: SingleFileBuiltModule,
ssrEntry: SinglePageBuiltModule,
builtPaths: Set<string>
) {
let timeStart = performance.now();
const renderers = ssrEntry.renderers;
const renderers = ssrEntry?.renderers;
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
@ -170,16 +233,9 @@ async function generatePage(
.map(({ sheet }) => sheet)
.reduce(mergeInlineCss, []);
let pageModulePromise = ssrEntry.pageMap?.get(pageData.component);
const middleware = ssrEntry.middleware;
let pageModulePromise = ssrEntry?.page;
const middleware = ssrEntry?.middleware;
if (!pageModulePromise && routeIsRedirect(pageData.route)) {
if(pageData.route.redirectRoute) {
pageModulePromise = ssrEntry.pageMap?.get(pageData.route.redirectRoute!.component);
} else {
pageModulePromise = () => Promise.resolve(RedirectComponentInstance);
}
}
if (!pageModulePromise) {
throw new Error(
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`

View file

@ -1,6 +1,6 @@
import type { GetModuleInfo, ModuleInfo } from 'rollup';
import { resolvedPagesVirtualModuleId } from '../app/index.js';
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
// This walks up the dependency graph and yields out each ModuleInfo object.
export function* walkParentInfos(
@ -43,8 +43,8 @@ export function* walkParentInfos(
// it is imported by the top-level virtual module.
export function moduleIsTopLevelPage(info: ModuleInfo): boolean {
return (
info.importers[0] === resolvedPagesVirtualModuleId ||
info.dynamicImporters[0] == resolvedPagesVirtualModuleId
info.importers[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) ||
info.dynamicImporters[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)
);
}

View file

@ -1,17 +1,20 @@
import type { Rollup } from 'vite';
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
import type { SSRResult } from '../../@types/astro';
import type { PageOptions } from '../../vite-plugin-astro/types';
import { prependForwardSlash, removeFileExtension } from '../path.js';
import { viteID } from '../util.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN, ASTRO_PAGE_MODULE_ID, getVirtualModulePageIdFromPath } from './plugins/plugin-pages.js';
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
export interface BuildInternals {
/**
* The module ids of all CSS chunks, used to deduplicate CSS assets between
* SSR build and client build in vite-plugin-css.
* Each CSS module is named with a chunk id derived from the Astro pages they
* are used in by default. It's easy to crawl this relation in the SSR build as
* the Astro pages are the entrypoint, but not for the client build as hydratable
* components are the entrypoint instead. This map is used as a cache from the SSR
* build so the client can pick up the same information and use the same chunk ids.
*/
cssChunkModuleIds: Set<string>;
cssModuleToChunkIdMap: Map<string, string>;
// A mapping of hoisted script ids back to the exact hoisted scripts it references
hoistedScriptIdToHoistedMap: Map<string, Set<string>>;
@ -92,12 +95,11 @@ export function createBuildInternals(): BuildInternals {
const hoistedScriptIdToPagesMap = new Map<string, Set<string>>();
return {
cssChunkModuleIds: new Set(),
cssModuleToChunkIdMap: new Map(),
hoistedScriptIdToHoistedMap,
hoistedScriptIdToPagesMap,
entrySpecifierToBundleMap: new Map<string, string>(),
pageToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(),
pageOptionsByPage: new Map(),
pagesByViteID: new Map(),
@ -215,6 +217,34 @@ export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values();
}
export function* eachRedirectPageData(internals: BuildInternals) {
for(const pageData of eachPageData(internals)) {
if(pageData.route.type === 'redirect') {
yield pageData;
}
}
}
export function* eachPageDataFromEntryPoint(
internals: BuildInternals
): Generator<[PageBuildData, string]> {
for (const [entryPoint, filePath] of internals.entrySpecifierToBundleMap) {
if (entryPoint.includes(ASTRO_PAGE_MODULE_ID)) {
const [, pageName] = entryPoint.split(':');
const pageData = internals.pagesByComponent.get(
`${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}`
);
if (!pageData) {
throw new Error(
"Build failed. Astro couldn't find the emitted page from " + pageName + ' pattern'
);
}
yield [pageData, filePath];
}
}
}
export function hasPrerenderedPages(internals: BuildInternals) {
for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender) {
@ -294,3 +324,9 @@ export function* getPageDatasByHoistedScriptId(
}
}
}
// From a component path such as pages/index.astro find the entrypoint module
export function getEntryFilePathFromComponentPath(internals: BuildInternals, path: string) {
const id = getVirtualModulePageIdFromPath(path);
return internals.entrySpecifierToBundleMap.get(id);
}

View file

@ -0,0 +1,144 @@
# Plugin directory (WIP)
This file serves as developer documentation to explain how the internal plugins work
## `plugin-middleware`
This plugin is responsible to retrieve the `src/middleware.{ts.js}` file and emit an entry point during the SSR build.
The final file is emitted only if the user has the middleware file. The final name of the file is `middleware.mjs`.
This is **not** a virtual module. The plugin will try to resolve the physical file.
## `plugin-renderers`
This plugin is responsible to collect all the renderers inside an Astro application and emit them in a single file.
The emitted file is called `renderers.mjs`.
The emitted file has content similar to:
```js
const renderers = [Object.assign({"name":"astro:jsx","serverEntrypoint":"astro/jsx/server.js","jsxImportSource":"astro"}, { ssr: server_default }),];
export { renderers };
```
## `plugin-pages`
This plugin is responsible to collect all pages inside an Astro application, and emit a single entry point file for each page.
This plugin **will emit code** only when building a static site.
In order to achieve that, the plugin emits these pages as **virtual modules**. Doing so allows us to bypass:
- rollup resolution of the files
- possible plugins that get triggered when the name of the module has an extension e.g. `.astro`
The plugin does the following operations:
- loop through all the pages and collects their paths;
- with each path, we create a new [string](#plugin-pages-mapping-resolution) that will serve and virtual module for that particular page
- when resolving the page, we check if the `id` of the module starts with `@astro-page`
- once the module is resolved, we emit [the code of the module](#plugin-pages-code-generation)
### `plugin pages` mapping resolution
The mapping is as follows:
```
src/pages/index.astro => @astro-page:src/pages/index@_@astro
```
1. We add a fixed prefix, which is used as virtual module naming convention;
2. We replace the dot that belongs extension with an arbitrary string.
This kind of patterns will then allow us to retrieve the path physical path of the
file back from that string. This is important for the [code generation](#plugin-pages-code-generation)
### `plugin pages` code generation
When generating the code of the page, we will import and export the following modules:
- the `renderers.mjs`
- the `middleware.mjs`
- the page, via dynamic import
The emitted code of each entry point will look like this:
```js
export { renderers } from '../renderers.mjs';
import { _ as _middleware } from '../middleware.mjs';
import '../chunks/astro.540fbe4e.mjs';
const page = () => import('../chunks/pages/index.astro.8aad0438.mjs');
const middleware = _middleware;
export { middleware, page };
```
If we have a `pages/` folder that looks like this:
```
├── blog
│ ├── first.astro
│ └── post.astro
├── first.astro
├── index.astro
├── issue.md
└── second.astro
```
The emitted entry points will be stored inside a `pages/` folder, and they
will look like this:
```
├── _astro
│ ├── first.132e69e0.css
│ ├── first.49cbf029.css
│ ├── post.a3e86c58.css
│ └── second.d178d0b2.css
├── chunks
│ ├── astro.540fbe4e.mjs
│ └── pages
│ ├── first.astro.493fa853.mjs
│ ├── index.astro.8aad0438.mjs
│ ├── issue.md.535b7d3b.mjs
│ ├── post.astro.26e892d9.mjs
│ └── second.astro.76540694.mjs
├── middleware.mjs
├── pages
│ ├── blog
│ │ ├── first.astro.mjs
│ │ └── post.astro.mjs
│ ├── first.astro.mjs
│ ├── index.astro.mjs
│ ├── issue.md.mjs
│ └── second.astro.mjs
└── renderers.mjs
```
Of course, all these files will be deleted by Astro at the end build.
## `plugin-ssr` (WIP)
This plugin is responsible to create a single `entry.mjs` file that will be used
in SSR.
This plugin **will emit code** only when building an **SSR** site.
The plugin will collect all the [virtual pages](#plugin-pages) and create
a JavaScript `Map`. These map will look like this:
```js
const _page$0 = () => import("../chunks/<INDEX.ASTRO_CHUNK>.mjs")
const _page$1 = () => import("../chunks/<ABOUT.ASTRO_CHUNK>.mjs")
const pageMap = new Map([
["src/pages/index.astro", _page$0],
["src/pages/about.astro", _page$1],
])
```
It will also import the [`renderers`](#plugin-renderers) virtual module
and the [`middleware`](#plugin-middleware) virtual module.

View file

@ -64,20 +64,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
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
@ -89,16 +76,31 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
// 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 client builds that has hydrated components as entrypoints, there's no way
// to crawl up and find the pages that use it. So we lookup the cache during SSR
// build (that has the pages information) to derive the same chunk id so they
// match up on build, making sure both builds has the CSS deduped.
// NOTE: Components that are only used with `client:only` may not exist in the cache
// and that's okay. We can use Rollup's default chunk strategy instead as these CSS
// are outside of the SSR build scope, which no dedupe is needed.
if (options.target === 'client') {
return internals.cssModuleToChunkIdMap.get(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]);
const chunkId = createNameHash(id, [id]);
internals.cssModuleToChunkIdMap.set(id, chunkId);
return chunkId;
}
}
return createNameForParentPages(id, meta);
const chunkId = createNameForParentPages(id, meta);
internals.cssModuleToChunkIdMap.set(id, chunkId);
return chunkId;
}
},
});
@ -113,15 +115,6 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
// 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.

View file

@ -6,9 +6,7 @@ import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
export const MIDDLEWARE_MODULE_ID = '@astro-middleware';
export const RESOLVED_MIDDLEWARE_MODULE_ID = '\0@astro-middleware';
let inputs: Set<string> = new Set();
export function vitePluginMiddleware(
opts: StaticBuildOptions,
_internals: BuildInternals
@ -21,26 +19,14 @@ export function vitePluginMiddleware(
}
},
resolveId(id) {
async resolveId(id) {
if (id === MIDDLEWARE_MODULE_ID && opts.settings.config.experimental.middleware) {
return RESOLVED_MIDDLEWARE_MODULE_ID;
}
},
async load(id) {
if (id === RESOLVED_MIDDLEWARE_MODULE_ID && opts.settings.config.experimental.middleware) {
const imports: string[] = [];
const exports: string[] = [];
let middlewareId = await this.resolve(
const middlewareId = await this.resolve(
`${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}`
);
if (middlewareId) {
imports.push(`import { onRequest } from "${middlewareId.id}"`);
exports.push(`export { onRequest }`);
return middlewareId.id;
}
const result = [imports.join('\n'), exports.join('\n')];
return result.join('\n');
}
},
};

View file

@ -1,58 +1,93 @@
import { extname } from 'node:path';
import type { Plugin as VitePlugin } from 'vite';
import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../../app/index.js';
import { addRollupInput } from '../add-rollup-input.js';
import { eachPageData, type BuildInternals } from '../internal.js';
import { type BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
import { routeIsRedirect } from '../../redirects/index.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
export const ASTRO_PAGE_MODULE_ID = '@astro-page:';
export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0@astro-page:';
// This is an arbitrary string that we are going to replace the dot of the extension
export const ASTRO_PAGE_EXTENSION_POST_PATTERN = '@_@';
/**
* 1. We add a fixed prefix, which is used as virtual module naming convention;
* 2. We replace the dot that belongs extension with an arbitrary string.
*
* @param path
*/
export function getVirtualModulePageNameFromPath(path: string) {
// we mask the extension, so this virtual file
// so rollup won't trigger other plugins in the process
const extension = extname(path);
return `${ASTRO_PAGE_MODULE_ID}${path.replace(
extension,
extension.replace('.', ASTRO_PAGE_EXTENSION_POST_PATTERN)
)}`;
}
export function getVirtualModulePageIdFromPath(path: string) {
const name = getVirtualModulePageNameFromPath(path);
return '\x00' + name;
}
function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return {
name: '@astro/plugin-build-pages',
options(options) {
if (opts.settings.config.output === 'static') {
return addRollupInput(options, [pagesVirtualModuleId]);
const inputs: Set<string> = new Set();
for (const [path, pageData] of Object.entries(opts.allPages)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
inputs.add(getVirtualModulePageNameFromPath(path));
}
return addRollupInput(options, Array.from(inputs));
}
},
resolveId(id) {
if (id === pagesVirtualModuleId) {
return resolvedPagesVirtualModuleId;
if (id.startsWith(ASTRO_PAGE_MODULE_ID)) {
return '\0' + id;
}
},
async load(id) {
if (id === resolvedPagesVirtualModuleId) {
let importMap = '';
if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
const imports: string[] = [];
const exports: string[] = [];
const content: string[] = [];
let i = 0;
imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
exports.push(`export { renderers };`);
for (const pageData of eachPageData(internals)) {
if(routeIsRedirect(pageData.route)) {
continue;
// we remove the module name prefix from id, this will result into a string that will start with "src/..."
const pageName = id.slice(ASTRO_PAGE_RESOLVED_MODULE_ID.length);
// We replaced the `.` of the extension with ASTRO_PAGE_EXTENSION_POST_PATTERN, let's replace it back
const pageData = internals.pagesByComponent.get(
`${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}`
);
if (pageData) {
const resolvedPage = await this.resolve(pageData.moduleSpecifier);
if (resolvedPage) {
imports.push(`const page = () => import(${JSON.stringify(pageData.moduleSpecifier)});`);
exports.push(`export { page }`);
imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
exports.push(`export { renderers };`);
if (opts.settings.config.experimental.middleware) {
imports.push(`import * as _middleware from "${MIDDLEWARE_MODULE_ID}";`);
exports.push(`export const middleware = _middleware;`);
}
return `${imports.join('\n')}${exports.join('\n')}`;
}
const variable = `_page${i}`;
imports.push(
`const ${variable} = () => import(${JSON.stringify(pageData.moduleSpecifier)});`
);
importMap += `[${JSON.stringify(pageData.component)}, ${variable}],`;
i++;
}
if (opts.settings.config.experimental.middleware) {
imports.push(`import * as _middleware from "${MIDDLEWARE_MODULE_ID}";`);
exports.push(`export const middleware = _middleware;`);
}
content.push(`export const pageMap = new Map([${importMap}]);`);
return `${imports.join('\n')}${content.join('\n')}${exports.join('\n')}`;
}
},
};

View file

@ -1,62 +1,94 @@
import type { Plugin as VitePlugin } from 'vite';
import type { AstroAdapter, AstroConfig } from '../../../@types/astro';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types';
import type { StaticBuildOptions } from '../types';
import glob from 'fast-glob';
import { fileURLToPath } from 'url';
import type { Plugin as VitePlugin } from 'vite';
import type { AstroAdapter } from '../../../@types/astro';
import { runHookBuildSsr } from '../../../integrations/index.js';
import { isHybridOutput } from '../../../prerender/utils.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { pagesVirtualModuleId } from '../../app/index.js';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types';
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 { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
import { routeIsRedirect } from '../../redirects/index.js';
import { getVirtualModulePageIdFromPath } from './plugin-pages.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g');
function vitePluginSSR(
internals: BuildInternals,
adapter: AstroAdapter,
config: AstroConfig
options: StaticBuildOptions
): VitePlugin {
return {
name: '@astrojs/vite-plugin-astro-ssr',
enforce: 'post',
options(opts) {
return addRollupInput(opts, [virtualModuleId]);
return addRollupInput(opts, [SSR_VIRTUAL_MODULE_ID]);
},
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
if (id === SSR_VIRTUAL_MODULE_ID) {
return RESOLVED_SSR_VIRTUAL_MODULE_ID;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
let middleware = '';
async load(id) {
if (id === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
const {
settings: { config },
allPages,
} = options;
const imports: string[] = [];
const contents: string[] = [];
const exports: string[] = [];
let middleware;
if (config.experimental?.middleware === true) {
middleware = 'middleware: _main.middleware';
imports.push(`import * as _middleware from "${MIDDLEWARE_MODULE_ID}"`);
middleware = 'middleware: _middleware';
}
return `import * as adapter from '${adapter.serverEntrypoint}';
let i = 0;
const pageMap: string[] = [];
for (const [path, pageData] of Object.entries(allPages)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
const virtualModuleName = getVirtualModulePageIdFromPath(path);
let module = await this.resolve(virtualModuleName);
if (module) {
const variable = `_page${i}`;
// we need to use the non-resolved ID in order to resolve correctly the virtual module
imports.push(`const ${variable} = () => import("${virtualModuleName}");`);
const pageData2 = internals.pagesByComponent.get(path);
if (pageData2) {
pageMap.push(`[${JSON.stringify(pageData2.component)}, ${variable}]`);
}
i++;
}
}
contents.push(`const pageMap = new Map([${pageMap.join(',')}]);`);
exports.push(`export { pageMap }`);
const content = `import * as adapter from '${adapter.serverEntrypoint}';
import { renderers } from '${RENDERERS_MODULE_ID}';
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,
pageMap,
renderers,
${middleware}
});
_privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
export * from '${pagesVirtualModuleId}';
${
adapter.exports
? `const _exports = adapter.createExports(_manifest, _args);
@ -77,6 +109,7 @@ const _start = 'start';
if(_start in adapter) {
adapter[_start](_manifest, _args);
}`;
return `${imports.join('\n')}${contents.join('\n')}${content}${exports.join('\n')}`;
}
return void 0;
},
@ -92,7 +125,7 @@ if(_start in adapter) {
if (chunk.type === 'asset') {
continue;
}
if (chunk.modules[resolvedVirtualModuleId]) {
if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) {
internals.ssrEntryChunk = chunk;
delete bundle[chunkName];
}
@ -250,7 +283,7 @@ export function pluginSSR(
hooks: {
'build:before': () => {
let vitePlugin = ssr
? vitePluginSSR(internals, options.settings.adapter!, options.settings.config)
? vitePluginSSR(internals, options.settings.adapter!, options)
: undefined;
return {

View file

@ -17,7 +17,6 @@ import { isModeServerWithNoAdapter } from '../../core/util.js';
import { runHookBuildSetup } from '../../integrations/index.js';
import { isHybridOutput } from '../../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { resolvedPagesVirtualModuleId } from '../app/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { info } from '../logger/core.js';
import { getOutDirWithinCwd } from './common.js';
@ -25,8 +24,12 @@ import { generatePages } from './generate.js';
import { trackPageData } from './internal.js';
import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js';
import { registerAllPlugins } from './plugins/index.js';
import { RESOLVED_MIDDLEWARE_MODULE_ID } from './plugins/plugin-middleware.js';
import {
ASTRO_PAGE_EXTENSION_POST_PATTERN,
ASTRO_PAGE_RESOLVED_MODULE_ID,
} from './plugins/plugin-pages.js';
import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
import { SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
import type { PageBuildData, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js';
@ -172,10 +175,17 @@ async function ssrBuild(
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
...viteConfig.build?.rollupOptions?.output,
entryFileNames(chunkInfo) {
if (chunkInfo.facadeModuleId === resolvedPagesVirtualModuleId) {
return opts.buildConfig.serverEntry;
} else if (chunkInfo.facadeModuleId === RESOLVED_MIDDLEWARE_MODULE_ID) {
if (chunkInfo.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
return makeAstroPageEntryPointFileName(chunkInfo.facadeModuleId);
} else if (
// checks if the path of the module we have middleware, e.g. middleware.js / middleware/index.js
chunkInfo.facadeModuleId?.includes('middleware') &&
// checks if the file actually export the `onRequest` function
chunkInfo.exports.includes('onRequest')
) {
return 'middleware.mjs';
} else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) {
return opts.settings.config.build.serverEntry;
} else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) {
return 'renderers.mjs';
} else {
@ -408,3 +418,29 @@ async function ssrMoveAssets(opts: StaticBuildOptions) {
removeEmptyDirs(serverAssets);
}
}
/**
* This function takes as input the virtual module name of an astro page and transform
* to generate an `.mjs` file:
*
* Input: `@astro-page:src/pages/index@_@astro`
*
* Output: `pages/index.astro.mjs`
*
* 1. We remove the module id prefix, `@astro-page:`
* 2. We remove `src/`
* 3. We replace square brackets with underscore, for example `[slug]`
* 4. At last, we replace the extension pattern with a simple dot
* 5. We append the `.mjs` string, so the file will always be a JS file
*
* @param facadeModuleId
*/
function makeAstroPageEntryPointFileName(facadeModuleId: string) {
return `${facadeModuleId
.replace(ASTRO_PAGE_RESOLVED_MODULE_ID, '')
.replace('src/', '')
.replaceAll('[', '_')
.replaceAll(']', '_')
// this must be last
.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}.mjs`;
}

View file

@ -49,8 +49,8 @@ export interface StaticBuildOptions {
type ImportComponentInstance = () => Promise<ComponentInstance>;
export interface SingleFileBuiltModule {
pageMap: Map<ComponentPath, ImportComponentInstance>;
export interface SinglePageBuiltModule {
page: ImportComponentInstance;
middleware: AstroMiddlewareInstance<unknown>;
renderers: SSRLoadedRenderer[];
}

View file

@ -518,7 +518,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
* For unsupported formats such as SVGs and GIFs, you may be able to use an `img` tag directly:
* ```astro
* ---
* import rocket from '../assets/images/rocket.svg'
* import rocket from '../assets/images/rocket.svg';
* ---
*
* <img src={rocket.src} width={rocket.width} height={rocket.height} alt="A rocketship in space." />
@ -627,7 +627,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
* Making changes to the response, such as setting headers, cookies, and the status code cannot be done outside of page components.
*/
ResponseSentError: {
title: 'Unable to set response',
title: 'Unable to set response.',
code: 3030,
message: 'The response has already been sent to the browser and cannot be altered.',
},
@ -647,7 +647,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
* ```
*/
MiddlewareNoDataOrNextCalled: {
title: "The middleware didn't return a response or call `next`",
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.',
@ -667,7 +667,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
* ```
*/
MiddlewareNotAResponse: {
title: 'The middleware returned something that is not a `Response` object',
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.',
},
@ -688,12 +688,67 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
* ```
*/
LocalsNotAnObject: {
title: 'Value assigned to `locals` is not accepted',
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
* @see
* - [Assets (Experimental)](https://docs.astro.build/en/guides/assets/)
* @description
* When using the default image services, `Image`'s and `getImage`'s `src` parameter must be either an imported image or an URL, it cannot be a filepath.
*
* ```astro
* ---
* import { Image } from "astro:assets";
* import myImage from "../my_image.png";
* ---
*
* <!-- GOOD: `src` is the full imported image. -->
* <Image src={myImage} alt="Cool image" />
*
* <!-- BAD: `src` is an image's `src` path instead of the full image. -->
* <Image src={myImage.src} alt="Cool image" />
* ```
*/
LocalImageUsedWrongly: {
title: 'ESM imported images must be passed as-is.',
code: 3034,
message: (imageFilePath: string) =>
`\`Image\`'s and \`getImage\`'s \`src\` parameter must be an imported image or an URL, it cannot be a filepath. Received \`${imageFilePath}\`.`,
},
/**
* @docs
* @see
* - [Astro.glob](https://docs.astro.build/en/reference/api-reference/#astroglob)
* @description
* `Astro.glob()` can only be used in `.astro` files. You can use [`import.meta.glob()`](https://vitejs.dev/guide/features.html#glob-import) instead to acheive the same result.
*/
AstroGlobUsedOutside: {
title: 'Astro.glob() used outside of an Astro file.',
code: 3035,
message: (globStr: string) =>
`\`Astro.glob(${globStr})\` can only be used in \`.astro\` files. \`import.meta.glob(${globStr})\` can be used instead to achieve a similar result.`,
hint: "See Vite's documentation on `import.meta.glob` for more information: https://vitejs.dev/guide/features.html#glob-import",
},
/**
* @docs
* @see
* - [Astro.glob](https://docs.astro.build/en/reference/api-reference/#astroglob)
* @description
* `Astro.glob()` did not return any matching files. There might be a typo in the glob pattern.
*/
AstroGlobNoMatch: {
title: 'Astro.glob() did not match any files.',
code: 3036,
message: (globStr: string) =>
`\`Astro.glob(${globStr})\` did not return any matching files. Check the pattern for typos.`,
},
/**
* @docs
* @see
@ -703,7 +758,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
*/
RedirectWithNoLocation: {
title: 'A redirect must be given a location with the `Location` header.',
code: 3035,
code: 3037,
},
// No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx
@ -973,7 +1028,6 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
},
/**
* @docs
* @message A content collection schema should not contain `slug` since it is reserved for slug generation. Remove this from your `COLLECTION_NAME` collection schema.
* @see
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
* @description
@ -982,9 +1036,8 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
ContentSchemaContainsSlugError: {
title: 'Content Schema should not contain `slug`.',
code: 9003,
message: (collection: string) => {
return `A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collection} collection schema.`;
},
message: (collectionName: string) =>
`A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collectionName} collection schema.`,
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on the `slug` field.',
},
@ -997,9 +1050,8 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
CollectionDoesNotExistError: {
title: 'Collection does not exist',
code: 9004,
message: (collection: string) => {
return `The collection **${collection}** does not exist. Ensure a collection directory with this name exists.`;
},
message: (collectionName: string) =>
`The collection **${collectionName}** does not exist. Ensure a collection directory with this name exists.`,
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more on creating collections.',
},
/**

View file

@ -1,4 +1,3 @@
import { fileURLToPath } from 'url';
import type {
AstroMiddlewareInstance,
AstroSettings,
@ -65,7 +64,7 @@ export async function preload({
try {
// Load the module from the Vite SSR Runtime.
const mod = (await env.loader.import(fileURLToPath(filePath))) as ComponentInstance;
const mod = (await env.loader.import(viteID(filePath))) as ComponentInstance;
return [renderers, mod];
} catch (error) {

View file

@ -177,6 +177,7 @@ function comparator(a: Item, b: Item) {
}
}
// endpoints are prioritized over pages
if (a.isPage !== b.isPage) {
return a.isPage ? 1 : -1;
}

View file

@ -0,0 +1,67 @@
import type { AstroSettings, RouteData } from '../@types/astro';
import { preload, type DevelopmentEnvironment } from '../core/render/dev/index.js';
import { getPrerenderStatus } from './metadata.js';
type GetSortedPreloadedMatchesParams = {
env: DevelopmentEnvironment;
matches: RouteData[];
settings: AstroSettings;
};
export async function getSortedPreloadedMatches({
env,
matches,
settings,
}: GetSortedPreloadedMatchesParams) {
return (
await preloadAndSetPrerenderStatus({
env,
matches,
settings,
})
).sort((a, b) => prioritizePrerenderedMatchesComparator(a.route, b.route));
}
type PreloadAndSetPrerenderStatusParams = {
env: DevelopmentEnvironment;
matches: RouteData[];
settings: AstroSettings;
};
async function preloadAndSetPrerenderStatus({
env,
matches,
settings,
}: PreloadAndSetPrerenderStatusParams) {
const preloaded = await Promise.all(
matches.map(async (route) => {
const filePath = new URL(`./${route.component}`, settings.config.root);
const preloadedComponent = await preload({ env, filePath });
// gets the prerender metadata set by the `astro:scanner` vite plugin
const prerenderStatus = getPrerenderStatus({
filePath,
loader: env.loader,
});
if (prerenderStatus !== undefined) {
route.prerender = prerenderStatus;
}
return { preloadedComponent, route, filePath } as const;
})
);
return preloaded;
}
function prioritizePrerenderedMatchesComparator(a: RouteData, b: RouteData): number {
if (areRegexesEqual(a.pattern, b.pattern)) {
if (a.prerender !== b.prerender) {
return a.prerender ? -1 : 1;
}
return a.component < b.component ? -1 : 1;
}
return 0;
}
function areRegexesEqual(regexp1: RegExp, regexp2: RegExp) {
return regexp1.source === regexp2.source && regexp1.global === regexp2.global;
}

View file

@ -1,12 +1,22 @@
import type { AstroGlobalPartial } from '../../@types/astro';
import { ASTRO_VERSION } from '../../core/constants.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
/** Create the Astro.glob() runtime function. */
function createAstroGlobFn() {
const globHandler = (importMetaGlobResult: Record<string, any>, globValue: () => any) => {
if (typeof importMetaGlobResult === 'string') {
throw new AstroError({
...AstroErrorData.AstroGlobUsedOutside,
message: AstroErrorData.AstroGlobUsedOutside.message(JSON.stringify(importMetaGlobResult)),
});
}
let allEntries = [...Object.values(importMetaGlobResult)];
if (allEntries.length === 0) {
throw new Error(`Astro.glob(${JSON.stringify(globValue())}) - no matches found.`);
throw new AstroError({
...AstroErrorData.AstroGlobNoMatch,
message: AstroErrorData.AstroGlobNoMatch.message(JSON.stringify(importMetaGlobResult)),
});
}
// Map over the `import()` promises, calling to load them.
return Promise.all(allEntries.map((fn) => fn()));

View file

@ -1,7 +1,14 @@
export { createComponent } from './astro-component.js';
export { createAstro } from './astro-global.js';
export { renderEndpoint } from './endpoint.js';
export { escapeHTML, HTMLBytes, HTMLString, markHTMLString, unescapeHTML } from './escape.js';
export {
escapeHTML,
HTMLBytes,
HTMLString,
isHTMLString,
markHTMLString,
unescapeHTML,
} from './escape.js';
export { renderJSX } from './jsx.js';
export {
addAttribute,

View file

@ -50,6 +50,9 @@ export class AstroComponentInstance {
value = await value;
}
if (isHeadAndContent(value)) {
if (this.result.extraHead.length === 0 && value.head) {
yield renderChild(value.head);
}
yield* value.content;
} else {
yield* renderChild(value);

View file

@ -16,7 +16,7 @@ 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 { getPrerenderStatus } from '../prerender/metadata.js';
import { getSortedPreloadedMatches } from '../prerender/routing.js';
import { isHybridOutput } from '../prerender/utils.js';
import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
@ -47,24 +47,12 @@ export async function matchRoute(
): Promise<MatchedRoute | undefined> {
const { logging, settings, routeCache } = env;
const matches = matchAllRoutes(pathname, manifest);
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
for await (const maybeRoute of matches) {
const filePath = new URL(`./${maybeRoute.component}`, settings.config.root);
const preloadedComponent = await preload({ env, filePath });
// gets the prerender metadata set by the `astro:scanner` vite plugin
const prerenderStatus = getPrerenderStatus({
filePath,
loader: env.loader,
});
if (prerenderStatus !== undefined) {
maybeRoute.prerender = prerenderStatus;
}
const [, mod] = preloadedComponent;
for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
// attempt to get static paths
// if this fails, we have a bad URL match!
const [, mod] = preloadedComponent;
const paramsAndPropsRes = await getParamsAndProps({
mod,
route: maybeRoute,
@ -210,7 +198,7 @@ export async function handleRoute(
await writeWebResponse(res, result.response);
} else {
let contentType = 'text/plain';
// Dynamic routes dont include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
// Dynamic routes don't include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
const filepath =
route.pathname ||
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/');

View file

@ -265,6 +265,21 @@ describe('CSS', function () {
new RegExp(`.svelte-scss.${scopedClass}[^{]*{font-family:ComicSansMS`)
);
});
it('client:only and SSR in two pages, both should have styles', async () => {
const onlyHtml = await fixture.readFile('/client-only-and-ssr/only/index.html');
const $onlyHtml = cheerio.load(onlyHtml);
const onlyHtmlCssHref = $onlyHtml('link[rel=stylesheet][href^=/_astro/]').attr('href');
const onlyHtmlCss = await fixture.readFile(onlyHtmlCssHref.replace(/^\/?/, '/'));
const ssrHtml = await fixture.readFile('/client-only-and-ssr/ssr/index.html');
const $ssrHtml = cheerio.load(ssrHtml);
const ssrHtmlCssHref = $ssrHtml('link[rel=stylesheet][href^=/_astro/]').attr('href');
const ssrHtmlCss = await fixture.readFile(ssrHtmlCssHref.replace(/^\/?/, '/'));
expect(onlyHtmlCss).to.include('.svelte-only-and-ssr');
expect(ssrHtmlCss).to.include('.svelte-only-and-ssr');
});
});
describe('Vite features', () => {

View file

@ -114,6 +114,32 @@ describe('astro:image', () => {
expect(logs).to.have.a.lengthOf(1);
expect(logs[0].message).to.contain('Received unsupported format');
});
it("errors when an ESM imported image's src is passed to Image/getImage instead of the full import ssss", async () => {
logs.length = 0;
let res = await fixture.fetch('/error-image-src-passed');
await res.text();
expect(logs).to.have.a.lengthOf(1);
expect(logs[0].message).to.contain('must be an imported image or an URL');
});
it('supports images from outside the project', async () => {
let res = await fixture.fetch('/outsideProject');
let html = await res.text();
$ = cheerio.load(html);
let $img = $('img');
expect($img).to.have.a.lengthOf(2);
expect(
$img.toArray().every((img) => {
return (
img.attribs['src'].startsWith('/@fs/') ||
img.attribs['src'].startsWith('/_image?href=%2F%40fs%2F')
);
})
).to.be.true;
});
});
describe('vite-isms', () => {
@ -228,9 +254,9 @@ describe('astro:image', () => {
expect($img).to.have.a.lengthOf(1);
// Verbose test for the full URL to make sure the image went through the full pipeline
expect($img.attr('src')).to.equal(
'/_image?href=%2Fsrc%2Fassets%2Fpenguin1.jpg%3ForigWidth%3D207%26origHeight%3D243%26origFormat%3Djpg&f=webp'
);
expect(
$img.attr('src').startsWith('/_image') && $img.attr('src').endsWith('f=webp')
).to.equal(true);
});
it('has width and height attributes', () => {
@ -297,12 +323,12 @@ describe('astro:image', () => {
it('has proper source for directly used image', () => {
let $img = $('#direct-image img');
expect($img.attr('src').startsWith('/src/')).to.equal(true);
expect($img.attr('src').startsWith('/')).to.equal(true);
});
it('has proper source for refined image', () => {
let $img = $('#refined-image img');
expect($img.attr('src').startsWith('/src/')).to.equal(true);
expect($img.attr('src').startsWith('/')).to.equal(true);
});
it('has proper sources for array of images', () => {
@ -310,7 +336,7 @@ describe('astro:image', () => {
const imgsSrcs = [];
$img.each((i, img) => imgsSrcs.push(img.attribs['src']));
expect($img).to.have.a.lengthOf(2);
expect(imgsSrcs.every((img) => img.startsWith('/src/'))).to.be.true;
expect(imgsSrcs.every((img) => img.startsWith('/'))).to.be.true;
});
it('has proper attributes for optimized image through getImage', () => {
@ -330,7 +356,7 @@ describe('astro:image', () => {
it('properly handles nested images', () => {
let $img = $('#nested-image img');
expect($img.attr('src').startsWith('/src/')).to.equal(true);
expect($img.attr('src').startsWith('/')).to.equal(true);
});
});
@ -348,7 +374,7 @@ describe('astro:image', () => {
});
it('includes /src in the path', async () => {
expect($('img').attr('src').startsWith('/src')).to.equal(true);
expect($('img').attr('src').includes('/src')).to.equal(true);
});
});
});

View file

@ -0,0 +1,11 @@
<!-- This file will be used as client:only and SSR on two different pages -->
<div class="svelte-only-and-ssr">
Svelte only and SSR
</div>
<style>
.svelte-only-and-ssr {
background-color: green;
}
</style>

View file

@ -0,0 +1,7 @@
---
import SvelteOnlyAndSsr from './_components/SvelteOnlyAndSsr.svelte'
---
<div>
<SvelteOnlyAndSsr client:only />
</div>

View file

@ -0,0 +1,7 @@
---
import SvelteOnlyAndSsr from './_components/SvelteOnlyAndSsr.svelte'
---
<div>
<SvelteOnlyAndSsr />
</div>

View file

@ -2,7 +2,6 @@ import { APIRoute } from "../../../../../src/@types/astro";
export const get = (async ({ params, request }) => {
const url = new URL(request.url);
console.log(url)
const src = url.searchParams.get("src");
return {

View file

@ -0,0 +1,6 @@
---
import { Image } from "astro:assets";
import myImage from "../assets/penguin1.jpg";
---
<Image src={myImage.src} alt="hello"/>

View file

@ -0,0 +1,8 @@
---
import { Image } from "astro:assets";
import imageOutsideProject from "../../../core-image-base/src/assets/penguin1.jpg";
---
<Image src={imageOutsideProject} alt="outside project" />
<img src={imageOutsideProject.src} />

View file

@ -0,0 +1,282 @@
// @ts-check
import { createFs, createRequestAndResponse } from '../test-utils.js';
import { createRouteManifest, matchAllRoutes } from '../../../dist/core/routing/index.js';
import { fileURLToPath } from 'url';
import { defaultLogging } from '../../test-utils.js';
import { createViteLoader } from '../../../dist/core/module-loader/vite.js';
import { createDevelopmentEnvironment } from '../../../dist/core/render/dev/environment.js';
import { expect } from 'chai';
import { createContainer } from '../../../dist/core/dev/container.js';
import * as cheerio from 'cheerio';
import testAdapter from '../../test-adapter.js';
import { getSortedPreloadedMatches } from '../../../dist/prerender/routing.js';
const root = new URL('../../fixtures/alias/', import.meta.url);
const fileSystem = {
'/src/pages/[serverDynamic].astro': `
---
export const prerender = false;
---
<p>Server dynamic route! slug:{Astro.params.serverDynamic}</p>
`,
'/src/pages/[xStaticDynamic].astro': `
---
export function getStaticPaths() {
return [
{
params: {
xStaticDynamic: "static-dynamic-route-here",
},
},
];
}
---
<p>Prerendered dynamic route!</p>
`,
'/src/pages/[aStaticDynamic].astro': `
---
export function getStaticPaths() {
return [
{
params: {
aStaticDynamic: "another-static-dynamic-route-here",
},
},
];
}
---
<p>Another prerendered dynamic route!</p>
`,
'/src/pages/[...serverRest].astro': `
---
export const prerender = false;
---
<p>Server rest route! slug:{Astro.params.serverRest}</p>
`,
'/src/pages/[...xStaticRest].astro': `
---
export function getStaticPaths() {
return [
{
params: {
xStaticRest: undefined,
},
},
];
}
---
<p>Prerendered rest route!</p>
`,
'/src/pages/[...aStaticRest].astro': `
---
export function getStaticPaths() {
return [
{
params: {
aStaticRest: "another/static-rest-route-here",
},
},
];
}
---
<p>Another prerendered rest route!</p>
`,
'/src/pages/nested/[...serverRest].astro': `
---
export const prerender = false;
---
<p>Nested server rest route! slug: {Astro.params.serverRest}</p>
`,
'/src/pages/nested/[...xStaticRest].astro': `
---
export function getStaticPaths() {
return [
{
params: {
xStaticRest: undefined,
},
},
];
}
---
<p>Nested prerendered rest route!</p>
`,
'/src/pages/nested/[...aStaticRest].astro': `
---
export function getStaticPaths() {
return [
{
params: {
aStaticRest: "another-nested-static-dynamic-rest-route-here",
},
},
];
}
---
<p>Another nested prerendered rest route!</p>
`,
};
describe('Route matching', () => {
let env;
let manifest;
let container;
let settings;
before(async () => {
const fs = createFs(fileSystem, root);
container = await createContainer({
fs,
root,
userConfig: {
trailingSlash: 'never',
output: 'hybrid',
experimental: {
hybridOutput: true,
},
adapter: testAdapter(),
},
disableTelemetry: true,
});
settings = container.settings;
const loader = createViteLoader(container.viteServer);
env = createDevelopmentEnvironment(container.settings, defaultLogging, loader);
manifest = createRouteManifest(
{
cwd: fileURLToPath(root),
settings,
fsMod: fs,
},
defaultLogging
);
});
after(async () => {
await container.close();
});
describe('Matched routes', () => {
it('should be sorted correctly', async () => {
const matches = matchAllRoutes('/try-matching-a-route', manifest);
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
const sortedRouteNames = preloadedMatches.map((match) => match.route.route);
expect(sortedRouteNames).to.deep.equal([
'/[astaticdynamic]',
'/[xstaticdynamic]',
'/[serverdynamic]',
'/[...astaticrest]',
'/[...xstaticrest]',
'/[...serverrest]',
]);
});
it('nested should be sorted correctly', async () => {
const matches = matchAllRoutes('/nested/try-matching-a-route', manifest);
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
const sortedRouteNames = preloadedMatches.map((match) => match.route.route);
expect(sortedRouteNames).to.deep.equal([
'/nested/[...astaticrest]',
'/nested/[...xstaticrest]',
'/nested/[...serverrest]',
'/[...astaticrest]',
'/[...xstaticrest]',
'/[...serverrest]',
]);
});
});
describe('Request', () => {
it('should correctly match a static dynamic route I', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/static-dynamic-route-here',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Prerendered dynamic route!');
});
it('should correctly match a static dynamic route II', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/another-static-dynamic-route-here',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Another prerendered dynamic route!');
});
it('should correctly match a server dynamic route', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/a-random-slug-was-matched',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Server dynamic route! slug:a-random-slug-was-matched');
});
it('should correctly match a static rest route I', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Prerendered rest route!');
});
it('should correctly match a static rest route II', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/another/static-rest-route-here',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Another prerendered rest route!');
});
it('should correctly match a nested static rest route index', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/nested',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Nested prerendered rest route!');
});
it('should correctly match a nested static rest route', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/nested/another-nested-static-dynamic-rest-route-here',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Another nested prerendered rest route!');
});
it('should correctly match a nested server rest route', async () => {
const { req, res, text } = createRequestAndResponse({
method: 'GET',
url: '/nested/a-random-slug-was-matched',
});
container.handle(req, res);
const html = await text();
const $ = cheerio.load(html);
expect($('p').text()).to.equal('Nested server rest route! slug: a-random-slug-was-matched');
});
});
});

View file

@ -0,0 +1,18 @@
import { expect } from 'chai';
import { createAstro } from '../../../dist/runtime/server/index.js';
describe('astro global', () => {
it('Glob should error if passed incorrect value', async () => {
const Astro = createAstro(undefined);
expect(() => {
Astro.glob('./**/*.md');
}).to.throw(/can only be used in/);
});
it('Glob should error if has no results', async () => {
const Astro = createAstro(undefined);
expect(() => {
Astro.glob([], () => './**/*.md');
}).to.throw(/did not return any matching files/);
});
});

View file

@ -203,6 +203,54 @@ export default defineMarkdocConfig({
})
```
### Syntax highlighting
`@astrojs/markdoc` provides [Shiki](https://github.com/shikijs/shiki) and [Prism](https://github.com/PrismJS) extensions to highlight your code blocks.
#### Shiki
Apply the `shiki()` extension to your Markdoc config using the `extends` property. You can optionally pass a shiki configuration object:
```js
// markdoc.config.mjs
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import shiki from '@astrojs/markdoc/shiki';
export default defineMarkdocConfig({
extends: [
shiki({
// Choose from Shiki's built-in themes (or add your own)
// Default: 'github-dark'
// https://github.com/shikijs/shiki/blob/main/docs/themes.md
theme: 'dracula',
// Enable word wrap to prevent horizontal scrolling
// Default: false
wrap: true,
// Pass custom languages
// Note: Shiki has countless langs built-in, including `.astro`!
// https://github.com/shikijs/shiki/blob/main/docs/languages.md
langs: [],
})
],
})
```
#### Prism
Apply the `prism()` extension to your Markdoc config using the `extends` property.
```js
// markdoc.config.mjs
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import prism from '@astrojs/markdoc/prism';
export default defineMarkdocConfig({
extends: [prism()],
})
```
📚 To learn about configuring Prism stylesheets, [see our syntax highlighting guide.](https://docs.astro.build/en/guides/markdown-content/#prism-configuration)
### Access frontmatter and content collection information from your templates
You can access content collection information from your Markdoc templates using the `$entry` variable. This includes the entry `slug`, `collection` name, and frontmatter `data` parsed by your content collection schema (if any). This example renders the `title` frontmatter property as a heading:

View file

@ -2,12 +2,18 @@ import type { AstroInstance } from 'astro';
import { Fragment } from 'astro/jsx-runtime';
import type { RenderableTreeNode } from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js';
import {
createComponent,
renderComponent,
render,
HTMLString,
isHTMLString,
} from 'astro/runtime/server/index.js';
export type TreeNode =
| {
type: 'text';
content: string;
content: string | HTMLString;
}
| {
type: 'component';
@ -25,6 +31,7 @@ export type TreeNode =
export const ComponentNode = createComponent({
factory(result: any, { treeNode }: { treeNode: TreeNode }) {
if (treeNode.type === 'text') return render`${treeNode.content}`;
const slots = {
default: () =>
render`${treeNode.children.map((child) =>
@ -46,7 +53,9 @@ export const ComponentNode = createComponent({
});
export function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): TreeNode {
if (typeof node === 'string' || typeof node === 'number') {
if (isHTMLString(node)) {
return { type: 'text', content: node as HTMLString };
} else if (typeof node === 'string' || typeof node === 'number') {
return { type: 'text', content: String(node) };
} else if (Array.isArray(node)) {
return {

View file

@ -1,6 +1,6 @@
{
"name": "@astrojs/markdoc",
"description": "Add support for Markdoc pages in your Astro site",
"description": "Add support for Markdoc in your Astro site",
"version": "0.2.3",
"type": "module",
"types": "./dist/index.d.ts",
@ -19,6 +19,8 @@
"bugs": "https://github.com/withastro/astro/issues",
"homepage": "https://docs.astro.build/en/guides/integrations-guide/markdoc/",
"exports": {
"./prism": "./dist/extensions/prism.js",
"./shiki": "./dist/extensions/shiki.js",
".": "./dist/index.js",
"./components": "./components/index.ts",
"./runtime": "./dist/runtime.js",
@ -39,7 +41,9 @@
"test:match": "mocha --timeout 20000 -g"
},
"dependencies": {
"@markdoc/markdoc": "^0.2.2",
"shiki": "^0.14.1",
"@astrojs/prism": "^2.1.2",
"@markdoc/markdoc": "^0.3.0",
"esbuild": "^0.17.12",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",

View file

@ -1,10 +1,18 @@
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
import _Markdoc from '@markdoc/markdoc';
import { nodes as astroNodes } from './nodes/index.js';
import { heading } from './heading-ids.js';
export type AstroMarkdocConfig<C extends Record<string, any> = Record<string, any>> =
MarkdocConfig & {
ctx?: C;
extends?: ResolvedAstroMarkdocConfig[];
};
export type ResolvedAstroMarkdocConfig = Omit<AstroMarkdocConfig, 'extends'>;
export const Markdoc = _Markdoc;
export const nodes = { ...Markdoc.nodes, ...astroNodes };
export const nodes = { ...Markdoc.nodes, heading };
export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
export function defineMarkdocConfig(config: AstroMarkdocConfig): AstroMarkdocConfig {
return config;
}

View file

@ -0,0 +1,24 @@
// leave space, so organize imports doesn't mess up comments
// @ts-expect-error Cannot find module 'astro/runtime/server/index.js' or its corresponding type declarations.
import { unescapeHTML } from 'astro/runtime/server/index.js';
import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter';
import { Markdoc, type AstroMarkdocConfig } from '../config.js';
export default function prism(): AstroMarkdocConfig {
return {
nodes: {
fence: {
attributes: Markdoc.nodes.fence.attributes!,
transform({ attributes: { language, content } }) {
const { html, classLanguage } = runHighlighterWithAstro(language, content);
// Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML
return unescapeHTML(
`<pre class="${classLanguage}"><code class="${classLanguage}">${html}</code></pre>`
);
},
},
},
};
}

View file

@ -0,0 +1,132 @@
// leave space, so organize imports doesn't mess up comments
// @ts-expect-error Cannot find module 'astro/runtime/server/index.js' or its corresponding type declarations.
import { unescapeHTML } from 'astro/runtime/server/index.js';
import Markdoc from '@markdoc/markdoc';
import type { ShikiConfig } from 'astro';
import type * as shikiTypes from 'shiki';
import { getHighlighter } from 'shiki';
import type { AstroMarkdocConfig } from '../config.js';
// Map of old theme names to new names to preserve compatibility when we upgrade shiki
const compatThemes: Record<string, string> = {
'material-darker': 'material-theme-darker',
'material-default': 'material-theme',
'material-lighter': 'material-theme-lighter',
'material-ocean': 'material-theme-ocean',
'material-palenight': 'material-theme-palenight',
};
const normalizeTheme = (theme: string | shikiTypes.IShikiTheme) => {
if (typeof theme === 'string') {
return compatThemes[theme] || theme;
} else if (compatThemes[theme.name]) {
return { ...theme, name: compatThemes[theme.name] };
} else {
return theme;
}
};
const ASTRO_COLOR_REPLACEMENTS = {
'#000001': 'var(--astro-code-color-text)',
'#000002': 'var(--astro-code-color-background)',
'#000004': 'var(--astro-code-token-constant)',
'#000005': 'var(--astro-code-token-string)',
'#000006': 'var(--astro-code-token-comment)',
'#000007': 'var(--astro-code-token-keyword)',
'#000008': 'var(--astro-code-token-parameter)',
'#000009': 'var(--astro-code-token-function)',
'#000010': 'var(--astro-code-token-string-expression)',
'#000011': 'var(--astro-code-token-punctuation)',
'#000012': 'var(--astro-code-token-link)',
};
const PRE_SELECTOR = /<pre class="(.*?)shiki(.*?)"/;
const LINE_SELECTOR = /<span class="line"><span style="(.*?)">([\+|\-])/g;
const INLINE_STYLE_SELECTOR = /style="(.*?)"/;
/**
* Note: cache only needed for dev server reloads, internal test suites, and manual calls to `Markdoc.transform` by the user.
* Otherwise, `shiki()` is only called once per build, NOT once per page, so a cache isn't needed!
*/
const highlighterCache = new Map<string, shikiTypes.Highlighter>();
export default async function shiki({
langs = [],
theme = 'github-dark',
wrap = false,
}: ShikiConfig = {}): Promise<AstroMarkdocConfig> {
theme = normalizeTheme(theme);
const cacheID: string = typeof theme === 'string' ? theme : theme.name;
if (!highlighterCache.has(cacheID)) {
highlighterCache.set(
cacheID,
await getHighlighter({ theme }).then((hl) => {
hl.setColorReplacements(ASTRO_COLOR_REPLACEMENTS);
return hl;
})
);
}
const highlighter = highlighterCache.get(cacheID)!;
for (const lang of langs) {
await highlighter.loadLanguage(lang);
}
return {
nodes: {
fence: {
attributes: Markdoc.nodes.fence.attributes!,
transform({ attributes }) {
let lang: string;
if (typeof attributes.language === 'string') {
const langExists = highlighter
.getLoadedLanguages()
.includes(attributes.language as any);
if (langExists) {
lang = attributes.language;
} else {
// eslint-disable-next-line no-console
console.warn(
`[Shiki highlighter] The language "${attributes.language}" doesn't exist, falling back to plaintext.`
);
lang = 'plaintext';
}
} else {
lang = 'plaintext';
}
let html = highlighter.codeToHtml(attributes.content, { lang });
// Q: Could these regexes match on a user's inputted code blocks?
// A: Nope! All rendered HTML is properly escaped.
// Ex. If a user typed `<span class="line"` into a code block,
// It would become this before hitting our regexes:
// &lt;span class=&quot;line&quot;
html = html.replace(PRE_SELECTOR, `<pre class="$1astro-code$2"`);
// Add "user-select: none;" for "+"/"-" diff symbols
if (attributes.language === 'diff') {
html = html.replace(
LINE_SELECTOR,
'<span class="line"><span style="$1"><span style="user-select: none;">$2</span>'
);
}
if (wrap === false) {
html = html.replace(INLINE_STYLE_SELECTOR, 'style="$1; overflow-x: auto;"');
} else if (wrap === true) {
html = html.replace(
INLINE_STYLE_SELECTOR,
'style="$1; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"'
);
}
// Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML
return unescapeHTML(html);
},
},
},
};
}

View file

@ -1,13 +1,8 @@
import Markdoc, { type ConfigType, type RenderableTreeNode, type Schema } from '@markdoc/markdoc';
import Markdoc, { type RenderableTreeNode, type Schema } from '@markdoc/markdoc';
import Slugger from 'github-slugger';
import { getTextContent } from '../runtime.js';
type ConfigTypeWithCtx = ConfigType & {
// TODO: decide on `ctx` as a convention for config merging
ctx: {
headingSlugger: Slugger;
};
};
import type { AstroMarkdocConfig } from './config.js';
import { getTextContent } from './runtime.js';
import { MarkdocError } from './utils.js';
function getSlug(
attributes: Record<string, any>,
@ -24,16 +19,31 @@ function getSlug(
return slug;
}
type HeadingIdConfig = AstroMarkdocConfig<{
headingSlugger: Slugger;
}>;
/*
Expose standalone node for users to import in their config.
Allows users to apply a custom `render: AstroComponent`
and spread our default heading attributes.
*/
export const heading: Schema = {
children: ['inline'],
attributes: {
id: { type: String },
level: { type: Number, required: true, default: 1 },
},
transform(node, config: ConfigTypeWithCtx) {
transform(node, config: HeadingIdConfig) {
const { level, ...attributes } = node.transformAttributes(config);
const children = node.transformChildren(config);
if (!config.ctx?.headingSlugger) {
throw new MarkdocError({
message:
'Unexpected problem adding heading IDs to Markdoc file. Did you modify the `ctx.headingSlugger` property in your Markdoc config?',
});
}
const slug = getSlug(attributes, children, config.ctx.headingSlugger);
const render = config.nodes?.heading?.render ?? `h${level}`;
@ -49,9 +59,9 @@ export const heading: Schema = {
},
};
export function setupHeadingConfig(): ConfigTypeWithCtx {
// Called internally to ensure `ctx` is generated per-file, instead of per-build.
export function setupHeadingConfig(): HeadingIdConfig {
const headingSlugger = new Slugger();
return {
ctx: {
headingSlugger,

View file

@ -32,7 +32,19 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
name: '@astrojs/markdoc',
hooks: {
'astro:config:setup': async (params) => {
const { config: astroConfig, addContentEntryType } = params as SetupHookParams;
const {
config: astroConfig,
updateConfig,
addContentEntryType,
} = params as SetupHookParams;
updateConfig({
vite: {
ssr: {
external: ['@astrojs/markdoc/prism', '@astrojs/markdoc/shiki'],
},
},
});
markdocConfigResult = await loadMarkdocConfig(astroConfig);
const userMarkdocConfig = markdocConfigResult?.config ?? {};
@ -52,7 +64,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
async getRenderModule({ entry, viteId }) {
const ast = Markdoc.parse(entry.body);
const pluginContext = this;
const markdocConfig = setupConfig(userMarkdocConfig, entry);
const markdocConfig = await setupConfig(userMarkdocConfig, entry);
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
return (
@ -90,7 +102,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
const res = `import { jsx as h } from 'astro/jsx-runtime';
import { Renderer } from '@astrojs/markdoc/components';
import { collectHeadings, setupConfig, Markdoc } from '@astrojs/markdoc/runtime';
import { collectHeadings, setupConfig, setupConfigSync, Markdoc } from '@astrojs/markdoc/runtime';
import * as entry from ${JSON.stringify(viteId + '?astroContentCollectionEntry')};
${
markdocConfigResult
@ -114,13 +126,13 @@ export function getHeadings() {
''
}
const headingConfig = userConfig.nodes?.heading;
const config = setupConfig(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry);
const config = setupConfigSync(headingConfig ? { nodes: { heading: headingConfig } } : {}, entry);
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast, config);
return collectHeadings(Array.isArray(content) ? content : content.children);
}
export async function Content (props) {
const config = setupConfig({
const config = await setupConfig({
...userConfig,
variables: { ...userConfig.variables, ...props },
}, entry);

View file

@ -1,4 +0,0 @@
import { heading } from './heading.js';
export { setupHeadingConfig } from './heading.js';
export const nodes = { heading };

View file

@ -1,32 +1,61 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
import Markdoc, {
type ConfigType as MarkdocConfig,
type RenderableTreeNode,
} from '@markdoc/markdoc';
import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc';
import type { ContentEntryModule } from 'astro';
import { setupHeadingConfig } from './nodes/index.js';
import type { AstroMarkdocConfig } from './config.js';
import { setupHeadingConfig } from './heading-ids.js';
/** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
export { default as Markdoc } from '@markdoc/markdoc';
/**
* Merge user config with default config and set up context (ex. heading ID slugger)
* Called on each file's individual transform
* Called on each file's individual transform.
* TODO: virtual module to merge configs per-build instead of per-file?
*/
export function setupConfig(userConfig: MarkdocConfig, entry: ContentEntryModule): MarkdocConfig {
const defaultConfig: MarkdocConfig = {
// `setupXConfig()` could become a "plugin" convention as well?
export async function setupConfig(
userConfig: AstroMarkdocConfig,
entry: ContentEntryModule
): Promise<Omit<AstroMarkdocConfig, 'extends'>> {
let defaultConfig: AstroMarkdocConfig = {
...setupHeadingConfig(),
variables: { entry },
};
if (userConfig.extends) {
for (let extension of userConfig.extends) {
if (extension instanceof Promise) {
extension = await extension;
}
defaultConfig = mergeConfig(defaultConfig, extension);
}
}
return mergeConfig(defaultConfig, userConfig);
}
/** Used for synchronous `getHeadings()` function */
export function setupConfigSync(
userConfig: AstroMarkdocConfig,
entry: ContentEntryModule
): Omit<AstroMarkdocConfig, 'extends'> {
let defaultConfig: AstroMarkdocConfig = {
...setupHeadingConfig(),
variables: { entry },
};
return mergeConfig(defaultConfig, userConfig);
}
/** Merge function from `@markdoc/markdoc` internals */
function mergeConfig(configA: MarkdocConfig, configB: MarkdocConfig): MarkdocConfig {
function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig): AstroMarkdocConfig {
return {
...configA,
...configB,
ctx: {
...configA.ctx,
...configB.ctx,
},
tags: {
...configA.tags,
...configB.tags,

View file

@ -35,8 +35,8 @@ describe('Markdoc - Image assets', () => {
const res = await baseFixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
expect(document.querySelector('#relative > img')?.src).to.equal(
'/_image?href=%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp'
expect(document.querySelector('#relative > img')?.src).to.match(
/\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp/
);
});
@ -44,8 +44,8 @@ describe('Markdoc - Image assets', () => {
const res = await baseFixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
expect(document.querySelector('#alias > img')?.src).to.equal(
'/_image?href=%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp'
expect(document.querySelector('#alias > img')?.src).to.match(
/\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp/
);
});
});

View file

@ -0,0 +1,114 @@
import { parseHTML } from 'linkedom';
import { expect } from 'chai';
import Markdoc from '@markdoc/markdoc';
import shiki from '../dist/extensions/shiki.js';
import prism from '../dist/extensions/prism.js';
import { setupConfig } from '../dist/runtime.js';
import { isHTMLString } from 'astro/runtime/server/index.js';
const entry = `
\`\`\`ts
const highlighting = true;
\`\`\`
\`\`\`css
.highlighting {
color: red;
}
\`\`\`
`;
describe('Markdoc - syntax highlighting', () => {
describe('shiki', () => {
it('transforms with defaults', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(ast, await getConfigExtendingShiki());
expect(content.children).to.have.lengthOf(2);
for (const codeBlock of content.children) {
expect(isHTMLString(codeBlock)).to.be.true;
const pre = parsePreTag(codeBlock);
expect(pre.classList).to.include('astro-code');
expect(pre.classList).to.include('github-dark');
}
});
it('transforms with `theme` property', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(
ast,
await getConfigExtendingShiki({
theme: 'dracula',
})
);
expect(content.children).to.have.lengthOf(2);
for (const codeBlock of content.children) {
expect(isHTMLString(codeBlock)).to.be.true;
const pre = parsePreTag(codeBlock);
expect(pre.classList).to.include('astro-code');
expect(pre.classList).to.include('dracula');
}
});
it('transforms with `wrap` property', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(
ast,
await getConfigExtendingShiki({
wrap: true,
})
);
expect(content.children).to.have.lengthOf(2);
for (const codeBlock of content.children) {
expect(isHTMLString(codeBlock)).to.be.true;
const pre = parsePreTag(codeBlock);
expect(pre.getAttribute('style')).to.include('white-space: pre-wrap');
expect(pre.getAttribute('style')).to.include('word-wrap: break-word');
}
});
});
describe('prism', () => {
it('transforms', async () => {
const ast = Markdoc.parse(entry);
const config = await setupConfig({
extends: [prism()],
});
const content = Markdoc.transform(ast, config);
expect(content.children).to.have.lengthOf(2);
const [tsBlock, cssBlock] = content.children;
expect(isHTMLString(tsBlock)).to.be.true;
expect(isHTMLString(cssBlock)).to.be.true;
const preTs = parsePreTag(tsBlock);
expect(preTs.classList).to.include('language-ts');
const preCss = parsePreTag(cssBlock);
expect(preCss.classList).to.include('language-css');
});
});
});
/**
* @param {import('astro').ShikiConfig} config
* @returns {import('../src/config.js').AstroMarkdocConfig}
*/
async function getConfigExtendingShiki(config) {
return await setupConfig({
extends: [shiki(config)],
});
}
/**
* @param {string} html
* @returns {HTMLPreElement}
*/
function parsePreTag(html) {
const { document } = parseHTML(html);
const pre = document.querySelector('pre');
expect(pre).to.exist;
return pre;
}

View file

@ -83,6 +83,7 @@ You can configure how your MDX is rendered with the following options:
- [Options inherited from Markdown config](#options-inherited-from-markdown-config)
- [`extendMarkdownConfig`](#extendmarkdownconfig)
- [`recmaPlugins`](#recmaplugins)
- [`optimize`](#optimize)
### Options inherited from Markdown config
@ -183,6 +184,71 @@ These are plugins that modify the output [estree](https://github.com/estree/estr
We suggest [using AST Explorer](https://astexplorer.net/) to play with estree outputs, and trying [`estree-util-visit`](https://unifiedjs.com/explore/package/estree-util-visit/) for searching across JavaScript nodes.
### `optimize`
- **Type:** `boolean | { customComponentNames?: string[] }`
This is an optional configuration setting to optimize the MDX output for faster builds and rendering via an internal rehype plugin. This may be useful if you have many MDX files and notice slow builds. However, this option may generate some unescaped HTML, so make sure your site's interactive parts still work correctly after enabling it.
This is disabled by default. To enable MDX optimization, add the following to your MDX integration configuration:
__`astro.config.mjs`__
```js
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [
mdx({
optimize: true,
})
]
});
```
#### `customComponentNames`
- **Type:** `string[]`
An optional property of `optimize` to prevent the MDX optimizer from handling any [custom components passed to imported MDX content via the components prop](https://docs.astro.build/en/guides/markdown-content/#custom-components-with-imported-mdx).
You will need to exclude these components from optimization as the optimizer eagerly converts content into a static string, which will break custom components that needs to be dynamically rendered.
For example, the intended MDX output of the following is `<Heading>...</Heading>` in place of every `"<h1>...</h1>"`:
```astro
---
import { Content, components } from '../content.mdx';
import Heading from '../Heading.astro';
---
<Content components={{...components, h1: Heading }} />
```
To configure optimization for this using the `customComponentNames` property, specify an array of HTML element names that should be treated as custom components:
__`astro.config.mjs`__
```js
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [
mdx({
optimize: {
// Prevent the optimizer from handling `h1` elements
// These will be treated as custom components
customComponentNames: ['h1'],
},
})
]
});
```
Note that if your MDX file [configures custom components using `export const components = { ... }`](https://docs.astro.build/en/guides/markdown-content/#assigning-custom-components-to-html-elements), then you do not need to manually configure this option. The optimizer will automatically detect them.
## Examples
* The [Astro MDX starter template](https://github.com/withastro/astro/tree/latest/examples/with-mdx) shows how to use MDX files in your Astro project.

View file

@ -42,6 +42,7 @@
"estree-util-visit": "^1.2.0",
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.3",
"hast-util-to-html": "^8.0.4",
"kleur": "^4.1.4",
"rehype-raw": "^6.1.1",
"remark-frontmatter": "^4.0.1",

View file

@ -0,0 +1,107 @@
# Internal documentation
## rehype-optimize-static
The `rehype-optimize-static` plugin helps optimize the intermediate [`hast`](https://github.com/syntax-tree/hast) when processing MDX, collapsing static subtrees of the `hast` as a `"static string"` in the final JSX output. Here's a "before" and "after" result:
Before:
```jsx
function _createMdxContent() {
return (
<>
<h1>My MDX Content</h1>
<pre>
<code class="language-js">
<span class="token function">console</span>
<span class="token punctuation">.</span>
<span class="token function">log</span>
<span class="token punctuation">(</span>
<span class="token string">'hello world'</span>
<span class="token punctuation">)</span>
</code>
</pre>
</>
);
}
```
After:
```jsx
function _createMdxContent() {
return (
<>
<h1>My MDX Content</h1>
<pre set:html="<code class=...</code>"></pre>
</>
);
}
```
> NOTE: If one of the nodes in `pre` is MDX, the optimization will not be applied to `pre`, but could be applied to the inner MDX node if its children are static.
This results in fewer JSX nodes, less compiled JS output, and less parsed AST, which results in faster Rollup builds and runtime rendering.
To acheive this, we use an algorithm to detect `hast` subtrees that are entirely static (containing no JSX) to be inlined as `set:html` to the root of the subtree.
The next section explains the algorithm, which you can follow along by pairing with the [source code](./rehype-optimize-static.ts). To analyze the `hast`, you can paste the MDX code into https://mdxjs.com/playground.
### How it works
Two variables:
- `allPossibleElements`: A set of subtree roots where we can add a new `set:html` property with its children as value.
- `elementStack`: The stack of elements (that could be subtree roots) while traversing the `hast` (node ancestors).
Flow:
1. Walk the `hast` tree.
2. For each `node` we enter, if the `node` is static (`type` is `element` or `mdxJsxFlowElement`), record in `allPossibleElements` and push to `elementStack`.
- Q: Why do we record `mdxJsxFlowElement`, it's MDX? <br>
A: Because we're looking for nodes whose children are static. The node itself doesn't need to be static.
- Q: Are we sure this is the subtree root node in `allPossibleElements`? <br>
A: No, but we'll clear that up later in step 3.
3. For each `node` we leave, pop from `elementStack`. If the `node`'s parent is in `allPossibleElements`, we also remove the `node` from `allPossibleElements`.
- Q: Why do we check for the node's parent? <br>
A: Checking for the node's parent allows us to identify a subtree root. When we enter a subtree like `C -> D -> E`, we leave in reverse: `E -> D -> C`. When we leave `E`, we see that it's parent `D` exists, so we remove `E`. When we leave `D`, we see `C` exists, so we remove `D`. When we leave `C`, we see that its parent doesn't exist, so we keep `C`, a subtree root.
4. _(Returning to the code written for step 2's `node` enter handling)_ We also need to handle the case where we find non-static elements. If found, we remove all the elements in `elementStack` from `allPossibleElements`. This happens before the code in step 2.
- Q: Why? <br>
A: Because if the `node` isn't static, that means all its ancestors (`elementStack`) have non-static children. So, the ancestors couldn't be a subtree root to be optimized anymore.
- Q: Why before step 2's `node` enter handling? <br>
A: If we find a non-static `node`, the `node` should still be considered in `allPossibleElements` as its children could be static.
5. Walk done. This leaves us with `allPossibleElements` containing only subtree roots that can be optimized.
6. Add the `set:html` property to the `hast` node, and remove its children.
7. 🎉 The rest of the MDX pipeline will do its thing and generate the desired JSX like above.
### Extra
#### MDX custom components
Astro's MDX implementation supports specifying `export const components` in the MDX file to render some HTML elements as Astro components or framework components. `rehype-optimize-static` also needs to parse this JS to recognize some elements as non-static.
#### Further optimizations
In [How it works](#how-it-works) step 4,
> we remove all the elements in `elementStack` from `allPossibleElements`
We can further optimize this by then also emptying the `elementStack`. This ensures that if we run this same flow for a deeper node in the tree, we don't remove the already-removed nodes from `allPossibleElements`.
While this breaks the concept of `elementStack`, it doesn't matter as the `elementStack` array pop in the "leave" handler (in step 3) would become a no-op.
Example `elementStack` value during walking phase:
```
Enter: A
Enter: A, B
Enter: A, B, C
(Non-static node found): <empty>
Enter: D
Enter: D, E
Leave: D
Leave: <empty>
Leave: <empty>
Leave: <empty>
Leave: <empty>
```

View file

@ -11,6 +11,7 @@ import { SourceMapGenerator } from 'source-map';
import { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite';
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
import type { OptimizeOptions } from './rehype-optimize-static.js';
import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js';
export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
@ -21,6 +22,7 @@ export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | '
remarkPlugins: PluggableList;
rehypePlugins: PluggableList;
remarkRehype: RemarkRehypeOptions;
optimize: boolean | OptimizeOptions;
};
type SetupHookParams = HookParameters<'astro:config:setup'> & {
@ -194,6 +196,7 @@ function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefault
remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins),
rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins),
remarkRehype: (markdownConfig.remarkRehype as any) ?? {},
optimize: false,
};
}
@ -214,6 +217,7 @@ function applyDefaultOptions({
remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins,
rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins,
shikiConfig: options.shikiConfig ?? defaults.shikiConfig,
optimize: options.optimize ?? defaults.optimize,
};
}

View file

@ -15,6 +15,7 @@ import type { VFile } from 'vfile';
import type { MdxOptions } from './index.js';
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
import rehypeMetaString from './rehype-meta-string.js';
import { rehypeOptimizeStatic } from './rehype-optimize-static.js';
import { remarkImageToComponent } from './remark-images-to-component.js';
import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.js';
@ -144,6 +145,13 @@ export function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
// computed from `astro.data.frontmatter` in VFile data
rehypeApplyFrontmatterExport,
];
if (mdxOptions.optimize) {
// Convert user `optimize` option to compatible `rehypeOptimizeStatic` option
const options = mdxOptions.optimize === true ? undefined : mdxOptions.optimize;
rehypePlugins.push([rehypeOptimizeStatic, options]);
}
return rehypePlugins;
}

View file

@ -0,0 +1,105 @@
import { visit } from 'estree-util-visit';
import { toHtml } from 'hast-util-to-html';
// accessing untyped hast and mdx types
type Node = any;
export interface OptimizeOptions {
customComponentNames?: string[];
}
const exportConstComponentsRe = /export\s+const\s+components\s*=/;
/**
* For MDX only, collapse static subtrees of the hast into `set:html`. Subtrees
* do not include any MDX elements.
*
* This optimization reduces the JS output as more content are represented as a
* string instead, which also reduces the AST size that Rollup holds in memory.
*/
export function rehypeOptimizeStatic(options?: OptimizeOptions) {
return (tree: any) => {
// A set of non-static components to avoid collapsing when walking the tree
// as they need to be preserved as JSX to be rendered dynamically.
const customComponentNames = new Set<string>(options?.customComponentNames);
// Find `export const components = { ... }` and get it's object's keys to be
// populated into `customComponentNames`. This configuration is used to render
// some HTML elements as custom components, and we also want to avoid collapsing them.
for (const child of tree.children) {
if (child.type === 'mdxjsEsm' && exportConstComponentsRe.test(child.value)) {
// Try to loosely get the object property nodes
const objectPropertyNodes = child.data.estree.body[0]?.declarations?.[0]?.init?.properties;
if (objectPropertyNodes) {
for (const objectPropertyNode of objectPropertyNodes) {
const componentName = objectPropertyNode.key?.name ?? objectPropertyNode.key?.value;
if (componentName) {
customComponentNames.add(componentName);
}
}
}
}
}
// All possible elements that could be the root of a subtree
const allPossibleElements = new Set<Node>();
// The current collapsible element stack while traversing the tree
const elementStack: Node[] = [];
visit(tree, {
enter(node) {
// @ts-expect-error read tagName naively
const isCustomComponent = node.tagName && customComponentNames.has(node.tagName);
// For nodes that can't be optimized, eliminate all elements in the
// `elementStack` from the `allPossibleElements` set.
if (node.type.startsWith('mdx') || isCustomComponent) {
for (const el of elementStack) {
allPossibleElements.delete(el);
}
// Micro-optimization: While this destroys the meaning of an element
// stack for this node, things will still work but we won't repeatedly
// run the above for other nodes anymore. If this is confusing, you can
// comment out the code below when reading.
elementStack.length = 0;
}
// For possible subtree root nodes, record them in `elementStack` and
// `allPossibleElements` to be used in the "leave" hook below.
if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
elementStack.push(node);
allPossibleElements.add(node);
}
},
leave(node, _, __, parents) {
// Do the reverse of the if condition above, popping the `elementStack`,
// and consolidating `allPossibleElements` as a subtree root.
if (node.type === 'element' || node.type === 'mdxJsxFlowElement') {
elementStack.pop();
// Many possible elements could be part of a subtree, in order to find
// the root, we check the parent of the element we're popping. If the
// parent exists in `allPossibleElements`, then we're definitely not
// the root, so remove ourselves. This will work retroactively as we
// climb back up the tree.
const parent = parents[parents.length - 1];
if (allPossibleElements.has(parent)) {
allPossibleElements.delete(node);
}
}
},
});
// For all possible subtree roots, collapse them into `set:html` and
// strip of their children
for (const el of allPossibleElements) {
if (el.type === 'mdxJsxFlowElement') {
el.attributes.push({
type: 'mdxJsxAttribute',
name: 'set:html',
value: toHtml(el.children),
});
} else {
el.properties['set:html'] = toHtml(el.children);
}
el.children = [];
}
};
}

View file

@ -0,0 +1,49 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from '../../../astro/test/test-utils.js';
import mdx from '@astrojs/mdx';
describe('build css from the component', async () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/astro-content-css/', import.meta.url),
integrations: [mdx()],
});
await fixture.build();
});
describe('Build', () => {
before(async () => {
await fixture.build();
});
it('including css and js from the component in pro', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('link[href$=".css"]').attr('href')).to.match(/^\/_astro\//);
expect($('script[src$=".js"]').attr('src')).to.match(/^\/_astro\//);
});
});
describe('Dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
devServer.stop();
});
it('ncluding css and js from the component in Dev', async () => {
let res = await fixture.fetch(`/`);
expect(res.status).to.equal(200);
const html = await res.text();
const $ = cheerio.load(html);
expect($.html()).to.include('CornflowerBlue');
expect($('script[src$=".js"]').attr('src')).to.include('astro');
});
});
});

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import mdx from "@astrojs/mdx";
// https://astro.build/config
export default defineConfig({
build: {
format: 'file'
},
integrations: [mdx()]
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-content-css",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*"
}
}

View file

@ -0,0 +1,12 @@
// 1. Import utilities from `astro:content`
import { z, defineCollection } from 'astro:content';
// 2. Define a schema for each collection you'd like to validate.
const dynamicCollection = defineCollection({
schema: z.object({
title: z.string(),
}),
});
// 3. Export a single `collections` object to register your collection(s)
export const collections = {
dynamic: dynamicCollection,
};

View file

@ -0,0 +1,18 @@
---
const { text } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /></head>
<body>
<div id="first">1st components with js. Props: {text}. <span>Styles</span>. JS: </div>
</body>
</html>
<script>
document.querySelector('#first').innerHTML += 'works';
</script>
<style>
#first > span {
color: CornflowerBlue;
}
</style>

View file

@ -0,0 +1,9 @@
---
title: 'First component'
---
import FirstDynamicComponentWithJS from './FirstComponentWithJS.astro';
<FirstDynamicComponentWithJS text={props.mdProps} />
Additional text from mdx 'first-component-with-js'

View file

@ -0,0 +1,16 @@
---
import { getCollection } from 'astro:content';
const entries = await getCollection('dynamic');
---
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /></head>
<body>
{entries.map(async entry => {
const { Content } = await entry.render();
return <Content mdProps="work" />;
})}
</body>
</html>

View file

@ -0,0 +1,9 @@
import mdx from '@astrojs/mdx';
export default {
integrations: [mdx({
optimize: {
customComponentNames: ['strong']
}
})]
}

View file

@ -0,0 +1,8 @@
{
"name": "@test/mdx-optimize",
"private": true,
"dependencies": {
"@astrojs/mdx": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,3 @@
<blockquote {...Astro.props} class="custom-blockquote">
<slot />
</blockquote>

View file

@ -0,0 +1,3 @@
<strong {...Astro.props} class="custom-strong">
<slot />
</strong>

View file

@ -0,0 +1,3 @@
I once heard a very **inspirational** quote:
> I like pancakes

View file

@ -0,0 +1,15 @@
---
import { Content, components } from './index.mdx'
import Strong from '../components/Strong.astro'
---
<!DOCTYPE html>
<html>
<head>
<title>Import MDX component</title>
</head>
<body>
<h1>Astro page</h1>
<Content components={{ ...components, strong: Strong }} />
</body>
</html>

View file

@ -0,0 +1,15 @@
import Blockquote from '../components/Blockquote.astro'
export const components = {
blockquote: Blockquote
}
# MDX page
I once heard a very inspirational quote:
> I like pancakes
```js
const pancakes = 'yummy'
```

View file

@ -0,0 +1,47 @@
import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import { loadFixture } from '../../../astro/test/test-utils.js';
const FIXTURE_ROOT = new URL('./fixtures/mdx-optimize/', import.meta.url);
describe('MDX optimize', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: FIXTURE_ROOT,
});
await fixture.build();
});
it('renders an MDX page fine', async () => {
const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);
expect(document.querySelector('h1').textContent).include('MDX page');
expect(document.querySelector('p').textContent).include(
'I once heard a very inspirational quote:'
);
const blockquote = document.querySelector('blockquote.custom-blockquote');
expect(blockquote).to.not.be.null;
expect(blockquote.textContent).to.include('I like pancakes');
const code = document.querySelector('pre.astro-code');
expect(code).to.not.be.null;
expect(code.textContent).to.include(`const pancakes = 'yummy'`);
});
it('renders an Astro page that imports MDX fine', async () => {
const html = await fixture.readFile('/import/index.html');
const { document } = parseHTML(html);
expect(document.querySelector('h1').textContent).include('Astro page');
expect(document.querySelector('p').textContent).include(
'I once heard a very inspirational quote:'
);
const blockquote = document.querySelector('blockquote.custom-blockquote');
expect(blockquote).to.not.be.null;
expect(blockquote.textContent).to.include('I like pancakes');
});
});

View file

@ -132,6 +132,8 @@ export default defineConfig ({
## Troubleshooting
- If you're getting a `Failed to fetch` error, make sure you're not using any browser extensions that are blocking the script.
For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!
You can also check our [Astro Integration Documentation][astro-integration] for more on integrations.

View file

@ -9,17 +9,15 @@ import { fileURLToPath } from 'url';
import sirv from './sirv.js';
const resolve = createRequire(import.meta.url).resolve;
type PartytownOptions =
| {
config?: PartytownConfig;
}
| undefined;
type PartytownOptions = {
config?: PartytownConfig;
};
function appendForwardSlash(str: string) {
return str.endsWith('/') ? str : str + '/';
}
export default function createPlugin(options: PartytownOptions): AstroIntegration {
export default function createPlugin(options?: PartytownOptions): AstroIntegration {
let config: AstroConfig;
let partytownSnippetHtml: string;
const partytownEntrypoint = resolve('@builder.io/partytown/package.json');

View file

@ -13,9 +13,9 @@ type Props = {
* As a bonus, we can signal to Preact that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value, name, hydrate }: Props) => {
const StaticHtml = ({ value, name, hydrate = true }: Props) => {
if (!value) return null;
const tagName = hydrate === false ? 'astro-static-slot' : 'astro-slot';
const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
return h(tagName, { name, dangerouslySetInnerHTML: { __html: value } });
};

View file

@ -7,7 +7,7 @@ import { createElement as h } from 'react';
* As a bonus, we can signal to React that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value, name, hydrate }) => {
const StaticHtml = ({ value, name, hydrate = true }) => {
if (!value) return null;
const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
return h(tagName, {

View file

@ -12,6 +12,7 @@ import { generateSitemap } from './generate-sitemap.js';
import { Logger } from './utils/logger.js';
import { validateOptions } from './validate-options.js';
export { EnumChangefreq as ChangeFreqEnum } from 'sitemap';
export type ChangeFreq = `${EnumChangefreq}`;
export type SitemapItem = Pick<
SitemapItemLoose,

View file

@ -108,7 +108,7 @@ export default defineConfig({
});
```
### imageConfig
### imagesConfig
**Type:** `VercelImageConfig`<br>
**Available for:** Edge, Serverless, Static
@ -124,7 +124,7 @@ import vercel from '@astrojs/vercel/static';
export default defineConfig({
output: 'server',
adapter: vercel({
imageConfig: {
imagesConfig: {
sizes: [320, 640, 1280]
}
})

View file

@ -10,7 +10,10 @@ const StaticHtml = defineComponent({
props: {
value: String,
name: String,
hydrate: Boolean,
hydrate: {
type: Boolean,
default: true,
},
},
setup({ name, value, hydrate }) {
if (!value) return () => null;

View file

@ -38,7 +38,7 @@
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/dlv": "^1.1.2",
"@types/node": "^14.18.21",
"@types/node": "^18.7.21",
"@types/which-pm-runs": "^1.0.0",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",

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