diff --git a/.gitignore b/.gitignore index eed4564..858bc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ node_modules build !tests/dist .coverage -htmlcov \ No newline at end of file +htmlcov +_build \ No newline at end of file diff --git a/README.md b/README.md index ce0b9bf..0ac9b1d 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,42 @@ -# Django Manifest Loader +# Django Manifest Loader -[![Build Status](https://img.shields.io/travis/shonin/django-manifest-loader/main?label=latest%20published%20branch&style=flat-square +[![Build Status](https://img.shields.io/travis/shonin/django-manifest-loader/main?label=stable%20branch&style=flat-square )](https://travis-ci.org/shonin/django-manifest-loader) [![Build Status](https://img.shields.io/travis/shonin/django-manifest-loader/dev?label=development%20branch&style=flat-square )](https://travis-ci.org/shonin/django-manifest-loader) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](#) -_Always have access to the latest webpack assets, with minimal configuration. Wraps Django's built in -`{% static %}` templatetag to allow you to link to assets according to a webpack manifest file. Handles webpack's -split chunks._ + +Reads a manifest file to import your assets into a Django template. Find +the URL for a single asset or the URLs for multiple assets by using +pattern matching against the file names. Path resolution handled using +Django's built-in `staticfiles` app. Minimal configuraton, +cache-busting, split chunks. + +## [Documentation](https://django-manifest-loader.readthedocs.io/en/latest/index.html) + +## About **Turns this** ```djangotemplate {% load manifest %} - ``` **Into this** ```djangotemplate - ``` +* [Official documentation](https://django-manifest-loader.readthedocs.io/en/latest/index.html) * For an in-depth look at this package, check out [this blog post here](https://medium.com/@shonin/django-and-webpack-now-work-together-seamlessly-a90cffdbab8e) -* [Quick start guide](https://medium.com/@shonin/django-and-webpack-in-4-short-steps-b39bd3380c71) - - -## Installation - -```shell script -pip install django-manifest-loader -``` - -## Django Setup - -```python -# settings.py - -INSTALLED_APPS = [ - ... - 'manifest_loader', # add to installed apps - ... -] - -STATICFILES_DIRS = [ - BASE_DIR / 'dist' # the directory webpack outputs to -] -``` - -You must add webpack's output directory to the `STATICFILES_DIRS` list. -The above example assumes that your webpack configuration is setup to output all files into a directory `dist/` that is -in the `BASE_DIR` of your project. - -`BASE_DIR`'s default value, as set by `$ djagno-admin startproject` is `BASE_DIR = Path(__file__).resolve().parent.parent`, in general -you shouldn't be modifying it. +* [Quick start blog post](https://medium.com/@shonin/django-and-webpack-in-4-short-steps-b39bd3380c71) -**Optional settings,** default values shown. -```python -# settings.py +## Quick reference: -MANIFEST_LOADER = { - 'output_dir': None, # where webpack outputs to, if not set will search in STATICFILES_DIRS for the manifest. - 'manifest_file': 'manifest.json', # name of your manifest file - 'cache': False, # recommended True for production, requires a server restart to pickup new values from the manifest. -} -``` - -## Webpack configuration - -You must install the `WebpackManifestPlugin`. Optionally, but recommended, is to install the `CleanWebpackPlugin`. - -```shell script -npm i --save-dev webpack-manifest-plugin clean-webpack-plugin -``` - -```javascript -// webpack.config.js - -const { CleanWebpackPlugin } = require('clean-webpack-plugin'); -const ManifestPlugin = require('webpack-manifest-plugin'); - -module.exports = { - ... - plugins: [ - new CleanWebpackPlugin(), // removes outdated assets from the output dir - new ManifestPlugin(), // generates the required manifest.json file - ], - ... -}; -``` - -# Usage - -Django Manifest Loader comes with two template tags that house all logic. The `manifest` tag takes a single string -input, such as `'main.js'`, looks it up against the webpack manifest, and then outputs the url to that compiled file. -It works just like Django's built it `static` tag, except it's finding the correct filename. - -The `manifest_match` tag takes two arguments, a sting to pattern match filenames against, and a string to embed matched file -urls into. See the `manifest_match` section for more information. - -## Single file use (for cache busting) (`manifest` tag) +### Manifest tag ```djangotemplate {% load manifest %} @@ -114,116 +50,21 @@ turns into ``` -Where the argument to the tag will be the original filename of a file processed by webpack. If in doubt, check your -`manifest.json` file generated by webpack to see what files are available. - -The reason this is worth while is because of the content hash after the original filename, which will invalidate the -browser cache every time the file is updated. This ensures that your users always have the latest assets. - -## Split chunks (`manifest_match` tag) +### Manifest match tag ```djangotemplate {% load manifest %} -{% manifest_match '*.js' '' %} ``` turns into ```html - + ``` -### Contributing - -Do it. Please feel free to file an issue or open a pull request. The code of conduct is basic human kindness. - ### License Django Manifest Loader is distributed under the [3-clause BSD license](https://opensource.org/licenses/BSD-3-Clause). diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3fc935c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,61 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../manifest_loader/templatetags')) + + +# -- Project information ----------------------------------------------------- + +project = 'django-manifest-loader' +copyright = '3-Clause BSD License' +author = 'django-manifest-loader' + +# The full version, including alpha/beta/rc tags +release = '1.01' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc','recommonmark', 'sphinx_rtd_theme' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +source_suffix = { + '.rst': 'restructuredtext', + '.txt': 'markdown', + '.md': 'markdown', +} \ No newline at end of file diff --git a/docs/docs/about_install.md b/docs/docs/about_install.md new file mode 100644 index 0000000..d01f256 --- /dev/null +++ b/docs/docs/about_install.md @@ -0,0 +1,124 @@ +# About + +Django Manifest Loader reads a manifest file to import your assets into a Django template. Find +the URL for a single asset OR find the URLs for multiple assets by using +pattern matching against the file names. Path resolution handled using +Django's built-in `staticfiles` app. Minimal configuraton, cache-busting, split chunks. +Designed for webpack, ready for anything. + +**Turns this** + +```html +{% load manifest %} + +``` + +turns into + +```html + +``` + +Where the argument to the tag will be the original filename of a file processed by webpack. If in doubt, check your +`manifest.json` file generated by webpack to see what files are available. + +This is worthwhile because of the content hash after the original filename, which will invalidate the browser cache every time the file is updated, which will ensure that your users always have the latest assets. + +## Manifest match tag + +```html +{% load manifest %} + +{% manifest_match '*.js' '' %} +``` + +turns into + +```html + + +``` + +This tag takes two arguments, a pattern to match against, according to the python `fnmatch` package rules, +and a string to input the file URLs into. The second argument must contain the string `{match}`, as it is replaced with the URLs. + +# Advanced Usage + +## Custom Loaders + +Custom loaders allow you to implement your own means of extracting data from your manifest file. If your manifest +file is not the default structure of [webpack manifest plugin](https://github.com/shellscape/webpack-manifest-plugin), this is how you can tell `django-manifest-loader` how to read it. + +First import the loader parent abstract class, and subclass it in your new loader class. + +```python +from manifest_loader.loaders import LoaderABC + +class MyCustomLoader(LoaderABC): +``` + +Your new loader must have two static methods that each take two required arguments: +`get_single_match(manifest, key)` and `get_multi_match(manifest, pattern)`. + +```python +from manifest_loader.loaders import LoaderABC + +class MyCustomLoader(LoaderABC): + @staticmethod + def get_single_match(manifest, key): + pass + + @staticmethod + def get_multi_match(manifest, pattern): + pass +``` + +* `get_single_match` - returns a `String`, finds a single file in your manifest file, according to the `key` +* `get_multi_match` - returns a `List` of files in your manifest, according to the `pattern` +* `manifest` - this is your full manifest file, after being processed by `json.load()`. It will be a dictionary or list + depending on which it is in your manifest file. +* `key` - `String`; the argument passed into the `manifest` template tag. e.g.: in the template tag `{% manifest 'index.js' %}`, + the string `'index.js'` is sent to `get_single_match` as `key` (without surrounding quotes) +* `pattern` - `String`; the first argument passed into the `manifest_match` template tag. e.g.: in the template tag + `{% manifest_match '*.js' '' %}`, the string `'*.js'` is sent to `get_multi_match` + as `pattern` (without surrounding quotes) + +**Below is the code for the default loader, which is a good starting point:** + +```python +import fnmatch +from manifest_loader.loaders import LoaderABC + +class DefaultLoader(LoaderABC): + @staticmethod + def get_single_match(manifest, key): + return manifest.get(key, key) + + @staticmethod + def get_multi_match(manifest, pattern): + matched_files = [file for file in manifest.keys() if + fnmatch.fnmatch(file, pattern)] + return [manifest.get(file) for file in matched_files] +``` + +In the above example, `get_single_match` retrieves the value on the `manifest` dictionary that matches the key `key`. If +the key does not exist on the dictionary, it instead returns the key. + +`get_multi_match` uses the recommended `fnmatch` python standard library to do pattern matching. You could also use +regex in it's place. Here, it iterates through all the keys in the manifest file, and builds a list of the keys that +match the given `pattern`. It then returns a list of the values associated with those matched keys. + +### Activating the custom loader + +To put the custom loader into use it needs to be registered in your `settings.py`. + +```python +# settings.py +from my_app.utils import MyCustomLoader + +MANIFEST_LOADER = { + ... + 'loader': MyCustomLoader +} +``` + +## URLs in Manifest File + +If your manifest file points to full URLs, instead of file names, the full URL will be output instead of pointing to the static file directory in Django. + +Example: + +```json +{ + "main.js": "http://localhost:8080/main.js" +} +``` + +```html +{% load manifest %} + + +``` + +Will output as: + +```html + +``` + + +# Tests and Code Coverage + +Run unit tests and verify 100% code coverage with: + +``` +git clone https://github.com/shonin/django-manifest-loader.git +cd django-manifest-loader +pip install -e . + +# run tests +python runtests.py + +# check code coverage +pip install coverage +coverage run --source=manifest_loader/ runtests.py +coverage report +``` diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..ccd3c94 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,26 @@ +Django Manifest Loader +========================= + +| |Stable Status| |Dev Status| |contributions welcome| + +Django Manifest Loader reads a manifest file to import your assets into a Django template. Find +the URL for a single asset OR find the URLs for multiple assets by using +pattern matching against the file names. Path resolution handled using +Django's built-in ``staticfiles`` app. Minimal configuraton, cache-busting, split chunks. +Designed for webpack, ready for anything. + +.. toctree:: + :maxdepth: 1 + + docs/about_install + docs/usage + docs/philosophy + docs/reference + docs/docs_license + +.. |Stable Status| image:: https://img.shields.io/travis/shonin/django-manifest-loader/main?label=stable%20branch&style=flat-square + :target: https://travis-ci.org/shonin/django-manifest-loader +.. |Dev Status| image:: https://img.shields.io/travis/shonin/django-manifest-loader/dev?label=development%20branch&style=flat-square + :target: https://travis-ci.org/shonin/django-manifest-loader +.. |contributions welcome| image:: https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square + :target: # diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..2119f51 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/index.rst b/index.rst new file mode 100644 index 0000000..64d4532 --- /dev/null +++ b/index.rst @@ -0,0 +1,17 @@ +.. django-manifest-loader documentation master file, created by + sphinx-quickstart on Thu Dec 17 17:41:38 2020. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Django-manifest-loader's documentation! +================================================== + +.. toctree:: + :maxdepth: 1 + + docs/README + docs/usage + docs/reference + docs/docs_license + + diff --git a/manifest_loader/exceptions.py b/manifest_loader/exceptions.py index feb28e7..c7abef7 100644 --- a/manifest_loader/exceptions.py +++ b/manifest_loader/exceptions.py @@ -4,3 +4,10 @@ def __init__(self, path, message='Manifest file not found. ' 'settings are wrong or you still need to ' 'generate the file.'): super().__init__(message.format(path)) + + +class CustomManifestLoaderNotValid(Exception): + def __init__(self, message='Custom manifest loader defined in settings.py ' + 'must inherit from ' + 'manifest_loader.loaders.LoaderABC'): + super().__init__(message) diff --git a/manifest_loader/loaders.py b/manifest_loader/loaders.py new file mode 100644 index 0000000..4fabf57 --- /dev/null +++ b/manifest_loader/loaders.py @@ -0,0 +1,26 @@ +import fnmatch +from abc import ABCMeta, abstractmethod + + +class LoaderABC(metaclass=ABCMeta): + @staticmethod + @abstractmethod + def get_single_match(manifest, key): + pass + + @staticmethod + @abstractmethod + def get_multi_match(manifest, pattern): + pass + + +class DefaultLoader(LoaderABC): + @staticmethod + def get_single_match(manifest, key): + return manifest.get(key, key) + + @staticmethod + def get_multi_match(manifest, pattern): + matched_files = [file for file in manifest.keys() if + fnmatch.fnmatch(file, pattern)] + return [manifest.get(file) for file in matched_files] \ No newline at end of file diff --git a/manifest_loader/templatetags/manifest.py b/manifest_loader/templatetags/manifest.py index 86d6780..8237f5c 100644 --- a/manifest_loader/templatetags/manifest.py +++ b/manifest_loader/templatetags/manifest.py @@ -1,6 +1,5 @@ import json import os -import fnmatch from django import template from django.templatetags.static import StaticNode @@ -11,7 +10,9 @@ from django.core.exceptions import ValidationError -from manifest_loader.exceptions import WebpackManifestNotFound +from manifest_loader.exceptions import WebpackManifestNotFound, \ + CustomManifestLoaderNotValid +from manifest_loader.loaders import DefaultLoader, LoaderABC register = template.Library() @@ -19,7 +20,8 @@ APP_SETTINGS = { 'output_dir': None, 'manifest_file': 'manifest.json', - 'cache': False + 'cache': False, + 'loader': DefaultLoader } if hasattr(settings, 'MANIFEST_LOADER'): @@ -27,16 +29,19 @@ @register.tag('manifest') -def do_manifest(parser, token): +def do_manifest(parser, token): + """Returns the manifest tag """ return ManifestNode(token) - @register.tag('manifest_match') def do_manifest_match(parser, token): + """Returns manifest match tag""" return ManifestMatchNode(token) + class ManifestNode(template.Node): + """ Initalizes the creation of the manifest template tag""" def __init__(self, token): bits = token.split_contents() if len(bits) < 2: @@ -44,14 +49,18 @@ def __init__(self, token): "'%s' takes one argument (name of file)" % bits[0]) self.bits = bits + def render(self, context): + """Renders the creation of the manifest tag""" manifest_key = get_value(self.bits[1], context) manifest = get_manifest() - manifest_value = manifest.get(manifest_key, manifest_key) + manifest_value = load_from_manifest(manifest, key=manifest_key) + return make_url(manifest_value, context) class ManifestMatchNode(template.Node): + """ Initalizes the creation of the manifest match template tag""" def __init__(self, token): self.bits = token.split_contents() if len(self.bits) < 3: @@ -61,17 +70,16 @@ def __init__(self, token): ) def render(self, context): + """ Renders the manifest match tag""" urls = [] search_string = get_value(self.bits[1], context) output_tag = get_value(self.bits[2], context) manifest = get_manifest() - matched_files = [file for file in manifest.keys() if - fnmatch.fnmatch(file, search_string)] - mapped_files = [manifest.get(file) for file in matched_files] + files = load_from_manifest(manifest, pattern=search_string) - for file in mapped_files: + for file in files: url = make_url(file, context) urls.append(url) output_tags = [output_tag.format(match=file) for file in urls] @@ -79,6 +87,7 @@ def render(self, context): def get_manifest(): + """ Returns the manifest file from the output directory """ cached_manifest = cache.get('webpack_manifest') if APP_SETTINGS['cache'] and cached_manifest: return cached_manifest @@ -102,6 +111,7 @@ def get_manifest(): def find_manifest_path(): + """ Returns manifest_file """ static_dirs = settings.STATICFILES_DIRS if len(static_dirs) == 1: return os.path.join(static_dirs[0], APP_SETTINGS['manifest_file']) @@ -113,18 +123,35 @@ def find_manifest_path(): def is_quoted_string(string): + """Method validates if it's a stirng""" if len(string) < 2: return False return string[0] == string[-1] and string[0] in ('"', "'") def get_value(string, context): + """Method validates the value of the string""" if is_quoted_string(string): return string[1:-1] return context.get(string, '') +def load_from_manifest(manifest, key=None, pattern=None): + loader = APP_SETTINGS['loader'] + + if not issubclass(loader, LoaderABC): + raise CustomManifestLoaderNotValid + + if key: + return loader.get_single_match(manifest, key) + elif pattern: + return loader.get_multi_match(manifest, pattern) + return '' + + def is_url(potential_url): + """Function validates if it's a URL """ + validate = URLValidator() try: validate(potential_url) @@ -134,6 +161,8 @@ def is_url(potential_url): def make_url(manifest_value, context): + """ Returns the URL that will be outputed to the static file directory""" + if is_url(manifest_value): url = manifest_value else: @@ -141,3 +170,4 @@ def make_url(manifest_value, context): if context.autoescape: url = conditional_escape(url) return url + diff --git a/setup.cfg b/setup.cfg index 10d708e..0577fe2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-manifest-loader -version = 0.1.1 +version = 0.2.0 description = A Django app to load webpack assets. long_description = file: README.md url = https://www.github.com/shonin/django-manifest-loader diff --git a/tests/tests.py b/tests/tests.py index 4f8bde6..f6f727e 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -8,9 +8,9 @@ get_manifest, APP_SETTINGS, is_quoted_string, is_url from manifest_loader.apps import ManifestLoader - -from manifest_loader.exceptions import WebpackManifestNotFound - +from manifest_loader.exceptions import WebpackManifestNotFound, \ + CustomManifestLoaderNotValid +from manifest_loader.loaders import LoaderABC, DefaultLoader NEW_STATICFILES_DIRS = [ settings.BASE_DIR / 'foo', @@ -284,3 +284,28 @@ def test_match_urls(self): class AppConfigTests(SimpleTestCase): def test_the_django_app(self): self.assertTrue(issubclass(ManifestLoader, AppConfig)) + + +class LoadFromManifestTests(SimpleTestCase): + def test_loader_not_subclass(self): + class Foo: + pass + + APP_SETTINGS.update({'loader': Foo}) + + with self.assertRaises(CustomManifestLoaderNotValid): + render_template( + '{% load manifest %}' + '{% manifest "main.js" %}' + ) + + APP_SETTINGS.update({'loader': DefaultLoader}) + + +class LoaderABCTests(SimpleTestCase): + def test_if_meta(self): + self.assertTrue(hasattr(LoaderABC, 'register')) + + def test_methods_not_implemented(self): + self.assertIsNone(LoaderABC.get_single_match('foo', 'bar')) + self.assertIsNone(LoaderABC.get_multi_match('foo', 'bar'))