// Writes api.json from lib/apispec.js, lib/manifest.js and version.json. Run after changing any of them: // node tools/build_api_json.mjs (tests/api.mjs checks that api.json is up to date) import { readFileSync, writeFileSync } from 'node:fs'; import { API_VERSION, METHODS, EVENTS, URL_PARAMS, KEYS } from '../lib/apispec.js'; import { CT_SPECS } from '../lib/ctapi.js'; import { SCHEMA, LOCAL_LIMITS, REGISTRY_LIMITS, MESH_EXT, IMAGE_EXT, ROLES, FORMATS } from '../lib/manifest.js'; const ver = JSON.parse(readFileSync(new URL('../version.json', import.meta.url), 'utf8')); const jsType = (t) => t.split('|').map((x) => ({ string: 'string', number: 'number', boolean: 'boolean', object: 'object', array: 'array', null: 'null', function: 'function' }[x] || x)); const methods = {}; for (const [name, m] of [...Object.entries(METHODS), ...Object.entries(CT_SPECS).map(([k, v]) => [`ct.${k}`, { group: 'ct', ...v, extension: true }])]) { methods[name] = { group: m.group, summary: m.summary, call: `window.unroll.${name}(${m.params.map((p) => p.name + (p.required === false ? '?' : '')).join(', ')})`, params: m.params.map((p) => ({ name: p.name, type: jsType(p.type), required: p.required !== false, ...(p.desc ? { description: p.desc } : {}), ...(p.enum ? { enum: p.enum } : {}), ...(p.min !== undefined ? { minimum: p.min } : {}), ...(p.max !== undefined ? { maximum: p.max } : {}) })), returns: 'Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>', }; } const path = { type: 'string', description: 'Relative path inside the package: no URL, no leading slash, no ".."' }; const layer = { type: 'object', required: ['id', 'role', 'label'], properties: { id: { type: 'string' }, role: { enum: ROLES }, label: { type: 'string' }, shortLabel: { type: 'string', description: 'Label where space is tight (layer chip, compare swipe)' }, resolutionUM: { type: 'number', exclusiveMinimum: 0, description: 'Resolution in micrometers' }, windings: { description: '"all", a list of ids, or { range: [first, last] }' }, texture: { ...path, description: 'Per-winding image path pattern containing {id}' }, files: { type: 'object', additionalProperties: path, description: 'Winding id to image path, overriding texture' }, atlas: path, atlasFallback: path, hi: { ...path, description: 'Close-up image pattern containing {id}' }, stats: path, source: { type: 'string', description: 'Where the layer comes from' }, aboutHtml: { type: 'string', description: 'Registry scrolls only' }, }, }; const manifest = { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Unroll scroll manifest', type: 'object', required: ['schema', 'id', 'name', 'voxelMM', 'geometry', 'windings', 'layers'], properties: { schema: { const: SCHEMA }, id: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' }, name: { type: 'string', maxLength: 120 }, voxelMM: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 10 }, base: { type: 'string', description: 'Relative folder that paths start from' }, geometry: { type: 'object', required: ['format'], properties: { format: { enum: FORMATS, description: 'grid-int16 is for registry scrolls only' }, units: { enum: ['voxels', 'mm'] }, axis: { enum: ['z', 'y'] }, index: path, bin: path } }, windings: { description: 'A list of { id, mesh } or { range: [first, last], mesh: pattern with {id}, outermost }', oneOf: [{ type: 'array', items: { type: 'object', required: ['id'], properties: { id: { type: 'string' }, mesh: { ...path, description: `.${MESH_EXT.join(', .')}` } } } }, { type: 'object', required: ['range'] }] }, outermost: { enum: ['first', 'last'] }, layers: { type: 'array', minItems: 1, items: layer, description: 'One layer per role; at least one ct or ink layer' }, description: { type: 'string' }, credits: { type: 'object', properties: { text: { type: 'string' }, url: { type: 'string', format: 'uri' }, citation: { type: 'string' }, holder: { type: 'string' } } }, dataTerms: { type: 'object', properties: { license: { type: 'string' }, url: { type: 'string', format: 'uri' }, notes: { type: 'string' } } }, display: { type: 'object', description: 'Registry scrolls only' }, }, 'x-imageExtensions': IMAGE_EXT, 'x-meshExtensions': MESH_EXT, 'x-limits': { openYourOwn: LOCAL_LIMITS, registry: REGISTRY_LIMITS }, }; const out = { name: 'Unroll', app: { version: ver.version, date: ver.date, sha: ver.sha }, apiVersion: API_VERSION, docs: { llms: '/llms.txt', agents: '/AGENTS.md', addingAScroll: '/docs/ADDING_A_SCROLL.md', changelog: '/CHANGELOG.md', embed: '/docs/EMBED.md', oembed: '/oembed.json' }, global: 'window.unroll', conventions: [ 'Every method returns a Promise of { ok: true, ... } or { ok: false, error, code }. Nothing throws.', 'Error codes: invalid (bad arguments), not_found, unavailable (feature missing in this build or scroll), timeout, conflict, failed, load_failed.', 'Screen coordinates are CSS pixels of the viewport; scene coordinates are millimeters.', 'Add-ons register more groups with unroll.register(group, methods, specs), for example unroll.bench. They appear in unroll.schema() with extension: true.', 'Every interactive element has data-testid and data-api naming its API equivalent.', ], embed: { url: 'https://sharpmeow-unroll.static.hf.space/index.html?embed=1', params: { embed: '1: compact layout, click to interact', autoplay: '1: play on load', controls: '0: hide the controls' }, iframeAllow: 'fullscreen; clipboard-write', }, postMessage: { description: 'Host pages drive an embedded viewer with postMessage. Every method above is callable by name; on, off and register are replaced by subscribe and unsubscribe.', toViewer: { call: { unroll: 1, id: 'number|string', method: 'string (an API method name)', args: 'array' }, subscribe: { unroll: 1, id: 'number|string', method: 'subscribe', args: ['eventName'] }, unsubscribe: { unroll: 1, id: 'number|string', method: 'unsubscribe', args: ['eventName'] }, }, fromViewer: { ready: { unroll: 1, type: 'ready', version: '{ version, date, sha, api }', methods: 'array of method names' }, result: { unroll: 1, type: 'result', id: 'the id you sent', result: '{ ok: true, ... } | { ok: false, error, code }' }, event: { unroll: 1, type: 'event', event: 'string', payload: 'object' }, }, forbidden: ['open.listLocal', 'open.removeLocal'], }, methods, events: EVENTS, urlParams: URL_PARAMS, keys: KEYS, manifestSchema: manifest, }; writeFileSync(new URL('../api.json', import.meta.url), JSON.stringify(out, null, 2) + '\n'); console.log(`api.json: ${Object.keys(methods).length} methods, app ${ver.version}`);