minify-html/gen/attrs.ts

211 lines
8.2 KiB
TypeScript

import {readFileSync, writeFileSync} from 'fs';
import ts, {Node, SourceFile, SyntaxKind, Type} from 'typescript';
import {join} from 'path';
import {DATA_DIR, prettyJson, RUST_OUT_DIR} from './_common';
const reactDeclarations = readFileSync(join(__dirname, 'data', 'react.d.ts'), 'utf8');
// TODO Consider and check behaviour when value matches case insensitively, after trimming whitespace, numerically (for number values), etc.
// TODO This file is currently manually sourced and written. Try to get machine-readable spec and automate.
const defaultAttributeValues: {
[attr: string]: {
tags: string[];
defaultValue: string;
isPositiveInteger?: boolean;
}[];
} = JSON.parse(readFileSync(join(DATA_DIR, 'attrs.json'), 'utf8'));
const tagNameNormalised = {
'anchor': 'a',
};
const attrNameNormalised = {
'classname': 'class',
};
const reactSpecificAttributes = [
'defaultChecked', 'defaultValue', 'suppressContentEditableWarning', 'suppressHydrationWarning',
];
const collapsibleAndTrimmable = {
'class': ['html:*'],
'd': ['svg:*'],
};
// TODO Is escapedText the API for getting name?
const getNameOfNode = (n: any) => n.name.escapedText;
const normaliseName = (name: string, norms: { [name: string]: string }) => [name.toLowerCase()].map(n => norms[n] || n)[0];
type AttrConfig = {
boolean: boolean;
redundantIfEmpty: boolean;
collapseAndTrim: boolean;
defaultValue?: string;
};
const rsTagAttr = ({
redundantIfEmpty,
defaultValue,
collapseAndTrim,
boolean,
}: AttrConfig) => `AttributeMinification {
boolean: ${boolean},
redundant_if_empty: ${redundantIfEmpty},
collapse_and_trim: ${collapseAndTrim},
default_value: ${defaultValue == undefined ? 'None' : `Some(b"${defaultValue}")`},
}`;
const processReactTypeDeclarations = (source: SourceFile) => {
const nodes: Node[] = [source];
// Use index-based loop to keep iterating as nodes array grows.
for (let i = 0; i < nodes.length; i++) {
// forEachChild doesn't work if return value is number (e.g. return value of Array.prototype.push).
nodes[i].forEachChild(c => void nodes.push(c));
}
const attributeNodes = nodes
.filter(n => n.kind === ts.SyntaxKind.InterfaceDeclaration)
.map(n => [/^([A-Za-z]*)(HTML|SVG)Attributes/.exec(getNameOfNode(n)), n])
.filter(([matches]) => !!matches)
.map(([matches, node]) => [matches![2].toLowerCase(), normaliseName(matches![1], tagNameNormalised), node])
.filter(([namespace, tagName]) => namespace !== 'html' || !['all', 'webview'].includes(tagName))
.map(([namespace, tag, node]) => ({namespace, tag, node}))
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.tag.localeCompare(b.tag));
// Process global HTML attributes first as they also appear on some specific HTML tags but we don't want to keep the specific ones if they're global.
if (attributeNodes[0].namespace !== 'html' || attributeNodes[0].tag !== '') {
throw new Error(`Global HTML attributes is not first to be processed`);
}
// Map structure: attr => namespace => tag => config.
const attributes = new Map<string, Map<'html' | 'svg', Map<string, AttrConfig>>>();
for (const {namespace, tag, node} of attributeNodes) {
const fullyQualifiedTagName = [namespace, tag || '*'].join(':');
for (const n of node.members.filter((n: Node) => n.kind === ts.SyntaxKind.PropertySignature)) {
const attrName = normaliseName(getNameOfNode(n), attrNameNormalised);
if (reactSpecificAttributes.includes(attrName)) continue;
const types: SyntaxKind[] = n.type.kind === ts.SyntaxKind.UnionType
? n.type.types.map((t: Node) => t.kind)
: [n.type.kind];
const boolean = types.includes(ts.SyntaxKind.BooleanKeyword);
// If types includes boolean and string, make it a boolean attr to prevent it from being removed if empty value.
const redundantIfEmpty = !boolean && types.some(t => t === ts.SyntaxKind.StringKeyword || t === ts.SyntaxKind.NumberKeyword);
const defaultValues = (defaultAttributeValues[attrName] || [])
.filter(a => a.tags.includes(fullyQualifiedTagName))
.map(a => a.defaultValue);
const collapseAndTrim = (collapsibleAndTrimmable[attrName] || []).includes(fullyQualifiedTagName);
if (defaultValues.length > 1) {
throw new Error(`Tag-attribute combination <${fullyQualifiedTagName} ${attrName}> has multiple default values: ${defaultValues}`);
}
const attr: AttrConfig = {
boolean,
redundantIfEmpty,
collapseAndTrim,
defaultValue: defaultValues[0],
};
if (!attributes.has(attrName)) attributes.set(attrName, new Map());
const namespacesForAttribute = attributes.get(attrName)!;
if (!namespacesForAttribute.has(namespace)) namespacesForAttribute.set(namespace, new Map());
const tagsForNsAttribute = namespacesForAttribute.get(namespace)!;
if (tagsForNsAttribute.has(tag)) throw new Error(`Duplicate tag-attribute combination: <${fullyQualifiedTagName} ${attrName}>`);
const globalAttr = tagsForNsAttribute.get('*');
if (globalAttr) {
if (globalAttr.boolean !== attr.boolean
|| globalAttr.redundantIfEmpty !== attr.redundantIfEmpty
|| globalAttr.collapseAndTrim !== attr.collapseAndTrim
|| globalAttr.defaultValue !== attr.defaultValue) {
throw new Error(`Global and tag-specific attributes conflict: ${prettyJson(globalAttr)} ${prettyJson(attr)}`);
}
} else {
tagsForNsAttribute.set(tag || '*', attr);
}
}
}
let code = `
use crate::spec::tag::ns::Namespace;
pub struct AttributeMinification {
pub boolean: bool,
pub redundant_if_empty: bool,
pub collapse_and_trim: bool,
pub default_value: Option<&'static [u8]>,
}
pub enum AttrMapEntry {
AllNamespaceElements(AttributeMinification),
SpecificNamespaceElements(phf::Map<&'static [u8], AttributeMinification>),
}
#[derive(Clone, Copy)]
pub struct ByNamespace {
// Make pub so this struct can be statically created in gen/attrs.rs.
pub html: Option<&'static AttrMapEntry>,
pub svg: Option<&'static AttrMapEntry>,
}
impl ByNamespace {
fn get(&self, ns: Namespace) -> Option<&'static AttrMapEntry> {
match ns {
Namespace::Html => self.html,
Namespace::Svg => self.svg,
}
}
}
pub struct AttrMap(phf::Map<&'static [u8], ByNamespace>);
impl AttrMap {
pub const fn new(map: phf::Map<&'static [u8], ByNamespace>) -> AttrMap {
AttrMap(map)
}
pub fn get(&self, ns: Namespace, tag: &[u8], attr: &[u8]) -> Option<&AttributeMinification> {
self.0.get(attr).and_then(|namespaces| namespaces.get(ns)).and_then(|entry| match entry {
AttrMapEntry::AllNamespaceElements(min) => Some(min),
AttrMapEntry::SpecificNamespaceElements(map) => map.get(tag),
})
}
}
`;
for (const [attrName, namespaces] of attributes) {
let byNsCode = '';
byNsCode += `static ${attrName.toUpperCase()}_ATTR: ByNamespace = ByNamespace {\n`;
for (const ns of ['html', 'svg'] as const) {
byNsCode += `\t${ns}: `;
const tagsMap = namespaces.get(ns);
if (!tagsMap) {
byNsCode += 'None';
} else {
const globalAttr = tagsMap.get('*');
if (globalAttr) {
code += `static ${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR: &AttrMapEntry = &AttrMapEntry::AllNamespaceElements(${rsTagAttr(globalAttr)});\n\n`;
} else {
code += `static ${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR: &AttrMapEntry = &AttrMapEntry::SpecificNamespaceElements(phf::phf_map! {\n${
[...tagsMap].map(([tagName, tagAttr]) => `b\"${tagName}\" => ${rsTagAttr(tagAttr)}`).join(',\n')
}\n});\n\n`;
}
byNsCode += `Some(${ns.toUpperCase()}_${attrName.toUpperCase()}_ATTR)`;
}
byNsCode += ',\n';
}
byNsCode += '};\n\n';
code += byNsCode;
}
code += 'pub static ATTRS: AttrMap = AttrMap::new(phf::phf_map! {\n';
for (const attr_name of attributes.keys()) {
code += `\tb\"${attr_name}\" => ${attr_name.toUpperCase()}_ATTR,\n`;
}
code += '});\n\n';
return code;
};
const source = ts.createSourceFile(`react.d.ts`, reactDeclarations, ts.ScriptTarget.ES2020);
writeFileSync(join(RUST_OUT_DIR, 'attrs.rs'), processReactTypeDeclarations(source));