jsbundler.py revision cedac228d2dd51db4b79ea1e72c7f249408ee061
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('/'): 124 parts[0] += '/' 125 self._prefix_map.append(parts) 126 127 def RewritePath(self, in_path): 128 '''Rewrites an input path according to the list of rules. 129 130 Args: 131 in_path, str: The input path to rewrite. 132 Returns: 133 str: The corresponding output path. 134 ''' 135 for in_prefix, out_prefix in self._prefix_map: 136 if in_path.startswith(in_prefix): 137 return os.path.join(out_prefix, in_path[len(in_prefix):]) 138 return in_path 139 140 141def ReadSources(options, args): 142 '''Reads all source specified on the command line, including sources 143 included by --root options. 144 ''' 145 146 def EnsureSourceLoaded(in_path, sources, path_rewriter): 147 if in_path not in sources: 148 out_path = path_rewriter.RewritePath(in_path) 149 sources[in_path] = SourceWithPaths(source.GetFileContents(in_path), 150 in_path, out_path) 151 152 # Only read the actual source file if we will do a dependency analysis or 153 # if we'll need it for the output. 154 need_source_text = (len(options.roots) > 0 or 155 options.mode in ('bundle', 'compressed_bundle')) 156 path_rewriter = PathRewriter(options.prefix_map) 157 sources = {} 158 for root in options.roots: 159 for name in treescan.ScanTreeForJsFiles(root): 160 EnsureSourceLoaded(name, sources, path_rewriter) 161 for path in args: 162 if need_source_text: 163 EnsureSourceLoaded(path, sources, path_rewriter) 164 else: 165 # Just add an empty representation of the source. 166 sources[path] = SourceWithPaths( 167 '', path, path_rewriter.RewritePath(path)) 168 return sources 169 170 171def CalcDeps(bundle, sources, top_level): 172 '''Calculates dependencies for a set of top-level files. 173 174 Args: 175 bundle: Bundle to add the sources to. 176 sources, dict: Mapping from input path to SourceWithPaths objects. 177 top_level, list: List of top-level input paths to calculate dependencies 178 for. 179 ''' 180 def GetBase(sources): 181 for source in sources.itervalues(): 182 if (os.path.basename(source.GetInPath()) == 'base.js' and 183 'goog' in source.provides): 184 return source 185 Die('goog.base not provided by any file') 186 187 providers = [s for s in sources.itervalues() if len(s.provides) > 0] 188 deps = depstree.DepsTree(providers) 189 namespaces = [] 190 for path in top_level: 191 namespaces.extend(sources[path].requires) 192 # base.js is an implicit dependency that always goes first. 193 bundle.Add(GetBase(sources)) 194 bundle.Add(deps.GetDependencies(namespaces)) 195 196 197def LinkOrCopyFiles(sources, dest_dir): 198 '''Copies a list of sources to a destination directory.''' 199 200 def LinkOrCopyOneFile(src, dst): 201 if not os.path.exists(os.path.dirname(dst)): 202 os.makedirs(os.path.dirname(dst)) 203 if os.path.exists(dst): 204 # Avoid clobbering the inode if source and destination refer to the 205 # same file already. 206 if os.path.samefile(src, dst): 207 return 208 os.unlink(dst) 209 try: 210 os.link(src, dst) 211 except: 212 shutil.copy(src, dst) 213 214 for source in sources: 215 LinkOrCopyOneFile(source.GetInPath(), 216 os.path.join(dest_dir, source.GetOutPath())) 217 218 219def WriteOutput(bundle, format, out_file, dest_dir): 220 '''Writes output in the specified format. 221 222 Args: 223 bundle: The ordered bundle iwth all sources already added. 224 format: Output format, one of list, html, bundle, compressed_bundle. 225 out_file: File object to receive the output. 226 dest_dir: Prepended to each path mentioned in the output, if applicable. 227 ''' 228 if format == 'list': 229 paths = bundle.GetOutPaths() 230 if dest_dir: 231 paths = (os.path.join(dest_dir, p) for p in paths) 232 paths = (os.path.normpath(p) for p in paths) 233 out_file.write('\n'.join(paths)) 234 elif format == 'html': 235 HTML_TEMPLATE = '<script src=\'%s\'>' 236 script_lines = (HTML_TEMPLATE % p for p in bundle.GetOutPaths()) 237 out_file.write('\n'.join(script_lines)) 238 elif format == 'bundle': 239 out_file.write(bundle.GetUncompressedSource()) 240 elif format == 'compressed_bundle': 241 out_file.write(bundle.GetCompressedSource()) 242 out_file.write('\n') 243 244 245def CreateOptionParser(): 246 parser = optparse.OptionParser(description=__doc__) 247 parser.usage = '%prog [options] <top_level_file>...' 248 parser.add_option('-d', '--dest_dir', action='store', metavar='DIR', 249 help=('Destination directory. Used when translating ' + 250 'input paths to output paths and when copying ' 251 'files.')) 252 parser.add_option('-o', '--output_file', action='store', metavar='FILE', 253 help=('File to output result to for modes that output ' 254 'a single file.')) 255 parser.add_option('-r', '--root', dest='roots', action='append', default=[], 256 metavar='ROOT', 257 help='Roots of directory trees to scan for sources.') 258 parser.add_option('-w', '--rewrite_prefix', action='append', default=[], 259 dest='prefix_map', metavar='SPEC', 260 help=('Two path prefixes, separated by colons ' + 261 'specifying that a file whose (relative) path ' + 262 'name starts with the first prefix should have ' + 263 'that prefix replaced by the second prefix to ' + 264 'form a path relative to the output directory.')) 265 parser.add_option('-m', '--mode', type='choice', action='store', 266 choices=['list', 'html', 'bundle', 267 'compressed_bundle', 'copy'], 268 default='list', metavar='MODE', 269 help=("Otput mode. One of 'list', 'html', 'bundle', " + 270 "'compressed_bundle' or 'copy'.")) 271 return parser 272 273 274def main(): 275 options, args = CreateOptionParser().parse_args() 276 if len(args) < 1: 277 Die('At least one top-level source file must be specified.') 278 sources = ReadSources(options, args) 279 bundle = Bundle() 280 if len(options.roots) > 0: 281 CalcDeps(bundle, sources, args) 282 bundle.Add((sources[name] for name in args)) 283 if options.mode == 'copy': 284 if options.dest_dir is None: 285 Die('Must specify --dest_dir when copying.') 286 LinkOrCopyFiles(bundle.GetSources(), options.dest_dir) 287 else: 288 if options.output_file: 289 out_file = open(options.output_file, 'w') 290 else: 291 out_file = sys.stdout 292 try: 293 WriteOutput(bundle, options.mode, out_file, options.dest_dir) 294 finally: 295 if options.output_file: 296 out_file.close() 297 298 299if __name__ == '__main__': 300 main() 301