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:
+57
-17
@@ -107,55 +107,95 @@ type Alternate struct {
|
||||
type Renderer struct {
|
||||
// Two sets, not one: base plus the block that kind of page defines. A single set would have two
|
||||
// definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019).
|
||||
page *template.Template
|
||||
list *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
page *template.Template
|
||||
list *template.Template
|
||||
// partials are named fragments a feature renders through, so no feature decides markup (ADR-0036).
|
||||
partials *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
}
|
||||
|
||||
// Partial renders a named fragment with the arguments a feature parsed. A feature under internal/ext is
|
||||
// handed one of these at wiring time, because markup belongs to the theme and a feature must not write any
|
||||
// (ADR-0036).
|
||||
//
|
||||
// Arguments are strings because that is what a shortcode call carries. A feature needing richer data is
|
||||
// the reason to widen this, not a reason to have made it `any` in advance.
|
||||
type Partial func(name string, args map[string]string) ([]byte, error)
|
||||
|
||||
// New parses the theme and prepares the Markdown converter.
|
||||
//
|
||||
// siteFS may be nil, in which case only the embedded reference theme is used. A malformed template is a
|
||||
// startup failure rather than a request-time one, so this returns an error the caller treats as fatal.
|
||||
func New(siteFS fs.FS) (*Renderer, error) {
|
||||
page, err := parseSet(siteFS, "templates/page.html")
|
||||
//
|
||||
// extend is the seam features plug into: it receives the renderer's Partial and returns the Markdown
|
||||
// extensions to enable. A callback rather than a parameter of feature types, because internal/render must
|
||||
// not import internal/ext — only cmd knows which features a build includes (conventions.md, ADR-0036). It
|
||||
// may be nil.
|
||||
func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
|
||||
page, err := parseSet(siteFS, "templates/base.html", "templates/page.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bundle templates: %w", err)
|
||||
}
|
||||
list, err := parseSet(siteFS, "templates/list.html")
|
||||
list, err := parseSet(siteFS, "templates/base.html", "templates/list.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing templates: %w", err)
|
||||
}
|
||||
partials, err := parseSet(siteFS, "templates/shortcodes.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("partial templates: %w", err)
|
||||
}
|
||||
css, err := readStyle(siteFS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &Renderer{page: page, list: list, partials: partials, style: css}
|
||||
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
|
||||
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
|
||||
// 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.
|
||||
md := goldmark.New(goldmark.WithExtensions(extension.Typographer))
|
||||
return &Renderer{page: page, list: list, md: md, style: css}, nil
|
||||
//
|
||||
// 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.
|
||||
extensions := []goldmark.Extender{extension.Typographer}
|
||||
if extend != nil {
|
||||
extensions = append(extensions, extend(r.Partial)...)
|
||||
}
|
||||
r.md = goldmark.New(goldmark.WithExtensions(extensions...))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// parseSet builds one kind of page: the embedded base and block, then the site's versions of exactly
|
||||
// those two files parsed after them.
|
||||
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
|
||||
// failed request (extensions.md rule 5).
|
||||
func (r *Renderer) Partial(name string, args map[string]string) ([]byte, error) {
|
||||
if r.partials.Lookup(name) == nil {
|
||||
return nil, fmt.Errorf("no template named %q", name)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := r.partials.ExecuteTemplate(&out, name, args); err != nil {
|
||||
return nil, fmt.Errorf("partial %s: %w", name, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseSet builds one set from the named embedded templates, then the site's versions of exactly those
|
||||
// files parsed after them.
|
||||
//
|
||||
// Parse order is the whole mechanism — the last definition of a name wins — so a site redefines one
|
||||
// named block and inherits the rest (ADR-0019). Only the same two names are overlaid: overlaying every
|
||||
// site template into every set would let a listing's "main" leak into bundle pages, which is the
|
||||
// collision per-kind sets exist to prevent.
|
||||
func parseSet(siteFS fs.FS, kind string) (*template.Template, error) {
|
||||
// named block and inherits the rest (ADR-0019). Only the files this set is built from are overlaid:
|
||||
// overlaying every site template into every set would let a listing's "main" leak into bundle pages,
|
||||
// which is the collision per-kind sets exist to prevent.
|
||||
func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) {
|
||||
// Funcs are attached before anything is parsed, so the chrome helpers are available to a site
|
||||
// override's blocks as well as the embedded ones (ADR-0034).
|
||||
set, err := template.New("theme").Funcs(funcs).ParseFS(themeFS, "templates/base.html", kind)
|
||||
set, err := template.New("theme").Funcs(funcs).ParseFS(themeFS, names...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse embedded: %w", err)
|
||||
}
|
||||
if siteFS == nil {
|
||||
return set, nil
|
||||
}
|
||||
for _, name := range []string{"templates/base.html", kind} {
|
||||
for _, name := range names {
|
||||
if _, err := fs.Stat(siteFS, name); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user