Skip to content

Commit 4e89713

Browse files
committed
Adds POC for bundle template tag
1 parent 9a5a60f commit 4e89713

File tree

12 files changed

+4361
-580
lines changed

12 files changed

+4361
-580
lines changed

.babelrc

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"presets": [
3+
[
4+
"@babel/preset-env",
5+
{
6+
"targets": {
7+
"edge": "17",
8+
"firefox": "60",
9+
"chrome": "67",
10+
"safari": "11.1",
11+
"ie": "11"
12+
}
13+
}
14+
]
15+
]
16+
}

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ app/output/*.pdf
1313
app/assets/other/wp.pdf
1414
app/assets/tmp/*
1515
app/assets/other/avatars/
16+
app/assets/**/bundle*/
1617
app/gcoin/
1718
app/media/
1819
.idea/

app/app/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@
486486
re_path(r'^modal/extend_issue_deadline/?', dashboard.views.extend_issue_deadline, name='extend_issue_deadline'),
487487

488488
# brochureware views
489+
re_path(r'^bundle_experiment/?', retail.views.bundle_experiment, name='bundle_experiment'),
489490
re_path(r'^homeold/?$', retail.views.index_old, name='homeold'),
490491
re_path(r'^home/?$', retail.views.index, name='home'),
491492
re_path(r'^landing/?$', retail.views.index, name='landing'),

app/assets/v2/js/bundle_experiment.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// if you want to assign to window in a webpacked bundle - use this. or window.
2+
this.test = 1;
3+
4+
// setting in the local scope will not assign to window
5+
const test = 2;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import os
2+
import re
3+
import shutil
4+
5+
from django.core.management.base import BaseCommand
6+
from django.template.loaders.app_directories import get_app_template_dirs
7+
from django.conf import settings
8+
9+
from dashboard.templatetags.bundle import render
10+
11+
12+
def rmdir(loc):
13+
# drop both the bundled and the bundles before recreating
14+
if os.path.exists(loc) and os.path.isdir(loc):
15+
print('- Deleting assets from: %s' % loc)
16+
shutil.rmtree(loc)
17+
18+
19+
def rmdirs(loc, kind):
20+
# base path of the assets
21+
base = ('%s/%s/v2/' % (settings.BASE_DIR, loc)).replace('/', os.sep)
22+
# delete both sets of assets
23+
rmdir('%sbundles/%s' % (base, kind))
24+
rmdir('%sbundled/%s' % (base, kind))
25+
26+
27+
class Command(BaseCommand):
28+
29+
help = 'generates .js/.scss files from bundle template tags'
30+
31+
def handle(self, *args, **options):
32+
template_dir_list = []
33+
for template_dir in get_app_template_dirs('templates'):
34+
if settings.BASE_DIR in template_dir:
35+
template_dir_list.append(template_dir)
36+
37+
template_list = []
38+
for template_dir in (template_dir_list + settings.TEMPLATES[0]['DIRS']):
39+
for base_dir, dirnames, filenames in os.walk(template_dir):
40+
for filename in filenames:
41+
if ".html" in filename:
42+
template_list.append(os.path.join(base_dir, filename))
43+
44+
# using regex to grab the bundle tags content from html
45+
block_pattern = re.compile(r'({%\sbundle(.|\n)*?(?<={%\sendbundle\s%}))')
46+
open_pattern = re.compile(r'({%\s+bundle\s+(js|css|merge_js|merge_css)\s+?(file)?\s+?([^\s]*)?\s+?%})')
47+
close_pattern = re.compile(r'({%\sendbundle\s%})')
48+
static_open_pattern = re.compile(r'({%\sstatic\s["|\'])')
49+
static_close_pattern = re.compile(r'(\s?%}(\"|\')?\s?\/?>)')
50+
51+
# remove the previously bundled files
52+
rmdirs('assets', 'js')
53+
rmdirs('assets', 'scss')
54+
rmdirs('static', 'js')
55+
rmdirs('static', 'scss')
56+
57+
print('\nStart generating bundle files\n')
58+
59+
# store unique entries for count
60+
rendered = dict()
61+
62+
for template in template_list:
63+
try:
64+
f = open(('%s' % template).replace('/', os.sep), 'r', encoding='utf8')
65+
66+
t = f.read()
67+
if re.search(block_pattern, t) is not None:
68+
for m in re.finditer(block_pattern, t):
69+
block = m.group(0)
70+
details = re.search(open_pattern, block)
71+
72+
# kind and name from the tag
73+
kind = 'scss' if details.group(2) == 'css' else details.group(2)
74+
name = details.group(4)
75+
76+
# remove open/close from the block
77+
block = re.sub(open_pattern, '', block)
78+
block = re.sub(close_pattern, '', block)
79+
80+
# clean static helper if we havent ran this through parse
81+
block = re.sub(static_open_pattern, '', block)
82+
block = re.sub(static_close_pattern, '>', block)
83+
84+
# render the template (producing a bundle file)
85+
rendered[render(block, kind, 'file', name, True)] = True
86+
87+
except Exception as e:
88+
# print('-- X - failed to parse %s: %s' % (template, e))
89+
pass
90+
91+
print('\nGenerated %s bundle files%s' % (len(rendered), ' - remember to run `yarn run build` then `./manage.py collectstatic --i other --no-input`\n' if settings.ENV in ['prod'] else ''))

app/dashboard/templatetags/bundle.py

+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import hashlib
2+
import os
3+
import re
4+
5+
from django import template
6+
from bs4 import BeautifulSoup
7+
from django.conf import settings
8+
from django.templatetags.static import static
9+
10+
register = template.Library()
11+
12+
"""
13+
Creates bundles from linked and inline Javascript or SCSS into a single file - compressed by py or webpack.
14+
15+
Syntax:
16+
17+
{% bundle [js|css|merge_js|merge_css] file [block_name] %}
18+
<script src="..."></script>
19+
<script>
20+
...
21+
</script>
22+
--or--
23+
<link href="..."/>
24+
<style>
25+
...
26+
</style>
27+
{% endbundle %}
28+
29+
(dev) to compress:
30+
31+
yarn run webpack
32+
33+
(prod) to compress:
34+
35+
./manage.py bundle && yarn run build
36+
"""
37+
38+
def css_elems(soup):
39+
return soup.find_all({'link': True, 'style': True})
40+
41+
42+
def js_elems(soup):
43+
return soup.find_all('script')
44+
45+
46+
def get_tag(ext, src):
47+
return '<script src="%s"></script>' % src if ext == "js" else '<link rel="stylesheet" href="%s"/>' % src
48+
49+
50+
def check_merge_changes(elems, attr, outputFile):
51+
# fn checks if content is changed since last op
52+
changed = False
53+
# if the block exists as a file - get timestamp so that we can perform cheap comp
54+
blockTs = 0
55+
try:
56+
blockTs = os.path.getmtime(outputFile)
57+
except:
58+
pass
59+
# if any file has changed then we need to regenerate
60+
for el in elems:
61+
if el.get(attr):
62+
# removes static url and erroneous quotes from path
63+
asset = '%s/assets/%s' % (settings.BASE_DIR, el[attr])
64+
# bundle straight to the bundled directory skipping 'bundles'
65+
ts = -1
66+
try:
67+
ts = os.path.getmtime(asset.replace('/', os.sep))
68+
except:
69+
pass
70+
# if any ts is changed then we regenerate
71+
if ts < blockTs:
72+
changed = True
73+
break
74+
else:
75+
changed = True
76+
break
77+
return changed
78+
79+
80+
def get_content(elems, attr, kind, merge):
81+
# concat all input in the block
82+
content = ''
83+
# construct the content by converting tags to import statements
84+
for el in elems:
85+
# is inclusion or inline tag?
86+
if el.get(attr):
87+
# removes static url and erroneous quotes from path
88+
asset = '%s/assets/%s' % (settings.BASE_DIR, el[attr])
89+
# if we're merging the content then run through minify and skip saving of intermediary
90+
if merge:
91+
# bundle straight to the bundled directory skipping 'bundles'
92+
f = open(asset.replace('/', os.sep), 'r', encoding='utf8')
93+
f.seek(0)
94+
c = f.read()
95+
# for production we should minifiy the assets
96+
if settings.ENV in ['prod'] and kind == 'merge_js':
97+
import jsmin
98+
c = jsmin.jsmin(c, quote_chars="'\"`")
99+
elif settings.ENV in ['prod'] and kind == 'merge_css':
100+
import cssmin
101+
c = cssmin.cssmin(c)
102+
# place the content with a new line sep
103+
content += c + '\n'
104+
else:
105+
# import the scripts from the assets dir
106+
if kind == 'js':
107+
content += 'import \'%s\';\n' % asset
108+
else:
109+
content += ' @import \'%s\';\n' % asset
110+
else:
111+
# content held within tags after cleaning up all whitespace on each newline (consistent content regardless of indentation)
112+
content += '\n'.join(str(x).strip() for x in (''.join([str(x) for x in el.contents]).splitlines()))
113+
114+
return content
115+
116+
117+
def render(block, kind, mode, name='asset', forced=False):
118+
# check if we're merging content
119+
merge = True if 'merge' in kind else False
120+
ext = kind.replace('merge_', '')
121+
122+
# output locations
123+
bundled = 'bundled'
124+
bundles = 'bundles' if not merge else bundled
125+
126+
# clean up the block -- essentially we want to drop anything that gets added by staticfinder (could we improve this by not using static in the templates?)
127+
cleanBlock = block.replace(settings.STATIC_URL, '')
128+
129+
# drop any quotes that appear inside the tags - keep the input consistent bs4 will overlook missing quotes
130+
findTags = re.compile(r'(<(script|link|style)(.*?)>)')
131+
if re.search(findTags, cleanBlock) is not None:
132+
for t in re.finditer(findTags, cleanBlock):
133+
tag = t.group(0)
134+
cleanBlock = cleanBlock.replace(tag, tag.replace('"', '').replace('\'', ''))
135+
136+
# in production staticfinder will attach an additional hash to the resource which doesnt exist on the local disk
137+
if settings.ENV in ['prod'] and forced != True:
138+
cleanBlock = re.sub(re.compile(r'(\..{12}\.(css|scss|js))'), r'.\2', cleanBlock)
139+
140+
# parse block with bs4
141+
soup = BeautifulSoup(cleanBlock, "lxml")
142+
# get a hash of the block we're working on (after parsing -- ensures we're always working against the same input)
143+
blockHash = hashlib.sha256(str(soup).encode('utf')).hexdigest()
144+
145+
# In production we don't need to generate new content unless we're running this via the bundle command
146+
if settings.ENV not in ['prod'] or forced == True:
147+
# concat all input in the block
148+
content = ''
149+
# pull the appropriate tags from the block
150+
elems = js_elems(soup) if ext == 'js' else css_elems(soup)
151+
attr = 'src' if ext == 'js' else 'href'
152+
# output disk location (hard-coding assets/v2 -- this could be a setting?)
153+
outputFile = ('%s/assets/v2/%s/%s/%s.%s.%s' % (settings.BASE_DIR, bundles, ext, name, blockHash[0:6], ext)).replace('/', os.sep)
154+
changed = True if merge == False or forced == True else check_merge_changes(elems, attr, outputFile)
155+
156+
# !merge kind is always tested - merge is only recreated if at least one of the inclusions has been altered
157+
if changed:
158+
# retrieve the content for the block/output file
159+
content = get_content(elems, attr, kind, merge)
160+
# ensure the bundles directory exists
161+
os.makedirs(os.path.dirname(outputFile), exist_ok=True)
162+
# open the file in read/write mode
163+
f = open(outputFile, 'a+', encoding='utf8')
164+
f.seek(0)
165+
166+
# if content (of the block) has changed - write new content
167+
if merge or f.read() != content:
168+
# clear the file before writing new content
169+
f.truncate(0)
170+
f.write(content)
171+
f.close()
172+
# print so that we have concise output in the bundle command
173+
print('- Generated: %s' % outputFile)
174+
175+
# in production and not forced we will just return the static bundle
176+
return get_tag(ext, static('v2/%s/%s/%s.%s.%s' % (bundled, ext, name, blockHash[0:6], 'css' if ext == 'scss' else ext)))
177+
178+
179+
class CompressorNode(template.Node):
180+
181+
182+
def __init__(self, nodelist, kind=None, mode='file', name=None):
183+
self.nodelist = nodelist
184+
self.kind = kind
185+
self.mode = mode
186+
self.name = name
187+
188+
189+
def render(self, context, forced=False):
190+
return render(self.nodelist.render(context), self.kind, self.mode, self.name)
191+
192+
193+
@register.tag
194+
def bundle(parser, token):
195+
# pull content and split args from bundle block
196+
nodelist = parser.parse(('endbundle',))
197+
parser.delete_first_token()
198+
199+
args = token.split_contents()
200+
201+
if not len(args) in (2, 3, 4):
202+
raise template.TemplateSyntaxError(
203+
"%r tag requires either one or three arguments." % args[0])
204+
205+
kind = 'scss' if args[1] == 'css' else args[1]
206+
207+
if len(args) >= 3:
208+
mode = args[2]
209+
else:
210+
mode = 'file'
211+
if len(args) == 4:
212+
name = args[3]
213+
else:
214+
name = None
215+
216+
return CompressorNode(nodelist, kind, mode, name)

0 commit comments

Comments
 (0)