core/converters/style.converter.ts
core/converters/style.converter.ts is a file in Bane's Lab Build Scripts. 191 lines of code and 70 definitions.
import type { Hoisted, Rescoped } from "#types/diagram.types";
import { decodeEntities, nextQuote, parseTag, serializeTag, tagEnd } from "#core/converters/markup.converter";
import type { Tag } from "#types/markup.types";
import { attributeOf } from "#core/selectors/markup.selector";
const TAG_OPEN = "<";
const STYLE_OPEN = "<style>";
const STYLE_CLOSE = "</style>";
const STYLE_ATTRIBUTE = "style";
const CLASS_ATTRIBUTE = "class";
const ID_ATTRIBUTE = "id";
const ROOT_TAG = "svg";
const VECTOR_CLASS = "diagram-vector";
const RULE_CLASS_PREFIX = "diagram-style-";
const ID_MARK = "#";
const CLASS_MARK = ".";
const SPACE = " ";
const LINE_END = "\n";
const DECLARATION_END = ";";
const IMPORTANT = "!important";
const RULE_OPEN = "{";
const RULE_CLOSE = "}";
const GROUP_OPEN = "(";
const GROUP_CLOSE = ")";
const NON_ELEMENT_OPENERS = new Set(["/", "!", "?"]);
const IDENTIFIER_CHARS = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_");
class Sheet {
private readonly blocks = new Set<string>();
private readonly classes = new Map<string, string>();
public addBlock(css: string): void {
this.blocks.add(css);
}
public classFor(declarations: string): string {
const known = this.classes.get(declarations);
if (known !== undefined) {
return known;
}
const created = RULE_CLASS_PREFIX + String(this.classes.size + 1);
this.classes.set(declarations, created);
return created;
}
public render(): string {
const rules = [...this.classes].map(
([declarations, name]) => CLASS_MARK + name + RULE_OPEN + declarations + RULE_CLOSE,
);
return [...this.blocks, ...rules].join(LINE_END) + LINE_END;
}
}
const identifierEnd = function identifierEnd(text: string, from: number): number {
let at = from;
while (at < text.length && IDENTIFIER_CHARS.has(text.charAt(at))) {
at += 1;
}
return at;
};
export const rescope = function rescope(css: string, rootId: string): Rescoped {
const needle = ID_MARK + rootId;
const derived = new Set<string>();
let out = "";
let at = 0;
if (rootId.length === 0) {
return { css, derived };
}
while (at < css.length) {
const found = css.indexOf(needle, at);
if (found === -1) {
return { css: out + css.slice(at), derived };
}
const end = identifierEnd(css, found + needle.length);
const suffix = css.slice(found + needle.length, end);
if (suffix.length > 0) {
derived.add(suffix);
}
out += css.slice(at, found) + (suffix.length === 0 ? CLASS_MARK : ID_MARK) + VECTOR_CLASS + suffix;
at = end;
}
return { css: out, derived };
};
const renameDerived = function renameDerived(markup: string, rootId: string, derived: ReadonlySet<string>): string {
let out = markup;
for (const suffix of derived) {
out = out.split(rootId + suffix).join(VECTOR_CLASS + suffix);
}
return out;
};
const splitDeclarations = function splitDeclarations(text: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let at = 0; at < text.length; at += 1) {
const char = text.charAt(at);
const wasQuoted = quote !== "";
quote = nextQuote(quote, char);
if (wasQuoted || quote !== "") {
continue;
}
depth += char === GROUP_OPEN ? 1 : 0;
depth -= char === GROUP_CLOSE ? 1 : 0;
if (char === DECLARATION_END && depth === 0) {
parts.push(text.slice(start, at));
start = at + 1;
}
}
parts.push(text.slice(start));
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
};
export const prioritize = function prioritize(declarations: string): string {
return splitDeclarations(declarations)
.map((declaration) => (declaration.endsWith(IMPORTANT) ? declaration : declaration + SPACE + IMPORTANT))
.join(DECLARATION_END);
};
const withClass = function withClass(tag: Tag, className: string): Tag {
const present = attributeOf(tag, CLASS_ATTRIBUTE) !== undefined;
const attributes = present
? tag.attributes.map((attribute) =>
attribute.name === CLASS_ATTRIBUTE
? { ...attribute, value: [attribute.value ?? "", className].join(SPACE).trim() }
: attribute,
)
: [...tag.attributes, { name: CLASS_ATTRIBUTE, quote: '"', value: className }];
return { ...tag, attributes };
};
const hoistTag = function hoistTag(tag: Tag, sheet: Sheet, isRoot: boolean): Tag {
const style = attributeOf(tag, STYLE_ATTRIBUTE);
const stripped = { ...tag, attributes: tag.attributes.filter((attribute) => attribute !== style) };
const rooted = isRoot ? withClass(stripped, VECTOR_CLASS) : stripped;
const declarations = decodeEntities(style?.value ?? "").trim();
return declarations.length === 0 ? rooted : withClass(rooted, sheet.classFor(prioritize(declarations)));
};
const rootIdOf = function rootIdOf(markup: string): string {
const open = markup.indexOf(TAG_OPEN + ROOT_TAG);
const close = open === -1 ? -1 : tagEnd(markup, open + 1);
return close < 0 ? "" : (attributeOf(parseTag(markup.slice(open + 1, close)), ID_ATTRIBUTE)?.value ?? "");
};
const liftBlocks = function liftBlocks(markup: string, sheet: Sheet, rootId: string): string {
const derived = new Set<string>();
let out = "";
let at = 0;
while (at < markup.length) {
const open = markup.indexOf(STYLE_OPEN, at);
const close = open === -1 ? -1 : markup.indexOf(STYLE_CLOSE, open);
if (close < 0) {
out += markup.slice(at);
at = markup.length;
continue;
}
const rescoped = rescope(decodeEntities(markup.slice(open + STYLE_OPEN.length, close)), rootId);
sheet.addBlock(rescoped.css);
for (const suffix of rescoped.derived) {
derived.add(suffix);
}
out += markup.slice(at, open);
at = close + STYLE_CLOSE.length;
}
return renameDerived(out, rootId, derived);
};
const liftAttributes = function liftAttributes(markup: string, sheet: Sheet): string {
let out = "";
let at = 0;
let rootSeen = false;
while (at < markup.length) {
const open = markup.indexOf(TAG_OPEN, at);
if (open === -1) {
return out + markup.slice(at);
}
const close = NON_ELEMENT_OPENERS.has(markup.charAt(open + 1)) ? -1 : tagEnd(markup, open + 1);
if (close < 0) {
out += markup.slice(at, open + 1);
at = open + 1;
continue;
}
const tag = parseTag(markup.slice(open + 1, close));
const isRoot: boolean = !rootSeen && tag.name === ROOT_TAG;
rootSeen ||= isRoot;
const touched = isRoot || attributeOf(tag, STYLE_ATTRIBUTE) !== undefined;
out +=
markup.slice(at, open) +
(touched ? serializeTag(hoistTag(tag, sheet, isRoot)) : markup.slice(open, close + 1));
at = close + 1;
}
return out;
};
export const hoistStyles = function hoistStyles(rendered: ReadonlyMap<string, string>): Hoisted {
const sheet = new Sheet();
const vectors = new Map<string, string>();
for (const [source, markup] of rendered) {
const lifted = liftBlocks(markup, sheet, rootIdOf(markup));
vectors.set(source, liftAttributes(lifted, sheet));
}
return { stylesheet: sheet.render(), vectors };
};