diff --git a/README.md b/README.md
index 9d9ac3c..d4d572e 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ available in this package.
import asyncio
from datetime import datetime
+import datastar_py
from datastar_py import ServerSentEventGenerator as SSE, attribute_generator as data
from datastar_py.quart import datastar_response, read_signals
from quart import Quart
@@ -43,7 +44,7 @@ def index():
return f"""
-
+
diff --git a/src/datastar_py/__init__.py b/src/datastar_py/__init__.py
index c041c24..f1c4f58 100644
--- a/src/datastar_py/__init__.py
+++ b/src/datastar_py/__init__.py
@@ -1,13 +1,41 @@
from __future__ import annotations
import json
+import re
from collections.abc import Mapping
from typing import Any
from .attributes import action_generator, attribute_generator
from .sse import SSE_HEADERS, ServerSentEventGenerator
-__all__ = ["SSE_HEADERS", "ServerSentEventGenerator", "action_generator", "attribute_generator"]
+__all__ = [
+ "SSE_HEADERS",
+ "ServerSentEventGenerator",
+ "action_generator",
+ "attribute_generator",
+ "url",
+]
+
+
+def url(version: str = "") -> str:
+ """Return the jsDelivr URL for the Datastar JavaScript module.
+
+ Accepts major, minor, or exact versions with an optional ``v`` prefix.
+ Major, minor, and omitted versions can resolve to the latest release.
+ """
+ if not isinstance(version, str):
+ raise TypeError("version must be a string or None")
+ ref = ""
+ if version != "":
+ if not re.fullmatch(
+ r"v?[0-9]+(?:\.[0-9]+(?:\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)?)?",
+ version.strip(),
+ ):
+ raise ValueError(
+ "version must be a valid jsdelivr compatible string, such as 'v1', '1.0', or '1.0.4'"
+ )
+ ref = f"@v{version.strip().removeprefix('v')}"
+ return f"https://cdn.jsdelivr.net/gh/starfederation/datastar{ref}/bundles/datastar.js"
def _read_signals(
diff --git a/tests/test_url.py b/tests/test_url.py
new file mode 100644
index 0000000..9d9686f
--- /dev/null
+++ b/tests/test_url.py
@@ -0,0 +1,57 @@
+import pytest
+
+import datastar_py
+
+
+@pytest.mark.parametrize(
+ ("version", "ref"),
+ (
+ ("", ""),
+ ("1", "@v1"),
+ ("v1", "@v1"),
+ ("1.0", "@v1.0"),
+ ("v1.0", "@v1.0"),
+ ("1.0.4", "@v1.0.4"),
+ ("v1.0.4", "@v1.0.4"),
+ ("1.0.0-RC.7", "@v1.0.0-RC.7"),
+ ("v1.0.0-RC.7", "@v1.0.0-RC.7"),
+ ),
+)
+def test_url_versions(version, ref):
+ assert datastar_py.url(version) == (
+ f"https://cdn.jsdelivr.net/gh/starfederation/datastar{ref}/bundles/datastar.js"
+ )
+
+
+def test_url_default():
+ assert datastar_py.url() == (
+ "https://cdn.jsdelivr.net/gh/starfederation/datastar/bundles/datastar.js"
+ )
+
+
+@pytest.mark.parametrize(
+ "version",
+ (
+ "v",
+ "v1.",
+ "1..0",
+ "1.0.4.1",
+ "1-RC.7",
+ "1.0-RC.7",
+ "1.0.4-",
+ "1.0.4-RC.",
+ "latest",
+ "v1/something",
+ "v1?x=1",
+ "v1#fragment",
+ ),
+)
+def test_url_rejects_invalid_versions(version):
+ with pytest.raises(ValueError, match="version"):
+ datastar_py.url(version)
+
+
+@pytest.mark.parametrize("version", (1, 1.0, True, b"v1", [], {}))
+def test_url_rejects_invalid_types(version):
+ with pytest.raises(TypeError, match="version must be a string or None"):
+ datastar_py.url(version)