Fix source display: add sxc mount, HTML tag forms, Playwright debug tools

- Add missing ./sx/sxc:/app/sxc:ro volume mount to dev-sx compose —
  container was using stale image copy of docs.sx where ~docs/code had
  (&key code) instead of (&key src), so highlight output was silently
  discarded
- Register HTML tags as special forms in WASM browser kernel so keyword
  attrs are preserved during render-to-dom
- Add trace-boot, hydrate-debug, eval-at modes to sx-inspect.js for
  debugging boot phases and island hydration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 21:39:32 +00:00
parent 83b4afcd7a
commit 75827b4828
3 changed files with 288 additions and 1 deletions

View File

@@ -17,6 +17,7 @@ services:
- ./lib:/app/lib:ro
- ./web:/app/web:ro
- ./sx/sx:/app/sx:ro
- ./sx/sxc:/app/sxc:ro
- ./shared:/app/shared:ro
# OCaml binary (rebuild with: cd hosts/ocaml && eval $(opam env) && dune build)
- ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro

View File

@@ -684,6 +684,34 @@ let () =
ignore (env_bind global_env "VOID_ELEMENTS" void_elements);
ignore (env_bind global_env "BOOLEAN_ATTRS" boolean_attrs);
(* --- HTML tag special forms (div, span, h1, ...) --- *)
(* Registered as custom special forms so keywords are preserved.
Handler receives (raw-args env), evaluates non-keyword args
while keeping keyword names intact. *)
let eval_tag_args raw_args env =
let args = Sx_runtime.sx_to_list raw_args in
let rec process = function
| [] -> []
| (Keyword _ as kw) :: value :: rest ->
(* keyword + its value: keep keyword, evaluate value *)
kw :: Sx_ref.eval_expr value env :: process rest
| (Keyword _ as kw) :: [] ->
(* trailing keyword with no value — boolean attr *)
[kw]
| expr :: rest ->
(* non-keyword: evaluate *)
Sx_ref.eval_expr expr env :: process rest
in
process args
in
List.iter (fun tag ->
ignore (Sx_ref.register_special_form (String tag)
(NativeFn ("sf:" ^ tag, fun handler_args ->
match handler_args with
| [raw_args; env] -> List (Symbol tag :: eval_tag_args raw_args env)
| _ -> Nil)))
) Sx_render.html_tags;
(* --- Error handling --- *)
bind "cek-try" (fun args ->
match args with

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
// sx-inspect.js — SX-aware Playwright page inspector
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
// Modes: inspect, diff, hydrate, eval, interact, screenshot
// Modes: inspect, diff, hydrate, eval, interact, screenshot, trace-boot, hydrate-debug, eval-at
// Output: JSON to stdout
const { chromium } = require('playwright');
@@ -1047,6 +1047,255 @@ async function modeReactive(page, url, island, actionsStr) {
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
// Mode: trace-boot — full console capture during boot (ALL prefixes)
// ---------------------------------------------------------------------------
async function modeTraceBoot(page, url, filterPrefix) {
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
page.on('pageerror', err => {
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Categorise
const phases = { platform: [], boot: [], errors: [], warnings: [], other: [] };
for (const l of allLogs) {
if (l.type === 'error' || l.type === 'pageerror') phases.errors.push(l);
else if (l.type === 'warning') phases.warnings.push(l);
else if (l.text.startsWith('[sx-platform]')) phases.platform.push(l);
else if (l.text.startsWith('[sx]')) phases.boot.push(l);
else phases.other.push(l);
}
// Apply optional prefix filter
const filtered = filterPrefix
? allLogs.filter(l => l.text.includes(filterPrefix))
: null;
return {
url,
total: allLogs.length,
summary: {
platform: phases.platform.length,
boot: phases.boot.length,
errors: phases.errors.length,
warnings: phases.warnings.length,
other: phases.other.length,
},
phases,
...(filtered ? { filtered } : {}),
};
}
// ---------------------------------------------------------------------------
// Mode: hydrate-debug — re-run hydration on one island with full tracing
// ---------------------------------------------------------------------------
async function modeHydrateDebug(page, url, islandName) {
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
page.on('pageerror', err => {
allLogs.push({ type: 'pageerror', text: err.message.slice(0, 500) });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Capture SSR HTML before re-hydration
const ssrHtml = await page.evaluate((name) => {
const el = name
? document.querySelector(`[data-sx-island="${name}"]`)
: document.querySelector('[data-sx-island]');
return el ? { name: el.getAttribute('data-sx-island'), html: el.innerHTML.substring(0, 2000) } : null;
}, islandName);
if (!ssrHtml) {
return { url, error: `Island not found: ${islandName || '(first)'}` };
}
const targetIsland = ssrHtml.name;
// Mark console position
const logCursorBefore = allLogs.length;
// Run instrumented re-hydration: clear flag, inject tracing, re-hydrate
const result = await page.evaluate((name) => {
const K = globalThis.SxKernel;
if (!K) return { error: 'SxKernel not available' };
const el = document.querySelector(`[data-sx-island="${name}"]`);
if (!el) return { error: `Element not found: ${name}` };
// Clear hydration flag
el.removeAttribute('data-sx-hydrated');
delete el._sxBoundislandhydrated;
delete el['_sxBound' + 'island-hydrated'];
// Check env state
const compName = '~' + name;
const checks = {};
checks.compType = K.eval(`(type-of ${compName})`);
checks.renderDomList = K.eval('(type-of render-dom-list)');
checks.cssx = K.eval('(type-of ~cssx/tw)');
checks.stateAttr = el.getAttribute('data-sx-state') || '{}';
// Parse state
window.__hd_state = checks.stateAttr;
checks.stateParsed = K.eval('(inspect (or (first (sx-parse (host-global "__hd_state"))) {}))');
// Get component params
checks.params = K.eval(`(inspect (component-params (env-get (global-env) "${compName}")))`);
// Manual render with error capture (NOT cek-try — let errors propagate)
let renderResult;
try {
window.__hd_state2 = checks.stateAttr;
const rendered = K.eval(`
(let ((comp (env-get (global-env) "${compName}"))
(kwargs (or (first (sx-parse (host-global "__hd_state2"))) {})))
(let ((local (env-merge (component-closure comp) (get-render-env nil))))
(for-each
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
(let ((el (render-to-dom (component-body comp) local nil)))
(host-get el "outerHTML"))))
`);
renderResult = { ok: true, html: (typeof rendered === 'string' ? rendered.substring(0, 2000) : String(rendered).substring(0, 2000)) };
} catch (e) {
renderResult = { ok: false, error: e.message };
}
// Now run the actual hydrate-island via boot.sx
try {
K.eval(`(hydrate-island (host-global "__hd_el"))`);
} catch (e) {
// ignore — we already got the manual render
}
// Capture hydrated HTML
const hydratedHtml = el.innerHTML.substring(0, 2000);
delete window.__hd_state;
delete window.__hd_state2;
return { checks, renderResult, hydratedHtml };
}, targetIsland);
// Collect console messages from the re-hydration
const hydrateLogs = allLogs.slice(logCursorBefore);
// Compare SSR vs hydrated
const ssrClasses = (ssrHtml.html.match(/class="[^"]*"/g) || []).length;
const hydClasses = (result.hydratedHtml || '').match(/class="[^"]*"/g) || [];
const manualClasses = (result.renderResult?.html || '').match(/class="[^"]*"/g) || [];
return {
url,
island: targetIsland,
ssrHtml: ssrHtml.html.substring(0, 500),
checks: result.checks,
manualRender: result.renderResult,
hydratedHtml: (result.hydratedHtml || '').substring(0, 500),
classCounts: {
ssr: ssrClasses,
manual: manualClasses.length,
hydrated: hydClasses.length,
},
hydrateLogs: hydrateLogs.length > 0 ? hydrateLogs : undefined,
};
}
// ---------------------------------------------------------------------------
// Mode: eval-at — inject eval breakpoint at a specific boot phase
// ---------------------------------------------------------------------------
async function modeEvalAt(browser, url, phase, expr) {
const validPhases = [
'before-modules', 'after-modules',
'before-pages', 'after-pages',
'before-components', 'after-components',
'before-hydrate', 'after-hydrate',
'after-boot',
];
if (!validPhases.includes(phase)) {
return { error: `Invalid phase: ${phase}. Valid: ${validPhases.join(', ')}` };
}
const context = await browser.newContext();
const page = await context.newPage();
const allLogs = [];
page.on('console', msg => {
allLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
// Inject a hook that pauses the boot at the desired phase.
// We do this by intercepting sx-platform-2.js and injecting eval calls.
await page.route('**/*sx-platform-2.js*', async (route) => {
const resp = await route.fetch();
let body = await resp.text();
// Pass expression via a global to avoid JS/SX quoting hell.
// K.eval() handles parsing internally, so we just pass the SX string.
// Also set up common DOM helpers as globals for easy SX access.
const evalCode = [
`window.__sxEvalAtExpr = ${JSON.stringify(expr)};`,
`window.__sxEl0 = document.querySelectorAll('[data-sx-island]')[0] || null;`,
`window.__sxEl1 = document.querySelectorAll('[data-sx-island]')[1] || null;`,
`try { console.log("[sx-eval-at] ${phase}: " + K.eval(window.__sxEvalAtExpr)); }`,
`catch(e) { console.log("[sx-eval-at] ${phase}: JS-ERROR: " + e.message); }`,
].join('\n ');
// Map phase names to injection points in the platform code
const injections = {
'before-modules': { before: 'loadWebStack();' },
'after-modules': { after: 'loadWebStack();' },
'before-pages': { before: 'K.eval("(process-page-scripts)");' },
'after-pages': { after: 'K.eval("(process-page-scripts)");' },
'before-components': { before: 'K.eval("(process-sx-scripts nil)");' },
'after-components': { after: 'K.eval("(process-sx-scripts nil)");' },
'before-hydrate': { before: 'K.eval("(sx-hydrate-islands nil)");' },
'after-hydrate': { after: 'K.eval("(sx-hydrate-islands nil)");' },
'after-boot': { after: 'console.log("[sx] boot done");' },
};
const inj = injections[phase];
if (inj.before) {
body = body.replace(inj.before, evalCode + '\n ' + inj.before);
} else if (inj.after) {
body = body.replace(inj.after, inj.after + '\n ' + evalCode);
}
await route.fulfill({ body, headers: { ...resp.headers(), 'content-type': 'application/javascript' } });
});
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
// Extract our eval-at result from console
const evalAtLogs = allLogs.filter(l => l.text.startsWith('[sx-eval-at]'));
const evalResult = evalAtLogs.length > 0
? evalAtLogs[0].text.replace(`[sx-eval-at] ${phase}: `, '')
: 'INJECTION FAILED — phase marker not found in platform code';
// Also collect boot sequence for context
const bootLogs = allLogs.filter(l =>
l.text.startsWith('[sx]') || l.text.startsWith('[sx-platform]') || l.text.startsWith('[sx-eval-at]') ||
l.type === 'error' || l.type === 'warning'
);
await context.close();
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
}
async function main() {
const argsJson = process.argv[2] || '{}';
let args;
@@ -1096,6 +1345,15 @@ async function main() {
case 'reactive':
result = await modeReactive(page, url, args.island || '', args.actions || '');
break;
case 'trace-boot':
result = await modeTraceBoot(page, url, args.filter || '');
break;
case 'hydrate-debug':
result = await modeHydrateDebug(page, url, args.island || '');
break;
case 'eval-at':
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
break;
default:
result = { error: `Unknown mode: ${mode}` };
}