269 lines
7.4 KiB
JavaScript
269 lines
7.4 KiB
JavaScript
/**
|
||
* @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<string>, Array<string>]>>}
|
||
*/
|
||
const constants = {
|
||
// See: <https://html.spec.whatwg.org/#attribute-name-state>.
|
||
name: [
|
||
['\t\n\f\r &/=>'.split(''), '\t\n\f\r "&\'/=>`'.split('')],
|
||
['\0\t\n\f\r "&\'/<=>'.split(''), '\0\t\n\f\r "&\'/<=>`'.split('')]
|
||
],
|
||
// See: <https://html.spec.whatwg.org/#attribute-value-(unquoted)-state>.
|
||
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: <https://html.spec.whatwg.org/#attribute-value-(single-quoted)-state>.
|
||
single: [
|
||
["&'".split(''), '"&\'`'.split('')],
|
||
["\0&'".split(''), '\0"&\'`'.split('')]
|
||
],
|
||
// See: <https://html.spec.whatwg.org/#attribute-value-(double-quoted)-state>.
|
||
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<string>} */
|
||
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('</' + node.tagName + '>')
|
||
}
|
||
|
||
return parts.join('')
|
||
}
|
||
|
||
/**
|
||
* @param {State} state
|
||
* @param {Properties | null | undefined} properties
|
||
* @returns {string}
|
||
*/
|
||
function serializeAttributes(state, properties) {
|
||
/** @type {Array<string>} */
|
||
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
|
||
// `<a>`.
|
||
// 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
|
||
// <!doctype html>
|
||
// <svg viewBox="0 0 100 100">
|
||
// <a href=https://example.com download>
|
||
// <circle cx=50 cy=40 r=35 />
|
||
// </a>
|
||
// </svg>
|
||
// ```
|
||
//
|
||
// See: <https://github.com/wooorm/property-information/blob/main/lib/svg.js>
|
||
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)
|
||
}
|