all: start publishing front-end code incl chives-image
- ID
6321762- date
2026-07-18 20:25:49+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
cefb712- message
all: start publishing front-end code incl chives-image * Move all the Python code into `python/` and `tests/python/` * Add the first JavaScript component in `javascript/`, and CLI commands to install and update JavaScript * Add automated tests for JavaScript components * Add a static site test that JavaScript components are up-to-date- changed files
30 files, 914 additions, 20 deletionsCHANGELOG.mdREADME.mdjavascript/chives-image.jspyproject.tomlpython/chives/__init__.pypython/chives/browser_fixtures.pypython/chives/cli.pypython/chives/dates.pypython/chives/fetch.pypython/chives/javascript.pypython/chives/media.pypython/chives/py.typedpython/chives/smartypants.pypython/chives/static_site_tests.pypython/chives/text.pypython/chives/urls.pyscripts/run_chives_tests.shtests/fixtures/media/Portrait_0.jpgtests/javascript/test_chives_image.pytests/python/test_browser_fixtures.pytests/python/test_cassettes.pytests/python/test_cli.pytests/python/test_dates.pytests/python/test_fetch.pytests/python/test_javascript.pytests/python/test_media.pytests/python/test_static_site_tests.pytests/python/test_text.pytests/python/test_urls.pytests/test_versions.py
Changed files
CHANGELOG.md (6291 → 6668)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 701f066..42600e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# CHANGELOG
+## v48 - 2026-07-19
+
+Start sharing front-end code between my projects.
+
+This release introduces a `<chives-image>` web component that renders an instance of `ImageEntity` created by `chives.media`.
+This standardises common image rendering across my static sites.
+
+This includes all the infrastructure for publishing JavaScript.
+Subsequent releases will add more front-end code.
+
## v47 - 2026-07-17
Fix a bug in the byte order mark handling in v47.
@@ -21,7 +31,7 @@ Specifically, when converting those files to VTT, the caption number at the star
## v44 - 2026-07-13
Improve support for VTT subtitles (which work with the HTML5-native `<video>` element) rather than SRT:
-
+
* Add a function `media.convert_srt_to_vtt` to convert SRT subtitles to VTT.
* Add an `srt-to-vtt` CLI command to convert SRT subtitles to VTT.
* Add a test in `StaticSiteTestSuite` that checks there are no SRT subtitles.
README.md (2999 → 3995)
diff --git a/README.md b/README.md
index 75b11a1..47a78e4 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,19 @@ This package bundles the common, reusable code I share across multiple archives
## Features
-Here's what chives includes:
+This repo is a dual-language toolkit with utilities for managing the metadata and rendering the front-end.
+
+The Python library handles:
- **Media inspection and processing:** get information like image dimensions and video duration, and convert subtitle files.
- **Metadata validation:** ensure high-quality metadata by verifying that files in metadata actually exist on disk, and enforcing consistent timestamp formats.
- **Static site testing:** pytest fixtures to check that static sites can be loaded without console or network errors.
- **HTTP and date utilities:** standardised helpers for downloading web pages (for bookmarking/archiving) and handling common date patterns.
+The JavaScript components handle front-end UI, including:
+
+- **Image rendering:** render the image entities created by the Python library.
+
## Design goals
My media archives are saved on my personal computer and I want them to last for years (ideally decades), which influences the design of this library:
@@ -43,11 +49,37 @@ I've made this repository public because it's convenient for my deployment and t
Because this is a personal utility library, I recommend you **copy the code**: duplicate the relevant functions and tests into your codebase (with a link back to this repo for attribution!).
Having your own copy will protect you from disruption caused by my breaking changes.
-If you really want the full package, you can install it from PyPI (but pin the version):
+* If you want the Python package, you can install it from PyPI (but pin the version):
+
+ ```console
+ $ pip install alexwlchan-chives==42
+ ```
+
+* If you want the JavaScript files, you have to add them as files in your project.
+
+ If you have the Python package installed, you can see a list of available files and install them with the Chives CLI:
+
+ ```console
+ $ chives-list-js
+ available 'chives-*.js' files:
+ chives-image.js
+
+ $ chives-write-js chives-image.js
+ chives-image.js
+ ```
+
+ This saves the `chives-image.js` to your current directory.
+
+ You can get the latest JS versions by running:
+
+ ```console
+ $ chives-update-js
+ Updating chives-image.js
+ Updating static/chives-image.js
+ Successfully updated 1 JavaScript component file(s)!
+ ```
-```console
-$ pip install alexwlchan-chives==42
-```
+ The JavaScript files are not available in npm or other package repositories.
For detailed usage instructions, check the docstrings inside the individual functions.
javascript/chives-image.js (0 → 6643)
diff --git a/javascript/chives-image.js b/javascript/chives-image.js
new file mode 100644
index 0000000..c1540d0
--- /dev/null
+++ b/javascript/chives-image.js
@@ -0,0 +1,240 @@
+// javascript/chives-image.js
+// chives version: 48
+
+// ChivesImage provides a <chives-image> component that renders
+// an ImageEntity on a web page.
+//
+// The image is lazy-loaded and wrapped in a link pointing to a URL
+// of your choice.
+//
+// Example usage:
+//
+// const elem = document.createElement("chives-image");
+//
+// elem.href = props.path;
+// elem.imageEntity = props;
+// elem.variant = "thumbnail";
+//
+class ChivesImage extends HTMLElement {
+ static get observedAttributes() {
+ return [
+ // Where this image should link to, if anywhere.
+ "href",
+ //
+ // Either "full" or "thumbnail". If you select "thumbnail" but the
+ // image doesn't have a thumbnail_path set, the full image is used.
+ // Defaults to "full".
+ "variant",
+ //
+ // Fields from ImageEntity
+ "path",
+ "thumbnail-path",
+ "width",
+ "height",
+ "tint-colour",
+ "alt-text",
+ "has-transparency",
+ ];
+ }
+
+ // href allows you to set the target href directly from JavaScript.
+ //
+ // For example:
+ //
+ // const elem = document.createElement("chives-image");
+ // elem.href = "https://example.com";
+ //
+ set href(url) {
+ this.setAttribute("href", url);
+ }
+
+ // variant allows you to set the variant directly from JavaScript.
+ //
+ // For example:
+ //
+ // const elem = document.createElement("chives-image");
+ // elem.variant = "thumbnail";
+ //
+ set variant(v) {
+ if (v !== "full" && v !== "thumbnail") {
+ console.warn(`Unrecognised variant: ${v}, want 'full' or 'thumbnail'`);
+ }
+ this.setAttribute("variant", v);
+ }
+
+ // imageEntity allows you to pass an ImageEntity directly from JavaScript.
+ //
+ // For example:
+ //
+ // const elem = document.createElement("chives-image");
+ // elem.imageEntity = imageEntity;
+ //
+ set imageEntity(entity) {
+ this.setAttribute("path", entity.path);
+ this.setAttribute("width", entity.width);
+ this.setAttribute("height", entity.height);
+ this.setAttribute("tint-colour", entity.tint_colour);
+
+ if (typeof entity.thumbnail_path === "string") {
+ this.setAttribute("thumbnail-path", entity.thumbnail_path);
+ }
+
+ if (typeof entity.alt_text === "string") {
+ this.setAttribute("alt-text", entity.alt_text);
+ }
+
+ if (entity.has_transparency) {
+ this.setAttribute("has-transparency", "");
+ } else {
+ this.removeAttribute("has-transparency");
+ }
+ }
+
+ // thumbnailPath allows you to set a custom thumbnail path.
+ //
+ // If you set a custom thumbnail path, the `variant` is automatically
+ // set to `thumbnail` on the assumption you want to render the thumbnail.
+ set thumbnailPath(path) {
+ this.setAttribute("thumbnail-path", path);
+ this.setAttribute("variant", "thumbnail");
+ }
+
+ // Rather than re-rendering the component on every attribute change
+ // (which will fire seven times when we set imageEntity), batch render
+ // changes together into the next event loop tick.
+ #renderTimeout = null;
+
+ connectedCallback() {
+ this.scheduleRender();
+ }
+
+ attributeChangedCallback() {
+ this.scheduleRender();
+ }
+
+ scheduleRender() {
+ if (this.#renderTimeout) {
+ cancelAnimationFrame(this.#renderTimeout);
+ }
+ this.#renderTimeout = requestAnimationFrame(() => {
+ this.render();
+ });
+ }
+
+ // Render the component.
+ render() {
+ // Gather properties from attributes
+ const href = this.getAttribute("href") || "";
+
+ const variant = this.getAttribute("variant") || "full";
+
+ const path = this.getAttribute("path");
+ const thumbnailPath = this.getAttribute("thumbnail-path");
+ const width = this.getAttribute("width");
+ const height = this.getAttribute("height");
+ const tintColour = this.getAttribute("tint-colour");
+ const altText = this.getAttribute("alt-text") || "";
+ const hasTransparency = this.hasAttribute("has-transparency");
+
+ // Decide which variant of the image will be displayed.
+ const displayPath =
+ variant === "full"
+ ? path
+ : typeof thumbnailPath === 'string'
+ ? thumbnailPath
+ : path;
+
+ // Choose which wrapper element to use. Use a link if there's
+ // an href, otherwise a wrapper div.
+ const wrapper = href
+ ? document.createElement("a")
+ : document.createElement("div");
+
+ if (href) {
+ wrapper.href = href;
+ }
+
+ // Add CSS classes and properties to the wrapper element.
+ wrapper.classList.add("chives_image");
+ if (hasTransparency) {
+ wrapper.classList.add("has_transparency");
+ }
+
+ wrapper.style.setProperty("--width", width);
+ wrapper.style.setProperty("--height", height);
+ wrapper.style.setProperty("--tint-colour", tintColour);
+
+ if (typeof displayPath !== 'string') {
+ console.log(displayPath);
+ }
+
+ // Add a data-orientation property to the wrapper element.
+ const widthNum = parseFloat(width);
+ const heightNum = parseFloat(height);
+
+ if (!isNaN(widthNum) && !isNaN(heightNum)) {
+ let orientation = "square";
+ if (widthNum > heightNum) {
+ orientation = "landscape";
+ } else if (widthNum < heightNum) {
+ orientation = "portrait";
+ }
+ wrapper.setAttribute("data-orientation", orientation);
+ }
+
+ // Build the appropriate media element.
+ if (displayPath.endsWith(".mp4")) {
+ const video = document.createElement("video");
+ video.src = displayPath;
+ video.loading = "lazy";
+ video.autoplay = true;
+ video.loop = true;
+ video.muted = true;
+ video.playsInline = true;
+ wrapper.appendChild(video);
+ } else {
+ const img = document.createElement("img");
+ img.src = displayPath;
+ img.loading = "lazy";
+
+ if (altText) {
+ img.alt = altText;
+ }
+
+ wrapper.appendChild(img);
+ }
+
+ this.replaceChildren(wrapper);
+ }
+}
+
+// Register global styles for the <chives-image> element.
+if (typeof document !== "undefined" &&
+ !document.getElementById("chives-image-styles")) {
+ const style = document.createElement("style");
+ style.id = "chives-image-styles";
+ style.textContent = `
+ .chives_image {
+ display: block;
+ aspect-ratio: var(--width) / var(--height);
+ background: var(--tint-colour);
+
+ &.has_transparency {
+ background: none;
+ }
+
+ img, video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+// Register the custom HTML tag <chives-image>
+if (!customElements.get("chives-image")) {
+ customElements.define("chives-image", ChivesImage);
+}
pyproject.toml (1579 → 1958)
diff --git a/pyproject.toml b/pyproject.toml
index be6ff22..0d8b540 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,16 @@
[build-system]
-requires = [
- "setuptools >= 65",
-]
-build-backend = "setuptools.build_meta"
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch]
+# Read the version info dynamically from my `__init__.py` file
+version.path = "python/chives/__init__.py"
+
+# Look in `python/chives` for my source code
+build.targets.wheel.packages = ["python/chives"]
+
+# Inject my external JavaScript components into the output archives
+build.targets.wheel.force-include = { "javascript" = "chives/javascript" }
[project]
name = "alexwlchan-chives"
@@ -31,21 +39,18 @@ static_site_tests = ["pytest"]
urls = ["certifi"]
[project.scripts]
+chives-list-js = "chives.cli:list_chives_js"
+chives-update-js = "chives.cli:update_js_components_cli"
+chives-write-js = "chives.cli:write_chives_js"
srt-to-vtt = "chives.cli:convert_srt_to_vtt_cli"
[project.urls]
"Homepage" = "https://alexwlchan.net/projects/chives/"
"Changelog" = "https://alexwlchan.net/projects/chives/releases/"
-[tool.setuptools.dynamic]
-version = {attr = "chives.__version__"}
-
-[tool.setuptools.packages.find]
-where = ["src"]
-
[tool.coverage.run]
branch = true
-source = ["chives", "tests"]
+source = ["python/chives", "tests"]
concurrency = ["greenlet"]
[tool.coverage.report]
src/chives/__init__.py (391) → python/chives/__init__.py (391)
diff --git a/src/chives/__init__.py b/python/chives/__init__.py
similarity index 90%
rename from src/chives/__init__.py
rename to python/chives/__init__.py
index fe49ddd..cf48440 100644
--- a/src/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
"""
-__version__ = "47"
+__version__ = "48"
src/chives/browser_fixtures.py (3361) → python/chives/browser_fixtures.py (3757)
diff --git a/src/chives/browser_fixtures.py b/python/chives/browser_fixtures.py
similarity index 94%
rename from src/chives/browser_fixtures.py
rename to python/chives/browser_fixtures.py
index 0141729..012e0a6 100644
--- a/src/chives/browser_fixtures.py
+++ b/python/chives/browser_fixtures.py
@@ -56,7 +56,16 @@ def file_uri(p: Path | str) -> str:
return pathname2url(absolute_path, add_scheme=True)
-@pytest.fixture(scope="session")
+# In regular test suites, a session-scoped fixture is fine, and means
+# we only need to start the browser once.
+#
+# For the chives test suite, we use this fixture from the Python tests
+# and inside a Pytester test suite, and a session-scoped fixture gives
+# errors about using the Playwright Async API.
+_browser_scope = "function" if os.environ.get("CHIVES_TEST") == "true" else "session"
+
+
+@pytest.fixture(scope=_browser_scope)
def browser() -> Iterator[Browser]:
"""
Launch an instance of WebKit for testing.
src/chives/cli.py (763) → python/chives/cli.py (2188)
diff --git a/src/chives/cli.py b/python/chives/cli.py
similarity index 54%
rename from src/chives/cli.py
rename to python/chives/cli.py
index c9c680f..4f730ca 100644
--- a/src/chives/cli.py
+++ b/python/chives/cli.py
@@ -2,10 +2,12 @@
Command-line scripts for running common Chives actions.
"""
+from importlib import resources
import os
from pathlib import Path
import sys
+from chives.javascript import update_js_components
from chives.media import convert_srt_to_vtt
@@ -25,3 +27,44 @@ def convert_srt_to_vtt_cli() -> None: # pragma: no cover
for p in paths:
vtt_path = convert_srt_to_vtt(srt_path=Path(p))
print(vtt_path)
+
+
+def update_js_components_cli() -> None: # pragma: no cover
+ """
+ Scan the current working directory for 'chives-*.js' files, and
+ overwrite them with the latest versions packaged in the library.
+ """
+ update_js_components(
+ root=Path.cwd(),
+ js_assets=resources.files("chives.javascript") / "javascript",
+ )
+
+
+def list_chives_js() -> None: # pragma: no cover
+ """
+ List the available 'chives-*.js' files.
+ """
+ js_assets = resources.files("chives.javascript") / "javascript"
+
+ print("available 'chives-*.js' files:")
+ print(", ".join(sorted(p.name for p in js_assets.iterdir())))
+
+
+def write_chives_js() -> None: # pragma: no cover
+ """
+ Write the named 'chives-*.js' files to the current directory.
+ """
+ js_assets = resources.files("chives.javascript") / "javascript"
+
+ for filename in sys.argv[1:]:
+ if (js_assets / filename).exists(): # type: ignore
+ try:
+ with open(filename, "x") as out_file:
+ out_file.write((js_assets / filename).read_text())
+ except FileExistsError:
+ sys.exit(
+ f"will not overwrite {filename!r}; do you want chives-update-js?"
+ )
+ print(filename)
+ else:
+ sys.exit(f"unrecognised JS file: {filename!r}")
src/chives/dates.py (2426) → python/chives/dates.py (2426)
diff --git a/src/chives/dates.py b/python/chives/dates.py
similarity index 100%
rename from src/chives/dates.py
rename to python/chives/dates.py
src/chives/fetch.py (3343) → python/chives/fetch.py (3343)
diff --git a/src/chives/fetch.py b/python/chives/fetch.py
similarity index 100%
rename from src/chives/fetch.py
rename to python/chives/fetch.py
python/chives/javascript.py (0 → 2388)
diff --git a/python/chives/javascript.py b/python/chives/javascript.py
new file mode 100644
index 0000000..a79c552
--- /dev/null
+++ b/python/chives/javascript.py
@@ -0,0 +1,82 @@
+"""
+Helper functions for working with the JavaScript components.
+"""
+
+import glob
+from importlib.resources.abc import Traversable
+import os
+from pathlib import Path
+import re
+
+from chives import __version__
+from chives.text import coloured
+
+
+def get_version(javascript_path: str | Path) -> str:
+ """
+ Return the version in the header of the JavaScript.
+
+ This expects the header of each component looks something like:
+
+ // javascript/chives-image.js
+ // chives version: 48
+
+ """
+ with open(javascript_path) as f:
+ try:
+ header = [next(f).rstrip() for _ in range(2)]
+ except StopIteration:
+ raise ValueError("did not find chives header in file")
+
+ want_name = f"// javascript/{Path(javascript_path).name}"
+ if header[0] != want_name:
+ raise ValueError(f"incorrect header: want {want_name!r}, got {header[0]!r}")
+
+ m = re.match(r"^// chives version: (?P<version>[0-9]+)$", header[1])
+ if m is None:
+ raise ValueError("incorrect header: did not find 'chives version' line")
+
+ return m.group("version")
+
+
+def update_js_components(root: Path, js_assets: Traversable | Path) -> None:
+ """
+ Scan the current working directory for 'chives-*.js' files, and
+ overwrite them with the latest versions packaged in the library.
+ """
+ print(f"Scanning for chives JavaScript components in: {root}")
+
+ target_files = glob.glob("**/chives-*.js", recursive=True, root_dir=root)
+
+ if not target_files:
+ print("No 'chives-*.js' files found in the current directory.")
+ return
+
+ updated_count = 0
+
+ for filepath in target_files:
+ filename = os.path.basename(filepath)
+ internal_file = js_assets / filename
+
+ if not internal_file.exists(): # type: ignore
+ print(f"Skipping {filepath}: not found in chives.")
+ continue
+
+ try:
+ if get_version(root / filepath) == __version__:
+ print(f"Skipping {filepath}: already up-to-date.")
+ continue
+ except ValueError:
+ pass
+
+ print(f"Updating {coloured(filepath, 'blue')}")
+
+ Path(root / filepath).write_text(internal_file.read_text())
+ updated_count += 1
+
+ print(
+ coloured(
+ f"Successfully updated {updated_count} JavaScript component file(s)!",
+ "green",
+ )
+ )
src/chives/media.py (11912) → python/chives/media.py (11912)
diff --git a/src/chives/media.py b/python/chives/media.py
similarity index 100%
rename from src/chives/media.py
rename to python/chives/media.py
src/chives/py.typed (0) → python/chives/py.typed (0)
diff --git a/src/chives/py.typed b/python/chives/py.typed
similarity index 100%
rename from src/chives/py.typed
rename to python/chives/py.typed
src/chives/smartypants.py (10488) → python/chives/smartypants.py (10488)
diff --git a/src/chives/smartypants.py b/python/chives/smartypants.py
similarity index 100%
rename from src/chives/smartypants.py
rename to python/chives/smartypants.py
src/chives/static_site_tests.py (8165) → python/chives/static_site_tests.py (8868)
diff --git a/src/chives/static_site_tests.py b/python/chives/static_site_tests.py
similarity index 95%
rename from src/chives/static_site_tests.py
rename to python/chives/static_site_tests.py
index ba1d107..cb93fac 100644
--- a/src/chives/static_site_tests.py
+++ b/python/chives/static_site_tests.py
@@ -12,10 +12,12 @@ import itertools
import os
from pathlib import Path
import subprocess
+import sys
from typing import TypeVar
import pytest
+from chives import __version__, javascript
from chives.dates import date_matches_any_format, find_all_dates
from chives.media import is_av1_video
from chives.urls import is_url_safe
@@ -257,3 +259,24 @@ class StaticSiteTestSuite[M](ABC):
]
assert similar_tags == [], f"Found similar tags: {similar_tags}"
+
+ def test_chives_js_is_up_to_date(self, site_root: Path) -> None:
+ """
+ All the chives-*.js JavaScript components are up-to-date.
+ """
+ bad_versions = {
+ p
+ for p in glob.glob(f"{site_root}/**/chives-*.js", recursive=True)
+ if javascript.get_version(p) != __version__
+ }
+
+ if bad_versions != set():
+ print(
+ "You have outdated JavaScript dependencies.\n"
+ "To update them, run:\n"
+ "\n"
+ " chives-update-js\n",
+ file=sys.stderr,
+ )
+
+ assert bad_versions == set()
src/chives/text.py (789) → python/chives/text.py (789)
diff --git a/src/chives/text.py b/python/chives/text.py
similarity index 100%
rename from src/chives/text.py
rename to python/chives/text.py
src/chives/urls.py (4628) → python/chives/urls.py (4628)
diff --git a/src/chives/urls.py b/python/chives/urls.py
similarity index 100%
rename from src/chives/urls.py
rename to python/chives/urls.py
scripts/run_chives_tests.sh (708 → 736)
diff --git a/scripts/run_chives_tests.sh b/scripts/run_chives_tests.sh
index 1130ad5..bc299a3 100755
--- a/scripts/run_chives_tests.sh
+++ b/scripts/run_chives_tests.sh
@@ -28,8 +28,10 @@ then
run_command 'git diff --exit-code'
fi
+export CHIVES_TEST=true
+
run_command 'ruff check'
-run_command 'ty check src tests'
+run_command 'ty check python tests'
run_command 'uv pip install --quiet -e .'
run_command "python3 -m coverage run -m pytest -q tests"
report_coverage
tests/fixtures/media/Portrait_0.jpg (0 → 248531)
diff --git a/tests/fixtures/media/Portrait_0.jpg b/tests/fixtures/media/Portrait_0.jpg
new file mode 100644
index 0000000..aa9632e
Binary files /dev/null and b/tests/fixtures/media/Portrait_0.jpg differ
tests/javascript/test_chives_image.py (0 → 7026)
diff --git a/tests/javascript/test_chives_image.py b/tests/javascript/test_chives_image.py
new file mode 100644
index 0000000..e083e51
--- /dev/null
+++ b/tests/javascript/test_chives_image.py
@@ -0,0 +1,247 @@
+"""
+Tests for the <chives-image> web component.
+"""
+
+import json
+from pathlib import Path
+
+from playwright.sync_api import expect, Page
+import pytest
+
+from chives.media import create_image_entity
+
+
+@pytest.fixture(autouse=True)
+def setup_chives_image_component(page: Page) -> None:
+ """
+ Intercept the page, open a blank state, and load the chives-image
+ component.
+ """
+ page.goto("about:blank")
+
+ with open("javascript/chives-image.js") as f:
+ component_js = f.read()
+
+ page.evaluate(component_js)
+
+
+def test_render_basic_image(page: Page) -> None:
+ """
+ The <chives-image> component can render a basic image entity.
+ """
+ e = create_image_entity(path="tests/fixtures/media/470906.png")
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("src") == "tests/fixtures/media/470906.png"
+ assert (img_elem).get_attribute("alt") is None
+
+
+def test_render_thumbnail(page: Page, tmp_path: Path) -> None:
+ """
+ If you select the `thumbnail` variant, the <chives-image> component
+ uses the thumbnail URL.
+ """
+ e = create_image_entity(
+ path="tests/fixtures/media/470906.png",
+ thumbnail_config={
+ "out_dir": tmp_path,
+ "width": 100,
+ },
+ )
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ elem.variant = 'thumbnail';
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("src") == e["thumbnail_path"]
+
+
+def test_skips_thumbnail_if_unavailable(page: Page) -> None:
+ """
+ If you select the `thumbnail` variant but there's no thumbnail path
+ available, the component falls back to the full-sized path.
+ """
+ e = create_image_entity(path="tests/fixtures/media/470906.png")
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ elem.variant = 'thumbnail';
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("src") == "tests/fixtures/media/470906.png"
+
+
+def test_set_thumbnail_path(page: Page) -> None:
+ """
+ If you set an explicit thumbnailPath, the <chives-image> component
+ uses the thumbnail URL.
+ """
+ e = create_image_entity(path="tests/fixtures/media/470906.png")
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ elem.thumbnailPath = 'https://example.com/thumbnail.png';
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("src") == "https://example.com/thumbnail.png"
+
+
+def test_set_href(page: Page) -> None:
+ """
+ If you set an href, the <chives-image> component links to that URL.
+ """
+ e = create_image_entity(path="tests/fixtures/media/470906.png")
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ elem.href = 'https://example.com/';
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ a_elem = chives_elem.locator("a")
+ expect(a_elem).to_be_visible()
+ assert (a_elem).get_attribute("href") == "https://example.com/"
+
+
+def test_sets_alt_text(page: Page) -> None:
+ """
+ If the image entity has alt text, it's added as an `alt` attribute
+ on the `<img>` element.
+ """
+ e = create_image_entity(
+ path="tests/fixtures/media/470906.png", alt_text="Some coloured red squares"
+ )
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("alt") == "Some coloured red squares"
+
+
+@pytest.mark.parametrize(
+ "path, orientation",
+ [
+ ("tests/fixtures/media/470906.png", "square"),
+ ("tests/fixtures/media/Landscape_0.jpg", "landscape"),
+ ("tests/fixtures/media/Portrait_0.jpg", "portrait"),
+ ],
+)
+def test_sets_orientation(page: Page, path: str, orientation: str) -> None:
+ """
+ The <chives-image> element sets a data-orientation attribute.
+ """
+ e = create_image_entity(path)
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ wrapper_elem = chives_elem.locator("div")
+ expect(wrapper_elem).to_be_visible()
+ assert (wrapper_elem).get_attribute("data-orientation") == orientation
+
+
+def test_animated_gif(page: Page) -> None:
+ """
+ The <chives-image> element can render an animated GIF.
+ """
+ e = create_image_entity("tests/fixtures/media/electric_field.gif")
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ img_elem = chives_elem.locator("img")
+ expect(img_elem).to_be_visible()
+ assert (img_elem).get_attribute("src") == e["path"]
+
+
+def test_animated_gif_with_video_thumbnail(page: Page, tmp_path: Path) -> None:
+ """
+ The <chives-image> element can render an animated GIF which has
+ an MP4 thumbnail.
+ """
+ e = create_image_entity(
+ "tests/fixtures/media/electric_field.gif",
+ thumbnail_config={
+ "out_dir": tmp_path,
+ "width": 25,
+ },
+ )
+ page.evaluate(
+ """
+ const elem = document.createElement("chives-image");
+ elem.imageEntity = %s;
+ elem.variant = 'thumbnail';
+ document.body.appendChild(elem);
+ """
+ % json.dumps(e),
+ )
+
+ chives_elem = page.locator("chives-image")
+
+ video_elem = chives_elem.locator("video")
+ expect(video_elem).to_be_visible()
+ assert (video_elem).get_attribute("src") == e["thumbnail_path"]
tests/test_browser_fixtures.py (5258) → tests/python/test_browser_fixtures.py (5258)
diff --git a/tests/test_browser_fixtures.py b/tests/python/test_browser_fixtures.py
similarity index 100%
rename from tests/test_browser_fixtures.py
rename to tests/python/test_browser_fixtures.py
tests/test_cassettes.py (3304) → tests/python/test_cassettes.py (3304)
diff --git a/tests/test_cassettes.py b/tests/python/test_cassettes.py
similarity index 100%
rename from tests/test_cassettes.py
rename to tests/python/test_cassettes.py
tests/test_cli.py (1499) → tests/python/test_cli.py (1499)
diff --git a/tests/test_cli.py b/tests/python/test_cli.py
similarity index 100%
rename from tests/test_cli.py
rename to tests/python/test_cli.py
tests/test_dates.py (2110) → tests/python/test_dates.py (2110)
diff --git a/tests/test_dates.py b/tests/python/test_dates.py
similarity index 100%
rename from tests/test_dates.py
rename to tests/python/test_dates.py
tests/test_fetch.py (6515) → tests/python/test_fetch.py (6515)
diff --git a/tests/test_fetch.py b/tests/python/test_fetch.py
similarity index 100%
rename from tests/test_fetch.py
rename to tests/python/test_fetch.py
tests/python/test_javascript.py (0 → 3512)
diff --git a/tests/python/test_javascript.py b/tests/python/test_javascript.py
new file mode 100644
index 0000000..a92266b
--- /dev/null
+++ b/tests/python/test_javascript.py
@@ -0,0 +1,106 @@
+"""
+Tests for `chives.javascript`.
+"""
+
+from pathlib import Path
+import subprocess
+
+import pytest
+
+from chives import __version__, javascript
+
+
+@pytest.mark.parametrize(
+ "contents, expect_error",
+ [
+ ("", "did not find chives header in file"),
+ ("// javascript/chives-example.js\n", "did not find chives header in file"),
+ (
+ "// javascript/chives-wrongname.js\n// chives version: 1\n",
+ "incorrect header",
+ ),
+ (
+ "// javascript/chives-example.js\n// bad version info\n",
+ "incorrect header: did not find 'chives version' line",
+ ),
+ ],
+)
+def test_malformed_header(tmp_path: Path, contents: str, expect_error: str) -> None:
+ """
+ The version parser returns meaningful errors.
+ """
+ (tmp_path / "chives-example.js").write_text(contents)
+
+ with pytest.raises(ValueError, match=expect_error):
+ javascript.get_version(tmp_path / "chives-example.js")
+
+
+git_root_cmd = ["git", "rev-parse", "--show-toplevel"]
+GIT_ROOT = subprocess.check_output(git_root_cmd, text=True).strip()
+JS_DIR = Path(GIT_ROOT) / "javascript"
+
+
+class TestUpdateJsComponents:
+ """
+ Tests for the `chives-update-js` command.
+ """
+
+ def test_no_js_files(self, tmp_path: Path) -> None:
+ """
+ If there are no JS files in the folder, it's a trivial no-op.
+ """
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+
+ def test_updates_matching_js_file(self, tmp_path: Path) -> None:
+ """
+ If there's a JS file in the folder with a matching filename,
+ it's replaced with the current version.
+ """
+ (tmp_path / "static").mkdir()
+ (tmp_path / "static/chives-image.js").write_text("")
+
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+
+ assert (
+ javascript.get_version(tmp_path / "static/chives-image.js") == __version__
+ )
+
+ def test_updates_outdated_js_file(self, tmp_path: Path) -> None:
+ """
+ If there's a JS file in the folder with a matching filename,
+ it's replaced with the current version.
+ """
+ (tmp_path / "static").mkdir()
+ (tmp_path / "static/chives-image.js").write_text(
+ "// javascript/chives-image.js\n// chives version: 1\n"
+ )
+
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+
+ assert (
+ javascript.get_version(tmp_path / "static/chives-image.js") == __version__
+ )
+
+ def test_ignores_unmatched_js_file(self, tmp_path: Path) -> None:
+ """
+ If there's a JS file that doesn't come from chives, it's left as-is.
+ """
+ js = "function greet() { console.log('Hello world!'); }"
+ (tmp_path / "chives-example.js").write_text(js)
+
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+
+ assert (tmp_path / "chives-example.js").read_text() == js
+
+ def test_ignores_already_up_to_date_file(self, tmp_path: Path) -> None:
+ """
+ Once a JS file is up-to-date, it's not edited again.
+ """
+ (tmp_path / "static").mkdir()
+ (tmp_path / "static/chives-image.js").write_text("")
+
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+ mtime = (tmp_path / "static/chives-image.js").stat().st_mtime
+
+ javascript.update_js_components(root=tmp_path, js_assets=JS_DIR)
+ assert (tmp_path / "static/chives-image.js").stat().st_mtime == mtime
tests/test_media.py (18739) → tests/python/test_media.py (18739)
diff --git a/tests/test_media.py b/tests/python/test_media.py
similarity index 100%
rename from tests/test_media.py
rename to tests/python/test_media.py
tests/test_static_site_tests.py (9666) → tests/python/test_static_site_tests.py (12144)
diff --git a/tests/test_static_site_tests.py b/tests/python/test_static_site_tests.py
similarity index 90%
rename from tests/test_static_site_tests.py
rename to tests/python/test_static_site_tests.py
index f6f5b67..62597ac 100644
--- a/tests/test_static_site_tests.py
+++ b/tests/python/test_static_site_tests.py
@@ -304,3 +304,82 @@ def test_checks_for_similar_tags(
known_similar_tags=known_similar_tags,
)
pytester.runpytest("-k", keyword).assert_outcomes(**expected_outcome)
+
+
+class TestChivesJSisUpToDate:
+ """
+ Tests for the `test_chives_js_is_up_to_date` method.
+ """
+
+ def test_no_js_is_passing(self, pytester: Pytester) -> None:
+ """
+ If the site doesn't have any JavaScript files, it's trivially okay.
+ """
+ create_pyfile(pytester)
+
+ keyword = "test_chives_js_is_up_to_date"
+
+ pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
+
+ def test_correct_js_is_passing(self, pytester: Pytester, site_root: Path) -> None:
+ """
+ If the site has up-to-date JavaScript files, it's okay.
+ """
+ shutil.copyfile(
+ GIT_ROOT / "javascript/chives-image.js", site_root / "chives-image.js"
+ )
+
+ (site_root / "static").mkdir()
+ shutil.copyfile(
+ GIT_ROOT / "javascript/chives-image.js",
+ site_root / "static/chives-image.js",
+ )
+
+ create_pyfile(pytester, site_root=site_root)
+
+ keyword = "test_chives_js_is_up_to_date"
+
+ pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
+
+ @pytest.mark.parametrize(
+ "filename, header",
+ [
+ (
+ # Wrong filename
+ "chives-example.js",
+ "// javascript/chives-wrongname.js\n// chives version: 48",
+ ),
+ (
+ # Missing javascript/ prefixes
+ "chives-example.js",
+ "// chives-example.js\n// chives version: 48",
+ ),
+ (
+ # Missing version line
+ "chives-example.js",
+ "// javascript/chives-example.js\n",
+ ),
+ (
+ # Old version line
+ "chives-example.js",
+ "// javascript/chives-example.js\n// chives version: 1",
+ ),
+ ],
+ )
+ def test_incorrect_js_is_error(
+ self, pytester: Pytester, site_root: Path, filename: str, header: str
+ ) -> None:
+ """
+ If the site has an old chives-js file or a file with an incorrect
+ header, it fails this test.
+ """
+ # Create a chives-*.js file in the site root with a bad header.
+ (site_root / filename).write_text(
+ header + "\n\n" + "function greet() { console.log('hello world'); }"
+ )
+
+ create_pyfile(pytester, site_root=site_root)
+
+ keyword = "test_chives_js_is_up_to_date"
+
+ pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
tests/test_text.py (2299) → tests/python/test_text.py (2299)
diff --git a/tests/test_text.py b/tests/python/test_text.py
similarity index 100%
rename from tests/test_text.py
rename to tests/python/test_text.py
tests/test_urls.py (5940) → tests/python/test_urls.py (5940)
diff --git a/tests/test_urls.py b/tests/python/test_urls.py
similarity index 100%
rename from tests/test_urls.py
rename to tests/python/test_urls.py
tests/test_versions.py (0 → 391)
diff --git a/tests/test_versions.py b/tests/test_versions.py
new file mode 100644
index 0000000..f8dfe0b
--- /dev/null
+++ b/tests/test_versions.py
@@ -0,0 +1,16 @@
+"""
+Check that the JavaScript files and Python versions are in sync.
+"""
+
+import glob
+
+from chives import __version__
+from chives.javascript import get_version
+
+
+def test_versions_in_sync() -> None:
+ """
+ The JavaScript components have the same version as the Python module.
+ """
+ for filepath in glob.glob("javascript/*.js"):
+ assert get_version(filepath) == __version__