Skip to content

feat: literate show-source and embed-lvt for rich interactive docs#251

Open
adnaan wants to merge 2 commits intomainfrom
feat/literate-and-embed-lvt
Open

feat: literate show-source and embed-lvt for rich interactive docs#251
adnaan wants to merge 2 commits intomainfrom
feat/literate-and-embed-lvt

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented May 6, 2026

Summary

Two complementary primitives that let tinkerdown markdown pages host rich interactive documentation — pages that both show and run the LiveTemplate code they document.

  • show-source flag on lvt blocks — 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 via lvt_show_source: true frontmatter.
  • New embed-lvt block — 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. Optional upstream= attribute auto-registers a reverse-proxy route — no tinkerdown.yaml entry needed.

The two compose: a single page can have both literate lvt blocks and embed-lvt blocks side-by-side. show-source works on both block types.

Authoring

Track A (literate template + live widget):

```lvt interactive show-source
<ul lvt-source="tasks">{{range .Data}}<li>{{.Text}}</li>{{end}}</ul>
```

Track B (embed a deployed app, auto-proxy):

```embed-lvt path="/apps/counter/" upstream="http://127.0.0.1:9090"
```

Examples: examples/literate-counter/, examples/embed-counter/. Reference docs: docs/guides/literate-docs.md, docs/sources/embed-lvt.md.

Test plan

  • Unit tests pass workspace-wide: GOWORK=off go test -tags=ci -count=1 ./... — 15 packages green
  • Track A e2e (chromedp): per-block flag, frontmatter default, hide-source override
  • Track B e2e (chromedp): server-side fetch + inline, unavailable-app badge fallback, per-visitor fetch
  • Manual UX on iPhone over Tailscale: literate-counter (http://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
  • Auto-proxy verified: [Routes] auto-registered 1 embed-lvt proxy route(s) on startup; proxied WS upgrade returns status=101; conflicts with manual routes: entries logged as warnings (first wins)

Notes

  • Inlined upstream data-lvt-id is renamed server-side to data-lvt-id-pending and renamed back client-side, to keep LiveTemplateClient.autoInit from 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).
  • The embed-lvt block is pointer-only by design — non-empty body is a parse error so authors don't accidentally write template code that gets silently discarded.
  • No livetemplate core changes required.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 6, 2026 05:45
Copy link
Copy Markdown

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

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_source frontmatter default to render a paired “source + live preview” card for lvt blocks.
  • Introduce embed-lvt blocks 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 thread embed_lvt.go
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 thread embed_lvt.go
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 thread embed_lvt.go
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 thread embed_lvt.go
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 thread page.go
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.

Comment thread docs/sources/embed-lvt.md
| `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 thread embed_lvt.go
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>
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