diff --git a/docs/content-model.md b/docs/content-model.md index 8470faa..08bbf87 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -59,8 +59,10 @@ overrides the defaults the binary embeds, so a bare root still renders. section or series landing page) rather than a plain container. - Local assets sit beside the body, referenced relatively. Assets never live in frontmatter. Moving a bundle moves its assets — the entire point of bundles. -- A directory starting with `_` other than `_index` is ignored. `draft: true` is excluded from queries - and feeds but reachable at its own URL in dev. +- A directory starting with `_` other than `_index` is ignored, and so is a **file**: `_tools.md` is a + fragment, not a bundle. That is how a file meant only to be included avoids taking a URL of its own, + appearing in its section's listing, and turning its bundle into a one-member series. +- `draft: true` is excluded from queries and feeds but reachable at its own URL in dev. ## Frontmatter @@ -298,6 +300,9 @@ all of them consequences of ADR-0038: A `gallery` inside an included file still resolves against the same bundle. +Name an included file with a leading underscore — `{{< include file="_tools.md" >}}` — or it is a bundle too, +with its own URL. + Sharing one fragment between bundles is deliberately not possible yet: it needs somewhere to keep shared parts, which is a decision about the disk contract rather than a missing feature. Transclusion of another bundle's *body* is Arc 4 and needs a cycle guard of its own. diff --git a/docs/state.md b/docs/state.md index 64847bd..7a9b8a6 100644 --- a/docs/state.md +++ b/docs/state.md @@ -9,7 +9,7 @@ If this file disagrees with the code, the code is right and this file is a bug. |---|---|---| | `go.mod` | module `khosra`; `goldmark`, `x/text`, `yaml.v3` direct | 10 | | `internal/content/doc.go` | package comment | 5 | -| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 352 | +| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building | 368 | | `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence` | 286 | | `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 386 | | `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 | @@ -19,7 +19,7 @@ If this file disagrees with the code, the code is right and this file is a bug. | `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 | | `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 | | `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 53 | -| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, 404 | 1437 | +| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, 404 | 1562 | Serves a bundle at `/{section}/{slug}/`, a paginated listing per section, tag listings global and section-narrowed, sequence navigation and a series archive on any nested bundle, and `static/` verbatim. diff --git a/internal/content/content.go b/internal/content/content.go index 036cb7c..7481872 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -84,6 +84,8 @@ func Scan(fsys fs.FS) ([]Bundle, error) { return nil case !strings.HasSuffix(p, ".md"): return nil + case isPartial(path.Base(p)): + return nil } data, err := fs.ReadFile(fsys, p) if err != nil { @@ -258,6 +260,20 @@ func isLangTag(s string) bool { return true } +// isPartial reports whether a filename is a fragment rather than a bundle of its own. +// +// An underscore prefix, the same mark a directory already uses, with `_index` excepted because that names +// the directory it sits in. Without this a file meant only to be included would also be a bundle: it would +// take a URL, appear in its section's listing, and turn its bundle into a one-member series +// (content-model.md). +func isPartial(base string) bool { + name := strings.TrimSuffix(base, ".md") + if i := strings.LastIndex(name, "."); i > 0 && isLangTag(name[i+1:]) { + name = name[:i] + } + return strings.HasPrefix(name, "_") && name != "_index" +} + // skipDir reports whether a directory is not content: hidden, or underscore-prefixed. func skipDir(base string) bool { return strings.HasPrefix(base, ".") && base != "." || strings.HasPrefix(base, "_") diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 1397f4d..057a224 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "testing" "testing/fstest" ) @@ -173,3 +174,32 @@ func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) { } } } + +func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) { + // A file meant only to be included must not also be a bundle: it would take a URL, show up in its + // section's listing, and make its bundle look like a one-member series (content-model.md). + fsys := fstest.MapFS{ + "content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nx\n")}, + "content/pages/about/_tools.md": {Data: []byte("A fragment.\n")}, + "content/pages/about/_notes.bn.md": {Data: []byte("একটি অংশ।\n")}, + "content/pages/_index.md": {Data: []byte("---\ntitle: Pages\n---\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + var keys []string + for _, b := range bundles { + keys = append(keys, b.Key) + } + sort.Strings(keys) + want := []string{"pages", "pages/about"} + if len(keys) != len(want) { + t.Fatalf("scanned %v, want %v — _index still names its directory", keys, want) + } + for i := range want { + if keys[i] != want[i] { + t.Fatalf("scanned %v, want %v", keys, want) + } + } +}