mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-08-08 00:08:36 +02:00
* fix(ci): move the EPUB build out of pre-commit into CI The build-epub hooks called scripts/build_epub.py, which raises when the mmdc binary is missing, while check_mermaid.py skips with a warning in the same situation. On arm64 — where @mermaid-js/mermaid-cli has no working build — that made 'pre-commit run --all-files' impossible to satisfy for a docs-only change without --no-verify. Remove the build-epub, vietnamese-build-epub and japanese-build-epub hooks and widen the CI build-epub job to a language matrix so PR-time coverage is not reduced: it previously built en only, and now builds en, vi, zh and ja. zh was covered by neither the hooks nor this job, so a broken zh diagram could only surface at release time. Closes #156 * docs: replace the stale Kroki narrative with local mmdc rendering Commite76bbe4swapped the Kroki HTTP dependency for local mmdc rendering but left the docs describing the old design. The claim appeared in seven live files, not the two originally reported: CLAUDE.md, scripts/README.md and their ja/uk/vi copies. Also corrects what the same staleness dragged along: - scripts/README.md documented --timeout and --max-concurrent, which no longer exist, and omitted --mmdc-path, --lang and --puppeteer-config - 'Async concurrent fetching' described a render_all() that is now a plain sequential loop with a dedupe cache - the network-error/rate-limiting troubleshooting entries are replaced with the failures that actually occur now (missing mmdc, Chromium sandbox) CHANGELOG entries recording the Kroki-to-mmdc switch are left alone; they describe the past accurately. Closes #157 * fix(scripts): make the ruff config match files that exist Ruff resolves include/per-file-ignores patterns relative to the directory holding pyproject.toml. Since the config lives in scripts/, the pattern "scripts/**/*.py" meant scripts/scripts/**/*.py, which matches nothing — 'ruff check scripts/' printed 'warning: No Python files found under the given path(s)' and then 'All checks passed!'. per-file-ignores had the same mistake. Both are now relative to scripts/, so ruff sees all 14 files. This silently disabled the pre-commit ruff hooks too, not just the documented command, and two test files had drifted out of format as a result. Reformatting them exposed a second problem: the pre-commit hook pinned ruff v0.8.2 while the CI lint job installs unpinned latest and this venv has 0.15.10. The versions disagree on formatting, so each reverted the other's output, and CI would have started failing on a file the local hook kept rewriting. Bump the hook pin and the requirements-dev floor to 0.15.10 so all three agree. Verified: 'pre-commit run --all-files' is stable across consecutive runs, 'ruff format --check scripts/' and 'ruff check scripts/' both pass, 92 tests pass. Closes #160 * docs: point localized module links at their own trees Files at the root of a language directory used ../NN-module/, which from uk/CATALOG.md resolves to the English 05-mcp/ at the repo root rather than uk/05-mcp/. Readers following a count stated about the localized tree landed in the English one. The link checker never caught it because the English target does exist. Files one level deeper (uk/04-subagents/README.md and friends) were already correct — ../06-hooks/ from there resolves to uk/06-hooks/ — so this is scoped to the 7 depth-1 files that actually escape: CATALOG.md in uk, vi, ja and zh, LEARNING-ROADMAP.md in uk and vi, and ja/claude_concepts_guide.md. 119 links in total. The ../NN- occurrences left in STYLE_GUIDE.md and TRANSLATION_NOTES.md are inside fenced examples showing what a module page should contain, where the ../ form is the correct convention. All 119 rewritten targets were verified to exist. Closes #158 * docs(hooks): stop conflating hook types with hook event categories '**Hook Types** (5 types, 31 events)' sat above four bullets, so the count read as wrong. It was not — the two numbers describe different axes that had been merged into one label. 06-hooks/README.md:126 documents five hook *types*: command, http, prompt, mcp_tool and agent. Those describe how a hook runs. The four bullets are event *categories* — Tool, Session, Task, Lifecycle — holding 31 events (6+7+6+12), and describe when it runs. Split the label so each number belongs to the axis it counts, in all nine affected files: README.md and INDEX.md plus the ja, uk, vi and zh copies. QUICK_REFERENCE.md and LEARNING-ROADMAP.md already stated '5 types' with the handler names attached and needed no change. Closes #159 * chore(scripts): drop the unused httpx dependency httpx was the HTTP client for Kroki rendering. Nothing has imported it sincee76bbe4moved diagram rendering to a local mmdc subprocess — check_links.py, the only other network caller, uses stdlib urllib.request. The PEP 723 blocks in build_epub.py and build_website.py had already dropped it; only the manifests and docs still declared it. Removes it from requirements.txt, pyproject.toml dependencies, the dependency tables and uv --with lines in scripts/README.md and its ja/uk copies, and the .cspell.json word list. Also drops the B113 bandit suppression, which existed solely for an httpx timeout false positive — bandit reports no issues without it. Also corrects two Requirements lines missed in the previous pass: the ja and uk script READMEs still listed an internet connection rather than mmdc. Note: tenacity is now dead for the same reason and is left in place. * chore(scripts): drop the unused tenacity dependency Nothing imports tenacity — the retry logic it was added for went away with the Kroki HTTP fetching ine76bbe4. It was still declared in requirements.txt, pyproject.toml, both `uv run --with` lines and all three dependency tables. * docs(i18n): sync the localized pre-commit check lists The ja/uk/vi CLAUDE.md files still listed build-epub as pre-commit check #5, stale since 6da8184 moved the EPUB build to CI. They also omitted markdown-rendering, and the ja/uk stated counts disagreed with their own lists. Now matches the English CLAUDE.md. * fix(scripts): silence PLR0917 now stable in ruff 0.16 CI installs the latest ruff (uv pip install ruff, unpinned), and 0.16.1 promoted too-many-positional-arguments from preview to stable, hard-failing the Code Quality check on build_epub.py's long-signature draw/helper functions. These sit in the same family as PLR0913, which is already ignored. Add PLR0917 so the local 0.15.10 pin and unpinned CI lint agree again.
521 lines
18 KiB
Python
521 lines
18 KiB
Python
"""Tests for the static website builder."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from build_website import (
|
|
BuildState,
|
|
PageInfo,
|
|
WebsiteConfig,
|
|
_disambiguate_url,
|
|
build_website,
|
|
collect_folder_markdown,
|
|
collect_pages,
|
|
derive_page_title,
|
|
heading_to_anchor,
|
|
is_excluded_dir,
|
|
is_excluded_top_level_markdown,
|
|
relative_link,
|
|
render_markdown,
|
|
replace_mermaid_blocks,
|
|
rewrite_links,
|
|
source_to_site_url,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def site_root(tmp_path: Path) -> Path:
|
|
"""Create a minimal repo-like tree the builder can render."""
|
|
(tmp_path / "README.md").write_text(
|
|
"<picture>\n"
|
|
' <source media="(prefers-color-scheme: dark)" '
|
|
'srcset="resources/logos/claude-howto-logo-dark.svg">\n'
|
|
' <img alt="Claude How To" src="resources/logos/claude-howto-logo.svg">\n'
|
|
"</picture>\n\n"
|
|
"# Home Page\n\nWelcome. See [Slash Commands](01-slash-commands/README.md).\n"
|
|
"Also check [script](scripts/build.sh) and the [logo](resources/logos/logo.svg).\n"
|
|
)
|
|
(tmp_path / "LEARNING-ROADMAP.md").write_text(
|
|
"# Learning Roadmap\n\nLink back to [Home](README.md#home-page).\n"
|
|
)
|
|
(tmp_path / "CONTRIBUTING.md").write_text("# Contributing\n\nHelp improve docs.")
|
|
(tmp_path / "CLAUDE.md").write_text("# Internal Agent Notes\n")
|
|
(tmp_path / "update-plan-2026-05-02.md").write_text("# Temporary Plan\n")
|
|
|
|
sc = tmp_path / "01-slash-commands"
|
|
sc.mkdir()
|
|
(sc / "README.md").write_text(
|
|
"# Slash Commands\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n"
|
|
"See [example](example.md).\n"
|
|
)
|
|
(sc / "example.md").write_text("# Example\n\nGo back to [overview](README.md).\n")
|
|
|
|
# Non-markdown repo files
|
|
scripts_dir = tmp_path / "scripts"
|
|
scripts_dir.mkdir()
|
|
(scripts_dir / "build.sh").write_text("#!/bin/bash\necho hi\n")
|
|
|
|
logos = tmp_path / "resources" / "logos"
|
|
logos.mkdir(parents=True)
|
|
(logos / "logo.svg").write_text("<svg></svg>")
|
|
(logos / "claude-howto-logo.svg").write_text("<svg></svg>")
|
|
(logos / "claude-howto-logo-dark.svg").write_text("<svg></svg>")
|
|
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def logger() -> logging.Logger:
|
|
return logging.getLogger("test_build_website")
|
|
|
|
|
|
# =============================================================================
|
|
# heading_to_anchor parity with check_cross_references
|
|
# =============================================================================
|
|
|
|
|
|
class TestHeadingToAnchor:
|
|
def test_simple_title(self) -> None:
|
|
assert heading_to_anchor("Hello World") == "hello-world"
|
|
|
|
def test_punctuation_removed(self) -> None:
|
|
assert heading_to_anchor("What's Next?") == "whats-next"
|
|
|
|
def test_unicode_preserved(self) -> None:
|
|
assert heading_to_anchor("Hướng dẫn") == "hướng-dẫn"
|
|
|
|
def test_emoji_stripped(self) -> None:
|
|
# The validator strips emoji; we mirror that exactly.
|
|
assert heading_to_anchor("🔥 Trending") == "-trending"
|
|
|
|
|
|
# =============================================================================
|
|
# source_to_site_url
|
|
# =============================================================================
|
|
|
|
|
|
class TestSourceToSiteUrl:
|
|
def test_root_readme_maps_to_index(self) -> None:
|
|
assert source_to_site_url("README.md") == "index.html"
|
|
|
|
def test_folder_readme_maps_to_folder_index(self) -> None:
|
|
assert (
|
|
source_to_site_url("01-slash-commands/README.md")
|
|
== "01-slash-commands/index.html"
|
|
)
|
|
|
|
def test_other_markdown_uses_html_extension(self) -> None:
|
|
assert (
|
|
source_to_site_url("01-slash-commands/example.md")
|
|
== "01-slash-commands/example.html"
|
|
)
|
|
|
|
|
|
class TestDisambiguateUrl:
|
|
def test_no_collision_passes_through(self) -> None:
|
|
used: set[str] = {"foo.html"}
|
|
assert _disambiguate_url("bar.html", used, "bar.md") == "bar.html"
|
|
|
|
def test_case_insensitive_collision_disambiguated(self) -> None:
|
|
# README → index.html lands first; INDEX.md (case-insensitive collision)
|
|
# must get a suffix on macOS / Windows filesystems.
|
|
used: set[str] = {"index.html"}
|
|
result = _disambiguate_url("INDEX.html", used, "INDEX.md")
|
|
assert result.lower() != "index.html"
|
|
assert result.endswith(".html")
|
|
|
|
|
|
# =============================================================================
|
|
# relative_link
|
|
# =============================================================================
|
|
|
|
|
|
class TestRelativeLink:
|
|
def test_same_directory(self) -> None:
|
|
assert relative_link("01/index.html", "01/example.html") == "example.html"
|
|
|
|
def test_anchor_appended(self) -> None:
|
|
assert (
|
|
relative_link("01/index.html", "02/index.html", "#intro")
|
|
== "../02/index.html#intro"
|
|
)
|
|
|
|
def test_self_link_returns_anchor_only(self) -> None:
|
|
assert relative_link("01/index.html", "01/index.html", "#section") == "#section"
|
|
|
|
def test_parent_directory(self) -> None:
|
|
assert relative_link("01/index.html", "index.html") == "../index.html"
|
|
|
|
|
|
# =============================================================================
|
|
# is_excluded_dir
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsExcludedDir:
|
|
def test_hidden_dirs_excluded(self) -> None:
|
|
assert is_excluded_dir(".git") is True
|
|
|
|
def test_known_dir_excluded(self) -> None:
|
|
assert is_excluded_dir("node_modules") is True
|
|
|
|
def test_chapter_dir_kept(self) -> None:
|
|
assert is_excluded_dir("01-slash-commands") is False
|
|
|
|
|
|
class TestIsExcludedTopLevelMarkdown:
|
|
def test_internal_agent_file_excluded(self) -> None:
|
|
assert is_excluded_top_level_markdown("CLAUDE.md") is True
|
|
|
|
def test_temporary_update_plan_excluded(self) -> None:
|
|
assert is_excluded_top_level_markdown("update-plan-2026-05-02.md") is True
|
|
|
|
def test_project_doc_included(self) -> None:
|
|
assert is_excluded_top_level_markdown("CONTRIBUTING.md") is False
|
|
|
|
|
|
# =============================================================================
|
|
# collect_folder_markdown
|
|
# =============================================================================
|
|
|
|
|
|
class TestCollectFolderMarkdown:
|
|
def test_readme_first(self, tmp_path: Path) -> None:
|
|
(tmp_path / "b.md").write_text("# B")
|
|
(tmp_path / "README.md").write_text("# Readme")
|
|
(tmp_path / "a.md").write_text("# A")
|
|
files = collect_folder_markdown(tmp_path)
|
|
assert [f.name for f in files] == ["README.md", "a.md", "b.md"]
|
|
|
|
def test_skips_hidden_subdirs(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# R")
|
|
hidden = tmp_path / ".cache"
|
|
hidden.mkdir()
|
|
(hidden / "junk.md").write_text("# junk")
|
|
files = collect_folder_markdown(tmp_path)
|
|
assert [f.name for f in files] == ["README.md"]
|
|
|
|
|
|
class TestCollectPages:
|
|
def test_additional_top_level_docs_are_collected(
|
|
self, site_root: Path, logger: logging.Logger
|
|
) -> None:
|
|
state = collect_pages(
|
|
WebsiteConfig(root_path=site_root, output_path=site_root / "site"), logger
|
|
)
|
|
assert "CONTRIBUTING.md" in state.source_to_url
|
|
assert state.source_to_url["CONTRIBUTING.md"] == "CONTRIBUTING.html"
|
|
assert "CLAUDE.md" not in state.source_to_url
|
|
assert "update-plan-2026-05-02.md" not in state.source_to_url
|
|
|
|
|
|
# =============================================================================
|
|
# derive_page_title
|
|
# =============================================================================
|
|
|
|
|
|
class TestDerivePageTitle:
|
|
def test_uses_h1(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "f.md"
|
|
f.write_text("Some intro\n# The Title\nBody")
|
|
assert derive_page_title(f, "Default") == "The Title"
|
|
|
|
def test_falls_back_when_no_h1(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "f.md"
|
|
f.write_text("No heading here")
|
|
assert derive_page_title(f, "Default") == "Default"
|
|
|
|
|
|
# =============================================================================
|
|
# replace_mermaid_blocks
|
|
# =============================================================================
|
|
|
|
|
|
class TestReplaceMermaidBlocks:
|
|
def test_replaces_fence(self) -> None:
|
|
md = "Before\n\n```mermaid\nflowchart LR\nA-->B\n```\n\nAfter"
|
|
out = replace_mermaid_blocks(md)
|
|
assert '<pre class="mermaid">' in out
|
|
assert "flowchart LR" in out
|
|
assert "```mermaid" not in out
|
|
|
|
def test_escapes_html(self) -> None:
|
|
md = "```mermaid\nA --> B<C>\n```\n"
|
|
out = replace_mermaid_blocks(md)
|
|
assert "<C>" in out
|
|
|
|
|
|
# =============================================================================
|
|
# render_markdown
|
|
# =============================================================================
|
|
|
|
|
|
class TestRenderMarkdown:
|
|
def test_heading_gets_github_anchor(self) -> None:
|
|
html_content = render_markdown("# Hello World\n\nBody")
|
|
assert 'id="hello-world"' in html_content
|
|
|
|
def test_duplicate_headings_get_suffix(self) -> None:
|
|
html_content = render_markdown("# Hi\n\n# Hi\n")
|
|
assert 'id="hi"' in html_content
|
|
assert 'id="hi-1"' in html_content
|
|
|
|
|
|
# =============================================================================
|
|
# rewrite_links
|
|
# =============================================================================
|
|
|
|
|
|
class TestRewriteLinks:
|
|
def _state(self) -> BuildState:
|
|
state = BuildState()
|
|
state.source_to_url = {
|
|
"README.md": "index.html",
|
|
"01-slash-commands/README.md": "01-slash-commands/index.html",
|
|
}
|
|
return state
|
|
|
|
def _config(self, root: Path) -> WebsiteConfig:
|
|
return WebsiteConfig(
|
|
root_path=root,
|
|
output_path=root / "out",
|
|
repo_url="https://github.com/example/repo",
|
|
branch="main",
|
|
)
|
|
|
|
def test_internal_markdown_link_rewritten(
|
|
self, tmp_path: Path, logger: logging.Logger
|
|
) -> None:
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
(tmp_path / "01-slash-commands").mkdir()
|
|
(tmp_path / "01-slash-commands" / "README.md").write_text("# Slash")
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
html_in = '<a href="01-slash-commands/README.md">go</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert "01-slash-commands/index.html" in out
|
|
assert ".md" not in out
|
|
|
|
def test_anchor_preserved(self, tmp_path: Path, logger: logging.Logger) -> None:
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
(tmp_path / "01-slash-commands").mkdir()
|
|
(tmp_path / "01-slash-commands" / "README.md").write_text("# Slash")
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
html_in = '<a href="01-slash-commands/README.md#run">go</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert "#run" in out
|
|
|
|
def test_non_markdown_link_uses_github_blob(
|
|
self, tmp_path: Path, logger: logging.Logger
|
|
) -> None:
|
|
(tmp_path / "scripts").mkdir()
|
|
(tmp_path / "scripts" / "build.sh").write_text("#!/bin/bash")
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
html_in = '<a href="scripts/build.sh">script</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert "github.com/example/repo/blob/main/scripts/build.sh" in out
|
|
assert 'target="_blank"' in out
|
|
|
|
def test_repo_directory_link_uses_github_tree(
|
|
self, tmp_path: Path, logger: logging.Logger
|
|
) -> None:
|
|
(tmp_path / "scripts").mkdir()
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
html_in = '<a href="scripts/">scripts</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert "github.com/example/repo/tree/main/scripts" in out
|
|
assert "github.com/example/repo/blob/main/scripts" not in out
|
|
|
|
def test_repo_root_link_uses_github_tree(
|
|
self, tmp_path: Path, logger: logging.Logger
|
|
) -> None:
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
html_in = '<a href=".">repo root</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert "github.com/example/repo/tree/main" in out
|
|
assert "github.com/example/repo/blob/main/." not in out
|
|
|
|
def test_external_link_left_alone(
|
|
self, tmp_path: Path, logger: logging.Logger
|
|
) -> None:
|
|
page = PageInfo(
|
|
source=tmp_path / "README.md",
|
|
rel_source="README.md",
|
|
output_url="index.html",
|
|
title="Home",
|
|
section="Introduction",
|
|
is_section_index=True,
|
|
)
|
|
(tmp_path / "README.md").write_text("# Home")
|
|
html_in = '<a href="https://anthropic.com">site</a>'
|
|
out = rewrite_links(
|
|
html_in, page, self._state(), self._config(tmp_path), logger
|
|
)
|
|
assert 'href="https://anthropic.com"' in out
|
|
|
|
|
|
# =============================================================================
|
|
# Full build smoke test
|
|
# =============================================================================
|
|
|
|
|
|
class TestBuildWebsite:
|
|
def test_smoke_build(self, site_root: Path, logger: logging.Logger) -> None:
|
|
out_dir = site_root / "site"
|
|
config = WebsiteConfig(
|
|
root_path=site_root,
|
|
output_path=out_dir,
|
|
repo_url="https://github.com/example/repo",
|
|
branch="main",
|
|
)
|
|
|
|
build_website(config, logger, skip_vendor=True)
|
|
|
|
# Index page rendered
|
|
index = out_dir / "index.html"
|
|
assert index.exists()
|
|
index_html = index.read_text(encoding="utf-8")
|
|
assert "Home Page" in index_html
|
|
assert "01-slash-commands/index.html" in index_html
|
|
assert "CONTRIBUTING.html" in index_html
|
|
# Non-markdown link rewritten to GitHub
|
|
assert "github.com/example/repo/blob/main/scripts/build.sh" in index_html
|
|
# <source srcset> inside <picture> rewritten to point at assets/
|
|
assert 'srcset="assets/resources/logos/' in index_html
|
|
|
|
# Additional top-level docs rendered
|
|
assert (out_dir / "CONTRIBUTING.html").exists()
|
|
assert not (out_dir / "CLAUDE.html").exists()
|
|
assert not (out_dir / "update-plan-2026-05-02.html").exists()
|
|
|
|
# Folder index rendered
|
|
sc_index = out_dir / "01-slash-commands" / "index.html"
|
|
assert sc_index.exists()
|
|
sc_html = sc_index.read_text(encoding="utf-8")
|
|
# Mermaid block became a <pre class="mermaid"> block
|
|
assert '<pre class="mermaid">' in sc_html
|
|
# Sibling markdown link rewritten
|
|
assert "example.html" in sc_html
|
|
|
|
# Example page rendered
|
|
example_page = out_dir / "01-slash-commands" / "example.html"
|
|
assert example_page.exists()
|
|
example_html = example_page.read_text(encoding="utf-8")
|
|
# Back-link to README rewrites to index.html
|
|
assert "index.html" in example_html
|
|
|
|
# Stylesheet copied into assets
|
|
assert (out_dir / "assets" / "site.css").exists()
|
|
# Logo copied into assets
|
|
assert (out_dir / "assets" / "resources" / "logos" / "logo.svg").exists()
|
|
# Template no longer references third-party CDNs.
|
|
for hostile in (
|
|
"cdn.tailwindcss.com",
|
|
"cdn.jsdelivr.net",
|
|
"fonts.googleapis.com",
|
|
):
|
|
assert hostile not in index_html, (
|
|
f"Built HTML still references {hostile} — CDN should be self-hosted"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# vendor_assets module smoke test
|
|
# =============================================================================
|
|
|
|
|
|
class TestVendorAssets:
|
|
def test_module_exports(self) -> None:
|
|
"""vendor_assets exposes the API build_website depends on."""
|
|
import vendor_assets
|
|
|
|
for attr in (
|
|
"build_tailwind_css",
|
|
"fetch_mermaid",
|
|
"fetch_fonts",
|
|
"write_vendor_manifest",
|
|
"ensure_tailwind_binary",
|
|
"TAILWIND_VERSION",
|
|
"MERMAID_VERSION",
|
|
):
|
|
assert hasattr(vendor_assets, attr), f"missing {attr}"
|
|
|
|
def test_detect_tailwind_asset_name(self) -> None:
|
|
"""Platform detection returns one of the known asset names."""
|
|
from vendor_assets import _detect_tailwind_asset_name
|
|
|
|
known = {
|
|
"tailwindcss-macos-arm64",
|
|
"tailwindcss-macos-x64",
|
|
"tailwindcss-linux-arm64",
|
|
"tailwindcss-linux-armv7",
|
|
"tailwindcss-linux-x64",
|
|
"tailwindcss-windows-x64.exe",
|
|
}
|
|
assert _detect_tailwind_asset_name() in known
|
|
|
|
def test_download_rejects_non_http_scheme(self, tmp_path: Path) -> None:
|
|
"""The _download helper refuses file:/ftp:/etc. — defense-in-depth."""
|
|
from vendor_assets import _download
|
|
|
|
with pytest.raises(ValueError, match="non-HTTP URL"):
|
|
_download("file:///etc/passwd", tmp_path / "out.bin")
|