Skip to content

Commit 39ea61e

Browse files
committed
refactor(route/caniuse): merge sidvishnmoi/respec-caniuse-route to main
1 parent d6e33c9 commit 39ea61e

9 files changed

Lines changed: 316 additions & 10 deletions

File tree

package-lock.json

Lines changed: 0 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
"helmet": "^4.1.0",
1818
"morgan": "^1.10.0",
1919
"node-fetch": "^2.6.1",
20-
"respec-caniuse-route": "^3.1.1",
2120
"respec-github-apis": "^2.0.0",
2221
"respec-xref-route": "^9.0.4",
2322
"split2": "^3.2.2",

routes/caniuse/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import cors from "cors";
55
import authGithubWebhook from "../../utils/auth-github-webhook.js";
66
import { env, seconds } from "../../utils/misc.js";
77

8-
import { createResponseBody } from "respec-caniuse-route";
8+
import { createResponseBody } from "./lib/index.js";
99
import updateRoute from "./update.js";
1010

1111
const caniuse = Router({ mergeParams: true });

routes/caniuse/lib/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This directory was originally maintained in a separate repository at https://github.com/sidvishnoi/respec-caniuse-route.

routes/caniuse/lib/constants.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const BROWSERS = new Map([
2+
['and_chr', 'Chrome (Android)'],
3+
['and_ff', 'Firefox (Android)'],
4+
['and_uc', 'UC Browser (Android)'],
5+
['android', 'Android'],
6+
['bb', 'Blackberry'],
7+
['chrome', 'Chrome'],
8+
['edge', 'Edge'],
9+
['firefox', 'Firefox'],
10+
['ie', 'IE'],
11+
['ios_saf', 'Safari (iOS)'],
12+
['op_mini', 'Opera Mini'],
13+
['op_mob', 'Opera Mobile'],
14+
['opera', 'Opera'],
15+
['safari', 'Safari'],
16+
['samsung', 'Samsung Internet'],
17+
]);
18+
19+
// Keys from https://github.com/Fyrd/caniuse/blob/master/CONTRIBUTING.md
20+
export const SUPPORT_TITLES = new Map([
21+
['y', 'Supported.'],
22+
['a', 'Almost supported (aka Partial support).'],
23+
['n', 'No support, or disabled by default.'],
24+
['p', 'No support, but has Polyfill.'],
25+
['u', 'Support unknown.'],
26+
['x', 'Requires prefix to work.'],
27+
['d', 'Disabled by default (needs to enabled).'],
28+
]);

routes/caniuse/lib/index.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import * as path from "path";
2+
import { promises as fs } from "fs";
3+
4+
import { html } from "ucontent";
5+
6+
import { BROWSERS, SUPPORT_TITLES } from "./constants.js";
7+
import { env } from "../../../utils/misc.js";
8+
import { MemCache } from "../../../utils/mem-cache.js";
9+
10+
const DATA_DIR = env("DATA_DIR");
11+
12+
interface Options {
13+
feature: string;
14+
browsers?: string[];
15+
versions?: number;
16+
format?: "html" | "json";
17+
}
18+
type NormalizedOptions = Required<Options>;
19+
20+
type SupportKeys = ("y" | "n" | "a" | string)[];
21+
// [ version, ['y', 'n'] ]
22+
type BrowserVersionData = [string, SupportKeys];
23+
24+
interface Data {
25+
[browserName: string]: BrowserVersionData[];
26+
}
27+
28+
const defaultOptions = {
29+
browsers: ["chrome", "firefox", "safari", "edge"],
30+
versions: 4,
31+
};
32+
33+
// Content in this cache is invalidated through `POST /caniuse/update`.
34+
export const cache = new MemCache<Data>(Infinity);
35+
36+
export async function createResponseBody(options: Options) {
37+
const opts = normalizeOptions(options);
38+
39+
switch (opts.format) {
40+
case "json":
41+
return await createResponseBodyJSON(opts);
42+
case "html":
43+
default:
44+
return await createResponseBodyHTML(opts);
45+
}
46+
}
47+
48+
export async function createResponseBodyJSON(options: NormalizedOptions) {
49+
const { feature, browsers, versions } = options;
50+
const data = await getData(feature);
51+
if (!data) {
52+
return null;
53+
}
54+
55+
if (!browsers.length) {
56+
browsers.push(...Object.keys(data));
57+
}
58+
59+
const response: Data = Object.create(null);
60+
for (const browser of browsers) {
61+
const browserData = data[browser] || [];
62+
response[browser] = browserData.slice(0, versions);
63+
}
64+
return response;
65+
}
66+
67+
export async function createResponseBodyHTML(options: NormalizedOptions) {
68+
const data = await createResponseBodyJSON(options);
69+
return data === null ? null : formatAsHTML(options, data);
70+
}
71+
72+
function normalizeOptions(options: Options): NormalizedOptions {
73+
const feature = options.feature;
74+
const versions = options.versions || defaultOptions.versions;
75+
const browsers = sanitizeBrowsersList(options.browsers);
76+
const format = options.format === "html" ? "html" : "json";
77+
return { feature, versions, browsers, format };
78+
}
79+
80+
function sanitizeBrowsersList(browsers?: string | string[]) {
81+
if (!Array.isArray(browsers)) {
82+
if (browsers === "all") return [];
83+
return defaultOptions.browsers;
84+
}
85+
const filtered = browsers.filter(browser => BROWSERS.has(browser));
86+
return filtered.length ? filtered : defaultOptions.browsers;
87+
}
88+
89+
async function getData(feature: string) {
90+
if (cache.has(feature)) {
91+
return cache.get(feature) as Data;
92+
}
93+
const file = path.format({
94+
dir: path.join(DATA_DIR, "caniuse"),
95+
name: `${feature}.json`,
96+
});
97+
98+
try {
99+
const str = await fs.readFile(file, "utf8");
100+
const data: Data = JSON.parse(str);
101+
cache.set(feature, data);
102+
return data;
103+
} catch (error) {
104+
console.error(error);
105+
return null;
106+
}
107+
}
108+
109+
function formatAsHTML(options: NormalizedOptions, data: Data) {
110+
const getSupportTitle = (supportKeys: SupportKeys) => {
111+
return supportKeys
112+
.filter(key => SUPPORT_TITLES.has(key))
113+
.map(key => SUPPORT_TITLES.get(key)!)
114+
.join(" ");
115+
};
116+
117+
const getClassName = (supportKeys: SupportKeys) =>
118+
`caniuse-cell ${supportKeys.join(" ")}`;
119+
120+
const renderLatestVersion = (
121+
browserName: string,
122+
[version, supportKeys]: BrowserVersionData,
123+
) => {
124+
const text = `${BROWSERS.get(browserName) || browserName} ${version}`;
125+
const className = getClassName(supportKeys);
126+
const title = getSupportTitle(supportKeys);
127+
return html`<button class="${className}" title="${title}">${text}</button>`;
128+
};
129+
130+
const renderOlderVersion = ([version, supportKeys]: BrowserVersionData) => {
131+
const text = version;
132+
const className = getClassName(supportKeys);
133+
const title = getSupportTitle(supportKeys);
134+
return html`<li class="${className}" title="${title}">${text}</li>`;
135+
};
136+
137+
const renderBrowser = (
138+
browser: string,
139+
browserData: BrowserVersionData[],
140+
) => {
141+
const [latestVersion, ...olderVersions] = browserData;
142+
return html`
143+
<div class="caniuse-browser">
144+
${renderLatestVersion(browser, latestVersion)}
145+
<ul>
146+
${olderVersions.map(renderOlderVersion)}
147+
</ul>
148+
</div>
149+
`;
150+
};
151+
152+
const browsers = html`${Object.entries(data).map(([browser, browserData]) =>
153+
renderBrowser(browser, browserData),
154+
)}`;
155+
156+
const featureURL = new URL(options.feature, "https://caniuse.com/").href;
157+
const moreInfo = html`<a href="${featureURL}">More info</a>`;
158+
159+
return html`${browsers} ${moreInfo}`.toString();
160+
}

routes/caniuse/lib/scraper.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Reads features-json/*.json files from caniuse repository
2+
// and writes each file in a "respec friendly way"
3+
// - Keep only the `stats` from features-json data
4+
// - Sort browser versions (latest first)
5+
// - Remove footnotes and other unnecessary data
6+
7+
import * as path from "path";
8+
import { existsSync } from "fs";
9+
import { readFile, writeFile, readdir, mkdir } from "fs/promises";
10+
11+
import sh from "../../../utils/sh.js";
12+
import { env } from "../../../utils/misc.js";
13+
14+
interface Input {
15+
stats: {
16+
[browserName: string]: { [version: string]: string };
17+
};
18+
}
19+
20+
interface Output {
21+
[browserName: string]: [string, ReturnType<typeof formatStatus>][];
22+
}
23+
24+
const DATA_DIR = env("DATA_DIR");
25+
const INPUT_REPO_SRC = "https://github.com/Fyrd/caniuse.git";
26+
const INPUT_REPO_NAME = "caniuse-raw";
27+
const INPUT_DIR = path.join(DATA_DIR, INPUT_REPO_NAME, "features-json");
28+
const OUTPUT_DIR = path.join(DATA_DIR, "caniuse");
29+
30+
const defaultOptions = { forceUpdate: false };
31+
type Options = typeof defaultOptions;
32+
33+
export default async function main(options: Partial<Options> = {}) {
34+
const opts = { ...defaultOptions, ...options };
35+
const hasUpdated = await updateInputSource();
36+
if (!hasUpdated && !opts.forceUpdate) {
37+
console.log("Nothing to update");
38+
return false;
39+
}
40+
41+
console.log("INPUT_DIR:", INPUT_DIR);
42+
console.log("OUTPUT_DIR:", OUTPUT_DIR);
43+
if (!existsSync(OUTPUT_DIR)) {
44+
await mkdir(OUTPUT_DIR, { recursive: true });
45+
}
46+
47+
const fileNames = await readdir(INPUT_DIR);
48+
console.log(`Processing ${fileNames.length} files...`);
49+
const promisesToProcess = fileNames.map(processFile);
50+
await Promise.all(promisesToProcess);
51+
console.log(`Processed ${fileNames.length} files.`);
52+
return true;
53+
}
54+
55+
async function updateInputSource() {
56+
const dataDir = path.join(DATA_DIR, INPUT_REPO_NAME);
57+
const shouldClone = !existsSync(dataDir);
58+
59+
const command = shouldClone
60+
? `git clone ${INPUT_REPO_SRC} ${INPUT_REPO_NAME} --filter=blob:none`
61+
: `git pull --depth=1`;
62+
const cwd = shouldClone ? path.resolve(DATA_DIR) : dataDir;
63+
64+
const stdout = await sh(command, { cwd, output: "stream" });
65+
const hasUpdated = !stdout.toString().includes("Already up to date");
66+
return hasUpdated;
67+
}
68+
69+
async function processFile(fileName: string) {
70+
const inputFile = path.join(INPUT_DIR, fileName);
71+
const outputFile = path.join(OUTPUT_DIR, fileName);
72+
73+
const json = await readJSON(inputFile);
74+
75+
const output: Output = {};
76+
for (const [browserName, browserData] of Object.entries(json.stats)) {
77+
const stats = Object.entries(browserData)
78+
.sort(semverCompare)
79+
.map(([version, status]) => [version, formatStatus(status)])
80+
.reverse() as [string, string[]][];
81+
output[browserName] = stats;
82+
}
83+
84+
await writeJSON(outputFile, output);
85+
}
86+
87+
type BrowserDataEntry = [string, string];
88+
/**
89+
* semverCompare
90+
* https://github.com/substack/semver-compare
91+
*/
92+
function semverCompare(a: BrowserDataEntry, b: BrowserDataEntry) {
93+
const pa = a[0].split(".");
94+
const pb = b[0].split(".");
95+
for (let i = 0; i < 3; i++) {
96+
const na = Number(pa[i]);
97+
const nb = Number(pb[i]);
98+
if (na > nb) return 1;
99+
if (nb > na) return -1;
100+
if (!isNaN(na) && isNaN(nb)) return 1;
101+
if (isNaN(na) && !isNaN(nb)) return -1;
102+
}
103+
return 0;
104+
}
105+
106+
/** @example "n d #6" => ["n", "d"] */
107+
function formatStatus(status: string) {
108+
return status
109+
.split("#", 1)[0] // don't care about footnotes.
110+
.split(" ")
111+
.filter(item => item);
112+
}
113+
114+
async function readJSON(file: string) {
115+
const str = await readFile(file, "utf8");
116+
return JSON.parse(str) as Input;
117+
}
118+
119+
async function writeJSON(file: string, json: Output) {
120+
const str = JSON.stringify(json);
121+
await writeFile(file, str);
122+
}

routes/caniuse/update.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// @ts-check
22
import { queue } from "../../utils/background-task-queue.js";
33

4-
import { cache } from "respec-caniuse-route";
5-
import { main as scraper } from "respec-caniuse-route/scraper.js";
4+
import { cache } from "./lib/index.js";
5+
import scraper from "./lib/scraper.js";
66

77
export default function route(req, res) {
88
if (req.body.ref !== "refs/heads/master") {

scripts/update-data-sources.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import "../build/utils/dotenv.js";
2+
import caniuse from "../build/routes/caniuse/lib/scraper.js";
13
import { main as xref } from "respec-xref-route/scraper.js";
2-
import { main as caniuse } from "respec-caniuse-route/scraper.js";
34

45
async function update() {
56
console.group("caniuse");

0 commit comments

Comments
 (0)