signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
211 lines • 8.77 kB
JavaScript
;
/*
* Copyright 2026 Signal K contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchReleasesFeed = fetchReleasesFeed;
exports.parseReleasesFeed = parseReleasesFeed;
exports.renderReleasesAsChangelog = renderReleasesAsChangelog;
exports.parseGithubSlug = parseGithubSlug;
exports.fetchReleasesMarkdown = fetchReleasesMarkdown;
const debug_1 = require("../debug");
// unified, rehype-parse, rehype-remark, remark-gfm and remark-stringify
// are ESM-only packages. tsc emits CommonJS, so static `import` lines
// would compile to `require()` calls that throw ERR_REQUIRE_ESM on
// Node versions before 22.12 (where require-of-ESM is opt-in). Lazy
// dynamic `import()` works on every supported Node — see
// loadHtmlProcessor below.
const debug = (0, debug_1.createDebug)('signalk-server:appstore:github-releases');
const FETCH_TIMEOUT_MS = 15_000;
// Minimal atom-feed extraction tuned to the shape github.com/<owner>/<repo>/releases.atom produces.
// Each <entry> has <title>v1.2.3</title>, <updated>2026-…</updated>,
// <link rel="alternate" href=".../releases/tag/v1.2.3"/>, and <content type="html"><h2>…</content>.
const ENTRY_RE = /<entry\b[\s\S]*?<\/entry>/g;
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/;
const UPDATED_RE = /<updated[^>]*>([\s\S]*?)<\/updated>/;
// Extract <link> tags first, then read attributes by name. The XML spec
// doesn't guarantee attribute order — matching `rel="..." href="..."`
// in one regex would miss feeds that emit href before rel.
const LINK_TAG_RE = /<link\b([^>]*)\/?>/g;
const ATTR_RE = (name) => new RegExp(`\\b${name}=["']([^"']+)["']`);
const CONTENT_RE = /<content[^>]*>([\s\S]*?)<\/content>/;
function extractAlternateLink(entryXml) {
let m;
LINK_TAG_RE.lastIndex = 0;
while ((m = LINK_TAG_RE.exec(entryXml))) {
const attrs = m[1];
const rel = ATTR_RE('rel').exec(attrs)?.[1];
if (rel === undefined || rel === 'alternate') {
const href = ATTR_RE('href').exec(attrs)?.[1];
if (href)
return href;
}
}
return undefined;
}
let htmlProcessorPromise;
async function loadHtmlProcessor() {
if (htmlProcessorPromise)
return htmlProcessorPromise;
htmlProcessorPromise = (async () => {
const { unified } = await import('unified');
const { default: rehypeParse } = await import('rehype-parse');
const { default: rehypeRemark } = await import('rehype-remark');
const { default: remarkGfm } = await import('remark-gfm');
const { default: remarkStringify } = await import('remark-stringify');
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify, {
bullet: '-',
fences: true,
emphasis: '_',
strong: '*'
});
})();
return htmlProcessorPromise;
}
// String.fromCodePoint throws RangeError for values outside [0, 0x10FFFF],
// which would otherwise abort htmlToMarkdown on a malformed entity (e.g.
// � or �) and degrade the Changelog tab to raw HTML.
function safeFromCodePoint(n) {
if (!Number.isFinite(n) || n < 0 || n > 0x10ffff)
return '';
return String.fromCodePoint(n);
}
function decodeEntities(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&#(\d+);/g, (_, n) => safeFromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => safeFromCodePoint(parseInt(n, 16)));
}
async function htmlToMarkdown(html) {
try {
const processor = await loadHtmlProcessor();
const file = await processor.process(html);
return String(file).trim();
}
catch (err) {
debug.enabled && debug('htmlToMarkdown failed: %O', err);
return html;
}
}
function parseEntry(entryXml) {
const title = TITLE_RE.exec(entryXml)?.[1];
const updated = UPDATED_RE.exec(entryXml)?.[1];
const href = extractAlternateLink(entryXml);
const content = CONTENT_RE.exec(entryXml)?.[1];
return {
tag: title ? decodeEntities(title).trim() : undefined,
url: href,
date: updated,
contentHtml: content ? decodeEntities(content) : undefined
};
}
async function fetchReleasesFeed(owner, repo) {
const url = `https://github.com/${owner}/${repo}/releases.atom`;
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: 'application/atom+xml' }
});
if (!res.ok) {
debug.enabled && debug('GET %s returned %d', url, res.status);
return undefined;
}
return await res.text();
}
catch (err) {
debug.enabled && debug('GET %s failed: %O', url, err);
return undefined;
}
}
async function parseReleasesFeed(xml) {
const matches = xml.match(ENTRY_RE) || [];
// Convert each entry's HTML body to Markdown in parallel — the unified
// pipeline is CPU-light per entry but feeds with 30+ releases were
// serializing into a noticeable wait on slow boxes.
const entries = await Promise.all(matches.map(async (raw) => {
const { tag, url, date, contentHtml } = parseEntry(raw);
if (!tag || !url)
return undefined;
const body = contentHtml ? await htmlToMarkdown(contentHtml) : '';
return { tag, url, date, bodyMarkdown: body };
}));
return entries.filter((e) => e !== undefined);
}
// github.com/<owner>/<repo>/releases.atom emits a synthetic entry for every
// git tag, not just published Releases. A tag with no Release gets an
// auto-generated body of exactly "Release <tag>" (or none). Those carry no
// notes, so treat them as "not a real release" and let the caller fall back
// to a published CHANGELOG.md.
function isPublishedRelease(entry) {
const body = entry.bodyMarkdown.trim();
return body !== '' && body !== `Release ${entry.tag}`;
}
function renderReleasesAsChangelog(entries) {
if (entries.length === 0)
return '';
const lines = [];
for (const e of entries) {
const datePart = e.date ? ` — ${e.date.substring(0, 10)}` : '';
lines.push(`## [${e.tag}](${e.url})${datePart}`);
lines.push('');
if (e.bodyMarkdown.trim()) {
lines.push(e.bodyMarkdown.trim());
lines.push('');
}
}
return lines.join('\n').trim();
}
// Anchored on a real GitHub URL prefix (https/ssh/git protocol or
// `git@`) so a hostile or typo'd URL like `https://notgithub.com/...`
// doesn't get parsed as a github slug — without the anchor any string
// ending in `github.com/<owner>/<repo>` would match.
// Repo segment may contain dots (e.g. github.com/org/my.plugin); the
// trailing ".git" suffix is stripped explicitly. Single source of
// truth — raw-metrics.ts and detail.ts both consume parseGithubSlug.
const GITHUB_SLUG_RE = /^(?:(?:git\+)?https?:\/\/(?:www\.)?|ssh:\/\/git@|git@)?github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[?#/].*)?$/i;
function parseGithubSlug(url) {
if (!url)
return undefined;
const m = GITHUB_SLUG_RE.exec(url);
if (!m)
return undefined;
return { owner: m[1], repo: m[2] };
}
/**
* Fetch and render a changelog from GitHub Releases for the given repo.
* Returns Markdown when at least one real release was found and parsed,
* otherwise undefined.
*
* Reads the public releases.atom feed rather than the /releases REST API on
* purpose: the feed is served off github.com and does not draw down the 60/hr
* unauthenticated REST budget. That budget is shared per source IP, so new
* users and boats on Starlink (which recycles IPs aggressively) could exhaust
* it before the App Store renders once. The cost is that the feed can't
* distinguish a published Release from a bare tag, so isPublishedRelease
* filters the placeholders out below.
*/
async function fetchReleasesMarkdown(owner, repo) {
const xml = await fetchReleasesFeed(owner, repo);
if (!xml)
return undefined;
const entries = (await parseReleasesFeed(xml)).filter(isPublishedRelease);
if (entries.length === 0)
return undefined;
return renderReleasesAsChangelog(entries);
}
//# sourceMappingURL=github-releases.js.map