mirror of
https://github.com/element-hq/element-call.git
synced 2026-09-11 09:15:39 +00:00
"Embedded" already means something here: the embedded package is the widget build. Where a comment meant Element Call running as a React component inside a host application, it now says so, and the params it starts from are `componentProperties`. "Host" stays the word for the application on the other side, whether that is a widget container or an application rendering the component. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
type AtRule,
|
|
type Container,
|
|
type Document,
|
|
type Plugin,
|
|
type Rule,
|
|
} from "postcss";
|
|
import selectorParser, {
|
|
type Node,
|
|
type Pseudo,
|
|
type Selector,
|
|
} from "postcss-selector-parser";
|
|
|
|
/**
|
|
* How the stylesheets find Element Call's root element. The attribute is put
|
|
* there by `useTheme`, on the container the host gives the component.
|
|
*/
|
|
export const ROOT_SELECTOR = "[data-element-call-root]";
|
|
|
|
// Both are `:where()`, which has no specificity of its own, so the rules keep
|
|
// exactly the weight they had before being scoped and nothing in Element Call's
|
|
// cascade changes — only where it applies.
|
|
//
|
|
// The root, or anything inside it. Appended to the element a rule is about,
|
|
// rather than prepended to the whole selector, so that a rule about the root
|
|
// itself (its theme class, say) still matches.
|
|
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
|
// The root itself, standing in for the document.
|
|
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
|
|
|
/**
|
|
* Confines a stylesheet to Element Call's root element, for the build of
|
|
* Element Call as a component.
|
|
*
|
|
* As a page of its own, Element Call can style the document: normalize.css and
|
|
* Compound speak of `html`, `body` and bare elements, and the design tokens are
|
|
* declared on `:root`. As a component, all of that would land on the host's
|
|
* document too. This rewrites every selector so that it matches only the root
|
|
* or its descendants:
|
|
*
|
|
* - `html`, `body` and `:root` become the root element, which is what stands in
|
|
* for the document inside a host.
|
|
* - Everything else keeps its selector and gains `:where([data-element-call-root],
|
|
* [data-element-call-root] *)` on the element it styles.
|
|
* - Selectors that already name the root are left alone, as are keyframe
|
|
* selectors and rules nested inside another rule, which are relative to it.
|
|
*
|
|
* CSS modules are scoped by their class names already, so only their selectors
|
|
* that would match by element alone — `pre` rather than `.pre` — are touched.
|
|
*
|
|
* The root's fonts and design tokens are still inherited by everything inside
|
|
* it, the way they were from `body` and `:root`, and `@font-face` declarations
|
|
* stay global, which they are by nature.
|
|
*/
|
|
export function scopeStylesToRoot(): Plugin {
|
|
return {
|
|
postcssPlugin: "element-call-scope-styles-to-root",
|
|
Once(root) {
|
|
const isModule =
|
|
root.source?.input.file?.endsWith(".module.css") ?? false;
|
|
root.walkRules((rule) => {
|
|
if (isRelative(rule)) return;
|
|
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
|
rule.selector,
|
|
{ lossless: false },
|
|
);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Whether a rule's selectors are relative to something other than the document. */
|
|
function isRelative(rule: Rule): boolean {
|
|
let parent: Container | Document | undefined = rule.parent;
|
|
while (parent !== undefined) {
|
|
if (parent.type === "rule") return true;
|
|
if (parent.type === "atrule") {
|
|
const { name } = parent as AtRule;
|
|
if (name.endsWith("keyframes") || name === "page") return true;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
|
|
selectorParser((selectors) => {
|
|
selectors.each((selector) => {
|
|
scopeSelector(selector, isModule);
|
|
});
|
|
// Mapping `html, body` onto the root leaves the same selector twice
|
|
const seen = new Set<string>();
|
|
selectors.each((selector) => {
|
|
const text = String(selector).trim();
|
|
if (seen.has(text)) selector.remove();
|
|
else seen.add(text);
|
|
});
|
|
});
|
|
|
|
// Everything, for stylesheets that speak of the document; only what a class
|
|
// does not already confine, for CSS modules
|
|
const scopeAll = processor(false);
|
|
const scopeBare = processor(true);
|
|
|
|
function scopeSelector(selector: Selector, isModule: boolean): void {
|
|
if (String(selector).includes(ROOT_SELECTOR)) return;
|
|
|
|
const compounds = splitCompounds(selector);
|
|
if (compounds.length === 0) return;
|
|
|
|
// Something said of the document is said of the root instead
|
|
const document = compounds[0].find(isDocumentSelector);
|
|
if (document !== undefined) {
|
|
document.replaceWith(pseudo(IS_ROOT));
|
|
return;
|
|
}
|
|
|
|
const subject = compounds.at(-1)!;
|
|
if (isModule && subject.some((node) => node.type === "class")) return;
|
|
|
|
// Pseudo-elements have to come last in a compound selector
|
|
const pseudoElement = subject.find(isPseudoElement);
|
|
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
|
|
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
|
|
}
|
|
|
|
/** The compound selectors making up a complex selector, in order. */
|
|
function splitCompounds(selector: Selector): Node[][] {
|
|
const compounds: Node[][] = [[]];
|
|
for (const node of selector.nodes) {
|
|
if (node.type === "combinator") compounds.push([]);
|
|
else if (node.type !== "comment") compounds.at(-1)!.push(node);
|
|
}
|
|
return compounds.filter((compound) => compound.length > 0);
|
|
}
|
|
|
|
function isDocumentSelector(node: Node): boolean {
|
|
return (
|
|
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
|
|
(node.type === "pseudo" && node.value === ":root")
|
|
);
|
|
}
|
|
|
|
function isPseudoElement(node: Node): node is Pseudo {
|
|
if (node.type !== "pseudo") return false;
|
|
return (
|
|
node.value.startsWith("::") ||
|
|
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
|
|
);
|
|
}
|
|
|
|
function pseudo(text: string): Pseudo {
|
|
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
|
|
}
|