@12ui/design 0.2.67 → 0.2.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/SKILL.md +1 -1
  2. package/open-design.json +2 -2
  3. package/package.json +1 -1
  4. package/runtime/bootstrap.mjs +131 -0
  5. package/runtime/codex-conversion/README.md +75 -0
  6. package/runtime/codex-conversion/bin/convert.mjs +21 -0
  7. package/runtime/codex-conversion/bin/export.mjs +4 -0
  8. package/runtime/codex-conversion/bin/finish.mjs +11 -0
  9. package/runtime/codex-conversion/bin/improve.mjs +12 -0
  10. package/runtime/codex-conversion/bin/install-skill.mjs +36 -0
  11. package/runtime/codex-conversion/bin/recover-image.mjs +11 -0
  12. package/runtime/codex-conversion/bin/recovery/prepare.mjs +68 -0
  13. package/runtime/codex-conversion/bin/recovery/run.mjs +37 -0
  14. package/runtime/codex-conversion/npm-shrinkwrap.json +1199 -0
  15. package/runtime/codex-conversion/package.json +45 -0
  16. package/runtime/codex-conversion/src/codex/arguments.mjs +14 -0
  17. package/runtime/codex-conversion/src/codex/completion.mjs +14 -0
  18. package/runtime/codex-conversion/src/codex/events.mjs +41 -0
  19. package/runtime/codex-conversion/src/codex/home.mjs +37 -0
  20. package/runtime/codex-conversion/src/codex/invoke.mjs +79 -0
  21. package/runtime/codex-conversion/src/contract-repair.mjs +44 -0
  22. package/runtime/codex-conversion/src/contracts/compile.mjs +53 -0
  23. package/runtime/codex-conversion/src/contracts/contracts.mjs +19 -0
  24. package/runtime/codex-conversion/src/contracts/geometry.mjs +38 -0
  25. package/runtime/codex-conversion/src/contracts/grid-frame.mjs +22 -0
  26. package/runtime/codex-conversion/src/contracts/groups.mjs +14 -0
  27. package/runtime/codex-conversion/src/contracts/initial.mjs +9 -0
  28. package/runtime/codex-conversion/src/contracts/normalize.mjs +46 -0
  29. package/runtime/codex-conversion/src/exports/browser.mjs +26 -0
  30. package/runtime/codex-conversion/src/exports/editable.mjs +34 -0
  31. package/runtime/codex-conversion/src/exports/files.mjs +7 -0
  32. package/runtime/codex-conversion/src/exports/index.mjs +24 -0
  33. package/runtime/codex-conversion/src/exports/react.mjs +44 -0
  34. package/runtime/codex-conversion/src/exports/viewport.mjs +17 -0
  35. package/runtime/codex-conversion/src/finish/index.mjs +58 -0
  36. package/runtime/codex-conversion/src/finish/patch.mjs +14 -0
  37. package/runtime/codex-conversion/src/images/execute.mjs +52 -0
  38. package/runtime/codex-conversion/src/images/frame-normalization.mjs +10 -0
  39. package/runtime/codex-conversion/src/images/geometry.mjs +9 -0
  40. package/runtime/codex-conversion/src/images/local-operation.mjs +17 -0
  41. package/runtime/codex-conversion/src/images/local.mjs +18 -0
  42. package/runtime/codex-conversion/src/images/native-collect.mjs +43 -0
  43. package/runtime/codex-conversion/src/images/native-files.mjs +11 -0
  44. package/runtime/codex-conversion/src/images/native-invoke.mjs +49 -0
  45. package/runtime/codex-conversion/src/images/native-records.mjs +57 -0
  46. package/runtime/codex-conversion/src/images/native.mjs +41 -0
  47. package/runtime/codex-conversion/src/images/prompt.mjs +14 -0
  48. package/runtime/codex-conversion/src/images/provider.mjs +22 -0
  49. package/runtime/codex-conversion/src/output.mjs +28 -0
  50. package/runtime/codex-conversion/src/project/invoke.mjs +35 -0
  51. package/runtime/codex-conversion/src/project/prompt.mjs +13 -0
  52. package/runtime/codex-conversion/src/project/run.mjs +83 -0
  53. package/runtime/codex-conversion/src/project/source.mjs +21 -0
  54. package/runtime/codex-conversion/src/references.mjs +114 -0
  55. package/runtime/codex-conversion/src/run-store-io.mjs +38 -0
  56. package/runtime/codex-conversion/src/run-store.mjs +114 -0
  57. package/runtime/codex-conversion/src/run.mjs +108 -0
  58. package/runtime/codex-conversion/src/runtime-pin.mjs +12 -0
  59. package/runtime/codex-conversion/templates/initial.schema.json +90 -0
  60. package/runtime/codex-conversion/templates/initial.txt +22 -0
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@12ui/codex-conversion",
3
+ "version": "0.1.0",
4
+ "description": "Portable Codex HTML conversion with durable external image execution",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "bin": {
10
+ "12ui-convert": "bin/convert.mjs",
11
+ "12ui-convert-export": "bin/export.mjs",
12
+ "12ui-convert-finish": "bin/finish.mjs",
13
+ "12ui-improve": "bin/improve.mjs"
14
+ },
15
+ "exports": {
16
+ ".": "./src/run.mjs",
17
+ "./project": "./src/project/run.mjs"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "templates",
23
+ "README.md",
24
+ "skill",
25
+ "npm-shrinkwrap.json"
26
+ ],
27
+ "scripts": {
28
+ "test": "node --test test/*.test.mjs",
29
+ "typecheck": "tsc --allowJs --checkJs --noEmit --module nodenext --target es2022 --skipLibCheck src/*.mjs src/codex/*.mjs src/images/*.mjs src/contracts/*.mjs src/exports/*.mjs src/finish/*.mjs src/project/*.mjs bin/*.mjs"
30
+ },
31
+ "dependencies": {
32
+ "sharp": "0.34.5",
33
+ "parse5": "7.3.0",
34
+ "css-tree": "3.1.0",
35
+ "esbuild": "0.27.7",
36
+ "playwright": "1.55.0",
37
+ "react": "19.2.0",
38
+ "react-dom": "19.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "22.15.0",
42
+ "@types/css-tree": "2.3.10",
43
+ "typescript": "5.8.3"
44
+ }
45
+ }
@@ -0,0 +1,14 @@
1
+ export const disabledFeatures = ['shell_tool', 'view_image', 'image_generation', 'multi_agent', 'apps', 'plugins', 'browser_use', 'computer_use'];
2
+
3
+ /** Preserved A invocation treatment. @param {{workDir:string, finalPath:string, sourcePath?:string, imagePaths?:string[], schemaPath:string,reasoning?:string}} spec */
4
+ export function buildArgv(spec) {
5
+ const args = ['-a', 'never', 'exec', '--ignore-user-config', '--ignore-rules', '--strict-config', '-C', spec.workDir,
6
+ '--skip-git-repo-check', '-s', 'danger-full-access', '-m', 'gpt-6-astra',
7
+ '-c', `model_reasoning_effort="${spec.reasoning ?? 'low'}"`, '-c', 'model_provider="openai"',
8
+ '-c', 'forced_login_method="chatgpt"', '-c', 'project_doc_max_bytes=0',
9
+ '--enable', 'skip_host_skill_discovery', '--json', '--color', 'never', '--output-last-message', spec.finalPath];
10
+ for (const feature of disabledFeatures) args.push('--disable', feature);
11
+ for (const image of spec.imagePaths ?? [spec.sourcePath]) { if (image) args.push('--image', image); }
12
+ args.push('--output-schema', spec.schemaPath, '-');
13
+ return args;
14
+ }
@@ -0,0 +1,14 @@
1
+ import { hash } from '../run-store.mjs';
2
+
3
+ /** Terminal lifecycle facts decide success; generic errors remain diagnostics. @param {any} execution @param {Buffer} bytes */
4
+ export function validateCompletion(execution, bytes) {
5
+ if (execution.exitCode !== 0 || execution.exitSignal || execution.timedOut || execution.interrupted || execution.spawnError) throw new Error('Planner execution did not complete; reconciliation required');
6
+ const { events, audit } = execution;
7
+ const effort = execution.expectedEffort ?? 'low';
8
+ if (!['low', 'medium'].includes(effort)) throw new Error('Unsupported expected planner effort');
9
+ if (!events || execution.eventReadError || events.malformedJsonlLines.length || events.failed.length || events.errorsAfterCompletion.length || events.tools.length || events.usage.length !== 1) throw new Error('Planner no-tool completion protocol violated');
10
+ if (!audit || execution.auditReadError || audit.tools.length || !audit.contexts.length || audit.contexts.some(context => context.model !== 'gpt-6-astra' || context.effort !== effort)) throw new Error(`Observed planner model/effort does not prove Astra ${effort}`);
11
+ if (execution.finalSha256 !== hash(bytes)) throw new Error('Planner final identity changed or final absent');
12
+ // Custody proves the request completed. Payload syntax belongs to normalization/repair.
13
+ return { sha256: hash(bytes), authentication: 'chatgpt', model: 'gpt-6-astra', effort, ...events, audit, finishedAt: execution.finishedAt, process: { exitCode: execution.exitCode, exitSignal: execution.exitSignal, timedOut: execution.timedOut, interrupted: execution.interrupted } };
14
+ }
@@ -0,0 +1,41 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** @param {string} filename */
5
+ export async function summarizeEvents(filename) {
6
+ const events = []; const malformedJsonlLines = [];
7
+ for (const [index,line] of (await readFile(filename, 'utf8')).split('\n').entries()) {
8
+ if (!line) continue;
9
+ try { events.push(JSON.parse(line)); } catch { malformedJsonlLines.push(index + 1); }
10
+ }
11
+ const items = events.filter(e => e.type === 'item.completed').map(e => e.item);
12
+ return {
13
+ threadId: events.find(e => e.type === 'thread.started')?.thread_id ?? null,
14
+ usage: events.filter(e => e.type === 'turn.completed').map(e => e.usage),
15
+ failed: events.filter(e => e.type === 'turn.failed'),
16
+ errors: events.filter(e => e.type === 'error'),
17
+ errorsAfterCompletion: events.filter((e, index) => e.type === 'error' && events.some((previous, before) => before < index && previous.type === 'turn.completed')),
18
+ malformedJsonlLines,
19
+ tools: items.filter(item => !['agent_message', 'reasoning', 'error'].includes(item.type)),
20
+ };
21
+ }
22
+
23
+ /** Inspect private rollouts without copying private session context into a run. @param {string} dir */
24
+ export async function auditRollouts(dir) {
25
+ const tools = []; const contexts = [];
26
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(error => {
27
+ if (error.code === 'ENOENT') return []; throw error;
28
+ })) {
29
+ const filename = path.join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ const nested = await auditRollouts(filename); tools.push(...nested.tools); contexts.push(...nested.contexts);
32
+ } else if (entry.name.endsWith('.jsonl')) {
33
+ for (const line of (await readFile(filename, 'utf8')).split('\n').filter(Boolean)) {
34
+ const event = JSON.parse(line); const item = event.payload ?? {};
35
+ if (['custom_tool_call', 'function_call'].includes(item.type)) tools.push({ name: item.name, callId: item.call_id });
36
+ if (event.type === 'turn_context') contexts.push({ model: item.model, effort: item.effort });
37
+ }
38
+ }
39
+ }
40
+ return { tools, contexts };
41
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdtemp, symlink, copyFile, readFile, realpath, chmod } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /** The planner receives an allowlisted environment, never provider credentials. @param {string} home */
7
+ export function subscriptionEnv(home) {
8
+ /** @type {NodeJS.ProcessEnv} */
9
+ const env = { CODEX_HOME: home };
10
+ for (const key of ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'SYSTEMROOT']) {
11
+ if (process.env[key]) env[key] = process.env[key];
12
+ }
13
+ return env;
14
+ }
15
+
16
+ /** @param {{authHome:string, binary:string, runDir:string,reasoning?:string}} options */
17
+ export async function prepareHome({ authHome, binary, runDir, reasoning = 'low' }) {
18
+ const authRoot = await realpath(authHome);
19
+ const runRoot = await realpath(runDir);
20
+ if (authRoot === runRoot || authRoot.startsWith(runRoot + path.sep)) throw new Error('authHome must be outside runDir');
21
+ const authPath = await realpath(path.join(authRoot, 'auth.json'));
22
+ if (authPath === runRoot || authPath.startsWith(runRoot + path.sep)) throw new Error('Credentials must be outside runDir');
23
+ const home = await mkdtemp(path.join(tmpdir(), '12ui-codex-'));
24
+ await chmod(home, 0o700);
25
+ await symlink(authPath, path.join(home, 'auth.json'));
26
+ try { await copyFile(path.join(authRoot, 'models_cache.json'), path.join(home, 'models_cache.json')); }
27
+ catch (error) { throw new Error('Explicit authHome must contain models_cache.json', { cause: error }); }
28
+ const cache = JSON.parse(await readFile(path.join(home, 'models_cache.json'), 'utf8'));
29
+ if (!cache.models?.some(model => model.slug === 'gpt-6-astra' && model.supported_reasoning_levels?.some(level => level.effort === reasoning))) {
30
+ throw new Error(`Auth model cache does not support gpt-6-astra ${reasoning}`);
31
+ }
32
+ const login = spawnSync(binary, ['login', 'status'], { env: subscriptionEnv(home), encoding: 'utf8', timeout: 30000 });
33
+ if (login.status !== 0 || !`${login.stdout ?? ''}${login.stderr ?? ''}`.includes('Logged in using ChatGPT')) {
34
+ throw new Error('ChatGPT authentication unavailable; API authentication is not supported for the planner');
35
+ }
36
+ return { home, authentication: 'chatgpt' };
37
+ }
@@ -0,0 +1,79 @@
1
+ import { open, readFile, mkdir, rm } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { prepareHome, subscriptionEnv } from './home.mjs';
5
+ import { buildArgv } from './arguments.mjs';
6
+ import { summarizeEvents, auditRollouts } from './events.mjs';
7
+ import { hash, writeJson } from '../run-store.mjs';
8
+ import { validateCompletion } from './completion.mjs';
9
+
10
+ /** Recover only a final proven complete and no-tool before interruption. @param {{callDir:string}} options */
11
+ export async function recoverCodex({ callDir }) {
12
+ let receipt;
13
+ try { receipt = JSON.parse(await readFile(path.join(callDir, 'completion.json'), 'utf8')); }
14
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
15
+ const bytes = await readFile(path.join(callDir, 'final.txt')).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
16
+ if (!bytes) return null;
17
+ if (!receipt) {
18
+ let execution;
19
+ try { execution = JSON.parse(await readFile(path.join(callDir, 'execution.json'), 'utf8')); }
20
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
21
+ try { receipt = validateCompletion(execution, bytes); } catch { return null; }
22
+ await writeJson(path.join(callDir, 'completion.json'), receipt);
23
+ }
24
+ if (receipt.sha256 !== hash(bytes)) throw new Error('Planner final identity changed');
25
+ return { bytes, metadata: receipt };
26
+ }
27
+
28
+ /**
29
+ * One planner invocation. Caller must durably claim dispatch before entering.
30
+ * @param {{callDir:string,authHome:string,binary?:string,runDir:string,prompt:string,sourcePath?:string,imagePaths?:string[],schemaPath:string,timeoutMs:number,preparedHome?:string,reasoning?:string}} spec
31
+ */
32
+ export async function invokeCodex(spec) {
33
+ const binary = spec.binary ?? 'codex';
34
+ const reasoning = spec.reasoning ?? 'low';
35
+ if (!['low', 'medium'].includes(reasoning)) throw new Error('reasoning must be low or medium');
36
+ const home = spec.preparedHome ?? (await prepareHome({ authHome: spec.authHome, binary, runDir: spec.runDir, reasoning })).home;
37
+ const workDir = path.join(home, 'work'); await mkdir(workDir);
38
+ const finalPath = path.join(spec.callDir, 'final.txt');
39
+ const args = buildArgv({ ...spec, workDir, finalPath });
40
+ // Record treatment, not private auth/runtime paths.
41
+ await writeJson(path.join(spec.callDir, 'invocation.json'), { model: 'gpt-6-astra', effort: reasoning, authentication: 'chatgpt', noTools: true, startedAt: new Date().toISOString() });
42
+ const stdout = await open(path.join(spec.callDir, 'events.jsonl'), 'wx');
43
+ const stderr = await open(path.join(spec.callDir, 'stderr.log'), 'wx');
44
+ let observationSaved = false;
45
+ try {
46
+ const child = spawn(binary, args, { cwd: workDir, env: subscriptionEnv(home), detached: true, stdio: ['pipe', stdout.fd, stderr.fd] });
47
+ let timedOut = false; let interrupted = false;
48
+ const stop = signal => { if (child.pid) { try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== 'ESRCH') throw error; } } };
49
+ let killTimer;
50
+ const terminate = () => { stop('SIGTERM'); killTimer = setTimeout(() => stop('SIGKILL'), 5000); };
51
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, spec.timeoutMs);
52
+ const signalHandler = () => { interrupted = true; terminate(); };
53
+ process.once('SIGINT', signalHandler); process.once('SIGTERM', signalHandler);
54
+ child.stdin.on('error', () => {}); child.stdin.end(spec.prompt);
55
+ let code = null; let exitSignal = null; let spawnError = null;
56
+ try {
57
+ code = await new Promise(resolve => { child.once('error', error => { spawnError = { name: error.name, code: /** @type {NodeJS.ErrnoException} */ (error).code ?? null }; }); child.once('close', (status, signal) => { exitSignal = signal; resolve(status); }); });
58
+ } finally {
59
+ clearTimeout(timer); clearTimeout(killTimer); stop('SIGTERM');
60
+ process.removeListener('SIGINT', signalHandler); process.removeListener('SIGTERM', signalHandler);
61
+ await stdout.sync(); await stderr.sync();
62
+ }
63
+ let events = null; let audit = null; let eventReadError = null; let auditReadError = null;
64
+ try { events = await summarizeEvents(path.join(spec.callDir, 'events.jsonl')); } catch { eventReadError = 'Event stream unreadable'; }
65
+ try { audit = await auditRollouts(path.join(home, 'sessions')); } catch { auditReadError = 'Private rollout audit unreadable'; }
66
+ const bytes = await readFile(finalPath).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
67
+ if (bytes) { const finalHandle = await open(finalPath, 'r'); await finalHandle.sync(); await finalHandle.close(); }
68
+ const execution = { expectedEffort: reasoning, exitCode: code, exitSignal, timedOut, interrupted, spawnError, events, audit, eventReadError, auditReadError, finalSha256: bytes ? hash(bytes) : null, finishedAt: new Date().toISOString() };
69
+ // Persist evidence before applying acceptance policy, including rejected calls.
70
+ await writeJson(path.join(spec.callDir, 'execution.json'), execution);
71
+ observationSaved = true;
72
+ const metadata = validateCompletion(execution, bytes ?? Buffer.alloc(0));
73
+ await writeJson(path.join(spec.callDir, 'completion.json'), metadata);
74
+ return { bytes: /** @type {Buffer} */ (bytes), metadata };
75
+ } finally {
76
+ await stdout.close(); await stderr.close();
77
+ if (observationSaved) await rm(home, { recursive: true, force: true });
78
+ }
79
+ }
@@ -0,0 +1,44 @@
1
+ import {writeFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {hash,writeJson} from './run-store.mjs';
4
+ import {initialSchema} from './contracts/initial.mjs';
5
+ import {normalizePlannerPayload} from './contracts/compile.mjs';
6
+ import {normalizeHtml} from './references.mjs';
7
+ import {invokeCodex,recoverCodex} from './codex/invoke.mjs';
8
+ import {prepareHome} from './codex/home.mjs';
9
+
10
+ /** @param {Buffer} bytes @param {any} source */
11
+ export function assessContract(bytes,source){
12
+ const compiled=normalizePlannerPayload(bytes.toString('utf8'),source);
13
+ if(!compiled.ok)return {...compiled,normalized:null};
14
+ const normalized=normalizeHtml(compiled.payload.html,compiled.plan.assets);
15
+ return {...compiled,ok:normalized.ok,normalized,issues:normalized.issues,recoveries:[...compiled.recoveries,...normalized.recoveries]};
16
+ }
17
+
18
+ /** @param {Buffer} bytes @param {any} assessment @param {any} source */
19
+ export function buildContractRepairPrompt(bytes,assessment,source){
20
+ return `Repair representation/contract defects in your saved screenshot conversion. This is not visual judging or polish. Preserve source intent, readable source text, HTML layout and all valid asset ownership decisions. Use the attached original source only to resolve the explicit issues below. Correct only what prevents a usable artifact; do not invent content, delete meaningful content to satisfy validation, or redesign the page. Normalize redundant representation in your answer instead of introducing another planning pass. Return a complete object in the original schema. No tools. This is the only bounded contract repair round. Source: ${source.width}x${source.height}.\nExplicit unresolved issues:\n${JSON.stringify(assessment.issues)}\nDeterministic representation changes already established (apply these while preserving other facts):\n${JSON.stringify(assessment.recoveries)}\nExact saved response:\n${bytes.toString('utf8')}`;
21
+ }
22
+
23
+ /** One durable extra call only after deterministic normalization cannot deliver.
24
+ * @param {{planner:any,source:any,sourcePath:string,runDir:string,store:any,authHome?:string,binary?:string,timeoutMs:number,invoke?:(spec:any)=>Promise<any>}} spec */
25
+ export async function resolveContract(spec){
26
+ const first=assessContract(spec.planner.bytes,spec.source);
27
+ await writeJson(path.join(spec.runDir,'contract-initial.json'),{responseSha256:hash(spec.planner.bytes),...first});
28
+ if(first.ok)return {...first,repair:null};
29
+ const prompt=buildContractRepairPrompt(spec.planner.bytes,first,spec.source);
30
+ const request={kind:'contract-repair-v1',parentResponseSha256:hash(spec.planner.bytes),issues:first.issues,normalizedChanges:first.recoveries,source:spec.source.sha256,promptSha256:hash(prompt),schemaSha256:hash(JSON.stringify(initialSchema)),model:'gpt-6-astra',effort:'medium'};
31
+ let preparedHome;
32
+ const repaired=await spec.store.runCall('contract-repair-1',request,async({callDir})=>{
33
+ if(!spec.authHome&&!spec.invoke)throw Error('authHome required for contract repair');
34
+ const schemaPath=path.join(callDir,'schema.json');await writeJson(schemaPath,initialSchema);await writeFile(path.join(callDir,'prompt.txt'),prompt);
35
+ return (spec.invoke??invokeCodex)({callDir,runDir:spec.runDir,authHome:spec.authHome,binary:spec.binary,sourcePath:spec.sourcePath,schemaPath,prompt,timeoutMs:spec.timeoutMs,reasoning:'medium',preparedHome});
36
+ },{recover:recoverCodex,prepare:async()=>{
37
+ if(spec.invoke)return;
38
+ if(!spec.authHome)throw Error('authHome required for contract repair');
39
+ preparedHome=(await prepareHome({authHome:spec.authHome,binary:spec.binary??'codex',runDir:spec.runDir,reasoning:'medium'})).home;
40
+ }});
41
+ const result=assessContract(repaired.bytes,spec.source);
42
+ await writeJson(path.join(spec.runDir,'contract-repair.json'),{responseSha256:hash(repaired.bytes),...result});
43
+ return {...result,repair:{metadata:repaired.metadata,reused:repaired.reused,responseSha256:hash(repaired.bytes)},initialIssues:first.issues};
44
+ }
@@ -0,0 +1,53 @@
1
+ import {normalizeDeclarations} from './normalize.mjs';
2
+ import {compileJointAssets} from './contracts.mjs';import {compileGroups} from './groups.mjs';
3
+ const str={type:'string'},arr=items=>({type:'array',items}),obj=properties=>({type:'object',properties,required:Object.keys(properties),additionalProperties:false});
4
+ export const operationSchema=obj({html:str,assets:arr(obj({id:str,source_bbox_px:arr({type:'integer'}),background:{type:'string',enum:['opaque','transparent']},request_group:str,operation:{type:'string',enum:['preserve-source','remove-named-overlays','crop-source']},desired_owner:str,named_removals:arr(obj({source_bbox_px:arr({type:'integer'}),text_or_element:str}))})),uncertainties:arr(str)});
5
+ function compile(result,source,{maxAssets=12,maxImageCalls=12}={}){
6
+ const compiled=compileJointAssets(result.assets.map(a=>({...a,prompt:a.desired_owner})),source,maxAssets);
7
+ const assets=compiled.map(a=>{const declared=result.assets.find(x=>x.id===a.id);if(declared.operation==='crop-source'&&declared.background!=='opaque')throw Error('Source crop requires an opaque owner');if(['preserve-source','crop-source'].includes(declared.operation)&&declared.named_removals.length)throw Error('Preservation owner declares removals');if(declared.operation==='remove-named-overlays'&&!declared.named_removals.length)throw Error('Removal operation has no named source overlays');
8
+ for(const r of declared.named_removals){const b=r.source_bbox_px;if(b.length!==4||!b.every(Number.isSafeInteger)||b[0]<0||b[1]<0||b[2]<=0||b[3]<=0||b[0]+b[2]>source.width||b[1]+b[3]>source.height||!r.text_or_element.trim())throw Error('Invalid named source removal');const o=a.owner;if(b[0]>=o.left+o.width||b[1]>=o.top+o.height||b[0]+b[2]<=o.left||b[1]+b[3]<=o.top)throw Error('Named removal does not intersect its owner');}
9
+ return {...a,operation:declared.operation,desired_owner:declared.desired_owner,named_removals:declared.named_removals};});
10
+ const declarations=[];for(const a of assets){let g=declarations.find(g=>g.key===a.request_group);if(!g){g={key:a.request_group,ids:[]};declarations.push(g);}g.ids.push(a.id);}
11
+ const groups=compileGroups(assets,source,declarations.map(g=>g.ids));if(groups.filter(g=>g.members[0].operation!=='crop-source').length>maxImageCalls)throw Error('Actual image requests exceed admission');for(const g of groups){if(new Set(g.members.map(a=>a.operation)).size!==1)throw Error('Group mixes incompatible operations');g.operation=g.members[0].operation;}
12
+ return {assets,groups,uncertainties:result.uncertainties};
13
+ }
14
+
15
+ /** Normalize redundant fields only; never invent an artwork or removal decision. */
16
+ export function validatePlannerPayload(raw, source, limits) {
17
+ const result = typeof raw === 'string' ? JSON.parse(raw) : raw;
18
+ if (!result || typeof result !== 'object' || typeof result.html !== 'string' || !Array.isArray(result.assets)) throw Error('Expected HTML and asset declarations');
19
+ if (!Number.isSafeInteger(source.width) || !Number.isSafeInteger(source.height) || source.width <= 0 || source.height <= 0) throw Error('Invalid source dimensions');
20
+ const recoveries = [];
21
+ const assets = result.assets.map((asset, index) => {
22
+ if (!asset || typeof asset !== 'object') throw Error(`Invalid asset ${index}`);
23
+ const a = {...asset};
24
+ if (a.request_group == null || a.request_group === '') { a.request_group = a.id; recoveries.push({asset:a.id,field:'request_group',to:a.id,basis:'independent owner default'}); }
25
+ if (typeof a.request_group !== 'string' || !a.request_group.trim()) throw Error('Invalid request group');
26
+ if (!['opaque','transparent'].includes(a.background)) throw Error('Invalid asset background');
27
+ if (!['preserve-source','remove-named-overlays','crop-source'].includes(a.operation)) throw Error('Invalid asset operation');
28
+ if (typeof a.desired_owner !== 'string' || !a.desired_owner.trim()) throw Error('Missing desired owner');
29
+ if (a.named_removals == null && ['preserve-source','crop-source'].includes(a.operation)) { a.named_removals = []; recoveries.push({asset:a.id,field:'named_removals',to:[],basis:'preserve-source declares no removals'}); }
30
+ if (!Array.isArray(a.named_removals)) throw Error('Expected named removals');
31
+ for (const r of a.named_removals) if (!r || !Array.isArray(r.source_bbox_px) || typeof r.text_or_element !== 'string') throw Error('Invalid named source removal');
32
+ return a;
33
+ });
34
+ const uncertainties = result.uncertainties ?? [];
35
+ if (!Array.isArray(uncertainties) || uncertainties.some(x => typeof x !== 'string')) throw Error('Invalid uncertainties');
36
+ if (result.uncertainties == null) recoveries.push({field:'uncertainties',to:[],basis:'no uncertainty statements supplied'});
37
+ const plan=compile({...result, assets, uncertainties}, source, limits);
38
+ for(const group of plan.groups)if(group.frame.normalization)recoveries.push({field:`group:${group.id}.frame`,basis:group.frame.normalization.basis,input:group.frame.input,providerOutput:group.frame.providerOutput,deliveryOutput:group.frame.output});
39
+ return {...plan, recoveries};
40
+ }
41
+ export const compileOperations = validatePlannerPayload;
42
+
43
+ /** A boundary outcome for the bounded repair workflow, rather than a terminal exception. */
44
+ export function normalizePlannerPayload(raw, source, limits) {
45
+ let payload=raw, recoveries=[];
46
+ try {
47
+ ({payload,recoveries}=normalizeDeclarations(raw));
48
+ const plan=validatePlannerPayload(payload,source,limits);
49
+ return {ok:true,payload,plan:{...plan,recoveries:[...recoveries,...plan.recoveries]},recoveries:[...recoveries,...plan.recoveries],issues:[]};
50
+ } catch(error) {
51
+ return {ok:false,payload,plan:null,recoveries,issues:[{code:'unresolved-plan-contract',message:String(error.message ?? error),repairable:true,requiresModel:true,instruction:'Preserve all valid artwork decisions. Resolve only the reported missing or contradictory source fact; do not invent geometry, delete owners, or replace artwork with a fallback.'}]};
52
+ }
53
+ }
@@ -0,0 +1,19 @@
1
+ import {realContextFrame} from './geometry.mjs';
2
+ function box(values,source){
3
+ if(!Array.isArray(values)||values.length!==4||!values.every(Number.isSafeInteger))throw Error('Expected four integer source coordinates');
4
+ const [left,top,width,height]=values;if(left<0||top<0||width<=0||height<=0||left+width>source.width||top+height>source.height)throw Error('Frame outside source');return {left,top,width,height};
5
+ }
6
+ function compilePlan(plan,source,maxAssets=12){
7
+ if(plan.assets.length>maxAssets)throw Error('Declared artwork exceeds admitted count');
8
+ const ids=new Set(),componentIds=new Set();
9
+ for(const a of plan.assets){if(!/^[a-z0-9_-]+$/.test(a.id)||ids.has(a.id))throw Error('Invalid or duplicate artwork id');ids.add(a.id);}
10
+ if(!plan.components.length)throw Error('No declared page components');
11
+ for(const c of plan.components){if(!c.id||componentIds.has(c.id))throw Error('Duplicate or empty component id');componentIds.add(c.id);box(c.source_frame,source);for(const id of c.artwork_ids)if(!ids.has(id))throw Error('Undeclared artwork reference');}
12
+ const assets=plan.assets.map(a=>{if(!plan.components.some(c=>c.artwork_ids.includes(a.id)))throw Error('Unowned artwork');const owner=box(a.source_bbox_px,source),frame=a.operation==='crop-source'?{supported:true,input:owner,output:{width:owner.width,height:owner.height},crop:{left:0,top:0,width:owner.width,height:owner.height}}:realContextFrame(source,owner);if(!frame.supported)throw Error(`Unsupported exact source frame for ${a.id}: ${'reason' in frame?frame.reason:'unsupported'}`);return {id:a.id,source_bbox_px:a.source_bbox_px,background:a.background,prompt:a.prompt,request_group:a.request_group,owner,frame,delivery:{path:a.id+'.png',width:owner.width*(a.operation==='crop-source'?1:2),height:owner.height*(a.operation==='crop-source'?1:2),nativeDisplayFrame:a.source_bbox_px,scale:a.operation==='crop-source'?1:2}};});
13
+ if(!assets.length&&!plan.no_artwork_reason.trim())throw Error('Missing artwork explanation');return {...plan,assets};
14
+ }
15
+
16
+ export function compileJointAssets(assets,source,maxAssets=12){
17
+ const proxy={style:'',components:assets.map(a=>({id:'owner-'+a.id,source_frame:a.source_bbox_px,editable_content:'',artwork_ids:[a.id],layout:''})),assets,no_artwork_reason:'Joint source-only response; zero-art suitability must be inspected',uncertainties:[]};
18
+ if(!assets.length)return [];return compilePlan(proxy,source,maxAssets).assets;
19
+ }
@@ -0,0 +1,38 @@
1
+ import {gridBoundaryFrame} from './grid-frame.mjs';
2
+ // Every input pixel and owner coordinate comes from the original source.
3
+ // A source-boundary provider-grid mapping is explicit; no padding or localization.
4
+ export function realContextFrame(source, target) {
5
+ for (const [key, value] of Object.entries({
6
+ sourceWidth: source.width, sourceHeight: source.height,
7
+ ...target,
8
+ })) if (!Number.isSafeInteger(value) || value < (key === 'left' || key === 'top' ? 0 : 1)) {
9
+ throw new RangeError(`Invalid source-frame ${key}`);
10
+ }
11
+ if (target.left + target.width > source.width || target.top + target.height > source.height) {
12
+ throw new RangeError('Target lies outside original source');
13
+ }
14
+ const up8 = n => Math.ceil(n / 8) * 8;
15
+ let best = null;
16
+ for (let width = up8(target.width); width <= Math.min(source.width, 1920); width += 8) {
17
+ const height = up8(Math.max(target.height, width / 3, 655360 / (4 * width)));
18
+ if (height > source.height || height > 1920 || height > width * 3
19
+ || width * height * 4 > 8294400) continue;
20
+ const area = width * height;
21
+ if (!best || area < best.area || (area === best.area && width < best.width)) {
22
+ best = { width, height, area };
23
+ }
24
+ }
25
+ if (!best) return gridBoundaryFrame(source,target) ?? { supported: false, reason: 'No legal exact-2x rectangle fits original source bounds' };
26
+ const left = Math.max(0, Math.min(source.width - best.width,
27
+ Math.floor(target.left - (best.width - target.width) / 2)));
28
+ const top = Math.max(0, Math.min(source.height - best.height,
29
+ Math.floor(target.top - (best.height - target.height) / 2)));
30
+ return {
31
+ supported: true,
32
+ input: { left, top, width: best.width, height: best.height },
33
+ output: { width: best.width * 2, height: best.height * 2 },
34
+ crop: { left: (target.left - left) * 2, top: (target.top - top) * 2,
35
+ width: target.width * 2, height: target.height * 2 },
36
+ inputAreaFraction: best.area / (source.width * source.height),
37
+ };
38
+ }
@@ -0,0 +1,22 @@
1
+ // Only used after the established exact-2x grid search fails. Source pixels and
2
+ // owner coordinates stay exact; only the provider's output canvas is rounded.
3
+ export function gridBoundaryFrame(source,target){
4
+ const up8=n=>Math.ceil(n/8)*8,up16=n=>Math.ceil(n/16)*16;
5
+ const widths=[];for(let width=up8(target.width);width<=Math.min(source.width,1920);width+=8)widths.push(width);
6
+ if(source.width%8&&source.width<=1920)widths.push(source.width);
7
+ let best=null;
8
+ for(const width of widths){
9
+ if(width<target.width)continue;
10
+ const roundedHeight=up8(Math.max(target.height,width/3,655360/(4*width)));
11
+ const height=roundedHeight>source.height&&roundedHeight-source.height<8?source.height:roundedHeight;
12
+ if(height<target.height||height>source.height||height>1920||!(width%8||height%8))continue;
13
+ const providerOutput={width:up16(width*2),height:up16(height*2)};
14
+ const {width:w,height:h}=providerOutput,area=w*h;
15
+ if(w>3840||h>3840||w>h*3||h>w*3||area<655360||area>8294400)continue;
16
+ if(!best||width*height<best.area||(width*height===best.area&&width<best.width))best={width,height,area:width*height,providerOutput};
17
+ }
18
+ if(!best)return null;
19
+ const left=Math.max(0,Math.min(source.width-best.width,Math.floor(target.left-(best.width-target.width)/2)));
20
+ const top=Math.max(0,Math.min(source.height-best.height,Math.floor(target.top-(best.height-target.height)/2)));
21
+ return {supported:/** @type {true} */(true),input:{left,top,width:best.width,height:best.height},output:{width:best.width*2,height:best.height*2},crop:{left:(target.left-left)*2,top:(target.top-top)*2,width:target.width*2,height:target.height*2},inputAreaFraction:best.area/(source.width*source.height),providerOutput:best.providerOutput,normalization:{name:'provider-grid-to-exact-frame',kernel:'lanczos3',basis:'Exact source boundary cannot fit the provider output grid; owner and input pixels unchanged'}};
22
+ }
@@ -0,0 +1,14 @@
1
+ import {realContextFrame} from './geometry.mjs';
2
+ // Groups keep the original spatial arrangement. They are never repacked into an atlas.
3
+ export function compileGroups(assets,source,declarations=assets.map(a=>[a.id])){
4
+ const used=new Set();const groups=declarations.map((ids,index)=>{
5
+ if(!ids.length)throw Error('Empty artwork request group');
6
+ const members=ids.map(id=>{const asset=assets.find(a=>a.id===id);if(!asset||used.has(id))throw Error('Unknown or repeated group owner');used.add(id);return asset;});
7
+ if(new Set(members.map(a=>a.background)).size!==1)throw Error('Conflicting group backgrounds');
8
+ for(const [i,a] of members.entries())for(const b of members.slice(i+1))if(a.owner.left<b.owner.left+b.owner.width&&b.owner.left<a.owner.left+a.owner.width&&a.owner.top<b.owner.top+b.owner.height&&b.owner.top<a.owner.top+a.owner.height)throw Error('Overlapping group owners require separate requests');
9
+ const left=Math.min(...members.map(a=>a.owner.left)),top=Math.min(...members.map(a=>a.owner.top));
10
+ const owner={left,top,width:Math.max(...members.map(a=>a.owner.left+a.owner.width))-left,height:Math.max(...members.map(a=>a.owner.top+a.owner.height))-top};
11
+ const local=members.every(a=>a.operation==='crop-source');const frame=local?{supported:true,input:owner,output:{width:owner.width,height:owner.height}}:realContextFrame(source,owner);if(!frame.supported)throw Error('Unsupported artwork group frame');
12
+ return {id:'group-'+String(index+1).padStart(2,'0'),background:members[0].background,frame,members:members.map(a=>({...a,frame:{...frame,crop:{left:(a.owner.left-frame.input.left)*2,top:(a.owner.top-frame.input.top)*2,width:a.owner.width*2,height:a.owner.height*2}}}))};
13
+ });if(used.size!==assets.length)throw Error('Ungrouped artwork owner');return groups;
14
+ }
@@ -0,0 +1,9 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ export const schemaPath = fileURLToPath(new URL('../../templates/initial.schema.json', import.meta.url));
4
+ export const initialSchema = JSON.parse(readFileSync(schemaPath, 'utf8'));
5
+ const prompt = readFileSync(new URL('../../templates/initial.txt', import.meta.url), 'utf8');
6
+ export function buildInitialPrompt({width,height}, cleanAssets = 'enhance') {
7
+ const preference = cleanAssets === 'crop' ? '\nUser clean-artwork preference: use crop-source for clean opaque owners needing no removal, transparency, enhancement or new details. crop-source delivers original pixels at native owner dimensions, with no 2x resampling. Declare named_removals: [] and reserve explicit source-sized CSS dimensions; do not rely on enhanced image intrinsic size. Keep preserve-source or remove-named-overlays for owners requiring AI work.' : '\nUser clean-artwork preference: enhance. Use preserve-source or remove-named-overlays as described; do not declare crop-source.';
8
+ return prompt + preference + `\nOriginal source dimensions ${width}×${height}. Maximum 12 actual assets and 12 compatible image-request groups. Inspect only the attached source image; do not use tools.`;
9
+ }
@@ -0,0 +1,46 @@
1
+ /** Resolve representation only. Geometry and artwork decisions are never guessed. */
2
+ export function normalizeDeclarations(raw) {
3
+ const recoveries=[];
4
+ let payload=raw;
5
+ if(typeof payload==='string') {
6
+ const trimmed=payload.trim(), fenced=trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i);
7
+ if(fenced) { payload=fenced[1]; recoveries.push({field:'payload',basis:'single JSON code fence removed'}); }
8
+ payload=JSON.parse(payload);
9
+ }
10
+ if(!payload || typeof payload!=='object') throw Error('Expected planner object');
11
+ payload=structuredClone(payload);
12
+ const set=(object,key,to,path,basis)=>{recoveries.push({field:path,from:object[key],to,basis});object[key]=to;};
13
+ const box=(object,key,path)=>{
14
+ const value=object[key];
15
+ if(Array.isArray(value) && value.length===4 && value.every(n=>Number.isSafeInteger(n) || typeof n==='string' && /^-?\d+$/.test(n.trim()) && Number.isSafeInteger(Number(n)))) {
16
+ const normalized=value.map(Number);
17
+ if(value.some((v,i)=>v!==normalized[i])) set(object,key,normalized,path,'lossless integer source coordinates');
18
+ }
19
+ };
20
+ if(payload.uncertainties==null) set(payload,'uncertainties',[],'uncertainties','no uncertainty statements supplied');
21
+ if(Array.isArray(payload.assets)) for(const [i,a] of payload.assets.entries()) {
22
+ if(!a || typeof a!=='object') continue;
23
+ const at=`assets[${i}]`;
24
+ for(const key of ['background','operation']) if(typeof a[key]==='string') {
25
+ const v=a[key].trim().toLowerCase();if(v!==a[key]) set(a,key,v,`${at}.${key}`,'enum spelling whitespace and case');
26
+ }
27
+ if(a.request_group==null || typeof a.request_group==='string' && !a.request_group.trim()) set(a,'request_group',a.id,`${at}.request_group`,'independent owner default');
28
+ if(['preserve-source','crop-source'].includes(a.operation) && a.named_removals==null) set(a,'named_removals',[],`${at}.named_removals`,'preserve-source declares no removals');
29
+ box(a,'source_bbox_px',`${at}.source_bbox_px`);
30
+ if(Array.isArray(a.named_removals)) for(const [j,r] of a.named_removals.entries()) if(r && typeof r==='object') box(r,'source_bbox_px',`${at}.named_removals[${j}].source_bbox_px`);
31
+ }
32
+ // Request grouping is an execution optimization, never an artwork ownership fact.
33
+ const groups=new Map();
34
+ for(const a of Array.isArray(payload.assets)?payload.assets:[]) if(a && typeof a.request_group==='string') groups.set(a.request_group,[...(groups.get(a.request_group) ?? []),a]);
35
+ let serial=0;
36
+ for(const [key,members] of groups) {
37
+ const validBox=a=>Array.isArray(a.source_bbox_px) && a.source_bbox_px.length===4 && a.source_bbox_px.every(Number.isSafeInteger);
38
+ const overlap=(a,b)=>{const [x,y,w,h]=a.source_bbox_px,[u,v,p,q]=b.source_bbox_px;return x<u+p && u<x+w && y<v+q && v<y+h;};
39
+ const incompatible=new Set(members.map(a=>a.background)).size>1 || new Set(members.map(a=>a.operation)).size>1 || members.every(validBox) && members.some((a,i)=>members.slice(i+1).some(b=>overlap(a,b)));
40
+ if(members.length>1 && incompatible) for(const a of members) {
41
+ let to;do {to=`normalized-group-${++serial}`;} while(groups.has(to));
42
+ set(a,'request_group',to,`asset:${a.id}.request_group`,'incompatible shared context split; exact owner and operation preserved');
43
+ }
44
+ }
45
+ return {payload,recoveries};
46
+ }
@@ -0,0 +1,26 @@
1
+ import {createServer} from 'node:http';
2
+ import {readFile} from 'node:fs/promises';
3
+ import {resolve,extname,sep} from 'node:path';
4
+ import {chromium} from 'playwright';
5
+ import sharp from 'sharp';
6
+ export async function serve(directory){const server=createServer(async(req,res)=>{try{const file=resolve(directory,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname));if(file!==resolve(directory)&&!file.startsWith(resolve(directory)+sep))throw Error('Outside root');const path=extname(file)?file:resolve(file,'index.html');res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp','.jpg':'image/jpeg','.woff2':'font/woff2'})[extname(path)]??'application/octet-stream');res.end(await readFile(path));}catch{res.statusCode=404;res.end();}});await new Promise(r=>server.listen(0,'127.0.0.1',()=>r(undefined)));return {url:`http://127.0.0.1:${/** @type {import('node:net').AddressInfo} */ (server.address()).port}`,close:()=>new Promise((r,j)=>server.close(e=>e?j(e):r(undefined)))};}
7
+ export async function render({outputDir,htmlFile,target,width,height,paginate=false}) {
8
+ const hosted=await serve(outputDir);let browser;
9
+ try {
10
+ browser=await chromium.launch({headless:true});
11
+ const page=await browser.newPage({viewport:{width,height},deviceScaleFactor:1});
12
+ await page.goto(`${hosted.url}/${htmlFile}`,{waitUntil:'networkidle'});
13
+ await page.evaluate(()=>document.fonts.ready);
14
+ if(target==='pdf') {
15
+ await page.emulateMedia({media:'screen'});
16
+ const contentHeight=await page.evaluate(()=>Math.ceil(Math.max(document.documentElement.scrollHeight,document.body.scrollHeight,document.documentElement.getBoundingClientRect().bottom,document.body.getBoundingClientRect().bottom)));
17
+ const pageHeight=paginate?height:contentHeight;
18
+ await page.pdf({path:resolve(outputDir,'render.pdf'),width:`${width}px`,height:`${pageHeight}px`,printBackground:true});
19
+ return {pdfPageWidth:width,pdfPageHeight:pageHeight,pdfPagination:paginate?'explicit height':'full content'};
20
+ }
21
+ const png=await page.screenshot({fullPage:true});
22
+ const dimensions=await sharp(png).metadata();
23
+ await sharp(png).toFormat(target==='jpg'?'jpeg':target).toFile(resolve(outputDir,`render.${target}`));
24
+ return {renderWidth:dimensions.width,renderHeight:dimensions.height};
25
+ } finally {await browser?.close();await hosted.close();}
26
+ }
@@ -0,0 +1,34 @@
1
+ import {serialize} from 'parse5';
2
+
3
+ // Match the javascript URL boundary in the bundled React 19.2 DOM renderer.
4
+ // parse5 has already decoded entities; browsers ignore CR/LF/TAB in schemes.
5
+ const javascriptProtocol=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;
6
+ const urlAttributes=new Set(['href','src','action','formaction','xlink:href']);
7
+
8
+
9
+ export function ownsEditableMarkup(node) {
10
+ const value=node.attrs?.find(attribute=>attribute.name==='contenteditable')?.value.toLowerCase();
11
+ return value===''||value==='true'||value==='plaintext-only';
12
+ }
13
+
14
+ /** Editable descendants belong to the browser, not React's child reconciler. */
15
+ export function editableMarkup(node,seams) {
16
+ function clean(parent) {
17
+ parent.childNodes=(parent.childNodes??[]).filter(child=>{
18
+ if(child.tagName==='script'){seams.push('Source script omitted; implement application behavior explicitly.');return false;}
19
+ child.attrs=child.attrs?.filter(attribute=>{
20
+ if(/^on/i.test(attribute.name)){seams.push(`Source ${attribute.name} handler omitted.`);return false;}
21
+ const name=attribute.prefix?`${attribute.prefix}:${attribute.name}`:attribute.name;
22
+ if((urlAttributes.has(name)||(name==='data'&&child.tagName==='object'))&&javascriptProtocol.test(attribute.value)){
23
+ seams.push(`Source javascript: ${name} URL omitted.`);return false;
24
+ }
25
+ return true;
26
+ });
27
+ clean(child);
28
+ if(child.content)clean(child.content);
29
+ return true;
30
+ });
31
+ }
32
+ clean(node);
33
+ return serialize(node);
34
+ }
@@ -0,0 +1,7 @@
1
+ import {cp,mkdir,readFile,writeFile,readdir} from 'node:fs/promises';
2
+ import {resolve,relative,sep,dirname} from 'node:path';
3
+ import {createHash} from 'node:crypto';
4
+ export const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
5
+ export async function inventory(directory){const out=[];async function walk(dir){for(const entry of await readdir(dir,{withFileTypes:true})){const p=resolve(dir,entry.name);if(entry.isSymbolicLink())throw Error('Symbolic links are not portable output assets');if(entry.isDirectory())await walk(p);else{const b=await readFile(p);out.push({path:relative(directory,p),bytes:b.length,sha256:sha(b)});}}}await walk(directory);return out.sort((a,b)=>a.path.localeCompare(b.path));}
6
+ export async function copyExclusive(sourceDir,outputDir){sourceDir=resolve(sourceDir);outputDir=resolve(outputDir);if(outputDir===sourceDir||outputDir.startsWith(sourceDir+sep))throw Error('Output must be outside source directory');await inventory(sourceDir);await mkdir(dirname(outputDir),{recursive:true});await mkdir(outputDir);await cp(sourceDir,outputDir,{recursive:true});}
7
+ export async function refreshManifest(outputDir,changes={}){let original={};try{original=JSON.parse(await readFile(resolve(outputDir,'manifest.json'),'utf8'));}catch(error){if(error.code!=='ENOENT')throw error;}const files=(await inventory(outputDir)).filter(f=>!['manifest.json','export-manifest.json'].includes(f.path)).map(f=>({...original.files?.find(x=>x.path===f.path),...f}));const manifest={...original,...changes,files};await writeFile(resolve(outputDir,'manifest.json'),JSON.stringify(manifest,null,2)+'\n');return manifest;}
@@ -0,0 +1,24 @@
1
+ import {readFile,writeFile} from 'node:fs/promises';
2
+ import {resolve,dirname,sep} from 'node:path';
3
+ import {createRequire} from 'node:module';
4
+ import {build} from 'esbuild';
5
+ import {toReact} from './react.mjs';
6
+ import {copyExclusive,inventory,refreshManifest} from './files.mjs';
7
+ import {render} from './browser.mjs';
8
+ import {exportViewport} from './viewport.mjs';
9
+ const require=createRequire(import.meta.url);
10
+ export async function buildReact(outputDir){await build({entryPoints:[resolve(outputDir,'main.jsx')],bundle:true,outfile:resolve(outputDir,'app.js'),nodePaths:['react','react-dom'].map(name=>resolve(dirname(require.resolve(name+'/package.json')),'..')),jsx:'automatic',external:['*.png','*.jpg','*.jpeg','*.webp','*.svg','*.woff','*.woff2'],logLevel:'silent'});}
11
+ export async function exportOutput({sourceDir,htmlFile='index.html',outputDir,target,width=undefined,height=undefined}){
12
+ if(!['assets','png','jpg','webp','pdf','react'].includes(target))throw Error(`Unsupported target: ${target}`);
13
+ sourceDir=resolve(sourceDir);outputDir=resolve(outputDir);const sourceFile=resolve(sourceDir,htmlFile);if(!sourceFile.startsWith(sourceDir+sep))throw Error('HTML must be within source directory');
14
+ const viewport=await exportViewport(sourceDir,width,height);
15
+ const html=await readFile(sourceFile,'utf8');await copyExclusive(sourceDir,outputDir);await writeFile(resolve(outputDir,'original.html'),html);
16
+ const metadata={target,width:viewport.width,height:viewport.height,viewportBasis:viewport.basis,sourceHtml:htmlFile,originalHtml:'original.html'};
17
+ if(target==='react'){
18
+ const result=await toReact(html);Object.assign(metadata,{cssScope:result.cssScope,unresolvedActions:result.seams});await writeFile(resolve(outputDir,'ConvertedPage.jsx'),result.code);
19
+ await writeFile(resolve(outputDir,'main.jsx'),`import React from 'react';import{createRoot}from'react-dom/client';import ConvertedPage from './ConvertedPage.jsx';createRoot(document.getElementById('root')).render(<ConvertedPage/>);`);
20
+ await writeFile(resolve(outputDir,'index.html'),'<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root" style="display:contents"></div><script type="module" src="./app.js"></script></body></html>');
21
+ await writeFile(resolve(outputDir,'package.json'),JSON.stringify({name:'converted-page',private:true,type:'module',scripts:{build:'esbuild main.jsx --bundle --outfile=app.js --loader:.js=jsx --external:*.png --external:*.jpg --external:*.jpeg --external:*.webp --external:*.svg --external:*.woff --external:*.woff2'},dependencies:{react:'19.2.0','react-dom':'19.2.0'},devDependencies:{esbuild:'0.27.7'}},null,2));await buildReact(outputDir);
22
+ }else if(target!=='assets'){Object.assign(metadata,await render({outputDir,htmlFile,target,...viewport}));if(target==='pdf')metadata.pdfSemantics='browser-render PDF; not native LayerDoc PDF';}
23
+ await refreshManifest(outputDir,{format:target,entrypoint:target==='react'?'index.html':htmlFile});metadata.files=(await inventory(outputDir)).filter(f=>f.path!=='export-manifest.json');await writeFile(resolve(outputDir,'export-manifest.json'),JSON.stringify(metadata,null,2)+'\n');return metadata;
24
+ }