Eines der mächtigsten Konzepte, auf das ich in letzter Zeit gestoßen bin, ist die Idee von Abstract Syntax Trees oder ASTs. Wenn Sie jemals Alchemie studiert haben, erinnern Sie sich vielleicht daran, dass die Motivation der Alchemisten darin bestand, einen Weg zu finden, Nicht-Gold durch wissenschaftliche oder arkane Methoden in Gold zu verwandeln.
ASTs sind so etwas Ähnliches. Mit ASTs können wir Markdown in HTML, JSX in JavaScript und vieles mehr umwandeln.
Warum sind ASTs nützlich?
Früh in meiner Karriere habe ich versucht, Dateien mit einer Suchen-und-Ersetzen-Methode zu ändern. Das war ziemlich kompliziert, also habe ich versucht, reguläre Ausdrücke zu verwenden. Ich habe die Idee aufgegeben, weil sie so brüchig war; die App ging ständig kaputt, weil jemand Text eingab, den ich nicht erwartet hatte, und das meine regulären Ausdrücke kaputt machte, was die gesamte App zum Absturz brachte.
Der Grund, warum das so schwierig war, ist, dass HTML flexibel ist. Das macht es extrem schwer, mit regulären Ausdrücken zu parsen. Stringbasierte Ersetzungen wie diese sind anfällig für Fehler, da sie möglicherweise einen Treffer verfehlen, zu viel treffen oder etwas Seltsames tun, das zu ungültigem Markup führt, wodurch die Seite holprig aussieht.
ASTs hingegen wandeln HTML in etwas viel Strukturierteres um, was es viel einfacher macht, in einen Textknoten einzutauchen und nur diesen Text zu ersetzen oder mit Elementen zu arbeiten, ohne sich mit dem Text befassen zu müssen.
Das macht AST-Transformationen sicherer und fehlerresistenter als eine rein stringbasierte Lösung.
Wofür werden ASTs verwendet?
Zuerst betrachten wir ein minimales Dokument mit ein paar Zeilen Markdown. Dieses wird als Datei namens home.md gespeichert, die wir im Content-Ordner unserer Website speichern.
# Hello World!
 An adorable corgi!
Some more text goes here.
Wenn wir davon ausgehen, dass wir Markdown kennen, können wir schlussfolgern, dass dieses Markdown, wenn es geparst wird, zu einem h1-Tag wird, das „Hello World!“ sagt, dann zwei Textabsätze: der erste enthält ein Bild eines Corgis und Text, der es beschreiben soll, und der zweite sagt: „Hier kommt noch mehr Text.“
Aber wie wird es von Markdown in HTML umgewandelt?
Da kommen ASTs ins Spiel!
Da es mehrere Sprachen unterstützt, werden wir die unist-Syntax Tree Spezifikation und insbesondere das Projekt unified verwenden.
Abhängigkeiten installieren
Zuerst müssen wir die Abhängigkeiten installieren, die erforderlich sind, um Markdown in einen AST zu parsen und ihn in HTML zu konvertieren. Dazu müssen wir sicherstellen, dass wir den Ordner als Paket initialisiert haben. Führen Sie den folgenden Befehl in Ihrem Terminal aus
# make sure you’re in your root folder (where `content` is)
# initialize this folder as an npm package
npm init
# install the dependencies
npm install unified remark-parse remark-html
Wenn wir davon ausgehen, dass unser Markdown in home.md gespeichert ist, können wir den AST mit dem folgenden Code abrufen
const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const html = require('remark-html');
const contents = unified()
.use(markdown)
.use(html)
.processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
.toString();
console.log(contents);
Dieser Code nutzt das integrierte fs-Modul von Node, das uns Zugriff auf das Dateisystem ermöglicht und dessen Manipulation erlaubt. Weitere Informationen dazu finden Sie in der offiziellen Dokumentation.
Wenn wir dies als src/index.js speichern und Node verwenden, um dieses Skript auszuführen, sehen wir im Terminal Folgendes
$ node src/index.js
<h1>Hello World!</h1>
<p><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"> An adorable corgi!</p>
<p>Some more text goes here.</p>
Wir weisen Unified an, remark-parse zu verwenden, um die Markdown-Datei in einen AST umzuwandeln, und dann remark-html, um den Markdown-AST in HTML umzuwandeln — oder genauer gesagt, in etwas namens VFile. Mit der toString()-Methode wird dieser AST in eine tatsächliche HTML-Zeichenkette umgewandelt, die wir im Browser anzeigen können!
Dank der hervorragenden Arbeit der Open-Source-Community erledigt remark die ganze harte Arbeit, Markdown in HTML umzuwandeln. (Siehe Diff)
Als Nächstes sehen wir uns an, wie das tatsächlich funktioniert.
Wie sieht ein AST aus?
Um den tatsächlichen AST anzuzeigen, schreiben wir ein kleines Plugin, um ihn auszugeben
const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const html = require('remark-html');
const contents = unified()
.use(markdown)
.use(() => tree => console.log(JSON.stringify(tree, null, 2)))
.use(html)
.processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
.toString();
Die Ausgabe nach Ausführung des Skripts lautet nun
{
"type": "root",
"children": [
{
"type": "heading",
"depth": 1,
"children": [
{
"type": "text",
"value": "Hello World!",
"position": {}
}
],
"position": {}
},
{
"type": "paragraph",
"children": [
{
"type": "image",
"title": null,
"url": "<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>",
"alt": "cardigan corgi",
"position": {}
},
{
"type": "text",
"value": " An adorable corgi!",
"position": {}
}
],
"position": {}
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"value": "Some more text goes here.",
"position": {}
}
],
"position": {}
}
],
"position": {}
}
Beachten Sie, dass die Positionsangaben zur Platzersparnis gekürzt wurden. Sie enthalten Informationen darüber, wo sich der Knoten im Dokument befindet. Für die Zwecke dieses Tutorials werden wir diese Informationen nicht verwenden. (Siehe Diff)
Das ist etwas überwältigend anzusehen, aber wenn wir uns hineinzoomen, können wir sehen, dass jeder Teil des Markdown zu einem Knotentyp mit einem darin enthaltenen Textknoten wird.
Zum Beispiel wird die Überschrift zu
{
"type": "heading",
"depth": 1,
"children": [
{
"type": "text",
"value": "Hello World!",
"position": {}
}
],
"position": {}
}
Hier ist, was das bedeutet
- Der Typ gibt an, um welche Art von Knoten es sich handelt.
- Jeder Knotentyp hat zusätzliche Eigenschaften, die den Knoten beschreiben. Die
depth-Eigenschaft der Überschrift gibt an, auf welcher Ebene sich die Überschrift befindet – eine Tiefe von 1 bedeutet, dass es sich um ein<h1>-Tag handelt, 2 bedeutet<h2>und so weiter. - Das
children-Array gibt an, was sich in diesem Knoten befindet. Sowohl bei der Überschrift als auch beim Absatz handelt es sich nur um Text, aber wir könnten hier auch Inline-Elemente wie<strong>sehen.
Das ist die Stärke von ASTs: Wir haben das Markdown-Dokument nun als Objekt beschrieben, das ein Computer verstehen kann. Wenn wir dies zurück in Markdown drucken wollen, weiß ein Markdown-Compiler, dass ein „Überschriften“-Knoten mit einer Tiefe von 1 mit # beginnt, und ein untergeordneter Textknoten mit dem Wert „Hallo“ bedeutet, dass die letzte Zeile # Hallo lauten sollte.
Wie AST-Transformationen funktionieren
Die Transformation eines AST erfolgt normalerweise über das Visitor-Pattern. Es ist nicht wichtig, die Feinheiten dieses Ablaufs zu verstehen, um produktiv zu sein, aber wenn Sie neugierig sind, bietet JavaScript Design Patterns for Humans von Soham Kamani ein großartiges Beispiel zur Erklärung. Wichtig ist zu wissen, dass die Mehrheit der Ressourcen zur AST-Arbeit von „Besuchen von Knoten“ spricht, was grob übersetzt „Teil des AST finden, um damit etwas zu tun“ bedeutet. In der Praxis schreiben wir eine Funktion, die auf AST-Knoten angewendet wird, die unseren Kriterien entsprechen.
Ein paar wichtige Hinweise zur Funktionsweise
- ASTs können riesig sein, daher werden wir aus Performance-Gründen Knoten direkt mutieren. Dies widerspricht meiner üblichen Herangehensweise – als allgemeine Regel mag ich keine globalen Zustände mutieren –, aber in diesem Kontext ist es sinnvoll.
- Besucher arbeiten rekursiv. Das bedeutet, dass, wenn wir einen Knoten verarbeiten und einen neuen Knoten desselben Typs erstellen, der Besucher auch auf dem neu erstellten Knoten ausgeführt wird, es sei denn, wir weisen den Besucher ausdrücklich an, dies nicht zu tun.
- Wir werden in diesem Tutorial nicht zu tief gehen, aber diese beiden Ideen helfen uns zu verstehen, was vor sich geht, wenn wir mit dem Code zu spielen beginnen.
Wie kann ich die HTML-Ausgabe des AST modifizieren?
Was ist, wenn wir die Ausgabe unseres Markdown ändern wollen? Nehmen wir an, unser Ziel ist es, Bild-Tags mit einem figure-Element zu umschließen und eine Bildunterschrift bereitzustellen, wie hier
<figure>
<img
src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>"
alt="cardigan corgi"
/>
<figcaption>An adorable corgi!</figcaption>
</figure>
Um dies zu erreichen, müssen wir den HTML-AST transformieren – nicht den Markdown-AST –, da Markdown keine Möglichkeit bietet, figure- oder figcaption-Elemente zu erstellen. Glücklicherweise können wir das dank der Interoperabilität von unified mit mehreren Parsern ohne viel benutzerdefinierten Code tun.
Markdown-AST in einen HTML-AST konvertieren
Um den Markdown-AST in einen HTML-AST zu konvertieren, fügen Sie remark-rehype hinzu und wechseln Sie zu rehype-stringify, um den AST wieder in HTML umzuwandeln.
npm install remark-rehype rehype-stringify
Nehmen Sie die folgenden Änderungen in src/index.js vor, um zu rehype zu wechseln
const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const html = require('rehype-stringify');
const contents = unified()
.use(markdown)
.use(remark2rehype)
.use(() => tree => console.log(JSON.stringify(tree, null, 2)))
.use(html)
.processSync(fs.readFileSync('corgi.md'))
.toString();
console.log(contents);
Beachten Sie, dass sich die Variable HTML von remark-html zu rehype-stringify geändert hat – beide wandeln den AST in ein Format um, das als HTML stringifiziert werden kann.
Wenn wir das Skript ausführen, sehen wir, dass das img-Element im AST jetzt so aussieht
{
"type": "element",
"tagName": "img",
"properties": {
"src": "https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg",
"alt": "cardigan corgi"
},
"children": [],
"position": {}
}
Dies ist der AST für die HTML-Darstellung des Bildes, sodass wir beginnen können, ihn zur Verwendung des figure-Elements zu ändern. (Siehe Diff)
Ein Plugin für unified schreiben
Um unser img-Element mit einem figure-Element zu umschließen, müssen wir ein Plugin schreiben. In unified werden Plugins mit der Methode use() hinzugefügt, die das Plugin als erstes Argument und eventuelle Optionen als zweites Argument entgegennimmt.
.use(plugin, options)
Der Plugin-Code ist eine Funktion (im unified-Jargon „Attacher“ genannt), die Optionen erhält. Diese Optionen werden verwendet, um eine neue Funktion (genannt „Transformer“) zu erstellen, die den AST erhält und damit arbeitet, um ihn zu transformieren. Weitere Details zu Plugins finden Sie im Plugin-Überblick in der unified-Dokumentation.
Die zurückgegebene Funktion erhält den gesamten AST als Argument und gibt nichts zurück. (Denken Sie daran, dass ASTs global mutiert werden.) Erstellen Sie eine neue Datei namens img-to-figure.js im selben Ordner wie index.js, und fügen Sie Folgendes ein:
module.exports = options => tree => {
console.log(tree);
};
Um dies zu verwenden, müssen wir es zu src/index.js hinzufügen
const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const html = require('rehype-stringify');
const imgToFigure = require('./img-to-figure');
const contents = unified()
.use(markdown)
.use(remark2rehype)
.use(imgToFigure)
.processSync(fs.readFileSync('corgi.md'))
.toString();
console.log(contents);
Wenn wir das Skript ausführen, sehen wir den gesamten Baum im Konsolenprotokoll
{
type: 'root',
children: [
{
type: 'element',
tagName: 'p',
properties: {},
children: [Array],
position: [Object]
},
{ type: 'text', value: '\\n' },
{
type: 'element',
tagName: 'p',
properties: {},
children: [Array],
position: [Object]
}
],
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 4, column: 1, offset: 129 }
}
}
Einen Besucher zum Plugin hinzufügen
Als Nächstes müssen wir einen Besucher hinzufügen. Dies ermöglicht uns den Zugriff auf den Code. Unified nutzt eine Reihe von Hilfspaketen, die alle mit unist-util-* präfigiert sind und es uns ermöglichen, gängige Dinge mit unserem AST zu tun, ohne benutzerdefinierten Code schreiben zu müssen.
Wir können unist-util-visit verwenden, um Knoten zu modifizieren. Dies gibt uns eine visit-Hilfsfunktion, die drei Argumente entgegennimmt:
- Den gesamten AST, mit dem wir arbeiten
- Eine Prädikatfunktion, um die Knoten zu identifizieren, die wir besuchen möchten
- Eine Funktion, um die gewünschten Änderungen am AST vorzunehmen
Zur Installation führen Sie Folgendes in Ihrer Kommandozeile aus:
npm install unist-util-visit
Implementieren wir einen Besucher in unserem Plugin, indem wir Folgendes hinzufügen:
const visit = require('unist-util-visit');
module.exports = options => tree => {
visit(
tree,
// only visit p tags that contain an img element
node =>
node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
node => {
console.log(node);
}
);
};
Wenn wir dies ausführen, sehen wir, dass nur ein Absatzknoten protokolliert wird
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{
type: 'element',
tagName: 'img',
properties: [Object],
children: [],
position: [Object]
},
{ type: 'text', value: ' An adorable corgi!', position: [Object] }
],
position: {
start: { line: 3, column: 1, offset: 16 },
end: { line: 3, column: 102, offset: 117 }
}
}
Perfekt! Wir erhalten nur den Absatzknoten, der das Bild enthält, das wir ändern möchten. Jetzt können wir mit der Transformation des AST beginnen!
Das Bild in ein figure-Element einschließen
Nachdem wir die Bildattribute haben, können wir mit der Änderung des AST beginnen. Denken Sie daran, da ASTs sehr groß sein können, mutieren wir sie direkt, um die Erstellung vieler Kopien und mögliche Verlangsamungen unseres Skripts zu vermeiden.
Wir beginnen damit, den tagName des Knotens von p auf figure zu ändern. Die restlichen Details können vorerst gleich bleiben.
Nehmen Sie die folgenden Änderungen in src/img-to-figure.js vor
const visit = require('unist-util-visit');
module.exports = options => tree => {
visit(
tree,
// only visit p tags that contain an img element
node =>
node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
node => {
node.tagName = 'figure';
}
);
};
Wenn wir unser Skript erneut ausführen und uns die Ausgabe ansehen, sehen wir, dass wir näher kommen!
<h1>Hello World!</h1>
<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi">An adorable corgi!</figure>
<p>Some more text goes here.</p>
Den Text neben dem Bild als Bildunterschrift verwenden
Um nicht benutzerdefinierte Syntax schreiben zu müssen, werden wir jeden Text, der inline mit einem Bild übergeben wird, als Bildunterschrift verwenden.
Wir können annehmen, dass Bilder in Markdown normalerweise keinen Inline-Text haben, aber es ist erwähnenswert, dass dies bei Personen, die Markdown schreiben, zu unbeabsichtigten Bildunterschriften führen kann. Wir gehen dieses Risiko in diesem Tutorial ein. Wenn Sie dies in Produktion nehmen möchten, wägen Sie die Vor- und Nachteile ab und wählen Sie das Beste für Ihre Situation.
Um den Text zu verwenden, suchen wir nach einem Textknoten innerhalb unseres übergeordneten Knotens. Wenn wir einen finden, wollen wir seinen Wert als unsere Bildunterschrift nehmen. Wenn keine Bildunterschrift gefunden wird, wollen wir diesen Knoten gar nicht transformieren, also können wir frühzeitig zurückkehren.
Nehmen Sie die folgenden Änderungen in src/img-to-figure.js vor, um die Bildunterschrift zu erfassen
const visit = require('unist-util-visit');
module.exports = options => tree => {
visit(
tree,
// only visit p tags that contain an img element
node =>
node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
node => {
// find the text node
const textNode = node.children.find(n => n.type === 'text');
// if there’s no caption, we don’t need to transform the node
if (!textNode) return;
const caption = textNode.value.trim();
console.log({ caption });
node.tagName = 'figure';
}
);
};
Wenn wir das Skript ausführen, sehen wir die Bildunterschrift im Protokoll
{ caption: 'An adorable corgi!' }
Ein figcaption-Element zum figure hinzufügen
Jetzt, da wir unseren Bildunterschriftentext haben, können wir ein figcaption hinzufügen, um ihn anzuzeigen. Wir könnten dies tun, indem wir einen neuen Knoten erstellen und den alten Textknoten löschen, aber da wir direkt mutieren, ist es etwas weniger kompliziert, den Textknoten einfach in ein Element zu ändern.
Elemente haben jedoch keinen Text, daher müssen wir einen neuen Textknoten als Kind des figcaption-Elements hinzufügen, um den Bildunterschriftentext anzuzeigen.
Nehmen Sie die folgenden Änderungen an src/img-to-figure.js vor, um die Bildunterschrift zum Markup hinzuzufügen
const visit = require('unist-util-visit');
module.exports = options => tree => {
visit(
tree,
// only visit p tags that contain an img element
node =>
node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
node => {
// find the text node
const textNode = node.children.find(n => n.type === 'text');
// if there’s no caption, we don’t need to transform the node
if (!textNode) return;
const caption = textNode.value.trim();
// change the text node to a figcaption element containing a text node
textNode.type = 'element';
textNode.tagName = 'figcaption';
textNode.children = [
{
type: 'text',
value: caption
}
];
node.tagName = 'figure';
}
);
};
Wenn wir das Skript erneut mit Node src/index.js ausführen, sehen wir das transformierte Bild, das in ein figure-Element eingeschlossen und mit einer figcaption beschrieben ist!
<h1>Hello World!</h1>
<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"><figcaption>An adorable corgi!</figcaption></figure>
<p>Some more text goes here.</p>
Die transformierten Inhalte in eine neue Datei speichern
Nachdem wir nun eine Reihe von Transformationen vorgenommen haben, wollen wir diese Anpassungen in einer tatsächlichen Datei speichern, damit wir sie teilen können.
Da das Markdown kein vollständiges HTML-Dokument enthält, werden wir ein weiteres rehype-Plugin namens rehype-document hinzufügen, um die vollständige Dokumentstruktur und ein title-Tag einzufügen.
Installieren Sie es, indem Sie Folgendes ausführen:
npm install rehype-document
Nehmen Sie als Nächstes die folgenden Änderungen an src/index.js vor
const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const doc = require('rehype-document');
const html = require('rehype-stringify');
const imgToFigure = require('./img-to-figure');
const contents = unified()
.use(markdown)
.use(remark2rehype)
.use(imgToFigure)
.use(doc, { title: 'A Transformed Document!' })
.use(html)
.processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
.toString();
const outputDir = `${process.cwd()}/public`;
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
fs.writeFileSync(`${outputDir}/home.html`, contents);
Führen Sie das Skript erneut aus, und wir sehen einen neuen Ordner im Stammverzeichnis namens public, und darin sehen wir home.html. Darin ist unser transformiertes Dokument gespeichert!
<!doctype html><html lang="en">
<head>
<meta charset="utf-8">
<title>A Transformed Document!</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>Hello World!</h1>
<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"><figcaption>An adorable corgi!</figcaption></figure>
<p>Some more text goes here.</p>
</body>
</html>
Wenn wir public/home.html im Browser öffnen, sehen wir unser transformiertes Markdown, das als figure mit einer Bildunterschrift gerendert wird.

Heiliger Bimbam! Schauen Sie sich diesen bezaubernden Corgi an! Und wir wissen, dass er bezaubernd ist, weil die Bildunterschrift es uns sagt.
Was als Nächstes tun
Dateien mithilfe von ASTs zu transformieren ist extrem mächtig – damit können wir so ziemlich alles erstellen, was wir uns vorstellen können, auf sichere Weise. Keine Regexes oder String-Parsing erforderlich!
Von hier aus können Sie tiefer in das Ökosystem von Plugins für remark und rehype eintauchen, um mehr über die Möglichkeiten zu erfahren und weitere Ideen zu bekommen, was Sie mit AST-Transformationen tun können, vom Erstellen Ihres eigenen Markdown-gesteuerten statischen Website-Generators über die Automatisierung von Leistungsverbesserungen durch Modifikation von Code an Ort und Stelle bis hin zu allem, was Sie sich vorstellen können!
AST-Transformation ist eine Coding-Superkraft. Beginnen Sie, indem Sie den Quellcode dieser Demo überprüfen – ich kann es kaum erwarten zu sehen, was Sie damit bauen! Teilen Sie Ihre Projekte mit mir auf Twitter.
Wow, ASTs sind definitiv etwas, über das ich mehr lernen wollte, und das hier ist ein ausgezeichneter Einstieg. Eine der mysteriösen APIs, die normalerweise außerhalb des Userlands liegt. Es scheint, dass viele großartige Tools ASTs für Dinge wie fortgeschrittene Linting-Regeln, Unit-Test-Generierung und sogar Komponentengenerierung aus Mocks von Programmen wie Sketch nutzen!
Kleine Anmerkung: Unter dem ersten Markdown-Snippet ist das abgeleitete Markdown falsch. (Sieht so aus, als wurde das Beispiel aktualisiert.)