diff --git a/schemas/postprocessing/css.json b/schemas/postprocessing/css.json index 00857fa8..ea071f17 100644 --- a/schemas/postprocessing/css.json +++ b/schemas/postprocessing/css.json @@ -9,6 +9,13 @@ "type": "string" }, "minItems": 1 + }, + + "extended": { + "type": "array", + "items": { + "$ref": "../common.json#/$defs/url" + } } }, @@ -25,6 +32,7 @@ "properties": { "name": { "type": "string", "pattern": "^@" }, "href": { "$ref": "../common.json#/$defs/url" }, + "extended": { "$ref": "#/$defs/extended" }, "syntax": { "$ref": "../common.json#/$defs/cssValue" }, "prose": { "type": "string" }, "descriptors": { @@ -54,6 +62,7 @@ "name": { "type": "string", "pattern": "^.*()$" }, "for": { "$ref": "#/$defs/scopes" }, "href": { "$ref": "../common.json#/$defs/url" }, + "extended": { "$ref": "#/$defs/extended" }, "prose": { "type": "string" }, "syntax": { "$ref": "../common.json#/$defs/cssValue" } } @@ -68,6 +77,7 @@ "properties": { "name": { "$ref": "../common.json#/$defs/cssPropertyName" }, "href": { "$ref": "../common.json#/$defs/url" }, + "extended": { "$ref": "#/$defs/extended" }, "syntax": { "$ref": "../common.json#/$defs/cssValue" }, "legacyAliasOf": { "$ref": "../common.json#/$defs/cssPropertyName" }, "styleDeclaration": { @@ -87,6 +97,7 @@ "properties": { "name": { "$ref": "../common.json#/$defs/cssPropertyName" }, "href": { "$ref": "../common.json#/$defs/url" }, + "extended": { "$ref": "#/$defs/extended" }, "prose": { "type": "string" }, "syntax": { "$ref": "../common.json#/$defs/cssValue" } } @@ -102,6 +113,7 @@ "name": { "type": "string", "pattern": "^[a-zA-Z0-9-\\+\\(\\)\\[\\]\\{\\}]+$" }, "for": { "$ref": "#/$defs/scopes" }, "href": { "$ref": "../common.json#/$defs/url" }, + "extended": { "$ref": "#/$defs/extended" }, "prose": { "type": "string" }, "syntax": { "$ref": "../common.json#/$defs/cssValue" } } diff --git a/src/postprocessing/cssmerge.js b/src/postprocessing/cssmerge.js index af199322..f939b643 100644 --- a/src/postprocessing/cssmerge.js +++ b/src/postprocessing/cssmerge.js @@ -111,8 +111,9 @@ export default { // The job is "almost" done but we now need to de-duplicate entries. // Duplicated entries exist when: - // - A property is defined in one spec and extended in other specs. We'll - // consolidate the entries (and syntaxes) to get back to a single entry. + // - A property, function or type is defined in one spec and extended in + // other specs. We'll consolidate the entries (and syntaxes) to get back to + // a single entry. // - An at-rule is defined in one spec. Additional descriptors are defined // in other specs. We'll consolidate the entries similarly. // - A descriptor is defined in one level of a spec series, and re-defined @@ -179,13 +180,22 @@ export default { // Identify the base definition for each feature, using the definition // (that has some known syntax) in the most recent level. Move that base // definition to the beginning of the array and get rid of other base - // definitions. - // (Note: the code chooses one definition if duplicates of base - // definitions in unrelated specs still exist) + // definitions. Notes: + // - For properties, an extension is an entry with a `newValues` key. + // - For functions and types, an extension is an entry without an `href` + // key (the spec that extends the base type does not contain any ``) + // - The code chooses one definition if duplicates of base definitions in + // unrelated specs still exist. for (const [name, dfns] of Object.entries(featureDfns)) { - let actualDfns = dfns.filter(dfn => dfn.syntax); + let actualDfns = dfns.filter(dfn => dfn.href && dfn.syntax); if (actualDfns.length === 0) { - actualDfns = dfns.filter(dfn => !dfn.newValues); + actualDfns = dfns.filter(dfn => dfn.href && !dfn.newValues); + } + if (actualDfns.length === 0) { + // No base definition, not a real type, let's discard it + // (problem should be captured through tests on individual extracts) + delete featureDfns[name]; + continue; } const best = actualDfns.reduce((dfn1, dfn2) => { if (dfn1.spec.series.shortname !== dfn2.spec.series.shortname) { @@ -199,6 +209,7 @@ export default { return dfn1; } }); + best.extended = []; featureDfns[name] = [best].concat( dfns.filter(dfn => !actualDfns.includes(dfn)) ); @@ -239,6 +250,18 @@ export default { continue; } baseDfn.syntax += ' | ' + dfn.newValues; + baseDfn.extended.push(dfn.href); + } + else if (dfn.syntax) { + // Extensions of functions and types are *re-definitions* in + // practice, new syntax overrides the base one. There should be + // only one such extension in unrelated specs, the code assumes + // that some sort of curation already took place, and picks up + // a winner randomly. + // Note: we don't have any `href` info for functions/types + // extensions, so we'll just use the URL of the crawled spec. + baseDfn.syntax = dfn.syntax; + baseDfn.extended = [dfn.spec.crawled ?? dfn.spec.url]; } if (baseDfn.descriptors && dfn.descriptors?.length > 0) { baseDfn.descriptors.push(...dfn.descriptors.filter(desc => diff --git a/test/merge-css.js b/test/merge-css.js index 1c8aca53..a79ae207 100644 --- a/test/merge-css.js +++ b/test/merge-css.js @@ -54,7 +54,7 @@ const descriptorBase = { type: 'discrete' }; -const descriptorExtended = Object.assign({}, descriptorBase, { +const descriptorExtension = Object.assign({}, descriptorBase, { href: 'https://drafts.csswg.org/css-stuff-2/#descdef-descriptor', value: 'extended' }); @@ -77,6 +77,7 @@ const property1 = { const propertyLegacy = { name: 'good-old-overlay', + href: 'https://compat.spec.whatwg.org/#good-old-overlay', legacyAliasOf: 'overlay' }; @@ -93,6 +94,12 @@ const type1 = { value: 'repeat | space | round | no-repeat' }; +const type1Extension = { + name: '', + type: 'type', + value: 'bis repetita' +}; + const functionVar = { name: 'var()', href: 'https://drafts.csswg.org/css-variables-2/#funcdef-var', @@ -113,14 +120,17 @@ const functionEnv = { * the outputs to the inputs directly. This conversion function takes * some object or value and converts it to ease comparisons. */ -function conv(entry) { +function conv(entry, parentKey) { const res = {}; if (typeof entry !== 'object') { return entry; } + if (entry.href && !entry.extended && parentKey !== 'descriptors') { + entry.extended = []; + } for (const [key, value] of Object.entries(entry)) { if (Array.isArray(value)) { - res[key] = value.map(conv); + res[key] = value.map(v => conv(v, key)); } else if (key === 'value') { res.syntax = value; @@ -309,7 +319,8 @@ describe('CSS extracts consolidation', function () { properties: [ Object.assign({}, property1, { value: null, - newValues: 'train' + newValues: 'train', + href: 'https://drafts.csswg.org/css-otherstuff-2/#tchou-tchou' }) ] }) @@ -318,7 +329,8 @@ describe('CSS extracts consolidation', function () { const result = await cssmerge.run({ results }); assert.deepEqual(result.properties, [ Object.assign({}, conv(property1), { - syntax: 'none | auto | train' + syntax: 'none | auto | train', + extended: ['https://drafts.csswg.org/css-otherstuff-2/#tchou-tchou'] }) ]); }); @@ -344,7 +356,8 @@ describe('CSS extracts consolidation', function () { properties: [ Object.assign({}, property1, { value: null, - newValues: 'train' + newValues: 'train', + href: 'https://drafts.csswg.org/css-otherstuff-1/#tchou-tchou' }) ] }) @@ -357,7 +370,8 @@ describe('CSS extracts consolidation', function () { properties: [ Object.assign({}, property1, { value: null, - newValues: 'train' + newValues: 'train', + href: 'https://drafts.csswg.org/css-otherstuff-2/#tchou-tchou' }) ] }) @@ -366,7 +380,8 @@ describe('CSS extracts consolidation', function () { const result = await cssmerge.run({ results }); assert.deepEqual(result.properties, [ Object.assign({}, conv(property1), { - syntax: 'none | auto | train' + syntax: 'none | auto | train', + extended: ['https://drafts.csswg.org/css-otherstuff-2/#tchou-tchou'] }) ]); }); @@ -432,7 +447,7 @@ describe('CSS extracts consolidation', function () { css: Object.assign({}, emptyExtract, { atrules: [ Object.assign({}, atrule2, { - descriptors: [descriptorExtended] + descriptors: [descriptorExtension] }) ] }) @@ -442,7 +457,7 @@ describe('CSS extracts consolidation', function () { assert.deepEqual(result.atrules, [ conv(Object.assign({}, atrule2, { syntax: '@media foo', - descriptors: [descriptorExtended] + descriptors: [descriptorExtension] })) ]); }); @@ -630,4 +645,57 @@ describe('CSS extracts consolidation', function () { ] }))); }); + + it('merges extended types', async () => { + const results = structuredClone([ + { + shortname: 'css-stuff-1', + series: { shortname: 'css-stuff' }, + seriesVersion: '1', + crawled: 'https://drafts.csswg.org/css-stuff-1/', + css: Object.assign({}, emptyExtract, { + values: [ + Object.assign({}, type1) + ] + }) + }, + { + shortname: 'css-otherstuff-1', + series: { shortname: 'css-otherstuff' }, + seriesVersion: '1', + crawled: 'https://drafts.csswg.org/css-otherstuff-1/', + css: Object.assign({}, emptyExtract, { + values: [ + Object.assign({}, type1Extension) + ] + }) + }, + ]); + const result = await cssmerge.run({ results }); + assert.deepEqual(result, conv(Object.assign({}, emptyMerged, { + types: [ + Object.assign({}, conv(type1), { + syntax: type1Extension.value, + extended: ['https://drafts.csswg.org/css-otherstuff-1/'] + }) + ] + }))); + }); + + it('discards type extensions without a base definition', async () => { + const results = structuredClone([ + { + shortname: 'css-stuff-1', + series: { shortname: 'css-stuff' }, + seriesVersion: '1', + css: Object.assign({}, emptyExtract, { + values: [ + Object.assign({}, type1Extension) + ] + }) + } + ]); + const result = await cssmerge.run({ results }); + assert.deepEqual(result, conv(emptyMerged)); + }); });