tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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:
Mext/demo-site/README.md | 10++++++++--
Mext/demo-site/dist/app.js | 20++++++++++++++------
Mext/demo-site/dist/packages.js | 39+++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/app.ts | 16+++++++++++-----
Mext/demo-site/src/packages.ts | 33+++++++++++++++++++++++++++++++++
Mext/demo-site/test/browser.mjs | 32+++++++++++++++++++++++++++++---
Mext/demo-site/test/packages.test.mjs | 16+++++++++++++++-
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']); +});