code.pablomurad.com

mdssg

Single-file static site generator for a folder of linked markdown notes

feat: fix
author
Pablo Murad
date
commit
33eb9ff1bfa4c095800b51c6caba5530dde40d4b
parent
85d16d18b5771e26244df1ae5f91d61be7348b8d
changes
39 files +1169 −139
@@ -70,6 +70,9 @@       - name: Test against the packaged zipapp
         run: python tools/check_pyz.py
 
+      - name: Build a 1500-note site within the performance budget
+        run: python tools/benchmark.py --notes 1500 --max-seconds 20
+
       - name: The archive must stay small
         shell: bash
         run: |
@@ -51,7 +51,13 @@ `mdssg doctor --explain-related X` will tell you why. 
 **The graph is visible.** Every build includes a compact, clickable `/graph/`
 map of the written notes, seed pages and the links between them. It is an SVG,
-not a JavaScript application.
+not a JavaScript application. Large sites keep the drawing bounded to 180
+nodes and 2,400 edges while the grouped text directory still lists every page.
+
+**Search stays static.** `/search/` loads a small JSON index generated with the
+site and searches it with about 2 KB of dependency-free JavaScript. Nothing is
+sent to a service. Each note also carries a compact graph of its immediate
+neighbours.
 
 ## Documentation
 
@@ -91,10 +97,10 @@ ``` 
 ## What is in the box
 
-Its own markdown parser, its own template engine, a visible link graph, an
-incremental build, a dev server with live reload, RSS, canonical URLs, a
-sitemap, and a CGI handler for hosting that predates all of this. The dependency
-list is empty.
+Its own markdown parser, its own template engine, global and local link graphs,
+static full-text search, a themed 404, an incremental build, a dev server with
+live reload, RSS, canonical URLs, a sitemap, and a CGI handler for hosting that
+predates all of this. The dependency list is empty.
 
 No plugin system, no shortcodes, no taxonomy engine, no asset pipeline.
 
@@ -103,6 +109,7 @@ ```sh
 python -m unittest discover -s tests -t . -v
 python tools/build_pyz.py && python tools/check_pyz.py
+python tools/benchmark.py --notes 1500 --max-seconds 20
 ```
 
 CI runs the suite on Linux, macOS and Windows across Python 3.11 through 3.14,
@@ -116,6 +123,8 @@ - **closed under linking** — after any build, every internal `href` in the   output resolves to a file that exists in the output
 - **clean equals incremental** — a clean build and an incremental build, after
   edits of every shape, must produce byte-identical trees
+- **large-site budget** — CI builds 1,500 linked notes from scratch in at most
+  20 seconds and then requires an unchanged rebuild to write zero files
 
 ## Status
 
@@ -12,9 +12,9 @@ Not a stub, and not a 404 with a nicer face: a real page at a real URL, assembled out of every sentence anywhere on the site that mentioned that name,
 each one quoted and linked back to where it came from.
 
-**This page has one linked from it.** [[Full Text Search]] is a feature mdssg
-does not have. Follow it and you will land on a page built entirely out of the
-places it gets discussed.
+**This page has one linked from it.** [[Future Note]] has not been written.
+Follow it and you will land on a page built entirely out of the places it gets
+discussed.
 
 ## Why this is the interesting part
 
@@ -50,8 +50,8 @@ note's URL comes from. So when you eventually write the note — anywhere, in any folder — it lands on the URL the seed already had.
 
 ```
-[[Full Text Search]]  with nothing written   ->  /full-text-search/
-content/notes/fts.md  titled Full Text Search ->  /full-text-search/
+[[Future Note]]          with nothing written ->  /future-note/
+content/notes/future.md  titled Future Note   ->  /future-note/
 ```
 
 No redirect, no broken bookmark, no 301 to maintain. This only works because
@@ -52,6 +52,10 @@ content/        your markdown, arranged however you like   static/         copied to the output untouched
 ```
 
+Every build also creates `/search/`, `/graph/`, a styled `404.html`, and the
+small assets they need. Search is local to the browser; no search server or
+account is involved.
+
 ## The commands
 
 | command | what it does |
@@ -13,7 +13,8 @@ 2. **Graph** — index every name and alias, resolve every `[[link]]`, invent a    seed for every name nothing wrote, collect mention excerpts, compute
    backlinks and [[Related Notes]].
 3. **Render** — turn block trees into HTML, resolving wikilinks through the
-   graph, expanding embeds, and lay out the global graph as SVG.
+   graph, expanding embeds, laying out global and local SVG graphs, and writing
+   the static search index.
 4. **Emit** — write each output only if its bytes differ from last time, then
    delete anything the previous build produced and this one did not.
 
@@ -100,6 +101,11 @@ ## Deliberate omissions
 
 No plugin system. No shortcodes. No taxonomy engine. No asset pipeline. No
-[[Full Text Search]] — yet.
+client framework.
 
 The dependency list is empty and the intent is to keep it that way.
+
+CI also generates 1,500 linked notes, performs a clean build under a fixed
+time budget, then requires the unchanged rebuild to write zero files. This
+catches performance regressions without putting a timing assertion in every
+platform and Python version of the unit-test matrix.
@@ -32,6 +32,8 @@ - [[Backlinks]] quote the sentence they came from, not just the title. - [[Related Notes]] come from the shape of the graph, with no tags to maintain
   and no model to run.
 - The global `/graph/` view turns that structure into a compact, clickable SVG.
+- Every note shows its immediate graph neighbours, and `/search/` searches the
+  complete site without a server or external service.
 
 ## Start here
 
@@ -44,8 +46,8 @@ - [[How It Works]] — the design, and the parts that were harder than expected 
 ## What it does not do
 
-It has no plugin system, no taxonomy engine, no shortcodes, no asset pipeline,
-and no [[Full Text Search]] yet.
+It has no plugin system, no taxonomy engine, no shortcodes, and no asset
+pipeline.
 
 It has an empty dependency list and the intent is to keep it that way.
 
@@ -11,7 +11,7 @@ An unknown section or key is a **build error**, listing what was expected:
 
 ```
-error: [site]: unknown option(s): titel. Known: author, description, language, title, url
+error: [site]: unknown option(s): titel. Known: author, description, language, repository, title, url
 ```
 
 A typo in a config file is the first mistake most people make, and ignoring it
@@ -27,6 +27,12 @@ url = ""                    # no trailing slash. Required for the RSS feed. description = ""
 author = ""
 language = "en"
+repository = ""
+
+[theme]
+accent = "#000080"
+logo = ""
+width = 1152
 
 [paths]
 content = "content"
@@ -58,6 +64,19 @@ cannot exist without it — a feed reader shows your markup on its own domain, so every link in it has to be absolute. Set it and mdssg rewrites the site's own
 links accordingly. It also enables canonical links and `sitemap.xml`. Leave it
 unset and the feed and sitemap are skipped.
+
+**`site.repository`** is optional. When set to a safe HTTP(S) URL, the theme
+adds a source-code link. Leaving it empty keeps a generated site from pointing
+back to the mdssg repository by mistake.
+
+**`site.language`** selects the built-in interface strings. English is the
+fallback; `pt` and variants such as `pt-BR` use Portuguese.
+
+**`theme.accent`**, **`theme.logo`**, and **`theme.width`** are the entire
+visual configuration surface. The accent is a six-digit hex colour. The logo
+is empty for the bundled mark, a root-relative path copied through `static/`,
+or a safe HTTP(S) URL. Width is limited to 640–2400 pixels. Deeper changes still
+belong in a template or stylesheet override.
 
 **`seeds.in_sitemap`** controls whether unwritten seed pages appear in
 `sitemap.xml`. It defaults to false: seeds remain reachable from the notes that
@@ -16,9 +16,13 @@ index.html        the home page   page.html         a note
   seed.html         a seed page
   graph.html        the global graph
+  search.html       static search
+  404.html          the not-found page
   _backlinks.html   the "mentioned in" panel
   _related.html     the "related" panel
+  _local_graph.html immediate graph neighbours
   style.css         the stylesheet
+  search.js         the dependency-free search client
   logo.svg          the site mark
 ```
 
@@ -79,7 +83,8 @@ Every page: 
 | name | contents |
 |------|----------|
-| `site` | `title`, `url`, `description`, `author`, `language` |
+| `site` | `title`, `url`, `description`, `author`, `language`, `repository` |
+| `theme`, `ui` | validated visual settings and translated interface strings |
 | `page` | `title`, `url`, `slug`, `date`, `tags`, `html`, `description`, `is_post`, `is_seed`, `noindex` |
 | `related` | list of `{title, url, seed}` |
 | `asset_url`, `logo_url`, `feed_url` | paths to the stylesheet, logo and feed |
@@ -87,7 +92,8 @@ Notes also get `backlinks`, a list of `{title, url, excerpts}` where each
 excerpt is HTML with the mention wrapped in `<mark>`. Seed pages get `mentions`
 in the same shape. The home page gets `posts` and `notes`. The graph page gets
-`graph`, containing the rendered SVG plus node, link and seed counts.
+`graph`, containing the bounded SVG, grouped page directory and counts. Linked
+notes also receive `local_graph` with incoming and outgoing neighbours.
 
 ## Styling the graph
 
@@ -101,11 +107,14 @@ .backlinks mark      /* the mention inside a quote   */ .seed-notice         /* the banner on a seed page    */
 .embed               /* transcluded content          */
 .embed-error         /* a cycle or a depth limit     */
-.graph-map            /* the complete SVG graph       */
+.graph-map            /* the bounded SVG graph        */
 .graph-node           /* a written note               */
 .graph-seed           /* an unwritten seed            */
+.graph-focus          /* current note in a local graph */
+.local-graph          /* immediate neighbours          */
+.search-view          /* search page                    */
 ```
 
-The bundled theme uses system fonts and ships no JavaScript at all.
-`/graph/` is reserved for the global graph; use another explicit slug for a
-note that would otherwise claim that URL.
+The bundled theme uses system fonts. Its only JavaScript is the small static
+search client; the graphs remain SVG and work without scripts. `/graph/` and
+`/search/` are reserved for their generated pages.
@@ -7,6 +7,12 @@ url = ""                    # no trailing slash. Required for the RSS feed. description = "A graph-aware static site generator with no dependencies"
 author = "Pablo Murad"
 language = "en"
+repository = "https://github.com/runawaydevil/mdssg"
+
+[theme]
+accent = "#000080"        # one solid colour used by the theme and graph
+logo = ""                 # root-relative or https URL; empty uses the bundled mark
+width = 1152              # maximum site width in pixels
 
 [paths]
 content = "content"         # where the markdown lives
@@ -165,7 +165,7 @@ try:             from . import render as render_module
 
             return (
-                self.index.get("version") != 3
+                self.index.get("version") != 4
                 or self.index.get("config_hash") != self.settings.digest
                 or self.index.get("sources") != render_module.source_state(self.settings)
             )
@@ -173,14 +173,18 @@ except (OSError, MdssgError):             return True
 
     def handle(self, path: str) -> tuple[int, bytes]:
+        from . import render as render_module
+
         route = self.index.get("routes", {}).get(normalise(path))
         if route is None:
             return 404, self.render_missing(path).encode("utf-8")
-        if route.get("kind") == "graph":
-            from . import render as render_module
-
-            graph_path = self.settings.output_dir.joinpath(*render_module.GRAPH_PARTS)
-            return 200, graph_path.read_bytes()
+        static_routes = {
+            "graph": render_module.GRAPH_PARTS,
+            "search": render_module.SEARCH_PARTS,
+        }
+        if route.get("kind") in static_routes:
+            target = self.settings.output_dir.joinpath(*static_routes[route["kind"]])
+            return 200, target.read_bytes()
         return 200, self.render(route).encode("utf-8")
 
     def context_for(self, slug: str) -> dict:
@@ -190,21 +194,15 @@ def render(self, route: dict) -> str:         from . import render as render_module
 
         data = self.context_for(route["slug"])
-        site = {
-            "title": self.settings.site.title,
-            "url": self.settings.site.url,
-            "description": self.settings.site.description,
-            "author": self.settings.site.author,
-            "language": self.settings.site.language,
-        }
         shared = {
-            "site": site,
-            "asset_url": render_module.ASSET_URL,
-            "logo_url": render_module.LOGO_URL,
-            "feed_url": "",
+            **render_module.shared_context(self.settings),
             "related": data.get("related", []),
             "stale": self.stale(),
         }
+        local_graph = data.get("local_graph")
+        if local_graph:
+            local_graph["svg"] = template.Markup(local_graph["svg"])
+        shared["local_graph"] = local_graph
 
         if route["kind"] == "seed":
             page = {
@@ -297,13 +295,9 @@ self.settings.base_path,         )
 
     def render_missing(self, path: str) -> str:
-        return md_html.prefix_root_urls(
-            "<!doctype html>\n<meta charset=\"utf-8\" />\n<title>Not found</title>\n"
-            f"<h1>404</h1>\n<p>Nothing is published at "
-            f"<code>{md_html.escape(path)}</code>.</p>\n"
-            '<p><a href="/">Back to the index</a></p>\n',
-            self.settings.base_path,
-        )
+        from . import render as render_module
+
+        return render_module.not_found_page(self.settings, self.loader)
 
 
 def main(root: Path | None = None, stream=None) -> int:
@@ -20,6 +20,12 @@ url = "" description = ""
 author = ""
 language = "en"
+repository = ""
+
+[theme]
+accent = "#000080"
+logo = ""
+width = 1152
 
 [paths]
 content = "content"
@@ -10,6 +10,7 @@ from __future__ import annotations 
 import hashlib
 import json
+import re
 import tomllib
 from dataclasses import asdict, dataclass
 from pathlib import Path, PurePosixPath, PureWindowsPath
@@ -32,6 +33,7 @@ url: str = ""     description: str = ""
     author: str = ""
     language: str = "en"
+    repository: str = ""
 
 
 @dataclass(frozen=True)
@@ -64,12 +66,20 @@ enabled: bool = True     count: int = 20
 
 
+@dataclass(frozen=True)
+class Theme:
+    accent: str = "#000080"
+    logo: str = ""
+    width: int = 1152
+
+
 SECTIONS: dict[str, type] = {
     "site": Site,
     "paths": Paths,
     "seeds": Seeds,
     "related": Related,
     "feed": Feed,
+    "theme": Theme,
 }
 
 
@@ -81,6 +91,7 @@ paths: Paths     seeds: Seeds
     related: Related
     feed: Feed
+    theme: Theme
 
     @property
     def content_dir(self) -> Path:
@@ -205,6 +216,21 @@ "[site] url: expected an http(s) site URL without credentials, "                 "a query, a fragment or an ambiguous path"
             )
         sections["site"] = Site(**{**asdict(site), "url": site.url.rstrip("/")})
+        site = sections["site"]
+
+    if site.repository:
+        parsed = urlsplit(site.repository)
+        if (
+            parsed.scheme not in ("http", "https")
+            or not parsed.netloc
+            or parsed.username is not None
+            or parsed.password is not None
+            or "\\" in site.repository
+        ):
+            raise ConfigError("[site] repository: expected a safe http(s) URL")
+        sections["site"] = Site(
+            **{**asdict(site), "repository": site.repository.rstrip("/")}
+        )
 
     paths: Paths = sections["paths"]
     for field_name in ("content", "output", "static", "templates"):
@@ -249,6 +275,31 @@ )     for name, valid in ranges:
         if not valid:
             raise ConfigError(f"{name}: value is outside the supported range")
+
+    theme: Theme = sections["theme"]
+    if not re.fullmatch(r"#[0-9a-fA-F]{6}", theme.accent):
+        raise ConfigError("[theme] accent: expected a six-digit hex colour")
+    if not 640 <= theme.width <= 2400:
+        raise ConfigError("[theme] width: expected a value from 640 to 2400 pixels")
+    if theme.logo:
+        parsed = urlsplit(theme.logo)
+        external = parsed.scheme in ("http", "https") and bool(parsed.netloc)
+        local = (
+            theme.logo.startswith("/")
+            and not theme.logo.startswith("//")
+            and "\\" not in theme.logo
+            and ".." not in PurePosixPath(parsed.path).parts
+            and not parsed.query
+            and not parsed.fragment
+        )
+        if (
+            not (external or local)
+            or parsed.username is not None
+            or parsed.password is not None
+        ):
+            raise ConfigError(
+                "[theme] logo: expected a root-relative path or safe http(s) URL"
+            )
 
     return Config(root=root, **sections)
 
@@ -452,12 +452,8 @@ {"title": node.title, "url": node.url, "seed": node.is_seed}             for node in self.related.get(slug, [])
         ]
 
-    def visualisation(self) -> dict:
-        """A deterministic, dependency-free SVG view of the whole graph."""
-        ranked = sorted(
-            self.nodes.values(),
-            key=lambda node: (-self._degree(node.slug), node.slug),
-        )
+    def _svg(self, ranked: list[Node], ui: dict[str, str], focus: str = "") -> Markup:
+        """Lay out a bounded set of nodes as deterministic SVG."""
         count = len(ranked)
         width, height = 960, 640
         centre_x, centre_y = width / 2, height / 2
@@ -481,15 +477,18 @@ for source in sorted(self.out_links)             for target in sorted(self.out_links[source])
             if source in positions and target in positions
         ]
+        edge_limit = 1200 if focus else 2400
+        edges = edges[:edge_limit]
         maximum_degree = max((self._degree(node.slug) for node in ranked), default=1)
-        label_limit = min(count, max(24, int(math.sqrt(count) * 8)))
+        label_limit = count if focus else min(count, max(24, int(math.sqrt(count) * 6)))
+        suffix = focus or "global"
 
         svg = [
             '<svg class="graph-map" viewBox="0 0 960 640" role="img" '
-            'aria-labelledby="graph-title graph-description">',
-            '<title id="graph-title">Site graph</title>',
-            '<desc id="graph-description">Linked notes and seed pages. '
-            "Each node opens its page.</desc>",
+            f'aria-labelledby="graph-title-{suffix} graph-description-{suffix}">',
+            f'<title id="graph-title-{suffix}">{md_html.escape(ui["graph_map_title"])}</title>',
+            f'<desc id="graph-description-{suffix}">'
+            f'{md_html.escape(ui["graph_map_description"])}</desc>',
             '<g class="graph-edges" aria-hidden="true">',
         ]
         for source, target in edges:
@@ -506,6 +505,8 @@ x, y = positions[node.slug]             degree = self._degree(node.slug)
             radius = 4.5 + 5 * math.sqrt(degree / max(1, maximum_degree))
             kind = " graph-seed" if node.is_seed else ""
+            if node.slug == focus:
+                kind += " graph-focus"
             title = md_html.escape(node.title)
             label = node.title if len(node.title) <= 24 else node.title[:23] + "…"
             svg.append(
@@ -523,16 +524,91 @@ + "</a>"             )
 
         svg.append("</g>\n</svg>")
-        seed_count = sum(node.is_seed for node in ranked)
+        return Markup("\n".join(svg))
+
+    @staticmethod
+    def _summary(ui: dict[str, str], nodes: int, edges: int, seeds: int) -> str:
+        def amount(value: int, singular: str, plural: str) -> str:
+            return f"{value} {ui[singular] if value == 1 else ui[plural]}"
+
+        return ", ".join(
+            (
+                amount(nodes, "node", "nodes"),
+                amount(edges, "link", "links"),
+                amount(seeds, "seed", "seeds"),
+            )
+        ) + "."
+
+    def visualisation(self, ui: dict[str, str], limit: int = 180) -> dict:
+        """A bounded SVG plus an accessible directory of every node."""
+        all_nodes = sorted(
+            self.nodes.values(),
+            key=lambda node: (-self._degree(node.slug), node.slug),
+        )
+        ranked = all_nodes[:limit]
+        edge_count = sum(len(targets) for targets in self.out_links.values())
+        seed_count = sum(node.is_seed for node in all_nodes)
+        groups: dict[str, list[dict]] = defaultdict(list)
+        for node in sorted(all_nodes, key=lambda item: item.title.casefold()):
+            key = node.title[:1].upper() or "#"
+            groups[key].append(
+                {"title": node.title, "url": node.url, "seed": node.is_seed}
+            )
+
         return {
-            "svg": Markup("\n".join(svg)),
-            "nodes": count,
-            "edges": len(edges),
+            "svg": self._svg(ranked, ui),
+            "nodes": len(all_nodes),
+            "shown": len(ranked),
+            "edges": edge_count,
             "seeds": seed_count,
-            "summary": (
-                f"{count} node{'s' if count != 1 else ''}, "
-                f"{len(edges)} link{'s' if len(edges) != 1 else ''}, "
-                f"{seed_count} seed{'s' if seed_count != 1 else ''}."
+            "summary": self._summary(ui, len(all_nodes), edge_count, seed_count),
+            "scope": (
+                f'{ui["showing"]} {len(ranked)} {ui["of"]} '
+                f'{len(all_nodes)} {ui["nodes"]}.'
+                if len(ranked) < len(all_nodes)
+                else ""
+            ),
+            "groups": [
+                {"name": name, "nodes": nodes}
+                for name, nodes in sorted(groups.items())
+            ],
+        }
+
+    def local_visualisation(
+        self,
+        slug: str,
+        ui: dict[str, str],
+        limit: int = 80,
+    ) -> dict | None:
+        neighbours = self.out_links.get(slug, set()) | self.in_links.get(slug, set())
+        if not neighbours:
+            return None
+
+        ranked = sorted(
+            (self.nodes[item] for item in neighbours),
+            key=lambda node: (-self._degree(node.slug), node.slug),
+        )
+        shown = [self.nodes[slug], *ranked[: limit - 1]]
+
+        def entries(slugs) -> list[dict]:
+            return [
+                {
+                    "title": self.nodes[item].title,
+                    "url": self.nodes[item].url,
+                    "seed": self.nodes[item].is_seed,
+                }
+                for item in sorted(slugs, key=lambda item: self.nodes[item].title.casefold())
+            ]
+
+        return {
+            "svg": self._svg(shown, ui, focus=slug),
+            "outgoing": entries(self.out_links.get(slug, set())),
+            "incoming": entries(self.in_links.get(slug, set())),
+            "scope": (
+                f'{ui["showing"]} {len(shown) - 1} {ui["of"]} '
+                f'{len(neighbours)} {ui["nodes"]}.'
+                if len(shown) - 1 < len(neighbours)
+                else ""
             ),
         }
 
@@ -0,0 +1,101 @@+"""Small built-in interface translations."""
+
+EN = {
+    "skip": "Skip to content",
+    "site_kind": "documentation",
+    "navigation": "Navigation",
+    "overview": "Overview",
+    "graph": "Graph",
+    "search": "Search",
+    "rss": "RSS feed",
+    "source": "Source code",
+    "about": "About",
+    "docs": "docs",
+    "stale": "This page changed after the CGI index was built. Rebuild the index to refresh links and graph data.",
+    "built_with": "Built with",
+    "writing": "Writing",
+    "notes": "Notes",
+    "mentioned_in": "Mentioned in",
+    "related": "Related",
+    "seed_notice": "This note has not been written yet. What follows is every place it has been mentioned, which is the shape of the note before it exists.",
+    "graph_instruction": "Select a node to open its page.",
+    "graph_map_title": "Site graph",
+    "graph_map_description": "Linked notes and seed pages. Each node opens its page.",
+    "written_note": "written note",
+    "seed_page": "seed page",
+    "connections": "Connections",
+    "links_to": "Links to",
+    "linked_from": "Linked from",
+    "all_pages": "All pages",
+    "search_intro": "Search titles and page contents. The index stays entirely in this site.",
+    "search_placeholder": "Search the site",
+    "search_loading": "Loading search index…",
+    "search_empty": "No pages found.",
+    "search_error": "The search index could not be loaded.",
+    "search_prompt": "Type at least two characters.",
+    "result": "result",
+    "results": "results",
+    "not_found": "Page not found",
+    "not_found_message": "Nothing is published at this address.",
+    "back_home": "Back to the index",
+    "node": "node",
+    "nodes": "nodes",
+    "link": "link",
+    "links": "links",
+    "seed": "seed",
+    "seeds": "seeds",
+    "showing": "Showing",
+    "of": "of",
+}
+
+PT = {
+    "skip": "Pular para o conteúdo",
+    "site_kind": "documentação",
+    "navigation": "Navegação",
+    "overview": "Início",
+    "graph": "Grafo",
+    "search": "Busca",
+    "rss": "Feed RSS",
+    "source": "Código-fonte",
+    "about": "Sobre",
+    "docs": "docs",
+    "stale": "Esta página mudou depois que o índice CGI foi criado. Reconstrua o índice para atualizar links e dados do grafo.",
+    "built_with": "Gerado com",
+    "writing": "Publicações",
+    "notes": "Notas",
+    "mentioned_in": "Mencionado em",
+    "related": "Relacionados",
+    "seed_notice": "Esta nota ainda não foi escrita. Abaixo estão todos os lugares em que ela foi mencionada: o formato da nota antes de ela existir.",
+    "graph_instruction": "Selecione um nó para abrir sua página.",
+    "graph_map_title": "Grafo do site",
+    "graph_map_description": "Notas e páginas-semente conectadas. Cada nó abre sua página.",
+    "written_note": "nota escrita",
+    "seed_page": "página-semente",
+    "connections": "Conexões",
+    "links_to": "Aponta para",
+    "linked_from": "Referenciada por",
+    "all_pages": "Todas as páginas",
+    "search_intro": "Pesquise títulos e conteúdos. O índice permanece inteiramente neste site.",
+    "search_placeholder": "Pesquisar no site",
+    "search_loading": "Carregando índice de busca…",
+    "search_empty": "Nenhuma página encontrada.",
+    "search_error": "Não foi possível carregar o índice de busca.",
+    "search_prompt": "Digite pelo menos dois caracteres.",
+    "result": "resultado",
+    "results": "resultados",
+    "not_found": "Página não encontrada",
+    "not_found_message": "Nada foi publicado neste endereço.",
+    "back_home": "Voltar ao início",
+    "node": "nó",
+    "nodes": "nós",
+    "link": "link",
+    "links": "links",
+    "seed": "semente",
+    "seeds": "sementes",
+    "showing": "Exibindo",
+    "of": "de",
+}
+
+
+def labels(language: str) -> dict[str, str]:
+    return PT if language.casefold().split("-", 1)[0] == "pt" else EN
@@ -2,7 +2,10 @@ """Turning collected pages into files on disk.""" 
 from __future__ import annotations
 
+import html as html_module
 import importlib.resources
+import json
+import re
 from dataclasses import asdict, dataclass, field
 from pathlib import Path
 
@@ -11,6 +14,7 @@ cache,     collect,
     feeds,
     graph,
+    i18n,
     md_html,
     report,
     sitemap,
@@ -28,6 +32,15 @@ LOGO_NAME = "logo.svg" LOGO_URL = "/assets/logo.svg"
 GRAPH_URL = "/graph/"
 GRAPH_PARTS = ("graph", "index.html")
+SEARCH_URL = "/search/"
+SEARCH_PARTS = ("search", "index.html")
+SEARCH_INDEX_NAME = "search.json"
+SEARCH_INDEX_URL = "/assets/search.json"
+SEARCH_SCRIPT_NAME = "search.js"
+SEARCH_SCRIPT_URL = "/assets/search.js"
+NOT_FOUND_PARTS = ("404.html",)
+_HTML_TAG = re.compile(r"<[^>]+>")
+_SPACE = re.compile(r"\s+")
 
 
 @dataclass
@@ -56,14 +69,32 @@ .read_text(encoding="utf-8")     )
 
 
-def check_graph_route(site_graph: graph.Graph) -> None:
-    if any(node.url == GRAPH_URL for node in site_graph.nodes.values()):
+def check_reserved_routes(site_graph: graph.Graph) -> None:
+    reserved = {GRAPH_URL, SEARCH_URL}
+    conflict = next(
+        (node.url for node in site_graph.nodes.values() if node.url in reserved),
+        "",
+    )
+    if conflict:
         raise BuildError(
-            f"{GRAPH_URL} is reserved for the global graph; "
+            f"{conflict} is reserved for a built-in page; "
             "give that note or seed a different name or explicit slug"
         )
 
 
+def shared_context(settings: Config, feed_url: str = "") -> dict:
+    return {
+        "site": asdict(settings.site),
+        "theme": asdict(settings.theme),
+        "ui": i18n.labels(settings.site.language),
+        "asset_url": ASSET_URL,
+        "logo_url": settings.theme.logo or LOGO_URL,
+        "feed_url": feed_url,
+        "search_index_url": settings.base_path + SEARCH_INDEX_URL,
+        "search_script_url": SEARCH_SCRIPT_URL,
+    }
+
+
 def emit_static(settings: Config, emitter: cache.Emitter) -> None:
     """Copy static/ through the emitter so deletions are noticed too."""
     if not settings.static_dir.is_dir():
@@ -138,45 +169,120 @@ )     return posts, notes
 
 
-def graph_page(
-    site_graph: graph.Graph,
+def special_page(
     settings: Config,
-    loader: template.Loader,
-    site: dict,
-    feed_url: str,
-) -> str:
-    page = {
-        "title": "Graph",
-        "name": "Graph",
-        "url": GRAPH_URL,
-        "slug": "graph",
+    title: str,
+    url: str,
+    description: str = "",
+    noindex: bool = False,
+) -> dict:
+    return {
+        "title": title,
+        "name": title,
+        "url": url,
+        "slug": url.strip("/") or "home",
         "date": None,
         "tags": [],
         "html": template.Markup(""),
-        "description": "A map of the links between notes.",
+        "description": description,
         "is_post": False,
         "is_seed": False,
-        "canonical": settings.site.url + GRAPH_URL if settings.site.url else "",
-        "noindex": False,
+        "canonical": settings.site.url + url if settings.site.url and url else "",
+        "noindex": noindex,
         "title_in_body": True,
     }
+
+
+def graph_page(
+    site_graph: graph.Graph,
+    settings: Config,
+    loader: template.Loader,
+    feed_url: str,
+) -> str:
+    shared = shared_context(settings, feed_url)
+    ui = shared["ui"]
     return md_html.prefix_root_urls(
         loader.render(
             "graph.html",
             {
-                "site": site,
-                "page": page,
-                "asset_url": ASSET_URL,
-                "logo_url": LOGO_URL,
-                "feed_url": feed_url,
+                **shared,
+                "page": special_page(
+                    settings,
+                    ui["graph"],
+                    GRAPH_URL,
+                    ui["graph_map_description"],
+                ),
+                "related": [],
+                "graph": site_graph.visualisation(ui),
+            },
+        ),
+        settings.base_path,
+    )
+
+
+def search_page(settings: Config, loader: template.Loader, feed_url: str) -> str:
+    shared = shared_context(settings, feed_url)
+    ui = shared["ui"]
+    return md_html.prefix_root_urls(
+        loader.render(
+            "search.html",
+            {
+                **shared,
+                "page": special_page(
+                    settings,
+                    ui["search"],
+                    SEARCH_URL,
+                    ui["search_intro"],
+                ),
                 "related": [],
-                "graph": site_graph.visualisation(),
             },
         ),
         settings.base_path,
     )
 
 
+def not_found_page(settings: Config, loader: template.Loader) -> str:
+    shared = shared_context(settings)
+    ui = shared["ui"]
+    return md_html.prefix_root_urls(
+        loader.render(
+            "404.html",
+            {
+                **shared,
+                "page": special_page(
+                    settings,
+                    f'404 - {ui["not_found"]}',
+                    "",
+                    ui["not_found_message"],
+                    noindex=True,
+                ),
+                "related": [],
+            },
+        ),
+        settings.base_path,
+    )
+
+
+def plain_text(value: str) -> str:
+    return _SPACE.sub(
+        " ",
+        html_module.unescape(_HTML_TAG.sub(" ", value)),
+    ).strip()
+
+
+def search_entry(title: str, url: str, body: str, settings: Config) -> dict:
+    public_url = settings.base_path + url if settings.base_path else url
+    return {"t": title, "u": public_url, "x": plain_text(body)}
+
+
+def search_data(entries: list[dict]) -> str:
+    return json.dumps(
+        sorted(entries, key=lambda item: item["t"].casefold()),
+        ensure_ascii=False,
+        separators=(",", ":"),
+    )
+
+
 def source_state(settings: Config) -> dict[str, list[int]]:
     """Cheap, exact-enough CGI staleness data without parsing every note."""
     state = {}
@@ -197,14 +303,15 @@ single request cannot work them out. So they are worked out once, here, and     written per page; a request then only has to read one small file and render
     one note. Nothing about a request touches the graph.
     """
-    import json
-
     pages = collect.collect(settings)
     site_graph = graph.build(pages, settings)
-    check_graph_route(site_graph)
+    check_reserved_routes(site_graph)
+    resolver = site_graph.resolver()
     loader = template.Loader(
         settings.templates_dir if settings.templates_dir.is_dir() else None
     )
+    shared = shared_context(settings)
+    ui = shared["ui"]
 
     work = settings.work_dir
     context_dir = work / "ctx"
@@ -238,6 +345,7 @@ "url": page.url,                 "links": site_graph.link_table(page.slug),
                 "backlinks": references(site_graph.backlinks.get(page.slug, [])),
                 "related": site_graph.related_for(page.slug),
+                "local_graph": site_graph.local_visualisation(page.slug, ui),
             },
         )
 
@@ -252,6 +360,7 @@ "url": node.url,                 "links": {},
                 "mentions": references(site_graph.seed_sources.get(node.slug, [])),
                 "related": site_graph.related_for(node.slug),
+                "local_graph": site_graph.local_visualisation(node.slug, ui),
             },
         )
         stats.seeds += 1
@@ -263,11 +372,12 @@     posts, notes = listing(pages)
     feed_url = feeds.FEED_URL if (settings.feed.enabled and settings.site.url) else ""
     routes[GRAPH_URL] = {"kind": "graph"}
+    routes[SEARCH_URL] = {"kind": "search"}
     write_text(
         work / "routes.json",
         json.dumps(
             {
-                "version": 3,
+                "version": 4,
                 "config_hash": settings.digest,
                 "sources": source_state(settings),
                 "routes": routes,
@@ -281,14 +391,38 @@ sort_keys=True,         ),
     )
 
-    for name in (ASSET_NAME, LOGO_NAME):
+    for name in (ASSET_NAME, LOGO_NAME, SEARCH_SCRIPT_NAME):
         write_text(
             settings.output_dir / "assets" / name,
             theme_asset(settings, name),
         )
+    entries = []
+    for page in pages:
+        rendered = md_html.render(
+            page.document,
+            resolver=resolver,
+            embed_renderer=transclude.Transcluder(site_graph, resolver),
+        )
+        entries.append(search_entry(page.title, page.url, rendered.html, settings))
+    for node in site_graph.seeds():
+        sources = site_graph.seed_sources.get(node.slug, [])
+        body = " ".join(str(excerpt) for source in sources for excerpt in source.excerpts)
+        entries.append(search_entry(node.title, node.url, body, settings))
+    write_text(
+        settings.output_dir / "assets" / SEARCH_INDEX_NAME,
+        search_data(entries),
+    )
     write_text(
         settings.output_dir.joinpath(*GRAPH_PARTS),
-        graph_page(site_graph, settings, loader, asdict(settings.site), feed_url),
+        graph_page(site_graph, settings, loader, feed_url),
+    )
+    write_text(
+        settings.output_dir.joinpath(*SEARCH_PARTS),
+        search_page(settings, loader, feed_url),
+    )
+    write_text(
+        settings.output_dir.joinpath(*NOT_FOUND_PARTS),
+        not_found_page(settings, loader),
     )
     stats.written = len(routes)
     return stats
@@ -297,7 +431,7 @@ def build(settings: Config) -> Stats:
     pages = collect.collect(settings)
     site_graph = graph.build(pages, settings)
-    check_graph_route(site_graph)
+    check_reserved_routes(site_graph)
     site_graph.report_near_misses()
     resolver = site_graph.resolver()
 
@@ -319,15 +453,17 @@ settings.output_dir.mkdir(parents=True, exist_ok=True)     emitter = cache.Emitter(settings.output_dir, manifest)
 
     emit_static(settings, emitter)
-    for name in (ASSET_NAME, LOGO_NAME):
+    for name in (ASSET_NAME, LOGO_NAME, SEARCH_SCRIPT_NAME):
         emitter.emit(("assets", name), theme_asset(settings, name))
 
     posts, notes = listing(pages)
-    site = asdict(settings.site)
     stats = Stats(pages=len(pages))
     summaries: dict[str, str] = {}
+    search_entries: list[dict] = []
 
     feed_url = feeds.FEED_URL if (settings.feed.enabled and settings.site.url) else ""
+    shared = shared_context(settings, feed_url)
+    ui = shared["ui"]
 
     for page in pages:
         rendered = md_html.render(
@@ -336,14 +472,15 @@ resolver=resolver,             embed_renderer=transclude.Transcluder(site_graph, resolver),
         )
         summaries[page.slug] = rendered.html
+        search_entries.append(
+            search_entry(page.title, page.url, rendered.html, settings)
+        )
         context = {
-            "site": site,
+            **shared,
             "page": page_context(page, rendered.html, settings),
-            "asset_url": ASSET_URL,
-            "logo_url": LOGO_URL,
-            "feed_url": feed_url,
             "backlinks": site_graph.backlinks.get(page.slug, []),
             "related": site_graph.related_for(page.slug),
+            "local_graph": site_graph.local_visualisation(page.slug, ui),
         }
 
         if page.is_home:
@@ -361,15 +498,26 @@ if emitter.emit(page.output_parts, output):             report.detail(f"{page.key} -> {page.url}")
 
     for node in site_graph.seeds():
+        mentions = site_graph.seed_sources.get(node.slug, [])
+        search_entries.append(
+            search_entry(
+                node.title,
+                node.url,
+                " ".join(
+                    str(excerpt)
+                    for source in mentions
+                    for excerpt in source.excerpts
+                ),
+                settings,
+            )
+        )
         context = {
-            "site": site,
+            **shared,
             "page": seed_context(node, settings),
-            "asset_url": ASSET_URL,
-            "logo_url": LOGO_URL,
-            "feed_url": feed_url,
-            "mentions": site_graph.seed_sources.get(node.slug, []),
+            "mentions": mentions,
             "backlinks": [],
             "related": site_graph.related_for(node.slug),
+            "local_graph": site_graph.local_visualisation(node.slug, ui),
         }
         output = md_html.prefix_root_urls(
             loader.render("seed.html", context),
@@ -381,7 +529,13 @@ stats.seeds += 1 
     emitter.emit(
         GRAPH_PARTS,
-        graph_page(site_graph, settings, loader, site, feed_url),
+        graph_page(site_graph, settings, loader, feed_url),
+    )
+    emitter.emit(SEARCH_PARTS, search_page(settings, loader, feed_url))
+    emitter.emit(NOT_FOUND_PARTS, not_found_page(settings, loader))
+    emitter.emit(
+        ("assets", SEARCH_INDEX_NAME),
+        search_data(search_entries),
     )
 
     if settings.feed.enabled:
@@ -176,7 +176,12 @@ self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")             return
 
         if not target.exists():
-            body = (NOT_FOUND % route).encode("utf-8") + RELOAD_SCRIPT.encode("utf-8")
+            not_found = self.state.settings.output_dir / "404.html"
+            if not_found.is_file():
+                html = not_found.read_text(encoding="utf-8")
+            else:
+                html = NOT_FOUND % route
+            body = html.encode("utf-8") + RELOAD_SCRIPT.encode("utf-8")
             self._send(404, body, "text/html; charset=utf-8")
             return
 
@@ -20,6 +20,7 @@ for page in pages         if not page.meta.get("noindex")
     ]
     urls.append(settings.site.url + "/graph/")
+    urls.append(settings.site.url + "/search/")
     if settings.seeds.in_sitemap and not settings.seeds.noindex:
         urls.extend(settings.site.url + node.url for node in seeds)
 
@@ -0,0 +1,9 @@+{% extends "base.html" %}
+{% block content %}
+<section class="not-found">
+  <p class="error-code">404</p>
+  <h1>{{ ui.not_found }}</h1>
+  <p>{{ ui.not_found_message }}</p>
+  <p><a href="/">{{ ui.back_home }}</a></p>
+</section>
+{% endblock %}
@@ -1,6 +1,6 @@ {% if backlinks %}
 <section class="backlinks">
-  <h2>Mentioned in</h2>
+  <h2>{{ ui.mentioned_in }}</h2>
   <ul>
   {% for link in backlinks %}
     <li>
@@ -0,0 +1,29 @@+{% if local_graph %}
+<section class="local-graph">
+  <h2>{{ ui.connections }}</h2>
+  <div class="local-graph-map">{{ local_graph.svg | raw }}</div>
+  {% if local_graph.scope %}<p class="graph-scope">{{ local_graph.scope }}</p>{% endif %}
+  <div class="local-graph-lists">
+    {% if local_graph.outgoing %}
+    <div>
+      <h3>{{ ui.links_to }}</h3>
+      <ul>
+      {% for node in local_graph.outgoing %}
+        <li><a href="{{ node.url }}">{{ node.title }}</a></li>
+      {% endfor %}
+      </ul>
+    </div>
+    {% endif %}
+    {% if local_graph.incoming %}
+    <div>
+      <h3>{{ ui.linked_from }}</h3>
+      <ul>
+      {% for node in local_graph.incoming %}
+        <li><a href="{{ node.url }}">{{ node.title }}</a></li>
+      {% endfor %}
+      </ul>
+    </div>
+    {% endif %}
+  </div>
+</section>
+{% endif %}
@@ -1,6 +1,6 @@ {% if related %}
 <section class="related">
-  <h2>Related</h2>
+  <h2>{{ ui.related }}</h2>
   <ul>
   {% for note in related %}
     <li><a href="{{ note.url }}">{{ note.title }}</a></li>
@@ -1,5 +1,5 @@ <!doctype html>
-<html lang="{{ site.language | default:"en" }}">
+<html lang="{{ site.language | default:"en" }}" style="--blue-dark: {{ theme.accent }}; --shell: {{ theme.width }}px">
 <head>
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -23,41 +23,44 @@ {% endif %} <link rel="stylesheet" href="{{ asset_url }}" />
 </head>
 <body>
-<a class="skip" href="#content">Skip to content</a>
+<a class="skip" href="#content">{{ ui.skip }}</a>
 
 <div class="site-frame">
 <header class="site-header">
   <a class="site-title" href="/">
     <img src="{{ logo_url }}" alt="" width="36" height="36" />
     <span class="site-name">{{ site.title }}</span>
-    <span class="site-kind">documentation</span>
+    <span class="site-kind">{{ ui.site_kind }}</span>
   </a>
 </header>
 
 <aside class="site-sidebar">
-  <p class="sidebar-label">Navigation</p>
-  <nav class="site-nav" aria-label="Site">
-    <a href="/">Overview</a>
-    <a href="/graph/">Graph</a>
+  <p class="sidebar-label">{{ ui.navigation }}</p>
+  <nav class="site-nav" aria-label="{{ ui.navigation }}">
+    <a href="/">{{ ui.overview }}</a>
+    <a href="/search/">{{ ui.search }}</a>
+    <a href="/graph/">{{ ui.graph }}</a>
     {% if feed_url %}
-    <a href="{{ feed_url }}">RSS feed</a>
+    <a href="{{ feed_url }}">{{ ui.rss }}</a>
+    {% endif %}
+    {% if site.repository %}
+    <a href="{{ site.repository }}">{{ ui.source }}</a>
     {% endif %}
-    <a href="https://github.com/runawaydevil/mdssg">Source code</a>
   </nav>
 
   {% if site.description %}
-  <p class="sidebar-label sidebar-about-label">About</p>
+  <p class="sidebar-label sidebar-about-label">{{ ui.about }}</p>
   <p class="site-tagline">{{ site.description }}</p>
   {% endif %}
 </aside>
 
 <main class="site-main" id="content">
 <div class="content-path">
-  <a href="/">docs</a><span>/</span><strong>{{ page.title }}</strong>
+  <a href="/">{{ ui.docs }}</a><span>/</span><strong>{{ page.title }}</strong>
 </div>
 {% if stale %}
 <aside class="stale-warning" role="status">
-  This page changed after the CGI index was built. Rebuild the index to refresh links and graph data.
+  {{ ui.stale }}
 </aside>
 {% endif %}
 {% block content %}{% endblock %}
@@ -67,7 +70,7 @@ <footer class="site-footer">   {% if site.author %}
   <p>{{ site.author }}</p>
   {% endif %}
-  <p class="colophon">Built with <a href="https://github.com/runawaydevil/mdssg">mdssg</a></p>
+  <p class="colophon">{{ ui.built_with }} <a href="https://github.com/runawaydevil/mdssg">mdssg</a></p>
 </footer>
 </div>
 </body>
@@ -1,16 +1,36 @@ {% extends "base.html" %}
 {% block content %}
 <section class="graph-view">
-  <h1>Graph</h1>
+  <h1>{{ ui.graph }}</h1>
   <p class="graph-summary">
-    {{ graph.summary }} Select a node to open its page.
+    {{ graph.summary }} {{ ui.graph_instruction }}
   </p>
   <div class="graph-canvas">
     {{ graph.svg | raw }}
   </div>
   <p class="graph-legend">
-    <span><i class="graph-key graph-key-note"></i> written note</span>
-    <span><i class="graph-key graph-key-seed"></i> seed page</span>
+    <span><i class="graph-key graph-key-note"></i> {{ ui.written_note }}</span>
+    <span><i class="graph-key graph-key-seed"></i> {{ ui.seed_page }}</span>
   </p>
+  {% if graph.scope %}
+  <p class="graph-scope">{{ graph.scope }}</p>
+  {% endif %}
+
+  <section class="graph-directory">
+    <h2>{{ ui.all_pages }}</h2>
+    {% for group in graph.groups %}
+    <div class="graph-group">
+      <h3>{{ group.name }}</h3>
+      <ul>
+      {% for node in group.nodes %}
+        <li>
+          <a href="{{ node.url }}">{{ node.title }}</a>
+          {% if node.seed %}<span>{{ ui.seed }}</span>{% endif %}
+        </li>
+      {% endfor %}
+      </ul>
+    </div>
+    {% endfor %}
+  </section>
 </section>
 {% endblock %}
@@ -10,9 +10,11 @@ {{ page.html | raw }}   {% endif %}
 </article>
 
+{% include "_local_graph.html" %}
+
 {% if posts %}
 <section class="listing">
-  <h2>Writing</h2>
+  <h2>{{ ui.writing }}</h2>
   <ul class="post-list">
   {% for post in posts %}
     <li>
@@ -28,7 +30,7 @@ {% endif %} 
 {% if notes %}
 <section class="listing">
-  <h2>Notes</h2>
+  <h2>{{ ui.notes }}</h2>
   <ul class="note-list">
   {% for note in notes %}
     <li><a href="{{ note.url }}">{{ note.title }}</a></li>
@@ -16,4 +16,5 @@ </article> 
 {% include "_backlinks.html" %}
 {% include "_related.html" %}
+{% include "_local_graph.html" %}
 {% endblock %}
@@ -0,0 +1,19 @@+{% extends "base.html" %}
+{% block content %}
+<section class="search-view"
+  data-search-index="{{ search_index_url }}"
+  data-loading="{{ ui.search_loading }}"
+  data-empty="{{ ui.search_empty }}"
+  data-error="{{ ui.search_error }}"
+  data-prompt="{{ ui.search_prompt }}"
+  data-result="{{ ui.result }}"
+  data-results="{{ ui.results }}">
+  <h1>{{ ui.search }}</h1>
+  <p>{{ ui.search_intro }}</p>
+  <label class="search-label" for="site-search">{{ ui.search }}</label>
+  <input id="site-search" type="search" placeholder="{{ ui.search_placeholder }}" autocomplete="off" />
+  <p class="search-status" role="status">{{ ui.search_loading }}</p>
+  <ol class="search-results"></ol>
+</section>
+<script src="{{ search_script_url }}" defer></script>
+{% endblock %}
@@ -0,0 +1,72 @@+(() => {
+  const view = document.querySelector("[data-search-index]");
+  if (!view) return;
+
+  const input = view.querySelector("input");
+  const status = view.querySelector(".search-status");
+  const results = view.querySelector(".search-results");
+  const normalise = value => value.normalize("NFD")
+    .replace(/[\u0300-\u036f]/g, "")
+    .toLowerCase();
+  let pages = [];
+
+  const message = key => view.dataset[key] || "";
+
+  const render = () => {
+    const query = normalise(input.value.trim());
+    results.replaceChildren();
+
+    if (query.length < 2) {
+      status.textContent = message("prompt");
+      return;
+    }
+
+    const found = pages
+      .map(page => {
+        const title = normalise(page.t);
+        const text = normalise(page.x);
+        const at = text.indexOf(query);
+        let score = at < 0 ? -1 : 1;
+        if (title.includes(query)) score += 5;
+        if (title.startsWith(query)) score += 4;
+        return { page, score, at };
+      })
+      .filter(item => item.score >= 0)
+      .sort((a, b) => b.score - a.score || a.page.t.localeCompare(b.page.t))
+      .slice(0, 30);
+
+    status.textContent = `${found.length} ${message(found.length === 1 ? "result" : "results")}`;
+    if (!found.length) {
+      status.textContent = message("empty");
+      return;
+    }
+
+    for (const item of found) {
+      const li = document.createElement("li");
+      const link = document.createElement("a");
+      const excerpt = document.createElement("p");
+      const start = Math.max(0, item.at - 70);
+      link.href = item.page.u;
+      link.textContent = item.page.t;
+      excerpt.textContent = item.page.x.slice(start, start + 180).trim();
+      li.append(link, excerpt);
+      results.append(li);
+    }
+  };
+
+  input.addEventListener("input", render);
+  fetch(view.dataset.searchIndex)
+    .then(response => {
+      if (!response.ok) throw new Error(response.status);
+      return response.json();
+    })
+    .then(data => {
+      pages = Array.isArray(data) ? data : [];
+      const query = new URLSearchParams(location.search).get("q");
+      if (query) input.value = query;
+      render();
+    })
+    .catch(() => {
+      status.textContent = message("error");
+    });
+})();
@@ -2,10 +2,7 @@ {% extends "base.html" %} {% block content %}
 <article class="note seed">
   <h1 class="note-title">{{ page.title }}</h1>
-  <p class="seed-notice">
-    This note has not been written yet. What follows is every place it has been
-    mentioned, which is the shape of the note before it exists.
-  </p>
+  <p class="seed-notice">{{ ui.seed_notice }}</p>
 
   <ol class="seed-mentions">
   {% for source in mentions %}
@@ -20,4 +17,5 @@ </ol> </article>
 
 {% include "_related.html" %}
+{% include "_local_graph.html" %}
 {% endblock %}
@@ -708,6 +708,162 @@ border-color: var(--hover);   border-style: dashed;
 }
 
+.graph-focus circle {
+  fill: var(--hover);
+  stroke: #000;
+  stroke-width: 3;
+}
+
+.graph-scope {
+  margin: 0.8rem 0;
+  color: var(--muted);
+  font: 0.75rem/1.4 var(--mono);
+}
+
+.graph-directory {
+  margin-top: 2rem;
+  border-top: 2px solid var(--rule-dark);
+}
+
+.graph-directory > h2,
+.local-graph > h2 {
+  margin: 0 0 1rem;
+  padding: 0.4rem 0.55rem;
+  color: #fff;
+  background: var(--blue-dark);
+  border: 0;
+  font: 700 0.85rem/1.2 var(--mono);
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+}
+
+.graph-group {
+  display: grid;
+  grid-template-columns: 2.5rem minmax(0, 1fr);
+  border-bottom: 1px solid var(--rule);
+}
+
+.graph-group h3 {
+  margin: 0;
+  padding: 0.55rem 0.35rem;
+  font: 700 0.9rem/1.3 var(--mono);
+}
+
+.graph-group ul {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 0.25rem 1rem;
+  margin: 0;
+  padding: 0.55rem 0;
+  list-style: none;
+}
+
+.graph-group li {
+  min-width: 0;
+  margin: 0;
+}
+
+.graph-group li span {
+  margin-left: 0.35rem;
+  color: var(--hover);
+  font: 0.68rem/1 var(--mono);
+}
+
+.local-graph {
+  max-width: 52rem;
+  margin-top: 3rem;
+  padding-top: 1rem;
+  border-top: 2px solid var(--rule-dark);
+}
+
+.local-graph-map {
+  background: #fff;
+  border: 1px solid var(--rule-dark);
+}
+
+.local-graph-map .graph-map {
+  min-width: 0;
+}
+
+.local-graph-lists {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 1rem;
+  padding: 0.8rem;
+  background: var(--panel);
+  border: 1px solid var(--rule);
+  border-top: 0;
+}
+
+.local-graph-lists h3 {
+  margin: 0 0 0.35rem;
+  font: 700 0.75rem/1.3 var(--mono);
+  text-transform: uppercase;
+}
+
+.local-graph-lists ul {
+  margin: 0;
+  padding-left: 1.2rem;
+  font-size: 0.85rem;
+}
+
+.search-view,
+.not-found {
+  max-width: 52rem;
+}
+
+.search-label {
+  display: block;
+  margin-bottom: 0.35rem;
+  font: 700 0.75rem/1.3 var(--mono);
+  text-transform: uppercase;
+}
+
+.search-view input {
+  width: 100%;
+  padding: 0.65rem 0.7rem;
+  color: #000;
+  background: #fff;
+  border: 2px solid var(--rule-dark);
+  border-left: 5px solid var(--blue-dark);
+  font: 1rem/1.3 var(--sans);
+}
+
+.search-status {
+  margin: 0.55rem 0 1rem;
+  color: var(--muted);
+  font: 0.75rem/1.3 var(--mono);
+}
+
+.search-results {
+  margin: 0;
+  padding: 0;
+  border-top: 1px solid var(--rule);
+  list-style: none;
+}
+
+.search-results li {
+  margin: 0;
+  padding: 0.7rem 0.35rem;
+  border-bottom: 1px solid var(--rule);
+}
+
+.search-results a {
+  font-weight: 700;
+}
+
+.search-results p {
+  margin: 0.25rem 0 0;
+  color: #333;
+  font-size: 0.88rem;
+}
+
+.error-code {
+  margin: 0;
+  color: var(--blue-dark);
+  font: 700 clamp(3rem, 15vw, 7rem)/1 var(--mono);
+}
+
 @media (max-width: 46rem) {
   html,
   body {
@@ -770,6 +926,10 @@ .graph-map {     width: 42rem;
   }
 
+  .local-graph-map .graph-map {
+    width: 100%;
+  }
+
   .site-footer {
     padding: 0.8rem 1rem;
     border-right: 0;
@@ -792,7 +952,9 @@ } 
   .related ul,
   .note-list,
-  .post-list li {
+  .post-list li,
+  .local-graph-lists,
+  .graph-group ul {
     grid-template-columns: 1fr;
   }
 
@@ -38,4 +38,4 @@ [tool.setuptools.packages.find] include = ["mdssg*"]
 
 [tool.setuptools.package-data]
-"mdssg.theme" = ["*.html", "*.css", "*.svg"]
+"mdssg.theme" = ["*.html", "*.css", "*.svg", "*.js"]
@@ -1,3 +1,4 @@+import json
 import tempfile
 import unittest
 from pathlib import Path
@@ -153,9 +154,17 @@ home = self.output("index.html")         self.assertIn('href="/garden/assets/style.css"', home)
         self.assertIn('href="/garden/note/"', home)
         self.assertIn('href="/garden/feed.xml"', home)
+        self.assertIn('href="/garden/search/"', home)
         self.assertIn(
             '<link rel="canonical" href="https://example.com/garden/"',
             home,
+        )
+        search = self.output("search/index.html")
+        self.assertIn('data-search-index="/garden/assets/search.json"', search)
+        index = json.loads(self.output("assets/search.json"))
+        self.assertEqual(
+            {entry["u"] for entry in index},
+            {"/garden/", "/garden/note/"},
         )
 
     def test_writes_a_page_per_note(self):
@@ -193,6 +202,75 @@ with self.assertRaises(BuildError) as caught:             self.build()
         self.assertIn("/graph/ is reserved", str(caught.exception))
 
+    def test_search_route_cannot_be_overwritten_by_content(self):
+        self.write("content/search.md", "---\ntitle: Search\n---\n")
+        with self.assertRaises(BuildError) as caught:
+            self.build()
+        self.assertIn("/search/ is reserved", str(caught.exception))
+
+    def test_theme_has_no_source_link_without_repository(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n")
+        self.build()
+        self.assertNotIn(">Source code</a>", self.output("index.html"))
+
+    def test_repository_and_visual_settings_reach_the_theme(self):
+        self.write(
+            "mdssg.toml",
+            '[site]\ntitle = "T"\nrepository = "https://example.com/source"\n'
+            '\n[theme]\naccent = "#123ABC"\nlogo = "/brand.svg"\nwidth = 900\n',
+        )
+        self.write("content/index.md", "---\ntitle: Home\n---\n")
+        self.write("static/brand.svg", "<svg></svg>")
+        self.build()
+        html = self.output("index.html")
+        self.assertIn('href="https://example.com/source"', html)
+        self.assertIn('src="/brand.svg"', html)
+        self.assertIn("--blue-dark: #123ABC; --shell: 900px", html)
+
+    def test_portuguese_interface_is_selected_from_language(self):
+        self.write(
+            "mdssg.toml",
+            '[site]\ntitle = "T"\nlanguage = "pt-BR"\n',
+        )
+        self.write("content/index.md", "---\ntitle: Início\n---\n")
+        self.build()
+        html = self.output("index.html")
+        self.assertIn(">Navegação</p>", html)
+        self.assertIn(">Busca</a>", html)
+        self.assertIn(">Grafo</a>", html)
+
+    def test_static_search_indexes_titles_and_contents(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n\nWelcome")
+        self.write(
+            "content/needle.md",
+            "---\ntitle: Hidden Needle\n---\n\nA distinctive capybara paragraph.",
+        )
+        self.build()
+        data = json.loads(self.output("assets/search.json"))
+        needle = next(item for item in data if item["t"] == "Hidden Needle")
+        self.assertEqual(needle["u"], "/hidden-needle/")
+        self.assertIn("distinctive capybara", needle["x"])
+        search = self.output("search/index.html")
+        self.assertIn('data-search-index="/assets/search.json"', search)
+        self.assertIn('src="/assets/search.js"', search)
+
+    def test_local_graph_is_rendered_on_linked_notes(self):
+        self.write("content/a.md", "---\ntitle: A\n---\n\nSee [[B]].")
+        self.write("content/b.md", "---\ntitle: B\n---\n")
+        self.build()
+        html = self.output("a/index.html")
+        self.assertIn('class="local-graph"', html)
+        self.assertIn("graph-focus", html)
+        self.assertIn('href="/b/"', html)
+
+    def test_full_themed_404_is_emitted(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n")
+        self.build()
+        html = self.output("404.html")
+        self.assertIn('class="site-frame"', html)
+        self.assertIn('class="error-code">404', html)
+        self.assertIn('name="robots" content="noindex,follow"', html)
+
     def test_static_files_are_copied(self):
         self.write("content/index.md", "body")
         self.write("static/robots.txt", "User-agent: *\n")
@@ -257,6 +335,7 @@ sitemap = self.output("sitemap.xml")         self.assertIn("https://example.com/", sitemap)
         self.assertIn("https://example.com/a/", sitemap)
         self.assertIn("https://example.com/graph/", sitemap)
+        self.assertIn("https://example.com/search/", sitemap)
 
     def test_sitemap_excludes_noindex_pages(self):
         self.write(
@@ -45,6 +45,15 @@ routes = json.loads((self.root / ".mdssg" / "routes.json").read_text(encoding="utf-8"))         self.assertEqual(routes["routes"]["/graph/"]["kind"], "graph")
         self.assertTrue((self.root / "public" / "graph" / "index.html").is_file())
 
+    def test_index_only_writes_search_and_404(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n\nSearchable words")
+        self.index()
+        routes = json.loads((self.root / ".mdssg" / "routes.json").read_text(encoding="utf-8"))
+        self.assertEqual(routes["routes"]["/search/"]["kind"], "search")
+        self.assertTrue((self.root / "public" / "search" / "index.html").is_file())
+        self.assertTrue((self.root / "public" / "assets" / "search.json").is_file())
+        self.assertTrue((self.root / "public" / "404.html").is_file())
+
     def test_seeds_are_routed(self):
         self.write("content/a.md", "---\ntitle: A\n---\n\nAbout [[Ghost]].")
         self.index()
@@ -81,6 +90,16 @@ self.assertIn("200 OK", head)         self.assertIn('class="graph-map"', body)
         self.assertIn('href="/kant/"', body)
 
+    def test_serves_search(self):
+        _, head, body = self.request("/search/")
+        self.assertIn("200 OK", head)
+        self.assertIn('id="site-search"', body)
+
+    def test_serves_local_graph_from_precomputed_context(self):
+        _, _, body = self.request("/kant/")
+        self.assertIn('class="local-graph"', body)
+        self.assertIn("graph-focus", body)
+
     def test_links_resolve_without_the_graph(self):
         _, _, body = self.request("/kant/")
         self.assertIn('href="/hegel/"', body)
@@ -116,6 +135,7 @@ code, head, body = self.request("/nope/")         self.assertEqual(code, 0)
         self.assertIn("404", head)
         self.assertIn("Nothing is published", body)
+        self.assertIn('class="site-frame"', body)
 
     def test_trailing_slash_is_optional(self):
         _, head, _ = self.request("/kant")
@@ -117,7 +117,7 @@     def test_clean_starts_over(self):
         run("build", "-C", str(self.site))
         _, message = run("build", "-C", str(self.site), "--clean")
-        self.assertNotIn("0 written", message)
+        self.assertNotIn(": 0 written,", message)
 
     def test_index_only_renders_no_pages(self):
         code, message = run("build", "-C", str(self.site), "--index-only")
@@ -13,6 +13,8 @@ self.assertEqual(settings.site.language, "en")         self.assertEqual(settings.paths.content, "content")
         self.assertTrue(settings.seeds.enabled)
         self.assertEqual(settings.related.hub_cap, 32)
+        self.assertEqual(settings.theme.accent, "#000080")
+        self.assertEqual(settings.theme.width, 1152)
 
     def test_directories_hang_off_the_root(self):
         settings = config.parse({"paths": {"output": "dist"}}, ROOT)
@@ -111,6 +113,44 @@ ):             with self.subTest(value=value):
                 with self.assertRaises(config.ConfigError):
                     config.parse({"site": {"url": value}}, ROOT)
+
+    def test_repository_must_be_a_safe_absolute_url(self):
+        settings = config.parse(
+            {"site": {"repository": "https://github.com/example/site/"}},
+            ROOT,
+        )
+        self.assertEqual(settings.site.repository, "https://github.com/example/site")
+        for value in ("javascript:alert(1)", "/source", "https://u:p@example.com/x"):
+            with self.subTest(value=value):
+                with self.assertRaises(config.ConfigError):
+                    config.parse({"site": {"repository": value}}, ROOT)
+
+    def test_theme_values_are_validated(self):
+        for data in (
+            {"theme": {"accent": "navy"}},
+            {"theme": {"width": 400}},
+            {"theme": {"logo": "relative.svg"}},
+            {"theme": {"logo": "/../secret.svg"}},
+            {"theme": {"logo": "javascript:alert(1)"}},
+        ):
+            with self.subTest(data=data):
+                with self.assertRaises(config.ConfigError):
+                    config.parse(data, ROOT)
+
+    def test_theme_accepts_custom_visuals(self):
+        settings = config.parse(
+            {
+                "theme": {
+                    "accent": "#123ABC",
+                    "logo": "/brand.svg",
+                    "width": 960,
+                }
+            },
+            ROOT,
+        )
+        self.assertEqual(settings.theme.accent, "#123ABC")
+        self.assertEqual(settings.theme.logo, "/brand.svg")
+        self.assertEqual(settings.theme.width, 960)
 
     def test_path_overlap_is_case_insensitive(self):
         with self.assertRaises(config.ConfigError):
@@ -76,9 +76,9 @@ ) 
     def test_it_demonstrates_a_seed(self):
         self.assertGreaterEqual(self.stats.seeds, 1)
-        html = self.read("full-text-search/index.html")
+        html = self.read("future-note/index.html")
         self.assertIn("seed-notice", html)
-        self.assertIn("<mark>Full Text Search</mark>", html)
+        self.assertIn("<mark>Future Note</mark>", html)
 
     def test_it_demonstrates_backlinks_with_quotes(self):
         html = self.read("wikilinks/index.html")
@@ -1,7 +1,7 @@ import re
 import unittest
 
-from mdssg import collect, config, graph, render, report
+from mdssg import collect, config, graph, i18n, render, report
 from mdssg.errors import BuildError
 
 from tests.test_build import SiteCase
@@ -11,6 +11,48 @@ class GraphCase(SiteCase):     def graph(self):
         settings = config.load(self.root)
         return graph.build(collect.collect(settings), settings)
+
+
+class VisualisationTests(GraphCase):
+    def test_global_svg_is_bounded_but_directory_contains_every_node(self):
+        for number in range(205):
+            self.write(
+                f"content/n{number:03}.md",
+                f"---\ntitle: Note {number:03}\n---\n",
+            )
+        view = self.graph().visualisation(i18n.EN)
+        self.assertEqual(view["nodes"], 205)
+        self.assertEqual(view["shown"], 180)
+        self.assertTrue(view["scope"])
+        listed = [
+            node["title"]
+            for group in view["groups"]
+            for node in group["nodes"]
+        ]
+        self.assertIn("Note 204", listed)
+
+    def test_local_graph_contains_both_directions(self):
+        self.write("content/a.md", "---\ntitle: A\n---\n\n[[B]]")
+        self.write("content/b.md", "---\ntitle: B\n---\n\n[[C]]")
+        self.write("content/c.md", "---\ntitle: C\n---\n")
+        view = self.graph().local_visualisation("b", i18n.EN)
+        self.assertEqual([item["title"] for item in view["incoming"]], ["A"])
+        self.assertEqual([item["title"] for item in view["outgoing"]], ["C"])
+        self.assertIn("graph-focus", view["svg"])
+
+    def test_dense_global_svg_caps_edges_but_keeps_the_real_count(self):
+        for number in range(70):
+            self.write(
+                f"content/n{number:02}.md",
+                f"---\ntitle: Note {number:02}\n---\n",
+            )
+        site = self.graph()
+        slugs = sorted(site.nodes)
+        for source in slugs:
+            site.out_links[source] = set(slugs) - {source}
+        view = site.visualisation(i18n.EN)
+        self.assertEqual(view["edges"], 70 * 69)
+        self.assertEqual(view["svg"].count("<line "), 2400)
 
 
 class ResolutionTests(GraphCase):
@@ -290,7 +290,8 @@     def test_every_bundled_template_compiles(self):
         loader = template.Loader()
         for name in ("base.html", "page.html", "index.html", "seed.html",
-                     "graph.html", "_backlinks.html", "_related.html"):
+                     "graph.html", "search.html", "404.html", "_backlinks.html",
+                     "_related.html", "_local_graph.html"):
             with self.subTest(name=name):
                 self.assertTrue(loader.get(name).nodes or loader.get(name).extends)
 
@@ -300,10 +301,16 @@ html = loader.render(             "page.html",
             {
                 "site": {"title": "S", "language": "en"},
+                "theme": {"accent": "#000080", "width": 1152},
+                "ui": {"skip": "Skip", "site_kind": "docs", "navigation": "Nav",
+                       "overview": "Home", "search": "Search", "graph": "Graph",
+                       "docs": "docs", "built_with": "Built with"},
                 "page": {"title": "T", "html": template.Markup("<p>body</p>")},
                 "asset_url": "/assets/style.css",
+                "logo_url": "/assets/logo.svg",
                 "backlinks": [],
                 "related": [],
+                "local_graph": None,
             },
         )
         self.assertIn("<!doctype html>", html)
@@ -0,0 +1,78 @@+"""Controlled large-site build check used by CI."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from mdssg import config, render
+
+
+def write(path: Path, text: str) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(text, encoding="utf-8", newline="\n")
+
+
+def run(note_count: int, maximum_seconds: float) -> tuple[float, float]:
+    with tempfile.TemporaryDirectory() as temporary:
+        root = Path(temporary)
+        write(
+            root / "mdssg.toml",
+            '[site]\ntitle = "Benchmark"\n\n[feed]\nenabled = false\n',
+        )
+        write(
+            root / "content" / "index.md",
+            "---\ntitle: Home\n---\n\nStart at [[Note 0000]].",
+        )
+
+        for number in range(note_count):
+            before = (number - 1) % note_count
+            after = (number + 1) % note_count
+            write(
+                root / "content" / f"note-{number:04}.md",
+                f"---\ntitle: Note {number:04}\n---\n\n"
+                f"Linked to [[Note {before:04}]] and [[Note {after:04}]].",
+            )
+
+        settings = config.load(root)
+        started = time.perf_counter()
+        first = render.build(settings)
+        cold = time.perf_counter() - started
+
+        started = time.perf_counter()
+        second = render.build(settings)
+        warm = time.perf_counter() - started
+
+        if first.pages != note_count + 1:
+            raise SystemExit(f"expected {note_count + 1} pages, built {first.pages}")
+        if second.written != 0:
+            raise SystemExit(f"warm build rewrote {second.written} files")
+        if cold > maximum_seconds:
+            raise SystemExit(
+                f"cold build took {cold:.2f}s, limit is {maximum_seconds:.2f}s"
+            )
+
+        return cold, warm
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--notes", type=int, default=1500)
+    parser.add_argument("--max-seconds", type=float, default=20)
+    args = parser.parse_args()
+    if not 1000 <= args.notes <= 2000:
+        parser.error("--notes must stay between 1000 and 2000")
+
+    cold, warm = run(args.notes, args.max_seconds)
+    print(f"{args.notes} notes: cold {cold:.2f}s, unchanged {warm:.2f}s")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
@@ -62,6 +62,9 @@ css = (site / "public" / "assets" / "style.css").read_text(encoding="utf-8")         assert "--paper" in css, "the bundled stylesheet did not survive packaging"
         logo = (site / "public" / "assets" / "logo.svg").read_text(encoding="utf-8")
         assert 'fill="#000080"' in logo, "the bundled logo did not survive packaging"
+        search = (site / "public" / "assets" / "search.js").read_text(encoding="utf-8")
+        assert "fetch(" in search, "the bundled search script did not survive packaging"
+        assert (site / "public" / "assets" / "search.json").is_file()
         checks += 1
 
     print(f"mdssg.pyz ok ({mdssg.__version__}, {checks} checks)")