astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
193 lines (192 loc) • 7.61 kB
JavaScript
import cssesc from "../../transitions/cssesc.js";
import { fade, slide } from "../../transitions/index.js";
import { escapeStyleText, markHTMLString } from "./escape.js";
const transitionNameMap = new WeakMap();
function incrementTransitionNumber(result) {
let num = 1;
if (transitionNameMap.has(result)) {
num = transitionNameMap.get(result) + 1;
}
transitionNameMap.set(result, num);
return num;
}
export function createTransitionScope(result, hash) {
const num = incrementTransitionNumber(result);
return `astro-${hash}-${num}`;
}
const getAnimations = (name) => {
if (name === "fade") return fade();
if (name === "slide") return slide();
if (typeof name === "object") return name;
};
const addPairs = (animations, stylesheet) => {
for (const [direction, images] of Object.entries(animations)) {
for (const [image, rules] of Object.entries(images)) {
stylesheet.addAnimationPair(direction, image, rules);
}
}
};
// Chrome (121) accepts custom-idents for view-transition-names as generated by cssesc,
// but it just ignores them during view transitions if they contain escaped 7-bit ASCII characters
// like \<space> or \. A special case are digits and minus at the beginning of the string,
// which cssesc also encodes as \xx
const reEncodeValidChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_".split("").reduce((v, c) => (v[c.charCodeAt(0)] = c, v), []);
const reEncodeInValidStart = "-0123456789_".split("").reduce((v, c) => (v[c.charCodeAt(0)] = c, v), []);
export function reEncode(s) {
let result = "";
let codepoint;
// we work on codepoints that might use more than 16bit, not character codes.
// so the index will often step by 1 as usual or by 2 if the codepoint is greater than 0xFFFF
for (let i = 0; i < s.length; i += (codepoint ?? 0) > 65535 ? 2 : 1) {
codepoint = s.codePointAt(i);
if (codepoint !== undefined) {
// this should never happen, they said!
// If we find a character in the range \x00 - \x7f that is not one of the reEncodeValidChars,
// we replace it with its hex value escaped by an underscore for decodability (and better readability,
// because most of them are punctuation like ,'"":;_..., and '_' might be a better choice than '-')
// The underscore itself (code 95) is also escaped and encoded as two underscores to avoid
// collisions between original and encoded strings.
// All other values are just copied over
result += codepoint < 128 ? codepoint === 95 ? "__" : reEncodeValidChars[codepoint] ?? "_" + codepoint.toString(16).padStart(2, "0") : String.fromCodePoint(codepoint);
}
}
// Digits and minus sign at the beginning of the string are special, so we simply prepend an underscore
return reEncodeInValidStart[result.codePointAt(0) ?? 0] ? "_" + result : result;
}
export function renderTransition(result, hash, animationName, transitionName) {
if (typeof (transitionName ?? "") !== "string") {
throw new Error(`Invalid transition name {${transitionName}}`);
}
// Default to `fade` (similar to `initial`, but snappier)
if (!animationName) animationName = "fade";
const scope = createTransitionScope(result, hash);
const name = transitionName ? cssesc(reEncode(transitionName), { isIdentifier: true }) : scope;
const sheet = new ViewTransitionStyleSheet(scope, name);
const animations = getAnimations(animationName);
if (animations) {
addPairs(animations, sheet);
} else if (animationName === "none") {
sheet.addFallback("old", "animation: none; mix-blend-mode: normal;");
sheet.addModern("old", "animation: none; opacity: 0; mix-blend-mode: normal;");
sheet.addAnimationRaw("new", "animation: none; mix-blend-mode: normal;");
sheet.addModern("group", "animation: none");
}
const css = escapeStyleText(sheet.toString());
result._metadata.extraHead.push(markHTMLString(`<style>${css}</style>`));
return scope;
}
export class ViewTransitionStyleSheet {
modern = [];
fallback = [];
scope;
name;
constructor(scope, name) {
this.scope = scope;
this.name = name;
}
toString() {
const { scope, name } = this;
const [modern, fallback] = [this.modern, this.fallback].map((rules) => rules.join(""));
return [
`[data-astro-transition-scope="${scope}"] { view-transition-name: ${name}; }`,
this.layer(modern),
fallback
].join("");
}
layer(cssText) {
return cssText ? `@layer astro { ${cssText} }` : "";
}
addRule(target, cssText) {
this[target].push(cssText);
}
addAnimationRaw(image, animation) {
this.addModern(image, animation);
this.addFallback(image, animation);
}
addModern(image, animation) {
const { name } = this;
this.addRule("modern", `::view-transition-${image}(${name}) { ${animation} }`);
}
addFallback(image, animation) {
const { scope } = this;
this.addRule(
"fallback",
// Two selectors here, the second in case there is an animation on the root.
`[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`
);
}
addAnimationPair(direction, image, rules) {
const { scope, name } = this;
const animation = stringifyAnimation(rules);
const prefix = direction === "backwards" ? `[data-astro-transition=back]` : direction === "forwards" ? "" : // Quote custom direction names so values that are not valid CSS identifiers
// (e.g. names starting with a digit or containing spaces) still match.
`[data-astro-transition=${cssesc(direction, {
quotes: "double",
wrap: true
})}]`;
this.addRule("modern", `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
this.addRule("fallback", `${prefix}[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
${prefix}[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`);
}
}
// Keep animation values within their declaration while preserving valid CSS functions and strings.
const CSS_VALUE_DELIMITERS = /[;{}]/g;
function escapeCSSValue(value) {
return value.toString().replace(CSS_VALUE_DELIMITERS, (character) => {
return `\\${character.charCodeAt(0).toString(16).toUpperCase()} `;
});
}
function addAnimationProperty(builder, prop, value) {
const escapedValue = escapeCSSValue(value);
let arr = builder[prop];
if (Array.isArray(arr)) {
arr.push(escapedValue);
} else {
builder[prop] = [escapedValue];
}
}
function animationBuilder() {
return { toString() {
let out = "";
for (let k in this) {
let value = this[k];
if (Array.isArray(value)) {
out += `\n\t${k}: ${value.join(", ")};`;
}
}
return out;
} };
}
export function stringifyAnimation(anim) {
if (Array.isArray(anim)) {
return stringifyAnimations(anim);
} else {
return stringifyAnimations([anim]);
}
}
export function stringifyAnimations(anims) {
const builder = animationBuilder();
for (const anim of anims) {
if (anim.duration) {
addAnimationProperty(builder, "animation-duration", toTimeValue(anim.duration));
}
if (anim.easing) {
addAnimationProperty(builder, "animation-timing-function", anim.easing);
}
if (anim.direction) {
addAnimationProperty(builder, "animation-direction", anim.direction);
}
if (anim.delay) {
addAnimationProperty(builder, "animation-delay", anim.delay);
}
if (anim.fillMode) {
addAnimationProperty(builder, "animation-fill-mode", anim.fillMode);
}
addAnimationProperty(builder, "animation-name", anim.name);
}
return builder.toString();
}
export function toTimeValue(num) {
return typeof num === "number" ? num + "ms" : num;
}