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:
parent
b137657699
commit
05915fec01
36 changed files with 804 additions and 279 deletions
7
.changeset/cool-jobs-draw.md
Normal file
7
.changeset/cool-jobs-draw.md
Normal 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.
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
34
packages/astro/src/core/render/dev/head.ts
Normal file
34
packages/astro/src/core/render/dev/head.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 can’t 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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -145,6 +145,7 @@ export default function astroJSX(): PluginObj {
|
|||
clientOnlyComponents: [],
|
||||
hydratedComponents: [],
|
||||
scripts: [],
|
||||
propagation: 'none',
|
||||
};
|
||||
}
|
||||
path.node.body.splice(
|
||||
|
|
29
packages/astro/src/runtime/server/astro-component.ts
Normal file
29
packages/astro/src/runtime/server/astro-component.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
}
|
53
packages/astro/src/runtime/server/render/astro/factory.ts
Normal file
53
packages/astro/src/runtime/server/render/astro/factory.ts
Normal 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';
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
25
packages/astro/src/runtime/server/render/astro/index.ts
Normal file
25
packages/astro/src/runtime/server/render/astro/index.ts
Normal 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';
|
82
packages/astro/src/runtime/server/render/astro/instance.ts
Normal file
82
packages/astro/src/runtime/server/render/astro/instance.ts
Normal 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]);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)();
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
25
packages/astro/src/runtime/server/render/stylesheet.ts
Normal file
25
packages/astro/src/runtime/server/render/stylesheet.ts
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
|
|
9
packages/astro/src/vite-plugin-astro/metadata.ts
Normal file
9
packages/astro/src/vite-plugin-astro/metadata.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
54
packages/astro/src/vite-plugin-head-propagation/index.ts
Normal file
54
packages/astro/src/vite-plugin-head-propagation/index.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -157,6 +157,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
hydratedComponents: [],
|
||||
clientOnlyComponents: [],
|
||||
scripts: [],
|
||||
propagation: 'none',
|
||||
} as PluginMetadata['astro'],
|
||||
vite: {
|
||||
lang: 'ts',
|
||||
|
|
160
packages/astro/test/units/dev/head-injection.test.js
Normal file
160
packages/astro/test/units/dev/head-injection.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue