From 30c26bd1acb0ece392752be0fea38725c832fe52 Mon Sep 17 00:00:00 2001 From: bdeshi Date: Mon, 3 Aug 2026 16:44:48 +0600 Subject: [PATCH] resolve relative links against the disk, serve them as addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 --- cmd/khosra/example_test.go | 9 +- cmd/khosra/main.go | 2 +- cmd/khosra/wire.go | 21 +++ .../content/posts/first-light/index.en.md | 5 + harness/content-model.md | 23 +++ harness/decisions.md | 34 ++++ harness/state.md | 8 +- harness/surface.md | 158 ++++++++++-------- internal/content/content.go | 7 + internal/ext/check/check.go | 39 +++++ internal/ext/links/doc.go | 11 ++ internal/ext/links/links.go | 134 +++++++++++++++ internal/ext/links/links_test.go | 133 +++++++++++++++ internal/render/render.go | 105 ++---------- internal/render/theme.go | 105 ++++++++++++ internal/render/view.go | 5 + 16 files changed, 630 insertions(+), 169 deletions(-) create mode 100644 internal/ext/links/doc.go create mode 100644 internal/ext/links/links.go create mode 100644 internal/ext/links/links_test.go create mode 100644 internal/render/theme.go diff --git a/cmd/khosra/example_test.go b/cmd/khosra/example_test.go index eb3fe45..ff647e9 100644 --- a/cmd/khosra/example_test.go +++ b/cmd/khosra/example_test.go @@ -46,7 +46,9 @@ func exampleSite(t *testing.T) http.Handler { t.Fatal(err) } site := content.NewSite(bundles) - r.Navigation(site.Sections) + // The same binding the binary uses, from the one function that does it — not a copy, which is how this + // test came to miss a feature that worked in the real server (ADR-0072). + bind(r, site) return web.Handler(web.Fixed(site, r), fsys, nil, settings, routes(fsys, settings, func() *content.Site { return site })) } @@ -141,6 +143,11 @@ var exampleFeatures = []featureCase{ 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{"../day-01.md") { + t.Errorf("a code span must stay literal:\n%s", got) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 8116925..6f25741 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -7,7 +7,6 @@ package render import ( "bytes" - "embed" "fmt" "html/template" "io/fs" @@ -23,9 +22,6 @@ import ( "khosra/internal/content" ) -//go:embed templates -var themeFS embed.FS - // Renderer holds the parsed theme and the Markdown converter. The theme is parsed once per rebuild and // swapped whole, never per request (conventions.md, ADR-0055). type Renderer struct { @@ -41,24 +37,14 @@ type Renderer struct { // sections reports the site's sections when asked. A callback, because sections change when content does and // the renderer must not hold a stale copy (ADR-0049). sections func() []string + // links resolves a bundle key to the URL it is served at, set per rebuild like sections because only the + // current index can answer it (ADR-0087). + links func(key, lang string) (string, bool) // compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged // (ADR-0066). Set at wiring time like sections, and never called otherwise. compose func(src []byte, origin Origin) []byte } -// parsedTheme is the sets a request executes, and the stylesheet the shell inlines. -type parsedTheme 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 - // partials are named fragments a feature renders through, so no feature decides markup (ADR-0036). - partials *template.Template - // extras is the set for a bundle's supporting-file listing. - extras *template.Template - style template.CSS -} - // originKey identifies the Origin in a parse. Unexported, so the typed accessor is the only way in. var originKey = parser.NewContextKey() @@ -141,34 +127,6 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar return r, nil } -// parseTheme parses every set the theme is made of, plus its stylesheet. -// -// Its own function because a running server parses the theme again on every rebuild (ADR-0055): startup and -// reparse must be the same code, or the theme a running site serves drifts from the one a fresh boot would. -func parseTheme(siteFS fs.FS) (*parsedTheme, 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/base.html", "templates/list.html") - if err != nil { - return nil, fmt.Errorf("listing templates: %w", err) - } - partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html") - 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 - } - return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil -} - // head builds the document shell every kind of page shares. // // canonical arrives as a path and leaves absolute when the site declared a base: a canonical link and an @@ -200,6 +158,12 @@ func (r *Renderer) absolute(path string) string { // sections exist. A callback rather than a slice, because content changes and a copy would go stale. func (r *Renderer) Navigation(sections func() []string) { r.sections = sections } +// Links registers how a bundle key becomes the URL it is served at. +// +// Set per rebuild beside Navigation, and for the same reason: only the index that exists now knows which +// route a key answers at, and a renderer holding a stale copy would emit addresses that used to work. +func (r *Renderer) Links(resolve func(key, lang string) (string, bool)) { r.links = resolve } + // Compose registers the rewrite a merging bundle's body goes through before it is parsed (ADR-0066). // // A seam rather than a call, for the same reason extend is one: only cmd knows which features exist, and @@ -243,55 +207,6 @@ func (r *Renderer) assets(names []string) template.HTML { 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. -// -// 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 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). A name may be a glob, which is how a - // directory of fragments is parsed after the single file it may replace (ADR-0071). - set, parsed := template.New("theme").Funcs(funcs), false - for _, from := range []fs.FS{themeFS, siteFS} { - if from == nil { - continue - } - for _, name := range names { - if matches, _ := fs.Glob(from, name); len(matches) == 0 { - continue - } - var err error - if set, err = set.ParseFS(from, name); err != nil { - return nil, fmt.Errorf("parse %s: %w", name, err) - } - parsed = true - } - } - // Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure, - // because the alternative is an empty set and a template error on the first request. - if !parsed { - return nil, fmt.Errorf("no template matched %v", names) - } - return set, nil -} - -// readStyle prefers the site's stylesheet and falls back to the reference one. -func readStyle(siteFS fs.FS) (template.CSS, error) { - if siteFS != nil { - if data, err := fs.ReadFile(siteFS, "templates/theme.css"); err == nil { - return template.CSS(data), nil - } - } - data, err := themeFS.ReadFile("templates/theme.css") - if err != nil { - return "", fmt.Errorf("read reference stylesheet: %w", err) - } - 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 @@ -340,7 +255,7 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se // The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's // own directory (ADR-0031: through the rooted filesystem, never a joined path). pc := parser.NewContext() - origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served} + origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served, Resolve: r.links} WithOrigin(pc, origin) // Fragments are spliced in before the parse, so their footnotes, abbreviations and headings are the // page's — one document, which is what composing a page from files nearly always wants. `include: embed` diff --git a/internal/render/theme.go b/internal/render/theme.go new file mode 100644 index 0000000..eba5cf0 --- /dev/null +++ b/internal/render/theme.go @@ -0,0 +1,105 @@ +// Building the theme: the embedded reference templates, the site's overlay of them, and the stylesheet. +// +// Split from render.go when that file reached the length advisory (conventions.md). Parsing a theme and +// rendering with one are two topics: this file runs once per rebuild, the other runs per request. +package render + +import ( + "embed" + "fmt" + "html/template" + "io/fs" +) + +//go:embed templates +var themeFS embed.FS + +// parsedTheme is the sets a request executes, and the stylesheet the shell inlines. +type parsedTheme 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 + // partials are named fragments a feature renders through, so no feature decides markup (ADR-0036). + partials *template.Template + // extras is the set for a bundle's supporting-file listing. + extras *template.Template + style template.CSS +} + +// parseTheme parses every set the theme is made of, plus its stylesheet. +// +// Its own function because a running server parses the theme again on every rebuild (ADR-0055): startup and +// reparse must be the same code, or the theme a running site serves drifts from the one a fresh boot would. +func parseTheme(siteFS fs.FS) (*parsedTheme, 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/base.html", "templates/list.html") + if err != nil { + return nil, fmt.Errorf("listing templates: %w", err) + } + partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html") + 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 + } + return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, 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 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). A name may be a glob, which is how a + // directory of fragments is parsed after the single file it may replace (ADR-0071). + set, parsed := template.New("theme").Funcs(funcs), false + for _, from := range []fs.FS{themeFS, siteFS} { + if from == nil { + continue + } + for _, name := range names { + if matches, _ := fs.Glob(from, name); len(matches) == 0 { + continue + } + var err error + if set, err = set.ParseFS(from, name); err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + parsed = true + } + } + // Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure, + // because the alternative is an empty set and a template error on the first request. + if !parsed { + return nil, fmt.Errorf("no template matched %v", names) + } + return set, nil +} + +// readStyle prefers the site's stylesheet and falls back to the reference one. +func readStyle(siteFS fs.FS) (template.CSS, error) { + if siteFS != nil { + if data, err := fs.ReadFile(siteFS, "templates/theme.css"); err == nil { + return template.CSS(data), nil + } + } + data, err := themeFS.ReadFile("templates/theme.css") + if err != nil { + return "", fmt.Errorf("read reference stylesheet: %w", err) + } + return template.CSS(data), nil +} diff --git a/internal/render/view.go b/internal/render/view.go index 8985cfb..dc4dae4 100644 --- a/internal/render/view.go +++ b/internal/render/view.go @@ -189,4 +189,9 @@ type Origin struct { Files fs.FS // Lang is the language of the variant being rendered, so a feature can hand it to a fragment (ADR-0067). Lang string + // Resolve reports the URL a bundle key is served at in a language, and false when no bundle has that key. + // It exists so a feature can turn a path on disk into an address without knowing what a route is: a slug + // moves the address and never the key (ADR-0035), so only the index can answer. Nil when the renderer was + // built without one, in which case a feature that needs it does nothing. + Resolve func(key, lang string) (url string, ok bool) }