Wie erstelle ich eine Browser Erweiterung

Avatar of Lars Kölker
Lars Kölker am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Ich wette, Sie verwenden gerade Browsererweiterungen. Einige davon sind äußerst beliebt und nützlich, wie z. B. Ad-Blocker, Passwort-Manager und PDF-Viewer. Diese Erweiterungen (oder „Add-ons“) beschränken sich nicht auf diese Zwecke – Sie können viel mehr damit tun! In diesem Artikel gebe ich Ihnen eine Einführung, wie Sie eine erstellen. Letztendlich werden wir sie in mehreren Browsern zum Laufen bringen.

Was wir machen

Wir erstellen eine Erweiterung namens „Transcribers of Reddit“, die die Barrierefreiheit von Reddit verbessern soll, indem sie bestimmte Kommentare an den Anfang des Kommentarbereichs verschiebt und aria-Attribute für Screenreader hinzufügt. Wir werden unsere Erweiterung auch etwas weiterentwickeln mit Optionen zum Hinzufügen von Rahmen und Hintergründen zu Kommentaren für besseren Textkontrast.

Die ganze Idee ist, dass Sie eine schöne Einführung in die Entwicklung einer Browsererweiterung erhalten. Wir beginnen mit der Erstellung der Erweiterung für Chromium-basierte Browser (z. B. Google Chrome, Microsoft Edge, Brave usw.). In einem zukünftigen Beitrag werden wir die Erweiterung für Firefox und Safari portieren, die kürzlich die Unterstützung für Web Extensions sowohl in den MacOS- als auch in den iOS-Versionen des Browsers hinzugefügt hat.

Bereit? Gehen wir das Schritt für Schritt durch.

Erstellen Sie ein Arbeitsverzeichnis

Bevor wir etwas anderes tun, brauchen wir einen Arbeitsbereich für unser Projekt. Alles, was wir wirklich brauchen, ist die Erstellung eines Ordners und die Benennung (ich nenne ihn transcribers-of-reddit). Erstellen Sie dann darin einen weiteren Ordner namens src für unseren Quellcode.

Definieren Sie den Einstiegspunkt

Der Einstiegspunkt ist eine Datei, die allgemeine Informationen über die Erweiterung enthält (d. h. Name der Erweiterung, Beschreibung usw.) und Berechtigungen oder auszuführende Skripte definiert.

Unser Einstiegspunkt kann eine Datei manifest.json sein, die sich im gerade erstellten Ordner src befindet. Darin fügen wir die folgenden drei Eigenschaften hinzu

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}

Die manifest_version ähnelt der Version in npm oder Node. Sie definiert, welche APIs verfügbar sind (oder nicht). Wir werden am Puls der Zeit arbeiten und die neueste Version, 3 (auch bekannt als mv3), verwenden.

Die zweite Eigenschaft ist name und gibt den Namen unserer Erweiterung an. Dieser Name wird überall angezeigt, wo unsere Erweiterung erscheint, z. B. im Chrome Web Store und auf der Seite chrome://extensions im Chrome-Browser.

Dann gibt es version. Sie kennzeichnet die Erweiterung mit einer Versionsnummer. Beachten Sie, dass diese Eigenschaft (im Gegensatz zu manifest_version) ein String ist, der nur Zahlen und Punkte enthalten kann (z. B. 1.3.5).

Weitere Informationen zu manifest.json

Es gibt tatsächlich noch viel mehr, was wir hinzufügen können, um unserer Erweiterung Kontext zu geben. Zum Beispiel können wir eine description angeben, die erklärt, was die Erweiterung tut. Es ist eine gute Idee, solche Dinge bereitzustellen, da sie den Benutzern eine bessere Vorstellung davon geben, worauf sie sich einlassen, wenn sie sie verwenden.

In diesem Fall fügen wir nicht nur eine Beschreibung hinzu, sondern liefern auch Icons und eine Webadresse, auf die der Chrome Web Store auf der Seite der Erweiterung verweist.

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • Die description wird auf der Verwaltungsseite von Chrome (chrome://extensions) angezeigt und sollte kurz sein, weniger als 132 Zeichen.
  • Die icons werden an vielen Stellen verwendet. Wie die Dokumentation besagt, ist es am besten, drei Versionen desselben Icons in verschiedenen Auflösungen bereitzustellen, vorzugsweise als PNG-Datei. Fühlen Sie sich frei, die im GitHub-Repository für dieses Beispiel zu verwenden.
  • Die homepage_url kann verwendet werden, um Ihre Website mit der Erweiterung zu verbinden. Eine Schaltfläche mit dem Link wird angezeigt, wenn Sie auf der Verwaltungsseite auf „Weitere Details“ klicken.
Unsere geöffnete Erweiterungskarte auf der Erweiterungsverwaltungsseite.

Berechtigungen festlegen

Ein großer Vorteil von Erweiterungen ist, dass ihre APIs es Ihnen ermöglichen, direkt mit dem Browser zu interagieren. Aber wir müssen der Erweiterung ausdrücklich diese Berechtigungen erteilen, was ebenfalls in die Datei manifest.json gehört.


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

Wofür haben wir dieser Erweiterung gerade die Erlaubnis erteilt? Erstens, Speicher. Wir möchten, dass diese Erweiterung die Einstellungen des Benutzers speichern kann, daher benötigen wir Zugriff auf den Web-Speicher des Browsers, um sie zu halten. Wenn der Benutzer beispielsweise rote Rahmen für die Kommentare wünscht, speichern wir dies für das nächste Mal, anstatt ihn dazu zu bringen, es erneut einzustellen.

Wir haben der Erweiterung auch die Erlaubnis erteilt, zu sehen, wie der Benutzer zur aktuellen Seite navigiert ist. Reddit ist eine Single-Page-Anwendung (SPA), was bedeutet, dass sie keine Seitenaktualisierung auslöst. Wir müssen diese Interaktion „abfangen“, da Reddit die Kommentare eines Beitrags nur lädt, wenn wir darauf klicken. Deshalb greifen wir auf webNavigation zu.

Das Ausführen von Code auf einer Seite kommt später, da dies einen komplett neuen Eintrag in manifest.json erfordert.

/explanation Abhängig von den erlaubten Berechtigungen kann der Browser dem Benutzer eine Warnung anzeigen, um die Berechtigungen zu akzeptieren. Das sind jedoch nur bestimmte, und Chrome hat eine gute Übersicht darüber.

Übersetzungen verwalten

Browsererweiterungen verfügen über eine integrierte Internationalisierungs-(i18n)-API. Sie ermöglicht es Ihnen, Übersetzungen für mehrere Sprachen zu verwalten (vollständige Liste). Um die API zu nutzen, müssen wir unsere Übersetzungen und die Standardsprache direkt in der Datei manifest.json definieren.

"default_locale": "en"

Dies setzt Englisch als Sprache. Sollte ein Browser auf eine andere, nicht unterstützte Sprache eingestellt sein, greift die Erweiterung auf die Standardsprache (in diesem Beispiel en) zurück.

Unsere Übersetzungen sind im Verzeichnis _locales definiert. Erstellen wir dort einen weiteren Ordner für jede Sprache, die Sie unterstützen möchten. Jeder Unterordner erhält seine eigene Datei messages.json.

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

Eine Übersetzungsdatei besteht aus mehreren Teilen

  • Übersetzungsschlüssel („id“): Dieser Schlüssel wird verwendet, um auf die Übersetzung zu verweisen.
  • Nachricht: Der eigentliche Übersetzungsinhalt
  • Beschreibung (optional): Beschreibt die Übersetzung (Ich würde sie nicht verwenden, sie blähen die Datei nur auf und Ihr Übersetzungsschlüssel sollte aussagekräftig genug sein)
  • Platzhalter (optional): Kann verwendet werden, um dynamische Inhalte in eine Übersetzung einzufügen

Hier ist ein Beispiel, das all das zusammenfasst

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // Translation
    "description": "User Greeting", // Optional description for translators
    "placeholders": { // Optional placeholders
      "daytime": { // As referenced inside the message
        "content": "$1",
        "example": "morning" // Example value for our content
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Die Verwendung von Platzhaltern ist etwas anspruchsvoller. Zuerst müssen wir den Platzhalter innerhalb der Nachricht definieren. Ein Platzhalter muss in $-Zeichen eingeschlossen werden. Danach müssen wir unseren Platzhalter zur „Platzhalterliste“ hinzufügen. Das ist etwas unintuitiv, aber Chrome möchte wissen, welchen Wert unsere Platzhalter erhalten sollen. Wir möchten hier (offensichtlich) einen dynamischen Wert verwenden, daher verwenden wir den speziellen content-Wert $1, der auf unseren eingefügten Wert verweist.

Die Eigenschaft example ist optional. Sie kann verwendet werden, um Übersetzern einen Hinweis zu geben, welcher Wert der Platzhalter haben könnte (wird aber nicht tatsächlich angezeigt).

Wir müssen die folgenden Übersetzungen für unsere Erweiterung definieren. Kopieren und fügen Sie sie in die Datei messages.json ein. Fühlen Sie sich frei, weitere Sprachen hinzuzufügen (z. B. wenn Sie Deutsch sprechen, fügen Sie einen de-Ordner in _locales ein und so weiter).

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

Sie fragen sich vielleicht, warum wir die Berechtigungen registriert haben, obwohl kein i18n-Berechtigungseintrag vorhanden ist, oder? Chrome ist diesbezüglich etwas seltsam, da Sie nicht jede Berechtigung registrieren müssen. Einige (z. B. chrome.i18n) erfordern keinen Eintrag im Manifest. Andere Berechtigungen erfordern einen Eintrag, werden dem Benutzer jedoch bei der Installation der Erweiterung nicht angezeigt. Wieder andere Berechtigungen sind „hybrid“ (z. B. chrome.runtime), was bedeutet, dass einige ihrer Funktionen ohne Deklaration einer Berechtigung verwendet werden können – andere Funktionen derselben API erfordern jedoch einen Eintrag im Manifest. Sie sollten sich die Dokumentation ansehen, um einen soliden Überblick über die Unterschiede zu erhalten.

Übersetzungen im Manifest verwenden

Das Erste, was unser Endbenutzer sehen wird, ist entweder der Eintrag im Chrome Web Store oder die Erweiterungsübersichtsseite. Wir müssen unsere Manifestdatei anpassen, um sicherzustellen, dass alles übersetzt ist.

{
  // Update these entries
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Die Anwendung dieser Syntax verwendet die entsprechende Übersetzung in unserer messages.json-Datei (z. B. _MSG_name_ verwendet die name-Übersetzung).

Übersetzungen in HTML-Seiten verwenden

Die Anwendung von Übersetzungen in einer HTML-Datei erfordert ein wenig JavaScript.

chrome.i18n.getMessage('name');

Dieser Code gibt unsere definierte Übersetzung zurück (Transcribers of Reddit). Platzhalter können auf ähnliche Weise gehandhabt werden.

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

Es wäre eine ziemliche Plackerei, Übersetzungen auf diese Weise auf alle Elemente anzuwenden. Aber wir können ein kleines Skript schreiben, das die Übersetzung basierend auf einem data-Attribut durchführt. Lassen Sie uns also einen neuen Ordner js im Verzeichnis src erstellen und darin eine neue Datei util.js hinzufügen.

src 
 └─ js
     └─ util.js

Das erledigt die Arbeit

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

Sobald dieses Skript zu einer HTML-Seite hinzugefügt wurde, können wir das Attribut data-intl zu einem Element hinzufügen, um seinen Inhalt festzulegen. Die Dokumentensprache wird auch basierend auf der Benutzersprache festgelegt.

<!-- Before JS execution -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- After JS execution -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

Hinzufügen eines Pop-ups und einer Optionsseite

Bevor wir uns mit der eigentlichen Programmierung befassen, müssen wir zwei Seiten erstellen

  1. Eine Optionsseite, die Benutzereinstellungen enthält
  2. Eine Pop-up-Seite, die sich öffnet, wenn Sie mit dem Erweiterungssymbol neben unserer Adressleiste interagieren. Diese Seite kann für verschiedene Szenarien verwendet werden (z. B. zum Anzeigen von Statistiken oder schnellen Einstellungen).
Die Optionsseite mit unseren Einstellungen.
Das Pop-up mit einem Link zur Optionsseite.

Hier ist ein Überblick über die Ordner und Dateien, die wir benötigen, um die Seiten zu erstellen

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

Die .css-Dateien enthalten einfaches CSS, nichts mehr und nichts weniger. Ich gehe nicht ins Detail, da ich weiß, dass die meisten von Ihnen, die dies lesen, bereits vollständig wissen, wie CSS funktioniert. Sie können die Stile aus dem GitHub-Repository für dieses Projekt kopieren und einfügen.

Beachten Sie, dass das Pop-up kein Tab ist und seine Größe vom Inhalt abhängt. Wenn Sie eine feste Pop-up-Größe wünschen, können Sie die Eigenschaften width und height für das html-Element festlegen.

Erstellen des Pop-ups

Hier ist ein HTML-Skelett, das die CSS- und JavaScript-Dateien verknüpft und eine Überschrift und eine Schaltfläche im <body> hinzufügt.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <!-- Our "translation" script -->
    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

Die h1 enthält den Namen und die Version der Erweiterung; die button wird verwendet, um die Optionsseite zu öffnen. Die Überschrift wird nicht mit einer Übersetzung gefüllt (da sie kein data-intl-Attribut hat), und die Schaltfläche hat noch keinen Klick-Handler, also müssen wir unsere Datei popup.js füllen.

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

Dieses Skript sucht zuerst nach der Manifestdatei. Chrome bietet die runtime API, die die Methode getManifest enthält (diese spezifische Methode erfordert keine runtime-Berechtigung). Sie gibt unser manifest.json als JSON-Objekt zurück. Nachdem wir den Titel mit dem Namen und der Version der Erweiterung gefüllt haben, können wir dem Einstellungen-Button einen Event-Listener hinzufügen. Wenn der Benutzer damit interagiert, öffnen wir die Optionsseite mit chrome.runtime.openOptionsPage() (auch hier ist kein Berechtigungseintrag erforderlich).

Die Pop-up-Seite ist nun fertig, aber die Erweiterung weiß noch nichts von ihrer Existenz. Wir müssen das Pop-up registrieren, indem wir die folgende Eigenschaft zur Datei manifest.json hinzufügen.

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Erstellen der Optionsseite

Die Erstellung dieser Seite folgt einem ziemlich ähnlichen Prozess wie dem, den wir gerade abgeschlossen haben. Zuerst füllen wir unsere Datei options.html. Hier ist etwas Markup, das wir verwenden können.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <!-- Our "translation" script -->
  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

Es gibt noch keine tatsächlichen Optionen (nur ihre Wrapper). Wir müssen das Skript für die Optionsseite schreiben. Zuerst definieren wir Variablen, um auf unsere Wrapper und Standardeinstellungen in options.js zuzugreifen. Das „Einfrieren“ unserer Standardeinstellungen verhindert, dass wir sie später versehentlich ändern.

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Als Nächstes müssen wir die gespeicherten Einstellungen laden. Dafür können wir die (zuvor registrierte) storage API verwenden. Insbesondere müssen wir definieren, ob wir die Daten lokal speichern möchten (chrome.storage.local) oder die Einstellungen über alle Geräte synchronisieren, auf denen der Endbenutzer angemeldet ist (chrome.storage.sync). Für dieses Projekt entscheiden wir uns für lokalen Speicher.

Das Abrufen von Werten muss mit der Methode get erfolgen. Sie akzeptiert zwei Argumente

  1. Die Einträge, die wir laden möchten
  2. Ein Callback, der die Werte enthält

Unsere Einträge können entweder ein String sein (z. B. wie unten settings) oder ein Array von Einträgen (nützlich, wenn wir mehrere Einträge laden möchten). Das Argument in der Callback-Funktion enthält ein Objekt aller Einträge, die wir zuvor in { settings: ... } definiert haben.

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // Fall back to default if settings are not defined
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // Create and display options
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

Um die Optionen darzustellen, müssen wir auch eine Funktion createOption() erstellen.

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

Innerhalb des onchange-Event-Listeners unseres Schalters (aka Radio-Buttons) rufen wir die Funktion updateSetting auf. Diese Methode schreibt den aktualisierten Wert unseres Radio-Buttons in den Speicher.

Um dies zu erreichen, werden wir die Funktion set verwenden. Sie hat zwei Argumente: den Eintrag, den wir überschreiben möchten, und einen (optionalen) Callback (den wir in unserem Fall nicht verwenden). Da unser settings-Eintrag kein boolescher Wert oder String ist, sondern ein Objekt, das verschiedene Einstellungen enthält, verwenden wir den Spread-Operator () und überschreiben nur unseren eigentlichen Schlüssel (Einstellung) innerhalb des settings-Objekts.

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Auch hier müssen wir die Erweiterung über unsere Optionsseite „informieren“, indem wir den folgenden Eintrag zur Datei manifest.json hinzufügen.

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

Abhängig von Ihrem Anwendungsfall können Sie auch erzwingen, dass der Optionsdialog als Pop-up geöffnet wird, indem Sie open_in_tab auf false setzen.

Installation der Erweiterung für die Entwicklung

Nachdem wir die Manifestdatei erfolgreich eingerichtet und sowohl das Pop-up als auch die Optionsseite hinzugefügt haben, können wir unsere Erweiterung installieren, um zu überprüfen, ob unsere Seiten tatsächlich funktionieren. Navigieren Sie zu chrome://extensions und aktivieren Sie den „Entwicklermodus“. Drei Schaltflächen erscheinen. Klicken Sie auf die mit „Unpacked laden“ beschriftete und wählen Sie den Ordner src Ihrer Erweiterung aus, um sie zu laden.

Die Erweiterung sollte nun erfolgreich installiert sein und unser Kachel „Transcribers of Reddit“ sollte auf der Seite erscheinen.

Wir können bereits mit unserer Erweiterung interagieren. Klicken Sie auf das Puzzleteil-Symbol (🧩) neben der Adressleiste des Browsers und klicken Sie auf die neu hinzugefügte Erweiterung „Transcribers of Reddit“. Sie sollten nun von einem kleinen Pop-up mit der Schaltfläche zum Öffnen der Optionsseite begrüßt werden.

Schön, nicht wahr? Es sieht auf Ihrem Gerät vielleicht etwas anders aus, da ich in diesen Screenshots den Dunkelmodus aktiviert habe.

Wenn Sie die Einstellungen „Hintergrund für Kommentare anzeigen“ und „Rand für Kommentare anzeigen“ aktivieren und die Seite dann neu laden, bleibt der Zustand erhalten, da wir ihn im lokalen Speicher des Browsers speichern.

Hinzufügen des Content-Skripts

OK, wir können also bereits das Pop-up auslösen und mit den Erweiterungseinstellungen interagieren, aber die Erweiterung tut noch nichts besonders Nützliches. Um ihr etwas Leben einzuhauchen, fügen wir ein Content-Skript hinzu.

Fügen Sie eine Datei namens comment.js im Verzeichnis js hinzu und stellen Sie sicher, dass sie in der Datei manifest.json definiert ist.

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

Die content_scripts besteht aus zwei Teilen

  • matches: Dieses Array enthält URLs, die dem Browser mitteilen, wo wir möchten, dass unsere Content-Skripte ausgeführt werden. Da wir eine Erweiterung für Reddit sind, möchten wir, dass dies auf jeder Seite ausgeführt wird, die ://www.redit.com/* entspricht, wobei das Sternchen ein Platzhalter ist, der alles nach der Top-Level-Domain abgleicht.
  • js: Dieses Array enthält die eigentlichen Content-Skripte.

Content-Skripte können nicht mit anderen (normalen) JavaScripts interagieren. Das bedeutet, wenn die Skripte einer Website eine Variable oder Funktion definieren, können wir nicht darauf zugreifen. Zum Beispiel

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

Lassen Sie uns nun mit dem Schreiben unseres Content-Skripts beginnen. Zuerst fügen wir einige Konstanten zu comment.js hinzu. Diese Konstanten enthalten RegEx-Ausdrücke und Selektoren, die später verwendet werden. CommentUtils wird verwendet, um festzustellen, ob ein Beitrag einen „tor-Kommentar“ enthält oder ob Kommentar-Wrapper vorhanden sind.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

Als Nächstes prüfen wir, ob ein Benutzer eine Kommentar-Seite („post“) direkt öffnet, führen dann eine RegEx-Prüfung durch und aktualisieren die Variable directPage. Dieser Fall tritt ein, wenn ein Benutzer die URL direkt öffnet (z. B. indem er sie in die Adressleiste eingibt oder auf ein <a>-Element auf einer anderen Seite, wie Twitter, klickt).

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Neben dem direkten Öffnen einer Seite interagiert ein Benutzer normalerweise mit der SPA. Um diesen Fall abzufangen, können wir mit der runtime API einen Nachrichten-Listener zu unserer Datei comment.js hinzufügen.

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

Alles, was wir jetzt brauchen, sind die Funktionen. Lassen Sie uns eine Funktion moveComments() erstellen. Sie verschiebt den speziellen „tor-Kommentar“ an den Anfang des Kommentarbereichs. Sie wendet auch bedingt eine Hintergrundfarbe und einen Rahmen (wenn Rahmen in den Einstellungen aktiviert sind) auf den Kommentar an. Dazu rufen wir die storage API auf und laden den settings-Eintrag.

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { // HIGHLIGHT 18
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

Die Funktion applyWaiAria() wird innerhalb der Funktion moveComments() aufgerufen – sie fügt aria-Attribute hinzu. Die andere Funktion erstellt eine eindeutige Kennung für die Verwendung mit den aria-Attributen.

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

Die folgende Funktion wartet darauf, dass die Kommentare geladen werden, und ruft den Callback-Parameter auf, wenn sie den Kommentar-Wrapper findet.

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Hinzufügen eines Service Workers

Erinnern Sie sich, als wir einen Listener für Nachrichten im Content-Skript hinzugefügt haben? Dieser Listener empfängt derzeit keine Nachrichten. Wir müssen ihn selbst an das Content-Skript senden. Zu diesem Zweck müssen wir einen Service Worker registrieren.

Wir müssen unseren Service Worker im manifest.json registrieren, indem wir den folgenden Code hinzufügen.

"background": {
  "service_worker": "sw.js"
}

Vergessen Sie nicht, die Datei sw.js im Verzeichnis src zu erstellen (Service Worker müssen immer im Stammverzeichnis der Erweiterung, src, erstellt werden.

Lassen Sie uns nun einige Konstanten für die Nachrichten- und Seitentypen erstellen.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

Wir können den eigentlichen Inhalt des Service Workers hinzufügen. Dies tun wir mit einem Event-Listener für den Verlauf (onHistoryStateUpdated), der erkennt, wenn eine Seite mit der History API aktualisiert wurde (die in SPAs üblicherweise verwendet wird, um zu navigieren, ohne die Seite neu zu laden). Innerhalb dieses Listeners fragen wir den aktiven Tab ab und extrahieren dessen tabId. Dann senden wir eine Nachricht an unser Content-Skript, die den Seitentyp und die URL enthält.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

Alles erledigt!

Wir sind fertig! Navigieren Sie zur Erweiterungsverwaltungsseite von Chrome ( chrome://extensions) und klicken Sie auf das Symbol zum Neuladen der entpackten Erweiterung. Wenn Sie einen Reddit-Beitrag öffnen, der einen „Transcribers of Reddit“-Kommentar mit einer Bildtranskription enthält (wie diesen), wird er an den Anfang des Kommentarbereichs verschoben und hervorgehoben, solange wir ihn in den Erweiterungseinstellungen aktiviert haben.

Die Erweiterung „Transcribers of Reddit“ hebt einen bestimmten Kommentar hervor, indem sie ihn an den Anfang der Kommentarleiste des Reddit-Threads verschiebt und ihm einen hellroten Rand gibt.

Fazit

War das so schwer, wie Sie dachten? Es ist definitiv viel einfacher, als ich dachte, bevor ich mich damit befasst habe. Nach der Einrichtung von manifest.json und der Erstellung aller benötigten Seiten und Assets schreiben wir eigentlich nur noch HTML, CSS und JavaScript wie gewohnt.

Wenn Sie unterwegs einmal nicht weiterwissen, ist die Chrome API-Dokumentation eine großartige Ressource, um wieder auf Kurs zu kommen.

Noch einmal, hier ist das GitHub-Repository mit dem gesamten Code, den wir in diesem Artikel durchgegangen sind. Lesen Sie es, verwenden Sie es und sagen Sie mir, was Sie davon halten!