Syntax Highlighting (und mehr!) mit Prism auf einer statischen Seite

Avatar of Adam Rackis
Adam Rackis am

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

Sie haben sich also entschieden, einen Blog mit Next.js zu erstellen. Wie jeder Entwickler-Blogger möchten Sie Codeausschnitte in Ihren Beiträgen haben, die mit Syntax Highlighting schön formatiert sind. Vielleicht möchten Sie auch Zeilennummern in den Ausschnitten anzeigen lassen und vielleicht sogar die Möglichkeit haben, bestimmte Codezeilen hervorzuheben.

Dieser Beitrag zeigt Ihnen, wie Sie das einrichten, sowie einige Tipps und Tricks, um diese anderen Funktionen zum Laufen zu bringen. Einiges davon ist kniffliger als Sie vielleicht erwarten.

Voraussetzungen

Wir verwenden den Next.js Blog Starter als Basis für unser Projekt, aber die gleichen Prinzipien sollten auch für andere Frameworks gelten. Dieses Repository enthält klare (und einfache) Anleitungen für den Einstieg. Erstellen Sie den Blog und legen wir los!

Eine weitere Sache, die wir hier verwenden, ist Prism.js, eine beliebte Bibliothek für Syntax Highlighting, die sogar hier auf CSS-Tricks verwendet wird. Der Next.js Blog Starter verwendet Remark, um Markdown in Markup umzuwandeln, also werden wir das remark-Prism.js Plugin verwenden, um unsere Codeausschnitte zu formatieren.

Grundlegende Prism.js-Integration

Beginnen wir mit der Integration von Prism.js in unseren Next.js Starter. Da wir bereits wissen, dass wir das remark-prism Plugin verwenden, ist das Erste, was zu tun ist, es mit Ihrem bevorzugten Paketmanager zu installieren

npm i remark-prism

Gehen Sie nun in die Datei markdownToHtml im Ordner /lib und aktivieren Sie remark-prism

import remarkPrism from "remark-prism";

// later ...

.use(remarkPrism, { plugins: ["line-numbers"] })

Je nach Version von remark-html, die Sie verwenden, müssen Sie möglicherweise auch dessen Verwendung auf .use(html, { sanitize: false }) umstellen.

Das gesamte Modul sollte jetzt so aussehen

import { remark } from "remark";
import html from "remark-html";
import remarkPrism from "remark-prism";

export default async function markdownToHtml(markdown) {
  const result = await remark()
    .use(html, { sanitize: false })
    .use(remarkPrism, { plugins: ["line-numbers"] })
    .process(markdown);

  return result.toString();
}

Hinzufügen von Prism.js-Stilen und -Thema

Importieren wir nun die CSS-Datei, die Prism.js zum Stylen der Codeausschnitte benötigt. In der Datei pages/_app.js importieren Sie das Haupt-Stylesheet von Prism.js und das Stylesheet für das Thema Ihrer Wahl. Ich verwende das "Tomorrow Night"-Thema von Prism.js, also sehen meine Imports so aus

import "prismjs/themes/prism-tomorrow.css";
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
import "../styles/prism-overrides.css";

Beachten Sie, dass ich auch eine prism-overrides.css-Stylesheetdatei begonnen habe, in der wir einige Standardwerte anpassen können. Diese wird später nützlich sein. Vorerst kann sie leer bleiben.

Und damit haben wir nun einige grundlegende Stile. Der folgende Code in Markdown

```js
class Shape {
  draw() {
    console.log("Uhhh maybe override me");
  }
}

class Circle {
  draw() {
    console.log("I'm a circle! :D");
  }
}
```

…sollte sich schön formatieren

Hinzufügen von Zeilennummern

Sie haben vielleicht bemerkt, dass der von uns generierte Codeausschnitt keine Zeilennummern anzeigt, obwohl wir das Plugin, das dies unterstützt, importiert haben, als wir remark-prism importierten. Die Lösung liegt auf der Hand in der README von remark-prism

Vergessen Sie nicht, das entsprechende CSS in Ihre Stylesheets einzufügen.

Mit anderen Worten, wir müssen der generierten <pre>-Tag eine CSS-Klasse .line-numbers aufzwingen, was wir wie folgt tun können

Und damit haben wir nun Zeilennummern!

Beachten Sie, dass ich basierend auf der Version von Prism.js, die ich habe, und dem "Tomorrow Night"-Thema, das ich gewählt habe, diese zu der oben begonnenen Datei prism-overrides.css hinzufügen musste

.line-numbers span.line-numbers-rows {
  margin-top: -1px;
}

Das brauchen Sie vielleicht nicht, aber hier haben Sie es. Wir haben Zeilennummern!

Hervorheben von Zeilen

Unser nächstes Feature wird etwas mehr Arbeit erfordern. Hier möchten wir die Möglichkeit haben, bestimmte Codezeilen im Ausschnitt hervorzuheben oder hervorzuheben.

Es gibt ein Prism.js Line-Highlight-Plugin; leider ist es nicht in remark-prism integriert. Das Plugin analysiert die Position des formatierten Codes im DOM und hebt Zeilen manuell basierend auf diesen Informationen hervor. Das ist mit dem remark-prism Plugin unmöglich, da zum Zeitpunkt der Ausführung des Plugins kein DOM vorhanden ist. Dies ist schließlich eine statische Seitengenerierung. Next.js verarbeitet unseren Markdown während eines Build-Schritts und generiert HTML, um den Blog zu rendern. All dieser Prism.js-Code wird während dieser statischen Seitengenerierung ausgeführt, wenn kein DOM vorhanden ist.

Aber keine Sorge! Es gibt einen unterhaltsamen Workaround, der gut zu CSS-Tricks passt: Wir können einfaches CSS (und einen Hauch von JavaScript) verwenden, um Codezeilen hervorzuheben.

Ich möchte klarstellen, dass dies eine nicht-triviale Menge an Arbeit ist. Wenn Sie keine Zeilenhervorhebung benötigen, können Sie gerne zum nächsten Abschnitt springen. Aber wenn nicht, kann es eine unterhaltsame Demonstration dessen sein, was möglich ist.

Unser Basis-CSS

Beginnen wir damit, das folgende CSS zu unserer prism-overrides.css-Stylesheet hinzuzufügen

:root {
  --highlight-background: rgb(0 0 0 / 0);
  --highlight-width: 0;
}

.line-numbers span.line-numbers-rows > span {
  position: relative;
}

.line-numbers span.line-numbers-rows > span::after {
  content: " ";
  background: var(--highlight-background);
  width: var(--highlight-width);
  position: absolute;
  top: 0;
}

Wir definieren zunächst einige CSS-Custom-Properties: eine Hintergrundfarbe und eine Hervorbreite. Wir setzen sie vorerst auf leere Werte. Später werden wir sie jedoch mit JavaScript mit aussagekräftigen Werten für die hervorzuhebenden Zeilen belegen.

Dann setzen wir die Zeilennummer <span> auf position: relative, damit wir ein ::after-Pseudo-Element mit absoluter Positionierung hinzufügen können. Es ist dieses Pseudo-Element, das wir zum Hervorheben unserer Zeilen verwenden werden.

Deklarieren der hervorgehobenen Zeilen

Nun fügen wir manuell ein data-Attribut zum generierten <pre>-Tag hinzu, lesen dieses dann im Code aus und verwenden JavaScript, um die obigen Stile anzupassen, um bestimmte Zeilen hervorzuheben. Das können wir auf die gleiche Weise tun, wie wir zuvor Zeilennummern hinzugefügt haben

Dies führt dazu, dass unser <pre>-Element mit dem Attribut data-line="3,8-10" gerendert wird, wobei Zeile 3 und die Zeilen 8-10 im Codeausschnitt hervorgehoben werden. Wir können Zeilennummern durch Kommas trennen oder Bereiche angeben.

Sehen wir uns an, wie wir das in JavaScript parsen und die Hervorhebung zum Laufen bringen können.

Lesen der hervorgehobenen Zeilen

Gehen Sie zu components/post-body.tsx. Wenn diese Datei für Sie JavaScript ist, können Sie sie gerne in TypeScript (.tsx) konvertieren oder einfach alle meine Typen ignorieren.

Zuerst benötigen wir einige Importe

import { useEffect, useRef } from "react";

Und wir müssen dieser Komponente eine ref hinzufügen

const rootRef = useRef<HTMLDivElement>(null);

Dann wenden wir sie auf das root-Element an

<div ref={rootRef} className="max-w-2xl mx-auto">

Das nächste Stück Code ist etwas lang, aber es tut nichts Verrücktes. Ich zeige es Ihnen und gehe es dann durch.

useEffect(() => {
  const allPres = rootRef.current.querySelectorAll("pre");
  const cleanup: (() => void)[] = [];

  for (const pre of allPres) {
    const code = pre.firstElementChild;
    if (!code || !/code/i.test(code.tagName)) {
      continue;
    }

    const highlightRanges = pre.dataset.line;
    const lineNumbersContainer = pre.querySelector(".line-numbers-rows");

    if (!highlightRanges || !lineNumbersContainer) {
      continue;
    }

    const runHighlight = () =>
      highlightCode(pre, highlightRanges, lineNumbersContainer);
    runHighlight();

    const ro = new ResizeObserver(runHighlight);
    ro.observe(pre);

    cleanup.push(() => ro.disconnect());
  }

  return () => cleanup.forEach(f => f());
}, []);

Wir führen einmalig einen Effekt aus, wenn der Inhalt vollständig auf dem Bildschirm gerendert wurde. Wir verwenden querySelectorAll, um alle <pre>-Elemente unter diesem root-Element abzurufen; mit anderen Worten, den Blogbeitrag, den der Benutzer gerade ansieht.

Für jedes davon stellen wir sicher, dass sich ein <code>-Element darunter befindet, und wir prüfen sowohl auf den Container der Zeilennummern als auch auf das data-line-Attribut. Das prüft dataset.line. Weitere Informationen finden Sie in den Dokumenten.

Wenn wir den zweiten continue bestehen, dann ist highlightRanges die Menge der zuvor deklarierten Hervorhebungen, die in unserem Fall "3,8-10" ist, wobei lineNumbersContainer der Container mit der CSS-Klasse .line-numbers ist.

Zuletzt deklarieren wir eine runHighlight-Funktion, die eine highlightCode-Funktion aufruft, die ich Ihnen gleich zeigen werde. Dann richten wir einen ResizeObserver ein, um dieselbe Funktion auszuführen, wann immer sich die Größe unseres Blogbeitrags ändert, d. h. wenn der Benutzer das Browserfenster vergrößert.

Die highlightCode-Funktion

Lassen Sie uns schließlich unsere highlightCode-Funktion sehen

function highlightCode(pre, highlightRanges, lineNumberRowsContainer) {
  const ranges = highlightRanges.split(",").filter(val => val);
  const preWidth = pre.scrollWidth;

  for (const range of ranges) {
    let [start, end] = range.split("-");
    if (!start || !end) {
      start = range;
      end = range;
    }

    for (let i = +start; i <= +end; i++) {
      const lineNumberSpan: HTMLSpanElement = lineNumberRowsContainer.querySelector(
        `span:nth-child(${i})`
      );
      lineNumberSpan.style.setProperty(
        "--highlight-background",
        "rgb(100 100 100 / 0.5)"
      );
      lineNumberSpan.style.setProperty("--highlight-width", `${preWidth}px`);
    }
  }
}

Wir erhalten jeden Bereich und lesen die Breite des <pre>-Elements aus. Dann durchlaufen wir jeden Bereich, finden das relevante Zeilennummer-<span> und setzen die CSS-Custom-Property-Werte für diese. Wir setzen jede gewünschte Hervorhebungsfarbe und die Breite auf den gesamten scrollWidth-Wert des <pre>-Elements. Ich habe es einfach gehalten und "rgb(100 100 100 / 0.5)" verwendet, aber Sie können gerne jede Farbe verwenden, die Ihrer Meinung nach am besten zu Ihrem Blog passt.

So sieht es aus:

Syntax highlighting for a block of Markdown code.

Zeilenhervorhebung ohne Zeilennummern

Sie haben vielleicht bemerkt, dass all dies bisher von der Anwesenheit von Zeilennummern abhängt. Aber was ist, wenn wir Zeilen hervorheben wollen, aber ohne Zeilennummern?

Eine Möglichkeit, dies zu implementieren, wäre, alles gleich zu lassen und eine neue Option hinzuzufügen, um diese Zeilennummern einfach mit CSS zu verstecken. Zuerst fügen wir eine neue CSS-Klasse hinzu, .hide-numbers

```js[class="line-numbers"][class="hide-numbers"][data-line="3,8-10"]
class Shape {
  draw() {
    console.log("Uhhh maybe override me");
  }
}

class Circle {
  draw() {
    console.log("I'm a circle! :D");
  }
}
```

Fügen wir nun CSS-Regeln hinzu, um die Zeilennummern auszublenden, wenn die Klasse .hide-numbers angewendet wird

.line-numbers.hide-numbers {
  padding: 1em !important;
}
.hide-numbers .line-numbers-rows {
  width: 0;
}
.hide-numbers .line-numbers-rows > span::before {
  content: " ";
}
.hide-numbers .line-numbers-rows > span {
  padding-left: 2.8em;
}

Die erste Regel macht die Verschiebung nach rechts unseres Basis-Codes rückgängig, um Platz für die Zeilennummern zu schaffen. Standardmäßig beträgt der Abstand (padding) des von mir gewählten Prism.js-Themas 1em. Das Zeilennummer-Plugin erhöht ihn auf 3.8em und fügt dann die Zeilennummern mit absoluter Positionierung ein. Was wir getan haben, setzt den Abstand auf den Standardwert von 1em zurück.

Die zweite Regel nimmt den Container der Zeilennummern und quetscht ihn auf keine Breite. Die dritte Regel löscht alle Zeilennummern selbst (sie werden mit ::before-Pseudo-Elementen generiert).

Die letzte Regel verschiebt lediglich die nun leeren <span>-Elemente für die Zeilennummern zurück an ihren ursprünglichen Platz, damit die Hervorhebung richtig positioniert werden kann. Wiederum, für mein Thema fügen die Zeilennummern normalerweise 3.8em linken Abstand hinzu, den wir auf den Standardwert von 1em zurückgesetzt haben. Diese neuen Stile fügen die restlichen 2.8em hinzu, damit alles wieder an seinem Platz ist, aber die Zeilennummern versteckt sind. Wenn Sie andere Plugins verwenden, benötigen Sie möglicherweise leicht abweichende Werte.

Hier ist, wie das Ergebnis aussieht

Syntax highlighting for a block of Markdown code.

Kopieren-in-die-Zwischenablage-Funktion

Bevor wir abschließen, fügen wir noch eine letzte Verfeinerung hinzu: einen Button, der es unserem geschätzten Leser ermöglicht, den Code aus unserem Ausschnitt zu kopieren. Es ist eine nette kleine Verbesserung, die Leuten erspart, den Codeausschnitt manuell auswählen und kopieren zu müssen.

Es ist tatsächlich ziemlich einfach. Dafür gibt es eine navigator.clipboard.writeText API. Wir übergeben dieser Methode den Text, den wir kopieren möchten, und das war's. Wir können neben jedem unserer <code>-Elemente einen Button einfügen, um den Text des Codes an diesen API-Aufruf zum Kopieren zu senden. Wir sind bereits dabei, diese <code>-Elemente zu bearbeiten, um Zeilen hervorzuheben, also integrieren wir unseren Kopieren-Button an derselben Stelle.

Zuerst fügen wir aus dem obigen useEffect-Code eine Zeile hinzu

useEffect(() => {
  const allPres = rootRef.current.querySelectorAll("pre");
  const cleanup: (() => void)[] = [];

  for (const pre of allPres) {
    const code = pre.firstElementChild;
    if (!code || !/code/i.test(code.tagName)) {
      continue;
    }

    pre.appendChild(createCopyButton(code));

Beachten Sie die letzte Zeile. Wir werden unseren Button direkt in den DOM unter unserem <pre>-Element einfügen, das bereits position: relative ist, was uns ermöglicht, den Button einfacher zu positionieren.

Sehen wir uns an, wie die Funktion createCopyButton aussieht

function createCopyButton(codeEl) {
  const button = document.createElement("button");
  button.classList.add("prism-copy-button");
  button.textContent = "Copy";

  button.addEventListener("click", () => {
    if (button.textContent === "Copied") {
      return;
    }
    navigator.clipboard.writeText(codeEl.textContent || "");
    button.textContent = "Copied";
    button.disabled = true;
    setTimeout(() => {
      button.textContent = "Copy";
      button.disabled = false;
    }, 3000);
  });

  return button;
}

Viel Code, aber es ist größtenteils Standard. Wir erstellen unseren Button, geben ihm eine CSS-Klasse und etwas Text. Und dann erstellen wir natürlich einen Klick-Handler, der das Kopieren durchführt. Nachdem das Kopieren abgeschlossen ist, ändern wir den Text des Buttons und deaktivieren ihn für ein paar Sekunden, um dem Benutzer Feedback zu geben, dass es funktioniert hat.

Die eigentliche Arbeit steckt in dieser Zeile

navigator.clipboard.writeText(codeEl.textContent || "");

Wir übergeben codeEl.textContent anstelle von innerHTML, da wir nur den tatsächlich gerenderten Text wollen und nicht das gesamte Markup, das Prism.js zur schönen Formatierung unseres Codes hinzufügt.

Nun sehen wir uns an, wie wir diesen Button stylen könnten. Ich bin kein Designer, aber das ist, was mir eingefallen ist

.prism-copy-button {
  position: absolute;
  top: 5px;
  right: 5px;
  width: 10ch;
  background-color: rgb(100 100 100 / 0.5);
  border-width: 0;
  color: rgb(0, 0, 0);
  cursor: pointer;
}

.prism-copy-button[disabled] {
  cursor: default;
}

Was so aussieht

Syntax highlighting for a block of Markdown code.

Und es funktioniert! Es kopiert unseren Code und bewahrt sogar die Formatierung (d.h. neue Zeilen und Einrückungen)!

Zusammenfassung

Ich hoffe, dies war für Sie nützlich. Prism.js ist eine wunderbare Bibliothek, aber sie wurde ursprünglich nicht für statische Seiten geschrieben. Dieser Beitrag hat Ihnen einige Tipps und Tricks gezeigt, wie Sie diese Lücke schließen und sie gut mit einer Next.js-Website zum Laufen bringen können.