Skip to content

Commit 90d241d

Browse files
committed
Fix TSX docs examples and playground payloads
1 parent 0c7bef4 commit 90d241d

19 files changed

+762
-154
lines changed

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ changes — update **both** the Starlight docs (for humans) and the agent guide
157157
(for agents). The agent guide should be token-efficient: dense, code-first,
158158
minimal prose.
159159

160+
## Docs Verification
161+
162+
When validating `tko.io` documentation changes with the local docs site:
163+
164+
- Use `playwright-cli` in headless mode by default. Do not use headed/browser-stealing runs unless the user explicitly asks for them.
165+
- Prefer a live Astro dev server on `127.0.0.1` so markdown/plugin edits reload while you work.
166+
- For docs pages with runnable examples, verify the live page and each `Open in Playground` button after edits.
167+
- Standard headless flow: `playwright-cli close-all`, `playwright-cli open http://127.0.0.1:4321/...`, inspect the snapshot for playground refs, click each button, switch to the playground tab, and confirm `#esbuild-status`, `#compile-time`, and `#error-bar`.
168+
- Treat docs example work as incomplete until the emitted playground payload compiles cleanly on the live site.
169+
- If a page has multiple TSX examples, check every TSX playground button, not just the first one.
170+
160171
## Important Guidelines
161172

162173
- Do not modify `tools/build.mk` or `tools/karma.conf.js` without understanding

plans/tsx-doc-examples-rollout.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Plan: TSX Docs Example Rollout
2+
3+
## Goal
4+
5+
Make the TSX side of the docs easier to read than the legacy HTML + viewmodel
6+
examples while keeping every example runnable in the playground.
7+
8+
The reader-facing convention is:
9+
10+
- Declare observables and other referenced variables at the top of the example
11+
- Show the binding-focused TSX snippet only
12+
- Hide mount/setup boilerplate from the visible example
13+
- Keep the legacy HTML + JS example intact inside the HTML tab
14+
- Ensure both tabs continue to open working playground sessions
15+
16+
---
17+
18+
## Current Problem
19+
20+
The first TSX migration pass made several examples longer and noisier than the
21+
legacy HTML version:
22+
23+
- Some TSX examples include repeated `root / render / applyBindings` ceremony
24+
- Some examples introduce intermediate objects that are not needed to explain
25+
the binding
26+
- Legacy HTML `viewModel` blocks can appear visually detached from the HTML tab
27+
- The docs are teaching setup details at the same level as binding syntax
28+
29+
This is backwards for readers. TSX should feel more direct, not more verbose.
30+
31+
---
32+
33+
## Reader-First Convention
34+
35+
### Visible TSX example
36+
37+
Each TSX example should show only:
38+
39+
1. Top-level variables and observables referenced by `ko-*={...}`
40+
2. The JSX that demonstrates the binding itself
41+
42+
Example shape:
43+
44+
```tsx
45+
const url = ko.observable('year-end.html')
46+
const details = ko.observable('Report including final year-end statistics')
47+
48+
<a ko-attr={{ href: url, title: details }}>Report</a>
49+
```
50+
51+
### Hidden playground wrapper
52+
53+
The playground button should wrap the visible snippet with the standard mount
54+
code automatically:
55+
56+
- locate or create `#root`
57+
- render the JSX into the root
58+
- call `tko.applyBindings({}, root)`
59+
60+
This keeps the docs short without breaking playground execution.
61+
62+
### HTML tab behavior
63+
64+
- Keep the legacy HTML snippet visible
65+
- Keep the matching JS viewmodel visible, but only inside the HTML tab panel
66+
- Preserve a working HTML playground button using the paired HTML + JS payload
67+
68+
---
69+
70+
## Scope
71+
72+
Apply this convention to TSX-capable docs examples across:
73+
74+
- `tko.io/src/content/docs/bindings/`
75+
- selected examples in `observables/`, `computed/`, and `components/` where a
76+
TSX companion is helpful and already being introduced
77+
- supporting docs for agents/readers:
78+
- `tko.io/public/agent-guide.md`
79+
- `tko.io/public/agent-testing.md`
80+
81+
Initial priority is bindings pages that already have active TSX work:
82+
83+
- `attr`
84+
- `click`
85+
- `enable`
86+
- `foreach`
87+
- `hasfocus`
88+
- `if`
89+
- `text`
90+
- `textInput`
91+
- `value`
92+
- component pages already touched in the current branch
93+
94+
---
95+
96+
## Implementation Plan
97+
98+
### Phase 1: Lock the convention in tooling
99+
100+
Update the docs rendering/playground pipeline so it supports the reader-first
101+
shape:
102+
103+
- `plugins/tsx-tabs.js`
104+
- continue pairing handwritten `tsx` + `html` examples
105+
- keep legacy JS blocks inside the HTML tab panel
106+
- `plugins/playground-button.js`
107+
- support TSX snippets that omit mount/setup boilerplate
108+
- auto-wrap TSX snippets before encoding the playground payload
109+
- keep HTML payload pairing exact and deterministic
110+
111+
Success criteria:
112+
113+
- visible TSX snippet can stop at the JSX example
114+
- playground still receives runnable `{ html, js }`
115+
- HTML tab still renders its JS block inside the tab
116+
117+
### Phase 2: Migrate simple binding examples
118+
119+
For simple bindings, prefer:
120+
121+
- observables first
122+
- inline JSX binding expression
123+
- no extra `view` variable unless it improves readability
124+
- no visible mount/setup footer
125+
126+
Examples include:
127+
128+
- `attr`
129+
- `enable`
130+
- `text`
131+
- `textInput`
132+
- `value`
133+
- `click`
134+
135+
### Phase 3: Migrate context-sensitive examples
136+
137+
Bindings like `foreach` and `if` need extra care because they mix:
138+
139+
- compile-time variables in `ko-*={...}`
140+
- runtime binding-context strings like `$data`, `$parent`, `name`
141+
142+
For these pages:
143+
144+
- keep top-level collections/computeds explicit
145+
- keep runtime binding-context references as strings in child nodes
146+
- add short notes only where the compile-time/runtime boundary is easy to miss
147+
148+
### Phase 4: Align docs and agent docs
149+
150+
After the pattern stabilizes in the binding pages, update:
151+
152+
- `public/agent-guide.md`
153+
- `public/agent-testing.md`
154+
155+
to document the new visible-snippet convention and the hidden playground
156+
wrapper behavior.
157+
158+
---
159+
160+
## Content Rules
161+
162+
Use these rules consistently during migration:
163+
164+
- If a `ko-*` attribute references a value, define that value first
165+
- Prefer observables when the original example is demonstrating reactive data
166+
- Prefer literals only when reactivity is not the point of the example
167+
- Do not show `document.getElementById`, `appendChild`, or `applyBindings`
168+
unless the page is explicitly teaching render/setup mechanics
169+
- Avoid helper objects like `const attrs = { ... }` unless the object itself is
170+
part of what the reader should learn
171+
- Keep the TSX tab shorter than the HTML + JS alternative whenever possible
172+
173+
---
174+
175+
## Verification
176+
177+
For each migrated page:
178+
179+
1. Run `bun run build`
180+
2. Run the docs site locally with `bun x astro dev --host 127.0.0.1 --port 4321 --force`
181+
3. Verify the page with headless `playwright-cli`
182+
4. Confirm:
183+
- TSX tab renders the shortened reader-facing snippet
184+
- HTML tab renders both legacy HTML and its JS block inside the tab
185+
- TSX playground button opens a runnable payload
186+
- HTML playground button opens a runnable payload
187+
- no visible error bar or console errors in the playground
188+
189+
For the full rollout, spot-check every migrated page and fully verify the more
190+
complex ones (`foreach`, `if`, components).
191+
192+
---
193+
194+
## Risks
195+
196+
- Hidden wrapper logic can drift from the documented render pattern if it is not
197+
kept in sync with the playground
198+
- Over-shortening TSX examples can hide important compile-time scope rules
199+
- Complex bindings may need a visible `view` variable for readability even if
200+
simple bindings do not
201+
- Astro/Expressive Code caching can obscure plugin changes; force rebuilds may
202+
be needed during iteration
203+
204+
---
205+
206+
## Outcome
207+
208+
After this rollout:
209+
210+
- TSX examples read as the modern, concise path
211+
- observables remain explicit and teach the compile-time scope rule
212+
- setup ceremony stops dominating simple binding pages
213+
- HTML examples remain fully available for legacy readers
214+
- both tabs remain runnable through the playground

tko.io/plugins/playground-button.js

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020

2121
const MASK_SVG = `url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'1.75'%3E%3Cpath%20d%3D'M15%203h6v6'%2F%3E%3Cpath%20d%3D'M10%2014%2021%203'%2F%3E%3Cpath%20d%3D'M18%2013v6a2%202%200%200%201-2%202H5a2%202%200%200%201-2-2V8a2%202%200%200%201%202-2h6'%2F%3E%3C%2Fsvg%3E")`
22+
const DEFAULT_PLAYGROUND_HTML = '<div id="root"></div>'
2223

2324
function h(tag, props, children = []) {
2425
return {
@@ -64,13 +65,54 @@ function autoApplyBindings(js) {
6465
return js + `\nko.applyBindings(${vmName});`
6566
}
6667

67-
function addPlaygroundButton(renderData, html, js) {
68-
const runnableJs = autoApplyBindings(js)
69-
if (!runnableJs) return
70-
const hash = encodePlaygroundHash(html, runnableJs)
71-
const ast = renderData.blockAst
68+
function looksLikeJsxExpression(code) {
69+
const trimmed = code.trim()
70+
return trimmed.startsWith('<') || trimmed.startsWith('(')
71+
}
7272

73-
const copyDiv = findNode(ast, n =>
73+
function indentBlock(code, spaces) {
74+
const indent = ' '.repeat(spaces)
75+
return code
76+
.split('\n')
77+
.map(line => (line ? `${indent}${line}` : line))
78+
.join('\n')
79+
}
80+
81+
function wrapTsxForPlayground(tsx) {
82+
const code = tsx.trim()
83+
if (!code) return code
84+
85+
// Hand-authored full examples should keep their explicit setup.
86+
if (
87+
/tko\.jsx\.render\s*\(/.test(code) ||
88+
/(?:^|\W)(?:ko|tko)\.applyBindings\s*\(/.test(code) ||
89+
/document\.getElementById\s*\(/.test(code)
90+
) {
91+
return code
92+
}
93+
94+
const blocks = code.split(/\n\s*\n/)
95+
const jsxBlock = blocks.at(-1)?.trim()
96+
if (!jsxBlock || !looksLikeJsxExpression(jsxBlock)) return code
97+
98+
const prelude = blocks.slice(0, -1).join('\n\n').trim()
99+
100+
let wrapped = ''
101+
if (prelude) wrapped += `${prelude}\n\n`
102+
wrapped += '// boilerplate added by the docs playground\n'
103+
wrapped += "const root = document.getElementById('root')\n"
104+
wrapped += 'const { node } = tko.jsx.render(\n'
105+
wrapped += `${indentBlock(jsxBlock, 2)}\n`
106+
wrapped += ')\n'
107+
wrapped += 'root.appendChild(node)\n'
108+
wrapped += 'tko.applyBindings({}, root)'
109+
return wrapped
110+
}
111+
112+
function insertPlaygroundButton(blockAst, html, js) {
113+
const hash = encodePlaygroundHash(html, js)
114+
115+
const copyDiv = findNode(blockAst, n =>
74116
n.type === 'element' &&
75117
n.tagName === 'div' &&
76118
Array.isArray(n.properties?.className) &&
@@ -89,11 +131,17 @@ function addPlaygroundButton(renderData, html, js) {
89131
copyDiv.children.unshift(link)
90132
}
91133

92-
export function pluginPlaygroundButton() {
93-
// Track pending blocks for pairing with a following JS block
94-
let pendingHtml = null
95-
let pendingTsx = null
134+
function addHtmlPlaygroundButton(blockAst, html, js) {
135+
const runnableJs = autoApplyBindings(js)
136+
if (!runnableJs) return
137+
insertPlaygroundButton(blockAst, html, runnableJs)
138+
}
96139

140+
function addTsxButton(blockAst, tsx) {
141+
insertPlaygroundButton(blockAst, DEFAULT_PLAYGROUND_HTML, wrapTsxForPlayground(tsx))
142+
}
143+
144+
export function pluginPlaygroundButton() {
97145
return {
98146
name: 'playground-button',
99147

@@ -177,38 +225,25 @@ export function pluginPlaygroundButton() {
177225
hooks: {
178226
postprocessRenderedBlock: ({ codeBlock, renderData }) => {
179227
if (codeBlock.language === 'tsx') {
180-
// TSX block (from tsx-tabs) — store for pairing with following JS block
181-
pendingTsx = { html: codeBlock.code.trim(), renderData }
182-
} else if (codeBlock.language === 'html') {
183-
const code = codeBlock.code
184-
const { html, js } = splitHtmlAndScript(code)
185-
186-
if (js) {
187-
// Self-contained HTML block with inline <script> — add button now
188-
pendingHtml = null
189-
pendingTsx = null
190-
addPlaygroundButton(renderData, html, js)
191-
} else {
192-
// HTML-only block — store for potential pairing with next JS block
193-
pendingHtml = { html: code.trim(), renderData }
194-
}
195-
} else if (codeBlock.language === 'javascript' && (pendingHtml || pendingTsx)) {
196-
// JS block after an HTML/TSX block — pair them
197-
const js = codeBlock.code.trim()
198-
if (js) {
199-
if (pendingHtml) {
200-
addPlaygroundButton(pendingHtml.renderData, pendingHtml.html, js)
201-
}
202-
if (pendingTsx) {
203-
addPlaygroundButton(pendingTsx.renderData, pendingTsx.html, js)
204-
}
205-
}
206-
pendingHtml = null
207-
pendingTsx = null
208-
} else {
209-
// Any other block type breaks the pairing
210-
pendingHtml = null
211-
pendingTsx = null
228+
addTsxButton(renderData.blockAst, codeBlock.code.trim())
229+
return
230+
}
231+
232+
if (codeBlock.language !== 'html') return
233+
234+
const { html, js } = splitHtmlAndScript(codeBlock.code)
235+
if (js) {
236+
addHtmlPlaygroundButton(renderData.blockAst, html, js)
237+
return
238+
}
239+
240+
const pairedJs = codeBlock.metaOptions.getString('playground-js')
241+
if (pairedJs) {
242+
addHtmlPlaygroundButton(
243+
renderData.blockAst,
244+
html,
245+
Buffer.from(pairedJs, 'base64url').toString('utf8')
246+
)
212247
}
213248
}
214249
}

0 commit comments

Comments
 (0)