mirror of
https://git.mills.io/prologic/zs.git
synced 2026-10-05 02:01:41 -07:00
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:
co-authored by
Claude Opus 5
parent
807e05cf0f
commit
ba7877ef0f
@@ -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 }}`.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user