UNPKG

7.32 kBJavaScriptView Raw
1'use strict';
2
3/**
4 * @typedef {import('./types').XastParent} XastParent
5 * @typedef {import('./types').XastRoot} XastRoot
6 * @typedef {import('./types').XastElement} XastElement
7 * @typedef {import('./types').XastInstruction} XastInstruction
8 * @typedef {import('./types').XastDoctype} XastDoctype
9 * @typedef {import('./types').XastText} XastText
10 * @typedef {import('./types').XastCdata} XastCdata
11 * @typedef {import('./types').XastComment} XastComment
12 * @typedef {import('./types').StringifyOptions} StringifyOptions
13 */
14
15const { textElems } = require('../plugins/_collections');
16
17/**
18 * @typedef {{
19 * indent: string,
20 * textContext: ?XastElement,
21 * indentLevel: number,
22 * }} State
23 */
24
25/**
26 * @typedef {Required<StringifyOptions>} Options
27 */
28
29/**
30 * @type {(char: string) => string}
31 */
32const encodeEntity = (char) => {
33 return entities[char];
34};
35
36/** @type {Options} */
37const defaults = {
38 doctypeStart: '<!DOCTYPE',
39 doctypeEnd: '>',
40 procInstStart: '<?',
41 procInstEnd: '?>',
42 tagOpenStart: '<',
43 tagOpenEnd: '>',
44 tagCloseStart: '</',
45 tagCloseEnd: '>',
46 tagShortStart: '<',
47 tagShortEnd: '/>',
48 attrStart: '="',
49 attrEnd: '"',
50 commentStart: '<!--',
51 commentEnd: '-->',
52 cdataStart: '<![CDATA[',
53 cdataEnd: ']]>',
54 textStart: '',
55 textEnd: '',
56 indent: 4,
57 regEntities: /[&'"<>]/g,
58 regValEntities: /[&"<>]/g,
59 encodeEntity,
60 pretty: false,
61 useShortTags: true,
62 eol: 'lf',
63 finalNewline: false,
64};
65
66/** @type {Record<string, string>} */
67const entities = {
68 '&': '&amp;',
69 "'": '&apos;',
70 '"': '&quot;',
71 '>': '&gt;',
72 '<': '&lt;',
73};
74
75/**
76 * convert XAST to SVG string
77 *
78 * @type {(data: XastRoot, config: StringifyOptions) => string}
79 */
80const stringifySvg = (data, userOptions = {}) => {
81 /**
82 * @type {Options}
83 */
84 const config = { ...defaults, ...userOptions };
85 const indent = config.indent;
86 let newIndent = ' ';
87 if (typeof indent === 'number' && Number.isNaN(indent) === false) {
88 newIndent = indent < 0 ? '\t' : ' '.repeat(indent);
89 } else if (typeof indent === 'string') {
90 newIndent = indent;
91 }
92 /**
93 * @type {State}
94 */
95 const state = {
96 indent: newIndent,
97 textContext: null,
98 indentLevel: 0,
99 };
100 const eol = config.eol === 'crlf' ? '\r\n' : '\n';
101 if (config.pretty) {
102 config.doctypeEnd += eol;
103 config.procInstEnd += eol;
104 config.commentEnd += eol;
105 config.cdataEnd += eol;
106 config.tagShortEnd += eol;
107 config.tagOpenEnd += eol;
108 config.tagCloseEnd += eol;
109 config.textEnd += eol;
110 }
111 let svg = stringifyNode(data, config, state);
112 if (config.finalNewline && svg.length > 0 && !svg.endsWith('\n')) {
113 svg += eol;
114 }
115 return svg;
116};
117exports.stringifySvg = stringifySvg;
118
119/**
120 * @type {(node: XastParent, config: Options, state: State) => string}
121 */
122const stringifyNode = (data, config, state) => {
123 let svg = '';
124 state.indentLevel += 1;
125 for (const item of data.children) {
126 if (item.type === 'element') {
127 svg += stringifyElement(item, config, state);
128 }
129 if (item.type === 'text') {
130 svg += stringifyText(item, config, state);
131 }
132 if (item.type === 'doctype') {
133 svg += stringifyDoctype(item, config);
134 }
135 if (item.type === 'instruction') {
136 svg += stringifyInstruction(item, config);
137 }
138 if (item.type === 'comment') {
139 svg += stringifyComment(item, config);
140 }
141 if (item.type === 'cdata') {
142 svg += stringifyCdata(item, config, state);
143 }
144 }
145 state.indentLevel -= 1;
146 return svg;
147};
148
149/**
150 * create indent string in accordance with the current node level.
151 *
152 * @type {(config: Options, state: State) => string}
153 */
154const createIndent = (config, state) => {
155 let indent = '';
156 if (config.pretty && state.textContext == null) {
157 indent = state.indent.repeat(state.indentLevel - 1);
158 }
159 return indent;
160};
161
162/**
163 * @type {(node: XastDoctype, config: Options) => string}
164 */
165const stringifyDoctype = (node, config) => {
166 return config.doctypeStart + node.data.doctype + config.doctypeEnd;
167};
168
169/**
170 * @type {(node: XastInstruction, config: Options) => string}
171 */
172const stringifyInstruction = (node, config) => {
173 return (
174 config.procInstStart + node.name + ' ' + node.value + config.procInstEnd
175 );
176};
177
178/**
179 * @type {(node: XastComment, config: Options) => string}
180 */
181const stringifyComment = (node, config) => {
182 return config.commentStart + node.value + config.commentEnd;
183};
184
185/**
186 * @type {(node: XastCdata, config: Options, state: State) => string}
187 */
188const stringifyCdata = (node, config, state) => {
189 return (
190 createIndent(config, state) +
191 config.cdataStart +
192 node.value +
193 config.cdataEnd
194 );
195};
196
197/**
198 * @type {(node: XastElement, config: Options, state: State) => string}
199 */
200const stringifyElement = (node, config, state) => {
201 // empty element and short tag
202 if (node.children.length === 0) {
203 if (config.useShortTags) {
204 return (
205 createIndent(config, state) +
206 config.tagShortStart +
207 node.name +
208 stringifyAttributes(node, config) +
209 config.tagShortEnd
210 );
211 } else {
212 return (
213 createIndent(config, state) +
214 config.tagShortStart +
215 node.name +
216 stringifyAttributes(node, config) +
217 config.tagOpenEnd +
218 config.tagCloseStart +
219 node.name +
220 config.tagCloseEnd
221 );
222 }
223 // non-empty element
224 } else {
225 let tagOpenStart = config.tagOpenStart;
226 let tagOpenEnd = config.tagOpenEnd;
227 let tagCloseStart = config.tagCloseStart;
228 let tagCloseEnd = config.tagCloseEnd;
229 let openIndent = createIndent(config, state);
230 let closeIndent = createIndent(config, state);
231
232 if (state.textContext) {
233 tagOpenStart = defaults.tagOpenStart;
234 tagOpenEnd = defaults.tagOpenEnd;
235 tagCloseStart = defaults.tagCloseStart;
236 tagCloseEnd = defaults.tagCloseEnd;
237 openIndent = '';
238 } else if (textElems.has(node.name)) {
239 tagOpenEnd = defaults.tagOpenEnd;
240 tagCloseStart = defaults.tagCloseStart;
241 closeIndent = '';
242 state.textContext = node;
243 }
244
245 const children = stringifyNode(node, config, state);
246
247 if (state.textContext === node) {
248 state.textContext = null;
249 }
250
251 return (
252 openIndent +
253 tagOpenStart +
254 node.name +
255 stringifyAttributes(node, config) +
256 tagOpenEnd +
257 children +
258 closeIndent +
259 tagCloseStart +
260 node.name +
261 tagCloseEnd
262 );
263 }
264};
265
266/**
267 * @type {(node: XastElement, config: Options) => string}
268 */
269const stringifyAttributes = (node, config) => {
270 let attrs = '';
271 for (const [name, value] of Object.entries(node.attributes)) {
272 // TODO remove attributes without values support in v3
273 if (value !== undefined) {
274 const encodedValue = value
275 .toString()
276 .replace(config.regValEntities, config.encodeEntity);
277 attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd;
278 } else {
279 attrs += ' ' + name;
280 }
281 }
282 return attrs;
283};
284
285/**
286 * @type {(node: XastText, config: Options, state: State) => string}
287 */
288const stringifyText = (node, config, state) => {
289 return (
290 createIndent(config, state) +
291 config.textStart +
292 node.value.replace(config.regEntities, config.encodeEntity) +
293 (state.textContext ? '' : config.textEnd)
294 );
295};