add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.
The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.
Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes <script> and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.
parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
// Package shortcodes expands `{{< name key="value" >}}` on its own line into a theme fragment.
|
||||
//
|
||||
// Contributes: a Markdown block parser and node renderer (PhaseParse).
|
||||
// Cascade keys: none.
|
||||
// Contract fields: a template per shortcode name in templates/shortcodes.html, receiving its arguments.
|
||||
// Not doing: inline shortcodes, file inclusion, galleries — each waits for a second real use.
|
||||
package shortcodes
|
||||
@@ -0,0 +1,161 @@
|
||||
package shortcodes
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// open and close delimit a call. Chosen to be something no Markdown construct claims and no author types
|
||||
// by accident; the syntax is a disk contract, so it does not change (ADR-0036).
|
||||
const (
|
||||
opener = "{{<"
|
||||
closer = ">}}"
|
||||
)
|
||||
|
||||
// New returns the Markdown extension, rendering each call through partial.
|
||||
//
|
||||
// The feature never writes markup: it hands the call's name and arguments to a theme template of the same
|
||||
// name and writes whatever comes back (ADR-0036).
|
||||
func New(partial render.Partial) goldmark.Extender {
|
||||
return extension{partial: partial}
|
||||
}
|
||||
|
||||
type extension struct {
|
||||
partial render.Partial
|
||||
}
|
||||
|
||||
// Extend registers the block parser and the node renderer. Priorities sit above goldmark's paragraph
|
||||
// parser so a line that is only a call never becomes a paragraph.
|
||||
func (e extension) Extend(md goldmark.Markdown) {
|
||||
md.Parser().AddOptions(parser.WithBlockParsers(
|
||||
util.Prioritized(blocks{}, 100)))
|
||||
md.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(fragments{partial: e.partial}, 100)))
|
||||
}
|
||||
|
||||
// kind identifies a parsed call in the tree.
|
||||
var kind = ast.NewNodeKind("Shortcode")
|
||||
|
||||
// node is one call: everything the renderer needs, and nothing from the source bytes.
|
||||
type node struct {
|
||||
ast.BaseBlock
|
||||
name string
|
||||
args map[string]string
|
||||
}
|
||||
|
||||
func (n *node) Kind() ast.NodeKind { return kind }
|
||||
|
||||
func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
|
||||
|
||||
// blocks parses a line that is nothing but a call.
|
||||
type blocks struct{}
|
||||
|
||||
func (blocks) Trigger() []byte { return []byte{'{'} }
|
||||
|
||||
func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
|
||||
line, seg := reader.PeekLine()
|
||||
name, args, ok := parse(string(line))
|
||||
if !ok {
|
||||
return nil, parser.NoChildren
|
||||
}
|
||||
reader.Advance(seg.Len() - 1)
|
||||
return &node{name: name, args: args}, parser.NoChildren
|
||||
}
|
||||
|
||||
// Continue never runs: a call is one line, closed as soon as it opens.
|
||||
func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State {
|
||||
return parser.Close
|
||||
}
|
||||
|
||||
func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
|
||||
|
||||
func (blocks) CanInterruptParagraph() bool { return true }
|
||||
|
||||
func (blocks) CanAcceptIndentedLine() bool { return false }
|
||||
|
||||
// fragments renders a parsed call through the theme.
|
||||
type fragments struct {
|
||||
partial render.Partial
|
||||
}
|
||||
|
||||
func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(kind, f.render)
|
||||
}
|
||||
|
||||
// render writes the theme's fragment for this call.
|
||||
//
|
||||
// A missing or broken template logs and renders nothing: a shortcode is content decoration, and one typo
|
||||
// in a bundle must not take a page down (extensions.md rule 5, ADR-0029).
|
||||
func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
call := n.(*node)
|
||||
out, err := f.partial(call.name, call.args)
|
||||
if err != nil {
|
||||
slog.Error("skipping shortcode", "name", call.name, "err", err)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if _, err := w.Write(out); err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// parse reads one line as a call, reporting false for anything else.
|
||||
//
|
||||
// The whole line must be the call, and every argument is key="value" — one spelling, so there is nothing
|
||||
// to guess and no half-parsed state. Values are returned raw; escaping is the template's job, which is
|
||||
// what keeps an author's text out of the markup (ADR-0036).
|
||||
func parse(line string) (name string, args map[string]string, ok bool) {
|
||||
body, found := strings.CutPrefix(strings.TrimSpace(line), opener)
|
||||
if !found {
|
||||
return "", nil, false
|
||||
}
|
||||
body, found = strings.CutSuffix(strings.TrimSpace(body), closer)
|
||||
if !found {
|
||||
return "", nil, false
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
name, rest, _ := strings.Cut(body, " ")
|
||||
if name == "" || strings.ContainsAny(name, `="`) {
|
||||
return "", nil, false
|
||||
}
|
||||
args = map[string]string{}
|
||||
for rest = strings.TrimSpace(rest); rest != ""; {
|
||||
key, value, remainder, valid := argument(rest)
|
||||
if !valid {
|
||||
return "", nil, false
|
||||
}
|
||||
args[key] = value
|
||||
rest = remainder
|
||||
}
|
||||
return name, args, true
|
||||
}
|
||||
|
||||
// argument reads one key="value" pair and returns what follows it.
|
||||
func argument(s string) (key, value, rest string, ok bool) {
|
||||
key, after, found := strings.Cut(s, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if !found || key == "" || strings.ContainsAny(key, `" `) {
|
||||
return "", "", "", false
|
||||
}
|
||||
quoted, found := strings.CutPrefix(after, `"`)
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
value, rest, found = strings.Cut(quoted, `"`)
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
return key, value, strings.TrimSpace(rest), true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package shortcodes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
|
||||
name, args, ok := parse(` {{< figure src="a.jpg" alt="A cat" >}} `)
|
||||
if !ok || name != "figure" {
|
||||
t.Fatalf("parse gave %q %v ok=%v", name, args, ok)
|
||||
}
|
||||
if args["src"] != "a.jpg" || args["alt"] != "A cat" {
|
||||
t.Errorf("args = %v", args)
|
||||
}
|
||||
if _, _, ok := parse(`{{< figure src="a.jpg" >}} and then prose`); ok {
|
||||
t.Error("a call must be the whole line, so trailing prose is not a call")
|
||||
}
|
||||
for _, line := range []string{
|
||||
"plain prose",
|
||||
"{{< figure", // unterminated
|
||||
`{{< src="a.jpg" >}}`, // no name
|
||||
`{{< figure src=a.jpg >}}`, // unquoted value
|
||||
`{{< figure src="unclosed >}}`, // unbalanced quote
|
||||
"{{<>}}", // empty
|
||||
} {
|
||||
if _, _, ok := parse(line); ok {
|
||||
t.Errorf("parse accepted %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wired builds a real Renderer wired to this extension, the way cmd does.
|
||||
func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer {
|
||||
t.Helper()
|
||||
var fsys fstest.MapFS
|
||||
if siteFS != nil {
|
||||
fsys = siteFS
|
||||
}
|
||||
r, err := render.New(fsys, func(p render.Partial) []goldmark.Extender {
|
||||
return []goldmark.Extender{New(p)}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func body(t *testing.T, r *render.Renderer, markdown string) string {
|
||||
t.Helper()
|
||||
b, err := content.Parse("posts/x.md", []byte("---\ntitle: X\n---\n"+markdown))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b, "en", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestFigureRendersThroughTheThemeFragment(t *testing.T) {
|
||||
got := body(t, wired(t, nil), "Before.\n\n{{< figure src=\"cat.jpg\" alt=\"A cat\" caption=\"Sleeping\" >}}\n\nAfter.\n")
|
||||
for _, want := range []string{
|
||||
"<figure>", `<img src="cat.jpg" alt="A cat">`, "<figcaption>Sleeping</figcaption>", "</figure>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "<p><figure>") || strings.Contains(got, "{{<") {
|
||||
t.Errorf("a call on its own line is a block, not paragraph text:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
|
||||
// The security property of ADR-0036, on a call that really parses: output is a template's, so hostile
|
||||
// argument text arrives as escaped data in whichever context it lands in.
|
||||
got := body(t, wired(t, nil), `{{< figure src="ok.jpg" alt="<script>alert(1)</script>" >}}`+"\n")
|
||||
if strings.Contains(got, "<script>") {
|
||||
t.Fatalf("an argument became markup:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
t.Errorf("the hostile alt text should survive as escaped text:\n%s", got)
|
||||
}
|
||||
|
||||
// A javascript: URL in an attribute the template uses as a URL is html/template's job, and getting it
|
||||
// for free is the reason a fragment renders this rather than the feature (ADR-0036).
|
||||
got = body(t, wired(t, nil), `{{< figure src="javascript:alert(1)" alt="x" >}}`+"\n")
|
||||
if strings.Contains(got, "javascript:alert(1)") {
|
||||
t.Errorf("a javascript: URL should not survive into src:\n%s", got)
|
||||
}
|
||||
|
||||
// A quote cannot even be expressed in an argument, so attribute breakout fails at the syntax before it
|
||||
// reaches escaping: the call is not a call, and the line stays prose.
|
||||
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, """) {
|
||||
t.Errorf("a malformed call must stay escaped text, not markup:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "raw HTML omitted") {
|
||||
t.Errorf("authored raw HTML must still be dropped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) {
|
||||
got := body(t, wired(t, nil), "{{< nosuchthing key=\"v\" >}}\n\nStill here.\n")
|
||||
if !strings.Contains(got, "Still here.") {
|
||||
t.Errorf("the rest of the page must survive:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "nosuchthing") {
|
||||
t.Errorf("a missing fragment renders nothing, not its own name:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASiteRedefinesOneFragment(t *testing.T) {
|
||||
site := fstest.MapFS{
|
||||
"templates/shortcodes.html": {Data: []byte(`{{define "figure"}}<div class="mine">{{.src}}</div>{{end}}`)},
|
||||
}
|
||||
got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n")
|
||||
if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) {
|
||||
t.Errorf("the site's fragment should win:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "<figure>") {
|
||||
t.Error("the embedded fragment should have been replaced, not appended")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user