/** * @typedef {import('hast').Element} Element * @typedef {import('hast').Parents} Parents * @typedef {import('hast').Properties} Properties * * @typedef {import('../index.js').State} State */ import {ccount} from 'ccount' import {stringify as commas} from 'comma-separated-tokens' import {find, svg} from 'property-information' import {stringify as spaces} from 'space-separated-tokens' import {stringifyEntities} from 'stringify-entities' import {closing} from '../omission/closing.js' import {opening} from '../omission/opening.js' /** * Maps of subsets. * * Each value is a matrix of tuples. * The value at `0` causes parse errors, the value at `1` is valid. * Of both, the value at `0` is unsafe, and the value at `1` is safe. * * @type {Record<'double' | 'name' | 'single' | 'unquoted', Array<[Array, Array]>>} */ const constants = { // See: . name: [ ['\t\n\f\r &/=>'.split(''), '\t\n\f\r "&\'/=>`'.split('')], ['\0\t\n\f\r "&\'/<=>'.split(''), '\0\t\n\f\r "&\'/<=>`'.split('')] ], // See: . unquoted: [ ['\t\n\f\r &>'.split(''), '\0\t\n\f\r "&\'<=>`'.split('')], ['\0\t\n\f\r "&\'<=>`'.split(''), '\0\t\n\f\r "&\'<=>`'.split('')] ], // See: . single: [ ["&'".split(''), '"&\'`'.split('')], ["\0&'".split(''), '\0"&\'`'.split('')] ], // See: . double: [ ['"&'.split(''), '"&\'`'.split('')], ['\0"&'.split(''), '\0"&\'`'.split('')] ] } /** * Serialize an element node. * * @param {Element} node * Node to handle. * @param {number | undefined} index * Index of `node` in `parent. * @param {Parents | undefined} parent * Parent of `node`. * @param {State} state * Info passed around about the current state. * @returns {string} * Serialized node. */ export function element(node, index, parent, state) { const schema = state.schema const omit = schema.space === 'svg' ? false : state.settings.omitOptionalTags let selfClosing = schema.space === 'svg' ? state.settings.closeEmptyElements : state.settings.voids.includes(node.tagName.toLowerCase()) /** @type {Array} */ const parts = [] /** @type {string} */ let last if (schema.space === 'html' && node.tagName === 'svg') { state.schema = svg } const attributes = serializeAttributes(state, node.properties) const content = state.all( schema.space === 'html' && node.tagName === 'template' ? node.content : node ) state.schema = schema // If the node is categorised as void, but it has children, remove the // categorisation. // This enables for example `menuitem`s, which are void in W3C HTML but not // void in WHATWG HTML, to be stringified properly. // Note: `menuitem` has since been removed from the HTML spec, and so is no // longer void. if (content) selfClosing = false if (attributes || !omit || !opening(node, index, parent)) { parts.push('<', node.tagName, attributes ? ' ' + attributes : '') if ( selfClosing && (schema.space === 'svg' || state.settings.closeSelfClosing) ) { last = attributes.charAt(attributes.length - 1) if ( !state.settings.tightSelfClosing || last === '/' || (last && last !== '"' && last !== "'") ) { parts.push(' ') } parts.push('/') } parts.push('>') } parts.push(content) if (!selfClosing && (!omit || !closing(node, index, parent))) { parts.push('') } return parts.join('') } /** * @param {State} state * @param {Properties | null | undefined} properties * @returns {string} */ function serializeAttributes(state, properties) { /** @type {Array} */ const values = [] let index = -1 /** @type {string} */ let key if (properties) { for (key in properties) { if (properties[key] !== null && properties[key] !== undefined) { const value = serializeAttribute(state, key, properties[key]) if (value) values.push(value) } } } while (++index < values.length) { const last = state.settings.tightAttributes ? values[index].charAt(values[index].length - 1) : undefined // In tight mode, don’t add a space after quoted attributes. if (index !== values.length - 1 && last !== '"' && last !== "'") { values[index] += ' ' } } return values.join('') } /** * @param {State} state * @param {string} key * @param {Properties[keyof Properties]} value * @returns {string} */ function serializeAttribute(state, key, value) { const info = find(state.schema, key) const x = state.settings.allowParseErrors && state.schema.space === 'html' ? 0 : 1 const y = state.settings.allowDangerousCharacters ? 0 : 1 let quote = state.quote /** @type {string | undefined} */ let result if (info.overloadedBoolean && (value === info.attribute || value === '')) { value = true } else if ( info.boolean || (info.overloadedBoolean && typeof value !== 'string') ) { value = Boolean(value) } if ( value === null || value === undefined || value === false || (typeof value === 'number' && Number.isNaN(value)) ) { return '' } const name = stringifyEntities( info.attribute, Object.assign({}, state.settings.characterReferences, { // Always encode without parse errors in non-HTML. subset: constants.name[x][y] }) ) // No value. // There is currently only one boolean property in SVG: `[download]` on // ``. // This property does not seem to work in browsers (Firefox, Safari, Chrome), // so I can’t test if dropping the value works. // But I assume that it should: // // ```html // // // // // // // ``` // // See: if (value === true) return name // `spaces` doesn’t accept a second argument, but it’s given here just to // keep the code cleaner. value = Array.isArray(value) ? (info.commaSeparated ? commas : spaces)(value, { padLeft: !state.settings.tightCommaSeparatedLists }) : String(value) if (state.settings.collapseEmptyAttributes && !value) return name // Check unquoted value. if (state.settings.preferUnquoted) { result = stringifyEntities( value, Object.assign({}, state.settings.characterReferences, { attribute: true, subset: constants.unquoted[x][y] }) ) } // If we don’t want unquoted, or if `value` contains character references when // unquoted… if (result !== value) { // If the alternative is less common than `quote`, switch. if ( state.settings.quoteSmart && ccount(value, quote) > ccount(value, state.alternative) ) { quote = state.alternative } result = quote + stringifyEntities( value, Object.assign({}, state.settings.characterReferences, { // Always encode without parse errors in non-HTML. subset: (quote === "'" ? constants.single : constants.double)[x][y], attribute: true }) ) + quote } // Don’t add a `=` for unquoted empties. return name + (result ? '=' + result : result) }