From 40a8d1bf168fb0e7fa7a7637d081d0579533fc79 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Sat, 18 Jul 2026 18:43:23 +0200 Subject: [PATCH] feat: version ranges --- README.md | 2 +- deno.jsonc | 1 + tasks/generate-redirects.ts | 116 +++++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60b393a6..80d2f149 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ deno run deploy:ipfs - [x] Generate rules for symlinks - [x] Index page listing all versions -- [ ] Support version ranges (eg. "4.0.x" for latest patch release) +- [x] Support version ranges (eg. "4.0.x" for latest patch release) diff --git a/deno.jsonc b/deno.jsonc index 5b7d9e3f..69cf3a10 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -8,6 +8,7 @@ "@std/dotenv": "jsr:@std/dotenv@^0.225.7", "@std/fs": "jsr:@std/fs@^1.0.24", "@std/path": "jsr:@std/path@^1.1.5", + "@std/semver": "jsr:@std/semver@^1.0.8", "blockstore-core": "npm:blockstore-core@^7.0.1", "ipfs-unixfs-importer": "npm:ipfs-unixfs-importer@^17.0.1", "lume/": "https://cdn.jsdelivr.net/gh/lumeland/lume@3.1.4/", diff --git a/tasks/generate-redirects.ts b/tasks/generate-redirects.ts index 96a951bc..2ba6f8d4 100644 --- a/tasks/generate-redirects.ts +++ b/tasks/generate-redirects.ts @@ -1,6 +1,12 @@ -import { parseArgs } from "@std/cli/parse-args"; +import { parseArgs } from "@std/cli"; import * as Fs from "@std/fs"; import * as Path from "@std/path"; +import { + canParse as canParseSemver, + greaterThan, + parse as parseSemver, +} from "@std/semver"; +import type { SemVer } from "@std/semver"; import artifactsJson from "../artifacts/artifacts.json" with { type: "json" }; import versionsJson from "../artifacts/versions.json" with { type: "json" }; @@ -119,6 +125,114 @@ let rules: Rule[] = Object.values(versions).flatMap((artifact) => { return list; }); +// VERSION RANGES (eg. "4.0.x" -> latest patch release) +// +// Generates redirects from semver range patterns to the latest matching +// stable version. For example, if versions 4.0.0 and 4.0.1 exist, visiting +// /4.0.x/ redirects to /4.0.1/. Only stable (non-prerelease) versions are +// considered as range targets; nightly builds like "4.x-nightly" are +// reachable via their literal version path but are not the target of a range. + +// Coerce version strings that may contain `x` wildcards (e.g. "4.x-nightly") +// into a parseable semver (e.g. "4.99.0-nightly"). Mirrors the coercion in +// diffuse/src/common/pages/version-upgrade.js so the build and the client +// agree on what counts as a prerelease. +function tryCoerceVersion(version: string): string | null { + if (canParseSemver(version)) return version; + const coerced = version.replace(/[xX]/g, "99"); + if (canParseSemver(coerced)) return coerced; + // Fill in missing patch segment (e.g. "4.99-nightly" -> "4.99.0-nightly") + const withPatch = coerced.replace( + /^(\d+\.\d+)([+-]|$)/, + "$1.0$2", + ); + if (canParseSemver(withPatch)) return withPatch; + return null; +} + +interface StableVersion { + // Original version string from versions.json (used in redirect targets) + version: string; + // Parsed semver for comparison + semver: SemVer; +} + +const stableVersions: StableVersion[] = Object.values(versions) + .map((artifact) => { + const coerced = tryCoerceVersion(artifact.version); + if (!coerced) return null; + const parsed = parseSemver(coerced); + if (parsed.prerelease?.length) return null; + return { version: artifact.version, semver: parsed } satisfies StableVersion; + }) + .filter((v): v is StableVersion => v !== null); + +// Latest stable version per major.minor (eg. 4.0.x -> 4.0.1) +const latestByMinor = new Map(); +for (const v of stableVersions) { + const key = `${v.semver.major}.${v.semver.minor}`; + const existing = latestByMinor.get(key); + if (!existing || greaterThan(v.semver, existing.semver)) { + latestByMinor.set(key, v); + } +} + +// Latest stable version per major (eg. 4.x -> 4.1.0) +const latestByMajor = new Map(); +for (const v of stableVersions) { + const key = `${v.semver.major}`; + const existing = latestByMajor.get(key); + if (!existing || greaterThan(v.semver, existing.semver)) { + latestByMajor.set(key, v); + } +} + +const rangeRules: Rule[] = []; + +for (const v of latestByMinor.values()) { + const pattern = `${v.semver.major}.${v.semver.minor}.x`; + rangeRules.push( + { + from: `/${pattern}/`, + to: `/${v.version}/`, + status: 302, + }, + { + from: `/${pattern}/hash/`, + to: `/${v.version}/hash/`, + status: 302, + }, + { + from: `/${pattern}/*`, + to: `/${v.version}/:splat`, + status: 302, + }, + ); +} + +for (const v of latestByMajor.values()) { + const pattern = `${v.semver.major}.x`; + rangeRules.push( + { + from: `/${pattern}/`, + to: `/${v.version}/`, + status: 302, + }, + { + from: `/${pattern}/hash/`, + to: `/${v.version}/hash/`, + status: 302, + }, + { + from: `/${pattern}/*`, + to: `/${v.version}/:splat`, + status: 302, + }, + ); +} + +rules = [...rules, ...rangeRules]; + // ARTIFACTS WITHOUT VERSION const withoutVersion = { ...artifacts }; -- 2.51.2