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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user