UNPKG

@bscotch/gamemaker-releases

Version:

Utility for combining GameMaker release data into a single feed.

148 lines 5.89 kB
import { Pathy } from '@bscotch/pathy'; import { assert } from '@bscotch/utility/browser'; import { defaultNotesCachePath } from './constants.js'; import { rawReleaseNotesCacheSchema, rawReleaseNoteSchema, } from './feeds.types.js'; import { fetchJson } from './fetch.js'; import { findMax } from './utils.js'; export async function listReleaseNotes(releases, cache = defaultNotesCachePath) { const cachePath = Pathy.asInstance(cache).withValidator(rawReleaseNotesCacheSchema); assert(cachePath.hasExtension('json'), `Cache path must have a .json extension`); const cacheData = await cachePath.read({ fallback: {} }); for (const release of releases) { for (const type of ['ide', 'runtime']) { let url = release[type].notesUrl; // URL can be malformed url = url.replace(/^(.+\.cloudfront\.net)(release.*)$/, '$1/$2'); if (cacheData[url]) { if (!cacheData[url].type) { cacheData[url].type = type; await cachePath.write(cacheData); } continue; } console.info('Notes cache miss:', url); try { const note = await fetchJson(url, rawReleaseNoteSchema); cacheData[url] = { type, ...rawReleaseNoteSchema.parse(note), }; await cachePath.write(cacheData); } catch (err) { console.warn(`Failed to fetch release notes from ${url}`, err); } } } return cleanNotes(cacheData); } function cleanNotes(cachedNotes) { const rawNotes = Object.entries(cachedNotes).map(([url, note]) => ({ ...note, url, })); // We used to assume that versions were unique, and throw if they // were not as a sanity check, but as of 2025/07/15 that stopped // being true 😒. It's not obvious what the implications of this are... const versions = rawNotes.map((note) => `${note.version} (${note.type})`); // Convert each note into a well-defined structured document. const cleanedNotes = rawNotes.map(cleanNote); // Try to normalize "since" versions, since they're often only the *last* digits for (const note of cleanedNotes) { if (!note.changes.since?.match(/^\d+$/)) { continue; } const possibleMatches = versions.filter((v) => v.endsWith(`.${note.changes.since}`)); if (possibleMatches.length === 1) { note.changes.since = possibleMatches[0]; continue; } if (!possibleMatches.length) { // Then just replace the current version with those digits note.changes.since = note.version.replace(/\d+$/, note.changes.since); continue; } // Score matches by digits in common const versionParts = note.version.split('.'); note.changes.since = findMax(possibleMatches, (possibleMatch) => { const matchParts = possibleMatch.split('.'); let score = 0; for (let j = 0; j < matchParts.length; j++) { score += matchParts[j] === versionParts[j] ? 1 : 0; } return score; }); } // Re-organize into a map for indexing by notes-URL, so that these can be merged // into the release feed data. const notesByUrl = cleanedNotes.reduce((acc, note) => { acc[note.url] = note; return acc; }, {}); return notesByUrl; } export function cleanNote(note) { assert(note.type, 'Note type must be set'); const { body, title } = parseHeader(note.release_notes.join('')); const groups = parseBody(body); const sinceVersion = relativeToVersion(title); // Normalize the HTML return { version: note.version, url: note.url, title, type: note.type, changes: { since: sinceVersion, groups, }, }; } function relativeToVersion(title) { if (!title) { return null; } const sinceMatch = title.match(/([0-9.]+)/i); return sinceMatch?.[1] || null; } function parseBody(html) { // Split on h3 elements, and then pull out the lists within each const parts = html.split(/<h3>\s*(.+?)\s*<\/h3>\s*<ul>\s*(.+?)\s*<\/ul>/gs); const changes = []; for (let groupIdx = 0; groupIdx < parts.length - 2; groupIdx += 3) { const [title, list] = [parts[groupIdx + 1], parts[groupIdx + 2]]; changes.push({ title, changes: parseList(list) }); } return changes; } function parseList(htmlList) { const parts = htmlList.split(/<li>\s*(.+?)\s*<\/li>/gs); const changes = []; for (let groupIdx = 0; groupIdx < parts.length - 1; groupIdx += 2) { const change = parts[groupIdx + 1]; changes.push(change); } return changes; } function parseHeader(html) { // Remove changelogs from other versions (if there are additional h2 headings, remove // those and all below them) html = html.trim(); // If there is an h2 with the word "since" in it (otherwise the first h2), treat that // one as the one describing this version and remove everything above and all headers // below it. const h2s = html.match(/<h2>\s*(.+?)\s*<\/h2>/gs); if (!h2s?.length) { return { title: null, body: html }; } const thisVersionH2 = h2s.find((h2) => h2.match(/\bsince\b/i)) || h2s[0]; let [, after] = html.split(thisVersionH2); const firstH2Index = after.indexOf('<h2>'); if (firstH2Index > 0) { after = after.slice(0, firstH2Index); } assert(!after.includes('<h2>'), `Somehow still has an h2 after parsing`); const [, title] = thisVersionH2.match(/^<h2>\s*(?<title>.+?)\s*<\/h2>/ms); return { title, body: after }; } //# sourceMappingURL=notes.js.map