render the HTML an author writes, and narrow the gate to one call site

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 &lt;b&gt; while the <span> beside it renders.

core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
This commit is contained in:
Claude Opus 5
2026-08-01 21:08:06 +06:00
committed by bdeshi
parent a893ab1821
commit 1f168d973b
11 changed files with 127 additions and 61 deletions
+6 -5
View File
@@ -106,11 +106,12 @@ staging a subset — a changed signature without its caller — produced a commi
`verify.sh` reported green, and a broken commit is something `git bisect` trips over for as long as the repo
lives. It only runs when something is staged.
**The injection boundary is a gate now, not a memory.** goldmark drops raw HTML from authored Markdown by
default, and that default was the only thing standing between a Markdown file and script injection. A
feature wanting to emit HTML renders a theme template instead (ADR-0036), so nothing here needs raw HTML
enabled — and `verify.sh` fails on `WithUnsafe` appearing in any non-comment line under `cmd/` or
`internal/`. Anything that genuinely needs it must argue with a failing gate first, which is the point.
**The injection boundary is a gate now, not a memory.** Raw HTML renders for site-root content, because
that content is the author's own (ADR-0060) — and the *trusted* half of that sentence is the whole claim.
`verify.sh` fails unless `html.WithUnsafe()` appears in exactly one file, `internal/render/render.go`. A
second call site means some other input has been handed the pipeline that trusts its author, which is the
failure ADR-0003 exists to prevent; when comments arrive they get their own goldmark without it. A feature
wanting to emit HTML still renders a theme template instead (ADR-0036).
## Coming back after a long absence
+2 -2
View File
@@ -366,8 +366,8 @@ text. `khosra check` reports every one as fatal and names the replacement, so a
running it until it exits zero. The engine cannot rewrite a site root — that is the author's data (ADR-0011).
Arguments are data, never markup: the call renders through a theme template of the same name
(`theme-contract.md`), and raw HTML in a body remains dropped, so the only HTML on a page came from a
template the site owns (ADR-0036). A call naming a shortcode the theme has no template for renders nothing
(`theme-contract.md`), so an argument is escaped for wherever the template puts it — which holds whatever
the page around it is allowed to do (ADR-0036). A call naming a shortcode the theme has no template for renders nothing
and logs it — one typo does not take a page down (ADR-0029).
Shortcodes run on site-root content only (ADR-0003), never on anything untrusted.
+22
View File
@@ -947,3 +947,25 @@ parser must refuse `:::`, `: definition` and `3::4` and does; and the alt-text c
with the syntax, which is exactly the kind of silent breakage a syntax change causes.
Revisit if: the container or inline forms need to differ from the directive convention — which would be a
change to this decision, not a quiet addition.
## ADR-0060 — Raw HTML renders for site-root content, and the gate narrows to one call site
Date: 2026-08-01 · Status: accepted (replaces ADR-0036's "raw HTML stays dropped" clause; the rest of that
decision — a shortcode renders through a theme fragment and writes no markup itself — is untouched)
Decision: `html.WithUnsafe()` is enabled in `internal/render/render.go`, so HTML an author writes in a
bundle body reaches the page. `verify.sh` no longer forbids the option; it requires it in exactly that one
file, and fails when it appears anywhere else. An untrusted source — a comment, a webmention — gets its own
goldmark without it, and building that renderer waits until such a source exists.
Why: dropping it was silently destructive. `H<sub>2</sub>O` rendered as "H2O", `10<sup>6</sup>` as "106",
and `check` reported nothing, so an author lost meaning with no signal anywhere — measured on the real
binary before this change. Invariant 2 already says content from the site root is *trusted*; the old gate
was defending the trusted half of a boundary that was never in question, while the untrusted half has no
code yet to defend. Chemistry, units, exponents and keystrokes are exactly what a hard-science site needs
and what no Markdown dialect expresses. Keeping the gate but narrowing it costs nothing and keeps the
boundary mechanical: the thing worth checking was never "is raw HTML on" but "how many pipelines trust
their input".
Consequence: cheap — authors write HTML where the dialect has no syntax, one gate instead of none, and the
silent-loss failure disappears. Expensive — a `<script>` an author writes now runs, which is their site and
their choice, but it means an author who pastes something they did not read has no net; and the day
comments arrive, the untrusted renderer must be built rather than assumed, with the gate as the reminder.
Revisit if: an untrusted source appears — which is when the second renderer is built and this gate proves
whether the split was ever real.
+3 -3
View File
@@ -21,7 +21,7 @@ table owns.
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058) |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058), and this is the one renderer that enables raw HTML (ADR-0060) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
@@ -47,7 +47,7 @@ section-narrowed, sequence navigation and a series archive on any nested bundle,
URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`.
Markdown is CommonMark plus tables, footnotes, definition lists, strikethrough and heading ids, and nothing
else (ADR-0058). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
else (ADR-0058); HTML an author writes renders, because the site root is trusted (ADR-0060). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by
@@ -107,7 +107,7 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| Item | Why it waits | Trigger to fix |
|---|---|---|
| No mechanical check that the counters are *correct* | Accepted at the Arc 1 boundary: the coupling gate makes forgetting them impossible, which is the real failure mode, and checking the values needs code to count | The first page-level transform (queue 15), now that the transform counter means something narrower |
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is now mechanical `verify.sh` rejects `WithUnsafe`, so authored Markdown cannot become HTML — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is mechanical in a different way since ADR-0060 — `verify.sh` requires `html.WithUnsafe()` in exactly one file, so a second pipeline trusting its input cannot appear unnoticed — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
| `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) |
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
+32 -32
View File
@@ -122,7 +122,7 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 611 lines + 427 test
## internal/ext/shortcodes — 611 lines + 435 test
doc.go 7 · images.go 250 · shortcodes.go 354
@@ -178,9 +178,9 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 710 lines + 461 test
## internal/render — 713 lines + 489 test
chrome.go 110 · render.go 470 · view.go 130
chrome.go 110 · render.go 473 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -190,34 +190,34 @@ chrome.go 110 · render.go 470 · view.go 130
- chrome.go:73 func numerals(lang string, n int) string
- chrome.go:82 func day(lang string, t time.Time) string
- chrome.go:98 func localiseDigits(lang, s string) string
- render.go:25 var themeFS embed.FS
- render.go:29 type Renderer struct
- render.go:48 type parsedTheme struct
- render.go:62 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:65 type Fragment struct
- render.go:75 type Picture struct
- render.go:92 type Origin struct
- render.go:101 var originKey = parser.NewContextKey()
- render.go:104 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:111 func WithOrigin(pc parser.Context, origin Origin)
- render.go:124 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:153 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:181 func (r *Renderer) head(title, lang, canonical string) head
- render.go:198 func (r *Renderer) absolute(path string) string
- render.go:206 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:218 func (r *Renderer) Refresh() error
- render.go:229 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:248 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:270 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:288 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:309 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:327 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:364 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:383 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:410 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:437 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:442 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:464 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:26 var themeFS embed.FS
- render.go:30 type Renderer struct
- render.go:49 type parsedTheme struct
- render.go:63 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:66 type Fragment struct
- render.go:76 type Picture struct
- render.go:93 type Origin struct
- render.go:102 var originKey = parser.NewContextKey()
- render.go:105 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:112 func WithOrigin(pc parser.Context, origin Origin)
- render.go:125 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:156 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:184 func (r *Renderer) head(title, lang, canonical string) head
- render.go:201 func (r *Renderer) absolute(path string) string
- render.go:209 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:221 func (r *Renderer) Refresh() error
- render.go:232 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:251 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:273 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:291 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:312 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:330 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:367 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:386 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:413 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:440 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:445 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:467 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:16 type head struct
- view.go:36 type Page struct
- view.go:55 type Sequence struct
@@ -228,7 +228,7 @@ chrome.go 110 · render.go 470 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1599 test
## internal/web — 734 lines + 1601 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
@@ -16,6 +16,10 @@ This bundle also has an `extras/` directory, so the theme offers a link to it at
The page has a footnote of its own[^page], a table, a definition list and ~~a struck phrase~~.
Where the dialect has no syntax for something, HTML does: the gauge measures H<sub>2</sub>O to about
10<sup>-3</sup> m, and the reading is taken with <kbd>Shift</kbd> held. Content in the site root is the
author's own, so it renders rather than being dropped.
| Gauge | Reading | Note |
|-------|---------|------|
| North | 41 mm | steady |
+13 -5
View File
@@ -109,12 +109,20 @@ func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
// An unquoted value is legal now (ADR-0059), so a hostile attribute name parses cleanly rather than
// failing at the syntax. It still cannot become markup: the fragment decides where a value lands, and
// html/template escapes it for that context. Escaping is the property, not a syntax accident.
got = body(t, wired(t, nil), `::figure{src=x.jpg onerror=alert(1)}`+"\n\n<script>alert(2)</script>\n")
if strings.Contains(got, "onerror") && !strings.Contains(got, "&quot;") {
t.Errorf("a malformed call must stay escaped text, not markup:\n%s", got)
got = body(t, wired(t, nil), `::figure{src=x.jpg onerror=alert(1)}`+"\n")
if strings.Contains(got, " onerror=") {
t.Errorf("an argument must not become an attribute of its own:\n%s", got)
}
if !strings.Contains(got, "raw HTML omitted") {
t.Errorf("authored raw HTML must still be dropped:\n%s", got)
// Authored raw HTML *does* render now, because the site root is trusted (ADR-0060). That is a decision
// about the author's own words, and it changes nothing here: a shortcode argument is data the template
// places, so it is escaped in whichever context it lands in, whatever the page around it is allowed to do.
got = body(t, wired(t, nil), "<span class=\"live\">rendered</span>\n\n::figure{src=a.jpg alt=<b>bold</b>}\n")
if !strings.Contains(got, `<span class="live">rendered</span>`) {
t.Errorf("authored HTML should reach the page intact:\n%s", got)
}
if strings.Contains(got, "<b>bold</b>") || !strings.Contains(got, "&lt;b&gt;") {
t.Errorf("an argument stays data even where raw HTML is allowed:\n%s", got)
}
}
+6 -3
View File
@@ -17,6 +17,7 @@ import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"khosra/internal/content"
)
@@ -133,8 +134,9 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
// not move the transforms counter.
//
// Raw HTML stays disabled — goldmark's default — so the only HTML a page carries comes from a template
// (ADR-0036, invariant 2). Nothing here may enable html.WithUnsafe.
// Raw HTML renders, because content from the site root is trusted (invariant 2, ADR-0060). This is the
// *one* renderer allowed to say so, which `verify.sh` enforces by counting the call: an untrusted source
// — a comment, a webmention — gets its own goldmark without this option, never this one.
extensions := []goldmark.Extender{extension.Typographer}
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
@@ -142,7 +144,8 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
// Heading IDs are a parser option rather than an extension, and they are the engine's half of a table of
// contents: the anchor has to exist before a theme can link to it (ADR-0058).
r.md = goldmark.New(goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(parser.WithAutoHeadingID()))
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()))
return r, nil
}
+28
View File
@@ -78,6 +78,34 @@ func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
}
}
// 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.
+2
View File
@@ -118,6 +118,8 @@ var exampleFeatures = []featureCase{
{what: "an include is parsed as Markdown, and its fragment has no page of its own", path: "/writing/notes-on-water/", code: 200,
expect: []string{`<h2 id="method">Method</h2>`, "<em>Emphasis and links survive</em>"}},
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
{what: "authored HTML renders, because the site root is trusted", path: "/writing/notes-on-water/", code: 200,
expect: []string{"H<sub>2</sub>O", "<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}},
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
+9 -11
View File
@@ -107,18 +107,16 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then
fi
fi
# Invariant 2, as mechanism rather than memory. goldmark drops raw HTML by default, and that default is
# the only thing standing between an authored Markdown file and script injection. A shortcode wanting to
# emit HTML renders a theme template instead (ADR-0036), so nothing in this engine has a reason to turn
# it off — and the day something seems to, it should have to argue with a failing gate first.
# Comment lines are excluded: the rule is worth explaining next to the code it governs, and a gate that
# fires on its own documentation is a gate nobody keeps.
unsafe=$(grep -rn 'WithUnsafe' --include='*.go' cmd internal 2>/dev/null |
grep -vE ':[0-9]+:[[:space:]]*//' | cut -d: -f1,2 || true)
if [ -n "$unsafe" ]; then
bad "html.WithUnsafe enables raw HTML from authored content (ADR-0003, ADR-0036): $(echo "$unsafe" | tr '\n' ' ')"
# Invariant 2, as mechanism rather than memory. Content from the site root is trusted, so raw HTML renders
# there (ADR-0060) — but the *trusted* half is the whole claim, and this is what holds it to one place.
# A second call site means some other input has been handed the pipeline that trusts its author, which is
# the failure ADR-0003 exists to prevent; when comments arrive they get their own goldmark without it.
# Matched by file rather than by line, so the rule can still be explained next to the code it governs.
unsafe=$(grep -rl 'html\.WithUnsafe()' --include='*.go' cmd internal 2>/dev/null | sort || true)
if [ "$unsafe" != "internal/render/render.go" ]; then
bad "html.WithUnsafe belongs in internal/render/render.go and nowhere else (ADR-0003, ADR-0060); found in: $(echo "${unsafe:-nothing}" | tr '\n' ' ')"
else
pass "raw HTML stays disabled"
pass "raw HTML is enabled in exactly one renderer"
fi
# The example site is a demonstration that has to stay true (ADR-0051). `check` over it catches the class of