resolve relative links against the disk, serve them as addresses
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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{"<script"}},
|
||||
{what: "relative links become served addresses, and a slug's address rather than its filename", path: "/posts/first-light/", code: 200,
|
||||
expect: []string{`href="/posts/day-01/"`, `href="/writing/notes-on-water/"`, `href="/posts/a-better-name/"`},
|
||||
absent: []string{`href="../day-01.en.md"`, `href="../renamed-thing.en.md"`}},
|
||||
{what: "a bundle's own asset link is left exactly as written", path: "/posts/first-light/", code: 200,
|
||||
expect: []string{`src="cover.jpg"`}},
|
||||
{what: "a file in root/ answers at an exact path, with its declared type", path: "/pubkey", code: 200,
|
||||
expect: []string{"ssh-ed25519"}},
|
||||
{what: "a .tmpl passthrough interpolates the site's own base and drops the suffix from its URL", path: "/.well-known/security.txt", code: 200,
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
if reveal {
|
||||
indexed.Reveal()
|
||||
}
|
||||
renderer.Navigation(indexed.Sections)
|
||||
bind(renderer, indexed)
|
||||
// Derivatives before the swap, so a picture is never referenced before it exists (ADR-0042). A failure is
|
||||
// not fatal: pages still serve the author's originals, which is what the fallback is for.
|
||||
if made, err := shortcodes.Derive(fsys, cache); err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/ext/discover"
|
||||
"khosra/internal/ext/links"
|
||||
"khosra/internal/ext/notation"
|
||||
"khosra/internal/ext/passthrough"
|
||||
"khosra/internal/ext/shortcodes"
|
||||
@@ -43,10 +44,30 @@ func extenders(partial render.Partial) []goldmark.Extender {
|
||||
// rewrites an author's plain text into markup, which is the line ADR-0034 draws (ADR-0078).
|
||||
extension.TaskList,
|
||||
notation.New(),
|
||||
links.New(),
|
||||
shortcodes.New(partial),
|
||||
}
|
||||
}
|
||||
|
||||
// bind attaches the callbacks only the current index can answer.
|
||||
//
|
||||
// One function because there are two callers — the rebuilder and the demo's coverage test — and a copy of
|
||||
// this wiring is exactly what ADR-0072 was written about: `Navigation` was already duplicated between them,
|
||||
// and adding `Links` to one of them made the demo test fail on a feature that worked in the binary.
|
||||
func bind(r *render.Renderer, site *content.Site) {
|
||||
// Sections change when content does, so the renderer asks rather than holding a copy (ADR-0049).
|
||||
r.Navigation(site.Sections)
|
||||
// A relative link becomes an address through the index, since a slug moves the route and never the key
|
||||
// (ADR-0035, ADR-0087).
|
||||
r.Links(func(key, lang string) (string, bool) {
|
||||
b, served, ok := site.Lookup(key, lang)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return content.URL(b.Route, served), true
|
||||
})
|
||||
}
|
||||
|
||||
// routes is the only list of features owning a URL path of their own.
|
||||
//
|
||||
// The sibling of extenders(), and the same rule: nothing below cmd may know which features exist, so core
|
||||
|
||||
@@ -11,3 +11,8 @@ It also owns a picture, which only a *directory* bundle can do:
|
||||
::figure{src=cover.jpg alt="A grey-green gradient standing in for a photograph" caption="A caption, rendered by the theme's figure fragment"}
|
||||
|
||||
The picture above is served from this bundle's own directory, and the `srcset` on it names generated widths.
|
||||
|
||||
Relative links are written the way the files sit on disk, so they resolve in an editor preview and on the
|
||||
site alike — and they survive a `slug` rename, because they go through the key rather than the address
|
||||
(ADR-0087). This links [a sibling post](../day-01.en.md), [a piece in another section](../../writing/notes-on-water/),
|
||||
and [the renamed one](../renamed-thing.en.md), whose address is not its filename.
|
||||
|
||||
@@ -215,6 +215,29 @@ What it writes is a draft: `title`, today's `date`, and `draft: true`. A tool th
|
||||
publishes by accident. Nothing is ever overwritten, and a key containing `..` is refused — it names a place
|
||||
under `content/`, not a path to walk.
|
||||
|
||||
## Relative links
|
||||
|
||||
A link written relative to the **file's** place on disk becomes the address that file is served at
|
||||
(ADR-0087), so `[a sibling](../day-01.en.md)` resolves in an editor preview and on the site alike. Output is
|
||||
root-relative, like every link between pages (ADR-0039).
|
||||
|
||||
It resolves through the **key**, so a `slug` rename does not break it: the address moves and the key does not
|
||||
(ADR-0035). A hand-written `/posts/a-better-name/` breaks the next time that slug changes; the relative form
|
||||
does not.
|
||||
|
||||
- Three shapes resolve: `../day-01.en.md`, `../day-01.md`, and `../day-01` with no suffix. A directory bundle
|
||||
is `../notes-on-water/` or its `index.en.md`.
|
||||
- The language being rendered is preferred, falling back the way every lookup does — a Bengali page links the
|
||||
Bengali variant.
|
||||
- **Anything that is not a bundle is left exactly as written**: an absolute URL, a root-relative path, a bare
|
||||
fragment or query, a `mailto:`, and any relative path whose extension is not `.md`. A bundle's own assets
|
||||
already resolve, because a bundle's URL mirrors its directory, so rewriting them would break what works.
|
||||
- A name climbing above `content/` is refused and logged, the rule an include and a code block's `file=`
|
||||
already follow.
|
||||
- `khosra check` reports a **relative link ending in `.md` that resolves to no bundle** as fatal. Only that
|
||||
form: an extensionless relative path may legitimately be an asset, and a checker that guessed would call a
|
||||
working link broken.
|
||||
|
||||
## Files served at the URL root
|
||||
|
||||
`root/` holds files that must answer at an exact address somebody else specified — `/.well-known/security.txt`,
|
||||
|
||||
@@ -1560,3 +1560,37 @@ No wrapper package: `log/slog` is the module, and a layer over it would be an ab
|
||||
Only `serve` takes the flags; `check` and `new` are short-lived and print their own findings.
|
||||
Revisit if: request logging shows up in a profile, or an operator needs per-route levels — neither of which a
|
||||
two-flag configuration can express, and both of which would be evidence for a real logging design.
|
||||
|
||||
## ADR-0087 — A relative link is written against the disk and served as an address
|
||||
Date: 2026-08-03 · Status: accepted
|
||||
Decision: a Markdown link whose destination is a relative path naming a bundle is rewritten to the URL that
|
||||
bundle is served at. `../day-01.en.md`, `../day-01.md` and `../day-01` all resolve; a directory bundle
|
||||
resolves by `../notes-on-water/` or its `index.en.md`. Output stays root-relative (ADR-0039). The language
|
||||
being rendered is preferred, falling back as every lookup does. `internal/ext/links/` does the work; the one
|
||||
seam is `Origin.Resolve`, a callback the renderer receives per rebuild beside `Navigation` because only the
|
||||
index that exists now knows which route a key answers at.
|
||||
Why: an author writes links against the files, which is what an editor preview resolves, and the engine
|
||||
publishing them unchanged means either broken previews or hand-maintained absolute paths. The second effect
|
||||
matters more: 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.
|
||||
This is the engine altering authored markup, which ADR-0045 polices. Legitimate here: it changes an address
|
||||
between two representations of one target, not an author's words. The test that keeps it honest is what it
|
||||
declines to touch, and that table is the feature's largest test — an absolute URL, a scheme-relative URL, a
|
||||
`mailto:`, a `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 a bundle's URL mirrors its directory, so rewriting them would break
|
||||
what works.
|
||||
Key derivation goes through `content.KeyFromName`, exported for this. The language-suffix rule — two or three
|
||||
lowercase letters before `.md`, `index` naming its directory (ADR-0021) — is the part that would drift between
|
||||
two copies, so it stays in one place; the five lines of joining and prefix-testing are duplicated in `check`
|
||||
rather than shared, which is the cheaper trade.
|
||||
`khosra check` reports a relative link ending in `.md` that resolves to no bundle, as fatal. Only that form:
|
||||
an extensionless relative path may legitimately be an asset, and a checker calling a working link broken is
|
||||
worse than one missing a case, because a checker nobody trusts gets ignored wholesale.
|
||||
Consequence: two things this change owed and paid. `render.go` reached the file-length advisory, so theme
|
||||
parsing moved to `theme.go` — one topic per file rather than a shard, since parsing runs per rebuild and
|
||||
rendering runs per request. 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 binary served it correctly; both now call one `bind`,
|
||||
which is what ADR-0072 was written about and had already drifted for `Navigation`.
|
||||
Revisit if: images want the same treatment. They deliberately do not get it — an image is an asset, and the
|
||||
asset case is the one this must never touch.
|
||||
|
||||
+5
-3
@@ -25,7 +25,8 @@ table owns.
|
||||
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
|
||||
| `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/theme.go` | parsing the theme: the embedded reference templates, a site's overlay of them, and the stylesheet. Split from `render.go` at the length advisory — parsing a theme runs once per rebuild, rendering with one runs per request |
|
||||
| `internal/render/render.go` | goldmark with the typographer, 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` (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) |
|
||||
@@ -33,6 +34,7 @@ table owns.
|
||||
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
|
||||
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
|
||||
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
|
||||
| `internal/ext/links/` | eighth feature: relative Markdown links become the URLs they are served at, resolved through key → route so a `slug` rename cannot break one (ADR-0087). Rewrites only destinations naming a bundle; an absolute URL, a fragment, a `mailto:` and any non-`.md` relative path are left exactly as written |
|
||||
| `internal/ext/discover/` | seventh feature: `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039). Left core in ADR-0085 — exact paths somebody else's software asks for by name, owning no core concept |
|
||||
| `internal/ext/passthrough/` | fifth feature, and the first to own a **route** (ADR-0081): files in `root/` served at the exact path they occupy, `.tmpl` rendered as text with the site's own settings, headers declared per path in `root/_headers.yaml`, underscore-prefixed names not addressable |
|
||||
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) |
|
||||
@@ -111,7 +113,7 @@ a row that leaves it empty (ADR-0070).
|
||||
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run` | A series archive. Membership is structural and the sort ascends, so it resolves through `Site.Sequence` — sharing the index but not the Query |
|
||||
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet | Output formats. HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
|
||||
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The only one is the derivative pass (ADR-0042), called from `cmd` inside `rebuilder`, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) | An in-memory swap. Replacing the index or the theme re-reads the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
|
||||
| Extensions | 7 | **3** — passed, and the registry is now partly built | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. The note below says which field was built and why the rest were not | An upstream extension enabled in the list. `Table`, `Footnote` and `DefinitionList` are goldmark's, so they are dialect rather than features of this engine (ADR-0058) — only a package under `internal/ext/` counts |
|
||||
| Extensions | 8 | **3** — passed, and the registry is now partly built | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. The note below says which field was built and why the rest were not | An upstream extension enabled in the list. `Table`, `Footnote` and `DefinitionList` are goldmark's, so they are dialect rather than features of this engine (ADR-0058) — only a package under `internal/ext/` counts |
|
||||
| Interface implementations | — | **2** | The interface itself | An interface this repo did not declare. Satisfying `fs.FS`, `http.Handler` or `goldmark.Extender` is using somebody else's abstraction, which is the opposite of inventing one |
|
||||
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — | The standard library, and a dependency's own test-only modules — `go list -m all` shows those, and the gate counts `require` entries instead (`scripts/budgets.env`) |
|
||||
|
||||
@@ -126,7 +128,7 @@ landed (ADR-0061, ADR-0062) and the count was never incremented, though the pros
|
||||
five. Six now, with `passthrough`. This is the latent item about the counters having no mechanical check,
|
||||
demonstrating itself; the count is authoritative only because someone just ran `ls internal/ext/`.
|
||||
|
||||
**A registry over the *other* attachment points would still buy nothing.** The seven features
|
||||
**A registry over the *other* attachment points would still buy nothing.** The eight features
|
||||
attach in four unrelated ways: `shortcodes` and `notation` are goldmark extenders listed in `extenders()`,
|
||||
`check` and `scaffold` are functions `cmd` calls for a subcommand, `watch` is a goroutine, and
|
||||
`passthrough` and `discover` hand back maps of URL paths. A registry would
|
||||
|
||||
+89
-69
@@ -6,9 +6,9 @@ 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 — 393 lines + 341 test
|
||||
## cmd/khosra — 414 lines + 348 test
|
||||
|
||||
check.go 45 · main.go 225 · new.go 42 · wire.go 81
|
||||
check.go 45 · main.go 225 · new.go 42 · wire.go 102
|
||||
|
||||
- check.go:16 func runCheck(args []string)
|
||||
- main.go:24 func main()
|
||||
@@ -21,14 +21,15 @@ check.go 45 · main.go 225 · new.go 42 · wire.go 81
|
||||
- main.go:196 func fatal(msg string, err error)
|
||||
- main.go:210 func logging(level, format string) error
|
||||
- new.go:12 func runNew(args []string)
|
||||
- wire.go:22 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:35 func extenders(partial render.Partial) []goldmark.Extender
|
||||
- wire.go:61 func routes(siteFS fs.FS, settings content.Settings, site func() *content.Site) map[string]http.Handler
|
||||
- wire.go:72 func claim(out map[string]http.Handler, feature string, from map[string]http.Handler)
|
||||
- wire.go:23 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:36 func extenders(partial render.Partial) []goldmark.Extender
|
||||
- wire.go:57 func bind(r *render.Renderer, site *content.Site)
|
||||
- wire.go:82 func routes(siteFS fs.FS, settings content.Settings, site func() *content.Site) map[string]http.Handler
|
||||
- wire.go:93 func claim(out map[string]http.Handler, feature string, from map[string]http.Handler)
|
||||
|
||||
## internal/content — 1076 lines + 598 test
|
||||
## internal/content — 1083 lines + 598 test
|
||||
|
||||
clock.go 12 · content.go 483 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
clock.go 12 · content.go 490 · 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() }
|
||||
@@ -38,30 +39,31 @@ clock.go 12 · content.go 483 · doc.go 5 · extras.go 92 · settings.go 59 · s
|
||||
- content.go:89 type Problem struct
|
||||
- content.go:103 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:112 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:151 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:198 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:207 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:217 func stringList(v any) []string
|
||||
- content.go:239 func asTime(v any) time.Time
|
||||
- content.go:255 func asInt(v any) int
|
||||
- content.go:273 func bundleFiles(v any, where string) []string
|
||||
- content.go:285 func terms(v any) []string
|
||||
- content.go:309 func TagSlug(tag string) string
|
||||
- content.go:318 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:324 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:346 func isLangTag(s string) bool
|
||||
- content.go:364 func isPartial(base string) bool
|
||||
- content.go:376 func skipDir(base string) bool
|
||||
- content.go:384 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:399 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:416 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:438 const PerPage = 10
|
||||
- content.go:444 func URL(key, lang string) string
|
||||
- content.go:457 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:467 const DerivedPrefix = "/derived/"
|
||||
- content.go:470 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:473 const TagsSegment = "tags"
|
||||
- content.go:477 func PageURL(key, lang string, page int) string
|
||||
- content.go:155 func KeyFromName(name string) (key, lang string, ok bool) { return splitName(name) }
|
||||
- content.go:158 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:205 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:214 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:224 func stringList(v any) []string
|
||||
- content.go:246 func asTime(v any) time.Time
|
||||
- content.go:262 func asInt(v any) int
|
||||
- content.go:280 func bundleFiles(v any, where string) []string
|
||||
- content.go:292 func terms(v any) []string
|
||||
- content.go:316 func TagSlug(tag string) string
|
||||
- content.go:325 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:331 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:353 func isLangTag(s string) bool
|
||||
- content.go:371 func isPartial(base string) bool
|
||||
- content.go:383 func skipDir(base string) bool
|
||||
- content.go:391 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:406 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:423 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:445 const PerPage = 10
|
||||
- content.go:451 func URL(key, lang string) string
|
||||
- content.go:464 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:474 const DerivedPrefix = "/derived/"
|
||||
- content.go:477 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:480 const TagsSegment = "tags"
|
||||
- content.go:484 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
|
||||
@@ -100,9 +102,9 @@ clock.go 12 · content.go 483 · doc.go 5 · extras.go 92 · settings.go 59 · s
|
||||
- site.go:395 func (s *Site) Everything() []Bundle
|
||||
- site.go:408 func (s *Site) Sections() []string
|
||||
|
||||
## internal/ext/check — 231 lines + 168 test
|
||||
## internal/ext/check — 270 lines + 168 test
|
||||
|
||||
check.go 223 · doc.go 8
|
||||
check.go 262 · doc.go 8
|
||||
|
||||
- check.go:16 type Finding struct
|
||||
- check.go:30 func Run(fsys fs.FS, bundles []content.Bundle, site *content.Site, problems []content.Problem) []Finding
|
||||
@@ -112,11 +114,13 @@ check.go 223 · doc.go 8
|
||||
- check.go:89 var altArg = regexp.MustCompile(`alt=("[^"]+"|[^"\s}]+)`)
|
||||
- check.go:95 func checkFigures(b content.Bundle) []Finding
|
||||
- check.go:106 var internalLink = regexp.MustCompile(`\]\((/[^)\s"]*)`)
|
||||
- check.go:112 func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
|
||||
- check.go:129 func engineOwned(target string) bool
|
||||
- check.go:146 func resolves(fsys fs.FS, target string, from content.Bundle, site *content.Site) bool
|
||||
- check.go:172 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
|
||||
- check.go:200 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
|
||||
- check.go:113 var bundleLink = regexp.MustCompile(`\]\((\.{0,2}/?[^) \s"#?]*\.md)`)
|
||||
- check.go:119 func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding
|
||||
- check.go:152 func bundleAt(target string, b content.Bundle, site *content.Site) (string, bool)
|
||||
- check.go:168 func engineOwned(target string) bool
|
||||
- check.go:185 func resolves(fsys fs.FS, target string, from content.Bundle, site *content.Site) bool
|
||||
- check.go:211 func asset(fsys fs.FS, trimmed string, site *content.Site) bool
|
||||
- check.go:239 func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding
|
||||
|
||||
## internal/ext/discover — 107 lines + 139 test
|
||||
|
||||
@@ -129,6 +133,21 @@ discover.go 98 · doc.go 9
|
||||
- discover.go:83 func xmlEscape(s string) string
|
||||
- discover.go:93 func writeAs(w http.ResponseWriter, contentType string, out []byte, what string)
|
||||
|
||||
## internal/ext/links — 145 lines + 133 test
|
||||
|
||||
doc.go 11 · links.go 134
|
||||
|
||||
- links.go:20 const contentDir = "content"
|
||||
- links.go:23 func New() goldmark.Extender { return extension{} }
|
||||
- links.go:25 type extension struct{}
|
||||
- links.go:30 func (extension) Extend(md goldmark.Markdown)
|
||||
- links.go:34 type rewrite struct{}
|
||||
- links.go:36 func (rewrite) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
|
||||
- links.go:61 func resolve(dest string, origin render.Origin) (string, bool)
|
||||
- links.go:91 func relative(dest string) (*url.URL, bool)
|
||||
- links.go:107 func keyOf(name string) (string, bool)
|
||||
- links.go:125 func suffix(ref *url.URL) string
|
||||
|
||||
## internal/ext/notation — 411 lines + 149 test
|
||||
|
||||
abbr.go 246 · doc.go 8 · notation.go 157
|
||||
@@ -314,9 +333,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 — 806 lines + 441 test
|
||||
## internal/render — 831 lines + 441 test
|
||||
|
||||
chrome.go 115 · render.go 499 · view.go 192
|
||||
chrome.go 115 · render.go 414 · theme.go 105 · view.go 197
|
||||
|
||||
- chrome.go:19 var chrome = map[string]map[string]string{
|
||||
- chrome.go:38 var months = map[string][]string{
|
||||
@@ -326,34 +345,35 @@ chrome.go 115 · render.go 499 · view.go 192
|
||||
- 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: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)
|
||||
- render.go:27 type Renderer struct
|
||||
- render.go:49 var originKey = parser.NewContextKey()
|
||||
- render.go:52 func OriginFrom(pc parser.Context) (Origin, bool)
|
||||
- render.go:59 func WithOrigin(pc parser.Context, origin Origin)
|
||||
- render.go:65 var callsKey = parser.NewContextKey()
|
||||
- render.go:76 func RecordCall(pc parser.Context, name string)
|
||||
- render.go:88 func callsFrom(pc parser.Context) []string
|
||||
- render.go:104 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
|
||||
- render.go:134 func (r *Renderer) head(title, lang, canonical string) head
|
||||
- render.go:151 func (r *Renderer) absolute(path string) string
|
||||
- render.go:159 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
- render.go:165 func (r *Renderer) Links(resolve func(key, lang string) (string, bool)) { r.links = resolve }
|
||||
- render.go:171 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
|
||||
- render.go:175 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
|
||||
- render.go:193 func (r *Renderer) assets(names []string) template.HTML
|
||||
- render.go:215 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
|
||||
- render.go:236 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
|
||||
- render.go:254 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
|
||||
- render.go:308 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:327 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
|
||||
- render.go:354 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
|
||||
- render.go:381 func (r *Renderer) item(b content.Bundle, lang string) Item
|
||||
- render.go:386 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
|
||||
- render.go:408 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
|
||||
- theme.go:15 var themeFS embed.FS
|
||||
- theme.go:18 type parsedTheme struct
|
||||
- theme.go:34 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
|
||||
- theme.go:65 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
|
||||
- theme.go:94 func readStyle(siteFS fs.FS) (template.CSS, error)
|
||||
- view.go:17 type head struct
|
||||
- view.go:37 type Page struct
|
||||
- view.go:63 type Sequence struct
|
||||
|
||||
@@ -147,6 +147,13 @@ func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error) {
|
||||
return kept, append(problems, collisions...), nil
|
||||
}
|
||||
|
||||
// KeyFromName derives a bundle key and language from a filename relative to content/.
|
||||
//
|
||||
// Exported so a feature resolving a relative link derives the key by the *same* rule the scanner used. The
|
||||
// language-suffix rule — two or three lowercase letters before .md, and `index` naming its directory
|
||||
// (ADR-0021) — lives in one place or the two copies disagree about which files are the same bundle.
|
||||
func KeyFromName(name string) (key, lang string, ok bool) { return splitName(name) }
|
||||
|
||||
// Parse reads one bundle from the bytes of a file, named relative to content/.
|
||||
func Parse(name string, data []byte) (Bundle, error) {
|
||||
key, lang, ok := splitName(name)
|
||||
|
||||
@@ -105,6 +105,13 @@ func checkFigures(b content.Bundle) []Finding {
|
||||
// internalLink matches a Markdown link or image whose target is a root-relative path.
|
||||
var internalLink = regexp.MustCompile(`\]\((/[^)\s"]*)`)
|
||||
|
||||
// bundleLink matches a Markdown link whose target is a relative path naming a Markdown file.
|
||||
//
|
||||
// Only the `.md` form, deliberately. An extensionless relative path may perfectly well be an asset, and a
|
||||
// checker that guessed would report a working link as broken — which is worse than missing one, since a
|
||||
// checker nobody trusts gets ignored wholesale. A `.md` target is unambiguously meant to be a bundle.
|
||||
var bundleLink = regexp.MustCompile(`\]\((\.{0,2}/?[^):\s"#?]*\.md)`)
|
||||
|
||||
// checkLinks resolves every root-relative link a body contains.
|
||||
//
|
||||
// Fatal: a link that 404s is the site lying to a reader, and it is exactly the mistake that survives a rename
|
||||
@@ -122,9 +129,41 @@ func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding {
|
||||
found = append(found, Finding{b.Path, "link goes nowhere: " + target, true})
|
||||
}
|
||||
}
|
||||
// Relative links are rewritten at render time (ADR-0087), so a mistyped one silently stays relative and
|
||||
// 404s when somebody clicks it. Nothing else looks: the rule above only sees root-relative paths.
|
||||
for _, m := range bundleLink.FindAllStringSubmatch(string(b.Body), -1) {
|
||||
target := m[1]
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
if _, ok := bundleAt(target, b, site); !ok {
|
||||
found = append(found, Finding{b.Path, "relative link resolves to no bundle: " + target, true})
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// bundleAt resolves a relative link the way the renderer does, and reports the key it names.
|
||||
//
|
||||
// The join and the prefix test are five obvious lines rather than a shared package — but the *key* derivation
|
||||
// goes through content.KeyFromName, because the language-suffix rule is the part that would drift between two
|
||||
// copies (ADR-0021, ADR-0087).
|
||||
func bundleAt(target string, b content.Bundle, site *content.Site) (string, bool) {
|
||||
joined := path.Join(path.Dir(b.Path), target)
|
||||
if !strings.HasPrefix(joined, "content/") {
|
||||
return "", false
|
||||
}
|
||||
key, _, ok := content.KeyFromName(strings.TrimPrefix(joined, "content/"))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if _, _, live := site.Lookup(key, b.Lang); !live {
|
||||
return "", false
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
|
||||
// engineOwned reports whether a path is generated rather than authored, so a checker has nothing to say.
|
||||
func engineOwned(target string) bool {
|
||||
for _, prefix := range []string{"/static/", content.DerivedPrefix, "/tags/"} {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package links rewrites relative Markdown links into the URLs they are served at.
|
||||
//
|
||||
// An author writes `./post2` or `../notes-on-water/` relative to the **file's** place on disk, which is what
|
||||
// an editor preview resolves against, and the engine emits the served address (ADR-0087). Output stays
|
||||
// root-relative, as every link between pages does (ADR-0039).
|
||||
//
|
||||
// It changes an address between two representations of one target, never an author's words — the distinction
|
||||
// ADR-0045 draws. It also makes links survive renames: a relative link resolves through key → route, and a
|
||||
// `slug` moves the route while never moving the key (ADR-0035), so a hand-written absolute path breaks where
|
||||
// this does not.
|
||||
package links
|
||||
@@ -0,0 +1,134 @@
|
||||
package links
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// contentDir is the one directory a relative link may resolve inside.
|
||||
const contentDir = "content"
|
||||
|
||||
// New returns the Markdown extension.
|
||||
func New() goldmark.Extender { return extension{} }
|
||||
|
||||
type extension struct{}
|
||||
|
||||
// Extend registers the rewrite as a transformer, after the include expander so a merged fragment's links are
|
||||
// in the tree by the time this walks it. An `include: embed` fragment is converted by this same pipeline with
|
||||
// the same Origin, so its links are rewritten on that nested parse rather than being missed.
|
||||
func (extension) Extend(md goldmark.Markdown) {
|
||||
md.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(rewrite{}, 200)))
|
||||
}
|
||||
|
||||
type rewrite struct{}
|
||||
|
||||
func (rewrite) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
origin, ok := render.OriginFrom(pc)
|
||||
if !ok || origin.Resolve == nil {
|
||||
return
|
||||
}
|
||||
// The error is always nil: the callback below never returns one, and swallowing a value that cannot exist
|
||||
// reads better than a branch that cannot run.
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
link, is := n.(*ast.Link)
|
||||
if !entering || !is {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if to, changed := resolve(string(link.Destination), origin); changed {
|
||||
link.Destination = []byte(to)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
// resolve turns one destination into a served URL, reporting whether it should be replaced at all.
|
||||
//
|
||||
// Everything it declines to touch it must leave *exactly* as written, which is most of what this function is
|
||||
// for: an absolute URL, a root-relative path, a fragment, a mail address, and — the case that matters most —
|
||||
// a relative path naming something that is not a bundle. A bundle's own assets already resolve correctly by
|
||||
// accident, because a bundle's URL mirrors its directory, so rewriting them would break what works.
|
||||
func resolve(dest string, origin render.Origin) (string, bool) {
|
||||
ref, ok := relative(dest)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
// path.Join cleans, so ".." is collapsed here rather than reaching the filesystem. A name that climbs out
|
||||
// of content/ is refused, the rule an include and a code block's file= already enforce (ADR-0038).
|
||||
joined := path.Join(origin.Dir, ref.Path)
|
||||
if joined != contentDir && !strings.HasPrefix(joined, contentDir+"/") {
|
||||
slog.Warn("a relative link climbs out of the content directory and is left as written",
|
||||
"link", dest, "from", origin.Dir)
|
||||
return "", false
|
||||
}
|
||||
key, ok := keyOf(strings.TrimPrefix(joined, contentDir+"/"))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
// The language of the page being rendered, so a Bengali page links the Bengali variant and falls back the
|
||||
// way every other lookup does (ADR-0009).
|
||||
to, ok := origin.Resolve(key, origin.Lang)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return to + suffix(ref), true
|
||||
}
|
||||
|
||||
// relative reports whether a destination is a path this package may resolve, and parses it.
|
||||
//
|
||||
// Declined: anything with a scheme (`https:`, `mailto:`, `tel:`), a host (`//example.com`), an absolute path,
|
||||
// and a destination with no path at all, which is a bare fragment or query.
|
||||
func relative(dest string) (*url.URL, bool) {
|
||||
if dest == "" || strings.HasPrefix(dest, "/") {
|
||||
return nil, false
|
||||
}
|
||||
ref, err := url.Parse(dest)
|
||||
if err != nil || ref.Scheme != "" || ref.Host != "" || ref.Path == "" {
|
||||
return nil, false
|
||||
}
|
||||
return ref, true
|
||||
}
|
||||
|
||||
// keyOf derives a bundle key from a name relative to content/, or reports that this is not one.
|
||||
//
|
||||
// Three cases and no more: a name ending in `.md` is a bundle file, a name with no extension at all is a
|
||||
// bundle directory or the same file written without its suffix, and a name with any other extension is an
|
||||
// asset and is never touched. That last line is what keeps `cover.jpg` working.
|
||||
func keyOf(name string) (string, bool) {
|
||||
name = strings.TrimSuffix(name, "/")
|
||||
switch path.Ext(name) {
|
||||
case ".md":
|
||||
key, _, ok := content.KeyFromName(name)
|
||||
return key, ok
|
||||
case "":
|
||||
// Derived through the same function rather than used as-is, so `index` naming its directory and the
|
||||
// language-suffix rule stay in one place (ADR-0021).
|
||||
key, _, ok := content.KeyFromName(name + ".md")
|
||||
return key, ok
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// suffix is the query and fragment the author wrote, preserved so `../post/#section` still lands on the
|
||||
// section it names.
|
||||
func suffix(ref *url.URL) string {
|
||||
var out strings.Builder
|
||||
if ref.RawQuery != "" {
|
||||
out.WriteString("?" + ref.RawQuery)
|
||||
}
|
||||
if ref.Fragment != "" {
|
||||
out.WriteString("#" + ref.Fragment)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package links
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// site is the pretend index every case resolves against: three bundles, one of them slugged, one with a
|
||||
// Bengali variant. Keys in, URLs out — exactly the shape the real resolver has.
|
||||
func site(key, lang string) (string, bool) {
|
||||
routes := map[string]string{
|
||||
"posts/day-01": "/posts/day-01/",
|
||||
"posts/first-light": "/posts/first-light/",
|
||||
"writing/notes-on-water": "/writing/notes-on-water/",
|
||||
"posts/renamed": "/posts/a-better-name/", // a slug moved the address, never the key
|
||||
}
|
||||
url, ok := routes[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if lang == "bn" {
|
||||
return "/bn" + url, true
|
||||
}
|
||||
return url, true
|
||||
}
|
||||
|
||||
// convert renders body as a bundle at dir would be, and returns the HTML.
|
||||
func convert(t *testing.T, dir, lang, body string) string {
|
||||
t.Helper()
|
||||
md := goldmark.New(goldmark.WithExtensions(New()))
|
||||
pc := parser.NewContext()
|
||||
render.WithOrigin(pc, render.Origin{Dir: dir, Lang: lang, Resolve: site})
|
||||
var out bytes.Buffer
|
||||
if err := md.Convert([]byte(body), &out, parser.WithContext(pc)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// What must be rewritten: a path on disk becomes the address that path is served at.
|
||||
func TestARelativeLinkBecomesTheServedAddress(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what, from, link, want string
|
||||
}{
|
||||
{"a sibling single-file bundle, written with its filename", "content/posts/first-light",
|
||||
"../day-01.en.md", `href="/posts/day-01/"`},
|
||||
{"the same, written without the suffix", "content/posts/first-light",
|
||||
"../day-01", `href="/posts/day-01/"`},
|
||||
{"the same, written with only .md", "content/posts/first-light",
|
||||
"../day-01.md", `href="/posts/day-01/"`},
|
||||
{"a directory bundle in another section", "content/posts/first-light",
|
||||
"../../writing/notes-on-water/", `href="/writing/notes-on-water/"`},
|
||||
{"a directory bundle by its index file", "content/posts/first-light",
|
||||
"../../writing/notes-on-water/index.en.md", `href="/writing/notes-on-water/"`},
|
||||
{"from a single-file bundle, whose directory is the section", "content/posts",
|
||||
"./first-light/", `href="/posts/first-light/"`},
|
||||
{"a slug moves the address and never the key", "content/posts/first-light",
|
||||
"../renamed.md", `href="/posts/a-better-name/"`},
|
||||
{"a fragment survives", "content/posts/first-light",
|
||||
"../day-01.md#the-part", `href="/posts/day-01/#the-part"`},
|
||||
{"a query survives", "content/posts/first-light",
|
||||
"../day-01.md?raw", `href="/posts/day-01/?raw"`},
|
||||
} {
|
||||
got := convert(t, c.from, "en", "["+c.what+"]("+c.link+")")
|
||||
if !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q from %s\n got %s\n want %s", c.what, c.link, c.from, strings.TrimSpace(got), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What prose it eats — the table that decides whether this ships. Every destination here must survive
|
||||
// **exactly** as written, because rewriting any of them would break something that works today.
|
||||
func TestWhatItLeavesAlone(t *testing.T) {
|
||||
for _, c := range []struct{ what, link string }{
|
||||
{"an absolute URL", "https://example.com/posts/day-01/"},
|
||||
{"a scheme-relative URL", "//example.com/x"},
|
||||
{"a mail address", "mailto:someone@example.com"},
|
||||
{"a telephone link", "tel:+8801000000"},
|
||||
{"a root-relative path, which is already an address", "/posts/day-01/"},
|
||||
{"a bare fragment", "#a-heading"},
|
||||
{"a bare query", "?raw"},
|
||||
{"a bundle's own asset, which already resolves because the URL mirrors the directory", "cover.jpg"},
|
||||
{"the same, written explicitly relative", "./cover.jpg"},
|
||||
{"an asset in a subdirectory", "./scans/page-01.png"},
|
||||
{"a name climbing out of the content directory", "../../../etc/passwd"},
|
||||
{"a relative path naming no bundle at all", "../nothing-here.md"},
|
||||
{"a relative path naming no bundle, without a suffix", "../nothing-here"},
|
||||
{"an asset one level up, which is not this bundle's to link but is still not a bundle", "../shared.css"},
|
||||
} {
|
||||
got := convert(t, "content/posts/first-light", "en", "["+c.what+"]("+c.link+")")
|
||||
if !strings.Contains(got, `href="`+c.link+`"`) {
|
||||
t.Errorf("%s: %q was altered\n got %s", c.what, c.link, strings.TrimSpace(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Bengali page links the Bengali variant, through the same fallback every lookup uses (ADR-0009).
|
||||
func TestTheLinkFollowsTheLanguageBeingRendered(t *testing.T) {
|
||||
got := convert(t, "content/posts/first-light", "bn", "[চলুন](../day-01.md)")
|
||||
if !strings.Contains(got, `href="/bn/posts/day-01/"`) {
|
||||
t.Errorf("a Bengali page should link the Bengali variant:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a resolver the feature does nothing at all, rather than guessing an address. That is the state a
|
||||
// renderer built with no site root is in, and a guess there would emit links to pages that do not exist.
|
||||
func TestWithoutAResolverNothingIsTouched(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(New()))
|
||||
pc := parser.NewContext()
|
||||
render.WithOrigin(pc, render.Origin{Dir: "content/posts/first-light", Lang: "en"})
|
||||
var out bytes.Buffer
|
||||
if err := md.Convert([]byte("[x](../day-01.md)"), &out, parser.WithContext(pc)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `href="../day-01.md"`) {
|
||||
t.Errorf("with no resolver the destination must survive:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Code spans are literal, so a path inside one is text and not a link. Worth asserting rather than assuming:
|
||||
// every syntax defect this engine has shipped was something eating ordinary prose.
|
||||
func TestAPathInACodeSpanIsNotALink(t *testing.T) {
|
||||
got := convert(t, "content/posts/first-light", "en", "Write `../day-01.md` to link it.")
|
||||
if !strings.Contains(got, "<code>../day-01.md</code>") {
|
||||
t.Errorf("a code span must stay literal:\n%s", got)
|
||||
}
|
||||
}
|
||||
+10
-95
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user