publish a bundle's extras as a browsable tree

Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.

Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.

Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.

Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.

Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
This commit is contained in:
Claude Opus 5
2026-07-31 13:18:34 +06:00
committed by bdeshi
parent f3e54b1849
commit 669a94a26a
16 changed files with 685 additions and 136 deletions
+7 -5
View File
@@ -17,11 +17,13 @@ import (
// A phrase may carry %s placeholders, filled in order by the caller. Templates reach these through the
// `t`, `num` and `day` functions; nothing here is content, and content never comes from here.
var chrome = map[string]map[string]string{
"newer": {"en": "Newer", "bn": "নতুন"},
"older": {"en": "Older", "bn": "পুরোনো"},
"empty": {"en": "Nothing here yet.", "bn": "এখনও কিছু নেই।"},
"page-of": {"en": "Page %s of %s", "bn": "পৃষ্ঠা %s / %s"},
"position": {"en": "%s of %s", "bn": "%s / %s"},
"newer": {"en": "Newer", "bn": "নতুন"},
"older": {"en": "Older", "bn": "পুরোনো"},
"empty": {"en": "Nothing here yet.", "bn": "এখনও কিছু নেই।"},
"page-of": {"en": "Page %s of %s", "bn": "পৃষ্ঠা %s / %s"},
"position": {"en": "%s of %s", "bn": "%s / %s"},
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
}
// months are Gregorian month names per language, indexed by [time.Month]-1.
+54 -91
View File
@@ -12,7 +12,6 @@ import (
"html/template"
"io/fs"
"path"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
@@ -24,91 +23,6 @@ import (
//go:embed templates
var themeFS embed.FS
// head is what every kind of page shares: the document shell the base template needs. Absence is the
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
type head struct {
// Title may be empty for a bundle; a listing always has one.
Title string
// Lang is the locale being served.
Lang string
// Canonical is the permalink of what was actually served, which differs from the URL requested when
// the fallback chain supplied another language (ADR-0009).
Canonical string
// Alternates lists every language this key exists in, for hreflang.
Alternates []Alternate
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
Style template.CSS
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
Site content.Settings
}
// Page is one bundle rendered.
type Page struct {
head
// Key is the bundle's identity, useful for building links.
Key string
// HTML is the rendered body.
HTML template.HTML
// Extra carries every frontmatter key the parser does not name (ADR-0002).
Extra map[string]any
// Sequence is the series this page sits in, nil when it sits in none.
Sequence *Sequence
}
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
// (ADR-0033).
type Sequence struct {
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
Title, URL string
// Members are every entry in reading order — ascending, unlike a dated listing.
Members []Item
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
// how many members there are.
Index, Count int
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
// the *earlier* entry, the opposite sense of a listing's PrevURL.
Prev, Next *Item
// First and Last are the ends of the series, set whenever it has members.
First, Last *Item
}
// List is a collection page: the result of a Query, one page of it.
type List struct {
head
// Items are the entries on this page, in the Query's order.
Items []Item
// Page is 1-based; Pages is the total, at least 1 even when empty.
Page, Pages int
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
PrevURL, NextURL string
// Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
// are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
Groups []Group
}
// Group is a named run of entries within a listing.
type Group struct {
Name string
Items []Item
}
// Item is one entry in a listing.
type Item struct {
Title string
Key string
URL string
Date time.Time
// Section is the entry's top-level section, so a flat listing can label where an entry came from without
// the engine deciding that it must be grouped (ADR-0046).
Section string
}
// Alternate is one language a bundle exists in.
type Alternate struct {
Lang string
URL string
}
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
// per request (conventions.md).
type Renderer struct {
@@ -118,8 +32,10 @@ type Renderer struct {
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
// extras is the set for a bundle's supporting-file listing.
extras *template.Template
md goldmark.Markdown
style template.CSS
// files is the site root, handed to features through Origin. Nil when there is none.
files fs.FS
// settings are the site's declarations, constant for the life of the process.
@@ -209,12 +125,16 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
if err != nil {
return nil, fmt.Errorf("partial templates: %w", err)
}
extras, err := parseSet(siteFS, "templates/base.html", "templates/extras.html")
if err != nil {
return nil, fmt.Errorf("extras templates: %w", err)
}
css, err := readStyle(siteFS)
if err != nil {
return nil, err
}
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings,
siteFS: siteFS, extend: extend}
r := &Renderer{page: page, list: list, partials: partials, extras: extras, style: css, files: siteFS,
settings: settings, siteFS: siteFS, extend: extend}
// 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
@@ -263,7 +183,8 @@ func (r *Renderer) fresh() error {
if err != nil {
return err
}
r.page, r.list, r.partials, r.style, r.md = next.page, next.list, next.partials, next.style, next.md
r.page, r.list, r.partials, r.extras = next.page, next.list, next.partials, next.extras
r.style, r.md = next.style, next.md
return nil
}
@@ -322,6 +243,48 @@ func readStyle(siteFS fs.FS) (template.CSS, error) {
return template.CSS(data), nil
}
// Extras renders a bundle's supporting files, with one entry selected or none.
//
// The engine enumerates, classifies and renders what it can; how a tree and a selected file look is the theme's
// (ADR-0046). A file it cannot render still arrives with a RawURL, because "cannot show it inline" is not
// "cannot offer it".
func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) {
if err := r.fresh(); err != nil {
return nil, err
}
title := b.Title
if title == "" {
title = b.Key
}
x := Extras{
head: r.head(title, served, content.ExtrasURL(b.Route, served, "")),
Bundle: r.item(b, served),
Entries: entries,
}
if selected != nil {
x.Selected = selected
x.head.Canonical = r.absolute(content.ExtrasURL(b.Route, served, selected.Path))
}
return r.execute(r.extras, x, b.Key+"/"+content.ExtrasDir)
}
// RenderText converts a markdown or plain-text file for display inside an extras listing.
//
// Markdown goes through the same converter as a body, so an included note reads the way the author wrote it.
// Anything else is shown as preformatted text, escaped — a log file is not markup.
func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error) {
if kind == "markdown" {
var out bytes.Buffer
if err := r.md.Convert(data, &out); err != nil {
return "", fmt.Errorf("markdown: %w", err)
}
return template.HTML(out.String()), nil
}
var escaped bytes.Buffer
template.HTMLEscape(&escaped, data)
return template.HTML("<pre>" + escaped.String() + "</pre>"), nil
}
// Bundle renders one bundle into a complete page.
//
// served is the language actually chosen by the fallback chain, and variants every language the key
+27
View File
@@ -0,0 +1,27 @@
{{define "main" -}}
<article>
<h1>{{if .Bundle.Title}}{{.Bundle.Title}}{{else}}{{.Bundle.Key}}{{end}} · {{t .Lang "extras"}}</h1>
<p><a href="{{.Bundle.URL}}">{{t .Lang "back-to-page"}}</a></p>
{{- if .Entries}}
<ul class="extras">
{{- range .Entries}}
<li>{{if .IsDir}}{{.Name}}/{{else}}<a href="{{.Path}}">{{.Name}}</a> <span class="kind">{{.Kind}}</span>{{end}}</li>
{{- end}}
</ul>
{{- else}}
<p>{{t .Lang "empty"}}</p>
{{- end}}
{{- with .Selected}}
<section class="selected">
<h2>{{.Name}}</h2>
{{- if .HTML}}
{{.HTML}}
{{- else if eq .Kind "image"}}
<img src="{{.RawURL}}" alt="{{.Name}}">
{{- else}}
<p><a href="{{.RawURL}}">{{.Name}}</a></p>
{{- end}}
</section>
{{- end}}
</article>
{{- end}}
+117
View File
@@ -0,0 +1,117 @@
// The types in this file are the theme contract in Go: what a template receives, nothing about how it is
// produced. Split from render.go when that file crossed the size warning — the seam was already here, since
// docs/theme-contract.md describes exactly this and nothing else.
package render
import (
"html/template"
"time"
"khosra/internal/content"
)
// head is what every kind of page shares: the document shell the base template needs. Absence is the
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
type head struct {
// Title may be empty for a bundle; a listing always has one.
Title string
// Lang is the locale being served.
Lang string
// Canonical is the permalink of what was actually served, which differs from the URL requested when
// the fallback chain supplied another language (ADR-0009).
Canonical string
// Alternates lists every language this key exists in, for hreflang.
Alternates []Alternate
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
Style template.CSS
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
Site content.Settings
}
// Page is one bundle rendered.
type Page struct {
head
// Key is the bundle's identity, useful for building links.
Key string
// HTML is the rendered body.
HTML template.HTML
// Extra carries every frontmatter key the parser does not name (ADR-0002).
Extra map[string]any
// Sequence is the series this page sits in, nil when it sits in none.
Sequence *Sequence
}
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
// (ADR-0033).
type Sequence struct {
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
Title, URL string
// Members are every entry in reading order — ascending, unlike a dated listing.
Members []Item
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
// how many members there are.
Index, Count int
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
// the *earlier* entry, the opposite sense of a listing's PrevURL.
Prev, Next *Item
// First and Last are the ends of the series, set whenever it has members.
First, Last *Item
}
// Extras is a bundle's supporting files, browsable (ADR-0047).
type Extras struct {
head
// Bundle is the page these files belong to, so a theme has its title and a way back.
Bundle Item
// Entries is the whole tree, sorted by path.
Entries []content.Entry
// Selected is the entry the URL named, nil on the bare listing.
Selected *Selected
}
// Selected is the entry a request picked out of the tree.
type Selected struct {
content.Entry
// HTML is the rendered file for a markdown or text entry, empty for anything else.
HTML template.HTML
// RawURL always works: it is how a theme embeds an image, links a PDF, or offers a download.
RawURL string
}
// List is a collection page: the result of a Query, one page of it.
type List struct {
head
// Items are the entries on this page, in the Query's order.
Items []Item
// Page is 1-based; Pages is the total, at least 1 even when empty.
Page, Pages int
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
PrevURL, NextURL string
// Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
// are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
Groups []Group
}
// Group is a named run of entries within a listing.
type Group struct {
Name string
Items []Item
}
// Item is one entry in a listing.
type Item struct {
Title string
Key string
URL string
Date time.Time
// Section is the entry's top-level section, so a flat listing can label where an entry came from without
// the engine deciding that it must be grouped (ADR-0046).
Section string
}
// Alternate is one language a bundle exists in.
type Alternate struct {
Lang string
URL string
}