commit 77da59915a8039e77273284ae1f6e6701d8dfbc4 parent 3dfbafc869fefcb48b5a3d1738c6e756e87aba9a Author: James Eversole <james@eversole.co> Date: Mon, 19 Oct 2026 13:08:05 -0500 Add a native-checked library picker and static agent references Browse published and local interfaces, inspect exports and examples, select versions and namespaces, and insert undoable manifest imports. Validate candidates in isolated native compiler Workers with cancellation and scope-preserving header inspection. Generate llms.txt, complete library references, source references and general tested examples from published interfaces. Keep references versioned and lazy-loaded; add browser, keyboard, layout and release-integrity coverage. Validated both backends in Chromium, Firefox and Linux WebKit, 120 unit tests, generated-JS parity and nix flake check. Diffstat:
37 files changed, 1091 insertions(+), 17 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -129,6 +129,53 @@ WASM assets despite immutable headers; they are a different cache experiment. The Nix wrapper supplies the matching driver, WebKit and patched libraries. Run from the repository root. +## Finding libraries and writing with assistants + +**Add import…** in the source actions opens a modal library browser. Search matches +library names, descriptions and public exports; the first view selects the newest +published version of each library. Details contain an export filter, available +calling conventions and tested examples, a historical-version selector, namespace +field and full import preview. Validated local files appear in the same browser. + +A cancellable, separate Worker uses the selected native backend to inspect the +header and compile the candidate source. Namespace conflicts and compiler +failures appear before insertion. Existing imports offer a jump action; unfinished +source can use Copy import. Insertion prepends a qualified directive while +preserving all original source, supplies one native textarea undo transaction, +and follows normal idle compilation. The dialog uses native focus containment, +Escape cancellation and focus restoration. Each candidate check has a 60-second +cutoff. A revision guard rejects stale replies; insertion rechecks source and +backend selection. Inspecting headers preserves the session's compiled scope. + +The footer's **For AI assistants** link and HTML `rel="help"` metadata point to +`llms.txt`. This emerging discovery convention supplements ordinary HTTP links. +The entrypoint links to a playground guide, library directory, language/protocol +contracts, raw source references and general worked examples. Each example gives +complete source, expression, interpretation, execution mode and expected output. +The references are static text and also work for readers using a browser. + +`reference.py` derives every published library interface from the bundle export +table, including reexports, and checks its bundle SHA-256. It generates the +picker's `reference/libraries.json` and per-release Markdown from the same records. +Calling conventions in `reference-input/libraries.json` are version-scoped; +`reference-input/examples.json` pins explicit module versions and independent +expected results. The browser matrix compiles and runs these examples on both +backends. Source pages are labelled repository references with source hashes; +the published export table describes the selected release's actual interface. + +References join the immutable asset tree. The root `llms.txt` links to that build's +versioned pages and should revalidate alongside HTML. Serve `.md` as +`text/markdown` or `text/plain`; raw source copies use `.tri.txt` for ordinary +plain-text delivery. A release check validates interface coverage, exact export +names, relative documentation links and the quickstart's tested example. + +Run the picker/reference matrix against a built site using +`TEST_IMPORT_PICKER=1 TEST_BACKEND=haskell-wasm` or `zig-wasm` with the Chromium, +Firefox or Linux WebKit harness. It covers exact insertion and undo, native +header/scope behavior, export search, historical versions, namespace conflicts, +malformed-source copy fallback, cancellation and local-file selection. The +WebKit runner also checks modal keyboard focus, Escape and narrow layouts. + ## Programs and controls All curated and arbitrary packages take the same verified bundle/heap execution diff --git a/ext/demo-site/asset-release.py b/ext/demo-site/asset-release.py @@ -8,13 +8,14 @@ from pathlib import Path def freeze(root): root = Path(root) - names = ['dist', 'runtime', 'images', 'style.css', 'catalogue.json', 'compiler.json', + names = ['dist', 'runtime', 'images', 'reference', 'style.css', 'catalogue.json', 'compiler.json', 'packages.json', 'environments.json'] files = sorted(p for name in names for p in ((root / name).rglob('*') if (root / name).is_dir() else [root / name]) if p.is_file()) rows = [{'path': p.relative_to(root).as_posix(), 'size': p.stat().st_size, 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in files] + rows.sort(key=lambda row: row['path']) encoded = json.dumps(rows, sort_keys=True, separators=(',', ':')).encode() version = hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() prefix = 'assets/' + version @@ -25,6 +26,9 @@ def freeze(root): raise ValueError('Expected one HTML entrypoint: ' + old) html = html.replace('"' + old + '"', '"./' + prefix + '/' + name + '"') html = html.replace('https://arbori.cx/images/preview.png', 'https://arbori.cx/' + prefix + '/images/preview.png') + llms = root / 'llms.txt' + if llms.exists(): + llms.write_text(llms.read_text().replace('./reference/', './' + prefix + '/reference/')) destination = root / prefix destination.mkdir(parents=True, exist_ok=False) for name in names: diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,3 +1,4 @@ +import { importPicker } from './import-picker.js'; import { localModulesUI } from './local-modules-ui.js'; import { outputReveal } from './output-reveal.js'; import { syntaxEditor, tokenize, paintSyntax } from './syntax.js'; @@ -121,6 +122,9 @@ const localModules = localModulesUI(() => { } queueCompile(); }); +const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, + available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + local: () => localModules.entries(), inputs: () => localModules.inputs() }); const exportModuleButton = el('export-compiled-module'); exportModuleButton.onclick = () => { if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) @@ -165,6 +169,8 @@ function controls(working) { sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); + el('add-import').hidden = !editableSource; + el('add-import').disabled = busy; el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; el('source-availability').textContent = sourceAvailable @@ -573,7 +579,11 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => { revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; +stopButton.onclick = () => { + imports.cancel(); + revealOutput.cancel(); + finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); +}; el('stop-run').onclick = () => stopButton.click(); el('reset').onclick = () => { if (busy) return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; diff --git a/ext/demo-site/dist/haskell-session.js b/ext/demo-site/dist/haskell-session.js @@ -48,6 +48,9 @@ export class HaskellSession { this.names = reply.scopeNames ?? names; return names; } + async inspectModule(source) { + return this.json({ type: 'module-header', source }); + } async exportModule() { const value = await this.json({ type: 'module-snapshot' }); return moduleSnapshot(value.names, value.snapshot); diff --git a/ext/demo-site/dist/import-picker.js b/ext/demo-site/dist/import-picker.js @@ -0,0 +1,194 @@ +import { fetchBytes } from './bundle.js'; +import { libraryIndex, latestLibraries, matchesLibrary } from './library-index.js'; +import { packageReleases } from './packages.js'; +export function importPicker(options) { + const get = (id) => document.getElementById(id); + const dialog = get('import-dialog'), open = get('add-import'); + const search = get('import-search'), results = get('import-results'), detail = get('import-detail'); + const alias = get('import-alias'), versions = get('import-version'); + const status = get('import-status'), preview = get('import-preview'), insert = get('insert-import'); + let libraries, selected, plan; + let worker, timer, debounce; + let revision = 0, snapshot = '', backend = ''; + const stop = () => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); }; + const finish = () => { stop(); dialog.close(); open.focus(); }; + const message = (text) => { status.textContent = text; }; + const statement = () => selected ? `!import ${selected.manifest} as ${alias.value}` : ''; + function check() { + stop(); + plan = undefined; + insert.disabled = true; + insert.textContent = 'Insert import'; + preview.textContent = statement(); + if (!selected) + return; + if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { + message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); + return; + } + message('Checking with the native compiler…'); + const current = revision; + debounce = setTimeout(() => { + if (!dialog.open || current !== revision) + return; + const url = new URL('./wasm-worker.js', import.meta.url); + url.searchParams.set('backend', backend); + const next = new Worker(url, { type: 'module' }); + worker = next; + const fail = (text) => { if (current !== revision) + return; stop(); message(`${text} You can copy the import and finish editing the source.`); }; + timer = setTimeout(() => fail('Import check timed out.'), 60_000); + next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); }; + next.onmessage = ({ data }) => { + if (current !== revision || !dialog.open) + return; + if (data.type === 'error') { + fail(data.message ?? 'Import check failed.'); + return; + } + if (data.type !== 'import-checked' || !data.plan) + return; + plan = data.plan; + stop(); + insert.disabled = false; + insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; + message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); + }; + next.postMessage({ type: 'check-import', source: snapshot, manifest: selected.manifest, alias: alias.value, localModules: options.inputs() }); + }, 250); + } + function show(choice, focus = true) { + selected = choice; + get('import-actions').hidden = false; + results.hidden = true; + get('import-browse').hidden = true; + detail.hidden = false; + get('import-title').textContent = choice.title; + get('import-description').textContent = choice.description; + get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { + if (i % 2 === 0) + return document.createTextNode(text); + const code = document.createElement('code'); + code.textContent = text; + return code; + })); + get('import-usage-details').hidden = !choice.library?.usage; + get('import-example').textContent = choice.library?.example ?? ''; + get('import-example-details').hidden = !choice.library?.example; + get('import-usage-details').open = false; + get('import-example-details').open = false; + alias.value = choice.alias; + versions.replaceChildren(); + versions.hidden = !choice.library; + get('import-version-label').hidden = !choice.library; + if (choice.library) + for (const release of packageReleases(libraries, choice.library.name)) { + const option = new Option(release.version, release.version); + option.selected = release.version === choice.library.version; + versions.add(option); + } + const reference = get('import-reference'); + reference.hidden = !choice.library; + if (choice.library) + reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href; + get('import-export-search').value = ''; + renderExports(); + check(); + if (focus) { + get('import-content').scrollTop = 0; + get('import-title').focus({ preventScroll: true }); + } + } + function renderExports() { + const query = get('import-export-search').value.toLowerCase(); + get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.'; + } + function browse() { + stop(); + selected = undefined; + plan = undefined; + detail.hidden = true; + get('import-actions').hidden = true; + get('import-content').scrollTop = 0; + get('import-browse').hidden = false; + results.hidden = false; + message(''); + results.replaceChildren(); + const choices = [ + ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), + ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), + ].filter(row => matchesLibrary(row, search.value)); + for (const choice of choices) { + const button = document.createElement('button'); + button.type = 'button'; + button.dataset['manifest'] = choice.manifest; + const title = document.createElement('strong'); + title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : ''); + const description = document.createElement('span'); + description.textContent = choice.description; + const label = document.createElement('small'); + const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5); + label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`; + button.append(title, description, label); + button.onclick = () => show(choice); + results.append(button); + } + if (!choices.length) + results.textContent = 'No matching libraries. Try an exported name or a broader search.'; + } + open.onclick = () => { + if (!options.available()) + return; + snapshot = options.source.value; + backend = options.backend(); + search.value = ''; + dialog.showModal(); + browse(); + search.focus(); + if (!libraries) { + message('Loading library references…'); + void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => { + libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))); + if (dialog.open && !selected) + browse(); + }).catch(error => { if (dialog.open) + message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); }); + } + }; + search.oninput = browse; + alias.oninput = check; + get('import-export-search').oninput = renderExports; + versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) + show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); }; + get('import-back').onclick = () => { browse(); search.focus(); }; + get('import-cancel').onclick = finish; + dialog.addEventListener('cancel', () => stop()); + dialog.addEventListener('close', () => { if (!dialog.open) + stop(); }); + get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; + insert.onclick = () => { + if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { + stop(); + insert.disabled = true; + message('The editor changed. Close and reopen the picker to use the current source.'); + return; + } + const checked = plan; + finish(); + options.source.focus(); + if (checked.existing) { + options.source.setSelectionRange(checked.existing.start, checked.existing.end); + return; + } + // Native textarea editing supplies one undo transaction on supported browsers. + options.source.setSelectionRange(0, 0); + if (!document.execCommand('insertText', false, checked.statement)) { + message('Your browser could not insert an undoable edit. Copy the import from the picker.'); + dialog.showModal(); + return; + } + options.source.setSelectionRange(0, checked.statement.length); + }; + return { cancel: () => { if (dialog.open) + finish(); } }; +} diff --git a/ext/demo-site/dist/import-plan.js b/ext/demo-site/dist/import-plan.js @@ -0,0 +1,30 @@ +import { sourceText } from './compiler.js'; +// Header meaning comes from the native compiler. Insert at the beginning, which +// preserves every original character and keeps the directive in the header. +export function importPlan(source, stage, manifest, alias) { + if (!/^[a-f0-9]{64}$/u.test(manifest)) + throw new Error('Invalid module manifest'); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(alias) || alias.length > 128) + throw new Error('Use a namespace starting with a letter or underscore, followed by letters, digits or underscores.'); + const statement = `!import ${manifest} as ${alias}\n`; + const existing = stage.imports.find(([ref]) => ref === manifest); + if (existing) { + // Locate a display selection in the compiler-masked header; this has no role + // in parsing or compiling source. Fall back to selecting the whole source. + let offset = 0, range = { start: 0, end: source.length }; + const masked = stage.body.split('\n'); + for (const [i, line] of source.split('\n').entries()) { + if (line !== masked[i] && line.trimStart().startsWith('!import ') && line.includes(manifest)) { + range = { start: offset, end: offset + line.length }; + break; + } + offset += line.length + 1; + } + return { source, statement, existing: { alias: existing[1], ...range } }; + } + if (stage.imports.some(([, name]) => name === alias)) + throw new Error(`Namespace ${alias} is already used by another import.`); + const next = statement + source; + sourceText(next); + return { source: next, statement }; +} diff --git a/ext/demo-site/dist/library-index.js b/ext/demo-site/dist/library-index.js @@ -0,0 +1,24 @@ +import { packageIndex, packageReleases } from './packages.js'; +export function libraryIndex(input) { + const releases = packageIndex(input), rows = input.releases; + return releases.map((release, i) => { + const row = rows[i]; + if (typeof row['description'] !== 'string' || row['description'].length > 2048 || + typeof row['alias'] !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(row['alias']) || + typeof row['reference'] !== 'string' || !/^libraries\/[A-Za-z0-9._-]+\.md$/u.test(row['reference']) || + (row['usage'] !== undefined && (typeof row['usage'] !== 'string' || row['usage'].length > 8192)) || + (row['example'] !== undefined && (typeof row['example'] !== 'string' || row['example'].length > 32768)) || + !Array.isArray(row['names']) || row['names'].length > 4096 || row['names'].some(n => typeof n !== 'string' || n.length > 512)) + throw new Error('Invalid library reference'); + return { ...release, description: row['description'], names: row['names'], alias: row['alias'], reference: row['reference'], + ...(typeof row['usage'] === 'string' ? { usage: row['usage'] } : {}), ...(typeof row['example'] === 'string' ? { example: row['example'] } : {}) }; + }); +} +export function latestLibraries(rows) { + return [...new Set(rows.map(r => r.name))].map(name => packageReleases(rows, name)[0]); +} +export function matchesLibrary(row, query) { + const words = query.toLowerCase().trim().split(/\s+/u); + const text = `${row.title} ${row.description} ${row.names.join(' ')}`.toLowerCase(); + return words.every(word => text.includes(word)); +} diff --git a/ext/demo-site/dist/local-modules-ui.js b/ext/demo-site/dist/local-modules-ui.js @@ -91,6 +91,6 @@ export function localModulesUI(changed) { })(); }; render(); - return { inputs: () => [...modules.values()].map(({ module }) => ({ manifest: module.manifest, bytes: module.bytes })), disable: value => { busy = value; input.disabled = busy || checking; for (const button of list.querySelectorAll('[data-remove-module]')) + return { entries: () => [...modules.values()].map(({ label, module }) => ({ label, manifest: module.manifest, names: [...module.names] })), inputs: () => [...modules.values()].map(({ module }) => ({ manifest: module.manifest, bytes: module.bytes })), disable: value => { busy = value; input.disabled = busy || checking; for (const button of list.querySelectorAll('[data-remove-module]')) button.disabled = busy || checking; } }; } diff --git a/ext/demo-site/dist/wasm-worker.js b/ext/demo-site/dist/wasm-worker.js @@ -1,3 +1,4 @@ +import { importPlan } from './import-plan.js'; import { localModuleInputs, validateLocalModule } from './local-modules.js'; import { resumeValue } from './io.js'; import { createRuntimeSession } from './runtime-session.js'; @@ -38,7 +39,20 @@ scope.onmessage = ({ data }) => { return; busy = true; void (async () => { - if (m['type'] === 'load') { + if (m['type'] === 'check-import') { + if (session) + throw new Error('Already initialized'); + const source = sourceText(m['source']); + if (typeof m['manifest'] !== 'string' || typeof m['alias'] !== 'string') + throw new Error('Invalid import request'); + session = await createRuntimeSession(selectedBackend, true); + const stage = await session.inspectModule(source); + const plan = importPlan(source, stage, m['manifest'], m['alias']); + if (!plan.existing) + await session.compile(plan.source, 'empty', localModuleInputs(m['localModules'])); + send({ type: 'import-checked', plan }); + } + else if (m['type'] === 'load') { if (session || stored) throw new Error('Already initialized'); const localId = m['localId']; diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -18,6 +18,7 @@ <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta property="og:image:alt" content="Arboricx: Portable programs. All trees. Leaf, Stem and Fork diagrams on a warm cream background."> + <link rel="help" type="text/plain" href="./llms.txt" title="Documentation for AI assistants"> <title>Arboricx: tricu and portable tree programs</title> <link rel="stylesheet" href="./style.css"><script type="module" src="./dist/app.js"></script> </head> @@ -51,7 +52,7 @@ <textarea id="source-editor" rows="18" maxlength="32768" spellcheck="false" autocapitalize="off" autocorrect="off" hidden></textarea> <pre id="source-notes"><code id="source">Loading Start Here…</code></pre> <p id="compile-diagnostic" class="error" role="status" hidden></p><p id="compile-activity" class="hint" role="status" hidden></p> - <div class="source-actions"><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Evaluation is always explicit.</span></div> + <div class="source-actions"><button id="add-import" disabled hidden>Add import…</button><button id="restore-source" disabled hidden>Restore published source</button><span class="hint">Edits compile after a pause. Evaluation is always explicit.</span></div> </section> <section class="expression-dock" aria-label="Evaluation"> <div class="output-toolbar" role="group" aria-label="Evaluation settings"> @@ -113,7 +114,26 @@ <article><h3>IO as an interaction tree.</h3><p>A tricu program describes actions and their continuations as trees. The host responds to each request and execution proceeds with that response. The conversation example puts text output, line input and composition into practice.</p><p><a href="https://git.eversole.co/James/tricu/file/lib/io.tri.html">Explore the IO library</a></p></article> <article><h3>Familiar syntax.</h3><p>tricu brings names, lambdas, recursion, pattern matching and familiar literals to Tree Calculus. Write <code>id = (x : x)</code>, compose functions and organize modules. The compiler turns that source into executable trees.</p><p><a href="https://git.eversole.co/James/tricu/file/docs/language.md.html">Read the source language guide</a></p></article> </div></section> -</main><noscript><p class="wrap">Enable JavaScript to use the editor.</p></noscript> - <footer class="wrap" data-inspector-background><p>Copyright 2026 <a href="https://eversole.co">James Eversole</a> · <a href="mailto:james@eversole.co">Email feedback</a></p></footer> +</main> + <dialog id="import-dialog" aria-labelledby="import-dialog-title"> + <div class="import-heading"><h2 id="import-dialog-title">Add a library</h2><button id="import-cancel">Cancel</button></div> + <div id="import-content" class="import-content"> + <div id="import-browse"><label for="import-search">Search libraries or exported names</label><input id="import-search" type="search" autocomplete="off"></div> + <div id="import-results" aria-label="Libraries"></div> + <section id="import-detail" hidden> + <button id="import-back">← Back to libraries</button><h3 id="import-title" tabindex="-1"></h3><p id="import-description"></p> + <label id="import-version-label" for="import-version">Version</label><select id="import-version"></select> + <label for="import-alias">Import as</label><input id="import-alias" maxlength="128" spellcheck="false" autocomplete="off"> + <pre id="import-preview" tabindex="0"></pre> + <p><a id="import-reference" target="_blank" rel="noopener">Calling conventions and source reference ↗</a></p> + <label for="import-export-search">Filter exports</label><input id="import-export-search" type="search" autocomplete="off"><pre id="import-exports" tabindex="0"></pre> + <details id="import-usage-details"><summary>Calling conventions</summary><p id="import-usage"></p></details> + <details id="import-example-details"><summary>Complete example</summary><pre id="import-example"></pre></details> + </section> + </div> + <div id="import-actions" class="import-actions" hidden><button id="copy-import">Copy import</button><button id="insert-import" class="primary" disabled>Insert import</button></div> + <p id="import-status" role="status" tabindex="0"></p> + </dialog><noscript><p class="wrap">Enable JavaScript to use the editor.</p></noscript> + <footer class="wrap" data-inspector-background><p>Copyright 2026 <a href="https://eversole.co">James Eversole</a> · <a href="mailto:james@eversole.co">Email feedback</a> · <a href="./llms.txt">For AI assistants</a></p></footer> </body> </html> diff --git a/ext/demo-site/reference-input/examples.json b/ext/demo-site/reference-input/examples.json @@ -0,0 +1,52 @@ +[ + { + "id": "identity", + "title": "Return a value", + "source": "!export answer\n\nidentity x = x\nanswer = identity 42\n", + "expression": "answer", + "output": "number", + "mode": "pure", + "expected": "42", + "terminal": "" + }, + { + "id": "arithmetic", + "title": "Define and apply a function", + "source": "!import {{native-math}} as N\n!export answer\n\ndouble n = N.multiply n 2\nanswer = double 21\n", + "expression": "answer", + "output": "number", + "mode": "pure", + "expected": "42", + "terminal": "" + }, + { + "id": "lists", + "title": "Transform and format a list", + "source": "!import {{base}} as B\n!import {{conversions}} as C\n!export answer\n\nanswer = B.intercalate \", \" (B.map C.showNumber [2 4 8])\n", + "expression": "answer", + "output": "string", + "mode": "pure", + "expected": "2, 4, 8", + "terminal": "" + }, + { + "id": "patterns", + "title": "Inspect a value with ordered equations", + "source": "!export answer\n\nfirstOr fallback [] = fallback\nfirstOr fallback [head | tail] = head\nfirstOr fallback _ = fallback\nanswer = firstOr 99 [7 9]\n", + "expression": "answer", + "output": "number", + "mode": "pure", + "expected": "7", + "terminal": "" + }, + { + "id": "text-io", + "title": "Sequence text output and return a value", + "source": "!import {{io}} as IO\n!export main\n\nmain = IO.io (do IO.bind\n IO.putStr \"Hello\\n\"\n IO.pure \"done\")\n", + "expression": "main", + "output": "string", + "mode": "io", + "expected": "done", + "terminal": "Hello\n" + } +] diff --git a/ext/demo-site/reference-input/getting-started.md b/ext/demo-site/reference-input/getting-started.md @@ -0,0 +1,117 @@ +# Write and run tricu in the Arboricx playground + +tricu is the source language. Arboricx bundles carry its compiled, named tree +values between runtimes. The playground at [arbori.cx](https://arbori.cx/) lets +you edit modules and evaluate expressions using Haskell or Zig WebAssembly. + +## A complete first program + +Choose **Start Here** in the Program selector. Replace its Source with: + +```tricu +!export answer + +identity x = x +answer = identity 42 +``` + +After a pause, the status becomes **Compiled**. In Expression enter `answer`, +choose **Evaluated result**, **Natural number**, and **Pure value**, then press +**Evaluate**. The result is `42`. Source edits compile automatically; Evaluate +runs the expression. **Stop** discards ongoing work; edit again or use Compile +in Settings to prepare the current draft. Restore published source restores the +selected demo. You can also choose the module scratch example in Settings. + +Definitions belong in Source. Expression uses the current definitions and +imports. `!export answer` selects the public bundle interface; private definitions +are also usable in the editor's expression scope. Every native module needs at +least one export. `!export *` exposes its local definitions. + +## Find and import libraries + +Use **Add import…** below Source. Search by library purpose or exported name, +select a library, inspect its exports/reference, choose a namespace, then insert. +The picker checks the candidate with the native compiler and inserts one undoable +edit. Existing imports have a jump action; ambiguous or unfinished source can use +**Copy import**. Cancel closes the check. Installed local files appear alongside +published libraries. Historical releases are available in the version selector. + +For text-only assistance, use the [library directory](libraries.md). Each release +page includes its **full manifest-hash import statement**, complete exported +names, available calling conventions, and source references. The machine-readable +[library index](libraries.json) contains the same release identities and names. +Choose a published version explicitly. Use its manifest as the import identity; +its separate bundle SHA-256 identifies the downloadable file. + +The playground resolves published and page-local manifests. The CLI additionally +resolves workspace source names such as `!import prelude *`; translate those names +to published manifest imports when adapting CLI examples for the playground. +Qualified imports make calls explicit: `N.add 20 22`. Imports belong in the module +header, before definitions. Imported names are available to expressions, while +reexports select the public interface. See [modules](modules.md). + +## Write source with the available syntax + +- Application is curried: `f x y` means `(f x) y`. Lambda: `(x : x)`. +- Names are ASCII identifiers; strings support Unicode scalar values. +- Numbers are natural numbers. Strings are lists of characters. Lists use spaces: + `[1 2 3]`; group applications as `[(f 1) (f 2)]`. +- A pair value is `t left right`; `(left, right)` is a pattern. +- Patterns include `[]`, `[head | tail]`, `_`, numeric/string literals and pins + such as `^B.true`. Pattern equations need a final irrefutable fallback. +- Functions can recurse directly; local self-recursion uses `let rec`. +- `let name = value in body`, `where`, `case` and `do operator` are supported. +- Use library functions for arithmetic, comparisons and Boolean elimination. + Function names and argument orders come from the selected library reference. +- Comments are `-- line` or `|- block -|`. Use spaces for layout. Group multiline + applications in parentheses. + +Read the [language feature guide](language-features.md) for practical conventions +and the [language contract](language.md) for exact syntax. In particular, +`B.lazyBool thenThunk elseThunk condition` takes branches **before** its condition; +`B.matchResult onError onSuccess result` passes both value/diagnostic and remainder +to a handler. Library protocols matter as much as function names. + +## Choose an output interpretation + +| Value | Interpretation | Execution | +| --- | --- | --- | +| Natural number | Natural number | Pure value | +| Character list | Text | Pure value | +| Boolean | Boolean | Pure value | +| General list, pair, function or other tree | tricu, △ source, DAG or Ternary | Pure value | +| Wrapped IO action | Interpretation of its final returned value | Text / line IO | + +Text expects a character list. A list of strings should be joined using a library +helper before displaying as Text. General lists can be inspected as trees. Stored +definition displays the selected compiled value; Evaluated result runs Expression. + +The [tested examples](examples.md) include complete source, expression, output +settings and expected results. Browser IO supports text output, line input, pure +values, sequencing and abort. Libraries also describe host operations such as +files and sockets; those require an appropriately configured native host. + +## Share programs and handle diagnostics + +**Export compiled module** in Settings downloads the current public interface as +an `.arboricx` file. Settings → Local modules validates an imported file and makes +its manifest available to source imports. Local files stay in page memory through +Stop and backend changes, and are cleared on reload. Exported bundles include the +executable trees reachable from the public interface. + +A compilation diagnostic belongs to the current source. Fix it before evaluating. +Check spelling, exported names, manifest identities, argument order, required +fallback patterns and indentation. A successful compilation still allows a +computation to diverge; Stop remains available. The editor currently accepts +16,384 source scalars, 16 local definitions and up to 4,096 imported/public names. +Large computations may exceed browser memory. + +## Guidance for text-only assistants + +Start with this guide and the library directory. Fetch the release pages needed +for the task, and consult source/protocol references for unfamiliar exports. +Provide complete source, the expression to enter, output interpretation and +execution mode. Derive expected results explicitly and distinguish a proposed +program from one you have actually run. Published interfaces and these static +references are readable through ordinary HTTP; playground execution is optional +for looking up names, imports and conventions. diff --git a/ext/demo-site/reference-input/libraries.json b/ext/demo-site/reference-input/libraries.json @@ -0,0 +1,16 @@ +{ + "base": {"description":"Lists and strings, Booleans, checked natural arithmetic, Maybe/Result protocols and contracts.","alias":"B","usage":"`add a b`, `sub a b` (saturating), `mul a b`, `div a b`, `mod a b`, `divMod a b`, `pow a b` use naturals. Division requires a nonzero divisor. `lt? a b`, `lte? a b`, `equal? a b` return Booleans (equal? compares trees). `map f xs`, `filter predicate xs`, `foldl f initial xs`, `foldr f initial xs`: both folds pass accumulator first, element second. `append xs ys` concatenates lists, including strings. `intercalate separator strings` joins between fields; `joinSuffix separator strings` adds a separator after every field. `take n xs`, `drop n xs`, `reverse xs`, `length xs`, `sum xs` handle lists. `lazyBool thenThunk elseThunk condition` calls the selected thunk with t. `matchBool onTrue onFalse condition` chooses a value. `matchResult onError onSuccess result` calls either handler with two arguments: diagnostic/value and remainder. Contract failures produce Results; successful checked arithmetic produces an ordinary natural."}, + "native-math": {"description":"Compact natural arithmetic, ordering and ASCII classification.","alias":"N","usage":"`add a b`, `subtract a b` (saturating), `multiply a b`, `quotient a b`, `remainder a b`, `power a b`; `divMod a b` returns `t quotient remainder`. `equal a b`, `lt a b`, `lte a b`, `gt a b`, `gte a b`, `zero n`, `even n`, `odd n` return Booleans. Inputs must be canonical naturals. Raw `divMod a 0` returns `t 0 a`; use base's checked division at external boundaries. `compare a b` returns t for equal, t t for less, t t t for greater. `successor n`, `predecessor n`, `minimum a b`, `maximum a b` are natural operations. See source for character-classification bounds."}, + "conversions": {"description":"Decimal text formatting for natural numbers.","alias":"C","usage":"`showNumber n` converts a canonical natural to an ASCII decimal character list, including \"0\". Display it as Text. Its input contract reports malformed values as Results."}, + "prelude": {"description":"Combined public interfaces of base, bytes and conversions.","alias":"P","usage":"Reexports base, bytes and conversions. Use their calling conventions with this module's qualifier, for example `P.showNumber 42` or `P.map f xs`. Reexported executable trees are included in the bundle."}, + "bytes": {"description":"Byte-oriented string helpers and codecs.","alias":"Bytes"}, + "io": {"description":"Host-interpreted interaction trees, sequencing and effect helpers.","alias":"IO","usage":"Wrap a final action with `IO.io action`. `IO.pure value` returns a value; `IO.bind action (value : nextAction)` sequences; `IO.putStr text` writes a character list; `IO.getLine` reads one line. `IO.abortIO diagnostic` aborts. Select Text / line IO for these wrapped actions. The browser supports these core actions; file, socket, concurrency and other host actions need the native host and its permissions. `do IO.bind` is explicit sequencing syntax; finish with an action such as `IO.pure value`. See the host ABI and IO source for Result-aware helper conventions."}, + "patterns": {"description":"Predicate-based matching and conditional dispatch.","alias":"Patterns","usage":"`match value rows` uses exact two-element `[predicate action]` rows. `cond rows` uses `guard predicateThunk actionThunk` pairs; thunks receive t. Check source for row shape and fallthrough behavior."}, + "parser": {"description":"Parser combinators with positioned diagnostics, commitment and rollback.","alias":"Parser"}, + "lexer": {"description":"Positioned character scans and first-match lexical dispatch.","alias":"Lexer"}, + "graph": {"description":"Dependency ordering and cycle reporting for caller-validated graphs.","alias":"Graph"}, + "http": {"description":"HTTP request/response parsing, formatting and Result protocols.","alias":"HTTP"}, + "socket": {"description":"Native-host socket action constructors.","alias":"Socket"}, + "binary": {"description":"Binary encodings and decoding helpers.","alias":"Binary"}, + "intensional": {"description":"Contracts and structural inspection of executable trees.","alias":"Intensional"} +} diff --git a/ext/demo-site/reference.py b/ext/demo-site/reference.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Build static agent references and picker metadata from published interfaces.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import sys + + +def generate(root, output): + site = root / 'ext/demo-site' + sys.path.insert(0, str(root / 'test')) + from bundle_dag import read + reference = output / 'reference' + reference.mkdir(parents=True, exist_ok=True) + def write(name, text): + path = reference / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + write('LICENSE.txt', (root / 'LICENSE').read_text()) + notes = json.loads((site / 'reference-input/libraries.json').read_text()) + releases = json.loads((site / 'packages.json').read_text())['releases'] + rows = [] + # Keep all releases. Browser-side SemVer ordering chooses the initial view. + for release in releases: + if not release['name'].startswith('tricu-lib-'): + continue + short = release['name'].removeprefix('tricu-lib-') + bundle = site / 'bundles' / (release['hash'] + '.arboricx') + assert hashlib.sha256(bundle.read_bytes()).hexdigest() == release['hash'] + names = list(read(bundle)[3]) + note = notes.get(short, {}) if release['version'] == '0.1.0' else {} + alias = note.get('alias', ''.join(word.capitalize() for word in short.split('-'))) + description = note.get('description', f'{short.replace("-", " ").capitalize()} library; inspect its complete interface and source reference below.') + filename = release['name'] + '-' + release['version'] + '.md' + rows.append(dict(release, title=short, description=description, alias=alias, + names=names, reference='libraries/' + filename, **({'usage': note['usage']} if 'usage' in note else {}))) + source = (root / release['title']).resolve() + assert source.is_relative_to(root / 'lib') and source.is_file() + relative = source.relative_to(root).as_posix() + # Source is a labelled checkout reference; export membership comes from + # the immutable published bundle, including inherited/reexported names. + write('source/' + relative + '.txt', source.read_text()) + write('libraries/' + filename, + f'# {short} · {release["version"]}\n\n{description}\n\n' + f'```tricu\n!import {release["manifest"]} as {alias}\n```\n\n' + f'Package: `{release["name"]}`\n\nManifest: `{release["manifest"]}`\n\n' + f'Bundle SHA-256: `{release["hash"]}`\n\n' + f'[Download bundle](https://arbori.cx/_arboricx/bundle/hash/{release["hash"]})\n\n' + '## Calling conventions\n\n' + note.get('usage', 'Consult the source and protocol guides for argument order, encoding and effects.') + '\n\n' + '[Complete examples](../examples.md) · [Language features](../language-features.md) · [Contracts](../contracts.md) · [Host ABI](../host-abi.md) · [Parser protocol](../parser.md)\n\n' + f'## Complete published interface ({len(names)} exports)\n\n```text\n' + '\n'.join(names) + '\n```\n\n' + f'## Source reference\n\n[Repository source](../source/{relative}.txt) from this site build. ' + f'Source SHA-256: `{hashlib.sha256(source.read_bytes()).hexdigest()}`. ' + 'The export list above describes the selected published bundle; repository source is provided for implementation context. ' + 'Workspace import names in source resolve through the [library directory](../libraries.md) when adapting code for the playground.\n') + write('libraries.md', '# Published tricu libraries\n\nChoose a release to get its complete exports, manifest import and calling conventions. ' + 'The playground picker initially shows the newest version of each library; all releases are listed here. ' + '[Start with the playground guide](getting-started.md).\n\n' + '\n'.join( + f'- [{r["title"]} {r["version"]}]({r["reference"]}) — {r["description"]}' for r in rows) + '\n') + # Publish readable source and language/protocol contracts at ordinary HTTP URLs. + for path in (root / 'lib').rglob('*.tri'): + write('source/' + path.relative_to(root).as_posix() + '.txt', path.read_text()) + for path in (root / 'docs').glob('*.md'): + text = path.read_text().replace('.md.html', '.md') + text = re.sub(r'\]\(\.\./lib/([^)]*?\.tri)(#[^)]*)?\)', lambda m: '](source/lib/' + m[1] + '.txt' + (m[2] or '') + ')', text) + text = re.sub(r'\]\(\.\./([^)]*)\)', r'](https://git.eversole.co/James/tricu/file/\1)', text) + write(path.name, text) + write('getting-started.md', (site / 'reference-input/getting-started.md').read_text()) + # Examples pin explicit release versions; adding a publication cannot silently + # change their program meaning or independent expected results. + pins = {r['name'].removeprefix('tricu-lib-'): r['manifest'] for r in rows if r['version'] == '0.1.0'} + examples = json.loads((site / 'reference-input/examples.json').read_text()) + for example in examples: + example['source'] = re.sub(r'\{\{([^}]+)\}\}', lambda m: pins[m[1]], example['source']) + for row in rows: + example = next((e for e in examples if row['manifest'] in e['source']), None) + if example: + row['example'] = example['source'] + '\nExpression: ' + example['expression'] + '\nInterpretation: ' + dict(number='Natural number', string='Text')[example['output']] + '\nExecution: ' + ('Pure value' if example['mode'] == 'pure' else 'Text / line IO') + '\nExpected result: ' + example['expected'] + write('libraries.json', json.dumps(dict(schema=1, releases=rows), indent=2) + '\n') + write('examples.json', json.dumps(examples, indent=2) + '\n') + write('examples.md', '# Playground examples\n\nReplace Source with a complete module below. Wait for Compiled, enter its expression, choose its settings and Evaluate.\n\n' + '\n'.join( + f'## {e["title"]}\n\n```tricu\n{e["source"]}```\n\nExpression: `{e["expression"]}`. ' + f'Output: **Evaluated result**. Interpretation: **{dict(number="Natural number", string="Text")[e["output"]]}**. ' + f'Execution: **{"Pure value" if e["mode"] == "pure" else "Text / line IO"}**.\n\n' + f'Expected result: `{e["expected"]}`.\n\n' + (f'Terminal output:\n\n```text\n{e["terminal"]}```\n\n' if e['terminal'] else '') for e in examples)) + (output / 'llms.txt').write_text('''# Arboricx and tricu + +> tricu is a programming language built on Tree Calculus. Arboricx carries compiled programs between hosts. This site is the language homepage and browser playground. + +## Write programs + +- [Playground guide](./reference/getting-started.md): source, expressions, imports, output settings, IO, diagnostics and sharing. +- [Library directory](./reference/libraries.md): complete published interfaces, versions, exact manifest imports and calling conventions. +- [Machine-readable library index](./reference/libraries.json): the same metadata used by Add import. +- [Language features](./reference/language-features.md): patterns, recursion, local bindings, sequencing and library protocols. +- [Language contract](./reference/language.md): source syntax and semantics. +- [Worked examples](./reference/examples.md): complete programs, settings and expected results. +- [Modules and packages](./reference/modules.md): source modules and portable bundle identities. + +## Protocols and boundaries + +- [Contracts](./reference/contracts.md): input validation and Result protocols. +- [Parser combinators](./reference/parser.md): replies, progress and rollback. +- [Host ABI](./reference/host-abi.md): IO actions and host capabilities. +- [Native CLI](./reference/build-and-run.md): run and package programs outside the browser. +- [License](./reference/LICENSE.txt): source and documentation licensing. + +Start with the playground guide, then fetch the relevant library release pages. Static references are available without executing WebAssembly. +''') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + generate(args.root.resolve(), args.output.resolve()) diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -49,6 +49,7 @@ let cp ${haskellRuntime}/{client.mjs,worker.mjs,bridge.mjs,pins.mjs,reactor.wasm} $out/runtime/haskell/ cp -r ${haskellRuntime}/{shim,data,site} $out/runtime/haskell/ chmod -R u+w $out + python3 ${./reference.py} --root ${../..} --output $out python3 ${./bundle-assets.py} $out ${./test/probe-api.mjs} python3 ${./asset-release.py} $out # Keep the dependency pack in this output's Nix closure, not its webroot. diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,3 +1,4 @@ +import { importPicker } from './import-picker.js'; import { localModulesUI } from './local-modules-ui.js'; import { outputReveal } from './output-reveal.js'; import { syntaxEditor, tokenize, paintSyntax } from './syntax.js'; @@ -105,6 +106,9 @@ const localModules = localModulesUI(() => { if (!busy) { dispose(); el('status').textContent = 'Changes pending'; } queueCompile(); }); +const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, + available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, + local: () => localModules.entries(), inputs: () => localModules.inputs() }); const exportModuleButton = el<HTMLButtonElement>('export-compiled-module'); exportModuleButton.onclick = () => { if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) return; @@ -140,6 +144,8 @@ function controls(working: boolean): void { compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); + el<HTMLButtonElement>('add-import').hidden = !editableSource; + el<HTMLButtonElement>('add-import').disabled = busy; el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; el('source-availability').hidden = editableSource || !loaded; el('source-availability').textContent = sourceAvailable @@ -356,7 +362,8 @@ inputForm.onsubmit = event => { catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => { revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; +stopButton.onclick = () => { + imports.cancel(); revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; el<HTMLButtonElement>('stop-run').onclick = () => stopButton.click(); el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -1,3 +1,4 @@ +import type { ImportPlan } from './import-plan.js'; import type { RuntimeStats } from './runtime-backends.js'; import type { OutputType } from './codecs.js'; import type { RequestId, WorkerMessage } from './protocol.js'; @@ -7,4 +8,5 @@ export type CompilerClient = { type: 'compile'; source: unknown; environment?: s | { type: 'run'; export: string; arguments: unknown; expression?: string; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'resume'; id: RequestId; value?: string }; export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }> + | { type: 'import-checked'; plan: ImportPlan } | { type: 'compiled'; names: string[]; stats: RuntimeStats; milliseconds: number }; diff --git a/ext/demo-site/src/haskell-session.ts b/ext/demo-site/src/haskell-session.ts @@ -1,3 +1,4 @@ +import type { ModuleStage } from './module-stage.js'; import { moduleSnapshot } from './module-bundle.js'; import type { LocalModuleInput } from './local-modules.js'; import { isTreeResult, treeResult } from './tree-result.js'; @@ -52,6 +53,9 @@ export class HaskellSession { return names; } + async inspectModule(source: string): Promise<ModuleStage> { + return this.json<ModuleStage>({ type: 'module-header', source }); + } async exportModule(): Promise<Uint8Array> { const value = await this.json<{ names: string[]; snapshot: GraphPreview }>({ type: 'module-snapshot' }); return moduleSnapshot(value.names, value.snapshot); diff --git a/ext/demo-site/src/import-picker.ts b/ext/demo-site/src/import-picker.ts @@ -0,0 +1,121 @@ +import { fetchBytes } from './bundle.js'; +import { libraryIndex, latestLibraries, matchesLibrary, type Library } from './library-index.js'; +import { packageReleases } from './packages.js'; +import type { ImportPlan } from './import-plan.js'; +import type { LocalModuleInput } from './local-modules.js'; +interface Choice { title: string; description: string; manifest: string; names: string[]; alias: string; library?: Library; } +interface Options { + source: HTMLTextAreaElement; + backend(): string; + available(): boolean; + local(): { label: string; manifest: string; names: string[] }[]; + inputs(): LocalModuleInput[]; +} +export function importPicker(options: Options): { cancel(): void } { + const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T; + const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import'); + const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail'); + const alias = get<HTMLInputElement>('import-alias'), versions = get<HTMLSelectElement>('import-version'); + const status = get('import-status'), preview = get('import-preview'), insert = get<HTMLButtonElement>('insert-import'); + let libraries: Library[] | undefined, selected: Choice | undefined, plan: ImportPlan | undefined; + let worker: Worker | undefined, timer: ReturnType<typeof setTimeout> | undefined, debounce: ReturnType<typeof setTimeout> | undefined; + let revision = 0, snapshot = '', backend = ''; + const stop = (): void => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); }; + const finish = (): void => { stop(); dialog.close(); open.focus(); }; + const message = (text: string): void => { status.textContent = text; }; + const statement = (): string => selected ? `!import ${selected.manifest} as ${alias.value}` : ''; + function check(): void { + stop(); plan = undefined; insert.disabled = true; insert.textContent = 'Insert import'; preview.textContent = statement(); + if (!selected) return; + if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); return; } + message('Checking with the native compiler…'); + const current = revision; + debounce = setTimeout(() => { + if (!dialog.open || current !== revision) return; + const url = new URL('./wasm-worker.js', import.meta.url); url.searchParams.set('backend', backend); + const next = new Worker(url, { type: 'module' }); worker = next; + const fail = (text: string): void => { if (current !== revision) return; stop(); message(`${text} You can copy the import and finish editing the source.`); }; + timer = setTimeout(() => fail('Import check timed out.'), 60_000); + next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); }; + next.onmessage = ({ data }: MessageEvent<{ type: string; plan?: ImportPlan; message?: string }>) => { + if (current !== revision || !dialog.open) return; + if (data.type === 'error') { fail(data.message ?? 'Import check failed.'); return; } + if (data.type !== 'import-checked' || !data.plan) return; + plan = data.plan; stop(); insert.disabled = false; + insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; + message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); + }; + next.postMessage({ type: 'check-import', source: snapshot, manifest: selected!.manifest, alias: alias.value, localModules: options.inputs() }); + }, 250); + } + function show(choice: Choice, focus = true): void { + selected = choice; get('import-actions').hidden = false; results.hidden = true; get('import-browse').hidden = true; detail.hidden = false; + get('import-title').textContent = choice.title; get('import-description').textContent = choice.description; + get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { + if (i % 2 === 0) return document.createTextNode(text); + const code = document.createElement('code'); code.textContent = text; return code; + })); get('import-usage-details').hidden = !choice.library?.usage; + get('import-example').textContent = choice.library?.example ?? ''; get('import-example-details').hidden = !choice.library?.example; + (get('import-usage-details') as HTMLDetailsElement).open = false; (get('import-example-details') as HTMLDetailsElement).open = false; + alias.value = choice.alias; versions.replaceChildren(); versions.hidden = !choice.library; + get('import-version-label').hidden = !choice.library; + if (choice.library) for (const release of packageReleases(libraries!, choice.library.name)) { + const option = new Option(release.version, release.version); option.selected = release.version === choice.library.version; versions.add(option); + } + const reference = get<HTMLAnchorElement>('import-reference'); reference.hidden = !choice.library; + if (choice.library) reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href; + get<HTMLInputElement>('import-export-search').value = ''; renderExports(); check(); + if (focus) { get('import-content').scrollTop = 0; get('import-title').focus({ preventScroll: true }); } + } + function renderExports(): void { + const query = get<HTMLInputElement>('import-export-search').value.toLowerCase(); + get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.'; + } + function browse(): void { + stop(); selected = undefined; plan = undefined; detail.hidden = true; get('import-actions').hidden = true; get('import-content').scrollTop = 0; get('import-browse').hidden = false; results.hidden = false; message(''); results.replaceChildren(); + const choices: Choice[] = [ + ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), + ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), + ].filter(row => matchesLibrary(row, search.value)); + for (const choice of choices) { + const button = document.createElement('button'); button.type = 'button'; button.dataset['manifest'] = choice.manifest; + const title = document.createElement('strong'); title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : ''); + const description = document.createElement('span'); description.textContent = choice.description; + const label = document.createElement('small'); + const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5); + label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`; + button.append(title, description, label); button.onclick = () => show(choice); results.append(button); + } + if (!choices.length) results.textContent = 'No matching libraries. Try an exported name or a broader search.'; + } + open.onclick = () => { + if (!options.available()) return; + snapshot = options.source.value; backend = options.backend(); search.value = ''; dialog.showModal(); browse(); search.focus(); + if (!libraries) { + message('Loading library references…'); + void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => { + libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown); + if (dialog.open && !selected) browse(); + }).catch(error => { if (dialog.open) message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); }); + } + }; + search.oninput = browse; alias.oninput = check; get<HTMLInputElement>('import-export-search').oninput = renderExports; + versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); }; + get('import-back').onclick = () => { browse(); search.focus(); }; + get('import-cancel').onclick = finish; + dialog.addEventListener('cancel', () => stop()); dialog.addEventListener('close', () => { if (!dialog.open) stop(); }); + get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; + insert.onclick = () => { + if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { stop(); insert.disabled = true; message('The editor changed. Close and reopen the picker to use the current source.'); return; } + const checked = plan; finish(); options.source.focus(); + if (checked.existing) { options.source.setSelectionRange(checked.existing.start, checked.existing.end); return; } + // Native textarea editing supplies one undo transaction on supported browsers. + options.source.setSelectionRange(0, 0); + if (!document.execCommand('insertText', false, checked.statement)) { + message('Your browser could not insert an undoable edit. Copy the import from the picker.'); + dialog.showModal(); return; + } + options.source.setSelectionRange(0, checked.statement.length); + }; + return { cancel: () => { if (dialog.open) finish(); } }; +} diff --git a/ext/demo-site/src/import-plan.ts b/ext/demo-site/src/import-plan.ts @@ -0,0 +1,26 @@ +import type { ModuleStage } from './module-stage.js'; +import { sourceText } from './compiler.js'; +export interface ImportPlan { source: string; statement: string; existing?: { alias: string; start: number; end: number }; } +// Header meaning comes from the native compiler. Insert at the beginning, which +// preserves every original character and keeps the directive in the header. +export function importPlan(source: string, stage: ModuleStage, manifest: string, alias: string): ImportPlan { + if (!/^[a-f0-9]{64}$/u.test(manifest)) throw new Error('Invalid module manifest'); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(alias) || alias.length > 128) throw new Error('Use a namespace starting with a letter or underscore, followed by letters, digits or underscores.'); + const statement = `!import ${manifest} as ${alias}\n`; + const existing = stage.imports.find(([ref]) => ref === manifest); + if (existing) { + // Locate a display selection in the compiler-masked header; this has no role + // in parsing or compiling source. Fall back to selecting the whole source. + let offset = 0, range = { start: 0, end: source.length }; + const masked = stage.body.split('\n'); + for (const [i, line] of source.split('\n').entries()) { + if (line !== masked[i] && line.trimStart().startsWith('!import ') && line.includes(manifest)) { range = { start: offset, end: offset + line.length }; break; } + offset += line.length + 1; + } + return { source, statement, existing: { alias: existing[1], ...range } }; + } + if (stage.imports.some(([, name]) => name === alias)) throw new Error(`Namespace ${alias} is already used by another import.`); + const next = statement + source; + sourceText(next); + return { source: next, statement }; +} diff --git a/ext/demo-site/src/library-index.ts b/ext/demo-site/src/library-index.ts @@ -0,0 +1,24 @@ +import { packageIndex, packageReleases, type Release } from './packages.js'; +export interface Library extends Release { description: string; names: string[]; alias: string; reference: string; usage?: string; example?: string; } +export function libraryIndex(input: unknown): Library[] { + const releases = packageIndex(input), rows = (input as { releases: Record<string, unknown>[] }).releases; + return releases.map((release, i) => { + const row = rows[i]!; + if (typeof row['description'] !== 'string' || row['description'].length > 2048 || + typeof row['alias'] !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(row['alias']) || + typeof row['reference'] !== 'string' || !/^libraries\/[A-Za-z0-9._-]+\.md$/u.test(row['reference']) || + (row['usage'] !== undefined && (typeof row['usage'] !== 'string' || row['usage'].length > 8192)) || + (row['example'] !== undefined && (typeof row['example'] !== 'string' || row['example'].length > 32768)) || + !Array.isArray(row['names']) || row['names'].length > 4096 || row['names'].some(n => typeof n !== 'string' || n.length > 512)) throw new Error('Invalid library reference'); + return { ...release, description: row['description'], names: row['names'] as string[], alias: row['alias'], reference: row['reference'], + ...(typeof row['usage'] === 'string' ? { usage: row['usage'] } : {}), ...(typeof row['example'] === 'string' ? { example: row['example'] } : {}) }; + }); +} +export function latestLibraries(rows: Library[]): Library[] { + return [...new Set(rows.map(r => r.name))].map(name => packageReleases(rows, name)[0] as Library); +} +export function matchesLibrary(row: { title: string; description: string; names: string[] }, query: string): boolean { + const words = query.toLowerCase().trim().split(/\s+/u); + const text = `${row.title} ${row.description} ${row.names.join(' ')}`.toLowerCase(); + return words.every(word => text.includes(word)); +} diff --git a/ext/demo-site/src/local-modules-ui.ts b/ext/demo-site/src/local-modules-ui.ts @@ -1,7 +1,7 @@ import { BUNDLE_LIMIT } from './bundle.js'; import { LOCAL_MODULE_BYTES, LOCAL_MODULE_COUNT, type LocalModule, type LocalModuleInput } from './local-modules.js'; // Hold local module files in page memory until removal or page departure. -export function localModulesUI(changed: () => void): { inputs(): LocalModuleInput[]; disable(busy: boolean): void } { +export function localModulesUI(changed: () => void): { inputs(): LocalModuleInput[]; entries(): { label: string; manifest: string; names: string[] }[]; disable(busy: boolean): void } { const input = document.getElementById('local-module-file') as HTMLInputElement; const list = document.getElementById('local-module-list')!; const status = document.getElementById('local-module-status')!; @@ -52,5 +52,5 @@ export function localModulesUI(changed: () => void): { inputs(): LocalModuleInpu })(); }; render(); - return { inputs: () => [...modules.values()].map(({ module }) => ({ manifest: module.manifest, bytes: module.bytes })), disable: value => { busy = value; input.disabled = busy || checking; for (const button of list.querySelectorAll<HTMLButtonElement>('[data-remove-module]')) button.disabled = busy || checking; } }; + return { entries: () => [...modules.values()].map(({ label, module }) => ({ label, manifest: module.manifest, names: [...module.names] })), inputs: () => [...modules.values()].map(({ module }) => ({ manifest: module.manifest, bytes: module.bytes })), disable: value => { busy = value; input.disabled = busy || checking; for (const button of list.querySelectorAll<HTMLButtonElement>('[data-remove-module]')) button.disabled = busy || checking; } }; } diff --git a/ext/demo-site/src/runtime-session.ts b/ext/demo-site/src/runtime-session.ts @@ -1,3 +1,4 @@ +import type { ModuleStage } from './module-stage.js'; import type { LocalModuleInput } from './local-modules.js'; import { HaskellSession } from './haskell-session.js'; import type { BundleHash } from './bundle.js'; @@ -11,6 +12,8 @@ export interface RuntimeSession { readonly disposed: boolean; load(bytes: Uint8Array, hash: BundleHash): Promise<void>; compile(source: string, profileName?: string, localModules?: LocalModuleInput[]): Promise<string[]>; + // Read-only native header inspection in an initialized compiler session. + inspectModule(source: string): Promise<ModuleStage>; exportModule(): Promise<Uint8Array>; run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; runExpression(source: string, output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; diff --git a/ext/demo-site/src/wasm-worker.ts b/ext/demo-site/src/wasm-worker.ts @@ -1,3 +1,4 @@ +import { importPlan } from './import-plan.js'; import { localModuleInputs, validateLocalModule } from './local-modules.js'; import { resumeValue } from './io.js'; import { createRuntimeSession, type RuntimeSession } from './runtime-session.js'; @@ -33,7 +34,16 @@ scope.onmessage = ({ data }) => { if (busy) return; busy = true; void (async () => { - if (m['type'] === 'load') { + if (m['type'] === 'check-import') { + if (session) throw new Error('Already initialized'); + const source = sourceText(m['source']); + if (typeof m['manifest'] !== 'string' || typeof m['alias'] !== 'string') throw new Error('Invalid import request'); + session = await createRuntimeSession(selectedBackend, true); + const stage = await session.inspectModule(source); + const plan = importPlan(source, stage, m['manifest'], m['alias']); + if (!plan.existing) await session.compile(plan.source, 'empty', localModuleInputs(m['localModules'])); + send({ type: 'import-checked', plan }); + } else if (m['type'] === 'load') { if (session || stored) throw new Error('Already initialized'); const localId = m['localId']; if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -31,3 +31,12 @@ #local-modules code, #local-modules p { overflow-wrap: anywhere; } #local-module-list { padding-left: 1.2rem; } #local-module-list li { margin-bottom: 1rem; } +/* The import browser is modal; the source remains the authoring surface. */ +#import-dialog{color:var(--ink);background:var(--paper,#f5f2ea);border:1px solid var(--line,#ced4c9);border-radius:12px;width:min(42rem,calc(100vw - 2rem));max-height:calc(100dvh - 2rem);padding:1.25rem;overflow:auto;box-sizing:border-box} +#import-dialog::backdrop{background:#132e25a8}.import-heading,.import-actions{display:flex;align-items:center;justify-content:space-between;gap:1rem}.import-heading h2{margin:0}.import-heading{margin-bottom:1rem} +#import-dialog input,#import-dialog select{box-sizing:border-box;max-width:100%;width:100%}#import-results{display:grid;gap:.6rem;margin-top:1rem;max-height:55dvh;overflow:auto}#import-results button{min-width:0;overflow-wrap:anywhere;text-align:left;display:grid;gap:.3rem;white-space:normal}#import-results span,#import-results small{font-weight:normal}#import-exports{max-height:12rem;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}#import-preview{white-space:pre-wrap;overflow-wrap:anywhere}#import-dialog h3{overflow-wrap:anywhere}#import-status{overflow-wrap:anywhere}.import-actions{justify-content:flex-end;flex-wrap:wrap} +@media(max-width:540px){#import-dialog{width:calc(100vw - 1rem);max-height:calc(100dvh - 1rem);padding:1rem}#import-results{max-height:50dvh}} + +#import-example,#import-usage{white-space:pre-wrap;overflow-wrap:anywhere}#import-dialog details{margin:.75rem 0} + +#import-dialog[open]{display:flex;flex-direction:column;overflow:hidden}.import-content{min-height:0;overflow:auto}.import-heading,.import-actions,#import-status{flex-shrink:0}#import-actions{padding-top:1rem}#import-status{max-height:8rem;overflow:auto;margin-bottom:0}#import-status:empty{display:none} diff --git a/ext/demo-site/test/asset-release.py b/ext/demo-site/test/asset-release.py @@ -17,7 +17,7 @@ with tempfile.TemporaryDirectory() as temporary: 'runtime/haskell/data/compiler.dag', 'runtime/zig/site-session.mjs', 'runtime/zig/arboricx.wasm', 'catalogue.json', 'packages.json', 'compiler.json', 'environments.json', 'style.css', 'images/icon.svg', - 'images/favicon.png', 'images/apple-touch-icon.png', 'images/preview.png'] + 'reference/libraries/example.md', 'reference/libraries.md', 'reference/getting-started.md', 'reference/libraries.json', 'images/favicon.png', 'images/apple-touch-icon.png', 'images/preview.png'] def build(number, changed=None, reverse=False): root = parent / str(number) root.mkdir() diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -53,7 +53,7 @@ try { return result.result.value; }; for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));} - const report=await evaluate(`import(${JSON.stringify(process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); assert.deepEqual(exceptions,[]); if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){ for(const width of [320,390,1440]){ diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -41,6 +41,7 @@ export async function startSiteServer(directory,autoRun=false) { if(path==='/syntax-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./syntax-probe.mjs',import.meta.url)));return;} if(path==='/local-module-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./local-module-probe.mjs',import.meta.url)));return;} if(path==='/module-goldens.json'){res.setHeader('Content-Type','application/json');res.end(await readFile(new URL('./module-goldens.json',import.meta.url)));return;} + if(path==='/import-picker-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./import-picker-probe.mjs',import.meta.url)));return;} if(path==='/module-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./module-probe.mjs',import.meta.url)));return;} if(path==='/ide-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./ide-probe.mjs',import.meta.url)));return;} if(path==='/live-ux-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./live-ux-probe.mjs',import.meta.url)));return;} @@ -50,6 +51,14 @@ export async function startSiteServer(directory,autoRun=false) { const descriptor=JSON.parse(await readFile(resolve(assetRoot,'compiler.json'))); res.setHeader('Content-Type','application/json');res.end(JSON.stringify({...descriptor,hash:'0'.repeat(64)}));return; } + // A second publication of the same immutable module exercises the history + // selector without changing the shipped release index or its identities. + if(process.env.TEST_IMPORT_PICKER&&path.endsWith('/reference/libraries.json')){ + const index=JSON.parse(await readFile(resolve(assetRoot,'reference/libraries.json'))); + const row=index.releases.find(r=>r.name==='tricu-lib-native-math'&&r.version==='0.1.0'); + index.releases.push({...row,version:'0.0.9'}); + res.setHeader('Content-Type','application/json');res.end(JSON.stringify(index));return; + } if(moduleManifest&&path.endsWith('/packages.json')){ const index=JSON.parse(await readFile(resolve(assetRoot,'packages.json'))); const other=index.releases.find(r=>r.name==='tricu-lib-packages-io'); @@ -65,12 +74,12 @@ export async function startSiteServer(directory,autoRun=false) { file=resolve(base,relative.startsWith('runtime/haskell/')?relative.slice('runtime/haskell/'.length):relative); if(!file.startsWith(base+sep))throw Error('path'); } - res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.css':'text/css','.svg':'image/svg+xml','.png':'image/png'})[extname(file)]??'application/octet-stream'); + res.setHeader('Content-Type',({'.txt':'text/plain; charset=utf-8','.md':'text/plain; charset=utf-8','.tri':'text/plain; charset=utf-8','.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.css':'text/css','.svg':'image/svg+xml','.png':'image/png'})[extname(file)]??'application/octet-stream'); let bytes=await readFile(file); if(moduleCorrupt&&path==='/_arboricx/bundle/hash/'+moduleCorrupt){bytes=Buffer.from(bytes);bytes[0]^=1;} if(process.env.TEST_COLD_LOAD&&path==='/nested/site/index.html')bytes=Buffer.from(bytes.toString().replace('<head>',`<head><script>new MutationObserver((_,observer)=>{if(document.getElementById('status')?.textContent==='Ready'){globalThis.__readyMs=performance.now();observer.disconnect();}}).observe(document.documentElement,{childList:true,subtree:true,characterData:true});</script>`)); if(autoRun&&path==='/nested/site/index.html'&&!new URL(req.url,'http://localhost').searchParams.has('noProbe'))bytes=Buffer.from(bytes.toString()+`<script type="module"> - try{const m=await import(${JSON.stringify(process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} + try{const m=await import(${JSON.stringify(process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} catch(e){await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:false,error:String(e)+'\\n'+(e.stack||'')})});} </script>`); if(process.env.TEST_ASSET_CACHE && ['/nested/site/index.html','/nested/site/assets.json'].includes(path))res.setHeader('Cache-Control','no-cache'); diff --git a/ext/demo-site/test/backend-webkit.mjs b/ext/demo-site/test/backend-webkit.mjs @@ -16,10 +16,26 @@ try { await page.goto(url); const report=await Promise.race([completion,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('WebKit probe timeout')),240000);})]); assert.deepEqual(errors,[]); + if(process.env.TEST_IMPORT_PICKER){ + await page.click('#add-import'); + await page.waitForFunction(()=>document.querySelector('#import-results button')); + for(let i=0;i<8;i++){await page.keyboard.press('Tab');assert(await page.evaluate(()=>document.querySelector('#import-dialog').contains(document.activeElement)),'Dialog contains keyboard focus');} + await page.keyboard.press('Escape'); + assert(await page.evaluate(()=>!document.querySelector('#import-dialog').open&&document.activeElement.id==='add-import'),'Escape restores focus'); + await page.click('#add-import'); + await page.getByRole('button',{name:/^native-math ·/}).click(); + } for(const width of [320,390,1280]){ await page.setViewportSize({width,height:844}); // Let ResizeObserver update the textarea's decorative mirror geometry. await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + if(process.env.TEST_IMPORT_PICKER)assert(await page.evaluate(()=>{ + const content=document.querySelector('#import-content').getBoundingClientRect(); + return ['import-cancel','insert-import','import-alias','import-preview'].every(id=>{ + const r=document.getElementById(id).getBoundingClientRect(); + return r.top>=0&&r.bottom<=innerHeight&&(id==='import-cancel'||id==='insert-import'||r.top>=content.top&&r.bottom<=content.bottom); + }); + }),'Import controls and preview visible at '+width); const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth ? [...document.querySelectorAll('body *')].filter(e=>{const r=e.getBoundingClientRect();return r.width&&r.right>innerWidth+1;}).map(e=>({tag:e.tagName,id:e.id,class:e.className,width:e.getBoundingClientRect().width,text:e.textContent.slice(0,100)})) : []); assert.deepEqual(overflow,[],'Viewport overflow at '+width); if(process.env.SCREENSHOT){await page.evaluate(()=>scrollTo({top:0,behavior:'instant'}));await page.screenshot({path:process.env.SCREENSHOT.replace(/\.png$/, '-'+width+'.png')});} diff --git a/ext/demo-site/test/cold-load-probe.mjs b/ext/demo-site/test/cold-load-probe.mjs @@ -9,6 +9,6 @@ export async function run(control){ const bundled=manifest.files.filter(r=>/\.(?:m?js)$/.test(r.path)).length===7; const js=transfers.filter(r=>/\.(?:m?js)(?:\?|$)/.test(r.path)); if(bundled&&js.length!==4)throw Error('Bundled homepage must load four JS entrypoints'); - if(transfers.some(r=>/compiler\.(?:json|dag)|environment\.tsv|runtime\/zig|local-module-worker/.test(r.path)))throw Error('Homepage fetched a lazy asset'); - return {userAgent:navigator.userAgent,checks:['cold homepage ready; compiler, Zig and local validation stay lazy'],requests:transfers.length,jsRequests:js.length,bytes:transfers.reduce((n,r)=>n+r.bytes,0),jsBytes:js.reduce((n,r)=>n+r.bytes,0),readyMs:globalThis.__readyMs,transfers}; + if(transfers.some(r=>/compiler\.(?:json|dag)|environment\.tsv|runtime\/zig|local-module-worker|reference\//.test(r.path)))throw Error('Homepage fetched a lazy asset'); + return {userAgent:navigator.userAgent,checks:['cold homepage ready; compiler, Zig, local validation and library references stay lazy'],requests:transfers.length,jsRequests:js.length,bytes:transfers.reduce((n,r)=>n+r.bytes,0),jsBytes:js.reduce((n,r)=>n+r.bytes,0),readyMs:globalThis.__readyMs,transfers}; } diff --git a/ext/demo-site/test/import-picker-probe.mjs b/ext/demo-site/test/import-picker-probe.mjs @@ -0,0 +1,82 @@ +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +const el=id=>document.getElementById(id),delay=ms=>new Promise(r=>setTimeout(r,ms)); +const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; +const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; +async function wait(fn,label){for(let i=0;i<4500;i++){if(fn())return;await delay(20);}throw Error('Timeout '+label+' / '+el('status').textContent+' / '+el('import-status').textContent);} +const status=value=>wait(()=>el('status').textContent===value,value); +export async function run(control,backend='haskell-wasm'){ + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; + const catalogue=await(await fetch(new URL('reference/libraries.json',base))).json(); + const lib=name=>catalogue.releases.find(r=>r.name==='tricu-lib-'+name&&r.version==='0.1.0'); + const math=lib('native-math'); + // Static references and independently specified examples can be consumed without + // a page session or a JavaScript module catalogue. + const llms=await(await fetch(new URL('llms.txt',location.href))).text(); + assert(llms.includes('reference/getting-started.md'),'Agent entrypoint points into this release'); + for(const [,path]of llms.matchAll(/\]\((\.\/[^)]+)\)/g))assert((await fetch(new URL(path,location.href))).ok,'Readable static reference '+path); + const examples=await(await fetch(new URL('reference/examples.json',base))).json(); + const host={before:async()=>{},after:()=>{},write:()=>{},read:async()=>'',running:()=>{}}; + const session=await createRuntimeSession(backend,true); + try{ + for(const example of examples){ + let terminal='';await session.compile(example.source,'empty'); + const result=await session.runExpression(example.expression,example.output,example.mode,{...host,write:text=>{terminal+=text;}}); + assert(result.value===example.expected,'Documented result '+example.id+': '+JSON.stringify(result)); + assert(terminal===example.terminal,'Documented terminal '+example.id); + } + await session.compile('!export answer\nanswer = 42','empty'); + const stage=await session.inspectModule(`-- 🐕\n!import ${math.manifest} as N\n!export answer\nanswer = 1`); + assert(stage.imports[0][0]===math.manifest&&stage.imports[0][1]==='N','Native header response'); + assert((await session.runExpression('answer','number','pure',host)).value==='42','Inspection preserves compiled scope'); + let rejected=false;try{await session.inspectModule(`!import ${math.manifest} as\n!export answer\nanswer=1`);}catch{rejected=true;} + assert(rejected,'Malformed header rejected'); + assert((await session.runExpression('answer','number','pure',host)).value==='42','Failed inspection preserves compiled scope'); + }finally{session.dispose();} + await status('Ready');change('runtime-backend',backend);await status('Ready'); + document.querySelector('[data-compiler-example=module]').click();await status('Compiled'); + const original='-- preserve this comment 🐕\n!export answer\nanswer = 42'; + input('source-editor',original);await status('Compiled'); + const open=async()=>{el('add-import').click();await wait(()=>el('import-results').querySelector('button'),'library list');assert(el('import-dialog').open,'Modal open');}; + const select=async()=>{input('import-search','remainder');await wait(()=>el('import-results').querySelector(`[data-manifest="${math.manifest}"]`),'search export');el('import-results').querySelector(`[data-manifest="${math.manifest}"]`).click();}; + await open();await select(); + assert(el('import-title').textContent==='native-math','Search reaches native math'); + assert(el('import-preview').textContent===`!import ${math.manifest} as N`,'Exact full manifest preview'); + assert(el('import-version').value==='0.1.0','Newest version initially selected'); + assert(el('import-version').options.length===2,'History available in details'); + assert(el('import-usage').textContent.includes('multiply')&&el('import-example').textContent.includes('Expected result: 42'),'Calling conventions and tested example available inline'); + input('import-alias','Numbers');change('import-version','0.0.9');await wait(()=>!el('insert-import').disabled,'historical release check'); + assert(el('import-version').value==='0.0.9'&&el('import-preview').textContent.endsWith('as Numbers'),'History preserves chosen namespace'); + change('import-version','0.1.0');input('import-alias','N'); + await wait(()=>!el('insert-import').disabled,'candidate compiled'); + el('insert-import').click(); + assert(!el('import-dialog').open,'Insertion closes dialog'); + assert(el('source-editor').value===`!import ${math.manifest} as N\n`+original,'Insertion preserves all original source'); + assert(el('source-editor').selectionStart===0&&el('source-editor').selectionEnd===(`!import ${math.manifest} as N\n`).length,'Inserted line selected'); + await status('Compiled'); + input('expression-editor','N.add answer 8');change('output-type','number');el('run').click();await status('Finished');assert(el('result').textContent==='50','Inserted namespace usable'); + // Undo is one native textarea edit; compilation does not replace the editor. + el('source-editor').focus();assert(document.execCommand('undo'),'Undo available'); + assert(el('source-editor').value===original,'One undo restores exact original source');await status('Compiled'); + await open();await select();await wait(()=>!el('insert-import').disabled,'reinsert checked');el('insert-import').click();await status('Compiled'); + const inserted=el('source-editor').value; + await open();await select();await wait(()=>!el('insert-import').disabled,'existing import'); + assert(el('import-status').textContent.includes('Already imported as N'),'Existing namespace shown');el('insert-import').click(); + assert(el('source-editor').value===inserted,'Jump does not edit'); + // Alias conflict is based on the native header; a failed check leaves the editor intact. + await open();input('import-search','conversions');el('import-results').querySelector('button').click();input('import-alias','N'); + await wait(()=>el('import-status').textContent.includes('already used'),'namespace conflict');assert(el('insert-import').disabled,'Conflict blocks insert');el('import-cancel').click(); + assert(el('source-editor').value===inserted,'Cancellation preserves source'); + input('source-editor','!export answer\nanswer = (');await status('Compilation failed'); + await open();await select();await wait(()=>el('import-status').textContent.includes('copy the import'),'malformed source fallback');assert(el('insert-import').disabled,'Malformed source blocks insertion');el('import-cancel').click(); + input('source-editor',original);await status('Compiled'); + await open();await select();el('import-cancel').click();await delay(400);assert(!el('import-dialog').open,'Check cancelled');assert(el('source-editor').value===original,'Cancellation is an edit-free operation'); + // Local filenames are text and their validated interfaces are searchable. + const bytes=await(await fetch('/_arboricx/bundle/hash/'+math.hash)).arrayBuffer(); + const files=new DataTransfer();files.items.add(new File([bytes],'<svg onload=bad>.arboricx'));el('local-module-file').files=files.files;el('local-module-file').dispatchEvent(new Event('change')); + await wait(()=>el('local-module-list').children.length===1&&!el('local-module-file').disabled,'local validation');await status('Compiled'); + await open();input('import-search','<svg');assert(!el('import-results').querySelector('svg'),'Filename is text');el('import-results').querySelector('button').click(); + assert(el('import-version').hidden,'Local file has no invented version');await wait(()=>!el('insert-import').disabled,'local import check');el('insert-import').click();await status('Compiled'); + input('expression-editor','Local.add 5 6');el('run').click();await status('Finished');assert(el('result').textContent==='11','Local library from unified picker'); + return {userAgent:navigator.userAgent,checks:['static agent references; complete published interfaces; general documented examples; native read-only header inspection; export search; exact imports; undo; existing import jump; namespace conflicts; malformed source copy fallback; cancellation; local-file discovery'],backend}; +} diff --git a/ext/demo-site/test/import-picker.test.mjs b/ext/demo-site/test/import-picker.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {importPlan} from '../dist/import-plan.js'; +import {libraryIndex,latestLibraries,matchesLibrary} from '../dist/library-index.js'; +const manifest='a'.repeat(64),other='b'.repeat(64); +const stage=(imports=[],body='')=>({imports,body,exports:[['*','']],native:true}); +test('import insertion is a prefix edit; source and Unicode comments remain intact',()=>{ + const source='-- 🐕 comment\n!export answer\nanswer = 42'; + assert.deepEqual(importPlan(source,stage(),manifest,'N'),{source:`!import ${manifest} as N\n${source}`,statement:`!import ${manifest} as N\n`}); +}); +test('native header determines duplicate identity and namespace conflicts',()=>{ + const source=`-- 🐕\n!import ${manifest} as Old\n!export answer\nanswer = 42`; + const plan=importPlan(source,stage([[manifest,'Old']],'-- 🐕\n \n \nanswer = 42'),manifest,'N'); + assert.equal(plan.source,source);assert.equal(plan.existing.alias,'Old');assert.equal(source.slice(plan.existing.start,plan.existing.end),`!import ${manifest} as Old`); + assert.throws(()=>importPlan(source,stage([[other,'N']]),manifest,'N'),/already used/); + assert.equal(importPlan(source,stage([[manifest,'*']]),manifest,'N').existing.alias,'*'); +}); +test('insertion validates namespace, manifest and resulting source bounds',()=>{ + for(const alias of ['','*','A.B','x\n!export *','1name','x'.repeat(129)])assert.throws(()=>importPlan('!export x\nx=1',stage(),manifest,alias),/namespace/i); + assert.throws(()=>importPlan('x=1',stage(),'deadbeef','N'),/manifest/); + assert.throws(()=>importPlan('x'.repeat(16384),stage(),manifest,'N'),/limit/); +}); +const row=version=>({name:'tricu-lib-test',version,hash:other,manifest,title:'test',description:'Natural arithmetic',names:['add','multiply'],alias:'N',reference:'libraries/tricu-lib-test-'+version+'.md'}); +test('library metadata is bounded and latest versions use release precedence',()=>{ + const rows=libraryIndex({schema:1,releases:[row('0.9.0'),row('0.10.0'),row('0.10.0-rc.1')]}); + assert.equal(latestLibraries(rows)[0].version,'0.10.0'); + assert(matchesLibrary(rows[0],'arithmetic multiply'));assert(!matchesLibrary(rows[0],'socket')); + for(const change of [{reference:'https://example.com/a.md'},{reference:'libraries/../../a.md'},{alias:'*'},{names:['a'.repeat(513)]},{description:[]}] )assert.throws(()=>libraryIndex({schema:1,releases:[{...row('1.0.0'),...change}]})); +}); diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -4,6 +4,8 @@ import argparse import hashlib import importlib.util import json +import re +import struct from pathlib import Path import subprocess import tempfile @@ -29,6 +31,11 @@ def main(): assert png[:8] == b'\x89PNG\r\n\x1a\n' assert (int.from_bytes(png[16:20], 'big'), int.from_bytes(png[20:24], 'big')) == size assert not (args.site / 'dist').exists() + llms = (args.site / 'llms.txt').read_text() + for path in re.findall(r'\]\((\./[^)]+)\)', llms): + assert (args.site / path).is_file(), path + assert assets['base'] in path + assert 'href="./llms.txt"' in html support = args.site / 'nix-support' args.site = args.site / assets['base'] assert sorted(p.relative_to(args.site).as_posix() for p in args.site.rglob('*') if p.is_file()) == [r['path'] for r in assets['files']] @@ -43,6 +50,55 @@ def main(): compiler = json.loads((args.site / 'compiler.json').read_text()) index = json.loads((args.site / 'packages.json').read_text()) environments = json.loads((args.site / 'environments.json').read_text()) + examples = json.loads((args.site / 'reference/examples.json').read_text()) + quickstart = (args.site / 'reference/getting-started.md').read_text() + for code in re.findall(r'```tricu\n(.*?)```', quickstart, re.S): + assert any(e['source'].strip() == code.strip() for e in examples) + libraries = json.loads((args.site / 'reference/libraries.json').read_text())['releases'] + published = [r for r in index['releases'] if r['name'].startswith('tricu-lib-')] + assert {(r['name'], r['version']) for r in libraries} == {(r['name'], r['version']) for r in published} + # Independent reader: public names are the bundle's export table, not source + # declaration guesses (which would miss inherited/reexported names). + def public_names(data): + sections = struct.unpack_from('>I', data, 12)[0] + manifest = None + for i in range(sections): + at = 32 + i * 32 + tag = struct.unpack_from('>I', data, at)[0] + offset, size = struct.unpack_from('>QQ', data, at + 12) + if tag == 1: manifest = data[offset:offset + size] + assert manifest is not None + offset = 12 + def word(): + nonlocal offset + value = struct.unpack_from('>I', manifest, offset)[0]; offset += 4 + return value + def text(): + nonlocal offset + size = word(); value = manifest[offset:offset + size]; offset += size + return value.decode() + for _ in range(9): text() + for _ in range(word()): text() + offset += 1 + for _ in range(word()): word(); text() + result = [] + for _ in range(word()): result.append(text()); word(); text(); text() + return result + for row in libraries: + release = next(r for r in published if (r['name'],r['version']) == (row['name'],row['version'])) + assert row['manifest'] == release['manifest'] and row['hash'] == release['hash'] + data = (pack / 'objects' / row['hash'][:3] / (row['hash'] + '.arboricx')).read_bytes() + assert row['names'] == public_names(data) + reference = (args.site / 'reference' / row['reference']).read_text() + assert '!import ' + row['manifest'] + ' as ' + row['alias'] in reference + assert all(name in reference for name in row['names']) + # All relative static Markdown links resolve inside the released reference tree. + for path in (args.site / 'reference').rglob('*.md'): + for href in re.findall(r'\]\(([^ )]+)(?: +[^)]*)?\)', path.read_text()): + if ':' in href or href.startswith('#'): continue + destination = (path.parent / href.split('#')[0]).resolve() + assert destination.is_file(), (path, href) + count = len(rows) + len(manifest['releases']) assert {row['hash'] for row in rows} == {entry['hash'] for entry in [*catalogue, compiler, *index['releases'], *[p for p in environments['profiles'].values() if p]]} for row in rows: diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -73,3 +73,13 @@ test('hero invitation, direct footer feedback and self-hosted preview metadata', for(const path of ['images/favicon.png','images/icon.svg','images/apple-touch-icon.png','images/preview.png'])await access(new URL(path,here)); assert.doesNotMatch(html,/twitter:|facebook\.com|linkedin\.com|contact-form|Privacy policy/); }); + +test('import picker and static agent entrypoint are discoverable and accessible',async()=>{ + const html=await read('index.html'); + assert.match(html,/<button id="add-import"[^>]*>Add import…<\/button>/); + assert.match(html,/<dialog id="import-dialog" aria-labelledby="import-dialog-title">/); + for(const id of ['import-search','import-alias','import-version','import-export-search'])assert.ok(html.includes(`for="${id}"`)); + assert.match(html,/<link rel="help" type="text\/plain" href=".\/llms.txt"/); + assert.match(html,/<footer[^>]*>.*For AI assistants.*<\/footer>/); + const source=await read('src/import-picker.ts');assert.match(source,/textContent/);assert.doesNotMatch(source,/innerHTML/); +}); diff --git a/ext/haskell-wasm/Reactor.hs b/ext/haskell-wasm/Reactor.hs @@ -201,6 +201,14 @@ request ptr len = guardRequest $ do tag:rest->do name<-R.utf8 tag;pure(name,rest) _->fail "missing operation" case (op,arguments) of + ("module-header",[sourceBytes])->do + requireIdle + initialized<-readIORef context + case initialized of Nothing->fail "compiler not initialized"; Just _->pure () + library<-readIORef compilerLibrary + source<-R.utf8 sourceBytes + staged<-M.stage library source + reply("ok\n"++R.json(M.stageJSON staged)) ("module-stage",[sourceBytes])->do requireIdle writeIORef current [] diff --git a/ext/haskell-wasm/browser-worker.mjs b/ext/haskell-wasm/browser-worker.mjs @@ -87,6 +87,9 @@ onmessage=async({data:m})=>{ }else if(m.type==='expression'){ await safe(()=>{scalarText(m.source,16384);if(typeof m.name!=='string'||!/^browserExpression[0-9]+$/.test(m.name)||!['pure','io'].includes(m.mode))throw Error('Invalid expression request');}); bytes=operation(['expression',m.name,m.source,m.mode,m.output]); + }else if(m.type==='module-header'){ + const source=await safe(()=>scalarText(m.source,16384)); + bytes=operation(['module-header',source]); }else if(m.type==='compile'){ try { const source=await safe(()=>{const text=scalarText(m.source,16384);if(!text.trim())throw Error('Enter a source unit or native module');return text;}); diff --git a/ext/wasm/site-session.mjs b/ext/wasm/site-session.mjs @@ -89,6 +89,10 @@ export async function create({base,compiler}) { if(await bundle.sha256(bytes)!==hash)throw Error('Bundle SHA-256 mismatch'); const next=importBundle(bundle.decodeBundle(bytes));clearDefinitions();definitions=next;g.collect(); });}, + inspectModule(source){return exclusive(async()=>{ + if(!compilerHash)throw Error('Compiler not initialized'); + return g.scope(()=>modules.stageModule(g,library,comp.sourceText(source))); + });}, exportModule(){return exclusive(async()=>{ if(!definitions.size)throw Error('No compiled module'); return g.scope(()=>moduleBundle.moduleSnapshot([...definitions.keys()],{...g.snapshot(codec.list(g,[...definitions.values()])),aliases:[]}));