astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
268 lines (266 loc) • 10.6 kB
JavaScript
import { clsx } from "clsx";
import { HTMLString, markHTMLString, stringifyForScript } from "../escape.js";
import { isPromise } 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|checked|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|inert|loop|muted|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|selected|itemscope)$/i;
const AMPERSAND_REGEX = /&/g;
const DOUBLE_QUOTE_REGEX = /"/g;
const STATIC_DIRECTIVES = new Set(["set:html", "set:text"]);
// Per the HTML spec, attribute names must not contain ASCII whitespace, ", ', >, /, or =.
export const INVALID_ATTR_NAME_CHAR = /[\s"'>/=]/;
// converts (most) arbitrary strings to valid JS identifiers
const toIdent = (k) => k.trim().replace(/(?!^)\b\w|\s+|\W+/g, (match, index) => {
if (/\W/.test(match)) return "";
return index === 0 ? match : match.toUpperCase();
});
export const toAttributeString = (value, shouldEscape = true) => {
if (!shouldEscape) return value;
const str = String(value);
if (!str.includes("&") && !str.includes("\"")) return str;
return str.replace(AMPERSAND_REGEX, "&").replace(DOUBLE_QUOTE_REGEX, """);
};
const kebab = (k) => k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
export const toStyleString = (obj) => {
let output = "";
for (const key of Object.keys(obj)) {
const value = obj[key];
if (!(typeof value === "string" && value.trim() || typeof value === "number")) continue;
if (output) output += ";";
output += key[0] !== "-" && key[1] !== "-" ? `${kebab(key)}:${value}` : `${key}:${value}`;
}
return output;
};
// Adds variables to an inline script.
export function defineScriptVars(vars) {
let output = "";
for (const [key, value] of Object.entries(vars)) {
// Use const instead of let as let global unsupported with Safari
// https://stackoverflow.com/questions/29194024/cant-use-let-keyword-in-safari-javascript
output += `const ${toIdent(key)} = ${stringifyForScript(value)};\n`;
}
return markHTMLString(output);
}
export function formatList(values) {
if (values.length === 1) {
return values[0];
}
return `${values.slice(0, -1).join(", ")} or ${values[values.length - 1]}`;
}
function isCustomElement(tagName) {
return tagName.includes("-");
}
function handleBooleanAttribute(key, value, shouldEscape, tagName) {
// The Popover API only accepts "auto", "manual", or the attribute being absent.
// There's no valid string value for "off", so it must always be rendered as a
// boolean attribute, even on custom elements.
if (key === "popover") {
return markHTMLString(value ? ` ${key}` : "");
}
// For custom elements, always render as string attributes
if (tagName && isCustomElement(tagName)) {
return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
}
// For regular HTML elements, use boolean attribute logic
return markHTMLString(value ? ` ${key}` : "");
}
const ATTRIBUTE_KIND = {
ORDINARY: 0,
INVALID_NAME: 1,
STATIC_DIRECTIVE: 2,
CLASS_LIST: 3,
STYLE: 4,
CLASS_NAME: 5,
BOOLEAN: 6,
BOOLEAN_IF_BOOLEAN: 7
};
function classifyAttribute(key) {
if (INVALID_ATTR_NAME_CHAR.test(key)) return ATTRIBUTE_KIND.INVALID_NAME;
if (STATIC_DIRECTIVES.has(key)) return ATTRIBUTE_KIND.STATIC_DIRECTIVE;
if (key === "class:list") return ATTRIBUTE_KIND.CLASS_LIST;
if (key === "style") return ATTRIBUTE_KIND.STYLE;
if (key === "className") return ATTRIBUTE_KIND.CLASS_NAME;
if (htmlBooleanAttributes.test(key)) return ATTRIBUTE_KIND.BOOLEAN;
// We cannot add it to htmlBooleanAttributes because it can be: boolean | "auto" | "manual"
if (key === "popover" || key === "download" || key === "hidden") {
return ATTRIBUTE_KIND.BOOLEAN_IF_BOOLEAN;
}
return ATTRIBUTE_KIND.ORDINARY;
}
// A helper used to turn expressions into attribute key/value
// In the compiler, addAttribute is only printed to process attributes of elements
// that may contain dynamic values. We don't need to pass tagName to addAttribute
// on the compiler side because it is used only for custom elements
export function addAttribute(value, key, shouldEscape = true, tagName = "") {
if (value == null) {
return "";
}
const kind = classifyAttribute(key);
switch (kind) {
// Reject attribute names with characters that could break out of the attribute context.
case ATTRIBUTE_KIND.INVALID_NAME: return "";
// compiler directives cannot be applied dynamically, log a warning and ignore.
case ATTRIBUTE_KIND.STATIC_DIRECTIVE:
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)
case ATTRIBUTE_KIND.CLASS_LIST: {
const listValue = toAttributeString(clsx(value), shouldEscape);
if (listValue === "") {
return "";
}
return markHTMLString(` class="${listValue}"`);
}
// support object styles for better JSX compat
case ATTRIBUTE_KIND.STYLE:
if (!(value instanceof HTMLString)) {
if (Array.isArray(value) && value.length === 2) {
return markHTMLString(` style="${toAttributeString(`${toStyleString(value[0])};${value[1]}`, shouldEscape)}"`);
}
if (typeof value === "object") {
return markHTMLString(` style="${toAttributeString(toStyleString(value), shouldEscape)}"`);
}
}
break;
// support `className` for better JSX compat
case ATTRIBUTE_KIND.CLASS_NAME: return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`);
// Boolean values only need the key
case ATTRIBUTE_KIND.BOOLEAN: return handleBooleanAttribute(key, value, shouldEscape, tagName);
case ATTRIBUTE_KIND.BOOLEAN_IF_BOOLEAN:
if (typeof value === "boolean") {
return handleBooleanAttribute(key, value, shouldEscape, tagName);
}
break;
case ATTRIBUTE_KIND.ORDINARY: break;
default: kind;
}
// Other attributes with an empty string value can omit rendering the value
if (value === "") {
return markHTMLString(` ${key}`);
}
return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
}
export function spreadElementAttributes(values) {
let output = "";
for (const key of Object.keys(values)) {
if (key === "children") continue;
output += addAttribute(values[key], key, true);
}
return output;
}
// Adds support for `<Component {...value} />
export function internalSpreadAttributes(values, shouldEscape = true, tagName) {
let output = "";
for (const key of Object.keys(values)) {
output += addAttribute(values[key], key, shouldEscape, tagName);
}
return markHTMLString(output);
}
export function renderElement(name, { props: _props, children = "" }, 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, name)}>`;
}
return `<${name}${internalSpreadAttributes(props, shouldEscape, name)}>${children}</${name}>`;
}
const noop = () => {};
/**
* Renders into a buffer until `flush` is called (which
* flushes the buffer)
*/
class BufferedRenderer {
chunks = [];
renderPromise;
destination;
/**
* Determines whether buffer has been flushed
* to the final destination.
*/
flushed = false;
constructor(destination, renderFunction) {
this.destination = destination;
this.renderPromise = renderFunction(this);
if (isPromise(this.renderPromise)) {
// Catch here in case it throws before `flush` is called,
// to prevent an unhandled rejection.
Promise.resolve(this.renderPromise).catch(noop);
}
}
write(chunk) {
// Before the buffer has been flushed, we want to
// append to the buffer, afterwards we'll write
// to the underlying destination if subsequent
// writes arrive.
if (this.flushed) {
this.destination.write(chunk);
} else {
this.chunks.push(chunk);
}
}
flush() {
if (this.flushed) {
throw new Error("The render buffer has already been flushed.");
}
this.flushed = true;
// Write the buffered chunks to the real destination
for (const chunk of this.chunks) {
this.destination.write(chunk);
}
// NOTE: We don't empty `this.chunks` after it's written as benchmarks show
// that it causes poorer performance, likely due to forced memory re-allocation,
// instead of letting the garbage collector handle it automatically.
// (Unsure how this affects on limited memory machines)
return this.renderPromise;
}
}
/**
* Executes the `bufferRenderFunction` to prerender it into a buffer destination, and return a promise
* with an object containing the `flush` function to flush the buffer to the final
* destination.
*
* @example
* ```ts
* // Render components in parallel ahead of time
* const finalRenders = [ComponentA, ComponentB].map((comp) => {
* return createBufferedRenderer(finalDestination, async (bufferDestination) => {
* await renderComponentToDestination(bufferDestination);
* });
* });
* // Render array of components serially
* for (const finalRender of finalRenders) {
* await finalRender.flush();
* }
* ```
*/
export function createBufferedRenderer(destination, renderFunction) {
return new BufferedRenderer(destination, renderFunction);
}
export const isNode = typeof process !== "undefined" && Object.prototype.toString.call(process) === "[object process]";
// @ts-expect-error: Deno is not part of the types.
export const isDeno = typeof Deno !== "undefined";
export const isWorkerd = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";
// This is an implementation of Promise.withResolvers(), which we can't yet rely on.
// We can remove this once the native function is available in Node.js
export function promiseWithResolvers() {
let resolve, reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
promise,
resolve,
reject
};
}