Fix what zs treats as content

Two ways a document's content could be dropped or left unrendered.

**A thematic break could swallow the prose above it.** Front matter was
found by searching for the first `---` line anywhere in a file and
treating everything before it as the header, so a document whose opening
prose happens to parse as YAML lost it:

    Note: this is important

    ---

    The rest of the document.

published as just "The rest of the document." -- the first line silently
became a variable named "Note". No error, no warning.

A front matter block must now open with `---` on the first line and
close with the next `---` line, which is what the README, the Quick
Start and every testdata fixture already show. Anything else is content
from top to bottom, so a thematic break is only ever a horizontal rule.
An unterminated block is content too, rather than consuming the file.

Files using the undocumented leading-block form (front matter with no
opening `---`) will stop having their variables recognised. That case is
detected and warned about by name, so it surfaces as a message rather
than as pages quietly losing their titles.

**`.markdown` files were never rendered.** `zs index` treated them as
content while `zs build` copied them through untouched, publishing raw,
unrendered Markdown. Both agree now, and `build()` asks `isMarkdown()`
rather than keeping its own list of extensions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiPehVTCDGWgEBJ7H4G5VL
This commit is contained in:
James Mills
2026-09-01 00:30:10 +10:00
co-authored by Claude Opus 5
parent 807e05cf0f
commit ba7877ef0f
3 changed files with 144 additions and 15 deletions
+5
View File
@@ -109,6 +109,11 @@ keywords: best website, hello, world
Markdown text goes after a header *separator*
```
The block must **open** with `---` on the very first line of the file and
**close** with the next `---` line. A file that doesn't start with `---` is
content from top to bottom, so a `---` thematic break partway down a document
is just a horizontal rule and never a variable block.
Use placeholders for variables and plugins in your markdown or html
files, e.g. `{{ title }}` or `{{ command arg1 arg2 }}`.
+63 -12
View File
@@ -500,7 +500,7 @@ func renameExt(path, oldext, newext string) string {
// isMarkdown reports whether a file extension is rendered as Markdown.
func isMarkdown(ext string) bool {
return ext == ".md"
return ext == ".md" || ext == ".markdown"
}
// outputURL returns the published URL for a source path: Markdown is rendered
@@ -630,10 +630,63 @@ func run(vars Vars, cmd string, args ...string) (string, error) {
return out, nil
}
// frontMatterDelim opens and closes a front matter block.
const frontMatterDelim = "---"
// isFrontMatterDelim reports whether a line is a front matter delimiter,
// ignoring its line ending.
func isFrontMatterDelim(line string) bool {
return strings.TrimRight(line, "\r\n") == frontMatterDelim
}
// splitFrontMatter separates a leading YAML front matter block from the content
// that follows it. The block must open with a "---" line at the very start of
// the file and close with the next "---" line.
//
// zs used to treat everything before the first "---" line *anywhere* in a file
// as the header, so a thematic break silently swallowed the prose above it
// whenever that prose happened to parse as YAML ("Note: this is important"
// does). Without a delimited block, the whole file is content.
func splitFrontMatter(s string) (header, body string, found bool) {
lines := strings.SplitAfter(s, "\n")
if len(lines) == 0 || !isFrontMatterDelim(lines[0]) {
return "", s, false
}
var b strings.Builder
for i := 1; i < len(lines); i++ {
if isFrontMatterDelim(lines[i]) {
return b.String(), strings.Join(lines[i+1:], ""), true
}
b.WriteString(lines[i])
}
// Unterminated: content, rather than swallowing the whole file.
return "", s, false
}
// warnLegacyFrontMatter reports a file whose leading block would have been
// parsed as front matter under the old rule, so that the change is visible
// instead of silently dropping variables.
func warnLegacyFrontMatter(path, s string) {
sep := strings.Index(s, "\n"+frontMatterDelim+"\n")
if sep <= 0 {
return
}
vars := Vars{}
if err := yaml.Unmarshal([]byte(s[:sep]), &vars); err != nil || len(vars) == 0 {
return
}
log.Warnf(
"%s: front matter must open with %q on the first line; treating the file as content",
path, frontMatterDelim,
)
}
// getVars returns list of variables defined in a text file and actual file
// content following the variables declaration. Header is separated from
// content by an empty line. Header can be either YAML or JSON.
// If no empty newline is found - file is treated as content-only.
// content following the variables declaration. The variables are a YAML front
// matter block opening and closing with a "---" line, the opening one being the
// first line of the file. A file without one is treated as content-only.
func getVars(path string, globals Vars) (Vars, string, error) {
if Ignore.MatchesPath(path) {
return nil, "", nil
@@ -663,15 +716,12 @@ func getVars(path string, globals Vars) (Vars, string, error) {
v["layout"] = "layout.html"
}
delim := "\n---\n"
sep := strings.Index(s, delim)
if sep == -1 {
header, body, found := splitFrontMatter(s)
if !found {
warnLegacyFrontMatter(path, s)
return v, s, nil
}
header := s[:sep]
body := s[sep+len(delim):]
vars := Vars{}
log.Debugf("header: %s", header)
if err := yaml.Unmarshal([]byte(header), &vars); err != nil {
@@ -885,9 +935,10 @@ func build(path string, w io.Writer, vars Vars) error {
}
ext := filepath.Ext(path)
switch ext {
case ".md":
if isMarkdown(ext) {
return buildMarkdown(path, w, vars)
}
switch ext {
case ".txt", ".html", ".css", ".xml", ".json", ".yml", ".yaml":
return buildText(path, w, vars)
}
+76 -3
View File
@@ -57,7 +57,7 @@ echo foo
func TestVars(t *testing.T) {
tests := map[string]Vars{
`
`---
foo: bar
title: Hello, world!
---
@@ -70,7 +70,7 @@ Some content in markdown
"output": filepath.Join(PUBDIR, "test.html"),
"__content": "Some content in markdown\n",
},
`
`---
url: "example.com/foo.html"
---
Hello
@@ -204,7 +204,7 @@ func TestVarsPrecedence(t *testing.T) {
}
// Front matter beats both.
if err := os.WriteFile("my-first-post.md", []byte("\ntitle: Own Title\n---\nHello\n"), 0644); err != nil {
if err := os.WriteFile("my-first-post.md", []byte("---\ntitle: Own Title\n---\nHello\n"), 0644); err != nil {
t.Fatal(err)
}
v, _, err = getVars("my-first-post.md", Vars{"title": "Site Title"})
@@ -268,3 +268,76 @@ func TestHeadings(t *testing.T) {
t.Error("expected an error for an out-of-range level")
}
}
func TestSplitFrontMatter(t *testing.T) {
tests := []struct {
name string
in string
header, body string
found bool
}{
{
name: "delimited block",
in: "---\ntitle: Hello\n---\nBody\n",
header: "title: Hello\n",
body: "Body\n",
found: true,
},
{
// The bug: prose that parses as YAML above a thematic break used
// to be eaten as front matter, losing it from the page.
name: "thematic break is not front matter",
in: "Note: this is important\n\n---\n\nThe rest.\n",
body: "Note: this is important\n\n---\n\nThe rest.\n",
found: false,
},
{
name: "no front matter at all",
in: "# Just content\n",
body: "# Just content\n",
found: false,
},
{
name: "unterminated block is content",
in: "---\ntitle: Hello\nBody without a closing delimiter\n",
body: "---\ntitle: Hello\nBody without a closing delimiter\n",
found: false,
},
{
name: "empty front matter",
in: "---\n---\nBody\n",
header: "",
body: "Body\n",
found: true,
},
{
name: "CRLF line endings",
in: "---\r\ntitle: Hello\r\n---\r\nBody\r\n",
header: "title: Hello\r\n",
body: "Body\r\n",
found: true,
},
{
name: "thematic break below real front matter is left alone",
in: "---\ntitle: Hello\n---\nAbove\n\n---\n\nBelow\n",
header: "title: Hello\n",
body: "Above\n\n---\n\nBelow\n",
found: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header, body, found := splitFrontMatter(tt.in)
if found != tt.found {
t.Fatalf("found = %v, want %v", found, tt.found)
}
if header != tt.header {
t.Errorf("header = %q, want %q", header, tt.header)
}
if body != tt.body {
t.Errorf("body = %q, want %q", body, tt.body)
}
})
}
}