Skip to content

Commit e33d638

Browse files
committed
Support nested node_modules
1 parent 95c0d3d commit e33d638

File tree

11 files changed

+191
-40
lines changed

11 files changed

+191
-40
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,49 @@ You can now import JavaScript modules in your Django templates:
6969
{% endblock %}
7070
```
7171

72+
### Private modules
73+
74+
You can also import private modules from your Django app:
75+
76+
```html
77+
<!-- index.html -->
78+
{% block content %}
79+
<script type="module">
80+
import "#myapp/js/my-module.js"
81+
</script>
82+
{% endblock %}
83+
```
84+
85+
To import a private module, prefix the module name with `#`.
86+
You need to define your private modules in your `package.json` file:
87+
88+
```json
89+
{
90+
"imports": {
91+
"#myapp/script": "./myapp/static/js/script.js",
92+
// You may use trailing stars to import all files in a directory.
93+
"#myapp/*": "./myapp/static/js/*"
94+
}
95+
}
96+
```
97+
98+
### Testing (with Jest)
99+
100+
You can use the `django_esm` package to test your JavaScript modules with Jest.
101+
Jest v27.4 and upwards will honor `imports` in your `package.json` file.
102+
103+
Before v27.4 that, you can try to use a custom `moduleNameMapper`, like so:
104+
105+
```js
106+
// jest.config.js
107+
module.exports = {
108+
//
109+
moduleNameMapper: {
110+
'^#(.*)$': '<rootDir>/staticfiles/js/$1' // @todo: remove this with Jest >=29.4
111+
},
112+
}
113+
```
114+
72115
## How it works
73116

74117
Django ESM works via native JavaScript module support in modern browsers.

django_esm/templatetags/esm.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
@register.simple_tag
1414
@functools.lru_cache
1515
def importmap():
16+
with (settings.BASE_DIR / "package.json").open() as f:
17+
package_json = json.load(f)
1618
return mark_safe( # nosec
1719
json.dumps(
18-
{"imports": dict(utils.parse_package_json(settings.BASE_DIR))},
20+
{"imports": dict(utils.parse_root_package(package_json))},
1921
indent=2 if settings.DEBUG else None,
2022
separators=None if settings.DEBUG else (",", ":"),
2123
)

django_esm/utils.py

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,95 @@
11
import json
22
from pathlib import Path
33

4+
from django.conf import settings
5+
from django.contrib.staticfiles.finders import get_finders
46
from django.contrib.staticfiles.storage import staticfiles_storage
57

8+
9+
def parse_root_package(package_json):
10+
"""Parse a project main package.json and return a dict of importmap entries."""
11+
imports = package_json.get("imports", {})
12+
if isinstance(imports, (str, list)):
13+
raise ValueError(f"package.imports must be an object, {type(imports)} given")
14+
15+
for module_name, module in imports.items():
16+
if not module_name.startswith("#"):
17+
raise ValueError(
18+
f"package.imports keys must start with #, {module_name} given"
19+
)
20+
try:
21+
mod = module["default"]
22+
except TypeError:
23+
mod = module
24+
url = mod
25+
if mod[0] in [".", "/"]:
26+
# local file
27+
url = get_static_from_abs_path(settings.BASE_DIR / mod)
28+
if mod.endswith("/*"):
29+
url = url[:-2] + "/"
30+
module_name = module_name[:-1]
31+
yield module_name, url
32+
33+
for dep_name, dep_version in package_json.get("dependencies", {}).items():
34+
yield from parse_package_json(settings.BASE_DIR / "node_modules" / dep_name)
35+
36+
37+
def get_static_from_abs_path(path: Path):
38+
for finder in get_finders():
39+
for storage in finder.storages.values():
40+
try:
41+
rel_path = path.relative_to(Path(storage.location))
42+
except ValueError:
43+
pass
44+
else:
45+
return staticfiles_storage.url(str(rel_path))
46+
raise ValueError(f"Could not find {path} in staticfiles")
47+
48+
649
# There is a long history how ESM is supported in Node.js
750
# So we implement some fallbacks, see also: https://nodejs.org/api/packages.html#exports
851
ESM_KEYS = ["exports", "module", "main"]
952

1053

11-
def parse_package_json(path: Path = None, node_modules: Path = None):
54+
def cast_exports(package_json):
55+
exports = {}
56+
for key in ESM_KEYS:
57+
try:
58+
exports = package_json[key]
59+
except KeyError:
60+
continue
61+
else:
62+
break
63+
if not exports:
64+
exports = {}
65+
elif isinstance(exports, str):
66+
exports = {".": exports}
67+
elif isinstance(exports, list):
68+
exports = {i: i for i in exports}
69+
return exports
70+
71+
72+
def parse_package_json(path: Path = None):
1273
"""Parse a project main package.json and return a dict of importmap entries."""
13-
if node_modules is None:
14-
node_modules = path / "node_modules"
1574
with (path / "package.json").open() as f:
1675
package_json = json.load(f)
1776
name = package_json["name"]
1877
dependencies = package_json.get("dependencies", {})
19-
if path.is_relative_to(node_modules):
20-
base_path = node_modules
78+
exports = cast_exports(package_json)
79+
80+
for module_name, module in exports.items():
81+
try:
82+
mod = module["default"]
83+
except TypeError:
84+
mod = module
85+
86+
yield str(Path(name) / module_name), staticfiles_storage.url(
87+
str((path / mod).relative_to(settings.BASE_DIR / "node_modules"))
88+
)
89+
90+
if (path / "node_modules").exists():
91+
node_modules = path / "node_modules"
2192
else:
22-
base_path = path
23-
for key in ESM_KEYS:
24-
export = package_json.get(key, None)
25-
if export:
26-
try:
27-
for module_name, module in export.items():
28-
try:
29-
yield str(Path(name) / module_name), staticfiles_storage.url(
30-
str((path / module["default"]).relative_to(base_path))
31-
)
32-
except TypeError:
33-
yield str(Path(name) / module_name), staticfiles_storage.url(
34-
str((path / module).relative_to(base_path))
35-
)
36-
except AttributeError:
37-
yield name, staticfiles_storage.url(
38-
str((path / export).relative_to(base_path))
39-
)
93+
node_modules = path / "/".join(".." for _ in Path(name).parts)
4094
for dep_name, dep_version in dependencies.items():
41-
yield from parse_package_json(node_modules / dep_name, node_modules)
95+
yield from parse_package_json(node_modules / dep_name)

tests/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import subprocess
23
from pathlib import Path
34

@@ -8,8 +9,9 @@
89

910
@pytest.fixture(scope="session")
1011
def package_json():
11-
subprocess.check_call(["npm", "install"], cwd=TEST_DIR)
12-
return TEST_DIR / "package.json"
12+
subprocess.check_call(["npm", "install", "--omit=dev"], cwd=TEST_DIR)
13+
with (TEST_DIR / "package.json").open() as f:
14+
return json.load(f)
1315

1416

1517
@pytest.fixture(scope="session")

tests/node_modules/.package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/node_modules/htmx.org/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/node_modules/htmx.org/README.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/node_modules/htmx.org/package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{
22
"name": "django-esm",
3-
"exports": {
4-
".": "./js/index.js",
5-
"./components":{
6-
"import": "./js/components/index.js",
7-
"default": "./js/components/index.js"
8-
}
3+
"imports": {
4+
"#index": {
5+
"import": "./testapp/static/js/index.js",
6+
"default": "./testapp/static/js/index.js"
7+
},
8+
"#components/*": "./testapp/static/js/components/*",
9+
"#htmx": "https://unpkg.com/htmx.org@1.9.10"
910
},
1011
"dependencies": {
1112
"htmx.org": "^1.9.9",

tests/test_utils.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,52 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
15
from django_esm import utils
26

7+
FIXTURE_DIR = Path(__file__).parent
8+
39

4-
def test_parse_package_json(package_json):
5-
import_map = dict(utils.parse_package_json(package_json.parent))
10+
def test_parse_root_package(package_json):
11+
import_map = dict(utils.parse_root_package(package_json))
612
assert import_map["htmx.org"] == "/static/htmx.org/dist/htmx.min.js"
713
assert import_map["lit"] == "/static/lit/index.js"
814
assert (
915
import_map["@lit/reactive-element"]
1016
== "/static/%40lit/reactive-element/reactive-element.js"
1117
)
1218
assert import_map["lit-html"] == "/static/lit-html/lit-html.js"
19+
assert import_map["#index"] == "/static/js/index.js"
20+
assert import_map["#components/"] == "/static/js/components/"
21+
assert import_map["#htmx"] == "https://unpkg.com/htmx.org@1.9.10"
22+
23+
24+
def test_parse_root_package__bad_imports(package_json):
25+
package_json["imports"] = "foo"
26+
with pytest.raises(ValueError) as e:
27+
dict(utils.parse_root_package(package_json))
28+
assert "must be an object" in str(e.value)
29+
30+
package_json["imports"] = ["foo"]
31+
with pytest.raises(ValueError) as e:
32+
dict(utils.parse_root_package(package_json))
33+
assert "must be an object" in str(e.value)
34+
35+
36+
def test_parse_root_package__bad_keys(package_json):
37+
package_json["imports"] = {"foo": "/bar"}
38+
with pytest.raises(ValueError) as e:
39+
dict(utils.parse_root_package(package_json))
40+
assert "must start with #" in str(e.value)
41+
42+
43+
def test_cast_exports():
44+
assert utils.cast_exports({"exports": {"foo": "bar"}}) == {"foo": "bar"}
45+
assert utils.cast_exports({"exports": "foo"}) == {".": "foo"}
46+
assert utils.cast_exports({"exports": ["foo"]}) == {"foo": "foo"}
47+
48+
49+
def test_get_static_from_abs_path():
50+
with pytest.raises(ValueError) as e:
51+
utils.get_static_from_abs_path(Path("/foo/bar"))
52+
assert "Could not find" in str(e.value)

0 commit comments

Comments
 (0)