Refactor runtime (#4201)
* Refactor runtime * Add back in generator change * Adding a changeset * Fix build
This commit is contained in:
parent
f207c417e0
commit
25d36d9558
15 changed files with 1070 additions and 952 deletions
5
.changeset/shy-dogs-return.md
Normal file
5
.changeset/shy-dogs-return.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds warning in dev when using client: directive on Astro component
|
54
packages/astro/src/runtime/server/astro-global.ts
Normal file
54
packages/astro/src/runtime/server/astro-global.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { AstroGlobalPartial } from '../../@types/astro';
|
||||||
|
|
||||||
|
// process.env.PACKAGE_VERSION is injected when we build and publish the astro package.
|
||||||
|
const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development';
|
||||||
|
|
||||||
|
/** Create the Astro.fetchContent() runtime function. */
|
||||||
|
function createDeprecatedFetchContentFn() {
|
||||||
|
return () => {
|
||||||
|
throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the Astro.glob() runtime function. */
|
||||||
|
function createAstroGlobFn() {
|
||||||
|
const globHandler = (importMetaGlobResult: Record<string, any>, globValue: () => any) => {
|
||||||
|
let allEntries = [...Object.values(importMetaGlobResult)];
|
||||||
|
if (allEntries.length === 0) {
|
||||||
|
throw new Error(`Astro.glob(${JSON.stringify(globValue())}) - no matches found.`);
|
||||||
|
}
|
||||||
|
// Map over the `import()` promises, calling to load them.
|
||||||
|
return Promise.all(allEntries.map((fn) => fn()));
|
||||||
|
};
|
||||||
|
// Cast the return type because the argument that the user sees (string) is different from the argument
|
||||||
|
// that the runtime sees post-compiler (Record<string, Module>).
|
||||||
|
return globHandler as unknown as AstroGlobalPartial['glob'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used to create the top-level Astro global; the one that you can use
|
||||||
|
// Inside of getStaticPaths.
|
||||||
|
export function createAstro(
|
||||||
|
filePathname: string,
|
||||||
|
_site: string | undefined,
|
||||||
|
projectRootStr: string
|
||||||
|
): AstroGlobalPartial {
|
||||||
|
const site = _site ? new URL(_site) : undefined;
|
||||||
|
const referenceURL = new URL(filePathname, `http://localhost`);
|
||||||
|
const projectRoot = new URL(projectRootStr);
|
||||||
|
return {
|
||||||
|
site,
|
||||||
|
generator: `Astro v${ASTRO_VERSION}`,
|
||||||
|
fetchContent: createDeprecatedFetchContentFn(),
|
||||||
|
glob: createAstroGlobFn(),
|
||||||
|
// INVESTIGATE is there a use-case for multi args?
|
||||||
|
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
|
||||||
|
// left with only `/src/images/tower.png`
|
||||||
|
if (resolved.startsWith(projectRoot.pathname)) {
|
||||||
|
resolved = '/' + resolved.slice(projectRoot.pathname.length);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
74
packages/astro/src/runtime/server/endpoint.ts
Normal file
74
packages/astro/src/runtime/server/endpoint.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
|
||||||
|
import type {
|
||||||
|
APIContext,
|
||||||
|
EndpointHandler,
|
||||||
|
Params
|
||||||
|
} from '../../@types/astro';
|
||||||
|
|
||||||
|
function getHandlerFromModule(mod: EndpointHandler, method: string) {
|
||||||
|
// If there was an exact match on `method`, return that function.
|
||||||
|
if (mod[method]) {
|
||||||
|
return mod[method];
|
||||||
|
}
|
||||||
|
// Handle `del` instead of `delete`, since `delete` is a reserved word in JS.
|
||||||
|
if (method === 'delete' && mod['del']) {
|
||||||
|
return mod['del'];
|
||||||
|
}
|
||||||
|
// If a single `all` handler was used, return that function.
|
||||||
|
if (mod['all']) {
|
||||||
|
return mod['all'];
|
||||||
|
}
|
||||||
|
// Otherwise, no handler found.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders an endpoint request to completion, returning the body. */
|
||||||
|
export async function renderEndpoint(mod: EndpointHandler, request: Request, params: Params) {
|
||||||
|
const chosenMethod = request.method?.toLowerCase();
|
||||||
|
const handler = getHandlerFromModule(mod, chosenMethod);
|
||||||
|
if (!handler || typeof handler !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Endpoint handler not found! Expected an exported function for "${chosenMethod}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handler.length > 1) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`
|
||||||
|
API routes with 2 arguments have been deprecated. Instead they take a single argument in the form of:
|
||||||
|
|
||||||
|
export function get({ params, request }) {
|
||||||
|
//...
|
||||||
|
}
|
||||||
|
|
||||||
|
Update your code to remove this warning.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
request,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxy = new Proxy(context, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop in target) {
|
||||||
|
return Reflect.get(target, prop);
|
||||||
|
} else if (prop in params) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`
|
||||||
|
API routes no longer pass params as the first argument. Instead an object containing a params property is provided in the form of:
|
||||||
|
|
||||||
|
export function get({ params }) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
Update your code to remove this warning.`);
|
||||||
|
return Reflect.get(params, prop);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as APIContext & Params;
|
||||||
|
|
||||||
|
return handler.call(mod, proxy, request);
|
||||||
|
}
|
|
@ -8,7 +8,9 @@ import { escapeHTML } from './escape.js';
|
||||||
import { serializeProps } from './serialize.js';
|
import { serializeProps } from './serialize.js';
|
||||||
import { serializeListValue } from './util.js';
|
import { serializeListValue } from './util.js';
|
||||||
|
|
||||||
const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];
|
const HydrationDirectivesRaw = ['load', 'idle', 'media', 'visible', 'only'];
|
||||||
|
const HydrationDirectives = new Set(HydrationDirectivesRaw);
|
||||||
|
export const HydrationDirectiveProps = new Set(HydrationDirectivesRaw.map(n => `client:${n}`));
|
||||||
|
|
||||||
export interface HydrationMetadata {
|
export interface HydrationMetadata {
|
||||||
directive: string;
|
directive: string;
|
||||||
|
@ -68,11 +70,9 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
|
||||||
extracted.hydration.value = value;
|
extracted.hydration.value = value;
|
||||||
|
|
||||||
// throw an error if an invalid hydration directive was provided
|
// throw an error if an invalid hydration directive was provided
|
||||||
if (HydrationDirectives.indexOf(extracted.hydration.directive) < 0) {
|
if (!HydrationDirectives.has(extracted.hydration.directive)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error: invalid hydration directive "${key}". Supported hydration methods: ${HydrationDirectives.map(
|
`Error: invalid hydration directive "${key}". Supported hydration methods: ${Array.from(HydrationDirectiveProps).join(', ')}`
|
||||||
(d) => `"client:${d}"`
|
|
||||||
).join(', ')}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
51
packages/astro/src/runtime/server/render/any.ts
Normal file
51
packages/astro/src/runtime/server/render/any.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { AstroComponent, renderAstroComponent } from './astro.js';
|
||||||
|
import { markHTMLString, HTMLString, escapeHTML } from '../escape.js';
|
||||||
|
import { stringifyChunk } from './common.js';
|
||||||
|
|
||||||
|
export async function* renderChild(child: any): AsyncIterable<any> {
|
||||||
|
child = await child;
|
||||||
|
if (child instanceof HTMLString) {
|
||||||
|
yield child;
|
||||||
|
} else if (Array.isArray(child)) {
|
||||||
|
for (const value of child) {
|
||||||
|
yield markHTMLString(await renderChild(value));
|
||||||
|
}
|
||||||
|
} else if (typeof child === 'function') {
|
||||||
|
// Special: If a child is a function, call it automatically.
|
||||||
|
// This lets you do {() => ...} without the extra boilerplate
|
||||||
|
// of wrapping it in a function and calling it.
|
||||||
|
yield* renderChild(child());
|
||||||
|
} else if (typeof child === 'string') {
|
||||||
|
yield markHTMLString(escapeHTML(child));
|
||||||
|
} 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 (typeof child === 'object' && Symbol.asyncIterator in child) {
|
||||||
|
yield* child;
|
||||||
|
} else {
|
||||||
|
yield child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSlot(result: any, slotted: string, fallback?: any): Promise<string> {
|
||||||
|
if (slotted) {
|
||||||
|
let iterator = renderChild(slotted);
|
||||||
|
let content = '';
|
||||||
|
for await (const chunk of iterator) {
|
||||||
|
if ((chunk as any).type === 'directive') {
|
||||||
|
content += stringifyChunk(result, chunk);
|
||||||
|
} else {
|
||||||
|
content += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markHTMLString(content);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
124
packages/astro/src/runtime/server/render/astro.ts
Normal file
124
packages/astro/src/runtime/server/render/astro.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
import type { RenderInstruction } from './types';
|
||||||
|
import type { AstroComponentFactory } from './index';
|
||||||
|
|
||||||
|
import { HydrationDirectiveProps } from '../hydration.js';
|
||||||
|
import { stringifyChunk } from './common.js';
|
||||||
|
import { markHTMLString } from '../escape.js';
|
||||||
|
import { renderChild } from './any.js';
|
||||||
|
|
||||||
|
// In dev mode, check props and make sure they are valid for an Astro component
|
||||||
|
function validateComponentProps(props: any, displayName: string) {
|
||||||
|
if(import.meta.env?.DEV && 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[];
|
||||||
|
|
||||||
|
constructor(htmlParts: TemplateStringsArray, expressions: any[]) {
|
||||||
|
this.htmlParts = htmlParts;
|
||||||
|
this.expressions = expressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function* renderAstroComponent(
|
||||||
|
component: InstanceType<typeof AstroComponent>
|
||||||
|
): AsyncIterable<string | 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 html = '';
|
||||||
|
for await (const chunk of renderAstroComponent(Component)) {
|
||||||
|
html += stringifyChunk(result, chunk);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderToIterable(
|
||||||
|
result: SSRResult,
|
||||||
|
componentFactory: AstroComponentFactory,
|
||||||
|
displayName: string,
|
||||||
|
props: any,
|
||||||
|
children: any
|
||||||
|
): Promise<AsyncIterable<string | 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);
|
||||||
|
}
|
43
packages/astro/src/runtime/server/render/common.ts
Normal file
43
packages/astro/src/runtime/server/render/common.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
import type { RenderInstruction } from './types.js';
|
||||||
|
|
||||||
|
import { markHTMLString } from '../escape.js';
|
||||||
|
import {
|
||||||
|
determineIfNeedsHydrationScript,
|
||||||
|
determinesIfNeedsDirectiveScript,
|
||||||
|
getPrescripts,
|
||||||
|
PrescriptType,
|
||||||
|
} from '../scripts.js';
|
||||||
|
|
||||||
|
export const Fragment = Symbol.for('astro:fragment');
|
||||||
|
export const Renderer = Symbol.for('astro:renderer');
|
||||||
|
|
||||||
|
|
||||||
|
// Rendering produces either marked strings of HTML or instructions for hydration.
|
||||||
|
// These directive instructions bubble all the way up to renderPage so that we
|
||||||
|
// can ensure they are added only once, and as soon as possible.
|
||||||
|
export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction) {
|
||||||
|
switch ((chunk as any).type) {
|
||||||
|
case 'directive': {
|
||||||
|
const { hydration } = chunk as RenderInstruction;
|
||||||
|
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
|
||||||
|
let needsDirectiveScript =
|
||||||
|
hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
|
||||||
|
|
||||||
|
let prescriptType: PrescriptType = needsHydrationScript
|
||||||
|
? 'both'
|
||||||
|
: needsDirectiveScript
|
||||||
|
? 'directive'
|
||||||
|
: null;
|
||||||
|
if (prescriptType) {
|
||||||
|
let prescripts = getPrescripts(prescriptType, hydration.directive);
|
||||||
|
return markHTMLString(prescripts);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return chunk.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
350
packages/astro/src/runtime/server/render/component.ts
Normal file
350
packages/astro/src/runtime/server/render/component.ts
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
import type {
|
||||||
|
AstroComponentMetadata,
|
||||||
|
SSRLoadedRenderer,
|
||||||
|
SSRResult,
|
||||||
|
} from '../../../@types/astro';
|
||||||
|
import type { RenderInstruction } from './types.js';
|
||||||
|
|
||||||
|
import { extractDirectives, generateHydrateScript } from '../hydration.js';
|
||||||
|
import { serializeProps } from '../serialize.js';
|
||||||
|
import { shorthash } from '../shorthash.js';
|
||||||
|
import { Fragment, Renderer } from './common.js';
|
||||||
|
import { markHTMLString } from '../escape.js';
|
||||||
|
import { renderSlot } from './any.js';
|
||||||
|
import { renderToIterable, renderAstroComponent, renderTemplate } from './astro.js';
|
||||||
|
import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
|
||||||
|
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
|
||||||
|
|
||||||
|
const rendererAliases = new Map([['solid', 'solid-js']]);
|
||||||
|
|
||||||
|
function guessRenderers(componentUrl?: string): string[] {
|
||||||
|
const extname = componentUrl?.split('.').pop();
|
||||||
|
switch (extname) {
|
||||||
|
case 'svelte':
|
||||||
|
return ['@astrojs/svelte'];
|
||||||
|
case 'vue':
|
||||||
|
return ['@astrojs/vue'];
|
||||||
|
case 'jsx':
|
||||||
|
case 'tsx':
|
||||||
|
return ['@astrojs/react', '@astrojs/preact'];
|
||||||
|
default:
|
||||||
|
return ['@astrojs/react', '@astrojs/preact', '@astrojs/vue', '@astrojs/svelte'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentType = 'fragment' | 'html' | 'astro-factory' | 'unknown';
|
||||||
|
|
||||||
|
function getComponentType(Component: unknown): ComponentType {
|
||||||
|
if (Component === Fragment) {
|
||||||
|
return 'fragment';
|
||||||
|
}
|
||||||
|
if(Component && typeof Component === 'object' && (Component as any)['astro:html']) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
if(Component && (Component as any).isAstroComponentFactory) {
|
||||||
|
return 'astro-factory';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderComponent(
|
||||||
|
result: SSRResult,
|
||||||
|
displayName: string,
|
||||||
|
Component: unknown,
|
||||||
|
_props: Record<string | number, any>,
|
||||||
|
slots: any = {}
|
||||||
|
): Promise<string | AsyncIterable<string | RenderInstruction>> {
|
||||||
|
Component = await 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 children: Record<string, string> = {};
|
||||||
|
if (slots) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(slots).map(([key, value]) =>
|
||||||
|
renderSlot(result, value as string).then((output) => {
|
||||||
|
children[key] = output;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const html = (Component as any).render({ slots: children });
|
||||||
|
return markHTMLString(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'astro-factory': {
|
||||||
|
async function* renderAstroComponentInline(): AsyncGenerator<
|
||||||
|
string | 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?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderers } = result._metadata;
|
||||||
|
const metadata: AstroComponentMetadata = { displayName };
|
||||||
|
|
||||||
|
const { hydration, isPage, props } = extractDirectives(_props);
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (hydration) {
|
||||||
|
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
|
||||||
|
metadata.hydrateArgs = hydration.value;
|
||||||
|
metadata.componentExport = hydration.componentExport;
|
||||||
|
metadata.componentUrl = hydration.componentUrl;
|
||||||
|
}
|
||||||
|
const probableRendererNames = guessRenderers(metadata.componentUrl);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(renderers) &&
|
||||||
|
renderers.length === 0 &&
|
||||||
|
typeof Component !== 'string' &&
|
||||||
|
!componentIsHTMLElement(Component)
|
||||||
|
) {
|
||||||
|
const message = `Unable to render ${metadata.displayName}!
|
||||||
|
|
||||||
|
There are no \`integrations\` set in your \`astro.config.mjs\` file.
|
||||||
|
Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const children: Record<string, string> = {};
|
||||||
|
if (slots) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(slots).map(([key, value]) =>
|
||||||
|
renderSlot(result, value as string).then((output) => {
|
||||||
|
children[key] = output;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the renderers `check` hook to see if any claim this component.
|
||||||
|
let renderer: SSRLoadedRenderer | undefined;
|
||||||
|
if (metadata.hydrate !== 'only') {
|
||||||
|
// If this component ran through `__astro_tag_component__`, we already know
|
||||||
|
// which renderer to match to and can skip the usual `check` calls.
|
||||||
|
// This will help us throw most relevant error message for modules with runtime errors
|
||||||
|
if (Component && (Component as any)[Renderer]) {
|
||||||
|
const rendererName = (Component as any)[Renderer];
|
||||||
|
renderer = renderers.find(({ name }) => name === rendererName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderer) {
|
||||||
|
let error;
|
||||||
|
for (const r of renderers) {
|
||||||
|
try {
|
||||||
|
if (await r.ssr.check.call({ result }, Component, props, children)) {
|
||||||
|
renderer = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error ??= e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no renderer is found and there is an error, throw that error because
|
||||||
|
// it is likely a problem with the component code.
|
||||||
|
if (!renderer && error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) {
|
||||||
|
const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Attempt: use explicitly passed renderer name
|
||||||
|
if (metadata.hydrateArgs) {
|
||||||
|
const passedName = metadata.hydrateArgs;
|
||||||
|
const rendererName = rendererAliases.has(passedName)
|
||||||
|
? rendererAliases.get(passedName)
|
||||||
|
: passedName;
|
||||||
|
renderer = renderers.find(
|
||||||
|
({ name }) => name === `@astrojs/${rendererName}` || name === rendererName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Attempt: user only has a single renderer, default to that
|
||||||
|
if (!renderer && renderers.length === 1) {
|
||||||
|
renderer = renderers[0];
|
||||||
|
}
|
||||||
|
// Attempt: can we guess the renderer from the export extension?
|
||||||
|
if (!renderer) {
|
||||||
|
const extname = metadata.componentUrl?.split('.').pop();
|
||||||
|
renderer = renderers.filter(
|
||||||
|
({ name }) => name === `@astrojs/${extname}` || name === extname
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no one claimed the renderer
|
||||||
|
if (!renderer) {
|
||||||
|
if (metadata.hydrate === 'only') {
|
||||||
|
// TODO: improve error message
|
||||||
|
throw new Error(`Unable to render ${metadata.displayName}!
|
||||||
|
|
||||||
|
Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.
|
||||||
|
Did you mean to pass <${metadata.displayName} client:only="${probableRendererNames
|
||||||
|
.map((r) => r.replace('@astrojs/', ''))
|
||||||
|
.join('|')}" />
|
||||||
|
`);
|
||||||
|
} else if (typeof Component !== 'string') {
|
||||||
|
const matchingRenderers = renderers.filter((r) => probableRendererNames.includes(r.name));
|
||||||
|
const plural = renderers.length > 1;
|
||||||
|
if (matchingRenderers.length === 0) {
|
||||||
|
throw new Error(`Unable to render ${metadata.displayName}!
|
||||||
|
|
||||||
|
There ${plural ? 'are' : 'is'} ${renderers.length} renderer${
|
||||||
|
plural ? 's' : ''
|
||||||
|
} configured in your \`astro.config.mjs\` file,
|
||||||
|
but ${plural ? 'none were' : 'it was not'} able to server-side render ${metadata.displayName}.
|
||||||
|
|
||||||
|
Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`);
|
||||||
|
} else if (matchingRenderers.length === 1) {
|
||||||
|
// We already know that renderer.ssr.check() has failed
|
||||||
|
// but this will throw a much more descriptive error!
|
||||||
|
renderer = matchingRenderers[0];
|
||||||
|
({ html } = await renderer.ssr.renderToStaticMarkup.call(
|
||||||
|
{ result },
|
||||||
|
Component,
|
||||||
|
props,
|
||||||
|
children,
|
||||||
|
metadata
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unable to render ${metadata.displayName}!
|
||||||
|
|
||||||
|
This component likely uses ${formatList(probableRendererNames)},
|
||||||
|
but Astro encountered an error during server-side rendering.
|
||||||
|
|
||||||
|
Please ensure that ${metadata.displayName}:
|
||||||
|
1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`.
|
||||||
|
If this is unavoidable, use the \`client:only\` hydration directive.
|
||||||
|
2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server.
|
||||||
|
|
||||||
|
If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (metadata.hydrate === 'only') {
|
||||||
|
html = await renderSlot(result, slots?.fallback);
|
||||||
|
} else {
|
||||||
|
({ html } = await renderer.ssr.renderToStaticMarkup.call(
|
||||||
|
{ result },
|
||||||
|
Component,
|
||||||
|
props,
|
||||||
|
children,
|
||||||
|
metadata
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK! The lit renderer doesn't include a clientEntrypoint for custom elements, allow it
|
||||||
|
// to render here until we find a better way to recognize when a client entrypoint isn't required.
|
||||||
|
if (
|
||||||
|
renderer &&
|
||||||
|
!renderer.clientEntrypoint &&
|
||||||
|
renderer.name !== '@astrojs/lit' &&
|
||||||
|
metadata.hydrate
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`${metadata.displayName} component has a \`client:${metadata.hydrate}\` directive, but no client entrypoint was provided by ${renderer.name}!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a custom element without a renderer. Because of that, render it
|
||||||
|
// 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(
|
||||||
|
await renderTemplate`<${Component}${internalSpreadAttributes(props)}${markHTMLString(
|
||||||
|
childSlots === '' && voidElementNames.test(Component)
|
||||||
|
? `/>`
|
||||||
|
: `>${childSlots}</${Component}>`
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
html = '';
|
||||||
|
for await (const chunk of iterable) {
|
||||||
|
html += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hydration) {
|
||||||
|
if (isPage || renderer?.name === 'astro:jsx') {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
return markHTMLString(html.replace(/\<\/?astro-slot\>/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands
|
||||||
|
const astroId = shorthash(
|
||||||
|
`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps(
|
||||||
|
props
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const island = await generateHydrateScript(
|
||||||
|
{ renderer: renderer!, result, astroId, props },
|
||||||
|
metadata as Required<AstroComponentMetadata>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render template if not all astro fragments are provided.
|
||||||
|
let unrenderedSlots: string[] = [];
|
||||||
|
if (html) {
|
||||||
|
if (Object.keys(children).length > 0) {
|
||||||
|
for (const key of Object.keys(children)) {
|
||||||
|
if (!html.includes(key === 'default' ? `<astro-slot>` : `<astro-slot name="${key}">`)) {
|
||||||
|
unrenderedSlots.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unrenderedSlots = Object.keys(children);
|
||||||
|
}
|
||||||
|
const template =
|
||||||
|
unrenderedSlots.length > 0
|
||||||
|
? unrenderedSlots
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`<template data-astro-template${key !== 'default' ? `="${key}"` : ''}>${
|
||||||
|
children[key]
|
||||||
|
}</template>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
island.children = `${html ?? ''}${template}`;
|
||||||
|
|
||||||
|
if (island.children) {
|
||||||
|
island.props['await-children'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* renderAll() {
|
||||||
|
yield { type: 'directive', hydration, result };
|
||||||
|
yield markHTMLString(renderElement('astro-island', island, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderAll();
|
||||||
|
}
|
42
packages/astro/src/runtime/server/render/dom.ts
Normal file
42
packages/astro/src/runtime/server/render/dom.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
|
||||||
|
import { markHTMLString } from '../escape.js';
|
||||||
|
import { renderSlot } from './any.js';
|
||||||
|
import { toAttributeString } from './util.js';
|
||||||
|
|
||||||
|
export function componentIsHTMLElement(Component: unknown) {
|
||||||
|
return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderHTMLElement(
|
||||||
|
result: SSRResult,
|
||||||
|
constructor: typeof HTMLElement,
|
||||||
|
props: any,
|
||||||
|
slots: any
|
||||||
|
) {
|
||||||
|
const name = getHTMLElementName(constructor);
|
||||||
|
|
||||||
|
let attrHTML = '';
|
||||||
|
|
||||||
|
for (const attr in props) {
|
||||||
|
attrHTML += ` ${attr}="${toAttributeString(await props[attr])}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return markHTMLString(
|
||||||
|
`<${name}${attrHTML}>${await renderSlot(result, slots?.default)}</${name}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHTMLElementName(constructor: typeof HTMLElement) {
|
||||||
|
const definedName = (
|
||||||
|
customElements as CustomElementRegistry & { getName(_constructor: typeof HTMLElement): string }
|
||||||
|
).getName(constructor);
|
||||||
|
if (definedName) return definedName;
|
||||||
|
|
||||||
|
const assignedName = constructor.name
|
||||||
|
.replace(/^HTML|Element$/g, '')
|
||||||
|
.replace(/[A-Z]/g, '-$&')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^-/, 'html-');
|
||||||
|
return assignedName;
|
||||||
|
}
|
43
packages/astro/src/runtime/server/render/head.ts
Normal file
43
packages/astro/src/runtime/server/render/head.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
|
||||||
|
import { markHTMLString } from '../escape.js';
|
||||||
|
import { renderElement } from './util.js';
|
||||||
|
|
||||||
|
// Filter out duplicate elements in our set
|
||||||
|
const uniqueElements = (item: any, index: number, all: any[]) => {
|
||||||
|
const props = JSON.stringify(item.props);
|
||||||
|
const children = item.children;
|
||||||
|
return (
|
||||||
|
index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const alreadyHeadRenderedResults = new WeakSet<SSRResult>();
|
||||||
|
export function renderHead(result: SSRResult): Promise<string> {
|
||||||
|
alreadyHeadRenderedResults.add(result);
|
||||||
|
const styles = Array.from(result.styles)
|
||||||
|
.filter(uniqueElements)
|
||||||
|
.map((style) => renderElement('style', style));
|
||||||
|
// Clear result.styles so that any new styles added will be inlined.
|
||||||
|
result.styles.clear();
|
||||||
|
const scripts = Array.from(result.scripts)
|
||||||
|
.filter(uniqueElements)
|
||||||
|
.map((script, i) => {
|
||||||
|
return renderElement('script', script, false);
|
||||||
|
});
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function is called by Astro components that do not contain a <head> component
|
||||||
|
// This accomodates 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> {
|
||||||
|
if (alreadyHeadRenderedResults.has(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield renderHead(result);
|
||||||
|
}
|
17
packages/astro/src/runtime/server/render/index.ts
Normal file
17
packages/astro/src/runtime/server/render/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { renderTemplate } from './astro.js';
|
||||||
|
|
||||||
|
export type { RenderInstruction } from './types';
|
||||||
|
export { renderSlot } from './any.js';
|
||||||
|
export { renderTemplate, renderAstroComponent, renderToString } from './astro.js';
|
||||||
|
export { stringifyChunk, Fragment, Renderer } from './common.js';
|
||||||
|
export { renderComponent } from './component.js';
|
||||||
|
export { renderHTMLElement } from './dom.js';
|
||||||
|
export { renderHead, maybeRenderHead } from './head.js';
|
||||||
|
export { renderPage } from './page.js';
|
||||||
|
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;
|
||||||
|
}
|
99
packages/astro/src/runtime/server/render/page.ts
Normal file
99
packages/astro/src/runtime/server/render/page.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
import type { AstroComponentFactory } from './index';
|
||||||
|
|
||||||
|
import { isAstroComponent, renderAstroComponent } from './astro.js';
|
||||||
|
import { stringifyChunk } from './common.js';
|
||||||
|
import { renderComponent } from './component.js';
|
||||||
|
import { maybeRenderHead } from './head.js';
|
||||||
|
import { createResponse } from '../response.js';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
export async function renderPage(
|
||||||
|
result: SSRResult,
|
||||||
|
componentFactory: AstroComponentFactory,
|
||||||
|
props: any,
|
||||||
|
children: any,
|
||||||
|
streaming: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!componentFactory.isAstroComponentFactory) {
|
||||||
|
const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
|
||||||
|
const output = await renderComponent(
|
||||||
|
result,
|
||||||
|
componentFactory.name,
|
||||||
|
componentFactory,
|
||||||
|
pageProps,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
let html = output.toString();
|
||||||
|
if (!/<!doctype html/i.test(html)) {
|
||||||
|
let rest = html;
|
||||||
|
html = `<!DOCTYPE html>`;
|
||||||
|
for await (let chunk of maybeRenderHead(result)) {
|
||||||
|
html += chunk;
|
||||||
|
}
|
||||||
|
html += rest;
|
||||||
|
}
|
||||||
|
return new Response(html, {
|
||||||
|
headers: new Headers([
|
||||||
|
['Content-Type', 'text/html; charset=utf-8'],
|
||||||
|
['Content-Length', Buffer.byteLength(html, 'utf-8').toString()],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const factoryReturnValue = await componentFactory(result, props, children);
|
||||||
|
|
||||||
|
if (isAstroComponent(factoryReturnValue)) {
|
||||||
|
let iterable = renderAstroComponent(factoryReturnValue);
|
||||||
|
let init = result.response;
|
||||||
|
let headers = new Headers(init.headers);
|
||||||
|
let body: BodyInit;
|
||||||
|
|
||||||
|
if (streaming) {
|
||||||
|
body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
async function read() {
|
||||||
|
let i = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of iterable) {
|
||||||
|
let html = stringifyChunk(result, chunk);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
if (!/<!doctype html/i.test(html)) {
|
||||||
|
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.enqueue(encoder.encode(html));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
} catch (e) {
|
||||||
|
controller.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
body = '';
|
||||||
|
let i = 0;
|
||||||
|
for await (const chunk of iterable) {
|
||||||
|
let html = stringifyChunk(result, chunk);
|
||||||
|
if (i === 0) {
|
||||||
|
if (!/<!doctype html/i.test(html)) {
|
||||||
|
body += '<!DOCTYPE html>\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body += html;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const bytes = encoder.encode(body);
|
||||||
|
headers.set('Content-Length', bytes.byteLength.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = createResponse(body, { ...init, headers });
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
return factoryReturnValue;
|
||||||
|
}
|
||||||
|
}
|
8
packages/astro/src/runtime/server/render/types.ts
Normal file
8
packages/astro/src/runtime/server/render/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import type { SSRResult } from '../../../@types/astro';
|
||||||
|
import type { HydrationMetadata } from '../hydration.js';
|
||||||
|
|
||||||
|
export interface RenderInstruction {
|
||||||
|
type: 'directive';
|
||||||
|
result: SSRResult;
|
||||||
|
hydration: HydrationMetadata;
|
||||||
|
}
|
128
packages/astro/src/runtime/server/render/util.ts
Normal file
128
packages/astro/src/runtime/server/render/util.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import type { SSRElement } from '../../../@types/astro';
|
||||||
|
|
||||||
|
import { markHTMLString, HTMLString } from '../escape.js';
|
||||||
|
import { serializeListValue } from '../util.js';
|
||||||
|
|
||||||
|
export const voidElementNames =
|
||||||
|
/^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
|
||||||
|
const htmlBooleanAttributes =
|
||||||
|
/^(allowfullscreen|async|autofocus|autoplay|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|itemscope)$/i;
|
||||||
|
const htmlEnumAttributes = /^(contenteditable|draggable|spellcheck|value)$/i;
|
||||||
|
// Note: SVG is case-sensitive!
|
||||||
|
const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i;
|
||||||
|
|
||||||
|
const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);
|
||||||
|
|
||||||
|
// converts (most) arbitrary strings to valid JS identifiers
|
||||||
|
const toIdent = (k: string) =>
|
||||||
|
k.trim().replace(/(?:(?<!^)\b\w|\s+|[^\w]+)/g, (match, index) => {
|
||||||
|
if (/[^\w]|\s/.test(match)) return '';
|
||||||
|
return index === 0 ? match : match.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toAttributeString = (value: any, shouldEscape = true) =>
|
||||||
|
shouldEscape ? String(value).replace(/&/g, '&').replace(/"/g, '"') : value;
|
||||||
|
|
||||||
|
const kebab = (k: string) =>
|
||||||
|
k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
||||||
|
const toStyleString = (obj: Record<string, any>) =>
|
||||||
|
Object.entries(obj)
|
||||||
|
.map(([k, v]) => `${kebab(k)}:${v}`)
|
||||||
|
.join(';');
|
||||||
|
|
||||||
|
// Adds variables to an inline script.
|
||||||
|
export function defineScriptVars(vars: Record<any, any>) {
|
||||||
|
let output = '';
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
output += `let ${toIdent(key)} = ${JSON.stringify(value)};\n`;
|
||||||
|
}
|
||||||
|
return markHTMLString(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatList(values: string[]): string {
|
||||||
|
if (values.length === 1) {
|
||||||
|
return values[0];
|
||||||
|
}
|
||||||
|
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper used to turn expressions into attribute key/value
|
||||||
|
export function addAttribute(value: any, key: string, shouldEscape = true) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === false) {
|
||||||
|
if (htmlEnumAttributes.test(key) || svgEnumAttributes.test(key)) {
|
||||||
|
return markHTMLString(` ${key}="false"`);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// compiler directives cannot be applied dynamically, log a warning and ignore.
|
||||||
|
if (STATIC_DIRECTIVES.has(key)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[astro] The "${key}" directive cannot be applied dynamically at runtime. It will not be rendered as an attribute.
|
||||||
|
|
||||||
|
Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the dynamic spread syntax (\`{...{ "${key}": value }}\`).`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// support "class" from an expression passed into an element (#782)
|
||||||
|
if (key === 'class:list') {
|
||||||
|
const listValue = toAttributeString(serializeListValue(value));
|
||||||
|
if (listValue === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return markHTMLString(` ${key.slice(0, -5)}="${listValue}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// support object styles for better JSX compat
|
||||||
|
if (key === 'style' && !(value instanceof HTMLString) && typeof value === 'object') {
|
||||||
|
return markHTMLString(` ${key}="${toStyleString(value)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// support `className` for better JSX compat
|
||||||
|
if (key === 'className') {
|
||||||
|
return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean values only need the key
|
||||||
|
if (value === true && (key.startsWith('data-') || htmlBooleanAttributes.test(key))) {
|
||||||
|
return markHTMLString(` ${key}`);
|
||||||
|
} else {
|
||||||
|
return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds support for `<Component {...value} />
|
||||||
|
export function internalSpreadAttributes(values: Record<any, any>, shouldEscape = true) {
|
||||||
|
let output = '';
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
output += addAttribute(value, key, shouldEscape);
|
||||||
|
}
|
||||||
|
return markHTMLString(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderElement(
|
||||||
|
name: string,
|
||||||
|
{ props: _props, children = '' }: SSRElement,
|
||||||
|
shouldEscape = true
|
||||||
|
) {
|
||||||
|
// Do not print `hoist`, `lang`, `is:global`
|
||||||
|
const { lang: _, 'data-astro-id': astroId, 'define:vars': defineVars, ...props } = _props;
|
||||||
|
if (defineVars) {
|
||||||
|
if (name === 'style') {
|
||||||
|
delete props['is:global'];
|
||||||
|
delete props['is:scoped'];
|
||||||
|
}
|
||||||
|
if (name === 'script') {
|
||||||
|
delete props.hoist;
|
||||||
|
children = defineScriptVars(defineVars) + '\n' + children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((children == null || children == '') && voidElementNames.test(name)) {
|
||||||
|
return `<${name}${internalSpreadAttributes(props, shouldEscape)} />`;
|
||||||
|
}
|
||||||
|
return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`;
|
||||||
|
}
|
Loading…
Reference in a new issue