Pārlūkot izejas kodu

API Diff Report tools (fix python format) (#11235)

Mou Sun 2 gadi atpakaļ
vecāks
revīzija
8efafcb340

+ 248 - 0
scripts/api_diff_report/api_diff_report.py

@@ -0,0 +1,248 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import argparse
+import logging
+import os
+import api_info
+import datetime
+import pytz
+
+STATUS_ADD = 'ADDED'
+STATUS_REMOVED = 'REMOVED'
+STATUS_MODIFIED = 'MODIFIED'
+STATUS_ERROR = 'BUILD ERROR'
+
+
+def main():
+  logging.getLogger().setLevel(logging.INFO)
+
+  args = parse_cmdline_args()
+
+  pr_branch = os.path.expanduser(args.pr_branch)
+  base_branch = os.path.expanduser(args.base_branch)
+  new_api_json = json.load(
+      open(os.path.join(pr_branch, api_info.API_INFO_FILE_NAME)))
+  old_api_json = json.load(
+      open(os.path.join(base_branch, api_info.API_INFO_FILE_NAME)))
+
+  diff = generate_diff_json(new_api_json, old_api_json)
+  if diff:
+    logging.info(f'json diff: \n{json.dumps(diff, indent=2)}')
+    logging.info(f'plain text diff report: \n{generate_text_report(diff)}')
+    logging.info(f'markdown diff report: \n{generate_markdown_report(diff)}')
+  else:
+    logging.info('No API Diff Detected.')
+
+
+def generate_diff_json(new_api, old_api, level='module'):
+  """diff_json only contains module & api that has a change.
+
+    format:
+    {
+      $(module_name_1): {
+        "api_types": {
+          $(api_type_1): {
+            "apis": {
+              $(api_1): {
+                "declaration": [
+                  $(api_1_declaration)
+                ],
+                "sub_apis": {
+                  $(sub_api_1): {
+                    "declaration": [
+                      $(sub_api_1_declaration)
+                    ]
+                  },
+                },
+                "status": $(diff_status)
+              }
+            }
+          }
+        }
+      }
+    }
+    """
+  NEXT_LEVEL = {'module': 'api_types', 'api_types': 'apis', 'apis': 'sub_apis'}
+  next_level = NEXT_LEVEL.get(level)
+
+  diff = {}
+  for key in set(new_api.keys()).union(old_api.keys()):
+    # Added API
+    if key not in old_api:
+      diff[key] = new_api[key]
+      diff[key]['status'] = STATUS_ADD
+      if diff[key].get('declaration'):
+        diff[key]['declaration'] = [STATUS_ADD] + diff[key]['declaration']
+    # Removed API
+    elif key not in new_api:
+      diff[key] = old_api[key]
+      diff[key]['status'] = STATUS_REMOVED
+      if diff[key].get('declaration'):
+        diff[key]['declaration'] = [STATUS_REMOVED] + diff[key]['declaration']
+    # Module Build Error. If a "module" exist but have no
+    # content (e.g. doc_path), it must have a build error.
+    elif level == 'module' and (not new_api[key]['path']
+                                or not old_api[key]['path']):
+      diff[key] = {'status': STATUS_ERROR}
+    # Check diff in child level and diff in declaration
+    else:
+      child_diff = generate_diff_json(new_api[key][next_level],
+                                      old_api[key][next_level],
+                                      level=next_level) if next_level else {}
+      declaration_diff = new_api[key].get('declaration') != old_api[key].get(
+          'declaration') if level in ['apis', 'sub_apis'] else False
+
+      # No diff
+      if not child_diff and not declaration_diff:
+        continue
+
+      diff[key] = new_api[key]
+      # Changes at child level
+      if child_diff:
+        diff[key][next_level] = child_diff
+
+      # Modified API (changes in API declaration)
+      if declaration_diff:
+        diff[key]['status'] = STATUS_MODIFIED
+        diff[key]['declaration'] = [STATUS_ADD] + \
+            new_api[key]['declaration'] + \
+            [STATUS_REMOVED] + \
+            old_api[key]['declaration']
+
+  return diff
+
+
+def generate_text_report(diff, level=0, print_key=True):
+  report = ''
+  indent_str = '  ' * level
+  for key, value in diff.items():
+    # filter out  ["path", "api_type_link", "api_link", "declaration", "status"]
+    if isinstance(value, dict):
+      if key in ['api_types', 'apis', 'sub_apis']:
+        report += generate_text_report(value, level=level)
+      else:
+        status_text = f"{value.get('status', '')}:" if 'status' in value else ''
+        if status_text:
+          if print_key:
+            report += f'{indent_str}{status_text} {key}\n'
+          else:
+            report += f'{indent_str}{status_text}\n'
+        if value.get('declaration'):
+          for d in value.get('declaration'):
+            report += f'{indent_str}{d}\n'
+        else:
+          report += f'{indent_str}{key}\n'
+        report += generate_text_report(value, level=level + 1)
+
+  return report
+
+
+def generate_markdown_title(commit, run_id):
+  pst_now = datetime.datetime.utcnow().astimezone(
+      pytz.timezone('America/Los_Angeles'))
+  return (
+      '## Apple API Diff Report\n' + 'Commit: %s\n' % commit
+      + 'Last updated: %s \n' % pst_now.strftime('%a %b %e %H:%M %Z %G')
+      + '**[View workflow logs & download artifacts]'
+      + '(https://github.com/firebase/firebase-ios-sdk/actions/runs/%s)**\n\n'
+      % run_id + '-----\n')
+
+
+def generate_markdown_report(diff, level=0):
+  report = ''
+  header_str = '#' * (level + 3)
+  for key, value in diff.items():
+    if isinstance(value, dict):
+      if key in ['api_types', 'apis', 'sub_apis']:
+        report += generate_markdown_report(value, level=level)
+      else:
+        current_status = value.get('status')
+        if current_status:
+          # Module level: Always print out module name as title
+          if level == 0:
+            report += f'{header_str} {key} [{current_status}]\n'
+          if current_status != STATUS_ERROR:  # ADDED,REMOVED,MODIFIED
+            report += '<details>\n<summary>\n'
+            report += f'[{current_status}] {key}\n'
+            report += '</summary>\n\n'
+            declarations = value.get('declaration', [])
+            sub_report = generate_text_report(value, level=1, print_key=False)
+            detail = process_declarations(current_status, declarations,
+                                          sub_report)
+            report += f'```diff\n{detail}\n```\n\n</details>\n\n'
+        else:  # no diff at current level
+          report += f'{header_str} {key}\n'
+          report += generate_markdown_report(value, level=level + 1)
+        # Module level: Always print out divider in the end
+        if level == 0:
+          report += '-----\n'
+
+  return report
+
+
+def process_declarations(current_status, declarations, sub_report):
+  """Diff syntax highlighting in Github Markdown."""
+  detail = ''
+  if current_status == STATUS_MODIFIED:
+    for line in (declarations + sub_report.split('\n')):
+      if STATUS_ADD in line:
+        prefix = '+ '
+        continue
+      elif STATUS_REMOVED in line:
+        prefix = '- '
+        continue
+      if line:
+        detail += f'{prefix}{line}\n'
+  else:
+    prefix = '+ ' if current_status == STATUS_ADD else '- '
+    for line in (declarations + sub_report.split('\n')):
+      if line:
+        detail += f'{prefix}{line}\n'
+
+  return categorize_declarations(detail)
+
+
+def categorize_declarations(detail):
+  """Categorize API info by Swift and Objective-C."""
+  lines = detail.split('\n')
+
+  swift_lines = [line.replace('Swift', '') for line in lines if 'Swift' in line]
+  objc_lines = [
+      line.replace('Objective-C', '') for line in lines if 'Objective-C' in line
+  ]
+
+  swift_detail = 'Swift:\n' + '\n'.join(swift_lines) if swift_lines else ''
+  objc_detail = 'Objective-C:\n' + '\n'.join(objc_lines) if objc_lines else ''
+
+  if not swift_detail and not objc_detail:
+    return detail
+  else:
+    return f'{swift_detail}\n{objc_detail}'.strip()
+
+
+def parse_cmdline_args():
+  parser = argparse.ArgumentParser()
+  parser.add_argument('-p', '--pr_branch')
+  parser.add_argument('-b', '--base_branch')
+  parser.add_argument('-c', '--commit')
+  parser.add_argument('-i', '--run_id')
+
+  args = parser.parse_args()
+  return args
+
+
+if __name__ == '__main__':
+  main()

+ 227 - 0
scripts/api_diff_report/api_info.py

@@ -0,0 +1,227 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import argparse
+import logging
+import os
+import subprocess
+import icore_module
+from urllib.parse import unquote
+from bs4 import BeautifulSoup
+
+API_INFO_FILE_NAME = 'api_info.json'
+
+
+def main():
+  logging.getLogger().setLevel(logging.INFO)
+
+  # Parse command-line arguments
+  args = parse_cmdline_args()
+  output_dir = os.path.expanduser(args.output_dir)
+  api_theme_dir = os.path.expanduser(args.api_theme_dir)
+  if not os.path.exists(output_dir):
+    os.makedirs(output_dir)
+
+  # Detect changed modules based on changed files
+  changed_api_files = get_api_files(args.file_list)
+  if not changed_api_files:
+    logging.info('No Changed API File Detected')
+    exit(1)
+  changed_modules = icore_module.detect_changed_modules(changed_api_files)
+  if not changed_modules:
+    logging.info('No Changed Module Detected')
+    exit(1)
+
+  # Generate API documentation and parse API declarations
+  # for each changed module
+  api_container = {}
+  for _, module in changed_modules.items():
+    api_doc_dir = os.path.join(output_dir, 'doc', module['name'])
+    build_api_doc(module, api_doc_dir, api_theme_dir)
+
+    if os.path.exists(api_doc_dir):
+      module_api_container = parse_module(api_doc_dir)
+      api_container[module['name']] = {
+          'path': api_doc_dir,
+          'api_types': module_api_container
+      }
+    else:  # api doc fail to build.
+      api_container[module['name']] = {'path': '', 'api_types': {}}
+
+  api_info_path = os.path.join(output_dir, API_INFO_FILE_NAME)
+  logging.info(f'Writing API data to {api_info_path}')
+  with open(api_info_path, 'w') as f:
+    f.write(json.dumps(api_container, indent=2))
+
+
+def get_api_files(file_list):
+  """Filter out non api files."""
+  return [
+      f for f in file_list
+      if f.endswith('.swift') or (f.endswith('.h') and 'Public' in f)
+  ]
+
+
+def build_api_doc(module, output_dir, api_theme_dir):
+  """Use Jazzy to build API documentation for a specific module's source
+    code."""
+  if module['language'] == icore_module.SWIFT:
+    logging.info('------------')
+    cmd = f'jazzy --module {module["name"]}'\
+        + ' --swift-build-tool xcodebuild'\
+        + ' --build-tool-arguments'\
+        + f' -scheme,{module["scheme"]}'\
+        + ',-destination,generic/platform=iOS,build'\
+        + f' --output {output_dir}'\
+        + f' --theme {api_theme_dir}'
+    logging.info(cmd)
+    result = subprocess.Popen(cmd,
+                              universal_newlines=True,
+                              shell=True,
+                              stdout=subprocess.PIPE)
+    logging.info(result.stdout.read())
+  elif module['language'] == icore_module.OBJECTIVE_C:
+    logging.info('------------')
+    cmd = 'jazzy --objc'\
+        + f' --framework-root {module["root_dir"]}'\
+        + f' --umbrella-header {module["umbrella_header"]}'\
+        + f' --output {output_dir}'\
+        + f' --theme {api_theme_dir}'
+    logging.info(cmd)
+    result = subprocess.Popen(cmd,
+                              universal_newlines=True,
+                              shell=True,
+                              stdout=subprocess.PIPE)
+    logging.info(result.stdout.read())
+
+
+def parse_module(api_doc_path):
+  """Parse "${module}/index.html" and extract necessary information
+    e.g.
+    {
+      $(api_type_1): {
+        "api_type_link": $(api_type_link),
+        "apis": {
+          $(api_name_1): {
+            "api_link": $(api_link_1),
+            "declaration": [$(swift_declaration), $(objc_declaration)],
+            "sub_apis": {
+              $(sub_api_name_1): {"declaration": [$(swift_declaration)]},
+              $(sub_api_name_2): {"declaration": [$(objc_declaration)]},
+              ...
+            }
+          },
+          $(api_name_2): {
+            ...
+          },
+        }
+      },
+      $(api_type_2): {
+        ..
+      },
+    }
+    """
+  module_api_container = {}
+  # Read the HTML content from the file
+  index_link = f'{api_doc_path}/index.html'
+  with open(index_link, 'r') as file:
+    html_content = file.read()
+
+  # Parse the HTML content
+  soup = BeautifulSoup(html_content, 'html.parser')
+
+  # Locate the element with class="nav-groups"
+  nav_groups_element = soup.find('ul', class_='nav-groups')
+  # Extract data and convert to JSON format
+  for nav_group in nav_groups_element.find_all('li', class_='nav-group-name'):
+    api_type = nav_group.find('a').text
+    api_type_link = nav_group.find('a')['href']
+
+    apis = {}
+    for nav_group_task in nav_group.find_all('li', class_='nav-group-task'):
+      api_name = nav_group_task.find('a').text
+      api_link = nav_group_task.find('a')['href']
+      apis[api_name] = {'api_link': api_link, 'declaration': [], 'sub_apis': {}}
+
+    module_api_container[api_type] = {
+        'api_type_link': api_type_link,
+        'apis': apis
+    }
+
+  parse_api(api_doc_path, module_api_container)
+
+  return module_api_container
+
+
+def parse_api(doc_path, module_api_container):
+  """Parse API html and extract necessary information.
+
+    e.g. ${module}/Classes.html
+    """
+  for api_type, api_type_abstract in module_api_container.items():
+    api_type_link = f'{doc_path}/{unquote(api_type_abstract["api_type_link"])}'
+    api_data_container = module_api_container[api_type]['apis']
+    with open(api_type_link, 'r') as file:
+      html_content = file.read()
+
+    # Parse the HTML content
+    soup = BeautifulSoup(html_content, 'html.parser')
+    for api in soup.find('div', class_='task-group').find_all('li',
+                                                              class_='item'):
+      api_name = api.find('a', class_='token').text
+      for api_declaration in api.find_all('div', class_='language'):
+        api_declaration_text = ' '.join(api_declaration.stripped_strings)
+        api_data_container[api_name]['declaration'].append(api_declaration_text)
+
+    for api, api_abstruct in api_type_abstract['apis'].items():
+      if api_abstruct['api_link'].endswith('.html'):
+        parse_sub_api(f'{doc_path}/{unquote(api_abstruct["api_link"])}',
+                      api_data_container[api]['sub_apis'])
+
+
+def parse_sub_api(api_link, sub_api_data_container):
+  """Parse SUB_API html and extract necessary information.
+
+    e.g. ${module}/Classes/${class_name}.html
+    """
+  with open(api_link, 'r') as file:
+    html_content = file.read()
+
+  # Parse the HTML content
+  soup = BeautifulSoup(html_content, 'html.parser')
+  for s_api in soup.find('div', class_='task-group').find_all('li',
+                                                              class_='item'):
+    api_name = s_api.find('a', class_='token').text
+    sub_api_data_container[api_name] = {'declaration': []}
+    for api_declaration in s_api.find_all('div', class_='language'):
+      api_declaration_text = ' '.join(api_declaration.stripped_strings)
+      sub_api_data_container[api_name]['declaration'].append(
+          api_declaration_text)
+
+
+def parse_cmdline_args():
+  parser = argparse.ArgumentParser()
+  parser.add_argument('-f', '--file_list', nargs='+', default=[])
+  parser.add_argument('-o', '--output_dir', default='output_dir')
+  parser.add_argument('-t',
+                      '--api_theme_dir',
+                      default='scripts/api_diff_report/theme')
+
+  args = parser.parse_args()
+  return args
+
+
+if __name__ == '__main__':
+  main()

+ 183 - 0
scripts/api_diff_report/icore_module.py

@@ -0,0 +1,183 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the 'License');
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import logging
+import json
+import subprocess
+
+SWIFT = 'Swift'
+OBJECTIVE_C = 'Objective-C'
+
+# List of Swift and Objective-C modules
+MODULE_LIST = [
+    'FirebaseABTesting',
+    'FirebaseAnalytics',  # Not buildable from source
+    'FirebaseAnalyticsOnDeviceConversion',  # Not buildable.
+    'FirebaseAnalyticsSwift',
+    'FirebaseAppCheck',
+    'FirebaseAppDistribution',
+    'FirebaseAuth',
+    'FirebaseCore',
+    'FirebaseCrashlytics',
+    'FirebaseDatabase',
+    'FirebaseDatabaseSwift',
+    'FirebaseDynamicLinks',
+    'FirebaseFirestore',
+    'FirebaseFirestoreSwift',
+    'FirebaseFunctions',
+    'FirebaseInAppMessaging'
+    'FirebaseInAppMessagingSwift',
+    'FirebaseInstallations',
+    'FirebaseMessaging',
+    'FirebaseMLModelDownloader',
+    'FirebasePerformance',
+    'FirebaseRemoteConfig',
+    'FirebaseRemoteConfigSwift',
+    # Not buildable. No scheme named "FirebaseSharedSwift"
+    'FirebaseSharedSwift',
+    'FirebaseStorage',
+    # Not buildable. NO "source_files"
+    'GoogleAppMeasurement',
+    # Not buildable. NO "source_files"
+    'GoogleAppMeasurementOnDeviceConversion'
+]
+
+
+def main():
+  module_info()
+
+
+def detect_changed_modules(changed_api_files):
+  """Detect changed modules based on changed API files."""
+  all_modules = module_info()
+  changed_modules = {}
+  for file_path in changed_api_files:
+    for k, v in all_modules.items():
+      if v['root_dir'] and v['root_dir'] in file_path:
+        changed_modules[k] = v
+        break
+
+  logging.info(f'changed_modules:\n{json.dumps(changed_modules, indent=4)}')
+  return changed_modules
+
+
+def module_info():
+  """retrieve module info in MODULE_LIST from `.podspecs`
+    The module info helps to build Jazzy
+    includes: module name, source_files, public_header_files,
+              language, umbrella_header, framework_root
+    """
+  module_from_podspecs = module_info_from_podspecs()
+  module_list = {}
+  for k, v in module_from_podspecs.items():
+    if k in MODULE_LIST:
+      if k not in module_list:
+        module_list[k] = v
+        module_list[k]['language'] = OBJECTIVE_C if v.get(
+            'public_header_files') else SWIFT
+        module_list[k]['scheme'] = get_scheme(k)
+        module_list[k]['umbrella_header'] = get_umbrella_header(
+            k, v.get('public_header_files'))
+        module_list[k]['root_dir'] = get_root_dir(k, v.get('source_files'))
+
+  logging.info(f'all_module:\n{json.dumps(module_list, indent=4)}')
+  return module_list
+
+
+def get_scheme(module_name):
+  """Jazzy documentation Info SWIFT only.
+
+    Get scheme from module name in .podspecs Assume the scheme is the
+    same as the module name:
+    """
+  MODULE_SCHEME_PATCH = {
+      'FirebaseInAppMessagingSwift': 'FirebaseInAppMessagingSwift-Beta',
+  }
+  if module_name in MODULE_SCHEME_PATCH:
+    return MODULE_SCHEME_PATCH[module_name]
+  return module_name
+
+
+def get_umbrella_header(module_name, public_header_files):
+  """Jazzy documentation Info OBJC only Get umbrella_header from
+    public_header_files in .podspecs Assume the umbrella_header is with the
+    format:
+
+    {module_name}/Sources/Public/{module_name}/{module_name}.h
+    """
+  if public_header_files:
+    if isinstance(public_header_files, list):
+      return public_header_files[0].replace('*', module_name)
+    elif isinstance(public_header_files, str):
+      return public_header_files.replace('*', module_name)
+  return ''
+
+
+def get_root_dir(module_name, source_files):
+  """Get source code root_dir from source_files in .podspecs Assume the
+    root_dir is with the format:
+
+    {module_name}/Sources or {module_name}/Source
+    """
+  MODULE_ROOT_PATCH = {
+      'FirebaseFirestore': 'Firestore/Source',
+      'FirebaseFirestoreSwift': 'Firestore/Swift/Source',
+      'FirebaseCrashlytics': 'Crashlytics/Crashlytics',
+      'FirebaseInAppMessagingSwift': 'FirebaseInAppMessaging/Swift/Source',
+  }
+  if module_name in MODULE_ROOT_PATCH:
+    return MODULE_ROOT_PATCH[module_name]
+  if source_files:
+    for source_file in source_files:
+      if f'{module_name}/Sources' in source_file:
+        return f'{module_name}/Sources'
+      if f'{module_name}/Source' in source_file:
+        return f'{module_name}/Source'
+  return ''
+
+
+def module_info_from_podspecs(root_dir=os.getcwd()):
+  result = {}
+  for filename in os.listdir(root_dir):
+    if filename.endswith('.podspec'):
+      podspec_data = parse_podspec(filename)
+      source_files = podspec_data.get('source_files')
+      if not podspec_data.get('source_files') and podspec_data.get('ios'):
+        source_files = podspec_data.get('ios').get('source_files')
+      result[podspec_data['name']] = {
+          'name': podspec_data['name'],
+          'source_files': source_files,
+          'public_header_files': podspec_data.get('public_header_files')
+      }
+  return result
+
+
+def parse_podspec(podspec_file):
+  result = subprocess.run(f'pod ipc spec {podspec_file}',
+                          stdout=subprocess.PIPE,
+                          stderr=subprocess.PIPE,
+                          text=True,
+                          shell=True)
+  if result.returncode != 0:
+    logging.info(f'Error: {result.stderr}')
+    return None
+
+  # Parse the JSON output
+  podspec_data = json.loads(result.stdout)
+  return podspec_data
+
+
+if __name__ == '__main__':
+  main()

+ 12 - 0
scripts/api_diff_report/theme/templates/deprecation.mustache

@@ -0,0 +1,12 @@
+{{#deprecation_message}}
+<div class="aside aside-deprecated">
+  <p class="aside-title">Deprecated</p>
+  {{{deprecation_message}}}
+</div>
+{{/deprecation_message}}
+{{#unavailable_message}}
+<div class="aside aside-unavailable">
+  <p class="aside-title">Unavailable</p>
+  {{{unavailable_message}}}
+</div>
+{{/unavailable_message}}

+ 88 - 0
scripts/api_diff_report/theme/templates/doc.mustache

@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>{{name}} {{kind}} Reference</title>
+    <link rel="stylesheet" type="text/css" href="{{path_to_root}}css/jazzy.css" />
+    <link rel="stylesheet" type="text/css" href="{{path_to_root}}css/highlight.css" />
+    {{#enable_katex}}
+    <link rel="stylesheet" type="text/css" href="{{path_to_root}}css/katex.min.css" />
+    {{/enable_katex}}
+    <meta charset='utf-8'>
+    <script src="{{path_to_root}}js/jquery.min.js" defer></script>
+    {{#enable_katex}}
+    <script src="{{path_to_root}}js/katex.min.js" defer></script>
+    {{/enable_katex}}
+    <script src="{{path_to_root}}js/jazzy.js" defer></script>
+    {{{custom_head}}}
+    {{^disable_search}}
+    <script src="{{path_to_root}}js/lunr.min.js" defer></script>
+    <script src="{{path_to_root}}js/typeahead.jquery.js" defer></script>
+    <script src="{{path_to_root}}js/jazzy.search.js" defer></script>
+    {{/disable_search}}
+  </head>
+  <body>
+    {{#dash_type}}
+    <a name="//apple_ref/{{language_stub}}/{{dash_type}}/{{name}}" class="dashAnchor"></a>
+    {{/dash_type}}
+    <a title="{{name}} {{kind}} Reference"></a>
+    {{> header}}
+    <div class="content-wrapper">
+      <p id="breadcrumbs">
+        <a href="{{path_to_root}}index.html">{{module_name}} Reference</a>
+        <img id="carat" src="{{path_to_root}}img/carat.png" alt=""/>
+        {{name}} {{kind}} Reference
+      </p>
+    </div>
+    <div class="content-wrapper">
+      {{> nav}}
+      <article class="main-content">
+        <section>
+          <section class="section">
+            {{^hide_name}}<h1>{{name}}</h1>{{/hide_name}}
+            {{> deprecation}}
+            {{#declaration}}
+              <div class="declaration">
+                <div class="language">
+                  {{#other_language_declaration}}<p class="aside-title">{{language}}</p>{{/other_language_declaration}}
+                  {{{declaration}}}
+                </div>
+                {{#other_language_declaration}}
+                <div class="language">
+                  <p class="aside-title">Swift</p>
+                  {{{other_language_declaration}}}
+                </div>
+                {{/other_language_declaration}}
+              </div>
+            {{/declaration}}
+            {{{overview}}}
+            {{#parameters.any?}}
+              <div>
+                <h4>Parameters</h4>
+                <table class="graybox">
+                  <tbody>
+                    {{#parameters}}
+                      {{> parameter}}
+                    {{/parameters}}
+                  </tbody>
+                </table>
+              </div>
+            {{/parameters.any?}}
+            {{#return}}
+              <div>
+                <h4>Return Value</h4>
+                {{{return}}}
+              </div>
+            {{/return}}
+            {{#source_host_item_url}}
+              <div class="slightly-smaller">
+                <a href="{{{.}}}">Show on {{source_host_name}}</a>
+              </div>
+            {{/source_host_item_url}}
+          </section>
+          {{> tasks}}
+        </section>
+        {{> footer}}
+      </article>
+    </div>
+  </body>
+</html>

+ 4 - 0
scripts/api_diff_report/theme/templates/footer.mustache

@@ -0,0 +1,4 @@
+<section id="footer">
+  {{{copyright}}}
+  <p>Generated by <a class="link" href="https://github.com/realm/jazzy" target="_blank" rel="external noopener">jazzy ♪♫ v{{jazzy_version}}</a>, a <a class="link" href="https://realm.io" target="_blank" rel="external noopener">Realm</a> project.</p>
+</section>

+ 18 - 0
scripts/api_diff_report/theme/templates/header.mustache

@@ -0,0 +1,18 @@
+<header>
+  <div class="content-wrapper">
+    <p><a href="{{path_to_root}}index.html">{{docs_title}}</a>{{#doc_coverage}} ({{doc_coverage}}% documented){{/doc_coverage}}</p>
+    {{#source_host_url}}
+    <p class="header-right"><a href="{{.}}"><img src="{{path_to_root}}img/{{source_host_image}}" alt="{{source_host_name}}"/>View on {{source_host_name}}</a></p>
+    {{/source_host_url}}
+    {{#dash_url}}
+    <p class="header-right"><a href="{{dash_url}}"><img src="{{path_to_root}}img/dash.png" alt="Dash"/>Install in Dash</a></p>
+    {{/dash_url}}
+    {{^disable_search}}
+    <div class="header-right">
+      <form role="search" action="{{path_to_root}}search.json">
+        <input type="text" placeholder="Search documentation" data-typeahead>
+      </form>
+    </div>
+    {{/disable_search}}
+  </div>
+</header>

+ 16 - 0
scripts/api_diff_report/theme/templates/nav.mustache

@@ -0,0 +1,16 @@
+<nav class="sidebar">
+  <ul class="nav-groups">
+    {{#structure}}
+    <li class="nav-group-name">
+      <a href="{{path_to_root}}{{url}}">{{section}}</a>
+      <ul class="nav-group-tasks">
+        {{#children}}
+        <li class="nav-group-task">
+          <a href="{{path_to_root}}{{url}}">{{name}}</a>
+        </li>
+        {{/children}}
+      </ul>
+    </li>
+    {{/structure}}
+  </ul>
+</nav>

+ 12 - 0
scripts/api_diff_report/theme/templates/parameter.mustache

@@ -0,0 +1,12 @@
+<tr>
+  <td>
+    <code>
+    <em>{{name}}</em>
+    </code>
+  </td>
+  <td>
+    <div>
+      {{{discussion}}}
+    </div>
+  </td>
+</tr>

+ 100 - 0
scripts/api_diff_report/theme/templates/task.mustache

@@ -0,0 +1,100 @@
+<div class="task-group">
+  {{#name}}
+  <div class="task-name-container">
+    <a name="/{{uid}}"></a>
+    <a name="//apple_ref/{{language_stub}}/Section/{{name}}" class="dashAnchor"></a>
+    <div class="section-name-container">
+      <a class="section-name-link" href="#/{{uid}}"></a>
+      <h3 class="section-name">{{{name_html}}}</h3>
+    </div>
+  </div>
+  {{/name}}
+  <ul>
+    {{#items}}
+    <li class="item">
+      <div>
+        <code>
+        <a name="/{{usr}}"></a>
+        <a name="//apple_ref/{{language_stub}}/{{dash_type}}/{{name}}" class="dashAnchor"></a>
+        {{#direct_link}}
+        <a class="direct-link{{#usage_discouraged}} discouraged{{/usage_discouraged}}" href="{{{path_to_root}}}{{{url}}}">{{name}}</a>
+        </code>
+        {{/direct_link}}
+        {{^direct_link}}
+        {{^usage_discouraged}}
+        <a class="token" href="#/{{usr}}">{{{name_html}}}</a>
+        {{/usage_discouraged}}
+        {{#usage_discouraged}}
+        <a class="token discouraged" href="#/{{usr}}">{{{name_html}}}</a>
+        {{/usage_discouraged}}
+        </code>
+        {{#declaration_note}}
+          <span class="declaration-note">
+            {{.}}
+          </span>
+        {{/declaration_note}}
+      </div>
+      <div class="height-container">
+        <div class="pointer-container"></div>
+        <section class="section">
+          <div class="pointer"></div>
+          {{> deprecation}}
+          {{#abstract}}
+          <div class="abstract">
+            {{{abstract}}}
+            {{#url}}
+            <a href="{{{path_to_root}}}{{{url}}}" class="slightly-smaller">See more</a>
+            {{/url}}
+          </div>
+          {{/abstract}}
+          {{#default_impl_abstract}}
+          <h4>Default Implementation</h4>
+          <div class="default_impl abstract">
+            {{{default_impl_abstract}}}
+          </div>
+          {{/default_impl_abstract}}
+          {{#declaration}}
+          <div class="declaration">
+            <h4>Declaration</h4>
+            <div class="language">
+              <p class="aside-title">{{language}}</p>
+              {{{declaration}}}
+            </div>
+            {{#other_language_declaration}}
+            <div class="language">
+              <p class="aside-title">Swift</p>
+              {{{other_language_declaration}}}
+            </div>
+            {{/other_language_declaration}}
+          </div>
+          {{/declaration}}
+          {{#parameters.count}}
+          <div>
+            <h4>Parameters</h4>
+            <table class="graybox">
+              <tbody>
+                {{#parameters}}
+                {{> parameter}}
+                {{/parameters}}
+              </tbody>
+            </table>
+          </div>
+          {{/parameters.count}}
+          {{#return}}
+          <div>
+            <h4>Return Value</h4>
+            {{{return}}}
+          </div>
+          {{/return}}
+          {{#source_host_item_url}}
+          <div class="slightly-smaller">
+            <a href="{{{.}}}">Show on {{source_host_name}}</a>
+          </div>
+          {{/source_host_item_url}}
+        </section>
+        {{/direct_link}}
+      </div>
+    </li>
+    {{/items}}
+  </ul>
+</div>

+ 7 - 0
scripts/api_diff_report/theme/templates/tasks.mustache

@@ -0,0 +1,7 @@
+{{#tasks.count}}
+<section class="section task-group-section">
+  {{#tasks}}
+  {{> task}}
+  {{/tasks}}
+</section>
+{{/tasks.count}}