diff --git a/cmd/adapter/main.go b/cmd/adapter/main.go new file mode 100644 index 0000000..c7353e1 --- /dev/null +++ b/cmd/adapter/main.go @@ -0,0 +1,58 @@ +// Command adapter runs the R-1 Telegram publishing adapter for one channel. +// +// It owns rendering and delivery only: it has no MTProto session and no +// provisioning code path, so a compromised adapter cannot create, rename, +// delete or re-permission anything (docs/adapter-contract.md). Configuration +// comes from `provision adapter-config`, per that same doc's Configuration +// section. +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "strconv" + + "github.com/tegwick/fluid-telegram/internal/adapter" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + token := os.Getenv("TELEGRAM_BOT_TOKEN") + if token == "" { + return fmt.Errorf("TELEGRAM_BOT_TOKEN is not set; injected from OpenBao at start, see docs/adapter-contract.md") + } + chatIDStr := os.Getenv("TELEGRAM_CHANNEL_ID") + if chatIDStr == "" { + return fmt.Errorf("TELEGRAM_CHANNEL_ID is not set; run `provision adapter-config`") + } + chatID, err := strconv.ParseInt(chatIDStr, 10, 64) + if err != nil { + return fmt.Errorf("TELEGRAM_CHANNEL_ID %q is not an integer: %w", chatIDStr, err) + } + + attachVisual := os.Getenv("ATTACH_VISUAL") == "true" + controlURL := os.Getenv("FLUID_CONTROL_URL") + hallBaseURL := os.Getenv("HALL_BASE_URL") + + addr := os.Getenv("ADAPTER_LISTEN_ADDR") + if addr == "" { + addr = ":8080" + } + + cfg := adapter.Config{ + ChatID: chatID, + AttachVisual: attachVisual, + HallBaseURL: hallBaseURL, + } + a := adapter.New(cfg, adapter.NewBotAPISender(token), adapter.NewMemStore(), adapter.NewHTTPTelemetry(controlURL)) + + log.Printf("adapter listening on %s (chat_id=%d, attach_visual=%v)", addr, chatID, attachVisual) + return http.ListenAndServe(addr, a.Handler()) +} diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..df5e6f0 --- /dev/null +++ b/internal/adapter/adapter.go @@ -0,0 +1,316 @@ +package adapter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +// Telegram's own limits, per docs/adapter-contract.md. +const ( + textOnlyLimit = 4096 + captionLimit = 1024 +) + +// recognisedConsentBases mirrors contracts/r1.openapi.yaml's subject_consent +// enum. A post with none of these is refused: absence of objection is not +// consent (FT-WP-0001 T07). +var recognisedConsentBases = map[string]bool{ + "explicit": true, + "standing": true, + "not-required": true, +} + +// Telemetry is where the adapter emits what only it can see, per +// docs/adapter-contract.md's "Telemetry the adapter should emit". It must +// never carry subscriber identity or post body text. +type Telemetry interface { + Emit(ctx context.Context, event string, fields map[string]any) +} + +// NopTelemetry discards events. Used when FLUID_CONTROL_URL is unset, e.g. in +// tests and local runs. +type NopTelemetry struct{} + +func (NopTelemetry) Emit(context.Context, string, map[string]any) {} + +// Config is what one adapter instance needs to publish to one channel. It is +// derived, never hand-written: `provision adapter-config` produces it (see +// internal/adaptercfg). One adapter instance serves one channel. +type Config struct { + ChatID int64 + AttachVisual bool // R-2 (true) attaches a portrait; R-3 (false) is text-only + HallBaseURL string +} + +// Adapter implements the three R-1 operations. It never writes to Telegram +// beyond sending to Config.ChatID, and it holds no MTProto session: see +// docs/adapter-contract.md "What the adapter must not do". +type Adapter struct { + cfg Config + sndr Sender + store Store + tel Telemetry + now func() time.Time +} + +// New builds an adapter for one channel. +func New(cfg Config, sndr Sender, store Store, tel Telemetry) *Adapter { + if tel == nil { + tel = NopTelemetry{} + } + return &Adapter{cfg: cfg, sndr: sndr, store: store, tel: tel, now: time.Now} +} + +// Handler wires the three endpoints exactly as contracts/r1.openapi.yaml +// names them. The gateway validates requests against that contract before the +// adapter sees them; this handler still assumes hostile content, per +// docs/adapter-contract.md. +func (a *Adapter) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/v1/channel-posts", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + a.handlePublish(w, r) + case http.MethodGet: + a.handleList(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } + }) + mux.HandleFunc("/v1/channel-posts/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + postID := r.URL.Path[len("/v1/channel-posts/"):] + if postID == "" { + writeError(w, http.StatusNotFound, "post_id is required") + return + } + a.handleGet(w, postID) + }) + return mux +} + +// postRequest mirrors the requestBody schema in contracts/r1.openapi.yaml. +type postRequest struct { + PostID string `json:"post_id"` + Body string `json:"body"` + SourceEntryID string `json:"source_entry_id"` + SourceEntryURL string `json:"source_entry_url"` + ReviewedBy string `json:"reviewed_by"` + SubjectDisplayName string `json:"subject_display_name,omitempty"` + SubjectConsent string `json:"subject_consent,omitempty"` + Variant string `json:"variant,omitempty"` + VisualURL string `json:"visual_url,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +func (a *Adapter) handlePublish(w http.ResponseWriter, r *http.Request) { + var req postRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + + if err := validateRequired(req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Refuse a post with no review or no recognised consent basis. Emitted as + // guardrail telemetry so the series exists even though it should always + // read false in practice, per docs/adapter-contract.md. + if req.ReviewedBy == "" { + a.tel.Emit(r.Context(), "unreviewed_publication", map[string]any{"post_id": req.PostID}) + writeError(w, http.StatusForbidden, "reviewed_by is required") + return + } + if req.SubjectConsent != "" && !recognisedConsentBases[req.SubjectConsent] { + writeError(w, http.StatusBadRequest, fmt.Sprintf("subject_consent %q is not recognised", req.SubjectConsent)) + return + } + if req.SubjectConsent == "" { + a.tel.Emit(r.Context(), "missing_subject_consent", map[string]any{"post_id": req.PostID}) + writeError(w, http.StatusForbidden, "no recognised consent basis for this subject's work") + return + } + if req.SourceEntryID == "" || req.SourceEntryURL == "" { + a.tel.Emit(r.Context(), "missing_source_attribution", map[string]any{"post_id": req.PostID}) + writeError(w, http.StatusBadRequest, "source_entry_id and source_entry_url are required") + return + } + + limit := textOnlyLimit + if a.cfg.AttachVisual { + limit = captionLimit + } + if len(req.Body) > limit { + // A composition that does not fit was not condensed; the adapter must + // refuse rather than truncate (docs/adapter-contract.md). + writeError(w, http.StatusBadRequest, + fmt.Sprintf("body is %d characters, over the %d limit for this delivery form", len(req.Body), limit)) + return + } + + rendered, err := RenderHTML(req.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + rendered += sourceLink(req.SourceEntryURL) + if len(rendered) > limit { + writeError(w, http.StatusBadRequest, "rendering plus source attribution exceeds the limit for this delivery form") + return + } + + existing, found := a.store.Get(req.PostID) + if found { + if existing.Body == rendered { + writeJSON(w, http.StatusOK, publicationView(existing)) + return + } + // Same post_id, changed content: edit rather than duplicate. A + // recomposition of the same source entry is a NEW post_id, not this + // path (docs/adapter-contract.md). + if err := a.editExisting(r.Context(), existing, rendered, req); err != nil { + a.storeFailure(req.PostID, existing, err) + writeTelegramErr(w, err) + return + } + existing.Body = rendered + existing.Variant = req.Variant + existing.SourceEntryID = req.SourceEntryID + existing.PublishedAt = a.now() + existing.State = StatePublished + existing.LastAttemptErr = "" + a.store.Put(existing) + writeJSON(w, http.StatusOK, publicationView(existing)) + return + } + + msgID, err := a.sendNew(r.Context(), rendered, req) + pub := Publication{ + PostID: req.PostID, + Body: rendered, + SourceEntryID: req.SourceEntryID, + Variant: req.Variant, + PublishedAt: a.now(), + } + if err != nil { + pub.State = StateFailed + pub.LastAttemptErr = err.Error() + a.store.Put(pub) + writeTelegramErr(w, err) + return + } + pub.State = StatePublished + pub.TelegramMsgID = msgID + a.store.Put(pub) + a.tel.Emit(r.Context(), "published", map[string]any{"post_id": req.PostID, "variant": req.Variant}) + writeJSON(w, http.StatusCreated, publicationView(pub)) +} + +func (a *Adapter) sendNew(ctx context.Context, rendered string, req postRequest) (int, error) { + if a.cfg.AttachVisual && req.VisualURL != "" { + return a.sndr.SendPhoto(ctx, a.cfg.ChatID, req.VisualURL, rendered) + } + return a.sndr.SendMessage(ctx, a.cfg.ChatID, rendered) +} + +func (a *Adapter) editExisting(ctx context.Context, existing Publication, rendered string, req postRequest) error { + if a.cfg.AttachVisual && req.VisualURL != "" { + return a.sndr.EditMessageCaption(ctx, a.cfg.ChatID, existing.TelegramMsgID, rendered) + } + return a.sndr.EditMessageText(ctx, a.cfg.ChatID, existing.TelegramMsgID, rendered) +} + +func (a *Adapter) storeFailure(postID string, existing Publication, err error) { + existing.LastAttemptErr = err.Error() + a.store.Put(existing) + _ = postID +} + +func (a *Adapter) handleGet(w http.ResponseWriter, postID string) { + pub, ok := a.store.Get(postID) + if !ok { + writeError(w, http.StatusNotFound, "this post has not been published") + return + } + writeJSON(w, http.StatusOK, publicationView(pub)) +} + +func (a *Adapter) handleList(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + filter := ListFilter{ + State: State(q.Get("state")), + Variant: q.Get("variant"), + SourceEntryID: q.Get("source_entry_id"), + } + if limit := q.Get("limit"); limit != "" { + var n int + if _, err := fmt.Sscanf(limit, "%d", &n); err == nil { + filter.Limit = n + } + } + pubs := a.store.List(filter) + views := make([]map[string]any, 0, len(pubs)) + for _, p := range pubs { + views = append(views, publicationView(p)) + } + writeJSON(w, http.StatusOK, map[string]any{"posts": views}) +} + +func validateRequired(req postRequest) error { + if req.PostID == "" { + return errors.New("post_id is required") + } + if len(req.PostID) > 128 { + return errors.New("post_id exceeds 128 characters") + } + if req.Body == "" { + return errors.New("body is required") + } + return nil +} + +func sourceLink(url string) string { + return fmt.Sprintf(` + +Source`, escapeAttr(url)) +} + +func publicationView(p Publication) map[string]any { + return map[string]any{ + "post_id": p.PostID, + "state": p.State, + "source_entry_id": p.SourceEntryID, + "variant": p.Variant, + "published_at": p.PublishedAt, + } +} + +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 writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// writeTelegramErr maps a Sender failure per docs/adapter-contract.md: a rate +// limit or any other backend failure becomes 502, never retried automatically +// for a call that may have already succeeded. +func writeTelegramErr(w http.ResponseWriter, err error) { + var rle *RateLimitError + if errors.As(err, &rle) { + w.Header().Set("Retry-After", fmt.Sprintf("%d", rle.RetryAfterSeconds)) + } + writeError(w, http.StatusBadGateway, "telegram was unreachable or refused the message") +} diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go new file mode 100644 index 0000000..815a5eb --- /dev/null +++ b/internal/adapter/adapter_test.go @@ -0,0 +1,228 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeSender struct { + nextID int + sent []string + captions []string + edits []string + failNext error +} + +func (f *fakeSender) SendMessage(_ context.Context, _ int64, html string) (int, error) { + if f.failNext != nil { + err := f.failNext + f.failNext = nil + return 0, err + } + f.nextID++ + f.sent = append(f.sent, html) + return f.nextID, nil +} + +func (f *fakeSender) SendPhoto(_ context.Context, _ int64, _, captionHTML string) (int, error) { + if f.failNext != nil { + err := f.failNext + f.failNext = nil + return 0, err + } + f.nextID++ + f.captions = append(f.captions, captionHTML) + return f.nextID, nil +} + +func (f *fakeSender) EditMessageText(_ context.Context, _ int64, _ int, html string) error { + f.edits = append(f.edits, html) + return nil +} + +func (f *fakeSender) EditMessageCaption(_ context.Context, _ int64, _ int, captionHTML string) error { + f.edits = append(f.edits, captionHTML) + return nil +} + +func validReq(postID string) postRequest { + return postRequest{ + PostID: postID, + Body: "a short post about the work", + SourceEntryID: "entry-1", + SourceEntryURL: "https://hall.example/entry-1", + ReviewedBy: "alice", + SubjectConsent: "standing", + Variant: "v1", + } +} + +func post(t *testing.T, h http.Handler, req postRequest) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/v1/channel-posts", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestPublishSucceeds(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + w := post(t, a.Handler(), validReq("p1")) + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if len(snd.sent) != 1 { + t.Fatalf("expected one message sent, got %d", len(snd.sent)) + } +} + +func TestPublishRefusesWithoutReview(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + req := validReq("p1") + req.ReviewedBy = "" + w := post(t, a.Handler(), req) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", w.Code) + } + if len(snd.sent) != 0 { + t.Fatal("expected no message sent") + } +} + +func TestPublishRefusesWithoutConsentBasis(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + req := validReq("p1") + req.SubjectConsent = "" + w := post(t, a.Handler(), req) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", w.Code) + } +} + +func TestPublishRefusesUnrecognisedConsentBasis(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + req := validReq("p1") + req.SubjectConsent = "vibes" + w := post(t, a.Handler(), req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestPublishRefusesOverLimitForTextOnly(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1, AttachVisual: false}, snd, NewMemStore(), nil) + req := validReq("p1") + req.Body = fmt.Sprintf("%4097s", "x") + w := post(t, a.Handler(), req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestPublishRefusesOverCaptionLimitWhenVisualAttached(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1, AttachVisual: true}, snd, NewMemStore(), nil) + req := validReq("p1") + req.Body = fmt.Sprintf("%1025s", "x") + req.VisualURL = "https://example.com/img.png" + w := post(t, a.Handler(), req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestPublishIsIdempotentOnPostID(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + req := validReq("p1") + + w1 := post(t, a.Handler(), req) + if w1.Code != http.StatusCreated { + t.Fatalf("first publish: status = %d", w1.Code) + } + w2 := post(t, a.Handler(), req) + if w2.Code != http.StatusOK { + t.Fatalf("repeat publish: status = %d, want 200", w2.Code) + } + if len(snd.sent) != 1 { + t.Fatalf("expected exactly one Telegram send, got %d", len(snd.sent)) + } +} + +func TestPublishEditsWhenSamePostIDHasNewBody(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + req := validReq("p1") + post(t, a.Handler(), req) + + req.Body = "a revised short post about the work" + w := post(t, a.Handler(), req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if len(snd.edits) != 1 { + t.Fatalf("expected one edit, got %d", len(snd.edits)) + } + if len(snd.sent) != 1 { + t.Fatalf("expected no additional send, got %d total sends", len(snd.sent)) + } +} + +func TestPublishMapsRateLimitTo502(t *testing.T) { + snd := &fakeSender{failNext: &RateLimitError{RetryAfterSeconds: 30}} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + w := post(t, a.Handler(), validReq("p1")) + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", w.Code) + } + if w.Header().Get("Retry-After") != "30" { + t.Fatalf("Retry-After = %q, want 30", w.Header().Get("Retry-After")) + } +} + +func TestGetPostNotFound(t *testing.T) { + a := New(Config{ChatID: 1}, &fakeSender{}, NewMemStore(), nil) + r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts/nope", nil) + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +func TestListPosts(t *testing.T) { + snd := &fakeSender{} + a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) + post(t, a.Handler(), validReq("p1")) + post(t, a.Handler(), validReq("p2")) + + r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts", nil) + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp struct { + Posts []map[string]any `json:"posts"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(resp.Posts)) + } +} diff --git a/internal/adapter/render.go b/internal/adapter/render.go new file mode 100644 index 0000000..44810ad --- /dev/null +++ b/internal/adapter/render.go @@ -0,0 +1,97 @@ +// Package adapter implements the R-1 connector contract +// (contracts/r1.openapi.yaml): an ordinary HTTP service that renders a +// composed post into a Telegram message and never writes to Telegram beyond +// that. +package adapter + +import ( + "fmt" + "regexp" + "strings" +) + +// ErrUnrenderable marks a post construct the converter cannot represent +// faithfully. Per docs/adapter-contract.md, that fails the publication rather +// than degrading it silently. +type ErrUnrenderable struct { + Reason string +} + +func (e *ErrUnrenderable) Error() string { + return fmt.Sprintf("cannot render post: %s", e.Reason) +} + +var ( + reHeading = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+)$`) + reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) + reItalic = regexp.MustCompile(`_(.+?)_`) + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reFence = regexp.MustCompile("(?s)```[a-zA-Z0-9]*\n(.*?)```") + reTableRow = regexp.MustCompile(`(?m)^\|.*\|[ \t]*$`) +) + +// RenderHTML converts a composed post's Markdown into Telegram's HTML parse +// mode, per the table in docs/adapter-contract.md. It is deterministic: the +// adapter must never compose, rewrite, shorten or embellish the text it is +// given, only translate its markup. +func RenderHTML(body string) (string, error) { + if strings.Contains(body, "<") || strings.Contains(body, ">") { + return "", &ErrUnrenderable{Reason: "body contains raw angle brackets, which HTML parse mode would interpret as tags"} + } + + s := body + + // Fenced code ->
. Must run before bold/italic so markup inside a
+ // code block is preserved literally, not reinterpreted.
+ s = reFence.ReplaceAllStringFunc(s, func(m string) string {
+ inner := reFence.FindStringSubmatch(m)[1]
+ return "" + strings.TrimRight(inner, "\n") + "
"
+ })
+
+ // Tables have no Telegram equivalent; flatten each row to a plain line.
+ s = reTableRow.ReplaceAllStringFunc(s, func(row string) string {
+ cells := strings.Split(strings.Trim(strings.TrimSpace(row), "|"), "|")
+ if isSeparatorRow(cells) {
+ return ""
+ }
+ for i, c := range cells {
+ cells[i] = strings.TrimSpace(c)
+ }
+ return strings.Join(cells, " — ")
+ })
+ s = strings.ReplaceAll(s, "\n\n\n", "\n\n") // collapse a blank line left by a stripped separator row
+
+ // Headings -> a bold line.
+ s = reHeading.ReplaceAllString(s, "$1")
+
+ // Links before bold/italic, since link text may itself carry emphasis
+ // markers that must not be double-escaped.
+ s = reLink.ReplaceAllStringFunc(s, func(m string) string {
+ g := reLink.FindStringSubmatch(m)
+ return fmt.Sprintf(`%s`, escapeAttr(g[2]), g[1])
+ })
+
+ s = reBoldStar.ReplaceAllString(s, "$1")
+ s = reItalic.ReplaceAllString(s, "$1")
+
+ return strings.TrimSpace(s), nil
+}
+
+func isSeparatorRow(cells []string) bool {
+ for _, c := range cells {
+ c = strings.TrimSpace(c)
+ if c == "" {
+ continue
+ }
+ if strings.Trim(c, "-: ") != "" {
+ return false
+ }
+ }
+ return true
+}
+
+func escapeAttr(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, `"`, """)
+ return s
+}
diff --git a/internal/adapter/render_test.go b/internal/adapter/render_test.go
new file mode 100644
index 0000000..75d98fc
--- /dev/null
+++ b/internal/adapter/render_test.go
@@ -0,0 +1,40 @@
+package adapter
+
+import "testing"
+
+func TestRenderHTML(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"heading", "# Title\nbody", "Title\nbody"},
+ {"bold", "this is **bold** text", "this is bold text"},
+ {"italic", "this is _italic_ text", "this is italic text"},
+ {"link", "see [the entry](https://example.com/e/1)", `see the entry`},
+ {"code", "```go\nfmt.Println(1)\n```", "fmt.Println(1)
"},
+ {
+ "table",
+ "| a | b |\n|---|---|\n| 1 | 2 |",
+ "a — b\n\n1 — 2",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := RenderHTML(tc.in)
+ if err != nil {
+ t.Fatalf("RenderHTML(%q): %v", tc.in, err)
+ }
+ if got != tc.want {
+ t.Errorf("RenderHTML(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRenderHTMLRejectsRawAngleBrackets(t *testing.T) {
+ _, err := RenderHTML("a