Skip to main content

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 deletions

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__