You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
7.2 KiB
300 lines
7.2 KiB
2 years ago
|
'use strict';
|
||
|
|
||
|
/**
|
||
|
* @typedef {import('./types').XastParent} XastParent
|
||
|
* @typedef {import('./types').XastRoot} XastRoot
|
||
|
* @typedef {import('./types').XastElement} XastElement
|
||
|
* @typedef {import('./types').XastInstruction} XastInstruction
|
||
|
* @typedef {import('./types').XastDoctype} XastDoctype
|
||
|
* @typedef {import('./types').XastText} XastText
|
||
|
* @typedef {import('./types').XastCdata} XastCdata
|
||
|
* @typedef {import('./types').XastComment} XastComment
|
||
|
* @typedef {import('./types').StringifyOptions} StringifyOptions
|
||
|
*/
|
||
|
|
||
|
const { textElems } = require('../plugins/_collections.js');
|
||
|
|
||
|
/**
|
||
|
* @typedef {{
|
||
|
* indent: string,
|
||
|
* textContext: null | XastElement,
|
||
|
* indentLevel: number,
|
||
|
* }} State
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* @typedef {Required<StringifyOptions>} Options
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* @type {(char: string) => string}
|
||
|
*/
|
||
|
const encodeEntity = (char) => {
|
||
|
return entities[char];
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {Options}
|
||
|
*/
|
||
|
const defaults = {
|
||
|
doctypeStart: '<!DOCTYPE',
|
||
|
doctypeEnd: '>',
|
||
|
procInstStart: '<?',
|
||
|
procInstEnd: '?>',
|
||
|
tagOpenStart: '<',
|
||
|
tagOpenEnd: '>',
|
||
|
tagCloseStart: '</',
|
||
|
tagCloseEnd: '>',
|
||
|
tagShortStart: '<',
|
||
|
tagShortEnd: '/>',
|
||
|
attrStart: '="',
|
||
|
attrEnd: '"',
|
||
|
commentStart: '<!--',
|
||
|
commentEnd: '-->',
|
||
|
cdataStart: '<![CDATA[',
|
||
|
cdataEnd: ']]>',
|
||
|
textStart: '',
|
||
|
textEnd: '',
|
||
|
indent: 4,
|
||
|
regEntities: /[&'"<>]/g,
|
||
|
regValEntities: /[&"<>]/g,
|
||
|
encodeEntity: encodeEntity,
|
||
|
pretty: false,
|
||
|
useShortTags: true,
|
||
|
eol: 'lf',
|
||
|
finalNewline: false,
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {Record<string, string>}
|
||
|
*/
|
||
|
const entities = {
|
||
|
'&': '&',
|
||
|
"'": ''',
|
||
|
'"': '"',
|
||
|
'>': '>',
|
||
|
'<': '<',
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* convert XAST to SVG string
|
||
|
*
|
||
|
* @type {(data: XastRoot, config: StringifyOptions) => string}
|
||
|
*/
|
||
|
const stringifySvg = (data, userOptions = {}) => {
|
||
|
/**
|
||
|
* @type {Options}
|
||
|
*/
|
||
|
const config = { ...defaults, ...userOptions };
|
||
|
const indent = config.indent;
|
||
|
let newIndent = ' ';
|
||
|
if (typeof indent === 'number' && Number.isNaN(indent) === false) {
|
||
|
newIndent = indent < 0 ? '\t' : ' '.repeat(indent);
|
||
|
} else if (typeof indent === 'string') {
|
||
|
newIndent = indent;
|
||
|
}
|
||
|
/**
|
||
|
* @type {State}
|
||
|
*/
|
||
|
const state = {
|
||
|
indent: newIndent,
|
||
|
textContext: null,
|
||
|
indentLevel: 0,
|
||
|
};
|
||
|
const eol = config.eol === 'crlf' ? '\r\n' : '\n';
|
||
|
if (config.pretty) {
|
||
|
config.doctypeEnd += eol;
|
||
|
config.procInstEnd += eol;
|
||
|
config.commentEnd += eol;
|
||
|
config.cdataEnd += eol;
|
||
|
config.tagShortEnd += eol;
|
||
|
config.tagOpenEnd += eol;
|
||
|
config.tagCloseEnd += eol;
|
||
|
config.textEnd += eol;
|
||
|
}
|
||
|
let svg = stringifyNode(data, config, state);
|
||
|
if (config.finalNewline && svg.length > 0 && svg[svg.length - 1] !== '\n') {
|
||
|
svg += eol;
|
||
|
}
|
||
|
return svg;
|
||
|
};
|
||
|
exports.stringifySvg = stringifySvg;
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastParent, config: Options, state: State) => string}
|
||
|
*/
|
||
|
const stringifyNode = (data, config, state) => {
|
||
|
let svg = '';
|
||
|
state.indentLevel += 1;
|
||
|
for (const item of data.children) {
|
||
|
if (item.type === 'element') {
|
||
|
svg += stringifyElement(item, config, state);
|
||
|
}
|
||
|
if (item.type === 'text') {
|
||
|
svg += stringifyText(item, config, state);
|
||
|
}
|
||
|
if (item.type === 'doctype') {
|
||
|
svg += stringifyDoctype(item, config);
|
||
|
}
|
||
|
if (item.type === 'instruction') {
|
||
|
svg += stringifyInstruction(item, config);
|
||
|
}
|
||
|
if (item.type === 'comment') {
|
||
|
svg += stringifyComment(item, config);
|
||
|
}
|
||
|
if (item.type === 'cdata') {
|
||
|
svg += stringifyCdata(item, config, state);
|
||
|
}
|
||
|
}
|
||
|
state.indentLevel -= 1;
|
||
|
return svg;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* create indent string in accordance with the current node level.
|
||
|
*
|
||
|
* @type {(config: Options, state: State) => string}
|
||
|
*/
|
||
|
const createIndent = (config, state) => {
|
||
|
let indent = '';
|
||
|
if (config.pretty && state.textContext == null) {
|
||
|
indent = state.indent.repeat(state.indentLevel - 1);
|
||
|
}
|
||
|
return indent;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastDoctype, config: Options) => string}
|
||
|
*/
|
||
|
const stringifyDoctype = (node, config) => {
|
||
|
return config.doctypeStart + node.data.doctype + config.doctypeEnd;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastInstruction, config: Options) => string}
|
||
|
*/
|
||
|
const stringifyInstruction = (node, config) => {
|
||
|
return (
|
||
|
config.procInstStart + node.name + ' ' + node.value + config.procInstEnd
|
||
|
);
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastComment, config: Options) => string}
|
||
|
*/
|
||
|
const stringifyComment = (node, config) => {
|
||
|
return config.commentStart + node.value + config.commentEnd;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastCdata, config: Options, state: State) => string}
|
||
|
*/
|
||
|
const stringifyCdata = (node, config, state) => {
|
||
|
return (
|
||
|
createIndent(config, state) +
|
||
|
config.cdataStart +
|
||
|
node.value +
|
||
|
config.cdataEnd
|
||
|
);
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastElement, config: Options, state: State) => string}
|
||
|
*/
|
||
|
const stringifyElement = (node, config, state) => {
|
||
|
// empty element and short tag
|
||
|
if (node.children.length === 0) {
|
||
|
if (config.useShortTags) {
|
||
|
return (
|
||
|
createIndent(config, state) +
|
||
|
config.tagShortStart +
|
||
|
node.name +
|
||
|
stringifyAttributes(node, config) +
|
||
|
config.tagShortEnd
|
||
|
);
|
||
|
} else {
|
||
|
return (
|
||
|
createIndent(config, state) +
|
||
|
config.tagShortStart +
|
||
|
node.name +
|
||
|
stringifyAttributes(node, config) +
|
||
|
config.tagOpenEnd +
|
||
|
config.tagCloseStart +
|
||
|
node.name +
|
||
|
config.tagCloseEnd
|
||
|
);
|
||
|
}
|
||
|
// non-empty element
|
||
|
} else {
|
||
|
let tagOpenStart = config.tagOpenStart;
|
||
|
let tagOpenEnd = config.tagOpenEnd;
|
||
|
let tagCloseStart = config.tagCloseStart;
|
||
|
let tagCloseEnd = config.tagCloseEnd;
|
||
|
let openIndent = createIndent(config, state);
|
||
|
let closeIndent = createIndent(config, state);
|
||
|
|
||
|
if (state.textContext) {
|
||
|
tagOpenStart = defaults.tagOpenStart;
|
||
|
tagOpenEnd = defaults.tagOpenEnd;
|
||
|
tagCloseStart = defaults.tagCloseStart;
|
||
|
tagCloseEnd = defaults.tagCloseEnd;
|
||
|
openIndent = '';
|
||
|
} else if (textElems.includes(node.name)) {
|
||
|
tagOpenEnd = defaults.tagOpenEnd;
|
||
|
tagCloseStart = defaults.tagCloseStart;
|
||
|
closeIndent = '';
|
||
|
state.textContext = node;
|
||
|
}
|
||
|
|
||
|
const children = stringifyNode(node, config, state);
|
||
|
|
||
|
if (state.textContext === node) {
|
||
|
state.textContext = null;
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
openIndent +
|
||
|
tagOpenStart +
|
||
|
node.name +
|
||
|
stringifyAttributes(node, config) +
|
||
|
tagOpenEnd +
|
||
|
children +
|
||
|
closeIndent +
|
||
|
tagCloseStart +
|
||
|
node.name +
|
||
|
tagCloseEnd
|
||
|
);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastElement, config: Options) => string}
|
||
|
*/
|
||
|
const stringifyAttributes = (node, config) => {
|
||
|
let attrs = '';
|
||
|
for (const [name, value] of Object.entries(node.attributes)) {
|
||
|
// TODO remove attributes without values support in v3
|
||
|
if (value !== undefined) {
|
||
|
const encodedValue = value
|
||
|
.toString()
|
||
|
.replace(config.regValEntities, config.encodeEntity);
|
||
|
attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd;
|
||
|
} else {
|
||
|
attrs += ' ' + name;
|
||
|
}
|
||
|
}
|
||
|
return attrs;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* @type {(node: XastText, config: Options, state: State) => string}
|
||
|
*/
|
||
|
const stringifyText = (node, config, state) => {
|
||
|
return (
|
||
|
createIndent(config, state) +
|
||
|
config.textStart +
|
||
|
node.value.replace(config.regEntities, config.encodeEntity) +
|
||
|
(state.textContext ? '' : config.textEnd)
|
||
|
);
|
||
|
};
|