feat: literate show-source and embed-lvt for rich interactive docs#251
Open
feat: literate show-source and embed-lvt for rich interactive docs#251
show-source and embed-lvt for rich interactive docs#251Conversation
Adds two complementary primitives for richer interactive documentation, each carrying its own example, reference doc, unit + e2e tests: Track A — `show-source` flag on `lvt` blocks. When set (per-block or via frontmatter `lvt_show_source: true`), the rendered page pairs a syntax-highlighted view of the template with the live interactive widget in a single card with `Source` / `Live preview` labels. Lets docs pages teach by showing both the template authors wrote and the running thing it produces. Track B — `embed-lvt` block embeds a separately deployed LiveTemplate app inline. At request time the server fetches the upstream HTML and inlines its `<div data-lvt-id="...">` wrapper; the browser-side `EmbedLvtBlock` opens a dedicated WebSocket so the embed runs as a normal LiveTemplate session inside the docs page (no iframe). The `upstream=` attribute auto-registers a reverse-proxy route at `path=`, so authors don't need a parallel `routes:` entry in `tinkerdown.yaml`. `show-source` works on `embed-lvt` too. Implementation notes: - `parser.go`: recognize `embed-lvt` fence, `show-source`/`hide-source` flags, `lvt_show_source` frontmatter; emit demo-card wrapper + syntax-highlighted source alongside live container; guard empty fenced bodies in `parseCodeBlock`. - `embed_lvt.go`: server-side fetch with cookie/lang forwarding, timeout, wrapper extraction via `golang.org/x/net/html`, fallback badge on failure. Renames extracted `data-lvt-id` to `data-lvt-id-pending` so `LiveTemplateClient.autoInit` doesn't race the per-block client; the TS block renames it back before connect. - `internal/server/proxy_routes.go`: `registerAutoEmbedRoutes` walks every page's `EmbedRoutes` after `Discover()` and appends to `s.proxyRoutes`. Conflicts with config-declared routes log a warn, first-registration wins. - `internal/server/server.go`: wires `ProcessEmbedLvt` into the request path; adds `.tinkerdown-lvt-demo` card styling shared by both tracks. - Client: new `EmbedLvtBlock` class registers the `embed-lvt` block type and instantiates a per-block `LiveTemplateClient` pointed at the upstream's path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds two documentation-focused primitives to tinkerdown’s markdown pipeline so pages can both display and run LiveTemplate UIs inline: (1) a show-source/hide-source mechanism for lvt (and embed-lvt) blocks, and (2) a new embed-lvt fenced block that server-side fetches an upstream LiveTemplate HTML wrapper and inlines it, optionally auto-registering a reverse-proxy route.
Changes:
- Add
show-source+lvt_show_sourcefrontmatter default to render a paired “source + live preview” card forlvtblocks. - Introduce
embed-lvtblocks with request-time server-side fetch/inlining, plus client-side attachment via a dedicated LiveTemplateClient. - Auto-register reverse-proxy routes from
embed-lvt upstream=...declarations during server discovery.
Reviewed changes
Copilot reviewed 23 out of 25 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tinkerdown.go | Adds EmbedRoutes to Page for collecting auto-proxy route declarations. |
| parser.go | Adds lvt_show_source frontmatter, show/hide flags, and emits embed-lvt placeholders; wraps lvt blocks in demo cards when enabled. |
| page.go | Parses embed-lvt blocks, enforces empty body, and collects (path, upstream) pairs for auto-proxy registration. |
| embed_lvt.go | Implements request-time placeholder replacement by fetching and inlining upstream LiveTemplate wrapper HTML. |
| internal/server/server.go | Calls ProcessEmbedLvt per page request; registers auto embed routes during discovery; adds CSS for demo card layout. |
| internal/server/proxy_routes.go | Adds auto-proxy route creation/registration based on discovered pages’ EmbedRoutes. |
| client/src/types.ts | Extends BlockType to include embed-lvt. |
| client/src/tinkerdown-client.ts | Registers a new EmbedLvtBlock type. |
| client/src/blocks/embed-lvt-block.ts | Attaches a dedicated LiveTemplateClient to inlined upstream wrapper HTML. |
| internal/assets/client/tinkerdown-client.browser.js | Bundled browser client update reflecting the new block type. |
| show_source_test.go | Unit tests for show-source behavior and flag parsing. |
| literate_show_source_e2e_test.go | E2E coverage for show-source (non-CI). |
| embed_lvt_test.go | Unit tests for placeholder emission, wrapper extraction, cookie forwarding, and auto-route collection. |
| embed_lvt_e2e_test.go | E2E coverage for server-side fetch/inlining and failure badge (non-CI). |
| docs/sources/embed-lvt.md | Reference documentation for embed-lvt. |
| docs/guides/literate-docs.md | Guide documentation for show-source. |
| examples/* | Adds runnable examples for literate lvt and embed-lvt. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+266
to
+284
| func buildEmbedContainer(a embedAttrs, wrapperHTML string) string { | ||
| var sb strings.Builder | ||
| sb.WriteString(`<div class="tinkerdown-embed-lvt" data-tinkerdown-block`) | ||
| if a.blockID != "" { | ||
| fmt.Fprintf(&sb, ` data-block-id=%q`, a.blockID) | ||
| } | ||
| sb.WriteString(` data-block-type="embed-lvt"`) | ||
| if a.server != "" { | ||
| fmt.Fprintf(&sb, ` data-embed-server=%q`, a.server) | ||
| } | ||
| if a.path != "" && a.path != "/" { | ||
| fmt.Fprintf(&sb, ` data-embed-path=%q`, a.path) | ||
| } | ||
| if a.session != "" { | ||
| fmt.Fprintf(&sb, ` data-embed-session=%q`, a.session) | ||
| } | ||
| if a.style != "" { | ||
| fmt.Fprintf(&sb, ` style=%q`, a.style) | ||
| } |
Comment on lines
+295
to
+305
| func embedUnavailableBadge(a embedAttrs, reason string) string { | ||
| style := `padding:0.75rem 1rem;border:1px dashed var(--card-border,#bbb);` + | ||
| `border-radius:8px;color:var(--muted-color,#666);font-size:0.9em;` | ||
| if a.style != "" { | ||
| style = a.style + ";" + style | ||
| } | ||
| return fmt.Sprintf( | ||
| `<div class="tinkerdown-embed-lvt unavailable" data-block-type="embed-lvt" style=%q title=%q>live demo unavailable</div>`, | ||
| style, | ||
| reason, | ||
| ) |
Comment on lines
+82
to
+108
| func parseEmbedAttrs(placeholder string) embedAttrs { | ||
| a := embedAttrs{path: "/", timeout: defaultEmbedTimeout} | ||
| for _, m := range dataAttrRe.FindAllStringSubmatch(placeholder, -1) { | ||
| switch m[1] { | ||
| case "data-block-id": | ||
| a.blockID = m[2] | ||
| case "data-embed-server": | ||
| a.server = m[2] | ||
| case "data-embed-path": | ||
| if m[2] != "" { | ||
| a.path = m[2] | ||
| } | ||
| case "data-embed-session": | ||
| a.session = m[2] | ||
| case "data-embed-timeout": | ||
| if d, err := time.ParseDuration(m[2]); err == nil && d > 0 { | ||
| a.timeout = d | ||
| } | ||
| case "data-embed-upstream": | ||
| a.upstream = m[2] | ||
| case "data-show-source": | ||
| a.showSource = m[2] == "true" | ||
| case "style": | ||
| a.style = m[2] | ||
| } | ||
| } | ||
| return a |
Comment on lines
+122
to
+137
| func buildUpstreamURL(a embedAttrs, req *http.Request) (string, error) { | ||
| if a.upstream != "" { | ||
| return strings.TrimRight(a.upstream, "/") + a.path, nil | ||
| } | ||
| if a.server == "" { | ||
| if req == nil { | ||
| return "", fmt.Errorf("no server/upstream attribute and no request to derive origin") | ||
| } | ||
| scheme := "http" | ||
| if req.TLS != nil || strings.EqualFold(req.Header.Get("X-Forwarded-Proto"), "https") { | ||
| scheme = "https" | ||
| } | ||
| return scheme + "://" + req.Host + a.path, nil | ||
| } | ||
| server := strings.TrimRight(a.server, "/") | ||
| return server + a.path, nil |
Comment on lines
+388
to
+397
| if up := cb.Metadata["upstream"]; up != "" { | ||
| path := cb.Metadata["path"] | ||
| if path == "" { | ||
| path = "/" | ||
| } | ||
| p.EmbedRoutes = append(p.EmbedRoutes, EmbedRoute{ | ||
| Path: path, | ||
| Upstream: up, | ||
| }) | ||
| } |
Comment on lines
+102
to
+135
| existing := make(map[string]string, len(s.proxyRoutes)) | ||
| for _, pr := range s.proxyRoutes { | ||
| existing[pr.pattern] = pr.upstream | ||
| } | ||
|
|
||
| pages := s.collectPagesForAutoRoutes() | ||
| added := 0 | ||
| for _, page := range pages { | ||
| if page == nil { | ||
| continue | ||
| } | ||
| for _, er := range page.EmbedRoutes { | ||
| pattern := er.Path | ||
| upstream := er.Upstream | ||
| if pattern == "" || upstream == "" { | ||
| continue | ||
| } | ||
| if existingUpstream, ok := existing[pattern]; ok { | ||
| if existingUpstream != upstream { | ||
| log.Printf("[Routes] embed-lvt route %s declares upstream %s but route already maps to %s — keeping existing", | ||
| pattern, upstream, existingUpstream) | ||
| } | ||
| continue | ||
| } | ||
| pr, err := newAutoProxyRoute(pattern, upstream) | ||
| if err != nil { | ||
| log.Printf("[Routes] skipping invalid embed-lvt route %s -> %s: %v", pattern, upstream, err) | ||
| continue | ||
| } | ||
| s.proxyRoutes = append(s.proxyRoutes, pr) | ||
| existing[pattern] = upstream | ||
| added++ | ||
| } | ||
| } |
Comment on lines
+21
to
+35
| This example assumes you've configured a tinkerdown proxy route that | ||
| forwards `/apps/counter/` to a LiveTemplate counter app. Add to your | ||
| `tinkerdown.yaml`: | ||
|
|
||
| ```yaml | ||
| routes: | ||
| - pattern: /apps/counter/ | ||
| type: proxy | ||
| upstream: http://localhost:9090 | ||
| ``` | ||
|
|
||
| Then run the LiveTemplate counter on `:9090` (e.g. from | ||
| `livetemplate/examples/counter`) and tinkerdown will reach it | ||
| through the proxy. No CORS configuration needed. | ||
|
|
| | `upstream` | (none) | HTTP origin of the deployed app (e.g. `http://127.0.0.1:9090`). When set, tinkerdown auto-registers a reverse-proxy from `path` to this upstream — no `tinkerdown.yaml` entry needed. The server-side fetch goes directly here, the browser-side WebSocket flows through the auto-registered proxy. | | ||
| | `server` | docs origin | Cross-origin direct mode: browser connects straight to this remote origin instead of through tinkerdown's proxy. The remote app must list the docs origin in `AllowedOrigins`. | | ||
| | `path` | `/` | Path the docs page proxies and fetches. Required when `upstream=` is set (otherwise the auto-proxy would intercept `/`). | | ||
| | `session` | unique-per-block | Two blocks with the same `session` value share one upstream session — useful for splitting one app's UI across the page. | |
Comment on lines
+65
to
+68
|
|
||
| const { wsUrl, liveUrl } = this.computeEndpoints(server, path); | ||
| this.log("Connecting to upstream", { wsUrl, liveUrl }); | ||
|
|
Comment on lines
+140
to
+162
| // fetchUpstream performs the GET. Forwards Cookie and Accept-Language | ||
| // from the docs request when present so the upstream sees the same | ||
| // auth context. | ||
| func fetchUpstream(upstreamURL string, req *http.Request, timeout time.Duration) (string, error) { | ||
| ctx := context.Background() | ||
| if req != nil { | ||
| ctx = req.Context() | ||
| } | ||
| ctx, cancel := context.WithTimeout(ctx, timeout) | ||
| defer cancel() | ||
|
|
||
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil) | ||
| if err != nil { | ||
| return "", fmt.Errorf("build request: %w", err) | ||
| } | ||
| if req != nil { | ||
| if v := req.Header.Get("Cookie"); v != "" { | ||
| httpReq.Header.Set("Cookie", v) | ||
| } | ||
| if v := req.Header.Get("Accept-Language"); v != "" { | ||
| httpReq.Header.Set("Accept-Language", v) | ||
| } | ||
| } |
…emos The original PR shipped one example per track (`literate-counter`, `embed-counter`). The plan called for several more authoring patterns that are worth seeing as runnable demos: - `examples/literate-linked/`: two `lvt` blocks reading the same `lvt-source` — toggling either widget updates both, illustrating multi-region UI from a single backing data source. Uses `lvt_show_source: true` frontmatter so each block ships its template view alongside the live preview without per-block flags. - `examples/embed-shared-session/`: two `embed-lvt` blocks pointing at the same upstream with `session="tour"` — split UI of one app across separate sections of the docs page, sharing one upstream session. - `examples/mixed-tracks/`: a literate `lvt` block and a remote `embed-lvt` block on the same page, showing the two runtimes coexist (separate WebSockets, separate state, no interaction). Each example is self-contained and validates clean. The shared-session and mixed-tracks demos reuse the same upstream counter setup as `embed-counter`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two complementary primitives that let
tinkerdownmarkdown pages host rich interactive documentation — pages that both show and run the LiveTemplate code they document.show-sourceflag onlvtblocks — pairs a syntax-highlighted template view with the live widget in one labeled card (Source / Live preview), so readers see exactly what produces what. Per-block flag or page-level vialvt_show_source: truefrontmatter.embed-lvtblock — embeds a separately deployed LiveTemplate app inline (not via<iframe>): server-side fetches the upstream HTML, inlines its<div data-lvt-id="…">wrapper, then the browser opens a dedicated WebSocket so the embed runs as a normal LiveTemplate session inside the docs page. Optionalupstream=attribute auto-registers a reverse-proxy route — notinkerdown.yamlentry needed.The two compose: a single page can have both literate
lvtblocks andembed-lvtblocks side-by-side.show-sourceworks on both block types.Authoring
Track A (literate template + live widget):
Track B (embed a deployed app, auto-proxy):
Examples:
examples/literate-counter/,examples/embed-counter/. Reference docs:docs/guides/literate-docs.md,docs/sources/embed-lvt.md.Test plan
GOWORK=off go test -tags=ci -count=1 ./...— 15 packages greenhide-sourceoverridehttp://devbox:8082/) — checkbox toggles propagate, source listing readable; embed-counter (http://devbox:8081/) — counter increments via auto-proxied WebSocket, source view paired with live preview[Routes] auto-registered 1 embed-lvt proxy route(s)on startup; proxied WS upgrade returnsstatus=101; conflicts with manualroutes:entries logged as warnings (first wins)Notes
data-lvt-idis renamed server-side todata-lvt-id-pendingand renamed back client-side, to keepLiveTemplateClient.autoInitfrom racing with the per-embed client. Without this, two clients fight over the same wrapper and one connects to a wrong default URL (visible as a benign-but-noisy console error).embed-lvtblock is pointer-only by design — non-empty body is a parse error so authors don't accidentally write template code that gets silently discarded.🤖 Generated with Claude Code