commit b99605e3168245139e3e63f1ec5878c0e8ecff26
parent cf86986b87cc51a5eac459f46e06ec14200f11c7
Author: James Eversole <james@eversole.co>
Date: Thu, 15 Oct 2026 06:30:58 -0500
Default demo site programs to newest package releases
Diffstat:
7 files changed, 149 insertions(+), 17 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md
@@ -176,8 +176,14 @@ build-only edge through IO or the compiler closure.
The package selector reads `packages.json`, a versioned static index of exact
name/version → native manifest + whole-bundle pins. A single **Program** selector
is shared by Playground and Export, with grouped **Examples**, **Published
-packages**, and a visible **Load by bundle hash…** option. Examples load their
-pinned bundle and suggested inputs. Demo names appear only under Examples, not
+packages**, and a visible **Load by bundle hash…** option. Versions are listed
+newest-first and opening an example or package selects its newest indexed release,
+independently of publication order. Conventional `MAJOR.MINOR.PATCH` versions use
+SemVer precedence (including prereleases); other accepted labels sort below them
+in numeric-aware label order. Examples use curated source and suggested inputs
+only when the selected release matches their catalogue pin; otherwise they load
+as compiled packages without borrowing source or inputs from another release.
+Shared bundle-hash links remain exact pins. Demo names appear only under Examples, not
again under Published packages. Examples with multiple releases show the version
picker and **Load package**; changing versions invalidates prior results, and
older releases load their exact bundle without borrowing current editable source.
diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js
@@ -6,7 +6,7 @@ import { readSettings } from './limits-ui.js';
import { ActiveClock } from './clock.js';
import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout } from './execution.js';
import { sourceText } from './compiler.js';
-import { packageIndex } from './packages.js';
+import { packageIndex, packageReleases } from './packages.js';
import { bundleHash, fetchBytes } from './bundle.js';
import { Graph } from './graph.js';
import { line, literals, outputType } from './codecs.js';
@@ -325,6 +325,14 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) {
} };
send({ type: 'load', ...selection, ...settings });
}
+function selectExample(entry) {
+ const name = examplePackage(entry);
+ const latest = name ? packageReleases(releases, name)[0] : undefined;
+ if (!latest || (latest.demo === entry.id && latest.hash === entry.hash))
+ select(entry, latest);
+ else
+ loadBundle(latest);
+}
function select(entry, release) {
editingSource = false;
sourceAvailable = false;
@@ -498,15 +506,15 @@ function chooseProgram(value, version) {
if (name && versionPackage !== name) {
versionPackage = name;
versionPicker.replaceChildren();
- for (const release of releases.filter(r => r.name === name)) {
+ for (const release of packageReleases(releases, name)) {
const option = document.createElement('option');
option.value = release.version;
option.textContent = release.version;
versionPicker.append(option);
}
}
- if (version !== undefined)
- versionPicker.value = version;
+ if (name)
+ versionPicker.value = version ?? packageReleases(releases, name)[0].version;
}
function populatePrograms() {
programPicker.replaceChildren();
@@ -546,7 +554,7 @@ programPicker.onchange = () => {
if (value.startsWith('demo:')) {
const entry = entries.find(e => e.id === value.slice(5));
if (entry)
- select(entry);
+ selectExample(entry);
}
else {
chooseProgram(value);
@@ -908,7 +916,7 @@ async function initializePackages() {
const initial = entries.find(e => e.id === 'intro') ?? entries[0];
if (!initial)
throw new Error('Empty catalogue');
- select(initial);
+ selectExample(initial);
}
}
catch (error) {
diff --git a/ext/demo-site/dist/packages.js b/ext/demo-site/dist/packages.js
@@ -23,6 +23,45 @@ export function packageIndex(value) {
...(r['demo'] === undefined ? {} : { demo: r['demo'] }) };
});
}
+// SemVer precedence for conventional releases; other accepted labels use a
+// deterministic numeric-aware ordering below conventional releases. Never infer
+// recency from index order.
+function compareLabel(a, b) {
+ const left = a.match(/[0-9]+|[^0-9]+/gu) ?? [], right = b.match(/[0-9]+|[^0-9]+/gu) ?? [];
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
+ const x = left[i], y = right[i];
+ const order = /^\d+$/u.test(x) && /^\d+$/u.test(y)
+ ? (BigInt(x) < BigInt(y) ? -1 : BigInt(x) > BigInt(y) ? 1 : 0)
+ : (x < y ? -1 : x > y ? 1 : 0);
+ if (order)
+ return order;
+ }
+ return left.length - right.length;
+}
+function compareVersion(a, b) {
+ const semver = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
+ const x = a.match(semver), y = b.match(semver);
+ if (!x || !y)
+ return x ? 1 : y ? -1 : compareLabel(a, b);
+ for (let i = 1; i <= 3; i++) {
+ const order = compareLabel(x[i], y[i]);
+ if (order)
+ return order;
+ }
+ if (x[4] === undefined || y[4] === undefined)
+ return x[4] === y[4] ? 0 : x[4] === undefined ? 1 : -1;
+ const left = x[4].split('.'), right = y[4].split('.');
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
+ const a = left[i], b = right[i], an = /^\d+$/u.test(a), bn = /^\d+$/u.test(b);
+ const order = an && bn ? compareLabel(a, b) : an !== bn ? (an ? -1 : 1) : (a < b ? -1 : a > b ? 1 : 0);
+ if (order)
+ return order;
+ }
+ return left.length - right.length;
+}
+export function packageReleases(releases, name) {
+ return releases.filter(r => r.name === name).sort((a, b) => compareVersion(b.version, a.version) || (a.version < b.version ? 1 : a.version > b.version ? -1 : 0));
+}
export function environmentProfile(value, name) {
const input = record(value), profiles = record(input['profiles']);
if (input['schema'] !== 1 || !Object.hasOwn(profiles, name))
diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts
@@ -6,7 +6,7 @@ import { readSettings } from './limits-ui.js';
import { ActiveClock } from './clock.js';
import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout, type Operation } from './execution.js';
import { sourceText } from './compiler.js';
-import { packageIndex, type Release } from './packages.js';
+import { packageIndex, packageReleases, type Release } from './packages.js';
import type { CompilerMessage } from './compiler-protocol.js';
import { bundleHash, fetchBytes, type BundleHash } from './bundle.js';
import { Graph } from './graph.js';
@@ -206,6 +206,12 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa
current.onerror = event => { if (worker === current) { event.preventDefault(); if (!currentVerified) loaded = false; finish(event.message || 'Worker failed.', true); } };
send({ type: 'load', ...selection, ...settings });
}
+function selectExample(entry: CatalogueEntry): void {
+ const name = examplePackage(entry);
+ const latest = name ? packageReleases(releases, name)[0] : undefined;
+ if (!latest || (latest.demo === entry.id && latest.hash === entry.hash)) select(entry, latest);
+ else loadBundle(latest);
+}
function select(entry: CatalogueEntry, release?: Release): void {
editingSource = false; sourceAvailable = false;
draft = false; scratch = false; originalSource = undefined;
@@ -306,11 +312,11 @@ function chooseProgram(value: string, version?: string): void {
if (name && versionPackage !== name) {
versionPackage = name;
versionPicker.replaceChildren();
- for (const release of releases.filter(r => r.name === name)) {
+ for (const release of packageReleases(releases, name)) {
const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option);
}
}
- if (version !== undefined) versionPicker.value = version;
+ if (name) versionPicker.value = version ?? packageReleases(releases, name)[0]!.version;
}
function populatePrograms(): void {
programPicker.replaceChildren();
@@ -333,7 +339,7 @@ function showRelease(release?: Release): void {
programPicker.onchange = () => {
const value = programPicker.value;
if (value.startsWith('demo:')) {
- const entry = entries.find(e => e.id === value.slice(5)); if (entry) select(entry);
+ const entry = entries.find(e => e.id === value.slice(5)); if (entry) selectExample(entry);
} else { chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram(); if (value === 'hash') hashInput.focus(); }
};
versionPicker.onchange = invalidateProgram;
@@ -513,7 +519,7 @@ async function initializePackages(): Promise<void> {
releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown);
populatePrograms();
if (!packagePane() || selection || draft) { operation = undefined; return; }
- if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); select(initial); }
+ if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); }
} catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); }
}
window.addEventListener('pane-change', () => {
diff --git a/ext/demo-site/src/packages.ts b/ext/demo-site/src/packages.ts
@@ -20,6 +20,39 @@ export function packageIndex(value: unknown): Release[] {
...(r['demo'] === undefined ? {} : { demo: r['demo'] as string }) };
});
}
+// SemVer precedence for conventional releases; other accepted labels use a
+// deterministic numeric-aware ordering below conventional releases. Never infer
+// recency from index order.
+function compareLabel(a: string, b: string): number {
+ const left = a.match(/[0-9]+|[^0-9]+/gu) ?? [], right = b.match(/[0-9]+|[^0-9]+/gu) ?? [];
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
+ const x = left[i]!, y = right[i]!;
+ const order = /^\d+$/u.test(x) && /^\d+$/u.test(y)
+ ? (BigInt(x) < BigInt(y) ? -1 : BigInt(x) > BigInt(y) ? 1 : 0)
+ : (x < y ? -1 : x > y ? 1 : 0);
+ if (order) return order;
+ }
+ return left.length - right.length;
+}
+function compareVersion(a: string, b: string): number {
+ const semver = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
+ const x = a.match(semver), y = b.match(semver);
+ if (!x || !y) return x ? 1 : y ? -1 : compareLabel(a, b);
+ for (let i = 1; i <= 3; i++) {
+ const order = compareLabel(x[i]!, y[i]!); if (order) return order;
+ }
+ if (x[4] === undefined || y[4] === undefined) return x[4] === y[4] ? 0 : x[4] === undefined ? 1 : -1;
+ const left = x[4].split('.'), right = y[4].split('.');
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
+ const a = left[i]!, b = right[i]!, an = /^\d+$/u.test(a), bn = /^\d+$/u.test(b);
+ const order = an && bn ? compareLabel(a, b) : an !== bn ? (an ? -1 : 1) : (a < b ? -1 : a > b ? 1 : 0);
+ if (order) return order;
+ }
+ return left.length - right.length;
+}
+export function packageReleases(releases: readonly Release[], name: string): Release[] {
+ return releases.filter(r => r.name === name).sort((a, b) => compareVersion(b.version, a.version) || (a.version < b.version ? 1 : a.version > b.version ? -1 : 0));
+}
export function environmentProfile(value: unknown, name: string): { compiler: BundleHash; profile: EnvironmentProfile | null } {
const input = record(value), profiles = record(input['profiles']);
if (input['schema'] !== 1 || !Object.hasOwn(profiles, name)) throw new Error('Unknown demo environment');
diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs
@@ -48,13 +48,16 @@ const lazy=makeBundle(lazyNodes,[['f',f],['q',q]]);published.lazy=pin(lazy);regi
const invalid=Buffer.from(huge);invalid.writeUInt16BE(1,42);published.invalid=pin(invalid);registry.set(published.invalid,invalid);
const requests = [];
-let compilerFault;
+let compilerFault, indexOverride;
const compilerDescriptor=JSON.parse(await readFile(resolve(root,'compiler.json')));
registry.set(compilerDescriptor.hash,await dependencyBytes(compilerDescriptor.hash));
const server = createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); requests.push(pathname);
response.setHeader('Cache-Control','no-store');
+ if(indexOverride && pathname.endsWith('/packages.json')){
+ response.setHeader('Content-Type','application/json');response.end(JSON.stringify(indexOverride));return;
+ }
if(compilerFault==='descriptor' && pathname.endsWith('/compiler.json')){
response.setHeader('Content-Type','application/json');response.end(JSON.stringify({...compilerDescriptor,backend:'optimized'}));return;
}
@@ -178,7 +181,7 @@ try {
assert.equal(await evaluate("document.getElementById('program-release').hidden"),false,'single-release examples keep an active version toolbar');
await evaluate("document.getElementById('program-picker').value='demo:rgb';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready'));
assert.equal(await evaluate("document.getElementById('program-release').hidden"),false);
- assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),['0.1.0','0.1.1']);
+ assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),['0.1.1','0.1.0']);
assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.1');
await evaluate("document.getElementById('package-version').value='0.1.0';document.getElementById('package-version').dispatchEvent(new Event('change'))");
assert.equal(await evaluate("document.getElementById('run').disabled"),true);
@@ -199,11 +202,13 @@ try {
await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))");
assert.equal(await evaluate("document.getElementById('program-release').hidden"),false);
assert.equal(await evaluate("document.getElementById('program-hash').hidden"),true);
+ assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.1','published packages default to newest release');
assert.equal(await evaluate("document.getElementById('run').disabled"),true);
assert.match(await evaluate("document.getElementById('package-info').textContent"),/No bundle loaded/);
for(const name of [...new Set(packageIndex.releases.filter(r=>!r.demo).map(r=>r.name))]) {
await evaluate(`document.getElementById('program-picker').value=${JSON.stringify('package:'+name)};document.getElementById('program-picker').dispatchEvent(new Event('change'))`);
- assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),packageIndex.releases.filter(r=>r.name===name).map(r=>r.version),`versions belong to ${name}`);
+ assert.deepEqual(await evaluate("[...document.getElementById('package-version').options].map(o=>o.value)"),packageIndex.releases.filter(r=>r.name===name).map(r=>r.version).sort().reverse(),`versions belong to ${name}`);
+ assert.equal(await evaluate("document.getElementById('package-version').selectedIndex"),0,`newest version selected for ${name}`);
}
await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'))");
await click('tab-export-definition');
@@ -1058,6 +1063,27 @@ try {
assert.equal(requests.some(path=>path.startsWith(prefix+'bundles/')),false,'no static bundle fallback');
for(const entry of [...catalogue,compilerDescriptor])assert.ok(requests.includes('/_arboricx/bundle/hash/'+entry.hash),'dependency fetched from registry');
+ // Publish a newer example without changing its catalogue source pin. The
+ // default must follow the release, while hash links still select exact bytes.
+ const intro=catalogue.find(e=>e.id==='intro');
+ const future={...packageIndex.releases.find(r=>r.demo==='intro'),version:'0.10.0',hash:published.id};
+ indexOverride={...packageIndex,releases:[future,...packageIndex.releases]};
+ await evaluate("history.replaceState(null,'',location.pathname)");
+ await send('Page.reload');await openPlayground();
+ assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro');
+ assert.equal(await evaluate("document.getElementById('package-version').value"),'0.10.0');
+ assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(published.id));
+ assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'newer bundle cannot borrow catalogue source');
+ assert.equal(await evaluate("document.getElementById('arguments').value"),'[]','newer bundle cannot borrow catalogue inputs');
+ await evaluate("document.getElementById('program-picker').value='demo:greet';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready'));
+ await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready'));
+ assert.equal(await evaluate("document.getElementById('package-version').value"),'0.10.0','reselecting example also defaults to latest');
+ await evaluate(`history.replaceState(null,'',location.pathname+'#bundle=${intro.hash}&demo=intro')`);
+ await send('Page.reload');await openPlayground();
+ assert.equal(await evaluate("document.getElementById('package-version').value"),'0.1.0','shared hash does not upgrade to latest');
+ assert.equal(await evaluate("document.getElementById('source-editor').value"),intro.source);
+ indexOverride=undefined;
+ console.log('PASS newer demo release without matching catalogue / no borrowed source or inputs / exact older shared pin');
assert.deepEqual(exceptions, []);
if (process.argv[3]) {
const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
diff --git a/ext/demo-site/test/packages.test.mjs b/ext/demo-site/test/packages.test.mjs
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
-import {packageIndex,environmentProfile} from '../dist/packages.js';
+import {packageIndex,packageReleases,environmentProfile} from '../dist/packages.js';
import {decodeBundle,loadBundle} from '../dist/bundle.js';
import {Graph} from '../dist/graph.js';
import {executionSettings,executionBudget} from '../dist/execution.js';
@@ -142,3 +142,17 @@ test('new practical libraries expose complete interfaces and lexer reference sou
assert.ok(goldens.haskellCases.some(c=>c.record.line===2&&c.record.offset===3));
assert.ok(goldens.haskellCases.some(c=>c.record.remaining==='é'));
});
+
+test('release choices are newest-first, numeric, independent of publication order and non-mutating',()=>{
+ const versions=['0.1.0','0.10.0','0.2.0','1.0.0-rc.2','1.0.0-rc.10','1.0.0','1.0.0-alpha','1.0.0-1'];
+ const releases=versions.map(version=>({name:'demo',version,hash:'a'.repeat(64),manifest:'b'.repeat(64),title:version}));
+ const expected=['1.0.0','1.0.0-rc.10','1.0.0-rc.2','1.0.0-alpha','1.0.0-1','0.10.0','0.2.0','0.1.0'];
+ for(const input of [releases,[...releases].reverse()]){
+ const snapshot=[...input];
+ assert.deepEqual(packageReleases([...input,{...input[0],name:'other',version:'99.0.0'}],'demo').map(r=>r.version),expected);
+ assert.deepEqual(input,snapshot);
+ }
+ assert.deepEqual(packageReleases(releases,'missing'),[]);
+ const labels=['build2','build10','0.1.0'].map(version=>({...releases[0],version}));
+ assert.deepEqual(packageReleases(labels,'demo').map(r=>r.version),['0.1.0','build10','build2']);
+});