forked from web-platform-dx/web-features
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
254 lines (219 loc) · 8.98 KB
/
index.ts
File metadata and controls
254 lines (219 loc) · 8.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
import fs from 'fs';
import path from 'path';
import { Temporal } from '@js-temporal/polyfill';
import { fdir } from 'fdir';
import YAML from 'yaml';
import { convertMarkdown } from "./text";
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;
// The longest description allowed, to avoid them growing into documentation.
const descriptionMaxLength = 300;
// Internal symbol to mark draft entries, so that using the draft field outside
// of a draft directory doesn't work.
const draft = Symbol('draft');
// This must match the definition in docs/guidelines.md
const identifierPattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
// All identifiers (including drafts) must be unique with respect to their
// siblings. These maps track them with respect to file names, for clearer error
// mesesages.
const uniqueIdMaps = {
features: new Map<string, string>(),
groups: new Map<string, string>(),
snapshots: new Map<string, string>(),
}
function* yamlEntries(root: string): Generator<[string, any]> {
const filePaths = new fdir()
.withBasePath()
.filter((fp) => fp.endsWith('.yml'))
.crawl(root)
.sync() as string[];
for (const fp of filePaths) {
// The feature identifier/key is the filename without extension.
const { name: key } = path.parse(fp);
const pathParts = fp.split(path.sep);
// Assert ID uniqueness
for (const [pool, map] of Object.entries(uniqueIdMaps)) {
if (!pathParts.includes("spec") && pathParts.includes(pool)) {
const otherFile: string | undefined = map.get(key);
if (otherFile) {
throw new Error(`ID collision between ${fp} and ${otherFile}`);
}
map.set(key, fp);
}
}
if (!identifierPattern.test(key)) {
throw new Error(`${key} is not a valid identifier (see guidelines)`);
}
const data = YAML.parse(fs.readFileSync(fp, { encoding: 'utf-8'}));
const distPath = `${fp}.dist`;
if (fs.existsSync(distPath)) {
const dist = YAML.parse(fs.readFileSync(distPath, { encoding: 'utf-8'}));
Object.assign(data, dist);
}
if (pathParts.includes('draft')) {
data[draft] = true;
}
yield [key, data];
}
}
// Load groups and snapshots first so that those identifiers can be validated
// while loading features.
const groups: { [key: string]: GroupData } = Object.fromEntries(yamlEntries('groups'));
// Validate group name and parent fields.
for (const [key, data] of Object.entries(groups)) {
if (typeof data.name !== 'string') {
throw new Error(`group ${key} does not have a name`);
}
// Walk the parent chain to detect cycles. This is not the most efficient
// way to detect cycles overall, but it is simple and will fail for some
// group if there is a cycle.
const chain = [key];
let iter = data;
while (iter.parent) {
chain.push(iter.parent);
if (chain.at(0) === chain.at(-1)) {
throw new Error(`cycle in group parent chain: ${chain.join(' < ')}`);
}
iter = groups[iter.parent];
if (!iter) {
throw new Error(`group ${chain.at(-2)} refers to parent ${chain.at(-1)} which does not exist.`);
}
}
}
const snapshots: { [key: string]: SnapshotData } = Object.fromEntries(yamlEntries('snapshots'));
// TODO: validate the snapshot data.
// Helper to iterate an optional string-or-array-of-strings value.
function* identifiers(value) {
if (value === undefined) {
return;
}
if (Array.isArray(value)) {
yield* value;
} else {
yield value;
}
}
// Map from BCD keys/paths to web-features identifiers.
const bcdToFeatureId: Map<string, string> = new Map();
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]) {
if (!data.draft_date) {
throw new Error(`The draft feature ${key} is missing the draft_date field. Set it to the current date.`);
}
continue;
}
// Attach `kind: feature` to ordinary features
if (!isMoved(data) && !isSplit(data)) {
data.kind = "feature";
}
// Upgrade authored strings to arrays of 1
const optionalArrays = [
"spec",
"group",
"snapshot",
"caniuse",
"foo"
];
const stringToStringArray = (value: string | string[]) => typeof value === "string" ? [value] : value;
for (const optionalArray of optionalArrays) {
const value = data[optionalArray];
if (value) {
data[optionalArray] = stringToStringArray(value);
}
}
if (data.discouraged) {
const value = data.discouraged.according_to;
if (value) {
data.discouraged.according_to = stringToStringArray(value);
}
}
// Convert markdown to text+HTML.
if (data.description) {
const { text, html } = convertMarkdown(data.description);
data.description = text;
data.description_html = html;
}
// Compute Baseline high date from low date.
if (data.status?.baseline === 'high') {
const [date, ranged] = parseRangedDateString(data.status.baseline_low_date);
const lowDate = Temporal.PlainDate.from(date);
const highDate = lowDate.add(BASELINE_LOW_TO_HIGH_DURATION);
data.status.baseline_high_date = ranged ? `≤${highDate}` : String(highDate);
}
// Ensure name and description are not too long.
if (data.name?.length > nameMaxLength) {
throw new Error(`The name field in ${key}.yml is too long, ${data.name.length} characters. The maximum allowed length is ${nameMaxLength}.`);
}
if (data.description?.length > descriptionMaxLength) {
throw new Error(`The description field in ${key}.yml is too long, ${data.description.length} characters. The maximum allowed length is ${descriptionMaxLength}.`);
}
// Ensure that only known group and snapshot identifiers are used.
for (const group of identifiers(data.group)) {
if (!Object.hasOwn(groups, group)) {
throw new Error(`group ${group} used in ${key}.yml is not a valid group. Add it to groups/ if needed.`);
}
}
for (const snapshot of identifiers(data.snapshot)) {
if (!Object.hasOwn(snapshots, snapshot)) {
throw new Error(`snapshot ${snapshot} used in ${key}.yml is not a valid snapshot. Add it to snapshots/ if needed.`);
}
}
if (data.compat_features) {
// Sort compat_features so that grouping and ordering in dist files has
// no effect on what web-features users see.
data.compat_features.sort();
// Check that no BCD key is used twice until the meaning is made clear in
// https://github.com/web-platform-dx/web-features/issues/1173.
for (const bcdKey of data.compat_features) {
const otherKey = bcdToFeatureId.get(bcdKey);
if (otherKey) {
throw new Error(`BCD key ${bcdKey} is used in both ${otherKey} and ${key}, which creates ambiguity for some consumers. Please see https://github.com/web-platform-dx/web-features/issues/1173 and help us find a good solution to allow this.`);
} else {
bcdToFeatureId.set(bcdKey, key);
}
}
}
features[key] = data;
}
for (const [id, feature] of Object.entries(features)) {
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}}`);
}
}
const compat = new Compat();
const browsers: Partial<WebFeaturesData["browsers"]> = {};
for (const browser of coreBrowserSet.map(identifier => compat.browser(identifier))) {
const { id, name } = browser;
const releases = browser.releases.filter(release => !release.isPrerelease()).map(release => ({
version: release.version,
date: String(release.date),
}))
browsers[id] = {
name,
releases,
}
}
export { browsers, features, groups, snapshots };