Head propagation (#5511)

* Head propagation

* Adding a changeset

* Fix broken build

* Self review stuff

* Use compiler prerelease exact version

* new compiler version

* Update packages/astro/src/vite-plugin-head-propagation/index.ts

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* Use getAstroMetadata

* add .js

* make relative lookup work on win

* Use compiler@0.30.0

* PR review comments

* Make renderHead an alias for a better named function

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
This commit is contained in:
Matthew Phillips 2022-12-06 16:26:15 -05:00 committed by GitHub
parent b137657699
commit 05915fec01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 804 additions and 279 deletions

View file

@ -0,0 +1,7 @@
---
'astro': patch
---
Low-level head propagation
This adds low-level head propagation ability within the Astro runtime. This is not really useable within an Astro app at the moment, but provides the APIs necessary for `renderEntry` to do head propagation.

View file

@ -100,7 +100,7 @@
"test:e2e:match": "playwright test -g"
},
"dependencies": {
"@astrojs/compiler": "^0.29.15",
"@astrojs/compiler": "^0.30.0",
"@astrojs/language-server": "^0.28.3",
"@astrojs/markdown-remark": "^1.1.3",
"@astrojs/telemetry": "^1.0.1",
@ -111,11 +111,11 @@
"@babel/plugin-transform-react-jsx": "^7.17.12",
"@babel/traverse": "^7.18.2",
"@babel/types": "^7.18.4",
"@proload/core": "^0.3.3",
"@proload/plugin-tsm": "^0.2.1",
"@types/babel__core": "^7.1.19",
"@types/html-escaper": "^3.0.0",
"@types/yargs-parser": "^21.0.0",
"@proload/core": "^0.3.3",
"@proload/plugin-tsm": "^0.2.1",
"boxen": "^6.2.1",
"ci-info": "^3.3.1",
"common-ancestor-path": "^1.0.1",

View file

@ -16,7 +16,7 @@ import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types';
import type { AstroConfigSchema } from '../core/config';
import type { AstroCookies } from '../core/cookies';
import type { AstroComponentFactory } from '../runtime/server';
import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
export type {
MarkdownHeading,
@ -1398,10 +1398,25 @@ export interface SSRMetadata {
hasRenderedHead: boolean;
}
/**
* A hint on whether the Astro runtime needs to wait on a component to render head
* content. The meanings:
*
* - __none__ (default) The component does not propagation head content.
* - __self__ The component appends head content.
* - __in-tree__ Another component within this component's dependency tree appends head content.
*
* These are used within the runtime to know whether or not a component should be waited on.
*/
export type PropagationHint = 'none' | 'self' | 'in-tree';
export interface SSRResult {
styles: Set<SSRElement>;
scripts: Set<SSRElement>;
links: Set<SSRElement>;
propagation: Map<string, PropagationHint>;
propagators: Map<AstroComponentFactory, AstroComponentInstance>;
extraHead: Array<any>;
cookies: AstroCookies | undefined;
createAstro(
Astro: AstroGlobalPartial,

View file

@ -12,6 +12,7 @@ export interface CompileProps {
astroConfig: AstroConfig;
viteConfig: ResolvedConfig;
filename: string;
id: string | undefined;
source: string;
}
@ -24,6 +25,7 @@ export async function compile({
astroConfig,
viteConfig,
filename,
id: moduleId,
source,
}: CompileProps): Promise<CompileResult> {
const cssDeps = new Set<string>();
@ -35,6 +37,7 @@ export async function compile({
// use `sourcemap: "both"` so that sourcemap is included in the code
// result passed to esbuild, but also available in the catch handler.
transformResult = await transform(source, {
moduleId,
pathname: filename,
projectRoot: astroConfig.root.toString(),
site: astroConfig.site?.toString(),

View file

@ -18,6 +18,7 @@ import legacyMarkdownVitePlugin from '../vite-plugin-markdown-legacy/index.js';
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import astroHeadPropagationPlugin from '../vite-plugin-head-propagation/index.js';
import { createCustomViteLogger } from './errors/dev/index.js';
import { resolveDependency } from './util.js';
@ -112,6 +113,7 @@ export async function createVite(
astroPostprocessVitePlugin({ settings }),
astroIntegrationsContainerPlugin({ settings, logging }),
astroScriptsPageSSRPlugin({ settings }),
astroHeadPropagationPlugin({ settings }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),

View file

@ -1,4 +1,4 @@
import type { RouteData, SSRElement } from '../../@types/astro';
import type { RouteData, SSRElement, SSRResult } from '../../@types/astro';
/**
* The RenderContext represents the parts of rendering that are specific to one request.
@ -11,6 +11,7 @@ export interface RenderContext {
scripts?: Set<SSRElement>;
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
propagation?: SSRResult['propagation'];
route?: RouteData;
status?: number;
}

View file

@ -98,6 +98,7 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
params,
props: pageProps,
pathname: ctx.pathname,
propagation: ctx.propagation,
resolve: env.resolve,
renderers: env.renderers,
request: ctx.request,

View file

@ -0,0 +1,34 @@
import type { SSRResult } from '../../../@types/astro';
import type { ModuleInfo, ModuleLoader } from '../../module-loader/index';
import { viteID } from '../../util.js';
import { getAstroMetadata } from '../../../vite-plugin-astro/index.js';
import { crawlGraph } from './vite.js';
export async function getPropagationMap(
filePath: URL,
loader: ModuleLoader
): Promise<SSRResult['propagation']> {
const map: SSRResult['propagation'] = new Map();
const rootID = viteID(filePath);
addInjection(map, loader.getModuleInfo(rootID))
for await (const moduleNode of crawlGraph(loader, rootID, true)) {
const id = moduleNode.id;
if (id) {
addInjection(map, loader.getModuleInfo(id));
}
}
return map;
}
function addInjection(map: SSRResult['propagation'], modInfo: ModuleInfo | null) {
if(modInfo) {
const astro = getAstroMetadata(modInfo);
if(astro && astro.propagation) {
map.set(modInfo.id, astro.propagation)
}
}
}

View file

@ -3,48 +3,23 @@ import type {
AstroSettings,
ComponentInstance,
RouteData,
RuntimeMode,
SSRElement,
SSRLoadedRenderer,
} from '../../../@types/astro';
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { enhanceViteSSRError } from '../../errors/dev/index.js';
import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js';
import { LogOptions } from '../../logger/core.js';
import type { ModuleLoader } from '../../module-loader/index';
import { isPage, resolveIdToUrl } from '../../util.js';
import { createRenderContext, renderPage as coreRenderPage } from '../index.js';
import { filterFoundRenderers, loadRenderer } from '../renderer.js';
import { RouteCache } from '../route-cache.js';
import { getStylesForURL } from './css.js';
import type { DevelopmentEnvironment } from './environment';
import { getScriptsForURL } from './scripts.js';
import { getPropagationMap } from './head.js';
export { createDevelopmentEnvironment } from './environment.js';
export type { DevelopmentEnvironment };
export interface SSROptionsOld {
/** an instance of the AstroSettings */
settings: AstroSettings;
/** location of file on disk */
filePath: URL;
/** logging options */
logging: LogOptions;
/** "development" or "production" */
mode: RuntimeMode;
/** production website */
origin: string;
/** the web request (needed for dynamic routes) */
pathname: string;
/** optional, in case we need to render something outside of a dev server */
route?: RouteData;
/** pass in route cache because SSR cant manage cache-busting */
routeCache: RouteCache;
/** Module loader (Vite) */
loader: ModuleLoader;
/** Request */
request: Request;
}
export interface SSROptions {
/** The environment instance */
env: DevelopmentEnvironment;
@ -163,7 +138,9 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)
});
});
return { scripts, styles, links };
const propagationMap = await getPropagationMap(filePath, env.loader);
return { scripts, styles, links, propagationMap };
}
export async function renderPage(options: SSROptions): Promise<Response> {
@ -173,7 +150,7 @@ export async function renderPage(options: SSROptions): Promise<Response> {
// The new instances are passed through.
options.env.renderers = renderers;
const { scripts, links, styles } = await getScriptsAndStyles({
const { scripts, links, styles, propagationMap } = await getScriptsAndStyles({
env: options.env,
filePath: options.filePath,
});
@ -185,6 +162,7 @@ export async function renderPage(options: SSROptions): Promise<Response> {
scripts,
links,
styles,
propagation: propagationMap,
route: options.route,
});

View file

@ -45,6 +45,7 @@ export interface CreateResultArgs {
links?: Set<SSRElement>;
scripts?: Set<SSRElement>;
styles?: Set<SSRElement>;
propagation?: SSRResult['propagation'];
request: Request;
status: number;
}
@ -154,6 +155,9 @@ export function createResult(args: CreateResultArgs): SSRResult {
styles: args.styles ?? new Set<SSRElement>(),
scripts: args.scripts ?? new Set<SSRElement>(),
links: args.links ?? new Set<SSRElement>(),
propagation: args.propagation ?? new Map(),
propagators: new Map(),
extraHead: [],
cookies,
/** This function returns the `Astro` faux-global */
createAstro(

View file

@ -145,6 +145,7 @@ export default function astroJSX(): PluginObj {
clientOnlyComponents: [],
hydratedComponents: [],
scripts: [],
propagation: 'none',
};
}
path.node.body.splice(

View file

@ -0,0 +1,29 @@
import type { PropagationHint } from '../../@types/astro';
import type { AstroComponentFactory } from './render/index.js';
function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string) {
// Add a flag to this callback to mark it as an Astro component
cb.isAstroComponentFactory = true;
cb.moduleId = moduleId;
return cb;
}
interface CreateComponentOptions {
factory: AstroComponentFactory;
moduleId?: string;
propagation?: PropagationHint;
}
function createComponentWithOptions(opts: CreateComponentOptions) {
const cb = baseCreateComponent(opts.factory, opts.moduleId);
cb.propagation = opts.propagation;
return cb;
}
// Used in creating the component. aka the main export.
export function createComponent(arg1: AstroComponentFactory, moduleId: string) {
if(typeof arg1 === 'function') {
return baseCreateComponent(arg1, moduleId);
} else {
return createComponentWithOptions(arg1);
}
}

View file

@ -39,6 +39,7 @@ export function createAstro(
fetchContent: createDeprecatedFetchContentFn(),
glob: createAstroGlobFn(),
// INVESTIGATE is there a use-case for multi args?
// TODO remove in 2.0
resolve(...segments: string[]) {
let resolved = segments.reduce((u, segment) => new URL(segment, u), referenceURL).pathname;
// When inside of project root, remove the leading path so you are

View file

@ -4,11 +4,13 @@ export { escapeHTML, HTMLBytes, HTMLString, markHTMLString, unescapeHTML } from
export { renderJSX } from './jsx.js';
export {
addAttribute,
createHeadAndContent,
defineScriptVars,
Fragment,
maybeRenderHead,
renderAstroComponent,
renderAstroTemplateResult as renderAstroComponent,
renderComponent,
renderComponentToIterable,
Renderer as Renderer,
renderHead,
renderHTMLElement,
@ -16,26 +18,18 @@ export {
renderSlot,
renderTemplate as render,
renderTemplate,
renderUniqueStylesheet,
renderToString,
stringifyChunk,
voidElementNames,
} from './render/index.js';
export type { AstroComponentFactory, RenderInstruction } from './render/index.js';
import type { AstroComponentFactory } from './render/index.js';
export { createComponent } from './astro-component.js';
export type { AstroComponentFactory, AstroComponentInstance, RenderInstruction } from './render/index.js';
import { markHTMLString } from './escape.js';
import { Renderer } from './render/index.js';
import { addAttribute } from './render/index.js';
// Used in creating the component. aka the main export.
export function createComponent(cb: AstroComponentFactory) {
// Add a flag to this callback to mark it as an Astro component
// INVESTIGATE does this need to cast
(cb as any).isAstroComponentFactory = true;
return cb;
}
export function mergeSlots(...slotted: unknown[]) {
const slots: Record<string, () => any> = {};
for (const slot of slotted) {

View file

@ -5,7 +5,7 @@ import {
escapeHTML,
HTMLString,
markHTMLString,
renderComponent,
renderComponentToIterable,
renderToString,
spreadAttributes,
voidElementNames,
@ -177,7 +177,7 @@ Did you forget to import the component or is it possible there is a typo?`);
props[Skip.symbol] = skip;
let output: ComponentIterable;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
output = await renderComponent(
output = await renderComponentToIterable(
result,
vnode.props['client:display-name'] ?? '',
null,
@ -185,7 +185,7 @@ Did you forget to import the component or is it possible there is a typo?`);
slots
);
} else {
output = await renderComponent(
output = await renderComponentToIterable(
result,
typeof vnode.type === 'function' ? vnode.type.name : vnode.type,
vnode.type,

View file

@ -1,5 +1,6 @@
import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
import { AstroComponent, renderAstroComponent } from './astro.js';
import { isRenderTemplateResult, renderAstroTemplateResult } from './astro/index.js';
import { isAstroComponentInstance } from './astro/index.js';
import { SlotString } from './slot.js';
export async function* renderChild(child: any): AsyncIterable<any> {
@ -25,13 +26,10 @@ export async function* renderChild(child: any): AsyncIterable<any> {
} else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values.
}
// Add a comment explaining why each of these are needed.
// Maybe create clearly named function for what this is doing.
else if (
child instanceof AstroComponent ||
Object.prototype.toString.call(child) === '[object AstroComponent]'
) {
yield* renderAstroComponent(child);
else if(isRenderTemplateResult(child)) {
yield* renderAstroTemplateResult(child);
} else if(isAstroComponentInstance(child)) {
yield* child.render();
} else if (ArrayBuffer.isView(child)) {
yield child;
} else if (

View file

@ -1,146 +0,0 @@
import type { SSRResult } from '../../../@types/astro';
import type { AstroComponentFactory } from './index';
import type { RenderInstruction } from './types';
import { HTMLBytes, markHTMLString } from '../escape.js';
import { HydrationDirectiveProps } from '../hydration.js';
import { isPromise } from '../util.js';
import { renderChild } from './any.js';
import { HTMLParts } from './common.js';
// Issue warnings for invalid props for Astro components
function validateComponentProps(props: any, displayName: string) {
if (props != null) {
for (const prop of Object.keys(props)) {
if (HydrationDirectiveProps.has(prop)) {
// eslint-disable-next-line
console.warn(
`You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
);
}
}
}
}
// The return value when rendering a component.
// This is the result of calling render(), should this be named to RenderResult or...?
export class AstroComponent {
private htmlParts: TemplateStringsArray;
private expressions: any[];
private error: Error | undefined;
constructor(htmlParts: TemplateStringsArray, expressions: any[]) {
this.htmlParts = htmlParts;
this.error = undefined;
this.expressions = expressions.map((expression) => {
// Wrap Promise expressions so we can catch errors
// There can only be 1 error that we rethrow from an Astro component,
// so this keeps track of whether or not we have already done so.
if (isPromise(expression)) {
return Promise.resolve(expression).catch((err) => {
if (!this.error) {
this.error = err;
throw err;
}
});
}
return expression;
});
}
get [Symbol.toStringTag]() {
return 'AstroComponent';
}
async *[Symbol.asyncIterator]() {
const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
yield markHTMLString(html);
yield* renderChild(expression);
}
}
}
// Determines if a component is an .astro component
export function isAstroComponent(obj: any): obj is AstroComponent {
return (
typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object AstroComponent]'
);
}
export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {
return obj == null ? false : obj.isAstroComponentFactory === true;
}
export async function* renderAstroComponent(
component: InstanceType<typeof AstroComponent>
): AsyncIterable<string | HTMLBytes | RenderInstruction> {
for await (const value of component) {
if (value || value === 0) {
for await (const chunk of renderChild(value)) {
switch (chunk.type) {
case 'directive': {
yield chunk;
break;
}
default: {
yield markHTMLString(chunk);
break;
}
}
}
}
}
}
// Calls a component and renders it into a string of HTML
export async function renderToString(
result: SSRResult,
componentFactory: AstroComponentFactory,
props: any,
children: any
): Promise<string> {
const Component = await componentFactory(result, props, children);
if (!isAstroComponent(Component)) {
const response: Response = Component;
throw response;
}
let parts = new HTMLParts();
for await (const chunk of renderAstroComponent(Component)) {
parts.append(chunk, result);
}
return parts.toString();
}
export async function renderToIterable(
result: SSRResult,
componentFactory: AstroComponentFactory,
displayName: string,
props: any,
children: any
): Promise<AsyncIterable<string | HTMLBytes | RenderInstruction>> {
validateComponentProps(props, displayName);
const Component = await componentFactory(result, props, children);
if (!isAstroComponent(Component)) {
// eslint-disable-next-line no-console
console.warn(
`Returning a Response is only supported inside of page components. Consider refactoring this logic into something like a function that can be used in the page.`
);
const response = Component;
throw response;
}
return renderAstroComponent(Component);
}
export async function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new AstroComponent(htmlParts, expressions);
}

View file

@ -0,0 +1,53 @@
import type { SSRResult, PropagationHint } from '../../../../@types/astro';
import type { HeadAndContent } from './head-and-content';
import type { RenderTemplateResult } from './render-template';
import { renderAstroTemplateResult } from './render-template.js';
import { isHeadAndContent } from './head-and-content.js';
import { HTMLParts } from '../common.js';
export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent;
// The callback passed to to $$createComponent
export interface AstroComponentFactory {
(result: any, props: any, slots: any): AstroFactoryReturnValue;
isAstroComponentFactory?: boolean;
moduleId: string | undefined;
propagation?: PropagationHint;
}
export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {
return obj == null ? false : obj.isAstroComponentFactory === true;
}
// Calls a component and renders it into a string of HTML
export async function renderToString(
result: SSRResult,
componentFactory: AstroComponentFactory,
props: any,
children: any
): Promise<string> {
const factoryResult = await componentFactory(result, props, children);
if (factoryResult instanceof Response) {
const response = factoryResult;
throw response;
}
let parts = new HTMLParts();
const templateResult = isHeadAndContent(factoryResult) ? factoryResult.content : factoryResult;
for await (const chunk of renderAstroTemplateResult(templateResult)) {
parts.append(chunk, result);
}
return parts.toString();
}
export function isAPropagatingComponent(result: SSRResult, factory: AstroComponentFactory): boolean {
let hint: PropagationHint = factory.propagation || 'none';
if(factory.moduleId && result.propagation.has(factory.moduleId) && hint === 'none') {
hint = result.propagation.get(factory.moduleId)!;
}
return hint === 'in-tree' || hint === 'self';
}

View file

@ -0,0 +1,24 @@
import type { RenderTemplateResult } from './render-template';
const headAndContentSym = Symbol.for('astro.headAndContent');
export type HeadAndContent = {
[headAndContentSym]: true;
head: string | RenderTemplateResult;
content: RenderTemplateResult;
}
export function isHeadAndContent(obj: unknown): obj is HeadAndContent {
return typeof obj === 'object' && !!((obj as any)[headAndContentSym]);
}
export function createHeadAndContent(
head: string | RenderTemplateResult,
content: RenderTemplateResult
): HeadAndContent {
return {
[headAndContentSym]: true,
head,
content
}
}

View file

@ -0,0 +1,25 @@
export {
createAstroComponentInstance,
isAstroComponentInstance
} from './instance.js';
export {
isAstroComponentFactory,
renderToString
} from './factory.js';
export {
isRenderTemplateResult,
renderAstroTemplateResult,
renderTemplate
} from './render-template.js';
export {
isHeadAndContent,
createHeadAndContent
} from './head-and-content.js';
export type {
AstroComponentFactory
} from './factory';
export type {
AstroComponentInstance
} from './instance';

View file

@ -0,0 +1,82 @@
import type { SSRResult } from '../../../../@types/astro';
import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
import { HydrationDirectiveProps } from '../../hydration.js';
import { renderChild } from '../any.js';
import { isHeadAndContent } from './head-and-content.js';
import { isAPropagatingComponent } from './factory.js';
import { isPromise } from '../../util.js';
type ComponentProps = Record<string | number, any>;
const astroComponentInstanceSym = Symbol.for('astro.componentInstance');
export class AstroComponentInstance {
[astroComponentInstanceSym] = true;
private readonly result: SSRResult;
private readonly props: ComponentProps;
private readonly slots: any;
private readonly factory: AstroComponentFactory;
private returnValue: ReturnType<AstroComponentFactory> | undefined;
constructor(result: SSRResult, props: ComponentProps, slots: any, factory: AstroComponentFactory) {
this.result = result;
this.props = props;
this.slots = slots;
this.factory = factory;
}
async init() {
this.returnValue = this.factory(this.result, this.props, this.slots);
return this.returnValue;
}
async *render() {
if(this.returnValue === undefined) {
await this.init();
}
let value: AstroFactoryReturnValue | undefined = this.returnValue;
if(isPromise(value)) {
value = await value;
}
if(isHeadAndContent(value)) {
yield * value.content;
} else {
yield * renderChild(value);
}
}
}
// Issue warnings for invalid props for Astro components
function validateComponentProps(props: any, displayName: string) {
if (props != null) {
for (const prop of Object.keys(props)) {
if (HydrationDirectiveProps.has(prop)) {
// eslint-disable-next-line
console.warn(
`You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
);
}
}
}
}
export function createAstroComponentInstance(
result: SSRResult,
displayName: string,
factory: AstroComponentFactory,
props: ComponentProps,
slots: any = {}
) {
validateComponentProps(props, displayName);
const instance = new AstroComponentInstance(result, props, slots, factory);
if(isAPropagatingComponent(result, factory) && !result.propagators.has(factory)) {
result.propagators.set(factory, instance);
}
return instance;
}
export function isAstroComponentInstance(obj: unknown): obj is AstroComponentInstance {
return typeof obj === 'object' && !!((obj as any)[astroComponentInstanceSym]);
}

View file

@ -0,0 +1,83 @@
import type { RenderInstruction } from '../types';
import { HTMLBytes, markHTMLString } from '../../escape.js';
import { isPromise } from '../../util.js';
import { renderChild } from '../any.js';
const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
// The return value when rendering a component.
// This is the result of calling render(), should this be named to RenderResult or...?
export class RenderTemplateResult {
public [renderTemplateResultSym] = true;
private htmlParts: TemplateStringsArray;
private expressions: any[];
private error: Error | undefined;
constructor(htmlParts: TemplateStringsArray, expressions: unknown[]) {
this.htmlParts = htmlParts;
this.error = undefined;
this.expressions = expressions.map((expression) => {
// Wrap Promise expressions so we can catch errors
// There can only be 1 error that we rethrow from an Astro component,
// so this keeps track of whether or not we have already done so.
if (isPromise(expression)) {
return Promise.resolve(expression).catch((err) => {
if (!this.error) {
this.error = err;
throw err;
}
});
}
return expression;
});
}
// TODO this is legacy and should be removed in 2.0
get [Symbol.toStringTag]() {
return 'AstroComponent';
}
async *[Symbol.asyncIterator]() {
const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
yield markHTMLString(html);
yield* renderChild(expression);
}
}
}
// Determines if a component is an .astro component
export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResult {
return (
typeof obj === 'object' && !!((obj as any)[renderTemplateResultSym])
);
}
export async function* renderAstroTemplateResult(
component: RenderTemplateResult
): AsyncIterable<string | HTMLBytes | RenderInstruction> {
for await (const value of component) {
if (value || value === 0) {
for await (const chunk of renderChild(value)) {
switch (chunk.type) {
case 'directive': {
yield chunk;
break;
}
default: {
yield markHTMLString(chunk);
break;
}
}
}
}
}
}
export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new RenderTemplateResult(htmlParts, expressions);
}

View file

@ -12,15 +12,18 @@ import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js';
import {
createAstroComponentInstance,
isAstroComponentFactory,
renderAstroComponent,
isAstroComponentInstance,
renderAstroTemplateResult,
renderTemplate,
renderToIterable,
} from './astro.js';
type AstroComponentInstance
} from './astro/index.js';
import { Fragment, Renderer, stringifyChunk } from './common.js';
import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
import { renderSlot, renderSlots } from './slot.js';
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
import { isPromise } from '../util.js';
const rendererAliases = new Map([['solid', 'solid-js']]);
@ -45,65 +48,25 @@ function guessRenderers(componentUrl?: string): string[] {
}
}
type ComponentType = 'fragment' | 'html' | 'astro-factory' | 'unknown';
export type ComponentIterable = AsyncIterable<string | HTMLBytes | RenderInstruction>;
function getComponentType(Component: unknown): ComponentType {
if (Component === Fragment) {
return 'fragment';
}
if (Component && typeof Component === 'object' && (Component as any)['astro:html']) {
return 'html';
}
if (isAstroComponentFactory(Component)) {
return 'astro-factory';
}
return 'unknown';
function isFragmentComponent(Component: unknown) {
return Component === Fragment;
}
export async function renderComponent(
function isHTMLComponent(Component: unknown) {
return (
Component && typeof Component === 'object' && (Component as any)['astro:html']
);
}
async function renderFrameworkComponent(
result: SSRResult,
displayName: string,
Component: unknown,
_props: Record<string | number, any>,
slots: any = {},
route?: RouteData | undefined
): Promise<ComponentIterable> {
Component = (await Component) ?? Component;
switch (getComponentType(Component)) {
case 'fragment': {
const children = await renderSlot(result, slots?.default);
if (children == null) {
return children;
}
return markHTMLString(children);
}
// .html components
case 'html': {
const { slotInstructions, children } = await renderSlots(result, slots);
const html = (Component as any).render({ slots: children });
const hydrationHtml = slotInstructions
? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('')
: '';
return markHTMLString(hydrationHtml + html);
}
case 'astro-factory': {
async function* renderAstroComponentInline(): AsyncGenerator<
string | HTMLBytes | RenderInstruction,
void,
undefined
> {
let iterable = await renderToIterable(result, Component as any, displayName, _props, slots);
yield* iterable;
}
return renderAstroComponentInline();
}
}
if (!Component && !_props['client:only']) {
throw new Error(
`Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`
@ -284,7 +247,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') {
const childSlots = Object.values(children).join('');
const iterable = renderAstroComponent(
const iterable = renderAstroTemplateResult(
await renderTemplate`<${Component}${internalSpreadAttributes(props)}${markHTMLString(
childSlots === '' && voidElementNames.test(Component)
? `/>`
@ -365,3 +328,68 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
return renderAll();
}
async function renderFragmentComponent(result: SSRResult, slots: any = {}) {
const children = await renderSlot(result, slots?.default);
if (children == null) {
return children;
}
return markHTMLString(children);
}
async function renderHTMLComponent(
result: SSRResult,
Component: unknown,
_props: Record<string | number, any>,
slots: any = {}
) {
const { slotInstructions, children } = await renderSlots(result, slots);
const html = (Component as any).render({ slots: children });
const hydrationHtml = slotInstructions
? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('')
: '';
return markHTMLString(hydrationHtml + html);
}
export function renderComponent(
result: SSRResult,
displayName: string,
Component: unknown,
props: Record<string | number, any>,
slots: any = {}
): Promise<ComponentIterable> | ComponentIterable | AstroComponentInstance {
if(isPromise(Component)) {
return Promise.resolve(Component).then(Unwrapped => {
return renderComponent(result, displayName, Unwrapped, props, slots) as any;
});
}
if(isFragmentComponent(Component)) {
return renderFragmentComponent(result, slots);
}
// .html components
if(isHTMLComponent(Component)) {
return renderHTMLComponent(result, Component, props, slots);
}
if(isAstroComponentFactory(Component)) {
return createAstroComponentInstance(result, displayName, Component, props, slots);
}
return renderFrameworkComponent(result, displayName, Component, props, slots);
}
export function renderComponentToIterable(
result: SSRResult,
displayName: string,
Component: unknown,
props: Record<string | number, any>,
slots: any = {}
): Promise<ComponentIterable> | ComponentIterable {
const renderResult = renderComponent(result, displayName, Component, props, slots);
if(isAstroComponentInstance(renderResult)) {
return renderResult.render();
}
return renderResult;
}

View file

@ -2,6 +2,7 @@ import type { SSRResult } from '../../../@types/astro';
import { markHTMLString } from '../escape.js';
import { renderElement } from './util.js';
import { renderChild } from './any.js';
// Filter out duplicate elements in our set
const uniqueElements = (item: any, index: number, all: any[]) => {
@ -12,8 +13,14 @@ const uniqueElements = (item: any, index: number, all: any[]) => {
);
};
export function renderHead(result: SSRResult): Promise<string> {
result._metadata.hasRenderedHead = true;
async function * renderExtraHead(result: SSRResult, base: string) {
yield base;
for(const part of result.extraHead) {
yield * renderChild(part);
}
}
function renderAllHeadContent(result: SSRResult) {
const styles = Array.from(result.styles)
.filter(uniqueElements)
.map((style) => renderElement('style', style));
@ -27,16 +34,30 @@ export function renderHead(result: SSRResult): Promise<string> {
const links = Array.from(result.links)
.filter(uniqueElements)
.map((link) => renderElement('link', link, false));
return markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n'));
const baseHeadContent = markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n'))
if(result.extraHead.length > 0) {
return renderExtraHead(result, baseHeadContent);
} else {
return baseHeadContent;
}
}
export function createRenderHead(result: SSRResult) {
result._metadata.hasRenderedHead = true;
return renderAllHeadContent.bind(null, result);
}
export const renderHead = createRenderHead;
// This function is called by Astro components that do not contain a <head> component
// This accommodates the fact that using a <head> is optional in Astro, so this
// is called before a component's first non-head HTML element. If the head was
// already injected it is a noop.
export async function* maybeRenderHead(result: SSRResult): AsyncIterable<string> {
export async function* maybeRenderHead(result: SSRResult) {
if (result._metadata.hasRenderedHead) {
return;
}
yield renderHead(result);
yield createRenderHead(result)();
}

View file

@ -1,17 +1,12 @@
import { renderTemplate } from './astro.js';
export type { RenderInstruction } from './types';
export type { AstroComponentFactory, AstroComponentInstance } from './astro/index';
export { renderAstroComponent, renderTemplate, renderToString } from './astro.js';
export { createHeadAndContent, renderAstroTemplateResult, renderToString, renderTemplate } from './astro/index.js';
export { Fragment, Renderer, stringifyChunk } from './common.js';
export { renderComponent } from './component.js';
export { renderComponent, renderComponentToIterable } from './component.js';
export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js';
export { renderSlot } from './slot.js';
export type { RenderInstruction } from './types';
export { addAttribute, defineScriptVars, voidElementNames } from './util.js';
// The callback passed to to $$createComponent
export interface AstroComponentFactory {
(result: any, props: any, slots: any): ReturnType<typeof renderTemplate> | Response;
isAstroComponentFactory?: boolean;
}
export { renderUniqueStylesheet } from './stylesheet.js';

View file

@ -5,7 +5,13 @@ import type { AstroComponentFactory } from './index';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { isHTMLString } from '../escape.js';
import { createResponse } from '../response.js';
import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from './astro.js';
import {
isAstroComponentFactory,
isAstroComponentInstance,
isRenderTemplateResult,
isHeadAndContent,
renderAstroTemplateResult
} from './astro/index.js';
import { chunkToByteArray, encoder, HTMLParts } from './common.js';
import { renderComponent } from './component.js';
import { maybeRenderHead } from './head.js';
@ -45,6 +51,22 @@ async function iterableToHTMLBytes(
return parts.toArrayBuffer();
}
// Recursively calls component instances that might have head content
// to be propagated up.
async function bufferHeadContent(result: SSRResult) {
const iterator = result.propagators.values();
while(true) {
const { value, done } = iterator.next();
if(done) {
break;
}
const returnValue = await value.init();
if(isHeadAndContent(returnValue)) {
result.extraHead.push(returnValue.head);
}
}
}
export async function renderPage(
result: SSRResult,
componentFactory: AstroComponentFactory | NonAstroPageComponent,
@ -57,16 +79,19 @@ export async function renderPage(
const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
let output: ComponentIterable;
try {
output = await renderComponent(
const renderResult = await renderComponent(
result,
componentFactory.name,
componentFactory,
pageProps,
null,
route
);
if(isAstroComponentInstance(renderResult)) {
output = renderResult.render();
} else {
output = renderResult;
}
} catch (e) {
if (AstroError.is(e) && !e.loc) {
e.setLocation({
@ -94,9 +119,13 @@ export async function renderPage(
});
}
const factoryReturnValue = await componentFactory(result, props, children);
const factoryIsHeadAndContent = isHeadAndContent(factoryReturnValue);
if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) {
// Wait for head content to be buffered up
await bufferHeadContent(result);
const templateResult = factoryIsHeadAndContent ? factoryReturnValue.content : factoryReturnValue;
if (isAstroComponent(factoryReturnValue)) {
let iterable = renderAstroComponent(factoryReturnValue);
let iterable = renderAstroTemplateResult(templateResult);
let init = result.response;
let headers = new Headers(init.headers);
let body: BodyInit;

View file

@ -0,0 +1,25 @@
import { SSRResult } from '../../../@types/astro';
import { renderElement } from './util.js';
import { markHTMLString } from '../escape.js';
const stylesheetRel = 'stylesheet';
export function renderStylesheet({ href }: { href: string }) {
return markHTMLString(renderElement('link', {
props: {
rel: stylesheetRel,
href
},
children: ''
}, false));
}
export function renderUniqueStylesheet(result: SSRResult, link: { href: string }) {
for (const existingLink of result.links) {
if(existingLink.props.rel === stylesheetRel && existingLink.props.href === link.href) {
return '';
}
}
return renderStylesheet(link);
}

View file

@ -18,6 +18,8 @@ import { normalizeFilename } from '../vite-plugin-utils/index.js';
import { cachedFullCompilation } from './compile.js';
import { handleHotUpdate } from './hmr.js';
import { parseAstroRequest, ParsedRequestResult } from './query.js';
export type { AstroPluginMetadata };
export { getAstroMetadata } from './metadata.js';
interface AstroPluginOptions {
settings: AstroSettings;
@ -108,6 +110,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
if (!compileResult) {
return null;
}
switch (query.type) {
case 'style': {
if (typeof query.index === 'undefined') {
@ -198,6 +201,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
astroConfig: config,
viteConfig: resolvedConfig,
filename,
id,
source,
};
@ -215,6 +219,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none',
};
return {
@ -236,6 +241,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
astroConfig: config,
viteConfig: resolvedConfig,
filename: context.file,
id: context.modules[0]?.id ?? undefined,
source: await context.read(),
};
const compile = () => cachedCompilation(compileProps);

View file

@ -0,0 +1,9 @@
import type { PluginMetadata } from './types';
import type { ModuleInfo } from '../core/module-loader';
export function getAstroMetadata(modInfo: ModuleInfo): PluginMetadata['astro'] | undefined {
if(modInfo.meta?.astro) {
return modInfo.meta.astro as PluginMetadata['astro'];
}
return undefined;
}

View file

@ -1,9 +1,11 @@
import type { TransformResult } from '@astrojs/compiler';
import type { PropagationHint } from '../@types/astro';
export interface PluginMetadata {
astro: {
hydratedComponents: TransformResult['hydratedComponents'];
clientOnlyComponents: TransformResult['clientOnlyComponents'];
scripts: TransformResult['scripts'];
propagation: PropagationHint;
};
}

View file

@ -0,0 +1,54 @@
import type { AstroSettings } from '../@types/astro';
import type { ModuleInfo } from 'rollup';
import * as vite from 'vite';
import { getAstroMetadata } from '../vite-plugin-astro/index.js';
const injectExp = /^\/\/\s*astro-head-inject/;
/**
* If any component is marked as doing head injection, walk up the tree
* and mark parent Astro components as having head injection in the tree.
* This is used at runtime to determine if we should wait for head content
* to be be populated before rendering the entire tree.
*/
export default function configHeadPropagationVitePlugin({
settings,
}: {
settings: AstroSettings;
}): vite.Plugin {
function addHeadInjectionInTree(graph: vite.ModuleGraph, id: string, getInfo: (id: string) => ModuleInfo | null, seen: Set<string> = new Set()) {
const mod = server.moduleGraph.getModuleById(id);
for(const parent of mod?.importers || []) {
if(parent.id) {
if(seen.has(parent.id)) {
continue;
}
const info = getInfo(parent.id);
if(info?.meta.astro) {
const astroMetadata = getAstroMetadata(info);
if(astroMetadata) {
astroMetadata.propagation = 'in-tree';
}
}
addHeadInjectionInTree(graph, parent.id, getInfo, seen);
}
}
}
let server: vite.ViteDevServer;
return {
name: 'astro:head-propagation',
configureServer(_server) {
server = _server;
},
transform(source, id) {
if(!server) {
return;
}
if(injectExp.test(source)) {
addHeadInjectionInTree(server.moduleGraph, id, (child) => this.getModuleInfo(child));
}
}
};
}

View file

@ -44,7 +44,7 @@ export default function loadFallbackPlugin({
async resolveId(id, parent) {
// See if this can be loaded from our fs
if (parent) {
const candidateId = npath.posix.join(npath.posix.dirname(parent), id);
const candidateId = npath.posix.join(npath.posix.dirname(slashify(parent)), id);
try {
// Check to see if this file exists and is not a directory.
const stats = await fs.promises.stat(candidateId);

View file

@ -207,6 +207,7 @@ ${setup}`.trim();
viteConfig: resolvedConfig,
filename,
source: astroResult,
id,
};
let transformResult = await cachedCompilation(compileProps);
@ -232,6 +233,7 @@ ${tsResult}`;
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none'
};
return {

View file

@ -157,6 +157,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
hydratedComponents: [],
clientOnlyComponents: [],
scripts: [],
propagation: 'none',
} as PluginMetadata['astro'],
vite: {
lang: 'ts',

View file

@ -0,0 +1,160 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { runInContainer } from '../../../dist/core/dev/index.js';
import { createFs, createRequestAndResponse } from '../test-utils.js';
const root = new URL('../../fixtures/alias/', import.meta.url);
describe('head injection', () => {
it('Dynamic injection from component created in the page frontmatter', async () => {
const fs = createFs(
{
'/src/components/Other.astro': `
<style>
div {
background: grey;
}
</style>
<div id="other">Other</div>
`,
'/src/common/head.js': `
// astro-head-inject
import Other from '../components/Other.astro';
import {
createComponent,
createHeadAndContent,
renderComponent,
renderTemplate,
renderUniqueStylesheet,
} from 'astro/runtime/server/index.js';
export function renderEntry() {
return createComponent({
factory(result, props, slots) {
return createHeadAndContent(
renderUniqueStylesheet(result, {
href: '/some/fake/styles.css'
}),
renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\`
);
},
propagation: 'self'
});
}
`.trim(),
'/src/pages/index.astro': `
---
import { renderEntry } from '../common/head.js';
const Head = renderEntry();
---
<h1>testing</h1>
<Head />
`,
},
root
);
await runInContainer({
fs, root,
userConfig: {
vite: { server: { middlewareMode: true } }
}
}, async (container) => {
const { req, res, done, text } = createRequestAndResponse({
method: 'GET',
url: '/',
});
container.handle(req, res);
await done;
const html = await text();
const $ = cheerio.load(html);
expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1);
expect($('#other')).to.have.a.lengthOf(1);
});
});
it('Dynamic injection from a layout component', async () => {
const fs = createFs(
{
'/src/components/Other.astro': `
<style>
div {
background: grey;
}
</style>
<div id="other">Other</div>
`,
'/src/common/head.js': `
// astro-head-inject
import Other from '../components/Other.astro';
import {
createComponent,
createHeadAndContent,
renderComponent,
renderTemplate,
renderUniqueStylesheet,
} from 'astro/runtime/server/index.js';
export function renderEntry() {
return createComponent({
factory(result, props, slots) {
return createHeadAndContent(
renderUniqueStylesheet(result, {
href: '/some/fake/styles.css'
}),
renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\`
);
},
propagation: 'self'
});
}
`.trim(),
'/src/components/Layout.astro': `
---
import { renderEntry } from '../common/head.js';
const ExtraHead = renderEntry();
---
<html>
<head>
<title>Normal head stuff</title>
</head>
<body>
<slot />
<ExtraHead />
</body>
</html>
`,
'/src/pages/index.astro': `
---
import Layout from '../components/Layout.astro';
---
<Layout>
<h1>Test page</h1>
</Layout>
`,
},
root
);
await runInContainer({
fs, root,
userConfig: {
vite: { server: { middlewareMode: true } }
}
}, async (container) => {
const { req, res, done, text } = createRequestAndResponse({
method: 'GET',
url: '/',
});
container.handle(req, res);
await done;
const html = await text();
const $ = cheerio.load(html);
expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1);
expect($('#other')).to.have.a.lengthOf(1);
});
});
});

View file

@ -373,7 +373,7 @@ importers:
packages/astro:
specifiers:
'@astrojs/compiler': ^0.29.15
'@astrojs/compiler': ^0.30.0
'@astrojs/language-server': ^0.28.3
'@astrojs/markdown-remark': ^1.1.3
'@astrojs/telemetry': ^1.0.1
@ -471,7 +471,7 @@ importers:
yargs-parser: ^21.0.1
zod: ^3.17.3
dependencies:
'@astrojs/compiler': 0.29.15
'@astrojs/compiler': 0.30.0
'@astrojs/language-server': 0.28.3
'@astrojs/markdown-remark': link:../markdown/remark
'@astrojs/telemetry': link:../telemetry
@ -3893,6 +3893,10 @@ packages:
/@astrojs/compiler/0.29.15:
resolution: {integrity: sha512-vicPD8oOPNkcFZvz71Uz/nJcadovurUQ3L0yMZNPb6Nn6T1nHhlSHt5nAKaurB2pYU9DrxOFWZS2/RdV+JsWmQ==}
/@astrojs/compiler/0.30.0:
resolution: {integrity: sha512-av2HV5NuyzI5E12hpn4k7XNEHbfF81/JUISPu6CclC5yKCxTS7z64hRU68tA8k7dYLATcxvjQtvN2H/dnxHaMw==}
dev: false
/@astrojs/language-server/0.28.3:
resolution: {integrity: sha512-fPovAX/X46eE2w03jNRMpQ7W9m2mAvNt4Ay65lD9wl1Z5vIQYxlg7Enp9qP225muTr4jSVB5QiLumFJmZMAaVA==}
hasBin: true