For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build Topic anchoring, render-map translation, server-side highlight resolution, the Topic HTTP API, and the minimal selection/sidebar UI for collaborative annotations.
Architecture: internal/collab owns anchor JSON types plus typed storage reads/writes; internal/render owns source-position metadata, render maps, and server-side anchor resolution; internal/server wires those pieces into JSON APIs and the iframe/sidebar UI. The implementation keeps #4 Incorporation out of scope while preserving the re-anchor contract by making every open non-global Topic resolvable as pre-marker or marker.
Tech Stack: Go 1.26, database/sql with modernc.org/sqlite, github.com/google/uuid, goldmark, golang.org/x/net/html, net/http, vanilla JavaScript, existing templates/static assets.
Reference spec: docs/superpowers/specs/2026-05-11-topic-core-design.html. Also check docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md for rationale.
internal/collab/
├── anchor.go # Anchor JSON model, validation, marshal/unmarshal helpers
├── anchor_test.go
├── mutators.go # MODIFIED: require non-null valid anchors; validate message kinds/body;
│ # add InsertTopicWithFirstMessage (single transaction)
├── mutators_test.go # MODIFIED: anchor and message validation tests; atomicity test
├── migrate.go # MODIFIED: backfill NULL topics.anchor → '{"kind":"global"}' on startup
├── reader.go # Read APIs for sidebar/API: open topics and message threads
└── reader_test.go
internal/render/
├── render.go # MODIFIED: Document gains RenderMap field
├── markdown.go # MODIFIED: NodeRenderer wrapper emits data-source-* and BlockMaps
├── html.go # MODIFIED: html.Tokenizer pass over authored HTML; same shape
├── cache.go # MODIFIED: cache keys include git blob SHA supplied by server
├── rendermap.go # RenderMap types and translation helpers
├── rendermap_test.go
├── sourcepos.go # Source-position helpers shared by markdown.go and html.go
├── sourcepos_test.go
├── resolver.go # HTML-parser-based resolver; emits wb-anchor marks with overlap support
└── resolver_test.go
internal/server/
├── server.go # MODIFIED: Deps gains Collab store and OperatorUserID
├── handler_content.go # MODIFIED: render .html through render.Cache; compute SourceSHA via
│ # collab.SourceSHA; apply resolver for active topics
├── handler_doc.go # MODIFIED: shell receives current source path for topics sidebar
├── handler_doc_test.go # MODIFIED: test server opens collab store
├── topics.go # Topic JSON API handlers (uses collab.SourceSHA for staleness;
│ # InsertTopicWithFirstMessage for atomic create)
├── topics_test.go
├── embed.go # MODIFIED: ContentMDData gains SourceSHA
├── templates/content_md.html # MODIFIED: source SHA meta and topics script hook
├── templates/shell.html # MODIFIED: sidebar pane next to iframe (iframe column first)
├── templates_test.go # MODIFIED: template assertions
├── static/content.js # MODIFIED: selection capture inside iframe
├── static/chrome.js # MODIFIED: sidebar API calls and message handling
├── static/chrome.css # MODIFIED: topic sidebar layout
└── static/prose.css # MODIFIED: wb-anchor highlight styling
cmd/wiki-browser/main.go # MODIFIED: pass Collab and operator into server.Deps
# Playwright-cli smoke script (not committed code; verification only)
docs/superpowers/plans/2026-05-11-topic-core-smoke.sh # OPTIONAL: scripted playwright-cli smoke
The first four tasks are backend-only and can be reviewed without browser work. Tasks 5-7 wire those primitives into HTTP rendering and JSON endpoints. Task 8 adds the minimal UI and a browser smoke test.
Files:
Create: internal/collab/anchor.go
Create: internal/collab/anchor_test.go
Modify: internal/collab/mutators.go
Modify: internal/collab/mutators_test.go
Step 1: Write the failing anchor tests
Create internal/collab/anchor_test.go:
package collab_test
import (
"encoding/json"
"strings"
"testing"
"github.com/getorcha/wiki-browser/internal/collab"
)
func TestAnchor_roundTrip(t *testing.T) {
cases := []collab.Anchor{
collab.PreMarkerAnchor{SourceSHA: strings.Repeat("a", 40), Start: 12, End: 18, Quote: "alpha"},
collab.MarkerAnchor{},
collab.GlobalAnchor{},
}
for _, in := range cases {
raw, err := collab.MarshalAnchor(in)
if err != nil {
t.Fatalf("MarshalAnchor(%T): %v", in, err)
}
out, err := collab.UnmarshalAnchor(raw)
if err != nil {
t.Fatalf("UnmarshalAnchor(%s): %v", raw, err)
}
raw2, err := collab.MarshalAnchor(out)
if err != nil {
t.Fatalf("MarshalAnchor(second): %v", err)
}
if string(raw2) != string(raw) {
t.Errorf("round trip %T = %s, want %s", in, raw2, raw)
}
}
}
func TestAnchor_validation(t *testing.T) {
for name, a := range map[string]collab.Anchor{
"pre-marker sha": collab.PreMarkerAnchor{SourceSHA: "short", Start: 1, End: 2, Quote: "q"},
"pre-marker range": collab.PreMarkerAnchor{SourceSHA: strings.Repeat("a", 40), Start: 2, End: 2, Quote: "q"},
"pre-marker quote": collab.PreMarkerAnchor{SourceSHA: strings.Repeat("a", 40), Start: 1, End: 2},
} {
if err := collab.ValidateAnchor(a); err == nil {
t.Errorf("%s: expected validation error", name)
}
}
}
func TestUnmarshalAnchor_rejectsUnknownKindAndExtraFields(t *testing.T) {
for _, raw := range []string{
`{"kind":"lost"}`,
`{"kind":"marker","start":1}`,
`{"kind":"global","quote":"x"}`,
`{"kind":"pre-marker","source_sha":"` + strings.Repeat("a", 40) + `","start":1,"end":2}`,
} {
if _, err := collab.UnmarshalAnchor(json.RawMessage(raw)); err == nil {
t.Errorf("UnmarshalAnchor(%s) succeeded, want error", raw)
}
}
}
Append to internal/collab/mutators_test.go:
func TestInsertTopic_requiresValidAnchor(t *testing.T) {
s := openStore(t)
if _, err := s.InsertTopic(collab.NewTopic{ID: "missing", SourcePath: "a.md", CreatedBy: "alice"}); err == nil {
t.Fatal("InsertTopic without anchor succeeded, want error")
}
raw, err := collab.MarshalAnchor(collab.GlobalAnchor{})
if err != nil {
t.Fatal(err)
}
if _, err := s.InsertTopic(collab.NewTopic{
ID: "global", SourcePath: "a.md", Anchor: raw, CreatedBy: "alice",
}); err != nil {
t.Fatalf("InsertTopic with global anchor: %v", err)
}
}
func TestInsertMessage_validatesKindAndBody(t *testing.T) {
s := openStore(t)
raw, err := collab.MarshalAnchor(collab.GlobalAnchor{})
if err != nil {
t.Fatal(err)
}
if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", Anchor: raw, CreatedBy: "alice"}); err != nil {
t.Fatal(err)
}
for _, m := range []collab.NewMessage{
{ID: "empty", TopicID: "t1", Kind: "human", AuthorUserID: ptr("alice")},
{ID: "bad-kind", TopicID: "t1", Kind: "system", Body: "body", AuthorUserID: ptr("alice")},
{ID: "bad-human", TopicID: "t1", Kind: "human", Body: "body"},
{ID: "bad-proposal", TopicID: "t1", Kind: "agent-proposal", Body: "body", AuthorUserID: ptr("alice")},
} {
if _, err := s.InsertMessage(m); err == nil {
t.Errorf("InsertMessage(%s) succeeded, want error", m.ID)
}
}
}
func TestInsertTopicWithFirstMessage_atomic(t *testing.T) {
s := openStore(t)
in := collab.NewTopicWithFirstMessage{
TopicID: "t1", SourcePath: "a.md", Anchor: globalAnchor(t), CreatedBy: "alice",
FirstMessageID: "m1", FirstMessageBody: "first",
}
if err := s.InsertTopicWithFirstMessage(in); err != nil {
t.Fatalf("InsertTopicWithFirstMessage: %v", err)
}
// A failure after the topic insert must roll back the topic too. Reuse
// an existing message ID so the topic insert succeeds and the message
// insert fails on the topic_messages primary key.
bad := in
bad.TopicID = "t2"
bad.FirstMessageID = "m1"
bad.FirstMessageBody = "second"
if err := s.InsertTopicWithFirstMessage(bad); err == nil {
t.Fatal("expected error for duplicate first message ID")
}
row := s.RawDBForTest().QueryRow(`SELECT COUNT(*) FROM topics WHERE id = ?`, "t2")
var n int
if err := row.Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("rolled-back topic still present: count=%d", n)
}
}
Run: go test ./internal/collab/... -run 'TestAnchor|TestInsertTopic_requiresValidAnchor|TestInsertMessage_validatesKindAndBody' -v
Expected: FAIL because Anchor, MarshalAnchor, UnmarshalAnchor, ValidateAnchor, and stricter mutator validation do not exist.
Create internal/collab/anchor.go:
package collab
import (
"bytes"
"encoding/json"
"fmt"
)
type Anchor interface {
AnchorKind() string
}
type PreMarkerAnchor struct {
SourceSHA string `json:"source_sha"`
Start int `json:"start"`
End int `json:"end"`
Quote string `json:"quote"`
}
func (PreMarkerAnchor) AnchorKind() string { return "pre-marker" }
type MarkerAnchor struct{}
func (MarkerAnchor) AnchorKind() string { return "marker" }
type GlobalAnchor struct{}
func (GlobalAnchor) AnchorKind() string { return "global" }
func MarshalAnchor(a Anchor) (json.RawMessage, error) {
if err := ValidateAnchor(a); err != nil {
return nil, err
}
switch v := a.(type) {
case PreMarkerAnchor:
return json.Marshal(struct {
Kind string `json:"kind"`
PreMarkerAnchor
}{Kind: v.AnchorKind(), PreMarkerAnchor: v})
case MarkerAnchor:
return json.Marshal(struct {
Kind string `json:"kind"`
}{Kind: v.AnchorKind()})
case GlobalAnchor:
return json.Marshal(struct {
Kind string `json:"kind"`
}{Kind: v.AnchorKind()})
default:
return nil, fmt.Errorf("collab anchor: unsupported type %T", a)
}
}
func UnmarshalAnchor(raw json.RawMessage) (Anchor, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var head struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil, fmt.Errorf("collab anchor: decode kind: %w", err)
}
switch head.Kind {
case "pre-marker":
var v struct {
Kind string `json:"kind"`
SourceSHA string `json:"source_sha"`
Start int `json:"start"`
End int `json:"end"`
Quote string `json:"quote"`
}
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("collab anchor: decode pre-marker: %w", err)
}
a := PreMarkerAnchor{SourceSHA: v.SourceSHA, Start: v.Start, End: v.End, Quote: v.Quote}
return a, ValidateAnchor(a)
case "marker":
var v struct {
Kind string `json:"kind"`
}
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("collab anchor: decode marker: %w", err)
}
return MarkerAnchor{}, nil
case "global":
var v struct {
Kind string `json:"kind"`
}
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("collab anchor: decode global: %w", err)
}
return GlobalAnchor{}, nil
default:
return nil, fmt.Errorf("collab anchor: unknown kind %q", head.Kind)
}
}
func ValidateAnchor(a Anchor) error {
switch v := a.(type) {
case PreMarkerAnchor:
if len(v.SourceSHA) != 40 {
return fmt.Errorf("collab anchor: pre-marker source_sha must be 40 chars")
}
if v.Start < 0 || v.End <= v.Start {
return fmt.Errorf("collab anchor: pre-marker range must be non-empty")
}
if v.Quote == "" {
return fmt.Errorf("collab anchor: pre-marker quote required")
}
return nil
case MarkerAnchor:
return nil
case GlobalAnchor:
return nil
case nil:
return fmt.Errorf("collab anchor: required")
default:
return fmt.Errorf("collab anchor: unsupported type %T", a)
}
}
In internal/collab/mutators.go, change NewTopic.Anchor from *string to json.RawMessage and import encoding/json plus strings.
Replace the start of InsertTopic validation with:
if t.ID == "" || t.SourcePath == "" || t.CreatedBy == "" {
return "", fmt.Errorf("collab.InsertTopic: id/source_path/created_by required")
}
if len(t.Anchor) == 0 {
return "", fmt.Errorf("collab.InsertTopic: anchor required")
}
if _, err := UnmarshalAnchor(t.Anchor); err != nil {
return "", fmt.Errorf("collab.InsertTopic: %w", err)
}
In the SQL insert, pass string(t.Anchor) instead of t.Anchor.
Add this helper near InsertMessage:
const maxMessageBodyBytes = 64 << 10
func validateMessage(m NewMessage) error {
if m.ID == "" || m.TopicID == "" || m.Kind == "" {
return fmt.Errorf("collab.InsertMessage: id/topic_id/kind required")
}
if strings.TrimSpace(m.Body) == "" {
return fmt.Errorf("collab.InsertMessage: body required")
}
if len([]byte(m.Body)) > maxMessageBodyBytes {
return fmt.Errorf("collab.InsertMessage: body exceeds 64 KiB")
}
switch m.Kind {
case "human":
if m.AuthorUserID == nil || *m.AuthorUserID == "" || m.ProposalID != nil {
return fmt.Errorf("collab.InsertMessage: human messages require author_user_id and no proposal_id")
}
case "agent-proposal":
if m.AuthorUserID != nil || m.ProposalID == nil || *m.ProposalID == "" {
return fmt.Errorf("collab.InsertMessage: agent-proposal messages require proposal_id and no author_user_id")
}
default:
return fmt.Errorf("collab.InsertMessage: unsupported kind %q", m.Kind)
}
return nil
}
Replace the first validation block in InsertMessage with:
if err := validateMessage(m); err != nil {
return "", err
}
Topic creation must be atomic — a Topic without its required first message is an invariant violation (sidebar previews assume one exists). Add this to internal/collab/mutators.go:
// NewTopicWithFirstMessage creates a Topic and its first message in one
// transaction so a partial failure leaves no rows behind.
type NewTopicWithFirstMessage struct {
TopicID string
SourcePath string
Anchor json.RawMessage
CreatedBy string
FirstMessageID string
FirstMessageBody string
}
func (s *Store) InsertTopicWithFirstMessage(in NewTopicWithFirstMessage) error {
if in.TopicID == "" || in.SourcePath == "" || in.CreatedBy == "" ||
in.FirstMessageID == "" {
return fmt.Errorf("collab.InsertTopicWithFirstMessage: required fields missing")
}
if len(in.Anchor) == 0 {
return fmt.Errorf("collab.InsertTopicWithFirstMessage: anchor required")
}
if _, err := UnmarshalAnchor(in.Anchor); err != nil {
return fmt.Errorf("collab.InsertTopicWithFirstMessage: %w", err)
}
if _, err := ValidateSourcePath(in.SourcePath); err != nil {
return fmt.Errorf("collab.InsertTopicWithFirstMessage: %w", err)
}
createdBy := in.CreatedBy
msg := NewMessage{
ID: in.FirstMessageID, TopicID: in.TopicID, Kind: "human",
Body: in.FirstMessageBody, AuthorUserID: &createdBy,
}
if err := validateMessage(msg); err != nil {
return err
}
return s.send(func(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO topics(id, source_path, anchor, created_at, created_by, updated_at)
VALUES (?, ?, ?, unixepoch(), ?, unixepoch())`,
in.TopicID, in.SourcePath, string(in.Anchor), in.CreatedBy,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("insert topic: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO topic_messages(
id, topic_id, kind, body, author_user_id, proposal_id, sequence, created_at
) VALUES (?, ?, 'human', ?, ?, NULL, 1, unixepoch())`,
msg.ID, msg.TopicID, msg.Body, msg.AuthorUserID,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("insert first message: %w", err)
}
return tx.Commit()
})
}
The sequence is hard-coded to 1 because this is by definition the first message; the funnel ensures no other writer can interleave.
Pre-existing dev databases may have rows with topics.anchor = NULL. After the validation tightening, the reader API in Task 2 will error on those rows. Add an idempotent backfill in internal/collab/collab.go, called from Open after Migrate:
// backfillNullAnchors converts legacy rows with anchor IS NULL to a
// '{"kind":"global"}' anchor so the typed reader never sees NULL.
func backfillNullAnchors(db *sql.DB) error {
_, err := db.Exec(
`UPDATE topics
SET anchor = '{"kind":"global"}'
WHERE anchor IS NULL`)
return err
}
Call it in Open right after bootstrapOperator(...):
if err := backfillNullAnchors(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("backfill anchors: %w", err)
}
In every internal/collab/*_test.go call to InsertTopic, create a valid anchor first:
func globalAnchor(t *testing.T) json.RawMessage {
t.Helper()
raw, err := collab.MarshalAnchor(collab.GlobalAnchor{})
if err != nil {
t.Fatal(err)
}
return raw
}
Use Anchor: globalAnchor(t) for topics whose anchor is irrelevant to the test. Put this helper in internal/collab/mutators_test.go; if another test file needs it, keep package collab_test and call it directly.
Run: go test ./internal/collab/... -v
Expected: PASS for all internal/collab tests, including TestInsertTopicWithFirstMessage_atomic.
git add internal/collab/anchor.go internal/collab/anchor_test.go internal/collab/mutators.go internal/collab/mutators_test.go internal/collab/collab.go internal/collab/*_test.go
git commit -m "wiki-browser: collab — validate topic anchors and atomic topic+message insert"
Files:
Create: internal/collab/reader.go
Create: internal/collab/reader_test.go
Step 1: Write the failing reader tests
Create internal/collab/reader_test.go:
package collab_test
import (
"testing"
"github.com/getorcha/wiki-browser/internal/collab"
)
func TestListOpenTopicsForSource(t *testing.T) {
s := openStore(t)
global := globalAnchor(t)
if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", Anchor: global, CreatedBy: "alice"}); err != nil {
t.Fatal(err)
}
if _, err := s.InsertTopic(collab.NewTopic{ID: "t2", SourcePath: "b.md", Anchor: global, CreatedBy: "alice"}); err != nil {
t.Fatal(err)
}
if _, err := s.InsertMessage(collab.NewMessage{ID: "m1", TopicID: "t1", Kind: "human", Body: "first message", AuthorUserID: ptr("alice")}); err != nil {
t.Fatal(err)
}
got, err := s.ListOpenTopicsForSource("a.md")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %#v", len(got), got)
}
if got[0].ID != "t1" || got[0].FirstMessagePreview != "first message" || got[0].MessageCount != 1 {
t.Errorf("topic = %#v", got[0])
}
}
func TestListMessagesOrdersBySequence(t *testing.T) {
s := openStore(t)
if _, err := s.InsertTopic(collab.NewTopic{ID: "t1", SourcePath: "a.md", Anchor: globalAnchor(t), CreatedBy: "alice"}); err != nil {
t.Fatal(err)
}
for _, body := range []string{"one", "two"} {
if _, err := s.InsertMessage(collab.NewMessage{ID: "m-" + body, TopicID: "t1", Kind: "human", Body: body, AuthorUserID: ptr("alice")}); err != nil {
t.Fatal(err)
}
}
got, err := s.ListMessages("t1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Body != "one" || got[1].Body != "two" {
t.Errorf("messages = %#v", got)
}
}
Run: go test ./internal/collab/... -run 'TestListOpenTopicsForSource|TestListMessagesOrdersBySequence' -v
Expected: FAIL because ListOpenTopicsForSource and ListMessages do not exist.
Create internal/collab/reader.go:
package collab
import (
"encoding/json"
"fmt"
)
type TopicSummary struct {
ID string `json:"id"`
SourcePath string `json:"source_path"`
Anchor json.RawMessage `json:"anchor"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
FirstMessagePreview string `json:"first_message_preview"`
MessageCount int `json:"message_count"`
}
type TopicMessage struct {
ID string `json:"id"`
TopicID string `json:"topic_id"`
Kind string `json:"kind"`
Body string `json:"body"`
AuthorUserID *string `json:"author_user_id,omitempty"`
ProposalID *string `json:"proposal_id,omitempty"`
Sequence int `json:"sequence"`
CreatedAt int64 `json:"created_at"`
}
func (s *Store) ListOpenTopicsForSource(sourcePath string) ([]TopicSummary, error) {
if _, err := ValidateSourcePath(sourcePath); err != nil {
return nil, fmt.Errorf("collab.ListOpenTopicsForSource: %w", err)
}
rows, err := s.db.Query(
`SELECT t.id, t.source_path, t.anchor, t.created_by, t.created_at, t.updated_at,
COALESCE((SELECT m.body FROM topic_messages m WHERE m.topic_id = t.id ORDER BY m.sequence LIMIT 1), ''),
(SELECT COUNT(*) FROM topic_messages m WHERE m.topic_id = t.id)
FROM topics t
WHERE t.source_path = ?
AND t.incorporated_at IS NULL
AND t.discarded_at IS NULL
ORDER BY t.updated_at DESC, t.created_at DESC, t.id`,
sourcePath,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TopicSummary
for rows.Next() {
var raw string
var t TopicSummary
if err := rows.Scan(&t.ID, &t.SourcePath, &raw, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt, &t.FirstMessagePreview, &t.MessageCount); err != nil {
return nil, err
}
if _, err := UnmarshalAnchor(json.RawMessage(raw)); err != nil {
return nil, fmt.Errorf("collab.ListOpenTopicsForSource: topic %s: %w", t.ID, err)
}
t.Anchor = json.RawMessage(raw)
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) ListMessages(topicID string) ([]TopicMessage, error) {
if topicID == "" {
return nil, fmt.Errorf("collab.ListMessages: topic id required")
}
rows, err := s.db.Query(
`SELECT id, topic_id, kind, body, author_user_id, proposal_id, sequence, created_at
FROM topic_messages
WHERE topic_id = ?
ORDER BY sequence`,
topicID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TopicMessage
for rows.Next() {
var m TopicMessage
if err := rows.Scan(&m.ID, &m.TopicID, &m.Kind, &m.Body, &m.AuthorUserID, &m.ProposalID, &m.Sequence, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
Run: go test ./internal/collab/... -run 'TestListOpenTopicsForSource|TestListMessagesOrdersBySequence' -v
Expected: PASS.
git add internal/collab/reader.go internal/collab/reader_test.go
git commit -m "wiki-browser: collab — add topic read APIs"
Files:
Create: internal/render/rendermap.go
Create: internal/render/rendermap_test.go
Modify: internal/render/render.go
Step 1: Write the failing tests
Create internal/render/rendermap_test.go:
package render_test
import (
"testing"
"github.com/getorcha/wiki-browser/internal/render"
)
func TestRenderedToSource_linearAndEntityExpansion(t *testing.T) {
m := render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 10,
SourceEnd: 30,
Segments: []render.Segment{
{RenderedStart: 0, RenderedEnd: 5, SourceStart: 10, SourceEnd: 15},
{RenderedStart: 5, RenderedEnd: 6, SourceStart: 15, SourceEnd: 20},
{RenderedStart: 6, RenderedEnd: 10, SourceStart: 20, SourceEnd: 24},
},
}}}
got, err := m.RenderedToSource(10, 30, 4, 7)
if err != nil {
t.Fatal(err)
}
if got.Start != 14 || got.End != 21 {
t.Errorf("range = %#v, want [14,21)", got)
}
}
func TestRenderedToSource_rejectsNonSource(t *testing.T) {
m := render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 0,
SourceEnd: 10,
Segments: []render.Segment{
{RenderedStart: 0, RenderedEnd: 2, SourceStart: 0, SourceEnd: 2},
{RenderedStart: 2, RenderedEnd: 4, NonSource: true},
},
}}}
if _, err := m.RenderedToSource(0, 10, 1, 3); err != render.ErrNonSourceSelection {
t.Fatalf("err = %v, want ErrNonSourceSelection", err)
}
}
func TestSourceToRendered(t *testing.T) {
m := render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 10,
SourceEnd: 30,
Segments: []render.Segment{
{RenderedStart: 0, RenderedEnd: 5, SourceStart: 10, SourceEnd: 15},
{RenderedStart: 5, RenderedEnd: 6, SourceStart: 15, SourceEnd: 20},
{RenderedStart: 6, RenderedEnd: 10, SourceStart: 20, SourceEnd: 24},
},
}}}
got := m.SourceToRendered(14, 21)
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %#v", len(got), got)
}
if got[0].RenderedStart != 4 || got[0].RenderedEnd != 7 {
t.Errorf("interval = %#v, want rendered [4,7)", got[0])
}
}
Run: go test ./internal/render/... -run 'TestRenderedToSource|TestSourceToRendered' -v
Expected: FAIL because render-map types and helpers do not exist.
Create internal/render/rendermap.go:
package render
import (
"errors"
"fmt"
)
var (
ErrUnknownBlock = errors.New("render: unknown block")
ErrNonSourceSelection = errors.New("render: selection overlaps non-source content")
ErrInvalidRenderedRange = errors.New("render: invalid rendered range")
)
type RenderMap struct {
Blocks []BlockMap
}
type BlockMap struct {
SourceStart int
SourceEnd int
Segments []Segment
}
type Segment struct {
RenderedStart int
RenderedEnd int
SourceStart int
SourceEnd int
NonSource bool
}
type SourceRange struct {
Start int
End int
}
type RenderedInterval struct {
BlockSourceStart int
BlockSourceEnd int
RenderedStart int
RenderedEnd int
}
func (m RenderMap) Block(sourceStart, sourceEnd int) (*BlockMap, bool) {
for i := range m.Blocks {
b := &m.Blocks[i]
if b.SourceStart == sourceStart && b.SourceEnd == sourceEnd {
return b, true
}
}
return nil, false
}
func (m RenderMap) RenderedToSource(blockStart, blockEnd, renderedStart, renderedEnd int) (SourceRange, error) {
if renderedStart < 0 || renderedEnd <= renderedStart {
return SourceRange{}, ErrInvalidRenderedRange
}
b, ok := m.Block(blockStart, blockEnd)
if !ok {
return SourceRange{}, ErrUnknownBlock
}
var out SourceRange
have := false
for _, s := range b.Segments {
if renderedEnd <= s.RenderedStart || renderedStart >= s.RenderedEnd {
continue
}
if s.NonSource {
return SourceRange{}, ErrNonSourceSelection
}
partStart := maxInt(renderedStart, s.RenderedStart)
partEnd := minInt(renderedEnd, s.RenderedEnd)
srcStart, srcEnd := segmentRenderedToSource(s, partStart, partEnd)
if !have {
out = SourceRange{Start: srcStart, End: srcEnd}
have = true
} else {
out.Start = minInt(out.Start, srcStart)
out.End = maxInt(out.End, srcEnd)
}
}
if !have {
return SourceRange{}, ErrInvalidRenderedRange
}
return out, nil
}
func (m RenderMap) SourceToRendered(sourceStart, sourceEnd int) []RenderedInterval {
var out []RenderedInterval
for _, b := range m.Blocks {
for _, s := range b.Segments {
if s.NonSource || sourceEnd <= s.SourceStart || sourceStart >= s.SourceEnd {
continue
}
partStart := maxInt(sourceStart, s.SourceStart)
partEnd := minInt(sourceEnd, s.SourceEnd)
rs, re := segmentSourceToRendered(s, partStart, partEnd)
out = append(out, RenderedInterval{
BlockSourceStart: b.SourceStart,
BlockSourceEnd: b.SourceEnd,
RenderedStart: rs,
RenderedEnd: re,
})
}
}
return out
}
func segmentRenderedToSource(s Segment, renderedStart, renderedEnd int) (int, int) {
renderedLen := s.RenderedEnd - s.RenderedStart
sourceLen := s.SourceEnd - s.SourceStart
if renderedLen == sourceLen {
return s.SourceStart + (renderedStart - s.RenderedStart), s.SourceStart + (renderedEnd - s.RenderedStart)
}
return s.SourceStart, s.SourceEnd
}
func segmentSourceToRendered(s Segment, sourceStart, sourceEnd int) (int, int) {
renderedLen := s.RenderedEnd - s.RenderedStart
sourceLen := s.SourceEnd - s.SourceStart
if renderedLen == sourceLen {
return s.RenderedStart + (sourceStart - s.SourceStart), s.RenderedStart + (sourceEnd - s.SourceStart)
}
return s.RenderedStart, s.RenderedEnd
}
func (m RenderMap) Validate() error {
for _, b := range m.Blocks {
if b.SourceEnd <= b.SourceStart {
return fmt.Errorf("render map: invalid block range [%d,%d)", b.SourceStart, b.SourceEnd)
}
}
return nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
All rendered offsets in RenderMap are UTF-8 byte counts of rendered text, not Go runes and not JavaScript UTF-16 code units. That keeps them directly comparable with Source byte offsets and avoids drift for non-ASCII text. The browser must compute rendered offsets with TextEncoder, and tests must include at least one selection containing non-ASCII text.
In internal/render/render.go, extend Document:
type Document struct {
HTML string
PlainText string
Title string
HasMermaid bool
RenderMap RenderMap
}
SourceSHA is deliberately not on Document. The hash is the git blob SHA (see internal/collab/hashes.go), and the handler computes it via collab.SourceSHA(d.Root, relPath) where the repo root is available for .gitattributes lookup. The render cache still must key entries by this SHA: the current mtime+size cache key can miss same-second resaves, and serving a stale RenderMap with a fresh source_sha would create anchors against the wrong source bytes.
Run: go test ./internal/render/... -run 'TestRenderedToSource|TestSourceToRendered' -v
Expected: PASS.
git add internal/render/render.go internal/render/rendermap.go internal/render/rendermap_test.go
git commit -m "wiki-browser: render — add render map translation helpers"
Files:
internal/render/sourcepos.gointernal/render/sourcepos_test.gointernal/render/markdown.gointernal/render/html.gointernal/render/render.gointernal/render/cache.gointernal/render/cache_test.gointernal/server/handler_content.gointernal/server/handler_content_test.gointernal/server/embed.gointernal/server/templates/content_md.htmlThe previous version of this task used regexes over the rendered output to inject data-source-* attributes and to build the render map. That can't recognize fenced code blocks, setext headings, lists, tables, blockquotes, or HTML blocks, and the per-block rendered/source mapping drifted from goldmark's actual output. This task uses the spec's approach instead: a goldmark NodeRenderer wrapper that reads each block node's Lines() segments, and an x/net/html Tokenizer pass over authored HTML.
Create internal/render/sourcepos_test.go:
package render_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/getorcha/wiki-browser/internal/render"
)
func TestRenderMarkdown_emitsSourcePositionsAndMap(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.md")
src := "# Title\n\nAlpha & beta\n\n- one\n- two\n"
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
doc, err := render.Render(p)
if err != nil {
t.Fatal(err)
}
// Heading is the first source block and its rendered <h1> must carry
// matching data-source-* attributes anchored at offset 0.
if !strings.Contains(doc.HTML, `data-source-start="0"`) {
t.Fatalf("missing data-source-start in HTML: %s", doc.HTML)
}
// Heading + paragraph + list = at least 3 block maps. The list itself
// counts as one block; list items aren't a separate block map.
if len(doc.RenderMap.Blocks) < 3 {
t.Fatalf("render map blocks = %d, want at least 3", len(doc.RenderMap.Blocks))
}
// Each BlockMap's SourceStart/SourceEnd must match an attribute pair
// in the rendered HTML — the resolver relies on that correspondence.
for _, b := range doc.RenderMap.Blocks {
want := `data-source-start="` + itoa(b.SourceStart) + `" data-source-end="` + itoa(b.SourceEnd) + `"`
if !strings.Contains(doc.HTML, want) {
t.Errorf("BlockMap [%d,%d) has no matching HTML attrs", b.SourceStart, b.SourceEnd)
}
}
}
func TestRenderMarkdown_renderMapMapsSelectionToSource(t *testing.T) {
// End-to-end: real renderer + real RenderMap.RenderedToSource on a known
// phrase. Catches drift between segments stored in the map and goldmark's
// actual rendered output.
dir := t.TempDir()
src := "Alpha bravo charlie\n"
p := filepath.Join(dir, "a.md")
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
doc, err := render.Render(p)
if err != nil {
t.Fatal(err)
}
if len(doc.RenderMap.Blocks) == 0 {
t.Fatal("no blocks")
}
b := doc.RenderMap.Blocks[0]
// "bravo" lives at rendered offset 6..11 inside the paragraph.
got, err := doc.RenderMap.RenderedToSource(b.SourceStart, b.SourceEnd, 6, 11)
if err != nil {
t.Fatal(err)
}
if string(readSlice(t, p, got.Start, got.End)) != "bravo" {
t.Errorf("got source slice %q, want %q", readSlice(t, p, got.Start, got.End), "bravo")
}
}
func TestRenderMarkdown_renderMapUsesUTF8ByteOffsets(t *testing.T) {
dir := t.TempDir()
src := "éclair bravo\n"
p := filepath.Join(dir, "unicode.md")
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
doc, err := render.Render(p)
if err != nil {
t.Fatal(err)
}
b := doc.RenderMap.Blocks[0]
// Rendered offsets are UTF-8 byte offsets. "éclair " is 8 bytes.
got, err := doc.RenderMap.RenderedToSource(b.SourceStart, b.SourceEnd, 8, 13)
if err != nil {
t.Fatal(err)
}
if string(readSlice(t, p, got.Start, got.End)) != "bravo" {
t.Errorf("got source slice %q, want bravo", readSlice(t, p, got.Start, got.End))
}
}
func TestRenderHTML_emitsSourcePositionsAndMap(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.html")
src := "<!doctype html><html><head><title>T</title></head><body><h1>Title</h1><p>A & B</p></body></html>"
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
doc, err := render.Render(p)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(doc.HTML, `data-source-start=`) || !strings.Contains(doc.HTML, `data-source-end=`) {
t.Fatalf("missing source attrs: %s", doc.HTML)
}
if len(doc.RenderMap.Blocks) < 2 {
t.Fatalf("render map blocks = %d, want at least 2", len(doc.RenderMap.Blocks))
}
}
func itoa(n int) string { return strconv.Itoa(n) } // add strconv import
func readSlice(t *testing.T, path string, start, end int) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return b[start:end]
}
Add a cache regression test in internal/render/cache_test.go:
func TestCacheGetForSourceMissesWhenSourceSHAChanges(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.md")
if err := os.WriteFile(p, []byte("# One\n"), 0o644); err != nil {
t.Fatal(err)
}
c := render.NewCache(4 << 20)
first, err := c.GetForSource(p, strings.Repeat("1", 40))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("# Two\n"), 0o644); err != nil {
t.Fatal(err)
}
second, err := c.GetForSource(p, strings.Repeat("2", 40))
if err != nil {
t.Fatal(err)
}
if first == second || second.Title != "Two" {
t.Fatalf("cache reused stale document: first=%q second=%q", first.Title, second.Title)
}
}
Modify internal/server/handler_content_test.go by replacing TestContentHTML_servesByteIdentical with:
func TestContentHTML_rendersThroughPipeline(t *testing.T) {
ts, root := newTestServer(t)
authored := "<!doctype html><html><body><h1>hi</h1></body></html>"
p := filepath.Join(root, "raw.html")
if err := os.WriteFile(p, []byte(authored), 0o644); err != nil {
t.Fatal(err)
}
resp, err := http.Get(ts.URL + "/content/raw.html")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := readAll(t, resp)
if !strings.Contains(body, `data-source-start=`) {
t.Errorf("authored HTML should pass through render pipeline; body=%s", body)
}
}
func TestContentHTML_rawStillByteIdentical(t *testing.T) {
// ?raw=1 keeps the existing contract: byte-identical raw source.
ts, _ := newTestServer(t)
resp, err := http.Get(ts.URL + "/content/raw.html?raw=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := readAll(t, resp)
if strings.Contains(body, `data-source-start=`) {
t.Errorf("raw served output should not be annotated: %s", body)
}
}
Run: go test ./internal/render/... ./internal/server/... -run 'TestRenderMarkdown_emitsSourcePositionsAndMap|TestRenderMarkdown_renderMapMapsSelectionToSource|TestRenderMarkdown_renderMapUsesUTF8ByteOffsets|TestRenderHTML_emitsSourcePositionsAndMap|TestCacheGetForSourceMissesWhenSourceSHAChanges|TestContentHTML_rendersThroughPipeline' -v
Expected: FAIL because source-position injection is absent and .html content is still streamed raw.
Create internal/render/sourcepos.go. The markdown half wraps goldmark's html.Renderer with per-node hooks that emit data-source-* from node.Lines() and accumulate BlockMap entries:
package render
import (
"strconv"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
gmhtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// blockSpanCollector accumulates BlockMaps for the blocks emitted by the
// goldmark NodeRenderer. It runs alongside the rendering pass and is
// drained after Render() returns.
type blockSpanCollector struct {
src []byte
blocks []BlockMap
}
// nodeSpan returns the [start,end) Source byte range covering all the
// Lines() segments on a block-level node, or false when the node has no
// segments (e.g., a List wraps ListItems without a Lines() of its own).
func nodeSpan(n ast.Node) (int, int, bool) {
segs := n.Lines()
if segs == nil || segs.Len() == 0 {
// Fall back to scanning children: the outermost block range covers
// the first leaf's Start to the last leaf's Stop.
var start, end int
have := false
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
cs, ce, ok := nodeSpan(c)
if !ok {
continue
}
if !have {
start, end, have = cs, ce, true
} else {
if cs < start {
start = cs
}
if ce > end {
end = ce
}
}
}
return start, end, have
}
first := segs.At(0)
last := segs.At(segs.Len() - 1)
return first.Start, last.Stop, true
}
// renderedTextLen returns the UTF-8 byte length of the rendered text content
// under n. It walks n's descendants, decodes entity text to the same visible
// string the browser selection sees, and counts len([]byte(visibleText)).
// Container tags such as Emphasis and Link do not count toward rendered-text
// offsets because selection offsets count text, not markup bytes.
func renderedTextLen(n ast.Node, src []byte) int
In internal/render/markdown.go, register a custom NodeRenderer at goldmark construction time. The simplest pattern is to pre-walk the AST in a single pass to build the RenderMap and a parallel map[ast.Node]SourceRange, then post-process the rendered HTML by replacing each block-opening tag with one that carries data-source-* attributes. Because the post-process walks the rendered HTML through x/net/html and the AST in lock-step (both are deterministic for a fixed input), the indices line up reliably — unlike the regex approach.
Concretely:
type rmBuilder struct {
src []byte
blocks []BlockMap
byNode map[ast.Node]SourceRange
}
func buildRenderMap(root ast.Node, src []byte) (*rmBuilder, error) {
b := &rmBuilder{src: src, byNode: make(map[ast.Node]SourceRange)}
err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || !isBlockNode(n) {
return ast.WalkContinue, nil
}
start, end, ok := nodeSpan(n)
if !ok {
return ast.WalkContinue, nil
}
r := SourceRange{Start: start, End: end}
b.byNode[n] = r
// One Segment per direct inline text child. RenderedStart/End are
// computed by accumulating renderedTextLen across the block's
// children; SourceStart/End come from each text child's Lines().
segs := segmentsForBlock(n, src)
b.blocks = append(b.blocks, BlockMap{
SourceStart: r.Start, SourceEnd: r.End, Segments: segs,
})
return ast.WalkContinue, nil
})
return b, err
}
// isBlockNode is true for nodes whose rendered tag we want to annotate.
// Matches the spec's block-level scope.
func isBlockNode(n ast.Node) bool {
switch n.Kind() {
case ast.KindHeading, ast.KindParagraph, ast.KindList, ast.KindListItem,
ast.KindBlockquote, ast.KindCodeBlock, ast.KindFencedCodeBlock,
ast.KindHTMLBlock, ast.KindThematicBreak:
return true
}
// Extension nodes (table, mermaid) match by tag-name kind if needed.
return false
}
// segmentsForBlock returns the rendered/source Segment list for the
// block's inline text content. For paragraphs and headings this is a
// linear sequence over the inline text children; for code blocks the
// whole literal contributes one Segment; for lists it is empty (children
// have their own BlockMaps).
func segmentsForBlock(n ast.Node, src []byte) []Segment
segmentsForBlock is not optional pseudocode. Implement it with these cases before moving on:
Segment for each text/string node, and add NonSource gaps only for generated visible text such as list markers.Segment for literal code text; syntax-highlighting wrapper tags do not contribute rendered-text offsets.NonSource when they appear in rendered text.Do not leave return 0, return nil, or /* implementation */ bodies in the committed code.
Then, after goldmark renders, post-process with x/net/html.Tokenizer instead of regex: walk the tokens, and on each block-level start tag, pop the next AST node from byNode (matched by tag name + ordinal). Emit the same token plus the data-source-* pair. This keeps the rendered output identical except for the injected attributes, and uses the real parser to handle nested structures and attributes correctly.
Note for the implementer. goldmark's
html.Rendereruses fixed per-node render functions. An alternative to the post-process pass is to register a custom NodeRenderer viarenderer.WithNodeRenderers(util.Prioritized(myRenderer, 100))that delegates to the default but overrides the opening tag emission for each block kind. Either works. The post-process pass is simpler to get right because it leaves the inline path untouched; the NodeRenderer override is more efficient. Pick whichever is easier to test against the spec's golden fixtures.
Required test coverage in sourcepos_test.go (TestRenderMarkdown_emitsSourcePositionsAndMap already covers heading + paragraph + list; add fixtures for fenced code, blockquote, and an HTML block) demonstrates the new path actually produces correct attribute ranges before we move on.
In internal/render/html.go, replace the raw-byte return path with an x/net/html.Tokenizer pass over the original bytes. The tokenizer reports byte ranges via z.Raw(); track each block-level open tag's start offset and find its matching end tag with a stack. Emit a new HTML stream where block opening tags carry data-source-start/data-source-end, preserving all attributes and inline content verbatim. Each block contributes one BlockMap whose Segments cover the decoded text nodes inside it.
Sketch:
func annotateHTMLSource(src []byte) (string, RenderMap, error) {
z := html.NewTokenizer(bytes.NewReader(src))
var out bytes.Buffer
type frame struct {
name string
blockIdx int // index in blocks where this frame's range starts
openStart int // byte offset in src of this frame's opening tag
}
var stack []frame
var blocks []BlockMap
pos := 0
for {
tt := z.Next()
raw := z.Raw()
switch tt {
case html.ErrorToken:
// EOF or parse error: flush remainder, return.
return out.String(), RenderMap{Blocks: blocks}, nil
case html.StartTagToken:
name := atomString(z)
if isBlockTag(name) {
blocks = append(blocks, BlockMap{SourceStart: pos})
stack = append(stack, frame{name: name, blockIdx: len(blocks) - 1, openStart: pos})
out.Write(injectDataSourceAttrs(raw, pos))
} else {
out.Write(raw)
}
case html.EndTagToken:
name := atomString(z)
if len(stack) > 0 && stack[len(stack)-1].name == name {
f := stack[len(stack)-1]
stack = stack[:len(stack)-1]
end := pos + len(raw)
blocks[f.blockIdx].SourceEnd = end
// Rewrite the deferred data-source-end on the matching open tag.
rewriteOpenEnd(&out, /* offset of injected attr */, end)
}
out.Write(raw)
case html.TextToken:
// One Segment per text token inside the current block.
if len(stack) > 0 {
appendTextSegment(&blocks[stack[len(stack)-1].blockIdx], pos, raw)
}
out.Write(raw)
default:
out.Write(raw)
}
pos += len(raw)
}
}
The exact helper bodies (injectDataSourceAttrs, rewriteOpenEnd, appendTextSegment) are mechanical; the important property is that all attribute parsing goes through x/net/html, never regex, so nested same-named blocks (<div><div>…</div></div>), unusual attribute quoting, and self-closing tags are handled correctly.
If data-source-end is needed on the opening tag before we know it (we discover it only at the close), emit a placeholder string of fixed width when the open is written and overwrite that slice on close. Alternatively, do two passes: first collect ranges, then emit.
In internal/render/markdown.go, after md.Renderer().Render(&body, src, doc):
rendered, rm, err := annotateMarkdownHTML(body.String(), src, doc)
if err != nil {
return nil, fmt.Errorf("annotate %s: %w", absPath, err)
}
return &Document{
HTML: rendered,
PlainText: plain,
Title: title,
HasMermaid: hasMermaid,
RenderMap: rm,
}, nil
In internal/render/html.go:
rendered, rm, err := annotateHTMLSource(raw)
if err != nil {
return nil, fmt.Errorf("annotate %s: %w", absPath, err)
}
return &Document{
HTML: rendered,
PlainText: plain,
Title: title,
HasMermaid: false,
RenderMap: rm,
}, nil
No SHA computation in the renderer — it lives in the handler (next step) where d.Root is available.
.html through itIn internal/render/cache.go, add a sourceSHA string field to cacheKey and expose a SHA-keyed getter. Keep Get(absPath) for existing non-collab callers, but route content-serving paths through GetForSource so the rendered HTML, RenderMap, and advertised source_sha are all tied to the same git blob SHA.
type cacheKey struct {
path string
mtime int64
size int64
sourceSHA string
}
func (c *Cache) GetForSource(absPath, sourceSHA string) (*Document, error) {
return c.get(absPath, sourceSHA)
}
func (c *Cache) Get(absPath string) (*Document, error) {
return c.get(absPath, "")
}
func (c *Cache) get(absPath, sourceSHA string) (*Document, error) {
// Existing Get body moves here. Build key with sourceSHA included:
// key := cacheKey{path: absPath, mtime: info.ModTime().Unix(), size: info.Size(), sourceSHA: sourceSHA}
// Entries cached for one sourceSHA must never be returned for another.
}
This does not move SHA calculation into internal/render; the handler computes the git blob SHA with collab.SourceSHA, then passes it to the cache. If sourceSHA changes while mtime+size do not, GetForSource must miss and re-render.
In internal/server/handler_content.go, add imports for internal/collab and internal/render, combine the .md and .html cases, and stamp SourceSHA via the same SHA used for the cache key:
func (d Deps) renderCurrentSource(abs, urlPath string) (*render.Document, string, error) {
sha, err := collab.SourceSHA(d.Root, urlPath)
if err != nil {
return nil, "", err
}
doc, err := d.Cache.GetForSource(abs, sha)
if err != nil {
return nil, "", err
}
// Close the race where the file changes after hashing but before render.
sha2, err := collab.SourceSHA(d.Root, urlPath)
if err != nil {
return nil, "", err
}
if sha2 != sha {
doc, err = d.Cache.GetForSource(abs, sha2)
if err != nil {
return nil, "", err
}
sha = sha2
}
return doc, sha, nil
}
switch strings.ToLower(filepath.Ext(abs)) {
case ".md", ".html":
doc, sha, err := d.renderCurrentSource(abs, urlPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = mustTemplates().ExecuteTemplate(w, "content_md.html", ContentMDData{
Title: doc.Title,
BodyHTML: template.HTML(doc.HTML),
HasMermaid: doc.HasMermaid,
SourceSHA: sha,
})
default:
http.NotFound(w, r)
}
collab.SourceSHA shells out to git hash-object, which is the spec-mandated form (line 412 of the spec). Computing it on every request is cheap relative to render and keeps it consistent with git history. Passing that SHA into GetForSource prevents the existing mtime+size cache key from serving stale render maps after same-second rewrites. The post-render recheck closes the remaining race where Source changes between the hash read and the render read.
Add SourceSHA string to ContentMDData in internal/server/embed.go.
In internal/server/templates/content_md.html, inside <head> after <title>:
{{ if .SourceSHA }}<meta name="wb-source-sha" content="{{ .SourceSHA }}">{{ end }}
Run: go test ./internal/render/... ./internal/server/... -run 'TestRenderMarkdown_emitsSourcePositionsAndMap|TestRenderMarkdown_renderMapMapsSelectionToSource|TestRenderMarkdown_renderMapUsesUTF8ByteOffsets|TestRenderHTML_emitsSourcePositionsAndMap|TestCacheGetForSourceMissesWhenSourceSHAChanges|TestContentHTML_rendersThroughPipeline|TestContentHTML_rawStillByteIdentical|TestContentMD_servesProseDocument|TestRenderContentMD' -v
Expected: PASS.
git add internal/render/sourcepos.go internal/render/sourcepos_test.go internal/render/markdown.go internal/render/html.go internal/render/render.go internal/render/cache.go internal/render/cache_test.go internal/server/handler_content.go internal/server/handler_content_test.go internal/server/templates/content_md.html internal/server/embed.go internal/server/templates_test.go
git commit -m "wiki-browser: render — emit source positions for content"
Files:
internal/render/resolver.gointernal/render/resolver_test.gointernal/server/handler_content.goThe previous version of this task wrapped highlights by extracting block bodies via regex, stripping tags, and re-emitting plain text — which would have destroyed every link, emphasis, inline-code, and image in any block that got a highlight. The spec requires walking the DOM and splitting text nodes at interval boundaries only. This task uses golang.org/x/net/html to do that, supports overlapping anchors (interval-splitting with data-topic-ids + wb-anchor-overlap class per spec §overlap), and renders both inline and block-level marker forms.
Create internal/render/resolver_test.go:
package render_test
import (
"encoding/json"
"strings"
"testing"
"github.com/getorcha/wiki-browser/internal/collab"
"github.com/getorcha/wiki-browser/internal/render"
)
func TestResolveAnchors_preMarkerWrapsProjectedText(t *testing.T) {
anchor, err := collab.MarshalAnchor(collab.PreMarkerAnchor{SourceSHA: strings.Repeat("a", 40), Start: 0, End: 5, Quote: "Alpha"})
if err != nil {
t.Fatal(err)
}
doc := &render.Document{
HTML: `<p data-source-start="0" data-source-end="11">Alpha bravo</p>`,
RenderMap: render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 0, SourceEnd: 11,
Segments: []render.Segment{{RenderedStart: 0, RenderedEnd: 11, SourceStart: 0, SourceEnd: 11}},
}}},
}
out, err := render.ResolveAnchors(doc, strings.Repeat("a", 40), []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.HTML, `<mark class="wb-anchor" data-topic-id="t1">Alpha</mark>`) {
t.Fatalf("missing mark: %s", out.HTML)
}
}
func TestResolveAnchors_preservesInlineFormatting(t *testing.T) {
// Regression: an earlier draft stripped inline tags from highlighted
// blocks. The resolver must leave <em>, <a>, <code> alone.
anchor, err := collab.MarshalAnchor(collab.PreMarkerAnchor{SourceSHA: strings.Repeat("b", 40), Start: 0, End: 11, Quote: "Alpha bravo"})
if err != nil {
t.Fatal(err)
}
doc := &render.Document{
HTML: `<p data-source-start="0" data-source-end="11">Alpha <em>bravo</em> charlie</p>`,
RenderMap: render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 0, SourceEnd: 11,
Segments: []render.Segment{{RenderedStart: 0, RenderedEnd: 11, SourceStart: 0, SourceEnd: 11}},
}}},
}
out, err := render.ResolveAnchors(doc, strings.Repeat("b", 40), []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.HTML, `<em>`) || !strings.Contains(out.HTML, `</em>`) {
t.Fatalf("inline formatting was stripped: %s", out.HTML)
}
}
func TestResolveAnchors_blockLevelMarkerProjectsToNextSibling(t *testing.T) {
// Empty <div data-orcha-anchor="t1"></div> projects the highlight onto
// the next sibling block, per spec resolver behaviour for block markers.
anchor, err := collab.MarshalAnchor(collab.MarkerAnchor{})
if err != nil {
t.Fatal(err)
}
doc := &render.Document{HTML: `<div data-orcha-anchor="t1"></div><p>Alpha bravo</p>`}
out, err := render.ResolveAnchors(doc, "", []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.HTML, `<p data-orcha-anchor-projected="t1"><mark class="wb-anchor" data-topic-id="t1">Alpha bravo</mark></p>`) &&
!strings.Contains(out.HTML, `<mark class="wb-anchor" data-topic-id="t1">Alpha bravo</mark>`) {
t.Fatalf("block-level marker did not project: %s", out.HTML)
}
}
func TestResolveAnchors_inlineMarkerWrapsContents(t *testing.T) {
anchor, err := collab.MarshalAnchor(collab.MarkerAnchor{})
if err != nil {
t.Fatal(err)
}
doc := &render.Document{HTML: `<p><span data-orcha-anchor="t1">Alpha</span> bravo</p>`}
out, err := render.ResolveAnchors(doc, "", []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.HTML, `<mark class="wb-anchor" data-topic-id="t1">Alpha</mark>`) {
t.Fatalf("missing marker mark: %s", out.HTML)
}
}
func TestResolveAnchors_overlappingAnchorsEmitOverlapClass(t *testing.T) {
sha := strings.Repeat("c", 40)
a1, _ := collab.MarshalAnchor(collab.PreMarkerAnchor{SourceSHA: sha, Start: 0, End: 7, Quote: "Alpha b"})
a2, _ := collab.MarshalAnchor(collab.PreMarkerAnchor{SourceSHA: sha, Start: 5, End: 11, Quote: " bravo"})
doc := &render.Document{
HTML: `<p data-source-start="0" data-source-end="11">Alpha bravo</p>`,
RenderMap: render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 0, SourceEnd: 11,
Segments: []render.Segment{{RenderedStart: 0, RenderedEnd: 11, SourceStart: 0, SourceEnd: 11}},
}}},
}
out, err := render.ResolveAnchors(doc, sha, []render.OpenTopic{
{ID: "t1", Anchor: a1}, {ID: "t2", Anchor: a2},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.HTML, `wb-anchor-overlap`) || !strings.Contains(out.HTML, `data-topic-ids="t1 t2"`) {
t.Fatalf("overlap not rendered: %s", out.HTML)
}
}
func TestResolveAnchors_globalDoesNotRenderInline(t *testing.T) {
anchor := json.RawMessage(`{"kind":"global"}`)
doc := &render.Document{HTML: `<p>Alpha</p>`}
out, err := render.ResolveAnchors(doc, "", []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out.HTML, "wb-anchor") {
t.Fatalf("global rendered inline: %s", out.HTML)
}
}
func TestResolveAnchors_preMarkerSkippedOnSHAMismatch(t *testing.T) {
anchor, _ := collab.MarshalAnchor(collab.PreMarkerAnchor{SourceSHA: strings.Repeat("a", 40), Start: 0, End: 5, Quote: "Alpha"})
doc := &render.Document{
HTML: `<p data-source-start="0" data-source-end="11">Alpha bravo</p>`,
RenderMap: render.RenderMap{Blocks: []render.BlockMap{{
SourceStart: 0, SourceEnd: 11,
Segments: []render.Segment{{RenderedStart: 0, RenderedEnd: 11, SourceStart: 0, SourceEnd: 11}},
}}},
}
out, err := render.ResolveAnchors(doc, strings.Repeat("z", 40), []render.OpenTopic{{ID: "t1", Anchor: anchor}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out.HTML, "wb-anchor") {
t.Fatalf("stale pre-marker should not render: %s", out.HTML)
}
}
Run: go test ./internal/render/... -run TestResolveAnchors -v
Expected: FAIL because resolver types/functions do not exist.
Create internal/render/resolver.go. The resolver parses the rendered HTML with golang.org/x/net/html, walks the DOM, and splits text nodes at interval boundaries only — leaving inline elements untouched. Block-level markers (empty elements with data-orcha-anchor) project highlight onto their next sibling block. Overlapping intervals emit a single <mark class="wb-anchor wb-anchor-overlap" data-topic-ids="t1 t2"> per region.
package render
import (
"bytes"
"encoding/json"
"sort"
"strings"
"golang.org/x/net/html"
"github.com/getorcha/wiki-browser/internal/collab"
)
type OpenTopic struct {
ID string
Anchor json.RawMessage
}
// ResolveAnchors overlays anchor highlights onto doc.HTML.
//
// currentSourceSHA is the git blob SHA of the Source file as the handler
// just observed it on disk. pre-marker anchors whose source_sha does not
// match are skipped (their offsets are stale and would land in the wrong
// place; the sidebar surfaces them in an "Awaiting re-anchor" group).
func ResolveAnchors(doc *Document, currentSourceSHA string, topics []OpenTopic) (*Document, error) {
// 1. Resolve each anchor to a set of (BlockSourceRange, RenderedInterval, TopicID),
// and partition marker topics into inline vs block-level (next-sibling) lists.
type hit struct {
blockStart, blockEnd int
renderedStart, renderedEnd int
topicID string
}
var preMarkerHits []hit
inlineMarkers := map[string]struct{}{}
blockMarkers := map[string]struct{}{}
for _, t := range topics {
a, err := collab.UnmarshalAnchor(t.Anchor)
if err != nil {
return nil, err
}
switch v := a.(type) {
case collab.PreMarkerAnchor:
if v.SourceSHA != currentSourceSHA {
continue
}
for _, in := range doc.RenderMap.SourceToRendered(v.Start, v.End) {
preMarkerHits = append(preMarkerHits, hit{
blockStart: in.BlockSourceStart, blockEnd: in.BlockSourceEnd,
renderedStart: in.RenderedStart, renderedEnd: in.RenderedEnd,
topicID: t.ID,
})
}
case collab.MarkerAnchor:
// Decide inline vs block-level by inspecting the rendered DOM
// in step 3; for now collect both lists keyed on topic ID.
inlineMarkers[t.ID] = struct{}{}
blockMarkers[t.ID] = struct{}{}
case collab.GlobalAnchor:
// no inline rendering
}
}
// 2. Group pre-marker hits by block, then within each block compute
// the interval cover (set of distinct rendered ranges + their
// covering topic-IDs) using a sweep. Each covered subrange becomes
// one <mark>; multi-topic ranges include both IDs.
type covered struct {
start, end int
topicIDs []string
}
byBlock := map[[2]int][]hit{}
for _, h := range preMarkerHits {
key := [2]int{h.blockStart, h.blockEnd}
byBlock[key] = append(byBlock[key], h)
}
blockCovers := make(map[[2]int][]covered, len(byBlock))
for k, hs := range byBlock {
blockCovers[k] = computeCoverage(hs)
}
// 3. Walk the rendered HTML and rewrite. We use golang.org/x/net/html
// in Tokenizer mode so we can preserve formatting and serialise
// output byte-for-byte except where we inject <mark> spans.
out, err := rewriteHTML(doc.HTML, blockCovers, inlineMarkers, blockMarkers)
if err != nil {
return nil, err
}
doc2 := *doc
doc2.HTML = out
return &doc2, nil
}
// computeCoverage sweeps the hits over a single block and returns the
// disjoint covered subranges with their topic-ID sets, sorted left-to-right.
func computeCoverage(hs []hit) []covered {
type evt struct {
pos int
opening bool
topicID string
}
var evts []evt
for _, h := range hs {
evts = append(evts, evt{pos: h.renderedStart, opening: true, topicID: h.topicID})
evts = append(evts, evt{pos: h.renderedEnd, opening: false, topicID: h.topicID})
}
sort.Slice(evts, func(i, j int) bool {
if evts[i].pos != evts[j].pos {
return evts[i].pos < evts[j].pos
}
// Process closings before openings at the same point so adjacent
// ranges don't appear to overlap by one position.
return !evts[i].opening && evts[j].opening
})
open := map[string]struct{}{}
var out []covered
var lastPos int
flush := func(end int) {
if end <= lastPos || len(open) == 0 {
lastPos = end
return
}
ids := make([]string, 0, len(open))
for id := range open {
ids = append(ids, id)
}
sort.Strings(ids)
out = append(out, covered{start: lastPos, end: end, topicIDs: ids})
lastPos = end
}
for _, e := range evts {
flush(e.pos)
if e.opening {
open[e.topicID] = struct{}{}
} else {
delete(open, e.topicID)
}
}
return out
}
// rewriteHTML walks doc.HTML as DOM, splits text nodes at coverage
// boundaries, and inserts <mark> nodes. Inline markers (elements with
// data-orcha-anchor) get their contents wrapped; block-level markers
// (empty elements) project onto the next sibling block by wrapping its
// inline content.
func rewriteHTML(in string, covers map[[2]int][]covered, inlineMarkers, blockMarkers map[string]struct{}) (string, error) {
doc, err := html.Parse(strings.NewReader("<html><head></head><body>" + in + "</body></html>"))
if err != nil {
return "", err
}
body := findBody(doc)
walkAndAnnotate(body, covers, inlineMarkers, blockMarkers)
var buf bytes.Buffer
if err := renderInner(&buf, body); err != nil {
return "", err
}
return buf.String(), nil
}
// walkAndAnnotate is the implementation hot-spot. It:
// * On every element with data-source-start/-end matching a key in `covers`,
// descends into its text nodes and applies the coverage list by splitting
// the text content into pre / <mark> / post pieces. <mark> nodes are
// inserted as siblings; inline element children are left alone except
// when a covered range straddles them, in which case each text child
// contributes its own <mark> wrappers in turn.
// * On every element with data-orcha-anchor matching a topic in inlineMarkers,
// wraps its inline contents in a <mark>.
// * On every empty element with data-orcha-anchor matching a topic in
// blockMarkers, finds its next non-empty sibling element and wraps that
// sibling's inline content in a <mark>.
// Coverage with multiple topic IDs uses class "wb-anchor wb-anchor-overlap"
// and data-topic-ids="t1 t2".
func walkAndAnnotate(n *html.Node, covers map[[2]int][]covered, inlineMarkers, blockMarkers map[string]struct{})
func findBody(n *html.Node) *html.Node
func renderInner(w *bytes.Buffer, n *html.Node) error
Implementation guidance for walkAndAnnotate:
Iterate covered ranges in order; track an offset cursor as you descend through text and inline children.
For each text node, slice it into pre-segment (no mark), in-segment (wrapped), post-segment (no mark), repeating as the cursor moves through subsequent covered ranges. Inline children that fall entirely inside a covered range get a <mark> wrapped around them (preserving their tags); inline children that straddle a boundary recurse — the resolver splits within their text descendants the same way.
<mark> is created via &html.Node{Type: html.ElementNode, Data: "mark", Attr: …}.
findBody must return the parsed wrapper body created by rewriteHTML, not an arbitrary ancestor. renderInner must render only that body's children so the wrapper <html><head><body> never leaks into doc.HTML.
Do not leave compileable no-op bodies such as return nil, return n, or /* implementation */; the resolver tests above must fail until these functions are real.
Step 4: Run resolver tests
Run: go test ./internal/render/... -run TestResolveAnchors -v
Expected: PASS, including the inline-formatting, block-level marker, overlap, and SHA-mismatch cases.
In internal/server/handler_content.go, after computing sha (introduced in Task 4 step 6) and doc, err := d.Cache.GetForSource(abs, sha), and before executing the template:
if d.Collab != nil && urlPath != "" && !strings.HasPrefix(urlPath, "_") {
topics, err := d.Collab.ListOpenTopicsForSource(urlPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
open := make([]render.OpenTopic, 0, len(topics))
for _, t := range topics {
open = append(open, render.OpenTopic{ID: t.ID, Anchor: t.Anchor})
}
doc, err = render.ResolveAnchors(doc, sha, open)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
Add Collab *collab.Store to server.Deps and import github.com/getorcha/wiki-browser/internal/collab.
Run: go test ./internal/render/... ./internal/server/... -v
Expected: PASS.
git add internal/render/resolver.go internal/render/resolver_test.go internal/server/server.go internal/server/handler_content.go
git commit -m "wiki-browser: render — resolve topic anchors with overlap support"
Files:
Create: internal/server/topics.go
Create: internal/server/topics_test.go
Modify: internal/server/server.go
Modify: internal/server/handler_doc_test.go
Modify: cmd/wiki-browser/main.go
Step 1: Update test server to include collab store
In internal/server/handler_doc_test.go, add imports:
"github.com/getorcha/wiki-browser/internal/collab"
Inside newTestServer, before server.Mux:
store, err := collab.Open(collab.Config{
Path: filepath.Join(t.TempDir(), "collab.db"),
OperatorUserID: "alice",
OperatorDisplayName: "Alice Example",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
Pass it into deps:
mux := server.Mux(server.Deps{
Title: "Test", Root: root, Walker: w, Index: idx, Cache: cache,
Collab: store, OperatorUserID: "alice",
})
Change newTestServer's return signature to expose the store so API tests can mutate it for cases like the closed-topic test:
func newTestServer(t *testing.T) (*httptest.Server, string, *collab.Store) { ... }
Update existing call sites (TestRoot_servesShellPointingAtWelcome, TestDoc_*, TestContent*) to discard the third return: ts, root, _ := newTestServer(t).
Create internal/server/topics_test.go:
package server_test
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"testing"
)
func TestTopicsAPI_createGlobalListReply(t *testing.T) {
ts, _, _ := newTestServer(t)
resp, err := http.Post(ts.URL+"/api/topics", "application/json", strings.NewReader(`{
"source_path":"a.md",
"global":true,
"first_message_body":"Please review the whole document."
}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d body=%s", resp.StatusCode, readAll(t, resp))
}
var created struct{ ID string `json:"id"` }
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
t.Fatal(err)
}
if created.ID == "" {
t.Fatal("created id empty")
}
resp, err = http.Get(ts.URL + "/api/topics?source_path=a.md")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := readAll(t, resp)
if !strings.Contains(body, "Please review the whole document.") {
t.Fatalf("list missing preview: %s", body)
}
resp, err = http.Post(ts.URL+"/api/topics/"+created.ID+"/messages", "application/json", strings.NewReader(`{"body":"Second message."}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("message status = %d body=%s", resp.StatusCode, readAll(t, resp))
}
}
func TestTopicsAPI_rejectsInvalidCreate(t *testing.T) {
ts, _, _ := newTestServer(t)
for _, body := range []string{
`{"source_path":"../x.md","global":true,"first_message_body":"x"}`,
`{"source_path":"a.md","first_message_body":"x"}`,
`{"source_path":"a.md","global":true,"selection":{"quote":"x"},"first_message_body":"x"}`,
`{"source_path":"a.md","global":true,"first_message_body":""}`,
} {
resp, err := http.Post(ts.URL+"/api/topics", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode < 400 {
t.Fatalf("body %s status=%d, want error", body, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestTopicsAPI_selectionStaleSourceReturns409(t *testing.T) {
ts, _, _ := newTestServer(t)
body := `{
"source_path":"a.md",
"selection":{"source_sha":"` + strings.Repeat("0", 40) + `","quote":"x","block_source_start":0,"block_source_end":1,"rendered_start":0,"rendered_end":1},
"first_message_body":"x"
}`
resp, err := http.Post(ts.URL+"/api/topics", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409 (stale_source); body=%s", resp.StatusCode, readAll(t, resp))
}
}
func TestTopicsAPI_appendToClosedTopicReturns410(t *testing.T) {
ts, root, store := newTestServer(t)
_ = root
// Create a topic via the API.
resp, err := http.Post(ts.URL+"/api/topics", "application/json", strings.NewReader(
`{"source_path":"a.md","global":true,"first_message_body":"hi"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var created struct{ ID string `json:"id"` }
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
t.Fatal(err)
}
// Mark it discarded directly via the raw DB (Discard endpoint is owned by #4).
if _, err := store.RawDBForTest().Exec(
`UPDATE topics SET discarded_at = unixepoch(), discarded_by = 'alice' WHERE id = ?`, created.ID,
); err != nil {
t.Fatal(err)
}
resp2, err := http.Post(ts.URL+"/api/topics/"+created.ID+"/messages", "application/json",
strings.NewReader(`{"body":"too late"}`))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusGone {
t.Fatalf("status = %d, want 410 Gone; body=%s", resp2.StatusCode, readAll(t, resp2))
}
resp3, err := http.Get(ts.URL + "/api/topics/" + created.ID + "/messages")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusGone {
t.Fatalf("list status = %d, want 410 Gone; body=%s", resp3.StatusCode, readAll(t, resp3))
}
}
The closed-topic test must mutate the same store used by the HTTP server; do not create a second store in a helper.
Run: go test ./internal/server/... -run 'TestTopicsAPI|TestDoc|TestContent' -v
Expected: FAIL because API routes and Deps.Collab fields are missing.
In internal/server/server.go, extend Deps:
Collab *collab.Store
OperatorUserID string
Register routes in Mux before /content/:
mux.HandleFunc("POST /api/topics", d.handleCreateTopic)
mux.HandleFunc("GET /api/topics", d.handleListTopics)
mux.HandleFunc("GET /api/topics/{id}/messages", d.handleListTopicMessages)
mux.HandleFunc("POST /api/topics/{id}/messages", d.handleAppendTopicMessage)
Create internal/server/topics.go:
package server
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/getorcha/wiki-browser/internal/collab"
"github.com/getorcha/wiki-browser/internal/render"
)
type topicCreateRequest struct {
SourcePath string `json:"source_path"`
Global bool `json:"global"`
Selection *selectionRequest `json:"selection"`
FirstMessageBody string `json:"first_message_body"`
}
type selectionRequest struct {
SourceSHA string `json:"source_sha"`
Quote string `json:"quote"`
BlockSourceStart int `json:"block_source_start"`
BlockSourceEnd int `json:"block_source_end"`
RenderedStart int `json:"rendered_start"`
RenderedEnd int `json:"rendered_end"`
}
type messageRequest struct {
Body string `json:"body"`
}
func (d Deps) handleCreateTopic(w http.ResponseWriter, r *http.Request) {
if d.Collab == nil {
writeJSONError(w, http.StatusServiceUnavailable, "topics_unavailable")
return
}
var req topicCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_json")
return
}
if err := validateBody(req.FirstMessageBody); err != nil {
writeJSONError(w, http.StatusBadRequest, "body_required")
return
}
if req.Global == (req.Selection != nil) {
writeJSONError(w, http.StatusBadRequest, "choose_global_or_selection")
return
}
if _, err := collab.ValidateSourcePath(req.SourcePath); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_source_path")
return
}
var anchor json.RawMessage
var err error
if req.Global {
anchor, err = collab.MarshalAnchor(collab.GlobalAnchor{})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "marshal_anchor_failed")
return
}
} else {
anchor, err = d.anchorFromSelection(req)
switch {
case errors.Is(err, errStaleSource):
writeJSONError(w, http.StatusConflict, "stale_source")
return
case errors.Is(err, errUnknownBlock):
writeJSONError(w, http.StatusConflict, "unknown_block")
return
case errors.Is(err, render.ErrNonSourceSelection):
writeJSONError(w, http.StatusConflict, "non_source_selection")
return
case err != nil:
writeJSONError(w, http.StatusBadRequest, "bad_anchor")
return
}
}
topicID := uuid.NewString()
firstMsgID := uuid.NewString()
if err := d.Collab.InsertTopicWithFirstMessage(collab.NewTopicWithFirstMessage{
TopicID: topicID, SourcePath: req.SourcePath, Anchor: anchor,
CreatedBy: d.OperatorUserID,
FirstMessageID: firstMsgID,
FirstMessageBody: req.FirstMessageBody,
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, "create_failed")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": topicID})
}
var (
errStaleSource = errors.New("stale source")
errUnknownBlock = errors.New("unknown block")
)
func (d Deps) anchorFromSelection(req topicCreateRequest) (json.RawMessage, error) {
abs := filepath.Join(d.Root, req.SourcePath)
doc, currentSHA, err := d.renderCurrentSource(abs, req.SourcePath)
if err != nil {
return nil, err
}
if req.Selection.SourceSHA != currentSHA {
return nil, errStaleSource
}
rng, err := doc.RenderMap.RenderedToSource(
req.Selection.BlockSourceStart, req.Selection.BlockSourceEnd,
req.Selection.RenderedStart, req.Selection.RenderedEnd,
)
if err != nil {
if errors.Is(err, render.ErrUnknownBlock) {
return nil, errUnknownBlock
}
return nil, err
}
return collab.MarshalAnchor(collab.PreMarkerAnchor{
SourceSHA: currentSHA, Start: rng.Start, End: rng.End, Quote: req.Selection.Quote,
})
}
func (d Deps) handleListTopics(w http.ResponseWriter, r *http.Request) {
sourcePath := r.URL.Query().Get("source_path")
topics, err := d.Collab.ListOpenTopicsForSource(sourcePath)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_source_path")
return
}
writeJSON(w, http.StatusOK, topics)
}
func (d Deps) handleListTopicMessages(w http.ResponseWriter, r *http.Request) {
topicID := r.PathValue("id")
switch open, err := d.Collab.IsTopicOpen(topicID); {
case errors.Is(err, sql.ErrNoRows):
writeJSONError(w, http.StatusNotFound, "unknown_topic")
return
case err != nil:
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
case !open:
writeJSONError(w, http.StatusGone, "topic_closed")
return
}
msgs, err := d.Collab.ListMessages(topicID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "list_messages_failed")
return
}
writeJSON(w, http.StatusOK, msgs)
}
func (d Deps) handleAppendTopicMessage(w http.ResponseWriter, r *http.Request) {
topicID := r.PathValue("id")
switch open, err := d.Collab.IsTopicOpen(topicID); {
case errors.Is(err, sql.ErrNoRows):
writeJSONError(w, http.StatusNotFound, "unknown_topic")
return
case err != nil:
writeJSONError(w, http.StatusInternalServerError, "lookup_failed")
return
case !open:
writeJSONError(w, http.StatusGone, "topic_closed")
return
}
var req messageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "bad_json")
return
}
if err := validateBody(req.Body); err != nil {
writeJSONError(w, http.StatusBadRequest, "body_required")
return
}
id := uuid.NewString()
operator := d.OperatorUserID
if _, err := d.Collab.InsertMessage(collab.NewMessage{
ID: id, TopicID: topicID, Kind: "human", Body: req.Body, AuthorUserID: &operator,
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, "insert_message_failed")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func validateBody(body string) error {
if strings.TrimSpace(body) == "" || len([]byte(body)) > 64<<10 {
return errors.New("invalid body")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, code string) {
writeJSON(w, status, map[string]string{"code": code})
}
IsTopicOpen is a new helper on the collab store. Add it to internal/collab/reader.go (extends Task 2's reader file):
// IsTopicOpen returns true when the Topic exists and has neither been
// incorporated nor discarded. Returns sql.ErrNoRows for unknown IDs.
func (s *Store) IsTopicOpen(topicID string) (bool, error) {
var incorporated, discarded sql.NullInt64
err := s.db.QueryRow(
`SELECT incorporated_at, discarded_at FROM topics WHERE id = ?`,
topicID,
).Scan(&incorporated, &discarded)
if err != nil {
return false, err
}
return !incorporated.Valid && !discarded.Valid, nil
}
Add database/sql to reader.go's imports if not already present.
In cmd/wiki-browser/main.go, pass the collab store into server deps:
mux := server.Mux(server.Deps{
Title: cfg.Title,
Root: cfg.Root,
Walker: w,
Index: idx,
Cache: renderCache,
Collab: collabStore,
OperatorUserID: cfg.Operator.UserID,
})
Run: go test ./internal/server/... ./cmd/wiki-browser/... -v
Expected: PASS.
git add internal/server/server.go internal/server/topics.go internal/server/topics_test.go internal/server/handler_doc_test.go cmd/wiki-browser/main.go
git commit -m "wiki-browser: server — add topic JSON API"
Files:
Modify: internal/server/embed.go
Modify: internal/server/handler_doc.go
Modify: internal/server/templates/shell.html
Modify: internal/server/templates/content_md.html
Modify: internal/server/templates_test.go
Modify: internal/server/static/chrome.css
Modify: internal/server/static/prose.css
Step 1: Add template tests
In internal/server/templates_test.go, update TestRenderShell_emitsIframe to include CurrentPath: "docs/a.md" and assert:
for _, want := range []string{
`id="wb-topic-sidebar"`,
`data-current-path="docs/a.md"`,
`id="wb-topic-list"`,
`id="wb-new-global-topic"`,
`id="wb-search-results"`, // preserved from prior shell — chrome.js depends on it
} {
if !strings.Contains(out, want) {
t.Errorf("shell missing %q", want)
}
}
Update TestRenderContentMD_emitsProseAndContent to pass SourceSHA: "abc" and assert:
if !strings.Contains(out, `name="wb-source-sha" content="abc"`) {
t.Errorf("missing source sha meta")
}
Run: go test ./internal/server/... -run 'TestRenderShell|TestRenderContentMD' -v
Expected: FAIL because shell sidebar markup and source SHA template fields are incomplete.
In internal/server/embed.go, ensure these fields exist:
type ShellData struct {
Title string
Groups []nav.Group
ContentPath string
CurrentPath string
}
type ContentMDData struct {
Title string
BodyHTML template.HTML
HasMermaid bool
SourceSHA string
}
In internal/server/templates/shell.html, inside <main class="wb-main">, replace the iframe-only layout with markup that keeps:
#wb-search-results element (chrome.js expects it for the search dropdown — dropping it breaks search). <div id="wb-search-results" class="wb-search-results"></div>
<iframe
id="wb-content"
name="content"
title="content"
sandbox="allow-same-origin allow-scripts allow-popups"
src="{{ .ContentPath }}"></iframe>
<section
id="wb-topic-sidebar"
class="wb-topic-sidebar"
data-current-path="{{ .CurrentPath }}"
aria-label="Topics">
<header class="wb-topic-sidebar__header">
<h2>Topics</h2>
<button id="wb-new-global-topic" type="button">New global Topic</button>
</header>
<div id="wb-topic-list" class="wb-topic-list"></div>
<section id="wb-topic-thread" class="wb-topic-thread" hidden>
<div id="wb-topic-messages" class="wb-topic-messages"></div>
<form id="wb-topic-reply-form" class="wb-topic-reply-form">
<textarea id="wb-topic-reply" name="body" rows="3" maxlength="65536"></textarea>
<button type="submit">Reply</button>
</form>
</section>
</section>
Add a template assertion for id="wb-search-results" in Step 1 so the existing search dropdown surface is preserved.
In internal/server/templates/content_md.html, ensure the <body> tag carries:
<body class="wb-prose" data-title="{{ .Title }}" data-source-sha="{{ .SourceSHA }}">
Keep the <meta name="wb-source-sha" ...> added in Task 4.
Append to internal/server/static/chrome.css. The iframe is first in the DOM and gets the wide minmax(0, 1fr) column; the sidebar is second and gets the 320px column. (The earlier draft had this reversed — sidebar wide, iframe narrow.) The #wb-search-results element is absolutely positioned by existing chrome.css, so it stays outside the grid track flow and doesn't compete for columns.
.wb-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
}
.wb-topic-sidebar {
border-left: 1px solid var(--wb-rule);
background: var(--wb-surface);
overflow: auto;
padding: 12px;
}
.wb-topic-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.wb-topic-sidebar__header h2 {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--wb-muted);
}
.wb-topic-sidebar button {
border: 1px solid var(--wb-rule);
background: var(--wb-bg);
color: var(--wb-text);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
cursor: pointer;
}
.wb-topic-card {
width: 100%;
text-align: left;
margin: 0 0 8px;
padding: 8px;
border: 1px solid var(--wb-rule);
border-radius: 6px;
background: var(--wb-bg);
}
.wb-topic-card[aria-selected="true"] {
border-color: var(--wb-accent);
box-shadow: 0 0 0 2px rgba(180, 83, 9, .12);
}
.wb-topic-card__quote,
.wb-topic-card__preview,
.wb-topic-message {
margin: 4px 0 0;
color: var(--wb-muted);
font-size: 12px;
line-height: 1.45;
}
.wb-topic-thread {
border-top: 1px solid var(--wb-rule);
margin-top: 12px;
padding-top: 12px;
}
.wb-topic-reply-form textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 8px;
font: inherit;
}
@media (max-width: 900px) {
.wb-main {
grid-template-columns: 1fr;
}
.wb-topic-sidebar {
display: none;
}
}
Append to internal/server/static/prose.css:
.wb-anchor {
background: var(--wb-accent-bg);
color: inherit;
border-radius: 2px;
padding: 0 2px;
}
.wb-anchor-overlap {
outline: 1px solid var(--wb-accent);
}
.wb-selection-composer {
position: fixed;
z-index: 1000;
width: min(280px, calc(100vw - 24px));
background: var(--wb-surface);
border: 1px solid var(--wb-rule);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(28, 25, 23, .16);
padding: 8px;
}
.wb-selection-composer textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--wb-rule);
border-radius: 4px;
padding: 6px;
font: inherit;
}
.wb-selection-composer__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
}
Run: go test ./internal/server/... -run 'TestRenderShell|TestRenderContentMD|TestDoc_servesShellPointingAtContent' -v
Expected: PASS.
git add internal/server/embed.go internal/server/handler_doc.go internal/server/templates/shell.html internal/server/templates/content_md.html internal/server/templates_test.go internal/server/static/chrome.css internal/server/static/prose.css
git commit -m "wiki-browser: ui — add topic sidebar shell"
Files:
Modify: internal/server/static/content.js
Modify: internal/server/static/chrome.js
Modify: internal/server/static/chrome.css
Modify: internal/server/static/prose.css
Step 1: Add iframe selection composer
The composer captures all offsets, the block, the quote, and the source SHA at the moment it opens. The live Selection is not read at submit time — by then the textarea has focus and the document selection can be collapsed, mutated, or pointing somewhere else entirely. Capturing a snapshot avoids that whole class of bug. Also: clicks outside the composer dismiss it (otherwise it can persist after the user starts a new selection).
Replace the empty annotation client section in internal/server/static/content.js with:
let composer = null;
function closestBlock(node) {
let el = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
while (el && el !== document.body) {
if (el.hasAttribute('data-source-start') && el.hasAttribute('data-source-end')) return el;
el = el.parentElement;
}
return null;
}
function renderedOffset(block, boundaryNode, boundaryOffset) {
const range = document.createRange();
range.setStart(block, 0);
range.setEnd(boundaryNode, boundaryOffset);
return new TextEncoder().encode(range.toString()).length;
}
function removeComposer() {
if (composer) composer.remove();
composer = null;
}
// snapshotSelection freezes everything the create-topic POST needs so the
// submit handler does not depend on the live Selection / Range — both can
// change once the textarea takes focus.
function snapshotSelection(sel) {
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const startBlock = closestBlock(sel.anchorNode);
const endBlock = closestBlock(sel.focusNode);
if (!startBlock || startBlock !== endBlock) return null;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
return {
quote: sel.toString(),
blockSourceStart: Number(startBlock.dataset.sourceStart),
blockSourceEnd: Number(startBlock.dataset.sourceEnd),
renderedStart: renderedOffset(startBlock, range.startContainer, range.startOffset),
renderedEnd: renderedOffset(startBlock, range.endContainer, range.endOffset),
rect,
sourceSHA: document.querySelector('meta[name="wb-source-sha"]')?.content || '',
};
}
function showComposer(snap) {
removeComposer();
composer = document.createElement('form');
composer.className = 'wb-selection-composer';
composer.innerHTML = '<textarea rows="3" maxlength="65536"></textarea><div class="wb-selection-composer__actions"><button type="button" data-cancel>Cancel</button><button type="submit">Save</button></div>';
composer.style.left = Math.max(12, Math.min(snap.rect.left, window.innerWidth - 292)) + 'px';
composer.style.top = Math.max(12, snap.rect.bottom + 8) + 'px';
document.body.appendChild(composer);
// Focus textarea — the selection will likely be cleared by focus(), which
// is why everything we need is already in `snap`.
composer.querySelector('textarea').focus();
composer.querySelector('[data-cancel]').addEventListener('click', removeComposer);
composer.addEventListener('submit', (e) => {
e.preventDefault();
const body = composer.querySelector('textarea').value.trim();
if (!body) return;
parent.postMessage({
kind: 'topic:create-from-selection',
source_sha: snap.sourceSHA,
first_message_body: body,
selection: {
quote: snap.quote,
block_source_start: snap.blockSourceStart,
block_source_end: snap.blockSourceEnd,
rendered_start: snap.renderedStart,
rendered_end: snap.renderedEnd,
}
}, location.origin);
removeComposer();
});
}
document.addEventListener('selectionchange', () => {
const sel = document.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
window.clearTimeout(window.__wbSelectionTimer);
window.__wbSelectionTimer = window.setTimeout(() => {
const live = document.getSelection();
const snap = snapshotSelection(live);
if (!snap) return;
showComposer(snap);
}, 120);
});
// Click outside the composer dismisses it. Mousedown beats selectionchange
// for hit-testing because the new selection is not committed yet.
document.addEventListener('mousedown', (e) => {
if (composer && !composer.contains(e.target)) removeComposer();
});
document.addEventListener('click', (e) => {
const mark = e.target.closest('.wb-anchor');
if (!mark) return;
parent.postMessage({ kind: 'topic:focus', topic_id: mark.dataset.topicId || '' }, location.origin);
});
window.wikiBrowser = window.wikiBrowser || {};
window.wikiBrowser.onAnnotationsReady = (_client) => {};
In internal/server/static/chrome.js, add these variables near existing constants:
const topicSidebar = document.getElementById('wb-topic-sidebar');
const topicList = document.getElementById('wb-topic-list');
const newGlobalTopic = document.getElementById('wb-new-global-topic');
const topicThread = document.getElementById('wb-topic-thread');
const topicMessages = document.getElementById('wb-topic-messages');
const topicReplyForm = document.getElementById('wb-topic-reply-form');
const topicReply = document.getElementById('wb-topic-reply');
let selectedTopicID = '';
Add these functions before the iframe load handler:
function currentSourcePath() {
const path = pathFromIframeURL(iframe.contentWindow.location.href).replace(/^\/doc\//, '');
return path === '/' ? '' : path;
}
async function loadTopics() {
const sourcePath = currentSourcePath();
if (!topicList || !sourcePath || sourcePath.startsWith('_')) return;
topicSidebar.dataset.currentPath = sourcePath;
const resp = await fetch('/api/topics?source_path=' + encodeURIComponent(sourcePath));
if (!resp.ok) return;
const topics = await resp.json();
topicList.innerHTML = '';
for (const t of topics) {
const anchor = JSON.parse(t.anchor || '{}');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'wb-topic-card';
btn.dataset.topicId = t.id;
btn.setAttribute('aria-selected', t.id === selectedTopicID ? 'true' : 'false');
btn.innerHTML = '<strong>' + escapeHTML(anchor.kind === 'global' ? 'Global' : 'Anchored') + '</strong>' +
(anchor.quote ? '<p class="wb-topic-card__quote">' + escapeHTML(truncate(anchor.quote, 80)) + '</p>' : '') +
'<p class="wb-topic-card__preview">' + escapeHTML(truncate(t.first_message_preview || '', 120)) + '</p>';
topicList.appendChild(btn);
}
}
async function openTopicThread(topicID) {
selectedTopicID = topicID;
if (!topicThread || !topicMessages) return;
const resp = await fetch('/api/topics/' + encodeURIComponent(topicID) + '/messages');
if (!resp.ok) return;
const msgs = await resp.json();
topicMessages.innerHTML = '';
for (const m of msgs) {
const div = document.createElement('div');
div.className = 'wb-topic-message';
div.textContent = m.body;
topicMessages.appendChild(div);
}
topicThread.hidden = false;
topicList.querySelectorAll('.wb-topic-card').forEach(card => {
card.setAttribute('aria-selected', card.dataset.topicId === topicID ? 'true' : 'false');
});
}
async function createTopic(payload) {
const resp = await fetch('/api/topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.status === 409) {
iframe.contentWindow.location.reload();
return;
}
if (!resp.ok) return;
const created = await resp.json();
selectedTopicID = created.id;
await loadTopics();
await openTopicThread(created.id);
iframe.contentWindow.location.reload();
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function truncate(s, n) {
s = String(s || '');
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
Inside the iframe load handler, after updateAriaCurrent(...), add:
loadTopics();
Add event listeners near the other click handlers:
if (topicList) {
topicList.addEventListener('click', (ev) => {
const card = ev.target.closest('.wb-topic-card');
if (!card) return;
openTopicThread(card.dataset.topicId);
});
}
if (newGlobalTopic) {
newGlobalTopic.addEventListener('click', async () => {
const body = window.prompt('New global Topic');
if (!body || !body.trim()) return;
await createTopic({ source_path: currentSourcePath(), global: true, first_message_body: body.trim() });
});
}
if (topicReplyForm) {
topicReplyForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!selectedTopicID || !topicReply.value.trim()) return;
const resp = await fetch('/api/topics/' + encodeURIComponent(selectedTopicID) + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: topicReply.value.trim() })
});
if (resp.ok) {
topicReply.value = '';
await openTopicThread(selectedTopicID);
await loadTopics();
}
});
}
Extend the existing message handler. Add an origin check so the parent never trusts a postMessage from elsewhere:
window.addEventListener('message', (ev) => {
if (ev.origin !== window.location.origin) return; // ignore foreign frames
const msg = ev.data || {};
if (msg.kind === 'topic:create-from-selection') {
createTopic({
source_path: currentSourcePath(),
first_message_body: msg.first_message_body,
selection: {
source_sha: msg.source_sha,
quote: msg.selection.quote,
block_source_start: msg.selection.block_source_start,
block_source_end: msg.selection.block_source_end,
rendered_start: msg.selection.rendered_start,
rendered_end: msg.selection.rendered_end
}
});
} else if (msg.kind === 'topic:focus' && msg.topic_id) {
openTopicThread(msg.topic_id);
}
});
(If chrome.js already has a message listener, fold the new branches into it rather than registering a second listener.)
Run: go test ./internal/server/... -v
Expected: PASS. These tests do not parse JavaScript; they verify template/static embedding still works.
The wiki-browser CLAUDE.md mandates browser verification via playwright-cli for UI changes. Build, start, drive:
make build
playwright-cli close-all # clean slate
nohup ./dist/wiki-browser -config=wiki-browser.example.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli open --browser=chromium http://localhost:8080/doc/README.md
playwright-cli resize 1440 900
Drive the interactions and assert via eval (wrap as () => …, see CLAUDE.md). Each step should be a separate command so failures point at one assertion:
# 1. Select text inside the first paragraph of the iframe.
playwright-cli eval "() => {
const f = document.getElementById('wb-content');
const doc = f.contentDocument;
const p = doc.querySelector('[data-source-start]');
const range = doc.createRange();
range.setStart(p.firstChild, 0);
range.setEnd(p.firstChild, Math.min(8, p.textContent.length));
const sel = doc.getSelection();
sel.removeAllRanges();
sel.addRange(range);
doc.dispatchEvent(new Event('selectionchange'));
return sel.toString();
}"
# 2. Wait for the composer (debounced 120ms) to appear and submit it.
playwright-cli eval "() => new Promise(r => setTimeout(() => {
const composer = document.getElementById('wb-content').contentDocument.querySelector('.wb-selection-composer');
if (!composer) return r({error: 'composer missing'});
composer.querySelector('textarea').value = 'Look at this';
composer.requestSubmit();
r({ok: true});
}, 200))"
# 3. After the iframe reloads, the wb-anchor mark should be present.
playwright-cli eval "() => new Promise(r => setTimeout(() => {
const html = document.getElementById('wb-content').contentDocument.documentElement.outerHTML;
r({hasMark: html.includes('class=\"wb-anchor\"')});
}, 1500))"
# 4. The sidebar should list one anchored topic.
playwright-cli eval "() => document.querySelectorAll('#wb-topic-list .wb-topic-card').length"
# 5. Capture a screenshot for visual review.
playwright-cli screenshot --filename=/tmp/wb-topics.png
playwright-cli close
Expected: step 1 returns the selected text; step 2 returns {ok: true}; step 3 returns {hasMark: true}; step 4 returns at least 1. Read /tmp/wb-topics.png in Claude Code to inspect inline.
If any step fails, do not proceed — fix the underlying issue and re-run.
git add internal/server/static/content.js internal/server/static/chrome.js internal/server/static/chrome.css internal/server/static/prose.css
git commit -m "wiki-browser: ui — add minimal topic interactions"
Files:
Modify: README.md only if the existing run instructions mention required config keys and omit collab_db or operator
Step 1: Run all Go tests
Run: go test ./...
Expected: PASS.
Run: rg -n "TB[D]|TO[D]O|FIXM[E]|panic\\(" internal
Expected: no matches in new implementation files. Existing historical docs may match; do not edit unrelated files.
Run: gofmt -w internal/collab internal/render internal/server cmd/wiki-browser
Expected: no output.
Run: go test ./...
Expected: PASS.
If README changed:
git add README.md
git commit -m "wiki-browser: docs — note topic config requirements"
If README did not change, do not create a commit for this task.
pre-marker anchors are created by the API only after the request's source_sha matches a fresh collab.SourceSHA(d.Root, path) read.marker anchors are rendered only when Source already contains data-orcha-anchor="<topic-id>"; #4 is responsible for creating and maintaining those markers during Incorporation. The resolver supports both inline (<span data-orcha-anchor>) and block-level (<div data-orcha-anchor> projecting onto the next sibling) forms.global anchors never render inline.content.js because the composer only appears when both selection endpoints have the same data-source-* block ancestor.human and agent-proposal in Go. SQLite remains schema-compatible with sub-project #1.source_sha everywhere in this sub-project is the git blob SHA, computed via collab.SourceSHA (which shells out to git hash-object). The hash is not computed inside the render layer; the handler computes it, passes it to render.Cache.GetForSource, rechecks after render for concurrent source changes, and emits the same value into the template so the rendered HTML, render map, and browser metadata stay bound to the same Source blob.RenderMap are UTF-8 byte counts of rendered text. Browser code must use TextEncoder rather than JavaScript string .length.InsertTopicWithFirstMessage wraps both inserts in a single SQLite transaction.<mark class="wb-anchor wb-anchor-overlap" data-topic-ids="t1 t2"> per overlap region, with topic IDs sorted deterministically.go test ./... passes.playwright-cli smoke (Task 8 step 4) completes: selection composer → highlight visible → reply posted → global Topic creation.rg -n "TB[D]|TO[D]O|FIXM[E]|panic\\(" internal has no matches introduced by this work.gofmt -l internal cmd is empty.--no-verify used anywhere.