Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions cmd/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,17 +447,54 @@ func (r *linkRewriter) RewriteRelative(body string, page PageEntry, ref string)
if inFence {
continue
}
lines[i] = markdownLinkRE.ReplaceAllStringFunc(ln, func(m string) string {
target := markdownLinkRE.FindStringSubmatch(m)[1]
if !isRelativeRef(target) {
return m
}
return "](" + r.resolveRelative(target, repo, srcDir, ref) + ")"
lines[i] = rewriteOutsideCodeSpans(ln, func(seg string) string {
return markdownLinkRE.ReplaceAllStringFunc(seg, func(m string) string {
target := markdownLinkRE.FindStringSubmatch(m)[1]
if !isRelativeRef(target) {
return m
}
return "](" + r.resolveRelative(target, repo, srcDir, ref) + ")"
})
})
}
return strings.Join(lines, "\n")
}

// rewriteOutsideCodeSpans applies f to the parts of a line that are NOT inside
// a backtick-delimited inline code span.
//
// Fenced blocks were already skipped, but inline spans were not, and Go generic
// call syntax inside one — `AssertPureState[T](t)` — contains the exact
// `](...)` shape a markdown link does. Rewriting it turned documented code into
// a URL in five places across the reference and guides before this was caught.
//
// Splitting on ` gives alternating outside/inside segments: even indices are
// prose, odd indices are code.
//
// An ODD number of backticks means the spans cannot be paired, and the parity
// is then wrong from the stray one onward — code would land on an "outside"
// index and be rewritten, which is the corruption this exists to prevent. Such
// a line is left entirely alone. The cost is a genuine link on a malformed line
// going un-rewritten, which leaves it exactly as upstream wrote it; the
// alternative is mangling documented code, and only one of those is recoverable
// by a later fix.
func rewriteOutsideCodeSpans(line string, f func(string) string) string {
n := strings.Count(line, "`")
if n == 0 {
return f(line)
}
if n%2 != 0 {
return line
}
parts := strings.Split(line, "`")
for i := range parts {
if i%2 == 0 {
parts[i] = f(parts[i])
}
}
return strings.Join(parts, "`")
}

// resolveRelative maps one upstream-relative link target to its docs-site or
// GitHub destination, preserving any #fragment.
func (r *linkRewriter) resolveRelative(target, repo, srcDir, ref string) string {
Expand Down
49 changes: 49 additions & 0 deletions cmd/sync/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,52 @@ func TestRewriteRelative_BareUnmappedBecomesGitHubURL(t *testing.T) {
t.Errorf("bare unmapped sibling not rewritten to GitHub: %q", got)
}
}

func TestRewriteRelative_LeavesInlineCodeSpansAlone(t *testing.T) {
// Go generic call syntax inside a code span contains the same ](...)
// shape a markdown link does. Rewriting it corrupted documented code in
// five places across the reference and guides before this was caught.
r := newLinkRewriter(relCfg())
page := PageEntry{SiteURL: "/reference/session", SourceRepo: "https://github.com/livetemplate/livetemplate", SourcePath: "docs/references/session.md"}

body := "Use `AssertPureState[T](t)` in tests, and see [the API](api-reference.md)."
got := r.RewriteRelative(body, page, "v0.23.0")

if !strings.Contains(got, "`AssertPureState[T](t)`") {
t.Errorf("inline code span was rewritten: %q", got)
}
if !strings.Contains(got, "[the API](/reference/api)") {
t.Errorf("real link outside the span should still rewrite: %q", got)
}
}

func TestRewriteRelative_MultipleCodeSpansOnOneLine(t *testing.T) {
r := newLinkRewriter(relCfg())
page := PageEntry{SiteURL: "/reference/session", SourceRepo: "https://github.com/livetemplate/livetemplate", SourcePath: "docs/references/session.md"}

body := "`f[A](t)` then [x](api-reference.md) then `g[B](t)` then [y](../guides/SCALING.md)"
got := r.RewriteRelative(body, page, "v0.23.0")

for _, keep := range []string{"`f[A](t)`", "`g[B](t)`"} {
if !strings.Contains(got, keep) {
t.Errorf("code span %s was rewritten: %q", keep, got)
}
}
if !strings.Contains(got, "[x](/reference/api)") || !strings.Contains(got, "[y](/guides/scaling)") {
t.Errorf("links between spans should rewrite: %q", got)
}
}

func TestRewriteRelative_UnbalancedBacktickErrsTowardNotRewriting(t *testing.T) {
// A stray backtick should not cause a corrupting rewrite; leaving the
// remainder alone is the safe failure direction.
r := newLinkRewriter(relCfg())
page := PageEntry{SiteURL: "/reference/session", SourceRepo: "https://github.com/livetemplate/livetemplate", SourcePath: "docs/references/session.md"}

body := "a stray ` backtick then `AssertPureState[T](t)`"
got := r.RewriteRelative(body, page, "v0.23.0")

if strings.Contains(got, "github.com") {
t.Errorf("unbalanced backtick produced a rewrite: %q", got)
}
}
33 changes: 31 additions & 2 deletions content/changelog/livetemplate.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: "Changelog"
source_repo: "https://github.com/livetemplate/livetemplate"
source_path: "CHANGELOG.md"
source_ref: "v0.22.0"
source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe"
source_ref: "v0.23.0"
source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6"
---

# Changelog
Expand All @@ -13,6 +13,35 @@ All notable changes to LiveTemplate will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v0.23.0] - 2026-08-02

### Added

- **`LiveHandler.Func()` returns `ServeHTTP` as an `http.HandlerFunc`.** The
value `Template.Handle()` returns already satisfied `http.Handler`, so
`http.Handle`/`mux.Handle` worked, but the stdlib entry points that take a
function — `http.HandleFunc`, and `ServeMux.HandleFunc` with Go 1.22 method
patterns — required spelling out `handler.ServeHTTP`. `Func()` is that method
value, so `http.HandleFunc("/counter", handler.Func())` and
`mux.HandleFunc("GET /counter", handler.Func())` read naturally. It is an
accessor, not a downgrade: `Shutdown`, `Publish` and `MetricsHandler` stay
available on the `LiveHandler` it came from.

### Changed

- **A failed WebSocket upgrade now logs a `hint` when the `http.ResponseWriter`
does not implement `http.Hijacker`.** An upgrade takes over the raw
connection, so middleware that wraps the writer (logging, gzip, status
capture) without forwarding `Hijack` breaks it — while GET and POST keep
rendering, making the symptom "the page renders but never goes live". The
underlying upgrader error names `http.Hijacker` but not the middleware that
caused it; the hint does, and points at forwarding `Hijack` or leaving the
writer unwrapped when `livetemplate.WSIsUpgrade(r)` is true. It is attached on
the writer's own defect, which need not be what the accompanying error reports
— an upgrader can reject a handshake earlier (a disallowed `Origin`) and never
reach the hijack — so it is worded as a second failure the upgrade would have
hit regardless, rather than as the reported cause.

## [v0.22.0] - 2026-07-26

### Added
Expand Down
8 changes: 4 additions & 4 deletions content/contributing/livetemplate.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: "Contributing to LiveTemplate Core Library"
source_repo: "https://github.com/livetemplate/livetemplate"
source_path: "CONTRIBUTING.md"
source_ref: "v0.22.0"
source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe"
source_ref: "v0.23.0"
source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6"
---

# Contributing to LiveTemplate Core Library
Expand Down Expand Up @@ -226,7 +226,7 @@ livetemplate/
└── scripts/ # Development scripts
```

For the complete file-by-file map with line counts and dependencies, see [docs/design/CODE_STRUCTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/design/CODE_STRUCTURE.md).
For the complete file-by-file map with line counts and dependencies, see [docs/design/CODE_STRUCTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/CODE_STRUCTURE.md).

**Note:** The client library, CLI tool, and examples are now in separate repositories:
- Client: https://github.com/livetemplate/client
Expand Down Expand Up @@ -528,7 +528,7 @@ Look for issues labeled `good first issue` - these are:
### Learning the Codebase

1. **Start with the Contributor Walkthrough**
- [`docs/guides/new-contributor-walkthrough.md`](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/new-contributor-walkthrough.md) - **START HERE!** Comprehensive guide to the 5-phase architecture with links to all code and tests
- [`docs/guides/new-contributor-walkthrough.md`](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/guides/new-contributor-walkthrough.md) - **START HERE!** Comprehensive guide to the 5-phase architecture with links to all code and tests

2. **Read the architecture docs**
- `CLAUDE.md` - Development guidelines
Expand Down
6 changes: 3 additions & 3 deletions content/guides/ephemeral-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: "Ephemeral Components Guide"
source_repo: "https://github.com/livetemplate/livetemplate"
source_path: "docs/guides/ephemeral-components.md"
source_ref: "v0.22.0"
source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe"
source_ref: "v0.23.0"
source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6"
---

# Ephemeral Components Guide
Expand Down Expand Up @@ -62,7 +62,7 @@ type AppState struct {
}
```

> **Note on `AssertPureState`**: If your tests use `lvt/testing.AssertPureState[T](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/t)` to verify state contains no dependency types, `*toast.Container` will need to be excluded. Component containers are not external dependencies — they hold transient UI data, not connections or handles. Use `AssertPureState` with the `IgnoreFields` option, or structure your state so component fields live in a separate struct that is not checked.
> **Note on `AssertPureState`**: If your tests use `lvt/testing.AssertPureState[T](t)` to verify state contains no dependency types, `*toast.Container` will need to be excluded. Component containers are not external dependencies — they hold transient UI data, not connections or handles. Use `AssertPureState` with the `IgnoreFields` option, or structure your state so component fields live in a separate struct that is not checked.

### Initialization

Expand Down
8 changes: 4 additions & 4 deletions content/guides/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
title: "LiveTemplate Observability Guide"
source_repo: "https://github.com/livetemplate/livetemplate"
source_path: "docs/guides/OBSERVABILITY.md"
source_ref: "v0.22.0"
source_commit: "22a4853506a682583b511e470bdd1e6193f4d5fe"
source_ref: "v0.23.0"
source_commit: "8294ce439a46a6a1f92e2a77b8a4978c9e526cc6"
---

# LiveTemplate Observability Guide
Expand Down Expand Up @@ -409,6 +409,6 @@ func RequestIDMiddleware(next http.Handler) http.Handler {

## Related Documentation

- [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.22.0/docs/guides/ARCHITECTURE.md) - System architecture overview
- [internal/observe/](https://github.com/livetemplate/livetemplate/tree/v0.22.0/docs/internal/observe) - Package implementation
- [ARCHITECTURE.md](https://github.com/livetemplate/livetemplate/blob/v0.23.0/docs/design/ARCHITECTURE.md) - System architecture overview
- [internal/observe/](https://github.com/livetemplate/livetemplate/tree/v0.23.0/internal/observe) - Package implementation
- [Go slog documentation](https://pkg.go.dev/log/slog) - Standard library reference
Loading
Loading