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 `
`
- ` ` 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/.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 := "\n\n\n \n \n\n\nTitle \nHello.
\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 {