Responsives Markdown in Next.js

Avatar of Kitty Giraudel
Kitty Giraudel am

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

Markdown ist wirklich ein großartiges Format. Es ist nah genug an einfachem Text, sodass jeder es schnell lernen kann, und es ist strukturiert genug, um geparst und schließlich in alles Mögliche umgewandelt zu werden.

Das heißt: Markdown zu parsen, zu verarbeiten, zu verbessern und zu konvertieren erfordert Code. Das Ausliefern all dieses Codes auf dem Client hat seinen Preis. Es ist an sich nicht riesig, aber es sind immer noch ein paar Dutzend Kilobytes Code, die nur zur Verarbeitung von Markdown und nichts anderem verwendet werden.

In diesem Artikel möchte ich erklären, wie man Markdown in einer Next.js-Anwendung mithilfe des Unified/Remark-Ökosystems (bin mir wirklich nicht sicher, welchen Namen ich verwenden soll, das ist alles super verwirrend) aus dem Client heraushält.

Allgemeine Idee

Die Idee ist, Markdown nur in den `getStaticProps`-Funktionen von Next.js zu verwenden, so dass dies während eines Builds (oder in einer Next-Serverless-Funktion bei Verwendung von Vercels inkrementellen Builds) geschieht, aber niemals im Client. Ich denke, `getServerSideProps` wäre auch in Ordnung, aber ich glaube, `getStaticProps` ist der wahrscheinlichere Anwendungsfall.

Dies würde ein AST (Abstract Syntax Tree, also ein großes verschachteltes Objekt, das unseren Inhalt beschreibt) zurückgeben, das sich aus dem Parsen und Verarbeiten des Markdown-Inhalts ergibt, und der Client wäre nur für das Rendern dieses AST in React-Komponenten verantwortlich.

Ich schätze, wir könnten Markdown sogar direkt in `getStaticProps` als HTML rendern und es mit `dangerouslySetInnerHtml` zurückgeben, aber wir sind nicht diese Art von Leuten. Sicherheit ist wichtig. Und auch die Flexibilität, Markdown so zu rendern, wie wir es mit unseren Komponenten wollen, anstatt dass es als einfacher HTML-Code gerendert wird. Ernsthaft Leute, tut das nicht. 😅

export const getStaticProps = async () => {
  // Get the Markdown content from somewhere, like a CMS or whatnot. It doesn’t
  // matter for the sake of this article, really. It could also be read from a
  // file.
  const markdown = await getMarkdownContentFromSomewhere()
  const ast = parseMarkdown(markdown)

  return { props: { ast } }
}

const Page = props => {
  // This would usually have your layout and whatnot as well, but omitted here
  // for sake of simplicity of course.
  return <MarkdownRenderer ast={props.ast} />
}

export default Page

Markdown parsen

Wir werden das Unified/Remark-Ökosystem verwenden. Wir müssen `unified` und `remark-parse` installieren, und das war's schon. Das Parsen des Markdowns selbst ist relativ einfach.

import { unified } from 'unified'
import markdown from 'remark-parse'

const parseMarkdown = content => unified().use(markdown).parse(content)

export default parseMarkdown

Was ich eine ganze Weile gebraucht habe, um zu verstehen, ist, warum meine zusätzlichen Plugins wie `remark-prism` oder `remark-slug` nicht so funktionierten. Das liegt daran, dass die `.parse(..)`-Methode von Unified das AST nicht mit Plugins verarbeitet. Wie der Name schon sagt, parst sie nur den Markdown-Inhalt als String in einen Baum.

Wenn wir Unified möchten, dass es unsere Plugins anwendet, müssen wir Unified die sogenannte "run"-Phase durchlaufen lassen. Normalerweise geschieht dies durch die Verwendung der `.process(..)`-Methode anstelle der `.parse(..)`-Methode. Leider parst `.process(..)` nicht nur Markdown und wendet Plugins an, sondern stringifiziert das AST auch in ein anderes Format (wie HTML über `remark-html` oder JSX mit `remark-react`). Und das ist nicht das, was wir wollen, da wir das AST erhalten möchten, aber nachdem es von Plugins verarbeitet wurde.

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+

Wir müssen also sowohl die Parsing- als auch die Laufphasen durchführen, aber nicht die Stringifizierungsphase. Unified stellt keine Methode bereit, um diese 2 von 3 Phasen durchzuführen, aber es stellt für jede Phase individuelle Methoden bereit, sodass wir es manuell tun können.

import { unified } from 'unified'
import markdown from 'remark-parse'
import prism from 'remark-prism'

const parseMarkdown = content => {
  const engine = unified().use(markdown).use(prism)
  const ast = engine.parse(content)

  // Unified‘s *process* contains 3 distinct phases: parsing, running and
  // stringifying. We do not want to go through the stringifying phase, since we
  // want to preserve an AST, so we cannot call `.process(..)`. Calling
  // `.parse(..)` is not enough though as plugins (so Prism) are executed during
  // the running phase. So we need to manually call the run phase (synchronously
  // for simplicity).
  // See: https://github.com/unifiedjs/unified#description
  return engine.runSync(ast)
}

Tada! Wir haben unser Markdown in einen Syntaxbaum geparst. Und dann haben wir unsere Plugins auf diesem Baum ausgeführt (hier synchron zur Einfachheit halber, aber Sie könnten `.run(..)` verwenden, um es asynchron zu tun). Aber wir haben unseren Baum nicht in eine andere Syntax wie HTML oder JSX umgewandelt. Das können wir selbst machen, beim Rendern.

Markdown rendern

Jetzt, da wir unseren coolen Baum bereit haben, können wir ihn so rendern, wie wir es beabsichtigen. Nehmen wir eine `MarkdownRenderer`-Komponente, die den Baum als `ast`-Prop erhält und alles mit React-Komponenten rendert.

const getComponent = node => {
  switch (node.type) {
    case 'root':
      return ({ children }) => <>{children}</>

    case 'paragraph':
      return ({ children }) => <p>{children}</p>

    case 'emphasis':
      return ({ children }) => <em>{children}</em>

    case 'heading':
      return ({ children, depth = 2 }) => {
        const Heading = `h${depth}`
        return <Heading>{children}</Heading>
      }

    case 'text':
      return ({ value }) => <>{value}</>

    /* Handle all types here … */

    default:
      console.log('Unhandled node type', node)
      return ({ children }) => <>{children}</>
  }
}

const Node = node => {
  const Component = getComponent(node)
  const { children } = node

  return children ? (
    <Component {...node}>
      {children.map((child, index) => (
        <Node key={index} {...child} />
      ))}
    </Component>
  ) : (
    <Component {...node} />
  )
}

const MarkdownRenderer = props => <Node {...props.ast} />

export default React.memo(MarkdownRenderer)

Der Großteil der Logik unseres Renderers lebt in der `Node`-Komponente. Sie findet heraus, was gerendert werden soll, basierend auf dem `type`-Schlüssel des AST-Knotens (dies ist unsere `getComponent`-Methode, die jeden Knotentyp behandelt) und rendert ihn dann. Wenn der Knoten Kinder hat, geht er rekursiv in die Kinder; andernfalls rendert er die Komponente einfach als abschließendes Blatt.

Aufräumen des Baums

Abhängig davon, welche Remark-Plugins wir verwenden, können wir beim Rendern unserer Seite auf folgendes Problem stoßen:

Error: Error serializing .content[0].content.children[3].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hName returned from getStaticProps in “/”. Reason: undefined cannot be serialized as JSON. Please use null or omit this value.

Das passiert, weil unser AST Schlüssel enthält, deren Werte `undefined` sind, was nicht sicher als JSON serialisiert werden kann. Next gibt uns die Lösung: Entweder wir lassen den Wert weg oder ersetzen ihn durch `null`, wenn wir ihn irgendwie benötigen.

Wir werden jedoch nicht jeden Pfad von Hand korrigieren, also müssen wir diesen AST rekursiv durchlaufen und aufräumen. Ich habe herausgefunden, dass dies beim Verwenden von remark-prism auftritt, einem Plugin zur Aktivierung der Syntaxhervorhebung für Codeblöcke. Das Plugin fügt tatsächlich ein `[data]`-Objekt zu Knoten hinzu.

Was wir tun können, ist, unseren AST vor der Rückgabe zu durchlaufen, um diese Knoten aufzuräumen.

const cleanNode = node => {
  if (node.value === undefined) delete node.value
  if (node.tagName === undefined) delete node.tagName
  if (node.data) {
    delete node.data.hName
    delete node.data.hChildren
    delete node.data.hProperties
  }

  if (node.children) node.children.forEach(cleanNode)

  return node
}

const parseMarkdown = content => {
  const engine = unified().use(markdown).use(prism)
  const ast = engine.parse(content)
  const processedAst = engine.runSync(parsed)

  cleanNode(processedAst)

  return processedAst
}

Eine letzte Sache, die wir tun können, um weniger Daten an den Client zu senden, ist das Entfernen des `position`-Objekts, das auf jedem einzelnen Knoten vorhanden ist und die ursprüngliche Position im Markdown-String enthält. Es ist kein großes Objekt (es hat nur zwei Schlüssel), aber wenn der Baum groß wird, summiert es sich schnell auf.

const cleanNode = node => {
  delete node.position

Zusammenfassung

Das war's Leute! Wir haben es geschafft, die Markdown-Verarbeitung auf den Build-/Server-Code zu beschränken, damit wir keine Markdown-Laufzeit an den Browser liefern, was unnötig kostspielig ist. Wir übergeben einen Datenbaum an den Client, den wir durchlaufen und in beliebige React-Komponenten umwandeln können.

Ich hoffe, das hilft. :)