2020-01-17 19:42:01 -05:00
|
|
|
const request = require('request-promise-native');
|
|
|
|
const {promises: fs} = require('fs');
|
|
|
|
const ts = require('typescript');
|
|
|
|
const path = require('path');
|
|
|
|
|
2020-01-23 09:53:09 -05:00
|
|
|
const compareEntryNames = (a, b) => a[0].localeCompare(b[0]);
|
|
|
|
const deepObjectifyMap = map => Object.fromEntries(
|
|
|
|
[...map.entries()]
|
|
|
|
.map(([key, value]) => [key, value instanceof Map ? deepObjectifyMap(value) : value])
|
|
|
|
.sort(compareEntryNames)
|
|
|
|
);
|
2020-01-17 19:42:01 -05:00
|
|
|
const fromCamelCase = camelCase => camelCase.split(/(?=^|[A-Z])/).map(w => w.toLowerCase());
|
2020-01-23 09:53:09 -05:00
|
|
|
const prettyjson = v => JSON.stringify(v, null, 2);
|
2020-01-17 19:42:01 -05:00
|
|
|
|
|
|
|
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': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:img'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'bottom',
|
|
|
|
}],
|
|
|
|
'decoding': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:img'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'auto',
|
|
|
|
}],
|
|
|
|
'enctype': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:form'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'application/x-www-form-urlencoded',
|
|
|
|
}],
|
|
|
|
'frameborder': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: '1',
|
|
|
|
isPositiveInteger: true,
|
|
|
|
}],
|
|
|
|
'formenctype': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:button', 'html:input'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'application/x-www-form-urlencoded',
|
|
|
|
}],
|
|
|
|
'height': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: '150',
|
|
|
|
isPositiveInteger: true,
|
|
|
|
}],
|
|
|
|
'importance': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'auto',
|
|
|
|
}],
|
|
|
|
'loading': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe', 'html:img'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'eager',
|
|
|
|
}],
|
2020-01-17 19:47:38 -05:00
|
|
|
'media': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:style'],
|
2020-01-17 19:47:38 -05:00
|
|
|
defaultValue: 'all',
|
|
|
|
}],
|
2020-01-17 19:42:01 -05:00
|
|
|
'method': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:form'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'get',
|
|
|
|
}],
|
|
|
|
'referrerpolicy': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe', 'html:img'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'no-referrer-when-downgrade',
|
|
|
|
}],
|
|
|
|
'rules': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:table'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'none',
|
|
|
|
}],
|
2020-01-23 21:17:46 -05:00
|
|
|
'shape': [{
|
|
|
|
tags: ['html:area'],
|
|
|
|
defaultValue: 'rect',
|
|
|
|
}],
|
2020-01-17 19:42:01 -05:00
|
|
|
'span': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:col', 'html:colgroup'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: '1',
|
|
|
|
isPositiveInteger: true,
|
|
|
|
}],
|
|
|
|
'target': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:a', 'html:form'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: '_self',
|
|
|
|
}],
|
|
|
|
'type': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:button'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'submit',
|
|
|
|
}, {
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:input'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'text',
|
|
|
|
}, {
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:link', 'html:style'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: 'text/css',
|
|
|
|
}],
|
|
|
|
'width': [{
|
2020-01-23 09:53:09 -05:00
|
|
|
tags: ['html:iframe'],
|
2020-01-17 19:42:01 -05:00
|
|
|
defaultValue: '300',
|
|
|
|
isPositiveInteger: true,
|
2020-01-23 09:53:09 -05:00
|
|
|
}]
|
2020-01-17 19:42:01 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
const collapsibleAndTrimmable = {
|
2020-01-23 09:53:09 -05:00
|
|
|
'class': ['html:*'],
|
|
|
|
'd': ['svg:*'],
|
2020-01-17 19:42:01 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
// 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)
|
2020-01-23 09:53:09 -05:00
|
|
|
.map(n => [/^([A-Za-z]*)(HTML|SVG)Attributes/.exec(getNameOfNode(n)), n])
|
2020-01-17 19:42:01 -05:00
|
|
|
.filter(([matches]) => matches)
|
2020-01-23 09:53:09 -05:00
|
|
|
.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`);
|
2020-01-17 19:42:01 -05:00
|
|
|
}
|
|
|
|
|
2020-01-23 09:53:09 -05:00
|
|
|
// Map structure: attr => namespace => tag => config.
|
2020-01-17 19:42:01 -05:00
|
|
|
const attributes = new Map();
|
|
|
|
|
2020-01-23 09:53:09 -05:00
|
|
|
for (const {namespace, tag, node} of attributeNodes) {
|
|
|
|
const fullyQualifiedTagName = [namespace, tag || '*'].join(':');
|
2020-01-17 19:42:01 -05:00
|
|
|
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] || [])
|
2020-01-23 09:53:09 -05:00
|
|
|
.filter(a => a.tags.includes(fullyQualifiedTagName))
|
2020-01-17 19:42:01 -05:00
|
|
|
.map(a => a.defaultValue);
|
2020-01-23 09:53:09 -05:00
|
|
|
const collapseAndTrim = (collapsibleAndTrimmable[attrName] || []).includes(fullyQualifiedTagName);
|
2020-01-17 19:42:01 -05:00
|
|
|
if (defaultValue.length > 1) {
|
2020-01-23 09:53:09 -05:00
|
|
|
throw new Error(`Tag-attribute combination <${fullyQualifiedTagName} ${attrName}> has multiple default values: ${defaultValue}`);
|
2020-01-17 19:42:01 -05:00
|
|
|
}
|
|
|
|
const attr = {
|
|
|
|
boolean,
|
|
|
|
redundant_if_empty: redundantIfEmpty,
|
|
|
|
collapse_and_trim: collapseAndTrim,
|
|
|
|
default_value: defaultValue[0],
|
|
|
|
};
|
|
|
|
|
|
|
|
if (!attributes.has(attrName)) attributes.set(attrName, new Map());
|
2020-01-23 09:53:09 -05:00
|
|
|
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}>`);
|
2020-01-17 19:42:01 -05:00
|
|
|
|
2020-01-23 09:53:09 -05:00
|
|
|
const globalAttr = tagsForNSAttribute.get('*');
|
2020-01-17 19:42:01 -05:00
|
|
|
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) {
|
2020-01-23 09:53:09 -05:00
|
|
|
throw new Error(`Global and tag-specific attributes conflict: ${prettyjson(globalAttr)} ${prettyjson(attr)}`);
|
2020-01-17 19:42:01 -05:00
|
|
|
}
|
|
|
|
} else {
|
2020-01-23 09:53:09 -05:00
|
|
|
tagsForNSAttribute.set(tag || '*', attr);
|
2020-01-17 19:42:01 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort output JSON object by property so diffs are clearer.
|
2020-01-23 09:53:09 -05:00
|
|
|
await fs.writeFile(ATTRS_PATH, prettyjson(deepObjectifyMap(attributes)));
|
2020-01-17 19:42:01 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
const source = ts.createSourceFile(`react.d.ts`, await fetchReactTypingsSource(), ts.ScriptTarget.ES2019);
|
|
|
|
await processReactTypeDeclarations(source);
|
|
|
|
})();
|