jsbundler.py revision 46d4c2bc3267f3f028f39e7e311b0f89aba2e4fd
1#!/usr/bin/env python 2 3# Copyright 2014 The Chromium Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7'''Produces various output formats from a set of JavaScript files with 8closure style require/provide calls. 9 10Scans one or more directory trees for JavaScript files. Then, from a 11given list of top-level files, sorts all required input files topologically. 12The top-level files are appended to the sorted list in the order specified 13on the command line. If no root directories are specified, the source 14files are assumed to be ordered already and no dependency analysis is 15performed. The resulting file list can then be used in one of the following 16ways: 17 18- list: a plain list of files, one per line is output. 19 20- html: a series of html <script> tags with src attributes containing paths 21 is output. 22 23- bundle: a concatenation of all the files, separated by newlines is output. 24 25- compressed_bundle: A bundle where non-significant whitespace, including 26 comments, has been stripped is output. 27 28- copy: the files are copied, or hard linked if possible, to the destination 29 directory. In this case, no output is generated. 30''' 31 32 33import optparse 34import os 35import shutil 36import sys 37 38_SCRIPT_DIR = os.path.realpath(os.path.dirname(__file__)) 39_CHROME_SOURCE = os.path.realpath( 40 os.path.join(_SCRIPT_DIR, *[os.path.pardir] * 6)) 41sys.path.insert(0, os.path.join( 42 _CHROME_SOURCE, 'third_party/WebKit/Source/build/scripts')) 43sys.path.insert(0, os.path.join( 44 _CHROME_SOURCE, ('chrome/third_party/chromevox/third_party/' + 45 'closure-library/closure/bin/build'))) 46import depstree 47import rjsmin 48import source 49import treescan 50 51 52def Die(message): 53 '''Prints an error message and exit the program.''' 54 print >>sys.stderr, message 55 sys.exit(1) 56 57 58class SourceWithPaths(source.Source): 59 '''A source.Source object with its relative input and output paths''' 60 61 def __init__(self, content, in_path, out_path): 62 super(SourceWithPaths, self).__init__(content) 63 self._in_path = in_path 64 self._out_path = out_path 65 66 def GetInPath(self): 67 return self._in_path 68 69 def GetOutPath(self): 70 return self._out_path 71 72 73class Bundle(): 74 '''An ordered list of sources without duplicates.''' 75 76 def __init__(self): 77 self._added_paths = set() 78 self._added_sources = [] 79 80 def Add(self, sources): 81 '''Appends one or more source objects the list if it doesn't already 82 exist. 83 84 Args: 85 sources: A SourceWithPath or an iterable of such objects. 86 ''' 87 if isinstance(sources, SourceWithPaths): 88 sources = [sources] 89 for source in sources: 90 path = source.GetInPath() 91 if path not in self._added_paths: 92 self._added_paths.add(path) 93 self._added_sources.append(source) 94 95 def GetOutPaths(self): 96 return (source.GetOutPath() for source in self._added_sources) 97 98 def GetSources(self): 99 return self._added_sources 100 101 def GetUncompressedSource(self): 102 return '\n'.join((s.GetSource() for s in self._added_sources)) 103 104 def GetCompressedSource(self): 105 return rjsmin.jsmin(self.GetUncompressedSource()) 106 107 108class PathRewriter(): 109 '''A list of simple path rewrite rules to map relative input paths to 110 relative output paths. 111 ''' 112 113 def __init__(self, specs): 114 '''Args: 115 specs: A list of mappings, each consisting of the input prefix and 116 the corresponding output prefix separated by colons. 117 ''' 118 self._prefix_map = [] 119 for spec in specs: 120 parts = spec.split(':') 121 if len(parts) != 2: 122 Die('Invalid prefix rewrite spec %s' % spec) 123 if not parts[0].endswith('/') and parts[0] != '': 124 parts[0] += '/' 125 self._prefix_map.append(parts) 126 self._prefix_map.sort(reverse=True) 127 128 def RewritePath(self, in_path): 129 '''Rewrites an input path according to the list of rules. 130 131 Args: 132 in_path, str: The input path to rewrite. 133 Returns: 134 str: The corresponding output path. 135 ''' 136 for in_prefix, out_prefix in self._prefix_map: 137 if in_path.startswith(in_prefix): 138 return os.path.join(out_prefix, in_path[len(in_prefix):]) 139 return in_path 140 141 142def ReadSources(options, args): 143 '''Reads all source specified on the command line, including sources 144 included by --root options. 145 ''' 146 147 def EnsureSourceLoaded(in_path, sources, path_rewriter): 148 if in_path not in sources: 149 out_path = path_rewriter.RewritePath(in_path) 150 sources[in_path] = SourceWithPaths(source.GetFileContents(in_path), 151 in_path, out_path) 152 153 # Only read the actual source file if we will do a dependency analysis or 154 # if we'll need it for the output. 155 need_source_text = (len(options.roots) > 0 or 156 options.mode in ('bundle', 'compressed_bundle')) 157 path_rewriter = PathRewriter(options.prefix_map) 158 sources = {} 159 for root in options.roots: 160 for name in treescan.ScanTreeForJsFiles(root): 161 EnsureSourceLoaded(name, sources, path_rewriter) 162 for path in args: 163 if need_source_text: 164 EnsureSourceLoaded(path, sources, path_rewriter) 165 else: 166 # Just add an empty representation of the source. 167 sources[path] = SourceWithPaths( 168 '', path, path_rewriter.RewritePath(path)) 169 return sources 170 171 172def CalcDeps(bundle, sources, top_level): 173 '''Calculates dependencies for a set of top-level files. 174 175 Args: 176 bundle: Bundle to add the sources to. 177 sources, dict: Mapping from input path to SourceWithPaths objects. 178 top_level, list: List of top-level input paths to calculate dependencies 179 for. 180 ''' 181 def GetBase(sources): 182 for source in sources.itervalues(): 183 if (os.path.basename(source.GetInPath()) == 'base.js' and 184 'goog' in source.provides): 185 return source 186 Die('goog.base not provided by any file') 187 188 providers = [s for s in sources.itervalues() if len(s.provides) > 0] 189 deps = depstree.DepsTree(providers) 190 namespaces = [] 191 for path in top_level: 192 namespaces.extend(sources[path].requires) 193 # base.js is an implicit dependency that always goes first. 194 bundle.Add(GetBase(sources)) 195 bundle.Add(deps.GetDependencies(namespaces)) 196 197 198def LinkOrCopyFiles(sources, dest_dir): 199 '''Copies a list of sources to a destination directory.''' 200 201 def LinkOrCopyOneFile(src, dst): 202 if not os.path.exists(os.path.dirname(dst)): 203 os.makedirs(os.path.dirname(dst)) 204 if os.path.exists(dst): 205 # Avoid clobbering the inode if source and destination refer to the 206 # same file already. 207 if os.path.samefile(src, dst): 208 return 209 os.unlink(dst) 210 try: 211 os.link(src, dst) 212 except: 213 shutil.copy(src, dst) 214 215 for source in sources: 216 LinkOrCopyOneFile(source.GetInPath(), 217 os.path.join(dest_dir, source.GetOutPath())) 218 219 220def WriteOutput(bundle, format, out_file, dest_dir): 221 '''Writes output in the specified format. 222 223 Args: 224 bundle: The ordered bundle iwth all sources already added. 225 format: Output format, one of list, html, bundle, compressed_bundle. 226 out_file: File object to receive the output. 227 dest_dir: Prepended to each path mentioned in the output, if applicable. 228 ''' 229 if format == 'list': 230 paths = bundle.GetOutPaths() 231 if dest_dir: 232 paths = (os.path.join(dest_dir, p) for p in paths) 233 paths = (os.path.normpath(p) for p in paths) 234 out_file.write('\n'.join(paths)) 235 elif format == 'html': 236 HTML_TEMPLATE = '<script src=\'%s\'>' 237 script_lines = (HTML_TEMPLATE % p for p in bundle.GetOutPaths()) 238 out_file.write('\n'.join(script_lines)) 239 elif format == 'bundle': 240 out_file.write(bundle.GetUncompressedSource()) 241 elif format == 'compressed_bundle': 242 out_file.write(bundle.GetCompressedSource()) 243 out_file.write('\n') 244 245 246def CreateOptionParser(): 247 parser = optparse.OptionParser(description=__doc__) 248 parser.usage = '%prog [options] <top_level_file>...' 249 parser.add_option('-d', '--dest_dir', action='store', metavar='DIR', 250 help=('Destination directory. Used when translating ' + 251 'input paths to output paths and when copying ' 252 'files.')) 253 parser.add_option('-o', '--output_file', action='store', metavar='FILE', 254 help=('File to output result to for modes that output ' 255 'a single file.')) 256 parser.add_option('-r', '--root', dest='roots', action='append', default=[], 257 metavar='ROOT', 258 help='Roots of directory trees to scan for sources.') 259 parser.add_option('-w', '--rewrite_prefix', action='append', default=[], 260 dest='prefix_map', metavar='SPEC', 261 help=('Two path prefixes, separated by colons ' + 262 'specifying that a file whose (relative) path ' + 263 'name starts with the first prefix should have ' + 264 'that prefix replaced by the second prefix to ' + 265 'form a path relative to the output directory.')) 266 parser.add_option('-m', '--mode', type='choice', action='store', 267 choices=['list', 'html', 'bundle', 268 'compressed_bundle', 'copy'], 269 default='list', metavar='MODE', 270 help=("Otput mode. One of 'list', 'html', 'bundle', " + 271 "'compressed_bundle' or 'copy'.")) 272 return parser 273 274 275def main(): 276 options, args = CreateOptionParser().parse_args() 277 if len(args) < 1: 278 Die('At least one top-level source file must be specified.') 279 sources = ReadSources(options, args) 280 bundle = Bundle() 281 if len(options.roots) > 0: 282 CalcDeps(bundle, sources, args) 283 bundle.Add((sources[name] for name in args)) 284 if options.mode == 'copy': 285 if options.dest_dir is None: 286 Die('Must specify --dest_dir when copying.') 287 LinkOrCopyFiles(bundle.GetSources(), options.dest_dir) 288 else: 289 if options.output_file: 290 out_file = open(options.output_file, 'w') 291 else: 292 out_file = sys.stdout 293 try: 294 WriteOutput(bundle, options.mode, out_file, options.dest_dir) 295 finally: 296 if options.output_file: 297 out_file.close() 298 299 300if __name__ == '__main__': 301 main() 302