give a page the assets its own content asked for, and write down the rule
Two decisions and one mechanism. The human wants demos, games and runnable
embeds to carry real CSS and JS while every ordinary page stays scriptless, and
wants adding an asset to be theme work rather than a rebuild.
The mechanism reuses what already had that property. A theme defines
`assets:<name>` beside its other fragments; shortcodes record their own name as
they are opened; after conversion the engine renders each matching fragment once
into Page.Assets. So a gallery calling one shortcode forty times carries its
stylesheet once, and a page that called nothing carries nothing. Frontmatter
`use:` reaches the same fragments without a call.
Considered and rejected: templates/assets.yaml, which reads more declaratively
and buys a parser, a contract shape and a rebuild for conditional markup; and a
table in Go mapping shortcode to files, which would hardcode exactly what was
deliberately made data-driven.
Collection is parse-phase, so no transform counter moves — goldmark's extender
list is already the ordered pipeline for parse work, which state.md's counter
says in its "does not count" column.
Separately, styles/scripts are lifted at last. They sat in content-model.md's
table unread, and the theme contract listed them under "what the engine
provides", which was aspirational rather than true. Both are bundle-relative: a
name with .. or a leading / is dropped and logged, the refusal ::include and a
code block's file= already make. The engine builds the URLs because a theme must
not construct an address.
ADR-0080 writes the antifeature list down, with its single exception inside it.
An antifeature nobody recorded does not bind anything, and each of these dies to
one reasonable-looking request at a time. The exception is author-invoked and
cannot fire by accident.
The reference theme emits the stylesheets and no script element at all. That was
the human's correction to a first attempt which had page.html emitting the tag
and verify.sh narrowed to permit it — narrowing the gate to fit the code was
backwards, and the narrowing was also wrong, passing a probe with a hardcoded src
because it filtered whole lines and every line carries {{define}}. verify.sh is
untouched. examples/demo-site redefines the head block instead, so the JavaScript
half is demonstrated by a site rather than built into the binary, which is a
better demonstration and a stronger property.
Evidence, against the demo site with a freshly built binary: the sandbox page
carries its own css and js at bundle-relative URLs; colophon calls ::tally twice
and carries tally.css once with zero scripts; about calls it never and carries
neither; listings unaffected. Plus a table test for the escape refusal, which
until now had only the running server behind it.
19 files, +355/-86. No counter moves. Demo is 31 bundles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,14 @@ var exampleFeatures = []featureCase{
|
||||
absent: []string{"<script", `style="color:`}},
|
||||
{what: "grouped panels are tabs, and need no script", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{`<details name="run" open>`, "<summary>Serve your own</summary>"}, absent: []string{"<script"}},
|
||||
{what: "a shortcode's assets arrive once however many times it is called", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{`<link rel="stylesheet" href="/static/tally.css">`, `class="tally"`}},
|
||||
{what: "a page that calls no such shortcode carries none of its assets", path: "/pages/about/", code: 200,
|
||||
absent: []string{"tally.css", "<script"}},
|
||||
{what: "a bundle's own assets are emitted by the site's head override, resolved against the bundle", path: "/pages/sandbox/", code: 200,
|
||||
expect: []string{`href="/pages/sandbox/sandbox.css"`, `src="/pages/sandbox/sandbox.js" defer`}},
|
||||
{what: "the script exception reaches only the page that asked — every other page stays scriptless", path: "/pages/colophon/", code: 200,
|
||||
absent: []string{"<script"}},
|
||||
{what: "a task list renders disabled checkboxes and nothing interactive", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{`<input checked="" disabled="" type="checkbox"`, `<input disabled="" type="checkbox"`},
|
||||
absent: []string{"<script"}},
|
||||
|
||||
@@ -93,7 +93,8 @@ readable by templates (ADR-0002). Never add a required field.
|
||||
| `order` | int | Position within the series this bundle is nested in (ADR-0033). Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016). Absent, the bundle orders by name, after every sibling carrying one |
|
||||
| `cover` | string | Relative path to the lead image |
|
||||
| `view` | string | Per-bundle View override (Arc 2) |
|
||||
| `styles` / `scripts` | []string | Page-specific assets, relative to the bundle |
|
||||
| `styles` / `scripts` | []string | This bundle's own CSS and JS, living beside the body and served under its URL. A scalar or a list. **Bundle-relative only**: a name containing `..` or starting with `/` is dropped and logged, so a page cannot ship a template or reach another bundle's files (ADR-0079). Scripts are emitted with `defer` |
|
||||
| `use` | []string | Theme assets to pull in without calling the shortcode that would (ADR-0079). Names the theme resolves through its `assets:<name>` fragments, never file paths — a name the theme does not define contributes nothing |
|
||||
| `lang` | string | Explicit language when the filename cannot carry it |
|
||||
| `include` | string | `embed` makes each `::include` file its own document, with its footnote ids namespaced and its notes rendered where it sits. Absent — the default — splices the files in before parsing, so the page is one document: one footnote list at the end, abbreviations reaching every part, every heading in the contents list (ADR-0066, ADR-0076) |
|
||||
|
||||
|
||||
@@ -1324,3 +1324,57 @@ checkbox is `disabled`, so a reader cannot tick it and nothing is stored; a them
|
||||
read as prose unbullets it in CSS, which the reference theme now does.
|
||||
Revisit if: authors start using task lists for working notes inside published bundles, in which case the
|
||||
answer is `extras/`, which already renders Markdown and is excluded from every listing — not a change here.
|
||||
|
||||
## ADR-0079 — A page carries the assets its own content asked for, named by the theme
|
||||
Date: 2026-08-02 · Status: accepted
|
||||
Decision: a page's CSS and JS come from two places and nowhere else. The theme defines `assets:<name>`
|
||||
fragments beside its other fragments; the engine records which shortcodes a conversion actually called,
|
||||
adds the bundle's `use:` list, and renders each matching fragment **once** into `Page.Assets` for the
|
||||
theme's `head` block. Separately, a bundle names its own files in `styles:` and `scripts:`, which are
|
||||
bundle-relative — a name containing `..` or starting with `/` is dropped and logged, the refusal
|
||||
`::include` and a code block's `file=` already make (ADR-0038). The engine builds those URLs, because a
|
||||
theme must not construct an address.
|
||||
Why: the human wants demos, games and runnable embeds to carry real assets while ordinary pages stay
|
||||
scriptless, and wants adding an asset to be theme work rather than a rebuild. Naming the fragment
|
||||
`assets:<name>` reuses the mechanism that already gives that property to shortcodes, so no new file
|
||||
format, no manifest parser, and no table in Go mapping a shortcode to the files it wants — which would
|
||||
have hardcoded exactly what was deliberately made data-driven. The alternative considered was
|
||||
`templates/assets.yaml`; it reads more declaratively and buys a parser, a contract shape and a rebuild
|
||||
for conditional markup.
|
||||
Collection is parse-phase: shortcodes record their own name as they are opened, so this moves no render
|
||||
transform counter — goldmark's extender list is already the ordered pipeline for parse work
|
||||
(`state.md` counters). Deduplication is first-call order, so a gallery calling one shortcode forty times
|
||||
carries its stylesheet once.
|
||||
Consequence: `base.html` gains an empty `head` block that only `page.html` fills, because a listing has no
|
||||
such field to read. A theme that defines no `assets:` fragment behaves exactly as before, and the
|
||||
reference theme still ships none — ADR-0063's "no assets" holds for what the engine embeds, and this is a
|
||||
mechanism for a theme rather than a decision to use it. The reference theme emits `.Assets` and `.Styles`
|
||||
but **not** `.Scripts`: it contains no `<script>` in any form, which `verify.sh` already enforced before
|
||||
this change and still does unaltered. A theme opts into the tag by redefining `head`, and
|
||||
`examples/demo-site` does exactly that — so the exception is demonstrated by a site rather than built
|
||||
into the binary.
|
||||
Revisit if: two shortcodes ever need assets emitted in a guaranteed order relative to each other, which
|
||||
first-call order does not promise.
|
||||
|
||||
## ADR-0080 — The antifeature list is a decision, and it has exactly one exception
|
||||
Date: 2026-08-02 · Status: accepted
|
||||
Decision: the following are refused on ordinary pages, permanently and by choice, not by omission —
|
||||
client-side rendering where the server can do it, custom cursors, forced scroll smoothing or inertia,
|
||||
site-wide link hover-preview popups, autoplay media, parallax and scroll-hijacking, infinite scroll and
|
||||
auto-loading pagination, modal and exit-intent popups, third-party embeds that load trackers to show a
|
||||
static preview, CAPTCHA and heavy anti-bot friction for readers, and disabling native browser behaviour
|
||||
(text selection, right-click, pinch-zoom). No tracking scripts, therefore no cookie-consent banner.
|
||||
The **one exception** is author-invoked, page-scoped assets (ADR-0079): a bundle may carry CSS and JS for
|
||||
a demo, a game, an app or a runnable embed. It is an exception because it cannot happen by accident — a
|
||||
page gets a script only when its own frontmatter or its own shortcode call asks, the reference theme
|
||||
defines no `assets:` fragment, and a page that asks for nothing emits nothing.
|
||||
Why: an antifeature that is not written down does not bind anything. Each of these dies to one
|
||||
reasonable-looking request at a time, and the value of the list is precisely that it is decided in
|
||||
advance rather than argued case by case. Writing the exception into the same decision is the point:
|
||||
an unrecorded exception is how the rest of a list stops being believed.
|
||||
Consequence: a request for any of the above is a conflict to surface (`CLAUDE.md` rule 9), not a feature
|
||||
to plan. Spam handling is honeypot fields and rate limiting rather than CAPTCHA, which constrains the
|
||||
Arc 3 comment path. Hover-preview *footnotes* are not the popups this refuses — a footnote is the
|
||||
author's own text, and `:::aside` already renders margin notes server-side with no script.
|
||||
Revisit if: a reader-facing need arises that genuinely cannot be met server-side. "Would be nicer with
|
||||
JavaScript" is not that, and never has been for any item on this list.
|
||||
|
||||
+6
-3
@@ -22,7 +22,7 @@ table owns.
|
||||
| `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. A Renderer never changes after `New`: a rebuild builds a new one and it is swapped with the index as a single `web.Snapshot`, so no page is assembled from two of them (ADR-0055, ADR-0056, ADR-0077). Heading ids are a parser option set here, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). `Compose` is the seam a merging bundle's splice arrives through |
|
||||
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` — ADR-0064, ADR-0065, ADR-0067), `Heading`, `Picture`, `Origin` |
|
||||
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page` (with `Assets`, `Styles` and `Scripts` — the assets a page's own calls and `use:` asked for, ADR-0079), `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` — ADR-0064, ADR-0065, ADR-0067), `Heading`, `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), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
|
||||
| `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes/` — seven fragment files rather than one, and a site may use either form (ADR-0071) — with the `sizes` its own layout implies (ADR-0068), `theme.css` (ADR-0026, ADR-0049) |
|
||||
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection, bounded and least-recently-used in `memo.go` (ADR-0042, ADR-0044, ADR-0073). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), `containers.go`: `:::name{…}` … `:::` wrapping a rendered body (ADR-0064), `toc.go`: the document's headings for a `::toc` call, to a depth the call may set (ADR-0065, ADR-0066), and `code.go`: chroma highlighting, a fence's options, and snippets read from a file (ADR-0075). `Merge` splices includes before the parse for a bundle that asks for it (ADR-0066) |
|
||||
@@ -42,6 +42,8 @@ table owns.
|
||||
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
|
||||
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end — that last one in `cmd/khosra`, beside the wiring it proves (ADR-0072) |
|
||||
|
||||
A page carries only the CSS and JS its own shortcode calls or its `use:` list asked for, rendered once each from the theme's `assets:<name>` fragments, plus its own `styles`/`scripts` files — bundle-relative, anything climbing out dropped (ADR-0079, ADR-0080). The reference theme emits the stylesheets and **no `<script>` at all**, which `verify.sh` enforces; `examples/demo-site` redefines the `head` block to add the tag, so the one JavaScript exception is demonstrated by a site rather than built into the binary.
|
||||
|
||||
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
|
||||
key (ADR-0035) — a paginated listing per section, tag listings global and
|
||||
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
|
||||
@@ -60,11 +62,12 @@ polls four times a second (ADR-0056).
|
||||
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
|
||||
URLs go absolute (ADR-0039).
|
||||
|
||||
Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`, `draft`. `include: embed` is read from `Extra` by the renderer; merging is the default (ADR-0066, ADR-0076). Every other key in
|
||||
Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`, `draft`, `styles`,
|
||||
`scripts`, `use`. `include: embed` is read from `Extra` by the renderer; merging is the default (ADR-0066, ADR-0076). Every other key in
|
||||
`content-model.md`'s table — including `type` — lands in `Extra` unread, so that table
|
||||
is the accepted format, not a list of what runs.
|
||||
|
||||
`examples/demo-site/` is a complete site kept in the repository to be read and served — 30 bundles across six
|
||||
`examples/demo-site/` is a complete site kept in the repository to be read and served — 31 bundles across six
|
||||
sections, one case per feature in `TestTheExampleSiteExercisesEveryFeature`, and `khosra check` run over it by
|
||||
`verify.sh` (ADR-0051). `make demo` serves it. Features are spread across the bundles where a real site would
|
||||
put them rather than piled on one page: text marks and the margin note on `writing/typography`, icons and
|
||||
|
||||
+85
-80
@@ -6,7 +6,7 @@ Every top-level declaration in the engine, with its line. Read this before openi
|
||||
file: it answers "where does X live" and "what is in this package" without the bodies. What each
|
||||
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
|
||||
|
||||
## cmd/khosra — 308 lines + 295 test
|
||||
## cmd/khosra — 308 lines + 303 test
|
||||
|
||||
check.go 45 · main.go 177 · new.go 42 · wire.go 44
|
||||
|
||||
@@ -22,41 +22,42 @@ check.go 45 · main.go 177 · new.go 42 · wire.go 44
|
||||
- wire.go:18 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:31 func extenders(partial render.Partial) []goldmark.Extender
|
||||
|
||||
## internal/content — 1044 lines + 558 test
|
||||
## internal/content — 1074 lines + 598 test
|
||||
|
||||
clock.go 12 · content.go 451 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
clock.go 12 · content.go 481 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
|
||||
- clock.go:9 var now = time.Now
|
||||
- clock.go:12 func Now() time.Time { return now() }
|
||||
- content.go:20 const DefaultLang = "en"
|
||||
- content.go:27 type Bundle struct
|
||||
- content.go:70 func OpenSite(dir string) (fs.FS, error)
|
||||
- content.go:82 type Problem struct
|
||||
- content.go:94 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:103 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:142 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:183 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:192 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:202 func stringList(v any) []string
|
||||
- content.go:224 func asTime(v any) time.Time
|
||||
- content.go:240 func asInt(v any) int
|
||||
- content.go:253 func terms(v any) []string
|
||||
- content.go:277 func TagSlug(tag string) string
|
||||
- content.go:286 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:292 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:314 func isLangTag(s string) bool
|
||||
- content.go:332 func isPartial(base string) bool
|
||||
- content.go:344 func skipDir(base string) bool
|
||||
- content.go:352 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:367 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:384 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:406 const PerPage = 10
|
||||
- content.go:412 func URL(key, lang string) string
|
||||
- content.go:425 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:435 const DerivedPrefix = "/derived/"
|
||||
- content.go:438 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:441 const TagsSegment = "tags"
|
||||
- content.go:445 func PageURL(key, lang string, page int) string
|
||||
- content.go:77 func OpenSite(dir string) (fs.FS, error)
|
||||
- content.go:89 type Problem struct
|
||||
- content.go:101 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:110 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:149 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:196 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:205 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:215 func stringList(v any) []string
|
||||
- content.go:237 func asTime(v any) time.Time
|
||||
- content.go:253 func asInt(v any) int
|
||||
- content.go:271 func bundleFiles(v any, where string) []string
|
||||
- content.go:283 func terms(v any) []string
|
||||
- content.go:307 func TagSlug(tag string) string
|
||||
- content.go:316 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:322 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:344 func isLangTag(s string) bool
|
||||
- content.go:362 func isPartial(base string) bool
|
||||
- content.go:374 func skipDir(base string) bool
|
||||
- content.go:382 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:397 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:414 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:436 const PerPage = 10
|
||||
- content.go:442 func URL(key, lang string) string
|
||||
- content.go:455 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:465 const DerivedPrefix = "/derived/"
|
||||
- content.go:468 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:471 const TagsSegment = "tags"
|
||||
- content.go:475 func PageURL(key, lang string, page int) string
|
||||
- extras.go:14 const ExtrasDir = "extras"
|
||||
- extras.go:17 type Entry struct
|
||||
- extras.go:33 func Extras(fsys fs.FS, b Bundle) []Entry
|
||||
@@ -175,9 +176,9 @@ 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 — 1330 lines + 842 test
|
||||
## internal/ext/shortcodes — 1333 lines + 842 test
|
||||
|
||||
code.go 211 · containers.go 192 · doc.go 7 · icons.go 129 · images.go 241 · memo.go 73 · shortcodes.go 382 · toc.go 95
|
||||
code.go 211 · containers.go 192 · doc.go 7 · icons.go 129 · images.go 241 · memo.go 73 · shortcodes.go 385 · toc.go 95
|
||||
|
||||
- code.go:27 const codeFragment = "code"
|
||||
- code.go:30 var codeKind = ast.NewNodeKind("ShortcodeCode")
|
||||
@@ -257,16 +258,16 @@ code.go 211 · containers.go 192 · doc.go 7 · icons.go 129 · images.go 241 ·
|
||||
- shortcodes.go:210 type blocks struct{}
|
||||
- shortcodes.go:212 func (blocks) Trigger() []byte { return []byte{' '} }
|
||||
- shortcodes.go:214 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
|
||||
- shortcodes.go:248 func gallery(pc parser.Context) []render.Picture
|
||||
- shortcodes.go:275 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
|
||||
- shortcodes.go:279 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
|
||||
- shortcodes.go:281 func (blocks) CanInterruptParagraph() bool { return true }
|
||||
- shortcodes.go:283 func (blocks) CanAcceptIndentedLine() bool { return false }
|
||||
- shortcodes.go:286 type fragments struct
|
||||
- shortcodes.go:290 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
|
||||
- shortcodes.go:301 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
|
||||
- shortcodes.go:329 func parse(line, prefix string) (name string, args map[string]string, ok bool)
|
||||
- shortcodes.go:364 func argument(s string) (key, value, rest string, ok bool)
|
||||
- shortcodes.go:251 func gallery(pc parser.Context) []render.Picture
|
||||
- shortcodes.go:278 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
|
||||
- shortcodes.go:282 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
|
||||
- shortcodes.go:284 func (blocks) CanInterruptParagraph() bool { return true }
|
||||
- shortcodes.go:286 func (blocks) CanAcceptIndentedLine() bool { return false }
|
||||
- shortcodes.go:289 type fragments struct
|
||||
- shortcodes.go:293 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
|
||||
- shortcodes.go:304 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
|
||||
- shortcodes.go:332 func parse(line, prefix string) (name string, args map[string]string, ok bool)
|
||||
- shortcodes.go:367 func argument(s string) (key, value, rest string, ok bool)
|
||||
- toc.go:16 const tocName = "toc"
|
||||
- toc.go:23 type tables struct{}
|
||||
- toc.go:25 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
|
||||
@@ -285,9 +286,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 — 733 lines + 441 test
|
||||
## internal/render — 806 lines + 441 test
|
||||
|
||||
chrome.go 115 · render.go 433 · view.go 185
|
||||
chrome.go 115 · render.go 499 · view.go 192
|
||||
|
||||
- chrome.go:19 var chrome = map[string]map[string]string{
|
||||
- chrome.go:38 var months = map[string][]string{
|
||||
@@ -297,44 +298,48 @@ chrome.go 115 · render.go 433 · view.go 185
|
||||
- chrome.go:78 func numerals(lang string, n int) string
|
||||
- chrome.go:87 func day(lang string, t time.Time) string
|
||||
- chrome.go:103 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:61 var originKey = parser.NewContextKey()
|
||||
- render.go:64 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||
- render.go:71 func WithOrigin(pc parser.Context, origin Origin)
|
||||
- render.go:84 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||
- render.go:114 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||
- render.go:142 func (r *Renderer) head(title, lang, canonical string) head
|
||||
- render.go:159 func (r *Renderer) absolute(path string) string
|
||||
- render.go:167 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
- render.go:173 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
- render.go:177 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||
- render.go:196 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||
- render.go:225 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||
- render.go:243 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||
- render.go:264 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||
- render.go:282 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||
- render.go:327 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:346 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:373 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
||||
- render.go:400 func (r *Renderer) item(b content.Bundle, lang string) Item
|
||||
- render.go:405 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
||||
- render.go:427 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
|
||||
- render.go:27 var themeFS embed.FS
|
||||
- render.go:31 type Renderer struct
|
||||
- render.go:50 type parsedTheme struct
|
||||
- render.go:63 var originKey = parser.NewContextKey()
|
||||
- render.go:66 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||
- render.go:73 func WithOrigin(pc parser.Context, origin Origin)
|
||||
- render.go:79 var callsKey = parser.NewContextKey()
|
||||
- render.go:90 func RecordCall(pc parser.Context, name string)
|
||||
- render.go:102 func callsFrom(pc parser.Context) []string
|
||||
- render.go:118 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||
- render.go:148 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||
- render.go:176 func (r *Renderer) head(title, lang, canonical string) head
|
||||
- render.go:193 func (r *Renderer) absolute(path string) string
|
||||
- render.go:201 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
- render.go:207 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
- render.go:211 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||
- render.go:229 func (r *Renderer) assets(names []string) template.HTML
|
||||
- render.go:253 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||
- render.go:282 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||
- render.go:300 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||
- render.go:321 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||
- render.go:339 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||
- render.go:393 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:412 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:439 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
||||
- render.go:466 func (r *Renderer) item(b content.Bundle, lang string) Item
|
||||
- render.go:471 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
||||
- render.go:493 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
|
||||
- view.go:17 type head struct
|
||||
- view.go:37 type Page struct
|
||||
- view.go:56 type Sequence struct
|
||||
- view.go:72 type Extras struct
|
||||
- view.go:83 type Selected struct
|
||||
- view.go:92 type List struct
|
||||
- view.go:106 type Group struct
|
||||
- view.go:112 type Item struct
|
||||
- view.go:123 type Alternate struct
|
||||
- view.go:135 type Partial func(name string, data Fragment) ([]byte, error)
|
||||
- view.go:138 type Fragment struct
|
||||
- view.go:154 type Heading struct
|
||||
- view.go:160 type Picture struct
|
||||
- view.go:177 type Origin struct
|
||||
- view.go:63 type Sequence struct
|
||||
- view.go:79 type Extras struct
|
||||
- view.go:90 type Selected struct
|
||||
- view.go:99 type List struct
|
||||
- view.go:113 type Group struct
|
||||
- view.go:119 type Item struct
|
||||
- view.go:130 type Alternate struct
|
||||
- view.go:142 type Partial func(name string, data Fragment) ([]byte, error)
|
||||
- view.go:145 type Fragment struct
|
||||
- view.go:161 type Heading struct
|
||||
- view.go:167 type Picture struct
|
||||
- view.go:184 type Origin struct
|
||||
|
||||
## internal/web — 747 lines + 1423 test
|
||||
|
||||
|
||||
+28
-2
@@ -91,6 +91,28 @@ either way. The reference theme ships the directory: `figure`, `gallery`, `icon`
|
||||
tip), `details`, `aside`, `toc`. Override one by defining that name anywhere the engine looks; everything you
|
||||
leave alone is inherited.
|
||||
|
||||
### Assets a fragment needs
|
||||
|
||||
A shortcode that needs CSS or JS declares it as a second fragment named `assets:<name>` (ADR-0079):
|
||||
|
||||
```
|
||||
{{define "lightbox"}}<a class="lb" href="{{.Args.src}}">…</a>{{end}}
|
||||
{{define "assets:lightbox"}}<link rel="stylesheet" href="/static/lightbox.css">{{end}}
|
||||
```
|
||||
|
||||
The engine renders `assets:lightbox` **once** into `Page.Assets` when the page called `::lightbox` at all —
|
||||
however many times — and renders nothing when it did not. A shortcode with no `assets:` fragment is the
|
||||
normal case and costs nothing to declare. Frontmatter `use: [lightbox]` pulls the same fragment in without
|
||||
a call, for a page whose script is not tied to one shortcode.
|
||||
|
||||
The fragment receives an empty `Fragment`: it emits fixed markup, not per-call markup. Two calls with
|
||||
different arguments still share one asset, which is what makes deduplication meaningful. Order is
|
||||
first-call, and nothing promises an order *between* two assets.
|
||||
|
||||
Where the files live is yours. `/static/` is served verbatim and needs no engine change, which is why the
|
||||
reference theme's examples point there. The reference theme itself defines no `assets:` fragment and ships
|
||||
no asset (ADR-0063) — this is a mechanism, not an invitation.
|
||||
|
||||
Templates in the same set may call each other — `{{template "picture" .}}` from `figure.html` reaches a block
|
||||
defined in `gallery.html`, because both are parsed into the partials set. The pipeline is all that passes, and
|
||||
there is no way to build a value in a template, so inclusion is useful where the callee needs exactly what the
|
||||
@@ -203,7 +225,9 @@ not insert characters into an author's prose to influence line breaking.
|
||||
`.HTML` is Markdown output, so the dialect decides which elements a theme has to be ready to style
|
||||
(`content-model.md`). Beyond CommonMark's own, since ADR-0058: `<table>` with `<thead>`/`<tbody>`,
|
||||
`<dl>`/`<dt>`/`<dd>`, `<del>`, `<sub>`, `<sup>`, `<mark>`, `<abbr title=…>`, and goldmark's footnote markup — `<sup id="fnref:N">` in the text and a
|
||||
`<div class="footnotes">` carrying an `<ol>` of notes. Headings arrive with an `id`.
|
||||
`<div class="footnotes">` carrying an `<ol>` of notes. Headings arrive with an `id`. Since ADR-0078,
|
||||
`<input type="checkbox" disabled>` opening a list item — a task list, static and unclickable; the
|
||||
reference stylesheet unbullets those lists with `:has`, since goldmark adds no class to find them by.
|
||||
|
||||
None of it is optional and none of it is configurable: a theme that styles none of these still renders a
|
||||
correct page. The reference stylesheet does the minimum — a rule under
|
||||
@@ -227,7 +251,9 @@ Breaking the contract is not a feature — it is a new contract version, and it
|
||||
| queries the page needs | its sequence neighbours, its taxonomy terms, its section's members |
|
||||
| named template lookup | per-type sets; a theme redefines a named block and inherits the rest (ADR-0019) |
|
||||
| URLs | every path the engine emits, so a theme never constructs one by hand |
|
||||
| per-page assets | the `styles` / `scripts` frontmatter lists, resolved relative to the bundle |
|
||||
| per-page assets | `.Styles` and `.Scripts` — the bundle's own files as finished URLs, from the frontmatter lists, with anything climbing out of the bundle already dropped (ADR-0079) |
|
||||
| theme assets in use | `.Assets` — the `assets:<name>` fragments this page's shortcode calls and `use:` list asked for, rendered once each. Emit it in the `head` block |
|
||||
| a `head` block | `base.html` calls `{{block "head" .}}`, empty by default; `page.html` fills it with `.Assets` and `.Styles`, because only a bundle carries assets. **`.Scripts` is not emitted by the reference theme** — it ships no `<script>` at all (ADR-0026, enforced), so a theme wanting the JavaScript half of ADR-0080's exception redefines this block and adds the tag. `examples/demo-site/templates/page.html` is a working example |
|
||||
| chrome strings | looked up by key and language, never hardcoded English in a template — live, see above |
|
||||
| validity windows | a template that renders time-dependent output declares one |
|
||||
|
||||
|
||||
@@ -37,3 +37,12 @@ clickable, and nothing is stored (ADR-0078):
|
||||
- [x] Draw icons from Unicode rather than shipping an icon font
|
||||
- [x] Serve every panel above as plain `<details>`
|
||||
- [ ] Ship a single byte of JavaScript on a page like this one
|
||||
|
||||
## Assets arrive only when asked for
|
||||
|
||||
`::tally` is a site-defined shortcode whose theme fragment declares a stylesheet. Calling it twice pulls
|
||||
that stylesheet in once, and a page that never calls it carries no stylesheet at all (ADR-0079):
|
||||
|
||||
::tally{label="Shortcodes the engine hardcodes" count=3}
|
||||
|
||||
::tally{label="Shortcodes the theme adds without a rebuild" count="as many as you like"}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: A Page That Carries Its Own Assets
|
||||
styles: [sandbox.css]
|
||||
scripts: [sandbox.js]
|
||||
---
|
||||
Every other page on this site ships no JavaScript. This one does, because its own frontmatter names a file
|
||||
sitting beside it — the single exception in ADR-0080, and the only way a script reaches a page here.
|
||||
|
||||
The reference theme would not emit the `<script>` tag at all; this site's `templates/page.html` redefines
|
||||
the `head` block to add it. The stylesheet needs no override, since a stylesheet is not the exception.
|
||||
|
||||
Both files are resolved relative to this bundle. A name reaching outside it is dropped before it is ever
|
||||
an address.
|
||||
@@ -0,0 +1 @@
|
||||
.sandbox { outline: 1px dashed #8a7f6a; padding: 0.5rem; }
|
||||
@@ -0,0 +1 @@
|
||||
document.title = document.title; /* the smallest honest demo */
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Pulled in only by pages that call ::tally (ADR-0079). */
|
||||
.tally { border-left: 3px solid #8a7f6a; padding-left: 0.75rem; }
|
||||
@@ -0,0 +1,15 @@
|
||||
{{/* The site's own page template: it inherits the whole document from the binary and redefines one block.
|
||||
|
||||
This is where the JavaScript half of ADR-0080's exception is demonstrated, deliberately *not* in the
|
||||
reference theme — that ships no <script> at all and verify.sh enforces it (ADR-0026). A real site
|
||||
that wants demos, games or runnable embeds opts in here, once, and every ordinary page still emits
|
||||
nothing because .Scripts is empty unless a bundle's own frontmatter named a file. */}}
|
||||
{{define "head" -}}
|
||||
{{.Assets}}
|
||||
{{- range .Styles}}
|
||||
<link rel="stylesheet" href="{{.}}">
|
||||
{{- end}}
|
||||
{{- range .Scripts}}
|
||||
<script src="{{.}}" defer></script>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{/* A shortcode whose markup needs a stylesheet, demonstrating that the theme — not the engine — decides
|
||||
which assets a call pulls in (ADR-0079). The engine renders `assets:tally` once however many times
|
||||
::tally is called, and not at all on a page that never calls it. */}}
|
||||
{{define "tally"}}<p class="tally">{{.Args.label}}: <strong>{{.Args.count}}</strong></p>{{end}}
|
||||
{{define "assets:tally"}}<link rel="stylesheet" href="/static/tally.css">
|
||||
{{end}}
|
||||
@@ -52,6 +52,13 @@ type Bundle struct {
|
||||
// Draft is true when frontmatter says so. A draft is not served at all until `-dev` reveals it, and
|
||||
// neither are the files inside its bundle (ADR-0024).
|
||||
Draft bool
|
||||
// Styles and Scripts are this bundle's own CSS and JS files, named in frontmatter and living beside the
|
||||
// body. Bundle-relative and nothing else: a name that climbs out is dropped at parse (ADR-0079). Empty
|
||||
// for the overwhelming majority of bundles, which is the point — an ordinary page carries no script.
|
||||
Styles, Scripts []string
|
||||
// Use names theme assets this bundle wants without calling the shortcode that would pull them in — the
|
||||
// frontmatter half of the same mechanism. These are names the theme resolves, never file paths.
|
||||
Use []string
|
||||
// Order is this bundle's position in the series it is nested under, zero when frontmatter omits it.
|
||||
// The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name
|
||||
// after every ordered one (ADR-0033).
|
||||
@@ -165,6 +172,12 @@ func Parse(name string, data []byte) (Bundle, error) {
|
||||
delete(b.Extra, "order")
|
||||
b.Draft, _ = b.Extra["draft"].(bool)
|
||||
delete(b.Extra, "draft")
|
||||
b.Styles = bundleFiles(b.Extra["styles"], b.Key)
|
||||
delete(b.Extra, "styles")
|
||||
b.Scripts = bundleFiles(b.Extra["scripts"], b.Key)
|
||||
delete(b.Extra, "scripts")
|
||||
b.Use = terms(b.Extra["use"])
|
||||
delete(b.Extra, "use")
|
||||
if slug, isStr := b.Extra["slug"].(string); isStr {
|
||||
// One segment, normalised like every other identifier (ADR-0015). Slashes would let a slug move the
|
||||
// bundle to another section, which is a move, not a rename.
|
||||
@@ -250,6 +263,23 @@ func asInt(v any) int {
|
||||
}
|
||||
|
||||
// terms reads a scalar or sequence of tag names, preserving case and script.
|
||||
// bundleFiles reads a scalar-or-list of filenames that must stay inside the bundle.
|
||||
//
|
||||
// A name that climbs out is dropped and logged rather than fatal (ADR-0029), the same refusal an include
|
||||
// and a code block's `file=` already make (ADR-0038): a page may ship its own stylesheet, never reach a
|
||||
// template, a dotfile, or another bundle's files with one.
|
||||
func bundleFiles(v any, where string) []string {
|
||||
var out []string
|
||||
for _, name := range terms(v) {
|
||||
if strings.Contains(name, "..") || strings.HasPrefix(name, "/") {
|
||||
slog.Warn("asset name leaves its bundle and is ignored", "name", name, "bundle", where)
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func terms(v any) []string {
|
||||
var out []string
|
||||
add := func(x any) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
@@ -208,11 +209,12 @@ func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) {
|
||||
// the one exception — a template reading `.Extra.date` got the raw YAML value beside the parsed one.
|
||||
func TestALiftedKeyLeavesExtra(t *testing.T) {
|
||||
b, err := Parse("posts/x.md", []byte("---\ntitle: T\ndate: 2026-03-08\ntags: [a]\norder: 10\n"+
|
||||
"aliases: [old/x]\nslug: s\ndraft: true\nkeeps: me\n---\nbody\n"))
|
||||
"aliases: [old/x]\nslug: s\ndraft: true\nstyles: [a.css]\nscripts: [a.js]\nuse: [lightbox]\n"+
|
||||
"keeps: me\n---\nbody\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, lifted := range []string{"title", "date", "tags", "order", "aliases", "slug", "draft"} {
|
||||
for _, lifted := range []string{"title", "date", "tags", "order", "aliases", "slug", "draft", "styles", "scripts", "use"} {
|
||||
if _, still := b.Extra[lifted]; still {
|
||||
t.Errorf("%q is on the Bundle, so it must not also be in Extra: %v", lifted, b.Extra)
|
||||
}
|
||||
@@ -224,3 +226,41 @@ func TestALiftedKeyLeavesExtra(t *testing.T) {
|
||||
t.Errorf("a key the parser does not name stays: %v", b.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared asset names a file beside the body and nothing else (ADR-0079). The refusal is the one
|
||||
// `::include` and a code block's `file=` already make, so a page can ship a stylesheet without being able
|
||||
// to publish a template, a dotfile, or another bundle's files. Dropped and logged, never fatal (ADR-0029).
|
||||
func TestAnAssetNameCannotLeaveItsBundle(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
front string
|
||||
want []string
|
||||
}{
|
||||
{"a sibling file is kept", "styles: [ok.css]", []string{"ok.css"}},
|
||||
{"a subdirectory is inside the bundle", "styles: [css/ok.css]", []string{"css/ok.css"}},
|
||||
{"climbing out is dropped", "styles: [../../templates/theme.css]", nil},
|
||||
{"an absolute path is dropped", "styles: [/etc/passwd]", nil},
|
||||
{"the good one survives beside the bad", "styles: [../x.css, ok.css]", []string{"ok.css"}},
|
||||
{"a scalar is a list of one", "styles: ok.css", []string{"ok.css"}},
|
||||
{"scripts follow the same rule", "scripts: [../x.js]", nil},
|
||||
} {
|
||||
b, err := Parse("posts/x.md", []byte("---\ntitle: T\n"+c.front+"\n---\nbody\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.what, err)
|
||||
}
|
||||
got := b.Styles
|
||||
if strings.HasPrefix(c.front, "scripts") {
|
||||
got = b.Scripts
|
||||
}
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("%s: got %v, want %v", c.what, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("%s: got %v, want %v", c.what, got, c.want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,9 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.
|
||||
}
|
||||
reader.Advance(seg.Len() - 1)
|
||||
n := &node{name: name, args: args}
|
||||
// The page carries whatever assets its own calls need, so the call is recorded here where the name is
|
||||
// known. What a name *means* in assets is the theme's to say (ADR-0079).
|
||||
render.RecordCall(pc, name)
|
||||
if origin, ok := render.OriginFrom(pc); ok {
|
||||
n.lang = origin.Lang
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
@@ -72,6 +74,38 @@ func WithOrigin(pc parser.Context, origin Origin) {
|
||||
pc.Set(originKey, origin)
|
||||
}
|
||||
|
||||
// callsKey identifies the set of shortcode names called during one parse. Same shape as originKey, and
|
||||
// unexported for the same reason.
|
||||
var callsKey = parser.NewContextKey()
|
||||
|
||||
// RecordCall notes that a shortcode of this name was called while converting this page.
|
||||
//
|
||||
// A feature records the call; the renderer decides what it means. That split is what keeps the engine
|
||||
// free of a table mapping shortcode names to the files they need — that mapping is the theme's, written
|
||||
// as an `assets:<name>` fragment (theme-contract.md, ADR-0079), so a theme adds an asset without a
|
||||
// rebuild exactly as it adds a shortcode.
|
||||
//
|
||||
// First-call order is preserved and repeats collapse, so a page calling one shortcode five times carries
|
||||
// its asset once.
|
||||
func RecordCall(pc parser.Context, name string) {
|
||||
seen, _ := pc.Get(callsKey).(*[]string)
|
||||
if seen == nil {
|
||||
seen = &[]string{}
|
||||
pc.Set(callsKey, seen)
|
||||
}
|
||||
if !slices.Contains(*seen, name) {
|
||||
*seen = append(*seen, name)
|
||||
}
|
||||
}
|
||||
|
||||
// callsFrom returns the shortcode names recorded during a parse, in first-call order.
|
||||
func callsFrom(pc parser.Context) []string {
|
||||
if seen, ok := pc.Get(callsKey).(*[]string); ok {
|
||||
return *seen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -186,6 +220,29 @@ func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// assets renders the theme's `assets:<name>` fragment once for each asset this page uses.
|
||||
//
|
||||
// Sources are the shortcodes the conversion actually called and the bundle's own `use:` list, so a page
|
||||
// carries the CSS and JS its own content needs and an ordinary page carries none (ADR-0079). A name the
|
||||
// theme defines no fragment for contributes nothing and is not an error: most shortcodes need no asset,
|
||||
// and asking the theme to declare that emptiness would be ceremony.
|
||||
func (r *Renderer) assets(names []string) template.HTML {
|
||||
var out strings.Builder
|
||||
var done []string
|
||||
for _, name := range names {
|
||||
if slices.Contains(done, name) {
|
||||
continue
|
||||
}
|
||||
done = append(done, name)
|
||||
fragment, err := r.Partial("assets:"+name, Fragment{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out.Write(fragment)
|
||||
}
|
||||
return template.HTML(out.String())
|
||||
}
|
||||
|
||||
// parseSet builds one set from the named embedded templates, then the site's versions of exactly those
|
||||
// files parsed after them.
|
||||
//
|
||||
@@ -306,6 +363,15 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
|
||||
HTML: template.HTML(body.String()),
|
||||
Extra: b.Extra,
|
||||
Sequence: r.sequence(seq, served),
|
||||
Assets: r.assets(append(callsFrom(pc), b.Use...)),
|
||||
}
|
||||
// A bundle's own files are already served under its URL (ADR-0024), so the engine builds the address
|
||||
// and the theme never constructs one — the same rule canonical and hreflang follow.
|
||||
for _, name := range b.Styles {
|
||||
p.Styles = append(p.Styles, content.URL(b.Route, served)+name)
|
||||
}
|
||||
for _, name := range b.Scripts {
|
||||
p.Scripts = append(p.Scripts, content.URL(b.Route, served)+name)
|
||||
}
|
||||
for _, l := range variants {
|
||||
path := content.URL(b.Route, l)
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<link rel="alternate" type="application/atom+xml" href="{{.Site.Base}}/feed.xml" title="{{.Site.Title}}">
|
||||
{{- end}}
|
||||
<style>{{.Style}}</style>
|
||||
{{- /* Per-kind head additions. Empty here because only a bundle has assets to declare, and a listing
|
||||
would have no field to read — page.html defines this block, every other kind inherits nothing. */}}
|
||||
{{- block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{- if or .Sections .Site.Title}}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
{{/* A page carries only what its own content asked for: assets the theme defines for the shortcodes this
|
||||
page actually called or its `use:` named, then the bundle's own stylesheets (ADR-0079). An ordinary
|
||||
page reaches none of these.
|
||||
|
||||
`.Scripts` is deliberately not emitted here. The reference theme contains no script element at all —
|
||||
it is a contract demonstration, not a design (ADR-0026), and verify.sh holds it to that by grepping
|
||||
for the literal tag, which is why this comment does not spell one either. A theme that wants the
|
||||
JavaScript half of ADR-0080's exception redefines this block and adds the tag; that is exactly what
|
||||
examples/demo-site/templates/page.html does. */}}
|
||||
{{define "head" -}}
|
||||
{{.Assets}}
|
||||
{{- range .Styles}}
|
||||
<link rel="stylesheet" href="{{.}}">
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{define "main" -}}
|
||||
<article>
|
||||
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
|
||||
|
||||
@@ -49,6 +49,13 @@ type Page struct {
|
||||
// ExtrasURL links this bundle's supporting files, empty when it has none — so a theme can offer them
|
||||
// without guessing whether they exist (ADR-0047).
|
||||
ExtrasURL string
|
||||
// Assets is the theme's own markup for everything this page's shortcodes and `use:` asked for, already
|
||||
// rendered and deduplicated. Emit it in `<head>`. Empty for a page that asked for nothing, which is
|
||||
// nearly all of them (ADR-0079).
|
||||
Assets template.HTML
|
||||
// Styles and Scripts are URLs of this bundle's own CSS and JS, built by the engine because a theme must
|
||||
// not construct an address. Empty unless frontmatter named files beside the body.
|
||||
Styles, Scripts []string
|
||||
}
|
||||
|
||||
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
|
||||
|
||||
Reference in New Issue
Block a user