diff --git a/docs/user-guide.md b/docs/user-guide.md
index d06c79dd..ec9ae75d 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -192,6 +192,17 @@ Everything the archivist files becomes more than a pile of documents: the memory
Open it at `http://:42070` (or `memory.` if the admin set up a domain). It updates itself as things get filed.
+The menu on the left is sorted by what you look for, not by how the files are stored:
+
+- **Recent**: the topics where something was filed in the last four weeks.
+- **Diary**: the family diary, month by month.
+- **People**: a page per family member.
+- **Topics**: everything filed about a subject, grouped by area of life (money and work, health, home, the car, …). A topic appears once something is filed under it, with the number of entries next to it.
+- **Documents**: every document by type, by year and by sender.
+- **Notes & links**: every note and saved link, newest first.
+
+The menu follows the language set for your famstack.
+
Every page has an edit link: edits happen in Forgejo (the family's private git server), so every change is tracked and nothing is ever lost. If you use Obsidian, you can clone the vault and browse it there; it's all plain Markdown.
---
diff --git a/stacklets/memory/Dockerfile b/stacklets/memory/Dockerfile
index 504ebf04..ef5cc086 100644
--- a/stacklets/memory/Dockerfile
+++ b/stacklets/memory/Dockerfile
@@ -78,6 +78,19 @@ COPY quartz/components/scripts/mermaid.inline.ts ./quartz/components/scripts/mer
COPY quartz/components/FamstackTitle.tsx ./quartz/components/FamstackTitle.tsx
COPY quartz/components/Welcome.tsx ./quartz/components/Welcome.tsx
+# The family navigation: a sidebar that replaces the Explorer, the list
+# pages behind it, and the emitter that writes both. See familyModel.ts.
+COPY quartz/components/familyModel.ts ./quartz/components/familyModel.ts
+COPY quartz/components/FamilyNav.tsx ./quartz/components/FamilyNav.tsx
+COPY quartz/components/FamilyList.tsx ./quartz/components/FamilyList.tsx
+COPY quartz/components/FamilyHead.tsx ./quartz/components/FamilyHead.tsx
+COPY quartz/components/FamilyCrumbs.tsx ./quartz/components/FamilyCrumbs.tsx
+COPY quartz/components/scripts/familyNav.inline.ts ./quartz/components/scripts/familyNav.inline.ts
+COPY quartz/plugins/familyLists.tsx ./quartz/plugins/emitters/familyLists.tsx
+# The shipped ontology, for the topic areas a vault installed earlier
+# does not name yet (see readOntology in familyModel.ts).
+COPY seeds/ontology.toml ./ontology.seed.toml
+
# The entrypoint serves the vault with Quartz — a pure view. The
# curator sidecar owns the git pull. See quartz/entrypoint.sh.
COPY quartz/entrypoint.sh /usr/local/bin/wiki-entrypoint.sh
diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py
index 5183358f..0a8f3c4c 100644
--- a/stacklets/memory/bot/cli/diary.py
+++ b/stacklets/memory/bot/cli/diary.py
@@ -1193,7 +1193,7 @@ async def run(llm, argv: list[str]) -> int:
# explains how to record something.
_err(f"nothing recorded in {room_arg} yet, publishing an empty diary")
await transcriber.aclose()
- return _publish_pages(diary.pages_for([]),
+ return _publish_pages(diary.pages_for([], bucket=bucket),
bucket=bucket, dry_run=dry_run)
_err(f"{len(messages)} message(s) in {room_arg}")
@@ -1372,7 +1372,7 @@ async def _project(records, llm, summaries_cache, *, room_id: str,
summaries_cache.save()
summaries_cache.save()
- pages = diary.pages_for(entries, room_id=room_id, summaries=summaries,
+ pages = diary.pages_for(entries, bucket=bucket, room_id=room_id, summaries=summaries,
media=diary_card.media_of(on_file))
return _publish_pages(pages, bucket=bucket, dry_run=dry_run)
diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py
index 9d400e83..efc6b784 100644
--- a/stacklets/memory/bot/cli/wiki.py
+++ b/stacklets/memory/bot/cli/wiki.py
@@ -1038,7 +1038,7 @@ def _build_references_section(
title = (s.get("title") or "").strip() or "(untitled)"
date = (s.get("date") or "").strip()
rel = s.get("rel") or ""
- link = _relative_link(rel, page_dir) if rel else ""
+ link = _relative_link(_published_path(rel), page_dir) if rel else ""
head = f"- [{n}] [{title}]({link})" if link else f"- [{n}] **{title}**"
if date:
head += f" - {date}"
@@ -1046,6 +1046,21 @@ def _build_references_section(
return "\n".join(rows)
+# Diary entries are the source the month pages are compiled from, and the
+# wiki does not publish them (quartz.config.ts ignores `**/diary/entries/**`).
+# A citation of one points at the month page that shows it.
+_DIARY_ENTRY = re.compile(
+ rf"^(?P[^/]+)/{diary.DIARY_DIR}/entries/(?P\d{{4}})/(?P\d{{2}})/[^/]+\.md$")
+
+
+def _published_path(rel: str) -> str:
+ """The published page for vault file `rel`: itself, or a diary entry's month."""
+ m = _DIARY_ENTRY.match(rel.lstrip("/"))
+ if not m:
+ return rel
+ return f"{m['bucket']}/{diary.DIARY_DIR}/{m['year']}/{m['month']}.md"
+
+
def _relative_link(rel: str, page_dir: str) -> str:
"""Link to vault file `rel`, as a full path from the vault root.
diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py
index 111b2f52..99b0da0e 100644
--- a/stacklets/memory/bot/diary.py
+++ b/stacklets/memory/bot/diary.py
@@ -1122,7 +1122,7 @@ def render_month(entries, *, room_id: str = "", summary: str = "",
return "\n".join(lines).rstrip() + "\n"
-def render_year(entries) -> str:
+def render_year(entries, *, bucket: str) -> str:
"""A year's landing page: its months, and who is in them.
Deterministic. Counting entries and naming the people who appear is
@@ -1144,15 +1144,20 @@ def render_year(entries) -> str:
label = _month_name(month[0].on)
count = len(month)
lines.append(
- f"- [{label}]({key}): "
+ f"- [{label}](/{bucket}/{DIARY_DIR}/{year}/{key}): "
f"{_counted(count, 'entry_one', 'entry_many')}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
-def render_index(entries) -> str:
- """The diary's front door: what it is, and a way into every year."""
+def render_index(entries, *, bucket: str) -> str:
+ """The diary's front door: what it is, and a way into every year.
+
+ Links are root-based (`//diary/2026/about`), like every link
+ the wiki generates: the wiki resolves links from the vault root, where
+ a page-relative `2026/about` names no page.
+ """
lines = [
f"# {_L['diary_title']}",
"",
@@ -1172,7 +1177,7 @@ def render_index(entries) -> str:
count = len(year)
months = len(_by_month(year))
lines.append(
- f"- [{key}]({key}/about): "
+ f"- [{key}](/{bucket}/{DIARY_DIR}/{key}/about): "
f"{_counted(count, 'entry_one', 'entry_many')} "
f"{_L['across']} "
f"{_counted(months, 'month_one', 'month_many')}")
@@ -1202,7 +1207,7 @@ def _and_list(names: list[str]) -> str:
return ", ".join(names[:-1]) + f" {_L['and']} {names[-1]}"
-def pages_for(entries, *, room_id: str = "",
+def pages_for(entries, *, bucket: str, room_id: str = "",
summaries: "dict[str, str] | None" = None,
media: "dict[str, str] | None" = None,
) -> list[tuple[str, str, str]]:
@@ -1219,18 +1224,19 @@ def pages_for(entries, *, room_id: str = "",
contents nobody can read.
Paths are relative to the shared bucket, which the caller prefixes
- -- the bucket is named in config (`family`, `office`, a surname)
- and this module has no business knowing which.
+ -- the bucket is named in config (`family`, `office`, a surname).
+ `bucket` is that name, needed only for the links between these pages,
+ which the wiki resolves from the vault root.
`summaries` and `media` arrive the same way and for the same
reason: both are the result of work this module cannot do (a model
call; a download and a file write), handed in keyed by month and by
event id so rendering stays a pure function of its inputs.
"""
- out = [(f"{DIARY_DIR}/about.md", render_index(entries), _L["diary_title"])]
+ out = [(f"{DIARY_DIR}/about.md", render_index(entries, bucket=bucket), _L["diary_title"])]
for year, in_year in sorted(_by_year(entries).items()):
out.append((
- f"{DIARY_DIR}/{year}/about.md", render_year(in_year), year,
+ f"{DIARY_DIR}/{year}/about.md", render_year(in_year, bucket=bucket), year,
))
for month, in_month in sorted(_by_month(in_year).items()):
out.append((
diff --git a/stacklets/memory/docker-compose.yml b/stacklets/memory/docker-compose.yml
index 0ad741dd..6799d8b5 100644
--- a/stacklets/memory/docker-compose.yml
+++ b/stacklets/memory/docker-compose.yml
@@ -56,6 +56,9 @@ services:
# The household's own name for the site. Blank is expected on
# instances predating stack_owner, and quartz.config.ts falls back.
WIKI_TITLE: ${WIKI_TITLE:-}
+ # The instance language: labels in the navigation and Quartz's own
+ # strings (dates, "Backlinks") follow it.
+ WIKI_LANGUAGE: ${LANGUAGE:-en}
ports:
# Container's Quartz preview server listens on 8080; we publish
# on the stacklet's declared port (42070). PORT_BIND_IP is set
diff --git a/stacklets/memory/quartz/components/FamilyCrumbs.tsx b/stacklets/memory/quartz/components/FamilyCrumbs.tsx
new file mode 100644
index 00000000..f5a3b85a
--- /dev/null
+++ b/stacklets/memory/quartz/components/FamilyCrumbs.tsx
@@ -0,0 +1,54 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { FullSlug, resolveRelative } from "../util/path"
+import { classNames } from "../util/lang"
+import { familyModel } from "./familyModel"
+
+// NEW COMPONENT (not an upstream override): breadcrumbs that follow the
+// family navigation, in place of upstream's, which follow the folders
+// ("family › diary › 2026"). A page the menu does not list takes the
+// path of what it belongs to: a document its type, a note its topic.
+const FamilyCrumbs: QuartzComponent = ({ ctx, fileData, allFiles, displayClass }: QuartzComponentProps) => {
+ const here = fileData.slug as FullSlug
+ const trail = familyModel(ctx, allFiles).crumbs(here)
+ return (
+
+ )
+}
+
+FamilyCrumbs.css = `
+.family-crumbs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.2rem 0.5rem;
+ margin: 0 0 1.1rem;
+ font-family: var(--codeFont);
+ font-size: 0.75rem;
+ letter-spacing: 0.02em;
+ color: var(--gray);
+}
+
+.family-crumbs a {
+ color: var(--secondary);
+ font-weight: 400;
+ text-decoration: none;
+}
+
+.family-crumbs a:hover {
+ color: var(--tertiary);
+}
+
+.family-crumbs .fc-sep {
+ color: var(--gray);
+ opacity: 0.5;
+}
+`
+
+export default (() => FamilyCrumbs) satisfies QuartzComponentConstructor
diff --git a/stacklets/memory/quartz/components/FamilyHead.tsx b/stacklets/memory/quartz/components/FamilyHead.tsx
new file mode 100644
index 00000000..f56e35b7
--- /dev/null
+++ b/stacklets/memory/quartz/components/FamilyHead.tsx
@@ -0,0 +1,112 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { FullSlug, resolveRelative } from "../util/path"
+import { classNames } from "../util/lang"
+import { familyModel, fmtDate } from "./familyModel"
+
+// NEW COMPONENT (not an upstream override): the page header, in place of
+// upstream's ArticleTitle, ContentMeta and TagList.
+//
+// DOCUMENT · INVOICE a mono label: what this is
+// Springfield Power & Light Bill one serif title
+// 31 Mar 2026 · Springfield P&L [Utility] [Marge]
+//
+// The chips link a record to its topics and people, which is how a
+// document reached from Documents leads on to the topic it belongs to.
+//
+// Many generated pages open with their own `# Heading`. That heading is
+// the better title (the home page's is the family's name, not "Family
+// Memory"), so it becomes the title here, and custom.scss hides the
+// copy at the top of the article. One title per page either way.
+
+type Hast = { type: string; tagName?: string; value?: string; children?: Hast[] }
+const textOf = (n: Hast): string => n.value ?? (n.children ?? []).map(textOf).join("")
+const leadingH1 = (tree: Hast | undefined) => {
+ const first = (tree?.children ?? []).find((n) => n.type === "element")
+ return first?.tagName === "h1" ? textOf(first).trim() : undefined
+}
+
+const FamilyHead: QuartzComponent = ({ ctx, fileData, allFiles, tree, displayClass }: QuartzComponentProps) => {
+ const here = fileData.slug as FullSlug
+ const head = familyModel(ctx, allFiles).head(here)
+ const title = leadingH1(tree as Hast) ?? fileData.frontmatter?.title
+ const facts = [head.date ? fmtDate(head.date) : "", ...head.meta].filter(Boolean)
+
+ return (
+ // A div, not : Quartz styles every header element as a flex row.
+
)
}
@@ -37,31 +41,50 @@ FamstackTitle.css = `
.famstack-title {
display: flex;
flex-direction: column;
- /* Room for the title's underline to sit clear of the wordmark. */
- gap: 0.5rem;
+ gap: 0.3rem;
+ padding-bottom: 0.8rem;
+ border-bottom: 1px solid rgba(61, 143, 160, 0.14);
}
+/* The family's name in the headline face, like the page titles. It is
+ the way back to the front page from anywhere; the hover says so. */
.famstack-title .page-title {
- font-size: 1.6rem;
+ font-size: 1.65rem;
margin: 0;
- font-family: var(--titleFont);
- font-weight: 600;
- letter-spacing: -0.03em;
+ font-family: var(--headerFont);
+ font-weight: 500;
+ letter-spacing: -0.02em;
line-height: 1.1;
}
-/* Underlined, because it is the way back to the front page from
- anywhere and should look like somewhere you can go. Drawn as a
- border rather than text-decoration so it sits clear of the
- descenders in a name like Simpsons. */
.famstack-title .page-title > a {
- border-bottom: 2px solid var(--secondary);
- padding-bottom: 2px;
- transition: border-color 0.2s ease;
+ color: var(--dark);
+ font-weight: 500;
+ transition: color 0.2s ease;
}
.famstack-title .page-title > a:hover {
- border-bottom-color: var(--tertiary);
+ color: var(--tertiary);
+}
+
+.famstack-title .fs-byline {
+ display: flex;
+ align-items: baseline;
+ gap: 0.45rem;
+ margin: 0;
+}
+
+.famstack-title .fs-tag {
+ font-family: var(--codeFont);
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--gray);
+}
+
+.famstack-title .fs-tag::before {
+ content: "·";
+ margin-right: 0.45rem;
}
.famstack-title .fs-brandmark {
@@ -103,8 +126,9 @@ FamstackTitle.css = `
/* On mobile the sidebar becomes a header row and space is tight, so
the maker's mark steps aside and the wiki name carries it alone. */
@media all and (max-width: 800px) {
- .famstack-title .fs-brandmark { display: none; }
- .famstack-title .page-title { font-size: 1.3rem; }
+ .famstack-title .fs-byline { display: none; }
+ .famstack-title { padding-bottom: 0; border-bottom: 0; }
+ .famstack-title .page-title { font-size: 1.2rem; }
}
`
diff --git a/stacklets/memory/quartz/components/Welcome.tsx b/stacklets/memory/quartz/components/Welcome.tsx
index 2231f031..b8b06716 100644
--- a/stacklets/memory/quartz/components/Welcome.tsx
+++ b/stacklets/memory/quartz/components/Welcome.tsx
@@ -1,5 +1,6 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
+import { L } from "./familyModel"
// NEW COMPONENT (not an upstream override) — the greeting on the home
// page, and only there. The layout gates it on the index slug.
@@ -10,17 +11,13 @@ import { classNames } from "../util/lang"
// written into it would survive exactly until the next rebuild. Chrome
// belongs in the chrome.
//
-// The copy is English because quartz.config.ts pins locale to en-US.
-// When the wiki is localised, this string moves with the locale rather
-// than staying here.
+// The copy follows the instance language, from the label table in
+// familyModel.ts.
const Welcome: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
return (
-
- Everything the household has kept: documents, the notes about them, and the people they
- belong to.
-
-
Search from the sidebar, or start with a name.
+
{L.welcome}
+
{L.welcomeHint}
)
}
diff --git a/stacklets/memory/quartz/components/familyModel.ts b/stacklets/memory/quartz/components/familyModel.ts
new file mode 100644
index 00000000..fecf83dd
--- /dev/null
+++ b/stacklets/memory/quartz/components/familyModel.ts
@@ -0,0 +1,741 @@
+import fs from "fs"
+import path from "path"
+import { QuartzPluginData } from "../plugins/vfile"
+import { BuildCtx } from "../util/ctx"
+
+// NEW MODULE (not an upstream override): the family navigation, computed
+// at build time from the vault and read by FamilyNav and FamilyLists.
+//
+// The vault's folders are laid out for the programs that write them:
+// documents by date, captures under the topic room they came from, one
+// folder per bucket. The family asks different questions (what is going
+// on, what do we have about the car, where is the diary), so the sidebar
+// is a second structure computed over the same files. Nothing moves on
+// disk: a path is a page's identity, and links and re-filing depend on it.
+//
+// Two dimensions. Topics say what something is about, Documents say what
+// kind of paper it is. A document is listed once under Documents and
+// counted under every topic it is tagged with.
+
+// ── Language ─────────────────────────────────────────────────────────────
+
+export type Lang = "en" | "de"
+
+export const lang: Lang = (process.env.WIKI_LANGUAGE ?? "en").toLowerCase().startsWith("de")
+ ? "de"
+ : "en"
+
+const LABELS = {
+ en: {
+ nav: "Family wiki",
+ menu: "Menu",
+ start: "Start",
+ recent: "Recent",
+ diary: "Diary",
+ people: "People",
+ topics: "Topics",
+ otherTopics: "Other topics",
+ documents: "Documents",
+ needsAttention: "Needs attention",
+ byType: "By type",
+ byYear: "By year",
+ senders: "Senders",
+ emails: "Emails",
+ notesLinks: "Notes & links",
+ notes: "Notes",
+ bookmarks: "Bookmarks",
+ tasks: "Open tasks",
+ nothing: "Nothing here yet.",
+ needsAttentionIntro:
+ "Payment reminders, and documents from the last eight weeks that still have an open action item.",
+ typeIntro: "Every document of this kind, newest first.",
+ topicIntro: "Everything filed about this topic, newest first.",
+ notesIntro: "Every note the family has saved, newest first.",
+ bookmarksIntro: "Every link the family has saved, newest first.",
+ documentsIntro: "Every document the family has filed. The latest are below; the menu sorts them by type, year and sender.",
+ yearIntro: "Every document dated this year, newest first.",
+ latest: "Latest",
+ document: "Document",
+ note: "Note",
+ bookmark: "Link",
+ email: "Email",
+ person: "Person",
+ topic: "Topic",
+ sender: "Sender",
+ filedBy: "filed by",
+ updated: "updated",
+ familyWiki: "family wiki",
+ welcome: "Everything the family has kept: documents, notes, the diary, and the people they belong to.",
+ welcomeHint: "Start with the menu on the left, or search for a name.",
+ editOnForgejo: "Edit on Forgejo",
+ },
+ de: {
+ nav: "Familienwiki",
+ menu: "Menü",
+ start: "Start",
+ recent: "Aktuell",
+ diary: "Tagebuch",
+ people: "Personen",
+ topics: "Themen",
+ otherTopics: "Weitere Themen",
+ documents: "Dokumente",
+ needsAttention: "Zu erledigen",
+ byType: "Nach Art",
+ byYear: "Nach Jahr",
+ senders: "Absender",
+ emails: "E-Mails",
+ notesLinks: "Notizen & Links",
+ notes: "Notizen",
+ bookmarks: "Lesezeichen",
+ tasks: "Offene Aufgaben",
+ nothing: "Hier ist noch nichts.",
+ needsAttentionIntro:
+ "Mahnungen und Dokumente der letzten acht Wochen mit einer offenen Aufgabe.",
+ typeIntro: "Alle Dokumente dieser Art, die neuesten zuerst.",
+ topicIntro: "Alles, was zu diesem Thema abgelegt ist, das Neueste zuerst.",
+ notesIntro: "Alle Notizen der Familie, die neuesten zuerst.",
+ bookmarksIntro: "Alle Links der Familie, die neuesten zuerst.",
+ documentsIntro: "Alle Dokumente der Familie. Unten die neuesten; im Menü nach Art, Jahr und Absender sortiert.",
+ yearIntro: "Alle Dokumente aus diesem Jahr, die neuesten zuerst.",
+ latest: "Neueste",
+ document: "Dokument",
+ note: "Notiz",
+ bookmark: "Link",
+ email: "E-Mail",
+ person: "Person",
+ topic: "Thema",
+ sender: "Absender",
+ filedBy: "abgelegt von",
+ updated: "aktualisiert",
+ familyWiki: "Familienwiki",
+ welcome: "Alles, was die Familie aufbewahrt: Dokumente, Notizen, das Tagebuch und die Menschen, zu denen sie gehören.",
+ welcomeHint: "Fangt im Menü links an oder sucht nach einem Namen.",
+ editOnForgejo: "In Forgejo bearbeiten",
+ },
+} as const
+
+export const L = LABELS[lang]
+
+export const locale = lang === "de" ? "de-DE" : "en-US"
+const dateFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric" })
+export const fmtDate = (d: Date) => dateFormat.format(d)
+
+// ── Tuning ───────────────────────────────────────────────────────────────
+
+// A topic counts as recent when something was filed in it within this
+// window. The list is capped, so a bulk import does not turn every topic
+// into news.
+const RECENT_DAYS = 28
+const RECENT_MAX = 5
+const ATTENTION_DAYS = 56
+const SENDERS_MAX = 5
+
+// Folders under the shared bucket that hold a kind of record rather than
+// a topic.
+const KIND_FOLDERS = new Set(["documents", "diary", "correspondents", "emails", "notes", "bookmarks", "media"])
+
+// Accounts that own a bucket but are not people.
+const isAccount = (slug: string) => slug === "admin" || slug.endsWith("-bot")
+
+// ── Ontology ─────────────────────────────────────────────────────────────
+//
+// `ontology.toml` sits at the vault root. Quartz does not read TOML, and
+// the file uses a small, regular subset (section headers, inline tables,
+// string arrays), so it is parsed here by pattern rather than pulling in a
+// TOML dependency the image does not have.
+
+type Names = Record
+interface Entry {
+ id: string
+ names: Names
+ synonyms: string[]
+ area?: string
+}
+export interface Ontology {
+ topics: Entry[]
+ doctypes: Entry[]
+ areas: Entry[]
+}
+
+export function parseOntology(text: string): Ontology {
+ const out: Ontology = { topics: [], doctypes: [], areas: [] }
+ const header = /^\[(topic|doctype|area)\.([A-Za-z0-9_]+)\]\s*$/gm
+ const heads = [...text.matchAll(header)]
+ heads.forEach((m, i) => {
+ const body = text.slice(m.index! + m[0].length, heads[i + 1]?.index ?? text.length)
+ const names: Names = {}
+ const nm = body.match(/^names\s*=\s*\{([^}]*)\}/m)
+ if (nm) for (const [, k, v] of nm[1].matchAll(/(\w+)\s*=\s*"([^"]*)"/g)) names[k] = v
+ const synonyms: string[] = []
+ const sm = body.match(/^synonyms\s*=\s*\{([\s\S]*?)\}\s*$/m)
+ if (sm) for (const [, v] of sm[1].matchAll(/"([^"]*)"/g)) synonyms.push(v)
+ const area = body.match(/^area\s*=\s*"([^"]+)"/m)?.[1]
+ const entry: Entry = { id: m[2], names, synonyms, area }
+ ;({ topic: out.topics, doctype: out.doctypes, area: out.areas })[m[1] as "topic"].push(entry)
+ })
+ return out
+}
+
+const readFile = (p: string) => {
+ try {
+ return fs.readFileSync(p, "utf8")
+ } catch {
+ return ""
+ }
+}
+
+// The vault's ontology is the family's copy: seeded once at install and
+// theirs from then on. The seed that ships with this image supplies the
+// areas a vault does not name yet, so an instance installed before areas
+// existed groups its topics after a restart, with no edit to its vault.
+// Whatever the vault says wins: its own areas, and a topic's own `area`.
+function readOntology(ctx: BuildCtx): Ontology {
+ const vault = parseOntology(readFile(path.join(ctx.argv.directory, "ontology.toml")))
+ const seed = parseOntology(readFile(path.join(process.cwd(), "ontology.seed.toml")))
+ const seedArea = new Map(seed.topics.map((t) => [t.id, t.area]))
+ return {
+ topics: vault.topics.map((t) => ({ ...t, area: t.area ?? seedArea.get(t.id) })),
+ doctypes: vault.doctypes,
+ areas: vault.areas.length ? vault.areas : seed.areas,
+ }
+}
+
+const nameOf = (e: Entry) => e.names[lang] ?? e.names.en ?? e.id
+
+// Tags, categories and folder slugs name a topic in whatever language the
+// instance files in, with or without umlauts ("behorde" for Behörde).
+// Folding them all to one key lets any of those spellings find the entry.
+export function fold(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/ä/g, "a")
+ .replace(/ö/g, "o")
+ .replace(/ü/g, "u")
+ .replace(/ß/g, "ss")
+ .normalize("NFKD")
+ .replace(/[̀-ͯ]/g, "")
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim()
+}
+
+function indexEntries(entries: Entry[]): Map {
+ const idx = new Map()
+ for (const e of entries) {
+ for (const key of [e.id, e.id.replace(/_/g, " "), ...Object.values(e.names), ...e.synonyms]) {
+ const k = fold(key)
+ if (k && !idx.has(k)) idx.set(k, e)
+ }
+ }
+ return idx
+}
+
+// ── The model ────────────────────────────────────────────────────────────
+
+export interface NavNode {
+ key?: string
+ label: string
+ slug?: string
+ count?: number
+ open?: boolean
+ children?: NavNode[]
+}
+
+export interface ListSection {
+ heading?: string
+ slugs?: string[]
+ tasks?: { text: string; slug: string }[]
+}
+
+export interface ListPage {
+ slug: string
+ title: string
+ intro: string
+ sections: ListSection[]
+}
+
+// What the page header shows above and below a page's title.
+export interface Head {
+ kicker: string[]
+ meta: string[]
+ date?: Date
+ chips: { label: string; slug?: string }[]
+}
+
+export interface Model {
+ nav: NavNode[]
+ lists: Map
+ head: (slug: string) => Head
+ crumbs: (slug: string) => NavNode[]
+ describe: (f: QuartzPluginData) => { date?: Date; meta: string[] }
+}
+
+type Kind = "document" | "note" | "bookmark" | "email"
+const RECORD_KINDS = new Set(["document", "note", "bookmark", "email"])
+
+interface Rec {
+ file: QuartzPluginData
+ slug: string
+ kind: Kind
+ filed?: Date
+ // The date a list sorts by: a document's own date, else when it was filed.
+ dated?: Date
+}
+
+interface Topic {
+ key: string
+ label: string
+ slug?: string
+ area: string
+ room: boolean
+ records: Rec[]
+ tasks: { text: string; slug: string }[]
+ last?: Date
+}
+
+const fm = (f: QuartzPluginData) => (f.frontmatter ?? {}) as Record
+const asList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : v ? [String(v)] : [])
+
+function parseDate(v: unknown): Date | undefined {
+ if (!v) return undefined
+ const d = new Date(String(v))
+ return isNaN(d.getTime()) ? undefined : d
+}
+
+function readText(ctx: BuildCtx, f: QuartzPluginData): string {
+ try {
+ return fs.readFileSync(path.join(ctx.argv.directory, String(f.relativePath)), "utf8")
+ } catch {
+ return ""
+ }
+}
+
+// `- [ ] Pay the bill by Friday` → "Pay the bill by Friday"
+const openTasks = (text: string) =>
+ [...text.matchAll(/^\s*[-*] \[ \] (.+)$/gm)].map((m) => m[1].trim())
+
+const newest = (a?: Date, b?: Date) => (!a ? b : !b ? a : a > b ? a : b)
+const byDatedDesc = (a: Rec, b: Rec) => (b.dated?.getTime() ?? 0) - (a.dated?.getTime() ?? 0)
+
+// Every page of a build renders the sidebar, and the emitters each pass
+// their own copy of the file list, so the model is kept per list.
+const cache = new WeakMap()
+
+export function familyModel(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
+ const hit = cache.get(allFiles)
+ if (hit) return hit
+ const model = build(ctx, allFiles)
+ cache.set(allFiles, model)
+ return model
+}
+
+function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
+ const onto = readOntology(ctx)
+ const topicIdx = indexEntries(onto.topics)
+ const typeIdx = indexEntries(onto.doctypes)
+ const now = Date.now()
+ const daysAgo = (d?: Date) => (d ? (now - d.getTime()) / 86_400_000 : Infinity)
+
+ // The shared bucket is wherever the diary or the documents live;
+ // `family` unless the instance renamed it.
+ const bucket =
+ allFiles
+ .map((f) => String(f.slug ?? "").match(/^([^/]+)\/(diary\/about|documents\/)/)?.[1])
+ .find(Boolean) ?? "family"
+
+ // ── topics ──
+ const topics = new Map()
+ const topicFor = (entry: Entry): Topic => {
+ let t = topics.get(entry.id)
+ if (!t) {
+ t = { key: entry.id, label: nameOf(entry), area: entry.area ?? "", room: false, records: [], tasks: [] }
+ topics.set(entry.id, t)
+ }
+ return t
+ }
+ // A folder under the shared bucket is a topic. When its name is an
+ // ontology topic in any language, it is that topic, so a "Reise" room
+ // and the travel topic stay one entry.
+ const folderTopic = (seg: string): Topic => {
+ const entry = topicIdx.get(fold(seg))
+ if (entry) return topicFor(entry)
+ const key = "room:" + seg
+ let t = topics.get(key)
+ if (!t) {
+ t = { key, label: seg, area: "", room: true, records: [], tasks: [] }
+ topics.set(key, t)
+ }
+ return t
+ }
+
+ const records: Rec[] = []
+ const recTopics = new Map()
+ const people: NavNode[] = []
+ const diaryYears = new Map()
+ let diaryRoot: string | undefined
+
+ for (const f of allFiles) {
+ const slug = String(f.slug ?? "")
+ const meta = fm(f)
+ const segs = slug.split("/")
+
+ // people: a member's hub page at the vault root
+ if (segs.length === 2 && segs[1] === "about" && meta.type === "person" && !isAccount(segs[0])) {
+ people.push({ label: String(meta.title ?? segs[0]), slug })
+ continue
+ }
+
+ // diary: /diary/about, /diary/YYYY/about, /diary/YYYY/MM
+ if (segs[0] === bucket && segs[1] === "diary" && segs[2] !== "entries") {
+ if (segs.length === 3 && segs[2] === "about") diaryRoot = slug
+ else if (/^\d{4}$/.test(segs[2] ?? "")) {
+ const y = diaryYears.get(segs[2]) ?? { slug: "", label: segs[2], months: [] }
+ if (segs[3] === "about") y.slug = slug
+ else if (/^\d{2}$/.test(segs[3] ?? ""))
+ y.months.push({ label: String(meta.title ?? segs[3]).replace(/\s*\d{4}$/, ""), slug })
+ diaryYears.set(segs[2], y)
+ }
+ continue
+ }
+
+ // topic folders: about page and task list
+ if (segs[0] === bucket && segs.length === 3 && !KIND_FOLDERS.has(segs[1])) {
+ const t = folderTopic(segs[1])
+ if (segs[2] === "about") {
+ t.slug = slug
+ if (t.room) t.label = String(meta.title ?? t.label)
+ if (meta.area) t.area = String(meta.area)
+ } else if (segs[2] === "todos") {
+ const open = openTasks(readText(ctx, f)).map((text) => ({ text, slug }))
+ t.tasks.push(...open)
+ if (open.length) t.last = newest(t.last, f.dates?.modified)
+ }
+ continue
+ }
+
+ if (!RECORD_KINDS.has(meta.type)) continue
+ const rec: Rec = {
+ file: f,
+ slug,
+ kind: meta.type as Kind,
+ filed: parseDate(meta.timestamp) ?? parseDate(meta.date) ?? f.dates?.modified,
+ }
+ rec.dated = meta.type === "document" ? parseDate(meta.date) ?? rec.filed : rec.filed
+ records.push(rec)
+
+ // A record belongs to every topic its category or tags name, and to
+ // the topic folder it was filed in.
+ const hits = new Set()
+ for (const tag of [meta.category, ...asList(meta.tags)]) {
+ if (!tag || /^person:/i.test(String(tag))) continue
+ const entry = topicIdx.get(fold(String(tag)))
+ if (entry) hits.add(topicFor(entry))
+ }
+ if (segs[0] === bucket && segs.length > 2 && !KIND_FOLDERS.has(segs[1])) hits.add(folderTopic(segs[1]))
+ recTopics.set(slug, [...hits])
+ for (const t of hits) {
+ t.records.push(rec)
+ t.last = newest(t.last, rec.filed)
+ }
+ }
+
+ const lists = new Map()
+ const addList = (page: ListPage) => lists.set(page.slug, page)
+ const slugs = (rs: Rec[]) => [...rs].sort(byDatedDesc).map((r) => r.slug)
+
+ // Every visible topic gets a page: its own about page when the curator
+ // wrote one, otherwise a list page with its tasks and records.
+ const visible = [...topics.values()].filter(
+ (t) => t.records.length > 0 || t.tasks.length > 0,
+ )
+ for (const t of visible) {
+ if (t.slug) continue
+ t.slug = `lists/topic/${t.key.replace(/^room:/, "")}`
+ const by = (k: Kind) => slugs(t.records.filter((r) => r.kind === k))
+ addList({
+ slug: t.slug,
+ title: t.label,
+ intro: L.topicIntro,
+ sections: [
+ { heading: L.tasks, tasks: t.tasks },
+ { heading: L.documents, slugs: by("document") },
+ { heading: L.notes, slugs: by("note") },
+ { heading: L.bookmarks, slugs: by("bookmark") },
+ { heading: L.emails, slugs: by("email") },
+ ],
+ })
+ }
+ const topicNode = (t: Topic): NavNode => ({
+ label: t.label,
+ slug: t.slug,
+ count: t.records.length || undefined,
+ })
+
+ // ── recent ──
+ const recent = visible
+ .filter((t) => daysAgo(t.last) <= RECENT_DAYS)
+ .sort((a, b) => (b.last?.getTime() ?? 0) - (a.last?.getTime() ?? 0))
+ .slice(0, RECENT_MAX)
+
+ // ── topics by area ──
+ // An area shows when one of its topics does. With a single topic it
+ // collapses into it; a topic named like its area (Gesundheit under
+ // Gesundheit) becomes the area's own link instead of repeating.
+ // Chat-room topics come first: the family made them.
+ const areaNodes: NavNode[] = []
+ const areaIds = [...onto.areas.map((a) => a.id), ""]
+ for (const id of areaIds) {
+ const area = onto.areas.find((a) => a.id === id)
+ const label = area ? nameOf(area) : L.otherTopics
+ const members = visible
+ .filter((t) => (area ? t.area === id : !onto.areas.some((a) => a.id === t.area)))
+ .filter((t) => t.key !== "credential")
+ .sort((a, b) => Number(b.room) - Number(a.room) || a.label.localeCompare(b.label, lang))
+ if (members.length === 0) continue
+ // "Other topics" is a shelf, not an area: it never takes a topic's place.
+ if (members.length === 1 && area) {
+ areaNodes.push({ ...topicNode(members[0]), label })
+ continue
+ }
+ const same = members.find((t) => fold(t.label) === fold(label))
+ const rest = members.filter((t) => t !== same)
+ areaNodes.push({
+ label,
+ slug: same?.slug,
+ count: members.reduce((n, t) => n + t.records.length, 0) || undefined,
+ children: rest.map(topicNode),
+ })
+ }
+
+ // ── documents ──
+ const docs = records.filter((r) => r.kind === "document")
+ const reminder = onto.doctypes.find((d) => d.id === "payment_reminder")
+ const attention = docs.filter((r) => {
+ const m = fm(r.file)
+ if (daysAgo(parseDate(m.date) ?? r.filed) > ATTENTION_DAYS) return false
+ if (reminder && typeIdx.get(fold(String(m.document_type ?? ""))) === reminder) return true
+ return openTasks(readText(ctx, r.file)).length > 0
+ })
+ if (attention.length)
+ addList({
+ slug: "lists/needs-attention",
+ title: L.needsAttention,
+ intro: L.needsAttentionIntro,
+ sections: [{ slugs: slugs(attention) }],
+ })
+
+ const types = new Map()
+ for (const r of docs) {
+ const raw = String(fm(r.file).document_type ?? "")
+ if (!raw) continue
+ const entry = typeIdx.get(fold(raw))
+ const key = entry?.id ?? fold(raw).replace(/ /g, "-")
+ const g = types.get(key) ?? { label: entry ? nameOf(entry) : raw, recs: [] }
+ g.recs.push(r)
+ types.set(key, g)
+ }
+ const typeNodes = [...types.entries()]
+ .sort((a, b) => b[1].recs.length - a[1].recs.length || a[1].label.localeCompare(b[1].label, lang))
+ .map(([key, g]) => {
+ const slug = `lists/type/${key}`
+ addList({ slug, title: g.label, intro: L.typeIntro, sections: [{ slugs: slugs(g.recs) }] })
+ return { label: g.label, slug, count: g.recs.length }
+ })
+
+ const years = new Map()
+ for (const r of docs) {
+ const y = r.slug.split("/")[2]
+ if (/^\d{4}$/.test(y)) years.set(y, [...(years.get(y) ?? []), r])
+ }
+ const yearNodes = [...years.entries()]
+ .sort((a, b) => Number(b[0]) - Number(a[0]))
+ .map(([y, rs]) => {
+ const slug = `lists/year/${y}`
+ addList({ slug, title: y, intro: L.yearIntro, sections: [{ slugs: slugs(rs) }] })
+ return { label: y, slug, count: rs.length }
+ })
+ if (docs.length)
+ addList({
+ slug: "lists/documents",
+ title: L.documents,
+ intro: L.documentsIntro,
+ sections: [
+ { heading: L.needsAttention, slugs: slugs(attention) },
+ { heading: L.latest, slugs: slugs(docs).slice(0, 30) },
+ ],
+ })
+
+ // Senders: correspondent pages, the ones that wrote most recently first.
+ const corrPages = new Map()
+ for (const f of allFiles) {
+ const m = fm(f)
+ if (m.type === "correspondent") corrPages.set(fold(String(m.canonical ?? m.title ?? "")), String(f.slug))
+ }
+ const lastFrom = new Map()
+ for (const r of docs) {
+ const c = fold(String(fm(r.file).correspondent ?? ""))
+ if (corrPages.has(c)) lastFrom.set(c, newest(lastFrom.get(c), parseDate(fm(r.file).date) ?? r.filed))
+ }
+ const senderNodes = [...lastFrom.entries()]
+ .sort((a, b) => (b[1]?.getTime() ?? 0) - (a[1]?.getTime() ?? 0))
+ .slice(0, SENDERS_MAX)
+ .map(([c]) => {
+ const slug = corrPages.get(c)!
+ const page = allFiles.find((f) => f.slug === slug)
+ return { label: String(fm(page!).title ?? c), slug }
+ })
+
+ const emails = records.filter((r) => r.kind === "email")
+ const docChildren: NavNode[] = []
+ if (attention.length)
+ docChildren.push({ label: L.needsAttention, slug: "lists/needs-attention", count: attention.length })
+ if (typeNodes.length) docChildren.push({ label: L.byType, children: typeNodes })
+ if (yearNodes.length) docChildren.push({ label: L.byYear, children: yearNodes })
+ if (corrPages.size)
+ docChildren.push({ label: L.senders, slug: `${bucket}/correspondents/index`, count: corrPages.size, children: senderNodes })
+ if (emails.length) docChildren.push({ label: L.emails, slug: `${bucket}/emails/index`, count: emails.length })
+
+ // ── notes & links ──
+ const notes = records.filter((r) => r.kind === "note")
+ const bookmarks = records.filter((r) => r.kind === "bookmark")
+ const captureNodes: NavNode[] = []
+ if (notes.length) {
+ addList({ slug: "lists/notes", title: L.notes, intro: L.notesIntro, sections: [{ slugs: slugs(notes) }] })
+ captureNodes.push({ label: L.notes, slug: "lists/notes", count: notes.length })
+ }
+ if (bookmarks.length) {
+ addList({ slug: "lists/bookmarks", title: L.bookmarks, intro: L.bookmarksIntro, sections: [{ slugs: slugs(bookmarks) }] })
+ captureNodes.push({ label: L.bookmarks, slug: "lists/bookmarks", count: bookmarks.length })
+ }
+
+ // ── assemble, in the agreed order ──
+ const nav: NavNode[] = [{ key: "start", label: L.start, slug: "index" }]
+ if (recent.length) nav.push({ key: "recent", label: L.recent, open: true, children: recent.map(topicNode) })
+ if (diaryRoot || diaryYears.size) {
+ const yearsDesc = [...diaryYears.values()].sort((a, b) => Number(b.label) - Number(a.label))
+ nav.push({
+ key: "diary",
+ label: L.diary,
+ slug: diaryRoot,
+ children: yearsDesc.map((y) => ({
+ label: y.label,
+ slug: y.slug || undefined,
+ children: y.months.sort((a, b) => b.slug!.localeCompare(a.slug!)),
+ })),
+ })
+ }
+ if (people.length)
+ nav.push({ key: "people", label: L.people, children: people.sort((a, b) => a.label.localeCompare(b.label, lang)) })
+ if (areaNodes.length) nav.push({ key: "topics", label: L.topics, children: areaNodes })
+ if (docs.length || emails.length)
+ nav.push({ key: "documents", label: L.documents, slug: "lists/documents", count: docs.length, children: docChildren })
+ if (captureNodes.length) nav.push({ key: "notes", label: L.notesLinks, children: captureNodes })
+
+ // ── page header and breadcrumbs ──
+ const bySlug = new Map(allFiles.map((f) => [String(f.slug), f]))
+ const peopleBy = new Map(people.map((p) => [fold(p.label), p.slug!]))
+ const personChip = (name: string) => {
+ const slug = peopleBy.get(fold(name)) ?? peopleBy.get(fold(name.split(" ")[0]))
+ return { label: name.charAt(0).toUpperCase() + name.slice(1), slug }
+ }
+ const kindLabel: Record = { note: L.note, bookmark: L.bookmark, email: L.email }
+
+ const describe = (f: QuartzPluginData) => {
+ const m = fm(f)
+ const meta: string[] = []
+ let date: Date | undefined
+ if (m.type === "document") {
+ const e = typeIdx.get(fold(String(m.document_type ?? "")))
+ if (m.document_type) meta.push(e ? nameOf(e) : String(m.document_type))
+ if (m.correspondent) meta.push(String(m.correspondent))
+ date = parseDate(m.date) ?? parseDate(m.timestamp)
+ } else {
+ meta.push(kindLabel[m.type] ?? String(m.type))
+ if (m.type === "bookmark" && m.resource) {
+ try {
+ meta.push(new URL(String(m.resource)).hostname.replace(/^www\./, ""))
+ } catch {}
+ }
+ date = parseDate(m.timestamp) ?? parseDate(m.date)
+ }
+ const persons = asList(m.persons)
+ if (persons.length) meta.push(persons.map((p) => personChip(p).label).join(", "))
+ return { date, meta }
+ }
+
+ const trim = (s?: string) => (s ?? "").replace(/\/index$/, "")
+ const pathTo = (nodes: NavNode[], slug: string, trail: NavNode[] = []): NavNode[] | undefined => {
+ for (const n of nodes) {
+ if (n.key === "recent") continue
+ if (n.slug && trim(n.slug) === trim(slug)) return trail
+ const hit = n.children && pathTo(n.children, slug, [...trail, n])
+ if (hit) return hit
+ }
+ return undefined
+ }
+ const topLevel = (key: string) => nav.find((n) => n.key === key)
+ const crumbs = (slug: string): NavNode[] => {
+ const start = nav[0]
+ const inNav = pathTo(nav, slug)
+ if (inNav) return [start, ...inNav]
+ const m = fm(bySlug.get(slug) ?? ({} as QuartzPluginData))
+ const topic = recTopics.get(slug)?.find((t) => t.room && t.slug)
+ if (topic) {
+ const trail = pathTo(nav, topic.slug!)
+ if (trail) return [start, ...trail, topicNode(topic)]
+ }
+ if (m.type === "document") {
+ const e = typeIdx.get(fold(String(m.document_type ?? "")))
+ const node = topLevel("documents")?.children?.find((c) => c.label === L.byType)?.children
+ ?.find((c) => c.slug === `lists/type/${e?.id ?? ""}`)
+ return [start, topLevel("documents")!, ...(node ? [node] : [])].filter(Boolean)
+ }
+ if (m.type === "correspondent") {
+ const docsNode = topLevel("documents")
+ const senders = docsNode?.children?.find((c) => c.label === L.senders)
+ return [start, ...(docsNode ? [docsNode] : []), ...(senders ? [senders] : [])]
+ }
+ if (m.type === "note" || m.type === "bookmark") {
+ const notesNode = topLevel("notes")
+ const leaf = notesNode?.children?.find((c) => c.slug === `lists/${m.type}s`)
+ return [start, ...(notesNode ? [notesNode] : []), ...(leaf ? [leaf] : [])]
+ }
+ return [start]
+ }
+
+ const head = (slug: string): Head => {
+ const f = bySlug.get(slug)
+ const m = fm(f ?? ({} as QuartzPluginData))
+ const list = lists.get(slug)
+ if (list) {
+ const trail = crumbs(slug).slice(1)
+ return { kicker: trail.map((n) => n.label), meta: [], chips: [] }
+ }
+ if (RECORD_KINDS.has(m.type)) {
+ const d = describe(f!)
+ const [first, ...rest] = d.meta
+ const kicker = m.type === "document" ? [L.document, first] : [first]
+ const topicsOf = (recTopics.get(slug) ?? []).filter((t) => t.slug)
+ return {
+ kicker: kicker.filter(Boolean),
+ meta: m.type === "document" ? rest.slice(0, 1) : m.filed_by ? [`${L.filedBy} ${personChip(String(m.filed_by)).label}`] : [],
+ date: d.date,
+ chips: [
+ ...topicsOf.map((t) => ({ label: t.label, slug: t.slug })),
+ ...asList(m.persons).map(personChip),
+ ],
+ }
+ }
+ const updated = f?.dates?.modified
+ if (m.type === "person") return { kicker: [L.person], meta: [], date: updated, chips: [] }
+ if (m.type === "correspondent") return { kicker: [L.sender], meta: [], date: updated, chips: [] }
+ if (m.type === "topic") {
+ const t = [...topics.values()].find((x) => x.slug === slug)
+ const area = onto.areas.find((a) => a.id === t?.area)
+ return { kicker: [L.topic, ...(area ? [nameOf(area)] : [])], meta: [], date: updated, chips: [] }
+ }
+ if (slug.startsWith(`${bucket}/diary/`)) return { kicker: [L.diary], meta: [], date: updated, chips: [] }
+ return { kicker: [], meta: [], date: slug === "index" ? undefined : updated, chips: [] }
+ }
+
+ return { nav, lists, head, crumbs, describe }
+}
diff --git a/stacklets/memory/quartz/components/scripts/familyNav.inline.ts b/stacklets/memory/quartz/components/scripts/familyNav.inline.ts
new file mode 100644
index 00000000..08486189
--- /dev/null
+++ b/stacklets/memory/quartz/components/scripts/familyNav.inline.ts
@@ -0,0 +1,84 @@
+import { FullSlug, pathToRoot, resolveRelative, simplifySlug } from "../../util/path"
+
+// Draws the family navigation from static/family-nav.json, which the
+// FamilyLists emitter rewrites on every build. Fetched once per page load
+// and reused across the SPA's page changes.
+
+interface NavNode {
+ key?: string
+ label: string
+ slug?: string
+ count?: number
+ open?: boolean
+ children?: NavNode[]
+}
+
+let data: Promise | undefined
+
+function load(here: FullSlug): Promise {
+ data ??= fetch(new URL(`${pathToRoot(here)}/static/family-nav.json`, window.location.href).toString())
+ .then((r) => r.json())
+ .catch(() => [])
+ return data
+}
+
+const same = (a: string | undefined, here: FullSlug) =>
+ !!a && simplifySlug(a as FullSlug) === simplifySlug(here)
+const contains = (n: NavNode, here: FullSlug): boolean =>
+ same(n.slug, here) || (n.children ?? []).some((c) => contains(c, here))
+
+function item(n: NavNode, depth: number, here: FullSlug): HTMLLIElement {
+ const li = document.createElement("li")
+ li.className = `fn-l${depth}`
+ if (n.key) li.dataset.key = n.key
+
+ const link = document.createElement(n.slug ? "a" : "span")
+ link.className = "fn-link"
+ const text = document.createElement("span")
+ text.className = "fn-label"
+ text.textContent = n.label
+ link.append(text)
+ if (n.count) {
+ const count = document.createElement("span")
+ count.className = "fn-count"
+ count.textContent = String(n.count)
+ link.append(count)
+ }
+ if (n.slug) {
+ ;(link as HTMLAnchorElement).href = resolveRelative(here, n.slug as FullSlug)
+ if (same(n.slug, here)) link.setAttribute("aria-current", "page")
+ }
+
+ if (!n.children?.length) {
+ li.append(link)
+ return li
+ }
+ const details = document.createElement("details")
+ details.open = !!n.open || contains(n, here)
+ const summary = document.createElement("summary")
+ summary.append(link)
+ const ul = document.createElement("ul")
+ for (const c of n.children) ul.append(item(c, depth + 1, here))
+ details.append(summary, ul)
+ li.append(details)
+ return li
+}
+
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+ const here = e.detail.url
+ const nav = await load(here)
+ for (const root of document.querySelectorAll(".family-nav")) {
+ const tree = root.querySelector(".fn-tree")!
+ tree.replaceChildren(...nav.map((n) => item(n, 1, here)))
+ // On a phone the navigation starts folded behind its Menu button, and
+ // a tap anywhere outside the open panel folds it again.
+ const phone = window.matchMedia("(max-width: 800px)")
+ const menu = root.querySelector(".fn-menu")!
+ menu.open = !phone.matches
+ const fold = (ev: MouseEvent) => {
+ if (phone.matches && menu.open && !root.contains(ev.target as Node)) menu.open = false
+ }
+ document.addEventListener("click", fold)
+ window.addCleanup(() => document.removeEventListener("click", fold))
+ }
+})
diff --git a/stacklets/memory/quartz/custom.scss b/stacklets/memory/quartz/custom.scss
index fc6981be..27153a54 100644
--- a/stacklets/memory/quartz/custom.scss
+++ b/stacklets/memory/quartz/custom.scss
@@ -106,10 +106,14 @@ h6 {
0 10px 28px rgba(22, 31, 36, 0.05);
padding: 2rem 2.5rem 2.5rem;
box-sizing: border-box;
+ // Down from the window edge, so the first line on the page sits level
+ // with the family's name in the sidebar.
+ margin-top: 3.5rem;
@media all and (max-width: 800px) {
padding: 1.25rem 1.25rem 2rem;
border-radius: 8px;
+ margin-top: 0;
}
}
@@ -278,3 +282,92 @@ footer {
footer ul {
font-size: 0.72rem;
}
+
+// ── Scrollbars ──────────────────────────────────────────────────────
+//
+// Thin and quiet, in the teal of the borders. The sidebars scroll on
+// their own, and a stock scrollbar is the heaviest line on the page.
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(61, 143, 160, 0.3) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(61, 143, 160, 0.3);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+// ── One title per page ──────────────────────────────────────────────
+//
+// FamilyHead takes a page's own leading `# Heading` as the title, so the
+// copy at the top of the article goes. Only a heading that opens the
+// article: one further down is a section and stays.
+
+.center article > h1:first-child {
+ display: none;
+}
+
+// The writers put a meta line under that heading ("From: … · About: …",
+// "About … · Captured …"), which FamilyHead now shows as the date line
+// and chips. Only a quote that opens with a bold label goes, so a plain
+// quote under a heading (the home page's address) stays.
+.center article > h1:first-child + blockquote:has(> p:first-child > strong:first-child) {
+ display: none;
+}
+
+// ── Search, as the sketch's ask box ─────────────────────────────────
+
+.search > .search-button {
+ background: var(--fs-paper);
+ border: 1px solid var(--fs-border);
+ border-radius: 10px;
+ min-height: 2.5rem;
+}
+
+.search > .search-button:hover {
+ border-color: var(--secondary);
+}
+
+// ── Left sidebar rhythm ─────────────────────────────────────────────
+//
+// Upstream spaces sidebar panels for a search box, a graph and a file
+// tree. Ours is a title, search and one menu, which reads as one block
+// when the gaps are smaller.
+
+.page > #quartz-body .sidebar.left {
+ gap: 1rem;
+}
+
+// Both sidebars start level with the top edge of the page card.
+@media all and (min-width: 801px) {
+ .page > #quartz-body .sidebar.left,
+ .page > #quartz-body .sidebar.right {
+ padding-top: 3.5rem;
+ }
+}
+
+// ── Phone header ────────────────────────────────────────────────────
+//
+// The sidebar becomes the row above the page. It is the positioned box
+// the open menu panel hangs from (see FamilyNav.tsx), and the family's
+// name stays on one line in it.
+
+@media all and (max-width: 800px) {
+ .page > #quartz-body .sidebar.left {
+ position: relative;
+ }
+
+ .famstack-title .page-title {
+ white-space: nowrap;
+ }
+}
diff --git a/stacklets/memory/quartz/plugins/familyLists.tsx b/stacklets/memory/quartz/plugins/familyLists.tsx
new file mode 100644
index 00000000..acf5cc45
--- /dev/null
+++ b/stacklets/memory/quartz/plugins/familyLists.tsx
@@ -0,0 +1,79 @@
+import { QuartzEmitterPlugin } from "../types"
+import { QuartzComponentProps } from "../../components/types"
+import HeaderConstructor from "../../components/Header"
+import BodyConstructor from "../../components/Body"
+import { pageResources, renderPage } from "../../components/renderPage"
+import { defaultProcessedContent } from "../vfile"
+import { FullPageLayout } from "../../cfg"
+import { FullSlug, pathToRoot } from "../../util/path"
+import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
+import { write } from "./helpers"
+import FamilyListContent from "../../components/FamilyList"
+import { familyModel } from "../../components/familyModel"
+
+// NEW EMITTER (not an upstream override), modelled on upstream's TagPage.
+//
+// Writes two things on every build:
+//
+// * static/family-nav.json: the sidebar, read by FamilyNav in the
+// browser. `quartz --serve` re-renders only the pages that changed,
+// so a sidebar baked into each page would go stale everywhere else.
+// This emitter has no `partialEmit`, which makes Quartz run it in
+// full on every change, so the file is always current.
+// * lists/…: a page for every entry that is not a file in the vault:
+// topics without an about page, document types, needs attention,
+// all notes, all bookmarks.
+
+export const FamilyLists: QuartzEmitterPlugin = () => {
+ const opts: FullPageLayout = {
+ ...sharedPageComponents,
+ ...defaultListPageLayout,
+ pageBody: FamilyListContent(),
+ }
+ const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
+ const Header = HeaderConstructor()
+ const Body = BodyConstructor()
+
+ return {
+ name: "FamilyLists",
+ getQuartzComponents() {
+ return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...afterBody, ...left, ...right, Footer]
+ },
+ async *emit(ctx, content, resources) {
+ const allFiles = content.map((c) => c[1].data)
+ const model = familyModel(ctx, allFiles)
+ const cfg = ctx.cfg.configuration
+
+ yield write({
+ ctx,
+ content: JSON.stringify(model.nav),
+ slug: "static/family-nav" as FullSlug,
+ ext: ".json",
+ })
+
+ for (const page of model.lists.values()) {
+ const slug = page.slug as FullSlug
+ const [tree, file] = defaultProcessedContent({
+ slug,
+ frontmatter: { title: page.title, tags: [] },
+ })
+ const externalResources = pageResources(pathToRoot(slug), resources)
+ const componentData: QuartzComponentProps = {
+ ctx,
+ fileData: file.data,
+ externalResources,
+ cfg,
+ children: [],
+ tree,
+ allFiles,
+ }
+ yield write({
+ ctx,
+ content: renderPage(cfg, slug, componentData, opts, externalResources),
+ slug,
+ ext: ".html",
+ })
+ }
+ },
+ }
+}
diff --git a/stacklets/memory/quartz/quartz.config.ts b/stacklets/memory/quartz/quartz.config.ts
index f4d8dbbc..63f7814d 100644
--- a/stacklets/memory/quartz/quartz.config.ts
+++ b/stacklets/memory/quartz/quartz.config.ts
@@ -13,6 +13,7 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
+import { FamilyLists } from "./quartz/plugins/emitters/familyLists"
// Runtime env. `WIKI_HOST` is "memory." in domain mode and
// the empty-domain rendering "memory." in port mode — we treat the
@@ -40,7 +41,9 @@ const config: QuartzConfig = {
// No analytics — this is a private family site, the upstream
// Plausible default would leak page views to a third party.
analytics: null,
- locale: "en-US",
+ // The instance language (`[core] language`), so dates and Quartz's
+ // own labels match the navigation's.
+ locale: (process.env.WIKI_LANGUAGE ?? "").toLowerCase().startsWith("de") ? "de-DE" : "en-US",
baseUrl,
// Skip git internals and Obsidian config dirs. The vault is a
// real git clone, not a stripped checkout, so `.git` matters.
@@ -56,7 +59,7 @@ const config: QuartzConfig = {
// `**/diary/entries/**` keeps the diary's records off the site. They
// are the raw source the diary pages are compiled from, and stay in
// the brain for search and the agent; the family reads the pages.
- ignorePatterns: [".git", ".obsidian", "private", "templates",
+ ignorePatterns: [".git", ".obsidian", "private", "templates", "README.md", "**/README.md",
"media/**/*.json", "media/**/.tmp-*",
"**/diary/entries/**"],
defaultDateType: "modified",
@@ -152,6 +155,7 @@ const config: QuartzConfig = {
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),
+ FamilyLists(),
Plugin.ContentIndex({ enableSiteMap: true, enableRSS: true }),
Plugin.Assets(),
Plugin.Static(),
diff --git a/stacklets/memory/quartz/quartz.layout.ts b/stacklets/memory/quartz/quartz.layout.ts
index 447e6a5f..688b1db2 100644
--- a/stacklets/memory/quartz/quartz.layout.ts
+++ b/stacklets/memory/quartz/quartz.layout.ts
@@ -8,7 +8,6 @@
*/
import { PageLayout, SharedLayout } from "./quartz/cfg"
-import { FileTrieNode } from "./quartz/util/fileTrie"
import * as Component from "./quartz/components"
// Our own components, imported directly rather than through the
// `Component` namespace so we do not have to overlay upstream's
@@ -16,30 +15,14 @@ import * as Component from "./quartz/components"
// walking the layout, so a direct import styles itself just the same.
import FamstackTitle from "./quartz/components/FamstackTitle"
import Welcome from "./quartz/components/Welcome"
+import FamilyNav from "./quartz/components/FamilyNav"
+import FamilyCrumbs from "./quartz/components/FamilyCrumbs"
+import FamilyHead from "./quartz/components/FamilyHead"
+import { L } from "./quartz/components/familyModel"
// `CODE_URL` is set in the container env from {code_url} — the
// user-facing Forgejo URL. Empty falls back to a `#` placeholder so
// the footer still renders even if env wiring drifts.
-// Dated pages are named for their number, not their title: a diary's
-// March lives at `2026/03`. Upstream's explorer sorts files by display
-// title, which files April above March and makes a year read as
-// nonsense. Compare numeric names as numbers and leave everything else
-// on upstream's alphabetical order, so this only ever reorders folders
-// whose pages are named for a date.
-//
-// Quartz serialises this function with `toString()` and re-evaluates it
-// in the browser, so it must not reference anything outside itself.
-const sortByDateThenTitle = (a: FileTrieNode, b: FileTrieNode): number => {
- if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
- const an = a.slugSegment
- const bn = b.slugSegment
- if (/^\d+$/.test(an) && /^\d+$/.test(bn)) return Number(an) - Number(bn)
- return a.displayName.localeCompare(b.displayName, undefined, {
- numeric: true,
- sensitivity: "base",
- })
-}
-
const codeUrl = process.env.CODE_URL || ""
const repoUrl = codeUrl ? `${codeUrl.replace(/\/$/, "")}/family/memory` : "#"
@@ -49,7 +32,7 @@ export const sharedPageComponents: SharedLayout = {
afterBody: [],
footer: Component.Footer({
links: {
- "Edit on Forgejo": repoUrl,
+ [L.editOnForgejo]: repoUrl,
},
}),
}
@@ -63,13 +46,13 @@ export const sharedPageComponents: SharedLayout = {
// palette, not before.
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
+ // Breadcrumbs, a label, one title, and the page's topics and people.
+ // See FamilyCrumbs.tsx and FamilyHead.tsx.
Component.ConditionalRender({
- component: Component.Breadcrumbs(),
+ component: FamilyCrumbs(),
condition: (page) => page.fileData.slug !== "index",
}),
- Component.ArticleTitle(),
- Component.ContentMeta(),
- Component.TagList(),
+ FamilyHead(),
// The greeting belongs to the front door only.
Component.ConditionalRender({
component: Welcome(),
@@ -80,12 +63,10 @@ export const defaultContentPageLayout: PageLayout = {
FamstackTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Flex({
- components: [
- { Component: Component.Search(), grow: true },
- { Component: Component.ReaderMode() },
- ],
+ components: [{ Component: Component.Search(), grow: true }],
}),
- Component.Explorer({ sortFn: sortByDateThenTitle }),
+ // The family's structure, not the vault's folders. See FamilyNav.tsx.
+ FamilyNav(),
],
right: [
Component.Graph(),
@@ -99,9 +80,8 @@ export const defaultContentPageLayout: PageLayout = {
// graph.
export const defaultListPageLayout: PageLayout = {
beforeBody: [
- Component.Breadcrumbs(),
- Component.ArticleTitle(),
- Component.ContentMeta(),
+ FamilyCrumbs(),
+ FamilyHead(),
],
left: [
FamstackTitle(),
@@ -109,7 +89,8 @@ export const defaultListPageLayout: PageLayout = {
Component.Flex({
components: [{ Component: Component.Search(), grow: true }],
}),
- Component.Explorer({ sortFn: sortByDateThenTitle }),
+ // The family's structure, not the vault's folders. See FamilyNav.tsx.
+ FamilyNav(),
],
right: [],
}
diff --git a/stacklets/memory/seeds/README.md b/stacklets/memory/seeds/README.md
index 360c196e..4d6ddc4f 100644
--- a/stacklets/memory/seeds/README.md
+++ b/stacklets/memory/seeds/README.md
@@ -10,7 +10,8 @@ learning history — revert anything that got out of hand.
## Layout
```
-ontology.toml classifier seed (topics, types, synonyms)
+ontology.toml classifier seed (topics, types, synonyms) and
+ the areas that group topics in the wiki menu
facts.toml household facts (people, services, dates)
family/ shared bucket (slug configurable via
diff --git a/stacklets/memory/seeds/ontology.toml b/stacklets/memory/seeds/ontology.toml
index d2873410..2befa858 100644
--- a/stacklets/memory/seeds/ontology.toml
+++ b/stacklets/memory/seeds/ontology.toml
@@ -10,6 +10,9 @@
# synonyms = { = ["..."] } # optional
# keywords = { = ["..."] } # optional
# types = ["", ...] # optional cross-ref
+# area = "" # optional, wiki navigation
+#
+# [area.] names = { = "..." } # a group of topics in the sidebar
#
# [doctype.] names = { = "..." }
# synonyms = { = ["..."] } # optional
@@ -24,49 +27,58 @@
[topic.insurance]
names = { de = "Versicherung", en = "Insurance" }
+area = "contracts"
synonyms = { de = ["Versicherungspolice"], en = ["policy", "coverage"] }
keywords = { de = ["versichert", "Schadensfall", "Beitrag", "Selbstbeteiligung"], en = ["insured", "claim", "premium", "deductible"] }
types = ["policy", "invoice", "letter", "statement"]
[topic.finance]
names = { de = "Finanzen", en = "Finance" }
+area = "money"
synonyms = { en = ["banking"] }
keywords = { de = ["Bank", "Konto", "Überweisung"], en = ["bank", "account", "transfer", "balance"] }
types = ["statement", "letter"]
[topic.investments]
names = { de = "Investments", en = "Investments" }
+area = "money"
synonyms = { de = ["Wertpapiere", "Aktien"], en = ["stocks", "portfolio", "securities"] }
types = ["statement", "letter"]
[topic.income]
names = { de = "Einkommen", en = "Income" }
+area = "money"
synonyms = { en = ["salary", "wages"] }
types = ["pay_slip", "statement"]
[topic.tax]
names = { de = "Steuer", en = "Tax" }
+area = "money"
synonyms = { en = ["taxation"] }
keywords = { de = ["Steuererklärung", "Abzug"], en = ["return", "deduction"] }
types = ["tax_assessment", "letter", "notice"]
[topic.tax_office]
names = { de = "Finanzamt", en = "Tax Office" }
+area = "money"
types = ["letter", "notice", "tax_assessment"]
[topic.medical]
names = { de = "Gesundheit", en = "Medical" }
+area = "health"
synonyms = { de = ["Arzt"], en = ["health", "healthcare"] }
keywords = { de = ["Diagnose", "Behandlung", "Rezept"], en = ["diagnosis", "treatment", "prescription"] }
types = ["medical_certificate", "prescription", "invoice", "letter"]
[topic.education]
names = { de = "Bildung", en = "Education" }
+area = "kids"
synonyms = { de = ["Schule", "Universität"], en = ["school", "university", "training"] }
types = ["certificate", "letter", "invoice"]
[topic.child]
names = { de = "Kind", en = "Child" }
+area = "kids"
synonyms = { de = ["Kinder"], en = ["children", "kids"] }
[topic.toys]
@@ -77,6 +89,7 @@ synonyms = { de = ["Kinder"], en = ["children", "kids"] }
# invoices too. Names + synonyms + a Lego brand mention in the
# body give the classifier enough to land on Toys.
names = { de = "Spielzeug", en = "Toys" }
+area = "kids"
synonyms = { de = ["Spielware"], en = ["toy"] }
types = ["manual", "warranty", "receipt", "invoice"]
@@ -86,15 +99,18 @@ types = ["manual", "warranty", "receipt", "invoice"]
# topic carries the subject so retrieval like "where is the UNO
# rule for the wild draw four" finds the right mirror.
names = { de = "Spiele", en = "Games" }
+area = "kids"
synonyms = { de = ["Brettspiel", "Kartenspiel"], en = ["board game", "card game"] }
types = ["manual"]
[topic.marriage]
names = { de = "Ehe", en = "Marriage" }
+area = "official"
types = ["certificate", "personal_record"]
[topic.vehicle]
names = { de = "Fahrzeug", en = "Vehicle" }
+area = "car"
synonyms = { de = ["Auto"], en = ["car", "automobile"] }
keywords = { de = ["TÜV", "KFZ"], en = ["registration", "inspection"] }
types = ["contract", "invoice", "certificate"]
@@ -104,57 +120,69 @@ types = ["contract", "invoice", "certificate"]
# Holiday rentals (chalet, Ferienwohnung, hotel bookings) belong to
# `topic.travel`, not here.
names = { de = "Wohnen", en = "Housing" }
+area = "home"
synonyms = { de = ["Miete"], en = ["rent", "mortgage"] }
keywords = { de = ["Vermieter", "Hausverwaltung", "Mietvertrag", "Hauptwohnsitz", "Betriebskostenabrechnung", "Wohnungsübergabe", "Hausordnung", "Nebenkosten"], en = ["landlord", "lease", "rental agreement", "primary residence", "utility bill", "tenancy"] }
types = ["contract", "invoice", "statement", "letter"]
[topic.property]
names = { de = "Immobilien", en = "Property" }
+area = "home"
synonyms = { de = ["Grundbuch"], en = ["real estate", "deed"] }
types = ["contract", "letter"]
[topic.architecture]
names = { de = "Architektur", en = "Architecture" }
+area = "home"
synonyms = { de = ["Bauplan"], en = ["floor plan", "blueprint"] }
[topic.utility]
names = { de = "Nebenkosten", en = "Utility" }
+area = "home"
synonyms = { de = ["Strom", "Gas", "Wasser"], en = ["electricity", "gas", "water", "heating"] }
types = ["invoice", "statement"]
[topic.employment]
names = { de = "Arbeit", en = "Employment" }
+area = "money"
synonyms = { de = ["Job"], en = ["work", "job"] }
types = ["contract", "pay_slip", "letter", "certificate"]
[topic.legal]
names = { de = "Recht", en = "Legal" }
+area = "official"
synonyms = { de = ["Anwalt"], en = ["lawyer", "court"] }
types = ["contract", "letter"]
[topic.government]
names = { de = "Behörde", en = "Government" }
+area = "official"
synonyms = { de = ["Amt"], en = ["authority", "agency"] }
types = ["letter", "notice", "certificate", "application"]
[topic.membership]
names = { de = "Mitgliedschaft", en = "Membership" }
+area = "contracts"
synonyms = { de = ["Verein"], en = ["club", "association"] }
[topic.subscription]
names = { de = "Abonnement", en = "Subscription" }
+area = "contracts"
synonyms = { de = ["Abo"], en = ["subscription"] }
[topic.shopping]
names = { de = "Einkauf", en = "Shopping" }
+area = "shopping"
[topic.electronics]
names = { de = "Elektronik", en = "Electronics" }
+area = "shopping"
synonyms = { de = ["Computer", "Telefon"], en = ["computer", "phone", "gadget"] }
types = ["invoice", "warranty", "manual"]
[topic.groceries]
names = { de = "Lebensmittel", en = "Groceries" }
+area = "shopping"
synonyms = { de = ["Supermarkt"], en = ["supermarket"] }
types = ["receipt"]
@@ -163,25 +191,30 @@ types = ["receipt"]
# Holiday rentals (chalet, Ferienwohnung, hotel) belong here, NOT
# under `topic.housing` -- that one is for long-term residence.
names = { de = "Reise", en = "Travel" }
+area = "leisure"
synonyms = { de = ["Urlaub", "Ferien"], en = ["holiday", "trip", "vacation"] }
keywords = { de = ["Anreise", "Abreise", "Aufenthalt", "Übernachtung", "Hotel", "Pension", "Chalet", "Ferienwohnung", "Ferienhaus", "Unterkunft", "Reservierung", "Buchungsbestätigung", "Check-in", "Check-out"], en = ["hotel", "chalet", "vacation rental", "holiday home", "lodging", "accommodation", "check-in", "check-out", "booking confirmation", "stay"] }
types = ["booking", "invoice"]
[topic.pet]
names = { de = "Haustier", en = "Pet" }
+area = "leisure"
synonyms = { de = ["Tierarzt"], en = ["vet"] }
[topic.telecom]
names = { de = "Telekommunikation", en = "Telecom" }
+area = "contracts"
synonyms = { de = ["Internet", "Handy"], en = ["phone", "internet", "mobile"] }
types = ["contract", "invoice"]
[topic.recipe]
names = { de = "Rezept", en = "Recipe" }
+area = "leisure"
synonyms = { de = ["Kochrezept"], en = ["cooking", "baking"] }
[topic.memory]
names = { de = "Erinnerung", en = "Memory" }
+area = "leisure"
synonyms = { de = ["Erinnerungsstück"], en = ["keepsake", "milestone"] }
# Family life: what the memories room and the diary are about. A letter
@@ -190,49 +223,60 @@ synonyms = { de = ["Erinnerungsstück"], en = ["keepsake", "milestone"] }
[topic.music]
names = { de = "Musik", en = "Music" }
+area = "leisure"
synonyms = { de = ["Konzert", "Instrument", "Chor", "Musikschule"], en = ["concert", "instrument", "choir", "recital", "music school"] }
[topic.sport]
names = { de = "Sport", en = "Sport" }
+area = "leisure"
synonyms = { de = ["Schwimmen", "Fußball", "Sportverein", "Turnier"], en = ["sports", "swimming", "football", "soccer", "tournament"] }
[topic.hobby]
names = { de = "Hobby", en = "Hobby" }
+area = "leisure"
synonyms = { de = ["Basteln", "Zeichnen", "Freizeit"], en = ["pastime", "crafts", "drawing"] }
[topic.celebration]
names = { de = "Feier", en = "Celebration" }
+area = "leisure"
synonyms = { de = ["Geburtstag", "Party", "Weihnachten", "Jubiläum", "Fest"], en = ["birthday", "party", "christmas", "anniversary"] }
[topic.friends]
names = { de = "Freunde", en = "Friends" }
+area = "leisure"
synonyms = { de = ["Freundschaft", "Spielverabredung"], en = ["friend", "friendship", "playdate"] }
[topic.family_life]
names = { de = "Familienleben", en = "Family Life" }
+area = "leisure"
synonyms = { de = ["Familienzeit", "Alltag"], en = ["family time", "everyday life"] }
[topic.personal_record]
names = { de = "Urkunde", en = "Personal Record" }
+area = "official"
synonyms = { de = ["Geburtsurkunde", "Sterbeurkunde"], en = ["birth certificate", "death certificate", "passport"] }
types = ["certificate", "personal_record"]
[topic.pension]
names = { de = "Rente", en = "Pension" }
+area = "money"
synonyms = { de = ["Altersvorsorge"], en = ["retirement"] }
types = ["statement", "letter"]
[topic.donation]
names = { de = "Spende", en = "Donation" }
+area = "money"
types = ["receipt", "letter"]
[topic.warranty]
names = { de = "Garantie", en = "Warranty" }
+area = "shopping"
synonyms = { en = ["guarantee"] }
types = ["warranty", "receipt"]
[topic.vaccination]
names = { de = "Impfung", en = "Vaccination" }
+area = "health"
synonyms = { de = ["Impfpass"], en = ["immunization"] }
types = ["medical_certificate", "certificate"]
@@ -241,6 +285,41 @@ names = { de = "Zugangsdaten", en = "Credential" }
synonyms = { de = ["Passwort"], en = ["password", "access code"] }
+# ──────────────────────────────────────────────────────────────────────
+# Areas: how topics group in the family navigation (the wiki sidebar).
+# A topic names its area with `area = ""`; a topic without one is
+# not listed there (credential, on purpose). Order here is the order in
+# the sidebar. Chat-room topics set `area:` in their about.md.
+# ──────────────────────────────────────────────────────────────────────
+
+[area.money]
+names = { de = "Geld & Arbeit", en = "Money & work" }
+
+[area.contracts]
+names = { de = "Versicherungen & Verträge", en = "Insurance & contracts" }
+
+[area.health]
+names = { de = "Gesundheit", en = "Health" }
+
+[area.home]
+names = { de = "Zuhause", en = "Home" }
+
+[area.kids]
+names = { de = "Kinder & Schule", en = "Kids & school" }
+
+[area.car]
+names = { de = "Auto", en = "Car" }
+
+[area.official]
+names = { de = "Ämter & Urkunden", en = "Official papers" }
+
+[area.shopping]
+names = { de = "Einkäufe", en = "Shopping" }
+
+[area.leisure]
+names = { de = "Freizeit", en = "Leisure" }
+
+
# ──────────────────────────────────────────────────────────────────────
# Document types — the *shape* of the document, independent of subject.
# ──────────────────────────────────────────────────────────────────────
diff --git a/tests/unit/stacklets/test_memory_diary.py b/tests/unit/stacklets/test_memory_diary.py
index 1d872ef9..eed73db8 100644
--- a/tests/unit/stacklets/test_memory_diary.py
+++ b/tests/unit/stacklets/test_memory_diary.py
@@ -851,7 +851,7 @@ def test_a_month_without_a_summary_goes_straight_to_its_entries(self):
def test_a_summary_reaches_the_month_it_describes(self):
pages = {path: body for path, body, _title in
- diary.pages_for(_compile(),
+ diary.pages_for(bucket="family", entries=_compile(),
summaries={"2026-03": "Only March."})}
assert "Only March." in pages["diary/2026/03.md"]
@@ -860,25 +860,27 @@ def test_a_summary_reaches_the_month_it_describes(self):
def test_the_index_lists_a_page_for_every_year(self):
entries = _compile()
- index = diary.render_index(entries)
+ index = diary.render_index(entries, bucket="family")
+ # Root-based, like every generated link: the wiki resolves links
+ # from the vault root, where `2026/about` names no page.
for key in {diary.year_key(e.on) for e in entries}:
- assert f"]({key}/about)" in index
+ assert f"](/family/diary/{key}/about)" in index
def test_a_year_lists_its_months_and_who_is_in_them(self):
entries = _compile()
in_2026 = [e for e in entries if e.on.year == 2026]
- page = diary.render_year(in_2026)
+ page = diary.render_year(in_2026, bucket="family")
assert "## Months" in page
- assert "[March](03)" in page
+ assert "[March](/family/diary/2026/03)" in page
assert "recorded by Marge and Homer" in page
def test_a_year_credits_only_who_recorded(self):
"""An addressee is the model's reading, not a fact about the
household, so it never reaches a landing page."""
- page = diary.render_year([diary.Entry(
+ page = diary.render_year(bucket="family", entries=[diary.Entry(
on=date(2026, 3, 16), confidence="spoken", basis="b", kind="voice",
sender="marge", body="words", addressee="Bart")])
@@ -891,7 +893,7 @@ def test_a_diary_nests_month_inside_year(self):
Flat `2026-03.md` files pile every month of every year into one
folder, which is the explorer sidebar the family actually reads.
"""
- paths = [path for path, _body, _title in diary.pages_for(_compile())]
+ paths = [path for path, _body, _title in diary.pages_for(bucket="family", entries=_compile())]
assert "diary/about.md" in paths
assert "diary/2026/about.md" in paths
@@ -902,7 +904,7 @@ def test_a_folders_own_page_is_about_not_index(self):
"""Quartz renders a folder URL through a layout with no body in
this wiki, so an `index.md` would be unreadable. Every other
entity here is `about.md` for the same reason."""
- paths = [path for path, _body, _title in diary.pages_for(_compile())]
+ paths = [path for path, _body, _title in diary.pages_for(bucket="family", entries=_compile())]
assert not any(p.endswith("index.md") for p in paths)
@@ -910,7 +912,7 @@ def test_a_month_page_is_titled_with_its_year(self):
"""The folder gives context in the sidebar; a link or a search
result does not, so the title carries the year itself."""
titles = {path: title for path, _body, title in
- diary.pages_for(_compile())}
+ diary.pages_for(bucket="family", entries=_compile())}
assert titles["diary/2026/03.md"] == "March 2026"
assert titles["diary/2026/about.md"] == "2026"
@@ -1160,7 +1162,7 @@ class TestEmptyDiary:
"""
def test_it_says_how_to_record_something(self):
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Memories" in page # names the room
assert "voice message" in page.lower()
@@ -1169,7 +1171,7 @@ def test_it_says_how_to_record_something(self):
def test_it_keeps_the_diary_title_and_opening(self):
"""Not a separate error page: the same front door, with the
years replaced by the note on how to fill them."""
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert page.startswith("# Family Diary")
assert "## Years" not in page
@@ -1178,7 +1180,7 @@ def test_it_explains_the_two_things_that_are_not_obvious(self):
"""Speaking the date and replying to a message both change what
the compiler does with a recording, and neither is guessable
from the room."""
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Today is the third of March" in page
assert "Reply to a message" in page
@@ -1186,7 +1188,7 @@ def test_it_explains_the_two_things_that_are_not_obvious(self):
def test_it_is_written_in_the_household_language(self):
diary.configure_language("de")
try:
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Noch nichts aufgenommen" in page
assert "Nothing recorded yet" not in page
finally:
@@ -1195,7 +1197,7 @@ def test_it_is_written_in_the_household_language(self):
def test_a_compiled_diary_shows_years_instead(self):
"""The note is for the empty case only. One entry and the page
goes back to being an index."""
- page = diary.render_index(_compile())
+ page = diary.render_index(_compile(), bucket="family")
assert "Press record." not in page
assert "## Years" in page
diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py
index 61d9e83e..ca78b877 100644
--- a/tests/unit/stacklets/test_memory_diary_card.py
+++ b/tests/unit/stacklets/test_memory_diary_card.py
@@ -87,12 +87,12 @@ def test_the_diary_pages_from_cards_are_the_pages_from_entries(self):
direct = diary.pages_for(
[replace(e, gist="", moments=[]) for e in entries],
- room_id=ROOM, media=media)
+ bucket="family", room_id=ROOM, media=media)
# What the card adds on purpose (its title and summary) is set
# aside: this pins that nothing of the entry itself is lost.
from_cards = diary.pages_for(
[replace(diary_card.to_entry(c), gist="", moments=[], title="") for c in cards],
- room_id=ROOM, media=diary_card.media_of(cards))
+ bucket="family", room_id=ROOM, media=diary_card.media_of(cards))
assert from_cards == direct
def test_a_long_recording_shows_the_cards_summary_and_its_quotes(self):
diff --git a/tests/unit/stacklets/test_memory_wiki.py b/tests/unit/stacklets/test_memory_wiki.py
index 4039d96c..e0ec9799 100644
--- a/tests/unit/stacklets/test_memory_wiki.py
+++ b/tests/unit/stacklets/test_memory_wiki.py
@@ -23,6 +23,7 @@
sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli"))
from wiki import ( # noqa: E402
+ _build_references_section,
_build_topic_prompt,
_capture_index_pages,
_clean_generated,
@@ -1034,3 +1035,30 @@ def test_an_unset_brain_dir_is_not_fatal_here(self, monkeypatch):
monkeypatch.delenv("BRAIN_REPO_DIR", raising=False)
assert _with_diary_link(self.PAGE, shared_bucket="family") == self.PAGE
+
+
+class TestReferences:
+ """The References block under a generated page links every cited source."""
+
+ def test_a_cited_diary_entry_links_to_its_month_page(self):
+ # The wiki does not publish diary/entries/ (the month pages are the
+ # readable form), so a link to the entry file would be a dead link
+ # on the family's front page.
+ page = "Maggie said her first word. [1]"
+ entries = [{
+ "title": "Maggie's first word",
+ "date": "2026-09-22",
+ "rel": "family/diary/entries/2026/09/2026-09-22-549db38495.md",
+ }]
+ section = _build_references_section(page, entries, page_dir="")
+ assert "[Maggie's first word](/family/diary/2026/09.md)" in section
+
+ def test_other_sources_keep_their_own_path(self):
+ page = "The mortgage runs thirty years. [1]"
+ entries = [{
+ "title": "Mortgage agreement",
+ "date": "2009-06-15",
+ "rel": "family/documents/2009/06/2009-06-15-mortgage-p23.md",
+ }]
+ section = _build_references_section(page, entries, page_dir="")
+ assert "(/family/documents/2009/06/2009-06-15-mortgage-p23.md)" in section