From 4da2a0d023236190711f7f8258b9aa03cf3a94af Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 31 Jan 2025 10:55:28 +0100 Subject: [PATCH] Add Opera release update script --- package-lock.json | 32 ++++ package.json | 1 + scripts/update-browser-releases/index.ts | 36 ++++ scripts/update-browser-releases/opera.ts | 213 +++++++++++++++++++++++ scripts/update-browser-releases/utils.ts | 45 +++++ 5 files changed, 327 insertions(+) create mode 100644 scripts/update-browser-releases/opera.ts diff --git a/package-lock.json b/package-lock.json index 5b039a2c358642..a7b9804a2ec817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "typescript": "~5.7.2", "web-features": "^2.15.0", "web-specs": "^3.0.0", + "xml2js": "^0.6.2", "yargs": "~17.7.0" }, "engines": { @@ -7066,6 +7067,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8132,6 +8140,30 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a51737cc87b239..47d50d4e2e947a 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "typescript": "~5.7.2", "web-features": "^2.15.0", "web-specs": "^3.0.0", + "xml2js": "^0.6.2", "yargs": "~17.7.0" }, "scripts": { diff --git a/scripts/update-browser-releases/index.ts b/scripts/update-browser-releases/index.ts index 70acd47e9b9d3b..391069bdb12186 100644 --- a/scripts/update-browser-releases/index.ts +++ b/scripts/update-browser-releases/index.ts @@ -6,6 +6,7 @@ import { updateChromiumReleases } from './chrome.js'; import { updateEdgeReleases } from './edge.js'; import { updateFirefoxReleases } from './firefox.js'; import { updateSafariReleases } from './safari.js'; +import { updateOperaReleases } from './opera.js'; const argv = yargs(process.argv.slice(2)) .usage('Usage: npm run update-browser-releases -- (flags)') @@ -29,6 +30,11 @@ const argv = yargs(process.argv.slice(2)) type: 'boolean', group: 'Engine selection:', }) + .option('opera', { + describe: 'Update Opera', + type: 'boolean', + group: 'Engine selection:', + }) .option('safari', { describe: 'Update Apple Safari', type: 'boolean', @@ -65,6 +71,7 @@ const updateAllBrowsers = argv['webview'] || argv['firefox'] || argv['edge'] || + argv['opera'] || argv['safari'] ); const updateChrome = argv['chrome'] || updateAllBrowsers; @@ -72,6 +79,7 @@ const updateWebview = argv['webview'] || updateAllBrowsers; const updateFirefox = argv['firefox'] || updateAllBrowsers; const updateEdge = argv['edge'] || updateAllBrowsers; const updateSafari = argv['safari'] || updateAllBrowsers; +const updateOpera = argv['opera'] || updateAllBrowsers; const updateAllDevices = argv['alldevices'] || !(argv['mobile'] || argv['desktop']); const updateMobile = argv['mobile'] || updateAllDevices; @@ -190,6 +198,24 @@ const options = { 'https://developer.apple.com/tutorials/data/documentation/safari-release-notes.json', releaseNoteURLBase: 'https://developer.apple.com', }, + opera: { + browserName: 'Opera for Desktop', + bcdFile: './browsers/opera.json', + bcdBrowserName: 'opera', + skippedReleases: [], + releaseFeedURL: 'https://blogs.opera.com/desktop/category/stable-2/feed/', + titleVersionPattern: /^Opera (\d+)$/, + descriptionEngineVersionPattern: /Chromium (\d+)/, + }, + opera_android: { + browserName: 'Opera for Android', + bcdFile: './browsers/opera_android.json', + bcdBrowserName: 'opera_android', + skippedReleases: [], + releaseFeedURL: 'https://forums.opera.com/category/20.rss', + titleVersionPattern: /^Opera for Android (\d+)$/, + descriptionEngineVersionPattern: /Chromium (\d+)/, + }, }; let result = ''; @@ -224,6 +250,16 @@ if (updateFirefox && updateMobile) { result += (result && add ? '\n' : '') + add; } +if (updateOpera && updateDesktop) { + const add = await updateOperaReleases(options.opera); + result += (result && add ? '\n' : '') + add; +} + +if (updateOpera && updateMobile) { + const add = await updateOperaReleases(options.opera_android); + result += (result && add ? '\n' : '') + add; +} + if (updateSafari && updateDesktop) { const add = await updateSafariReleases(options.safari_desktop); result += (result && add ? '\n' : '') + add; diff --git a/scripts/update-browser-releases/opera.ts b/scripts/update-browser-releases/opera.ts new file mode 100644 index 00000000000000..d5c6df40f62db4 --- /dev/null +++ b/scripts/update-browser-releases/opera.ts @@ -0,0 +1,213 @@ +import fs from 'node:fs/promises'; + +import xml2js from 'xml2js'; + +import stringify from '../lib/stringify-and-order-properties.js'; + +import { createOrUpdateBrowserEntry, updateBrowserEntry } from './utils'; + +const USER_AGENT = + 'MDN-Browser-Release-Update-Bot/1.0 (+https://developer.mozilla.org/)'; + +interface RSSItem { + title: string; + pubDate: string; + description: string; + link: string; +} + +interface Release { + version: string; + date: string; + releaseNote: string; + channel: 'current'; + engine: 'Blink'; + engineVersion: string; +} + +/** + * Fetches an RSS feed, using a typical RSS user agent. + * @param url The URL of the RSS feed. + * @returns Promise + */ +const fetchRSS = async (url: string) => { + const response = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.text(); +}; + +/** + * Parses an RSS feed into a JSON object. + * @param rssText The content of the RSS feed. + * @returns the RSS items. + */ +const parseRSS = async (rssText: string): Promise => { + const parser = new xml2js.Parser({ explicitArray: false }); + + const result = await parser.parseStringPromise(rssText); + + return result.rss.channel.item; +}; + +/** + * Fetches and parses an RSS feed. + * @param url The URL of the RSS feed. + * @returns the RSS items. + */ +const getRSSItems = async (url): Promise => { + const rssText = await fetchRSS(url); + const items = await parseRSS(rssText); + + return Array.isArray(items) ? items : [items]; +}; + +/** + * Extracts the latest release from the items. + * @param items + * @param versionPattern + * @param engineVersionPattern + * @returns the latest release, if found, otherwise null. + */ +const findRelease = ( + items: RSSItem[], + versionPattern: RegExp, + engineVersionPattern: RegExp, +): Release | null => { + const item = items.find( + (item) => + versionPattern.test(item.title) && + engineVersionPattern.test(item.description), + ); + + if (!item) { + return null; + } + + const version = (item.title.match(versionPattern) as RegExpMatchArray)[1]; + const date = new Date(item.pubDate).toISOString().split('T')[0]; + const releaseNote = item.link; + const engineVersion = ( + item.description.match(engineVersionPattern) as RegExpMatchArray + )[1]; + + return { + version, + date, + releaseNote, + channel: 'current', + engine: 'Blink', + engineVersion, + }; +}; + +/** + * Converts a message into a GFM Markdown warning. + * @param message the message of the warning. + * @returns the message as a GFM warning. + */ +const warn = (message: string) => + `> [!WARN]\n${message + .split('\n') + .map((line) => `> ${line}`) + .join('\n')}`; + +/** + * Updates the JSON files listing the Opera browser releases. + * @param options The list of options for this type of Safari. + * @returns The log of what has been generated (empty if nothing) + */ +export const updateOperaReleases = async (options) => { + const browser = options.bcdBrowserName; + + const isDesktop = browser === 'opera'; + + let result = ''; + + const items = await getRSSItems(options.releaseFeedURL); + const release = findRelease( + items, + options.titleVersionPattern, + options.descriptionEngineVersionPattern, + ); + + if (!release) { + return warn( + `**${options.browserName}**: No stable release found among ${items.length} items in [this RSS feed](<${options.releaseFeedURL}>)!`, + ); + } + + const file = await fs.readFile(`${options.bcdFile}`, 'utf-8'); + const data = JSON.parse(file.toString()); + + const current = structuredClone( + data.browsers[browser].releases[release.version], + ); + + if (isDesktop && !current) { + return warn( + `Latest stable **${options.browserName}** release **${release.version}** not yet tracked.`, + ); + } + + result += createOrUpdateBrowserEntry( + data, + browser, + release.version, + release.channel, + release.engine, + release.engineVersion, + release.date, + release.releaseNote, + ); + + // Set previous release to "retired". + const previousVersion = String(Number(release.version) - 1); + result += updateBrowserEntry( + data, + browser, + previousVersion, + undefined, + 'retired', + undefined, + undefined, + ); + + if (isDesktop) { + // 1. Set next release to "beta". + result += createOrUpdateBrowserEntry( + data, + browser, + String(Number(release.version) + 1), + 'beta', + release.engine, + String(Number(release.engineVersion) + 1), + ); + + // 2. Add another release as "nightly". + result += createOrUpdateBrowserEntry( + data, + browser, + String(Number(release.version) + 2), + 'nightly', + release.engine, + String(Number(release.engineVersion) + 2), + ); + } + + await fs.writeFile(`./${options.bcdFile}`, stringify(data) + '\n'); + + // Returns the log + if (result) { + result = `### Updates for ${options.browserName}${result}`; + } + + return result; +}; diff --git a/scripts/update-browser-releases/utils.ts b/scripts/update-browser-releases/utils.ts index 6ae9a912073114..31e238c1d34ede 100644 --- a/scripts/update-browser-releases/utils.ts +++ b/scripts/update-browser-releases/utils.ts @@ -82,3 +82,48 @@ export const updateBrowserEntry = ( return result; }; + +/** + * + * @param json + * @param browser + * @param version + * @param status + * @param engine + * @param engineVersion + * @param releaseDate + * @param releaseNotesURL + */ +export const createOrUpdateBrowserEntry = ( + json, + browser, + version, + status: 'retired' | 'current' | 'beta' | 'nightly', + engine: string | undefined, + engineVersion: string | undefined, + releaseDate: string | undefined = undefined, + releaseNotesURL: string | undefined = undefined, +) => { + if (json.browsers[browser].releases[version]) { + return updateBrowserEntry( + json, + browser, + version, + releaseDate, + status, + releaseNotesURL, + engineVersion, + ); + } + + return newBrowserEntry( + json, + browser, + version, + status, + engine, + releaseDate, + releaseNotesURL, + engineVersion, + ); +};