Skip to content

Commit 7104c0a

Browse files
committed
chore: Migrate to TypeScript class format to allow DomSerializer extension
1 parent d0ac5e1 commit 7104c0a

File tree

1 file changed

+174
-145
lines changed

1 file changed

+174
-145
lines changed

src/index.ts

Lines changed: 174 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -74,44 +74,6 @@ const unencodedElements = new Set([
7474
"noscript",
7575
]);
7676

77-
function replaceQuotes(value: string): string {
78-
return value.replace(/"/g, """);
79-
}
80-
81-
/**
82-
* Format attributes
83-
*/
84-
function formatAttributes(
85-
attributes: Record<string, string | null> | undefined,
86-
opts: DomSerializerOptions
87-
) {
88-
if (!attributes) return;
89-
90-
const encode =
91-
(opts.encodeEntities ?? opts.decodeEntities) === false
92-
? replaceQuotes
93-
: opts.xmlMode || opts.encodeEntities !== "utf8"
94-
? encodeXML
95-
: escapeAttribute;
96-
97-
return Object.keys(attributes)
98-
.map((key) => {
99-
const value = attributes[key] ?? "";
100-
101-
if (opts.xmlMode === "foreign") {
102-
/* Fix up mixed-case attribute names */
103-
key = attributeNames.get(key) ?? key;
104-
}
105-
106-
if (!opts.emptyAttrs && !opts.xmlMode && value === "") {
107-
return key;
108-
}
109-
110-
return `${key}="${encode(value)}"`;
111-
})
112-
.join(" ");
113-
}
114-
11577
/**
11678
* Self-enclosing tags
11779
*/
@@ -137,52 +99,6 @@ const singleTag = new Set([
13799
"wbr",
138100
]);
139101

140-
/**
141-
* Renders a DOM node or an array of DOM nodes to a string.
142-
*
143-
* Can be thought of as the equivalent of the `outerHTML` of the passed node(s).
144-
*
145-
* @param node Node to be rendered.
146-
* @param options Changes serialization behavior
147-
*/
148-
export function render(
149-
node: AnyNode | ArrayLike<AnyNode>,
150-
options: DomSerializerOptions = {}
151-
): string {
152-
const nodes = "length" in node ? node : [node];
153-
154-
let output = "";
155-
156-
for (let i = 0; i < nodes.length; i++) {
157-
output += renderNode(nodes[i], options);
158-
}
159-
160-
return output;
161-
}
162-
163-
export default render;
164-
165-
function renderNode(node: AnyNode, options: DomSerializerOptions): string {
166-
switch (node.type) {
167-
case ElementType.Root:
168-
return render(node.children, options);
169-
// @ts-expect-error We don't use `Doctype` yet
170-
case ElementType.Doctype:
171-
case ElementType.Directive:
172-
return renderDirective(node);
173-
case ElementType.Comment:
174-
return renderComment(node);
175-
case ElementType.CDATA:
176-
return renderCdata(node);
177-
case ElementType.Script:
178-
case ElementType.Style:
179-
case ElementType.Tag:
180-
return renderTag(node, options);
181-
case ElementType.Text:
182-
return renderText(node, options);
183-
}
184-
}
185-
186102
const foreignModeIntegrationPoints = new Set([
187103
"mi",
188104
"mo",
@@ -197,83 +113,196 @@ const foreignModeIntegrationPoints = new Set([
197113

198114
const foreignElements = new Set(["svg", "math"]);
199115

200-
function renderTag(elem: Element, opts: DomSerializerOptions) {
201-
// Handle SVG / MathML in HTML
202-
if (opts.xmlMode === "foreign") {
203-
/* Fix up mixed-case element names */
204-
elem.name = elementNames.get(elem.name) ?? elem.name;
205-
/* Exit foreign mode at integration points */
206-
if (
207-
elem.parent &&
208-
foreignModeIntegrationPoints.has((elem.parent as Element).name)
209-
) {
210-
opts = { ...opts, xmlMode: false };
116+
export class DomSerializer {
117+
protected output: string;
118+
protected options: DomSerializerOptions;
119+
120+
/**
121+
* Creates a serializer instance
122+
*
123+
* @param options Changes serialization behavior
124+
*/
125+
constructor(options: DomSerializerOptions = {}) {
126+
this.options = options;
127+
this.output = "";
128+
}
129+
130+
/**
131+
* Renders a DOM node or an array of DOM nodes to a string.
132+
*
133+
* Can be thought of as the equivalent of the `outerHTML` of the passed node(s).
134+
*
135+
* @param node Node to be rendered.
136+
*/
137+
render(node: AnyNode | ArrayLike<AnyNode>): string {
138+
const nodes = "length" in node ? node : [node];
139+
140+
this.output = "";
141+
142+
for (let i = 0; i < nodes.length; i++) {
143+
this.renderNode(nodes[i]);
211144
}
145+
146+
return this.output;
212147
}
213-
if (!opts.xmlMode && foreignElements.has(elem.name)) {
214-
opts = { ...opts, xmlMode: "foreign" };
148+
149+
renderNode(node: AnyNode): void {
150+
switch (node.type) {
151+
case ElementType.Root:
152+
this.render(node.children);
153+
break;
154+
// @ts-expect-error We don't use `Doctype` yet
155+
case ElementType.Doctype:
156+
case ElementType.Directive:
157+
this.renderDirective(node);
158+
break;
159+
case ElementType.Comment:
160+
this.renderComment(node);
161+
break;
162+
case ElementType.CDATA:
163+
this.renderCdata(node);
164+
break;
165+
case ElementType.Script:
166+
case ElementType.Style:
167+
case ElementType.Tag:
168+
this.renderTag(node);
169+
break;
170+
case ElementType.Text:
171+
this.renderText(node);
172+
break;
173+
}
215174
}
216175

217-
let tag = `<${elem.name}`;
218-
const attribs = formatAttributes(elem.attribs, opts);
176+
renderTag(elem: Element): void {
177+
// Handle SVG / MathML in HTML
178+
if (this.options.xmlMode === "foreign") {
179+
/* Fix up mixed-case element names */
180+
elem.name = elementNames.get(elem.name) ?? elem.name;
181+
/* Exit foreign mode at integration points */
182+
if (
183+
elem.parent &&
184+
foreignModeIntegrationPoints.has((elem.parent as Element).name)
185+
) {
186+
this.options = { ...this.options, xmlMode: false };
187+
}
188+
}
189+
if (!this.options.xmlMode && foreignElements.has(elem.name)) {
190+
this.options = { ...this.options, xmlMode: "foreign" };
191+
}
219192

220-
if (attribs) {
221-
tag += ` ${attribs}`;
222-
}
193+
this.output += `<${elem.name}`;
194+
const attribs = this.formatAttributes(elem.attribs);
223195

224-
if (
225-
elem.children.length === 0 &&
226-
(opts.xmlMode
227-
? // In XML mode or foreign mode, and user hasn't explicitly turned off self-closing tags
228-
opts.selfClosingTags !== false
229-
: // User explicitly asked for self-closing tags, even in HTML mode
230-
opts.selfClosingTags && singleTag.has(elem.name))
231-
) {
232-
if (!opts.xmlMode) tag += " ";
233-
tag += "/>";
234-
} else {
235-
tag += ">";
236-
if (elem.children.length > 0) {
237-
tag += render(elem.children, opts);
196+
if (attribs) {
197+
this.output += ` ${attribs}`;
238198
}
239199

240-
if (opts.xmlMode || !singleTag.has(elem.name)) {
241-
tag += `</${elem.name}>`;
200+
if (
201+
elem.children.length === 0 &&
202+
(this.options.xmlMode
203+
? // In XML mode or foreign mode, and user hasn't explicitly turned off self-closing tags
204+
this.options.selfClosingTags !== false
205+
: // User explicitly asked for self-closing tags, even in HTML mode
206+
this.options.selfClosingTags && singleTag.has(elem.name))
207+
) {
208+
if (!this.options.xmlMode) this.output += " ";
209+
this.output += "/>";
210+
} else {
211+
this.output += ">";
212+
if (elem.children.length > 0) {
213+
this.output += render(elem.children, this.options);
214+
}
215+
216+
if (this.options.xmlMode || !singleTag.has(elem.name)) {
217+
this.output += `</${elem.name}>`;
218+
}
242219
}
243220
}
244221

245-
return tag;
246-
}
222+
renderDirective(elem: ProcessingInstruction): void {
223+
this.output += `<${elem.data}>`;
224+
}
247225

248-
function renderDirective(elem: ProcessingInstruction) {
249-
return `<${elem.data}>`;
250-
}
226+
renderText(elem: Text): void {
227+
let data = elem.data || "";
228+
229+
// If entities weren't decoded, no need to encode them back
230+
if (
231+
(this.options.encodeEntities ?? this.options.decodeEntities) !== false &&
232+
!(
233+
!this.options.xmlMode &&
234+
elem.parent &&
235+
unencodedElements.has((elem.parent as Element).name)
236+
)
237+
) {
238+
data =
239+
this.options.xmlMode || this.options.encodeEntities !== "utf8"
240+
? encodeXML(data)
241+
: escapeText(data);
242+
}
251243

252-
function renderText(elem: Text, opts: DomSerializerOptions) {
253-
let data = elem.data || "";
254-
255-
// If entities weren't decoded, no need to encode them back
256-
if (
257-
(opts.encodeEntities ?? opts.decodeEntities) !== false &&
258-
!(
259-
!opts.xmlMode &&
260-
elem.parent &&
261-
unencodedElements.has((elem.parent as Element).name)
262-
)
263-
) {
264-
data =
265-
opts.xmlMode || opts.encodeEntities !== "utf8"
266-
? encodeXML(data)
267-
: escapeText(data);
244+
this.output += data;
268245
}
269246

270-
return data;
271-
}
247+
renderCdata(elem: CDATA): void {
248+
this.output += `<![CDATA[${(elem.children[0] as Text).data}]]>`;
249+
}
272250

273-
function renderCdata(elem: CDATA) {
274-
return `<![CDATA[${(elem.children[0] as Text).data}]]>`;
251+
renderComment(elem: Comment): void {
252+
this.output += `<!--${elem.data}-->`;
253+
}
254+
255+
replaceQuotes(value: string): string {
256+
return value.replace(/"/g, "&quot;");
257+
}
258+
259+
/**
260+
* Format attributes
261+
*/
262+
formatAttributes(
263+
attributes: Record<string, string | null> | undefined
264+
): string | undefined {
265+
if (!attributes) return;
266+
267+
const encode =
268+
(this.options.encodeEntities ?? this.options.decodeEntities) === false
269+
? this.replaceQuotes
270+
: this.options.xmlMode || this.options.encodeEntities !== "utf8"
271+
? encodeXML
272+
: escapeAttribute;
273+
274+
return Object.keys(attributes)
275+
.map((key) => {
276+
const value = attributes[key] ?? "";
277+
278+
if (this.options.xmlMode === "foreign") {
279+
/* Fix up mixed-case attribute names */
280+
key = attributeNames.get(key) ?? key;
281+
}
282+
283+
if (!this.options.emptyAttrs && !this.options.xmlMode && value === "") {
284+
return key;
285+
}
286+
287+
return `${key}="${encode(value)}"`;
288+
})
289+
.join(" ");
290+
}
275291
}
276292

277-
function renderComment(elem: Comment) {
278-
return `<!--${elem.data}-->`;
293+
/**
294+
* Renders a DOM node or an array of DOM nodes to a string.
295+
*
296+
* Can be thought of as the equivalent of the `outerHTML` of the passed node(s).
297+
*
298+
* @param node Node to be rendered.
299+
* @param options Changes serialization behavior
300+
*/
301+
export function render(
302+
node: AnyNode | ArrayLike<AnyNode>,
303+
options: DomSerializerOptions = {}
304+
): string {
305+
return new DomSerializer(options).render(node);
279306
}
307+
308+
export default render;

0 commit comments

Comments
 (0)