diff --git a/blurry/__init__.py b/blurry/__init__.py
index a3fedc6..7cbebe1 100644
--- a/blurry/__init__.py
+++ b/blurry/__init__.py
@@ -25,6 +25,7 @@
from blurry.markdown import convert_markdown_file_to_html
from blurry.open_graph import open_graph_meta_tags
from blurry.plugins import discovered_html_plugins
+from blurry.plugins import discovered_jinja_extensions
from blurry.plugins import discovered_jinja_filter_plugins
from blurry.schema_validation import validate_front_matter_as_schema
from blurry.settings import get_build_directory
@@ -69,6 +70,9 @@ def get_jinja_env():
}
)
),
+ extensions=[
+ jinja_extension.load() for jinja_extension in discovered_jinja_extensions
+ ],
)
for filter_plugin in discovered_jinja_filter_plugins:
try:
diff --git a/blurry/cli.py b/blurry/cli.py
index ef689b1..84e25c3 100644
--- a/blurry/cli.py
+++ b/blurry/cli.py
@@ -2,6 +2,7 @@
from rich.table import Table
from blurry.plugins import discovered_html_plugins
+from blurry.plugins import discovered_jinja_extensions
from blurry.plugins import discovered_jinja_filter_plugins
from blurry.plugins import discovered_markdown_plugins
@@ -27,7 +28,10 @@ def print_plugin_table():
plugin_table.add_row(
"\n".join([p.name for p in discovered_markdown_plugins]),
"\n".join([p.name for p in discovered_html_plugins]),
- "\n".join([p.name for p in discovered_jinja_filter_plugins]),
+ "\n".join(
+ [p.name for p in discovered_jinja_filter_plugins]
+ + [p.name for p in discovered_jinja_extensions]
+ ),
)
console.print(plugin_table)
diff --git a/blurry/plugins/__init__.py b/blurry/plugins/__init__.py
index d17b6b1..cb26555 100644
--- a/blurry/plugins/__init__.py
+++ b/blurry/plugins/__init__.py
@@ -3,3 +3,4 @@
discovered_markdown_plugins = entry_points(group="blurry.markdown_plugins")
discovered_html_plugins = entry_points(group="blurry.html_plugins")
discovered_jinja_filter_plugins = entry_points(group="blurry.jinja_filter_plugins")
+discovered_jinja_extensions = entry_points(group="blurry.jinja_extensions")
diff --git a/blurry/plugins/jinja_plugins/__init__.py b/blurry/plugins/jinja_plugins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blurry/plugins/jinja_plugins/blurry_image_extension.py b/blurry/plugins/jinja_plugins/blurry_image_extension.py
new file mode 100644
index 0000000..041716f
--- /dev/null
+++ b/blurry/plugins/jinja_plugins/blurry_image_extension.py
@@ -0,0 +1,75 @@
+import mimetypes
+from pathlib import Path
+from urllib.parse import urlparse
+
+from jinja2_simple_tags import StandaloneTag
+from rich.console import Console
+from wand.exceptions import BlobError
+from wand.image import Image
+
+from blurry.images import add_image_width_to_path
+from blurry.settings import get_build_directory
+from blurry.utils import build_path_to_url
+
+warning_console = Console(stderr=True, style="bold yellow")
+
+
+class BlurryImage(StandaloneTag):
+ safe_output = True
+ tags = {"blurry_image"}
+
+ def render(self, *args, **kwargs):
+ (image_url, width) = args
+ image_content_path: str = "." + urlparse(image_url).path
+ image_path = get_build_directory() / image_content_path
+
+ try:
+ with Image(filename=str(image_path)) as image:
+ image_width = image.width
+ image_height = image.height
+ image_mimetype = image.mimetype
+ except BlobError:
+ warning_console.print(f"Could not find image: {image_path}")
+ return ""
+
+ attributes = {
+ "width": image_width,
+ "height": image_height,
+ }
+ for attribute_key in kwargs:
+ if attribute_key in ["width", "height"]:
+ warning_console.print(
+ f"blurry_image: Received {attribute_key} in template {self.template} but this attribute is dynamic. Skipping."
+ )
+ continue
+ attributes[attribute_key] = kwargs.get(attribute_key)
+
+ if width:
+ image_path = add_image_width_to_path(image_path, width)
+
+ if image_mimetype in [
+ mimetypes.types_map[".jpg"],
+ mimetypes.types_map[".png"],
+ ]:
+ image_path = Path(str(image_path).replace(image_path.suffix, ".avif"))
+
+ if not image_path.exists():
+ warning_console.print(
+ f"blurry_image: Could not find {image_path}. Skipping."
+ )
+ return ""
+
+ attributes["src"] = build_path_to_url(image_path)
+
+ if "alt" not in attributes:
+ warning_console.print(
+ f"blurry_image: alt attribute missing for image in {self.template}. "
+ "This can negatively affect accessibility. "
+ "Use an empty alt tag if an image is only for show."
+ )
+
+ attributes_str = " ".join(
+ f'{name}="{value}"' for name, value in attributes.items()
+ )
+
+ return f""
diff --git a/docs/content/plugins/write-a-jinja-extension-plugin.md b/docs/content/plugins/write-a-jinja-extension-plugin.md
new file mode 100644
index 0000000..64ff30d
--- /dev/null
+++ b/docs/content/plugins/write-a-jinja-extension-plugin.md
@@ -0,0 +1,30 @@
++++
+"@type" = "WebPage"
+name = "Plugins: write a Jinja extension plugin"
+abstract = "Documentation for Blurry's Jinja extension plugins"
+datePublished = 2024-04-28
++++
+
+# Plugins: write an Jinja extension plugin
+
+Blurry makes it easy to add [custom Jinja extensions](https://jinja.palletsprojects.com/en/3.1.x/extensions/) to your site.
+What is a Jinja extension?
+From the Jinja docs:
+
+> Jinja supports extensions that can add extra filters, tests, globals or even extend the parser. The main motivation of extensions is to move often used code into a reusable class like adding support for internationalization.
+
+With [custom extensions](https://jinja.palletsprojects.com/en/3.1.x/extensions/#module-jinja2.ext) you can add custom tags to Jinja, like Blurry's `{% blurry_image %}` tag.
+
+## Example: `{% blurry_image %}`
+
+This tag finds the optimized version of an image at the specified URL, and optionally of the specified size.
+You can find it in Blurry's source code in `blurry/plugins/jinja_plugins/blurry_image_extension.py`.
+
+Under the hood the extension uses [`jinja2-simple-tags`](https://github.com/dldevinc/jinja2-simple-tags) to simplify the process of writing a custom extension.
+
+To use a custom Jinja extension you've developed, add the appropriate plugin syntax to your project's `pyproject.toml` file:
+
+```toml
+[tool.poetry.plugins."blurry.jinja_extensions"]
+stars = "{{ yourproject.your_extension_file }}:YourExtension"
+```
diff --git a/docs/content/templates/syntax.md b/docs/content/templates/syntax.md
index 150b0fe..9b07fb2 100644
--- a/docs/content/templates/syntax.md
+++ b/docs/content/templates/syntax.md
@@ -3,6 +3,7 @@
name = "Templates: syntax"
abstract = "Documentation for Blurry's template files and Jinja syntax"
datePublished = 2023-04-09
+dateModified = 2023-04-28
image = {contentUrl = "../images/schema.org-logo.png"}
+++
@@ -42,3 +43,30 @@ If your templates require more granularity than the Schema.org types, you can wr
[blurry.template_schema_types]
ContextWebPage = 'WebPage'
```
+
+## Blurry-included plugins
+
+Blurry ships with some plugins to simplify writing templates.
+
+### `{% blurry_image %}`
+
+This extension adds the `{% blurry_image %}` tag to simplify including images reference in [Markdown front matter](../content/markdown.md) in your templates.
+It does a few things:
+
+- Finds the image in your build directory
+- Extracts the images width & height
+- Builds an `
` tag with width, height, and the othwer attributes specified in the tag
+
+#### Examples
+
+Basic example:
+
+```jinja
+{% blurry_image page.thumbnailUrl, alt="Image description" %}
+```
+
+Example with explicit width (image with this width must be present in the build folder):
+
+```jinja
+{% blurry_image page.thumbnailUrl, 250, id="image-id", class="responsive-image", loading="lazy" %}
+```
diff --git a/docs/poetry.lock b/docs/poetry.lock
index 2d64f50..022e451 100644
--- a/docs/poetry.lock
+++ b/docs/poetry.lock
@@ -1,9 +1,10 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.6.0"
description = "Reusable constraint types to use with typing.Annotated"
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -13,8 +14,9 @@ files = [
[[package]]
name = "blurry-cli"
-version = "0.7.0"
+version = "0.7.2"
description = "A Mistune-based static site generator for Python"
+category = "main"
optional = false
python-versions = "^3.10"
files = []
@@ -25,6 +27,7 @@ dpath = "^2.1.6"
ffmpeg-python = "^0.2.0"
htmlmin2 = "^0.1.13"
Jinja2 = "^3.0.0"
+jinja2-simple-tags = "^0.6.1"
livereload = "^2.6.3"
mistune = "^3.0.0rc5"
pydantic2-schemaorg = "^0.1.1"
@@ -43,6 +46,7 @@ url = ".."
name = "blurry-plugin-blur-blurry-name"
version = "0.1.0"
description = "A simple plugin to blur 'Blurry' in the Blurry documentation"
+category = "main"
optional = false
python-versions = "^3.10"
files = []
@@ -62,6 +66,7 @@ resolved_reference = "4255c21c98fe9c24eec00257d613ff7aecc23b66"
name = "cachetools"
version = "5.3.3"
description = "Extensible memoizing collections and decorators"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -73,6 +78,7 @@ files = [
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -87,6 +93,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
+category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
@@ -98,6 +105,7 @@ files = [
name = "dpath"
version = "2.1.6"
description = "Filesystem-like pathing and searching for dictionaries"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -109,6 +117,7 @@ files = [
name = "ffmpeg-python"
version = "0.2.0"
description = "Python bindings for FFmpeg - with complex filtering support"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -126,6 +135,7 @@ dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4
name = "frozendict"
version = "2.4.1"
description = "A simple immutable dictionary"
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -171,6 +181,7 @@ files = [
name = "future"
version = "1.0.0"
description = "Clean single-source support for Python 3 and 2"
+category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -182,6 +193,7 @@ files = [
name = "htmlmin2"
version = "0.1.13"
description = "An HTML Minifier"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -192,6 +204,7 @@ files = [
name = "jinja2"
version = "3.1.3"
description = "A very fast and expressive template engine."
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -205,10 +218,26 @@ MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
+[[package]]
+name = "jinja2-simple-tags"
+version = "0.6.1"
+description = "Base classes for quick-and-easy template tag development"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "jinja2-simple-tags-0.6.1.tar.gz", hash = "sha256:54abf83883dcd13f8fd2ea2c42feeea8418df3640907bd5251dec5e25a6af0e3"},
+ {file = "jinja2_simple_tags-0.6.1-py2.py3-none-any.whl", hash = "sha256:7b7cfa92f6813a1e0f0b61b9efcab60e6793674753e1f784ff270542e80ae20f"},
+]
+
+[package.dependencies]
+Jinja2 = ">=2.10"
+
[[package]]
name = "livereload"
version = "2.6.3"
description = "Python LiveReload is an awesome tool for web developers"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -224,6 +253,7 @@ tornado = {version = "*", markers = "python_version > \"2.7\""}
name = "lxml"
version = "5.2.1"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -395,6 +425,7 @@ source = ["Cython (>=3.0.10)"]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -419,6 +450,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
name = "markupsafe"
version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -488,6 +520,7 @@ files = [
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -499,6 +532,7 @@ files = [
name = "mistune"
version = "3.0.2"
description = "A sane and fast Markdown parser with useful plugins and renderers"
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -510,6 +544,7 @@ files = [
name = "pydantic"
version = "2.6.4"
description = "Data validation using Python type hints"
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -529,6 +564,7 @@ email = ["email-validator (>=2.0.0)"]
name = "pydantic-core"
version = "2.16.3"
description = ""
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -620,6 +656,7 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
name = "pydantic2-schemaorg"
version = "0.1.1"
description = "Pydantic classes for Schema.org"
+category = "main"
optional = false
python-versions = "<4.0,>=3.10"
files = [
@@ -634,6 +671,7 @@ pydantic = ">=2.6.1,<3.0.0"
name = "pygments"
version = "2.17.2"
description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -649,6 +687,7 @@ windows-terminal = ["colorama (>=0.4.6)"]
name = "pyld"
version = "2.0.4"
description = "Python implementation of the JSON-LD API"
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -671,6 +710,7 @@ requests = ["requests"]
name = "rich"
version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
optional = false
python-versions = ">=3.7.0"
files = [
@@ -689,6 +729,7 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
name = "selectolax"
version = "0.3.21"
description = "Fast HTML5 parser with CSS selectors."
+category = "main"
optional = false
python-versions = "*"
files = [
@@ -757,6 +798,7 @@ cython = ["Cython (==0.29.36)"]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
+category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -768,6 +810,7 @@ files = [
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
+category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -779,6 +822,7 @@ files = [
name = "tornado"
version = "6.4"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
+category = "main"
optional = false
python-versions = ">= 3.8"
files = [
@@ -799,6 +843,7 @@ files = [
name = "typer"
version = "0.6.1"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -819,6 +864,7 @@ test = ["black (>=22.3.0,<23.0.0)", "coverage (>=5.2,<6.0)", "isort (>=5.0.6,<6.
name = "typing-extensions"
version = "4.11.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
+category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -830,6 +876,7 @@ files = [
name = "wand"
version = "0.6.13"
description = "Ctypes-based simple MagickWand API binding for Python"
+category = "main"
optional = false
python-versions = "*"
files = [
diff --git a/docs/templates/base.html b/docs/templates/base.html
index fb7cf72..22502da 100644
--- a/docs/templates/base.html
+++ b/docs/templates/base.html
@@ -63,6 +63,7 @@