├── .circleci └── config.yml ├── .coveragerc ├── .gitignore ├── LICENSE ├── README.md ├── django_jsx ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── compilejsx.py └── templatetags │ ├── __init__.py │ └── jsx.py ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── more_templates │ └── another_template.html ├── templates │ ├── test_dir_C │ │ └── test_file_D.zip │ ├── test_file_A.txt │ └── test_file_B.html ├── test_compilejsx.py ├── test_jsx_tag.py ├── test_serialize.py ├── test_settings.py └── test_template_files.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/app 5 | docker: 6 | # https://discuss.circleci.com/t/testing-in-different-environments/450/13 7 | - image: themattrix/tox 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | key: tox-v1-{{ checksum "tox.ini" }}-{{ checksum "setup.py" }} 12 | - run: tox 13 | - save_cache: 14 | key: tox-v1-{{ checksum "tox.ini" }}-{{ checksum "setup.py" }} 15 | paths: 16 | - ~/app/.tox 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = */tests/*, .tox/*, runtests.py, setup.py 4 | source = . 5 | 6 | [report] 7 | show_missing = true 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .project 3 | .pydevproject 4 | *~ 5 | *.swp 6 | *.db 7 | *.orig 8 | *.DS_Store 9 | .coverage 10 | .tox 11 | *.egg-info/* 12 | docs/_build/* 13 | build 14 | dist/* 15 | *.mo 16 | node_modules/* 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2017, Caktus Consulting Group, LLC 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-jsx [![CircleCI](https://circleci.com/gh/caktus/django-jsx.svg?style=svg)](https://circleci.com/gh/caktus/django-jsx) 2 | 3 | Django-JSX is a integration tool for Django projects using the excellent React 4 | UI library. It enables direct embedding of the JSX syntax into Django templates 5 | through a `{% jsx %}` template block. Your JSX will be extracted and compiled 6 | along with your other front-end assets and rendered into your page. 7 | 8 | Django-JSX makes React and Django work together. 9 | 10 | ## Features 11 | 12 | - Embed React's JSX syntax directly into your project's Django templates 13 | - Access the template's context variables inside your JSX attribute expressions 14 | - Expose your library of React components to your traditional template developers 15 | 16 | 17 | ## Installation and Use 18 | 19 | django-jsx requires Django >= 1.8 and Python >= 2.7 or >= 3.4. 20 | 21 | To install from PyPi: 22 | 23 | pip install django-jsx 24 | 25 | Add django-jsx to your Django settings' `INSTALLED_APPS`: 26 | 27 | INSTALLED_APPS = [ 28 | ... 29 | 'django_jsx', 30 | ] 31 | 32 | Now, in your templates, you can use JSX and React components within a `{% jsx %}` 33 | block easily. 34 | 35 | {% load jsx %} 36 | {% jsx %} 37 | 38 | {% endjsx %} 39 | 40 | You'll need to include Django-JSX into your front-end build process. This includes 41 | two steps. First, you need to run the `compilejsx` management command, which will 42 | generate your "JSX Registry". This creates an ES6/JSX module you'll place with your 43 | other front-end assets. 44 | 45 | python manage.py compilejsx -o project/static/js/jsx_registry.js 46 | 47 | Now that all the inline JSX you used in your templates is extracted for your 48 | front-end to use, you'll import those JSX snippets and render them all. You are 49 | responsible for making all your React components available for this step in 50 | the process. 51 | 52 | import jsxRegistry from './jsx_registry.js' 53 | import DropdownWidget from './widgets/dropdown.js' 54 | import LoadingWidget from './widgets/loading.js' 55 | 56 | jsxRegistry.renderAllDjangoJSX({ 57 | DropdownWidget, 58 | LoadingWidget 59 | }) 60 | 61 | 62 | ## How it works 63 | 64 | * The `compilejsx` management command finds all the `jsx` blocks in the project's templates. It 65 | creates a jsx_registry.js file which has a snippet of javascript for 66 | each JSX block, which when called, will render the JSX. 67 | It also defines a `renderAllDjangoJSX` function which will do that for a given page. 68 | 69 | * Include the output file from `compilejsx` when bundling your JavaScript. It uses 70 | ECMAScript 2015 features and so might need some transpiling. 71 | 72 | * When rendered by Django, the `jsx` template tag is replaced by an empty script tag, whose purpose 73 | is to store (as a data attribute) a snapshot of the contents of the template context 74 | at that point in the template. 75 | 76 | * At load time in a page with jsx tags, the page should call `renderAllDjangoJSX`. It will 77 | iterate over all the `jsx` script tags in the page's HTML, and render the appropriate 78 | React component into each one using the template context from the script tag and the Javascript 79 | compiled from the original JSX. 80 | 81 | ## License 82 | 83 | django-jsx is released under the BSD License. See the 84 | [LICENSE](https://github.com/caktus/django-jsx/blob/master/LICENSE) file for more details. 85 | 86 | 87 | ### Contributing 88 | 89 | If you think you've found a bug or are interested in contributing to this project 90 | check out [django-jsx on Github](https://github.com/caktus/django-jsx). 91 | 92 | Development sponsored by [Caktus Consulting Group, LLC](http://www.caktusgroup.com/services). 93 | -------------------------------------------------------------------------------- /django_jsx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/django_jsx/__init__.py -------------------------------------------------------------------------------- /django_jsx/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/django_jsx/management/__init__.py -------------------------------------------------------------------------------- /django_jsx/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/django_jsx/management/commands/__init__.py -------------------------------------------------------------------------------- /django_jsx/management/commands/compilejsx.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | import os 4 | import re 5 | import hashlib 6 | 7 | import django.template 8 | from django.template.backends.django import DjangoTemplates 9 | from django.core.management.base import BaseCommand 10 | 11 | # Regex matching all JSX blocks in a template 12 | R_JSX = re.compile(r'\{% *jsx *%\}(.*?)\{% *endjsx *%\}', re.DOTALL) 13 | 14 | # Regex to spot the beginning of an HTML element in JSX text 15 | R_COMPONENT = re.compile(r'<(\w+)') 16 | 17 | START_JS = """ 18 | import React from 'react'; 19 | import ReactDOM from 'react-dom'; 20 | var jsx_registry = {}; 21 | """ 22 | 23 | # ' % \ 147 | (sha1(self.jsx.encode('utf-8')).hexdigest(), escape(ctx)) 148 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # https://docs.djangoproject.com/en/1.10/topics/testing/advanced/#using-the-django-test-runner-to-test-reusable-applications 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | if __name__ == "__main__": 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | test_modules = sys.argv[1:] if len(sys.argv) > 1 else ["tests"] 16 | failures = test_runner.run_tests(test_modules) 17 | sys.exit(bool(failures)) 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | [flake8] 4 | max-line-length=100 5 | exclude=migrations,.tox,node_modules 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-jsx', 5 | version='0.4.0', 6 | author='Calvin Spealman', 7 | author_email='calvin@caktusgroup.com', 8 | packages=find_packages(exclude=['sample_project']), 9 | include_package_data=True, 10 | license='BSD', 11 | description='Integration library for React/JSX and Django', 12 | classifiers=[ 13 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 14 | 'Intended Audience :: Developers', 15 | 'License :: OSI Approved :: BSD License', 16 | 'Programming Language :: Python', 17 | ], 18 | ) 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/tests/__init__.py -------------------------------------------------------------------------------- /tests/more_templates/another_template.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/tests/more_templates/another_template.html -------------------------------------------------------------------------------- /tests/templates/test_dir_C/test_file_D.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/tests/templates/test_dir_C/test_file_D.zip -------------------------------------------------------------------------------- /tests/templates/test_file_A.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/tests/templates/test_file_A.txt -------------------------------------------------------------------------------- /tests/templates/test_file_B.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caktus/django-jsx/ba8f77ca9b96c96328b44886b3c7389231570210/tests/templates/test_file_B.html -------------------------------------------------------------------------------- /tests/test_compilejsx.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import hashlib 4 | import io 5 | import os 6 | import sys 7 | import tempfile 8 | 9 | from django.core.management import call_command 10 | from django.test import TestCase 11 | 12 | from django_jsx.management.commands.compilejsx import compile_templates, END_JS, START_JS 13 | 14 | 15 | class CompileJSXTest(TestCase): 16 | """ 17 | Tests for the compilejsx management command, which looks at all the 18 | template files and emits a jsx_registry.jsx file with information about 19 | the JSX blocks in the templates, and some JavaScript code to make use of the 20 | information. 21 | """ 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.files_to_delete = [] 25 | 26 | @classmethod 27 | def tearDownClass(cls): 28 | for fn in cls.files_to_delete: 29 | try: 30 | os.remove(fn) 31 | except Exception as e: 32 | print(e) 33 | delattr(cls, 'files_to_delete') 34 | 35 | @classmethod 36 | def make_testfile(cls): 37 | """Returns name of the test file""" 38 | (filehandle, filename) = tempfile.mkstemp() 39 | os.close(filehandle) 40 | cls.files_to_delete.append(filename) 41 | return filename 42 | 43 | def test_invoking_for_stdout(self): 44 | # Default output is to stdout 45 | out = io.StringIO() 46 | orig_out = sys.stdout 47 | try: 48 | sys.stdout = out 49 | call_command('compilejsx') 50 | self.assertIn(START_JS, out.getvalue()) 51 | finally: 52 | sys.stdout = orig_out 53 | 54 | def test_invoking_to_output_file(self): 55 | # --output sends output to named file 56 | filename = type(self).make_testfile() 57 | call_command('compilejsx', output=filename) 58 | output = open(filename, "rb").read().decode('utf-8') 59 | self.assertIn(START_JS, output) 60 | 61 | def try_it(self, test_content, expected_result, raw=False): 62 | # Make template file containing a jsx block whose body is `test_content`, run 63 | # compilejsx, and make sure the output is `expected_result`. Or if `raw` is true, 64 | # then `test_content` is the entire content of the test file to compile. 65 | filename = type(self).make_testfile() 66 | expected_result = expected_result.replace('{filename}', filename) 67 | if raw: 68 | test_text = test_content 69 | else: 70 | test_text = "{% jsx %}" + test_content + "{% endjsx %}" 71 | with open(filename, "w") as f: 72 | f.write(test_text) 73 | 74 | # "Compile" it 75 | output = io.StringIO() 76 | compile_templates([filename], output) 77 | 78 | # Strip boilerplate to simplify checking 79 | start = len(START_JS) + 1 80 | end = 0 - (len(END_JS) + 1) 81 | result = output.getvalue()[start:end - 1] 82 | 83 | self.maxDiff = None 84 | self.assertEqual(expected_result, result) 85 | 86 | def test_empty_template(self): 87 | # If template is empty, output is just the boilerplate. 88 | # Make empty file 89 | filename = type(self).make_testfile() 90 | with open(filename, "w"): 91 | pass 92 | # "Compile" it 93 | output = io.StringIO() 94 | compile_templates([filename], output) 95 | 96 | # Check boilerplate 97 | self.assertTrue(output.getvalue().startswith(START_JS + "\n")) 98 | self.assertTrue(output.getvalue().endswith(END_JS + "\n")) 99 | 100 | # Strip boilerplate to simplify checking what's not boilerplate 101 | start = len(START_JS) + 1 102 | end = 0 - (len(END_JS) + 1) 103 | result = output.getvalue()[start:end - 1] 104 | self.assertEqual('', result) 105 | 106 | def test_template_with_empty_jsx_block(self): 107 | # If the block is empty, the output is pretty minimal 108 | 109 | test_content = '' 110 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 111 | expected = '''/* {filename} */ 112 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 113 | return (); 114 | }''' % sha1 115 | self.try_it(test_content, expected) 116 | 117 | def test_template_with_minimal_component(self): 118 | # If the block just has a minimal React component, the output includes 119 | # a jsx_registry entry for it. 120 | test_content = '' 121 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 122 | expected = '''/* {filename} */ 123 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 124 | if (Object.hasOwnProperty.call(COMPONENTS, 'NeatThing')) var {NeatThing} = COMPONENTS; 125 | return (%s); 126 | }''' % (sha1, test_content) 127 | self.try_it(test_content, expected) 128 | 129 | def test_template_with_component_with_static_property(self): 130 | # Static properties don't change the output 131 | test_content = '' 132 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 133 | expected = '''/* {filename} */ 134 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 135 | if (Object.hasOwnProperty.call(COMPONENTS, 'NiftyFeature')) var {NiftyFeature} = COMPONENTS; 136 | return (%s); 137 | }''' % (sha1, test_content) 138 | self.try_it(test_content, expected) 139 | 140 | def test_template_with_component_with_variable_property(self): 141 | # Variable properties don't change the output 142 | test_content = '' 143 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 144 | expected = '''/* {filename} */ 145 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 146 | if (Object.hasOwnProperty.call(COMPONENTS, 'WonderBar')) var {WonderBar} = COMPONENTS; 147 | return (%s); 148 | }''' % (sha1, test_content) 149 | self.try_it(test_content, expected) 150 | 151 | def test_template_with_component_with_expression_property(self): 152 | # Expressions in properties don't change the output 153 | test_content = '' 154 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 155 | expected = '''/* {filename} */ 156 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 157 | if (Object.hasOwnProperty.call(COMPONENTS, 'Component')) var {Component} = COMPONENTS; 158 | return (%s); 159 | }''' % (sha1, test_content) 160 | self.try_it(test_content, expected) 161 | 162 | def test_template_with_component_with_deep_variable(self): 163 | # Variable properties don't change the output 164 | test_content = '' 165 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 166 | expected = '''/* {filename} */ 167 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 168 | if (Object.hasOwnProperty.call(COMPONENTS, 'Component')) var {Component} = COMPONENTS; 169 | return (%s); 170 | }''' % (sha1, test_content) 171 | self.try_it(test_content, expected) 172 | 173 | def test_template_with_nested_html(self): 174 | # Each tag level contributes to the output. compilejsx doesn't know or care 175 | # which tags are React components. 176 | test_content = '''
177 | 185 |
''' 186 | sha1 = hashlib.sha1(test_content.encode('utf-8')).hexdigest() 187 | expected = '''/* {filename} */ 188 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 189 | if (Object.hasOwnProperty.call(COMPONENTS, 'MobileModalSectionSearch')) var {MobileModalSectionSearch} = COMPONENTS; 190 | if (Object.hasOwnProperty.call(COMPONENTS, 'div')) var {div} = COMPONENTS; 191 | return (%s); 192 | }''' % (sha1, test_content) # noqa (long line hard to avoid here) 193 | self.try_it(test_content, expected) 194 | 195 | def test_duplicate_blocks_with_different_contexts(self): 196 | # compilejsx comes up with the same jsx_registry entry repeatedly if there are multiple 197 | # blocks with the same content but with different contexts. But this is okay, as 198 | # the rendered template will have a tag for each occurrence of the block, each 199 | # with its own unique context, and the JavaScript will render a component for 200 | # each one using that context. 201 | block_content = '' 202 | test_content = '''{% load jsx %} 203 | {% with foo=1 %} 204 | {% jsx %}BLOCK_CONTENT{% endjsx %} 205 | {% endwith %} 206 | {% with foo=2 %} 207 | {% jsx %}BLOCK_CONTENT{% endjsx %} 208 | {% endwith %} 209 | '''.replace("BLOCK_CONTENT", block_content) 210 | sha1 = hashlib.sha1(block_content.encode('utf-8')).hexdigest() 211 | expected = '''/* {filename} */ 212 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 213 | if (Object.hasOwnProperty.call(COMPONENTS, 'Component')) var {Component} = COMPONENTS; 214 | return (%s); 215 | } 216 | jsx_registry["%s"] = (COMPONENTS, ctx) => { 217 | if (Object.hasOwnProperty.call(COMPONENTS, 'Component')) var {Component} = COMPONENTS; 218 | return (%s); 219 | }''' % (sha1, block_content, sha1, block_content) 220 | self.try_it(test_content, expected, raw=True) 221 | -------------------------------------------------------------------------------- /tests/test_jsx_tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import hashlib 4 | import json 5 | import re 6 | from django.template import Context, Engine 7 | from django.template import TemplateSyntaxError 8 | from django.test import TestCase 9 | 10 | from django_jsx.templatetags.jsx import set_nested 11 | 12 | 13 | RESULT_REGEX = re.compile( 14 | r'') 16 | 17 | DEFAULT_CONTEXT = { 18 | 'False': False, 19 | 'True': True, 20 | 'None': None 21 | } 22 | 23 | ENGINE = Engine.get_default() 24 | 25 | 26 | def unescape(s): 27 | return s.replace('&', '&').replace('<', '<')\ 28 | .replace('>', '>').replace('"', '"').replace(''', "'") 29 | 30 | 31 | class SetNestedTest(TestCase): 32 | def test_simple_key(self): 33 | d = {} 34 | set_nested(d, 'foo', 3) 35 | self.assertEqual({'foo': 3}, d) 36 | 37 | def test_one_level(self): 38 | d = {} 39 | set_nested(d, 'foo.bar', 3) 40 | self.assertEqual({'foo': {'bar': 3}}, d) 41 | 42 | def test_two_levels(self): 43 | d = {} 44 | set_nested(d, 'foo.bar.baz', 3) 45 | self.assertEqual({'foo': {'bar': {'baz': 3}}}, d) 46 | 47 | def test_with_existing_stuff(self): 48 | d = {'one': 1, 'foo': {'baz': 2}} 49 | set_nested(d, 'foo.bar', 3) 50 | self.assertEqual({'one': 1, 'foo': {'bar': 3, 'baz': 2}}, d) 51 | 52 | def test_with_existing_object(self): 53 | """ 54 | If a top level item to be serialized is an object, we shouldn't fail at 55 | trying to set the lower level item. 56 | """ 57 | d = {'foo': object()} 58 | set_nested(d, 'foo.bar', 3) 59 | self.assertEqual({'foo': {'bar': 3}}, d) 60 | 61 | def test_top_level_item_doesnt_clobber_nested(self): 62 | # foo.bar has previously been set 63 | d = {'foo': {'bar': 3}} 64 | # if we later try to set foo, we shouldn't clobber foo.bar 65 | set_nested(d, 'foo', object()) 66 | self.assertEqual({'foo': {'bar': 3}}, d) 67 | 68 | 69 | class JsxTagTest(TestCase): 70 | def test_loading_tags(self): 71 | # We can `load` the tag library 72 | engine = Engine.get_default() 73 | template_object = engine.from_string("{% load jsx %}") 74 | result = template_object.render(Context({})) 75 | self.assertEqual("", result) 76 | 77 | def try_it(self, content, expected_ctx, raw=False, context=None): 78 | # Assert that if we do a {% jsx %} block with the given content, that we 79 | # get the standard empty script tag in the output, the data-sha1 attribute 80 | # has the sha1 digest of the content, and the `data-ctx` is 81 | # a JSON-encoding of `expected_ctx`. 82 | # If `raw` is True, use `content` as the entire template content, not just 83 | # the insides of a jsx block, and just return the result rather than trying 84 | # to validate it. 85 | # If `context` is given, use it as the template context. 86 | if raw: 87 | template_content = content 88 | else: 89 | template_content = "{% load jsx %}{% jsx %}" + content + "{% endjsx %}" 90 | template_object = ENGINE.from_string(template_content) 91 | result = template_object.render(Context(context or {})) 92 | if raw: 93 | return result 94 | else: 95 | m = RESULT_REGEX.match(result) 96 | self.assertEqual(m.group('sha1'), hashlib.sha1(content.encode('utf-8')).hexdigest()) 97 | ctx = json.loads(unescape(m.group('ctx'))) 98 | self.assertEqual(ctx, expected_ctx) 99 | 100 | def test_empty_tag(self): 101 | # No content -> no context 102 | expected_ctx = {} 103 | self.try_it('', expected_ctx) 104 | 105 | def test_empty_component(self): 106 | # No references to context -> no context 107 | content = '' 108 | expected_ctx = {} 109 | self.try_it(content, expected_ctx) 110 | 111 | def test_component_with_properties_using_context(self): 112 | # Refer to context -> context we used gets included 113 | content = '''''' 114 | expected_ctx = {'False': False, 'True': True} 115 | self.try_it(content, expected_ctx) 116 | 117 | def test_referring_to_elements_numerically(self): 118 | content = '''''' 119 | context = {'list': [1, 2, 3]} 120 | expected_ctx = {'list': {'0': 1}} 121 | self.try_it(content, expected_ctx, context=context) 122 | 123 | def test_missing_variables(self): 124 | "If variable is missing, set it to empty string (by default)." 125 | content = '''''' 126 | context = {} 127 | expected_ctx = {'does': {'not': {'exist': ''}}} 128 | self.try_it(content, expected_ctx, context=context) 129 | 130 | def test_missing_variables_with_string_if_invalid_set(self): 131 | "If variable is missing, use Engine's string_if_invalid value." 132 | ENGINE.string_if_invalid = 'hey, missing var -> %s' 133 | content = '''''' 134 | context = {} 135 | expected_ctx = {'does': {'not': {'exist': 'hey, missing var -> does.not.exist'}}} 136 | self.try_it(content, expected_ctx, context=context) 137 | 138 | # SHOULD NOT BE ALLOWED - compilejsx will reject 139 | # def test_two_identical_blocks_with_different_contexts(self): 140 | # # If an identical block is repeated with different context values, we 141 | # # can't handle it, so it should report an error and force the user to make 142 | # # the blocks different. 143 | # block_content = '' 144 | # test_content = '''{% spaceless %} 145 | # {% load jsx %} 146 | # {% with foo=1 %} 147 | # {% jsx %}BLOCK_CONTENT{% endjsx %} 148 | # {% endwith %} 149 | # {% with foo=2 %} 150 | # {% jsx %}BLOCK_CONTENT{% endjsx %} 151 | # {% endwith %} 152 | # {% endspaceless %}'''.replace("BLOCK_CONTENT", block_content) 153 | # result = self.try_it(test_content, None, raw=True) 154 | # sha1 = hashlib.sha1(block_content.encode('utf-8')).hexdigest() 155 | # expected_output = ( 156 | # '' 157 | # '' 158 | # .replace('SHA1', sha1)) 159 | # self.assertEqual(expected_output, unescape(result)) 160 | 161 | def test_block_in_loop(self): 162 | # We can put a block in a loop, and we get an output tag for each loop iteration 163 | test_content = '''{% spaceless %} 164 | {% load jsx %} 165 | {% for i in values %} 166 | {% jsx %}{% endjsx %} 167 | {% endfor %} 168 | {% endspaceless %}''' 169 | result = self.try_it(test_content, None, raw=True, context={'values': [1, 2, 3]}) 170 | sha1 = hashlib.sha1(''.encode('utf-8')).hexdigest() 171 | expected_output = ( 172 | '' 173 | '' 174 | '' 175 | .replace('SHA1', sha1)) 176 | self.assertEqual(expected_output, unescape(result)) 177 | 178 | def test_nested_blocks(self): 179 | # Nested JSX blocks are not allowed 180 | test_content = ''' 181 | {% load jsx %} 182 | {% jsx %} 183 | 184 | {% jsx %}{% endjsx %} 185 | 186 | {% endjsx %}''' 187 | with self.assertRaises(TemplateSyntaxError) as raise_context: 188 | self.try_it(test_content, None, raw=True,) 189 | exc = raise_context.exc 190 | self.assertIn('jsx blocks cannot be nested in a template', str(exc)) 191 | -------------------------------------------------------------------------------- /tests/test_serialize.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import json 3 | 4 | from django.template import Context 5 | from django.test import TestCase 6 | 7 | from django_jsx.templatetags.jsx import serialize_opportunistically 8 | 9 | 10 | class SerializeOpportunisticallyTest(TestCase): 11 | def test_no_expressions(self): 12 | obj = { 13 | 'a': 1, 14 | 'b': 2, 15 | 'c': 3 16 | } 17 | result = serialize_opportunistically(obj, []) 18 | self.assertEqual({}, json.loads(result)) 19 | 20 | def test_simple_expressions(self): 21 | obj = { 22 | 'a': 1, 23 | 'b': 2, 24 | 'c': 3 25 | } 26 | result = serialize_opportunistically(obj, ['a', 'b']) 27 | expect = { 28 | 'a': 1, 29 | 'b': 2, 30 | } 31 | self.assertEqual(expect, json.loads(result)) 32 | 33 | def test_missing_variable(self): 34 | """ 35 | In Django templates, if a variable is not defined in the context, then 36 | Django sets that variable to the special value ``string_if_invalid`` 37 | which is normally the empty string, but is customizable in Django 38 | settings TEMPLATES['OPTIONS']['string_if_invalid']. Django doesn't 39 | raise a user-visible error, so we should do the same thing in 40 | django_jsx. 41 | """ 42 | result = serialize_opportunistically(Context(), ['no.such.variable']) 43 | expect = { 44 | 'no': { 45 | 'such': { 46 | 'variable': '' 47 | } 48 | } 49 | } 50 | self.assertEqual(expect, json.loads(result)) 51 | 52 | def test_nested_expressions(self): 53 | obj = { 54 | 'a': { 55 | 'd': 4, 56 | 'f': { 57 | 'g': 'Hello' 58 | } 59 | }, 60 | 'b': 2, 61 | 'c': { 62 | 'e': 5 63 | } 64 | } 65 | result = serialize_opportunistically(Context(obj), ['a.d', 'b', 'c.e', 'a.f', 'a.f.g']) 66 | expect = { 67 | 'a': { 68 | 69 | 'd': 4, 70 | 'f': { 71 | 'g': 'Hello' 72 | } 73 | }, 74 | 'b': 2, 75 | 'c': { 76 | 'e': 5 77 | }, 78 | } 79 | self.assertEqual(expect, json.loads(result)) 80 | 81 | def test_top_level_object_doesnt_clobber_nested_expressions(self): 82 | # create an object with some attributes 83 | class Location(dict): 84 | full_name = 'New York' 85 | location = Location() 86 | 87 | # the order of expressions is important here 88 | expressions = [ 89 | 'location.full_name', # set up the nested dict 90 | 'location', # this should not clobber the nested dict 91 | ] 92 | obj = {'location': location} 93 | result = serialize_opportunistically(Context(obj), expressions) 94 | expect = { 95 | 'location': { 96 | 'full_name': 'New York', 97 | } 98 | } 99 | self.assertEqual(expect, json.loads(result)) 100 | 101 | def test_with_callable(self): 102 | def call1(): 103 | return "called 1" 104 | 105 | def call2(): 106 | return "called 2" 107 | 108 | obj = { 109 | 'call1': call1, 110 | 'a': { 111 | 'b': call2, 112 | 'c': call1, 113 | } 114 | } 115 | result = serialize_opportunistically(obj, ['call1', 'a.b']) 116 | expect = { 117 | 'call1': 'called 1', 118 | 'a': { 119 | 'b': 'called 2' 120 | } 121 | } 122 | self.assertEqual(expect, json.loads(result)) 123 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # SETTINGS file for running tests on this pluggable application 2 | 3 | # https://docs.djangoproject.com/en/1.10/topics/testing/advanced/#using-the-django-test-runner-to-test-reusable-applications 4 | 5 | SECRET_KEY = 'fake-key' 6 | INSTALLED_APPS = [ 7 | "tests", 8 | "django_jsx", 9 | ] 10 | 11 | # We need a database configuration or Django complains, but we're not 12 | # actually using the database. An in-memory Sqlite database should 13 | # be very light-weight and not leave anything behind after testing. 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | 'NAME': ':memory:', 18 | } 19 | } 20 | 21 | 22 | # COPIED from project template in Django 1.10 23 | TEMPLATES = [ 24 | { 25 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 26 | 'DIRS': [], 27 | 'APP_DIRS': True, 28 | 'OPTIONS': { 29 | 'context_processors': [ 30 | 'django.template.context_processors.debug', 31 | 'django.template.context_processors.request', 32 | 'django.contrib.auth.context_processors.auth', 33 | 'django.contrib.messages.context_processors.messages', 34 | ], 35 | }, 36 | }, 37 | ] 38 | -------------------------------------------------------------------------------- /tests/test_template_files.py: -------------------------------------------------------------------------------- 1 | """Tests for finding all the template files""" 2 | from __future__ import unicode_literals 3 | import os.path 4 | 5 | from django.test import TestCase 6 | from django.test import override_settings 7 | 8 | from django_jsx.management.commands.compilejsx import list_template_files 9 | 10 | 11 | class TemplateFindingTest(TestCase): 12 | @override_settings( 13 | INSTALLED_APPS=['tests'], 14 | TEMPLATES=[ 15 | { 16 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 17 | 'APP_DIRS': True, 18 | }, 19 | ]) 20 | def test_finding_templates_with_test_app(self): 21 | # If 'tests' is the only installed app, we find its templates 22 | this_dir = os.path.dirname(__file__) 23 | result = list_template_files() 24 | expected = [ 25 | os.path.join(this_dir, "templates", filename) 26 | for filename in 27 | [ 28 | "test_file_B.html", 29 | "test_file_A.txt", 30 | "test_dir_C/test_file_D.zip", 31 | ] 32 | ] 33 | self.assertEqual(set(expected), set(result)) 34 | 35 | @override_settings( 36 | TEMPLATES=[ 37 | { 38 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 39 | 'APP_DIRS': True, 40 | }, 41 | ], 42 | INSTALLED_APPS=[]) 43 | def test_finding_templates_without_test_app(self): 44 | # If there are no installed apps, we don't find any templates 45 | result = list_template_files() 46 | expected = [] 47 | self.assertEqual(set(expected), set(result)) 48 | 49 | @override_settings( 50 | INSTALLED_APPS=['tests'], 51 | TEMPLATES=[ 52 | { 53 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 54 | 'DIRS': [os.path.join(os.path.dirname(__file__), "more_templates")], 55 | 'APP_DIRS': True, 56 | }, 57 | ]) 58 | def test_finding_templates_in_additional_dirs(self): 59 | # If the user configured additional dirs to find templates in, we spot them 60 | result = list_template_files() 61 | this_dir = os.path.dirname(__file__) 62 | self.assertEqual( 63 | set(result), 64 | { 65 | os.path.join(this_dir, "templates", filename) 66 | for filename in 67 | [ 68 | "test_file_B.html", 69 | "test_file_A.txt", 70 | "test_dir_C/test_file_D.zip", 71 | ] 72 | } | { 73 | os.path.join(this_dir, "more_templates", "another_template.html") 74 | } 75 | ) 76 | 77 | @override_settings( 78 | INSTALLED_APPS=['tests'], 79 | TEMPLATES=[ 80 | { 81 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 82 | 'DIRS': [os.path.join(os.path.dirname(__file__), "more_templates")], 83 | 'APP_DIRS': False, 84 | }, 85 | ]) 86 | def test_finding_templates_with_app_dirs_false(self): 87 | this_dir = os.path.dirname(__file__) 88 | result = list_template_files() 89 | expected = [os.path.join(this_dir, "more_templates", "another_template.html")] 90 | self.assertEqual(set(expected), set(result)) 91 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py27,py35,py36,py37}-dj111 3 | {py35,py36,py37}-dj20 4 | {py35,py36,py37}-dj21 5 | py37-flake8 6 | py37-coverage 7 | 8 | [testenv] 9 | basepython = 10 | py27: python2.7 11 | py35: python3.5 12 | py36: python3.6 13 | py37: python3.7 14 | deps = 15 | dj111: Django>=1.11,<2.0 16 | dj20: Django>=2.0,<2.1 17 | dj21: Django>=2.1,<2.2 18 | commands = {envpython} runtests.py 19 | 20 | [testenv:py37-flake8] 21 | deps = flake8>=3.4 22 | Django>=1.11,<2.0 23 | skip_install = true 24 | commands={envbindir}/flake8 25 | 26 | [testenv:py37-coverage] 27 | deps = coverage>=3.7 28 | Django>=1.11,<2.0 29 | commands = coverage run runtests.py 30 | coverage report -m --fail-under 90 31 | --------------------------------------------------------------------------------