Skip to content

Add ESM playground at /playground/esm#325

Merged
brianmhunt merged 11 commits into
mainfrom
playground/esm
Apr 17, 2026
Merged

Add ESM playground at /playground/esm#325
brianmhunt merged 11 commits into
mainfrom
playground/esm

Conversation

@brianmhunt
Copy link
Copy Markdown
Member

@brianmhunt brianmhunt commented Apr 16, 2026

Summary

Adds a second playground at /playground/esm that demonstrates TKO running natively in the browser β€” import maps, a <script type="module">, and zero build step.

Depends on #324 (the shell extraction refactor).

What's in the box

  • src/playground/runner-esm.ts β€” ~60 lines. Single tab (index.html), no esbuild, passes user HTML through to the iframe with a small console-forwarding snippet injected.
  • src/playground/entry-esm.ts β€” page entry. Same CodeMirror URL imports as IIFE; just uses a different runner.
  • src/pages/playground/esm.astro β€” mirrors playground.astro, ~15 lines.

The shell (shell.ts) and shared styles didn't need a single change to support this second runner. That's the whole point of the refactor in #324.

Default example

<script type="importmap">
{ "imports": { "@tko/build.reference": "https://esm.sh/@tko/build.reference" } }
</script>

<div id="root">
  <h1>Count: <span data-bind="text: count"></span></h1>
  <button data-bind="click: increment">Increment</button>
</div>

<script type="module">
  import ko from '@tko/build.reference'
  const vm = {
    count: ko.observable(0),
    increment() { vm.count(vm.count() + 1) }
  }
  ko.applyBindings(vm, document.getElementById('root'))
</script>

Copy that HTML to any static host β†’ it works. That's the story the ESM playground is here to tell.

Why two pages, not a toggle

The playgrounds demonstrate genuinely different things:

  • /playground β€” global tko, JSX, esbuild compiles TSX in-browser
  • /playground/esm β€” import maps, data-bind HTML, no build step

Branching on mode inside one file would intermingle two disparate concepts and turn into spaghetti. Two pages + a shared shell keep the concepts isolated.

Test plan

  • /playground/esm loads
  • Single index.html tab with importmap + module script
  • Status shows "ESM (no build step)" (not "Loading esbuild...")
  • Iframe renders "Count: 0" + Increment (proves @tko/build.reference imported from esm.sh and applyBindings ran)
  • No errors in error bar
  • Console capture + clear still work (shared shell)

πŸ€– Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • New ESM playground page and an alternate IIFE playground mode; preview console now forwards runtime logs/errors to the UI.
    • Playground loads assets/styles externally for faster, modular page behavior; header β€œPlayground” link now opens the ESM playground.
  • Documentation

    • Reworked deployment docs into a consolidated β€œDeploy in Seconds” guide demonstrating zero-build ESM deployments; legacy single-file deploy doc removed.
  • Refactor

    • Playground UI and runners reorganized for clearer modular structure and maintainability.

Splits the 565-line playground.astro into compartmentalized pieces:

- styles.css: the CSS block, unchanged
- runner.ts: Runner interface (title, tabs, init, buildSrcdoc)
- shell.ts: runner-agnostic UI (editors, tabs, console, status, URL hash)
- runner-iife.ts: current esbuild.transform + IIFE srcdoc behavior
- entry-iife.ts: page entry; wires CodeMirror/esbuild URL imports + runner
- playground.astro: ~15 lines, just <head> + mount point + script

URL hash format preserved (flat {html, js}) so existing shared URLs keep
working. Dependency injection pattern keeps URL imports (esbuild-wasm,
CodeMirror, esm.sh) at the page entry; shell/runner modules stay pure TS
that Vite bundles cleanly.

No behavior change β€” prepares the ground for a separate /playground/esm
page that reuses shell.ts with a different runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 19:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 16, 2026

Caution

Review failed

Pull request was closed or merged during review

πŸ“ Walkthrough

Walkthrough

Refactors the inline playground into a modular system: adds ESM and IIFE runners, a shell, entry modules, console-forwarding, and stylesheet; updates the Header link to /playground/esm; replaces the old playground page; and moves/rewrites deploy docs to an ESM-based deployment guide. (50 words)

Changes

Cohort / File(s) Summary
Navigation & Pages
src/components/Header.astro, src/pages/playground.astro, src/pages/playground/esm.astro
Header Playground link now targets /playground/esm; playground.astro stripped of inline styles/scripts and reduced to minimal mount point; new full-page ESM playground added at playground/esm.astro.
Playground Entry Modules
src/playground/entry-esm.ts, src/playground/entry-iife.ts
Added two entry bootstraps that import CodeMirror runtimes via URL and call mount() with respective runner factories. Review import URL handling and ts-ignore annotations.
Runner Types & Implementations
src/playground/runner.ts, src/playground/runner-esm.ts, src/playground/runner-iife.ts
New runner interface/types and two implementations: ESM runner (no build step, returns default HTML) and IIFE runner (uses esbuild wasm to compile TSX→JS). Check build/init status flows and exported API shapes.
Shell & Console Forwarding
src/playground/shell.ts, src/playground/console-forward.ts
New mount() shell builds editors, persists state to URL hash, debounces runs, handles iframe messaging, and renders console/errors; console-forward utilities inject postMessage-based console/error forwarding and escape script bodies. High-density logic β€” review carefully.
Styling
src/playground/styles.css, src/pages/playground imports
Extracted playground CSS into new stylesheet; playground.astro and playground/esm.astro import this file. Verify responsive rules and CodeMirror integration.
Plugin: playground links
tko.io/plugins/playground-button.js
Playground button generator now detects ESM/importmap HTML and targets /playground/esm; encoding omits js when undefined and allows custom target. Check hash payload format and target behavior.
Documentation
src/content/docs/getting-started/deploy.md, src/content/docs/getting-started/deploy.mdx
Replaced old deploy.md (removed) and added deploy.mdx with ESM-focused "Deploy in Seconds" examples and hosting steps. Confirm intended doc presence/route and content parity.

Sequence Diagram

sequenceDiagram
    participant User
    participant Editor as CodeMirror Editor
    participant Shell as Playground Shell
    participant Runner as Runner (ESM/IIFE)
    participant Iframe as Preview iframe
    participant IframeWindow as iframe Window

    User->>Editor: Edit code
    Note over Editor: Debounced change event
    Editor->>Shell: onChange
    Shell->>Shell: persist to URL hash
    Shell->>Runner: buildSrcdoc(files)

    alt ESM Runner
        Runner->>Runner: prepare HTML (no build)
        Runner-->>Shell: return srcdoc HTML (with console forward)
    else IIFE Runner
        Runner->>Runner: compile TSX via esbuild
        Runner->>Runner: inject console forward + compiled JS
        Runner-->>Shell: return srcdoc HTML
    end

    Shell->>Iframe: set iframe.srcdoc
    Iframe->>IframeWindow: execute HTML/JS
    IframeWindow-->>Shell: postMessage(console/errors)
    Shell->>Shell: render console & status
    Shell->>User: show output/status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A bunny hops to mount and run,
ESM bells and IIFE drum,
Tabs and frames, a console’s cheer,
Hashes save what we hold dear,
Hooray β€” the playground’s hoppin’ fun! πŸ₯•πŸŽ‰

πŸš₯ Pre-merge checks | βœ… 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (2 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The PR title 'Add ESM playground at /playground/esm' directly and clearly summarizes the main change: adding a new ESM playground page at the specified route.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch playground/esm

Warning

Review ran into problems

πŸ”₯ Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Brian M Hunt and others added 2 commits April 16, 2026 15:16
Codex flagged: wrapping the editor mounts in a data-role="editor-mounts"
div broke .editor-mount { flex: 1 } because the wrapper is a plain block,
not a flex container. CodeMirror collapsed to content height (~27px)
instead of filling the panel.

Fix: drop the wrapper, append mounts directly to .editor-panel (which
is already display: flex, flex-direction: column). Matches the original
DOM layout exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single index.html tab, importmap β†’ esm.sh, module script. No esbuild, no
JSX, no build step. The iframe srcdoc is the user's HTML verbatim (plus a
console-forwarding snippet).

Reuses the shell extracted in the previous refactor; only runner-esm.ts
and entry-esm.ts are new runtime code. The IIFE and ESM runners share
zero mode-specific logic β€” they meet only at the Runner interface.

Why split pages instead of toggling: the two playgrounds tell different
stories. IIFE = "one script tag, global tko, JSX via build." ESM =
"import maps, zero build, copy-and-deploy." Honest URLs let docs link
the right one per context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a second docs-site playground at /playground/esm to demonstrate running TKO natively in the browser via import maps + <script type="module"> with no build step, reusing the extracted playground shell from the prior refactor.

Changes:

  • Introduces an ESM runner that renders a single index.html tab and forwards iframe console output to the shell UI.
  • Adds a new /playground/esm Astro page + entry module wiring to mount the shell with the ESM runner.
  • Extracts shared playground CSS into src/playground/styles.css and updates /playground to use the modular shell entry.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tko.io/src/playground/styles.css New shared playground styling (moved from inline <style>).
tko.io/src/playground/shell.ts Runner-agnostic UI shell: editors/tabs/iframe/status/console + URL hash persistence.
tko.io/src/playground/runner.ts Defines the Runner interface + tab/status types.
tko.io/src/playground/runner-iife.ts IIFE/esbuild-backed runner implementing Runner for the existing playground.
tko.io/src/playground/runner-esm.ts New ESM/no-build runner that injects console forwarding into user HTML.
tko.io/src/playground/entry-iife.ts Entry wiring for /playground to mount shell with the IIFE runner.
tko.io/src/playground/entry-esm.ts Entry wiring for /playground/esm to mount shell with the ESM runner.
tko.io/src/pages/playground.astro Simplified page template for /playground using shared CSS + entry module.
tko.io/src/pages/playground/esm.astro New page template for /playground/esm using shared CSS + entry module.

Comment thread tko.io/src/playground/runner-esm.ts Outdated
const headClose = /<\/head>/i
if (headClose.test(html)) return html.replace(headClose, `${CONSOLE_FORWARD}</head>`)
const bodyOpen = /<body[^>]*>/i
if (bodyOpen.test(html)) return html.replace(bodyOpen, (m) => `${m}${CONSOLE_FORWARD}`)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

injectConsoleForward() can prepend the console-forwarding <script> before <!doctype html> (when there is no <head>/<body> tag), which makes the doctype no longer the first token and can trigger quirks mode / unexpected parsing. Consider either requiring a full <html><head>…</head><body>…</body></html> document for the ESM tab, or explicitly inserting after an initial doctype / <html> start tag so the doctype stays first.

Suggested change
if (bodyOpen.test(html)) return html.replace(bodyOpen, (m) => `${m}${CONSOLE_FORWARD}`)
if (bodyOpen.test(html)) return html.replace(bodyOpen, (m) => `${m}${CONSOLE_FORWARD}`)
const leadingDoctype = /^(\s*<!doctype[^>]*>)/i
if (leadingDoctype.test(html)) return html.replace(leadingDoctype, `$1${CONSOLE_FORWARD}`)
const leadingHtmlOpen = /^(\s*<html\b[^>]*>)/i
if (leadingHtmlOpen.test(html)) return html.replace(leadingHtmlOpen, `$1${CONSOLE_FORWARD}`)

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +160
const data = e.data
if (data?.type === 'console') {
addConsoleMsg(data.args.join(' '), data.method)
} else if (data?.type === 'error') {
addConsoleMsg(data.message, 'error')
}
})

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message event handler accepts any postMessage from any source. This allows unrelated frames/tabs (or injected scripts) to spoof console/error entries in the playground UI. Filter messages to the preview iframe (e.g., e.source === preview.contentWindow) and, if possible, also validate e.origin (srcdoc sandbox frames typically use "null").

Copilot uses AI. Check for mistakes.
Comment thread tko.io/src/playground/shell.ts Outdated
try {
const json = decodeURIComponent(atob(location.hash.slice(1)))
const parsed = JSON.parse(json)
return typeof parsed === 'object' && parsed !== null ? parsed : null
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadFromHash() returns any parsed object without validating the values are strings. If a hash contains non-string values (e.g. { html: 123 }), those get passed to CodeMirror as doc, which can throw and break page load. Consider normalizing/validating per-tab values (only accept strings; otherwise fall back to defaults).

Suggested change
return typeof parsed === 'object' && parsed !== null ? parsed : null
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null
}
const files: Record<string, string> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
return null
}
files[key] = value
}
return files

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +169
const dotClass =
status.state === 'ready' ? 'dot--ready' : status.state === 'error' ? 'dot--error' : 'dot--loading'
statusEl.innerHTML = `<span class="dot ${dotClass}"></span>${status.label}`
if (status.state === 'ready') loading.hidden = true
if (status.state === 'error') loading.textContent = status.label
}

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setStatus() uses innerHTML to render status.label. In error paths this label comes from e.message, which can contain </& and accidentally break markup; it’s also an unnecessary XSS footgun. Prefer building the dot element separately and setting the label via textContent.

Copilot uses AI. Check for mistakes.
Comment thread tko.io/src/playground/runner-esm.ts Outdated
Comment on lines +37 to +55
const CONSOLE_FORWARD = `<script>
;['log','warn','error','info'].forEach(m => {
const orig = console[m];
console[m] = (...args) => {
window.parent.postMessage({ type: 'console', method: m, args: args.map(a => {
try { return typeof a === 'object' ? JSON.stringify(a) : String(a) } catch { return String(a) }
}) }, '*');
orig.apply(console, args);
};
});
window.onerror = (msg) => {
window.parent.postMessage({ type: 'error', message: String(msg) }, '*');
};
window.onunhandledrejection = (e) => {
window.parent.postMessage({ type: 'error', message: String(e.reason) }, '*');
};
</script>
`

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console-forwarding snippet is duplicated (very similar logic in both runners). This increases the chance of the two playgrounds diverging (e.g. one adds a new console method or error handler and the other doesn’t). Consider factoring the forwarder + injection into a small shared helper used by both runners.

Copilot uses AI. Check for mistakes.
Brian M Hunt and others added 2 commits April 16, 2026 15:19
One example, one story: importmap + module script, copy-and-deploy.
The IIFE script-tag alternative was duplicating the story and pulling
attention away from the "no build step" promise.

Also links to the new /playground/esm so readers can try the exact
code before downloading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single Hello/TKO example, shared verbatim between the deploy page code
block and the ESM playground's default. Full HTML structure (html/head/
body) so a copy-paste into a new file is a complete, teach-friendly
document.

Also points the top-nav Playground button at /playground/esm so the
site-wide 'try it' story matches the Deploy in Seconds promise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (5)
tko.io/src/content/docs/getting-started/deploy.md (1)

38-38: Nit: trailing-slash convention for Astro content pages.

Per the retrieved learning about Eleventy trailing slashes, Astro/Starlight pages under tko.io/src/content/ should use trailing-slash URLs. Consider /playground/esm/ for consistency with other internal doc links.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/content/docs/getting-started/deploy.md` at line 38, The link text
currently points to "/playground/esm" without a trailing slash; update the
internal doc link to use the trailing-slash convention by changing
"/playground/esm" to "/playground/esm/" in the deploy.md content so it matches
other Astro/Starlight pages and internal docs. Ensure any identical occurrences
in the same file (e.g., the "ESM playground" link) are updated consistently.
tko.io/src/playground/entry-esm.ts (2)

19-23: Minor: tsx/js language entries are unused for the ESM runner.

createEsmRunner() exposes only the html tab (see runner-esm.ts line 56), so javascript({ jsx, typescript }) and javascript() are dead code pulled into the bundle. You can drop them to shrink the ESM entry, or keep them if you plan to add a TSX tab shortly.

♻️ Suggested trim
-    languages: {
-      html: () => htmlLang(),
-      tsx: () => javascript({ jsx: true, typescript: true }),
-      js: () => javascript(),
-    },
+    languages: {
+      html: () => htmlLang(),
+    },

And drop the now-unused javascript import.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/entry-esm.ts` around lines 19 - 23, The TSX and JS
language entries in the languages object (the 'tsx' and 'js' keys) are unused by
createEsmRunner() and can be removed to shrink the ESM bundle; delete those
entries from the languages object in entry-esm.ts and also remove the now-unused
javascript import to avoid dead code while keeping the html entry (htmlLang())
intact for createEsmRunner().

14-14: Non-null assertion on getElementById('app').

document.getElementById('app')! will throw a cryptic Cannot read properties of null if the script ever runs before #app exists (e.g. someone moves the <script> out of <body> or the element id changes). A small guard with a clear error improves debuggability for playground users forking this file.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/entry-esm.ts` at line 14, Replace the non-null
assertion on document.getElementById('app')! with an explicit null check:
retrieve the element into a local variable (e.g., const appEl =
document.getElementById('app')), verify appEl is not null, and if it is null
throw a clear, actionable Error (or wait for DOMContentLoaded) before passing
the element into the root/mount call that uses it; this removes the cryptic
`Cannot read properties of null` and makes failures clear when
getElementById('app') can't find the element.
tko.io/src/playground/runner-iife.ts (1)

35-54: Duplicated console-forward snippet with runner-esm.ts.

This block is a near-verbatim copy of CONSOLE_FORWARD in tko.io/src/playground/runner-esm.ts (lines 26–43). Extracting it into a shared helper (e.g. ./console-forward.ts) avoids drift if one side gains a fix (origin filtering, structured cloning of args, etc.) and the other doesn't.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/runner-iife.ts` around lines 35 - 54, Duplicate
console-forward logic: extract the shared snippet (the console override,
window.onerror and window.onunhandledrejection) into a reusable helper (e.g.,
export function installConsoleForward(...) from a new console-forward module)
and replace the inline copy in both runner-iife and runner-esm with an import
and call to that function; specifically move the logic that overrides console
methods, and sets window.onerror and window.onunhandledrejection into the new
installConsoleForward helper (preserving behavior of the current console[m]
wrapper and postMessage payload), export it (name it installConsoleForward or
CONSOLE_FORWARD installer) and import/use it from the places that currently
contain the duplicated code so future fixes (origin filtering, structured
cloning of args, etc.) are applied in one place.
tko.io/src/playground/shell.ts (1)

198-203: Ctrl/⌘+Enter listener is attached to document rather than the container.

Scoping the shortcut to document is fine for a full-page playground, but it means if mount is ever called twice (HMR, SPA navigation) the listeners stack and run() fires multiple times. Consider attaching to container and/or returning a disposer for future-proofing.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/shell.ts` around lines 198 - 203, The keydown handler
is being attached to document which will stack if mount is called multiple
times; change it to attach to the playground container element (use
container.addEventListener) and capture the handler in a named function/const so
it can be removed later, then return (or expose) a disposer/unmount function
that calls container.removeEventListener with that handler; keep the same logic
calling run() when (e.ctrlKey || e.metaKey) && e.key === 'Enter' and ensure you
still call e.preventDefault() in the handler.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tko.io/src/content/docs/getting-started/deploy.md`:
- Around line 18-20: The importmap in getting-started/deploy.md currently points
to the floating URL "https://esm.sh/@tko/build.reference"; change it to a pinned
versioned URL (e.g., https://esm.sh/@tko/build.reference@vX.Y.Z) matching the
current site header version, and make the same change to the DEFAULT_HTML
constant in playground/runner-esm.ts so both examples use a concrete version;
after making these edits, add or update a short deployment guide under
tko.io/public/agents/ describing the need to pin esm.sh imports for reproducible
static deploys.

In `@tko.io/src/playground/runner-esm.ts`:
- Around line 45-51: The fallback in injectConsoleForward currently prepends
CONSOLE_FORWARD before the document which can break <!doctype html>; update
injectConsoleForward to avoid injecting before the doctype or <html> tag: when
neither </head> nor <body> matches, search for a leading <!doctype ...> or an
opening <html[^>]*> and insert CONSOLE_FORWARD immediately after that match (or
after the matched tag's end); if neither doctype nor <html> exists, instead fail
fast by returning the original html unchanged or throwing/returning a clear
status error so we do not force the script into quirks mode. Ensure changes are
made in the injectConsoleForward function and reference CONSOLE_FORWARD for
insertion.

In `@tko.io/src/playground/runner-iife.ts`:
- Around line 56-63: The inline script injection that interpolates ${compiledJs}
inside the IIFE/setTimeout block can be broken by a literal "</script>" coming
from compiledJs; update the code that injects compiledJs (the interpolation used
within the setTimeout/inline <script> in runner-iife.ts) to sanitize/escape any
closing script tags before interpolation (e.g. replace occurrences of
"</script>" with "<\/script>" using a safe replaceAll or regex) or alternatively
emit the compiled JS as a Blob or data: URL and load it via a <script src=...>
to avoid inline parsing issues; change the logic where compiledJs is spliced in
so it either performs the escape or switches to Blob/data-URL loading.

In `@tko.io/src/playground/shell.ts`:
- Around line 21-24: The saveToHash function currently assigns location.hash
which creates a new history entry on every call; change saveToHash to compute
the encoded hash (the result of btoa(encodeURIComponent(json))) and then call
history.replaceState(null, '', window.location.pathname + window.location.search
+ '#' + encoded) so the URL updates without pushing to browser history; update
references inside saveToHash (json, encoded) and remove the direct location.hash
assignment.
- Around line 162-168: The setStatus function currently injects status.label
into statusEl.innerHTML which risks XSS because status.label can contain
untrusted runner/error text; instead, build the DOM nodes and set only
textContent for the label: create (or reuse) the dot span (set its class via
classList using dotClass) and a separate text node or span for the label, set
labelEl.textContent = status.label, clear or replace statusEl's children with
the dot and label nodes, and keep using loading.hidden/loading.textContent for
the loading/error UI; update references in setStatus, statusEl, and loading
accordingly.
- Around line 152-159: The listener for window 'message' assumes data.args is an
array and calls data.args.join(' '), which can throw and break the handler; in
the message handler (the arrow function passed to window.addEventListener in
playground/shell.ts) replace the direct join call with a safe guard: compute a
safe string for console messages by checking Array.isArray(data.args) and
joining when true, otherwise fallback to String(data.args ?? '') (or an empty
string) before calling addConsoleMsg, so malformed/non-array args no longer
cause exceptions.

In `@tko.io/src/playground/styles.css`:
- Around line 208-213: The .console-msg CSS rule uses the deprecated property
word-break: break-word; replace that declaration with the modern equivalent
overflow-wrap: anywhere to satisfy stylelint and maintain the same behavior;
update the .console-msg block (the selector named ".console-msg") to remove
word-break: break-word and add overflow-wrap: anywhere.

---

Nitpick comments:
In `@tko.io/src/content/docs/getting-started/deploy.md`:
- Line 38: The link text currently points to "/playground/esm" without a
trailing slash; update the internal doc link to use the trailing-slash
convention by changing "/playground/esm" to "/playground/esm/" in the deploy.md
content so it matches other Astro/Starlight pages and internal docs. Ensure any
identical occurrences in the same file (e.g., the "ESM playground" link) are
updated consistently.

In `@tko.io/src/playground/entry-esm.ts`:
- Around line 19-23: The TSX and JS language entries in the languages object
(the 'tsx' and 'js' keys) are unused by createEsmRunner() and can be removed to
shrink the ESM bundle; delete those entries from the languages object in
entry-esm.ts and also remove the now-unused javascript import to avoid dead code
while keeping the html entry (htmlLang()) intact for createEsmRunner().
- Line 14: Replace the non-null assertion on document.getElementById('app')!
with an explicit null check: retrieve the element into a local variable (e.g.,
const appEl = document.getElementById('app')), verify appEl is not null, and if
it is null throw a clear, actionable Error (or wait for DOMContentLoaded) before
passing the element into the root/mount call that uses it; this removes the
cryptic `Cannot read properties of null` and makes failures clear when
getElementById('app') can't find the element.

In `@tko.io/src/playground/runner-iife.ts`:
- Around line 35-54: Duplicate console-forward logic: extract the shared snippet
(the console override, window.onerror and window.onunhandledrejection) into a
reusable helper (e.g., export function installConsoleForward(...) from a new
console-forward module) and replace the inline copy in both runner-iife and
runner-esm with an import and call to that function; specifically move the logic
that overrides console methods, and sets window.onerror and
window.onunhandledrejection into the new installConsoleForward helper
(preserving behavior of the current console[m] wrapper and postMessage payload),
export it (name it installConsoleForward or CONSOLE_FORWARD installer) and
import/use it from the places that currently contain the duplicated code so
future fixes (origin filtering, structured cloning of args, etc.) are applied in
one place.

In `@tko.io/src/playground/shell.ts`:
- Around line 198-203: The keydown handler is being attached to document which
will stack if mount is called multiple times; change it to attach to the
playground container element (use container.addEventListener) and capture the
handler in a named function/const so it can be removed later, then return (or
expose) a disposer/unmount function that calls container.removeEventListener
with that handler; keep the same logic calling run() when (e.ctrlKey ||
e.metaKey) && e.key === 'Enter' and ensure you still call e.preventDefault() in
the handler.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 420c05b3-51bf-4091-ae7f-01638bb76161

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between e4441a3 and 7132da8.

πŸ“’ Files selected for processing (11)
  • tko.io/src/components/Header.astro
  • tko.io/src/content/docs/getting-started/deploy.md
  • tko.io/src/pages/playground.astro
  • tko.io/src/pages/playground/esm.astro
  • tko.io/src/playground/entry-esm.ts
  • tko.io/src/playground/entry-iife.ts
  • tko.io/src/playground/runner-esm.ts
  • tko.io/src/playground/runner-iife.ts
  • tko.io/src/playground/runner.ts
  • tko.io/src/playground/shell.ts
  • tko.io/src/playground/styles.css

Comment on lines +18 to +20
<script type="importmap">
{ "imports": { "@tko/build.reference": "https://esm.sh/@tko/build.reference" } }
</script>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🌐 Web query:

Does esm.sh publish @tko/build.referenceand expose a default export compatible withimport ko from '@tko/build.reference'?

πŸ’‘ Result:

Yes, esm.sh publishes @tko/build.reference and exposes a default export compatible with import ko from '@tko/build.reference'. esm.sh is a universal CDN that serves any npm package, including @tko/build.reference (latest 4.0.0-beta1.7, published recently), by transforming it for ESM usage in browsers. The package has dist/index.js (1.8KB) and dist/index.mjs (1.8KB) files, which esm.sh uses as entry points. These small index files (vs. large bundled browser.js at 297KB) are designed as unbundled ESM exports that re-export the main TKO library (Knockout successor), providing a default export named 'ko' as per TKO/Knockout convention. esm.sh handles CJS/ESM conversion and tree-shaking, ensuring default import compatibility. Available at https://esm.sh/@tko/build.reference, similar to other packages like React (https://esm.sh/react).

Citations:


🏁 Script executed:

# Find and check the deploy.md file
find . -name "deploy.md" -type f | head -20

Repository: knockout/tko

Length of output: 107


🏁 Script executed:

# Find runner-esm.ts
find . -name "runner-esm.ts" -type f

Repository: knockout/tko

Length of output: 93


🏁 Script executed:

# Check for agent guides directory
find . -path "*/tko.io/public/agents*" -type d

Repository: knockout/tko

Length of output: 120


🏁 Script executed:

# Read deploy.md to verify content at lines 18-20
cat -n ./tko.io/src/content/docs/getting-started/deploy.md | head -30

Repository: knockout/tko

Length of output: 1139


🏁 Script executed:

# Read runner-esm.ts to verify lines 8-10
cat -n ./tko.io/src/playground/runner-esm.ts | head -20

Repository: knockout/tko

Length of output: 817


🏁 Script executed:

# Check for corresponding agent guide files
ls -la ./tko.io/public/agents/
ls -la ./tko.io/public/agents/verified-behaviors/ 2>/dev/null | head -20

Repository: knockout/tko

Length of output: 1831


🏁 Script executed:

# Check if there's a deploy-related guide in the agent directory
find ./tko.io/public/agents -name "*deploy*" -o -name "*getting*"

Repository: knockout/tko

Length of output: 38


🏁 Script executed:

# Read the guide.md to see if it covers deployment
head -50 ./tko.io/public/agents/guide.md

Repository: knockout/tko

Length of output: 2138


Pin the @tko/build.reference version in the importmap.

https://esm.sh/@tko/build.reference resolves to the latest published version at request time. A future breaking release of @tko/build.reference (or of its transitive dependencies re-resolved by esm.sh) will silently break every copy of this index.html that users have deployed to static hosts. Since this snippet is explicitly advertised as copy-paste deployable, pinning to a concrete version (and ideally the current v{version} shown in the header) ensures reproducible deploys.

Same concern applies to DEFAULT_HTML in tko.io/src/playground/runner-esm.ts (lines 8–10), which mirrors this example.

Per the coding guidelines, update or create a corresponding deployment guide in tko.io/public/agents/ for AI agents when finalizing the documentation changes.

πŸ›‘οΈ Suggested pin
-      { "imports": { "@tko/build.reference": "https://esm.sh/@tko/build.reference" } }
+      { "imports": { "@tko/build.reference": "https://esm.sh/@tko/build.reference@<version>" } }
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/content/docs/getting-started/deploy.md` around lines 18 - 20, The
importmap in getting-started/deploy.md currently points to the floating URL
"https://esm.sh/@tko/build.reference"; change it to a pinned versioned URL
(e.g., https://esm.sh/@tko/build.reference@vX.Y.Z) matching the current site
header version, and make the same change to the DEFAULT_HTML constant in
playground/runner-esm.ts so both examples use a concrete version; after making
these edits, add or update a short deployment guide under tko.io/public/agents/
describing the need to pin esm.sh imports for reproducible static deploys.

Comment thread tko.io/src/playground/runner-esm.ts Outdated
Comment on lines +45 to +51
function injectConsoleForward(html: string): string {
const headClose = /<\/head>/i
if (headClose.test(html)) return html.replace(headClose, `${CONSOLE_FORWARD}</head>`)
const bodyOpen = /<body[^>]*>/i
if (bodyOpen.test(html)) return html.replace(bodyOpen, (m) => `${m}${CONSOLE_FORWARD}`)
return CONSOLE_FORWARD + html
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

injectConsoleForward fallback injects before the document.

If the user removes both </head> and <body> (or starts with <!doctype html> only), the forward script is prepended before <!doctype html>, which invalidates the doctype and puts the browser into quirks mode inside the iframe β€” breaking CSS the user will then debug in isolation. Prefer injecting after the doctype/<html> tag, or refuse to run with a clear status error.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/runner-esm.ts` around lines 45 - 51, The fallback in
injectConsoleForward currently prepends CONSOLE_FORWARD before the document
which can break <!doctype html>; update injectConsoleForward to avoid injecting
before the doctype or <html> tag: when neither </head> nor <body> matches,
search for a leading <!doctype ...> or an opening <html[^>]*> and insert
CONSOLE_FORWARD immediately after that match (or after the matched tag's end);
if neither doctype nor <html> exists, instead fail fast by returning the
original html unchanged or throwing/returning a clear status error so we do not
force the script into quirks mode. Ensure changes are made in the
injectConsoleForward function and reference CONSOLE_FORWARD for insertion.

Comment thread tko.io/src/playground/runner-iife.ts
Comment thread tko.io/src/playground/shell.ts
Comment thread tko.io/src/playground/shell.ts
Comment thread tko.io/src/playground/shell.ts
Comment on lines +208 to +213
.console-msg {
padding: 0.2rem 0.75rem;
color: var(--pg-text-muted);
white-space: pre-wrap;
word-break: break-word;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Replace deprecated word-break: break-word with overflow-wrap: anywhere.

Stylelint flags word-break: break-word as deprecated; browsers historically map it to overflow-wrap: anywhere. Using the modern property avoids the lint error and is the documented replacement.

πŸ› οΈ Proposed fix
 .console-msg {
   padding: 0.2rem 0.75rem;
   color: var(--pg-text-muted);
   white-space: pre-wrap;
-  word-break: break-word;
+  overflow-wrap: anywhere;
 }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.console-msg {
padding: 0.2rem 0.75rem;
color: var(--pg-text-muted);
white-space: pre-wrap;
word-break: break-word;
}
.console-msg {
padding: 0.2rem 0.75rem;
color: var(--pg-text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
🧰 Tools
πŸͺ› Stylelint (17.7.0)

[error] 212-212: Deprecated keyword "break-word" for property "word-break" (declaration-property-value-keyword-no-deprecated)

(declaration-property-value-keyword-no-deprecated)

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/src/playground/styles.css` around lines 208 - 213, The .console-msg
CSS rule uses the deprecated property word-break: break-word; replace that
declaration with the modern equivalent overflow-wrap: anywhere to satisfy
stylelint and maintain the same behavior; update the .console-msg block (the
selector named ".console-msg") to remove word-break: break-word and add
overflow-wrap: anywhere.

Brian M Hunt and others added 6 commits April 16, 2026 15:48
The Expressive Code playground-button plugin was stripping all <script>
tags from HTML blocks and splitting them into the IIFE playground's
{html, js} hash. That mangles ESM examples β€” the whole point of an
<script type="importmap"> + <script type="module"> example is that
it's a single self-contained file.

Detect those two script types and route the button to /playground/esm
with the HTML verbatim. Classic <script> blocks (and <script src=...>)
still go to /playground and get the old split-and-auto-applyBindings
treatment.

Fixes the "Deploy in Seconds" code block opening into the wrong
playground with the scripts stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tabs now, both ESM:

- Plain ESM β€” data-bind + <script type="module">, zero tooling
- ESM + TSX β€” same single-file story, but JSX/TypeScript via
  in-browser esbuild-wasm compile

The TSX variant uses <script type="text/tsx"> as an inert source
holder (browsers ignore unknown script types, so source is preserved
verbatim), then a small module bootstrap:

  1. Loads esbuild-wasm via the import map
  2. Transforms the TSX source
  3. Wraps the output in a blob URL and dynamic-import()s it

That keeps everything ESM end-to-end β€” import statements in the TSX
(e.g. `import ko from '@tko/build.reference'`) resolve through the
import map like any module. No new Function, no globals, no eval.

The playground-button plugin already detects <script type="module">
so both tabs open in /playground/esm automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
data-bind:    <input data-bind="textInput: name" />
native:       <input ko-textInput="name" />

Shorter, less noise, and better editor tooling since ko-<binding> is a
real attribute the HTML language server can see, instead of a string
microsyntax inside data-bind. The TSX tab already used native providers
(ko-click) β€” this brings the Plain ESM tab and the playground default
into line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both tabs now demonstrate the TKO MVVM split:
  - <template id="..."> holds reusable markup with native ko- bindings
  - viewModel owns state; component.register wires them
  - drop <ko-greeting> / <ko-counter> anywhere, any number of times

The TSX tab's iframe renders two independent counters to make the
reuse story visible. Each instance gets its own observable; the
template is shared.

Custom element names hyphenated per HTML spec (ko-greeting, ko-counter)
to silence the "custom elements must contain a hyphen" warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both variants now extend ko.Component:

  class KoGreeting extends ko.Component {
    static get template() { return { element: 'ko-greeting-template' } }
    name = ko.observable('TKO')
  }
  KoGreeting.register()

ComponentABC handles the plumbing: element name is auto-derived from the
class name (kebab-case), `register()` wires up template + viewModel in
one call, and the class gets LifeCycle for free.

Also added short inline comments explaining the template/component
split and the class contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot + CodeRabbit flagged a mix of real bugs and defensive
improvements. Fixed all of the substantive ones:

Real bugs:
- saveToHash now uses history.replaceState instead of assigning
  location.hash. Every debounced edit was pushing a new history entry,
  so a few seconds of typing filled the Back button.
- injectConsoleForward preserves a leading <!doctype html>. If a user
  omits both <head> and <body>, the forwarder was prepending before
  the doctype and knocking the iframe into quirks mode. Now we inject
  after the doctype when there's no head/body, and only fall back to
  raw prepend when there's no doctype either.
- Compiled TSX is escaped with escapeScriptBody before being spliced
  into an inline <script> block. A stray </script> string literal in
  user code (or an esbuild emit) would have terminated the script tag
  early β€” now <\/script is written so the parser doesn't.

Defensive:
- Filter postMessage listener to e.source === preview.contentWindow so
  unrelated frames can't spoof console entries.
- loadFromHash drops non-string values (guards CodeMirror against
  being handed a number).
- postMessage handler checks Array.isArray(data.args) and validates
  data.method before rendering.
- setStatus builds the dot span with createElement + appendChild
  instead of innerHTML β€” status.label can contain error messages with
  <, &, etc. and was being parsed as HTML.

Code quality:
- Console-forward snippet extracted into console-forward.ts, shared by
  both runners (IIFE and ESM). The two were drifting.
- word-break: break-word (deprecated) β†’ overflow-wrap: anywhere.

Verified in preview: ESM playground still renders "TKO"/"Hello, TKO";
IIFE playground still compiles TSX and renders "Count: 0"; spoofed
postMessage from window is now filtered; replaceState alone grows
history by 0 (remaining growth comes from iframe srcdoc navigations,
a pre-existing browser quirk orthogonal to this PR).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brianmhunt brianmhunt merged commit d17298c into main Apr 17, 2026
7 of 8 checks passed
@brianmhunt brianmhunt deleted the playground/esm branch April 17, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants