import type { ReleaseFile } from "../bucket/repo.ts"; import type { Release } from "./releases.ts"; // The machine-readable shapes of §22, written by hand rather than with JSON.stringify: clients in // the wild were built against Kepr 2's bytes, and Kepr 2 escaped `/`, `<` and `>` (its // `jsonEscapeStr`). URLs are assembled from validated repo names, versions and platforms, and the // percent-encoded filename, which need no escaping, so their slashes stay bare, as Kepr 2 wrote them. const ESCAPES: Record = { '"': '\\"', "\\": "\\\\", "/": "\\/", "<": "\\u003c", ">": "\\u003e", "\n": "\\n", "\r": "\\r", "\t": "\\t", }; // Kepr 2 escaped bytes, so of the control characters only ASCII's (below 0x20, and 0x7f) change. function jsonEscape(text: string): string { return text.replace(/["\\/<>\p{Cc}]/gu, (ch) => { const code = ch.charCodeAt(0); if (code >= 0x80) return ch; return ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, "0")}`; }); } function str(text: string): string { return `"${jsonEscape(text)}"`; } function optional(text: string | null): string { return text === null ? "null" : str(text); } function provenance(version: string, file: ReleaseFile): string { return `{"version":${str(version)},"platform":${str(file.platform)},"checksum":${str(`blake3:${file.blake3}`)},"source":"koh-offer"}`; } // §22.4: notes are omitted entirely when empty, never sent as null or "". function head(release: Release): string { const notes = release.notes === "" ? "" : `,"notes":${str(release.notes)}`; return `{"version":${str(release.version)}${notes}`; } /** * A file's name in its download link: the stored name, unless another platform's file in the * release has it too, as when every platform builds `zig-out/bin/nina` (§30, 2026-10-05). */ export function downloadName(repo: string, release: Release, file: ReleaseFile): string { const shared = release.files.some((other) => other !== file && other.name === file.name); return shared ? `${repo}-${file.platform}` : file.name; } /** §22.4's release JSON shape, field for field and in Kepr 2's order. */ export function releaseJson(domain: string, repo: string, release: Release): string { const platforms = release.files.map( (file) => `${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${release.version}/${encodeURIComponent(downloadName(repo, release, file))}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`, ); return `${head(release)},"platforms":{${platforms.join(",")}}}`; } /** `[{"version","notes"}]`, newest first; unlike the release shape, Kepr 2 always sent notes here. */ export function historyJson(releases: Release[]): string { const rows = releases.map((r) => `{"version":${str(r.version)},"notes":${str(r.notes)}}`); return `[${rows.join(",")}]`; } export type UpdateHead = { id: string; version: string; createdAt: number }; /** `//update-info` (§22.4). File URLs take Kepr 2's `-` form here. */ export function updateInfoJson( domain: string, repo: string, saveHead: UpdateHead | null, newest: Release | undefined, ): string { const headPart = saveHead ? `{"save_id":${str(saveHead.id)},"version":${str(saveHead.version)},"timestamp":${saveHead.createdAt}}` : "null"; const platforms = newest?.files.map( (file) => `${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${newest.version}/${repo}-${file.platform}","blake3":${str(file.blake3)}}`, ); const build = newest ? `{"version":${str(newest.version)},"platforms":{${platforms?.join(",")}}}` : "null"; return `{"head":${headPart},"latest_build":${build}}`; } export type RepoMeta = { repo: string; saveCount: number; lineCount: number; lastSaveAt: number; latestRelease: string | null; description: string | null; language: string | null; category: string | null; }; /** * `//meta` (§22.4). `latest_version` is the newest release, as is `latest_release`; Kepr 2's * `languages` and `categories` were single strings, so they stay strings here. */ export function metaJson(meta: RepoMeta): string { return `{"repo":${str(meta.repo)},"save_count":${meta.saveCount},"line_count":${meta.lineCount},"last_save_at":${meta.lastSaveAt},"latest_release":${optional(meta.latestRelease)},"latest_version":${optional(meta.latestRelease)},"description":${optional(meta.description)},"languages":${optional(meta.language)},"categories":${optional(meta.category)}}`; } /** Kepr 2's checksums.txt: a header, the SHA-256 lines, then the Blake3 lines (§22.1). */ export function checksumsTxt(repo: string, release: Release): string { const lines = (algorithm: "sha256" | "blake3") => release.files.map((file) => `${algorithm}:${file[algorithm]} ${repo}-${file.platform}\n`); return `# ${repo} ${release.version}\n# built by kepr\n\n${[...lines("sha256"), ...lines("blake3")].join("")}`; }