|
| 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 | +} |
0 commit comments