├── .github └── workflows │ └── code_quality_check.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE.txt ├── Makefile ├── README.rst ├── django_node_assets ├── __init__.py ├── finders.py └── management │ ├── __init__.py │ └── commands │ ├── __init__.py │ └── npminstall.py └── pyproject.toml /.github/workflows/code_quality_check.yml: -------------------------------------------------------------------------------- 1 | name: Code quality check 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 3.13 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: "3.13" 15 | 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | python -m pip install ruff 20 | 21 | - name: Check code quality 22 | run: | 23 | python -m ruff check 24 | python -m ruff format --check 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cow] 2 | .idea 3 | .ruff_cache 4 | build/ 5 | dist/ 6 | django_node_assets.egg-info/ 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | Changelog 3 | ######### 4 | 5 | Release 0.9.15 6 | -------------- 7 | 8 | - Added Django 5.2 support 9 | 10 | Release 0.9.14 11 | -------------- 12 | 13 | - Added the NODE_PACKAGE_MANAGER_INSTALL_OPTIONS setting 14 | - Declared compatibility with Python 3.12 and Django 5.0 15 | - Removed Django 3.2/4.1 versions support 16 | 17 | Release 0.9.13 18 | -------------- 19 | 20 | - Moved package configuration to pyproject.toml 21 | - Added feature to get path to npm executable automatically by using shutil.which function, thanks to `prplecake `_ 22 | - Declared compatibility with Django 4.2 23 | 24 | Release 0.9.12 25 | -------------- 26 | 27 | - Fixed a typo in README.rst, thanks to `proofit404 `_ 28 | - Declared compatibility with Python 3.11 and Django 4.1 29 | 30 | Release 0.9.11 31 | -------------- 32 | 33 | - Reformatted code with Black 34 | - Replaced subprocess.Popen by subprocess.check_output method to call npm install command 35 | - Replaced os, os.path, shutil module features by pathlib module features 36 | - Remove Python3.6 and Django 2.1/3.0/3.1 versions support, add Django 4.0 support 37 | 38 | Release 0.9.10 39 | -------------- 40 | 41 | - Declared compatibility with Python 3.10 42 | 43 | Release 0.9.9 44 | ------------- 45 | 46 | - Sorted imports by isort 47 | - Updated documentation 48 | 49 | Release 0.9.8 50 | ------------- 51 | 52 | - Dropped Python 3.4, Python 3.5 and Django 2.0 support 53 | - Declared compatibility with Python 3.9 and Django 3.1 54 | 55 | Release 0.9.7 56 | ------------- 57 | 58 | - Fixed package.json copying 59 | 60 | Release 0.9.6 61 | ------------- 62 | 63 | - Added __init__.py files to management and management/commands directories to build distributive properly (issue https://github.com/pypa/setuptools/issues/97) 64 | 65 | Release 0.9.5 66 | ------------- 67 | 68 | - Stopped using symbolic link to copy package.json (issue https://github.com/whitespy/django-node-assets/issues/2) 69 | 70 | Release 0.9.4 71 | ------------- 72 | 73 | - Fixed NodeModulesFinder.find method 74 | 75 | Release 0.9.3 76 | ------------- 77 | 78 | - Improved the npminstall management command 79 | - Changed imports order 80 | 81 | Release 0.9.2 82 | ------------- 83 | 84 | - Updated README.rst 85 | 86 | Release 0.9.1 87 | ------------- 88 | 89 | - Supplemented README.rst 90 | 91 | Release 0.9.0 92 | ------------- 93 | 94 | - Initial release 95 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Andrey Butenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check-code fix-code build-dist check-dist upload-dist clean 2 | 3 | check-code: 4 | @python -m ruff check 5 | @python -m ruff format --check 6 | 7 | fix-code: 8 | @python -m ruff check --fix 9 | @python -m ruff format 10 | 11 | build-dist: clean 12 | @python -m build 13 | 14 | check-dist: 15 | @python -m twine check dist/* 16 | 17 | upload-dist: 18 | @python -m twine upload dist/* 19 | 20 | clean: 21 | @rm -rf dist/ django_node_assets.egg-info/ 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ################## 2 | Django-node-assets 3 | ################## 4 | 5 | .. image:: https://badge.fury.io/py/django-node-assets.svg 6 | :target: https://badge.fury.io/py/django-node-assets 7 | 8 | .. image:: https://img.shields.io/badge/linter-ruff-FF69B4 9 | :target: https://github.com/astral-sh/ruff 10 | 11 | .. image:: https://github.com/whitespy/django-node-assets/actions/workflows/code_quality_check.yml/badge.svg 12 | :target: https://github.com/whitespy/django-node-assets/actions/workflows/code_quality_check.yml 13 | 14 | | 15 | 16 | The Django application that allows to install and to serve static assets via Node.js package manager infrastructure. 17 | The application exposes management command to install dependencies from your **package.json** and several static files 18 | finders to find files from installed node packages and exclude metadata of node packages and unwanted files when 19 | static files will be collected via Django`s **collectstatic** management command execution. 20 | 21 | Features 22 | -------- 23 | 24 | - Avoiding vendoring static assets in your repository like jQuery plugins, Bootstrap toolkit, etc 25 | - Avoiding mess in **STATIC_ROOT** through exclusion node packages` metatadata and unwanted files 26 | - Installing dependencies by Django`s management command 27 | 28 | Installation 29 | ------------ 30 | 31 | .. code:: bash 32 | 33 | $ pip install django-node-assets 34 | 35 | Configuration 36 | ------------- 37 | 38 | Add 'django_node_assets' to your INSTALLED_APPS: 39 | 40 | .. code:: python 41 | 42 | INSTALLED_APPS = [ 43 | ... 44 | "django_node_assets", 45 | ] 46 | 47 | Add NodeModulesFinder to STATICFILES_FINDERS: 48 | 49 | .. code:: python 50 | 51 | STATICFILES_FINDERS = [ 52 | "django.contrib.staticfiles.finders.FileSystemFinder", 53 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 54 | "django_node_assets.finders.NodeModulesFinder", 55 | ] 56 | 57 | Specify absolute path to the package.json file: 58 | 59 | .. code:: python 60 | 61 | NODE_PACKAGE_JSON = "/var/assets/package.json" 62 | 63 | .. note:: 64 | 65 | A package.json must have the "dependencies" section and look like: 66 | 67 | .. code:: json 68 | 69 | { 70 | "dependencies": { 71 | "jquery": "^3.2.1", 72 | "bootstrap": "^3.3.5" 73 | } 74 | } 75 | 76 | Details here: https://docs.npmjs.com/files/package.json#dependencies 77 | 78 | 79 | Specify the absolute path to a directory where the **npminstall** management command will install assets: 80 | 81 | .. code:: python 82 | 83 | NODE_MODULES_ROOT = "/var/assets/node_modules" 84 | 85 | .. note:: 86 | 87 | A base dir must be called **node_modules**. 88 | 89 | Override path to the node package manager executable (optional) 90 | 91 | .. code:: python 92 | 93 | NODE_PACKAGE_MANAGER_EXECUTABLE = "/usr/local/bin/npm" 94 | 95 | .. note:: 96 | 97 | The node package manager must be already installed in your system. 98 | 99 | Override options of the node package manager install command (optional) 100 | 101 | .. code:: python 102 | 103 | NODE_PACKAGE_MANAGER_INSTALL_OPTIONS = ["--dry-run"] 104 | 105 | Defaults to **--no-package-lock**, **--production**. 106 | 107 | Usage 108 | ----- 109 | 110 | Call the **npminstall** management command to install dependencies specified in the package.json 111 | 112 | .. code:: bash 113 | 114 | $ python manage.py npminstall 115 | 116 | Use Django`s static template tag to link installed assets 117 | 118 | .. code:: html 119 | 120 | {% load static %} 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /django_node_assets/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.15" 2 | -------------------------------------------------------------------------------- /django_node_assets/finders.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import django 4 | from django.conf import settings 5 | from django.contrib.staticfiles.finders import BaseFinder 6 | from django.contrib.staticfiles.utils import get_files 7 | from django.core.files.storage import FileSystemStorage 8 | 9 | 10 | class NodeModulesFinder(BaseFinder): 11 | """ 12 | The static files finder that find static files stored 13 | in the NODE_MODULES_ROOT, and excludes metadata and unwanted files when 14 | static files will be collected. 15 | """ 16 | 17 | storage = FileSystemStorage(location=settings.NODE_MODULES_ROOT) 18 | ignore_patterns = [ 19 | "*.less", 20 | "*.scss", 21 | "*.styl", 22 | "*.sh", 23 | "*.htm", 24 | "*.html", 25 | "*.md", 26 | "*.markdown", 27 | "*.rst", 28 | "*.php", 29 | "*.rb", 30 | "*.txt", 31 | "*.map", 32 | "*.yml", 33 | "*.yaml", 34 | "*.json", 35 | "*.xml", 36 | "*.ts", 37 | "*.es6", 38 | "*.coffee", 39 | "*.litcoffee", 40 | "*.lock", 41 | "*.patch", 42 | "README*", 43 | "LICENSE*", 44 | "LICENCE*", 45 | "CHANGES", 46 | "CHANGELOG", 47 | "HISTORY", 48 | "NOTICE", 49 | "COPYING", 50 | "license", 51 | "*test*", 52 | "*bin*", 53 | "*samples*", 54 | "*example*", 55 | "*docs*", 56 | "*tests*", 57 | "*demo*", 58 | "Makefile*", 59 | "Gemfile*", 60 | "Gruntfile*", 61 | "gulpfile.js", 62 | ".tagconfig", 63 | ".npmignore", 64 | ".gitignore", 65 | ".gitattributes", 66 | ".gitmodules", 67 | ".editorconfig", 68 | ".sqlite", 69 | "grunt", 70 | "gulp", 71 | "less", 72 | "sass", 73 | "scss", 74 | "coffee", 75 | "tasks", 76 | "node_modules", 77 | ] 78 | 79 | def find(self, path, **kwargs): 80 | if django.VERSION >= (5, 2): 81 | find_all = kwargs.get("find_all", False) 82 | else: 83 | find_all = kwargs.get("all", False) 84 | 85 | matches = [] 86 | if self.storage.exists(path): 87 | matched_path = self.storage.path(path) 88 | if not find_all: 89 | return matched_path 90 | matches.append(matched_path) 91 | return matches 92 | 93 | def list(self, *args, **kwargs): 94 | for path in get_files(self.storage, self.ignore_patterns): 95 | yield path, self.storage 96 | 97 | 98 | class ManifestNodeModulesFinder(NodeModulesFinder): 99 | """ 100 | The static files finder that looks in the directory of each dependency 101 | specified in the package.json and excludes metadata and unwanted files when 102 | static files will be collected. 103 | """ 104 | 105 | def list(self, *args, **kwargs): 106 | try: 107 | with open(settings.NODE_PACKAGE_JSON, encoding="utf-8") as f: 108 | package_json = json.load(f) 109 | except FileNotFoundError: 110 | for path in get_files(self.storage, self.ignore_patterns): 111 | yield path, self.storage 112 | else: 113 | if "dependencies" in package_json and isinstance( 114 | package_json["dependencies"], dict 115 | ): 116 | node_modules = package_json["dependencies"].keys() 117 | 118 | for module in node_modules: 119 | if self.storage.exists(module): 120 | for path in get_files( 121 | self.storage, self.ignore_patterns, module 122 | ): 123 | yield path, self.storage 124 | -------------------------------------------------------------------------------- /django_node_assets/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitespy/django-node-assets/14f734416311f415c79a720f0b8ee5fd7fd6b8c3/django_node_assets/management/__init__.py -------------------------------------------------------------------------------- /django_node_assets/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whitespy/django-node-assets/14f734416311f415c79a720f0b8ee5fd7fd6b8c3/django_node_assets/management/commands/__init__.py -------------------------------------------------------------------------------- /django_node_assets/management/commands/npminstall.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | from pathlib import Path 4 | 5 | from django.conf import settings 6 | from django.core.management.base import BaseCommand 7 | 8 | 9 | class NodePackageContext: 10 | def __init__(self): 11 | self.package_json = Path(settings.NODE_MODULES_ROOT).parent.joinpath( 12 | "package.json" 13 | ) 14 | 15 | def __enter__(self): 16 | if not self.package_json.exists(): 17 | self.package_json.symlink_to(settings.NODE_PACKAGE_JSON) 18 | return self 19 | 20 | def __exit__(self, exc_type, exc_val, exc_tb): 21 | if self.package_json.is_symlink(): 22 | self.package_json.unlink() 23 | return False 24 | 25 | 26 | class Command(BaseCommand): 27 | help = "Installs all dependencies listed in the package.json" 28 | 29 | def handle(self, **options): 30 | if not hasattr(settings, "NODE_PACKAGE_JSON"): 31 | self.stderr.write('The "NODE_PACKAGE_JSON" setting is not specified.') 32 | return 33 | 34 | if not Path(settings.NODE_PACKAGE_JSON).exists(): 35 | self.stderr.write(f'The "{settings.NODE_PACKAGE_JSON}" file not found.') 36 | return 37 | 38 | node_modules_root = Path(settings.NODE_MODULES_ROOT) 39 | 40 | if not node_modules_root.is_dir(): 41 | node_modules_root.mkdir(parents=True) 42 | 43 | node_package_manager_executable = getattr( 44 | settings, "NODE_PACKAGE_MANAGER_EXECUTABLE", None 45 | ) or shutil.which("npm") 46 | 47 | node_package_manager_install_options = getattr( 48 | settings, 49 | "NODE_PACKAGE_MANAGER_INSTALL_OPTIONS", 50 | [ 51 | "--no-package-lock", 52 | "--production", 53 | ], 54 | ) 55 | 56 | with NodePackageContext(): 57 | try: 58 | output = subprocess.check_output( 59 | args=[ 60 | node_package_manager_executable, 61 | "install", 62 | *node_package_manager_install_options, 63 | ], 64 | cwd=node_modules_root.parent, 65 | text=True, 66 | ) 67 | except subprocess.CalledProcessError: 68 | self.stderr.write("An error occurred.") 69 | else: 70 | self.stdout.write(output) 71 | self.stdout.write( 72 | self.style.SUCCESS( 73 | "All dependencies have been successfully installed." 74 | ) 75 | ) 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name="django-node-assets" 7 | version = "0.9.15" 8 | description = "The Django application that allows to install and serve assets via Node.js package manager infrastructure." 9 | readme = "README.rst" 10 | license = { file = "LICENSE.txt" } 11 | authors = [ 12 | { name = "Andrey Butenko", email = "whitespysoftware@gmail.com" }, 13 | ] 14 | keywords=[ 15 | "django", 16 | "assets", 17 | "staticfiles", 18 | "Node.js", 19 | "npm", 20 | "package.json", 21 | ] 22 | classifiers = [ 23 | "Environment :: Web Environment", 24 | "Intended Audience :: Developers", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Framework :: Django", 32 | "Framework :: Django :: 4.2", 33 | "Framework :: Django :: 5.0", 34 | "Framework :: Django :: 5.1", 35 | "Framework :: Django :: 5.2", 36 | ] 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "build", 41 | "ruff", 42 | "twine", 43 | ] 44 | 45 | [project.urls] 46 | GitHub = "https://github.com/whitespy/django-node-assets" 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "A", # flake8-builtins 51 | "ARG", # flake8-unused-arguments 52 | "B", # flake8-bugbear 53 | "BLE", # flake8-blind-except 54 | "C4", # flake8-comprehensions 55 | "C90", # McCabe cyclomatic complexity 56 | "E", # pycodestyle (Error) 57 | "F", # Pyflakes 58 | "FLY", # flynt 59 | "FURB", # refurb 60 | "I", # isort 61 | "INT", # flake8-gettext 62 | "ISC", # flake8-implicit-str-concat 63 | "N", # pep8-naming 64 | "PERF", # Perflint 65 | "PIE", # flake8-pie 66 | "PL", # Pylint 67 | "Q", # flake8-quotes 68 | "RET", # flake8-return 69 | "RSE", # flake8-raise 70 | "S", # flake8-bandit 71 | "SIM", # flake8-simplify 72 | "SLF", # flake8-self 73 | "SLOT", # flake8-slots 74 | "T20", # flake8-print 75 | "UP", # pyupgrade 76 | "W", # pycodestyle (Warning) 77 | ] 78 | ignore = [ 79 | "A002", 80 | "ARG002", 81 | "S404", 82 | ] 83 | 84 | [tool.ruff.lint.isort] 85 | known-first-party = ["django_node_assets"] 86 | --------------------------------------------------------------------------------