diff --git a/README.md b/README.md
index 8eff78c..b7efc74 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,49 @@ You can now import JavaScript modules in your Django templates:
{% endblock %}
```
+### Private modules
+
+You can also import private modules from your Django app:
+
+```html
+
+{% block content %}
+
+{% endblock %}
+```
+
+To import a private module, prefix the module name with `#`.
+You need to define your private modules in your `package.json` file:
+
+```json
+{
+ "imports": {
+ "#myapp/script": "./myapp/static/js/script.js",
+ // You may use trailing stars to import all files in a directory.
+ "#myapp/*": "./myapp/static/js/*"
+ }
+}
+```
+
+### Testing (with Jest)
+
+You can use the `django_esm` package to test your JavaScript modules with Jest.
+Jest v27.4 and upwards will honor `imports` in your `package.json` file.
+
+Before v27.4 that, you can try to use a custom `moduleNameMapper`, like so:
+
+```js
+// jest.config.js
+module.exports = {
+ // …
+ moduleNameMapper: {
+ '^#(.*)$': '/staticfiles/js/$1' // @todo: remove this with Jest >=29.4
+ },
+}
+```
+
## How it works
Django ESM works via native JavaScript module support in modern browsers.
diff --git a/django_esm/templatetags/esm.py b/django_esm/templatetags/esm.py
index 8f172cf..073cb24 100644
--- a/django_esm/templatetags/esm.py
+++ b/django_esm/templatetags/esm.py
@@ -13,9 +13,11 @@
@register.simple_tag
@functools.lru_cache
def importmap():
+ with (settings.BASE_DIR / "package.json").open() as f:
+ package_json = json.load(f)
return mark_safe( # nosec
json.dumps(
- {"imports": dict(utils.parse_package_json(settings.BASE_DIR))},
+ {"imports": dict(utils.parse_root_package(package_json))},
indent=2 if settings.DEBUG else None,
separators=None if settings.DEBUG else (",", ":"),
)
diff --git a/django_esm/utils.py b/django_esm/utils.py
index b08e43f..e935b5d 100644
--- a/django_esm/utils.py
+++ b/django_esm/utils.py
@@ -1,41 +1,95 @@
import json
from pathlib import Path
+from django.conf import settings
+from django.contrib.staticfiles.finders import get_finders
from django.contrib.staticfiles.storage import staticfiles_storage
+
+def parse_root_package(package_json):
+ """Parse a project main package.json and return a dict of importmap entries."""
+ imports = package_json.get("imports", {})
+ if isinstance(imports, (str, list)):
+ raise ValueError(f"package.imports must be an object, {type(imports)} given")
+
+ for module_name, module in imports.items():
+ if not module_name.startswith("#"):
+ raise ValueError(
+ f"package.imports keys must start with #, {module_name} given"
+ )
+ try:
+ mod = module["default"]
+ except TypeError:
+ mod = module
+ url = mod
+ if mod[0] in [".", "/"]:
+ # local file
+ url = get_static_from_abs_path(settings.BASE_DIR / mod)
+ if mod.endswith("/*"):
+ url = url[:-2] + "/"
+ module_name = module_name[:-1]
+ yield module_name, url
+
+ for dep_name, dep_version in package_json.get("dependencies", {}).items():
+ yield from parse_package_json(settings.BASE_DIR / "node_modules" / dep_name)
+
+
+def get_static_from_abs_path(path: Path):
+ for finder in get_finders():
+ for storage in finder.storages.values():
+ try:
+ rel_path = path.relative_to(Path(storage.location))
+ except ValueError:
+ pass
+ else:
+ return staticfiles_storage.url(str(rel_path))
+ raise ValueError(f"Could not find {path} in staticfiles")
+
+
# There is a long history how ESM is supported in Node.js
# So we implement some fallbacks, see also: https://nodejs.org/api/packages.html#exports
ESM_KEYS = ["exports", "module", "main"]
-def parse_package_json(path: Path = None, node_modules: Path = None):
+def cast_exports(package_json):
+ exports = {}
+ for key in ESM_KEYS:
+ try:
+ exports = package_json[key]
+ except KeyError:
+ continue
+ else:
+ break
+ if not exports:
+ exports = {}
+ elif isinstance(exports, str):
+ exports = {".": exports}
+ elif isinstance(exports, list):
+ exports = {i: i for i in exports}
+ return exports
+
+
+def parse_package_json(path: Path = None):
"""Parse a project main package.json and return a dict of importmap entries."""
- if node_modules is None:
- node_modules = path / "node_modules"
with (path / "package.json").open() as f:
package_json = json.load(f)
name = package_json["name"]
dependencies = package_json.get("dependencies", {})
- if path.is_relative_to(node_modules):
- base_path = node_modules
+ exports = cast_exports(package_json)
+
+ for module_name, module in exports.items():
+ try:
+ mod = module["default"]
+ except TypeError:
+ mod = module
+
+ yield str(Path(name) / module_name), staticfiles_storage.url(
+ str((path / mod).relative_to(settings.BASE_DIR / "node_modules"))
+ )
+
+ if (path / "node_modules").exists():
+ node_modules = path / "node_modules"
else:
- base_path = path
- for key in ESM_KEYS:
- export = package_json.get(key, None)
- if export:
- try:
- for module_name, module in export.items():
- try:
- yield str(Path(name) / module_name), staticfiles_storage.url(
- str((path / module["default"]).relative_to(base_path))
- )
- except TypeError:
- yield str(Path(name) / module_name), staticfiles_storage.url(
- str((path / module).relative_to(base_path))
- )
- except AttributeError:
- yield name, staticfiles_storage.url(
- str((path / export).relative_to(base_path))
- )
+ node_modules = path / "/".join(".." for _ in Path(name).parts)
for dep_name, dep_version in dependencies.items():
- yield from parse_package_json(node_modules / dep_name, node_modules)
+ yield from parse_package_json(node_modules / dep_name)
diff --git a/tests/conftest.py b/tests/conftest.py
index 3e12b37..af02101 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+import json
import subprocess
from pathlib import Path
@@ -8,8 +9,9 @@
@pytest.fixture(scope="session")
def package_json():
- subprocess.check_call(["npm", "install"], cwd=TEST_DIR)
- return TEST_DIR / "package.json"
+ subprocess.check_call(["npm", "install", "--omit=dev"], cwd=TEST_DIR)
+ with (TEST_DIR / "package.json").open() as f:
+ return json.load(f)
@pytest.fixture(scope="session")
diff --git a/tests/node_modules/.package-lock.json b/tests/node_modules/.package-lock.json
index 8d89cd1..527af82 100644
--- a/tests/node_modules/.package-lock.json
+++ b/tests/node_modules/.package-lock.json
@@ -22,9 +22,9 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"node_modules/htmx.org": {
- "version": "1.9.9",
- "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.9.tgz",
- "integrity": "sha512-PDEZU1me7UGLzQk98LyfLvwFgdtn9mrCVMmAxv1/UjshUnxsc+rouu+Ot2QfFZxsY4mBCoOed5nK7m9Nj2Tu7g=="
+ "version": "1.9.10",
+ "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.10.tgz",
+ "integrity": "sha512-UgchasltTCrTuU2DQLom3ohHrBvwr7OqpwyAVJ9VxtNBng4XKkVsqrv0Qr3srqvM9ZNI3f1MmvVQQqK7KW/bTA=="
},
"node_modules/lit": {
"version": "3.1.0",
diff --git a/tests/node_modules/htmx.org/CHANGELOG.md b/tests/node_modules/htmx.org/CHANGELOG.md
index ec47598..8fa3d5f 100644
--- a/tests/node_modules/htmx.org/CHANGELOG.md
+++ b/tests/node_modules/htmx.org/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [1.9.10] - 2023-12-21
+
+* `hx-on*` attributes now support the form `hx-on-`, with a trailing dash, to better support template systems (such as EJS)
+ that do not like double colons in HTML attributes.
+* Added an `htmx.config.triggerSpecsCache` configuration property that can be set to an object to cache the trigger spec parsing
+* Added a `path-params.js` extension for populating request paths with variable values
+* Many smaller bug fixes & improvements
+
## [1.9.9] - 2023-11-21
* Allow CSS selectors with whitespace in attributes like `hx-target` by using parens or curly-braces
diff --git a/tests/node_modules/htmx.org/README.md b/tests/node_modules/htmx.org/README.md
index 81d21bc..a9287d1 100644
--- a/tests/node_modules/htmx.org/README.md
+++ b/tests/node_modules/htmx.org/README.md
@@ -33,7 +33,7 @@ By removing these arbitrary constraints htmx completes HTML as a
## quick start
```html
-
+