Skip to content

Commit 06709f9

Browse files
authored
Merge pull request #615 from davep/script-management
Add support for overriding third party resources
2 parents 4640cfb + e5bde81 commit 06709f9

17 files changed

Lines changed: 327 additions & 20 deletions

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,11 @@ Rules:
241241
following the patterns used for `sidebar_pages`, `head`,
242242
`extra_stylesheets`, and the path template fields.
243243

244+
* **Third-party scripts/stylesheets**: if we ever add another third-party resource,
245+
its URLs/endpoints should be added to the nested `third_party` configuration mapping
246+
(e.g., `third_party.resource_name.url_type`) rather than creating a top-level property.
247+
This keeps all external script and stylesheet configuration grouped and consistent.
248+
Follow the validation and merge patterns established in `config.py` for the existing
249+
`third_party` fields.
250+
244251
[//]: # (AGENTS.md ends here)

ChangeLog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# BlogMore ChangeLog
22

3+
## Unreleased
4+
5+
**Released: WiP**
6+
7+
- Added a `third_party` configuration mapping to allow overriding the script
8+
and stylesheet URLs/locations for Mermaid, KaTeX, MathJax, FontAwesome,
9+
and Force-Graph. ([#615](https://github.com/davep/blogmore/pull/615))
10+
- Refactored the FontAwesome metadata caching to hash the configured URL,
11+
ensuring cache invalidation when a user updates their targeted version.
12+
([#615](https://github.com/davep/blogmore/pull/615))
13+
314
## v2.42.0
415

516
**Released: 2026-06-08**

THEME_DEVELOPMENT_GUIDELINES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ v2.x. Key ones that appear in nearly every template:
140140
- `bundle_css`, `bundle_css_url`, `fontawesome_is_bundled` — for CSS bundling support
141141
- `inline_theme_js`, `theme_js_content` — for theme JavaScript inlining support
142142
- `with_related`, `related_title` — for related posts feature support
143+
- `mermaid_script_url`, `katex_css_url`, `katex_js_url`, `mathjax_js_url`, `fontawesome_woff2_url`, `force_graph_js_url` — for third-party script/resource configuration support
143144

144145
### Stable Post attributes
145146

blogmore.yaml.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,28 @@ posts_per_feed: 20
133133
# Only meaningful when with_maths is true. Configuration file only.
134134
# maths_provider: katex
135135

136+
# Optional: Third-party script and stylesheet resource URLs
137+
# By default, these point to public CDN locations. You can override individual
138+
# URLs (for example, to target different versions, use custom CDN providers,
139+
# or point to locally-served files).
140+
# Configuration file only.
141+
#
142+
# third_party:
143+
# mermaid:
144+
# script_url: "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
145+
# katex:
146+
# css_url: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
147+
# js_url: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"
148+
# mathjax:
149+
# js_url: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
150+
# fontawesome:
151+
# metadata_url: "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.7.2/metadata/icons.json"
152+
# webfonts_base: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts"
153+
# css_url: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
154+
# woff2_url: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-brands-400.woff2"
155+
# force_graph:
156+
# js_url: "https://unpkg.com/force-graph"
157+
136158
# Optional: Enable automated build-time related posts calculation (default: false)
137159
# When true, BlogMore uses a pure-Python TF-IDF and Cosine Similarity engine to
138160
# automatically find and display contextually relevant posts for each entry,

docs/configuration.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,50 @@ This is a **configuration file only** option — it cannot be set on the command
551551
maths_provider: katex
552552
```
553553

554+
#### `third_party`
555+
556+
Allows overriding the URLs and script/stylesheet locations for third-party libraries (Mermaid, KaTeX, MathJax, FontAwesome, and Force-Graph). By default, these point to public CDN locations. This configuration allows you to pin specific versions or point to custom CDN/local asset locations.
557+
558+
This is a **configuration file only** option — it cannot be set on the command line.
559+
560+
**Type:** Mapping
561+
**Default:** (See default CDN URLs in the example below)
562+
563+
Within `third_party`, you can configure the following mappings:
564+
565+
* `mermaid`
566+
* `script_url` — The script module URL for Mermaid.
567+
* `katex`
568+
* `css_url` — The stylesheet URL for KaTeX.
569+
* `js_url` — The script URL for KaTeX.
570+
* `mathjax`
571+
* `js_url` — The script URL for MathJax.
572+
* `fontawesome`
573+
* `metadata_url` — The URL to fetch FontAwesome icon metadata (Unicode mappings) for CSS optimization.
574+
* `webfonts_base` — The base URL for FontAwesome brands WOFF2 and TTF web font files.
575+
* `css_url` — Fallback full stylesheet URL when CSS optimization fails.
576+
* `woff2_url` — FontAwesome brands WOFF2 preload URL.
577+
* `force_graph`
578+
* `js_url` — The script URL for the Force-Graph visualization library.
579+
580+
```yaml
581+
third_party:
582+
mermaid:
583+
script_url: "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
584+
katex:
585+
css_url: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
586+
js_url: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"
587+
mathjax:
588+
js_url: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
589+
fontawesome:
590+
metadata_url: "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.7.2/metadata/icons.json"
591+
webfonts_base: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts"
592+
css_url: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
593+
woff2_url: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-brands-400.woff2"
594+
force_graph:
595+
js_url: "https://unpkg.com/force-graph"
596+
```
597+
554598
#### `with_related`
555599

556600
Enable automated build-time related posts calculation. When `true`, BlogMore uses a pure-Python TF-IDF and Cosine Similarity engine to automatically calculate and list contextually relevant posts for each entry, with zero runtime overhead for readers.

docs/template-api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ below lists all variables that are available in every template.
5757
| `with_graph` | `bool` | `True` when the graph page is enabled. |
5858
| `graph_url` | `str` | URL to the graph page (respects `graph_path` and `clean_urls`). |
5959
| `graph_css_url` | `str` | URL to the graph-page stylesheet (with cache-bust query string). |
60+
| `force_graph_js_url` | `str` | URL to the Force-Graph JS library script. |
6061
| `with_mermaid` | `bool` | `True` when Mermaid diagram rendering is enabled. |
6162
| `has_mermaid` | `bool` | `True` if the currently rendered page/post (or any post listed in the current index view) contains a Mermaid diagram. |
63+
| `mermaid_script_url` | `str` | URL to the Mermaid ESM script. |
6264
| `with_maths` | `bool` | `True` when LaTeX mathematical formula rendering is enabled. |
6365
| `maths_provider` | `str` | The selected math rendering engine (`'katex'` or `'mathjax'`). |
6466
| `has_math` | `bool` | `True` if the currently rendered page/post (or any post listed in the current index view) contains a mathematical equation. |
67+
| `katex_css_url` | `str` | URL to the KaTeX stylesheet. |
68+
| `katex_js_url` | `str` | URL to the KaTeX script. |
69+
| `mathjax_js_url` | `str` | URL to the MathJax script. |
6570
| `inline_theme_js` | `bool` | `True` when theme JavaScript inlining is enabled. |
6671
| `theme_js_content` | `str \| None` | The content of `theme.js` to be inlined. |
6772
| `theme_js_url` | `str` | URL to `theme.js` (with cache-bust query string). |
@@ -70,6 +75,7 @@ below lists all variables that are available in every template.
7075
| `has_platform_icons` | `bool` | `True` when generated platform icons are present. |
7176
| `fontawesome_css_url` | `str \| None` | Font Awesome CSS URL, when social icons are used. |
7277
| `fontawesome_is_bundled` | `bool` | `True` when Font Awesome is included in the bundle. |
78+
| `fontawesome_woff2_url` | `str` | Font Awesome brands WOFF2 font file URL. |
7379
| `extra_stylesheets` | `list[str]` | List of extra stylesheet URLs from configuration. |
7480
| `tag_dir` | `str` | URL prefix for tag pages (e.g. `/tags`). |
7581
| `category_dir` | `str` | URL prefix for category pages (e.g. `/categories`). |

src/blogmore/config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"external_links_check_timeout",
8282
"image_widths",
8383
"stop_words",
84+
"third_party",
8485
}
8586
)
8687

@@ -750,4 +751,56 @@ def parse_site_config_from_dict(
750751
)
751752
kwargs["stop_words"] = []
752753

754+
# --- third_party ---------------------------------------------------------
755+
third_party_defaults = {
756+
"mermaid": {
757+
"script_url": "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
758+
},
759+
"katex": {
760+
"css_url": "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css",
761+
"js_url": "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js",
762+
},
763+
"mathjax": {
764+
"js_url": "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
765+
},
766+
"fontawesome": {
767+
"metadata_url": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.7.2/metadata/icons.json",
768+
"webfonts_base": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts",
769+
"css_url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css",
770+
"woff2_url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-brands-400.woff2",
771+
},
772+
"force_graph": {"js_url": "https://unpkg.com/force-graph"},
773+
}
774+
775+
raw_third_party = config.get("third_party")
776+
if raw_third_party is None:
777+
kwargs["third_party"] = third_party_defaults
778+
elif isinstance(raw_third_party, dict):
779+
merged = {}
780+
for section, default_keys in third_party_defaults.items():
781+
merged[section] = dict(default_keys)
782+
user_section = raw_third_party.get(section)
783+
if isinstance(user_section, dict):
784+
for key in default_keys:
785+
user_val = user_section.get(key)
786+
if user_val is not None:
787+
if isinstance(user_val, str):
788+
merged[section][key] = user_val
789+
else:
790+
errors.append(
791+
f"third_party.{section}.{key} in the configuration file "
792+
"must be a string; ignoring value"
793+
)
794+
elif user_section is not None:
795+
errors.append(
796+
f"third_party.{section} in the configuration file must be a mapping; "
797+
"ignoring value"
798+
)
799+
kwargs["third_party"] = merged
800+
else:
801+
errors.append(
802+
"third_party in the configuration file must be a mapping; ignoring value"
803+
)
804+
kwargs["third_party"] = third_party_defaults
805+
753806
return kwargs, errors

src/blogmore/fontawesome.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
~2-5KB.
66
"""
77

8+
import hashlib
89
import json
910
import urllib.error
1011
import urllib.request
@@ -65,14 +66,23 @@ class FontAwesomeOptimizer:
6566
sites.
6667
"""
6768

68-
def __init__(self, icon_names: list[str]) -> None:
69+
def __init__(
70+
self,
71+
icon_names: list[str],
72+
metadata_url: str = FONTAWESOME_METADATA_URL,
73+
webfonts_base: str = FONTAWESOME_CDN_WEBFONTS_BASE,
74+
) -> None:
6975
"""Initialize the optimizer with the set of icons to include.
7076
7177
Args:
7278
icon_names: List of FontAwesome brand icon names (e.g.
7379
``["github", "mastodon"]``).
80+
metadata_url: URL to fetch FontAwesome icon metadata from.
81+
webfonts_base: Base URL for webfont files.
7482
"""
7583
self.icon_names = icon_names
84+
self.metadata_url = metadata_url
85+
self.webfonts_base = webfonts_base
7686

7787
def fetch_icon_metadata(self) -> dict[str, Any]:
7888
"""Fetch FontAwesome icon metadata with local caching.
@@ -91,7 +101,8 @@ def fetch_icon_metadata(self) -> dict[str, Any]:
91101
ValueError: If the response cannot be parsed as JSON.
92102
"""
93103
cache_dir = get_user_cache_dir()
94-
cache_file = cache_dir / f"fa-metadata-{FONTAWESOME_VERSION}.json"
104+
url_hash = hashlib.sha256(self.metadata_url.encode("utf-8")).hexdigest()[:12]
105+
cache_file = cache_dir / f"fa-metadata-{url_hash}.json"
95106

96107
# Try to load from cache first
97108
if cache_file.exists():
@@ -102,7 +113,7 @@ def fetch_icon_metadata(self) -> dict[str, Any]:
102113
pass
103114

104115
# Network fetch
105-
with urllib.request.urlopen(FONTAWESOME_METADATA_URL) as response:
116+
with urllib.request.urlopen(self.metadata_url) as response:
106117
content = response.read().decode("utf-8")
107118

108119
# Update cache
@@ -128,8 +139,8 @@ def build_css(self, metadata: dict[str, Any]) -> str:
128139
rules, and one `::before` rule per requested icon found in the
129140
metadata.
130141
"""
131-
woff2_url = f"{FONTAWESOME_CDN_WEBFONTS_BASE}/fa-brands-400.woff2"
132-
ttf_url = f"{FONTAWESOME_CDN_WEBFONTS_BASE}/fa-brands-400.ttf"
142+
woff2_url = f"{self.webfonts_base.rstrip('/')}/fa-brands-400.woff2"
143+
ttf_url = f"{self.webfonts_base.rstrip('/')}/fa-brands-400.ttf"
133144

134145
lines: list[str] = [
135146
"@font-face {",

src/blogmore/generator/assets.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from blogmore.code_styles import build_code_css
1616
from blogmore.console import print_warning, timed_step
1717
from blogmore.fontawesome import (
18-
FONTAWESOME_CDN_CSS_URL,
1918
FONTAWESOME_LOCAL_CSS_MINIFIED_PATH,
2019
FONTAWESOME_LOCAL_CSS_PATH,
2120
FontAwesomeOptimizer,
@@ -47,7 +46,9 @@ def __init__(self, site_config: SiteConfig) -> None:
4746
site_config: The site configuration.
4847
"""
4948
self.site_config = site_config
50-
self.fontawesome_css_url: str = FONTAWESOME_CDN_CSS_URL
49+
self.fontawesome_css_url: str = site_config.third_party["fontawesome"][
50+
"css_url"
51+
]
5152
self._fontawesome_css_content: str | None = None
5253
self.extras_html_paths: frozenset[str] = frozenset()
5354

@@ -175,15 +176,21 @@ def prepare_fontawesome_css(self) -> str | None:
175176
for social in socials
176177
if isinstance(social, dict) and "site" in social
177178
]
178-
optimizer = FontAwesomeOptimizer(icon_names)
179+
optimizer = FontAwesomeOptimizer(
180+
icon_names,
181+
metadata_url=self.site_config.third_party["fontawesome"]["metadata_url"],
182+
webfonts_base=self.site_config.third_party["fontawesome"]["webfonts_base"],
183+
)
179184

180185
try:
181186
with timed_step("Downloading FontAwesome metadata..."):
182187
metadata = optimizer.fetch_icon_metadata()
183188
except (urllib.error.URLError, ValueError, OSError) as error:
184189
print_warning(f"Warning: Could not fetch FontAwesome metadata: {error}")
185190
print_warning("Falling back to full FontAwesome CDN stylesheet.")
186-
self.fontawesome_css_url = FONTAWESOME_CDN_CSS_URL
191+
self.fontawesome_css_url = self.site_config.third_party["fontawesome"][
192+
"css_url"
193+
]
187194
return None
188195

189196
self.fontawesome_css_url = (

src/blogmore/generator/context.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from blogmore import __version__
88
from blogmore.clean_url import make_url_clean
9-
from blogmore.fontawesome import FONTAWESOME_CDN_BRANDS_WOFF2_URL
109
from blogmore.generator.constants import (
1110
ARCHIVE_CSS_FILENAME,
1211
BUNDLE_CSS_FILENAME,
@@ -264,7 +263,14 @@ def get_global_context(self) -> dict[str, Any]:
264263
"theme_js_content": self.theme_js_content,
265264
"fontawesome_is_bundled": self.fontawesome_is_bundled,
266265
"fontawesome_css_url": self.with_cache_bust(self.fontawesome_css_url),
267-
"fontawesome_woff2_url": FONTAWESOME_CDN_BRANDS_WOFF2_URL,
266+
"fontawesome_woff2_url": self.site_config.third_party["fontawesome"][
267+
"woff2_url"
268+
],
269+
"mermaid_script_url": self.site_config.third_party["mermaid"]["script_url"],
270+
"katex_css_url": self.site_config.third_party["katex"]["css_url"],
271+
"katex_js_url": self.site_config.third_party["katex"]["js_url"],
272+
"mathjax_js_url": self.site_config.third_party["mathjax"]["js_url"],
273+
"force_graph_js_url": self.site_config.third_party["force_graph"]["js_url"],
268274
"styles_css_url": self.get_asset_url(
269275
CSS_FILENAME, self.site_config.minify_css
270276
),

0 commit comments

Comments
 (0)