From 0fc92f364316c874d366b00cdc884a33e1900b25 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 24 May 2026 18:13:05 -0500 Subject: [PATCH] Implemented Markdown escaping for render output --- internal/render/markdown.go | 34 ++++++++++++++++++---- internal/render/markdown_test.go | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/internal/render/markdown.go b/internal/render/markdown.go index be9a910..a7cd205 100644 --- a/internal/render/markdown.go +++ b/internal/render/markdown.go @@ -17,13 +17,13 @@ func (MarkdownRenderer) Render(transcript Transcript, opts Options) (string, err if title == "" { title = "Transcript" } - lines = append(lines, "# "+title, "") + lines = append(lines, "# "+escapeMarkdownInline(title), "") if opts.IncludeMetadata { lines = append(lines, - fmt.Sprintf("- Application: %s", transcript.Metadata.Application), - fmt.Sprintf("- Version: %s", transcript.Metadata.Version), - fmt.Sprintf("- Output schema: %s", transcript.Schema), + fmt.Sprintf("- Application: %s", escapeMarkdownInline(transcript.Metadata.Application)), + fmt.Sprintf("- Version: %s", escapeMarkdownInline(transcript.Metadata.Version)), + fmt.Sprintf("- Output schema: %s", escapeMarkdownInline(transcript.Schema)), "", ) } @@ -37,11 +37,11 @@ func (MarkdownRenderer) Render(transcript Transcript, opts Options) (string, err parts = append(parts, fmt.Sprintf("[#%d]", segment.ID)) } - text := segment.Text + text := escapeMarkdownInline(segment.Text) if shouldItalicize(segment.Categories) { text = "*" + text + "*" } - parts = append(parts, fmt.Sprintf("**%s:** %s", segment.Speaker, text)) + parts = append(parts, fmt.Sprintf("**%s:** %s", escapeMarkdownInline(segment.Speaker), text)) lines = append(lines, strings.Join(parts, " ")) lines = append(lines, "") } @@ -53,6 +53,28 @@ func (MarkdownRenderer) Render(transcript Transcript, opts Options) (string, err return output, nil } +func escapeMarkdownInline(value string) string { + replacer := strings.NewReplacer( + `\`, `\\`, + "`", "\\`", + "*", "\\*", + "_", "\\_", + "{", "\\{", + "}", "\\}", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "#", "\\#", + "+", "\\+", + "!", "\\!", + "|", "\\|", + "<", "\\<", + ">", "\\>", + ) + return replacer.Replace(value) +} + func shouldItalicize(categories []string) bool { for _, category := range categories { switch category { diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go index 1160dbf..1a25ecb 100644 --- a/internal/render/markdown_test.go +++ b/internal/render/markdown_test.go @@ -108,6 +108,48 @@ func TestMarkdownRendererMetadataOnlyWhenRequested(t *testing.T) { } } +func TestMarkdownRendererEscapesUserProvidedMarkdown(t *testing.T) { + transcript := Transcript{ + Schema: "seriatim-intermediate", + Metadata: Metadata{ + Application: "seriatim *cli*", + Version: "v[test]", + }, + Segments: []Segment{ + { + ID: 1, + Start: 1, + End: 2, + Speaker: "Dr. *A_[1]", + Text: "Use *literal* [link](target) and `code` \\ slash!", + }, + { + ID: 2, + Start: 2, + End: 3, + Speaker: "Narrator", + Text: "_aside_ with | pipe", + Categories: []string{"background"}, + }, + }, + } + + output, err := MarkdownRenderer{}.Render(transcript, Options{ + Title: "# Planning [notes]", + IncludeTimestamps: false, + IncludeMetadata: true, + }) + if err != nil { + t.Fatalf("render markdown: %v", err) + } + + assertContains(t, output, "# \\# Planning \\[notes\\]") + assertContains(t, output, "- Application: seriatim \\*cli\\*") + assertContains(t, output, "- Version: v\\[test\\]") + assertContains(t, output, "**Dr. \\*A\\_\\[1\\]:** Use \\*literal\\* \\[link\\]\\(target\\) and \\`code\\` \\\\ slash\\!") + assertContains(t, output, "**Narrator:** *\\_aside\\_ with \\| pipe*") +} + func TestMarkdownRendererCategoryHintItalicsAndUnknownCategories(t *testing.T) { transcript := Transcript{ Segments: []Segment{ @@ -176,3 +218,10 @@ func TestMarkdownRendererIsDeterministic(t *testing.T) { t.Fatalf("expected HH:MM:SS timestamp formatting:\n%s", first) } } + +func assertContains(t *testing.T, value string, want string) { + t.Helper() + if !strings.Contains(value, want) { + t.Fatalf("expected output to contain %q:\n%s", want, value) + } +}