Commit Graph
14 Commits
Author SHA1 Message Date
bdeshi 6c9a34a8fd add the gallery shortcode and the seam it needed
A feature now learns which bundle is rendering: render.Bundle puts an Origin —
the bundle's directory plus the rooted fs.FS — on the parse context, and
render.OriginFrom reads it back. Available while parsing, not while rendering,
which decides where a feature does its filesystem work: goldmark hands the
context to a block parser and not to a node renderer, so gallery gathers its
filenames at parse time and carries them on the node.

Reads stay inside the site root because Origin passes the fs.FS rather than a
path to join (ADR-0031).

Fragment{Args, Items} lands with it (ADR-0037), so figure's template now reads
.Args.src. Authored arguments and engine-gathered items stay in separate fields:
a src argument beside a src the engine found would otherwise silently pick one.

A gallery is pictures beside the bundle, in filename order, skipping
subdirectories and anything a browser cannot show. Filename order is what makes
the sparse numeric-prefix convention work without numbers in URLs (ADR-0016).

New latent row: the reference theme's images carry no width/height and a
gallery's carry no alt, which is below the output floor conventions.md states.
Nothing can supply either yet — dimensions need the image read, and a filename is
not alt text. Queue 13 computes dimensions and brings structured items with it.
2026-08-01 02:23:35 +06:00
bdeshi 1f7ca09313 add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.

The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.

Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes &lt;script&gt; and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.

parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
2026-08-01 02:23:35 +06:00
bdeshi 7117d70b45 record ADR-0035: a slug belongs to the bundle, not to a variant
Confirms ADR-0009's "same path" clause against the alternative that was on the
table: a Bengali variant at its own Bengali address. Chosen: one path per bundle,
prefixed per language, so identity stays stable across translations (invariant 3)
and the resolver keeps one lookup direction.

The declined option is named in the ADR so it is not re-proposed as new. Cost is
stated too — a Bengali reader sees a Latin address, and reversing this later needs
an alias for every published path.

No code moves: `slug` is still unread. Recorded now because nothing is published
yet, which is the only cheap moment a permalink decision has.
2026-08-01 02:23:35 +06:00
bdeshi 273023b340 write down that addresses are never localised
Localisation stops at the words around the content. No path segment is
translated: reserved segments stay `page` and `tags` in every language, page
numbers stay ASCII, and a variant is reached at the English key under its prefix.
The theme contract gains the matching rule — a URL, key or path segment must
never go through `t`, `num` or `day`.

This is already how the code behaves; every localisation function lives in
internal/render/chrome.go and the content package, which owns every URL, cannot
reach it. Recorded because nothing in the code says so out loud.

state.md now names the five frontmatter keys the parser actually lifts, so
content-model.md's table reads as the accepted format rather than a list of what
runs — `slug` in particular is unread today.
2026-08-01 02:23:35 +06:00
bdeshi c244855ebc localise chrome, smooth prose, in English and Bengali
The engine now owns the words it puts on a page that the author did not write
(ADR-0034). `internal/render/chrome.go` holds the phrase table, Gregorian month
names and decimal digits per language, keyed phrase-then-language so both forms
sit side by side and a half-translated row is visible while reading. Templates
reach it through `t`, `num` and `day`, registered before parsing so a site
override's blocks may call them too.

The reference theme stops hardcoding English: "Newer", "Page 2 of 2" and every
date now come from the table, while `datetime` attributes stay ASCII because a
parser reads them.

Authored text gets goldmark's typographer and nothing else — quotes, dashes and
ellipses smoothed, code spans untouched because it works on the parsed tree. It
is a parser option rather than a function over a page, so the transforms counter
does not move; state.md now says why, so the next reader does not miscount.

Deliberately absent: relative dates, which need a validity window that only the
cache entry will have, and body widow prevention, which cannot be done safely by
a pass over rendered HTML.
2026-08-01 02:23:35 +06:00
bdeshi 50f3bd1942 record ADR-0033: series membership is structural
Answers the question that blocked sequences. A chapter belongs to the series it
is nested under, so the engine reads no `series` field; `order` stays, optional,
and a member without one sorts by name after every member carrying one.

This supersedes ADR-0016's membership clause and keeps its position rules — the
point of that ADR was that position never reaches a URL, which still holds. The
directory already states membership unambiguously; a second statement in
frontmatter can only agree or be a typo that silently orphans a chapter.

Cost, stated in the ADR: a series can never span directories, and membership now
rides on the bundle key, which ADR-0008 makes permanent.
2026-08-01 02:23:34 +06:00
bdeshi b14658094e add tag listings, global and section-narrowed
One global namespace (ADR-0018): /tags/{term}/ spans every section and
/{section}/tags/{term}/ narrows it. Listings group by section so one busy term
stays readable, which needed List.Groups alongside Items — list.html renders
whichever is set.

This is Query's second use, so it gained a Tag field rather than being generalised
on speculation: one filter, two callers. Tag slugs lowercase and hyphenate,
preserving script, so "Long Monsoon" and "long monsoon" are one term while Bengali
passes through unchanged. Hand-chosen slugs per term still wait for the type
declaration that owns overrides.

`tags` is reserved at the top level and inside every section, alongside `page` and
the language prefixes. A tag listing redirects to its canonical URL only once it is
known to exist, matching the rule bundles already followed — otherwise a canonical
URL for nothing confirms what is not there.

One stale test expectation fixed rather than worked around: it asserted tags land
in Extra, which stopped being true when tags became a named field.

Evidence: /tags/monsoon/ lists Hello World under posts and First Rain under comics;
/comics/tags/monsoon/ shows one; /tags/monsoon 301s; /tags/nothing/ and /tags/ 404.
2026-08-01 02:23:34 +06:00
bdeshi 60a7e10aee add paginated section index pages
The first collection page earns the Query primitive: content.Query{Section, Lang}
with Site.Run, newest first, undated after dated, ties broken by key so the same
query always answers in the same order. No cache signature — nothing caches, and a
signature with no consumer is speculation.

Pagination lives in the path (ADR-0028): page one is the bare listing URL,
/page/1/ redirects to it, and a page past the end is 404 rather than an empty page,
because an empty page is a URL that means nothing. `page` is therefore a reserved
segment inside a section, now recorded in content-model.md.

Two kinds of page means two parsed template sets already — base plus the block that
kind defines — which is ADR-0019's per-type shape arriving by need rather than by
anticipation. A head struct is embedded in both Page and List so base.html has one
contract, and theme-contract.md gains the listing fields.

Bundle gains Date, accepting an unquoted YAML date or an RFC 3339 string, since
yaml.v3 hands back time.Time for one and a string for the other.

Evidence: 12 posts → /posts/ shows 10 with rel=next to /posts/page/2/,
/posts/page/2/ shows 3 with rel=prev to /posts/, ordering is post-12 11 10,
/posts/page/1/ 301s to /posts/, /posts/page/9/ is 404, /bn/posts/ is 200.
2026-08-01 02:23:34 +06:00
bdeshi 2f253f94dd honour aliases as permanent redirects
An alias is a promise that an old URL keeps working, so it answers 301 to the
canonical one rather than serving the same content twice (ADR-0008). Frontmatter
takes a scalar or a list and tolerates surrounding slashes, because authors write
both.

Ambiguity is dropped, not resolved: an alias naming a real bundle, or claimed by
two bundles, is logged and ignored so the real bundle keeps its URL. Aliases
compose with language prefixes for free, since the resolver splits the language
before the key is looked up.

The redirect still fires only for an alias that exists, so a nonexistent path
cannot be probed by 301 — the property prompt 3 established.

Evidence: /pages/bio/ and /about/ both 301 to /pages/about/, /bn/pages/bio/ 301s
to /bn/pages/about/, and /pages/nothing/ is 404.
2026-08-01 02:23:34 +06:00
bdeshi 80057aa183 serve language variants under a prefix
The default locale stays at the root; every other language is the same key under
/{lang}/ (ADR-0009). /en/… is never live and redirects to the root form so the URL
space cannot fork. Lookup now takes a language and reports which one it served,
following requested → default → any rather than 404ing when a translation is
missing.

That is the second routing case, so the resolver is extracted to resolve.go and
the mux keeps one entry: URL shape is the resolver's business. A leading segment
counts as a language only when some bundle is written in it, so an unknown prefix
is a 404 rather than a stripped path — and a section may not be named after a
language in use, now recorded in content-model.md.

Because the served variant can differ from the URL requested, Page gained
.Canonical (the variant actually served) and .Alternates for hreflang. A theme
must never build a path, so both come from the engine.

Evidence: /bn/pages/about/ serves the Bengali body with lang="bn" and canonical
/bn/pages/about/; /bn/posts/hello-world/ falls back to English with canonical
/posts/hello-world/; /en/pages/about/ 301s to /pages/about/; /fr/… is 404.
2026-08-01 02:23:34 +06:00
bdeshi 4ba70b0459 content-model: state the language-suffix shape and the collision rule
The parser treats two or three lowercase letters before .md as a language and
rejects a key claimed by two spellings of one variant. Both were implemented and
neither was in the doc that owns the disk contract.

Folded from a separate state commit:
state: point verified-against at the commit that introduced code
2026-08-01 02:23:34 +06:00
bdeshi c468cf24c3 rename the project to khosra, initialise the module
Naming is free before a module is published, a URL is shared, or a binary is
deployed; every day it waits costs more. Swept every form: module path, binary,
cmd/ directory, KHOSRA_SITE, the feature-loop skill directory, and the prose in
earlier ADRs — which describe this project under its old name, not a different
project. Recorded as ADR-0030.

go mod init lands here rather than with the first feature because the module
path is what the rename is about. x/text and yaml.v3 are required but not yet
imported, so both are indirect and no direct dependency is claimed yet.
2026-08-01 02:23:34 +06:00
bdeshi 581d6c08ee harness: withdraw five pre-code ADRs to deferred ideas
Twenty-seven ADRs existed before a line of Go. Five specified the shape of
unbuilt mechanisms — cache validity, declared types, the settings cascade,
taxonomies, extras — which breaks the rule against abstraction before a
second concrete use, in prose where the counters cannot see it. They move to
ideas/deferred-decisions.md as recorded intent and return shaped by whatever
implements them.

Citations retargeted throughout; where one was decoration the rule now stands
on its own reasoning. Type declarations and the cascade drop to [spec] with
the MVP behaviour stated instead, so the first prompts have less to build.
conventions.md names http.ServeMux as the router, closing a hole that invited
hand-rolling a path splitter. The ADR gate now checks a number is registered
in the log rather than headed by an entry, so withdrawals resolve and invented
numbers still fail. Two architecture invariants corrected: identity no longer
implies a required language suffix, and the duplicated permalink clause is gone.
2026-08-01 02:23:33 +06:00
bdeshi 54c14a20be init 2026-07-30 00:34:18 +06:00