1
"""build.py - Minifies and creates the JS build directory."""
16
HERE = os.path.dirname(__file__)
17
BUILD_DIR = os.path.normpath(os.path.join(HERE, '..', '..', '..', 'build'))
18
DEFAULT_SRC_DIR = os.path.normpath(os.path.join(HERE, '..', '..', '..', 'app', 'javascript', 'lazr'))
20
ESCAPE_STAR_PROPERTY_RE = re.compile(r'\*([a-zA-Z0-9_-]+):')
21
UNESCAPE_STAR_PROPERTY_RE = re.compile(r'([a-zA-Z0-9_-]+)_ie_star_hack:')
22
URL_RE = re.compile("url\([ \"\']*([^ \"\']+)[ \"\']*\)")
24
from jsmin import JavascriptMinify
27
def relative_path(from_file, to_file):
28
"""Return the relative path between from_file and to_file."""
29
dir_from, base_from = os.path.split(from_file)
30
dir_to, base_to = os.path.split(to_file)
31
path = os.path.relpath(dir_to, dir_from)
34
return os.path.join(path, base_to)
38
"""A file made up of several combined files.
40
It offers support for detecting if the file needs updating and updating it
44
def __init__(self, src_files, target_file):
45
self.src_files = src_files
46
self.target_file = target_file
48
def needs_update(self):
49
"""Return True when the file needs to be updated.
51
This is usually because the target file doesn't exist yet or because
52
one of the source file was modified.
54
# If the target file doesn't exist, we need updating!
55
if not os.path.exists(self.target_file):
58
# Check if the target file was modified after all the src files.
59
target_mtime = os.stat(self.target_file).st_mtime
60
for src_file in self.src_files:
61
if os.stat(src_file).st_mtime > target_mtime:
67
sys.stdout.write(msg + '\n')
70
"""Update the file from its source files."""
71
target_fh = open(self.target_file, 'w')
73
for src_file in self.src_files:
74
self.log("Processing '%s'" % os.path.basename(src_file))
75
target_fh.write(self.get_file_header(src_file))
80
target_fh.write(self.filter_file_content(content, src_file))
82
os.remove(self.target_file)
87
def get_comment(self, msg):
88
"""Return a string wrapped in a comment to be include in the output.
90
Can be used to help annotate the output file.
94
def get_file_header(self, path):
95
"""Return a string to include before outputting a file.
97
Can be used by subclasses to output a file reference in the combined
98
file. Default implementation returns nothing.
102
def filter_file_content(self, file_content, path):
103
"""Hook to process the file content before being combined."""
107
class JSComboFile(ComboFile):
108
"""ComboFile for JavaScript files.
110
Outputs the filename before each combined file and make sure that
111
each file content has a new line.
114
def get_comment(self, msg):
115
return "// %s\n" % msg
117
def get_file_header(self, path):
118
return self.get_comment(relative_path(self.target_file, path))
120
def filter_file_content(self, file_content, path):
121
return file_content + '\n'
124
class CSSComboFile(ComboFile):
125
"""FileCombiner for CSS files.
127
It uses the cssutils.CSSParser to convert all url() instances
128
to the new location, and minify the result.
131
def __init__(self, src_files, target_file, resource_prefix="",
132
minify=True, rewrite_urls=True):
133
super(CSSComboFile, self).__init__(src_files, target_file)
134
self.resource_prefix = resource_prefix.rstrip("/")
136
self.rewrite_urls = rewrite_urls
138
def get_comment(self, msg):
139
return "/* %s */\n" % msg
141
def get_file_header(self, path):
142
return self.get_comment(relative_path(self.target_file, path))
144
def filter_file_content(self, file_content, path):
145
"""URLs are made relative to the target and the CSS is minified."""
146
if self.rewrite_urls:
147
src_dir = os.path.dirname(path)
148
relative_parts = relative_path(self.target_file, src_dir).split(
150
def fix_relative_url(match):
152
# Don't modify absolute URLs or 'data:' urls.
153
if (url.startswith("http") or
154
url.startswith("/") or
155
url.startswith("data:")):
156
return match.group(0)
157
parts = relative_parts + url.split("/")
160
if part == ".." and result and result[-1] != "..":
164
return "url(%s)" % "/".join(
165
filter(None, [self.resource_prefix] + result))
166
file_content = URL_RE.sub(fix_relative_url, file_content)
169
old_serializer = cssutils.ser
170
cssutils.setSerializer(cssutils.serialize.CSSSerializer())
172
cssutils.ser.prefs.useMinified()
174
stylesheet = ESCAPE_STAR_PROPERTY_RE.sub(
175
r'\1_ie_star_hack:', file_content)
176
parser = cssutils.CSSParser(raiseExceptions=True)
177
css = parser.parseString(stylesheet)
178
stylesheet = UNESCAPE_STAR_PROPERTY_RE.sub(
179
r'*\1:', css.cssText)
180
return stylesheet + "\n"
182
cssutils.setSerializer(old_serializer)
183
return file_content + "\n"
188
def __init__(self, name='lazr', build_dir=BUILD_DIR, src_dir=DEFAULT_SRC_DIR,
189
extra_files=None, exclude_regex='', file_type='raw'):
190
"""Create a new Builder.
192
:param name: The name of the package we are building. This will
193
be used to compute the standalone JS and CSS files.
194
:param build_dir: The directory containing the build tree.
195
:param src_dir: The directory containing the source files.
196
:param extra_files: List of files that should be bundled in the
198
:param exclude_regex: A regex that will exclude file paths from the
199
final rollup. -min and -debug versions will still be built.
200
:param file_type: A string specifying which type of files to include
201
in the final rollup. Default is to use the raw, unmodified JS
202
file. Possible values are 'raw', 'min', and 'debug'. File types
203
are identified by their basename suffix: foo.js, foo-min.js,
207
self.build_dir = build_dir
208
self.src_dir = src_dir
209
# We need to support the case where this is being invoked directly
210
# from source rather than a package. If this is the case, the package
211
# src directory won't exist.
212
if not os.path.exists(src_dir):
213
self.src_dir = DEFAULT_SRC_DIR
214
self.built_files = []
216
if extra_files is None:
217
self.extra_files = []
219
self.extra_files = extra_files
221
self.exclusion_regex = exclude_regex
222
self.file_type = file_type
224
self.log("Using filter: " + self.file_type)
227
sys.stdout.write(msg + '\n')
230
"""An error was encountered, abort build."""
231
sys.stderr.write(msg + '\n')
234
def file_is_excluded(self, filepath):
235
"""Is the given file path excluded from the rollup process?"""
236
if not self.exclusion_regex:
237
# Include everything.
239
return re.search(self.exclusion_regex, filepath)
241
def ensure_build_directory(self, path):
242
"""Make sure that the named relative path is a directory."""
243
target_dir = os.path.join(self.build_dir, path)
244
if os.path.exists(target_dir):
245
if not os.path.isdir(target_dir):
247
"The target path, '%s', is not a directory!" % target_dir)
249
self.log('Creating %s' % target_dir)
250
os.makedirs(target_dir)
253
def ensure_link(self, src, dst):
254
"""Make sure that src is linked to dst."""
255
if os.path.lexists(dst):
256
if not os.path.islink(dst):
258
"The target path, '%s', is not a symbolic link! " % dst)
260
self.log('Linking %s -> %s' % (src, dst))
263
def link_and_minify(self, component, js_file):
264
"""Create raw, debug and min version of js_file."""
265
component_dir = os.path.join(self.build_dir, component)
266
basename = os.path.splitext(os.path.basename(js_file))[0]
268
raw_file = os.path.join(component_dir, basename + '.js')
269
rel_js_file = relative_path(raw_file, js_file)
270
self.ensure_link(rel_js_file, raw_file)
272
debug_file = os.path.join(component_dir, basename + '-debug.js')
273
self.ensure_link(rel_js_file, debug_file)
275
min_file = os.path.join(component_dir, basename + '-min.js')
276
if (not os.path.exists(min_file)
277
or os.stat(min_file).st_mtime < os.stat(js_file).st_mtime):
278
self.log("Minifying %s into %s." % (js_file, min_file))
279
js_in = open(js_file, 'r')
280
min_out = open(min_file, 'w')
281
minifier = JavascriptMinify()
282
minifier.minify(js_in, min_out)
286
self.built_files.append(
291
def build_assets(self, component_name):
292
"""Build a component's "assets" directory."""
294
isdir = os.path.isdir
296
assets_path = join(component_name, 'assets')
297
src_assets_dir = join(self.src_dir, assets_path)
298
if not isdir(src_assets_dir):
301
target_assets_dir = self.ensure_build_directory(assets_path)
302
# Symlink everything except the skins subdirectory.
303
self.link_directory_content(
304
src_assets_dir, target_assets_dir,
305
lambda src: not src.endswith('skins'))
307
src_skins_dir = join(src_assets_dir, 'skins')
308
if not isdir(src_skins_dir):
312
for skin in os.listdir(src_skins_dir):
313
self.build_skin(component_name, skin)
315
def link_directory_content(self, src_dir, target_dir, link_filter=None):
316
"""Link all the files in src_dir into target_dir.
318
This doesn't recurse into subdirectories, but will happily link
319
subdirectories. It also skips linking backup files.
321
:param link_filter: A callable taking the source file as a parameter.
322
If the filter returns False, no symlink will be created. By
323
default a symlink is created for everything.
325
for name in os.listdir(src_dir):
326
if name.endswith('~'):
328
src = os.path.join(src_dir, name)
329
if link_filter and not link_filter(src):
331
target = os.path.join(target_dir, name)
332
self.ensure_link(relative_path(target, src), target)
334
def build_skin(self, component_name, skin_name):
335
"""Build a skin for a particular component."""
338
skin_dir = join(component_name, 'assets', 'skins', skin_name)
339
src_skin_dir = join(self.src_dir, skin_dir)
340
target_skin_dir = self.ensure_build_directory(skin_dir)
342
# Link everything in there
343
self.link_directory_content(src_skin_dir, target_skin_dir)
345
# Holds all the combined files that are part of the skin
346
skin_files = self.skins.setdefault(skin_name, [])
348
# Create the combined core+skin CSS file.
349
for skin_file in glob(join(src_skin_dir, '*-skin.css')):
350
module_name = os.path.basename(skin_file)[:-len('-skin.css')]
352
target_skin_file = join(target_skin_dir, '%s.css' % module_name)
353
skin_files.append(target_skin_file)
355
# Combine files from the build directory so that
356
# relative paths are sane.
358
os.path.join(target_skin_dir, os.path.basename(skin_file))]
359
core_css_file = join(
360
self.src_dir, component_name, 'assets',
361
'%s-core.css' % module_name)
362
if os.path.exists(core_css_file):
363
css_files.insert(0, core_css_file)
365
combined_css = CSSComboFile(css_files, target_skin_file)
366
if combined_css.needs_update():
367
self.log('Combining %s into %s...' % (
368
", ".join(map(os.path.basename, css_files)),
370
combined_css.update()
372
def update_combined_js_file(self):
373
# Compile all the files in one JS file. Apply the filter to see
374
# which file extensions we should include.
375
build_file = os.path.join(self.build_dir, "%s.js" % self.name)
378
extra_files = [f for f in self.extra_files if f.endswith('.js')]
379
built_files = [f[self.file_type] for f in self.built_files]
381
included_files.extend(extra_files)
382
included_files.extend(built_files)
383
files_to_combine = [f for f in included_files
384
if not self.file_is_excluded(f)]
386
combined_js = JSComboFile(files_to_combine, build_file)
387
if combined_js.needs_update():
388
self.log('Updating %s...' % build_file)
391
def update_combined_css_skins(self):
392
"""Create one combined CSS file per skin."""
393
extra_css_files = [f for f in self.extra_files if f.endswith('.css')]
394
for skin_name in self.skins:
395
skin_build_file = os.path.join(self.build_dir, "%s-%s.css" %
396
(self.name, skin_name))
398
css_files = extra_css_files + self.skins[skin_name]
399
combined_css = CSSComboFile(css_files, skin_build_file)
400
if combined_css.needs_update():
401
self.log('Updating %s...' % skin_build_file)
402
combined_css.update()
404
def find_components(self):
405
"""Find all of the project sub-component names and directories."""
406
for name in os.listdir(self.src_dir):
407
path = os.path.join(self.src_dir, name)
408
if not os.path.isdir(path):
413
for name, cpath in self.find_components():
414
files_to_link = glob(os.path.join(cpath, '*.js'))
415
if len(files_to_link) == 0:
417
self.ensure_build_directory(name)
419
for js_file in files_to_link:
420
self.link_and_minify(name, js_file)
422
self.build_assets(name)
424
self.update_combined_js_file()
425
self.update_combined_css_skins()
429
"""Parse the command line options."""
430
parser = optparse.OptionParser(
431
usage="%prog [options] [extra_files]",
433
"Create a build directory of CSS/JS files. "
436
'-n', '--name', dest='name', default='lazr',
437
help=('The basename of the generated compilation file. Defaults to '
440
'-b', '--builddir', dest='build_dir', default=BUILD_DIR,
441
help=('The directory that should contain built files.'))
443
'-s', '--srcdir', dest='src_dir', default=DEFAULT_SRC_DIR,
444
help=('The directory containing the src files.'))
446
'-x', '--exclude', dest='exclude', default='',
448
help=('Exclude any files that match the given regular expression.'))
450
'-f', '--filetype', dest='file_type', default='min',
451
help=('Only bundle files in the source directory that match the '
452
'specified file-type filter. Possible values are '
453
'[min, raw, debug]. [default: %default]'))
454
return parser.parse_args()
458
options, extra= get_options()
462
build_dir=options.build_dir,
463
src_dir=options.src_dir,
465
exclude_regex=options.exclude,
466
file_type=options.file_type,