├── npm_mjs ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── collectstatic.py │ │ ├── rspack.config.template.js │ │ ├── makemessages.py │ │ ├── create_package_json.py │ │ ├── npm_install.py │ │ └── transpile.py ├── templatetags │ ├── __init__.py │ └── transpile.py ├── tests │ ├── __init__.py │ └── test_json5_parser.py ├── signals.py ├── package.json5 ├── paths.py ├── tools.py ├── storage.py └── json5_parser.py ├── requirements.txt ├── MANIFEST.in ├── setup.py ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── pytest.ini ├── runtests.py ├── pyproject.toml ├── .gitignore ├── .pre-commit-config.yaml ├── .ackrc ├── LICENSE ├── README.md └── CONTRIBUTING.md /npm_mjs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /npm_mjs/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /npm_mjs/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django >= 4.2 2 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /npm_mjs/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test suite for django-npm-mjs package. 3 | """ 4 | -------------------------------------------------------------------------------- /npm_mjs/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | post_npm_install = Signal() 4 | post_transpile = Signal() 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements.txt 4 | include npm_mjs/package.json5 5 | include npm_mjs/templates/npm_mjs/static_urls_js.html 6 | include npm_mjs/management/commands/rspack.config.template.js 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages 4 | from setuptools import setup 5 | 6 | # allow setup.py to be run from any path 7 | os.chdir(os.path.normpath(os.path.join(__file__, os.pardir))) 8 | 9 | setup( 10 | packages=find_packages(), 11 | include_package_data=True, 12 | ) 13 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.14" 19 | - uses: pre-commit/action@v3.0.1 20 | -------------------------------------------------------------------------------- /npm_mjs/package.json5: -------------------------------------------------------------------------------- 1 | // Django-npm-mjs will combine this file with package.json files in other installed 2 | // apps before executing npm commands. Different from a regular package.json, comments 3 | // are allowed in this file. 4 | { 5 | description: "Install dependencies for ES6 transpilation", 6 | private: true, 7 | dependencies: { 8 | "@rspack/core": "1.6.7", 9 | "@rspack/cli": "1.6.7" 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /npm_mjs/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | PROJECT_PATH = str( 6 | getattr(settings, "PROJECT_PATH", getattr(settings, "PROJECT_DIR", "./")), 7 | ) 8 | 9 | TRANSPILE_CACHE_PATH = os.path.join(PROJECT_PATH, ".transpile/") 10 | TRANSPILE_TIME_PATH = os.path.join(TRANSPILE_CACHE_PATH, "time") 11 | 12 | SETTINGS_PATHS = [str(x) for x in getattr(settings, "SETTINGS_PATHS", [])] 13 | 14 | STATIC_ROOT = str(getattr(settings, "STATIC_ROOT", "./static/")) 15 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/collectstatic.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.management.commands.collectstatic import ( 2 | Command as CollectStaticCommand, 3 | ) 4 | 5 | 6 | class Command(CollectStaticCommand): 7 | def set_options(self, *args, **options): 8 | return_value = super().set_options(*args, **options) 9 | self.ignore_patterns += [ 10 | "js/*.mjs", 11 | "js/modules/*", 12 | "js/plugins/*", 13 | "js/workers/*", 14 | ] 15 | return return_value 16 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # Pytest configuration for django-npm-mjs 3 | 4 | # Test discovery patterns 5 | python_files = test_*.py 6 | python_classes = Test* 7 | python_functions = test_* 8 | 9 | # Test paths 10 | testpaths = npm_mjs/tests 11 | 12 | # Output options 13 | addopts = 14 | -v 15 | --strict-markers 16 | --tb=short 17 | --disable-warnings 18 | 19 | # Markers for categorizing tests 20 | markers = 21 | unit: Unit tests for individual components 22 | integration: Integration tests requiring Django setup 23 | slow: Tests that take a long time to run 24 | 25 | # Minimum Python version 26 | minversion = 3.11 27 | -------------------------------------------------------------------------------- /npm_mjs/tools.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from .paths import TRANSPILE_TIME_PATH 4 | 5 | _last_run = {} 6 | 7 | 8 | def load_last_name(): 9 | global _last_run 10 | try: 11 | with open(TRANSPILE_TIME_PATH, "rb") as f: 12 | _last_run = {**_last_run, **pickle.load(f)} 13 | except (EOFError, OSError, TypeError): 14 | pass 15 | 16 | 17 | def get_last_run(name): 18 | global _last_run 19 | if name not in _last_run: 20 | load_last_name() 21 | if name not in _last_run: 22 | return 0 23 | return _last_run[name] 24 | 25 | 26 | def set_last_run(name, timestamp): 27 | global _last_run 28 | load_last_name() 29 | _last_run[name] = timestamp 30 | with open(TRANSPILE_TIME_PATH, "wb") as f: 31 | pickle.dump(_last_run, f) 32 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Test runner for django-npm-mjs package. 4 | 5 | This script runs the test suite without requiring a full Django setup. 6 | For integration tests with Django, use Django's test runner. 7 | """ 8 | import os 9 | import sys 10 | import unittest 11 | 12 | # Add the package to the path 13 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 14 | 15 | 16 | def run_tests(): 17 | """Discover and run all tests.""" 18 | # Discover tests in the npm_mjs/tests directory 19 | loader = unittest.TestLoader() 20 | start_dir = os.path.join(os.path.dirname(__file__), "npm_mjs", "tests") 21 | suite = loader.discover(start_dir, pattern="test_*.py") 22 | 23 | # Run the tests 24 | runner = unittest.TextTestRunner(verbosity=2) 25 | result = runner.run(suite) 26 | 27 | # Return exit code based on success 28 | return 0 if result.wasSuccessful() else 1 29 | 30 | 31 | if __name__ == "__main__": 32 | sys.exit(run_tests()) 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=68.2.2", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "django-npm-mjs" 10 | version = "3.3.0" 11 | description = "A Django package to use npm.js dependencies and transpile ES2015+" 12 | license = "LGPL-3.0-or-later" 13 | readme = "README.md" 14 | authors = [ 15 | {name="Johannes Wilm", email="johannes@fiduswriter.org"} 16 | ] 17 | classifiers = [ 18 | "Environment :: Web Environment", 19 | "Framework :: Django", 20 | "Framework :: Django :: 4.2", 21 | "Framework :: Django :: 5.2", 22 | "Framework :: Django :: 6.0", 23 | "Intended Audience :: Developers", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Topic :: Internet :: WWW/HTTP", 31 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content" 32 | ] 33 | urls = {repository = "https://github.com/fiduswriter/django-npm-mjs"} 34 | dynamic = ["dependencies"] 35 | 36 | [tool.setuptools.dynamic] 37 | dependencies = {file = "requirements.txt"} 38 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/rspack.config.template.js: -------------------------------------------------------------------------------- 1 | const rspack = require("@rspack/core") // eslint-disable-line no-undef 2 | 3 | const settings = window.settings // Replaced by django-npm-mjs 4 | const transpile = window.transpile // Replaced by django-npm-mjs 5 | 6 | 7 | const predefinedVariables = { 8 | transpile_VERSION: transpile.VERSION 9 | } 10 | 11 | if (settings.DEBUG) { 12 | //baseRule.exclude = /node_modules/ 13 | predefinedVariables.staticUrl = `(url => ${JSON.stringify( 14 | settings.STATIC_URL 15 | )} + url)` 16 | } else if ( 17 | settings.STATICFILES_STORAGE !== 18 | "npm_mjs.storage.ManifestStaticFilesStorage" 19 | ) { 20 | predefinedVariables.staticUrl = `(url => ${JSON.stringify( 21 | settings.STATIC_URL 22 | )} + url + "?v=" + ${transpile.VERSION})` 23 | } 24 | 25 | module.exports = { 26 | // eslint-disable-line no-undef 27 | mode: settings.DEBUG ? "development" : "production", 28 | // module: { 29 | // rules: [] // [baseRule] 30 | // }, 31 | output: { 32 | path: transpile.OUT_DIR, 33 | chunkFilename: transpile.VERSION + "-[id].js", 34 | publicPath: transpile.BASE_URL 35 | }, 36 | plugins: [new rspack.DefinePlugin(predefinedVariables)], 37 | entry: transpile.ENTRIES 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.11", "3.12", "3.13", "3.14"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | 31 | - name: Run tests 32 | run: | 33 | python runtests.py 34 | 35 | - name: Test with pytest (if available) 36 | run: | 37 | pip install pytest 38 | pytest -v 39 | continue-on-error: false 40 | 41 | test-pypy: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Set up PyPy 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: 'pypy-3.10' 51 | 52 | - name: Install dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | pip install -r requirements.txt 56 | 57 | - name: Run tests on PyPy 58 | run: | 59 | python runtests.py 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | .direnv/ 90 | .envrc 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | -------------------------------------------------------------------------------- /npm_mjs/templatetags/transpile.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import quote 3 | from urllib.parse import urljoin 4 | 5 | from django import template 6 | from django.apps import apps 7 | from django.contrib.staticfiles.storage import ManifestStaticFilesStorage 8 | from django.templatetags.static import PrefixNode 9 | from django.templatetags.static import StaticNode 10 | 11 | from npm_mjs.tools import get_last_run 12 | 13 | register = template.Library() 14 | 15 | 16 | class StaticTranspileNode(StaticNode): 17 | @classmethod 18 | def handle_simple(cls, path): 19 | path = re.sub(r"^js/(.*)\.mjs", r"js/\1.js", path) 20 | if apps.is_installed("django.contrib.staticfiles"): 21 | from django.contrib.staticfiles.storage import staticfiles_storage 22 | 23 | if isinstance(staticfiles_storage, ManifestStaticFilesStorage): 24 | return staticfiles_storage.url(path) 25 | else: 26 | return staticfiles_storage.url(path) + "?v=%s" % get_last_run( 27 | "transpile", 28 | ) 29 | else: 30 | return urljoin( 31 | PrefixNode.handle_simple("STATIC_URL"), 32 | quote(path), 33 | ) + "?v=%s" % get_last_run("transpile") 34 | 35 | 36 | @register.tag 37 | def static(parser, token): 38 | """ 39 | Join the given path with the STATIC_URL setting adding a version number 40 | and the location of the transpile folder. 41 | Usage:: 42 | {% static path [as varname] %} 43 | Examples:: 44 | {% static "js/index.mjs" %} # turns into js/index.js?v=213... 45 | {% static "css/style.css" %} # turns into css/style.css?v=213... 46 | {% static variable_with_path %} 47 | {% static variable_with_path as varname %} 48 | """ 49 | return StaticTranspileNode.handle_token(parser, token) 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.14 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 25.12.0 6 | hooks: 7 | - id: black 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-ast 12 | - id: check-merge-conflict 13 | - id: check-case-conflict 14 | - id: detect-private-key 15 | - id: check-added-large-files 16 | - id: check-symlinks 17 | - id: check-toml 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | - id: mixed-line-ending 21 | args: [--fix=lf] 22 | - repo: https://github.com/pre-commit/pygrep-hooks 23 | rev: v1.10.0 24 | hooks: 25 | - id: python-check-blanket-noqa 26 | - id: python-check-mock-methods 27 | - id: python-no-eval 28 | - id: python-no-log-warn 29 | - id: rst-backticks 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.19.1 32 | hooks: 33 | - id: pyupgrade 34 | args: 35 | - --py310-plus 36 | exclude: migrations/ 37 | - repo: https://github.com/adamchainz/django-upgrade 38 | rev: 1.29.1 39 | hooks: 40 | - id: django-upgrade 41 | args: 42 | - --target-version=4.2 43 | - repo: https://github.com/asottile/yesqa 44 | rev: v1.5.0 45 | hooks: 46 | - id: yesqa 47 | - repo: https://github.com/asottile/add-trailing-comma 48 | rev: v3.1.0 49 | hooks: 50 | - id: add-trailing-comma 51 | args: 52 | - --py36-plus 53 | - repo: https://github.com/hadialqattan/pycln 54 | rev: v2.6.0 55 | hooks: 56 | - id: pycln 57 | - repo: https://github.com/pycqa/flake8 58 | rev: 7.1.1 59 | hooks: 60 | - id: flake8 61 | exclude: | 62 | (?x)^( 63 | .*/migrations/.* 64 | )$ 65 | additional_dependencies: 66 | - flake8-bugbear 67 | - flake8-comprehensions 68 | - flake8-tidy-imports 69 | - flake8-print 70 | args: [--max-line-length=120] 71 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/makemessages.py: -------------------------------------------------------------------------------- 1 | from base.management import BaseCommand 2 | from django.core.management.commands import makemessages 3 | 4 | # This makes makemessages create both translations for Python and JavaScript 5 | # code in one go. 6 | 7 | # Note that translating JavaScript files with template strings requires xgettext 0.24 (see README.md)) 8 | 9 | 10 | class Command(makemessages.Command, BaseCommand): 11 | 12 | def add_arguments(self, parser): 13 | # Call the parent class's add_arguments first 14 | super().add_arguments(parser) 15 | 16 | # Modify the domain argument's help text 17 | for action in parser._actions: 18 | if action.dest == "domain": 19 | action.help = ( 20 | "Domain of the messages file ('django' or 'djangojs'). " 21 | "By default, both domains will be processed." 22 | ) 23 | action.default = None # This indicates we'll handle both domains 24 | elif action.dest == "locale": 25 | action.help = "Locale(s) to process. If none are specified, all locales will be processed." 26 | elif action.dest == "all": 27 | action.help = ( 28 | "Process all locales. " 29 | "If not specified, and no locales are provided, all locales will be processed." 30 | ) 31 | action.default = False 32 | 33 | def handle(self, *args, **options): 34 | options["ignore_patterns"] += [ 35 | "venv", 36 | ".direnv", 37 | "node_modules", 38 | "static-transpile", 39 | ] 40 | if len(options["locale"]) == 0 and not options["all"]: 41 | options["all"] = True 42 | if options["domain"]: 43 | self.stdout.write("Domain %s" % options["domain"]) 44 | return super().handle(*args, **options) 45 | else: 46 | options["domain"] = "django" 47 | self.stdout.write("Domain %s" % options["domain"]) 48 | super().handle(*args, **options) 49 | options["domain"] = "djangojs" 50 | self.stdout.write("Domain %s" % options["domain"]) 51 | super().handle(*args, **options) 52 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/create_package_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from django.apps import apps as django_apps 5 | from django.core.management.base import BaseCommand 6 | 7 | from npm_mjs.json5_parser import parse_json5 8 | from npm_mjs.paths import TRANSPILE_CACHE_PATH 9 | 10 | 11 | def deep_merge_dicts(old_dict, merge_dict, scripts=False): 12 | for key in merge_dict: 13 | if key in old_dict: 14 | if isinstance(old_dict[key], dict) and isinstance(merge_dict[key], dict): 15 | if key == "scripts": 16 | deep_merge_dicts(old_dict[key], merge_dict[key], True) 17 | else: 18 | deep_merge_dicts(old_dict[key], merge_dict[key]) 19 | else: 20 | # In the scripts section, allow adding to hooks such as 21 | # "preinstall" and "postinstall" 22 | if scripts and key in old_dict: 23 | old_dict[key] += " && %s" % merge_dict[key] 24 | else: 25 | old_dict[key] = merge_dict[key] 26 | else: 27 | old_dict[key] = merge_dict[key] 28 | 29 | 30 | class Command(BaseCommand): 31 | help = "Join package.json files from apps into common package.json" 32 | 33 | def handle(self, *args, **options): 34 | package = {} 35 | configs = django_apps.get_app_configs() 36 | for config in configs: 37 | json5_package_path = os.path.join(config.path, "package.json5") 38 | json_package_path = os.path.join(config.path, "package.json") 39 | if os.path.isfile(json5_package_path): 40 | with open(json5_package_path, encoding="utf-8") as data_file: 41 | data = parse_json5(data_file.read(), debug=True) 42 | elif os.path.isfile(json_package_path): 43 | with open(json_package_path, encoding="utf-8") as data_file: 44 | data = json.loads(data_file.read()) 45 | else: 46 | continue 47 | deep_merge_dicts(package, data) 48 | os.makedirs(TRANSPILE_CACHE_PATH, exist_ok=True) 49 | package_path = os.path.join(TRANSPILE_CACHE_PATH, "package.json") 50 | with open(package_path, "w") as outfile: 51 | json.dump(package, outfile) 52 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/npm_install.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import os 4 | import time 5 | from subprocess import call 6 | 7 | from django.apps import apps as django_apps 8 | from django.core.management import call_command 9 | from django.core.management.base import BaseCommand 10 | 11 | from npm_mjs import signals 12 | from npm_mjs.paths import SETTINGS_PATHS 13 | from npm_mjs.paths import TRANSPILE_CACHE_PATH 14 | from npm_mjs.tools import get_last_run 15 | from npm_mjs.tools import set_last_run 16 | 17 | 18 | def get_package_hash(): 19 | """Generate a hash of all package.json files""" 20 | hash_md5 = hashlib.md5() 21 | for config in django_apps.get_app_configs(): 22 | for filename in ["package.json", "package.json5"]: 23 | filepath = os.path.join(config.path, filename) 24 | if os.path.exists(filepath): 25 | with open(filepath, "rb") as f: 26 | hash_md5.update(f.read()) 27 | return hash_md5.hexdigest() 28 | 29 | 30 | def install_npm(force, stdout, post_npm_signal=True): 31 | change_times = [0] 32 | for path in SETTINGS_PATHS: 33 | change_times.append(os.path.getmtime(path)) 34 | settings_change = max(change_times) 35 | package_hash = get_package_hash() 36 | cache_file = os.path.join(TRANSPILE_CACHE_PATH, "package_hash.json") 37 | 38 | if os.path.exists(cache_file): 39 | with open(cache_file) as f: 40 | cached_hash = json.load(f).get("hash") 41 | else: 42 | cached_hash = None 43 | 44 | npm_install = False 45 | if ( 46 | settings_change > get_last_run("npm_install") 47 | or package_hash != cached_hash 48 | or force 49 | ): 50 | stdout.write("Installing pnpm dependencies...") 51 | os.makedirs(TRANSPILE_CACHE_PATH, exist_ok=True) 52 | set_last_run("npm_install", int(round(time.time()))) 53 | call_command("create_package_json") 54 | 55 | stdout.write("Installing dependencies...") 56 | call(["npx", "-y", "pnpm", "install"], cwd=TRANSPILE_CACHE_PATH) 57 | 58 | # Update cache 59 | with open(cache_file, "w") as f: 60 | json.dump({"hash": package_hash}, f) 61 | 62 | if post_npm_signal: 63 | signals.post_npm_install.send(sender=None) 64 | npm_install = True 65 | else: 66 | stdout.write("No changes detected, skipping pnpm install.") 67 | 68 | return npm_install 69 | 70 | 71 | class Command(BaseCommand): 72 | help = "Run npm install on package.json files in app folders." 73 | 74 | def add_arguments(self, parser): 75 | parser.add_argument( 76 | "--force", 77 | action="store_true", 78 | dest="force", 79 | default=False, 80 | help="Force npm install even if no change is detected.", 81 | ) 82 | 83 | parser.add_argument( 84 | "--nosignal", 85 | action="store_false", 86 | dest="post_npm_signal", 87 | default=True, 88 | help="Send a signal after finishing npm install.", 89 | ) 90 | 91 | def handle(self, *args, **options): 92 | install_npm(options["force"], self.stdout, options["post_npm_signal"]) 93 | -------------------------------------------------------------------------------- /npm_mjs/storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import posixpath 3 | import re 4 | from urllib.parse import unquote 5 | from urllib.parse import urldefrag 6 | from urllib.parse import urljoin 7 | 8 | from django.conf import settings 9 | from django.contrib.staticfiles.storage import HashedFilesMixin 10 | from django.contrib.staticfiles.storage import ( 11 | ManifestStaticFilesStorage as DefaultManifestStaticFilesStorage, 12 | ) 13 | 14 | 15 | def add_js_static_pattern(pattern): 16 | if pattern[0] == "*.js": 17 | templates = pattern[1] + ( 18 | ( 19 | "(?PstaticUrl\\(['\"]{0,1}\\s*(?P.*?)[\"']{0,1}\\))", 20 | "'%(url)s'", 21 | ) 22 | ) 23 | pattern = (pattern[0], templates) 24 | return pattern 25 | 26 | 27 | class ManifestStaticFilesStorage(DefaultManifestStaticFilesStorage): 28 | patterns = tuple(map(add_js_static_pattern, HashedFilesMixin.patterns)) 29 | 30 | def url_converter(self, name, hashed_files, template=None): 31 | """ 32 | Return the custom URL converter for the given file name. 33 | """ 34 | # Modified from 35 | # https://github.com/django/django/blob/main/django/contrib/staticfiles/storage.py 36 | # to handle absolute URLS 37 | 38 | if template is None: 39 | template = self.default_template 40 | 41 | def converter(matchobj): 42 | """ 43 | Convert the matched URL to a normalized and hashed URL. 44 | This requires figuring out which files the matched URL resolves 45 | to and calling the url() method of the storage. 46 | """ 47 | matches = matchobj.groupdict() 48 | matched = matches["matched"] 49 | url = matches["url"] 50 | 51 | # Ignore absolute/protocol-relative and data-uri URLs. 52 | if re.match(r"^[a-z]+:", url): 53 | return matched 54 | 55 | # Strip off the fragment so a path-like fragment won't interfere. 56 | url_path, fragment = urldefrag(url) 57 | 58 | # Ignore URLs without a path 59 | if not url_path: 60 | return matched 61 | 62 | if url_path.startswith("/"): 63 | # Absolute paths are assumed to have their root at STATIC_ROOT 64 | target_name = url_path[1:] 65 | else: 66 | # We're using the posixpath module to mix paths and URLs conveniently. 67 | source_name = name if os.sep == "/" else name.replace(os.sep, "/") 68 | target_name = posixpath.join(posixpath.dirname(source_name), url_path) 69 | 70 | # Determine the hashed name of the target file with the storage backend. 71 | hashed_url = self._url( 72 | self._stored_name, 73 | unquote(target_name), 74 | force=True, 75 | hashed_files=hashed_files, 76 | ) 77 | 78 | transformed_url = "/".join( 79 | url_path.split("/")[:-1] + hashed_url.split("/")[-1:], 80 | ) 81 | 82 | # Restore the fragment that was stripped off earlier. 83 | if fragment: 84 | transformed_url += ("?#" if "?#" in url else "#") + fragment 85 | 86 | if url_path.startswith("/"): 87 | transformed_url = urljoin(settings.STATIC_URL, transformed_url[1:]) 88 | 89 | # Return the hashed version to the file 90 | matches["url"] = unquote(transformed_url) 91 | return template % matches 92 | 93 | return converter 94 | -------------------------------------------------------------------------------- /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-ack-defaults 2 | --ignore-directory=is:.bzr 3 | --ignore-directory=is:.cdv 4 | --ignore-directory=is:~.dep 5 | --ignore-directory=is:~.dot 6 | --ignore-directory=is:~.nib 7 | --ignore-directory=is:~.plst 8 | --ignore-directory=is:.git 9 | --ignore-directory=is:.hg 10 | --ignore-directory=is:.pc 11 | --ignore-directory=is:.svn 12 | --ignore-directory=is:_MTN 13 | --ignore-directory=is:CVS 14 | --ignore-directory=is:RCS 15 | --ignore-directory=is:SCCS 16 | --ignore-directory=is:_darcs 17 | --ignore-directory=is:_sgbak 18 | --ignore-directory=is:autom4te.cache 19 | --ignore-directory=is:blib 20 | --ignore-directory=is:_build 21 | --ignore-directory=is:cover_db 22 | --ignore-directory=is:node_modules 23 | --ignore-directory=is:CMakeFiles 24 | --ignore-directory=is:static-transpile 25 | --ignore-directory=is:media 26 | --ignore-directory=is:static-collected 27 | --ignore-directory=is:static-libs 28 | --ignore-directory=is:.transpile-cache 29 | --ignore-directory=is:.transpile 30 | --ignore-directory=is:fiduswriter.egg-info 31 | --ignore-directory=is:fixtures 32 | --ignore-directory=is:venv 33 | --ignore-directory=is:.direnv 34 | --ignore-directory=is:build 35 | --ignore-file=ext:bak 36 | --ignore-file=match:/~$/ 37 | --ignore-file=match:/^#.+#$/ 38 | --ignore-file=match:/[._].*\.swp$/ 39 | --ignore-file=match:/core\.\d+$/ 40 | --ignore-file=match:/[.-]min[.]js$/ 41 | --ignore-file=match:/[.]js[.]min$/ 42 | --ignore-file=match:/[.]min[.]css$/ 43 | --ignore-file=match:/[.]css[.]min$/ 44 | --ignore-file=match:-package.json 45 | --ignore-file=ext:pdf 46 | --ignore-file=ext:gif,jpg,jpeg,png 47 | --type-add=perl:ext:pl,pm,pod,t,psgi 48 | --type-add=perl:firstlinematch:/^#!.*\bperl/ 49 | --type-add=perltest:ext:t 50 | --type-add=make:ext:mk 51 | --type-add=make:ext:mak 52 | --type-add=make:is:makefile 53 | --type-add=make:is:Makefile 54 | --type-add=make:is:GNUmakefile 55 | --type-add=rake:is:Rakefile 56 | --type-add=cmake:is:CMakeLists.txt 57 | --type-add=cmake:ext:cmake 58 | --type-add=actionscript:ext:as,mxml 59 | --type-add=ada:ext:ada,adb,ads 60 | --type-add=asp:ext:asp 61 | --type-add=aspx:ext:master,ascx,asmx,aspx,svc 62 | --type-add=asm:ext:asm,s 63 | --type-add=batch:ext:bat,cmd 64 | --type-add=cfmx:ext:cfc,cfm,cfml 65 | --type-add=clojure:ext:clj 66 | --type-add=cc:ext:c,h,xs 67 | --type-add=hh:ext:h 68 | --type-add=coffeescript:ext:coffee 69 | --type-add=cpp:ext:cpp,cc,cxx,m,hpp,hh,h,hxx 70 | --type-add=csharp:ext:cs 71 | --type-add=css:ext:css 72 | --type-add=dart:ext:dart 73 | --type-add=delphi:ext:pas,int,dfm,nfm,dof,dpk,dproj,groupproj,bdsgroup,bdsproj 74 | --type-add=elixir:ext:ex,exs 75 | --type-add=elisp:ext:el 76 | --type-add=erlang:ext:erl,hrl 77 | --type-add=fortran:ext:f,f77,f90,f95,f03,for,ftn,fpp 78 | --type-add=go:ext:go 79 | --type-add=groovy:ext:groovy,gtmpl,gpp,grunit,gradle 80 | --type-add=haskell:ext:hs,lhs 81 | --type-add=html:ext:htm,html 82 | --type-add=java:ext:java,properties 83 | --type-add=js:ext:js 84 | --type-add=jsp:ext:jsp,jspx,jhtm,jhtml 85 | --type-add=json:ext:json 86 | --type-add=less:ext:less 87 | --type-add=lisp:ext:lisp,lsp 88 | --type-add=lua:ext:lua 89 | --type-add=lua:firstlinematch:/^#!.*\blua(jit)?/ 90 | --type-add=objc:ext:m,h 91 | --type-add=objcpp:ext:mm,h 92 | --type-add=ocaml:ext:ml,mli 93 | --type-add=matlab:ext:m 94 | --type-add=parrot:ext:pir,pasm,pmc,ops,pod,pg,tg 95 | --type-add=php:ext:php,phpt,php3,php4,php5,phtml 96 | --type-add=php:firstlinematch:/^#!.*\bphp/ 97 | --type-add=plone:ext:pt,cpt,metadata,cpy,py 98 | --type-add=python:ext:py 99 | --type-add=python:firstlinematch:/^#!.*\bpython/ 100 | --type-add=rr:ext:R 101 | --type-add=ruby:ext:rb,rhtml,rjs,rxml,erb,rake,spec 102 | --type-add=ruby:is:Rakefile 103 | --type-add=ruby:firstlinematch:/^#!.*\bruby/ 104 | --type-add=rust:ext:rs 105 | --type-add=sass:ext:sass,scss 106 | --type-add=scala:ext:scala 107 | --type-add=scheme:ext:scm,ss 108 | --type-add=shell:ext:sh,bash,csh,tcsh,ksh,zsh,fish 109 | --type-add=shell:firstlinematch:/^#!.*\b(?:ba|t?c|k|z|fi)?sh\b/ 110 | --type-add=smalltalk:ext:st 111 | --type-add=sql:ext:sql,ctl 112 | --type-add=tcl:ext:tcl,itcl,itk 113 | --type-add=tex:ext:tex,cls,sty 114 | --type-add=tt:ext:tt,tt2,ttml 115 | --type-add=vb:ext:bas,cls,frm,ctl,vb,resx 116 | --type-add=verilog:ext:v,vh,sv 117 | --type-add=vhdl:ext:vhd,vhdl 118 | --type-add=vim:ext:vim 119 | --type-add=xml:ext:xml,dtd,xsl,xslt,ent 120 | --type-add=xml:firstlinematch:/<[?]xml/ 121 | --type-add=yaml:ext:yaml,yml 122 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /npm_mjs/json5_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON5 parser using regex and native JSON parsing. 3 | Compatible with PyPy and doesn't require external dependencies. 4 | 5 | This parser handles: 6 | - Single-line comments (//) 7 | - Multi-line comments (/* */) 8 | - Unquoted object keys 9 | - Trailing commas 10 | - Single-quoted strings 11 | """ 12 | 13 | import json 14 | import logging 15 | import re 16 | from typing import Any 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def parse_json5(content: str, debug: bool = False) -> dict[str, Any]: 22 | """ 23 | Parse JSON5 content and return a dictionary. 24 | 25 | Args: 26 | content: JSON5 string content 27 | debug: If True, print debug output on parse errors (default: False) 28 | 29 | Returns: 30 | Dictionary representation of the JSON5 content 31 | 32 | Raises: 33 | json.JSONDecodeError: If the content cannot be parsed 34 | """ 35 | # Process the content in a single pass to: 36 | # 1. Remove comments (but not // or /* */ inside strings) 37 | # 2. Convert single-quoted strings to double-quoted strings 38 | # 3. Preserve all string content correctly 39 | 40 | def process_content(text): 41 | """Process JSON5 content: remove comments and convert quotes.""" 42 | result = [] 43 | i = 0 44 | 45 | while i < len(text): 46 | char = text[i] 47 | 48 | # Check for double-quoted strings - preserve as-is 49 | if char == '"': 50 | # Copy the entire double-quoted string, handling escapes 51 | result.append(char) 52 | i += 1 53 | while i < len(text): 54 | char = text[i] 55 | result.append(char) 56 | if char == "\\" and i + 1 < len(text): 57 | # Copy the escaped character 58 | i += 1 59 | result.append(text[i]) 60 | i += 1 61 | elif char == '"': 62 | # End of string 63 | i += 1 64 | break 65 | else: 66 | i += 1 67 | continue 68 | 69 | # Check for single-quoted strings - convert to double quotes 70 | if char == "'": 71 | # Start of single-quoted string 72 | result.append('"') # Convert to double quote 73 | i += 1 74 | while i < len(text): 75 | char = text[i] 76 | if char == "\\" and i + 1 < len(text): 77 | # Handle escape sequences 78 | next_char = text[i + 1] 79 | if next_char == "'": 80 | # Escaped single quote in single-quoted string 81 | # In double-quoted string, we don't need to escape it 82 | result.append("'") 83 | i += 2 84 | else: 85 | # Other escape sequences - preserve 86 | result.append(char) 87 | i += 1 88 | result.append(text[i]) 89 | i += 1 90 | elif char == "'": 91 | # End of single-quoted string 92 | result.append('"') # Convert to double quote 93 | i += 1 94 | break 95 | elif char == '"': 96 | # Double quote inside single-quoted string - escape it 97 | result.append('\\"') 98 | i += 1 99 | else: 100 | result.append(char) 101 | i += 1 102 | continue 103 | 104 | # Check for multi-line comment start 105 | if i + 1 < len(text) and text[i : i + 2] == "/*": # noqa: E203 106 | # Find the end of the comment 107 | end = text.find("*/", i + 2) 108 | if end != -1: 109 | # Skip the entire comment, but preserve newlines for line number accuracy 110 | comment_text = text[i : end + 2] # noqa: E203 111 | newlines = comment_text.count("\n") 112 | result.append("\n" * newlines) 113 | i = end + 2 114 | else: 115 | # Unclosed comment, skip to end 116 | i = len(text) 117 | continue 118 | 119 | # Check for single-line comment 120 | # Make sure it's not part of a URL (http:// or https://) 121 | if i + 1 < len(text) and text[i : i + 2] == "//": # noqa: E203 122 | # Check if this is part of a URL 123 | if i > 0 and text[i - 1] == ":": 124 | # This is likely a URL, keep it 125 | result.append(char) 126 | i += 1 127 | else: 128 | # This is a comment - skip to end of line 129 | while i < len(text) and text[i] != "\n": 130 | i += 1 131 | # Keep the newline 132 | if i < len(text): 133 | result.append("\n") 134 | i += 1 135 | continue 136 | 137 | # Regular character 138 | result.append(char) 139 | i += 1 140 | 141 | return "".join(result) 142 | 143 | content = process_content(content) 144 | 145 | # Convert unquoted keys to quoted keys 146 | # Match: word characters followed by colon (not already quoted) 147 | # This regex looks for keys at the start of a line or after { or , 148 | content = re.sub(r"([\{\,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:", r'\1"\2":', content) 149 | 150 | # Also handle keys at the beginning of the content 151 | content = re.sub( 152 | r"^(\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:", 153 | r'\1"\2":', 154 | content, 155 | flags=re.MULTILINE, 156 | ) 157 | 158 | # Remove trailing commas before closing braces/brackets 159 | content = re.sub(r",(\s*[}\]])", r"\1", content) 160 | 161 | # Parse as regular JSON 162 | try: 163 | return json.loads(content) 164 | except json.JSONDecodeError as e: 165 | # Log debug information if requested 166 | if debug: 167 | logger.error("\n" + "=" * 80) 168 | logger.error("JSON5 Parser Error - Processed content that failed to parse:") 169 | logger.error("=" * 80) 170 | lines = content.split("\n") 171 | for i, line in enumerate(lines, 1): 172 | marker = " --> " if i == e.lineno else " " 173 | logger.error(f"{marker}{i:3}: {line}") 174 | logger.error("=" * 80) 175 | logger.error(f"Error at line {e.lineno}, column {e.colno}: {e.msg}") 176 | logger.error("=" * 80 + "\n") 177 | raise 178 | 179 | 180 | def encode_json5(data: dict[str, Any], indent: int = 2) -> str: 181 | """ 182 | Encode dictionary to JSON5 format (actually just JSON with nice formatting). 183 | 184 | Args: 185 | data: Dictionary to encode 186 | indent: Number of spaces for indentation 187 | 188 | Returns: 189 | JSON5-formatted string 190 | """ 191 | return json.dumps(data, indent=indent, ensure_ascii=False) 192 | 193 | 194 | def load_json5(file_path: str, debug: bool = False) -> dict[str, Any]: 195 | """ 196 | Load and parse a JSON5 file. 197 | 198 | Args: 199 | file_path: Path to JSON5 file 200 | debug: If True, print debug output on parse errors (default: False) 201 | 202 | Returns: 203 | Dictionary representation of the JSON5 content 204 | """ 205 | with open(file_path, encoding="utf-8") as f: 206 | content = f.read() 207 | return parse_json5(content, debug=debug) 208 | 209 | 210 | def dump_json5(data: dict[str, Any], file_path: str, indent: int = 2): 211 | """ 212 | Dump dictionary to a JSON5 file (as regular JSON). 213 | 214 | Args: 215 | data: Dictionary to dump 216 | file_path: Path to output file 217 | indent: Number of spaces for indentation 218 | """ 219 | with open(file_path, "w", encoding="utf-8") as f: 220 | json.dump(data, f, indent=indent, ensure_ascii=False) 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-npm-mjs 2 | A Django package to use npm.js dependencies and transpile ES2015+ 3 | 4 | This package is used by Fidus Writer to bundle JavaScript. We try to keep it as generic as possible, so if there is something that seems very odd and specific to Fidus Writer, it is likely just an oversight from us. Please contact us and we'll see what we can do about it. 5 | 6 | This package similar to django-compressor in that it treats JavaScript files before they are served to the user. But there are some differences: 7 | 8 | * It does not mix different JavaScript module entry files. It only bundles everything imported from one entry file. With ES2015+ there is not as much need to have lots of JavaScript files operating in the global namespace. 9 | 10 | * It allows importing from one django app in another app within the same project as if they were in the same folder similar to how static files and templates are handled by Django. 11 | 12 | * It includes handling of npm.js imports. 13 | 14 | * The JavaScript entry files' base names do not change and an automatic version query is added to be able to wipe the browser cache (`/js/my_file.mjs` turns into `/js/my_file.js?v=239329884`). This way it is also possible to refer to the URL from JavaScript (for example for use with web workers). 15 | 16 | * It allows for JavaScript plugin hooks between django apps used in cases when a django project can be used both with or without a specific app, and the JavaScript from one app needs to import things from another app. 17 | 18 | 19 | Quick start 20 | ----------- 21 | 1. Install "npm_mjs" 22 | 23 | pip install django-npm-mjs 24 | 25 | 2. Add "npm_mjs" to your INSTALLED_APPS setting like this:: 26 | 27 | INSTALLED_APPS = [ 28 | ... 29 | 'npm_mjs', 30 | ] 31 | 32 | 3. Define a `PROJECT_PATH` in the settings as the root folder of the project (`PROJECT_DIR` will also be accepted):: 33 | 34 | PROJECT_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) 35 | 36 | 4. Define a `SETTINGS_PATHS` in the settings to contain the paths of all setting files (settings.py + any local_settings.py or similar file you may have defined) - this is to transpile again whenever settings have changed:: 37 | 38 | SETTINGS_PATHS = [os.path.dirname(__file__), ] 39 | 40 | 5. Add the `static-transpile` folder inside the `PROJECT_PATH` to the `STATICFILES_DIRS` like this:: 41 | 42 | STATICFILES_DIRS = ( 43 | os.path.join(PROJECT_PATH, 'static-transpile'), 44 | ... 45 | ) 46 | 47 | 6. Load transpile, and use `static` template tags to your templates to refer to JavaScript files. 48 | All entry files to ES2015+ modules need to have \*.mjs endings. Entries can look like this:: 49 | 50 | {% load transpile %} 51 | ... 52 | 53 | 54 | You can continue to load other resources such as CSS files as before using the `static` template tag:: 55 | 56 | 57 | 58 | 7. Run `./manage.py transpile`. 59 | 60 | 8. Run `./manage.py runserver`. Your ES2015+ modules will be served as browser compatible JS files and all static files will have a versioned ending so that you can set your static server to let browsers cache static files indefinitely as long as DEBUG is set to False. 61 | 62 | 63 | NPM.JS dependencies 64 | ------------------- 65 | 66 | 1. Add package.json or package.json5 files into one or more of your apps. All package files will be merged. 67 | 68 | 2. Import in your JS files from any of the npm modules specified in your package files. 69 | 70 | 3. Run `./manage.py transpile`. 71 | 72 | 4. Run `./manage.py runserver`. 73 | 74 | Referring to the transpile version within JavaScript sources 75 | ------------------------------------------------------------ 76 | 77 | In your JavaScript sources, you can refer to the version string of the last transpile run like this:: 78 | 79 | transpile.VERSION 80 | 81 | For example:: 82 | 83 | let downloadJS = `download.js?v=${transpile.VERSION}` // Latest version of transpiled version of download.mjs 84 | 85 | 86 | ManifestStaticFilesStorage 87 | -------------------------- 88 | If you use `ManifestStaticFilesStorage`, import it from `npm_mjs.storage` like this: 89 | 90 | ```py 91 | from npm_mjs.storage import ManifestStaticFilesStorage 92 | ``` 93 | 94 | If you use that version, you can refer to other static files within your JavaScript files using the `staticUrl()` function like this: 95 | 96 | ```js 97 | const cssUrl = staticUrl('/css/document.css') 98 | ``` 99 | 100 | Note that you will need to use absolute paths starting from the `STATIC_ROOT` for the `staticUrl()` function. Different from the default `ManifestStaticFilesStorage`, our version will generally interprete file urls starting with a slash as being relative to the `STATIC_ROOT`. 101 | 102 | Translations 103 | ------------ 104 | 105 | Commands such as `./manage.py makemessages` and `./manage.py compilemessages` will work as always in Django, with some slightly different defaults. Not specifying any language will default to running with `--all` (all languages). Not specifying any domain will default to running for both "django" and "djangojs" (Python and Javascript files). The `static-transpile` directory will also be ignored by default. 106 | 107 | **NOTE: JavaScript files that contain template strings will require at least xgettext version 0.24 or higher. See below for installation instructions.** 108 | 109 | 110 | Install xgettext 0.24 111 | --------------------- 112 | 113 | First check which xgettext version your OS comes with: 114 | 115 | ```bash 116 | xgettext --version 117 | ``` 118 | 119 | If it is below version 0.24, you will need to install a newer version. For example in your current virtual environment: 120 | 121 | Step 1: Activate Your Virtual Environment 122 | ```bash 123 | source /path/to/venv/bin/activate 124 | ``` 125 | 126 | Step 2: Install Build Dependencies 127 | Install tools required to compile software: 128 | 129 | ```bash 130 | sudo apt-get update 131 | sudo apt-get install -y build-essential libtool automake autoconf 132 | ``` 133 | 134 | Step 3: Download and Extract Gettext 0.24 135 | 136 | ```bash 137 | wget https://ftp.gnu.org/pub/gnu/gettext/gettext-0.24.tar.gz 138 | tar -xzf gettext-0.24.tar.gz 139 | cd gettext-0.24 140 | ``` 141 | 142 | Step 4: Configure and Install into the Venv 143 | Install to your venv's directory using --prefix: 144 | 145 | ```bash 146 | ./configure --prefix=$VIRTUAL_ENV 147 | make 148 | make install 149 | ``` 150 | 151 | Step 5: Verify Installation 152 | Ensure the new xgettext is in your venv and check the version: 153 | 154 | ```bash 155 | which xgettext # Should output a path inside your venv 156 | xgettext --version # Should show 0.24 157 | ``` 158 | 159 | Step 6: Cleanup 160 | 161 | ```bash 162 | cd .. 163 | rm -rf gettext-0.24 gettext-0.24.tar.gz 164 | ``` 165 | 166 | Testing 167 | ------- 168 | 169 | The package includes a comprehensive test suite to ensure reliability and prevent regressions. 170 | 171 | ### Running Tests 172 | 173 | Run all tests using the provided test runner: 174 | 175 | ```bash 176 | python runtests.py 177 | ``` 178 | 179 | Or use Python's unittest directly: 180 | 181 | ```bash 182 | python -m unittest discover npm_mjs/tests 183 | ``` 184 | 185 | If you have pytest installed: 186 | 187 | ```bash 188 | pytest 189 | ``` 190 | 191 | ### Critical Regression Tests 192 | 193 | The test suite includes specific tests for previously encountered bugs: 194 | 195 | - **Double slashes in strings** (`"path//to//file"` should not be treated as comments) 196 | - **Single quotes inside double-quoted strings** (proper quote conversion) 197 | - **Long lines** (handling files with line 7, column 178 errors) 198 | - **URLs in strings and comments** (preserving `https://` patterns) 199 | 200 | For more details, see `npm_mjs/tests/README.md`. 201 | 202 | ### Debugging JSON5 Parse Errors 203 | 204 | If you encounter errors when parsing package.json5 files, the parser provides helpful debug output using Python's logging module. The Django management command automatically enables debug mode, which shows: 205 | 206 | - The processed content after comment removal and quote conversion 207 | - The exact line and column where parsing failed 208 | - The specific error message 209 | 210 | Example debug output: 211 | 212 | ``` 213 | ================================================================================ 214 | JSON5 Parser Error - Processed content that failed to parse: 215 | ================================================================================ 216 | --> 4: "key": "value with problem", 217 | ================================================================================ 218 | Error at line 4, column 26: Expecting ',' delimiter 219 | ================================================================================ 220 | ``` 221 | 222 | When using the parser directly in your code, you can enable debug output: 223 | 224 | ```python 225 | import logging 226 | from npm_mjs.json5_parser import parse_json5 227 | 228 | # Configure logging to see debug output 229 | logging.basicConfig(level=logging.ERROR, format='%(message)s') 230 | 231 | # Enable debug output for troubleshooting 232 | try: 233 | result = parse_json5(content, debug=True) 234 | except json.JSONDecodeError as e: 235 | # Debug info logged as ERROR before exception is raised 236 | pass 237 | ``` 238 | 239 | By default, `debug=False` to keep the output clean in automated scripts and tests. When `debug=True`, error details are logged at the ERROR level using Python's standard logging module. 240 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to django-npm-mjs 2 | 3 | Thank you for your interest in contributing to django-npm-mjs! This document provides guidelines and instructions for contributing. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [Development Setup](#development-setup) 9 | - [Running Tests](#running-tests) 10 | - [Code Style](#code-style) 11 | - [Submitting Changes](#submitting-changes) 12 | - [Reporting Bugs](#reporting-bugs) 13 | - [Feature Requests](#feature-requests) 14 | 15 | ## Getting Started 16 | 17 | 1. Fork the repository on GitHub 18 | 2. Clone your fork locally: 19 | ```bash 20 | git clone https://github.com/YOUR-USERNAME/django-npm-mjs.git 21 | cd django-npm-mjs 22 | ``` 23 | 3. Create a branch for your changes: 24 | ```bash 25 | git checkout -b feature/your-feature-name 26 | ``` 27 | 28 | ## Development Setup 29 | 30 | ### Prerequisites 31 | 32 | - Python 3.11 or higher 33 | - pip 34 | 35 | ### Setting Up Your Environment 36 | 37 | 1. Create a virtual environment: 38 | ```bash 39 | python -m venv venv 40 | source venv/bin/activate # On Windows: venv\Scripts\activate 41 | ``` 42 | 43 | 2. Install dependencies: 44 | ```bash 45 | pip install -r requirements.txt 46 | ``` 47 | 48 | 3. Install pre-commit hooks (optional but recommended): 49 | ```bash 50 | pip install pre-commit 51 | pre-commit install 52 | ``` 53 | 54 | ## Running Tests 55 | 56 | ### Quick Test Run 57 | 58 | Run all tests with the test runner: 59 | 60 | ```bash 61 | python runtests.py 62 | ``` 63 | 64 | ### Using unittest 65 | 66 | Run all tests: 67 | ```bash 68 | python -m unittest discover npm_mjs/tests 69 | ``` 70 | 71 | Run specific test class: 72 | ```bash 73 | python -m unittest npm_mjs.tests.test_json5_parser.TestJSON5Comments 74 | ``` 75 | 76 | Run specific test method: 77 | ```bash 78 | python -m unittest npm_mjs.tests.test_json5_parser.TestJSON5Comments.test_single_line_comment_inline 79 | ``` 80 | 81 | ### Using pytest 82 | 83 | If you have pytest installed: 84 | 85 | ```bash 86 | pytest -v 87 | ``` 88 | 89 | Run specific tests: 90 | ```bash 91 | pytest -v -k "Comments" # All comment-related tests 92 | ``` 93 | 94 | ### Writing Tests 95 | 96 | When adding new features or fixing bugs: 97 | 98 | 1. **Add tests first** (Test-Driven Development) 99 | 2. **Use descriptive test names** that explain what is being tested 100 | 3. **Add docstrings** to explain the test's purpose 101 | 4. **Test edge cases** and error conditions 102 | 5. **Add regression tests** for bug fixes 103 | 104 | Example test: 105 | 106 | ```python 107 | def test_new_feature(self): 108 | """Test that new feature handles X correctly.""" 109 | content = '{ key: "value" }' 110 | result = parse_json5(content) 111 | self.assertEqual(result, {'key': 'value'}) 112 | ``` 113 | 114 | ### Test Organization 115 | 116 | Tests are organized in `npm_mjs/tests/`: 117 | 118 | - `test_json5_parser.py` - Tests for JSON5 parser functionality 119 | 120 | When adding tests: 121 | - Group related tests in the same test class 122 | - Use clear, descriptive names 123 | - Keep tests focused and isolated 124 | 125 | ## Code Style 126 | 127 | ### Python Style Guide 128 | 129 | - Follow [PEP 8](https://pep8.org/) style guide 130 | - Use 4 spaces for indentation (no tabs) 131 | - Maximum line length: 88 characters (Black formatter default) 132 | - Use meaningful variable and function names 133 | 134 | ### Pre-commit Hooks 135 | 136 | The project uses pre-commit hooks to enforce code quality. Run manually with: 137 | 138 | ```bash 139 | pre-commit run --all-files 140 | ``` 141 | 142 | ### Docstrings 143 | 144 | Use docstrings for all public modules, functions, classes, and methods: 145 | 146 | ```python 147 | def parse_json5(content: str) -> Dict[str, Any]: 148 | """ 149 | Parse JSON5 content and return a dictionary. 150 | 151 | Args: 152 | content: JSON5 string content 153 | 154 | Returns: 155 | Dictionary representation of the JSON5 content 156 | 157 | Raises: 158 | json.JSONDecodeError: If the content cannot be parsed 159 | """ 160 | ``` 161 | 162 | ### Type Hints 163 | 164 | Use type hints for function signatures: 165 | 166 | ```python 167 | from typing import Dict, Any 168 | 169 | def my_function(name: str, count: int) -> Dict[str, Any]: 170 | return {"name": name, "count": count} 171 | ``` 172 | 173 | ## Submitting Changes 174 | 175 | ### Commit Messages 176 | 177 | Write clear, descriptive commit messages: 178 | 179 | - Use the imperative mood ("Add feature" not "Added feature") 180 | - First line: brief summary (50 chars or less) 181 | - Leave a blank line 182 | - Add detailed description if needed 183 | 184 | Example: 185 | ``` 186 | Fix JSON5 parser handling of double slashes in strings 187 | 188 | The parser was incorrectly treating // inside string literals as 189 | comments, causing strings like "path//to//file" to be truncated. 190 | This fix adds proper string boundary tracking to prevent comment 191 | removal inside strings. 192 | 193 | Fixes #123 194 | ``` 195 | 196 | ### Pull Request Process 197 | 198 | 1. **Update tests**: Ensure all tests pass and add new tests for your changes 199 | 2. **Update documentation**: Update README.md or other docs as needed 200 | 3. **Run pre-commit**: Make sure pre-commit checks pass 201 | 4. **Create pull request**: 202 | - Provide a clear description of the changes 203 | - Reference any related issues 204 | - Explain why the change is needed 205 | 206 | 5. **Respond to feedback**: Address any review comments promptly 207 | 208 | ### Pull Request Checklist 209 | 210 | Before submitting a pull request, ensure: 211 | 212 | - [ ] All tests pass (`python runtests.py`) 213 | - [ ] New tests added for new features/bug fixes 214 | - [ ] Code follows the project's style guidelines 215 | - [ ] Documentation updated (if applicable) 216 | - [ ] Pre-commit hooks pass 217 | - [ ] Commit messages are clear and descriptive 218 | - [ ] Branch is up to date with main 219 | 220 | ## Reporting Bugs 221 | 222 | ### Before Reporting 223 | 224 | 1. Check if the bug has already been reported in [Issues](https://github.com/fiduswriter/django-npm-mjs/issues) 225 | 2. Verify you're using the latest version 226 | 3. Try to reproduce the bug with a minimal example 227 | 228 | ### Bug Report Template 229 | 230 | When reporting a bug, include: 231 | 232 | 1. **Description**: Clear description of the issue 233 | 2. **Steps to reproduce**: Minimal steps to reproduce the bug 234 | 3. **Expected behavior**: What you expected to happen 235 | 4. **Actual behavior**: What actually happened 236 | 5. **Environment**: 237 | - Python version 238 | - Django version 239 | - Operating system 240 | - django-npm-mjs version 241 | 6. **Code sample**: Minimal code that reproduces the issue 242 | 7. **Error message**: Full error message and stack trace 243 | 244 | Example: 245 | 246 | ```markdown 247 | ## Description 248 | JSON5 parser fails when parsing strings with double slashes 249 | 250 | ## Steps to Reproduce 251 | 1. Create a package.json5 with: `{ path: "some//path" }` 252 | 2. Run `./manage.py transpile` 253 | 254 | ## Expected Behavior 255 | Should parse successfully with path = "some//path" 256 | 257 | ## Actual Behavior 258 | Error: Unterminated string starting at line 1 259 | 260 | ## Environment 261 | - Python: 3.12 262 | - Django: 5.0 263 | - OS: Ubuntu 22.04 264 | - django-npm-mjs: 3.2.1 265 | 266 | ## Code Sample 267 | ```python 268 | from npm_mjs.json5_parser import parse_json5 269 | content = '{ path: "some//path" }' 270 | result = parse_json5(content) # Fails here 271 | ``` 272 | 273 | ## Error Message 274 | ``` 275 | json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 10 (char 9) 276 | ``` 277 | ``` 278 | 279 | ## Feature Requests 280 | 281 | We welcome feature requests! When suggesting a new feature: 282 | 283 | 1. **Check existing issues** to see if it's already been suggested 284 | 2. **Explain the use case**: Why is this feature needed? 285 | 3. **Describe the solution**: How should it work? 286 | 4. **Consider alternatives**: Are there other ways to achieve this? 287 | 5. **Breaking changes**: Note if this would break existing functionality 288 | 289 | ## Development Guidelines 290 | 291 | ### JSON5 Parser Development 292 | 293 | When modifying the JSON5 parser (`npm_mjs/json5_parser.py`): 294 | 295 | 1. **Maintain PyPy compatibility**: No C extensions 296 | 2. **Keep it simple**: The parser should be easy to understand 297 | 3. **Focus on package.json**: Prioritize features used in package.json files 298 | 4. **Add regression tests**: For every bug fix 299 | 5. **Update documentation**: Keep JSON5_PARSER_MIGRATION.md up to date 300 | 301 | ### Testing Philosophy 302 | 303 | - **Test behavior, not implementation**: Tests should verify what the code does, not how it does it 304 | - **Keep tests isolated**: Each test should be independent 305 | - **Use realistic examples**: Test with actual use cases 306 | - **Test error conditions**: Don't just test the happy path 307 | 308 | ### Performance Considerations 309 | 310 | - Keep in mind that this is build-time processing, not runtime 311 | - Optimize for correctness first, performance second 312 | - Profile before optimizing 313 | 314 | ## Questions? 315 | 316 | If you have questions: 317 | 318 | 1. Check the [README.md](README.md) for basic usage 319 | 2. Look at existing code and tests for examples 320 | 3. Open an issue for discussion 321 | 322 | ## License 323 | 324 | By contributing, you agree that your contributions will be licensed under the LGPL-3.0-or-later license. 325 | 326 | ## Code of Conduct 327 | 328 | Be respectful and constructive in all interactions. We want this to be a welcoming community for everyone. 329 | 330 | Thank you for contributing to django-npm-mjs! 331 | -------------------------------------------------------------------------------- /npm_mjs/management/commands/transpile.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import subprocess 5 | import time 6 | from subprocess import call 7 | from urllib.parse import urljoin 8 | 9 | from django.apps import apps 10 | from django.conf import settings 11 | from django.contrib.staticfiles import finders 12 | from django.core.management.base import BaseCommand 13 | from django.templatetags.static import PrefixNode 14 | 15 | from .collectstatic import Command as CSCommand 16 | from .npm_install import install_npm 17 | from npm_mjs import signals 18 | from npm_mjs.paths import PROJECT_PATH 19 | from npm_mjs.paths import STATIC_ROOT 20 | from npm_mjs.paths import TRANSPILE_CACHE_PATH 21 | from npm_mjs.tools import set_last_run 22 | 23 | # Run this script every time you update an *.mjs file or any of the 24 | # modules it loads. 25 | 26 | OLD_RSPACK_CONFIG_JS = "" 27 | 28 | RSPACK_CONFIG_JS_PATH = os.path.join(TRANSPILE_CACHE_PATH, "rspack.config.js") 29 | 30 | try: 31 | with open(RSPACK_CONFIG_JS_PATH) as file: 32 | OLD_RSPACK_CONFIG_JS = file.read() 33 | except OSError: 34 | pass 35 | 36 | 37 | class Command(BaseCommand): 38 | help = ( 39 | "Transpile ES2015+ JavaScript to ES5 JavaScript + include NPM " "dependencies" 40 | ) 41 | 42 | def add_arguments(self, parser): 43 | parser.add_argument( 44 | "--force", 45 | action="store_true", 46 | dest="force", 47 | default=False, 48 | help="Force transpile even if no change is detected.", 49 | ) 50 | 51 | def handle(self, *args, **options): 52 | if options["force"]: 53 | force = True 54 | else: 55 | force = False 56 | start = int(round(time.time())) 57 | npm_install = install_npm(force, self.stdout) 58 | js_paths = finders.find("js/", True) 59 | # Remove paths inside of collection dir 60 | js_paths = [x for x in js_paths if not x.startswith(STATIC_ROOT)] 61 | # Reverse list so that overrides function as expected. Static file from 62 | # first app mentioned in INSTALLED_APPS has preference. 63 | js_paths.reverse() 64 | 65 | transpile_path = os.path.join(PROJECT_PATH, "static-transpile") 66 | 67 | if os.path.exists(transpile_path): 68 | files = [] 69 | for js_path in js_paths: 70 | for root, _dirnames, filenames in os.walk(js_path): 71 | for filename in filenames: 72 | files.append(os.path.join(root, filename)) 73 | newest_file = max(files, key=os.path.getmtime) 74 | if ( 75 | os.path.commonprefix([newest_file, transpile_path]) == transpile_path 76 | and not npm_install 77 | and not force 78 | ): 79 | # Transpile not needed as nothing has changed and not forced 80 | return 81 | # Remove any previously created static output dirs 82 | shutil.rmtree(transpile_path, ignore_errors=True) 83 | self.stdout.write("Transpiling...") 84 | os.makedirs(TRANSPILE_CACHE_PATH, exist_ok=True) 85 | # We reload the file as other values may have changed in the meantime 86 | set_last_run("transpile", start) 87 | # Create a static output dir 88 | out_dir = os.path.join(transpile_path, "js/") 89 | os.makedirs(out_dir, exist_ok=True) 90 | with open(os.path.join(transpile_path, "README.txt"), "w") as f: 91 | f.write( 92 | "These files have been automatically generated. " 93 | "DO NOT EDIT THEM! \n Changes will be overwritten. Edit " 94 | "the original files in one of the django apps, and run " 95 | "./manage.py transpile.", 96 | ) 97 | 98 | mainfiles = [] 99 | sourcefiles = [] 100 | lib_sourcefiles = [] 101 | for path in js_paths: 102 | for mainfile in ( 103 | subprocess.check_output( 104 | ["find", path, "-type", "f", "-name", "*.mjs", "-print"], 105 | ) 106 | .decode("utf-8") 107 | .split("\n")[:-1] 108 | ): 109 | mainfiles.append(mainfile) 110 | for sourcefile in ( 111 | subprocess.check_output( 112 | ["find", path, "-type", "f", "-wholename", "*js"], 113 | ) 114 | .decode("utf-8") 115 | .split("\n")[:-1] 116 | ): 117 | if "static/js" in sourcefile: 118 | sourcefiles.append(sourcefile) 119 | if "static-libs/js" in sourcefile: 120 | lib_sourcefiles.append(sourcefile) 121 | # Collect all JavaScript in a temporary dir (similar to 122 | # ./manage.py collectstatic). 123 | # This allows for the modules to import from oneanother, across Django 124 | # Apps. 125 | 126 | cache_path = os.path.join(TRANSPILE_CACHE_PATH, "js/") 127 | os.makedirs(cache_path, exist_ok=True) 128 | # Note all cache files so that we can remove outdated files that no 129 | # longer are in the prject. 130 | cache_files = [] 131 | # Note all plugin dirs and the modules inside of them to crate index.js 132 | # files inside of them. 133 | plugin_dirs = {} 134 | for sourcefile in sourcefiles: 135 | relative_path = sourcefile.split("static/js/")[1] 136 | outfile = os.path.join(cache_path, relative_path) 137 | cache_files.append(outfile) 138 | dirname = os.path.dirname(outfile) 139 | os.makedirs(dirname, exist_ok=True) 140 | shutil.copyfile(sourcefile, outfile) 141 | # Check for plugin connectors 142 | if relative_path[:8] == "plugins/": 143 | if dirname not in plugin_dirs: 144 | plugin_dirs[dirname] = [] 145 | module_name = os.path.splitext(os.path.basename(relative_path))[0] 146 | if module_name != "init" and module_name not in plugin_dirs[dirname]: 147 | plugin_dirs[dirname].append(module_name) 148 | 149 | for sourcefile in lib_sourcefiles: 150 | relative_path = sourcefile.split("static-libs/js/")[1] 151 | outfile = os.path.join(cache_path, relative_path) 152 | cache_files.append(outfile) 153 | dirname = os.path.dirname(outfile) 154 | if not os.path.exists(dirname): 155 | os.makedirs(dirname) 156 | shutil.copyfile(sourcefile, outfile) 157 | elif not os.path.isfile(outfile): 158 | shutil.copyfile(sourcefile, outfile) 159 | elif os.path.getmtime(outfile) < os.path.getmtime(sourcefile): 160 | shutil.copyfile(sourcefile, outfile) 161 | 162 | # Write an index.js file for every plugin dir 163 | for plugin_dir in plugin_dirs: 164 | index_js = "" 165 | for module_name in plugin_dirs[plugin_dir]: 166 | index_js += 'export * from "./%s"\n' % module_name 167 | outfile = os.path.join(plugin_dir, "index.js") 168 | cache_files.append(outfile) 169 | if not os.path.isfile(outfile): 170 | index_file = open(outfile, "w") 171 | index_file.write(index_js) 172 | index_file.close() 173 | else: 174 | index_file = open(outfile) 175 | old_index_js = index_file.read() 176 | index_file.close() 177 | if old_index_js != index_js: 178 | index_file = open(outfile, "w") 179 | index_file.write(index_js) 180 | index_file.close() 181 | 182 | # Check for outdated files that should be removed 183 | for existing_file in ( 184 | subprocess.check_output(["find", cache_path, "-type", "f"]) 185 | .decode("utf-8") 186 | .split("\n")[:-1] 187 | ): 188 | if existing_file not in cache_files: 189 | self.stdout.write("Removing %s" % existing_file) 190 | os.remove(existing_file) 191 | if apps.is_installed("django.contrib.staticfiles"): 192 | from django.contrib.staticfiles.storage import staticfiles_storage 193 | 194 | static_base_url = staticfiles_storage.base_url 195 | else: 196 | static_base_url = PrefixNode.handle_simple("STATIC_URL") 197 | transpile_base_url = urljoin(static_base_url, "js/") 198 | if ( 199 | hasattr(settings, "RSPACK_CONFIG_TEMPLATE") 200 | and settings.RSPACK_CONFIG_TEMPLATE 201 | ): 202 | rspack_config_template_path = settings.RSPACK_CONFIG_TEMPLATE 203 | else: 204 | rspack_config_template_path = os.path.join( 205 | os.path.dirname(os.path.realpath(__file__)), 206 | "rspack.config.template.js", 207 | ) 208 | entries = {} 209 | for mainfile in mainfiles: 210 | basename = os.path.basename(mainfile) 211 | modulename = basename.split(".")[0] 212 | file_path = os.path.join(cache_path, basename) 213 | entries[modulename] = file_path 214 | find_static = CSCommand() 215 | find_static.set_options( 216 | **{ 217 | "interactive": False, 218 | "verbosity": 0, 219 | "link": False, 220 | "clear": False, 221 | "dry_run": True, 222 | "ignore_patterns": ["js/", "admin/"], 223 | "use_default_ignore_patterns": True, 224 | "post_process": True, 225 | }, 226 | ) 227 | found_files = find_static.collect() 228 | static_frontend_files = ( 229 | found_files["modified"] 230 | + found_files["unmodified"] 231 | + found_files["post_processed"] 232 | ) 233 | transpile = { 234 | "OUT_DIR": out_dir, 235 | "VERSION": start, 236 | "BASE_URL": transpile_base_url, 237 | "ENTRIES": entries, 238 | "STATIC_FRONTEND_FILES": [ 239 | urljoin(static_base_url, x) for x in static_frontend_files 240 | ], 241 | } 242 | with open(rspack_config_template_path) as f: 243 | rspack_config_template = f.read() 244 | settings_dict = {} 245 | for var in dir(settings): 246 | if var in ["DATABASES", "SECRET_KEY"]: 247 | # For extra security, we do not copy DATABASES or SECRET_KEY 248 | continue 249 | try: 250 | settings_dict[var] = getattr(settings, var) 251 | except AttributeError: 252 | pass 253 | rspack_config_js = rspack_config_template.replace( 254 | "window.transpile", 255 | json.dumps(transpile), 256 | ).replace("window.settings", json.dumps(settings_dict, default=lambda x: False)) 257 | 258 | if rspack_config_js is not OLD_RSPACK_CONFIG_JS: 259 | with open(RSPACK_CONFIG_JS_PATH, "w") as f: 260 | f.write(rspack_config_js) 261 | call(["./node_modules/.bin/rspack"], cwd=TRANSPILE_CACHE_PATH) 262 | end = int(round(time.time())) 263 | self.stdout.write("Time spent transpiling: " + str(end - start) + " seconds") 264 | signals.post_transpile.send(sender=None) 265 | -------------------------------------------------------------------------------- /npm_mjs/tests/test_json5_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Comprehensive test suite for the JSON5 parser. 3 | 4 | Tests cover: 5 | - Comment removal (single-line and multi-line) 6 | - Quote conversion (single to double) 7 | - Unquoted keys 8 | - Trailing commas 9 | - Edge cases with strings containing special characters 10 | - URL preservation in strings 11 | - Mixed quote types 12 | - Escape sequences 13 | - Complex realistic scenarios 14 | """ 15 | 16 | import json 17 | import os 18 | import tempfile 19 | import unittest 20 | 21 | from npm_mjs.json5_parser import dump_json5 22 | from npm_mjs.json5_parser import encode_json5 23 | from npm_mjs.json5_parser import load_json5 24 | from npm_mjs.json5_parser import parse_json5 25 | 26 | 27 | class TestJSON5ParserBasics(unittest.TestCase): 28 | """Test basic JSON5 parsing functionality.""" 29 | 30 | def test_basic_unquoted_key(self): 31 | """Test parsing unquoted object keys.""" 32 | content = '{ key: "value" }' 33 | result = parse_json5(content) 34 | self.assertEqual(result, {"key": "value"}) 35 | 36 | def test_quoted_key_with_special_chars(self): 37 | """Test parsing keys with special characters (already quoted).""" 38 | content = '{ "@rspack/core": "1.6.7" }' 39 | result = parse_json5(content) 40 | self.assertEqual(result, {"@rspack/core": "1.6.7"}) 41 | 42 | def test_standard_json(self): 43 | """Test that standard JSON still works.""" 44 | content = '{"name": "test", "version": "1.0.0"}' 45 | result = parse_json5(content) 46 | self.assertEqual(result, {"name": "test", "version": "1.0.0"}) 47 | 48 | def test_nested_objects(self): 49 | """Test parsing nested objects.""" 50 | content = """ 51 | { 52 | dependencies: { 53 | "@rspack/core": "1.6.7", 54 | "other": "1.0.0" 55 | } 56 | } 57 | """ 58 | result = parse_json5(content) 59 | self.assertEqual( 60 | result, 61 | {"dependencies": {"@rspack/core": "1.6.7", "other": "1.0.0"}}, 62 | ) 63 | 64 | def test_arrays(self): 65 | """Test parsing arrays.""" 66 | content = '{ items: ["a", "b", "c"] }' 67 | result = parse_json5(content) 68 | self.assertEqual(result, {"items": ["a", "b", "c"]}) 69 | 70 | def test_mixed_types(self): 71 | """Test parsing various JSON types.""" 72 | content = """ 73 | { 74 | string: "text", 75 | number: 42, 76 | float: 3.14, 77 | bool_true: true, 78 | bool_false: false, 79 | null_value: null 80 | } 81 | """ 82 | result = parse_json5(content) 83 | self.assertEqual( 84 | result, 85 | { 86 | "string": "text", 87 | "number": 42, 88 | "float": 3.14, 89 | "bool_true": True, 90 | "bool_false": False, 91 | "null_value": None, 92 | }, 93 | ) 94 | 95 | 96 | class TestJSON5Comments(unittest.TestCase): 97 | """Test comment removal functionality.""" 98 | 99 | def test_single_line_comment_start(self): 100 | """Test single-line comment at the start.""" 101 | content = """ 102 | // This is a comment 103 | { key: "value" } 104 | """ 105 | result = parse_json5(content) 106 | self.assertEqual(result, {"key": "value"}) 107 | 108 | def test_single_line_comment_inline(self): 109 | """Test single-line comment after a value.""" 110 | content = """ 111 | { 112 | key: "value", // This is a comment 113 | other: "value2" 114 | } 115 | """ 116 | result = parse_json5(content) 117 | self.assertEqual(result, {"key": "value", "other": "value2"}) 118 | 119 | def test_multi_line_comment(self): 120 | """Test multi-line comment removal.""" 121 | content = """ 122 | /* This is a 123 | multi-line comment */ 124 | { key: "value" } 125 | """ 126 | result = parse_json5(content) 127 | self.assertEqual(result, {"key": "value"}) 128 | 129 | def test_multi_line_comment_inline(self): 130 | """Test inline multi-line comment.""" 131 | content = """ 132 | { 133 | /* comment */ key: "value" 134 | } 135 | """ 136 | result = parse_json5(content) 137 | self.assertEqual(result, {"key": "value"}) 138 | 139 | def test_comment_with_url(self): 140 | """Test that URLs in comments don't break parsing.""" 141 | content = """ 142 | { 143 | // See https://example.com for more info 144 | name: "test", 145 | url: "https://github.com/user/repo" // GitHub repo 146 | } 147 | """ 148 | result = parse_json5(content) 149 | self.assertEqual( 150 | result, 151 | {"name": "test", "url": "https://github.com/user/repo"}, 152 | ) 153 | 154 | def test_multi_line_comment_with_slashes(self): 155 | """Test multi-line comment containing // inside.""" 156 | content = """ 157 | { 158 | /* This is a comment with https://example.com 159 | and some // slashes too */ 160 | name: "test" 161 | } 162 | """ 163 | result = parse_json5(content) 164 | self.assertEqual(result, {"name": "test"}) 165 | 166 | 167 | class TestJSON5StringsWithSlashes(unittest.TestCase): 168 | """Test that // inside strings is preserved correctly.""" 169 | 170 | def test_double_slashes_in_string(self): 171 | """Test string with // that are not comments.""" 172 | content = '{ path: "some//path//with//slashes" }' 173 | result = parse_json5(content) 174 | self.assertEqual(result, {"path": "some//path//with//slashes"}) 175 | 176 | def test_url_in_string(self): 177 | """Test URL preservation in strings.""" 178 | content = """ 179 | { 180 | url: "https://example.com", 181 | homepage: "https://github.com/user/repo" 182 | } 183 | """ 184 | result = parse_json5(content) 185 | self.assertEqual( 186 | result, 187 | {"url": "https://example.com", "homepage": "https://github.com/user/repo"}, 188 | ) 189 | 190 | def test_comment_marker_in_string(self): 191 | """Test that // in string is not treated as comment.""" 192 | content = '{ comment: "This is not a // comment" }' 193 | result = parse_json5(content) 194 | self.assertEqual(result, {"comment": "This is not a // comment"}) 195 | 196 | def test_mixed_slashes(self): 197 | """Test various slash patterns in strings.""" 198 | content = """ 199 | { 200 | single: "a/b/c", 201 | double: "a//b//c", 202 | url: "http://example.com", 203 | path: "C://Users//test" 204 | } 205 | """ 206 | result = parse_json5(content) 207 | self.assertEqual( 208 | result, 209 | { 210 | "single": "a/b/c", 211 | "double": "a//b//c", 212 | "url": "http://example.com", 213 | "path": "C://Users//test", 214 | }, 215 | ) 216 | 217 | 218 | class TestJSON5Quotes(unittest.TestCase): 219 | """Test single and double quote handling.""" 220 | 221 | def test_single_quoted_string(self): 222 | """Test single-quoted string conversion.""" 223 | content = "{ key: 'value' }" 224 | result = parse_json5(content) 225 | self.assertEqual(result, {"key": "value"}) 226 | 227 | def test_single_quotes_with_double_inside(self): 228 | """Test single-quoted string containing double quotes.""" 229 | content = "{ single: 'value with \"quotes\"' }" 230 | result = parse_json5(content) 231 | self.assertEqual(result, {"single": 'value with "quotes"'}) 232 | 233 | def test_double_quotes_with_single_inside(self): 234 | """Test double-quoted string containing single quotes.""" 235 | content = "{ double: \"value with 'quotes'\" }" 236 | result = parse_json5(content) 237 | self.assertEqual(result, {"double": "value with 'quotes'"}) 238 | 239 | def test_mixed_quote_types(self): 240 | """Test mixing single and double quotes.""" 241 | content = """ 242 | { 243 | single: 'value1', 244 | double: "value2", 245 | mixed1: 'has "double" inside', 246 | mixed2: "has 'single' inside" 247 | } 248 | """ 249 | result = parse_json5(content) 250 | self.assertEqual( 251 | result, 252 | { 253 | "single": "value1", 254 | "double": "value2", 255 | "mixed1": 'has "double" inside', 256 | "mixed2": "has 'single' inside", 257 | }, 258 | ) 259 | 260 | def test_url_with_single_quotes(self): 261 | """Test URL in single-quoted string.""" 262 | content = "{ url: 'https://example.com' }" 263 | result = parse_json5(content) 264 | self.assertEqual(result, {"url": "https://example.com"}) 265 | 266 | 267 | class TestJSON5EscapeSequences(unittest.TestCase): 268 | """Test escape sequence handling.""" 269 | 270 | def test_escaped_double_quotes(self): 271 | """Test escaped double quotes in strings.""" 272 | content = r'{ message: "He said \"hello\"" }' 273 | result = parse_json5(content) 274 | self.assertEqual(result, {"message": 'He said "hello"'}) 275 | 276 | def test_escaped_backslash(self): 277 | """Test escaped backslashes.""" 278 | content = r'{ path: "C:\\Users\\test\\path" }' 279 | result = parse_json5(content) 280 | self.assertEqual(result, {"path": r"C:\Users\test\path"}) 281 | 282 | def test_escaped_newline(self): 283 | """Test escaped newline character.""" 284 | content = r'{ text: "line1\nline2" }' 285 | result = parse_json5(content) 286 | self.assertEqual(result, {"text": "line1\nline2"}) 287 | 288 | def test_escaped_tab(self): 289 | """Test escaped tab character.""" 290 | content = r'{ text: "col1\tcol2" }' 291 | result = parse_json5(content) 292 | self.assertEqual(result, {"text": "col1\tcol2"}) 293 | 294 | def test_mixed_escapes_and_slashes(self): 295 | """Test combination of escapes and slashes.""" 296 | content = r'{ message: "He said \"hello // world\"" }' 297 | result = parse_json5(content) 298 | self.assertEqual(result, {"message": 'He said "hello // world"'}) 299 | 300 | 301 | class TestJSON5TrailingCommas(unittest.TestCase): 302 | """Test trailing comma handling.""" 303 | 304 | def test_trailing_comma_object(self): 305 | """Test trailing comma in object.""" 306 | content = """ 307 | { 308 | key1: "value1", 309 | key2: "value2", 310 | } 311 | """ 312 | result = parse_json5(content) 313 | self.assertEqual(result, {"key1": "value1", "key2": "value2"}) 314 | 315 | def test_trailing_comma_array(self): 316 | """Test trailing comma in array.""" 317 | content = """ 318 | { 319 | items: [ 320 | "item1", 321 | "item2", 322 | ] 323 | } 324 | """ 325 | result = parse_json5(content) 326 | self.assertEqual(result, {"items": ["item1", "item2"]}) 327 | 328 | def test_trailing_comma_nested(self): 329 | """Test trailing commas in nested structures.""" 330 | content = """ 331 | { 332 | obj: { 333 | key: "value", 334 | }, 335 | arr: ["a", "b",], 336 | } 337 | """ 338 | result = parse_json5(content) 339 | self.assertEqual(result, {"obj": {"key": "value"}, "arr": ["a", "b"]}) 340 | 341 | def test_multiple_trailing_commas(self): 342 | """Test multiple trailing commas in same structure.""" 343 | content = """ 344 | { 345 | a: "1", 346 | b: "2", 347 | c: "3", 348 | } 349 | """ 350 | result = parse_json5(content) 351 | self.assertEqual(result, {"a": "1", "b": "2", "c": "3"}) 352 | 353 | 354 | class TestJSON5ComplexScenarios(unittest.TestCase): 355 | """Test complex realistic scenarios.""" 356 | 357 | def test_package_json5_example(self): 358 | """Test a realistic package.json5 file.""" 359 | content = """ 360 | // Package configuration 361 | { 362 | name: "my-package", 363 | version: "1.0.0", 364 | description: "A package with // in description", 365 | // Repository info 366 | repository: { 367 | type: "git", 368 | url: "https://github.com/user/repo.git" // Main repo 369 | }, 370 | scripts: { 371 | test: "echo \\"test\\"", 372 | build: "npm run compile" 373 | }, 374 | dependencies: { 375 | "@org/package": "^1.0.0", 376 | "another": "2.0.0", // Latest version 377 | }, 378 | /* Multi-line comment 379 | with various content 380 | including https://example.com 381 | and // comment markers */ 382 | author: "John Doe" 383 | } 384 | """ 385 | result = parse_json5(content) 386 | self.assertEqual(result["name"], "my-package") 387 | self.assertEqual(result["version"], "1.0.0") 388 | self.assertEqual(result["description"], "A package with // in description") 389 | self.assertEqual( 390 | result["repository"]["url"], 391 | "https://github.com/user/repo.git", 392 | ) 393 | self.assertEqual(result["scripts"]["test"], 'echo "test"') 394 | self.assertEqual(result["dependencies"]["@org/package"], "^1.0.0") 395 | self.assertEqual(result["author"], "John Doe") 396 | 397 | def test_npm_mjs_package_json5(self): 398 | """Test the actual package.json5 from this project.""" 399 | content = """ 400 | // Django-npm-mjs will combine this file with package.json files in other installed 401 | // apps before executing npm commands. Different from a regular package.json, comments 402 | // are allowed in this file. 403 | { 404 | description: "Install dependencies for ES6 transpilation", 405 | private: true, 406 | dependencies: { 407 | "@rspack/core": "1.6.7", 408 | "@rspack/cli": "1.6.7" 409 | }, 410 | } 411 | """ 412 | result = parse_json5(content) 413 | self.assertEqual( 414 | result["description"], 415 | "Install dependencies for ES6 transpilation", 416 | ) 417 | self.assertTrue(result["private"]) 418 | self.assertEqual(result["dependencies"]["@rspack/core"], "1.6.7") 419 | self.assertEqual(result["dependencies"]["@rspack/cli"], "1.6.7") 420 | 421 | def test_long_description_line(self): 422 | """Test parsing with a very long description (regression test for char 178 error).""" 423 | long_desc = ( 424 | "This is a very long description that should push us past " 425 | "character 178 when combined with the other content" 426 | ) 427 | content = f""" 428 | {{ 429 | name: "test-package", 430 | version: "1.0.0", 431 | description: "{long_desc}", 432 | author: "Test Author", 433 | dependencies: {{ 434 | "package1": "^1.0.0" 435 | }} 436 | }} 437 | """ 438 | result = parse_json5(content) 439 | self.assertEqual(result["name"], "test-package") 440 | self.assertIn("very long description", result["description"]) 441 | 442 | def test_inline_objects_and_arrays(self): 443 | """Test compact inline notation.""" 444 | content = """ 445 | { 446 | version: "1.0.0", 447 | engines: { node: ">=14.0.0", npm: ">=6.0.0" }, 448 | keywords: ["django", "npm", "es6"], 449 | main: "index.js" 450 | } 451 | """ 452 | result = parse_json5(content) 453 | self.assertEqual(result["engines"]["node"], ">=14.0.0") 454 | self.assertEqual(result["keywords"], ["django", "npm", "es6"]) 455 | 456 | def test_empty_structures(self): 457 | """Test empty objects and arrays.""" 458 | content = """ 459 | { 460 | empty_obj: {}, 461 | empty_arr: [], 462 | obj_with_empty: { nested: {} }, 463 | arr_with_empty: [[]] 464 | } 465 | """ 466 | result = parse_json5(content) 467 | self.assertEqual(result["empty_obj"], {}) 468 | self.assertEqual(result["empty_arr"], []) 469 | self.assertEqual(result["obj_with_empty"]["nested"], {}) 470 | self.assertEqual(result["arr_with_empty"], [[]]) 471 | 472 | 473 | class TestJSON5EdgeCases(unittest.TestCase): 474 | """Test edge cases and boundary conditions.""" 475 | 476 | def test_comment_at_end_of_file(self): 477 | """Test comment at the very end.""" 478 | content = """ 479 | { key: "value" } 480 | // Final comment 481 | """ 482 | result = parse_json5(content) 483 | self.assertEqual(result, {"key": "value"}) 484 | 485 | def test_multiple_keys_one_line(self): 486 | """Test multiple keys on a single line.""" 487 | content = '{ key1: "value1", key2: "value2", key3: "value3" }' 488 | result = parse_json5(content) 489 | self.assertEqual(result, {"key1": "value1", "key2": "value2", "key3": "value3"}) 490 | 491 | def test_whitespace_variations(self): 492 | """Test various whitespace patterns.""" 493 | content = """ 494 | { 495 | key1 : "value1" , 496 | key2:"value2", 497 | key3 : "value3" 498 | } 499 | """ 500 | result = parse_json5(content) 501 | self.assertEqual(result, {"key1": "value1", "key2": "value2", "key3": "value3"}) 502 | 503 | def test_unicode_strings(self): 504 | """Test Unicode content in strings.""" 505 | content = '{ text: "Hello 世界 🌍" }' 506 | result = parse_json5(content) 507 | self.assertEqual(result, {"text": "Hello 世界 🌍"}) 508 | 509 | def test_numbers_with_underscores_not_supported(self): 510 | """Test that number separators are handled (pass through to JSON parser).""" 511 | # JSON5 supports underscores in numbers, but Python's json doesn't 512 | # Our parser just passes through, so this will fail in json.loads 513 | content = "{ number: 1_000_000 }" 514 | with self.assertRaises(json.JSONDecodeError): 515 | parse_json5(content) 516 | 517 | def test_glob_patterns(self): 518 | """Test glob patterns often found in package.json.""" 519 | content = """ 520 | { 521 | files: ["src/**/*.js", "dist/**/*.mjs"], 522 | pattern: "**/*.test.js" 523 | } 524 | """ 525 | result = parse_json5(content) 526 | self.assertEqual(result["files"], ["src/**/*.js", "dist/**/*.mjs"]) 527 | self.assertEqual(result["pattern"], "**/*.test.js") 528 | 529 | 530 | class TestJSON5FileOperations(unittest.TestCase): 531 | """Test file loading and saving operations.""" 532 | 533 | def test_load_json5_file(self): 534 | """Test loading a JSON5 file.""" 535 | with tempfile.NamedTemporaryFile(mode="w", suffix=".json5", delete=False) as f: 536 | f.write('{ key: "value", number: 42 }') 537 | temp_path = f.name 538 | 539 | try: 540 | result = load_json5(temp_path) 541 | self.assertEqual(result, {"key": "value", "number": 42}) 542 | finally: 543 | os.unlink(temp_path) 544 | 545 | def test_dump_json5_file(self): 546 | """Test dumping data to a JSON file.""" 547 | data = {"key": "value", "number": 42} 548 | 549 | with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: 550 | temp_path = f.name 551 | 552 | try: 553 | dump_json5(data, temp_path) 554 | 555 | # Read back and verify 556 | with open(temp_path) as f: 557 | content = f.read() 558 | result = json.loads(content) 559 | 560 | self.assertEqual(result, data) 561 | finally: 562 | os.unlink(temp_path) 563 | 564 | def test_encode_json5(self): 565 | """Test encoding data to JSON string.""" 566 | data = {"key": "value", "nested": {"inner": "data"}} 567 | result = encode_json5(data) 568 | 569 | # Should be valid JSON 570 | parsed = json.loads(result) 571 | self.assertEqual(parsed, data) 572 | 573 | # Should be formatted with indentation 574 | self.assertIn("\n", result) 575 | self.assertIn(" ", result) 576 | 577 | 578 | class TestJSON5ErrorHandling(unittest.TestCase): 579 | """Test error handling and edge cases.""" 580 | 581 | def test_invalid_json_after_processing(self): 582 | """Test that invalid JSON raises appropriate error.""" 583 | content = '{ key: "value", }' # Trailing comma before } 584 | # This should actually work due to our trailing comma removal 585 | result = parse_json5(content) 586 | self.assertEqual(result, {"key": "value"}) 587 | 588 | def test_unclosed_string(self): 589 | """Test that unclosed string raises error.""" 590 | content = '{ key: "value }' 591 | with self.assertRaises(json.JSONDecodeError): 592 | parse_json5(content) 593 | 594 | def test_unclosed_object(self): 595 | """Test that unclosed object raises error.""" 596 | content = '{ key: "value"' 597 | with self.assertRaises(json.JSONDecodeError): 598 | parse_json5(content) 599 | 600 | def test_unclosed_array(self): 601 | """Test that unclosed array raises error.""" 602 | content = '{ arr: ["item1", "item2" }' 603 | with self.assertRaises(json.JSONDecodeError): 604 | parse_json5(content) 605 | 606 | def test_empty_string(self): 607 | """Test parsing empty string.""" 608 | with self.assertRaises(json.JSONDecodeError): 609 | parse_json5("") 610 | 611 | def test_only_comment(self): 612 | """Test parsing file with only comments.""" 613 | content = "// Just a comment" 614 | with self.assertRaises(json.JSONDecodeError): 615 | parse_json5(content) 616 | 617 | 618 | if __name__ == "__main__": 619 | unittest.main() 620 | --------------------------------------------------------------------------------