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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Vendored third-party JavaScript under docs/assets/javascripts/ is treated
# as binary so PR diffs stay readable. See CONTRIBUTING.md for the convention.
docs/assets/javascripts/** binary
3 changes: 3 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: pip install zensical

- name: Verify vendored Mermaid
run: python3 scripts/vendor_mermaid.py --check

- name: Build documentation
run: zensical build --clean

Expand Down
47 changes: 47 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,53 @@ zensical serve
zensical build --clean
```

## Vendored dependencies

The file `docs/assets/javascripts/mermaid.tiny.js` is a pinned copy of the
`@mermaid-js/tiny` UMD build. We self-host it because the Content Security
Policy on `docs.aws.amazon.com` blocks Zensical's default CDN load of Mermaid
from `unpkg.com`.

Mermaid's tiny build supports flowcharts and sequence, state, class, and
entity-relationship diagrams. It does not support mindmap or architecture
diagrams, or KaTeX math rendering.

Upgrade procedure:

```bash
# 1. Show the pinned version and the latest on npm
python3 scripts/vendor_mermaid.py --latest

# 2. Edit scripts/vendor_mermaid.toml. Bump `version`. Run the script.
# It will fail and print the new SHA-256. Paste that value into `sha256`
# in the TOML, then run the script again.
python3 scripts/vendor_mermaid.py

# 3. Preview locally and spot-check pages with diagrams.
zensical serve

# 4. Commit scripts/vendor_mermaid.toml and docs/assets/javascripts/mermaid.tiny.js
# together in the same commit.
```

CI verifies the committed file matches the pinned SHA-256. If you hand-edit the
vendored file or forget to update the SHA on upgrade, the build fails.

### Directory convention

JavaScript under `docs/` is split by ownership:

- `docs/assets/javascripts/` holds vendored third-party bundles. Treat these
as read-only outputs of the vendoring scripts under `scripts/`. Do not
hand-edit them. Upgrade by bumping the version pin in the corresponding
script and re-running it. Files in this directory are marked `binary` in
`.gitattributes` so PR diffs do not try to render minified code.
- `docs/javascripts/` holds first-party scripts we author and maintain, such
as `mermaid-init.js`, which initializes the vendored Mermaid build.

Both directories are served from the same origin and register through
`extra_javascript` in `zensical.toml`.

## Formatting

Run `mdformat` to auto-format Markdown files before committing:
Expand Down
2,572 changes: 2,572 additions & 0 deletions docs/assets/javascripts/mermaid.tiny.js

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions docs/javascripts/mermaid-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Initialize the self-hosted Mermaid tiny build.
//
// The companion file docs/assets/javascripts/mermaid.tiny.js is a UMD bundle
// that sets window.mermaid when it loads. Zensical's theme integration picks
// up window.mermaid automatically and uses it instead of loading Mermaid from
// unpkg (which is blocked by the Content Security Policy on
// docs.aws.amazon.com).
//
// See CONTRIBUTING.md ("Vendored dependencies") for the upgrade procedure.

if (window.mermaid && typeof window.mermaid.initialize === "function") {
window.mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
});
}
183 changes: 183 additions & 0 deletions scripts/vendor_mermaid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Vendor a pinned build of @mermaid-js/tiny into docs/assets/javascripts/.

Why this exists:
docs.aws.amazon.com applies a Content Security Policy that disallows
third-party script origins. Zensical's default Mermaid integration loads
mermaid from unpkg.com, which the CSP blocks. We therefore self-host
Mermaid from docs/assets/ (served same-origin).

We use the "tiny" build (@mermaid-js/tiny), which ships as a single UMD
file with no lazy-loaded chunks. All currently-used diagram types
(flowchart, sequence, state, class, ER) are supported. Mindmap,
architecture, and KaTeX math are not.

Configuration:
Version and expected SHA-256 are read from scripts/vendor_mermaid.toml.

Usage:
python3 scripts/vendor_mermaid.py # download and verify
python3 scripts/vendor_mermaid.py --check # verify only, do not download
python3 scripts/vendor_mermaid.py --latest # print pinned + latest on npm

Upgrading:
1. Bump `version` in scripts/vendor_mermaid.toml.
2. Run the script. It will fail with the new SHA-256 printed. Paste that
value into `sha256` in the TOML.
3. Run the script again. It should succeed.
4. Preview with `zensical serve`, then commit the TOML and the vendored
file together.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
import tomllib
import urllib.request
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
PIN_FILE = Path(__file__).resolve().parent / "vendor_mermaid.toml"
DEST_FILE = REPO_ROOT / "docs" / "assets" / "javascripts" / "mermaid.tiny.js"
SOURCE_URL_TEMPLATE = (
"https://cdn.jsdelivr.net/npm/@mermaid-js/tiny@{version}/dist/mermaid.tiny.js"
)
NPM_REGISTRY_LATEST = "https://registry.npmjs.org/@mermaid-js/tiny/latest"


def load_pin() -> tuple[str, str]:
"""Read the pinned version and expected SHA-256 from the TOML file."""
if not PIN_FILE.exists():
print(f"ERROR: pin file missing: {PIN_FILE}", file=sys.stderr)
sys.exit(1)

with PIN_FILE.open("rb") as handle:
data = tomllib.load(handle)

missing = [key for key in ("version", "sha256") if key not in data]
if missing:
print(
f"ERROR: {PIN_FILE.name} is missing required keys: "
f"{', '.join(missing)}",
file=sys.stderr,
)
sys.exit(1)
return data["version"], data["sha256"]


def sha256_of(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
hasher.update(chunk)
return hasher.hexdigest()


def check_only(version: str, expected_sha256: str) -> int:
if not DEST_FILE.exists():
print(
f"ERROR: {DEST_FILE.relative_to(REPO_ROOT)} is missing. "
f"Run without --check to download.",
file=sys.stderr,
)
return 1

actual = sha256_of(DEST_FILE)
if actual != expected_sha256:
print(
f"ERROR: SHA-256 mismatch for {DEST_FILE.relative_to(REPO_ROOT)}",
file=sys.stderr,
)
print(f" expected (from {PIN_FILE.name}): {expected_sha256}", file=sys.stderr)
print(f" actual: {actual}", file=sys.stderr)
return 1

print(
f"OK: {DEST_FILE.relative_to(REPO_ROOT)} matches "
f"@mermaid-js/tiny@{version} (sha256={expected_sha256})"
)
return 0


def print_latest(version: str) -> int:
with urllib.request.urlopen(NPM_REGISTRY_LATEST, timeout=10) as response:
payload = json.load(response)
print(f"Pinned: {version}")
print(f"Latest: {payload['version']}")
return 0


def download_and_verify(version: str, expected_sha256: str) -> int:
# Idempotent: skip download if the committed file already matches.
if DEST_FILE.exists() and sha256_of(DEST_FILE) == expected_sha256:
print(f"Already up to date: @mermaid-js/tiny@{version}")
return 0

source_url = SOURCE_URL_TEMPLATE.format(version=version)
print(f"Downloading @mermaid-js/tiny@{version} from {source_url}")

DEST_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_file = DEST_FILE.with_suffix(DEST_FILE.suffix + ".tmp")

try:
with urllib.request.urlopen(source_url, timeout=30) as response:
tmp_file.write_bytes(response.read())

actual = sha256_of(tmp_file)
if actual != expected_sha256:
print("ERROR: SHA-256 mismatch after download", file=sys.stderr)
print(
f" expected (from {PIN_FILE.name}): {expected_sha256}",
file=sys.stderr,
)
print(f" actual: {actual}", file=sys.stderr)
print(
f"\nIf you intentionally bumped `version` in {PIN_FILE.name}, "
f"update `sha256` to the 'actual' value above and re-run.",
file=sys.stderr,
)
tmp_file.unlink(missing_ok=True)
return 1

tmp_file.replace(DEST_FILE)
except Exception:
tmp_file.unlink(missing_ok=True)
raise

print(f"Vendored @mermaid-js/tiny@{version}")
print(f" Path: {DEST_FILE.relative_to(REPO_ROOT)}")
print(f" SHA-256: {expected_sha256}")
return 0


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--check",
action="store_true",
help="Verify the committed vendored file matches the pinned SHA-256 "
"without downloading.",
)
group.add_argument(
"--latest",
action="store_true",
help="Print the currently-pinned version and the latest version on npm, "
"then exit.",
)
args = parser.parse_args()

version, expected_sha256 = load_pin()

if args.check:
return check_only(version, expected_sha256)
if args.latest:
return print_latest(version)
return download_and_verify(version, expected_sha256)


if __name__ == "__main__":
sys.exit(main())
8 changes: 8 additions & 0 deletions scripts/vendor_mermaid.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Pin file for scripts/vendor_mermaid.py.
#
# Bump `version`, run `python3 scripts/vendor_mermaid.py`. The script will
# fail with the new SHA-256 printed; paste that value into `sha256` below,
# then re-run. See CONTRIBUTING.md ("Vendored dependencies") for details.

version = "11.14.0"
sha256 = "aa1f0a2b8e3ce63bc187079fc3481f02758bf71498b3d6d2dddcd069b8233f61"
8 changes: 7 additions & 1 deletion zensical.toml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ extra_css = ["stylesheets/extra.css"]
# The path provided should be relative to the "docs_dir".
#
# Read more: https://zensical.org/docs/customization/#additional-javascript
#extra_javascript = ["javascripts/extra.js"]
#
# mermaid.tiny.js: self-hosted Mermaid diagram renderer.
# See CONTRIBUTING.md for the upgrade procedure.
extra_javascript = [
"assets/javascripts/mermaid.tiny.js",
"javascripts/mermaid-init.js",
]

# ----------------------------------------------------------------------------
# Section for configuring theme options
Expand Down
Loading