UNPKG

postcss-minify-selectors

Version:

Minify selectors with PostCSS.

365 lines (346 loc) • 13.8 kB
/** * @typedef {'list' | 'complex' | 'compound' | 'combinator' | 'qualified-name' | 'class' | 'id' | 'attribute' | 'pseudo' | 'nesting' | 'raw'} NodeKind * @typedef {'valid' | 'invalid' | 'opaque'} ParseStatus * @typedef {'outer-unforgiving' | 'forgiving' | 'unforgiving' | 'relative' | 'compound-only'} ListMode * @typedef {readonly [number, number, number]} Specificity * @typedef {{status:'valid',specificity:Specificity}|{status:'opaque'}} SpecificityResult * @typedef {number} SemanticFacts * @typedef {{kind:NodeKind,startToken:number,endToken:number,subtreeEnd:number,status:ParseStatus,specificity?:Specificity,specificityId?:number,facts?:SemanticFacts,payload:number}} ArenaNode * @typedef {{mode:ListMode,keyframe?:boolean,hasDefaultNamespace?:boolean}} ListPayload * @typedef {{value:string}} CombinatorPayload * @typedef {{namespace:{kind:'absent'}|{kind:'empty'}|{kind:'wildcard'}|{kind:'named',token:number},subject:{kind:'universal',token:number}|{kind:'type',token:number}}} QualifiedNamePayload * @typedef {{name:string,nameToken:number,colonCount:1|2,pseudoKind:'class'|'element'|'unknown',argumentGrammar?:string,specificityPolicy:string,argumentNode?:number}} PseudoPayload * @typedef {{namespace:{kind:'absent'}|{kind:'empty'}|{kind:'wildcard'}|{kind:'named',token:number},nameToken:number,matcher?:string,valueToken?:number,modifierToken?:number,caseBehavior:'default'|'ascii-insensitive'|'case-sensitive'}} AttributePayload * @typedef {{lists:readonly Readonly<ListPayload>[],combinators:readonly Readonly<CombinatorPayload>[],qualifiedNames:readonly Readonly<QualifiedNamePayload>[],pseudos:readonly Readonly<PseudoPayload>[],attributes:readonly Readonly<AttributePayload>[]}} PayloadTables * @typedef {import('./tokenUtils.js').CSSToken} CSSToken */ import { EMPTY_FACTS, ZERO_SPECIFICITY } from './arenaSemantics.js'; export { semanticFacts, createSemanticFacts, hasSemanticFact, addSemanticFact, mergeSemanticFacts, isFoldEligible, zeroSpecificity, addSpecificity, } from './arenaSemantics.js'; const summaryKinds = new Set(['list', 'complex', 'compound', 'pseudo']); /** @type {ReadonlyMap<NodeKind, keyof PayloadTables>} */ const payloadTableForKind = new Map([ ['list', 'lists'], ['combinator', 'combinators'], ['qualified-name', 'qualifiedNames'], ['pseudo', 'pseudos'], ['attribute', 'attributes'], ]); class SelectorArenaBuilder { /** @param {string} source @param {readonly CSSToken[]} tokens @param {boolean} verifyArena */ constructor(source, tokens, verifyArena) { this.source = source; this.tokens = tokens; this.verifyArena = verifyArena; /** @type {ArenaNode[]} */ this.nodes = []; /** @type {number[]} */ this.frames = []; /** @type {number[]} */ this.lastChildren = []; /** @type {number[]} */ this.roots = []; this.specificityByKey = new Map([ ['0,0,0', { id: 0, tuple: ZERO_SPECIFICITY }], ]); /** @type {{lists:ListPayload[],combinators:CombinatorPayload[],qualifiedNames:QualifiedNamePayload[],pseudos:PseudoPayload[],attributes:AttributePayload[]}} */ this.payloads = { lists: [], combinators: [], qualifiedNames: [], pseudos: [], attributes: [], }; } /** @param {NodeKind} kind @param {number} startToken @param {number} endToken @param {{status?:ParseStatus,payload?:number,specificity?:Specificity,facts?:SemanticFacts}} [options] */ open(kind, startToken, endToken, options = {}) { if (this.verifyArena) this.#validateBounds(startToken, endToken); const parent = this.frames.at(-1); if (this.verifyArena && parent !== undefined) { const parentNode = this.nodes[parent]; if (startToken < parentNode.startToken || endToken > parentNode.endToken) throw new RangeError( 'child token span must be contained by its parent' ); const previousChild = this.lastChildren[parent]; if ( previousChild !== undefined && startToken < this.nodes[previousChild].endToken ) throw new RangeError( 'sibling token spans must be ordered and non-overlapping' ); } else if (parent === undefined) { this.roots.push(this.nodes.length); } const index = this.nodes.length; const hasSummary = summaryKinds.has(kind); this.nodes.push({ kind, startToken, endToken, subtreeEnd: index + 1, status: options.status ?? 'valid', specificity: hasSummary ? (options.specificity ?? ZERO_SPECIFICITY) : undefined, specificityId: undefined, facts: hasSummary ? (options.facts ?? EMPTY_FACTS) : undefined, payload: options.payload ?? -1, }); if (this.verifyArena && parent !== undefined) this.lastChildren[parent] = index; this.frames.push(index); return index; } /** @param {number} nodeIndex */ close(nodeIndex) { const node = this.nodes[nodeIndex]; if (node && summaryKinds.has(node.kind)) throw new Error('summary arena nodes must close with closeSummary'); this.#closeFrame(nodeIndex); } /** * @param {number} nodeIndex * @param {{status?:ParseStatus,specificity?:Specificity,facts?:SemanticFacts}} [summary] */ closeSummary(nodeIndex, summary = {}) { const node = this.nodes[nodeIndex]; if (!node || !summaryKinds.has(node.kind)) throw new Error('closeSummary requires an open summary arena node'); const specificity = summary.specificity ?? node.specificity; const hasSafeSpecificity = specificity !== undefined && specificity.every( (component) => Number.isSafeInteger(component) && component >= 0 ); const requestedStatus = summary.status ?? node.status; node.status = requestedStatus === 'valid' && !hasSafeSpecificity ? 'opaque' : requestedStatus; if (node.status === 'valid' && hasSafeSpecificity) { const interned = this.internSpecificity(specificity); node.specificity = interned.tuple; node.specificityId = interned.id; } else { node.specificity = undefined; node.specificityId = undefined; } node.facts = summary.facts ?? node.facts ?? EMPTY_FACTS; this.#closeFrame(nodeIndex); } /** @param {Specificity} specificity */ internSpecificity(specificity) { const key = specificity.join(','); let found = this.specificityByKey.get(key); if (!found) { const tuple = /** @type {[number, number, number]} */ ([...specificity]); if (this.verifyArena) Object.freeze(tuple); found = { id: this.specificityByKey.size, tuple }; this.specificityByKey.set(key, found); } return found; } /** @param {number} nodeIndex */ #closeFrame(nodeIndex) { if (this.frames.pop() !== nodeIndex) throw new Error('arena nodes must close in stack order'); const node = this.nodes[nodeIndex]; node.subtreeEnd = this.nodes.length; if (!this.verifyArena) return; let child = nodeIndex + 1; while (child < node.subtreeEnd) { const childNode = this.nodes[child]; if ( childNode.subtreeEnd <= child || childNode.subtreeEnd > node.subtreeEnd ) throw new RangeError('invalid preorder subtree bounds'); child = childNode.subtreeEnd; } if (child !== node.subtreeEnd) throw new RangeError('children do not fill the preorder subtree'); } /** @param {NodeKind} kind @param {number} startToken @param {number} endToken @param {{status?:ParseStatus,payload?:number,specificity?:Specificity,facts?:SemanticFacts}} [options] */ leaf(kind, startToken, endToken, options) { const index = this.open(kind, startToken, endToken, options); if (summaryKinds.has(kind)) this.closeSummary(index, options); else this.close(index); return index; } /** @param {keyof SelectorArenaBuilder['payloads']} table @param {object} payload */ payload(table, payload) { const values = this.payloads[table]; const index = values.length; values.push(/** @type {never} */ (payload)); return index; } finish() { if (this.frames.length !== 0) throw new Error('cannot finish an open arena'); if (!this.nodes[0]) throw new Error('arena must contain a root'); if (!this.verifyArena) return new SelectorArena( this.source, this.tokens, this.nodes, this.payloads, false ); if ( this.roots.length !== 1 || this.roots[0] !== 0 || this.nodes[0]?.subtreeEnd !== this.nodes.length ) throw new Error('arena must contain a single preorder root'); const root = this.nodes[0]; if (root.startToken !== 0 || root.endToken !== this.tokens.length) throw new RangeError('arena root token span must cover all tokens'); this.#validatePayloadIndexes(); this.#validatePayloadReferences(); if ( root.kind !== 'list' && !( root.kind === 'raw' && root.status === 'opaque' && root.subtreeEnd === 1 ) ) throw new Error( 'arena root must be a selector list or a full-span opaque raw fallback' ); for (const node of this.nodes) { Object.freeze(node); } for (const table of Object.values(this.payloads)) { for (const payload of table) { Object.freeze(payload); } Object.freeze(table); } for (const payload of this.payloads.qualifiedNames) { Object.freeze(payload.namespace); Object.freeze(payload.subject); } for (const payload of this.payloads.attributes) Object.freeze(payload.namespace); Object.freeze(this.nodes); Object.freeze(this.payloads); return new SelectorArena( this.source, this.tokens, this.nodes, this.payloads, true ); } #validatePayloadIndexes() { for (const node of this.nodes) { const tableName = payloadTableForKind.get(node.kind); if (!tableName) { if (node.payload !== -1) throw new RangeError( `${node.kind} nodes must not have a payload index` ); continue; } const table = this.payloads[tableName]; if ( !Number.isInteger(node.payload) || node.payload < 0 || node.payload >= table.length ) throw new RangeError(`invalid ${node.kind} payload index`); } } #validatePayloadReferences() { /** @param {number} index */ const validTokenIndex = (index) => Number.isInteger(index) && index >= 0 && index < this.tokens.length; for (let nodeIndex = 0; nodeIndex < this.nodes.length; nodeIndex++) { const node = this.nodes[nodeIndex]; if (node.kind === 'qualified-name') { const payload = this.payloads.qualifiedNames[node.payload]; if ( (payload.namespace.kind === 'named' && !validTokenIndex(payload.namespace.token)) || !validTokenIndex(payload.subject.token) ) throw new RangeError('invalid qualified-name token reference'); } else if (node.kind === 'attribute') { const payload = this.payloads.attributes[node.payload]; const references = [ payload.nameToken, payload.valueToken, payload.modifierToken, payload.namespace.kind === 'named' ? payload.namespace.token : undefined, ]; if ( references.some( (reference) => reference !== undefined && !validTokenIndex(reference) ) ) throw new RangeError('invalid attribute token reference'); } else if (node.kind === 'pseudo') { const payload = this.payloads.pseudos[node.payload]; if (!validTokenIndex(payload.nameToken)) throw new RangeError('invalid pseudo token reference'); if (payload.argumentNode !== undefined) { const argument = this.nodes[payload.argumentNode]; if ( !Number.isInteger(payload.argumentNode) || payload.argumentNode <= nodeIndex || payload.argumentNode >= node.subtreeEnd || argument?.kind !== 'list' ) throw new RangeError('invalid pseudo argument node reference'); } } } } /** @param {number} startToken @param {number} endToken */ #validateBounds(startToken, endToken) { if ( !Number.isInteger(startToken) || !Number.isInteger(endToken) || startToken < 0 || endToken < startToken || endToken > this.tokens.length ) throw new RangeError('invalid exclusive token span'); } } export class SelectorArena { /** @param {string} source @param {readonly CSSToken[]} tokens @param {readonly Readonly<ArenaNode>[]} nodes @param {PayloadTables} payloads @param {boolean} freeze */ constructor(source, tokens, nodes, payloads, freeze) { this.source = source; this.tokens = tokens; this.nodes = nodes; this.payloads = payloads; if (freeze) Object.freeze(this); } /** @param {number} nodeIndex @param {(childIndex:number)=>void} callback */ forEachChild(nodeIndex, callback) { const node = this.nodes[nodeIndex]; if (!node) throw new RangeError('unknown arena node'); let child = nodeIndex + 1; while (child < node.subtreeEnd) { callback(child); child = this.nodes[child].subtreeEnd; } } } /** @param {string} source @param {readonly CSSToken[]} tokens @param {(builder:SelectorArenaBuilder)=>void} build @param {boolean} [verifyArena] @return {SelectorArena} */ export function buildSelectorArena(source, tokens, build, verifyArena = true) { const builder = new SelectorArenaBuilder(source, tokens, verifyArena); build(builder); return builder.finish(); }