Remove attrs with default values; create minified comparison script; remove `=` from boolean attrs; fix closing tag writing before collapsed whitespace; rebuild hyperbuild only in bench build script instead of all dependencies; conservatively collapse whitespace for html-minifier to match hyperbuild behaviour; update bench results

This commit is contained in:
Wilson Lin 2020-01-18 11:42:01 +11:00
parent cf56c0c2e6
commit 27af2368ff
19 changed files with 2238 additions and 824 deletions

View File

@ -6,14 +6,13 @@ pushd "$(dirname "$0")"
nodejs_cargo_toml="../nodejs/native/Cargo.toml"
rm -rf node_modules
if [ -f "$nodejs_cargo_toml.orig" ]; then
echo 'Not altering Node.js Cargo.toml file'
else
cp "$nodejs_cargo_toml" "$nodejs_cargo_toml.orig"
fi
sed -i 's%^hyperbuild = .*$%hyperbuild = { path = "../.." }%' "$nodejs_cargo_toml"
HYPERBUILD_NODEJS_SKIP_BIN_DOWNLOAD=1 npm i
HYPERBUILD_NODEJS_SKIP_BIN_DOWNLOAD=1 npm rebuild hyperbuild
mv "$nodejs_cargo_toml.orig" "$nodejs_cargo_toml"
pushd hyperbuild-bench
cargo build --release

3
bench/compare.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
git --no-pager diff --no-index --word-diff=color --word-diff-regex=. "min/html-minifier/$1.html" "min/hyperbuild-nodejs/$1.html" | less

View File

@ -5,12 +5,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 353526,
"relative": 0.9585430133182942
"absolute": 353501,
"relative": 0.9584752288403974
},
"html-minifier": {
"absolute": 353730,
"relative": 0.9590961346579324
"absolute": 355185,
"relative": 0.9630411912715283
},
"minimize": {
"absolute": 359912,
@ -23,12 +23,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 231730,
"relative": 0.941161658212065
"absolute": 231706,
"relative": 0.9410641832204925
},
"html-minifier": {
"absolute": 234186,
"relative": 0.9511365990163149
"absolute": 234306,
"relative": 0.9516239739741772
},
"minimize": {
"absolute": 238547,
@ -41,8 +41,8 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 90485,
"relative": 0.983222679807452
"absolute": 90484,
"relative": 0.9832118136674309
},
"html-minifier": {
"absolute": 90599,
@ -59,12 +59,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 271220,
"relative": 0.8756319775813419
"absolute": 270833,
"relative": 0.8743825506389188
},
"html-minifier": {
"absolute": 270355,
"relative": 0.8728393307978899
"absolute": 277911,
"relative": 0.8972338268623564
},
"minimize": {
"absolute": 278990,
@ -77,12 +77,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 79834,
"relative": 0.9433520820532212
"absolute": 79806,
"relative": 0.9430212222904949
},
"html-minifier": {
"absolute": 79273,
"relative": 0.9367230703785981
"absolute": 81446,
"relative": 0.9624001512501772
},
"minimize": {
"absolute": 81844,
@ -95,12 +95,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 5744300,
"relative": 0.9094278392290278
"absolute": 5744290,
"relative": 0.9094262560459782
},
"html-minifier": {
"absolute": 5663106,
"relative": 0.896573342775437
"absolute": 5785725,
"relative": 0.9159861750123369
},
"minimize": {
"absolute": 5799350,
@ -113,12 +113,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 196589,
"relative": 0.9964468548836738
"absolute": 196577,
"relative": 0.9963860307162046
},
"html-minifier": {
"absolute": 196568,
"relative": 0.9963404125906027
"absolute": 196600,
"relative": 0.9965026103705206
},
"minimize": {
"absolute": 196776,
@ -131,12 +131,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 28189,
"relative": 0.8285764674759707
"absolute": 28154,
"relative": 0.8275476911319479
},
"html-minifier": {
"absolute": 28109,
"relative": 0.8262249786896328
"absolute": 29086,
"relative": 0.8549425354927839
},
"minimize": {
"absolute": 30318,
@ -149,12 +149,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 1263698,
"relative": 0.9967377432692293
"absolute": 1263682,
"relative": 0.9967251233205608
},
"html-minifier": {
"absolute": 1263082,
"relative": 0.9962518752454974
"absolute": 1263150,
"relative": 0.9963055100273379
},
"minimize": {
"absolute": 1264354,
@ -167,12 +167,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 648036,
"relative": 0.9945654926432332
"absolute": 648004,
"relative": 0.9945163810263407
},
"html-minifier": {
"absolute": 647776,
"relative": 0.9941664607559813
"absolute": 647822,
"relative": 0.9942370587052644
},
"minimize": {
"absolute": 648422,
@ -185,12 +185,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 86770,
"relative": 0.7731513245239644
"absolute": 86693,
"relative": 0.7724652273476552
},
"html-minifier": {
"absolute": 86383,
"relative": 0.7697030179365405
"absolute": 88366,
"relative": 0.7873722478147359
},
"minimize": {
"absolute": 89682,
@ -203,12 +203,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 272544,
"relative": 0.8647962583371939
"absolute": 272480,
"relative": 0.8645931830152878
},
"html-minifier": {
"absolute": 265242,
"relative": 0.841626633328468
"absolute": 266639,
"relative": 0.8460593868394499
},
"minimize": {
"absolute": 307860,
@ -221,12 +221,12 @@
"relative": 1
},
"hyperbuild-nodejs": {
"absolute": 1319433,
"relative": 0.9350021790660842
"absolute": 1319432,
"relative": 0.9350014704267072
},
"html-minifier": {
"absolute": 1308864,
"relative": 0.927512569490949
"absolute": 1327244,
"relative": 0.940537361239552
},
"minimize": {
"absolute": 1369798,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -8,6 +8,10 @@ module.exports = {
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
// hyperbuild can do context-aware whitespace removal, which is safe when configured correctly to match how whitespace is used in the document.
// html-minifier cannot, so whitespace must be collapsed conservatively.
// Alternatively, hyperbuild can also be made to remove whitespace regardless of context.
conservativeCollapse: true,
customEventAttributes: [],
decodeEntities: true,
ignoreCustomComments: [],

View File

@ -7,7 +7,7 @@ const tests = require('./tests');
for (const t of tests) {
for (const p of Object.keys(programs)) {
try {
const minPath = path.join(__dirname, 'min', p, t.name);
const minPath = path.join(__dirname, 'min', p, `${t.name}.html`);
mkdirp.sync(path.dirname(minPath));
fs.writeFileSync(minPath, programs[p](t.contentAsString, t.contentAsBuffer));
} catch (err) {

View File

@ -1,67 +1,67 @@
{
"Amazon": {
"hyperbuild-nodejs": 508.6052506017873,
"html-minifier": 42.25586310792637,
"minimize": 112.84668317196737
"hyperbuild-nodejs": 499.9428905642954,
"html-minifier": 36.3036627447208,
"minimize": 113.33527430279831
},
"BBC": {
"hyperbuild-nodejs": 532.8102905973569,
"html-minifier": 54.543166381705994,
"minimize": 158.04711027873367
"hyperbuild-nodejs": 534.5358584753244,
"html-minifier": 49.23362197831472,
"minimize": 161.96803168572117
},
"Bing": {
"hyperbuild-nodejs": 2163.2302078550147,
"html-minifier": 226.06394079630297,
"minimize": 545.0653574947338
"hyperbuild-nodejs": 2137.27013270331,
"html-minifier": 223.08512226985184,
"minimize": 550.9181761277221
},
"Bootstrap": {
"hyperbuild-nodejs": 276.9069916779089,
"html-minifier": 9.088104379498866,
"minimize": 23.551904759608103
"hyperbuild-nodejs": 277.1391080206004,
"html-minifier": 8.043255283692064,
"minimize": 22.245439492019898
},
"Coding Horror": {
"hyperbuild-nodejs": 1130.8127333329164,
"html-minifier": 57.409616058969526,
"minimize": 187.28893041569714
"hyperbuild-nodejs": 1096.4910673601032,
"html-minifier": 49.83595257976626,
"minimize": 188.32749988717788
},
"ECMA-262": {
"hyperbuild-nodejs": 16.78425400473028,
"html-minifier": 0.5081461293026476,
"minimize": 1.3377251957362182
"hyperbuild-nodejs": 16.200240897950334,
"html-minifier": 0.45522858062374655,
"minimize": 1.3356053866389666
},
"Google": {
"hyperbuild-nodejs": 1856.1401576820815,
"html-minifier": 326.33685434942925,
"minimize": 555.6574376056606
"hyperbuild-nodejs": 1832.4626475818236,
"html-minifier": 242.1462398878334,
"minimize": 564.1884364813526
},
"Hacker News": {
"hyperbuild-nodejs": 2243.718482073889,
"html-minifier": 86.20536879655822,
"minimize": 272.83901988612814
"hyperbuild-nodejs": 2127.8084431041266,
"html-minifier": 74.78979361035866,
"minimize": 272.3995630011103
},
"NY Times": {
"hyperbuild-nodejs": 281.05098122387943,
"html-minifier": 35.57258739419913,
"minimize": 85.73538788107287
"hyperbuild-nodejs": 265.55362689112695,
"html-minifier": 37.146711151201565,
"minimize": 87.7133467873164
},
"Reddit": {
"hyperbuild-nodejs": 410.12831233429847,
"html-minifier": 45.16614107393938,
"minimize": 122.77229783626386
"hyperbuild-nodejs": 391.75000439723124,
"html-minifier": 45.067854272152125,
"minimize": 125.87983932864549
},
"Stack Overflow": {
"hyperbuild-nodejs": 844.9492250936706,
"html-minifier": 47.752958982642866,
"minimize": 157.06321076776504
"hyperbuild-nodejs": 818.3755008258345,
"html-minifier": 41.43093414076361,
"minimize": 159.71387801780298
},
"Twitter": {
"hyperbuild-nodejs": 279.4114354197891,
"html-minifier": 41.71822833204345,
"minimize": 164.8527211050192
"hyperbuild-nodejs": 274.57816497268476,
"html-minifier": 36.94949014023178,
"minimize": 168.81796573617953
},
"Wikipedia": {
"hyperbuild-nodejs": 57.721598283350104,
"html-minifier": 3.1325913382989596,
"minimize": 8.741663457588
"hyperbuild-nodejs": 54.852210553433345,
"html-minifier": 2.821530343574604,
"minimize": 8.66394750522524
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -88,32 +88,67 @@ fn generate_fastrie_code(var_name: &str, value_type: &str, built: &FastrieBuild<
)
}
fn generate_attr_map(name: &str) {
let name_words = name_words(name);
let snake_case = snake_case(&name_words);
let file_name = name_words.join("_");
let attrs: HashMap<String, Vec<String>> = read_json(file_name.as_str());
#[derive(Serialize, Deserialize)]
struct TagAttr {
boolean: bool,
redundant_if_empty: bool,
collapse_and_trim: bool,
default_value: Option<String>,
}
impl TagAttr {
fn code(&self) -> String {
format!(r"
AttributeMinification {{
boolean: {boolean},
redundant_if_empty: {redundant_if_empty},
collapse_and_trim: {collapse_and_trim},
default_value: {default_value},
}}
",
boolean = self.boolean,
redundant_if_empty = self.redundant_if_empty,
collapse_and_trim = self.collapse_and_trim,
default_value = match &self.default_value {
Some(val) => format!("Some({})", create_byte_string_literal(val.as_bytes())),
None => "None".to_string(),
},
)
}
}
fn generate_attr_map() {
let attrs: HashMap<String, HashMap<String, TagAttr>> = read_json("attrs");
let mut code = String::new();
for (name, elems) in attrs.iter() {
if !elems.contains(&"".to_string()) {
for (attr_name, tags_map) in attrs.iter() {
if let Some(global_attr) = tags_map.get("") {
code.push_str(format!(
"static {}_{}_ATTR: &phf::Set<&'static [u8]> = &phf::phf_set!({});\n\n",
name.to_uppercase(),
snake_case,
elems.iter().map(|e| format!("b\"{}\"", e)).collect::<Vec<String>>().join(", "),
"static {}_ATTR: &AttrMapEntry = &AttrMapEntry::AllHtmlElements({});\n\n",
attr_name.to_uppercase(),
global_attr.code(),
).as_str());
} else {
code.push_str(format!(
"static {}_ATTR: &AttrMapEntry = &AttrMapEntry::DistinctHtmlElements(phf::phf_map! {{\n{}\n}});\n\n",
attr_name.to_uppercase(),
tags_map
.iter()
.map(|(tag_name, tag_attr)| format!(
"b\"{}\" => {}",
tag_name,
tag_attr.code(),
))
.collect::<Vec<String>>()
.join(",\n"),
).as_str());
};
};
code.push_str(format!("pub static {}: crate::pattern::AttrMap = crate::pattern::AttrMap::new(phf::phf_map!{{\n", snake_case).as_str());
for (name, elems) in attrs.iter() {
if elems.contains(&"".to_string()) {
code.push_str(format!("\tb\"{}\" => crate::pattern::AttrMapEntry::AllHtmlElements,\n", name).as_str());
} else {
code.push_str(format!("\tb\"{}\" => crate::pattern::AttrMapEntry::SomeHtmlElements({}_{}_ATTR),\n", name, name.to_uppercase(), snake_case).as_str());
};
code.push_str("pub static ATTRS: AttrMap = AttrMap::new(phf::phf_map! {\n");
for attr_name in attrs.keys() {
code.push_str(format!("\tb\"{}\" => {}_ATTR,\n", attr_name, attr_name.to_uppercase()).as_str());
};
code.push_str("});\n\n");
write_rs(file_name.as_str(), code);
write_rs("attrs", code);
}
#[derive(Serialize, Deserialize)]
@ -182,8 +217,7 @@ fn generate_tries() {
}
fn main() {
generate_attr_map("boolean attrs");
generate_attr_map("redundant if empty attrs");
generate_attr_map();
generate_entities();
generate_patterns();
generate_tries();

1830
gen/attrs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,113 +0,0 @@
{
"allowfullscreen": [
"iframe"
],
"allowtransparency": [
"iframe"
],
"async": [
"script"
],
"autofocus": [
"button",
"input",
"keygen",
"select",
"textarea"
],
"autoplay": [
"media"
],
"capture": [
"input"
],
"checked": [
"input"
],
"controls": [
"media"
],
"default": [
"track"
],
"defaultchecked": [
""
],
"defer": [
"script"
],
"disabled": [
"button",
"fieldset",
"input",
"keygen",
"optgroup",
"option",
"select",
"textarea"
],
"disablepictureinpicture": [
"video"
],
"formnovalidate": [
"button",
"input"
],
"hidden": [
""
],
"itemscope": [
""
],
"loop": [
"media"
],
"multiple": [
"input",
"select"
],
"muted": [
"media"
],
"nomodule": [
"script"
],
"novalidate": [
"form"
],
"open": [
"details",
"dialog"
],
"playsinline": [
"media",
"video"
],
"readonly": [
"input",
"textarea"
],
"required": [
"input",
"select",
"textarea"
],
"reversed": [
"ol"
],
"scoped": [
"style"
],
"seamless": [
"iframe"
],
"selected": [
"option"
],
"suppresscontenteditablewarning": [
""
],
"suppresshydrationwarning": [
""
]
}

205
gen/build/attrs.js Normal file
View File

@ -0,0 +1,205 @@
const request = require('request-promise-native');
const {promises: fs} = require('fs');
const ts = require('typescript');
const path = require('path');
const fromCamelCase = camelCase => camelCase.split(/(?=^|[A-Z])/).map(w => w.toLowerCase());
const ATTRS_PATH = path.join(__dirname, '..', 'attrs.json');
const REACT_TYPINGS_URL = 'https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/master/types/react/index.d.ts';
const REACT_TYPINGS_FILE = path.join(__dirname, 'react.d.ts');
const fetchReactTypingsSource = async () => {
try {
return await fs.readFile(REACT_TYPINGS_FILE, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
const source = await request(REACT_TYPINGS_URL);
await fs.writeFile(REACT_TYPINGS_FILE, source);
return source;
}
};
const tagNameNormalised = {
'anchor': 'a',
};
const attrNameNormalised = {
'classname': 'class',
};
const reactSpecificAttributes = [
'defaultChecked', 'defaultValue', 'suppressContentEditableWarning', 'suppressHydrationWarning',
];
// TODO Consider and check behaviour when value matches case insensitively, after trimming whitespace, numerically (for number values), etc.
// TODO This is currently manually sourced and written. Try to get machine-readable spec and automate.
const defaultAttributeValues = {
'align': [{
tags: ['img'],
defaultValue: 'bottom',
}],
'decoding': [{
tags: ['img'],
defaultValue: 'auto',
}],
'enctype': [{
tags: ['form'],
defaultValue: 'application/x-www-form-urlencoded',
}],
'frameborder': [{
tags: ['iframe'],
defaultValue: '1',
isPositiveInteger: true,
}],
'formenctype': [{
tags: ['button', 'input'],
defaultValue: 'application/x-www-form-urlencoded',
}],
'height': [{
tags: ['iframe'],
defaultValue: '150',
isPositiveInteger: true,
}],
'importance': [{
tags: ['iframe'],
defaultValue: 'auto',
}],
'loading': [{
tags: ['iframe', 'img'],
defaultValue: 'eager',
}],
'method': [{
tags: ['form'],
defaultValue: 'get',
}],
'referrerpolicy': [{
tags: ['iframe', 'img'],
defaultValue: 'no-referrer-when-downgrade',
}],
'rules': [{
tags: ['table'],
defaultValue: 'none',
}],
'span': [{
tags: ['col', 'colgroup'],
defaultValue: '1',
isPositiveInteger: true,
}],
'target': [{
tags: ['a', 'form'],
defaultValue: '_self',
}],
'type': [{
tags: ['button'],
defaultValue: 'submit',
}, {
tags: ['input'],
defaultValue: 'text',
}, {
tags: ['link'],
defaultValue: 'text/css',
}],
'width': [{
tags: ['iframe'],
defaultValue: '300',
isPositiveInteger: true,
}],
};
const collapsibleAndTrimmable = {
'class': [''],
};
// TODO Is escapedText the API for getting name?
const getNameOfNode = n => n.name.escapedText;
const normaliseName = (name, norms) => [name.toLowerCase()].map(n => norms[n] || n)[0];
const processReactTypeDeclarations = async (source) => {
const nodes = [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]*)HTMLAttributes/.exec(getNameOfNode(n)), n])
.filter(([matches]) => matches)
.map(([matches, node]) => [normaliseName(matches[1], tagNameNormalised), node])
.filter(([tagName]) => !['all', 'webview'].includes(tagName))
.sort((a, b) => a[0].localeCompare(b[0]));
// Process global attributes first as they also appear on some specific tags but we don't want to keep the specific ones if they're global.
if (attributeNodes[0][0] !== '') {
throw new Error(`Global attributes is not first to be processed`);
}
const attributes = new Map();
for (const [tagName, node] of attributeNodes) {
for (const n of node.members.filter(n => n.kind === ts.SyntaxKind.PropertySignature)) {
const attrName = normaliseName(getNameOfNode(n), attrNameNormalised);
if (reactSpecificAttributes.includes(attrName)) continue;
const types = n.type.kind === ts.SyntaxKind.UnionType
? n.type.types.map(t => 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.includes(ts.SyntaxKind.StringKeyword) || types.includes(ts.SyntaxKind.NumberKeyword));
const defaultValue = (defaultAttributeValues[attrName] || [])
.filter(a => a.tags.includes(tagName))
.map(a => a.defaultValue);
const collapseAndTrim = (collapsibleAndTrimmable[attrName] || []).includes(tagName);
if (defaultValue.length > 1) {
throw new Error(`Tag-attribute combination has multiple default values: ${defaultValue}`);
}
const attr = {
boolean,
redundant_if_empty: redundantIfEmpty,
collapse_and_trim: collapseAndTrim,
default_value: defaultValue[0],
};
if (!attributes.has(attrName)) attributes.set(attrName, new Map());
const tagsForAttribute = attributes.get(attrName);
if (tagsForAttribute.has(tagName)) throw new Error(`Duplicate tag-attribute combination: <${tagName} ${attrName}>`);
const globalAttr = tagsForAttribute.get('');
if (globalAttr) {
if (globalAttr.boolean !== attr.boolean
|| globalAttr.redundant_if_empty !== attr.redundant_if_empty
|| globalAttr.collapse_and_trim !== attr.collapse_and_trim
|| globalAttr.default_value !== attr.default_value) {
throw new Error(`Global and tag-specific attributes conflict: ${JSON.stringify(globalAttr, null, 2)} ${JSON.stringify(attr, null, 2)}`);
}
} else {
tagsForAttribute.set(tagName, attr);
}
}
}
// Sort output JSON object by property so diffs are clearer.
await fs.writeFile(ATTRS_PATH, JSON.stringify(
Object.fromEntries(
[...attributes.entries()]
.map(([attrName, tagsMap]) => [attrName, Object.fromEntries(
[...tagsMap.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
)])
.sort((a, b) => a[0].localeCompare(b[0]))
),
null,
2,
));
};
(async () => {
const source = ts.createSourceFile(`react.d.ts`, await fetchReactTypingsSource(), ts.ScriptTarget.ES2019);
await processReactTypeDeclarations(source);
})();

View File

@ -1,93 +0,0 @@
const request = require('request-promise-native');
const {promises: fs} = require('fs');
const ts = require('typescript');
const path = require('path');
const fromCamelCase = camelCase => camelCase.split(/(?=^|[A-Z])/).map(w => w.toLowerCase());
const BOOLEAN_ATTRS_PATH = path.join(__dirname, '..', 'boolean_attrs.json');
const REDUNDANT_IF_EMPTY_ATTRS_PATH = path.join(__dirname, '..', 'redundant_if_empty_attrs.json');
const REACT_TYPINGS_URL = 'https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/master/types/react/index.d.ts';
const REACT_TYPINGS_FILE = path.join(__dirname, 'react.d.ts');
const fetchReactTypingsSource = async () => {
try {
return await fs.readFile(REACT_TYPINGS_FILE, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
const source = await request(REACT_TYPINGS_URL);
await fs.writeFile(REACT_TYPINGS_FILE, source);
return source;
}
};
const attrInterfaceToTagName = {
'anchor': 'a',
};
const attrNameNormalised = {
'classname': 'class',
};
const reactSpecificAttributes = [
'defaultChecked', 'defaultValue', 'suppressContentEditableWarning', 'suppressHydrationWarning',
];
const processReactTypeDeclarations = async (source) => {
const booleanAttributes = new Map();
const redundantIfEmptyAttributes = new Map();
const unvisited = [source];
while (unvisited.length) {
const node = unvisited.shift();
if (node.kind === ts.SyntaxKind.InterfaceDeclaration) {
const name = node.name.escapedText;
let matches;
if ((matches = /^([A-Za-z]*)HTMLAttributes/.exec(name))) {
const tagName = [matches[1].toLowerCase()].map(n => attrInterfaceToTagName[n] || n)[0];
if (!['all', 'webview'].includes(tagName)) {
for (const n of node.members.filter(n => n.kind === ts.SyntaxKind.PropertySignature)) {
// TODO Is escapedText the API for getting name?
const attr = [n.name.escapedText.toLowerCase()].map(n => attrNameNormalised[n] || n)[0];
const types = n.type.kind === ts.SyntaxKind.UnionType
? n.type.types.map(t => t.kind)
: [n.type.kind];
// If types includes boolean and string, make it a boolean attr to prevent it from being removed if empty value.
if (types.includes(ts.SyntaxKind.BooleanKeyword)) {
if (!booleanAttributes.has(attr)) {
booleanAttributes.set(attr, []);
}
booleanAttributes.get(attr).push(tagName);
} else if (types.includes(ts.SyntaxKind.StringKeyword) || types.includes(ts.SyntaxKind.NumberKeyword)) {
if (!redundantIfEmptyAttributes.has(attr)) {
redundantIfEmptyAttributes.set(attr, []);
}
redundantIfEmptyAttributes.get(attr).push(tagName);
}
}
}
}
}
// forEachChild doesn't seem to work if return value is number (e.g. Array.prototype.push return value).
node.forEachChild(c => void unvisited.push(c));
}
// Sort output JSON object by property so diffs are clearer.
await fs.writeFile(BOOLEAN_ATTRS_PATH, JSON.stringify(
Object.fromEntries([...booleanAttributes.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
null,
2,
));
await fs.writeFile(REDUNDANT_IF_EMPTY_ATTRS_PATH, JSON.stringify(
Object.fromEntries([...redundantIfEmptyAttributes.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
null,
2,
));
};
(async () => {
const source = ts.createSourceFile(`react.d.ts`, await fetchReactTypingsSource(), ts.ScriptTarget.ES2019);
await processReactTypeDeclarations(source);
})();

View File

@ -1,467 +0,0 @@
{
"abbr": [
"td",
"th"
],
"about": [
""
],
"accept": [
"input"
],
"acceptcharset": [
"form"
],
"accesskey": [
""
],
"action": [
"form"
],
"allow": [
"iframe"
],
"alt": [
"area",
"img",
"input"
],
"as": [
"link"
],
"autocapitalize": [
""
],
"autocomplete": [
"form",
"input",
"select",
"textarea"
],
"autocorrect": [
""
],
"autosave": [
""
],
"cellpadding": [
"table"
],
"cellspacing": [
"table"
],
"challenge": [
"keygen"
],
"charset": [
"meta",
"script"
],
"cite": [
"blockquote",
"del",
"ins",
"quote"
],
"class": [
""
],
"classid": [
"object"
],
"color": [
""
],
"cols": [
"textarea"
],
"colspan": [
"td",
"th"
],
"content": [
"meta"
],
"contextmenu": [
""
],
"controlslist": [
"media"
],
"coords": [
"area"
],
"crossorigin": [
"input",
"link",
"media",
"script"
],
"data": [
"object"
],
"datatype": [
""
],
"datetime": [
"del",
"ins",
"time"
],
"defaultvalue": [
""
],
"dir": [
""
],
"dirname": [
"textarea"
],
"enctype": [
"form"
],
"form": [
"button",
"fieldset",
"input",
"keygen",
"label",
"meter",
"object",
"output",
"select",
"textarea"
],
"formaction": [
"button",
"input"
],
"formenctype": [
"button",
"input"
],
"formmethod": [
"button",
"input"
],
"formtarget": [
"button",
"input"
],
"frameborder": [
"iframe"
],
"headers": [
"td",
"th"
],
"height": [
"canvas",
"embed",
"iframe",
"img",
"input",
"object",
"video"
],
"high": [
"meter"
],
"href": [
"a",
"area",
"base",
"link"
],
"hreflang": [
"a",
"area",
"link"
],
"htmlfor": [
"label",
"output"
],
"httpequiv": [
"meta"
],
"id": [
""
],
"integrity": [
"link",
"script"
],
"is": [
""
],
"itemid": [
""
],
"itemprop": [
""
],
"itemref": [
""
],
"itemtype": [
""
],
"keyparams": [
"keygen"
],
"keytype": [
"keygen"
],
"kind": [
"track"
],
"label": [
"optgroup",
"option",
"track"
],
"lang": [
""
],
"list": [
"input"
],
"low": [
"meter"
],
"manifest": [
"html"
],
"marginheight": [
"iframe"
],
"marginwidth": [
"iframe"
],
"max": [
"input",
"meter",
"progress"
],
"maxlength": [
"input",
"textarea"
],
"media": [
"a",
"area",
"link",
"source",
"style"
],
"mediagroup": [
"media"
],
"method": [
"form"
],
"min": [
"input",
"meter"
],
"minlength": [
"input",
"textarea"
],
"name": [
"button",
"fieldset",
"form",
"iframe",
"input",
"keygen",
"map",
"meta",
"object",
"output",
"param",
"select",
"textarea"
],
"nonce": [
"script",
"style"
],
"optimum": [
"meter"
],
"pattern": [
"input"
],
"ping": [
"a"
],
"placeholder": [
"",
"input",
"textarea"
],
"poster": [
"video"
],
"prefix": [
""
],
"preload": [
"media"
],
"property": [
""
],
"radiogroup": [
""
],
"referrerpolicy": [
"a",
"iframe"
],
"rel": [
"a",
"area",
"link"
],
"resource": [
""
],
"results": [
""
],
"role": [
""
],
"rows": [
"textarea"
],
"rowspan": [
"td",
"th"
],
"sandbox": [
"iframe"
],
"scope": [
"td",
"th"
],
"scrolling": [
"iframe"
],
"security": [
""
],
"shape": [
"area"
],
"size": [
"input",
"select"
],
"sizes": [
"img",
"link",
"source"
],
"slot": [
""
],
"span": [
"col",
"colgroup"
],
"src": [
"embed",
"iframe",
"img",
"input",
"media",
"script",
"source",
"track"
],
"srcdoc": [
"iframe"
],
"srclang": [
"track"
],
"srcset": [
"img",
"source"
],
"start": [
"ol"
],
"step": [
"input"
],
"summary": [
"table"
],
"tabindex": [
""
],
"target": [
"a",
"area",
"base",
"form"
],
"title": [
""
],
"type": [
"a",
"embed",
"input",
"link",
"menu",
"object",
"script",
"source",
"style"
],
"typeof": [
""
],
"usemap": [
"img",
"object"
],
"value": [
"button",
"data",
"input",
"li",
"meter",
"option",
"param",
"progress",
"select",
"textarea"
],
"vocab": [
""
],
"width": [
"canvas",
"col",
"embed",
"iframe",
"img",
"input",
"object",
"video"
],
"wmode": [
"object"
],
"wrap": [
"textarea"
]
}

View File

@ -1,5 +1,3 @@
use phf::{Map, Set};
pub struct SinglePattern {
pub seq: &'static [u8],
pub table: &'static [usize],
@ -31,23 +29,3 @@ impl SinglePattern {
None
}
}
pub enum AttrMapEntry {
AllHtmlElements,
SomeHtmlElements(&'static Set<&'static [u8]>),
}
pub struct AttrMap(Map<&'static [u8], AttrMapEntry>);
impl AttrMap {
pub const fn new(map: Map<&'static [u8], AttrMapEntry>) -> AttrMap {
AttrMap(map)
}
pub fn contains(&self, tag: &[u8], attr: &[u8]) -> bool {
self.0.get(attr).filter(|elems| match elems {
AttrMapEntry::AllHtmlElements => true,
AttrMapEntry::SomeHtmlElements(set) => set.contains(tag),
}).is_some()
}
}

View File

@ -29,7 +29,7 @@ impl ClosingTagOmissionRule {
}
}
pub fn can_omit_as_prev(&self, after: &[u8]) -> bool {
pub fn can_omit_as_before(&self, after: &[u8]) -> bool {
self.followed_by.contains(after)
}
}

View File

@ -1,4 +1,4 @@
use phf::{phf_set, Set};
use phf::Map;
use crate::err::ProcessingResult;
use crate::proc::{Processor, ProcessorRange};
@ -7,11 +7,34 @@ use crate::unit::attr::value::{DelimiterType, process_attr_value, ProcessedAttrV
mod value;
include!(concat!(env!("OUT_DIR"), "/gen_boolean_attrs.rs"));
pub struct AttributeMinification {
pub boolean: bool,
pub redundant_if_empty: bool,
pub collapse_and_trim: bool,
pub default_value: Option<&'static [u8]>,
}
static COLLAPSIBLE_AND_TRIMMABLE_ATTRS: Set<&'static [u8]> = phf_set! {
b"class",
};
pub enum AttrMapEntry {
AllHtmlElements(AttributeMinification),
DistinctHtmlElements(Map<&'static [u8], AttributeMinification>),
}
pub struct AttrMap(Map<&'static [u8], &'static AttrMapEntry>);
impl AttrMap {
pub const fn new(map: Map<&'static [u8], &'static AttrMapEntry>) -> AttrMap {
AttrMap(map)
}
pub fn get(&self, tag: &[u8], attr: &[u8]) -> Option<&AttributeMinification> {
self.0.get(attr).and_then(|entry| match entry {
AttrMapEntry::AllHtmlElements(min) => Some(min),
AttrMapEntry::DistinctHtmlElements(map) => map.get(tag),
})
}
}
include!(concat!(env!("OUT_DIR"), "/gen_attrs.rs"));
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum AttrType {
@ -40,10 +63,11 @@ pub fn process_attr(proc: &mut Processor, element: ProcessorRange) -> Processing
// It's possible to expect attribute name but not be called at an attribute, e.g. due to whitespace between name and
// value, which causes name to be considered boolean attribute and `=` to be start of new (invalid) attribute name.
let name = chain!(proc.match_while_pred(is_name_char).require_with_reason("attribute name")?.keep().out_range());
let is_boolean = BOOLEAN_ATTRS.contains(&proc[element], &proc[name]);
let attr_cfg = ATTRS.get(&proc[element], &proc[name]);
let is_boolean = attr_cfg.filter(|attr| attr.boolean).is_some();
let after_name = proc.checkpoint();
let should_collapse_and_trim_value_ws = COLLAPSIBLE_AND_TRIMMABLE_ATTRS.contains(&proc[name]);
let should_collapse_and_trim_value_ws = attr_cfg.filter(|attr| attr.collapse_and_trim).is_some();
chain!(proc.match_while_pred(is_whitespace).discard());
let has_value = chain!(proc.match_char(b'=').keep().matched());
@ -53,6 +77,9 @@ pub fn process_attr(proc: &mut Processor, element: ProcessorRange) -> Processing
chain!(proc.match_while_pred(is_whitespace).discard());
if is_boolean {
skip_attr_value(proc)?;
// Discard `=`.
debug_assert_eq!(proc.written_count(after_name), 1);
proc.erase_written(after_name);
(AttrType::NoValue, None)
} else {
match process_attr_value(proc, should_collapse_and_trim_value_ws)? {

View File

@ -195,10 +195,10 @@ pub fn process_content(proc: &mut Processor, parent: Option<ProcessorRange>) ->
// Whitespace is leading or trailing.
// `trim` is on, so don't write it.
} else if collapse {
// If writing space, then prev_sibling_closing_tag no longer represents immediate previous sibling node; space will be new previous sibling node (as a text node).
prev_sibling_closing_tag.take().map(|tag| tag.write_closing_tag(proc));
// Current contiguous whitespace needs to be reduced to a single space character.
proc.write(b' ');
// If writing space, then prev_sibling_closing_tag no longer represents immediate previous sibling node.
prev_sibling_closing_tag.take().map(|tag| tag.write_closing_tag(proc));
} else {
unreachable!();
};

View File

@ -5,13 +5,11 @@ use crate::proc::{Processor, ProcessorRange};
use crate::spec::codepoint::{is_alphanumeric, is_whitespace};
use crate::spec::tag::omission::CLOSING_TAG_OMISSION_RULES;
use crate::spec::tag::void::VOID_TAGS;
use crate::unit::attr::{AttrType, process_attr, ProcessedAttr};
use crate::unit::attr::{AttributeMinification, ATTRS, AttrType, process_attr, ProcessedAttr};
use crate::unit::content::process_content;
use crate::unit::script::process_script;
use crate::unit::style::process_style;
include!(concat!(env!("OUT_DIR"), "/gen_redundant_if_empty_attrs.rs"));
pub static JAVASCRIPT_MIME_TYPES: Set<&'static [u8]> = phf_set! {
b"application/ecmascript",
b"application/javascript",
@ -73,7 +71,7 @@ pub fn process_tag(proc: &mut Processor, prev_sibling_closing_tag: Option<Proces
let source_tag_name = chain!(proc.match_while_pred(is_valid_tag_name_char).require_with_reason("tag name")?.discard().range());
if let Some(prev_tag) = prev_sibling_closing_tag {
let can_omit = match CLOSING_TAG_OMISSION_RULES.get(&proc[prev_tag.name]) {
Some(rule) => rule.can_omit_as_prev(&proc[source_tag_name]),
Some(rule) => rule.can_omit_as_before(&proc[source_tag_name]),
_ => false,
};
if !can_omit {
@ -142,7 +140,11 @@ pub fn process_tag(proc: &mut Processor, prev_sibling_closing_tag: Option<Proces
}
(_, name) => {
// TODO Check if HTML tag before checking if attribute removal applies to all elements.
erase_attr = value.is_none() && REDUNDANT_IF_EMPTY_ATTRS.contains(&proc[tag_name], name);
erase_attr = match (value, ATTRS.get(&proc[tag_name], name)) {
(None, Some(AttributeMinification { redundant_if_empty: true, .. })) => true,
(Some(val), Some(AttributeMinification { default_value: Some(defval), .. })) => proc[val].eq(*defval),
_ => false,
};
}
};
if erase_attr {
@ -157,7 +159,11 @@ pub fn process_tag(proc: &mut Processor, prev_sibling_closing_tag: Option<Proces
if self_closing || is_void_tag {
if self_closing {
// Write discarded tag closing characters.
if is_void_tag { proc.write_slice(b">"); } else { proc.write_slice(b"/>"); };
if is_void_tag {
proc.write_slice(b">");
} else {
proc.write_slice(b"/>");
};
};
return Ok(ProcessedTag { name: tag_name, has_closing_tag: false });
};
@ -171,6 +177,7 @@ pub fn process_tag(proc: &mut Processor, prev_sibling_closing_tag: Option<Proces
// Require closing tag for non-void.
chain!(proc.match_seq(b"</").require_with_reason("closing tag")?.discard());
let closing_tag = chain!(proc.match_while_pred(is_valid_tag_name_char).require_with_reason("closing tag name")?.discard().range());
// We need to check closing tag matches as otherwise when we later write closing tag, it might be longer than source closing tag and cause source to be overwritten.
if !proc[closing_tag].eq(&proc[tag_name]) {
return Err(ErrorType::ClosingTagMismatch);
};