@@ -4,7 +4,7 @@ A static site generator for a folder of linked markdown notes. One file, no dependencies, Linux and Windows.
```sh
-python tools/build_pyz.py # produces mdssg.pyz, about 60 KB
+python tools/build_pyz.py # produces one small mdssg.pyz
python mdssg.pyz init my-site
python mdssg.pyz serve -C my-site
```
@@ -49,6 +49,10 @@ **Related notes come from the shape of the graph** — bibliographic coupling and co-citation over your own links. No tags to maintain, no model to run, and
`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.
+
## Documentation
**The documentation is the example site, and it is written with mdssg.**
@@ -87,14 +91,12 @@ ```
## What is in the box
-Its own markdown parser, its own template engine, a 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. Roughly 3,900 lines of Python
-and an empty dependency list.
+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.
-No plugin system, no shortcodes, no taxonomy engine, no asset pipeline. No
-graph visualisation, permanently — every garden generator has one and nobody
-navigates by it.
+No plugin system, no shortcodes, no taxonomy engine, no asset pipeline.
## Development
@@ -13,7 +13,7 @@ 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 and expanding embeds.
+ graph, expanding embeds, and lay out the global graph as SVG.
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,7 +100,6 @@ ## Deliberate omissions
No plugin system. No shortcodes. No taxonomy engine. No asset pipeline. No
-[[Full Text Search]] — yet. No graph visualisation, permanently: every garden
-generator has one and nobody navigates by it.
+[[Full Text Search]] — yet.
The dependency list is empty and the intent is to keep it that way.
@@ -31,6 +31,7 @@ page assembled from every sentence that mentioned it. - [[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.
## Start here
@@ -44,12 +45,9 @@ ## What it does not do
It has no plugin system, no taxonomy engine, no shortcodes, no asset pipeline,
-no [[Full Text Search]] yet, and no graph visualisation — that last one is a
-permanent non-goal rather than a missing feature. Every garden generator has
-one, and nobody navigates by it.
+and no [[Full Text Search]] yet.
-It is roughly 3,900 lines of Python with an empty dependency list, and the
-intent is to keep all three of those properties.
+It has an empty dependency list and the intent is to keep it that way.
## Status
@@ -15,9 +15,11 @@ base.html the html shell index.html the home page
page.html a note
seed.html a seed page
+ graph.html the global graph
_backlinks.html the "mentioned in" panel
_related.html the "related" panel
style.css the stylesheet
+ logo.svg the site mark
```
## The language
@@ -80,11 +82,12 @@ |------|----------| | `site` | `title`, `url`, `description`, `author`, `language` |
| `page` | `title`, `url`, `slug`, `date`, `tags`, `html`, `description`, `is_post`, `is_seed`, `noindex` |
| `related` | list of `{title, url, seed}` |
-| `asset_url`, `feed_url` | paths to the stylesheet and feed |
+| `asset_url`, `logo_url`, `feed_url` | paths to the stylesheet, logo and feed |
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`.
+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.
## Styling the graph
@@ -98,7 +101,11 @@ .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-node /* a written note */
+.graph-seed /* an unwritten seed */
```
-The bundled stylesheet is about 400 lines, uses system fonts, adapts to
-`prefers-color-scheme`, and ships no JavaScript at all.
+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.
@@ -165,7 +165,7 @@ try: from . import render as render_module
return (
- self.index.get("version") != 2
+ self.index.get("version") != 3
or self.index.get("config_hash") != self.settings.digest
or self.index.get("sources") != render_module.source_state(self.settings)
)
@@ -176,6 +176,11 @@ def handle(self, path: str) -> tuple[int, bytes]: 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()
return 200, self.render(route).encode("utf-8")
def context_for(self, slug: str) -> dict:
@@ -195,6 +200,7 @@ } shared = {
"site": site,
"asset_url": render_module.ASSET_URL,
+ "logo_url": render_module.LOGO_URL,
"feed_url": "",
"related": data.get("related", []),
"stale": self.stale(),
@@ -452,6 +452,90 @@ {"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),
+ )
+ count = len(ranked)
+ width, height = 960, 640
+ centre_x, centre_y = width / 2, height / 2
+ reach = height / 2 - 55
+ positions: dict[str, tuple[float, float]] = {}
+
+ for index, node in enumerate(ranked):
+ if index == 0:
+ positions[node.slug] = (centre_x, centre_y)
+ continue
+ angle = index * 2.399963229728653
+ radius = reach * math.sqrt(index / max(1, count - 1))
+ positions[node.slug] = (
+ centre_x + math.cos(angle) * radius * 1.45,
+ centre_y + math.sin(angle) * radius,
+ )
+
+ edges = [
+ (source, target)
+ for source in sorted(self.out_links)
+ for target in sorted(self.out_links[source])
+ if source in positions and target in positions
+ ]
+ maximum_degree = max((self._degree(node.slug) for node in ranked), default=1)
+ label_limit = min(count, max(24, int(math.sqrt(count) * 8)))
+
+ 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>",
+ '<g class="graph-edges" aria-hidden="true">',
+ ]
+ for source, target in edges:
+ x1, y1 = positions[source]
+ x2, y2 = positions[target]
+ svg.append(
+ f'<line x1="{x1:.1f}" y1="{y1:.1f}" '
+ f'x2="{x2:.1f}" y2="{y2:.1f}" />'
+ )
+ svg.append("</g>\n<g class=\"graph-nodes\">")
+
+ for index, node in enumerate(ranked):
+ 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 ""
+ title = md_html.escape(node.title)
+ label = node.title if len(node.title) <= 24 else node.title[:23] + "…"
+ svg.append(
+ f'<a class="graph-node{kind}" href="{md_html.escape_attr(node.url)}">'
+ f"<title>{title}: {degree} connection"
+ f'{"s" if degree != 1 else ""}</title>'
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{radius:.1f}" />'
+ + (
+ f'<text x="{x + radius + 4:.1f}" y="{y + 3.5:.1f}">'
+ f"{md_html.escape(label)}</text>"
+ if index < label_limit
+ else ""
+ )
+ + "</a>"
+ )
+
+ svg.append("</g>\n</svg>")
+ seed_count = sum(node.is_seed for node in ranked)
+ return {
+ "svg": Markup("\n".join(svg)),
+ "nodes": count,
+ "edges": len(edges),
+ "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 ''}."
+ ),
+ }
+
def build(pages: list[Page], settings: Config) -> Graph:
return Graph(pages, settings)
@@ -19,10 +19,15 @@ textutil, transclude,
)
from .config import Config
+from .errors import BuildError
from .model import Page, date_key
ASSET_NAME = "style.css"
ASSET_URL = "/assets/style.css"
+LOGO_NAME = "logo.svg"
+LOGO_URL = "/assets/logo.svg"
+GRAPH_URL = "/graph/"
+GRAPH_PARTS = ("graph", "index.html")
@dataclass
@@ -40,16 +45,23 @@ path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8", newline="\n")
-def stylesheet(settings: Config) -> str:
- """The site's own stylesheet if it has one, otherwise the theme's."""
- override = settings.templates_dir / ASSET_NAME
+def theme_asset(settings: Config, name: str) -> str:
+ override = settings.templates_dir / name
if override.is_file():
return override.read_text(encoding="utf-8-sig")
return (
importlib.resources.files(template.THEME_PACKAGE)
- .joinpath(ASSET_NAME)
+ .joinpath(name)
.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()):
+ raise BuildError(
+ f"{GRAPH_URL} is reserved for the global graph; "
+ "give that note or seed a different name or explicit slug"
+ )
def emit_static(settings: Config, emitter: cache.Emitter) -> None:
@@ -126,6 +138,45 @@ ) return posts, notes
+def graph_page(
+ site_graph: graph.Graph,
+ settings: Config,
+ loader: template.Loader,
+ site: dict,
+ feed_url: str,
+) -> str:
+ page = {
+ "title": "Graph",
+ "name": "Graph",
+ "url": GRAPH_URL,
+ "slug": "graph",
+ "date": None,
+ "tags": [],
+ "html": template.Markup(""),
+ "description": "A map of the links between notes.",
+ "is_post": False,
+ "is_seed": False,
+ "canonical": settings.site.url + GRAPH_URL if settings.site.url else "",
+ "noindex": False,
+ "title_in_body": True,
+ }
+ 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,
+ "related": [],
+ "graph": site_graph.visualisation(),
+ },
+ ),
+ settings.base_path,
+ )
+
+
def source_state(settings: Config) -> dict[str, list[int]]:
"""Cheap, exact-enough CGI staleness data without parsing every note."""
state = {}
@@ -150,6 +201,10 @@ import json
pages = collect.collect(settings)
site_graph = graph.build(pages, settings)
+ check_graph_route(site_graph)
+ loader = template.Loader(
+ settings.templates_dir if settings.templates_dir.is_dir() else None
+ )
work = settings.work_dir
context_dir = work / "ctx"
@@ -206,11 +261,13 @@ if stale.stem not in expected_contexts: stale.unlink()
posts, notes = listing(pages)
+ feed_url = feeds.FEED_URL if (settings.feed.enabled and settings.site.url) else ""
+ routes[GRAPH_URL] = {"kind": "graph"}
write_text(
work / "routes.json",
json.dumps(
{
- "version": 2,
+ "version": 3,
"config_hash": settings.digest,
"sources": source_state(settings),
"routes": routes,
@@ -224,7 +281,15 @@ sort_keys=True, ),
)
- write_text(settings.output_dir / "assets" / ASSET_NAME, stylesheet(settings))
+ for name in (ASSET_NAME, LOGO_NAME):
+ write_text(
+ settings.output_dir / "assets" / name,
+ theme_asset(settings, name),
+ )
+ write_text(
+ settings.output_dir.joinpath(*GRAPH_PARTS),
+ graph_page(site_graph, settings, loader, asdict(settings.site), feed_url),
+ )
stats.written = len(routes)
return stats
@@ -232,6 +297,7 @@ def build(settings: Config) -> Stats:
pages = collect.collect(settings)
site_graph = graph.build(pages, settings)
+ check_graph_route(site_graph)
site_graph.report_near_misses()
resolver = site_graph.resolver()
@@ -253,7 +319,8 @@ settings.output_dir.mkdir(parents=True, exist_ok=True) emitter = cache.Emitter(settings.output_dir, manifest)
emit_static(settings, emitter)
- emitter.emit(("assets", ASSET_NAME), stylesheet(settings))
+ for name in (ASSET_NAME, LOGO_NAME):
+ emitter.emit(("assets", name), theme_asset(settings, name))
posts, notes = listing(pages)
site = asdict(settings.site)
@@ -273,6 +340,7 @@ context = { "site": site,
"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),
@@ -297,6 +365,7 @@ context = { "site": site,
"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, []),
"backlinks": [],
@@ -309,6 +378,11 @@ ) if emitter.emit((node.slug, "index.html"), output):
report.detail(f"(seed) -> {node.url}")
stats.seeds += 1
+
+ emitter.emit(
+ GRAPH_PARTS,
+ graph_page(site_graph, settings, loader, site, feed_url),
+ )
if settings.feed.enabled:
feed = feeds.build(settings, pages, summaries)
@@ -19,6 +19,7 @@ settings.site.url + page.url for page in pages
if not page.meta.get("noindex")
]
+ urls.append(settings.site.url + "/graph/")
if settings.seeds.in_sitemap and not settings.seeds.noindex:
urls.extend(settings.site.url + node.url for node in seeds)
@@ -3,8 +3,8 @@ <html lang="{{ site.language | default:"en" }}"> <head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
-<meta name="color-scheme" content="light dark" />
-<meta name="theme-color" content="#f4f1e8" />
+<meta name="color-scheme" content="light" />
+<meta name="theme-color" content="#ffffff" />
<title>{% block title %}{{ page.title }} · {{ site.title }}{% endblock %}</title>
{% if page.description %}
<meta name="description" content="{{ page.description }}" />
@@ -28,21 +28,33 @@ <div class="site-frame">
<header class="site-header">
<a class="site-title" href="/">
- <span class="site-mark" aria-hidden="true"></span>
- <span>{{ site.title }}</span>
+ <img src="{{ logo_url }}" alt="" width="36" height="36" />
+ <span class="site-name">{{ site.title }}</span>
+ <span class="site-kind">documentation</span>
</a>
- {% if site.description %}
- <p class="site-tagline">{{ site.description }}</p>
- {% endif %}
+</header>
+
+<aside class="site-sidebar">
+ <p class="sidebar-label">Navigation</p>
<nav class="site-nav" aria-label="Site">
- <a href="/">Index</a>
+ <a href="/">Overview</a>
+ <a href="/graph/">Graph</a>
{% if feed_url %}
- <a href="{{ feed_url }}">RSS</a>
+ <a href="{{ feed_url }}">RSS feed</a>
{% endif %}
+ <a href="https://github.com/runawaydevil/mdssg">Source code</a>
</nav>
-</header>
+
+ {% if site.description %}
+ <p class="sidebar-label sidebar-about-label">About</p>
+ <p class="site-tagline">{{ site.description }}</p>
+ {% endif %}
+</aside>
-<main id="content">
+<main class="site-main" id="content">
+<div class="content-path">
+ <a href="/">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.
@@ -0,0 +1,16 @@+{% extends "base.html" %}
+{% block content %}
+<section class="graph-view">
+ <h1>Graph</h1>
+ <p class="graph-summary">
+ {{ graph.summary }} Select a node to open its page.
+ </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>
+ </p>
+</section>
+{% endblock %}
@@ -0,0 +1,3 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="mdssg">
+ <path fill="#000080" d="M4 56V12h12v8h12v8h8v-8h12v-8h12v44H48V28H36v28H24V28h-8v28H4Z"/>
+</svg>
@@ -1,726 +1,830 @@-/* mdssg default theme.
- Editorial, responsive and dependency-free: no webfonts, images or script. */
-
:root {
- color-scheme: light dark;
- --measure: 43rem;
+ color-scheme: light;
+ --paper: #fff;
+ --panel: #f2f2f2;
+ --panel-dark: #e2e2e2;
+ --ink: #111;
+ --muted: #555;
+ --rule: #aaa;
+ --rule-dark: #222;
+ --blue: #0000ee;
+ --blue-dark: #000080;
+ --visited: #551a8b;
+ --hover: #8b0000;
+ --warning: #fff6bf;
+ --mono: "Courier New", Courier, monospace;
+ --sans: Arial, Helvetica, sans-serif;
--shell: 72rem;
- --ink: #17241f;
- --ink-soft: #53615b;
- --ink-faint: #626e68;
- --paper: #f4f1e8;
- --surface: #fbfaf6;
- --surface-soft: #ece9df;
- --rule: #d8d4c8;
- --accent: #176b52;
- --accent-strong: #0d513d;
- --accent-soft: #dcebe4;
- --seed: #9a641d;
- --seed-soft: #f3e7cd;
- --shadow: 0 1.5rem 4rem rgb(40 47 43 / 10%);
- --radius: 1.5rem;
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
- --text: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
- sans-serif;
- --display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino,
- Georgia, serif;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --ink: #e8eee9;
- --ink-soft: #aab8b1;
- --ink-faint: #7e8c85;
- --paper: #101713;
- --surface: #17201b;
- --surface-soft: #202a24;
- --rule: #303c35;
- --accent: #78d7b2;
- --accent-strong: #a1e6ca;
- --accent-soft: #18382c;
- --seed: #e3b868;
- --seed-soft: #392d18;
- --shadow: 0 1.5rem 5rem rgb(0 0 0 / 28%);
- }
-}
-
-*,
-*::before,
-*::after {
+* {
box-sizing: border-box;
}
html {
- -webkit-text-size-adjust: 100%;
- scroll-behavior: smooth;
+ background: var(--panel-dark);
+ font-size: 16px;
+ text-size-adjust: 100%;
}
body {
- min-height: 100vh;
margin: 0;
- background:
- radial-gradient(circle at 8% 4%, var(--accent-soft), transparent 28rem),
- var(--paper);
color: var(--ink);
- font-family: var(--text);
- font-size: 1.0625rem;
- line-height: 1.7;
- text-rendering: optimizeLegibility;
+ background: var(--panel-dark);
+ font-family: var(--sans);
+ line-height: 1.55;
}
::selection {
- background: var(--accent);
- color: var(--surface);
+ color: #fff;
+ background: var(--blue-dark);
+}
+
+a {
+ color: var(--blue);
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 2px;
+}
+
+a:visited {
+ color: var(--visited);
+}
+
+a:hover {
+ color: var(--hover);
+ background: #ffffcc;
+}
+
+a:focus-visible {
+ outline: 2px dotted var(--blue-dark);
+ outline-offset: 3px;
}
.skip {
position: fixed;
- top: 0.75rem;
- left: 0.75rem;
- z-index: 20;
- padding: 0.55rem 0.8rem;
- border-radius: 0.5rem;
- background: var(--accent-strong);
- color: var(--surface);
- text-decoration: none;
- transform: translateY(-150%);
+ z-index: 10;
+ top: 0.5rem;
+ left: 0.5rem;
+ padding: 0.45rem 0.65rem;
+ color: #fff;
+ background: var(--blue-dark);
+ border: 2px solid #000;
+ font: 700 0.8rem var(--mono);
+ transform: translateY(-200%);
}
.skip:focus {
- transform: translateY(0);
+ color: #fff;
+ transform: none;
}
.site-frame {
display: grid;
- grid-template-columns: minmax(12rem, 17rem) minmax(0, var(--measure));
+ grid-template-columns: 14rem minmax(0, 1fr);
+ grid-template-rows: auto 1fr auto;
grid-template-areas:
- "header main"
+ "header header"
+ "sidebar main"
"footer main";
- align-items: start;
- gap: 1.5rem clamp(2.5rem, 7vw, 7rem);
- width: min(calc(100% - 3rem), var(--shell));
- margin-inline: auto;
- padding-block: clamp(2rem, 6vw, 5rem);
+ width: min(100%, var(--shell));
+ min-height: 100vh;
+ margin: 0 auto;
+ background: var(--paper);
+ border-right: 1px solid var(--rule);
+ border-left: 1px solid var(--rule);
}
.site-header {
grid-area: header;
- position: sticky;
- top: 2rem;
- padding-top: 0.35rem;
+ display: flex;
+ align-items: center;
+ min-height: 4.75rem;
+ padding: 0.75rem 1rem;
+ background: var(--paper);
+ border-top: 6px solid var(--blue-dark);
+ border-bottom: 2px solid var(--rule-dark);
}
.site-title {
display: inline-flex;
- align-items: center;
gap: 0.7rem;
- color: var(--ink);
- font-size: 1rem;
- font-weight: 750;
- letter-spacing: -0.015em;
+ align-items: center;
+ color: var(--blue-dark);
text-decoration: none;
}
-.site-mark {
- display: grid;
- width: 2rem;
- height: 2rem;
- place-items: center;
- border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--rule));
- border-radius: 0.65rem;
- background: var(--accent-soft);
- color: var(--accent);
- transform: rotate(-4deg);
- transition: transform 180ms ease, background 180ms ease;
+.site-title:visited,
+.site-title:hover {
+ color: var(--blue-dark);
+}
+
+.site-title:hover {
+ background: transparent;
+}
+
+.site-title img {
+ display: block;
+ width: 2.25rem;
+ height: 2.25rem;
}
-.site-mark::before {
- content: "";
- width: 0.65rem;
- height: 0.65rem;
- border: 2px solid currentColor;
- border-radius: 50% 50% 50% 0;
- transform: rotate(-45deg);
+.site-name {
+ font: 700 1.35rem/1 var(--mono);
}
-.site-title:hover .site-mark {
- background: var(--surface);
- transform: rotate(5deg);
+.site-kind {
+ margin-left: 0.2rem;
+ color: var(--muted);
+ font: 400 0.7rem/1 var(--mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
}
-.site-tagline {
- max-width: 15rem;
- margin: 1rem 0 0;
- color: var(--ink-soft);
- font-size: 0.9rem;
- line-height: 1.55;
+.site-sidebar {
+ grid-area: sidebar;
+ padding: 1.35rem 1rem 1rem;
+ background: var(--panel);
+ border-right: 1px solid var(--rule);
}
-.site-nav {
- display: flex;
- gap: 1rem;
- margin-top: 1.4rem;
- font-family: var(--mono);
- font-size: 0.72rem;
+.sidebar-label {
+ margin: 0 0 0.45rem;
+ color: var(--muted);
+ font: 700 0.7rem/1.2 var(--mono);
letter-spacing: 0.08em;
text-transform: uppercase;
}
+.site-nav {
+ display: grid;
+ margin: 0 0 1.75rem;
+ border-top: 1px solid var(--rule);
+}
+
.site-nav a {
- color: var(--ink-faint);
- text-decoration: none;
+ display: block;
+ padding: 0.45rem 0.35rem;
+ color: var(--blue-dark);
+ border-bottom: 1px solid var(--rule);
+ font: 700 0.82rem/1.3 var(--mono);
+}
+
+.site-nav a:visited {
+ color: var(--blue-dark);
}
.site-nav a:hover {
- color: var(--accent);
+ color: #fff;
+ background: var(--blue-dark);
+}
+
+.site-tagline {
+ margin: 0;
+ color: #333;
+ font-size: 0.82rem;
+ line-height: 1.45;
}
-main {
+.site-main {
grid-area: main;
min-width: 0;
- padding: clamp(1.6rem, 4vw, 3.5rem);
- border: 1px solid color-mix(in srgb, var(--rule) 80%, transparent);
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--surface) 94%, transparent);
- box-shadow: var(--shadow);
+ padding: 1.8rem clamp(1.25rem, 5vw, 4rem) 4rem;
+ background: var(--paper);
+}
+
+.content-path {
+ display: flex;
+ gap: 0.45rem;
+ align-items: baseline;
+ margin: 0 0 2rem;
+ padding: 0.42rem 0.6rem;
+ overflow: hidden;
+ color: var(--muted);
+ background: var(--panel);
+ border: 1px solid var(--rule);
+ font: 0.75rem/1.3 var(--mono);
+ white-space: nowrap;
+}
+
+.content-path strong {
+ overflow: hidden;
+ color: var(--ink);
+ font-weight: 400;
+ text-overflow: ellipsis;
}
.site-footer {
grid-area: footer;
- align-self: end;
- padding-bottom: 0.5rem;
- color: var(--ink-faint);
- font-size: 0.78rem;
- line-height: 1.5;
+ align-self: stretch;
+ padding: 1rem;
+ color: var(--muted);
+ background: var(--panel);
+ border-top: 1px solid var(--rule);
+ border-right: 1px solid var(--rule);
+ font: 0.7rem/1.5 var(--mono);
}
.site-footer p {
- margin: 0.2rem 0;
+ margin: 0;
}
.site-footer a {
- color: inherit;
+ color: var(--blue-dark);
}
-/* --- prose ------------------------------------------------------------- */
-
-.note-title,
-.note > h1:first-child {
- max-width: 14ch;
- margin: 0 0 1.5rem;
- font-family: var(--display);
- font-size: clamp(2.5rem, 8vw, 4.6rem);
- font-weight: 600;
- letter-spacing: -0.045em;
- line-height: 0.98;
- text-wrap: balance;
+.note,
+.listing,
+.seed,
+.stale-warning {
+ width: 100%;
+ max-width: 52rem;
}
-.note-meta,
-.note-tags {
- margin: -0.65rem 0 1.5rem;
- color: var(--ink-faint);
- font-family: var(--mono);
- font-size: 0.76rem;
- letter-spacing: 0.04em;
-}
-
-.note-tags {
- margin-top: -1.1rem;
-}
-
-.note h2,
-.note h3,
-.note h4,
-.note h5,
-.note h6 {
- color: var(--ink);
+h1,
+h2,
+h3,
+h4 {
+ color: #000;
+ font-family: var(--sans);
line-height: 1.2;
- text-wrap: balance;
}
-.note h2 {
- margin: 3.2rem 0 1rem;
- font-family: var(--display);
- font-size: clamp(1.65rem, 4vw, 2.15rem);
- font-weight: 600;
- letter-spacing: -0.025em;
+h1 {
+ margin: 0 0 1.4rem;
+ padding-bottom: 0.45rem;
+ border-bottom: 2px solid #000;
+ font-size: clamp(1.8rem, 5vw, 2.3rem);
}
-.note h3 {
- margin: 2.4rem 0 0.8rem;
- font-size: 1.16rem;
+h2 {
+ margin: 2.3rem 0 0.8rem;
+ padding-bottom: 0.28rem;
+ border-bottom: 1px solid var(--rule);
+ font-size: 1.4rem;
}
-.note h4,
-.note h5,
-.note h6 {
- margin: 2rem 0 0.7rem;
- font-size: 0.92rem;
- letter-spacing: 0.06em;
- text-transform: uppercase;
+h3 {
+ margin: 1.8rem 0 0.65rem;
+ font-size: 1.12rem;
}
-.note p,
-.note ul,
-.note ol,
-.note blockquote,
-.note pre,
-.note table {
- margin: 0 0 1.35rem;
+h4 {
+ margin: 1.5rem 0 0.5rem;
+ font: 700 0.95rem/1.3 var(--mono);
}
-.note > p:first-of-type {
- color: var(--ink-soft);
- font-size: 1.15rem;
- line-height: 1.65;
+.heading-anchor {
+ margin-left: 0.3rem;
+ color: var(--blue);
+ font: 700 0.62em/1 var(--mono);
+ text-decoration: none;
+ vertical-align: 0.08em;
}
-.note li {
- margin-bottom: 0.42rem;
- padding-left: 0.18rem;
+.heading-anchor:visited {
+ color: var(--blue);
}
-.note li::marker {
- color: var(--accent);
+p,
+ul,
+ol,
+dl,
+blockquote,
+pre,
+table {
+ margin-top: 0;
+ margin-bottom: 1rem;
}
-.note li > ul,
-.note li > ol {
- margin: 0.45rem 0 0;
+.note > p:first-of-type {
+ color: var(--ink);
+ font-size: 1rem;
}
-a {
- color: var(--accent-strong);
- text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
- text-decoration-thickness: 1px;
- text-underline-offset: 0.2em;
- transition: color 150ms ease, text-decoration-color 150ms ease;
+ul,
+ol {
+ padding-left: 1.55rem;
}
-a:hover {
- color: var(--accent);
- text-decoration-color: currentColor;
+li + li {
+ margin-top: 0.22rem;
}
-a:focus-visible {
- border-radius: 0.15rem;
- outline: 3px solid color-mix(in srgb, var(--accent) 32%, transparent);
- outline-offset: 3px;
+dt {
+ font-weight: 700;
}
-.heading-anchor {
- margin-left: 0.45rem;
- color: var(--ink-faint);
- font-family: var(--text);
- font-size: 0.65em;
- font-weight: 500;
- text-decoration: none;
- opacity: 0;
+dd {
+ margin: 0 0 0.8rem 1.25rem;
}
-h1:hover .heading-anchor,
-h2:hover .heading-anchor,
-h3:hover .heading-anchor,
-h4:hover .heading-anchor,
-.heading-anchor:focus {
- opacity: 1;
+hr {
+ height: 0;
+ margin: 2rem 0;
+ border: 0;
+ border-top: 2px solid var(--rule-dark);
}
-blockquote {
- position: relative;
- padding: 0.85rem 1rem 0.85rem 1.25rem;
- border-left: 3px solid var(--accent);
- border-radius: 0 0.8rem 0.8rem 0;
- background: var(--accent-soft);
- color: var(--ink-soft);
+img,
+svg {
+ max-width: 100%;
}
-blockquote > :last-child {
- margin-bottom: 0;
+.note > img,
+.note p > img,
+figure img {
+ display: block;
+ height: auto;
+ border: 1px solid var(--rule-dark);
}
-hr {
- width: 4rem;
- height: 0;
- margin: 3.5rem 0;
- border: 0;
- border-top: 3px solid var(--accent);
+figure {
+ margin: 1.5rem 0;
}
-img {
- display: block;
- max-width: 100%;
- height: auto;
- border-radius: 0.9rem;
+figcaption {
+ margin-top: 0.35rem;
+ color: var(--muted);
+ font: 0.75rem/1.4 var(--mono);
}
-code {
- padding: 0.14em 0.36em;
- border: 1px solid color-mix(in srgb, var(--rule) 75%, transparent);
- border-radius: 0.35rem;
- background: var(--surface-soft);
+code,
+kbd,
+samp {
font-family: var(--mono);
- font-size: 0.84em;
+ font-size: 0.88em;
+}
+
+:not(pre) > code {
+ padding: 0.08rem 0.25rem;
+ background: var(--panel);
+ border: 1px solid #ccc;
}
pre {
- padding: 1.15rem 1.25rem;
+ max-width: 100%;
+ padding: 0.85rem 1rem;
overflow-x: auto;
- border: 1px solid var(--rule);
- border-radius: 0.85rem;
- background: var(--surface-soft);
- font-size: 0.82rem;
- line-height: 1.6;
- scrollbar-color: var(--ink-faint) transparent;
+ color: #000;
+ background: #f7f7f7;
+ border: 1px solid var(--rule-dark);
+ border-left: 5px solid var(--blue-dark);
+ line-height: 1.5;
+ tab-size: 2;
}
pre code {
padding: 0;
+ background: transparent;
border: 0;
- background: none;
- font-size: inherit;
+}
+
+kbd {
+ padding: 0.08rem 0.3rem;
+ background: var(--panel);
+ border: 1px solid var(--rule-dark);
+ border-bottom-width: 2px;
+}
+
+blockquote {
+ margin-right: 0;
+ margin-left: 0;
+ padding: 0.75rem 1rem;
+ background: var(--panel);
+ border-left: 5px solid var(--blue-dark);
+}
+
+blockquote > :last-child {
+ margin-bottom: 0;
}
table {
- width: 100%;
+ display: block;
+ width: max-content;
+ max-width: 100%;
+ overflow-x: auto;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
- padding: 0.65rem 0.75rem;
- border-bottom: 1px solid var(--rule);
+ padding: 0.45rem 0.6rem;
+ border: 1px solid var(--rule);
text-align: left;
+ vertical-align: top;
}
th {
- background: var(--surface-soft);
- color: var(--ink-soft);
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- text-transform: uppercase;
+ color: #000;
+ background: var(--panel-dark);
}
-.note table {
- display: block;
- overflow-x: auto;
- border: 1px solid var(--rule);
- border-radius: 0.75rem;
+tbody tr:nth-child(even) {
+ background: #fafafa;
}
-.task-list {
- padding-left: 0;
- list-style: none;
+input[type="checkbox"] {
+ margin: 0 0.35rem 0 0;
+ accent-color: var(--blue-dark);
}
-.task-list input {
- width: 1rem;
- height: 1rem;
- margin-right: 0.45rem;
- accent-color: var(--accent);
+.task-list-item {
+ list-style: none;
}
-/* --- graph and listings ------------------------------------------------ */
+.task-list-item input {
+ margin-left: -1.4rem;
+}
.wikilink-seed {
- color: var(--seed);
- text-decoration-line: underline;
+ color: var(--hover);
text-decoration-style: dashed;
}
+.wikilink-seed:visited {
+ color: var(--hover);
+}
+
.backlinks,
.related,
.listing {
- margin-top: 3.5rem;
- padding-top: 1.5rem;
- border-top: 1px solid var(--rule);
+ max-width: 52rem;
+ margin-top: 3rem;
+ padding-top: 1rem;
+ border-top: 2px solid var(--rule-dark);
}
.backlinks h2,
.related h2,
.listing h2 {
- margin: 0 0 1.1rem;
- color: var(--ink-faint);
- font-family: var(--mono);
- font-size: 0.7rem;
- font-weight: 700;
- letter-spacing: 0.12em;
+ 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;
}
.backlinks ul,
.related ul,
-.listing ul {
+.note-list,
+.post-list {
margin: 0;
padding: 0;
list-style: none;
}
-.backlinks > ul > li {
- margin-bottom: 0.8rem;
- padding: 1rem;
+.backlinks ul {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.backlinks li {
+ margin: 0;
+ padding: 0.6rem 0.7rem;
+ background: #fafafa;
border: 1px solid var(--rule);
- border-radius: 0.8rem;
- background: var(--surface-soft);
}
-.backlinks blockquote,
-.seed-mentions blockquote {
+.backlinks blockquote {
margin: 0.5rem 0 0;
- padding: 0;
- border: 0;
- background: none;
font-size: 0.88rem;
}
-.backlinks mark,
-.seed-mentions mark {
- padding: 0.08em 0.2em;
- border-radius: 0.2rem;
- background: var(--accent-soft);
- color: inherit;
- font-weight: 700;
-}
-
.related ul,
.note-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 0.6rem;
+ gap: 0.5rem;
}
-.related li a,
-.note-list li a {
+.related li,
+.note-list li {
+ margin: 0;
+}
+
+.related a,
+.note-list a {
display: block;
height: 100%;
- padding: 0.7rem 0.8rem;
+ padding: 0.65rem 0.75rem;
+ background: #fafafa;
border: 1px solid var(--rule);
- border-radius: 0.65rem;
- background: var(--surface-soft);
- text-decoration: none;
+ font-weight: 700;
+}
+
+.related a:hover,
+.note-list a:hover {
+ color: #fff;
+ background: var(--blue-dark);
+}
+
+.post-list {
+ border-top: 1px solid var(--rule);
}
.post-list li {
display: grid;
- grid-template-columns: 6.3rem minmax(0, 1fr);
+ grid-template-columns: 6.5rem minmax(0, 1fr);
gap: 0.75rem;
- padding: 0.65rem 0;
+ align-items: baseline;
+ margin: 0;
+ padding: 0.65rem 0.35rem;
border-bottom: 1px solid var(--rule);
}
-.post-list time {
- color: var(--ink-faint);
- font-family: var(--mono);
- font-size: 0.75rem;
- font-variant-numeric: tabular-nums;
+.post-list a {
+ font-weight: 700;
+}
+
+.post-list time,
+.note-meta,
+.note-tags {
+ color: var(--muted);
+ font: 0.75rem/1.4 var(--mono);
+}
+
+.note-meta,
+.note-tags {
+ margin: -0.7rem 0 1.5rem;
}
-/* --- seeds, embeds and footnotes -------------------------------------- */
+.note-tags {
+ padding: 0.3rem 0.45rem;
+ background: var(--panel);
+ border-left: 3px solid var(--rule);
+}
-.seed-notice,
-.embed-error,
+.seed,
.stale-warning {
- margin: 0 0 2rem;
- padding: 0.9rem 1rem;
- border: 1px solid color-mix(in srgb, var(--seed) 40%, var(--rule));
- border-radius: 0.75rem;
- background: var(--seed-soft);
- color: var(--ink-soft);
- font-size: 0.9rem;
+ padding: 1rem;
+ background: var(--warning);
+ border: 2px solid var(--rule-dark);
}
.stale-warning {
- margin-bottom: 2rem;
- border-color: color-mix(in srgb, var(--seed) 55%, var(--rule));
- background: var(--seed-soft);
- color: var(--ink);
+ margin-bottom: 1.5rem;
+ font-weight: 700;
+}
+
+.seed-notice {
+ font-weight: 700;
}
.seed-mentions {
- margin: 0;
- padding-left: 1.25rem;
+ padding-left: 1.6rem;
}
-.seed-mentions > li {
- margin-bottom: 1.4rem;
+.seed-mentions > li + li {
+ margin-top: 1rem;
}
.embed {
- margin: 1.8rem 0;
- padding: 1.25rem 1.35rem 1rem;
+ margin: 1.25rem 0;
+ padding: 1rem;
+ background: #fafafa;
border: 1px solid var(--rule);
- border-left: 4px solid var(--accent);
- border-radius: 0.8rem;
- background: var(--surface-soft);
+}
+
+.embed > :first-child {
+ margin-top: 0;
}
-.embed figcaption {
- margin-top: 1rem;
- color: var(--ink-faint);
- font-family: var(--mono);
- font-size: 0.7rem;
- letter-spacing: 0.04em;
- text-transform: uppercase;
+.embed > :last-child {
+ margin-bottom: 0;
}
-.footnote-ref a {
- padding: 0 0.12em;
- text-decoration: none;
+mark {
+ color: #000;
+ background: #ffff66;
}
.footnotes {
- margin-top: 3.5rem;
- padding-top: 1.5rem;
+ margin-top: 2.5rem;
+ padding-top: 0.8rem;
border-top: 1px solid var(--rule);
- color: var(--ink-soft);
- font-size: 0.86rem;
+ font-size: 0.88rem;
+}
+
+.graph-view {
+ width: 100%;
+ max-width: 54rem;
+}
+
+.graph-summary {
+ margin: -0.6rem 0 1rem;
+ color: var(--muted);
+ font: 0.8rem/1.4 var(--mono);
+}
+
+.graph-canvas {
+ width: 100%;
+ overflow: auto;
+ background: #fff;
+ border: 2px solid var(--rule-dark);
+}
+
+.graph-map {
+ display: block;
+ width: 100%;
+ min-width: 42rem;
+ height: auto;
+}
+
+.graph-edges line {
+ stroke: #aaa;
+ stroke-width: 1.2;
+ vector-effect: non-scaling-stroke;
+}
+
+.graph-node circle {
+ fill: var(--blue-dark);
+ stroke: #fff;
+ stroke-width: 1.5;
+ vector-effect: non-scaling-stroke;
+}
+
+.graph-node text {
+ fill: #111;
+ font: 700 12px var(--mono);
+ paint-order: stroke;
+ pointer-events: none;
+ stroke: #fff;
+ stroke-width: 3px;
+ stroke-linejoin: bevel;
+}
+
+.graph-node:hover {
+ background: transparent;
+}
+
+.graph-node:hover circle,
+.graph-node:focus circle {
+ fill: var(--hover);
+ stroke: #000;
+ stroke-width: 2.5;
}
-.footnotes ol {
- padding-left: 1.25rem;
+.graph-seed circle {
+ fill: #fff;
+ stroke: var(--hover);
+ stroke-dasharray: 3 2;
+ stroke-width: 2;
}
-.footnote-back {
- margin-left: 0.25rem;
- font-family: var(--mono);
- font-size: 0.72em;
- text-decoration: none;
+.graph-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.25rem;
+ margin: 0;
+ padding: 0.55rem 0.7rem;
+ background: var(--panel);
+ border: 1px solid var(--rule);
+ border-top: 0;
+ font: 0.75rem/1.3 var(--mono);
}
-@media (max-width: 52rem) {
+.graph-legend span {
+ display: inline-flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+.graph-key {
+ display: inline-block;
+ width: 0.7rem;
+ height: 0.7rem;
+ background: var(--blue-dark);
+ border: 1px solid var(--blue-dark);
+}
+
+.graph-key-seed {
+ background: #fff;
+ border-color: var(--hover);
+ border-style: dashed;
+}
+
+@media (max-width: 46rem) {
+ html,
+ body {
+ background: var(--paper);
+ }
+
.site-frame {
grid-template-columns: 1fr;
+ grid-template-rows: auto auto 1fr auto;
grid-template-areas:
"header"
+ "sidebar"
"main"
"footer";
- width: min(calc(100% - 2rem), var(--measure));
- padding-block: 1.25rem 2.5rem;
+ border: 0;
}
.site-header {
- position: static;
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 0.25rem 1rem;
- padding: 0.4rem 0.25rem 0.8rem;
+ min-height: 4rem;
+ padding: 0.65rem 1rem;
}
+ .site-kind {
+ display: none;
+ }
+
+ .site-sidebar {
+ padding: 0.75rem 1rem;
+ border-right: 0;
+ border-bottom: 1px solid var(--rule);
+ }
+
+ .site-sidebar .sidebar-label,
.site-tagline {
display: none;
}
.site-nav {
- grid-column: 2;
- grid-row: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 1rem;
margin: 0;
+ border: 0;
}
- .site-footer {
- padding-inline: 0.25rem;
+ .site-nav a {
+ padding: 0.2rem 0;
+ border: 0;
}
-}
+
+ .site-main {
+ padding: 1.25rem 1rem 3rem;
+ }
-@media (max-width: 34rem) {
- body {
- font-size: 1rem;
- background: var(--paper);
+ .content-path {
+ margin-bottom: 1.5rem;
}
- .site-frame {
- width: 100%;
- gap: 0.5rem;
- padding-top: 0.6rem;
+ .graph-map {
+ width: 42rem;
}
- .site-header,
.site-footer {
- padding-inline: 1rem;
+ padding: 0.8rem 1rem;
+ border-right: 0;
}
+}
- .site-mark {
- width: 1.75rem;
- height: 1.75rem;
+@media (max-width: 30rem) {
+ .site-title img {
+ width: 2rem;
+ height: 2rem;
}
- main {
- padding: 1.6rem 1.1rem 2.2rem;
- border-inline: 0;
- border-radius: 0;
- box-shadow: none;
+ .site-name {
+ font-size: 1.15rem;
}
- .note-title,
- .note > h1:first-child {
- font-size: clamp(2.4rem, 15vw, 3.7rem);
+ h1 {
+ font-size: 1.75rem;
}
.related ul,
- .note-list {
- grid-template-columns: 1fr;
- }
-
+ .note-list,
.post-list li {
grid-template-columns: 1fr;
- gap: 0.15rem;
}
-}
-@media (prefers-reduced-motion: no-preference) {
- main {
- animation: arrive 320ms ease-out both;
- }
-
- @keyframes arrive {
- from {
- opacity: 0;
- transform: translateY(0.45rem);
- }
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- html {
- scroll-behavior: auto;
- }
-
- *,
- *::before,
- *::after {
- scroll-behavior: auto !important;
- transition-duration: 0.01ms !important;
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
+ .post-list li {
+ gap: 0.2rem;
}
}
@media print {
+ html,
body,
- main {
+ .site-frame {
background: #fff;
- color: #000;
}
.site-frame {
display: block;
- width: 100%;
- }
-
- main {
- padding: 0;
border: 0;
- box-shadow: none;
}
.site-header,
+ .site-sidebar,
.site-footer,
- .backlinks,
- .related,
- .heading-anchor {
+ .content-path {
display: none;
+ }
+
+ .site-main {
+ padding: 0;
+ }
+
+ a {
+ color: #000;
}
}
@@ -38,4 +38,4 @@ [tool.setuptools.packages.find] include = ["mdssg*"]
[tool.setuptools.package-data]
-"mdssg.theme" = ["*.html", "*.css"]
+"mdssg.theme" = ["*.html", "*.css", "*.svg"]
@@ -172,6 +172,27 @@ self.build() css = self.output("assets/style.css")
self.assertIn("--paper", css)
+ def test_logo_is_emitted(self):
+ self.write("content/index.md", "body")
+ self.build()
+ logo = self.output("assets/logo.svg")
+ self.assertIn('fill="#000080"', logo)
+
+ def test_graph_is_always_emitted_and_linked(self):
+ self.write("content/index.md", "---\ntitle: Home\n---\n\nSee [[One]].")
+ self.write("content/one.md", "---\ntitle: One\n---\n")
+ self.build()
+ graph = self.output("graph/index.html")
+ self.assertIn('class="graph-map"', graph)
+ self.assertIn('href="/one/"', graph)
+ self.assertIn('href="/graph/"', self.output("index.html"))
+
+ def test_graph_route_cannot_be_overwritten_by_content(self):
+ self.write("content/graph.md", "---\ntitle: Graph\n---\n")
+ with self.assertRaises(BuildError) as caught:
+ self.build()
+ self.assertIn("/graph/ is reserved", str(caught.exception))
+
def test_static_files_are_copied(self):
self.write("content/index.md", "body")
self.write("static/robots.txt", "User-agent: *\n")
@@ -235,6 +256,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)
def test_sitemap_excludes_noindex_pages(self):
self.write(
@@ -38,6 +38,13 @@ self.write("content/index.md", "---\ntitle: Home\n---\n") self.index()
self.assertFalse((self.root / "public" / "index.html").exists())
+ def test_index_only_writes_and_routes_the_graph(self):
+ self.write("content/index.md", "---\ntitle: Home\n---\n")
+ self.index()
+ 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_seeds_are_routed(self):
self.write("content/a.md", "---\ntitle: A\n---\n\nAbout [[Ghost]].")
self.index()
@@ -67,6 +74,12 @@ def test_serves_the_home_page(self): _, head, body = self.request("/")
self.assertIn("200 OK", head)
self.assertIn("Start at", body)
+
+ def test_serves_the_graph(self):
+ _, head, body = self.request("/graph/")
+ self.assertIn("200 OK", head)
+ self.assertIn('class="graph-map"', body)
+ self.assertIn('href="/kant/"', body)
def test_links_resolve_without_the_graph(self):
_, _, body = self.request("/kant/")
@@ -88,6 +88,11 @@ def test_it_demonstrates_related_notes(self):
self.assertIn(">Related</h2>", self.read("seed-pages/index.html"))
+ def test_it_has_a_global_graph(self):
+ html = self.read("graph/index.html")
+ self.assertIn('class="graph-map"', html)
+ self.assertIn("nodes,", html)
+
def test_folders_do_not_reach_urls(self):
# concepts/wikilinks.md and reference/markdown.md, both flat.
self.assertTrue((self.output / "wikilinks" / "index.html").is_file())
@@ -290,7 +290,7 @@ def test_every_bundled_template_compiles(self):
loader = template.Loader()
for name in ("base.html", "page.html", "index.html", "seed.html",
- "_backlinks.html", "_related.html"):
+ "graph.html", "_backlinks.html", "_related.html"):
with self.subTest(name=name):
self.assertTrue(loader.get(name).nodes or loader.get(name).extends)