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

@@ -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 `<link rel="stylesheet">` 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

View File

@@ -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:
- `<!doctype html>`
- `<html lang="en">`
- UTF-8 `<meta charset>`
- optional `<link rel="stylesheet" href="...">` when `css_href` is configured
- empty `<title>`
- `<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

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)
}
})
}
}

View File

@@ -29,6 +29,7 @@ func PlanOutputs(ctx context.Context, req Request) ([]Output, error) {
Markdown: transform.MarkdownOptions{
Mode: req.Transform.MarkdownToHTML.Mode,
Input: req.Transform.MarkdownToHTML.Input,
CssHref: req.Transform.MarkdownToHTML.CssHref,
},
})
if err != nil {

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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></title>\n</head>\n<body>\n<h1>Title</h1>\n<p>Hello.</p>\n</body>\n</html>\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 := "<meta charset=\"utf-8\">\n<link rel=\"stylesheet\" href=\"/assets/report.css?v=1&amp;theme=main\">\n<title></title>"
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), `<link rel="stylesheet" href="https://example.com/assets/report.css">`) {
t.Fatalf("html = %q, want stylesheet link", outputs[0].Data)
}
}
func TestGenerateMarkdownIndexSelectsOnlyMarkdownFile(t *testing.T) {
backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n")

View File

@@ -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("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title></title>\n</head>\n<body>\n")
buf.WriteString("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n")
if cssHref != "" {
buf.WriteString("<link rel=\"stylesheet\" href=\"")
buf.WriteString(html.EscapeString(cssHref))
buf.WriteString("\">\n")
}
buf.WriteString("<title></title>\n</head>\n<body>\n")
buf.Write(body)
buf.WriteString("</body>\n</html>\n")
return buf.Bytes()

View File

@@ -25,6 +25,7 @@ type Request struct {
type MarkdownOptions struct {
Mode string
Input string
CssHref string
}
type Transformer interface {