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 - +