Keep generated prose out of Markdown structure

This commit is contained in:
2026-08-13 02:17:11 +00:00
parent 2bd921f247
commit a18d5134c7
12 changed files with 233 additions and 13 deletions

View File

@@ -627,6 +627,36 @@ func TestBuildDailyRenderContext(t *testing.T) {
})
}
func TestValidatedGeneratedTextRendersAsPlainText(t *testing.T) {
generated, _, err := ValidateDaily([]byte("{\"summary\":\"Summary.\\n## Fabricated Alert\\n- Fabricated warning\",\"forecast_discussion\":[\"Discussion with [unsafe](javascript:alert(1))\"],\"precipitation_timing\":\"Timing.\\n```not code```\"}"))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
context, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), generated, testDailyDerived())
if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err)
}
rendered, err := reporttemplate.Render("daily", context)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"Summary.\n\\## Fabricated Alert\n\\- Fabricated warning",
"Discussion with \\[unsafe\\]\\(javascript:alert\\(1\\)\\)",
"Timing.\n\\`\\`\\`not code\\`\\`\\`",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered report missing escaped generated prose %q:\n%s", want, text)
}
}
for _, unwanted := range []string{"\n## Fabricated Alert", "\n- Fabricated warning", "[unsafe](javascript:alert(1))", "```not code```"} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered report preserved generated Markdown structure %q:\n%s", unwanted, text)
}
}
}
func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil)
if err != nil {

View File

@@ -0,0 +1,89 @@
package reporttemplate
import (
"strings"
"unicode"
)
var inlineMarkdownReplacer = strings.NewReplacer(
`\`, `\\`,
"`", "\\`",
"*", "\\*",
"_", "\\_",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"<", "\\<",
">", "\\>",
"!", "\\!",
"~", "\\~",
"|", "\\|",
"&", "\\&",
)
// plainText preserves prose and paragraph breaks while preventing dynamic
// values from creating Markdown structure owned by repository templates.
func plainText(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
lines := strings.Split(value, "\n")
for index, line := range lines {
lines[index] = escapeMarkdownLine(line)
}
return strings.Join(lines, "\n")
}
func escapeMarkdownLine(line string) string {
indentEnd := 0
spaces := 0
hasTab := false
for indentEnd < len(line) {
switch line[indentEnd] {
case ' ':
spaces++
indentEnd++
case '\t':
hasTab = true
indentEnd++
default:
goto escaped
}
}
escaped:
indent := line[:indentEnd]
if hasTab || spaces >= 4 {
indent = ""
}
return indent + escapeMarkdownBlockPrefix(escapeMarkdownInline(line[indentEnd:]))
}
func escapeMarkdownInline(value string) string {
value = inlineMarkdownReplacer.Replace(value)
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, value)
}
func escapeMarkdownBlockPrefix(value string) string {
if value == "" {
return value
}
switch value[0] {
case '#', '+', '-', '>', '=':
return "\\" + value
}
index := 0
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
index++
}
if index > 0 && index < len(value) && value[index] == '.' {
return value[:index] + "\\" + value[index:]
}
return value
}

View File

@@ -920,6 +920,94 @@ func TestRenderHourlyUsesSharedOpenEndedPrecipitationTiming(t *testing.T) {
}
}
func TestRenderTreatsGeneratedProseAsPlainText(t *testing.T) {
const summary = "Summary paragraph.\n\n## Fabricated Alert\n- Fabricated warning\n1. Fabricated list\n> Fabricated quote"
const discussion = "Discussion paragraph.\n\n<script>unsafe</script>\n[unsafe](javascript:alert(1))\n```code```\n**emphasis**\n indented code\x00"
const timing = "Timing paragraph.\n\n## Fabricated Timing"
tests := []struct {
name string
template string
context any
}{
{
name: "daily",
template: "daily",
context: testDailyRenderContext{
Report: testDailyReportContext{Title: "Daily"},
GeneratedText: testDailyGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
Modules: testDailyModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
},
},
{
name: "today",
template: "today",
context: testTodayRenderContext{
Report: testTodayReportContext{Title: "Today"},
GeneratedText: testTomorrowGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
Modules: testTodayModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
},
},
{
name: "tomorrow",
template: "tomorrow",
context: testTomorrowRenderContext{
Report: testTomorrowReportContext{Title: "Tomorrow"},
GeneratedText: testTomorrowGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
Modules: testTomorrowModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
},
},
{
name: "hourly",
template: "hourly",
context: testRenderContext{
Report: testReportContext{Title: "Hourly"},
GeneratedText: testGeneratedText{Summary: summary, ForecastDiscussion: discussion, PrecipitationTiming: timing},
Modules: testModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rendered, err := Render(test.template, test.context)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"Summary paragraph.\n\n\\## Fabricated Alert\n\\- Fabricated warning\n1\\. Fabricated list\n\\> Fabricated quote",
"Discussion paragraph.\n\n\\<script\\>unsafe\\</script\\>\n\\[unsafe\\]\\(javascript:alert\\(1\\)\\)\n\\`\\`\\`code\\`\\`\\`\n\\*\\*emphasis\\*\\*\nindented code ",
"Timing paragraph.\n\n\\## Fabricated Timing",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing escaped prose %q:\n%s", want, text)
}
}
for _, unwanted := range []string{
"\n## Fabricated Alert",
"\n- Fabricated warning",
"\n1. Fabricated list",
"\n> Fabricated quote",
"<script>unsafe</script>",
"[unsafe](javascript:alert(1))",
"```code```",
"**emphasis**",
"\n indented code",
"\n## Fabricated Timing",
} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template preserved Markdown structure %q:\n%s", unwanted, text)
}
}
})
}
}
func testPrecipitationTimingForGeneratedProseTest() *testPrecipTiming {
return &testPrecipTiming{PrecipitationWindows: []testPrecipWindow{{}}}
}
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
t.Fatalf("Template() error = %v, want unknown template", err)

View File

@@ -11,6 +11,7 @@ func templateFuncs() template.FuncMap {
"hasRelevantAlerts": hasRelevantAlerts,
"hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk,
"isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk,
"plainText": plainText,
}
}

View File

@@ -3,10 +3,10 @@
**Forecast date:** {{ .Report.ForecastDateLabel }}
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
{{ plainText .GeneratedText.Summary }}
{{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ plainText . }}
{{ end }}

View File

@@ -2,7 +2,7 @@
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
{{ plainText .GeneratedText.Summary }}
{{ template "alert_digest" . }}## Current Conditions
@@ -18,4 +18,4 @@
{{ end -}}
## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }}
{{ plainText .GeneratedText.ForecastDiscussion }}

View File

@@ -3,6 +3,6 @@
{{ range . }}{{ $window := . }}
- **{{ if or .PeriodEndsHourLabel .PeriodEnds }}{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ else }}Starting at {{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: {{ with .ExpectationPhrase }}{{ . }}{{ else }}Chance of precipitation.{{ end }}{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ plainText . }}
{{ end }}
{{ end }}{{ end }}{{ end }}

View File

@@ -3,7 +3,7 @@
**Forecast date:** {{ .Report.ForecastDateLabel }}
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
{{ plainText .GeneratedText.Summary }}
{{ template "alert_digest" . }}{{ with .Modules.CurrentConditions }}
## Current Conditions
@@ -13,5 +13,5 @@
{{ template "today_daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ plainText . }}
{{ end }}

View File

@@ -3,10 +3,10 @@
**Forecast date:** {{ .Report.ForecastDateLabel }}
**Updated:** {{ .Report.GeneratedAtLabel }}
{{ .GeneratedText.Summary }}
{{ plainText .GeneratedText.Summary }}
{{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ plainText . }}
{{ end }}