diff --git a/docs/config.md b/docs/config.md index 348a909..c919a2f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -313,6 +313,7 @@ transform: markdown_to_html: enabled: true mode: sidecar + css_href: /assets/report.css ``` `publish.html` controls whether generated HTML outputs are published. When `publish.html` is `true`, `transform.markdown_to_html.enabled` must also be `true`. @@ -322,10 +323,13 @@ Markdown transform fields: - `transform.markdown_to_html.enabled`: enables Markdown-to-HTML generation for this destination. - `transform.markdown_to_html.mode`: optional. Accepted values are `sidecar` and `index`; default is `sidecar` when a Markdown transform block is present. - `transform.markdown_to_html.input`: optional source manifest path for `index` mode only. +- `transform.markdown_to_html.css_href`: optional stylesheet href to link from generated HTML. `sidecar` mode renders every manifest-listed `.md` file to a same-directory `.html` output. `index` mode renders one Markdown source to `index.html` at the destination bundle path. If `index` mode omits `input`, the selected source bundle must contain exactly one Markdown file. -At least one output type must be enabled. Enabled Markdown transforms are rejected when `publish.html` is `false`, and `input` is rejected unless `mode` is `index`. +`css_href` may be an absolute `http` or `https` URL, a root-relative path such as `/assets/report.css`, or a relative URL path such as `assets/report.css`. Query strings are allowed. `distributor` injects the href as a `` element but does not copy, publish, verify, or manage the CSS file solely because `css_href` is set. + +At least one output type must be enabled. Enabled Markdown transforms are rejected when `publish.html` is `false`, `input` is rejected unless `mode` is `index`, and `css_href` is rejected when the Markdown transform is disabled. ## Destination Path Mapping diff --git a/docs/integrations/markdown.md b/docs/integrations/markdown.md index 82a853b..bdac338 100644 --- a/docs/integrations/markdown.md +++ b/docs/integrations/markdown.md @@ -10,7 +10,7 @@ Rendering uses `github.com/yuin/goldmark`. The exact version is pinned in `go.mo ## Renderer Behavior -The transformer constructs `goldmark.New()` with no project-specific extensions, parser options, renderer options, templates, CSS, or metadata injection. +The transformer constructs `goldmark.New()` with no project-specific extensions, parser options, renderer options, templates, or source manifest metadata injection. Supported output modes: @@ -19,6 +19,8 @@ Supported output modes: In `index` mode, `transform.markdown_to_html.input` may name the source manifest path to render. If `input` is omitted, the source manifest must list exactly one `.md` file. The selected input must be a clean relative source path, must be listed in the source manifest, and must end in `.md`. +When `transform.markdown_to_html.css_href` is set, generated HTML includes a stylesheet link in the document head. The href may be an absolute HTTP(S) URL, a root-relative path, or a relative URL path. Distributor treats this as a link reference only; it does not copy, publish, verify, or manage the CSS file solely because `css_href` is configured. + Raw HTML embedded in Markdown is not passed through by the current renderer behavior. Tests allow Goldmark's disabled-or-escaped raw HTML output forms and reject literal script tags in generated HTML. ## HTML Wrapper @@ -28,10 +30,11 @@ Rendered Markdown body HTML is wrapped in a fixed document shell: - `` - `` - UTF-8 `` +- optional `` when `css_href` is configured - empty `` - `<body>` containing the rendered Markdown body -The wrapper is deterministic and does not read configuration, templates, CSS, or source manifest metadata. +The wrapper is deterministic. When `css_href` is omitted, the generated wrapper is unchanged from the unstyled output. When `css_href` is configured, its escaped link element is part of the generated output bytes. ## Output Metadata diff --git a/internal/config/config.go b/internal/config/config.go index 7f9e075..3e394d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/validate.go b/internal/config/validate.go index 002983f..0e78034 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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) diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 8d71906..5b538c2 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -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) + } + }) + } +} diff --git a/internal/publish/output.go b/internal/publish/output.go index 2be281a..d018ff5 100644 --- a/internal/publish/output.go +++ b/internal/publish/output.go @@ -27,8 +27,9 @@ func PlanOutputs(ctx context.Context, req Request) ([]Output, error) { SourceBundle: req.SourceBundle, SourceBackend: req.SourceBackend, Markdown: transform.MarkdownOptions{ - Mode: req.Transform.MarkdownToHTML.Mode, - Input: req.Transform.MarkdownToHTML.Input, + Mode: req.Transform.MarkdownToHTML.Mode, + Input: req.Transform.MarkdownToHTML.Input, + CssHref: req.Transform.MarkdownToHTML.CssHref, }, }) if err != nil { diff --git a/internal/publish/output_test.go b/internal/publish/output_test.go index dc86ad4..96be01d 100644 --- a/internal/publish/output_test.go +++ b/internal/publish/output_test.go @@ -167,6 +167,7 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) { Enabled: true, Mode: config.TransformModeIndex, Input: "report.md", + CssHref: "/assets/report.css", }}, Transformers: testResolver{transform.MarkdownToHTML: transformer}, }) @@ -174,8 +175,8 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) { if err != nil { t.Fatalf("PlanOutputs() error = %v", err) } - if transformer.request.Markdown.Mode != config.TransformModeIndex || transformer.request.Markdown.Input != "report.md" { - t.Fatalf("markdown options = %#v, want index/report.md", transformer.request.Markdown) + if transformer.request.Markdown.Mode != config.TransformModeIndex || transformer.request.Markdown.Input != "report.md" || transformer.request.Markdown.CssHref != "/assets/report.css" { + t.Fatalf("markdown options = %#v, want index/report.md with css href", transformer.request.Markdown) } } diff --git a/internal/transform/markdown/markdown.go b/internal/transform/markdown/markdown.go index 8bfde9b..1504950 100644 --- a/internal/transform/markdown/markdown.go +++ b/internal/transform/markdown/markdown.go @@ -123,7 +123,7 @@ func (t *Transformer) render(ctx context.Context, req transform.Request, sourceF if err := t.renderer.Convert(data, &rendered); err != nil { return nil, fmt.Errorf("render markdown source %q: %w", sourceFile, err) } - return wrapHTML(rendered.Bytes()), nil + return wrapHTML(rendered.Bytes(), req.Markdown.CssHref), nil } func markdownMode(mode string) string { diff --git a/internal/transform/markdown/markdown_test.go b/internal/transform/markdown/markdown_test.go index ff89b91..bb2ef62 100644 --- a/internal/transform/markdown/markdown_test.go +++ b/internal/transform/markdown/markdown_test.go @@ -40,6 +40,43 @@ func TestGenerateMarkdownSidecar(t *testing.T) { } } +func TestGenerateMarkdownWithoutCSSHrefPreservesWrapper(t *testing.T) { + backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n") + + outputs, err := New().Generate(context.Background(), transform.Request{SourceBackend: backend, SourceBundle: sourceBundle}) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + want := "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>\n\n\n

Title

\n

Hello.

\n\n\n" + if got := string(outputs[0].Data); got != want { + t.Fatalf("html = %q, want existing wrapper %q", got, want) + } +} + +func TestGenerateMarkdownSidecarWithCSSHref(t *testing.T) { + backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n") + + outputs, err := New().Generate(context.Background(), transform.Request{ + SourceBackend: backend, + SourceBundle: sourceBundle, + Markdown: transform.MarkdownOptions{CssHref: "/assets/report.css?v=1&theme=main"}, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + output := outputs[0] + html := string(output.Data) + wantLink := "\n\n" + if !strings.Contains(html, wantLink) { + t.Fatalf("html = %q, want stylesheet link %q", html, wantLink) + } + if output.SHA256 != bundle.FileDigest(output.Data) || output.Size != int64(len(output.Data)) { + t.Fatalf("digest/size metadata = %s/%d", output.SHA256, output.Size) + } +} + func TestGenerateMarkdownIndexExplicitInput(t *testing.T) { backend := fake.New() sourceBundle := testutil.WriteFakeSourceBundle(t, backend, "", testutil.BundleOptions{ @@ -74,6 +111,29 @@ func TestGenerateMarkdownIndexExplicitInput(t *testing.T) { } } +func TestGenerateMarkdownIndexWithCSSHref(t *testing.T) { + backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n") + + outputs, err := New().Generate(context.Background(), transform.Request{ + SourceBackend: backend, + SourceBundle: sourceBundle, + Markdown: transform.MarkdownOptions{ + Mode: transform.MarkdownModeIndex, + CssHref: "https://example.com/assets/report.css", + }, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + if got, want := outputs[0].Path, "index.html"; got != want { + t.Fatalf("path = %q, want %q", got, want) + } + if !strings.Contains(string(outputs[0].Data), ``) { + t.Fatalf("html = %q, want stylesheet link", outputs[0].Data) + } +} + func TestGenerateMarkdownIndexSelectsOnlyMarkdownFile(t *testing.T) { backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n") diff --git a/internal/transform/markdown/template.go b/internal/transform/markdown/template.go index 2ae1e77..828400a 100644 --- a/internal/transform/markdown/template.go +++ b/internal/transform/markdown/template.go @@ -1,10 +1,19 @@ package markdown -import "bytes" +import ( + "bytes" + "html" +) -func wrapHTML(body []byte) []byte { +func wrapHTML(body []byte, cssHref string) []byte { var buf bytes.Buffer - buf.WriteString("\n\n\n\n\n\n\n") + buf.WriteString("\n\n\n\n") + if cssHref != "" { + buf.WriteString("\n") + } + buf.WriteString("\n\n\n") buf.Write(body) buf.WriteString("\n\n") return buf.Bytes() diff --git a/internal/transform/transform.go b/internal/transform/transform.go index b92a6c4..cf0fa1e 100644 --- a/internal/transform/transform.go +++ b/internal/transform/transform.go @@ -23,8 +23,9 @@ type Request struct { } type MarkdownOptions struct { - Mode string - Input string + Mode string + Input string + CssHref string } type Transformer interface {