Skip to content

Commit

Permalink
Support nested node_modules
Browse files Browse the repository at this point in the history
  • Loading branch information
codingjoe committed Dec 22, 2023
1 parent 95c0d3d commit e33d638
Show file tree
Hide file tree
Showing 11 changed files with 191 additions and 40 deletions.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- index.html -->
{% block content %}
<script type="module">
import "#myapp/js/my-module.js"
</script>
{% 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: {
'^#(.*)$': '<rootDir>/staticfiles/js/$1' // @todo: remove this with Jest >=29.4
},
}
```

## How it works

Django ESM works via native JavaScript module support in modern browsers.
Expand Down
4 changes: 3 additions & 1 deletion django_esm/templatetags/esm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (",", ":"),
)
Expand Down
102 changes: 78 additions & 24 deletions django_esm/utils.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 4 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import subprocess
from pathlib import Path

Expand All @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions tests/node_modules/.package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions tests/node_modules/htmx.org/CHANGELOG.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/node_modules/htmx.org/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/node_modules/htmx.org/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions tests/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "django-esm",
"exports": {
".": "./js/index.js",
"./components":{
"import": "./js/components/index.js",
"default": "./js/components/index.js"
}
"imports": {
"#index": {
"import": "./testapp/static/js/index.js",
"default": "./testapp/static/js/index.js"
},
"#components/*": "./testapp/static/js/components/*",
"#htmx": "https://unpkg.com/htmx.org@1.9.10"
},
"dependencies": {
"htmx.org": "^1.9.9",
Expand Down
44 changes: 42 additions & 2 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
from pathlib import Path

import pytest

from django_esm import utils

FIXTURE_DIR = Path(__file__).parent


def test_parse_package_json(package_json):
import_map = dict(utils.parse_package_json(package_json.parent))
def test_parse_root_package(package_json):
import_map = dict(utils.parse_root_package(package_json))
assert import_map["htmx.org"] == "/static/htmx.org/dist/htmx.min.js"
assert import_map["lit"] == "/static/lit/index.js"
assert (
import_map["@lit/reactive-element"]
== "/static/%40lit/reactive-element/reactive-element.js"
)
assert import_map["lit-html"] == "/static/lit-html/lit-html.js"
assert import_map["#index"] == "/static/js/index.js"
assert import_map["#components/"] == "/static/js/components/"
assert import_map["#htmx"] == "https://unpkg.com/htmx.org@1.9.10"


def test_parse_root_package__bad_imports(package_json):
package_json["imports"] = "foo"
with pytest.raises(ValueError) as e:
dict(utils.parse_root_package(package_json))
assert "must be an object" in str(e.value)

package_json["imports"] = ["foo"]
with pytest.raises(ValueError) as e:
dict(utils.parse_root_package(package_json))
assert "must be an object" in str(e.value)


def test_parse_root_package__bad_keys(package_json):
package_json["imports"] = {"foo": "/bar"}
with pytest.raises(ValueError) as e:
dict(utils.parse_root_package(package_json))
assert "must start with #" in str(e.value)


def test_cast_exports():
assert utils.cast_exports({"exports": {"foo": "bar"}}) == {"foo": "bar"}
assert utils.cast_exports({"exports": "foo"}) == {".": "foo"}
assert utils.cast_exports({"exports": ["foo"]}) == {"foo": "foo"}


def test_get_static_from_abs_path():
with pytest.raises(ValueError) as e:
utils.get_static_from_abs_path(Path("/foo/bar"))
assert "Could not find" in str(e.value)
1 change: 1 addition & 0 deletions tests/testapp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
# https://docs.djangoproject.com/en/5.0/howto/static-files/

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
BASE_DIR / "node_modules",
]

0 comments on commit e33d638

Please sign in to comment.