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 { expect } from '@playwright/test';
import { getErrorOverlayContent, testFactory } from './test-utils.js'; import { getErrorOverlayContent, silentLogging, testFactory } from './test-utils.js';
const test = testFactory({ const test = testFactory({
root: './fixtures/errors/', root: './fixtures/errors/',
// Only test the error overlay, don't print to console
vite: {
logLevel: 'silent',
},
}); });
let devServer; let devServer;
test.beforeAll(async ({ astro }) => { 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 }) => { test.afterAll(async ({ astro }) => {
@ -89,4 +96,16 @@ test.describe('Error display', () => {
expect(await page.locator('vite-error-overlay').count()).toEqual(0); 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.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 }) => { test('React counter', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/')); await page.goto(astro.resolveUrl('/'));

View file

@ -14,6 +14,22 @@ test.afterAll(async () => {
}); });
test.describe('Nested Frameworks in Vue', () => { 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 }) => { test('React counter', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/')); 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 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 // 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 testFiles = await fs.readdir(new URL('.', import.meta.url));
const testFileToPort = new Map(); const testFileToPort = new Map();

View file

@ -118,12 +118,12 @@
"@astrojs/markdown-remark": "^2.2.1", "@astrojs/markdown-remark": "^2.2.1",
"@astrojs/telemetry": "^2.1.1", "@astrojs/telemetry": "^2.1.1",
"@astrojs/webapi": "^2.1.1", "@astrojs/webapi": "^2.1.1",
"@babel/core": "^7.18.2", "@babel/core": "^7.21.8",
"@babel/generator": "^7.18.2", "@babel/generator": "^7.18.2",
"@babel/parser": "^7.18.4", "@babel/parser": "^7.18.4",
"@babel/plugin-transform-react-jsx": "^7.17.12", "@babel/plugin-transform-react-jsx": "^7.17.12",
"@babel/traverse": "^7.18.2", "@babel/traverse": "^7.18.2",
"@babel/types": "^7.18.4", "@babel/types": "^7.21.5",
"@types/babel__core": "^7.1.19", "@types/babel__core": "^7.1.19",
"@types/yargs-parser": "^21.0.0", "@types/yargs-parser": "^21.0.0",
"acorn": "^8.8.2", "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) => { * export const onRequest = defineMiddleware((context, next) => {
* context.locals.greeting = "Hello!"; * context.locals.greeting = "Hello!";
* next(); * return next();
* }); * });
* ``` * ```
* Inside a `.astro` file: * Inside a `.astro` file:

View file

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

View file

@ -2,14 +2,13 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import slash from 'slash'; import slash from 'slash';
import type { AstroConfig, AstroSettings } from '../../@types/astro'; import { prependForwardSlash } from '../../core/path.js';
import { imageMetadata, type Metadata } from './metadata.js'; import { imageMetadata, type Metadata } from './metadata.js';
export async function emitESMImage( export async function emitESMImage(
id: string | undefined, id: string | undefined,
watchMode: boolean, watchMode: boolean,
fileEmitter: any, fileEmitter: any
settings: Pick<AstroSettings, 'config'>
): Promise<Metadata | undefined> { ): Promise<Metadata | undefined> {
if (!id) { if (!id) {
return undefined; return undefined;
@ -40,34 +39,14 @@ export async function emitESMImage(
url.searchParams.append('origHeight', meta.height.toString()); url.searchParams.append('origHeight', meta.height.toString());
url.searchParams.append('origFormat', meta.format); url.searchParams.append('origFormat', meta.format);
meta.src = rootRelativePath(settings.config, url); meta.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
} }
return meta; 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 { function fileURLToNormalizedPath(filePath: URL): string {
// Uses `slash` package instead of Vite's `normalizePath` // Uses `slash` package instead of Vite's `normalizePath`
// to avoid CJS bundling issues. // to avoid CJS bundling issues.
return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/'); 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 url = new URL(req.url, 'file:');
const filePath = url.searchParams.get('href'); if (!url.searchParams.has('href')) {
if (!filePath) {
return next(); 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); const file = await fs.readFile(filePathURL);
// Get the file's metadata from the URL // Get the file's metadata from the URL
@ -243,7 +242,7 @@ export default function assets({
const cleanedUrl = removeQueryString(id); const cleanedUrl = removeQueryString(id);
if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(cleanedUrl)) { 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)}`; return `export default ${JSON.stringify(meta)}`;
} }
}, },

View file

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

View file

@ -111,7 +111,7 @@ export async function getEntryData(
} }
schema = schema({ 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'); const clientLocalsSymbol = Symbol.for('astro.locals');
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;
const responseSentSymbol = Symbol.for('astro.responseSent'); const responseSentSymbol = Symbol.for('astro.responseSent');
export interface MatchOptions { export interface MatchOptions {
@ -177,7 +175,12 @@ export class App {
if(route.type === 'redirect') { if(route.type === 'redirect') {
return RedirectComponentInstance; return RedirectComponentInstance;
} else { } 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 { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type { import type {
AstroMiddlewareInstance, AstroMiddlewareInstance,
ComponentInstance,
RouteData, RouteData,
SerializedRouteData, SerializedRouteData,
SSRComponentMetadata, SSRComponentMetadata,
SSRLoadedRenderer, SSRLoadedRenderer,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import type { SinglePageBuiltModule } from '../build/types';
export type ComponentPath = string; export type ComponentPath = string;
@ -31,7 +31,7 @@ export interface RouteInfo {
export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & { export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
routeData: SerializedRouteData; routeData: SerializedRouteData;
}; };
type ImportComponentInstance = () => Promise<ComponentInstance>; type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
export interface SSRManifest { export interface SSRManifest {
adapterName: string; adapterName: string;

View file

@ -12,6 +12,8 @@ import type {
EndpointOutput, EndpointOutput,
ImageTransform, ImageTransform,
MiddlewareResponseHandler, MiddlewareResponseHandler,
RedirectRouteData,
RouteData,
RouteType, RouteType,
SSRError, SSRError,
SSRLoadedRenderer, SSRLoadedRenderer,
@ -20,7 +22,12 @@ import {
generateImage as generateImageInternal, generateImage as generateImageInternal,
getStaticImageList, getStaticImageList,
} from '../../assets/generate.js'; } 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 { import {
prependForwardSlash, prependForwardSlash,
removeLeadingForwardSlash, removeLeadingForwardSlash,
@ -35,7 +42,7 @@ import { debug, info } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js'; import { callMiddleware } from '../middleware/callMiddleware.js';
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js'; import { callGetStaticPaths } from '../render/route-cache.js';
import { getRedirectLocationOrThrow, routeIsRedirect, RedirectComponentInstance } from '../redirects/index.js'; import { getRedirectLocationOrThrow, RedirectComponentInstance } from '../redirects/index.js';
import { import {
createAssetLink, createAssetLink,
createModuleScriptsSet, createModuleScriptsSet,
@ -45,15 +52,47 @@ import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js'; import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js'; import { getOutputFilename } from '../util.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.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 { import type {
PageBuildData, PageBuildData,
SingleFileBuiltModule, SinglePageBuiltModule,
StaticBuildOptions, StaticBuildOptions,
StylesheetAsset, StylesheetAsset,
} from './types'; } from './types';
import { getTimeStat } from './util.js'; 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 { function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
return ( return (
// Drafts are disabled // Drafts are disabled
@ -92,7 +131,6 @@ export function chunkIsPage(
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
const timer = performance.now(); 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 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); const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
if (ssr && !hasPrerenderedPages(internals)) return; if (ssr && !hasPrerenderedPages(internals)) return;
@ -100,18 +138,43 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
const verb = ssr ? 'prerendering' : 'generating'; const verb = ssr ? 'prerendering' : 'generating';
info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`); 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>(); const builtPaths = new Set<string>();
if (ssr) { if (ssr) {
for (const pageData of eachPageData(internals)) { for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
if (pageData.route.prerender) if (pageData.route.prerender) {
await generatePage(opts, internals, pageData, ssrEntry, builtPaths); 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 { } else {
for (const pageData of eachPageData(internals)) { for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) {
await generatePage(opts, internals, pageData, ssrEntry, builtPaths); 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, opts: StaticBuildOptions,
internals: BuildInternals, internals: BuildInternals,
pageData: PageBuildData, pageData: PageBuildData,
ssrEntry: SingleFileBuiltModule, ssrEntry: SinglePageBuiltModule,
builtPaths: Set<string> builtPaths: Set<string>
) { ) {
let timeStart = performance.now(); let timeStart = performance.now();
const renderers = ssrEntry.renderers; const renderers = ssrEntry?.renderers;
const pageInfo = getPageDataByComponent(internals, pageData.route.component); const pageInfo = getPageDataByComponent(internals, pageData.route.component);
@ -170,16 +233,9 @@ async function generatePage(
.map(({ sheet }) => sheet) .map(({ sheet }) => sheet)
.reduce(mergeInlineCss, []); .reduce(mergeInlineCss, []);
let pageModulePromise = ssrEntry.pageMap?.get(pageData.component); let pageModulePromise = ssrEntry?.page;
const middleware = ssrEntry.middleware; 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) { if (!pageModulePromise) {
throw new Error( throw new Error(
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.` `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 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. // This walks up the dependency graph and yields out each ModuleInfo object.
export function* walkParentInfos( export function* walkParentInfos(
@ -43,8 +43,8 @@ export function* walkParentInfos(
// it is imported by the top-level virtual module. // it is imported by the top-level virtual module.
export function moduleIsTopLevelPage(info: ModuleInfo): boolean { export function moduleIsTopLevelPage(info: ModuleInfo): boolean {
return ( return (
info.importers[0] === resolvedPagesVirtualModuleId || info.importers[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) ||
info.dynamicImporters[0] == resolvedPagesVirtualModuleId info.dynamicImporters[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)
); );
} }

View file

@ -1,17 +1,20 @@
import type { Rollup } from 'vite'; import type { Rollup } from 'vite';
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
import type { SSRResult } from '../../@types/astro'; import type { SSRResult } from '../../@types/astro';
import type { PageOptions } from '../../vite-plugin-astro/types'; import type { PageOptions } from '../../vite-plugin-astro/types';
import { prependForwardSlash, removeFileExtension } from '../path.js'; import { prependForwardSlash, removeFileExtension } from '../path.js';
import { viteID } from '../util.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 { export interface BuildInternals {
/** /**
* The module ids of all CSS chunks, used to deduplicate CSS assets between * Each CSS module is named with a chunk id derived from the Astro pages they
* SSR build and client build in vite-plugin-css. * 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 // A mapping of hoisted script ids back to the exact hoisted scripts it references
hoistedScriptIdToHoistedMap: Map<string, Set<string>>; hoistedScriptIdToHoistedMap: Map<string, Set<string>>;
@ -92,12 +95,11 @@ export function createBuildInternals(): BuildInternals {
const hoistedScriptIdToPagesMap = new Map<string, Set<string>>(); const hoistedScriptIdToPagesMap = new Map<string, Set<string>>();
return { return {
cssChunkModuleIds: new Set(), cssModuleToChunkIdMap: new Map(),
hoistedScriptIdToHoistedMap, hoistedScriptIdToHoistedMap,
hoistedScriptIdToPagesMap, hoistedScriptIdToPagesMap,
entrySpecifierToBundleMap: new Map<string, string>(), entrySpecifierToBundleMap: new Map<string, string>(),
pageToBundleMap: new Map<string, string>(), pageToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(), pagesByComponent: new Map(),
pageOptionsByPage: new Map(), pageOptionsByPage: new Map(),
pagesByViteID: new Map(), pagesByViteID: new Map(),
@ -215,6 +217,34 @@ export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values(); 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) { export function hasPrerenderedPages(internals: BuildInternals) {
for (const pageData of eachPageData(internals)) { for (const pageData of eachPageData(internals)) {
if (pageData.route.prerender) { 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 = { const cssBuildPlugin: VitePlugin = {
name: 'astro:rollup-plugin-build-css', 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) { 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 assetFileNames = outputOptions.assetFileNames;
const namingIncludesHash = assetFileNames?.toString().includes('[hash]'); const namingIncludesHash = assetFileNames?.toString().includes('[hash]');
const createNameForParentPages = namingIncludesHash 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. // 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. // This causes CSS to be built into shared chunks when used by multiple pages.
if (isBuildableCSSRequest(id)) { 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, { for (const [pageInfo] of walkParentInfos(id, {
getModuleInfo: meta.getModuleInfo, getModuleInfo: meta.getModuleInfo,
})) { })) {
if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) { if (new URL(pageInfo.id, 'file://').searchParams.has(PROPAGATED_ASSET_FLAG)) {
// Split delayed assets to separate modules // Split delayed assets to separate modules
// so they can be injected where needed // 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 // Skip if the chunk has no CSS, we want to handle CSS chunks only
if (meta.importedCss.size < 1) continue; 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 // 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 // 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. // 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'; import type { StaticBuildOptions } from '../types';
export const MIDDLEWARE_MODULE_ID = '@astro-middleware'; 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( export function vitePluginMiddleware(
opts: StaticBuildOptions, opts: StaticBuildOptions,
_internals: BuildInternals _internals: BuildInternals
@ -21,26 +19,14 @@ export function vitePluginMiddleware(
} }
}, },
resolveId(id) { async resolveId(id) {
if (id === MIDDLEWARE_MODULE_ID && opts.settings.config.experimental.middleware) { if (id === MIDDLEWARE_MODULE_ID && opts.settings.config.experimental.middleware) {
return RESOLVED_MIDDLEWARE_MODULE_ID; const middlewareId = await this.resolve(
}
},
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(
`${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}` `${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}`
); );
if (middlewareId) { if (middlewareId) {
imports.push(`import { onRequest } from "${middlewareId.id}"`); return middlewareId.id;
exports.push(`export { onRequest }`);
} }
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 type { Plugin as VitePlugin } from 'vite';
import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../../app/index.js';
import { addRollupInput } from '../add-rollup-input.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 { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js';
import { routeIsRedirect } from '../../redirects/index.js'; import { routeIsRedirect } from '../../redirects/index.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.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 { function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return { return {
name: '@astro/plugin-build-pages', name: '@astro/plugin-build-pages',
options(options) { options(options) {
if (opts.settings.config.output === 'static') { 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) { resolveId(id) {
if (id === pagesVirtualModuleId) { if (id.startsWith(ASTRO_PAGE_MODULE_ID)) {
return resolvedPagesVirtualModuleId; return '\0' + id;
} }
}, },
async load(id) { async load(id) {
if (id === resolvedPagesVirtualModuleId) { if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
let importMap = '';
const imports: string[] = []; const imports: string[] = [];
const exports: string[] = []; const exports: string[] = [];
const content: string[] = [];
let i = 0; // 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}";`); imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
exports.push(`export { renderers };`); exports.push(`export { renderers };`);
for (const pageData of eachPageData(internals)) {
if(routeIsRedirect(pageData.route)) {
continue;
}
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) { if (opts.settings.config.experimental.middleware) {
imports.push(`import * as _middleware from "${MIDDLEWARE_MODULE_ID}";`); imports.push(`import * as _middleware from "${MIDDLEWARE_MODULE_ID}";`);
exports.push(`export const middleware = _middleware;`); exports.push(`export const middleware = _middleware;`);
} }
content.push(`export const pageMap = new Map([${importMap}]);`); return `${imports.join('\n')}${exports.join('\n')}`;
}
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 glob from 'fast-glob';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { Plugin as VitePlugin } from 'vite';
import type { AstroAdapter } from '../../../@types/astro';
import { runHookBuildSsr } from '../../../integrations/index.js'; import { runHookBuildSsr } from '../../../integrations/index.js';
import { isHybridOutput } from '../../../prerender/utils.js'; import { isHybridOutput } from '../../../prerender/utils.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.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 { joinPaths, prependForwardSlash } from '../../path.js';
import { serializeRouteData } from '../../routing/index.js'; import { serializeRouteData } from '../../routing/index.js';
import { addRollupInput } from '../add-rollup-input.js'; import { addRollupInput } from '../add-rollup-input.js';
import { getOutFile, getOutFolder } from '../common.js'; import { getOutFile, getOutFolder } from '../common.js';
import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin'; 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'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
export const virtualModuleId = '@astrojs-ssr-virtual-entry'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId; const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g');
function vitePluginSSR( function vitePluginSSR(
internals: BuildInternals, internals: BuildInternals,
adapter: AstroAdapter, adapter: AstroAdapter,
config: AstroConfig options: StaticBuildOptions
): VitePlugin { ): VitePlugin {
return { return {
name: '@astrojs/vite-plugin-astro-ssr', name: '@astrojs/vite-plugin-astro-ssr',
enforce: 'post', enforce: 'post',
options(opts) { options(opts) {
return addRollupInput(opts, [virtualModuleId]); return addRollupInput(opts, [SSR_VIRTUAL_MODULE_ID]);
}, },
resolveId(id) { resolveId(id) {
if (id === virtualModuleId) { if (id === SSR_VIRTUAL_MODULE_ID) {
return resolvedVirtualModuleId; return RESOLVED_SSR_VIRTUAL_MODULE_ID;
} }
}, },
load(id) { async load(id) {
if (id === resolvedVirtualModuleId) { if (id === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
let middleware = ''; const {
settings: { config },
allPages,
} = options;
const imports: string[] = [];
const contents: string[] = [];
const exports: string[] = [];
let middleware;
if (config.experimental?.middleware === true) { 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 { renderers } from '${RENDERERS_MODULE_ID}';
import * as _main from '${pagesVirtualModuleId}';
import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { deserializeManifest as _deserializeManifest } from 'astro/app';
import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest';
const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), {
pageMap: _main.pageMap, pageMap,
renderers: _main.renderers, renderers,
${middleware} ${middleware}
}); });
_privateSetManifestDontUseThis(_manifest); _privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
export * from '${pagesVirtualModuleId}';
${ ${
adapter.exports adapter.exports
? `const _exports = adapter.createExports(_manifest, _args); ? `const _exports = adapter.createExports(_manifest, _args);
@ -77,6 +109,7 @@ const _start = 'start';
if(_start in adapter) { if(_start in adapter) {
adapter[_start](_manifest, _args); adapter[_start](_manifest, _args);
}`; }`;
return `${imports.join('\n')}${contents.join('\n')}${content}${exports.join('\n')}`;
} }
return void 0; return void 0;
}, },
@ -92,7 +125,7 @@ if(_start in adapter) {
if (chunk.type === 'asset') { if (chunk.type === 'asset') {
continue; continue;
} }
if (chunk.modules[resolvedVirtualModuleId]) { if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) {
internals.ssrEntryChunk = chunk; internals.ssrEntryChunk = chunk;
delete bundle[chunkName]; delete bundle[chunkName];
} }
@ -250,7 +283,7 @@ export function pluginSSR(
hooks: { hooks: {
'build:before': () => { 'build:before': () => {
let vitePlugin = ssr let vitePlugin = ssr
? vitePluginSSR(internals, options.settings.adapter!, options.settings.config) ? vitePluginSSR(internals, options.settings.adapter!, options)
: undefined; : undefined;
return { return {

View file

@ -17,7 +17,6 @@ import { isModeServerWithNoAdapter } from '../../core/util.js';
import { runHookBuildSetup } from '../../integrations/index.js'; import { runHookBuildSetup } from '../../integrations/index.js';
import { isHybridOutput } from '../../prerender/utils.js'; import { isHybridOutput } from '../../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.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 { AstroError, AstroErrorData } from '../errors/index.js';
import { info } from '../logger/core.js'; import { info } from '../logger/core.js';
import { getOutDirWithinCwd } from './common.js'; import { getOutDirWithinCwd } from './common.js';
@ -25,8 +24,12 @@ import { generatePages } from './generate.js';
import { trackPageData } from './internal.js'; import { trackPageData } from './internal.js';
import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js'; import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js';
import { registerAllPlugins } from './plugins/index.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 { 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 type { PageBuildData, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js'; import { getTimeStat } from './util.js';
@ -172,10 +175,17 @@ async function ssrBuild(
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`, assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
...viteConfig.build?.rollupOptions?.output, ...viteConfig.build?.rollupOptions?.output,
entryFileNames(chunkInfo) { entryFileNames(chunkInfo) {
if (chunkInfo.facadeModuleId === resolvedPagesVirtualModuleId) { if (chunkInfo.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
return opts.buildConfig.serverEntry; return makeAstroPageEntryPointFileName(chunkInfo.facadeModuleId);
} else if (chunkInfo.facadeModuleId === RESOLVED_MIDDLEWARE_MODULE_ID) { } 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'; 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) { } else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) {
return 'renderers.mjs'; return 'renderers.mjs';
} else { } else {
@ -408,3 +418,29 @@ async function ssrMoveAssets(opts: StaticBuildOptions) {
removeEmptyDirs(serverAssets); 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>; type ImportComponentInstance = () => Promise<ComponentInstance>;
export interface SingleFileBuiltModule { export interface SinglePageBuiltModule {
pageMap: Map<ComponentPath, ImportComponentInstance>; page: ImportComponentInstance;
middleware: AstroMiddlewareInstance<unknown>; middleware: AstroMiddlewareInstance<unknown>;
renderers: SSRLoadedRenderer[]; 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: * For unsupported formats such as SVGs and GIFs, you may be able to use an `img` tag directly:
* ```astro * ```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." /> * <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. * Making changes to the response, such as setting headers, cookies, and the status code cannot be done outside of page components.
*/ */
ResponseSentError: { ResponseSentError: {
title: 'Unable to set response', title: 'Unable to set response.',
code: 3030, code: 3030,
message: 'The response has already been sent to the browser and cannot be altered.', 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: { 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, code: 3031,
message: message:
'The middleware needs to either return a `Response` object or call the `next` function.', '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: { 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, code: 3032,
message: 'Any data returned from middleware must be a valid `Response` object.', 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: { LocalsNotAnObject: {
title: 'Value assigned to `locals` is not accepted', title: 'Value assigned to `locals` is not accepted.',
code: 3033, code: 3033,
message: message:
'`locals` can only be assigned to an object. Other values like numbers, strings, etc. are not accepted.', '`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`.', 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 * @docs
* @see * @see
@ -703,7 +758,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
*/ */
RedirectWithNoLocation: { RedirectWithNoLocation: {
title: 'A redirect must be given a location with the `Location` header.', 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. // No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx // Vite Errors - 4xxx
@ -973,7 +1028,6 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
}, },
/** /**
* @docs * @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 * @see
* - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/) * - [The reserved entry `slug` field](https://docs.astro.build/en/guides/content-collections/)
* @description * @description
@ -982,9 +1036,8 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
ContentSchemaContainsSlugError: { ContentSchemaContainsSlugError: {
title: 'Content Schema should not contain `slug`.', title: 'Content Schema should not contain `slug`.',
code: 9003, code: 9003,
message: (collection: string) => { message: (collectionName: string) =>
return `A content collection schema should not contain \`slug\` since it is reserved for slug generation. Remove this from your ${collection} collection schema.`; `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.', 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: { CollectionDoesNotExistError: {
title: 'Collection does not exist', title: 'Collection does not exist',
code: 9004, code: 9004,
message: (collection: string) => { message: (collectionName: string) =>
return `The collection **${collection}** does not exist. Ensure a collection directory with this name exists.`; `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.', 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 { import type {
AstroMiddlewareInstance, AstroMiddlewareInstance,
AstroSettings, AstroSettings,
@ -65,7 +64,7 @@ export async function preload({
try { try {
// Load the module from the Vite SSR Runtime. // 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]; return [renderers, mod];
} catch (error) { } catch (error) {

View file

@ -177,6 +177,7 @@ function comparator(a: Item, b: Item) {
} }
} }
// endpoints are prioritized over pages
if (a.isPage !== b.isPage) { if (a.isPage !== b.isPage) {
return a.isPage ? 1 : -1; 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 type { AstroGlobalPartial } from '../../@types/astro';
import { ASTRO_VERSION } from '../../core/constants.js'; import { ASTRO_VERSION } from '../../core/constants.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
/** Create the Astro.glob() runtime function. */ /** Create the Astro.glob() runtime function. */
function createAstroGlobFn() { function createAstroGlobFn() {
const globHandler = (importMetaGlobResult: Record<string, any>, globValue: () => any) => { 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)]; let allEntries = [...Object.values(importMetaGlobResult)];
if (allEntries.length === 0) { 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. // Map over the `import()` promises, calling to load them.
return Promise.all(allEntries.map((fn) => fn())); return Promise.all(allEntries.map((fn) => fn()));

View file

@ -1,7 +1,14 @@
export { createComponent } from './astro-component.js'; export { createComponent } from './astro-component.js';
export { createAstro } from './astro-global.js'; export { createAstro } from './astro-global.js';
export { renderEndpoint } from './endpoint.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 { renderJSX } from './jsx.js';
export { export {
addAttribute, addAttribute,

View file

@ -50,6 +50,9 @@ export class AstroComponentInstance {
value = await value; value = await value;
} }
if (isHeadAndContent(value)) { if (isHeadAndContent(value)) {
if (this.result.extraHead.length === 0 && value.head) {
yield renderChild(value.head);
}
yield* value.content; yield* value.content;
} else { } else {
yield* renderChild(value); 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 { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
import { createRequest } from '../core/request.js'; import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.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 { isHybridOutput } from '../prerender/utils.js';
import { log404 } from './common.js'; import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
@ -47,24 +47,12 @@ export async function matchRoute(
): Promise<MatchedRoute | undefined> { ): Promise<MatchedRoute | undefined> {
const { logging, settings, routeCache } = env; const { logging, settings, routeCache } = env;
const matches = matchAllRoutes(pathname, manifest); const matches = matchAllRoutes(pathname, manifest);
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
for await (const maybeRoute of matches) { for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
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;
// attempt to get static paths // attempt to get static paths
// if this fails, we have a bad URL match! // if this fails, we have a bad URL match!
const [, mod] = preloadedComponent;
const paramsAndPropsRes = await getParamsAndProps({ const paramsAndPropsRes = await getParamsAndProps({
mod, mod,
route: maybeRoute, route: maybeRoute,
@ -210,7 +198,7 @@ export async function handleRoute(
await writeWebResponse(res, result.response); await writeWebResponse(res, result.response);
} else { } else {
let contentType = 'text/plain'; 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 = const filepath =
route.pathname || route.pathname ||
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/'); 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`) 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', () => { describe('Vite features', () => {

View file

@ -114,6 +114,32 @@ describe('astro:image', () => {
expect(logs).to.have.a.lengthOf(1); expect(logs).to.have.a.lengthOf(1);
expect(logs[0].message).to.contain('Received unsupported format'); 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', () => { describe('vite-isms', () => {
@ -228,9 +254,9 @@ describe('astro:image', () => {
expect($img).to.have.a.lengthOf(1); expect($img).to.have.a.lengthOf(1);
// Verbose test for the full URL to make sure the image went through the full pipeline // Verbose test for the full URL to make sure the image went through the full pipeline
expect($img.attr('src')).to.equal( expect(
'/_image?href=%2Fsrc%2Fassets%2Fpenguin1.jpg%3ForigWidth%3D207%26origHeight%3D243%26origFormat%3Djpg&f=webp' $img.attr('src').startsWith('/_image') && $img.attr('src').endsWith('f=webp')
); ).to.equal(true);
}); });
it('has width and height attributes', () => { it('has width and height attributes', () => {
@ -297,12 +323,12 @@ describe('astro:image', () => {
it('has proper source for directly used image', () => { it('has proper source for directly used image', () => {
let $img = $('#direct-image img'); 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', () => { it('has proper source for refined image', () => {
let $img = $('#refined-image img'); 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', () => { it('has proper sources for array of images', () => {
@ -310,7 +336,7 @@ describe('astro:image', () => {
const imgsSrcs = []; const imgsSrcs = [];
$img.each((i, img) => imgsSrcs.push(img.attribs['src'])); $img.each((i, img) => imgsSrcs.push(img.attribs['src']));
expect($img).to.have.a.lengthOf(2); 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', () => { it('has proper attributes for optimized image through getImage', () => {
@ -330,7 +356,7 @@ describe('astro:image', () => {
it('properly handles nested images', () => { it('properly handles nested images', () => {
let $img = $('#nested-image img'); 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 () => { 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 }) => { export const get = (async ({ params, request }) => {
const url = new URL(request.url); const url = new URL(request.url);
console.log(url)
const src = url.searchParams.get("src"); const src = url.searchParams.get("src");
return { 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 ### 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: 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 { Fragment } from 'astro/jsx-runtime';
import type { RenderableTreeNode } from '@markdoc/markdoc'; import type { RenderableTreeNode } from '@markdoc/markdoc';
import Markdoc 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 = export type TreeNode =
| { | {
type: 'text'; type: 'text';
content: string; content: string | HTMLString;
} }
| { | {
type: 'component'; type: 'component';
@ -25,6 +31,7 @@ export type TreeNode =
export const ComponentNode = createComponent({ export const ComponentNode = createComponent({
factory(result: any, { treeNode }: { treeNode: TreeNode }) { factory(result: any, { treeNode }: { treeNode: TreeNode }) {
if (treeNode.type === 'text') return render`${treeNode.content}`; if (treeNode.type === 'text') return render`${treeNode.content}`;
const slots = { const slots = {
default: () => default: () =>
render`${treeNode.children.map((child) => render`${treeNode.children.map((child) =>
@ -46,7 +53,9 @@ export const ComponentNode = createComponent({
}); });
export function createTreeNode(node: RenderableTreeNode | RenderableTreeNode[]): TreeNode { 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) }; return { type: 'text', content: String(node) };
} else if (Array.isArray(node)) { } else if (Array.isArray(node)) {
return { return {

View file

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

View file

@ -1,10 +1,18 @@
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
import _Markdoc 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 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; 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 Slugger from 'github-slugger';
import { getTextContent } from '../runtime.js'; import type { AstroMarkdocConfig } from './config.js';
import { getTextContent } from './runtime.js';
type ConfigTypeWithCtx = ConfigType & { import { MarkdocError } from './utils.js';
// TODO: decide on `ctx` as a convention for config merging
ctx: {
headingSlugger: Slugger;
};
};
function getSlug( function getSlug(
attributes: Record<string, any>, attributes: Record<string, any>,
@ -24,16 +19,31 @@ function getSlug(
return slug; 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 = { export const heading: Schema = {
children: ['inline'], children: ['inline'],
attributes: { attributes: {
id: { type: String }, id: { type: String },
level: { type: Number, required: true, default: 1 }, level: { type: Number, required: true, default: 1 },
}, },
transform(node, config: ConfigTypeWithCtx) { transform(node, config: HeadingIdConfig) {
const { level, ...attributes } = node.transformAttributes(config); const { level, ...attributes } = node.transformAttributes(config);
const children = node.transformChildren(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 slug = getSlug(attributes, children, config.ctx.headingSlugger);
const render = config.nodes?.heading?.render ?? `h${level}`; 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(); const headingSlugger = new Slugger();
return { return {
ctx: { ctx: {
headingSlugger, headingSlugger,

View file

@ -32,7 +32,19 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
name: '@astrojs/markdoc', name: '@astrojs/markdoc',
hooks: { hooks: {
'astro:config:setup': async (params) => { '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); markdocConfigResult = await loadMarkdocConfig(astroConfig);
const userMarkdocConfig = markdocConfigResult?.config ?? {}; const userMarkdocConfig = markdocConfigResult?.config ?? {};
@ -52,7 +64,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
async getRenderModule({ entry, viteId }) { async getRenderModule({ entry, viteId }) {
const ast = Markdoc.parse(entry.body); const ast = Markdoc.parse(entry.body);
const pluginContext = this; const pluginContext = this;
const markdocConfig = setupConfig(userMarkdocConfig, entry); const markdocConfig = await setupConfig(userMarkdocConfig, entry);
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => { const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
return ( return (
@ -90,7 +102,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
const res = `import { jsx as h } from 'astro/jsx-runtime'; const res = `import { jsx as h } from 'astro/jsx-runtime';
import { Renderer } from '@astrojs/markdoc/components'; 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')}; import * as entry from ${JSON.stringify(viteId + '?astroContentCollectionEntry')};
${ ${
markdocConfigResult markdocConfigResult
@ -114,13 +126,13 @@ export function getHeadings() {
'' ''
} }
const headingConfig = userConfig.nodes?.heading; 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 ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast, config); const content = Markdoc.transform(ast, config);
return collectHeadings(Array.isArray(content) ? content : content.children); return collectHeadings(Array.isArray(content) ? content : content.children);
} }
export async function Content (props) { export async function Content (props) {
const config = setupConfig({ const config = await setupConfig({
...userConfig, ...userConfig,
variables: { ...userConfig.variables, ...props }, variables: { ...userConfig.variables, ...props },
}, entry); }, 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 type { MarkdownHeading } from '@astrojs/markdown-remark';
import Markdoc, { import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc';
type ConfigType as MarkdocConfig,
type RenderableTreeNode,
} from '@markdoc/markdoc';
import type { ContentEntryModule } from 'astro'; 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 */ /** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
export { default as Markdoc } from '@markdoc/markdoc'; export { default as Markdoc } from '@markdoc/markdoc';
/** /**
* Merge user config with default config and set up context (ex. heading ID slugger) * 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 { export async function setupConfig(
const defaultConfig: MarkdocConfig = { userConfig: AstroMarkdocConfig,
// `setupXConfig()` could become a "plugin" convention as well? entry: ContentEntryModule
): Promise<Omit<AstroMarkdocConfig, 'extends'>> {
let defaultConfig: AstroMarkdocConfig = {
...setupHeadingConfig(), ...setupHeadingConfig(),
variables: { entry }, 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); return mergeConfig(defaultConfig, userConfig);
} }
/** Merge function from `@markdoc/markdoc` internals */ /** Merge function from `@markdoc/markdoc` internals */
function mergeConfig(configA: MarkdocConfig, configB: MarkdocConfig): MarkdocConfig { function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig): AstroMarkdocConfig {
return { return {
...configA, ...configA,
...configB, ...configB,
ctx: {
...configA.ctx,
...configB.ctx,
},
tags: { tags: {
...configA.tags, ...configA.tags,
...configB.tags, ...configB.tags,

View file

@ -35,8 +35,8 @@ describe('Markdoc - Image assets', () => {
const res = await baseFixture.fetch('/'); const res = await baseFixture.fetch('/');
const html = await res.text(); const html = await res.text();
const { document } = parseHTML(html); const { document } = parseHTML(html);
expect(document.querySelector('#relative > img')?.src).to.equal( expect(document.querySelector('#relative > img')?.src).to.match(
'/_image?href=%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp' /\/_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 res = await baseFixture.fetch('/');
const html = await res.text(); const html = await res.text();
const { document } = parseHTML(html); const { document } = parseHTML(html);
expect(document.querySelector('#alias > img')?.src).to.equal( expect(document.querySelector('#alias > img')?.src).to.match(
'/_image?href=%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp' /\/_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) - [Options inherited from Markdown config](#options-inherited-from-markdown-config)
- [`extendMarkdownConfig`](#extendmarkdownconfig) - [`extendMarkdownConfig`](#extendmarkdownconfig)
- [`recmaPlugins`](#recmaplugins) - [`recmaPlugins`](#recmaplugins)
- [`optimize`](#optimize)
### Options inherited from Markdown config ### 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. 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 ## 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. * 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", "estree-util-visit": "^1.2.0",
"github-slugger": "^1.4.0", "github-slugger": "^1.4.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hast-util-to-html": "^8.0.4",
"kleur": "^4.1.4", "kleur": "^4.1.4",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"remark-frontmatter": "^4.0.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 { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite'; import type { Plugin as VitePlugin } from 'vite';
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js'; import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
import type { OptimizeOptions } from './rehype-optimize-static.js';
import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js'; import { getFileInfo, ignoreStringPlugins, parseFrontmatter } from './utils.js';
export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & { export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
@ -21,6 +22,7 @@ export type MdxOptions = Omit<typeof markdownConfigDefaults, 'remarkPlugins' | '
remarkPlugins: PluggableList; remarkPlugins: PluggableList;
rehypePlugins: PluggableList; rehypePlugins: PluggableList;
remarkRehype: RemarkRehypeOptions; remarkRehype: RemarkRehypeOptions;
optimize: boolean | OptimizeOptions;
}; };
type SetupHookParams = HookParameters<'astro:config:setup'> & { type SetupHookParams = HookParameters<'astro:config:setup'> & {
@ -194,6 +196,7 @@ function markdownConfigToMdxOptions(markdownConfig: typeof markdownConfigDefault
remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins), remarkPlugins: ignoreStringPlugins(markdownConfig.remarkPlugins),
rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins), rehypePlugins: ignoreStringPlugins(markdownConfig.rehypePlugins),
remarkRehype: (markdownConfig.remarkRehype as any) ?? {}, remarkRehype: (markdownConfig.remarkRehype as any) ?? {},
optimize: false,
}; };
} }
@ -214,6 +217,7 @@ function applyDefaultOptions({
remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins, remarkPlugins: options.remarkPlugins ?? defaults.remarkPlugins,
rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins, rehypePlugins: options.rehypePlugins ?? defaults.rehypePlugins,
shikiConfig: options.shikiConfig ?? defaults.shikiConfig, 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 type { MdxOptions } from './index.js';
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js'; import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
import rehypeMetaString from './rehype-meta-string.js'; import rehypeMetaString from './rehype-meta-string.js';
import { rehypeOptimizeStatic } from './rehype-optimize-static.js';
import { remarkImageToComponent } from './remark-images-to-component.js'; import { remarkImageToComponent } from './remark-images-to-component.js';
import remarkPrism from './remark-prism.js'; import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.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 // computed from `astro.data.frontmatter` in VFile data
rehypeApplyFrontmatterExport, 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; 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 ## 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! 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. 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'; import sirv from './sirv.js';
const resolve = createRequire(import.meta.url).resolve; const resolve = createRequire(import.meta.url).resolve;
type PartytownOptions = type PartytownOptions = {
| {
config?: PartytownConfig; config?: PartytownConfig;
} };
| undefined;
function appendForwardSlash(str: string) { function appendForwardSlash(str: string) {
return str.endsWith('/') ? str : str + '/'; return str.endsWith('/') ? str : str + '/';
} }
export default function createPlugin(options: PartytownOptions): AstroIntegration { export default function createPlugin(options?: PartytownOptions): AstroIntegration {
let config: AstroConfig; let config: AstroConfig;
let partytownSnippetHtml: string; let partytownSnippetHtml: string;
const partytownEntrypoint = resolve('@builder.io/partytown/package.json'); 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 * As a bonus, we can signal to Preact that this subtree is
* entirely static and will never change via `shouldComponentUpdate`. * 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; 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 } }); 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 * As a bonus, we can signal to React that this subtree is
* entirely static and will never change via `shouldComponentUpdate`. * entirely static and will never change via `shouldComponentUpdate`.
*/ */
const StaticHtml = ({ value, name, hydrate }) => { const StaticHtml = ({ value, name, hydrate = true }) => {
if (!value) return null; if (!value) return null;
const tagName = hydrate ? 'astro-slot' : 'astro-static-slot'; const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
return h(tagName, { return h(tagName, {

View file

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

View file

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

View file

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

View file

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

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