Bug 1421932 - Update compare-locales to 2.5.1, python-fluent to 0.4.4. r=Pike
authorStaś Małolepszy <stas@mozilla.com>
Thu, 30 Nov 2017 14:18:24 +0100
changeset 394531 81cef8fbd704f73061c97f0940ef335d9242e1ac
parent 394530 4eb0f190d3aa4ab33858c31d563d28770b2b8281
child 394532 42f9f4668d0315822831a4b653099ccb8bacf0c2
push id56359
push useraxel@mozilla.com
push dateFri, 01 Dec 2017 08:36:23 +0000
treeherderautoland@81cef8fbd704 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersPike
bugs1421932
milestone59.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1421932 - Update compare-locales to 2.5.1, python-fluent to 0.4.4. r=Pike MozReview-Commit-ID: GedJNGewU9R
third_party/python/compare-locales/compare_locales/__init__.py
third_party/python/compare-locales/compare_locales/checks.py
third_party/python/compare-locales/compare_locales/compare.py
third_party/python/compare-locales/compare_locales/merge.py
third_party/python/compare-locales/compare_locales/parser.py
third_party/python/compare-locales/compare_locales/paths.py
third_party/python/compare-locales/compare_locales/plurals.py
third_party/python/compare-locales/compare_locales/tests/test_defines.py
third_party/python/compare-locales/compare_locales/tests/test_dtd.py
third_party/python/compare-locales/compare_locales/tests/test_ftl.py
third_party/python/compare-locales/compare_locales/tests/test_merge.py
third_party/python/compare-locales/compare_locales/tests/test_merge_comments.py
third_party/python/compare-locales/compare_locales/tests/test_merge_dtd.py
third_party/python/compare-locales/compare_locales/tests/test_merge_ftl.py
third_party/python/compare-locales/compare_locales/tests/test_merge_messages.py
third_party/python/compare-locales/compare_locales/tests/test_merge_properties.py
third_party/python/compare-locales/compare_locales/tests/test_merge_unknown.py
third_party/python/compare-locales/compare_locales/tests/test_merge_whitespace.py
third_party/python/compare-locales/compare_locales/tests/test_parser.py
third_party/python/compare-locales/compare_locales/tests/test_properties.py
third_party/python/fluent/PKG-INFO
third_party/python/fluent/fluent/migrate/__init__.py
third_party/python/fluent/fluent/migrate/context.py
third_party/python/fluent/fluent/migrate/errors.py
third_party/python/fluent/fluent/migrate/helpers.py
third_party/python/fluent/fluent/migrate/merge.py
third_party/python/fluent/fluent/migrate/transforms.py
third_party/python/fluent/setup.cfg
third_party/python/fluent/setup.py
third_party/python/fluent/tools/fluentfmt.py
third_party/python/fluent/tools/migrate/README.md
third_party/python/fluent/tools/migrate/blame.py
third_party/python/fluent/tools/migrate/examples/__init__.py
third_party/python/fluent/tools/migrate/examples/about_dialog.ftl
third_party/python/fluent/tools/migrate/examples/about_dialog.py
third_party/python/fluent/tools/migrate/examples/about_downloads.ftl
third_party/python/fluent/tools/migrate/examples/about_downloads.py
third_party/python/fluent/tools/migrate/examples/brand.ftl
third_party/python/fluent/tools/migrate/examples/bug_1291693.py
third_party/python/fluent/tools/migrate/examples/menubar.ftl
third_party/python/fluent/tools/migrate/examples/toolbar.ftl
third_party/python/fluent/tools/migrate/migrate-l10n.py
third_party/python/fluent/tools/parse.py
third_party/python/fluent/tools/serialize.py
--- a/third_party/python/compare-locales/compare_locales/__init__.py
+++ b/third_party/python/compare-locales/compare_locales/__init__.py
@@ -1,1 +1,1 @@
-version = "2.1"
+version = "2.5.1"
--- a/third_party/python/compare-locales/compare_locales/checks.py
+++ b/third_party/python/compare-locales/compare_locales/checks.py
@@ -208,17 +208,18 @@ class DTDChecker(Checker):
         if self.extra_tests is not None and 'android-dtd' in self.extra_tests:
             self.processContent = True
         self.__known_entities = None
 
     def known_entities(self, refValue):
         if self.__known_entities is None and self.reference is not None:
             self.__known_entities = set()
             for ent in self.reference:
-                self.__known_entities.update(self.entities_for_value(ent.val))
+                self.__known_entities.update(
+                    self.entities_for_value(ent.raw_val))
         return self.__known_entities if self.__known_entities is not None \
             else self.entities_for_value(refValue)
 
     def entities_for_value(self, value):
         reflist = set(m.group(1).encode('utf-8')
                       for m in self.eref.finditer(value))
         reflist -= self.xmllist
         return reflist
@@ -243,17 +244,17 @@ class DTDChecker(Checker):
                        {'spec': spec.pattern})
 
     def check(self, refEnt, l10nEnt):
         """Try to parse the refvalue inside a dummy element, and keep
         track of entities that we need to define to make that work.
 
         Return a checker that offers just those entities.
         """
-        refValue, l10nValue = refEnt.val, l10nEnt.val
+        refValue, l10nValue = refEnt.raw_val, l10nEnt.raw_val
         # find entities the refValue references,
         # reusing markup from DTDParser.
         reflist = self.known_entities(refValue)
         inContext = self.entities_for_value(refValue)
         entities = ''.join('<!ENTITY %s "">' % s for s in sorted(reflist))
         parser = sax.make_parser()
         parser.setFeature(sax.handler.feature_external_ges, False)
 
@@ -429,65 +430,63 @@ class DTDChecker(Checker):
                 yield ('error', m.end(0)+offset, msg, 'android')
 
 
 class FluentChecker(Checker):
     '''Tests to run on Fluent (FTL) files.
     '''
     pattern = re.compile('.*\.ftl')
 
-    # Positions yielded by FluentChecker.check are absolute offsets from the
-    # beginning of the file.  This is different from the base Checker behavior
-    # which yields offsets from the beginning of the current entity's value.
     def check(self, refEnt, l10nEnt):
         ref_entry = refEnt.entry
         l10n_entry = l10nEnt.entry
         # verify that values match, either both have a value or none
         if ref_entry.value is not None and l10n_entry.value is None:
-            yield ('error', l10n_entry.span.start,
-                   'Missing value', 'fluent')
+            yield ('error', 0, 'Missing value', 'fluent')
         if ref_entry.value is None and l10n_entry.value is not None:
-            yield ('error', l10n_entry.value.span.start,
-                   'Obsolete value', 'fluent')
+            offset = l10n_entry.value.span.start - l10n_entry.span.start
+            yield ('error', offset, 'Obsolete value', 'fluent')
 
         # verify that we're having the same set of attributes
         ref_attr_names = set((attr.id.name for attr in ref_entry.attributes))
         ref_pos = dict((attr.id.name, i)
                        for i, attr in enumerate(ref_entry.attributes))
         l10n_attr_counts = \
             Counter(attr.id.name for attr in l10n_entry.attributes)
         l10n_attr_names = set(l10n_attr_counts)
         l10n_pos = dict((attr.id.name, i)
                         for i, attr in enumerate(l10n_entry.attributes))
         # check for duplicate Attributes
         # only warn to not trigger a merge skip
         for attr_name, cnt in l10n_attr_counts.items():
             if cnt > 1:
+                offset = (
+                    l10n_entry.attributes[l10n_pos[attr_name]].span.start
+                    - l10n_entry.span.start)
                 yield (
                     'warning',
-                    l10n_entry.attributes[l10n_pos[attr_name]].span.start,
+                    offset,
                     'Attribute "{}" occurs {} times'.format(
                         attr_name, cnt),
                     'fluent')
 
         missing_attr_names = sorted(ref_attr_names - l10n_attr_names,
                                     key=lambda k: ref_pos[k])
         for attr_name in missing_attr_names:
-            yield ('error', l10n_entry.span.start,
-                   'Missing attribute: ' + attr_name, 'fluent')
+            yield ('error', 0, 'Missing attribute: ' + attr_name, 'fluent')
 
         obsolete_attr_names = sorted(l10n_attr_names - ref_attr_names,
                                      key=lambda k: l10n_pos[k])
         obsolete_attrs = [
             attr
             for attr in l10n_entry.attributes
             if attr.id.name in obsolete_attr_names
         ]
         for attr in obsolete_attrs:
-            yield ('error', attr.span.start,
+            yield ('error', attr.span.start - l10n_entry.span.start,
                    'Obsolete attribute: ' + attr.id.name, 'fluent')
 
 
 def getChecker(file, extra_tests=None):
     if PropertiesChecker.use(file):
         return PropertiesChecker(extra_tests)
     if DTDChecker.use(file):
         return DTDChecker(extra_tests)
--- a/third_party/python/compare-locales/compare_locales/compare.py
+++ b/third_party/python/compare-locales/compare_locales/compare.py
@@ -5,20 +5,17 @@
 'Mozilla l10n compare locales tool'
 
 import codecs
 import os
 import shutil
 import re
 from collections import defaultdict
 
-try:
-    from json import dumps
-except:
-    from simplejson import dumps
+from json import dumps
 
 from compare_locales import parser
 from compare_locales import paths, mozpath
 from compare_locales.checks import getChecker
 
 
 class Tree(object):
     def __init__(self, valuetype):
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/merge.py
@@ -0,0 +1,115 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+'Merge resources across channels.'
+
+from collections import OrderedDict, defaultdict
+from codecs import encode
+
+
+from compare_locales import parser as cl
+from compare_locales.compare import AddRemove
+
+
+class MergeNotSupportedError(ValueError):
+    pass
+
+
+def merge_channels(name, *resources):
+    try:
+        parser = cl.getParser(name)
+    except UserWarning:
+        raise MergeNotSupportedError(
+            'Unsupported file format ({}).'.format(name))
+
+    # A map of comments to the keys of entities they belong to.
+    comments = {}
+
+    def parse_resource(resource):
+        # The counter dict keeps track of number of identical comments.
+        counter = defaultdict(int)
+        parser.readContents(resource)
+        pairs = [get_key_value(entity, counter) for entity in parser.walk()]
+        return OrderedDict(pairs)
+
+    def get_key_value(entity, counter):
+        if isinstance(entity, cl.Comment):
+            counter[entity.all] += 1
+            # Use the (value, index) tuple as the key. AddRemove will
+            # de-deplicate identical comments at the same index.
+            return ((entity.all, counter[entity.all]), entity)
+
+        if isinstance(entity, cl.Whitespace):
+            # Use the Whitespace instance as the key so that it's always
+            # unique. Adjecent whitespace will be folded into the longer one in
+            # prune.
+            return (entity, entity)
+
+        # When comments change, AddRemove gives us one 'add' and one 'delete'
+        # (because a comment's key is its content).  In merge_two we'll try to
+        # de-duplicate comments by looking at the entity they belong to.  Set
+        # up the back-reference from the comment to its entity here.
+        if isinstance(entity, cl.Entity) and entity.pre_comment:
+            comments[entity.pre_comment] = entity.key
+
+        return (entity.key, entity)
+
+    entities = reduce(
+        lambda x, y: merge_two(comments, x, y),
+        map(parse_resource, resources))
+
+    return encode(serialize_legacy_resource(entities), parser.encoding)
+
+
+def merge_two(comments, newer, older):
+    diff = AddRemove()
+    diff.set_left(newer.keys())
+    diff.set_right(older.keys())
+
+    def get_entity(key):
+        entity = newer.get(key, None)
+
+        # Always prefer the newer version.
+        if entity is not None:
+            return entity
+
+        entity = older.get(key)
+
+        # If it's an old comment attached to an entity, try to find that
+        # entity in newer and return None to use its comment instead in prune.
+        if isinstance(entity, cl.Comment) and entity in comments:
+            next_entity = newer.get(comments[entity], None)
+            if next_entity is not None and next_entity.pre_comment:
+                # We'll prune this before returning the merged result.
+                return None
+
+        return entity
+
+    # Create a flat sequence of all entities in order reported by AddRemove.
+    contents = [(key, get_entity(key)) for _, key in diff]
+
+    def prune(acc, cur):
+        _, entity = cur
+        if entity is None:
+            # Prune Nones which stand for duplicated comments.
+            return acc
+
+        if len(acc) and isinstance(entity, cl.Whitespace):
+            _, prev_entity = acc[-1]
+
+            if isinstance(prev_entity, cl.Whitespace):
+                # Prefer the longer whitespace.
+                if len(entity.all) > len(prev_entity.all):
+                    acc[-1] = (entity, entity)
+                return acc
+
+        acc.append(cur)
+        return acc
+
+    pruned = reduce(prune, contents, [])
+    return OrderedDict(pruned)
+
+
+def serialize_legacy_resource(entities):
+    return "".join((entity.all for entity in entities.values()))
--- a/third_party/python/compare-locales/compare_locales/parser.py
+++ b/third_party/python/compare-locales/compare_locales/parser.py
@@ -3,16 +3,23 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import re
 import bisect
 import codecs
 from collections import Counter
 import logging
 
+try:
+    from html import unescape as html_unescape
+except ImportError:
+    from HTMLParser import HTMLParser
+    html_parser = HTMLParser()
+    html_unescape = html_parser.unescape
+
 from fluent.syntax import FluentParser as FTLParser
 from fluent.syntax import ast as ftl
 
 __constructors = []
 
 
 # The allowed capabilities for the Parsers.  They define the exact strategy
 # used by ContentComparer.merge.
@@ -27,92 +34,75 @@ CAN_SKIP = 2
 CAN_MERGE = 4
 
 
 class EntityBase(object):
     '''
     Abstraction layer for a localizable entity.
     Currently supported are grammars of the form:
 
-    1: pre white space
-    2: entity definition
-    3: entity key (name)
-    4: entity value
-    5: post white space
-                                                 <--[1]
+    1: entity definition
+    2: entity key (name)
+    3: entity value
+
     <!ENTITY key "value">
 
-    <-------[2]--------->
+    <--- definition ---->
     '''
-    def __init__(self, ctx, pre_comment,
-                 span, pre_ws_span, def_span,
-                 key_span, val_span, post_span):
+    def __init__(self, ctx, pre_comment, span, key_span, val_span):
         self.ctx = ctx
         self.span = span
-        self.pre_ws_span = pre_ws_span
-        self.def_span = def_span
         self.key_span = key_span
         self.val_span = val_span
-        self.post_span = post_span
         self.pre_comment = pre_comment
-        pass
 
     def position(self, offset=0):
         """Get the 1-based line and column of the character
         with given offset into the Entity.
 
         If offset is negative, return the end of the Entity.
         """
         if offset < 0:
             pos = self.span[1]
         else:
             pos = self.span[0] + offset
-        return self.ctx.lines(pos)[0]
+        return self.ctx.linecol(pos)
 
     def value_position(self, offset=0):
         """Get the 1-based line and column of the character
         with given offset into the value.
 
         If offset is negative, return the end of the value.
         """
+        assert self.val_span is not None
         if offset < 0:
             pos = self.val_span[1]
         else:
             pos = self.val_span[0] + offset
-        return self.ctx.lines(pos)[0]
+        return self.ctx.linecol(pos)
 
     # getter helpers
 
     def get_all(self):
         return self.ctx.contents[self.span[0]:self.span[1]]
 
-    def get_pre_ws(self):
-        return self.ctx.contents[self.pre_ws_span[0]:self.pre_ws_span[1]]
-
-    def get_def(self):
-        return self.ctx.contents[self.def_span[0]:self.def_span[1]]
-
     def get_key(self):
         return self.ctx.contents[self.key_span[0]:self.key_span[1]]
 
     def get_raw_val(self):
+        if self.val_span is None:
+            return None
         return self.ctx.contents[self.val_span[0]:self.val_span[1]]
 
-    def get_post(self):
-        return self.ctx.contents[self.post_span[0]:self.post_span[1]]
-
     # getters
 
     all = property(get_all)
-    pre_ws = property(get_pre_ws)
-    definition = property(get_def)
     key = property(get_key)
     val = property(get_raw_val)
     raw_val = property(get_raw_val)
-    post = property(get_post)
 
     def __repr__(self):
         return self.key
 
     re_br = re.compile('<br\s*/?>', re.U)
     re_sgml = re.compile('</?\w+.*?>', re.U | re.M)
 
     def count_words(self):
@@ -127,111 +117,104 @@ class EntityBase(object):
         return self.key == other.key and self.val == other.val
 
 
 class Entity(EntityBase):
     pass
 
 
 class Comment(EntityBase):
-    def __init__(self, ctx, span, pre_ws_span, def_span,
-                 post_span):
+    def __init__(self, ctx, span):
         self.ctx = ctx
         self.span = span
-        self.pre_ws_span = pre_ws_span
-        self.def_span = def_span
-        self.post_span = post_span
+        self.val_span = None
 
     @property
     def key(self):
         return None
 
-    @property
-    def val(self):
-        return None
-
     def __repr__(self):
         return self.all
 
 
 class Junk(object):
     '''
     An almost-Entity, representing junk data that we didn't parse.
     This way, we can signal bad content as stuff we don't understand.
     And the either fix that, or report real bugs in localizations.
     '''
     junkid = 0
 
     def __init__(self, ctx, span):
         self.ctx = ctx
         self.span = span
-        self.pre_ws = self.definition = self.post = ''
         self.__class__.junkid += 1
         self.key = '_junk_%d_%d-%d' % (self.__class__.junkid, span[0], span[1])
 
     def position(self, offset=0):
         """Get the 1-based line and column of the character
         with given offset into the Entity.
 
         If offset is negative, return the end of the Entity.
         """
         if offset < 0:
             pos = self.span[1]
         else:
             pos = self.span[0] + offset
-        return self.ctx.lines(pos)[0]
+        return self.ctx.linecol(pos)
 
     # getter helpers
     def get_all(self):
         return self.ctx.contents[self.span[0]:self.span[1]]
 
     # getters
     all = property(get_all)
+    raw_val = property(get_all)
     val = property(get_all)
 
     def __repr__(self):
         return self.key
 
 
 class Whitespace(EntityBase):
     '''Entity-like object representing an empty file with whitespace,
     if allowed
     '''
     def __init__(self, ctx, span):
         self.ctx = ctx
-        self.key_span = self.val_span = self.span = span
-        self.def_span = self.pre_ws_span = (span[0], span[0])
-        self.post_span = (span[1], span[1])
+        self.span = self.key_span = self.val_span = span
 
     def __repr__(self):
         return self.raw_val
 
 
 class Parser(object):
     capabilities = CAN_SKIP | CAN_MERGE
-    tail = re.compile('\s+\Z')
+    reWhitespace = re.compile('\s+', re.M)
 
     class Context(object):
         "Fixture for content and line numbers"
         def __init__(self, contents):
             self.contents = contents
+            # Subclasses may use bitmasks to keep state.
+            self.state = 0
             self._lines = None
 
-        def lines(self, *positions):
-            # return line and column tuples, 1-based
+        def linecol(self, position):
+            "Returns 1-based line and column numbers."
             if self._lines is None:
                 nl = re.compile('\n', re.M)
                 self._lines = [m.end()
                                for m in nl.finditer(self.contents)]
-            line_nrs = [bisect.bisect(self._lines, p) for p in positions]
-            # compute columns
-            pos_ = [
-                (1 + line, 1 + p - (self._lines[line-1] if line else 0))
-                for line, p in zip(line_nrs, positions)]
-            return pos_
+
+            line_offset = bisect.bisect(self._lines, position)
+            line_start = self._lines[line_offset - 1] if line_offset else 0
+            col_offset = position - line_start
+
+            return line_offset + 1, col_offset + 1
 
     def __init__(self):
         if not hasattr(self, 'encoding'):
             self.encoding = 'utf-8'
         self.ctx = None
         self.last_comment = None
 
     def readFile(self, file):
@@ -246,106 +229,98 @@ class Parser(object):
         '''Read contents and create parsing context.
 
         contents are in native encoding, but with normalized line endings.
         '''
         (contents, length) = codecs.getdecoder(self.encoding)(contents)
         self.ctx = Parser.Context(contents)
 
     def parse(self):
-        l = []
-        m = {}
-        for e in self:
-            m[e.key] = len(l)
-            l.append(e)
-        return (l, m)
+        list_ = list(self)
+        map_ = dict((e.key, i) for i, e in enumerate(list_))
+        return (list_, map_)
 
     def __iter__(self):
-        return self.walk(onlyEntities=True)
+        return self.walk(only_localizable=True)
 
-    def walk(self, onlyEntities=False):
+    def walk(self, only_localizable=False):
         if not self.ctx:
             # loading file failed, or we just didn't load anything
             return
         ctx = self.ctx
         contents = ctx.contents
-        offset = 0
-        entity, offset = self.getEntity(ctx, offset)
-        while entity:
-            if (not onlyEntities or
-                    isinstance(entity, Entity) or
-                    type(entity) is Junk):
+
+        next_offset = 0
+        while next_offset < len(contents):
+            entity = self.getNext(ctx, next_offset)
+
+            if isinstance(entity, (Entity, Junk)):
                 yield entity
-            entity, offset = self.getEntity(ctx, offset)
-        if len(contents) > offset:
-            yield Junk(ctx, (offset, len(contents)))
+            elif not only_localizable:
+                yield entity
+
+            next_offset = entity.span[1]
 
-    def getEntity(self, ctx, offset):
+    def getNext(self, ctx, offset):
+        m = self.reWhitespace.match(ctx.contents, offset)
+        if m:
+            return Whitespace(ctx, m.span())
         m = self.reKey.match(ctx.contents, offset)
         if m:
-            offset = m.end()
-            entity = self.createEntity(ctx, m)
-            return (entity, offset)
+            return self.createEntity(ctx, m)
         m = self.reComment.match(ctx.contents, offset)
         if m:
-            offset = m.end()
-            self.last_comment = Comment(ctx, *[m.span(i) for i in xrange(4)])
-            return (self.last_comment, offset)
-        return self.getTrailing(ctx, offset, self.reKey, self.reComment)
+            self.last_comment = Comment(ctx, m.span())
+            return self.last_comment
+        return self.getJunk(ctx, offset, self.reKey, self.reComment)
 
-    def getTrailing(self, ctx, offset, *expressions):
+    def getJunk(self, ctx, offset, *expressions):
         junkend = None
         for exp in expressions:
             m = exp.search(ctx.contents, offset)
             if m:
                 junkend = min(junkend, m.start()) if junkend else m.start()
-        if junkend is None:
-            if self.tail.match(ctx.contents, offset):
-                white_end = len(ctx.contents)
-                return (Whitespace(ctx, (offset, white_end)), white_end)
-            else:
-                return (None, offset)
-        return (Junk(ctx, (offset, junkend)), junkend)
+        return Junk(ctx, (offset, junkend or len(ctx.contents)))
 
     def createEntity(self, ctx, m):
         pre_comment = self.last_comment
         self.last_comment = None
-        return Entity(ctx, pre_comment,
-                      *[m.span(i) for i in xrange(6)])
+        return Entity(ctx, pre_comment, m.span(), m.span('key'), m.span('val'))
 
     @classmethod
     def findDuplicates(cls, entities):
         found = Counter(entity.key for entity in entities)
         for entity_id, cnt in found.items():
             if cnt > 1:
                 yield '{} occurs {} times'.format(entity_id, cnt)
 
 
 def getParser(path):
     for item in __constructors:
         if re.search(item[0], path):
             return item[1]
     raise UserWarning("Cannot find Parser")
 
 
-# Subgroups of the match will:
-# 1: pre white space
-# 2: pre comments
-# 3: entity definition
-# 4: entity key (name)
-# 5: entity value
-# 6: post comment (and white space) in the same line (dtd only)
-#                                            <--[1]
-# <!-- pre comments -->                      <--[2]
-# <!ENTITY key "value"> <!-- comment -->
-#
-# <-------[3]---------><------[6]------>
+class DTDEntity(Entity):
+    @property
+    def val(self):
+        '''Unescape HTML entities into corresponding Unicode characters.
+
+        Named (&amp;), decimal (&#38;), and hex (&#x26; and &#x0026;) formats
+        are supported. Unknown entities are left intact.
 
+        As of Python 2.7 and Python 3.6 the following 252 named entities are
+        recognized and unescaped:
 
-class DTDEntity(Entity):
+            https://github.com/python/cpython/blob/2.7/Lib/htmlentitydefs.py
+            https://github.com/python/cpython/blob/3.6/Lib/html/entities.py
+        '''
+        return html_unescape(self.raw_val)
+
     def value_position(self, offset=0):
         # DTDChecker already returns tuples of (line, col) positions
         if isinstance(offset, tuple):
             line_pos, col_pos = offset
             line, col = super(DTDEntity, self).value_position()
             if line_pos == 1:
                 col = col + col_pos
             else:
@@ -369,58 +344,54 @@ class DTDParser(Parser):
         u'\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F' + \
         u'\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD'
     # + \U00010000-\U000EFFFF seems to be unsupported in python
 
     # NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 |
     #     [#x0300-#x036F] | [#x203F-#x2040]
     NameChar = NameStartChar + ur'\-\.0-9' + u'\xB7\u0300-\u036F\u203F-\u2040'
     Name = '[' + NameStartChar + '][' + NameChar + ']*'
-    reKey = re.compile('(?:(?P<pre>\s*)(?P<entity><!ENTITY\s+(?P<key>' + Name +
-                       ')\s+(?P<val>\"[^\"]*\"|\'[^\']*\'?)\s*>)'
-                       '(?P<post>\s+)?)',
+    reKey = re.compile('<!ENTITY\s+(?P<key>' + Name + ')\s+'
+                       '(?P<val>\"[^\"]*\"|\'[^\']*\'?)\s*>',
                        re.DOTALL | re.M)
     # add BOM to DTDs, details in bug 435002
     reHeader = re.compile(u'^\ufeff')
-    reComment = re.compile('(\s*)(<!--(-?[%s])*?-->)(\s*)' % CharMinusDash,
+    reComment = re.compile('<!--(?P<val>-?[%s])*?-->' % CharMinusDash,
                            re.S)
-    rePE = re.compile(u'(?:(\s*)'
-                      u'(<!ENTITY\s+%\s+(' + Name +
-                      u')\s+SYSTEM\s+(\"[^\"]*\"|\'[^\']*\')\s*>\s*%' + Name +
-                      u';)([ \t]*(?:' + XmlComment + u'\s*)*\n?)?)')
+    rePE = re.compile(u'<!ENTITY\s+%\s+(?P<key>' + Name + ')\s+'
+                      u'SYSTEM\s+(?P<val>\"[^\"]*\"|\'[^\']*\')\s*>\s*'
+                      u'%' + Name + ';'
+                      u'(?:[ \t]*(?:' + XmlComment + u'\s*)*\n?)?')
 
-    def getEntity(self, ctx, offset):
+    def getNext(self, ctx, offset):
         '''
-        Overload Parser.getEntity to special-case ParsedEntities.
+        Overload Parser.getNext to special-case ParsedEntities.
         Just check for a parsed entity if that method claims junk.
 
         <!ENTITY % foo SYSTEM "url">
         %foo;
         '''
         if offset is 0 and self.reHeader.match(ctx.contents):
             offset += 1
-        entity, inneroffset = Parser.getEntity(self, ctx, offset)
+        entity = Parser.getNext(self, ctx, offset)
         if (entity and isinstance(entity, Junk)) or entity is None:
             m = self.rePE.match(ctx.contents, offset)
             if m:
-                inneroffset = m.end()
                 self.last_comment = None
-                entity = DTDEntity(ctx, '', *[m.span(i) for i in xrange(6)])
-        return (entity, inneroffset)
+                entity = DTDEntity(
+                    ctx, '', m.span(), m.span('key'), m.span('val'))
+        return entity
 
     def createEntity(self, ctx, m):
         valspan = m.span('val')
         valspan = (valspan[0]+1, valspan[1]-1)
         pre_comment = self.last_comment
         self.last_comment = None
         return DTDEntity(ctx, pre_comment,
-                         m.span(),
-                         m.span('pre'),
-                         m.span('entity'), m.span('key'), valspan,
-                         m.span('post'))
+                         m.span(), m.span('key'), valspan)
 
 
 class PropertiesEntity(Entity):
     escape = re.compile(r'\\((?P<uni>u[0-9a-fA-F]{1,4})|'
                         '(?P<nl>\n\s*)|(?P<single>.))', re.M)
     known_escapes = {'n': '\n', 'r': '\r', 't': '\t', '\\': '\\'}
 
     @property
@@ -433,38 +404,36 @@ class PropertiesEntity(Entity):
                 return ''
             return self.known_escapes.get(found['single'], found['single'])
 
         return self.escape.sub(unescape, self.raw_val)
 
 
 class PropertiesParser(Parser):
     def __init__(self):
-        self.reKey = re.compile('^(\s*)'
-                                '([^#!\s\n][^=:\n]*?)\s*[:=][ \t]*', re.M)
-        self.reComment = re.compile('(\s*)(((?:[#!][^\n]*\n?)+))', re.M)
+        self.reKey = re.compile(
+            '(?P<key>[^#!\s\n][^=:\n]*?)\s*[:=][ \t]*', re.M)
+        self.reComment = re.compile('(?:[#!][^\n]*\n)*(?:[#!][^\n]*)', re.M)
         self._escapedEnd = re.compile(r'\\+$')
         self._trailingWS = re.compile(r'\s*(?:\n|\Z)', re.M)
         Parser.__init__(self)
 
-    def getEntity(self, ctx, offset):
+    def getNext(self, ctx, offset):
         # overwritten to parse values line by line
         contents = ctx.contents
+
+        m = self.reWhitespace.match(contents, offset)
+        if m:
+            return Whitespace(ctx, m.span())
+
         m = self.reComment.match(contents, offset)
         if m:
-            spans = [m.span(i) for i in xrange(3)]
-            start_trailing = offset = m.end()
-            while offset < len(contents):
-                m = self._trailingWS.match(contents, offset)
-                if not m:
-                    break
-                offset = m.end()
-            spans.append((start_trailing, offset))
-            self.last_comment = Comment(ctx, *spans)
-            return (self.last_comment, offset)
+            self.last_comment = Comment(ctx, m.span())
+            return self.last_comment
+
         m = self.reKey.match(contents, offset)
         if m:
             startline = offset = m.end()
             while True:
                 endval = nextline = contents.find('\n', offset)
                 if nextline == -1:
                     endval = offset = len(contents)
                     break
@@ -472,147 +441,142 @@ class PropertiesParser(Parser):
                 _e = self._escapedEnd.search(contents, offset, nextline)
                 offset = nextline + 1
                 if _e is None:
                     break
                 # backslashes at end of line, if 2*n, not escaped
                 if len(_e.group()) % 2 == 0:
                     break
                 startline = offset
+
             # strip trailing whitespace
             ws = self._trailingWS.search(contents, startline)
             if ws:
                 endval = ws.start()
-                offset = ws.end()
+
             pre_comment = self.last_comment
             self.last_comment = None
             entity = PropertiesEntity(
                 ctx, pre_comment,
-                (m.start(), offset),   # full span
-                m.span(1),  # leading whitespan
-                (m.start(2), offset),   # entity def span
-                m.span(2),   # key span
-                (m.end(), endval),   # value span
-                (offset, offset))  # post comment span, empty
-            return (entity, offset)
-        return self.getTrailing(ctx, offset, self.reKey, self.reComment)
+                (m.start(), endval),   # full span
+                m.span('key'),
+                (m.end(), endval))   # value span
+            return entity
+
+        return self.getJunk(ctx, offset, self.reKey, self.reComment)
 
 
 class DefinesInstruction(EntityBase):
     '''Entity-like object representing processing instructions in inc files
     '''
-    def __init__(self, ctx, span, pre_ws_span, def_span, val_span, post_span):
+    def __init__(self, ctx, span, val_span):
         self.ctx = ctx
         self.span = span
-        self.pre_ws_span = pre_ws_span
-        self.def_span = def_span
         self.key_span = self.val_span = val_span
-        self.post_span = post_span
 
     def __repr__(self):
         return self.raw_val
 
 
 class DefinesParser(Parser):
     # can't merge, #unfilter needs to be the last item, which we don't support
     capabilities = CAN_COPY
-    tail = re.compile(r'(?!)')  # never match
+    reWhitespace = re.compile('\n+', re.M)
+
+    EMPTY_LINES = 1 << 0
+    PAST_FIRST_LINE = 1 << 1
 
     def __init__(self):
-        self.reComment = re.compile(
-            '((?:[ \t]*\n)*)'
-            '((?:^# .*?(?:\n|\Z))+)'
-            '((?:[ \t]*(?:\n|\Z))*)', re.M)
-        self.reKey = re.compile('((?:[ \t]*\n)*)'
-                                '(#define[ \t]+(\w+)(?:[ \t](.*?))?(?:\n|\Z))'
-                                '((?:[ \t]*(?:\n|\Z))*)',
-                                re.M)
-        self.rePI = re.compile('((?:[ \t]*\n)*)'
-                               '(#(\w+)[ \t]+(.*?)(?:\n|\Z))'
-                               '((?:[ \t]*(?:\n|\Z))*)',
-                               re.M)
+        self.reComment = re.compile('(?:^# .*?\n)*(?:^# [^\n]*)', re.M)
+        # corresponds to
+        # https://hg.mozilla.org/mozilla-central/file/72ee4800d4156931c89b58bd807af4a3083702bb/python/mozbuild/mozbuild/preprocessor.py#l561  # noqa
+        self.reKey = re.compile(
+            '#define[ \t]+(?P<key>\w+)(?:[ \t](?P<val>[^\n]*))?', re.M)
+        self.rePI = re.compile('#(?P<val>\w+[ \t]+[^\n]+)', re.M)
         Parser.__init__(self)
 
-    def getEntity(self, ctx, offset):
+    def getNext(self, ctx, offset):
         contents = ctx.contents
+
+        m = self.reWhitespace.match(contents, offset)
+        if m:
+            if ctx.state & self.EMPTY_LINES:
+                return Whitespace(ctx, m.span())
+            if ctx.state & self.PAST_FIRST_LINE and len(m.group()) == 1:
+                return Whitespace(ctx, m.span())
+            else:
+                return Junk(ctx, m.span())
+
+        # We're not in the first line anymore.
+        ctx.state |= self.PAST_FIRST_LINE
+
         m = self.reComment.match(contents, offset)
         if m:
-            offset = m.end()
-            self.last_comment = Comment(ctx, *[m.span(i) for i in xrange(4)])
-            return (self.last_comment, offset)
+            self.last_comment = Comment(ctx, m.span())
+            return self.last_comment
         m = self.reKey.match(contents, offset)
         if m:
-            offset = m.end()
-            return (self.createEntity(ctx, m), offset)
+            return self.createEntity(ctx, m)
         m = self.rePI.match(contents, offset)
         if m:
-            offset = m.end()
-            return (DefinesInstruction(ctx, *[m.span(i) for i in xrange(5)]),
-                    offset)
-        return self.getTrailing(ctx, offset,
-                                self.reComment, self.reKey, self.rePI)
+            instr = DefinesInstruction(ctx, m.span(), m.span('val'))
+            if instr.val == 'filter emptyLines':
+                ctx.state |= self.EMPTY_LINES
+            if instr.val == 'unfilter emptyLines':
+                ctx.state &= ~ self.EMPTY_LINES
+            return instr
+        return self.getJunk(
+            ctx, offset, self.reComment, self.reKey, self.rePI)
 
 
 class IniSection(EntityBase):
     '''Entity-like object representing sections in ini files
     '''
-    def __init__(self, ctx, span, pre_ws_span, def_span, val_span, post_span):
+    def __init__(self, ctx, span, val_span):
         self.ctx = ctx
         self.span = span
-        self.pre_ws_span = pre_ws_span
-        self.def_span = def_span
         self.key_span = self.val_span = val_span
-        self.post_span = post_span
 
     def __repr__(self):
         return self.raw_val
 
 
 class IniParser(Parser):
     '''
     Parse files of the form:
     # initial comment
     [cat]
     whitespace*
     #comment
     string=value
     ...
     '''
     def __init__(self):
-        self.reComment = re.compile(
-            '((?:[ \t]*\n)*)'
-            '((?:^[;#].*?(?:\n|\Z))+)'
-            '((?:[ \t]*(?:\n|\Z))*)', re.M)
-        self.reSection = re.compile(
-            '((?:[ \t]*\n)*)'
-            '(\[(.*?)\])'
-            '((?:[ \t]*(?:\n|\Z))*)', re.M)
-        self.reKey = re.compile(
-            '((?:[ \t]*\n)*)'
-            '((.+?)=(.*))'
-            '((?:[ \t]*(?:\n|\Z))*)', re.M)
+        self.reComment = re.compile('(?:^[;#][^\n]*\n)*(?:^[;#][^\n]*)', re.M)
+        self.reSection = re.compile('\[(?P<val>.*?)\]', re.M)
+        self.reKey = re.compile('(?P<key>.+?)=(?P<val>.*)', re.M)
         Parser.__init__(self)
 
-    def getEntity(self, ctx, offset):
+    def getNext(self, ctx, offset):
         contents = ctx.contents
+        m = self.reWhitespace.match(contents, offset)
+        if m:
+            return Whitespace(ctx, m.span())
         m = self.reComment.match(contents, offset)
         if m:
-            offset = m.end()
-            self.last_comment = Comment(ctx, *[m.span(i) for i in xrange(4)])
-            return (self.last_comment, offset)
+            self.last_comment = Comment(ctx, m.span())
+            return self.last_comment
         m = self.reSection.match(contents, offset)
         if m:
-            offset = m.end()
-            return (IniSection(ctx, *[m.span(i) for i in xrange(5)]), offset)
+            return IniSection(ctx, m.span(), m.span('val'))
         m = self.reKey.match(contents, offset)
         if m:
-            offset = m.end()
-            return (self.createEntity(ctx, m), offset)
-        return self.getTrailing(ctx, offset,
-                                self.reComment, self.reSection, self.reKey)
+            return self.createEntity(ctx, m)
+        return self.getJunk(
+            ctx, offset, self.reComment, self.reSection, self.reKey)
 
 
 class FluentAttribute(EntityBase):
     ignored_fields = ['span']
 
     def __init__(self, entity, attr_node):
         self.ctx = entity.ctx
         self.attr = attr_node
@@ -637,20 +601,26 @@ class FluentEntity(Entity):
         self.ctx = ctx
         self.span = (start, end)
 
         self.key_span = (entry.id.span.start, entry.id.span.end)
 
         if entry.value is not None:
             self.val_span = (entry.value.span.start, entry.value.span.end)
         else:
-            self.val_span = (0, 0)
+            self.val_span = None
 
         self.entry = entry
 
+        # EntityBase instances are expected to have pre_comment. It's used by
+        # other formats to associate a Comment with an Entity. FluentEntities
+        # don't need it because message comments are part of the entry AST and
+        # are not separate Comment instances.
+        self.pre_comment = None
+
     _word_count = None
 
     def count_words(self):
         if self._word_count is None:
             self._word_count = 0
 
             def count_words(node):
                 if isinstance(node, ftl.TextElement):
@@ -660,58 +630,93 @@ class FluentEntity(Entity):
             self.entry.traverse(count_words)
 
         return self._word_count
 
     def equals(self, other):
         return self.entry.equals(
             other.entry, ignored_fields=self.ignored_fields)
 
-    # Positions yielded by FluentChecker.check are absolute offsets from the
-    # beginning of the file.  This is different from the base Checker behavior
-    # which yields offsets from the beginning of the current entity's value.
-    def position(self, pos=None):
-        if pos is None:
-            pos = self.entry.span.start
-        return self.ctx.lines(pos)[0]
-
-    # FluentEntities don't differentiate between entity and value positions
-    # because all positions are absolute from the beginning of the file.
-    def value_position(self, pos=None):
-        return self.position(pos)
+    # In Fluent we treat entries as a whole.  FluentChecker reports errors at
+    # offsets calculated from the beginning of the entry.
+    def value_position(self, offset=0):
+        return self.position(offset)
 
     @property
     def attributes(self):
         for attr_node in self.entry.attributes:
             yield FluentAttribute(self, attr_node)
 
 
+class FluentSection(EntityBase):
+    def __init__(self, ctx, entry):
+        self.entry = entry
+        self.ctx = ctx
+
+        self.span = (entry.span.start, entry.span.end)
+        self.key_span = self.val_span = (
+            entry.name.span.start, entry.name.span.end)
+
+
 class FluentParser(Parser):
     capabilities = CAN_SKIP
 
     def __init__(self):
         super(FluentParser, self).__init__()
         self.ftl_parser = FTLParser()
 
-    def walk(self, onlyEntities=False):
+    def walk(self, only_localizable=False):
         if not self.ctx:
             # loading file failed, or we just didn't load anything
             return
+
         resource = self.ftl_parser.parse(self.ctx.contents)
+
+        if resource.comment:
+            last_span_end = resource.comment.span.end
+
+            if not only_localizable:
+                if 0 < resource.comment.span.start:
+                    yield Whitespace(
+                        self.ctx, (0, resource.comment.span.start))
+                yield Comment(
+                    self.ctx,
+                    (resource.comment.span.start, resource.comment.span.end))
+        else:
+            last_span_end = 0
+
         for entry in resource.body:
+            if not only_localizable:
+                if entry.span.start > last_span_end:
+                    yield Whitespace(
+                        self.ctx, (last_span_end, entry.span.start))
+
             if isinstance(entry, ftl.Message):
                 yield FluentEntity(self.ctx, entry)
             elif isinstance(entry, ftl.Junk):
                 start = entry.span.start
                 end = entry.span.end
                 # strip leading whitespace
                 start += re.match('\s*', entry.content).end()
                 # strip trailing whitespace
                 ws, we = re.search('\s*$', entry.content).span()
                 end -= we - ws
                 yield Junk(self.ctx, (start, end))
+            elif isinstance(entry, ftl.Comment) and not only_localizable:
+                span = (entry.span.start, entry.span.end)
+                yield Comment(self.ctx, span)
+            elif isinstance(entry, ftl.Section) and not only_localizable:
+                yield FluentSection(self.ctx, entry)
+
+            last_span_end = entry.span.end
+
+        # Yield Whitespace at the EOF.
+        if not only_localizable:
+            eof_offset = len(self.ctx.contents)
+            if eof_offset > last_span_end:
+                yield Whitespace(self.ctx, (last_span_end, eof_offset))
 
 
 __constructors = [('\\.dtd$', DTDParser()),
                   ('\\.properties$', PropertiesParser()),
                   ('\\.ini$', IniParser()),
                   ('\\.inc$', DefinesParser()),
                   ('\\.ftl$', FluentParser())]
--- a/third_party/python/compare-locales/compare_locales/paths.py
+++ b/third_party/python/compare-locales/compare_locales/paths.py
@@ -130,17 +130,17 @@ class ProjectConfig(object):
         Assert that no rules are set.
         Also, normalize output already here.
         '''
         assert not self.rules
 
         def filter_(module, path, entity=None):
             try:
                 rv = filter(module, path, entity=entity)
-            except:
+            except BaseException:  # we really want to handle EVERYTHING here
                 return 'error'
             rv = {
                 True: 'error',
                 False: 'ignore',
                 'report': 'warning'
             }.get(rv, rv)
             assert rv in ('error', 'ignore', 'warning', None)
             return rv
@@ -429,17 +429,17 @@ class TOMLParser(object):
         self.data = None
         self.pc = ProjectConfig()
         self.pc.PATH = path
 
     def load(self):
         try:
             with open(self.path, 'rb') as fin:
                 self.data = toml.load(fin)
-        except:
+        except (toml.TomlError, IOError):
             raise ConfigNotFound(self.path)
 
     def processEnv(self):
         assert self.data is not None
         self.pc.add_environment(**self.data.get('env', {}))
 
     def processLocales(self):
         assert self.data is not None
@@ -537,35 +537,35 @@ class L10nConfigParser(object):
         # optional defaults to be passed to the inner ConfigParser (unused?)
         self.defaults = kwargs
 
     def getDepth(self, cp):
         '''Get the depth for the comparison from the parsed l10n.ini.
         '''
         try:
             depth = cp.get('general', 'depth')
-        except:
+        except (NoSectionError, NoOptionError):
             depth = '.'
         return depth
 
     def getFilters(self):
         '''Get the test functions from this ConfigParser and all children.
 
         Only works with synchronous loads, used by compare-locales, which
         is local anyway.
         '''
         filter_path = mozpath.join(mozpath.dirname(self.inipath), 'filter.py')
         try:
-            l = {}
-            execfile(filter_path, {}, l)
-            if 'test' in l and callable(l['test']):
-                filters = [l['test']]
+            local = {}
+            execfile(filter_path, {}, local)
+            if 'test' in local and callable(local['test']):
+                filters = [local['test']]
             else:
                 filters = []
-        except:
+        except BaseException:  # we really want to handle EVERYTHING here
             filters = []
 
         for c in self.children:
             filters += c.getFilters()
 
         return filters
 
     def loadConfigs(self):
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/plurals.py
@@ -0,0 +1,160 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+'Mapping of locales to CLDR plural categories as implemented by PluralForm.jsm'
+
+CATEGORIES_BY_INDEX = (
+    # 0 (Chinese)
+    ('other',),
+    # 1 (English)
+    ('one', 'other'),
+    # 2 (French)
+    ('one', 'other'),
+    # 3 (Latvian)
+    ('zero', 'one', 'other'),
+    # 4 (Scottish Gaelic)
+    ('one', 'two', 'few', 'other'),
+    # 5 (Romanian)
+    ('one', 'few', 'other'),
+    # 6 (Lithuanian)
+    # CLDR: one, few, many (fractions), other
+    ('one', 'other', 'few'),
+    # 7 (Russian)
+    # CLDR: one, few, many, other (fractions)
+    ('one', 'few', 'many'),
+    # 8 (Slovak)
+    # CLDR: one, few, many (fractions), other
+    ('one', 'few', 'other'),
+    # 9 (Polish)
+    # CLDR: one, few, many, other (fractions)
+    ('one', 'few', 'many'),
+    # 10 (Slovenian)
+    ('one', 'two', 'few', 'other'),
+    # 11 (Irish Gaelic)
+    ('one', 'two', 'few', 'many', 'other'),
+    # 12 (Arabic)
+    # CLDR: zero, one, two, few, many, other
+    ('one', 'two', 'few', 'many', 'other', 'zero'),
+    # 13 (Maltese)
+    ('one', 'few', 'many', 'other'),
+    # 14 (Macedonian)
+    # CLDR: one, other
+    ('one', 'two', 'other'),
+    # 15 (Icelandic)
+    ('one', 'other'),
+    # 16 (Breton)
+    ('one', 'two', 'few', 'many', 'other'),
+    # 17 (Shuar)
+    # CLDR: (missing)
+    ('zero', 'other')
+)
+
+CATEGORIES_BY_LOCALE = {
+    'ach': CATEGORIES_BY_INDEX[1],
+    'af': CATEGORIES_BY_INDEX[1],
+    'an': CATEGORIES_BY_INDEX[1],
+    'ar': CATEGORIES_BY_INDEX[12],
+    'as': CATEGORIES_BY_INDEX[1],
+    'ast': CATEGORIES_BY_INDEX[1],
+    'az': CATEGORIES_BY_INDEX[0],
+    'be': CATEGORIES_BY_INDEX[7],
+    'bg': CATEGORIES_BY_INDEX[1],
+    'bn-BD': CATEGORIES_BY_INDEX[1],
+    'bn-IN': CATEGORIES_BY_INDEX[1],
+    'br': CATEGORIES_BY_INDEX[1],
+    'bs': CATEGORIES_BY_INDEX[1],
+    'ca': CATEGORIES_BY_INDEX[1],
+    'cak': CATEGORIES_BY_INDEX[1],
+    'cs': CATEGORIES_BY_INDEX[8],
+    'cy': CATEGORIES_BY_INDEX[1],
+    'da': CATEGORIES_BY_INDEX[1],
+    'de': CATEGORIES_BY_INDEX[1],
+    'dsb': CATEGORIES_BY_INDEX[10],
+    'el': CATEGORIES_BY_INDEX[1],
+    'en-GB': CATEGORIES_BY_INDEX[1],
+    'en-US': CATEGORIES_BY_INDEX[1],
+    'en-ZA': CATEGORIES_BY_INDEX[1],
+    'eo': CATEGORIES_BY_INDEX[1],
+    'es-AR': CATEGORIES_BY_INDEX[1],
+    'es-CL': CATEGORIES_BY_INDEX[1],
+    'es-ES': CATEGORIES_BY_INDEX[1],
+    'es-MX': CATEGORIES_BY_INDEX[1],
+    'et': CATEGORIES_BY_INDEX[1],
+    'eu': CATEGORIES_BY_INDEX[1],
+    'fa': CATEGORIES_BY_INDEX[0],
+    'ff': CATEGORIES_BY_INDEX[1],
+    'fi': CATEGORIES_BY_INDEX[1],
+    'fr': CATEGORIES_BY_INDEX[2],
+    'fy-NL': CATEGORIES_BY_INDEX[1],
+    'ga-IE': CATEGORIES_BY_INDEX[11],
+    'gd': CATEGORIES_BY_INDEX[4],
+    'gl': CATEGORIES_BY_INDEX[1],
+    'gn': CATEGORIES_BY_INDEX[1],
+    'gu-IN': CATEGORIES_BY_INDEX[2],
+    'he': CATEGORIES_BY_INDEX[1],
+    'hi-IN': CATEGORIES_BY_INDEX[1],
+    'hr': CATEGORIES_BY_INDEX[7],
+    'hsb': CATEGORIES_BY_INDEX[10],
+    'hu': CATEGORIES_BY_INDEX[1],
+    'hy-AM': CATEGORIES_BY_INDEX[1],
+    'ia': CATEGORIES_BY_INDEX[1],
+    'id': CATEGORIES_BY_INDEX[0],
+    'is': CATEGORIES_BY_INDEX[15],
+    'it': CATEGORIES_BY_INDEX[1],
+    'ja': CATEGORIES_BY_INDEX[0],
+    'ja-JP-mac': CATEGORIES_BY_INDEX[0],
+    'jiv': CATEGORIES_BY_INDEX[17],
+    'ka': CATEGORIES_BY_INDEX[0],
+    'kab': CATEGORIES_BY_INDEX[1],
+    'kk': CATEGORIES_BY_INDEX[1],
+    'km': CATEGORIES_BY_INDEX[1],
+    'kn': CATEGORIES_BY_INDEX[1],
+    'ko': CATEGORIES_BY_INDEX[0],
+    'lij': CATEGORIES_BY_INDEX[1],
+    'lo': CATEGORIES_BY_INDEX[0],
+    'lt': CATEGORIES_BY_INDEX[6],
+    'ltg': CATEGORIES_BY_INDEX[3],
+    'lv': CATEGORIES_BY_INDEX[3],
+    'mai': CATEGORIES_BY_INDEX[1],
+    'mk': CATEGORIES_BY_INDEX[15],
+    'ml': CATEGORIES_BY_INDEX[1],
+    'mr': CATEGORIES_BY_INDEX[1],
+    'ms': CATEGORIES_BY_INDEX[1],
+    'my': CATEGORIES_BY_INDEX[1],
+    'nb-NO': CATEGORIES_BY_INDEX[1],
+    'ne-NP': CATEGORIES_BY_INDEX[1],
+    'nl': CATEGORIES_BY_INDEX[1],
+    'nn-NO': CATEGORIES_BY_INDEX[1],
+    'oc': CATEGORIES_BY_INDEX[1],
+    'or': CATEGORIES_BY_INDEX[1],
+    'pa-IN': CATEGORIES_BY_INDEX[1],
+    'pl': CATEGORIES_BY_INDEX[9],
+    'pt-BR': CATEGORIES_BY_INDEX[1],
+    'pt-PT': CATEGORIES_BY_INDEX[1],
+    'rm': CATEGORIES_BY_INDEX[1],
+    'ro': CATEGORIES_BY_INDEX[1],
+    'ru': CATEGORIES_BY_INDEX[7],
+    'si': CATEGORIES_BY_INDEX[1],
+    'sk': CATEGORIES_BY_INDEX[8],
+    'sl': CATEGORIES_BY_INDEX[10],
+    'son': CATEGORIES_BY_INDEX[1],
+    'sq': CATEGORIES_BY_INDEX[1],
+    'sr': CATEGORIES_BY_INDEX[7],
+    'sv-SE': CATEGORIES_BY_INDEX[1],
+    'ta': CATEGORIES_BY_INDEX[1],
+    'te': CATEGORIES_BY_INDEX[1],
+    'th': CATEGORIES_BY_INDEX[0],
+    'tl': CATEGORIES_BY_INDEX[1],
+    'tr': CATEGORIES_BY_INDEX[0],
+    'trs': CATEGORIES_BY_INDEX[1],
+    'uk': CATEGORIES_BY_INDEX[7],
+    'ur': CATEGORIES_BY_INDEX[1],
+    'uz': CATEGORIES_BY_INDEX[0],
+    'vi': CATEGORIES_BY_INDEX[1],
+    'wo': CATEGORIES_BY_INDEX[0],
+    'xh': CATEGORIES_BY_INDEX[1],
+    'zam': CATEGORIES_BY_INDEX[1],
+    'zh-CN': CATEGORIES_BY_INDEX[1],
+    'zh-TW': CATEGORIES_BY_INDEX[0]
+}
--- a/third_party/python/compare-locales/compare_locales/tests/test_defines.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_defines.py
@@ -6,90 +6,186 @@
 import unittest
 
 from compare_locales.tests import ParserTestMixin
 
 
 mpl2 = '''\
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this file,
-# You can obtain one at http://mozilla.org/MPL/2.0/.
-'''
+# You can obtain one at http://mozilla.org/MPL/2.0/.'''
 
 
 class TestDefinesParser(ParserTestMixin, unittest.TestCase):
 
     filename = 'defines.inc'
 
     def testBrowser(self):
-        self._test(mpl2 + '''#filter emptyLines
+        self._test(mpl2 + '''
+#filter emptyLines
 
 #define MOZ_LANGPACK_CREATOR mozilla.org
 
 # If non-English locales wish to credit multiple contributors, uncomment this
 # variable definition and use the format specified.
 # #define MOZ_LANGPACK_CONTRIBUTORS <em:contributor>Joe Solon</em:contributor>
 
 #unfilter emptyLines
 
 ''', (
             ('Comment', mpl2),
+            ('Whitespace', '\n'),
             ('DefinesInstruction', 'filter emptyLines'),
+            ('Whitespace', '\n\n'),
             ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n\n'),
             ('Comment', '#define'),
-            ('DefinesInstruction', 'unfilter emptyLines')))
+            ('Whitespace', '\n\n'),
+            ('DefinesInstruction', 'unfilter emptyLines'),
+            ('Junk', '\n\n')))
 
     def testBrowserWithContributors(self):
-        self._test(mpl2 + '''#filter emptyLines
+        self._test(mpl2 + '''
+#filter emptyLines
 
 #define MOZ_LANGPACK_CREATOR mozilla.org
 
 # If non-English locales wish to credit multiple contributors, uncomment this
 # variable definition and use the format specified.
 #define MOZ_LANGPACK_CONTRIBUTORS <em:contributor>Joe Solon</em:contributor>
 
 #unfilter emptyLines
 
 ''', (
             ('Comment', mpl2),
+            ('Whitespace', '\n'),
             ('DefinesInstruction', 'filter emptyLines'),
+            ('Whitespace', '\n\n'),
             ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n\n'),
             ('Comment', 'non-English'),
+            ('Whitespace', '\n'),
             ('MOZ_LANGPACK_CONTRIBUTORS',
              '<em:contributor>Joe Solon</em:contributor>'),
-            ('DefinesInstruction', 'unfilter emptyLines')))
+            ('Whitespace', '\n\n'),
+            ('DefinesInstruction', 'unfilter emptyLines'),
+            ('Junk', '\n\n')))
 
     def testCommentWithNonAsciiCharacters(self):
-        self._test(mpl2 + '''#filter emptyLines
+        self._test(mpl2 + '''
+#filter emptyLines
 
 # e.g. #define seamonkey_l10n <DT><A HREF="urn:foo">SeaMonkey v češtině</a>
 #define seamonkey_l10n_long
 
 #unfilter emptyLines
 
 ''', (
             ('Comment', mpl2),
+            ('Whitespace', '\n'),
             ('DefinesInstruction', 'filter emptyLines'),
+            ('Whitespace', '\n\n'),
             ('Comment', u'češtině'),
+            ('Whitespace', '\n'),
             ('seamonkey_l10n_long', ''),
+            ('Whitespace', '\n\n'),
+            ('DefinesInstruction', 'unfilter emptyLines'),
+            ('Junk', '\n\n')))
+
+    def test_no_empty_lines(self):
+        self._test('''#define MOZ_LANGPACK_CREATOR mozilla.org
+#define MOZ_LANGPACK_CREATOR mozilla.org
+''', (
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n')))
+
+    def test_empty_line_between(self):
+        self._test('''#define MOZ_LANGPACK_CREATOR mozilla.org
+
+#define MOZ_LANGPACK_CREATOR mozilla.org
+''', (
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Junk', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n')))
+
+    def test_empty_line_at_the_beginning(self):
+        self._test('''
+#define MOZ_LANGPACK_CREATOR mozilla.org
+#define MOZ_LANGPACK_CREATOR mozilla.org
+''', (
+            ('Junk', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n')))
+
+    def test_filter_empty_lines(self):
+        self._test('''#filter emptyLines
+
+#define MOZ_LANGPACK_CREATOR mozilla.org
+#define MOZ_LANGPACK_CREATOR mozilla.org
+#unfilter emptyLines''', (
+            ('DefinesInstruction', 'filter emptyLines'),
+            ('Whitespace', '\n\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
             ('DefinesInstruction', 'unfilter emptyLines')))
 
+    def test_unfilter_empty_lines_with_trailing(self):
+        self._test('''#filter emptyLines
+
+#define MOZ_LANGPACK_CREATOR mozilla.org
+#define MOZ_LANGPACK_CREATOR mozilla.org
+#unfilter emptyLines
+''', (
+            ('DefinesInstruction', 'filter emptyLines'),
+            ('Whitespace', '\n\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
+            ('MOZ_LANGPACK_CREATOR', 'mozilla.org'),
+            ('Whitespace', '\n'),
+            ('DefinesInstruction', 'unfilter emptyLines'),
+            ('Whitespace', '\n')))
+
     def testToolkit(self):
         self._test('''#define MOZ_LANG_TITLE English (US)
 ''', (
-            ('MOZ_LANG_TITLE', 'English (US)'),))
+            ('MOZ_LANG_TITLE', 'English (US)'),
+            ('Whitespace', '\n')))
 
     def testToolkitEmpty(self):
         self._test('', tuple())
 
     def test_empty_file(self):
         '''Test that empty files generate errors
 
         defines.inc are interesting that way, as their
         content is added to the generated file.
         '''
         self._test('\n', (('Junk', '\n'),))
         self._test('\n\n', (('Junk', '\n\n'),))
         self._test(' \n\n', (('Junk', ' \n\n'),))
 
+    def test_whitespace_value(self):
+        '''Test that there's only one whitespace between key and value
+        '''
+        # funny formatting of trailing whitespace to make it explicit
+        # and flake-8 happy
+        self._test('''\
+#define one \n\
+#define two  \n\
+#define tre   \n\
+''', (
+            ('one', ''),
+            ('Whitespace', '\n'),
+            ('two', ' '),
+            ('Whitespace', '\n'),
+            ('tre', '  '),
+            ('Whitespace', '\n'),))
+
 
 if __name__ == '__main__':
     unittest.main()
--- a/third_party/python/compare-locales/compare_locales/tests/test_dtd.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_dtd.py
@@ -25,84 +25,94 @@ class TestDTD(ParserTestMixin, unittest.
 <!ENTITY good.two "two">
 <!ENTITY bad.two "bad "quoted" word">
 <!ENTITY good.three "three">
 <!ENTITY good.four "good ' quote">
 <!ENTITY good.five "good 'quoted' word">
 '''
     quoteRef = (
         ('good.one', 'one'),
-        ('Junk', '<!ENTITY bad.one "bad " quote">'),
+        ('Whitespace', '\n'),
+        ('Junk', '<!ENTITY bad.one "bad " quote">\n'),
         ('good.two', 'two'),
-        ('Junk', '<!ENTITY bad.two "bad "quoted" word">'),
+        ('Whitespace', '\n'),
+        ('Junk', '<!ENTITY bad.two "bad "quoted" word">\n'),
         ('good.three', 'three'),
+        ('Whitespace', '\n'),
         ('good.four', 'good \' quote'),
-        ('good.five', 'good \'quoted\' word'),)
+        ('Whitespace', '\n'),
+        ('good.five', 'good \'quoted\' word'),
+        ('Whitespace', '\n'),)
 
     def test_quotes(self):
         self._test(self.quoteContent, self.quoteRef)
 
     def test_apos(self):
         qr = re.compile('[\'"]', re.M)
 
         def quot2apos(s):
             return qr.sub(lambda m: m.group(0) == '"' and "'" or '"', s)
 
         self._test(quot2apos(self.quoteContent),
-                   map(lambda t: (t[0], quot2apos(t[1])), self.quoteRef))
+                   ((ref[0], quot2apos(ref[1])) for ref in self.quoteRef))
 
     def test_parsed_ref(self):
         self._test('''<!ENTITY % fooDTD SYSTEM "chrome://brand.dtd">
   %fooDTD;
 ''',
                    (('fooDTD', '"chrome://brand.dtd"'),))
 
     def test_trailing_comment(self):
         self._test('''<!ENTITY first "string">
 <!ENTITY second "string">
 <!--
 <!ENTITY commented "out">
 -->
 ''',
-                   (('first', 'string'), ('second', 'string'),
-                    ('Comment', 'out')))
+                   (
+                       ('first', 'string'),
+                       ('Whitespace', '\n'),
+                       ('second', 'string'),
+                       ('Whitespace', '\n'),
+                       ('Comment', 'out'),
+                       ('Whitespace', '\n')))
 
     def test_license_header(self):
         p = parser.getParser('foo.dtd')
         p.readContents(self.resource('triple-license.dtd'))
         entities = list(p.walk())
         self.assert_(isinstance(entities[0], parser.Comment))
         self.assertIn('MPL', entities[0].all)
-        e = entities[1]
+        e = entities[2]
         self.assert_(isinstance(e, parser.Entity))
         self.assertEqual(e.key, 'foo')
         self.assertEqual(e.val, 'value')
-        self.assertEqual(len(entities), 2)
+        self.assertEqual(len(entities), 4)
         p.readContents('''\
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this file,
    - You can obtain one at http://mozilla.org/MPL/2.0/.  -->
 <!ENTITY foo "value">
 ''')
         entities = list(p.walk())
         self.assert_(isinstance(entities[0], parser.Comment))
         self.assertIn('MPL', entities[0].all)
-        e = entities[1]
+        e = entities[2]
         self.assert_(isinstance(e, parser.Entity))
         self.assertEqual(e.key, 'foo')
         self.assertEqual(e.val, 'value')
-        self.assertEqual(len(entities), 2)
+        self.assertEqual(len(entities), 4)
 
     def testBOM(self):
         self._test(u'\ufeff<!ENTITY foo.label "stuff">'.encode('utf-8'),
                    (('foo.label', 'stuff'),))
 
     def test_trailing_whitespace(self):
         self._test('<!ENTITY foo.label "stuff">\n  \n',
-                   (('foo.label', 'stuff'),))
+                   (('foo.label', 'stuff'), ('Whitespace', '\n  \n')))
 
     def test_unicode_comment(self):
         self._test('<!-- \xe5\x8f\x96 -->',
                    (('Comment', u'\u53d6'),))
 
     def test_empty_file(self):
         self._test('', tuple())
         self._test('\n', (('Whitespace', '\n'),))
@@ -113,38 +123,60 @@ class TestDTD(ParserTestMixin, unittest.
         self.parser.readContents('''\
 <!ENTITY one  "value">
 <!ENTITY  two "other
 escaped value">
 ''')
         one, two = list(self.parser)
         self.assertEqual(one.position(), (1, 1))
         self.assertEqual(one.value_position(), (1, 16))
-        self.assertEqual(one.position(-1), (2, 1))
+        self.assertEqual(one.position(-1), (1, 23))
         self.assertEqual(two.position(), (2, 1))
         self.assertEqual(two.value_position(), (2, 16))
         self.assertEqual(two.value_position(-1), (3, 14))
         self.assertEqual(two.value_position(10), (3, 5))
 
-    def test_post(self):
-        self.parser.readContents('<!ENTITY a "a"><!ENTITY b "b">')
-        a, b = list(self.parser)
-        self.assertEqual(a.post, '')
-        self.parser.readContents('<!ENTITY a "a"> <!ENTITY b "b">')
-        a, b = list(self.parser)
-        self.assertEqual(a.post, ' ')
-
     def test_word_count(self):
         self.parser.readContents('''\
 <!ENTITY a "one">
 <!ENTITY b "one<br>two">
 <!ENTITY c "one<span>word</span>">
 <!ENTITY d "one <a href='foo'>two</a> three">
 ''')
         a, b, c, d = list(self.parser)
         self.assertEqual(a.count_words(), 1)
         self.assertEqual(b.count_words(), 2)
         self.assertEqual(c.count_words(), 1)
         self.assertEqual(d.count_words(), 3)
 
+    def test_html_entities(self):
+        self.parser.readContents('''\
+<!ENTITY named "&amp;">
+<!ENTITY numcode "&#38;">
+<!ENTITY shorthexcode "&#x26;">
+<!ENTITY longhexcode "&#x0026;">
+<!ENTITY unknown "&unknownEntity;">
+''')
+        entities = iter(self.parser)
+
+        entity = next(entities)
+        self.assertEqual(entity.raw_val, '&amp;')
+        self.assertEqual(entity.val, '&')
+
+        entity = next(entities)
+        self.assertEqual(entity.raw_val, '&#38;')
+        self.assertEqual(entity.val, '&')
+
+        entity = next(entities)
+        self.assertEqual(entity.raw_val, '&#x26;')
+        self.assertEqual(entity.val, '&')
+
+        entity = next(entities)
+        self.assertEqual(entity.raw_val, '&#x0026;')
+        self.assertEqual(entity.val, '&')
+
+        entity = next(entities)
+        self.assertEqual(entity.raw_val, '&unknownEntity;')
+        self.assertEqual(entity.val, '&unknownEntity;')
+
 
 if __name__ == '__main__':
     unittest.main()
--- a/third_party/python/compare-locales/compare_locales/tests/test_ftl.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_ftl.py
@@ -1,15 +1,16 @@
 # -*- coding: utf-8 -*-
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import unittest
 
+from compare_locales import parser
 from compare_locales.tests import ParserTestMixin
 
 
 class TestFluentParser(ParserTestMixin, unittest.TestCase):
     maxDiff = None
     filename = 'foo.ftl'
 
     def test_equality_same(self):
@@ -97,17 +98,17 @@ h =
 abc =
     A
     B
     C
 ''')
 
         [abc] = list(self.parser)
         self.assertEqual(abc.key, 'abc')
-        self.assertEqual(abc.val, '\n    A\n    B\n    C')
+        self.assertEqual(abc.val, '    A\n    B\n    C')
         self.assertEqual(abc.all, 'abc =\n    A\n    B\n    C')
 
     def test_message_with_attribute(self):
         self.parser.readContents('''\
 abc = ABC
     .attr = Attr
 ''')
 
@@ -119,15 +120,86 @@ abc = ABC
     def test_message_with_attribute_and_no_value(self):
         self.parser.readContents('''\
 abc
     .attr = Attr
 ''')
 
         [abc] = list(self.parser)
         self.assertEqual(abc.key, 'abc')
-        self.assertEqual(abc.val, '')
+        self.assertEqual(abc.val, None)
         self.assertEqual(abc.all, 'abc\n    .attr = Attr')
         attributes = list(abc.attributes)
         self.assertEqual(len(attributes), 1)
         attr = attributes[0]
         self.assertEqual(attr.key, 'attr')
         self.assertEqual(attr.val, 'Attr')
+
+    def test_non_localizable(self):
+        self.parser.readContents('''\
+// Resource Comment
+
+foo = Foo
+
+// Section Comment
+[[ Section Header ]]
+
+bar = Bar
+
+// Standalone Comment
+
+// Baz Comment
+baz = Baz
+''')
+        entities = self.parser.walk()
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Comment))
+        self.assertEqual(entity.all, '// Resource Comment')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n\n')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.FluentEntity))
+        self.assertEqual(entity.val, 'Foo')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n\n')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.FluentSection))
+        self.assertEqual(
+            entity.all, '// Section Comment\n[[ Section Header ]]')
+        self.assertEqual(entity.val, 'Section Header ')
+        self.assertEqual(
+            entity.entry.comment.content, 'Section Comment')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n\n')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.FluentEntity))
+        self.assertEqual(entity.val, 'Bar')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n\n')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Comment))
+        self.assertEqual(entity.all, '// Standalone Comment')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n\n')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.FluentEntity))
+        self.assertEqual(entity.val, 'Baz')
+        self.assertEqual(entity.entry.comment.content, 'Baz Comment')
+
+        entity = next(entities)
+        self.assertTrue(isinstance(entity, parser.Whitespace))
+        self.assertEqual(entity.all, '\n')
--- a/third_party/python/compare-locales/compare_locales/tests/test_merge.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge.py
@@ -288,19 +288,19 @@ class TestDTD(unittest.TestCase, Content
                     'missing': 1,
                     'missing_w': 1,
                     'unchanged': 2,
                     'unchanged_w': 2
                 }},
              'details': {
                  'l10n.dtd': [
                      {'error': u'Unparsed content "<!ENTY bar '
-                               u'\'gimmick\'>" '
+                               u'\'gimmick\'>\n" '
                                u'from line 2 column 1 to '
-                               u'line 2 column 22'},
+                               u'line 3 column 1'},
                      {'missingEntity': u'bar'}]
                 }
              })
         mergefile = mozpath.join(self.tmp, "merge", "l10n.dtd")
         self.assertTrue(os.path.isfile(mergefile))
         p = getParser(mergefile)
         p.readFile(mergefile)
         [m, n] = p.parse()
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_comments.py
@@ -0,0 +1,186 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+from compare_locales.merge import merge_channels
+
+
+class TestMergeComments(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_comment_added_in_first(self):
+        channels = (b"""
+foo = Foo 1
+# Bar Comment 1
+bar = Bar 1
+""", b"""
+foo = Foo 2
+bar = Bar 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+# Bar Comment 1
+bar = Bar 1
+""")
+
+    def test_comment_still_in_last(self):
+        channels = (b"""
+foo = Foo 1
+bar = Bar 1
+""", b"""
+foo = Foo 2
+# Bar Comment 2
+bar = Bar 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+# Bar Comment 2
+bar = Bar 1
+""")
+
+    def test_comment_changed(self):
+        channels = (b"""
+foo = Foo 1
+# Bar Comment 1
+bar = Bar 1
+""", b"""
+foo = Foo 2
+# Bar Comment 2
+bar = Bar 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+# Bar Comment 1
+bar = Bar 1
+""")
+
+
+class TestMergeStandaloneComments(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_comment_added_in_first(self):
+        channels = (b"""
+# Standalone Comment 1
+
+# Foo Comment 1
+foo = Foo 1
+""", b"""
+# Foo Comment 2
+foo = Foo 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Standalone Comment 1
+
+# Foo Comment 1
+foo = Foo 1
+""")
+
+    def test_comment_still_in_last(self):
+        channels = (b"""
+# Foo Comment 1
+foo = Foo 1
+""", b"""
+# Standalone Comment 2
+
+# Foo Comment 2
+foo = Foo 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Standalone Comment 2
+
+# Foo Comment 1
+foo = Foo 1
+""")
+
+    def test_comments_in_both(self):
+        channels = (b"""
+# Standalone Comment 1
+
+# Foo Comment 1
+foo = Foo 1
+""", b"""
+# Standalone Comment 2
+
+# Foo Comment 2
+foo = Foo 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Standalone Comment 2
+
+# Standalone Comment 1
+
+# Foo Comment 1
+foo = Foo 1
+""")
+
+    def test_identical_comments_in_both(self):
+        channels = (b"""
+# Standalone Comment
+
+# Foo Comment 1
+foo = Foo 1
+""", b"""
+# Standalone Comment
+
+# Foo Comment 2
+foo = Foo 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Standalone Comment
+
+# Foo Comment 1
+foo = Foo 1
+""")
+
+    def test_standalone_which_is_attached_in_first(self):
+        channels = (b"""
+# Ambiguous Comment
+foo = Foo 1
+
+# Bar Comment 1
+bar = Bar 1
+""", b"""
+# Ambiguous Comment
+
+# Bar Comment 2
+bar = Bar 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Ambiguous Comment
+
+foo = Foo 1
+
+# Bar Comment 1
+bar = Bar 1
+""")
+
+    def test_standalone_which_is_attached_in_second(self):
+        channels = (b"""
+# Ambiguous Comment
+
+# Bar Comment 1
+bar = Bar 1
+""", b"""
+# Ambiguous Comment
+foo = Foo 1
+
+# Bar Comment 2
+bar = Bar 2
+""")
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""
+# Ambiguous Comment
+foo = Foo 1
+
+# Bar Comment 1
+bar = Bar 1
+""")
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_dtd.py
@@ -0,0 +1,133 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+from compare_locales.merge import merge_channels
+
+
+class TestMergeDTD(unittest.TestCase):
+    name = "foo.dtd"
+    maxDiff = None
+
+    def test_no_changes(self):
+        channels = (b"""
+<!ENTITY foo "Foo 1">
+""", b"""
+<!ENTITY foo "Foo 2">
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+<!ENTITY foo "Foo 1">
+""")
+
+    def test_trailing_whitespace(self):
+        channels = (b"""
+<!ENTITY foo "Foo 1">
+""", b"""
+<!ENTITY foo "Foo 2"> \n""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+<!ENTITY foo "Foo 1"> \n""")
+
+    def test_browser_dtd(self):
+        channels = (b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the browser main menu ... -->
+<!-- LOCALIZATION NOTE : FILE Do not translate commandkeys -->
+
+<!-- LOCALIZATION NOTE (mainWindow.titlemodifier) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.titlemodifier "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.separator): DONT_TRANSLATE -->
+<!ENTITY mainWindow.separator " - ">
+<!-- LOCALIZATION NOTE (mainWindow.privatebrowsing2): This will be appended ...
+                                                      inside the ... -->
+<!ENTITY mainWindow.privatebrowsing2 "(Private Browsing)">
+""", b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the browser main menu ... -->
+<!-- LOCALIZATION NOTE : FILE Do not translate commandkeys -->
+
+<!-- LOCALIZATION NOTE (mainWindow.title): DONT_TRANSLATE -->
+<!ENTITY mainWindow.title "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.titlemodifier) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.titlemodifier "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.privatebrowsing): This will be appended ...
+                                                     inside the ... -->
+<!ENTITY mainWindow.privatebrowsing "(Private Browsing)">
+""")
+
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the browser main menu ... -->
+<!-- LOCALIZATION NOTE : FILE Do not translate commandkeys -->
+
+<!-- LOCALIZATION NOTE (mainWindow.title): DONT_TRANSLATE -->
+<!ENTITY mainWindow.title "&brandFullName;">
+
+<!-- LOCALIZATION NOTE (mainWindow.titlemodifier) : DONT_TRANSLATE -->
+<!ENTITY mainWindow.titlemodifier "&brandFullName;">
+<!-- LOCALIZATION NOTE (mainWindow.privatebrowsing): This will be appended ...
+                                                     inside the ... -->
+<!ENTITY mainWindow.privatebrowsing "(Private Browsing)">
+<!-- LOCALIZATION NOTE (mainWindow.separator): DONT_TRANSLATE -->
+<!ENTITY mainWindow.separator " - ">
+<!-- LOCALIZATION NOTE (mainWindow.privatebrowsing2): This will be appended ...
+                                                      inside the ... -->
+<!ENTITY mainWindow.privatebrowsing2 "(Private Browsing)">
+""")
+
+    def test_aboutServiceWorkers_dtd(self):
+        channels = (b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY title                     "About Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY maintitle                 "Registered Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_not_enabled       "Service Workers are not enabled.">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_no_serviceworkers "No Service Workers registered.">
+""", b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY title                     "About Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY maintitle                 "Registered Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_not_enabled       "Service Workers are not enabled.">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_no_serviceworkers "No Service Workers registered.">
+""")
+
+        self.assertMultiLineEqual(
+            merge_channels(self.name, *channels), b"""\
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY title                     "About Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY maintitle                 "Registered Service Workers">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_not_enabled       "Service Workers are not enabled.">
+<!-- LOCALIZATION NOTE the term "Service Workers" should not be translated. -->
+<!ENTITY warning_no_serviceworkers "No Service Workers registered.">
+""")
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_ftl.py
@@ -0,0 +1,316 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+from compare_locales.merge import merge_channels
+
+
+class TestMergeFluent(unittest.TestCase):
+    name = "foo.ftl"
+
+    def test_no_changes(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_attribute_in_first(self):
+        channels = (b"""
+foo = Foo 1
+    .attr = Attr 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+    .attr = Attr 1
+""")
+
+    def test_attribute_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+    .attr = Attr 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_attribute_changed(self):
+        channels = (b"""
+foo = Foo 1
+    .attr = Attr 1
+""", b"""
+foo = Foo 2
+    .attr = Attr 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+    .attr = Attr 1
+""")
+
+    def test_tag_in_first(self):
+        channels = (b"""
+foo = Foo 1
+    #tag
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+    #tag
+""")
+
+    def test_tag_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+    #tag
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_tag_changed(self):
+        channels = (b"""
+foo = Foo 1
+    #tag1
+""", b"""
+foo = Foo 2
+    #tag2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+    #tag1
+""")
+
+    def test_section_in_first(self):
+        channels = (b"""
+[[ Section 1 ]]
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+[[ Section 1 ]]
+foo = Foo 1
+""")
+
+    def test_section_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+[[ Section 2 ]]
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+[[ Section 2 ]]
+foo = Foo 1
+""")
+
+    def test_section_changed(self):
+        channels = (b"""
+[[ Section 1 ]]
+foo = Foo 1
+""", b"""
+[[ Section 2 ]]
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+[[ Section 2 ]]
+[[ Section 1 ]]
+foo = Foo 1
+""")
+
+    def test_message_comment_in_first(self):
+        channels = (b"""
+// Comment 1
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Comment 1
+foo = Foo 1
+""")
+
+    def test_message_comment_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+// Comment 2
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_message_comment_changed(self):
+        channels = (b"""
+// Comment 1
+foo = Foo 1
+""", b"""
+// Comment 2
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Comment 1
+foo = Foo 1
+""")
+
+    def test_section_comment_in_first(self):
+        channels = (b"""
+// Comment 1
+[[ Section ]]
+""", b"""
+[[ Section ]]
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Comment 1
+[[ Section ]]
+""")
+
+    def test_section_comment_in_last(self):
+        channels = (b"""
+[[ Section ]]
+""", b"""
+// Comment 2
+[[ Section ]]
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+[[ Section ]]
+""")
+
+    def test_section_comment_changed(self):
+        channels = (b"""
+// Comment 1
+[[ Section ]]
+""", b"""
+// Comment 2
+[[ Section ]]
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Comment 1
+[[ Section ]]
+""")
+
+    def test_standalone_comment_in_first(self):
+        channels = (b"""
+foo = Foo 1
+
+// Comment 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+
+// Comment 1
+""")
+
+    def test_standalone_comment_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+
+// Comment 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+
+// Comment 2
+""")
+
+    def test_standalone_comment_changed(self):
+        channels = (b"""
+foo = Foo 1
+
+// Comment 1
+""", b"""
+foo = Foo 2
+
+// Comment 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+
+// Comment 2
+
+// Comment 1
+""")
+
+    def test_resource_comment_in_first(self):
+        channels = (b"""
+// Resource Comment 1
+
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Resource Comment 1
+
+foo = Foo 1
+""")
+
+    def test_resource_comment_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+// Resource Comment 1
+
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Resource Comment 1
+
+foo = Foo 1
+""")
+
+    def test_resource_comment_changed(self):
+        channels = (b"""
+// Resource Comment 1
+
+foo = Foo 1
+""", b"""
+// Resource Comment 2
+
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+// Resource Comment 2
+
+// Resource Comment 1
+
+foo = Foo 1
+""")
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_messages.py
@@ -0,0 +1,93 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+from compare_locales.merge import merge_channels
+
+
+class TestMergeTwo(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_no_changes(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_message_added_in_first(self):
+        channels = (b"""
+foo = Foo 1
+bar = Bar 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+bar = Bar 1
+""")
+
+    def test_message_still_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+bar = Bar 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+bar = Bar 2
+""")
+
+    def test_message_reordered(self):
+        channels = (b"""
+foo = Foo 1
+bar = Bar 1
+""", b"""
+bar = Bar 2
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+bar = Bar 1
+""")
+
+
+class TestMergeThree(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_no_changes(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""", b"""
+foo = Foo 3
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_message_still_in_last(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""", b"""
+foo = Foo 3
+bar = Bar 3
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+bar = Bar 3
+""")
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_properties.py
@@ -0,0 +1,41 @@
+# coding=utf8
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from codecs import decode, encode
+import unittest
+
+from compare_locales.merge import merge_channels
+
+
+class TestMergeProperties(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_no_changes(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+""")
+
+    def test_encoding(self):
+        channels = (encode(u"""
+foo = Foo 1…
+""", "utf8"), encode(u"""
+foo = Foo 2…
+""", "utf8"))
+        output = merge_channels(self.name, *channels)
+        self.assertEqual(output, encode(u"""
+foo = Foo 1…
+""", "utf8"))
+
+        u_output = decode(output, "utf8")
+        self.assertEqual(u_output, u"""
+foo = Foo 1…
+""")
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_unknown.py
@@ -0,0 +1,21 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+
+from compare_locales.merge import merge_channels, MergeNotSupportedError
+
+
+class TestMergeUnknown(unittest.TestCase):
+    name = "foo.unknown"
+
+    def test_not_supported_error(self):
+        channels = (b"""
+foo = Foo 1
+""", b"""
+foo = Foo 2
+""")
+        pattern = "Unsupported file format \(foo\.unknown\)\."
+        with self.assertRaisesRegexp(MergeNotSupportedError, pattern):
+            merge_channels(self.name, *channels)
new file mode 100644
--- /dev/null
+++ b/third_party/python/compare-locales/compare_locales/tests/test_merge_whitespace.py
@@ -0,0 +1,76 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+from compare_locales.merge import merge_channels
+
+
+class TestMergeWhitespace(unittest.TestCase):
+    name = "foo.properties"
+
+    def test_trailing_spaces(self):
+        channels = (b"""
+foo = Foo 1
+      """, b"""
+foo = Foo 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+      """)
+
+    def test_blank_lines_between_messages(self):
+        channels = (b"""
+foo = Foo 1
+
+bar = Bar 1
+""", b"""
+foo = Foo 2
+bar = Bar 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+
+bar = Bar 1
+""")
+
+    def test_no_eol(self):
+        channels = (b"""
+foo = Foo 1""", b"""
+foo = Foo 2
+bar = Bar 2
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+foo = Foo 1
+bar = Bar 2
+""")
+
+    def test_still_in_last_with_blank(self):
+        channels = (b"""
+
+foo = Foo 1
+
+baz = Baz 1
+
+""", b"""
+
+foo = Foo 2
+
+bar = Bar 2
+
+baz = Baz 2
+
+""")
+        self.assertEqual(
+            merge_channels(self.name, *channels), b"""
+
+foo = Foo 1
+
+bar = Bar 2
+
+baz = Baz 1
+
+""")
--- a/third_party/python/compare-locales/compare_locales/tests/test_parser.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_parser.py
@@ -3,37 +3,41 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 import unittest
 
 from compare_locales import parser
 
 
 class TestParserContext(unittest.TestCase):
-    def test_lines(self):
-        "Test that Parser.Context.lines returns 1-based tuples"
+    def test_linecol(self):
+        "Should return 1-based line and column numbers."
         ctx = parser.Parser.Context('''first line
 second line
 third line
 ''')
         self.assertEqual(
-            ctx.lines(0, 1),
-            [(1, 1), (1, 2)]
+            ctx.linecol(0),
+            (1, 1)
+        )
+        self.assertEqual(
+            ctx.linecol(1),
+            (1, 2)
         )
         self.assertEqual(
-            ctx.lines(len('first line')),
-            [(1, len('first line') + 1)]
+            ctx.linecol(len('first line')),
+            (1, len('first line') + 1)
         )
         self.assertEqual(
-            ctx.lines(len('first line') + 1),
-            [(2, 1)]
+            ctx.linecol(len('first line') + 1),
+            (2, 1)
         )
         self.assertEqual(
-            ctx.lines(len(ctx.contents)),
-            [(4, 1)]
+            ctx.linecol(len(ctx.contents)),
+            (4, 1)
         )
 
     def test_empty_parser(self):
         p = parser.Parser()
         entities, _map = p.parse()
         self.assertListEqual(
             entities,
             []
--- a/third_party/python/compare-locales/compare_locales/tests/test_properties.py
+++ b/third_party/python/compare-locales/compare_locales/tests/test_properties.py
@@ -17,21 +17,25 @@ class TestPropertiesParser(ParserTestMix
 two_line = This is the first \
 of two lines
 one_line_trailing = This line ends in \\
 and has junk
 two_lines_triple = This line is one of two and ends in \\\
 and still has another line coming
 ''', (
             ('one_line', 'This is one line'),
+            ('Whitespace', '\n'),
             ('two_line', u'This is the first of two lines'),
+            ('Whitespace', '\n'),
             ('one_line_trailing', u'This line ends in \\'),
+            ('Whitespace', '\n'),
             ('Junk', 'and has junk\n'),
             ('two_lines_triple', 'This line is one of two and ends in \\'
-             'and still has another line coming')))
+             'and still has another line coming'),
+            ('Whitespace', '\n')))
 
     def testProperties(self):
         # port of netwerk/test/PropertiesTest.cpp
         self.parser.readContents(self.resource('test.properties'))
         ref = ['1', '2', '3', '4', '5', '6', '7', '8',
                'this is the first part of a continued line '
                'and here is the 2nd part']
         i = iter(self.parser)
@@ -58,17 +62,21 @@ and an end''', (('bar', 'one line with a
 
     def test_license_header(self):
         self._test('''\
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 foo=value
-''', (('Comment', 'MPL'), ('foo', 'value')))
+''', (
+            ('Comment', 'MPL'),
+            ('Whitespace', '\n\n'),
+            ('foo', 'value'),
+            ('Whitespace', '\n')))
 
     def test_escapes(self):
         self.parser.readContents(r'''
 # unicode escapes
 zero = some \unicode
 one = \u0
 two = \u41
 three = \u042
@@ -82,70 +90,91 @@ seven = \n\r\t\\
             self.assertEqual(e.val, r)
 
     def test_trailing_comment(self):
         self._test('''first = string
 second = string
 
 #
 #commented out
-''', (('first', 'string'), ('second', 'string'),
-            ('Comment', 'commented out')))
+''', (
+            ('first', 'string'),
+            ('Whitespace', '\n'),
+            ('second', 'string'),
+            ('Whitespace', '\n\n'),
+            ('Comment', 'commented out'),
+            ('Whitespace', '\n')))
 
     def test_trailing_newlines(self):
         self._test('''\
 foo = bar
 
 \x20\x20
-  ''', (('foo', 'bar'),))
+  ''', (('foo', 'bar'), ('Whitespace', '\n\n\x20\x20\n ')))
 
     def test_just_comments(self):
         self._test('''\
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # LOCALIZATION NOTE These strings are used inside the Promise debugger
 # which is available as a panel in the Debugger.
-''', (('Comment', 'MPL'), ('Comment', 'LOCALIZATION NOTE')))
+''', (
+            ('Comment', 'MPL'),
+            ('Whitespace', '\n\n'),
+            ('Comment', 'LOCALIZATION NOTE'),
+            ('Whitespace', '\n')))
 
     def test_just_comments_without_trailing_newline(self):
         self._test('''\
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # LOCALIZATION NOTE These strings are used inside the Promise debugger
 # which is available as a panel in the Debugger.''', (
-            ('Comment', 'MPL'), ('Comment', 'LOCALIZATION NOTE')))
+            ('Comment', 'MPL'),
+            ('Whitespace', '\n\n'),
+            ('Comment', 'LOCALIZATION NOTE')))
 
     def test_trailing_comment_and_newlines(self):
         self._test('''\
 # LOCALIZATION NOTE These strings are used inside the Promise debugger
 # which is available as a panel in the Debugger.
 
 
 
-''',  (('Comment', 'LOCALIZATION NOTE'),))
+''',  (
+            ('Comment', 'LOCALIZATION NOTE'),
+            ('Whitespace', '\n\n\n')))
 
     def test_empty_file(self):
         self._test('', tuple())
         self._test('\n', (('Whitespace', '\n'),))
         self._test('\n\n', (('Whitespace', '\n\n'),))
-        self._test(' \n\n', (('Whitespace', ' \n\n'),))
+        self._test(' \n\n', (('Whitespace', '\n\n'),))
 
     def test_positions(self):
         self.parser.readContents('''\
 one = value
 two = other \\
 escaped value
 ''')
         one, two = list(self.parser)
         self.assertEqual(one.position(), (1, 1))
         self.assertEqual(one.value_position(), (1, 7))
         self.assertEqual(two.position(), (2, 1))
         self.assertEqual(two.value_position(), (2, 7))
         self.assertEqual(two.value_position(-1), (3, 14))
         self.assertEqual(two.value_position(10), (3, 3))
 
+    # Bug 1399059 comment 18
+    def test_z(self):
+        self.parser.readContents('''\
+one = XYZ ABC
+''')
+        one, = list(self.parser)
+        self.assertEqual(one.val, 'XYZ ABC')
+
 
 if __name__ == '__main__':
     unittest.main()
deleted file mode 100644
--- a/third_party/python/fluent/PKG-INFO
+++ /dev/null
@@ -1,16 +0,0 @@
-Metadata-Version: 1.1
-Name: fluent
-Version: 0.4.2
-Summary: Localization library for expressive translations.
-Home-page: https://github.com/projectfluent/python-fluent
-Author: Mozilla
-Author-email: l10n-drivers@mozilla.org
-License: APL 2
-Description: UNKNOWN
-Keywords: fluent,localization,l10n
-Platform: UNKNOWN
-Classifier: Development Status :: 3 - Alpha
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: Apache Software License
-Classifier: Programming Language :: Python :: 2.7
-Classifier: Programming Language :: Python :: 3.5
--- a/third_party/python/fluent/fluent/migrate/__init__.py
+++ b/third_party/python/fluent/fluent/migrate/__init__.py
@@ -1,10 +1,13 @@
 # coding=utf8
 
 from .context import MergeContext                      # noqa: F401
+from .errors import (                                  # noqa: F401
+    MigrationError, NotSupportedError, UnreadableReferenceError
+)
 from .transforms import (                              # noqa: F401
     Source, COPY, REPLACE_IN_TEXT, REPLACE, PLURALS, CONCAT
 )
 from .helpers import (                                 # noqa: F401
-    LITERAL, EXTERNAL_ARGUMENT, MESSAGE_REFERENCE
+    EXTERNAL_ARGUMENT, MESSAGE_REFERENCE
 )
 from .changesets import convert_blame_to_changesets    # noqa: F401
--- a/third_party/python/fluent/fluent/migrate/context.py
+++ b/third_party/python/fluent/fluent/migrate/context.py
@@ -1,29 +1,34 @@
 # coding=utf8
 from __future__ import unicode_literals
 
 import os
 import codecs
 import logging
 
+try:
+    from itertools import zip_longest
+except ImportError:
+    from itertools import izip_longest as zip_longest
+
 import fluent.syntax.ast as FTL
 from fluent.syntax.parser import FluentParser
 from fluent.syntax.serializer import FluentSerializer
 from fluent.util import fold
 try:
     from compare_locales.parser import getParser
 except ImportError:
     def getParser(path):
         raise RuntimeError('compare-locales required')
 
 from .cldr import get_plural_categories
 from .transforms import Source
 from .merge import merge_resource
-from .util import get_message
+from .errors import NotSupportedError, UnreadableReferenceError
 
 
 class MergeContext(object):
     """Stateful context for merging translation resources.
 
     `MergeContext` must be configured with the target language and the
     directory locations of the input data.
 
@@ -36,17 +41,17 @@ class MergeContext(object):
 
         - The legacy (DTD, properties) translation files for the given
           language.  The translations from these files will be transformed
           into FTL and merged into the existing FTL files for this language.
 
         - A list of `FTL.Message` objects some of whose nodes are special
           helper or transform nodes:
 
-              helpers: LITERAL, EXTERNAL_ARGUMENT, MESSAGE_REFERENCE
+              helpers: EXTERNAL_ARGUMENT, MESSAGE_REFERENCE
               transforms: COPY, REPLACE_IN_TEXT, REPLACE, PLURALS, CONCAT
     """
 
     def __init__(self, lang, reference_dir, localization_dir):
         self.fluent_parser = FluentParser(with_spans=False)
         self.fluent_serializer = FluentSerializer()
 
         # An iterable of plural category names relevant to the context's
@@ -74,141 +79,187 @@ class MergeContext(object):
         # corresponding to localized entities which will be migrated.
         self.dependencies = {}
 
     def read_ftl_resource(self, path):
         """Read an FTL resource and parse it into an AST."""
         f = codecs.open(path, 'r', 'utf8')
         try:
             contents = f.read()
+        except UnicodeDecodeError as err:
+            logger = logging.getLogger('migrate')
+            logger.warn('Unable to read file {}: {}'.format(path, err))
+            raise err
         finally:
             f.close()
 
         ast = self.fluent_parser.parse(contents)
 
         annots = [
             annot
             for entry in ast.body
             for annot in entry.annotations
         ]
 
         if len(annots):
             logger = logging.getLogger('migrate')
             for annot in annots:
                 msg = annot.message
-                logger.warn(u'Syntax error in {}: {}'.format(path, msg))
+                logger.warn('Syntax error in {}: {}'.format(path, msg))
 
         return ast
 
     def read_legacy_resource(self, path):
         """Read a legacy resource and parse it into a dict."""
         parser = getParser(path)
         parser.readFile(path)
         # Transform the parsed result which is an iterator into a dict.
         return {entity.key: entity.val for entity in parser}
 
-    def add_reference(self, path, realpath=None):
-        """Add an FTL AST to this context's reference resources."""
-        fullpath = os.path.join(self.reference_dir, realpath or path)
-        try:
-            ast = self.read_ftl_resource(fullpath)
-        except IOError as err:
-            logger = logging.getLogger('migrate')
-            logger.error(u'Missing reference file: {}'.format(path))
-            raise err
-        except UnicodeDecodeError as err:
-            logger = logging.getLogger('migrate')
-            logger.error(u'Error reading file {}: {}'.format(path, err))
-            raise err
-        else:
-            self.reference_resources[path] = ast
+    def maybe_add_localization(self, path):
+        """Add a localization resource to migrate translations from.
 
-    def add_localization(self, path):
-        """Add an existing localization resource.
+        Only legacy resources can be added as migration sources.  The resource
+        may be missing on disk.
 
-        If it's an FTL resource, add an FTL AST.  Otherwise, it's a legacy
-        resource.  Use a compare-locales parser to create a dict of (key,
-        string value) tuples.
+        Uses a compare-locales parser to create a dict of (key, string value)
+        tuples.
         """
-        fullpath = os.path.join(self.localization_dir, path)
-        if fullpath.endswith('.ftl'):
-            try:
-                ast = self.read_ftl_resource(fullpath)
-            except IOError:
-                logger = logging.getLogger('migrate')
-                logger.warn(u'Missing localization file: {}'.format(path))
-            except UnicodeDecodeError as err:
-                logger = logging.getLogger('migrate')
-                logger.warn(u'Error reading file {}: {}'.format(path, err))
-            else:
-                self.localization_resources[path] = ast
+        if path.endswith('.ftl'):
+            error_message = (
+                'Migrating translations from Fluent files is not supported '
+                '({})'.format(path))
+            logging.getLogger('migrate').error(error_message)
+            raise NotSupportedError(error_message)
+
+        try:
+            fullpath = os.path.join(self.localization_dir, path)
+            collection = self.read_legacy_resource(fullpath)
+        except IOError:
+            logger = logging.getLogger('migrate')
+            logger.warn('Missing localization file: {}'.format(path))
         else:
-            try:
-                collection = self.read_legacy_resource(fullpath)
-            except IOError:
-                logger = logging.getLogger('migrate')
-                logger.warn(u'Missing localization file: {}'.format(path))
-            else:
-                self.localization_resources[path] = collection
+            self.localization_resources[path] = collection
 
-    def add_transforms(self, path, transforms):
-        """Define transforms for path.
+    def add_transforms(self, target, reference, transforms):
+        """Define transforms for target using reference as template.
+
+        `target` is a path of the destination FTL file relative to the
+        localization directory. `reference` is a path to the template FTL
+        file relative to the reference directory.
 
         Each transform is an extended FTL node with `Transform` nodes as some
         values.  Transforms are stored in their lazy AST form until
         `merge_changeset` is called, at which point they are evaluated to real
         FTL nodes with migrated translations.
 
         Each transform is scanned for `Source` nodes which will be used to
         build the list of dependencies for the transformed message.
         """
         def get_sources(acc, cur):
             if isinstance(cur, Source):
                 acc.add((cur.path, cur.key))
             return acc
 
+        refpath = os.path.join(self.reference_dir, reference)
+        try:
+            ast = self.read_ftl_resource(refpath)
+        except IOError as err:
+            error_message = 'Missing reference file: {}'.format(refpath)
+            logging.getLogger('migrate').error(error_message)
+            raise UnreadableReferenceError(error_message)
+        except UnicodeDecodeError as err:
+            error_message = 'Error reading file {}: {}'.format(refpath, err)
+            logging.getLogger('migrate').error(error_message)
+            raise UnreadableReferenceError(error_message)
+        else:
+            # The reference file will be used by the merge function as
+            # a template for serializing the merge results.
+            self.reference_resources[target] = ast
+
         for node in transforms:
             # Scan `node` for `Source` nodes and collect the information they
             # store into a set of dependencies.
             dependencies = fold(get_sources, node, set())
             # Set these sources as dependencies for the current transform.
-            self.dependencies[(path, node.id.name)] = dependencies
+            self.dependencies[(target, node.id.name)] = dependencies
 
-        path_transforms = self.transforms.setdefault(path, [])
+            # Read all legacy translation files defined in Source transforms.
+            for path in set(path for path, _ in dependencies):
+                self.maybe_add_localization(path)
+
+        path_transforms = self.transforms.setdefault(target, [])
         path_transforms += transforms
 
+        if target not in self.localization_resources:
+            fullpath = os.path.join(self.localization_dir, target)
+            try:
+                ast = self.read_ftl_resource(fullpath)
+            except IOError:
+                logger = logging.getLogger('migrate')
+                logger.info(
+                    'Localization file {} does not exist and '
+                    'it will be created'.format(target))
+            except UnicodeDecodeError:
+                logger = logging.getLogger('migrate')
+                logger.warn(
+                    'Localization file {} will be re-created and some '
+                    'translations might be lost'.format(target))
+            else:
+                self.localization_resources[target] = ast
+
     def get_source(self, path, key):
-        """Get an entity value from the localized source.
+        """Get an entity value from a localized legacy source.
 
         Used by the `Source` transform.
         """
-        if path.endswith('.ftl'):
-            resource = self.localization_resources[path]
-            return get_message(resource.body, key)
-        else:
-            resource = self.localization_resources[path]
-            return resource.get(key, None)
+        resource = self.localization_resources[path]
+        return resource.get(key, None)
+
+    def messages_equal(self, res1, res2):
+        """Compare messages of two FTL resources.
+
+        Uses FTL.BaseNode.equals to compare all messages in two FTL resources.
+        If the order or number of messages differ, the result is also False.
+        """
+        def message_id(message):
+            "Return the message's identifer name for sorting purposes."
+            return message.id.name
+
+        messages1 = sorted(
+            (entry for entry in res1.body if isinstance(entry, FTL.Message)),
+            key=message_id)
+        messages2 = sorted(
+            (entry for entry in res2.body if isinstance(entry, FTL.Message)),
+            key=message_id)
+        for msg1, msg2 in zip_longest(messages1, messages2):
+            if msg1 is None or msg2 is None:
+                return False
+            if not msg1.equals(msg2):
+                return False
+        return True
 
     def merge_changeset(self, changeset=None):
         """Return a generator of FTL ASTs for the changeset.
 
         The input data must be configured earlier using the `add_*` methods.
         if given, `changeset` must be a set of (path, key) tuples describing
         which legacy translations are to be merged.
 
         Given `changeset`, return a dict whose keys are resource paths and
         values are `FTL.Resource` instances.  The values will also be used to
         update this context's existing localization resources.
         """
 
         if changeset is None:
-            # Merge all known legacy translations.
+            # Merge all known legacy translations. Used in tests.
             changeset = {
                 (path, key)
                 for path, strings in self.localization_resources.iteritems()
+                if not path.endswith('.ftl')
                 for key in strings.iterkeys()
             }
 
         for path, reference in self.reference_resources.iteritems():
             current = self.localization_resources.get(path, FTL.Resource())
             transforms = self.transforms.get(path, [])
 
             def in_changeset(ident):
@@ -235,21 +286,24 @@ class MergeContext(object):
                 return message_deps & changeset
 
             # Merge legacy translations with the existing ones using the
             # reference as a template.
             snapshot = merge_resource(
                 self, reference, current, transforms, in_changeset
             )
 
-            # If none of the transforms is in the given changeset, the merged
-            # snapshot is identical to the current translation. We compare
-            # JSON trees rather then use filtering by `in_changeset` to account
-            # for translations removed from `reference`.
-            if snapshot.to_json() == current.to_json():
+            # Skip this path if the messages in the merged snapshot are
+            # identical to those in the current state of the localization file.
+            # This may happen when:
+            #
+            #   - none of the transforms is in the changset, or
+            #   - all messages which would be migrated by the context's
+            #     transforms already exist in the current state.
+            if self.messages_equal(current, snapshot):
                 continue
 
             # Store the merged snapshot on the context so that the next merge
             # already takes it into account as the existing localization.
             self.localization_resources[path] = snapshot
 
             # The result for this path is a complete `FTL.Resource`.
             yield path, snapshot
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/fluent/migrate/errors.py
@@ -0,0 +1,10 @@
+class MigrationError(ValueError):
+    pass
+
+
+class NotSupportedError(MigrationError):
+    pass
+
+
+class UnreadableReferenceError(MigrationError):
+    pass
--- a/third_party/python/fluent/fluent/migrate/helpers.py
+++ b/third_party/python/fluent/fluent/migrate/helpers.py
@@ -8,22 +8,16 @@ They take a string argument and immediat
 (As opposed to Transforms which are AST nodes on their own and only return the
 migrated AST nodes when they are evaluated by a MergeContext.) """
 
 from __future__ import unicode_literals
 
 import fluent.syntax.ast as FTL
 
 
-def LITERAL(value):
-    """Create a Pattern with a single TextElement."""
-    elements = [FTL.TextElement(value)]
-    return FTL.Pattern(elements)
-
-
 def EXTERNAL_ARGUMENT(name):
     """Create an ExternalArgument expression."""
 
     return FTL.ExternalArgument(
         id=FTL.Identifier(name)
     )
 
 
--- a/third_party/python/fluent/fluent/migrate/merge.py
+++ b/third_party/python/fluent/fluent/migrate/merge.py
@@ -6,19 +6,20 @@ import fluent.syntax.ast as FTL
 from .transforms import evaluate
 from .util import get_message, get_transform
 
 
 def merge_resource(ctx, reference, current, transforms, in_changeset):
     """Transform legacy translations into FTL.
 
     Use the `reference` FTL AST as a template.  For each en-US string in the
-    reference, first check if it's in the currently processed changeset with
-    `in_changeset`; then check for an existing translation in the current FTL
-    `localization` or for a migration specification in `transforms`.
+    reference, first check for an existing translation in the current FTL
+    `localization` and use it if it's present; then if the string has
+    a transform defined in the migration specification and if it's in the
+    currently processed changeset, evaluate the transform.
     """
 
     def merge_body(body):
         return [
             entry
             for entry in map(merge_entry, body)
             if entry is not None
         ]
--- a/third_party/python/fluent/fluent/migrate/transforms.py
+++ b/third_party/python/fluent/fluent/migrate/transforms.py
@@ -3,54 +3,54 @@
 
 Transforms are AST nodes which describe how legacy translations should be
 migrated.  They are created inert and only return the migrated AST nodes when
 they are evaluated by a MergeContext.
 
 All Transforms evaluate to Fluent Patterns. This makes them suitable for
 defining migrations of values of message, attributes and variants.  The special
 CONCAT Transform is capable of joining multiple Patterns returned by evaluating
-other Transforms into a single Pattern.  It can also concatenate Fluent
-Expressions, like MessageReferences and ExternalArguments.
+other Transforms into a single Pattern.  It can also concatenate Pattern
+elements: TextElements and Placeables.
 
 The COPY, REPLACE and PLURALS Transforms inherit from Source which is a special
 AST Node defining the location (the file path and the id) of the legacy
 translation.  During the migration, the current MergeContext scans the
 migration spec for Source nodes and extracts the information about all legacy
-translations being migrated. Thus,
+translations being migrated. For instance,
 
     COPY('file.dtd', 'hello')
 
 is equivalent to:
 
-    LITERAL(Source('file.dtd', 'hello'))
-
-where LITERAL is a helper defined in the helpers.py module for creating Fluent
-Patterns from the text passed as the argument.
+    FTL.Pattern([
+        FTL.TextElement(Source('file.dtd', 'hello'))
+    ])
 
-The LITERAL helper and the special REPLACE_IN_TEXT Transforms are useful for
-working with text rather than (path, key) source definitions.  This is the case
-when the migrated translation requires some hardcoded text, e.g. <a> and </a>
-when multiple translations become a single one with a DOM overlay.
+Sometimes it's useful to work with text rather than (path, key) source
+definitions.  This is the case when the migrated translation requires some
+hardcoded text, e.g. <a> and </a> when multiple translations become a single
+one with a DOM overlay. In such cases it's best to use the AST nodes:
 
     FTL.Message(
         id=FTL.Identifier('update-failed'),
         value=CONCAT(
             COPY('aboutDialog.dtd', 'update.failed.start'),
-            LITERAL('<a>'),
+            FTL.TextElement('<a>'),
             COPY('aboutDialog.dtd', 'update.failed.linkText'),
-            LITERAL('</a>'),
+            FTL.TextElement('</a>'),
             COPY('aboutDialog.dtd', 'update.failed.end'),
         )
     )
 
 The REPLACE_IN_TEXT Transform also takes text as input, making in possible to
 pass it as the foreach function of the PLURALS Transform.  In this case, each
 slice of the plural string will be run through a REPLACE_IN_TEXT operation.
-Those slices are strings, so a REPLACE(path, key, …) isn't suitable for them.
+Those slices are strings, so a REPLACE(path, key, …) wouldn't be suitable for
+them.
 
     FTL.Message(
         FTL.Identifier('delete-all'),
         value=PLURALS(
             'aboutDownloads.dtd',
             'deleteAll',
             EXTERNAL_ARGUMENT('num'),
             lambda text: REPLACE_IN_TEXT(
@@ -61,17 +61,23 @@ Those slices are strings, so a REPLACE(path, key, …) isn't suitable for them.
             )
         )
     )
 """
 
 from __future__ import unicode_literals
 
 import fluent.syntax.ast as FTL
-from .helpers import LITERAL
+from .errors import NotSupportedError
+
+
+def pattern_from_text(value):
+    return FTL.Pattern([
+        FTL.TextElement(value)
+    ])
 
 
 def evaluate(ctx, node):
     def eval_node(subnode):
         if isinstance(subnode, Transform):
             return subnode(ctx)
         else:
             return subnode
@@ -82,50 +88,48 @@ def evaluate(ctx, node):
 class Transform(FTL.BaseNode):
     def __call__(self, ctx):
         raise NotImplementedError
 
 
 class Source(Transform):
     """Declare the source translation to be migrated with other transforms.
 
-    When evaluated `Source` returns a simple string value.  All \\uXXXX from
-    the original translations are converted beforehand to the literal
-    characters they encode.
+    When evaluated, `Source` returns a simple string value. Escaped characters
+    are unescaped by the compare-locales parser according to the file format:
 
-    HTML entities are left unchanged for now because we can't know if they
-    should be converted to the characters they represent or not.  Consider the
-    following example in which `&amp;` could be replaced with the literal `&`:
+      - in properties files: \\uXXXX,
+      - in DTD files: known named, decimal, and hexadecimal HTML entities.
 
-        Privacy &amp; History
+    Consult the following files for the list of known named HTML entities:
 
-    vs. these two examples where the HTML encoding should be preserved:
-
-        Erreur&nbsp;!
-        Use /help &lt;command&gt; for more information.
+    https://github.com/python/cpython/blob/2.7/Lib/htmlentitydefs.py
+    https://github.com/python/cpython/blob/3.6/Lib/html/entities.py
 
     """
 
-    # XXX Perhaps there's a strict subset of HTML entities which must or must
-    # not be replaced?
+    def __init__(self, path, key):
+        if path.endswith('.ftl'):
+            raise NotSupportedError(
+                'Migrating translations from Fluent files is not supported '
+                '({})'.format(path))
 
-    def __init__(self, path, key):
         self.path = path
         self.key = key
 
     def __call__(self, ctx):
         return ctx.get_source(self.path, self.key)
 
 
 class COPY(Source):
     """Create a Pattern with the translation value from the given source."""
 
     def __call__(self, ctx):
         source = super(self.__class__, self).__call__(ctx)
-        return LITERAL(source)
+        return pattern_from_text(source)
 
 
 class REPLACE_IN_TEXT(Transform):
     """Replace various placeables in the translation with FTL placeables.
 
     The original placeables are defined as keys on the `replacements` dict.
     For each key the value is defined as a list of FTL Expressions to be
     interpolated.
@@ -205,29 +209,38 @@ class REPLACE(Source):
 
 
 class PLURALS(Source):
     """Create a Pattern with plurals from given source.
 
     Build an `FTL.SelectExpression` with the supplied `selector` and variants
     extracted from the source.  The source needs to be a semicolon-separated
     list of variants.  Each variant will be run through the `foreach` function,
-    which should return an `FTL.Node` or a `Transform`.
+    which should return an `FTL.Node` or a `Transform`. By default, the
+    `foreach` function transforms the source text into a Pattern with a single
+    TextElement.
     """
 
-    def __init__(self, path, key, selector, foreach=LITERAL):
+    def __init__(self, path, key, selector, foreach=pattern_from_text):
         super(self.__class__, self).__init__(path, key)
         self.selector = selector
         self.foreach = foreach
 
     def __call__(self, ctx):
         value = super(self.__class__, self).__call__(ctx)
         selector = evaluate(ctx, self.selector)
         variants = value.split(';')
         keys = ctx.plural_categories
+
+        # A special case for languages with one plural category. We don't need
+        # to insert a SelectExpression at all for them.
+        if len(keys) == len(variants) == 1:
+            variant, = variants
+            return evaluate(ctx, self.foreach(variant))
+
         last_index = min(len(variants), len(keys)) - 1
 
         def createVariant(zipped_enum):
             index, (key, variant) = zipped_enum
             # Run the legacy variant through `foreach` which returns an
             # `FTL.Node` describing the transformation required for each
             # variant.  Then evaluate it to a migrated FTL node.
             value = evaluate(ctx, self.foreach(variant))
@@ -259,17 +272,17 @@ class CONCAT(Transform):
                 acc.extend(cur.elements)
                 return acc
             elif (isinstance(cur, FTL.TextElement) or
                   isinstance(cur, FTL.Placeable)):
                 acc.append(cur)
                 return acc
 
             raise RuntimeError(
-                'CONCAT accepts FTL Patterns and Expressions.'
+                'CONCAT accepts FTL Patterns, TextElements and Placeables.'
             )
 
         # Merge adjecent `FTL.TextElement` nodes.
         def merge_adjecent_text(acc, cur):
             if type(cur) == FTL.TextElement and len(acc):
                 last = acc[-1]
                 if type(last) == FTL.TextElement:
                     last.value += cur.value
deleted file mode 100644
--- a/third_party/python/fluent/setup.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[egg_info]
-tag_build = 
-tag_date = 0
-
deleted file mode 100644
--- a/third_party/python/fluent/setup.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env python
-
-from setuptools import setup
-
-setup(name='fluent',
-      version='0.4.2',
-      description='Localization library for expressive translations.',
-      author='Mozilla',
-      author_email='l10n-drivers@mozilla.org',
-      license='APL 2',
-      url='https://github.com/projectfluent/python-fluent',
-      keywords=['fluent', 'localization', 'l10n'],
-      classifiers=[
-          'Development Status :: 3 - Alpha',
-          'Intended Audience :: Developers',
-          'License :: OSI Approved :: Apache Software License',
-          'Programming Language :: Python :: 2.7',
-          'Programming Language :: Python :: 3.5',
-      ],
-      packages=['fluent', 'fluent.syntax', 'fluent.migrate'],
-      package_data={
-          'fluent.migrate': ['cldr_data/*']
-      }
-      )
new file mode 100755
--- /dev/null
+++ b/third_party/python/fluent/tools/fluentfmt.py
@@ -0,0 +1,23 @@
+#!/usr/bin/python
+
+import sys
+
+sys.path.append('./')
+import codecs
+from fluent.syntax import parse, serialize
+
+
+def read_file(path):
+    with codecs.open(path, 'r', encoding='utf-8') as file:
+        text = file.read()
+    return text
+
+
+def pretty_print(fileType, data):
+    ast = parse(data)
+    print(serialize(ast))
+
+if __name__ == "__main__":
+    file_type = 'ftl'
+    f = read_file(sys.argv[1])
+    pretty_print(file_type, f)
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/README.md
@@ -0,0 +1,39 @@
+# Migration Tools
+
+`migrate-l10n.py` is a CLI script which uses the `fluent.migrate` module under
+the hood to run migrations on existing translations.
+
+## Examples
+
+The `examples/` directory contains a number of sample migrations. To run them
+you'll need at least one clone of a localization repository, e.g. from
+https://hg.mozilla.org/l10n-central/.
+
+Amend your `PYTHONPATH` to make sure that all `fluent.*` modules can be found:
+
+    $ export PYTHONPATH=$(pwd)/../..:$PYTHONPATH
+
+Then run migrations passing the `examples` directory as the reference:
+
+    $ ./migrate-l10n.py --lang it --reference-dir examples --localization-dir ~/moz/l10n-central/it examples.about_dialog examples.about_downloads examples.bug_1291693
+
+Here's what the output should look like:
+
+    Annotating /home/stas/moz/l10n-central/it
+    Running migration examples.bug_1291693
+      Writing to /home/stas/moz/l10n-central/it/browser/branding/official/brand.ftl
+        Committing changeset: Bug 1291693 - Migrate the menubar to FTL, part 1
+      Writing to /home/stas/moz/l10n-central/it/browser/menubar.ftl
+      Writing to /home/stas/moz/l10n-central/it/browser/toolbar.ftl
+      Writing to /home/stas/moz/l10n-central/it/browser/branding/official/brand.ftl
+        Committing changeset: Bug 1291693 - Migrate the menubar to FTL, part 2
+    Running migration examples.about_dialog
+      Writing to /home/stas/moz/l10n-central/it/browser/about_dialog.ftl
+        Committing changeset: Migrate about:dialog, part 1
+    Running migration examples.about_downloads
+      Writing to /home/stas/moz/l10n-central/it/mobile/about_downloads.ftl
+        Committing changeset: Migrate about:download in Firefox for Android, part 1
+      Writing to /home/stas/moz/l10n-central/it/mobile/about_downloads.ftl
+        Committing changeset: Migrate about:download in Firefox for Android, part 2
+      Writing to /home/stas/moz/l10n-central/it/mobile/about_downloads.ftl
+        Committing changeset: Migrate about:download in Firefox for Android, part 3
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/blame.py
@@ -0,0 +1,60 @@
+import argparse
+import json
+import hglib
+from hglib.util import b, cmdbuilder
+from compare_locales.parser import getParser, Junk
+
+
+class Blame(object):
+    def __init__(self, repopath):
+        self.client = hglib.open(repopath)
+        self.users = []
+        self.blame = {}
+
+    def main(self):
+        args = cmdbuilder(
+            b('annotate'), self.client.root(), d=True, u=True, T='json')
+        blame_json = ''.join(self.client.rawcommand(args))
+        file_blames = json.loads(blame_json)
+
+        for file_blame in file_blames:
+            self.handleFile(file_blame)
+
+        return {'authors': self.users,
+                'blame': self.blame}
+
+    def handleFile(self, file_blame):
+        abspath = file_blame['abspath']
+
+        try:
+            parser = getParser(abspath)
+        except UserWarning:
+            return
+
+        self.blame[abspath] = {}
+
+        parser.readFile(file_blame['path'])
+        entities, emap = parser.parse()
+        for e in entities:
+            if isinstance(e, Junk):
+                continue
+            entity_lines = file_blame['lines'][
+                (e.value_position()[0] - 1):e.value_position(-1)[0]
+            ]
+            # ignore timezone
+            entity_lines.sort(key=lambda blame: -blame['date'][0])
+            line_blame = entity_lines[0]
+            user = line_blame['user']
+            timestamp = line_blame['date'][0]  # ignore timezone
+            if user not in self.users:
+                self.users.append(user)
+            userid = self.users.index(user)
+            self.blame[abspath][e.key] = [userid, timestamp]
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument("repopath")
+    args = parser.parse_args()
+    blame = Blame(args.repopath)
+    blimey = blame.main()
+    print(json.dumps(blimey, indent=4, separators=(',', ': ')))
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/about_dialog.ftl
@@ -0,0 +1,12 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+update-failed =
+    Update failed. <label>Download the latest version</label>
+channel-description =
+    You are currently on the <label>{ $channelName }</label> update channel.
+community =
+    { brand-short-name } is designed by <label>{ vendor-short-name }</label>,
+    a <label>global community</label> working together to keep the Web
+    open, public and accessible to all.
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/about_dialog.py
@@ -0,0 +1,85 @@
+# coding=utf8
+
+import fluent.syntax.ast as FTL
+from fluent.migrate import (
+    CONCAT, EXTERNAL_ARGUMENT, MESSAGE_REFERENCE, COPY, REPLACE
+)
+
+
+def migrate(ctx):
+    """Migrate about:dialog, part {index}"""
+
+    ctx.add_transforms('browser/about_dialog.ftl', 'about_dialog.ftl', [
+        FTL.Message(
+            id=FTL.Identifier('update-failed'),
+            value=CONCAT(
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'update.failed.start'
+                ),
+                FTL.TextElement('<a>'),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'update.failed.linkText'
+                ),
+                FTL.TextElement('</a>'),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'update.failed.end'
+                )
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('channel-description'),
+            value=CONCAT(
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'channel.description.start'
+                ),
+                FTL.Placeable(EXTERNAL_ARGUMENT('channelname')),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'channel.description.end'
+                )
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('community'),
+            value=CONCAT(
+                REPLACE(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'community.start2',
+                    {
+                        '&brandShortName;': MESSAGE_REFERENCE(
+                            'brand-short-name'
+                        )
+                    }
+                ),
+                FTL.TextElement('<a>'),
+                REPLACE(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'community.mozillaLink',
+                    {
+                        '&vendorShortName;': MESSAGE_REFERENCE(
+                            'vendor-short-name'
+                        )
+                    }
+                ),
+                FTL.TextElement('</a>'),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'community.middle2'
+                ),
+                FTL.TextElement('<a>'),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'community.creditsLink'
+                ),
+                FTL.TextElement('</a>'),
+                COPY(
+                    'browser/chrome/browser/aboutDialog.dtd',
+                    'community.end3'
+                )
+            )
+        ),
+    ])
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/about_downloads.ftl
@@ -0,0 +1,38 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+title  = Downloads
+header = Your Downloads
+empty  = No Downloads
+
+open-menuitem
+    .label = Open
+retry-menuitem
+    .label = Retry
+remove-menuitem
+    .label = Delete
+pause-menuitem
+    .label = Pause
+resume-menuitem
+    .label = Resume
+cancel-menuitem
+    .label = Cancel
+remove-all-menuitem
+    .label = Delete All
+
+delete-all-title = Delete All
+delete-all-message =
+    { $num ->
+        [1] Delete this download?
+       *[other] Delete { $num } downloads?
+    }
+
+download-state-downloading = Downloading…
+download-state-canceled = Canceled
+download-state-failed = Failed
+download-state-paused = Paused
+download-state-starting = Starting…
+download-state-unknown = Unknown
+
+download-size-unknown = Unknown size
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/about_downloads.py
@@ -0,0 +1,179 @@
+# coding=utf8
+
+import fluent.syntax.ast as FTL
+from fluent.migrate import EXTERNAL_ARGUMENT, COPY, PLURALS, REPLACE_IN_TEXT
+
+
+def migrate(ctx):
+    """Migrate about:download in Firefox for Android, part {index}"""
+
+    ctx.add_transforms('mobile/about_downloads.ftl', 'about_downloads.ftl', [
+        FTL.Message(
+            id=FTL.Identifier('title'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.dtd',
+                'aboutDownloads.title'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('header'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.dtd',
+                'aboutDownloads.header'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('empty'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.dtd',
+                'aboutDownloads.empty'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('open-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.open'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('retry-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.retry'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('remove-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.remove'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('pause-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.pause'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('resume-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.resume'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('cancel-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.cancel'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('remove-all-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'mobile/android/chrome/aboutDownloads.dtd',
+                        'aboutDownloads.removeAll'
+                    )
+                )
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('delete-all-title'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadAction.deleteAll'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('delete-all-message'),
+            value=PLURALS(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadMessage.deleteAll',
+                EXTERNAL_ARGUMENT('num'),
+                lambda text: REPLACE_IN_TEXT(
+                    text,
+                    {
+                        '#1': EXTERNAL_ARGUMENT('num')
+                    }
+                )
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-state-downloading'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.downloading'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-state-canceled'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.canceled'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-state-failed'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.failed'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-state-paused'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.paused'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-state-starting'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.starting'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('download-size-unknown'),
+            value=COPY(
+                'mobile/android/chrome/aboutDownloads.properties',
+                'downloadState.unknownSize'
+            )
+        ),
+    ])
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/brand.ftl
@@ -0,0 +1,13 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+brand-shorter-name = Firefox
+brand-short-name = Firefox
+brand-full-name = Mozilla Firefox
+vendor-short-name = Mozilla
+
+trademark-info =
+    Firefox and the Firefox logos are trademarks of the Mozilla Foundation.
+
+sync-brand-short-name = Sync
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/bug_1291693.py
@@ -0,0 +1,1917 @@
+# coding=utf8
+
+import fluent.syntax.ast as FTL
+from fluent.migrate import MESSAGE_REFERENCE, COPY, REPLACE
+
+
+def migrate(ctx):
+    """Bug 1291693 - Migrate the menubar to FTL, part {index}"""
+
+    ctx.add_transforms('browser/menubar.ftl', 'menubar.ftl', [
+        FTL.Message(
+            id=FTL.Identifier('file-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fileMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fileMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('tab-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'tabCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'tabCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('tab-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'tabCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('new-user-context-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newUserContext.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newUserContext.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('new-navigator-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newNavigatorCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newNavigatorCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('new-navigator-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newNavigatorCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('new-private-window-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newPrivateWindow.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newPrivateWindow.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('new-non-remote-window-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'newNonRemoteWindow.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('open-location-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'openLocationCmd.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('open-file-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'openFileCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'openFileCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('open-file-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'openFileCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('close-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'closeCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'closeCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('close-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'closeCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('close-window-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'closeWindow.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'closeWindow.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('save-page-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'savePageCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'savePageCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('save-page-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'savePageCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('email-page-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'emailPageCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'emailPageCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('print-setup-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printSetupCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printSetupCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('print-preview-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printPreviewCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printPreviewCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('print-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('print-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'printCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('go-offline-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'goOfflineCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'goOfflineCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('quit-application-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('quit-application-menuitem-win'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmdWin2.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmdWin2.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('quit-application-menuitem-mac'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmdMac2.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('quit-application-command-unix'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'quitApplicationCmdUnix.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('edit-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'editMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'editMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('undo-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'undoCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'undoCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('undo-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'undoCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('redo-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'redoCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'redoCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('redo-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'redoCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('cut-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'cutCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'cutCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('cut-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'cutCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('copy-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'copyCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'copyCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('copy-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'copyCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('paste-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pasteCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pasteCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('paste-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pasteCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('delete-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'deleteCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'deleteCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('select-all-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'selectAllCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'selectAllCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('select-all-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'selectAllCmd.key',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-on-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findOnCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findOnCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-on-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findOnCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-again-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findAgainCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findAgainCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-again-command1'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findAgainCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-again-command2'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findAgainCmd.commandkey2',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('find-selection-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'findSelectionCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bidi-switch-text-direction-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bidiSwitchTextDirectionItem.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bidiSwitchTextDirectionItem.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bidi-switch-text-direction-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bidiSwitchTextDirectionItem.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('preferences-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'preferencesCmd2.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'preferencesCmd2.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('preferences-menuitem-unix'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'preferencesCmdUnix.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'preferencesCmdUnix.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-toolbar-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewToolbarsMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewToolbarsMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-sidebar-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewSidebarMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewSidebarMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-customize-toolbar-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewCustomizeToolbar.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'viewCustomizeToolbar.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoom.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoom.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-enlarge-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomEnlargeCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomEnlargeCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-enlarge-command1'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomEnlargeCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-enlarge-command2'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomEnlargeCmd.commandkey2',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-enlarge-command3'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomEnlargeCmd.commandkey3',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reduce-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomReduceCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomReduceCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reduce-command1'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomReduceCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reduce-command2'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomReduceCmd.commandkey2',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reset-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomResetCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomResetCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reset-command1'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomResetCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-reset-command2'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomResetCmd.commandkey2',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-zoom-toggle-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomToggleCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullZoomToggleCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-style-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStyleMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStyleMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-style-no-style-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStyleNoStyle.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStyleNoStyle.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-style-persistent-only-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStylePersistentOnly.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageStylePersistentOnly.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-tabs-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'showAllTabsCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'showAllTabsCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bidi-switch-page-direction-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bidiSwitchPageDirectionItem.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bidiSwitchPageDirectionItem.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('enter-full-screen-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'enterFullScreenCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'enterFullScreenCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('exit-full-screen-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'exitFullScreenCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'exitFullScreenCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-screen-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullScreenCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullScreenCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('full-screen-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'fullScreenCmd.macCommandKey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('history-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-history-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'showAllHistoryCmd2.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-history-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'showAllHistoryCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('clear-recent-history-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'clearRecentHistory.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('history-synced-tabs-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncTabsMenu3.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('history-restore-last-session-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyRestoreLastSession.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('history-undo-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyUndoMenu.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('history-undo-window-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyUndoWindowMenu.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bookmarks-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-bookmarks-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'showAllBookmarks2.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-bookmarks-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('show-all-bookmarks-command-gtk'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksGtkCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bookmark-this-page-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarkThisPageCmd.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('edit-this-page-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'editThisBookmarkCmd.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('bookmark-this-page-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarkThisPageCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('subscribe-to-page-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'subscribeToPageMenupopup.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('subscribe-to-page-menupopup'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'subscribeToPageMenupopup.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('add-cur-pages-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'addCurPagesCmd.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('recent-bookmarks-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'recentBookmarks.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('other-bookmarks-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'otherBookmarksCmd.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('personalbar-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'personalbarCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'personalbarCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('tools-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'toolsMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'toolsMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('downloads-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'downloads.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'downloads.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('downloads-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'downloads.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('downloads-command-unix'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'downloadsUnix.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('addons-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'addons.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'addons.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('addons-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'addons.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('sync-sign-in-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    REPLACE(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncSignIn.label',
+                        {
+                            '&syncBrand.shortName.label;': MESSAGE_REFERENCE(
+                                'sync-brand-short-name'
+                            )
+                        }
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncSignIn.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('sync-sync-now-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncSyncNowItem.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncSyncNowItem.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('sync-re-auth-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    REPLACE(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncReAuthItem.label',
+                        {
+                            '&syncBrand.shortName.label;': MESSAGE_REFERENCE(
+                                'sync-brand-short-name'
+                            )
+                        }
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncReAuthItem.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('sync-toolbar-button'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncToolbarButton.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('web-developer-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'webDeveloperMenu.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'webDeveloperMenu.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-source-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageSourceCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageSourceCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-source-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageSourceCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-info-menuitem'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageInfoCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageInfoCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('page-info-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'pageInfoCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('mirror-tab-menu'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'mirrorTabCmd.label',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'mirrorTabCmd.accesskey',
+                    )
+                ),
+            ]
+        ),
+    ])
+
+    ctx.add_transforms('browser/toolbar.ftl', 'toolbar.ftl', [
+        FTL.Message(
+            id=FTL.Identifier('urlbar-textbox'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('placeholder'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'urlbar.placeholder2',
+                    )
+                ),
+                FTL.Attribute(
+                    FTL.Identifier('accesskey'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'urlbar.accesskey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-bookmarks-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksButton.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-bookmarks-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-bookmarks-command-win'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'bookmarksWinCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-history-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historyButton.label',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-history-command'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('key'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'historySidebarCmd.commandkey',
+                    )
+                ),
+            ]
+        ),
+        FTL.Message(
+            id=FTL.Identifier('view-tabs-broadcaster'),
+            attributes=[
+                FTL.Attribute(
+                    FTL.Identifier('label'),
+                    COPY(
+                        'browser/chrome/browser/browser.dtd',
+                        'syncedTabs.sidebar.label',
+                    )
+                ),
+            ]
+        ),
+    ])
+
+    ctx.add_transforms('browser/branding/official/brand.ftl', 'brand.ftl', [
+        FTL.Message(
+            id=FTL.Identifier('brand-shorter-name'),
+            value=COPY(
+                'browser/branding/official/brand.dtd',
+                'brandShorterName'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('brand-short-name'),
+            value=COPY(
+                'browser/branding/official/brand.dtd',
+                'brandShortName'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('brand-full-name'),
+            value=COPY(
+                'browser/branding/official/brand.dtd',
+                'brandFullName'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('vendor-short-name'),
+            value=COPY(
+                'browser/branding/official/brand.dtd',
+                'vendorShortName'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('trademark-info'),
+            value=COPY(
+                'browser/branding/official/brand.dtd',
+                'trademarkInfo.part1'
+            )
+        ),
+        FTL.Message(
+            id=FTL.Identifier('sync-brand-short-name'),
+            value=COPY(
+                'browser/branding/official/brand.properties',
+                'syncBrandShortName'
+            )
+        ),
+    ])
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/menubar.ftl
@@ -0,0 +1,336 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[[ File menu ]]
+
+file-menu
+    .label = File
+    .accesskey = F
+tab-menuitem
+    .label = New Tab
+    .accesskey = T
+tab-command
+    .key = t
+new-user-context-menu
+    .label = New Container Tab
+    .accesskey = C
+new-navigator-menuitem
+    .label = New Window
+    .accesskey = N
+new-navigator-command
+    .key = N
+new-private-window-menuitem
+    .label = New Private Window
+    .accesskey = W
+new-non-remote-window-menuitem
+    .label = New Non-e10s Window
+
+// Only displayed on OS X, and only on windows that aren't main browser windows,
+// or when there are no windows but Firefox is still running.
+open-location-menuitem
+    .label = Open Location…
+open-file-menuitem
+    .label = Open File…
+    .accesskey = O
+open-file-command
+    .key = o
+
+close-menuitem
+    .label = Close
+    .accesskey = C
+close-command
+    .key = W
+close-window-menuitem
+    .label = Close Window
+    .accesskey = d
+
+// .accesskey2 is for content area context menu
+save-page-menuitem
+    .label = Save Page As…
+    .accesskey = A
+    .accesskey2 = P
+save-page-command
+    .key = s
+
+email-page-menuitem
+    .label = Email Link…
+    .accesskey = E
+
+print-setup-menuitem
+    .label = Page Setup…
+    .accesskey = u
+print-preview-menuitem
+    .label = Print Preview…
+    .accesskey = v
+print-menuitem
+    .label = Print…
+    .accesskey = P
+print-command
+    .key = p
+
+go-offline-menuitem
+    .label = Work Offline
+    .accesskey = k
+
+quit-application-menuitem
+    .label = Quit
+    .accesskey = Q
+quit-application-menuitem-win
+    .label = Exit
+    .accesskey = x
+quit-application-menuitem-mac
+    .label = Quit { brand-shorter-name }
+// Used by both Linux and OSX builds
+quit-application-command-unix
+    .key = Q
+
+[[ Edit menu ]]
+
+edit-menu
+    .label = Edit
+    .accesskey = E
+undo-menuitem
+    .label = Undo
+    .accesskey = U
+undo-command
+    .key = Z
+redo-menuitem
+    .label = Redo
+    .accesskey = R
+redo-command
+    .key = Y
+cut-menuitem
+    .label = Cut
+    .accesskey = t
+cut-command
+    .key = X
+copy-menuitem
+    .label = Copy
+    .accesskey = C
+copy-command
+    .key = C
+paste-menuitem
+    .label = Paste
+    .accesskey = P
+paste-command
+    .key = V
+delete-menuitem
+    .label = Delete
+    .accesskey = D
+select-all-menuitem
+    .label = Select All
+    .accesskey = A
+select-all-command
+    .key = A
+
+find-on-menuitem
+    .label = Find in This Page…
+    .accesskey = F
+find-on-command
+    .key = f
+find-again-menuitem
+    .label = Find Again
+    .accesskey = g
+find-again-command1
+    .key = g
+find-again-command2
+    .keycode = VK_F3
+find-selection-command
+    .key = e
+
+bidi-switch-text-direction-menuitem
+    .label = Switch Text Direction
+    .accesskey = w
+bidi-switch-text-direction-command
+    .key = X
+
+preferences-menuitem
+    .label = Options
+    .accesskey = O
+preferences-menuitem-unix
+    .label = Preferences
+    .accesskey = n
+
+
+[[ View menu ]]
+
+view-menu
+    .label = View
+    .accesskey = V
+view-toolbars-menu
+    .label = Toolbars
+    .accesskey = T
+view-sidebar-menu
+    .label = Sidebar
+    .accesskey = e
+view-customize-toolbar-menuitem
+    .label = Customize…
+    .accesskey = C
+
+full-zoom-menu
+    .label = Zoom
+    .accesskey = Z
+full-zoom-enlarge-menuitem
+    .label = Zoom In
+    .accesskey = I
+full-zoom-enlarge-command1
+    .key = +
+full-zoom-enlarge-command2
+    .key =
+full-zoom-enlarge-command3
+    .key = ""
+full-zoom-reduce-menuitem
+    .label = Zoom Out
+    .accesskey = O
+full-zoom-reduce-command1
+    .key = -
+full-zoom-reduce-command2
+    .key = ""
+full-zoom-reset-menuitem
+    .label = Reset
+    .accesskey = R
+full-zoom-reset-command1
+    .key = 0
+full-zoom-reset-command2
+    .key = ""
+full-zoom-toggle-menuitem
+    .label = Zoom Text Only
+    .accesskey = T
+
+page-style-menu
+    .label = Page Style
+    .accesskey = y
+page-style-no-style-menuitem
+    .label = No Style
+    .accesskey = n
+page-style-persistent-only-menuitem
+    .label = Basic Page Style
+    .accesskey = b
+
+show-all-tabs-menuitem
+    .label = Show All Tabs
+    .accesskey = A
+bidi-switch-page-direction-menuitem
+    .label = Switch Page Direction
+    .accesskey = D
+
+// Match what Safari and other Apple applications use on OS X Lion.
+[[ Full Screen controls ]]
+
+enter-full-screen-menuitem
+    .label = Enter Full Screen
+    .accesskey = F
+exit-full-screen-menuitem
+    .label = Exit Full Screen
+    .accesskey = F
+full-screen-menuitem
+    .label = Full Screen
+    .accesskey = F
+full-screen-command
+    .key = f
+
+
+[[ History menu ]]
+
+history-menu
+    .label = History
+    .accesskey = s
+show-all-history-menuitem
+    .label = Show All History
+show-all-history-command
+    .key = H
+clear-recent-history-menuitem
+    .label = Clean Recent History…
+history-synced-tabs-menuitem
+    .label = Synced Tabs
+history-restore-last-session-menuitem
+    .label = Restore Previous Session
+history-undo-menu
+    .label = Recently Closed Tabs
+history-undo-window-menu
+    .label = Recently Closed Windows
+
+
+[[ Bookmarks menu ]]
+
+bookmarks-menu
+    .label = Bookmarks
+    .accesskey = B
+show-all-bookmarks-menuitem
+    .label = Show All Bookmarks
+show-all-bookmarks-command
+    .key = b
+// .key should not contain the letters A-F since the are reserved shortcut
+// keys on Linux.
+show-all-bookmarks-command-gtk
+    .key = o
+bookmark-this-page-broadcaster
+    .label = Bookmark This Page
+edit-this-page-broadcaster
+    .label = Edit This Page
+bookmark-this-page-command
+    .key = d
+subscribe-to-page-menuitem
+    .label = Subscribe to This Page…
+subscribe-to-page-menupopup
+    .label = Subscribe to This Page…
+add-cur-pages-menuitem
+    .label = Bookmark All Tabs…
+recent-bookmarks-menuitem
+    .label = Recently Bookmarked
+
+other-bookmarks-menu
+    .label = Other Bookmarks
+personalbar-menu
+    .label = Bookmarks Toolbar
+    .accesskey = B
+
+
+[[ Tools menu ]]
+
+tools-menu
+    .label = Tools
+    .accesskey = T
+downloads-menuitem
+    .label = Downloads
+    .accesskey = D
+downloads-command
+    .key = j
+downloads-command-unix
+    .key = y
+addons-menuitem
+    .label = Add-ons
+    .accesskey = A
+addons-command
+    .key = A
+
+sync-sign-in-menuitem
+    .label = Sign In To { sync-brand-short-name }…
+    .accesskey = Y
+sync-sync-now-menuitem
+    .label = Sync Now
+    .accesskey = S
+sync-re-auth-menuitem
+    .label = Reconnect to { sync-brand-short-name }…
+    .accesskey = R
+sync-toolbar-button
+    .label = Sync
+
+web-developer-menu
+    .label = Web Developer
+    .accesskey = W
+
+page-source-broadcaster
+    .label = Page Source
+    .accesskey = o
+page-source-command
+    .key = u
+page-info-menuitem
+    .label = Page Info
+    .accesskey = I
+page-info-command
+    .key = i
+mirror-tab-menu
+    .label = Mirror Tab
+    .accesskey = m
new file mode 100644
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/examples/toolbar.ftl
@@ -0,0 +1,24 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+urlbar-textbox
+    .placeholder = Search or enter address
+    .accesskey = d
+
+
+[[ Toolbar items ]]
+
+view-bookmarks-broadcaster
+    .label = Bookmarks
+view-bookmarks-command
+    .key = b
+view-bookmarks-command-win
+    .key = i
+
+view-history-broadcaster
+    .label = History
+view-history-command
+    .key = h
+view-tabs-broadcaster
+    .label = Synced Tabs
new file mode 100755
--- /dev/null
+++ b/third_party/python/fluent/tools/migrate/migrate-l10n.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python
+# coding=utf8
+
+import os
+import sys
+import json
+import logging
+import argparse
+import importlib
+
+import hglib
+from hglib.util import b
+
+from fluent.migrate import (
+    MergeContext, MigrationError, convert_blame_to_changesets
+)
+from blame import Blame
+
+
+def main(lang, reference_dir, localization_dir, blame, migrations, dry_run):
+    """Run migrations and commit files with the result."""
+    changesets = convert_blame_to_changesets(blame)
+    client = hglib.open(localization_dir)
+
+    for migration in migrations:
+
+        print('Running migration {}'.format(migration.__name__))
+
+        # For each migration create a new context.
+        ctx = MergeContext(lang, reference_dir, localization_dir)
+
+        try:
+            # Add the migration spec.
+            migration.migrate(ctx)
+        except MigrationError as err:
+            sys.exit(err.message)
+
+        # Keep track of how many changesets we're committing.
+        index = 0
+
+        for changeset in changesets:
+            # Run the migration for the changeset.
+            snapshot = ctx.serialize_changeset(changeset['changes'])
+
+            # Did it change any files?
+            if not snapshot:
+                continue
+
+            # Write serialized FTL files to disk.
+            for path, content in snapshot.iteritems():
+                fullpath = os.path.join(localization_dir, path)
+                print('  Writing to {}'.format(fullpath))
+                if not dry_run:
+                    fulldir = os.path.dirname(fullpath)
+                    if not os.path.isdir(fulldir):
+                        os.makedirs(fulldir)
+                    with open(fullpath, 'w') as f:
+                        f.write(content.encode('utf8'))
+                        f.close()
+
+            index += 1
+            author = changeset['author'].encode('utf8')
+            message = migration.migrate.__doc__.format(
+                index=index,
+                author=author
+            )
+
+            print('    Committing changeset: {}'.format(message))
+            if not dry_run:
+                client.commit(
+                    b(message), user=b(author), addremove=True
+                )
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(
+        description='Migrate translations to FTL.'
+    )
+    parser.add_argument(
+        'migrations', metavar='MIGRATION', type=str, nargs='+',
+        help='migrations to run (Python modules)'
+    )
+    parser.add_argument(
+        '--lang', type=str,
+        help='target language code'
+    )
+    parser.add_argument(
+        '--reference-dir', type=str,
+        help='directory with reference FTL files'
+    )
+    parser.add_argument(
+        '--localization-dir', type=str,
+        help='directory for localization files'
+    )
+    parser.add_argument(
+        '--blame', type=argparse.FileType(), default=None,
+        help='path to a JSON with blame information'
+    )
+    parser.add_argument(
+        '--dry-run', action='store_true',
+        help='do not write to disk nor commit any changes'
+    )
+    parser.set_defaults(dry_run=False)
+
+    logger = logging.getLogger('migrate')
+    logger.setLevel(logging.INFO)
+
+    args = parser.parse_args()
+
+    if args.blame:
+        # Load pre-computed blame from a JSON file.
+        blame = json.load(args.blame)
+    else:
+        # Compute blame right now.
+        print('Annotating {}'.format(args.localization_dir))
+        blame = Blame(args.localization_dir).main()
+
+    main(
+        lang=args.lang,
+        reference_dir=args.reference_dir,
+        localization_dir=args.localization_dir,
+        blame=blame,
+        migrations=map(importlib.import_module, args.migrations),
+        dry_run=args.dry_run
+    )
new file mode 100755
--- /dev/null
+++ b/third_party/python/fluent/tools/parse.py
@@ -0,0 +1,25 @@
+#!/usr/bin/python
+
+import sys
+
+sys.path.append('./')
+import codecs
+from fluent.syntax import parse
+import json
+
+
+def read_file(path):
+    with codecs.open(path, 'r', encoding='utf-8') as file:
+        text = file.read()
+    return text
+
+
+def print_ast(fileType, data):
+    ast = parse(data)
+    print(json.dumps(ast.to_json(), indent=2, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+    file_type = 'ftl'
+    f = read_file(sys.argv[1])
+    print_ast(file_type, f)
new file mode 100755
--- /dev/null
+++ b/third_party/python/fluent/tools/serialize.py
@@ -0,0 +1,23 @@
+#!/usr/bin/python
+
+import sys
+import json
+
+sys.path.append('./')
+import codecs
+from fluent.syntax import ast, serialize
+
+
+def read_json(path):
+    with codecs.open(path, 'r', encoding='utf-8') as file:
+        return json.load(file)
+
+
+def pretty_print(fileType, data):
+    resource = ast.from_json(data)
+    print(serialize(resource))
+
+if __name__ == "__main__":
+    file_type = 'ftl'
+    f = read_json(sys.argv[1])
+    pretty_print(file_type, f)