Add support for CSS links in generated HTML outputs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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&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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -25,6 +25,7 @@ type Request struct {
|
||||
type MarkdownOptions struct {
|
||||
Mode string
|
||||
Input string
|
||||
CssHref string
|
||||
}
|
||||
|
||||
type Transformer interface {
|
||||
|
||||
Reference in New Issue
Block a user