diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md
index 1326062..5e94ebc 100644
--- a/docs/internal/generatedtext.md
+++ b/docs/internal/generatedtext.md
@@ -70,5 +70,7 @@ go test ./internal/generatedtext
```
Generated text supplies prose slots only; deterministic weather facts remain in
-module and fact values. Every report definition must resolve to exactly one
-supported catalog pair.
+module and fact values. The renderer applies its plain-text policy to every
+generated prose insertion, preserving ordinary text and paragraph breaks while
+preventing provider text from creating Markdown or HTML structure. Every report
+definition must resolve to exactly one supported catalog pair.
diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md
index 0db1720..5680d2b 100644
--- a/docs/internal/reporttemplate.md
+++ b/docs/internal/reporttemplate.md
@@ -30,7 +30,11 @@ partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data
packages or complete fact bundles, and it must not reimplement weather
selection or generated-text validation. Context construction rejects
-report-identity disagreements before template execution.
+report-identity disagreements before template execution. Every generated-prose
+insertion uses the `plainText` helper. It retains ordinary prose and paragraph
+breaks but renders Markdown/HTML syntax, code indentation, and control
+characters as safe text, so the repository templates remain the sole owners of
+report structure.
## Boundaries and verification
diff --git a/docs/templates.md b/docs/templates.md
index d324162..1bb612c 100644
--- a/docs/templates.md
+++ b/docs/templates.md
@@ -47,6 +47,11 @@ from rendering.
calculations, source selection, or prompt-input shaping to a template.
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
in generated prose merely to compensate for a template change.
+- Render every `.GeneratedText` value through `plainText`. It preserves prose
+ and paragraph breaks while escaping Markdown and HTML syntax, removing code
+ indentation, and replacing control characters. Never interpolate generated
+ prose directly: repository templates alone own headings, lists, links, and
+ other Markdown structure.
- When changing the generated-prose contract, update the matching prompt,
schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
@@ -69,7 +74,7 @@ Minimal list pattern:
```gotemplate
{{ range .GeneratedText.ForecastDiscussion }}
-{{ . }}
+{{ plainText . }}
{{ end }}
```
@@ -82,6 +87,7 @@ Templates have these helpers in addition to Go template built-ins:
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
+| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
For example, the alert partial uses the first two functions to decide whether
to render the section:
diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go
index 58017ef..20f42b0 100644
--- a/internal/generatedtext/render_context_test.go
+++ b/internal/generatedtext/render_context_test.go
@@ -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 {
diff --git a/internal/reporttemplate/plain_text.go b/internal/reporttemplate/plain_text.go
new file mode 100644
index 0000000..2f3d86d
--- /dev/null
+++ b/internal/reporttemplate/plain_text.go
@@ -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
+}
diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go
index fdec34f..32df6eb 100644
--- a/internal/reporttemplate/reporttemplate_test.go
+++ b/internal/reporttemplate/reporttemplate_test.go
@@ -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\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\\\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",
+ "",
+ "[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)
diff --git a/internal/reporttemplate/template_funcs.go b/internal/reporttemplate/template_funcs.go
index a659358..84f2fdb 100644
--- a/internal/reporttemplate/template_funcs.go
+++ b/internal/reporttemplate/template_funcs.go
@@ -11,6 +11,7 @@ func templateFuncs() template.FuncMap {
"hasRelevantAlerts": hasRelevantAlerts,
"hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk,
"isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk,
+ "plainText": plainText,
}
}
diff --git a/internal/reporttemplate/templates/daily.md.tmpl b/internal/reporttemplate/templates/daily.md.tmpl
index 053b555..8347bb3 100644
--- a/internal/reporttemplate/templates/daily.md.tmpl
+++ b/internal/reporttemplate/templates/daily.md.tmpl
@@ -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 }}
diff --git a/internal/reporttemplate/templates/hourly.md.tmpl b/internal/reporttemplate/templates/hourly.md.tmpl
index bed2837..e345458 100644
--- a/internal/reporttemplate/templates/hourly.md.tmpl
+++ b/internal/reporttemplate/templates/hourly.md.tmpl
@@ -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 }}
diff --git a/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl b/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl
index f636a93..a2d14d2 100644
--- a/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl
+++ b/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl
@@ -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 }}
diff --git a/internal/reporttemplate/templates/today.md.tmpl b/internal/reporttemplate/templates/today.md.tmpl
index dd8b9d6..012c909 100644
--- a/internal/reporttemplate/templates/today.md.tmpl
+++ b/internal/reporttemplate/templates/today.md.tmpl
@@ -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 }}
diff --git a/internal/reporttemplate/templates/tomorrow.md.tmpl b/internal/reporttemplate/templates/tomorrow.md.tmpl
index 053b555..8347bb3 100644
--- a/internal/reporttemplate/templates/tomorrow.md.tmpl
+++ b/internal/reporttemplate/templates/tomorrow.md.tmpl
@@ -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 }}