diff --git a/assertions.test.ts b/assertions.test.ts new file mode 100644 index 00000000000..2ff81193779 --- /dev/null +++ b/assertions.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { assertValidFeatureReference } from "./assertions"; + +describe("assertValidReference()", function () { + it("throws if target ID is a move", function () { + assert.throws(() => { + assertValidFeatureReference("a", "some-moving-feature", { + "some-moving-feature": { kind: "moved" }, + }); + }); + }); + + it("throws if target ID is a split", function () { + assert.throws(() => { + assertValidFeatureReference("a", "some-split-feature", { + "some-split-feature": { kind: "split" }, + }); + }); + }); + + it("throws if target ID is not defined", function () { + assert.throws(() => { + assertValidFeatureReference( + "a", + "this-is-a-completely-invalid-feature", + {}, + ); + }); + }); + + it("does not throw if target ID is a feature", function () { + assert.doesNotThrow(() => { + assertValidFeatureReference("a", "dom", { dom: { kind: "feature" } }); + }); + }); +}); diff --git a/assertions.ts b/assertions.ts new file mode 100644 index 00000000000..90d7a546f16 --- /dev/null +++ b/assertions.ts @@ -0,0 +1,29 @@ +import { isOrdinaryFeatureData } from "./type-guards"; +import { WebFeaturesData } from "./types.quicktype"; + +/** + * Assert that a reference from one feature to another is an ordinary feature + * reference (i.e., it's defined and not some kind of redirect). + * + * @export + * @param {string} sourceId The feature that is referencing another feature + * @param {string} targetId The feature being referenced + * @param {WebFeaturesData["features"]} features Feature data + */ +export function assertValidFeatureReference( + sourceId: string, + targetId: string, + features: WebFeaturesData["features"], +) { + const target: unknown = features[targetId]; + if (target === undefined) { + throw new Error(`${sourceId} references a non-existent feature`); + } + if (!isOrdinaryFeatureData(target)) { + throw new Error( + `${sourceId} references a redirect "${targetId} instead of an ordinary feature ID`, + ); + } +} + +// TODO: assertValidSnapshotReference diff --git a/features/conic-gradients.yml b/features/conic-gradients.yml index 296df0f44ff..bbe65dfd98e 100644 --- a/features/conic-gradients.yml +++ b/features/conic-gradients.yml @@ -8,4 +8,5 @@ status: compat_features: - css.types.gradient.conic-gradient - css.types.gradient.conic-gradient.doubleposition + - css.types.gradient.conic-gradient.single_color_stop - css.types.gradient.repeating-conic-gradient diff --git a/features/conic-gradients.yml.dist b/features/conic-gradients.yml.dist index 9b55cfcd57f..ff1a7cb8638 100644 --- a/features/conic-gradients.yml.dist +++ b/features/conic-gradients.yml.dist @@ -41,3 +41,15 @@ compat_features: # safari: "12.1" # safari_ios: "12.2" - css.types.gradient.conic-gradient.doubleposition + + # baseline: low + # baseline_low_date: 2025-04-04 + # support: + # chrome: "135" + # chrome_android: "135" + # edge: "135" + # firefox: "136" + # firefox_android: "136" + # safari: "18.4" + # safari_ios: "18.4" + - css.types.gradient.conic-gradient.single_color_stop diff --git a/features/gradients.yml b/features/gradients.yml index fe513c4390c..e95ec8fb792 100644 --- a/features/gradients.yml +++ b/features/gradients.yml @@ -16,18 +16,22 @@ compat_features: - css.types.gradient.linear-gradient.interpolation_hints - css.types.gradient.linear-gradient.premultiplied_gradients - css.types.gradient.linear-gradient.to + - css.types.gradient.linear-gradient.single_color_stop - css.types.gradient.linear-gradient.unitless_0_angle - css.types.gradient.repeating-linear-gradient - css.types.gradient.repeating-linear-gradient.doubleposition - css.types.gradient.repeating-linear-gradient.interpolation_hints - css.types.gradient.repeating-linear-gradient.to + - css.types.gradient.repeating-linear-gradient.single_color_stop - css.types.gradient.repeating-linear-gradient.unitless_0_angle - css.types.gradient.radial-gradient - css.types.gradient.radial-gradient.at - css.types.gradient.radial-gradient.doubleposition - css.types.gradient.radial-gradient.interpolation_hints - css.types.gradient.radial-gradient.premultiplied_gradients + - css.types.gradient.radial-gradient.single_color_stop - css.types.gradient.repeating-radial-gradient - css.types.gradient.repeating-radial-gradient.at - css.types.gradient.repeating-radial-gradient.doubleposition - css.types.gradient.repeating-radial-gradient.interpolation_hints + - css.types.gradient.repeating-radial-gradient.single_color_stop diff --git a/features/gradients.yml.dist b/features/gradients.yml.dist index f5ac728b631..625d60b9b77 100644 --- a/features/gradients.yml.dist +++ b/features/gradients.yml.dist @@ -145,3 +145,18 @@ compat_features: # safari_ios: "15" - css.types.gradient.linear-gradient.premultiplied_gradients - css.types.gradient.radial-gradient.premultiplied_gradients + + # baseline: low + # baseline_low_date: 2025-04-04 + # support: + # chrome: "135" + # chrome_android: "135" + # edge: "135" + # firefox: "136" + # firefox_android: "136" + # safari: "18.4" + # safari_ios: "18.4" + - css.types.gradient.linear-gradient.single_color_stop + - css.types.gradient.radial-gradient.single_color_stop + - css.types.gradient.repeating-linear-gradient.single_color_stop + - css.types.gradient.repeating-radial-gradient.single_color_stop diff --git a/features/numeric-separators.yml b/features/numeric-separators.yml new file mode 100644 index 00000000000..85500442056 --- /dev/null +++ b/features/numeric-separators.yml @@ -0,0 +1,6 @@ +name: Numeric separators +description: To improve readability for numeric literals, underscores (`_`) can be used as separators. For example, `1_050.95` is equivalent to `1050.95`. +spec: https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html#prod-NumericLiteralSeparator +group: javascript +compat_features: + - javascript.grammar.numeric_separators diff --git a/features/numeric-separators.yml.dist b/features/numeric-separators.yml.dist new file mode 100644 index 00000000000..996e1916346 --- /dev/null +++ b/features/numeric-separators.yml.dist @@ -0,0 +1,17 @@ +# Generated from: numeric-separators.yml +# Do not edit this file by hand. Edit the source file instead! + +status: + baseline: high + baseline_low_date: 2020-07-28 + baseline_high_date: 2023-01-28 + support: + chrome: "75" + chrome_android: "75" + edge: "79" + firefox: "70" + firefox_android: "79" + safari: "13" + safari_ios: "13" +compat_features: + - javascript.grammar.numeric_separators diff --git a/features/numeric-seperators.yml b/features/numeric-seperators.yml index 85500442056..04c6806c7fd 100644 --- a/features/numeric-seperators.yml +++ b/features/numeric-seperators.yml @@ -1,6 +1,2 @@ -name: Numeric separators -description: To improve readability for numeric literals, underscores (`_`) can be used as separators. For example, `1_050.95` is equivalent to `1050.95`. -spec: https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html#prod-NumericLiteralSeparator -group: javascript -compat_features: - - javascript.grammar.numeric_separators +kind: moved +redirect_target: numeric-separators diff --git a/features/numeric-seperators.yml.dist b/features/numeric-seperators.yml.dist index eaa3c384275..9bda2b05ad1 100644 --- a/features/numeric-seperators.yml.dist +++ b/features/numeric-seperators.yml.dist @@ -1,17 +1,6 @@ # Generated from: numeric-seperators.yml -# Do not edit this file by hand. Edit the source file instead! +# This file intentionally left blank. +# Do not edit this file. +# The data for this feature has moved to numeric-separators.yml -status: - baseline: high - baseline_low_date: 2020-07-28 - baseline_high_date: 2023-01-28 - support: - chrome: "75" - chrome_android: "75" - edge: "79" - firefox: "70" - firefox_android: "79" - safari: "13" - safari_ios: "13" -compat_features: - - javascript.grammar.numeric_separators +{} diff --git a/features/single-color-gradients.yml b/features/single-color-gradients.yml index b8e1a4789ba..5d1e93a8ee5 100644 --- a/features/single-color-gradients.yml +++ b/features/single-color-gradients.yml @@ -1,11 +1,4 @@ -name: Single color stop gradients -description: A single color stop can be provided to the `linear-gradient()`, `radial-gradient()`, and `conic-gradient()` CSS functions, and their repeating counterparts, to create a solid color background. -spec: https://drafts.csswg.org/css-images-4/#color-stop-syntax -group: gradients -compat_features: - - css.types.gradient.conic-gradient.single_color_stop - - css.types.gradient.linear-gradient.single_color_stop - - css.types.gradient.radial-gradient.single_color_stop - - css.types.gradient.repeating-conic-gradient.single_color_stop - - css.types.gradient.repeating-linear-gradient.single_color_stop - - css.types.gradient.repeating-radial-gradient.single_color_stop +kind: split +redirect_targets: + - gradients + - conic-gradients diff --git a/features/single-color-gradients.yml.dist b/features/single-color-gradients.yml.dist index 38d174ab005..e7dfa57a3c0 100644 --- a/features/single-color-gradients.yml.dist +++ b/features/single-color-gradients.yml.dist @@ -1,21 +1,8 @@ # Generated from: single-color-gradients.yml -# Do not edit this file by hand. Edit the source file instead! +# This file intentionally left blank. +# Do not edit this file. +# The data for this feature has moved to: +# - gradients.yml +# - conic-gradients.yml -status: - baseline: low - baseline_low_date: 2025-04-04 - support: - chrome: "135" - chrome_android: "135" - edge: "135" - firefox: "136" - firefox_android: "136" - safari: "18.4" - safari_ios: "18.4" -compat_features: - - css.types.gradient.conic-gradient.single_color_stop - - css.types.gradient.linear-gradient.single_color_stop - - css.types.gradient.radial-gradient.single_color_stop - - css.types.gradient.repeating-conic-gradient.single_color_stop - - css.types.gradient.repeating-linear-gradient.single_color_stop - - css.types.gradient.repeating-radial-gradient.single_color_stop +{} diff --git a/index.ts b/index.ts index 3cefc981622..c7f7789bd5d 100644 --- a/index.ts +++ b/index.ts @@ -5,10 +5,12 @@ import { Temporal } from '@js-temporal/polyfill'; import { fdir } from 'fdir'; import YAML from 'yaml'; import { convertMarkdown } from "./text"; -import { FeatureData, GroupData, SnapshotData, WebFeaturesData } from './types'; +import { GroupData, SnapshotData, WebFeaturesData } from './types'; import { BASELINE_LOW_TO_HIGH_DURATION, coreBrowserSet, parseRangedDateString } from 'compute-baseline'; import { Compat } from 'compute-baseline/browser-compat-data'; +import { assertValidFeatureReference } from './assertions'; +import { isMoved, isSplit } from './type-guards'; // The longest name allowed, to allow for compact display. const nameMaxLength = 80; @@ -120,7 +122,7 @@ function* identifiers(value) { // Map from BCD keys/paths to web-features identifiers. const bcdToFeatureId: Map = new Map(); -const features: { [key: string]: FeatureData } = {}; +const features: WebFeaturesData["features"] = {}; for (const [key, data] of yamlEntries('features')) { // Draft features reserve an identifier but aren't complete yet. Skip them. if (data[draft]) { @@ -130,6 +132,11 @@ for (const [key, data] of yamlEntries('features')) { continue; } + // Attach `kind: feature` to ordinary features + if (!isMoved(data) && !isSplit(data)) { + data.kind = "feature"; + } + // Convert markdown to text+HTML. if (data.description) { const { text, html } = convertMarkdown(data.description); @@ -185,12 +192,25 @@ for (const [key, data] of yamlEntries('features')) { features[key] = data; } -// Assert that discouraged feature's alternatives are valid for (const [id, feature] of Object.entries(features)) { - for (const alternative of feature.discouraged?.alternatives ?? []) { - if (!(alternative in features)) { - throw new Error(`${id}'s alternative "${alternative}" is not a valid feature ID`); - } + const { kind } = feature; + switch (kind) { + case "feature": + for (const alternative of feature.discouraged?.alternatives ?? []) { + assertValidFeatureReference(id, alternative, features) + } + break; + case "moved": + assertValidFeatureReference(id, feature.redirect_target, features); + break; + case "split": + for (const target of feature.redirect_targets) { + assertValidFeatureReference(id, target, features); + } + break; + default: + kind satisfies never; + throw new Error(`Unhandled feature kind ${kind}}`); } } diff --git a/package-lock.json b/package-lock.json index 1098dab41cc..1c5013b1896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "fast-json-stable-stringify": "^2.1.0", "fdir": "^6.5.0", "hast-util-to-string": "^3.0.1", + "mocha": "^11.7.2", "prettier": "^3.6.2", "quicktype": "^23.2.6", "rehype-parse": "^9.0.1", @@ -6581,11 +6582,9 @@ "devDependencies": { "@types/chai": "^5.2.2", "@types/chai-jest-snapshot": "^1.3.8", - "@types/mocha": "^10.0.10", "c8": "^10.1.3", "chai": "^6.0.1", - "chai-jest-snapshot": "^2.0.0", - "mocha": "^11.7.2" + "chai-jest-snapshot": "^2.0.0" }, "peerDependencies": { "@mdn/browser-compat-data": "^7.0.0" diff --git a/package.json b/package.json index 260d6398c32..e56de968dc7 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,12 @@ "test:coverage": "npm run --workspaces test:coverage", "test:dist": "tsx scripts/dist.ts --check", "test:format": "prettier --check .", + "test:index": "mocha -r tsx './*.test.ts'", "test:lint": "npx eslint .", "test:schematypes": "tsx scripts/schema.ts", "test:specs": "tsx scripts/specs.ts", "test:types": "npm run --workspaces test:types && tsc", - "test": "npm run test:caniuse -- --quiet && npm run test:schematypes && npm run test:specs && npm run test:types && npm run test:format && npm run test:dist && npm run test --workspaces && npm run test:lint", + "test": "npm run test:caniuse -- --quiet && npm run test:schematypes && npm run test:specs && npm run test:types && npm run test:format && npm run test:index && npm run test:dist && npm test --workspaces && npm run test:lint", "update-drafts": "tsx scripts/update-drafts.ts", "remove-tagged-compat-features": "tsx scripts/remove-tagged-compat-features.ts && npm run format" }, @@ -53,6 +54,7 @@ "fast-json-stable-stringify": "^2.1.0", "fdir": "^6.5.0", "hast-util-to-string": "^3.0.1", + "mocha": "^11.7.2", "prettier": "^3.6.2", "quicktype": "^23.2.6", "rehype-parse": "^9.0.1", diff --git a/packages/compute-baseline/package.json b/packages/compute-baseline/package.json index 8fcf340d2fb..fa1abe85965 100644 --- a/packages/compute-baseline/package.json +++ b/packages/compute-baseline/package.json @@ -28,11 +28,9 @@ "devDependencies": { "@types/chai": "^5.2.2", "@types/chai-jest-snapshot": "^1.3.8", - "@types/mocha": "^10.0.10", "c8": "^10.1.3", "chai": "^6.0.1", - "chai-jest-snapshot": "^2.0.0", - "mocha": "^11.7.2" + "chai-jest-snapshot": "^2.0.0" }, "peerDependencies": { "@mdn/browser-compat-data": "^7.0.0" diff --git a/schemas/data.schema.json b/schemas/data.schema.json index 1d4f5f899d1..7ee7b28da67 100644 --- a/schemas/data.schema.json +++ b/schemas/data.schema.json @@ -44,7 +44,18 @@ "description": "Feature identifiers and data", "type": "object", "additionalProperties": { - "$ref": "#/definitions/FeatureData" + "oneOf": [ + { + "$ref": "#/definitions/FeatureData" + }, + { + "$ref": "#/definitions/FeatureMovedData" + }, + { + "$ref": "#/definitions/FeatureSplitData" + } + ], + "$comment": "Use the `kind` property as a discriminator." } }, "groups": { @@ -102,6 +113,9 @@ "description": "A feature data entry", "type": "object", "properties": { + "kind": { + "const": "feature" + }, "name": { "description": "Short name", "type": "string" @@ -143,7 +157,44 @@ "$ref": "#/definitions/Discouraged" } }, - "required": ["name", "description", "description_html", "spec", "status"], + "required": [ + "kind", + "name", + "description", + "description_html", + "spec", + "status" + ], + "additionalProperties": false + }, + "FeatureMovedData": { + "description": "A feature has permanently moved to exactly one other ID", + "type": "object", + "properties": { + "kind": { + "const": "moved" + }, + "redirect_target": { + "description": "The new ID for this feature", + "type": "string" + } + }, + "required": ["kind", "redirect_target"], + "additionalProperties": false + }, + "FeatureSplitData": { + "description": "A feature has split into two or more other features", + "type": "object", + "properties": { + "kind": { + "const": "split" + }, + "redirect_targets": { + "description": "The new IDs for this feature", + "$ref": "#/definitions/Strings" + } + }, + "required": ["kind", "redirect_targets"], "additionalProperties": false }, "GroupData": { @@ -161,6 +212,37 @@ "required": ["name"], "additionalProperties": false }, + "Release": { + "description": "Browser release information", + "type": "object", + "properties": { + "version": { + "description": "The version string, as in \"10\" or \"17.1\"", + "type": "string" + }, + "date": { + "description": " The release date, as in \"2023-12-11\"", + "type": "string" + } + }, + "required": ["version", "date"], + "additionalProperties": false + }, + "SnapshotData": { + "type": "object", + "properties": { + "name": { + "description": "Short name", + "type": "string" + }, + "spec": { + "description": "Specification", + "type": "string" + } + }, + "required": ["name", "spec"], + "additionalProperties": false + }, "Status": { "type": "object", "properties": { @@ -226,37 +308,6 @@ "required": ["baseline", "support"], "additionalProperties": false }, - "Release": { - "description": "Browser release information", - "type": "object", - "properties": { - "version": { - "description": "The version string, as in \"10\" or \"17.1\"", - "type": "string" - }, - "date": { - "description": " The release date, as in \"2023-12-11\"", - "type": "string" - } - }, - "required": ["version", "date"], - "additionalProperties": false - }, - "SnapshotData": { - "type": "object", - "properties": { - "name": { - "description": "Short name", - "type": "string" - }, - "spec": { - "description": "Specification", - "$ref": "#/definitions/URL" - } - }, - "required": ["name", "spec"], - "additionalProperties": false - }, "StringOrStrings": { "oneOf": [ { diff --git a/scripts/build.ts b/scripts/build.ts index 3d1a883bbec..04c4aa2a14e 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -7,6 +7,7 @@ import { basename } from "node:path"; import winston from "winston"; import yargs from "yargs"; import * as data from "../index.js"; +import { isOrdinaryFeatureData } from "../type-guards.js"; import { validate } from "./validate.js"; const logger = winston.createLogger({ @@ -68,6 +69,10 @@ function buildPackage() { function buildExtendedJSON() { for (const [id, featureData] of Object.entries(data.features)) { + if (!isOrdinaryFeatureData(featureData)) { + continue; + } + if ( Array.isArray(featureData.compat_features) && featureData.compat_features.length && diff --git a/scripts/dist.ts b/scripts/dist.ts index 209aa1d154a..1832f6a4b89 100644 --- a/scripts/dist.ts +++ b/scripts/dist.ts @@ -14,6 +14,7 @@ import { isDeepStrictEqual } from "node:util"; import winston from "winston"; import YAML, { Document, Scalar, YAMLSeq } from "yaml"; import yargs from "yargs"; +import type { FeatureData, FeatureMovedData, FeatureSplitData } from "../types"; const compat = new Compat(); @@ -181,6 +182,39 @@ function compareStatus(a: SupportStatus, b: SupportStatus) { return 0; } +function toRedirectDist( + id: string, + source: FeatureMovedData | FeatureSplitData, +): YAML.Document { + const dist = new Document({}); + + const comment = [ + `Generated from: ${id}.yml`, + `This file intentionally left blank.`, + `Do not edit this file.`, + ]; + + const { kind } = source; + switch (kind) { + case "moved": + comment.push( + `The data for this feature has moved to ${source.redirect_target}.yml`, + ); + break; + case "split": + comment.push(`The data for this feature has moved to:`); + comment.push(...source.redirect_targets.map((dest) => ` - ${dest}.yml`)); + break; + default: + kind satisfies never; + throw new Error(`Unhandled feature kind ${kind}}`); + } + + dist.commentBefore = comment.map((line) => ` ${line}`).join("\n"); + + return dist; +} + /** * Generate a dist YAML document from a feature definition YAML file path. * @@ -192,6 +226,11 @@ function toDist(sourcePath: string): YAML.Document { const source = YAML.parse(fs.readFileSync(sourcePath, { encoding: "utf-8" })); const { name: id } = path.parse(sourcePath); + if ("redirect_target" in source || "redirect_targets" in source) { + return toRedirectDist(id, source); + } + source as Partial; + // Collect tagged compat features. A `compat_features` list in the source // takes precedence, but can be removed if it matches the tagged features. const taggedCompatFeatures = (tagsToFeatures.get(`web-features:${id}`) ?? []) diff --git a/scripts/find-ranged-headline-statuses.ts b/scripts/find-ranged-headline-statuses.ts index a5dabd2f0cd..e60f21efd00 100644 --- a/scripts/find-ranged-headline-statuses.ts +++ b/scripts/find-ranged-headline-statuses.ts @@ -1,7 +1,13 @@ import { features } from "../index"; +import { isOrdinaryFeatureData } from "../type-guards"; for (const [key, data] of Object.entries(features)) { - if ((data.status.baseline_low_date ?? "").includes("≤")) { - console.log(key); + if (isOrdinaryFeatureData(data)) { + if ( + "status" in data && + (data.status.baseline_low_date ?? "").includes("≤") + ) { + console.log(key); + } } } diff --git a/scripts/inspect-feature.ts b/scripts/inspect-feature.ts index 7c7c9e97f07..121dca044cf 100644 --- a/scripts/inspect-feature.ts +++ b/scripts/inspect-feature.ts @@ -3,6 +3,7 @@ import escapeHtml from "escape-html"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import winston from "winston"; import YAML from "yaml"; import yargs from "yargs"; import { features } from ".."; @@ -33,6 +34,15 @@ const argv = yargs(process.argv.slice(2)) defaultDescription: "warn", }).argv as Args; +const logger = winston.createLogger({ + level: argv.verbose > 0 ? "debug" : "warn", + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple(), + ), + transports: new winston.transports.Console(), +}); + function main(): void { for (const filePath of argv.paths) { const { name, ext, dir } = path.parse(filePath); @@ -45,6 +55,12 @@ function main(): void { if (!feature) { throw new Error(`No feature found for ID ${id}`); } + if (feature.kind === "moved" || feature.kind === "split") { + logger.warn( + `${id} is a ${feature.kind} feature. Did you mean to inspect this?`, + ); + continue; + } const { compat_features } = feature; diff --git a/scripts/specs.ts b/scripts/specs.ts index a2372fc5e9e..c1e7284ff28 100644 --- a/scripts/specs.ts +++ b/scripts/specs.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import webSpecs from 'web-specs' with { type: 'json' }; import { features } from '../index.js'; +import { isOrdinaryFeatureData } from "../type-guards.js"; // Specs needs to be in "good standing". Nightly URLs are used if available, // otherwise the snapshot/versioned URL is used. See browser-specs/web-specs @@ -195,6 +196,10 @@ for (const [allowedUrl, message] of defaultAllowlist) { } for (const [id, data] of Object.entries(features)) { + if (!isOrdinaryFeatureData(data)) { + continue; + } + const specs = Array.isArray(data.spec) ? data.spec : [data.spec]; for (const spec of specs) { let url: URL; diff --git a/scripts/stats.ts b/scripts/stats.ts index 1894c73ba96..404efe51ddf 100644 --- a/scripts/stats.ts +++ b/scripts/stats.ts @@ -2,6 +2,7 @@ import { Compat } from "compute-baseline/browser-compat-data"; import { fileURLToPath } from "node:url"; import yargs from "yargs"; import { features } from "../index.js"; +import { isOrdinaryFeatureData } from "../type-guards.js"; const argv = yargs(process.argv.slice(2)) .scriptName("stats") @@ -14,11 +15,20 @@ const argv = yargs(process.argv.slice(2)) }).argv; export function stats(detailed: boolean = false) { - const featureCount = Object.keys(features).length; + const featureCount = Object.values(features).filter( + isOrdinaryFeatureData, + ).length; const keys = []; const doneKeys = Array.from( - new Set(Object.values(features).flatMap((f) => f.compat_features ?? [])), + new Set( + Object.values(features).flatMap((f) => { + if (isOrdinaryFeatureData(f)) { + return f.compat_features ?? []; + } + return []; + }), + ), ); const toDoKeys = []; @@ -35,6 +45,7 @@ export function stats(detailed: boolean = false) { } const featureSizes = Object.values(features) + .filter(isOrdinaryFeatureData) .map((feature) => (feature.compat_features ?? []).length) .sort((a, b) => a - b); diff --git a/scripts/update-drafts.ts b/scripts/update-drafts.ts index f3fac0babdf..ab84d708596 100644 --- a/scripts/update-drafts.ts +++ b/scripts/update-drafts.ts @@ -11,6 +11,7 @@ import { Document, parse } from "yaml"; import yargs from "yargs"; import { features } from "../index.js"; +import { isOrdinaryFeatureData } from "../type-guards.js"; import { FeatureData } from "../types.js"; type WebSpecsSpec = (typeof webSpecs)[number]; @@ -84,7 +85,7 @@ async function main() { // Build a map of used BCD keys to feature. const webFeatures = new Map(); Object.values(features).map((data) => { - if (data.compat_features) { + if (isOrdinaryFeatureData(data) && data.compat_features) { for (const compatFeature of data.compat_features) { webFeatures.set(compatFeature, data.name); } @@ -261,9 +262,12 @@ async function main() { } // Clean up completed specs, even if they've been superseded - const assignedKeys = Object.values(features).flatMap( - (f) => f.compat_features ?? [], - ); + const assignedKeys = Object.values(features).flatMap((f) => { + if (isOrdinaryFeatureData(f)) { + return f.compat_features ?? []; + } + return []; + }); for (const spec of webSpecs) { const id = formatIdentifier(spec.shortname); const destination = `features/draft/spec/${id}.yml`; diff --git a/type-guards.ts b/type-guards.ts new file mode 100644 index 00000000000..806a88139d4 --- /dev/null +++ b/type-guards.ts @@ -0,0 +1,13 @@ +import type { FeatureData, FeatureMovedData, FeatureSplitData } from "./types"; + +export function isOrdinaryFeatureData(x: unknown): x is FeatureData { + return typeof x === "object" && "kind" in x && x.kind === "feature"; +} + +export function isSplit(x: unknown): x is FeatureSplitData { + return typeof x === "object" && "kind" in x && x.kind === "split"; +} + +export function isMoved(x: unknown): x is FeatureMovedData { + return typeof x === "object" && "kind" in x && x.kind === "moved"; +} diff --git a/types.quicktype.ts b/types.quicktype.ts index a62d87701e3..abf8e3474c5 100644 --- a/types.quicktype.ts +++ b/types.quicktype.ts @@ -60,6 +60,10 @@ export interface Release { /** * A feature data entry + * + * A feature has permanently moved to exactly one other ID + * + * A feature has split into two or more other features */ export interface FeatureData { /** @@ -73,11 +77,11 @@ export interface FeatureData { /** * Short description of the feature, as a plain text string */ - description: string; + description?: string; /** * Short description of the feature, as an HTML string */ - description_html: string; + description_html?: string; /** * Whether developers are formally discouraged from using this feature */ @@ -86,10 +90,11 @@ export interface FeatureData { * Group identifier(s) */ group?: string[] | string; + kind: Kind; /** * Short name */ - name: string; + name?: string; /** * Snapshot identifier(s) */ @@ -97,12 +102,20 @@ export interface FeatureData { /** * Specification URL(s) */ - spec: string[] | string; + spec?: string[] | string; /** * Whether a feature is considered a "Baseline" web platform feature and when it achieved * that status */ - status: StatusHeadline; + status?: StatusHeadline; + /** + * The new ID for this feature + */ + redirect_target?: string; + /** + * The new IDs for this feature + */ + redirect_targets?: string[]; } /** @@ -120,6 +133,8 @@ export interface Discouraged { alternatives?: string[]; } +export type Kind = "feature" | "moved" | "split"; + /** * Whether a feature is considered a "Baseline" web platform feature and when it achieved * that status diff --git a/types.ts b/types.ts index a0cb96cb7d8..de0c7ad2fbe 100644 --- a/types.ts +++ b/types.ts @@ -10,6 +10,7 @@ import type { Browsers, Discouraged, GroupData, + Kind, FeatureData as QuicktypeMonolithicFeatureData, Status as QuicktypeStatus, StatusHeadline as QuicktypeStatusHeadline, @@ -66,10 +67,12 @@ const goodSupportStatus: QuicktypeStatusHeadline | SupportStatus = { export interface WebFeaturesData extends Pick { - features: { [key: string]: FeatureData }; + features: { + [key: string]: FeatureData | FeatureMovedData | FeatureSplitData; + }; } -export type FeatureData = Required< +export type FeatureData = { kind: "feature" } & Required< Pick< QuicktypeMonolithicFeatureData, "description_html" | "description" | "name" | "spec" | "status" @@ -83,6 +86,7 @@ export type FeatureData = Required< >; const goodFeatureData: FeatureData = { + kind: "feature", name: "Test", description: "Hi", description_html: "Hi", @@ -93,4 +97,38 @@ const goodFeatureData: FeatureData = { }, }; +type FeatureRedirectData = { kind: Exclude } & Required< + Pick +>; + +export interface FeatureMovedData + extends Omit { + kind: "moved"; +} + +const goodFeatureMovedData: FeatureMovedData = { + kind: "moved", + redirect_target: "", +}; +const badFeatureMovedData: FeatureMovedData = { + kind: "moved", + // @ts-expect-error + redirect_targets: ["", ""], +}; + +export interface FeatureSplitData + extends Omit { + kind: "split"; +} + +const goodFeatureSplitData: FeatureSplitData = { + kind: "split", + redirect_targets: ["", ""], +}; +const badFeatureSplitData: FeatureSplitData = { + kind: "split", + // @ts-expect-error + redirect_target: "", +}; + export type BrowserIdentifier = keyof Browsers;