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
|
- ./lib:/app/lib:ro
|
||||||
- ./web:/app/web:ro
|
- ./web:/app/web:ro
|
||||||
- ./sx/sx:/app/sx:ro
|
- ./sx/sx:/app/sx:ro
|
||||||
|
- ./sx/sxc:/app/sxc:ro
|
||||||
- ./shared:/app/shared:ro
|
- ./shared:/app/shared:ro
|
||||||
# OCaml binary (rebuild with: cd hosts/ocaml && eval $(opam env) && dune build)
|
# 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
|
- ./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 "VOID_ELEMENTS" void_elements);
|
||||||
ignore (env_bind global_env "BOOLEAN_ATTRS" boolean_attrs);
|
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 --- *)
|
(* --- Error handling --- *)
|
||||||
bind "cek-try" (fun args ->
|
bind "cek-try" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// sx-inspect.js — SX-aware Playwright page inspector
|
// sx-inspect.js — SX-aware Playwright page inspector
|
||||||
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
|
// 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
|
// Output: JSON to stdout
|
||||||
|
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
@@ -1047,6 +1047,255 @@ async function modeReactive(page, url, island, actionsStr) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main
|
// 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() {
|
async function main() {
|
||||||
const argsJson = process.argv[2] || '{}';
|
const argsJson = process.argv[2] || '{}';
|
||||||
let args;
|
let args;
|
||||||
@@ -1096,6 +1345,15 @@ async function main() {
|
|||||||
case 'reactive':
|
case 'reactive':
|
||||||
result = await modeReactive(page, url, args.island || '', args.actions || '');
|
result = await modeReactive(page, url, args.island || '', args.actions || '');
|
||||||
break;
|
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:
|
default:
|
||||||
result = { error: `Unknown mode: ${mode}` };
|
result = { error: `Unknown mode: ${mode}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user