diff --git a/README.md b/README.md index 8822d03..9b49a86 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ WIKI_PORT=3030 docker compose up -d |---|---| | `wiki` | Wiki.js 2.5 | | `db` | PostgreSQL 16, its backing store | -| `bootstrap` | Runs once and exits — completes setup, imports the pages, confirms guest read | +| `bootstrap` | Runs once and exits — completes setup, imports the pages, builds the sidebar, confirms guest read | Your checkout is mounted **read-only**, so Wiki.js can import from it but never writes back and never dirties your working tree. @@ -60,8 +60,12 @@ until you re-import: docker compose run --rm --no-deps bootstrap ``` -That is idempotent — it re-imports the content and repairs the configuration if anything -has drifted. +That is idempotent — it re-imports the content, rebuilds the sidebar and repairs the +configuration if anything has drifted. + +The sidebar is generated from the pages that exist, in the reading order set by `NAV_ORDER` +in `wikijs/bootstrap.py`. A new page appears automatically; add it to that list to place it +deliberately rather than alphabetically at the end. ### Starting over @@ -107,8 +111,9 @@ docker compose up -d python3 tools/check_wikijs.py ``` -It fails when a page is missing, returns a non-200, or bounces an anonymous visitor to the -login screen. +It fails when a page is missing, returns a non-200, bounces an anonymous visitor to the +login screen, or is absent from the sidebar that visitor receives — reachable by URL is not +the same as discoverable. CI runs both on every push and pull request. diff --git a/tools/check_wikijs.py b/tools/check_wikijs.py index 88e8dca..4455e29 100755 --- a/tools/check_wikijs.py +++ b/tools/check_wikijs.py @@ -11,12 +11,17 @@ 1. a page in the repository is missing from Wiki.js, 2. a page does not return 200 to an anonymous request, -3. a page's own title is absent from what is served, or -4. anonymous access is bounced to a login screen. +3. a page's own title is absent from what is served, +4. anonymous access is bounced to a login screen, or +5. a page is missing from the sidebar an anonymous visitor receives. -Point 4 is the one that guards "no auth". Wiki.js answers a page a guest may not -read with the login screen under a 200, so status alone proves nothing — the -title has to actually be in the body. +Point 4 guards "no auth". Wiki.js answers a page a guest may not read with the +login screen under a 200, so status alone proves nothing — the title has to +actually be in the body. + +Point 5 guards discoverability. Every page was once reachable by URL while the +sidebar listed only Home (#5), which this check passed: reachable is not the +same as discoverable, and testing only the first missed it. Override the base URL with WIKI_URL when not on the default port: @@ -26,7 +31,9 @@ from __future__ import annotations +import base64 import html +import json import os import re import sys @@ -42,6 +49,9 @@ LOGIN_MARKERS = ("login-container", "loginBgUrl", "Sign In") +# Wiki.js hands the theme its sidebar as base64 JSON on the page root element. +SIDEBAR_RE = re.compile(r'sidebar="([^"]+)"') + class NoRedirect(urllib.request.HTTPRedirectHandler): """ @@ -94,6 +104,34 @@ def fetch(path: str) -> tuple[int, str]: ) +def sidebar_targets(body: str) -> set[str] | None: + """Targets in the sidebar an anonymous visitor was served, or None.""" + match = SIDEBAR_RE.search(body) + if not match: + return None + try: + items = json.loads(base64.b64decode(match.group(1))) + except (ValueError, json.JSONDecodeError): + return None + return {item.get("t", "") for item in items} + + +def check_navigation(body: str, expected: list[tuple[str, str]]) -> list[str]: + targets = sidebar_targets(body) + if targets is None: + return ["the page carries no sidebar — anonymous visitors get no navigation"] + + failures = [] + for path, title in expected: + target = "/" if path == "home" else f"/{path}" + if target not in targets: + failures.append( + f"{title!r} ({target}) is not in the sidebar — " + f"reachable by URL, but nothing links to it" + ) + return failures + + def main() -> int: expected = pages() if not expected: @@ -121,6 +159,9 @@ def main() -> int: if title and title not in body and html.escape(title) not in body: failures.append(f"/{path}: served 200 but the title {title!r} is missing") + if path == "home": + failures.extend(check_navigation(body, expected)) + print(f"Checked {len(expected)} pages against {WIKI_URL}.") if failures: @@ -128,9 +169,14 @@ def main() -> int: for failure in failures: print(f" FAIL {failure}") print("\nThe Wiki.js stack is not serving this repository anonymously.") + print("Re-running the bootstrap usually fixes configuration drift:") + print(" docker compose run --rm --no-deps bootstrap") return 1 - print("OK — every page is served, and no page asks for a login.") + print( + "OK — every page is served, listed in the sidebar, " + "and no page asks for a login." + ) return 0 diff --git a/wikijs/bootstrap.py b/wikijs/bootstrap.py index 23d296d..694cf5a 100755 --- a/wikijs/bootstrap.py +++ b/wikijs/bootstrap.py @@ -13,7 +13,8 @@ 3. Points the Local File System storage target at the mounted repository and imports every Markdown page. 4. Removes README, which documents the repository rather than the project. -5. Verifies guests can read without logging in, and grants it if not. +5. Rebuilds the sidebar so every page is reachable without knowing its URL. +6. Verifies guests can read without logging in, and grants it if not. Every step is idempotent, so re-running only refreshes the content. @@ -41,6 +42,32 @@ GUEST_GROUP_ID = 2 READ_PERMISSIONS = ["read:pages", "read:assets", "read:comments"] +# Reading order for the sidebar: what somebody new should meet first, not +# alphabetical. Pages not listed here are appended alphabetically, so adding a +# page still shows up without touching this list. +NAV_ORDER = [ + "home", + "Getting-Started", + "Authorization", + "API/Architecture", + "Database/Architecture", + "Orders-and-Fulfillment", + "Configuration", + "Deployment", +] + +NAV_ICONS = { + "home": "mdi-home", + "Getting-Started": "mdi-rocket-launch-outline", + "Authorization": "mdi-shield-key-outline", + "API/Architecture": "mdi-api", + "Database/Architecture": "mdi-database-outline", + "Orders-and-Fulfillment": "mdi-truck-outline", + "Configuration": "mdi-cog-outline", + "Deployment": "mdi-server", +} +DEFAULT_NAV_ICON = "mdi-file-document-outline" + def log(message: str) -> None: print(f"[bootstrap] {message}", flush=True) @@ -250,6 +277,59 @@ def remove_non_wiki_pages(token: str) -> None: log(f"removed non-wiki page: {page['path']}") +def rebuild_navigation(token: str) -> None: + """ + Replace the sidebar with one entry per published page. + + Wiki.js seeds a static navigation list holding only Home. Left alone, every + other page is reachable by URL but linked from nowhere, so a reader who does + not already know the page names cannot find them. + + Rebuilt from the pages that actually exist rather than from a hand-kept + list, so adding or removing a page is reflected on the next run. + """ + pages = {page["path"]: page["title"] for page in list_pages(token)} + ordered = [p for p in NAV_ORDER if p in pages] + ordered += sorted(p for p in pages if p not in NAV_ORDER) + + items = [ + { + "id": f"page-{index}", + "kind": "link", + "label": pages[path], + "icon": NAV_ICONS.get(path, DEFAULT_NAV_ICON), + # The home page is reached at / rather than /home. + "targetType": "home" if path == "home" else "page", + "target": "/" if path == "home" else f"/{path}", + "visibilityMode": "all", + "visibilityGroups": [], + } + for index, path in enumerate(ordered) + ] + + result = graphql( + """ + mutation($tree: [NavigationTreeInput]!) { + navigation { + updateTree(tree: $tree) { responseResult { succeeded message } } + } + } + """, + token, + {"tree": [{"locale": "en", "items": items}]}, + )["navigation"]["updateTree"]["responseResult"] + if not result["succeeded"]: + sys.exit(f"could not rebuild the navigation: {result['message']}") + + # MIXED keeps the list above and still offers the page browser alongside it. + graphql( + "mutation { navigation { updateConfig(mode: MIXED) " + "{ responseResult { succeeded message } } } }", + token, + ) + log(f"sidebar rebuilt with {len(items)} entries") + + def ensure_guest_read(token: str) -> None: group = graphql( """ @@ -307,6 +387,7 @@ def main() -> int: configure_site(token) import_content(token) remove_non_wiki_pages(token) + rebuild_navigation(token) ensure_guest_read(token) pages = list_pages(token)