1
"""build.py - Minifies and creates the JS build directory."""
18
HERE = os.path.dirname(__file__)
19
BUILD_DIR = os.path.normpath(os.path.join(HERE, '..', '..', '..', 'build'))
20
DEFAULT_SRC_DIR = os.path.normpath(os.path.join(HERE, '..', '..', '..'))
21
PKG_SRC_DIR = pkg_resources.resource_filename(
22
pkg_resources.Requirement.parse("lazr-js"), "lazrjs")
24
ESCAPE_STAR_PROPERTY_RE = re.compile(r'\*([a-zA-Z0-9_-]+):')
25
UNESCAPE_STAR_PROPERTY_RE = re.compile(r'([a-zA-Z0-9_-]+)_ie_star_hack:')
26
URL_RE = re.compile("url\([ \"\']*([^ \"\']+)[ \"\']*\)")
28
from jsmin import JavascriptMinify
31
def relative_path(from_file, to_file):
32
"""Return the relative path between from_file and to_file."""
33
dir_from, base_from = os.path.split(from_file)
34
dir_to, base_to = os.path.split(to_file)
35
path = os.path.relpath(dir_to, dir_from)
38
return os.path.join(path, base_to)
42
"""A file made up of several combined files.
44
It offers support for detecting if the file needs updating and updating it
48
def __init__(self, src_files, target_file):
49
self.src_files = src_files
50
self.target_file = target_file
52
def needs_update(self):
53
"""Return True when the file needs to be updated.
55
This is usually because the target file doesn't exist yet or because
56
one of the source file was modified.
58
# If the target file doesn't exist, we need updating!
59
if not os.path.exists(self.target_file):
62
# Check if the target file was modified after all the src files.
63
target_mtime = os.stat(self.target_file).st_mtime
64
for src_file in self.src_files:
65
if os.stat(src_file).st_mtime > target_mtime:
71
sys.stdout.write(msg + '\n')
74
"""Update the file from its source files."""
75
target_fh = open(self.target_file, 'w')
77
for src_file in self.src_files:
78
self.log("Processing '%s'" % os.path.basename(src_file))
79
target_fh.write(self.get_file_header(src_file))
84
target_fh.write(self.filter_file_content(content, src_file))
86
os.remove(self.target_file)
91
def get_comment(self, msg):
92
"""Return a string wrapped in a comment to be include in the output.
94
Can be used to help annotate the output file.
98
def get_file_header(self, path):
99
"""Return a string to include before outputting a file.
101
Can be used by subclasses to output a file reference in the combined
102
file. Default implementation returns nothing.
106
def filter_file_content(self, file_content, path):
107
"""Hook to process the file content before being combined."""
111
class JSComboFile(ComboFile):
112
"""ComboFile for JavaScript files.
114
Outputs the filename before each combined file and make sure that
115
each file content has a new line.
118
def get_comment(self, msg):
119
return "// %s\n" % msg
121
def get_file_header(self, path):
122
return self.get_comment(relative_path(self.target_file, path))
124
def filter_file_content(self, file_content, path):
125
return file_content + '\n'
128
class CSSComboFile(ComboFile):
129
"""FileCombiner for CSS files.
131
It uses the cssutils.CSSParser to convert all url() instances
132
to the new location, and minify the result.
135
def __init__(self, src_files, target_file, resource_prefix="",
136
minify=True, rewrite_urls=True):
137
super(CSSComboFile, self).__init__(src_files, target_file)
138
self.resource_prefix = resource_prefix.rstrip("/")
140
self.rewrite_urls = rewrite_urls
142
def get_comment(self, msg):
143
return "/* %s */\n" % msg
145
def get_file_header(self, path):
146
return self.get_comment(relative_path(self.target_file, path))
148
def filter_file_content(self, file_content, path):
149
"""URLs are made relative to the target and the CSS is minified."""
150
if self.rewrite_urls:
151
src_dir = os.path.dirname(path)
152
relative_parts = relative_path(self.target_file, src_dir).split(
154
def fix_relative_url(match):
156
# Don't modify absolute URLs or 'data:' urls.
157
if (url.startswith("http") or
158
url.startswith("/") or
159
url.startswith("data:")):
160
return match.group(0)
161
parts = relative_parts + url.split("/")
164
if part == ".." and result and result[-1] != "..":
168
return "url(%s)" % "/".join(
169
filter(None, [self.resource_prefix] + result))
170
file_content = URL_RE.sub(fix_relative_url, file_content)
173
old_serializer = cssutils.ser
174
cssutils.setSerializer(cssutils.serialize.CSSSerializer())
176
cssutils.ser.prefs.useMinified()
178
stylesheet = ESCAPE_STAR_PROPERTY_RE.sub(
179
r'\1_ie_star_hack:', file_content)
180
parser = cssutils.CSSParser(raiseExceptions=True)
181
css = parser.parseString(stylesheet)
182
stylesheet = UNESCAPE_STAR_PROPERTY_RE.sub(
183
r'*\1:', css.cssText)
184
return stylesheet + "\n"
186
cssutils.setSerializer(old_serializer)
187
return file_content + "\n"
192
def __init__(self, name='lazr', build_dir=BUILD_DIR, src_dir=PKG_SRC_DIR,
193
extra_files=None, exclude_regex='', file_type='raw',
194
copy_yui_to=BUILD_DIR):
195
"""Create a new Builder.
197
:param name: The name of the package we are building. This will
198
be used to compute the standalone JS and CSS files.
199
:param build_dir: The directory containing the build tree.
200
:param src_dir: The directory containing the source files.
201
:param extra_files: List of files that should be bundled in the
203
:param exclude_regex: A regex that will exclude file paths from the
204
final rollup. -min and -debug versions will still be built.
205
:param file_type: A string specifying which type of files to include
206
in the final rollup. Default is to use the raw, unmodified JS
207
file. Possible values are 'raw', 'min', and 'debug'. File types
208
are identified by their basename suffix: foo.js, foo-min.js,
210
:param copy_yui_to: The absolute path (as a string) to the place where
211
the YUI modules should be copied to. Defaults to BUILD_DIR.
214
self.build_dir = build_dir
215
self.src_dir = src_dir
216
# We need to support the case where this is being invoked directly
217
# from source rather than a package. If this is the case, the package
218
# src directory won't exist.
219
if not os.path.exists(src_dir):
220
self.src_dir = DEFAULT_SRC_DIR
221
self.built_files = []
223
if extra_files is None:
224
self.extra_files = []
226
self.extra_files = extra_files
228
self.exclusion_regex = exclude_regex
229
self.file_type = file_type
230
self.copy_yui_to = copy_yui_to
232
self.log("Using filter: " + self.file_type)
235
sys.stdout.write(msg + '\n')
238
"""An error was encountered, abort build."""
239
sys.stderr.write(msg + '\n')
242
def file_is_excluded(self, filepath):
243
"""Is the given file path excluded from the rollup process?"""
244
if not self.exclusion_regex:
245
# Include everything.
247
return re.search(self.exclusion_regex, filepath)
249
def ensure_build_directory(self, path):
250
"""Make sure that the named relative path is a directory."""
251
target_dir = os.path.join(self.build_dir, path)
252
if os.path.exists(target_dir):
253
if not os.path.isdir(target_dir):
255
"The target path, '%s', is not a directory!" % target_dir)
257
self.log('Creating %s' % target_dir)
258
os.makedirs(target_dir)
261
def ensure_link(self, src, dst):
262
"""Make sure that src is linked to dst."""
263
if os.path.lexists(dst):
264
if not os.path.islink(dst):
266
"The target path, '%s', is not a symbolic link! " % dst)
268
self.log('Linking %s -> %s' % (src, dst))
271
def link_and_minify(self, component, js_file):
272
"""Create raw, debug and min version of js_file."""
273
component_dir = os.path.join(self.build_dir, component)
274
basename = os.path.splitext(os.path.basename(js_file))[0]
276
raw_file = os.path.join(component_dir, basename + '.js')
277
rel_js_file = relative_path(raw_file, js_file)
278
self.ensure_link(rel_js_file, raw_file)
280
debug_file = os.path.join(component_dir, basename + '-debug.js')
281
self.ensure_link(rel_js_file, debug_file)
283
min_file = os.path.join(component_dir, basename + '-min.js')
284
if (not os.path.exists(min_file)
285
or os.stat(min_file).st_mtime < os.stat(js_file).st_mtime):
286
self.log("Minifying %s into %s." % (js_file, min_file))
287
js_in = open(js_file, 'r')
288
min_out = open(min_file, 'w')
289
minifier = JavascriptMinify()
290
minifier.minify(js_in, min_out)
294
self.built_files.append(
299
def build_assets(self, component_name):
300
"""Build a component's "assets" directory."""
302
isdir = os.path.isdir
304
assets_path = join(component_name, 'assets')
305
src_assets_dir = join(self.src_dir, assets_path)
306
if not isdir(src_assets_dir):
309
target_assets_dir = self.ensure_build_directory(assets_path)
310
# Symlink everything except the skins subdirectory.
311
self.link_directory_content(
312
src_assets_dir, target_assets_dir,
313
lambda src: not src.endswith('skins'))
315
src_skins_dir = join(src_assets_dir, 'skins')
316
if not isdir(src_skins_dir):
320
for skin in os.listdir(src_skins_dir):
321
self.build_skin(component_name, skin)
323
def link_directory_content(self, src_dir, target_dir, link_filter=None):
324
"""Link all the files in src_dir into target_dir.
326
This doesn't recurse into subdirectories, but will happily link
327
subdirectories. It also skips linking backup files.
329
:param link_filter: A callable taking the source file as a parameter.
330
If the filter returns False, no symlink will be created. By
331
default a symlink is created for everything.
333
for name in os.listdir(src_dir):
334
if name.endswith('~'):
336
src = os.path.join(src_dir, name)
337
if link_filter and not link_filter(src):
339
target = os.path.join(target_dir, name)
340
self.ensure_link(relative_path(target, src), target)
342
def build_skin(self, component_name, skin_name):
343
"""Build a skin for a particular component."""
346
skin_dir = join(component_name, 'assets', 'skins', skin_name)
347
src_skin_dir = join(self.src_dir, skin_dir)
348
target_skin_dir = self.ensure_build_directory(skin_dir)
350
# Link everything in there
351
self.link_directory_content(src_skin_dir, target_skin_dir)
353
# Holds all the combined files that are part of the skin
354
skin_files = self.skins.setdefault(skin_name, [])
356
# Create the combined core+skin CSS file.
357
for skin_file in glob(join(src_skin_dir, '*-skin.css')):
358
module_name = os.path.basename(skin_file)[:-len('-skin.css')]
360
target_skin_file = join(target_skin_dir, '%s.css' % module_name)
361
skin_files.append(target_skin_file)
363
# Combine files from the build directory so that
364
# relative paths are sane.
366
os.path.join(target_skin_dir, os.path.basename(skin_file))]
367
core_css_file = join(
368
self.src_dir, component_name, 'assets',
369
'%s-core.css' % module_name)
370
if os.path.exists(core_css_file):
371
css_files.insert(0, core_css_file)
373
combined_css = CSSComboFile(css_files, target_skin_file)
374
if combined_css.needs_update():
375
self.log('Combining %s into %s...' % (
376
", ".join(map(os.path.basename, css_files)),
378
combined_css.update()
380
def update_combined_js_file(self):
381
# Compile all the files in one JS file. Apply the filter to see
382
# which file extensions we should include.
383
build_file = os.path.join(self.build_dir, "%s.js" % self.name)
386
extra_files = [f for f in self.extra_files if f.endswith('.js')]
387
built_files = [f[self.file_type] for f in self.built_files]
389
included_files.extend(extra_files)
390
included_files.extend(built_files)
391
files_to_combine = [f for f in included_files
392
if not self.file_is_excluded(f)]
394
combined_js = JSComboFile(files_to_combine, build_file)
395
if combined_js.needs_update():
396
self.log('Updating %s...' % build_file)
399
def update_combined_css_skins(self):
400
"""Create one combined CSS file per skin."""
401
extra_css_files = [f for f in self.extra_files if f.endswith('.css')]
402
for skin_name in self.skins:
403
skin_build_file = os.path.join(self.build_dir, "%s-%s.css" %
404
(self.name, skin_name))
406
css_files = extra_css_files + self.skins[skin_name]
407
combined_css = CSSComboFile(css_files, skin_build_file)
408
if combined_css.needs_update():
409
self.log('Updating %s...' % skin_build_file)
410
combined_css.update()
412
def find_components(self):
413
"""Find all of the project sub-component names and directories."""
414
for name in os.listdir(self.src_dir):
415
path = os.path.join(self.src_dir, name)
416
if not os.path.isdir(path):
420
def copy_yui_package(self):
421
"""Copy the contents of our 'yui' dir to self.copy_yui_to."""
422
yui_dirs = glob(os.path.join(self.src_dir, 'yui') + '/*')
424
dir_name = os.path.split(dir)[-1]
425
dst_dir = os.path.join(self.copy_yui_to, dir_name)
426
if not os.path.isdir(dst_dir):
427
shutil.copytree(dir, dst_dir)
430
self.copy_yui_package()
431
for name, cpath in self.find_components():
432
files_to_link = glob(os.path.join(cpath, '*.js'))
433
if len(files_to_link) == 0:
435
self.ensure_build_directory(name)
437
for js_file in files_to_link:
438
self.link_and_minify(name, js_file)
440
self.build_assets(name)
442
self.update_combined_js_file()
443
self.update_combined_css_skins()
447
"""Parse the command line options."""
448
parser = optparse.OptionParser(
449
usage="%prog [options] [extra_files]",
451
"Create a build directory of CSS/JS files. "
454
'-n', '--name', dest='name', default='lazr',
455
help=('The basename of the generated compilation file. Defaults to '
458
'-b', '--builddir', dest='build_dir', default=BUILD_DIR,
459
help=('The directory that should contain built files.'))
461
'-s', '--srcdir', dest='src_dir', default=PKG_SRC_DIR,
462
help=('The directory containing the src files.'))
464
'-x', '--exclude', dest='exclude', default='',
466
help=('Exclude any files that match the given regular expression.'))
468
'-f', '--filetype', dest='file_type', default='min',
469
help=('Only bundle files in the source directory that match the '
470
'specified file-type filter. Possible values are '
471
'[min, raw, debug]. [default: %default]'))
473
'-c', '--copy-yui-to', dest='copy_yui_to', default=BUILD_DIR,
474
help=('Copy the YUI tree to the given directory before building'))
475
return parser.parse_args()
479
options, extra= get_options()
482
build_dir=options.build_dir,
483
src_dir=options.src_dir,
485
exclude_regex=options.exclude,
486
file_type=options.file_type,
487
copy_yui_to=options.copy_yui_to,