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
11 changes: 6 additions & 5 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ Theme-specific display and rendering options. These are supported by the default
| `nav_default_state` | `str` | `"collapsed"` | Sidebar nav state: `"collapsed"` or `"expanded"` (default theme only) |
| `show_build_info` | `bool` | `true` | Show build timestamp in footer (default theme only) |
| `show_build_commit` | `bool` | `false` | Show git commit in footer (default theme only) |
| `math_cdn` | `bool` | `true` | Load KaTeX from CDN for math rendering (default theme only). Set `false` to provide KaTeX yourself via `_styles/` and `_scripts/`. |
| `mermaid_cdn` | `bool` | `true` | Load Mermaid from CDN for diagram rendering (default theme only). Set `false` to provide Mermaid yourself via `_scripts/`. |
| `math_cdn` | `bool\|str` | `"auto"` | KaTeX CDN loading. `"auto"` detects math usage at build time. `true` always loads, `false` never loads. Set `false` to provide KaTeX yourself via `_styles/` and `_scripts/`. |
| `mermaid_cdn` | `bool\|str` | `"auto"` | Mermaid CDN loading. `"auto"` detects mermaid usage at build time. `true` always loads, `false` never loads. Set `false` to provide Mermaid yourself via `_scripts/`. |

## `[nav]`

Expand Down Expand Up @@ -132,9 +132,10 @@ Atom feed generation. Requires `site.base_url` to be set.

## `[search]`

| Field | Type | Default | Description |
| ----------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
| `include_content` | `bool` | `true` | Include full page body in search index. When `false`, only title, slug, and tags are searchable. |
| Field | Type | Default | Description |
| ----------------- | ----------------- | ----------- | ------------------------------------------------------------------------------------------------ |
| `include_content` | `bool` | `true` | Include full page body in search index. When `false`, only title, slug, and tags are searchable. |
| `stopwords` | `str\|list[str]` | `"default"` | Stopword filtering for search. `"default"` uses lunr.js built-in English stopwords. `"none"` disables filtering. A list of strings provides a custom stopword list. |

## `[dates]`

Expand Down
5 changes: 0 additions & 5 deletions plans/issues.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
# Known Issues

## Config / UX

- **Search stopword list needs research and configurability**: The client-side JS search library's default stopword list is long and may filter out actual content. Needs: (1) research a better default list, (2) make the list configurable via `rockgarden.toml`, (3) pass the configured list to the JS search library initialization.

## Performance

- **CDN scripts load on every page regardless of usage**: KaTeX and Mermaid CDN scripts load on all pages even when no math or diagrams are present. Could detect usage at build time and set per-page flags to conditionally include them.
- **Mermaid ESM import lacks SRI integrity check**: KaTeX uses `integrity="sha384-..."` on its `<script>` tag, but ES module `import` statements don't support `integrity` directly. Could use an import map with integrity metadata to get equivalent protection. Low priority since the version is pinned.

## Feature gaps
Expand Down
21 changes: 19 additions & 2 deletions src/rockgarden/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,16 @@ class ThemeConfig(BaseModel):
show_build_info: bool = True
show_build_commit: bool = False
main_content_padding: str = "px-12"
math_cdn: bool = True
mermaid_cdn: bool = True
math_cdn: bool | str = "auto"
mermaid_cdn: bool | str = "auto"

@field_validator("math_cdn", "mermaid_cdn", mode="after")
@classmethod
def validate_cdn(cls, v: bool | str) -> bool | str:
if isinstance(v, str) and v != "auto":
msg = f"must be true, false, or 'auto', got {v!r}"
raise ValueError(msg)
return v


class NavLinkConfig(BaseModel):
Expand Down Expand Up @@ -128,6 +136,15 @@ class SearchConfig(BaseModel):
"""Search index configuration."""

include_content: bool = True
stopwords: str | list[str] = "default"

@field_validator("stopwords", mode="after")
@classmethod
def validate_stopwords(cls, v: str | list[str]) -> str | list[str]:
if isinstance(v, str) and v not in ("default", "none"):
msg = f"stopwords must be 'default', 'none', or a list of words, got {v!r}"
raise ValueError(msg)
return v


class DatesConfig(BaseModel):
Expand Down
18 changes: 16 additions & 2 deletions src/rockgarden/output/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import json
import re
import shutil
import sys
import time
Expand Down Expand Up @@ -429,6 +430,15 @@ def build_site(
clean_urls = config.site.clean_urls
base_path = config.site.base_path or get_base_path(config.site.base_url)

# Resolve CDN auto-detection by scanning raw content
math_cdn = config.theme.math_cdn
if math_cdn == "auto":
_math_re = re.compile(r"\$\$|```math|\$[^\s\d$]")

This comment was marked as outdated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case that's not worth addressing at this time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Previously flagged and still present. \$[^\s\d$] still false-positives on shell variable names ($HOME, $PATH) and JS template literals (${x}), which are common in technical docs and will cause KaTeX to load unnecessarily on those pages. A tighter pattern that avoids matching $WORD (uppercase/lowercase identifier-style variables) would be r'\$\$|```math|(?<![\w])\$(?![\s\d\w])' — the (?![\w]) negative lookahead drops $VAR while still catching inline math like $x^2$ when followed by a math expression character.

math_cdn = any(_math_re.search(p.content) for p in pages)
mermaid_cdn = config.theme.mermaid_cdn
if mermaid_cdn == "auto":
mermaid_cdn = any("```mermaid" in p.content for p in pages)

# Incremental build setup
manifest: BuildManifest | None = None
manifest_path = site_root / ".rockgarden" / "build-manifest.json"
Expand All @@ -439,6 +449,7 @@ def build_site(
cur_template_hash = compute_template_hash(site_root, config.theme.name)
cur_macro_hash = compute_macro_hash(site_root)
output_dir_str = str(output.resolve())
cur_cdn_flags = f"math={math_cdn},mermaid={mermaid_cdn}"

manifest = BuildManifest.load(manifest_path)
if manifest and not manifest.needs_full_rebuild(
Expand All @@ -447,6 +458,7 @@ def build_site(
cur_macro_hash,
output_dir_str,
len(pages),
cur_cdn_flags,
):
use_incremental = True
else:
Expand All @@ -456,6 +468,7 @@ def build_site(
macro_hash=cur_macro_hash,
output_dir=output_dir_str,
page_count=len(pages),
cdn_flags=cur_cdn_flags,
)

collections = partition_collections(pages, config.collections, source)
Expand Down Expand Up @@ -529,14 +542,15 @@ def build_site(
"daisyui_theme": config.theme.daisyui_default,
"daisyui_themes": config.theme.daisyui_themes,
"search_enabled": config.theme.search,
"search_stopwords": config.search.stopwords,
"build_info": build_info,
"cache_hash": cache_hash,
"user_styles": user_styles,
"user_scripts": user_scripts,
"assets_dir": assets_dir,
"main_content_padding": config.theme.main_content_padding,
"math_cdn": config.theme.math_cdn,
"mermaid_cdn": config.theme.mermaid_cdn,
"math_cdn": math_cdn,
"mermaid_cdn": mermaid_cdn,
"feed_enabled": config.feed.enabled and bool(config.site.base_url),
"feed_path": config.feed.path,
}
Expand Down
6 changes: 6 additions & 0 deletions src/rockgarden/output/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class BuildManifest:
macro_hash: str
output_dir: str
page_count: int
cdn_flags: str = ""
pages: dict[str, PageManifestEntry] = field(default_factory=dict)

@classmethod
Expand All @@ -45,6 +46,7 @@ def load(cls, path: Path) -> BuildManifest | None:
macro_hash=data["macro_hash"],
output_dir=data["output_dir"],
page_count=data["page_count"],
cdn_flags=data.get("cdn_flags", ""),
pages=pages,
)
except (json.JSONDecodeError, KeyError, TypeError):
Expand All @@ -60,6 +62,7 @@ def save(self, path: Path) -> None:
"macro_hash": self.macro_hash,
"output_dir": self.output_dir,
"page_count": self.page_count,
"cdn_flags": self.cdn_flags,
"pages": {
slug: {"content_hash": e.content_hash, "output_path": e.output_path}
for slug, e in self.pages.items()
Expand All @@ -84,6 +87,7 @@ def needs_full_rebuild(
macro_hash: str,
output_dir: str,
page_count: int,
cdn_flags: str = "",
) -> bool:
"""Check if a full rebuild is needed due to global changes."""
if self.config_hash != config_hash:
Expand All @@ -96,6 +100,8 @@ def needs_full_rebuild(
return True
if self.page_count != page_count:
return True
if self.cdn_flags != cdn_flags:
return True
if not Path(output_dir).exists():
return True
return False
Expand Down
10 changes: 10 additions & 0 deletions src/rockgarden/templates/layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@
data.forEach(doc => { documents[doc.url] = doc; });

searchIndex = lunr(function() {
{% if site.search_stopwords == "none" %}
this.pipeline.remove(lunr.stopWordFilter);
this.searchPipeline.remove(lunr.stopWordFilter);
{% elif site.search_stopwords is sequence and site.search_stopwords is not string %}
var customStopWords = lunr.generateStopWordFilter({{ site.search_stopwords | tojson }});
this.pipeline.remove(lunr.stopWordFilter);
this.pipeline.add(customStopWords);
this.searchPipeline.remove(lunr.stopWordFilter);
this.searchPipeline.add(customStopWords);
{% endif %}
this.ref('url');
this.field('title', { boost: 10 });
this.field('tags', { boost: 5 });
Expand Down
98 changes: 98 additions & 0 deletions tests/test_cdn_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for CDN auto-detection (math/mermaid)."""

from rockgarden.config import Config, SiteConfig, ThemeConfig
from rockgarden.output.builder import build_site


def _build_with_content(tmp_path, content, theme_config=None):
"""Build a single-page site and return the output HTML."""
source = tmp_path / "content"
source.mkdir()
(source / "page.md").write_text(content)
output = tmp_path / "output"
config = Config(
site=SiteConfig(source=source, output=output),
theme=theme_config or ThemeConfig(),
)
build_site(config, source, output)
return (output / "page" / "index.html").read_text()


def test_auto_math_detected(tmp_path):
html = _build_with_content(tmp_path, "# Math\n\n$x^2$\n")
assert "katex" in html.lower()


def test_auto_math_not_detected(tmp_path):
html = _build_with_content(tmp_path, "# No math\n\nJust text.\n")
assert "katex" not in html.lower()


def test_auto_mermaid_detected(tmp_path):
html = _build_with_content(
tmp_path, "# Diagram\n\n```mermaid\ngraph LR\n A-->B\n```\n"
)
assert "mermaid" in html


def test_auto_mermaid_not_detected(tmp_path):
html = _build_with_content(tmp_path, "# No diagrams\n\nJust text.\n")
assert "mermaid.esm" not in html


def test_math_cdn_true_always_loads(tmp_path):
html = _build_with_content(
tmp_path, "# No math\n\nJust text.\n", ThemeConfig(math_cdn=True)
)
assert "katex" in html.lower()


def test_math_cdn_false_never_loads(tmp_path):
html = _build_with_content(
tmp_path, "# Math\n\n$x^2$\n", ThemeConfig(math_cdn=False)
)
assert "katex" not in html.lower()


def test_mermaid_cdn_true_always_loads(tmp_path):
html = _build_with_content(
tmp_path, "# No diagrams\n\nJust text.\n", ThemeConfig(mermaid_cdn=True)
)
assert "mermaid.esm" in html


def test_mermaid_cdn_false_never_loads(tmp_path):
html = _build_with_content(
tmp_path,
"# Diagram\n\n```mermaid\ngraph LR\n A-->B\n```\n",
ThemeConfig(mermaid_cdn=False),
)
assert "mermaid.esm" not in html


def test_math_block_detected(tmp_path):
html = _build_with_content(tmp_path, "# Math\n\n```math\nx^2\n```\n")
assert "katex" in html.lower()


def test_dollar_sign_in_prose_no_math(tmp_path):
html = _build_with_content(tmp_path, "# Pricing\n\nCosts $5 per month.\n")
assert "katex" not in html.lower()


def test_block_math_double_dollar(tmp_path):
html = _build_with_content(tmp_path, "# Math\n\n$$x^2 + y^2$$\n")
assert "katex" in html.lower()


def test_config_auto_default():
config = ThemeConfig()
assert config.math_cdn == "auto"
assert config.mermaid_cdn == "auto"


def test_config_invalid_cdn_value():
import pytest

with pytest.raises(ValueError, match="must be true, false, or 'auto'"):
ThemeConfig(math_cdn="always")
57 changes: 57 additions & 0 deletions tests/test_search_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for search stopword configuration."""

import pytest

from rockgarden.config import Config, SearchConfig, SiteConfig
from rockgarden.output.builder import build_site


def test_stopwords_default():
config = SearchConfig()
assert config.stopwords == "default"


def test_stopwords_none():
config = SearchConfig(stopwords="none")
assert config.stopwords == "none"


def test_stopwords_custom_list():
config = SearchConfig(stopwords=["the", "a", "an"])
assert config.stopwords == ["the", "a", "an"]


def test_stopwords_invalid_string():
with pytest.raises(ValueError, match="stopwords must be"):
SearchConfig(stopwords="custom")


def _build_and_get_html(tmp_path, stopwords="default"):
source = tmp_path / "content"
source.mkdir()
(source / "page.md").write_text("# Hello\n\nSome content.\n")
output = tmp_path / "output"
config = Config(
site=SiteConfig(source=source, output=output),
search=SearchConfig(stopwords=stopwords),
)
build_site(config, source, output)
return (output / "page" / "index.html").read_text()


def test_default_stopwords_no_pipeline_change(tmp_path):
html = _build_and_get_html(tmp_path, "default")
assert "lunr.stopWordFilter" not in html
assert "generateStopWordFilter" not in html


def test_none_stopwords_removes_filter(tmp_path):
html = _build_and_get_html(tmp_path, "none")
assert "this.pipeline.remove(lunr.stopWordFilter)" in html


def test_custom_stopwords_sets_filter(tmp_path):
html = _build_and_get_html(tmp_path, ["the", "a"])
assert "generateStopWordFilter" in html
assert '"the"' in html
assert '"a"' in html
Loading