Initial Commit
This commit is contained in:
254
packages/devtools/docs/compress-svg/src/parser/svg/svg.ts
Normal file
254
packages/devtools/docs/compress-svg/src/parser/svg/svg.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
SVG_COMPRESS_CODES,
|
||||
SvgCompressError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { SvgNode } from "../../types/common.js";
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
};
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s.replace(/&([^;]+);/g, (_match, name: string) => {
|
||||
if (name.startsWith("#x")) {
|
||||
return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
|
||||
}
|
||||
if (name.startsWith("#")) {
|
||||
return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
|
||||
}
|
||||
return HTML_ENTITIES[name] ?? /* istanbul ignore next */ `&${name};`;
|
||||
});
|
||||
}
|
||||
|
||||
function parseAttrs(raw: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re =
|
||||
/([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`<>=]*)))?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const name = m[1]!;
|
||||
const val = m[2] ?? m[3] ?? m[4] ?? "";
|
||||
attrs[name] = decodeEntities(val);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
export function parseSvg(svg: string): SvgNode {
|
||||
if (!svg.trim()) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Empty input string"
|
||||
);
|
||||
}
|
||||
|
||||
const stack: SvgNode[] = [];
|
||||
let pos = 0;
|
||||
let root: SvgNode | null = null;
|
||||
|
||||
function pushNode(node: SvgNode): void {
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent) {
|
||||
(parent.children ??= []).push(node);
|
||||
} else if (!root) {
|
||||
root = node;
|
||||
}
|
||||
}
|
||||
|
||||
while (pos < svg.length) {
|
||||
if (svg[pos] !== "<") {
|
||||
const end = svg.indexOf("<", pos);
|
||||
const text = end === -1 ? svg.slice(pos) : svg.slice(pos, end);
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent) {
|
||||
(parent.children ??= []).push({ type: "text", value: text });
|
||||
}
|
||||
pos = end === -1 ? svg.length : end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// XML/processing instruction: <?...?>
|
||||
if (svg.startsWith("<?", pos)) {
|
||||
const end = svg.indexOf("?>", pos + 2);
|
||||
if (end === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed processing instruction"
|
||||
);
|
||||
}
|
||||
pos = end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comment: <!--...-->
|
||||
if (svg.startsWith("<!--", pos)) {
|
||||
const end = svg.indexOf("-->", pos + 4);
|
||||
if (end === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed comment"
|
||||
);
|
||||
}
|
||||
const value = svg.slice(pos + 4, end);
|
||||
pushNode({ type: "comment", value });
|
||||
pos = end + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CDATA: <![CDATA[...]]>
|
||||
if (svg.startsWith("<![CDATA[", pos)) {
|
||||
const end = svg.indexOf("]]>", pos + 9);
|
||||
if (end === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed CDATA section"
|
||||
);
|
||||
}
|
||||
const value = svg.slice(pos + 9, end);
|
||||
pushNode({ type: "cdata", value });
|
||||
pos = end + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// DOCTYPE: <!...>
|
||||
if (svg.startsWith("<!", pos)) {
|
||||
const end = svg.indexOf(">", pos + 2);
|
||||
if (end === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed DOCTYPE"
|
||||
);
|
||||
}
|
||||
const inner = svg.slice(pos + 2, end).trim();
|
||||
if (inner.toUpperCase().startsWith("DOCTYPE")) {
|
||||
const value = inner.slice(7).trim();
|
||||
pushNode({ type: "doctype", value });
|
||||
}
|
||||
pos = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Closing tag: </name>
|
||||
if (svg.startsWith("</", pos)) {
|
||||
const end = svg.indexOf(">", pos + 2);
|
||||
if (end === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed closing tag"
|
||||
);
|
||||
}
|
||||
const name = svg.slice(pos + 2, end).trim();
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top || top.name !== name) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
`Mismatched closing tag: </${name}>, expected </${top?.name ?? "none"}>`
|
||||
);
|
||||
}
|
||||
const closed = stack.pop()!;
|
||||
if (stack.length === 0) {
|
||||
root = closed;
|
||||
}
|
||||
pos = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Opening tag: <name attrs> or <name attrs/>
|
||||
const tagEnd = findTagEnd(svg, pos + 1);
|
||||
if (tagEnd === -1) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"Unclosed opening tag"
|
||||
);
|
||||
}
|
||||
const tagContent = svg.slice(pos + 1, tagEnd);
|
||||
const selfClosing = tagContent.endsWith("/");
|
||||
const inner = selfClosing ? tagContent.slice(0, -1) : tagContent;
|
||||
|
||||
const spaceIdx = inner.search(/[\s]/);
|
||||
const name =
|
||||
spaceIdx === -1 ? inner.trim() : inner.slice(0, spaceIdx).trim();
|
||||
const attrStr = spaceIdx === -1 ? "" : inner.slice(spaceIdx + 1);
|
||||
const attributes = parseAttrs(attrStr);
|
||||
|
||||
const node: SvgNode = {
|
||||
type: "element",
|
||||
name,
|
||||
attributes,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (selfClosing) {
|
||||
pushNode(node);
|
||||
} else {
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent) {
|
||||
(parent.children ??= []).push(node);
|
||||
}
|
||||
stack.push(node);
|
||||
}
|
||||
pos = tagEnd + 1;
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
`Unclosed elements: ${stack.map((n) => n.name).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
throw new SvgCompressError(
|
||||
SVG_COMPRESS_CODES.PARSE_FAILED,
|
||||
"No root element found"
|
||||
);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function findTagEnd(svg: string, start: number): number {
|
||||
let i = start;
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
while (i < svg.length) {
|
||||
const ch = svg[i];
|
||||
if (ch === '"' && !inSingle) inDouble = !inDouble;
|
||||
else if (ch === "'" && !inDouble) inSingle = !inSingle;
|
||||
else if (ch === ">" && !inSingle && !inDouble) return i;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function serializeSvg(node: SvgNode): string {
|
||||
switch (node.type) {
|
||||
case "text":
|
||||
return node.value ?? /* istanbul ignore next */ "";
|
||||
case "comment":
|
||||
return `<!--${node.value ?? /* istanbul ignore next */ ""}-->`;
|
||||
case "cdata":
|
||||
return `<![CDATA[${node.value ?? /* istanbul ignore next */ ""}]]>`;
|
||||
case "doctype":
|
||||
return `<!DOCTYPE ${node.value ?? /* istanbul ignore next */ ""}>`;
|
||||
case "element": {
|
||||
const name = node.name ?? /* istanbul ignore next */ "unknown";
|
||||
const attrs = node.attributes
|
||||
? Object.entries(node.attributes)
|
||||
.map(([k, v]) => `${k}="${v}"`)
|
||||
.join(" ")
|
||||
: "";
|
||||
const attrStr = attrs ? ` ${attrs}` : "";
|
||||
const children = node.children ?? /* istanbul ignore next */ [];
|
||||
if (children.length === 0) {
|
||||
return `<${name}${attrStr}/>`;
|
||||
}
|
||||
const childStr = children.map(serializeSvg).join("");
|
||||
return `<${name}${attrStr}>${childStr}</${name}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user