astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
160 lines (159 loc) • 6.98 kB
JavaScript
import { AstroJSX, isVNode } from "../../jsx-runtime/index.js";
import { escapeStyleText } from "./escape.js";
import { escapeHTML, HTMLString, markHTMLString, spreadAttributes, voidElementNames } from "./index.js";
import { isAstroComponentFactory } from "./render/astro/factory.js";
import { renderComponentToString } from "./render/component.js";
import { mergeSlotInstructions, SlotString } from "./render/slot.js";
const ClientOnlyPlaceholder = "astro-client-only";
// If the `vnode.type` is a function, we could render it as JSX or as framework components.
// Inside `renderJSXNode`, we first try to render as framework components, and if `renderJSXNode`
// is called again while rendering the component, it's likely that the `astro:jsx` is invoking
// `renderJSXNode` again (loop). In this case, we try to render as JSX instead.
//
// This Symbol is assigned to `vnode.props` to track if it had tried to render as framework components.
// It mutates `vnode.props` to be able to scope to the current render call.
const hasTriedRenderComponentSymbol = Symbol("hasTriedRenderComponent");
export async function renderJSX(result, vnode) {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (true) {
case vnode instanceof HTMLString:
if (vnode.toString().trim() === "") {
return "";
}
return vnode;
case typeof vnode === "string": return markHTMLString(escapeHTML(vnode));
case typeof vnode === "function": return vnode;
case !vnode && vnode !== 0: return "";
case Array.isArray(vnode): {
const renderedItems = await Promise.all(vnode.map((v) => renderJSX(result, v)));
// Collect instructions from SlotString items to preserve hydration scripts
let instructions = null;
let content = "";
for (const item of renderedItems) {
if (item instanceof SlotString) {
content += item;
instructions = mergeSlotInstructions(instructions, item);
} else {
content += item;
}
}
if (instructions) {
return markHTMLString(new SlotString(content, instructions));
}
return markHTMLString(content);
}
}
return renderJSXVNode(result, vnode);
}
async function renderJSXVNode(result, vnode) {
if (isVNode(vnode)) {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (true) {
case !vnode.type: {
throw new Error(`Unable to render ${result.pathname} because it contains an undefined Component!
Did you forget to import the component or is it possible there is a typo?`);
}
case vnode.type === Symbol.for("astro:fragment"): return renderJSX(result, vnode.props.children);
case isAstroComponentFactory(vnode.type): {
let props = {};
let slots = {};
for (const [key, value] of Object.entries(vnode.props ?? {})) {
if (key === "children" || value && typeof value === "object" && value["$$slot"]) {
slots[key === "children" ? "default" : key] = () => renderJSX(result, value);
} else {
props[key] = value;
}
}
// We don't use renderToString because it doesn't contain the server island script handling
const str = await renderComponentToString(result, vnode.type.name, vnode.type, props, slots);
const html = markHTMLString(str);
return html;
}
case !vnode.type && vnode.type !== 0: return "";
case typeof vnode.type === "string" && vnode.type !== ClientOnlyPlaceholder && !vnode.type.includes("-"): return markHTMLString(await renderElement(result, vnode.type, vnode.props ?? {}));
}
if (vnode.type) {
if (typeof vnode.type === "function" && vnode.props["server:root"]) {
const output = await vnode.type(vnode.props ?? {});
return await renderJSX(result, output);
}
if (typeof vnode.type === "function") {
if (vnode.props[hasTriedRenderComponentSymbol]) {
// omitting compiler-internals from user components
delete vnode.props[hasTriedRenderComponentSymbol];
const output = await vnode.type(vnode.props ?? {});
if (output?.[AstroJSX] || !output) {
return await renderJSXVNode(result, output);
} else {
return;
}
} else {
vnode.props[hasTriedRenderComponentSymbol] = true;
}
}
const { children = null, ...props } = vnode.props ?? {};
const _slots = { default: [] };
// For custom HTML elements (string type with hyphen), the `slot` attribute on children
// is a standard HTML attribute for web component Shadow DOM slot distribution, not an
// Astro slot assignment. Skip slot extraction to preserve it in the output.
const isCustomElement = typeof vnode.type === "string" && vnode.type.includes("-");
function extractSlots(child) {
if (Array.isArray(child)) {
return child.map((c) => extractSlots(c));
}
if (!isVNode(child)) {
_slots.default.push(child);
return;
}
if ("slot" in child.props && !isCustomElement) {
_slots[child.props.slot] = [..._slots[child.props.slot] ?? [], child];
delete child.props.slot;
return;
}
_slots.default.push(child);
}
extractSlots(children);
for (const [key, value] of Object.entries(props)) {
if (value?.["$$slot"]) {
_slots[key] = value;
delete props[key];
}
}
const slotPromises = [];
const slots = {};
for (const [key, value] of Object.entries(_slots)) {
slotPromises.push(renderJSX(result, value).then((output) => {
if (output.toString().trim().length === 0) return;
slots[key] = () => output;
}));
}
await Promise.all(slotPromises);
let output;
if (vnode.type === ClientOnlyPlaceholder && vnode.props["client:only"]) {
output = await renderComponentToString(result, vnode.props["client:display-name"] ?? "", null, props, slots);
} else {
output = await renderComponentToString(result, typeof vnode.type === "function" ? vnode.type.name : vnode.type, vnode.type, props, slots);
}
return markHTMLString(output);
}
}
// numbers, plain objects, etc
return markHTMLString(`${vnode}`);
}
async function renderElement(result, tag, { children, ...props }) {
return markHTMLString(`<${tag}${spreadAttributes(props)}${markHTMLString((children == null || children === "") && voidElementNames.test(tag) ? `/>` : `>${children == null ? "" : await renderJSX(result, prerenderElementChildren(tag, children))}</${tag}>`)}`);
}
/**
* Pre-render the children with the given `tag` information
*/
function prerenderElementChildren(tag, children) {
// Literal `<style>`/`<script>` content (including content injected by remark/rehype
// plugins) is collapsed into `set:html` at compile time, so any remaining plain-string
// children here are dynamic values that should be escaped like other element content.
// `escapeStyleText` only escapes `<`, preserving quotes CSS commonly relies on while
// still preventing the value from closing the `<style>` tag early.
if (typeof children === "string" && tag === "style") {
return markHTMLString(escapeStyleText(children));
}
return children;
}