Add support for CSS links in generated HTML outputs

This commit is contained in:
2026-06-13 22:16:15 -05:00
parent c84d8868d1
commit b10a8bd194
11 changed files with 213 additions and 13 deletions

View File

@@ -110,6 +110,7 @@ type MarkdownToHTML struct {
Enabled bool `yaml:"enabled"`
Mode string `yaml:"mode"`
Input string `yaml:"input"`
CssHref string `yaml:"css_href"`
}
type PathMapping struct {

View File

@@ -2,8 +2,10 @@ package config
import (
"fmt"
"net/url"
"regexp"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/distributor/internal/link"
)
@@ -281,9 +283,15 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled {
return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true")
}
if transform.MarkdownToHTML.CssHref != "" && !transform.MarkdownToHTML.Enabled {
return fmt.Errorf("transform.markdown_to_html.css_href requires transform.markdown_to_html.enabled to be true")
}
if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex {
return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", TransformModeIndex)
}
if err := validateCSSHref(transform.MarkdownToHTML.CssHref); err != nil {
return fmt.Errorf("transform.markdown_to_html.css_href %w", err)
}
if transform.MarkdownToHTML.Enabled && !publish.HTML {
return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true")
}
@@ -293,6 +301,49 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
return nil
}
func validateCSSHref(value string) error {
if value == "" {
return nil
}
for _, character := range value {
if unicode.IsControl(character) || unicode.IsSpace(character) {
return fmt.Errorf("must not contain whitespace or control characters")
}
}
if strings.ContainsAny(value, "\\<>\"'") {
return fmt.Errorf("must not contain backslashes or HTML-sensitive characters")
}
if strings.HasPrefix(value, "//") {
return fmt.Errorf("must not be scheme-relative")
}
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("must be a valid URL reference: %w", err)
}
if parsed.Fragment != "" {
return fmt.Errorf("must not include a fragment")
}
if parsed.Scheme != "" {
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("scheme must be http or https")
}
if parsed.Host == "" {
return fmt.Errorf("host is required for absolute URLs")
}
if parsed.User != nil {
return fmt.Errorf("must not include userinfo")
}
return nil
}
if parsed.Host != "" {
return fmt.Errorf("must not be scheme-relative")
}
if parsed.Path == "" {
return fmt.Errorf("relative URL path is required")
}
return nil
}
func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors {
if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed {
errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+PathMappingFixed)

View File

@@ -328,6 +328,24 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
Input: "report.md",
}},
},
{
name: "html only sidecar css href allowed",
publish: PublishPolicy{HTML: true},
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
Enabled: true,
Mode: TransformModeSidecar,
CssHref: "/assets/report.css",
}},
},
{
name: "html only index css href allowed",
publish: PublishPolicy{HTML: true},
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
Enabled: true,
Mode: TransformModeIndex,
CssHref: "assets/report.css?v=20260614",
}},
},
{
name: "source and html sidecar allowed",
publish: PublishPolicy{Source: true, HTML: true},
@@ -425,6 +443,16 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
}},
wantErr: true,
},
{
name: "disabled markdown css href rejected",
publish: PublishPolicy{Source: true},
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
Enabled: false,
Mode: TransformModeSidecar,
CssHref: "/assets/report.css",
}},
wantErr: true,
},
{
name: "disabled markdown wrong mode rejected",
publish: PublishPolicy{Source: true},
@@ -436,3 +464,44 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
},
}
}
func TestValidateCSSHref(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "empty"},
{name: "root relative", value: "/assets/report.css"},
{name: "relative", value: "assets/report.css"},
{name: "parent relative", value: "../assets/report.css"},
{name: "query", value: "/assets/report.css?v=20260614"},
{name: "http", value: "http://example.com/report.css"},
{name: "https", value: "https://example.com/assets/report.css?v=1"},
{name: "javascript", value: "javascript:alert(1)", wantErr: true},
{name: "data", value: "data:text/css,body{}", wantErr: true},
{name: "file", value: "file:///tmp/report.css", wantErr: true},
{name: "scheme relative", value: "//example.com/report.css", wantErr: true},
{name: "userinfo", value: "https://user@example.com/report.css", wantErr: true},
{name: "fragment", value: "/assets/report.css#main", wantErr: true},
{name: "space", value: "/assets/report css", wantErr: true},
{name: "tab", value: "/assets/report\tcss", wantErr: true},
{name: "newline", value: "/assets/report\ncss", wantErr: true},
{name: "backslash", value: `assets\report.css`, wantErr: true},
{name: "less than", value: "/assets/<report>.css", wantErr: true},
{name: "double quote", value: `/assets/"report".css`, wantErr: true},
{name: "single quote", value: "/assets/'report'.css", wantErr: true},
{name: "query only", value: "?v=1", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCSSHref(tt.value)
if tt.wantErr && err == nil {
t.Fatal("validateCSSHref() error = nil, want error")
}
if !tt.wantErr && err != nil {
t.Fatalf("validateCSSHref() error = %v", err)
}
})
}
}