Dropping raw HTML was silently destructive. H<sub>2</sub>O rendered as "H2O",
10<sup>6</sup> as "106", <kbd>Ctrl</kbd> as "Ctrl", and khosra check reported
nothing — an author lost meaning with no signal anywhere. Measured on the real
binary before and after.
Invariant 2 already says content from the site root is trusted, so the old gate
was defending the half of the boundary that was never in question while the
untrusted half has no code to defend yet. Chemistry, units, exponents and
keystrokes are what a hard-science site needs and what no Markdown dialect
expresses, so html.WithUnsafe() goes on in internal/render/render.go.
The gate does not disappear; it narrows. verify.sh used to fail on WithUnsafe
appearing anywhere and now fails unless it appears in exactly that one file —
watched doing both, accepting one call site and naming both files when a second
appears. A second pipeline trusting its input is the failure ADR-0003 exists to
prevent, and when comments arrive they get their own goldmark without it. The
gate is the reminder that the split has to be built rather than assumed.
The security test that asserted "raw HTML must still be dropped" now asserts the
property that actually holds and matters more: a shortcode argument stays data
whatever the page around it is allowed to do. ::figure{alt=<b>bold</b>} still
arrives as <b> while the <span> beside it renders.
core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
382 lines
13 KiB
Go
382 lines
13 KiB
Go
package render
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"khosra/internal/content"
|
|
)
|
|
|
|
func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
|
|
r, err := New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\n\n# Heading\n\nSome *prose*.\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{
|
|
"<!doctype html>", `<html lang="en">`, "<title>Hello</title>",
|
|
"<h1>Hello</h1>", "<em>prose</em>", "<style>",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("output missing %q\n---\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
|
|
r, err := New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("status/note.md", []byte("just a note\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(out), "<title>status/note</title>") {
|
|
t.Errorf("a titleless bundle must still produce a title element:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
|
|
siteFS := fstest.MapFS{
|
|
"templates/page.html": {Data: []byte(`{{define "main"}}<section class="mine">{{.Title}}</section>{{end}}`)},
|
|
}
|
|
r, err := New(siteFS, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, `<section class="mine">About</section>`) {
|
|
t.Errorf("the site override should win:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "<!doctype html>") || !strings.Contains(got, `<link rel="canonical"`) {
|
|
t.Errorf("the document should still come from the embedded base:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "<article>") {
|
|
t.Error("the embedded main should have been replaced, not appended")
|
|
}
|
|
}
|
|
|
|
// Content from the site root is trusted, so an author's HTML renders rather than being dropped (ADR-0060).
|
|
// The cases that matter are the ones the dialect cannot express: a subscript in a formula, a keystroke.
|
|
func TestAuthoredHTMLRenders(t *testing.T) {
|
|
r, err := New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("posts/h.md", []byte("---\ntitle: H\n---\n\n"+
|
|
"Water is H<sub>2</sub>O, about 10<sup>6</sup> of them. Press <kbd>Ctrl</kbd>.\n\n"+
|
|
"<aside class=\"note\">A block of it, too.</aside>\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{"H<sub>2</sub>O", "10<sup>6</sup>", "<kbd>Ctrl</kbd>", `<aside class="note">`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("authored HTML did not survive: missing %q\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "raw HTML omitted") {
|
|
t.Errorf("nothing should be dropped from trusted content:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The site is never half-stale: every render method reads the theme snapshot that was current when it started,
|
|
// and only Refresh replaces it (ADR-0056). Before that, two of the four reparsed on their own, so a listing
|
|
// could serve a new template while a bundle served the old one — and a tag listing reparsed never.
|
|
func TestEveryRenderMethodServesOneThemeSnapshot(t *testing.T) {
|
|
mark := func(v string) []byte {
|
|
return []byte(`{{define "main"}}<section>` + v + `</section>{{end}}`)
|
|
}
|
|
siteFS := fstest.MapFS{
|
|
"templates/page.html": {Data: mark("V1")},
|
|
"templates/list.html": {Data: mark("V1")},
|
|
"templates/extras.html": {Data: mark("V1")},
|
|
}
|
|
r, err := New(siteFS, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("posts/essay.md", []byte("---\ntitle: Essay\ntags: [monsoon]\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.Route = b.Key
|
|
|
|
// One call per render method the theme reaches. Any of them reparsing on its own is what makes a site
|
|
// half-stale, so they are checked together or not at all.
|
|
paths := map[string]func() ([]byte, error){
|
|
"Bundle": func() ([]byte, error) { return r.Bundle(b, "en", []string{"en"}, nil) },
|
|
"Listing": func() ([]byte, error) { return r.Listing("posts", "en", []content.Bundle{b}, 1) },
|
|
"Tag": func() ([]byte, error) { return r.Tag("", "monsoon", "en", []content.Bundle{b}, 1) },
|
|
"Extras": func() ([]byte, error) { return r.Extras(b, "en", nil, nil) },
|
|
}
|
|
assertAll := func(want, when string) {
|
|
t.Helper()
|
|
for name, render := range paths {
|
|
out, err := render()
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if !strings.Contains(string(out), "<section>"+want+"</section>") {
|
|
t.Errorf("%s served the wrong theme %s — want %s:\n%s", name, when, want, out)
|
|
}
|
|
}
|
|
}
|
|
assertAll("V1", "before any edit")
|
|
|
|
for _, name := range []string{"templates/page.html", "templates/list.html", "templates/extras.html"} {
|
|
siteFS[name] = &fstest.MapFile{Data: mark("V2")}
|
|
}
|
|
assertAll("V1", "after an edit but before a Refresh")
|
|
if err := r.Refresh(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertAll("V2", "after a Refresh")
|
|
}
|
|
|
|
// Refresh is what a rebuild calls, so an edited template takes effect without a restart (ADR-0055). Before it
|
|
// existed, the watcher noticed a template edit and the rebuild it fired changed nothing.
|
|
func TestRefreshSwapsAnEditedTemplateInAndKeepsTheWorkingOneOnAnError(t *testing.T) {
|
|
siteFS := fstest.MapFS{
|
|
"templates/page.html": {Data: []byte(`{{define "main"}}<section>first</section>{{end}}`)},
|
|
}
|
|
r, err := New(siteFS, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rendered := func() string {
|
|
t.Helper()
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(out)
|
|
}
|
|
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
|
|
t.Fatalf("the site override should be in use before any edit:\n%s", got)
|
|
}
|
|
|
|
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}<section>second</section>{{end}}`)}
|
|
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
|
|
t.Error("an edit on disk must not reach a render on its own: parsing stays off the request path")
|
|
}
|
|
if err := r.Refresh(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := rendered()
|
|
if !strings.Contains(got, "<section>second</section>") || strings.Contains(got, "<section>first</section>") {
|
|
t.Errorf("Refresh should have swapped the edited template in:\n%s", got)
|
|
}
|
|
|
|
// A typo must not cost the site its working theme, because the alternative is serving nothing at all.
|
|
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}{{end`)}
|
|
if err := r.Refresh(); err == nil {
|
|
t.Fatal("a malformed template must be reported, not stored")
|
|
}
|
|
if got := rendered(); !strings.Contains(got, "<section>second</section>") {
|
|
t.Errorf("the last good theme should still be serving:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
|
|
siteFS := fstest.MapFS{
|
|
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},
|
|
}
|
|
r, err := New(siteFS, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(out), "LISTING ONLY") {
|
|
t.Error("a listing override must not reach bundle pages — that is why sets are per kind")
|
|
}
|
|
}
|
|
|
|
func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) {
|
|
siteFS := fstest.MapFS{"templates/theme.css": {Data: []byte("body{color:rebeccapurple}")}}
|
|
r, err := New(siteFS, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := content.Parse("pages/x.md", []byte("hi\n"))
|
|
out, err := r.Bundle(b, "en", nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(out), "rebeccapurple") {
|
|
t.Error("the site stylesheet should replace the reference one")
|
|
}
|
|
}
|
|
|
|
func TestADeclaredBaseMakesMachineReadableURLsAbsolute(t *testing.T) {
|
|
// A canonical link and an hreflang are read by machines that resolve neither against the page, so both
|
|
// go absolute as soon as the site says where it lives (ADR-0039).
|
|
r, err := New(nil, content.Settings{Base: "https://khosra.example", Title: "Khosra"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{
|
|
`rel="canonical" href="https://khosra.example/posts/hello/"`,
|
|
`hreflang="bn" href="https://khosra.example/bn/posts/hello/"`,
|
|
`property="og:url" content="https://khosra.example/posts/hello/"`,
|
|
`<title>Hello · Khosra</title>`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWithoutABaseEverythingStaysRelative(t *testing.T) {
|
|
r, err := New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
if !strings.Contains(got, `rel="canonical" href="/posts/hello/"`) {
|
|
t.Errorf("a site that declared no base still links to itself:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "og:site_name") {
|
|
t.Error("no declared title means no site_name tag, rather than an empty one")
|
|
}
|
|
}
|
|
|
|
func TestATagListingOffersBothShapesAndLetsTheThemeChoose(t *testing.T) {
|
|
// The engine may not decide that a tag listing looks grouped (ADR-0046). It supplies the partition, because
|
|
// a template cannot group for itself, and the flat list beside it, because choosing is markup.
|
|
r, err := New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var bundles []content.Bundle
|
|
for _, spec := range []struct{ name, section string }{
|
|
{"posts/essay", "posts"}, {"comics/strip", "comics"}, {"posts/other", "posts"},
|
|
} {
|
|
b, err := content.Parse(spec.name+".md", []byte("---\ntitle: "+spec.name+"\n---\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.Route = b.Key
|
|
bundles = append(bundles, b)
|
|
}
|
|
out, err := r.Tag("", "monsoon", "en", bundles, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The reference theme renders groups, so the sections appear as headings.
|
|
got := string(out)
|
|
for _, want := range []string{"<h2>posts</h2>", "<h2>comics</h2>"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
// A theme preferring a flat list must have one, with each entry able to say where it came from.
|
|
if !strings.Contains(got, "posts/essay") || !strings.Contains(got, "comics/strip") {
|
|
t.Errorf("every entry should be present whichever shape is used:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAPageCanReachTheRestOfTheSite(t *testing.T) {
|
|
// The four things a reader could not get to before the reference theme was finished (ADR-0049): another
|
|
// section, this page's tags, its extras, and the same page in another language.
|
|
fsys := fstest.MapFS{
|
|
"content/posts/one/index.md": {Data: []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n")},
|
|
"content/posts/one/extras/note.md": {Data: []byte("a note\n")},
|
|
}
|
|
r, err := New(fsys, content.Settings{Title: "Khosra"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Navigation(func() []string { return []string{"posts", "comics"} })
|
|
b, err := content.Parse("posts/one/index.md", []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.Path, b.Route = "content/posts/one/index.md", b.Key
|
|
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := string(out)
|
|
for _, want := range []string{
|
|
`href="/posts/"`, // navigation
|
|
`href="/comics/"`, // a section this page is not in
|
|
`rel="tag" href="/tags/monsoon/"`, // its own tags, at the term's listing
|
|
`href="/posts/one/extras/"`, // its extras, offered only because they exist
|
|
`href="/bn/posts/one/" hreflang="bn"`, // the other language, as a visible relative link
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("a reader cannot reach %s:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPageOffersNoExtrasLinkWhenThereAreNone(t *testing.T) {
|
|
// Guessing would give every page a link to a 404.
|
|
fsys := fstest.MapFS{"content/posts/bare/index.md": {Data: []byte("---\ntitle: Bare\n---\nx\n")}}
|
|
r, err := New(fsys, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := content.Parse("posts/bare/index.md", []byte("---\ntitle: Bare\n---\nx\n"))
|
|
b.Path, b.Route = "content/posts/bare/index.md", b.Key
|
|
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(out), "/extras/") {
|
|
t.Errorf("no extras exist, so nothing should link them:\n%s", out)
|
|
}
|
|
}
|