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.
299 lines
7.2 KiB
299 lines
7.2 KiB
'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) |
|
); |
|
};
|
|
|