From c17fd096663f339a2545a7f278bff7bf0d2d3ec9 Mon Sep 17 00:00:00 2001 From: Marco Gazerro Date: Sat, 21 Feb 2026 20:53:45 +0100 Subject: [PATCH 1/4] internal/runtime: fix Markdown conversion on macro return Previously Markdown-to-HTML conversion after macro calls wrongly used the caller format instead of the macro format. This caused Markdown macro output to be lost in HTML contexts. This commit aligna macro return conversion with call-time semantics so conversion is driven by the called macro format, and add regression tests for direct and indirect Markdown macro calls from HTML templates. --- internal/runtime/run.go | 2 +- test/misc/multi_file_template_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/runtime/run.go b/internal/runtime/run.go index ddf7ec857..f166d1ea4 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -1585,7 +1585,7 @@ func (vm *VM) run() (Addr, bool) { if b == ReturnString { out := vm.renderer.Out().(*strings.Builder) vm.setString(1, out.String()) - } else if fn.Format == ast.FormatMarkdown && ast.Format(b) == ast.FormatHTML { + } else if vm.fn.Format == ast.FormatMarkdown && ast.Format(b) == ast.FormatHTML { out := vm.renderer.Out().(*bytes.Buffer) err := vm.env.conv(out.Bytes(), call.renderer.out) if err != nil { diff --git a/test/misc/multi_file_template_test.go b/test/misc/multi_file_template_test.go index bbdf860e0..242249c0f 100644 --- a/test/misc/multi_file_template_test.go +++ b/test/misc/multi_file_template_test.go @@ -3256,6 +3256,20 @@ func TestMultiFileTemplate(t *testing.T) { expectedOut: "--- start Markdown ---\n**bold**--- end Markdown ---\n", }, + "Show markdown macro in an HTML context": { + sources: fstest.Files{ + "index.html": `{% macro M markdown %}# Hi{% end %}{{ M() }}`, + }, + expectedOut: "--- start Markdown ---\n# Hi--- end Markdown ---\n", + }, + + "Show markdown macro in an HTML context - Indirect": { + sources: fstest.Files{ + "index.html": `{% macro M markdown %}# Hi{% end %}{% _ = &M %}{{ M() }}`, + }, + expectedOut: "--- start Markdown ---\n# Hi--- end Markdown ---\n", + }, + "Recursive macro call (1)": { sources: fstest.Files{ "index.html": `{% macro m(i int) %}{{ i }}{% if i > 0 %}{{ m(i - 1) }}{% end if %}{% end macro %}{{ m(5) }}`, From 554836717eb203604d069cf02a247b00ff384405 Mon Sep 17 00:00:00 2001 From: Marco Gazerro Date: Sat, 21 Feb 2026 21:06:19 +0100 Subject: [PATCH 2/4] internal/compiler: propagate template format across extends Previously template compilation preserved the entry file format even after rewriting the tree through extends; for example, with 'index.md' containing '{% extends "layout.html" %}'', the compiled main function still had Markdown format instead of HTML. This commit propagates 'tree.Format' while resolving extends chains, so the compiled main template function reflects the format of the final extended template. --- internal/compiler/checker.go | 1 + templates_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/compiler/checker.go b/internal/compiler/checker.go index 31550fe89..b5af8a26a 100644 --- a/internal/compiler/checker.go +++ b/internal/compiler/checker.go @@ -84,6 +84,7 @@ func typecheck(tree *ast.Tree, importer native.Importer, opts checkerOptions) (m compilation.extendedTrees[extends.Tree.Path] = true tree.Nodes = append([]ast.Node{dummyImport}, extends.Tree.Nodes...) tree.Path = extends.Tree.Path + tree.Format = extends.Tree.Format tc.path = extends.Tree.Path } diff --git a/templates_test.go b/templates_test.go index bbb477fbf..44cb0155a 100644 --- a/templates_test.go +++ b/templates_test.go @@ -190,6 +190,20 @@ func TestFormatFS(t *testing.T) { } } +func TestTemplateMainFormatWithExtends(t *testing.T) { + fsys := fstest.Files{ + "index.md": `{% extends "layout.html" %}`, + "layout.html": `

Title

`, + } + template, err := BuildTemplate(fsys, "index.md", nil) + if err != nil { + t.Fatal(err) + } + if template.fn.Format != ast.FormatHTML { + t.Fatalf("expected main format %s, got %s", ast.FormatHTML, template.fn.Format) + } +} + // TestUnexpandedTransformer ensures the transformer runs before expansion. func TestUnexpandedTransformer(t *testing.T) { fsys := fstest.Files{ From d76f31aed0f991ea0e9c62afbc9e2a6458a6608c Mon Sep 17 00:00:00 2001 From: Marco Gazerro Date: Sat, 21 Feb 2026 21:26:19 +0100 Subject: [PATCH 3/4] scriggo: implement `Template.Format` method Implement 'Template.Format', which reports the output format produced when executing a template. --- templates.go | 5 ++++ templates_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/templates.go b/templates.go index 9dfadb111..55eae3bf5 100644 --- a/templates.go +++ b/templates.go @@ -174,6 +174,11 @@ func (t *Template) Disassemble(n int) []byte { return assemblies["main"] } +// Format returns the output format of the template when executed. +func (t *Template) Format() Format { + return Format(t.fn.Format) +} + // UsedVars returns the names of the global variables used in the template. // A variable used in dead code may not be returned as used. func (t *Template) UsedVars() []string { diff --git a/templates_test.go b/templates_test.go index 44cb0155a..af2830f4a 100644 --- a/templates_test.go +++ b/templates_test.go @@ -6,6 +6,7 @@ package scriggo import ( "fmt" + "io/fs" "reflect" "strings" "testing" @@ -204,6 +205,64 @@ func TestTemplateMainFormatWithExtends(t *testing.T) { } } +func TestTemplateFormat(t *testing.T) { + tests := map[string]struct { + fsys fs.FS + name string + expected Format + }{ + "Text": { + fsys: fstest.Files{"index.txt": `hello`}, + name: "index.txt", + expected: FormatText, + }, + "HTML": { + fsys: fstest.Files{"index.html": `

hello

`}, + name: "index.html", + expected: FormatHTML, + }, + "Markdown": { + fsys: fstest.Files{"index.md": `# hello`}, + name: "index.md", + expected: FormatMarkdown, + }, + "MarkdownExtendsHTML": { + fsys: fstest.Files{ + "index.md": `{% extends "layout.html" %}`, + "layout.html": `

hello

`, + }, + name: "index.md", + expected: FormatHTML, + }, + "MultipleExtendsUseFinalFormat": { + fsys: fstest.Files{ + "index.md": `{% extends "level1.html" %}`, + "level1.html": `{% extends "level2.html" %}`, + "level2.html": `

hello

`, + }, + name: "index.md", + expected: FormatHTML, + }, + "FormatFS": { + fsys: testFormatFS{Files: fstest.Files{"index": `hello`}, format: FormatJSON}, + name: "index", + expected: FormatJSON, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + template, err := BuildTemplate(tc.fsys, tc.name, nil) + if err != nil { + t.Fatal(err) + } + if got := template.Format(); got != tc.expected { + t.Fatalf("expected %s, got %s", tc.expected, got) + } + }) + } +} + // TestUnexpandedTransformer ensures the transformer runs before expansion. func TestUnexpandedTransformer(t *testing.T) { fsys := fstest.Files{ From 5a73ad44d5db432ff43a15e779ef21e5b76cde74 Mon Sep 17 00:00:00 2001 From: Marco Gazerro Date: Sat, 21 Feb 2026 22:17:06 +0100 Subject: [PATCH 4/4] cmd/scriggo: improve Markdown handling in scriggo serve Improve the scriggo serve command handling of Markdown files: - Serve .md files as-is when explicitly requested (no HTML rendering). - When a path has no extension, and no corresponding .html file exists but a matching .md file does, render the Markdown and wrap it in a full HTML page before serving. Closes #974 --- cmd/scriggo/help.go | 23 +++++--- cmd/scriggo/serve.go | 127 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 118 insertions(+), 32 deletions(-) diff --git a/cmd/scriggo/help.go b/cmd/scriggo/help.go index 6acd906ee..4653defc8 100644 --- a/cmd/scriggo/help.go +++ b/cmd/scriggo/help.go @@ -269,20 +269,27 @@ usage: scriggo serve [-S n] [--metrics] [--disable-livereload] [-const name=valu Serve runs a web server and serves the template rooted at the current directory. It is useful to learn Scriggo templates. -It renders HTML and Markdown files based on file extension. +Path resolution rules: -For example: + /path/name.html + renders 'path/name.html' as an HTML template. - http://localhost:8080/article + /path/name.md + serves the 'path/name.md' file as-is (without rendering it to HTML). -it renders the file 'article.html' as HTML if exists, otherwise renders the -file 'article.md' as Markdown. + /path/name + renders 'path/name.html' if it exists; otherwise, if 'path/name.md' + exists, renders it as Markdown and wraps it in a full HTML page. -Serving a URL terminating with a slash: + /path/ and /path + render 'path/index' using the previous rule. - http://localhost:8080/blog/ +Example: -it renders 'blog/index.html' or 'blog/index.md'. + http://localhost:8080/article + +it renders the file 'article.html' as HTML if exists, otherwise renders the +file 'article.md' as Markdown wrapped in HTML. Markdown is converted to HTML with the Goldmark parser with the options html.WithUnsafe, parser.WithAutoHeadingID and extension.GFM. diff --git a/cmd/scriggo/serve.go b/cmd/scriggo/serve.go index eb138bd1b..3448be5a7 100644 --- a/cmd/scriggo/serve.go +++ b/cmd/scriggo/serve.go @@ -296,7 +296,7 @@ func (srv *server) serveTemplate(w http.ResponseWriter, r *http.Request) { } return } - } else if ext != ".html" && ext != ".md" { + } else if ext != ".html" { srv.static.ServeHTTP(w, r) return } @@ -366,32 +366,33 @@ func (srv *server) serveTemplate(w http.ResponseWriter, r *http.Request) { } runTime := time.Since(start) - s := b.Bytes() - i := indexEndBody(s) - if i == -1 { - i = len(s) - } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if srv.liveReloads == nil { - _, _ = w.Write(s) + + if template.Format() == scriggo.FormatMarkdown { + md := goldmark.New(goldmarkOptions...) + var body bytes.Buffer + err = md.Convert(b.Bytes(), &body) + if err != nil { + http.Error(w, err.Error(), 500) + } + var urlPath string + if srv.liveReloads != nil { + urlPath = r.URL.Path + } + writeWrappedBody(w, urlPath, name, &body) } else { - _, _ = w.Write(s[:i]) - _, _ = io.WriteString(w, ``) - _, _ = w.Write(s[i:]) + _, _ = w.Write(s[:i]) + _, _ = writeLiveReloadScript(w, r.URL.Path) + _, _ = w.Write(s[i:]) + } } if srv.metrics.active { @@ -513,3 +514,81 @@ func isBodyTag(s []byte) bool { } return false } + +// writeWrappedBody writes a complete HTML page to w. It inserts body between +// the tags. If urlPath is non-empty, it includes a live reload script +// using urlPath. The title parameter specifies the page title. +func writeWrappedBody(w io.Writer, urlPath, title string, body *bytes.Buffer) { + _, _ = io.WriteString(w, ` + + + + + `) + _, _ = io.WriteString(w, title) + _, _ = io.WriteString(w, ` + + + +`) + _, _ = body.WriteTo(w) + if urlPath != "" { + _, _ = writeLiveReloadScript(w, urlPath) + } + _, _ = io.WriteString(w, ` + +`) +} + +// writeLiveReloadScript writes a live reload script for urlPath to w. +// It returns the number of bytes written and any error. +func writeLiveReloadScript(w io.Writer, urlPath string) (int, error) { + n, err := io.WriteString(w, ``) + return n, err +}