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)
|
|||
|
}
|