~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/scripts/utilities/js/jsbuild.py

[r=deryck][bug=803954] Bring lazr-js source into lp tree and package
        yui as a dependency

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
"""build.py - Minifies and creates the JS build directory."""
 
2
 
 
3
__metaclass__ = type
 
4
__all__ = []
 
5
 
 
6
 
 
7
import optparse
 
8
import os
 
9
import re
 
10
import sys
 
11
 
 
12
from glob import glob
 
13
 
 
14
import cssutils
 
15
 
 
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'))
 
19
 
 
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\([ \"\']*([^ \"\']+)[ \"\']*\)")
 
23
 
 
24
from jsmin import JavascriptMinify
 
25
 
 
26
 
 
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)
 
32
    if path == ".":
 
33
        return base_to
 
34
    return os.path.join(path, base_to)
 
35
 
 
36
 
 
37
class ComboFile:
 
38
    """A file made up of several combined files.
 
39
 
 
40
    It offers support for detecting if the file needs updating and updating it
 
41
    from it source files.
 
42
    """
 
43
 
 
44
    def __init__(self, src_files, target_file):
 
45
        self.src_files = src_files
 
46
        self.target_file = target_file
 
47
 
 
48
    def needs_update(self):
 
49
        """Return True when the file needs to be updated.
 
50
 
 
51
        This is usually because the target file doesn't exist yet or because
 
52
        one of the source file was modified.
 
53
        """
 
54
        # If the target file doesn't exist, we need updating!
 
55
        if not os.path.exists(self.target_file):
 
56
            return True
 
57
 
 
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:
 
62
                return True
 
63
        else:
 
64
            return False
 
65
 
 
66
    def log(self, msg):
 
67
        sys.stdout.write(msg + '\n')
 
68
 
 
69
    def update(self):
 
70
        """Update the file from its source files."""
 
71
        target_fh = open(self.target_file, 'w')
 
72
        try:
 
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))
 
76
                fh = open(src_file)
 
77
                content = fh.read()
 
78
                fh.close()
 
79
                try:
 
80
                    target_fh.write(self.filter_file_content(content, src_file))
 
81
                except Exception:
 
82
                    os.remove(self.target_file)
 
83
                    raise
 
84
        finally:
 
85
            target_fh.close()
 
86
 
 
87
    def get_comment(self, msg):
 
88
        """Return a string wrapped in a comment to be include in the output.
 
89
 
 
90
        Can be used to help annotate the output file.
 
91
        """
 
92
        return ''
 
93
 
 
94
    def get_file_header(self, path):
 
95
        """Return a string to include before outputting a file.
 
96
 
 
97
        Can be used by subclasses to output a file reference in the combined
 
98
        file. Default implementation returns nothing.
 
99
        """
 
100
        return ''
 
101
 
 
102
    def filter_file_content(self, file_content, path):
 
103
        """Hook to process the file content before being combined."""
 
104
        return file_content
 
105
 
 
106
 
 
107
class JSComboFile(ComboFile):
 
108
    """ComboFile for JavaScript files.
 
109
 
 
110
    Outputs the filename before each combined file and make sure that
 
111
    each file content has a new line.
 
112
    """
 
113
 
 
114
    def get_comment(self, msg):
 
115
        return "// %s\n" % msg
 
116
 
 
117
    def get_file_header(self, path):
 
118
        return self.get_comment(relative_path(self.target_file, path))
 
119
 
 
120
    def filter_file_content(self, file_content, path):
 
121
        return file_content + '\n'
 
122
 
 
123
 
 
124
class CSSComboFile(ComboFile):
 
125
    """FileCombiner for CSS files.
 
126
 
 
127
    It uses the cssutils.CSSParser to convert all url() instances
 
128
    to the new location, and minify the result.
 
129
    """
 
130
 
 
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("/")
 
135
        self.minify = minify
 
136
        self.rewrite_urls = rewrite_urls
 
137
 
 
138
    def get_comment(self, msg):
 
139
        return "/* %s */\n" % msg
 
140
 
 
141
    def get_file_header(self, path):
 
142
        return self.get_comment(relative_path(self.target_file, path))
 
143
 
 
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(
 
149
                os.path.sep)
 
150
            def fix_relative_url(match):
 
151
                url = match.group(1)
 
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("/")
 
158
                result = []
 
159
                for part in parts:
 
160
                    if part == ".." and result and result[-1] != "..":
 
161
                        result.pop(-1)
 
162
                        continue
 
163
                    result.append(part)
 
164
                return "url(%s)" % "/".join(
 
165
                    filter(None, [self.resource_prefix] + result))
 
166
            file_content = URL_RE.sub(fix_relative_url, file_content)
 
167
 
 
168
        if self.minify:
 
169
            old_serializer = cssutils.ser
 
170
            cssutils.setSerializer(cssutils.serialize.CSSSerializer())
 
171
            try:
 
172
                cssutils.ser.prefs.useMinified()
 
173
 
 
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"
 
181
            finally:
 
182
                cssutils.setSerializer(old_serializer)
 
183
        return file_content + "\n"
 
184
 
 
185
 
 
186
class Builder:
 
187
 
 
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.
 
191
 
 
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
 
197
            standalone file.
 
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,
 
204
            foo-debug.js, etc.
 
205
        """
 
206
        self.name = name
 
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 = []
 
215
        self.skins = {}
 
216
        if extra_files is None:
 
217
            self.extra_files = []
 
218
        else:
 
219
            self.extra_files = extra_files
 
220
 
 
221
        self.exclusion_regex = exclude_regex
 
222
        self.file_type = file_type
 
223
 
 
224
        self.log("Using filter: " + self.file_type)
 
225
 
 
226
    def log(self, msg):
 
227
        sys.stdout.write(msg + '\n')
 
228
 
 
229
    def fail(self, msg):
 
230
        """An error was encountered, abort build."""
 
231
        sys.stderr.write(msg + '\n')
 
232
        sys.exit(1)
 
233
 
 
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.
 
238
            return False
 
239
        return re.search(self.exclusion_regex, filepath)
 
240
 
 
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):
 
246
                self.fail(
 
247
                    "The target path, '%s', is not a directory!" % target_dir)
 
248
        else:
 
249
            self.log('Creating %s' % target_dir)
 
250
            os.makedirs(target_dir)
 
251
        return target_dir
 
252
 
 
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):
 
257
                self.fail(
 
258
                    "The target path, '%s', is not a symbolic link! " % dst)
 
259
        else:
 
260
            self.log('Linking %s -> %s' % (src, dst))
 
261
            os.symlink(src, dst)
 
262
 
 
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]
 
267
 
 
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)
 
271
 
 
272
        debug_file = os.path.join(component_dir, basename + '-debug.js')
 
273
        self.ensure_link(rel_js_file, debug_file)
 
274
 
 
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)
 
283
            js_in.close()
 
284
            min_out.close()
 
285
 
 
286
        self.built_files.append(
 
287
            {'raw': raw_file,
 
288
             'debug': debug_file,
 
289
             'min': min_file})
 
290
 
 
291
    def build_assets(self, component_name):
 
292
        """Build a component's "assets" directory."""
 
293
        join = os.path.join
 
294
        isdir = os.path.isdir
 
295
 
 
296
        assets_path = join(component_name, 'assets')
 
297
        src_assets_dir = join(self.src_dir, assets_path)
 
298
        if not isdir(src_assets_dir):
 
299
            return
 
300
 
 
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'))
 
306
 
 
307
        src_skins_dir = join(src_assets_dir, 'skins')
 
308
        if not isdir(src_skins_dir):
 
309
            return
 
310
 
 
311
        # Process sub-skins.
 
312
        for skin in os.listdir(src_skins_dir):
 
313
            self.build_skin(component_name, skin)
 
314
 
 
315
    def link_directory_content(self, src_dir, target_dir, link_filter=None):
 
316
        """Link all the files in src_dir into target_dir.
 
317
 
 
318
        This doesn't recurse into subdirectories, but will happily link
 
319
        subdirectories. It also skips linking backup files.
 
320
 
 
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.
 
324
        """
 
325
        for name in os.listdir(src_dir):
 
326
            if name.endswith('~'):
 
327
                continue
 
328
            src = os.path.join(src_dir, name)
 
329
            if link_filter and not link_filter(src):
 
330
                continue
 
331
            target = os.path.join(target_dir, name)
 
332
            self.ensure_link(relative_path(target, src), target)
 
333
 
 
334
    def build_skin(self, component_name, skin_name):
 
335
        """Build a skin for a particular component."""
 
336
        join = os.path.join
 
337
 
 
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)
 
341
 
 
342
        # Link everything in there
 
343
        self.link_directory_content(src_skin_dir, target_skin_dir)
 
344
 
 
345
        # Holds all the combined files that are part of the skin
 
346
        skin_files = self.skins.setdefault(skin_name, [])
 
347
 
 
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')]
 
351
 
 
352
            target_skin_file = join(target_skin_dir, '%s.css' % module_name)
 
353
            skin_files.append(target_skin_file)
 
354
 
 
355
            # Combine files from the build directory so that
 
356
            # relative paths are sane.
 
357
            css_files = [
 
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)
 
364
 
 
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)),
 
369
                    target_skin_file))
 
370
                combined_css.update()
 
371
 
 
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)
 
376
 
 
377
        included_files = []
 
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]
 
380
 
 
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)]
 
385
 
 
386
        combined_js = JSComboFile(files_to_combine, build_file)
 
387
        if combined_js.needs_update():
 
388
            self.log('Updating %s...' % build_file)
 
389
            combined_js.update()
 
390
 
 
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))
 
397
 
 
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()
 
403
 
 
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):
 
409
                continue
 
410
            yield name, path
 
411
 
 
412
    def do_build(self):
 
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:
 
416
                continue
 
417
            self.ensure_build_directory(name)
 
418
 
 
419
            for js_file in files_to_link:
 
420
                self.link_and_minify(name, js_file)
 
421
 
 
422
            self.build_assets(name)
 
423
 
 
424
        self.update_combined_js_file()
 
425
        self.update_combined_css_skins()
 
426
 
 
427
 
 
428
def get_options():
 
429
    """Parse the command line options."""
 
430
    parser = optparse.OptionParser(
 
431
        usage="%prog [options] [extra_files]",
 
432
        description=(
 
433
            "Create a build directory of CSS/JS files. "
 
434
            ))
 
435
    parser.add_option(
 
436
        '-n', '--name', dest='name', default='lazr',
 
437
        help=('The basename of the generated compilation file. Defaults to '
 
438
            '"lazr".'))
 
439
    parser.add_option(
 
440
        '-b', '--builddir', dest='build_dir', default=BUILD_DIR,
 
441
        help=('The directory that should contain built files.'))
 
442
    parser.add_option(
 
443
        '-s', '--srcdir', dest='src_dir', default=DEFAULT_SRC_DIR,
 
444
        help=('The directory containing the src files.'))
 
445
    parser.add_option(
 
446
        '-x', '--exclude', dest='exclude', default='',
 
447
        metavar='REGEX',
 
448
        help=('Exclude any files that match the given regular expression.'))
 
449
    parser.add_option(
 
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()
 
455
 
 
456
 
 
457
def main():
 
458
    options, extra= get_options()
 
459
 
 
460
    Builder(
 
461
       name=options.name,
 
462
       build_dir=options.build_dir,
 
463
       src_dir=options.src_dir,
 
464
       extra_files=extra,
 
465
       exclude_regex=options.exclude,
 
466
       file_type=options.file_type,
 
467
       ).do_build()