Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
58 changes: 52 additions & 6 deletions tools/check_wikijs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -26,7 +31,9 @@

from __future__ import annotations

import base64
import html
import json
import os
import re
import sys
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -121,16 +159,24 @@ 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:
print(f"\n{len(failures)} problem(s):\n")
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


Expand Down
83 changes: 82 additions & 1 deletion wikijs/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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)
Expand Down
Loading