malte70.blog()

Automatische Markdown-Preview via PHP-Skript für Apache

Unter f.malte70.de habe ich eine öffentliche Ablage für alles Mögliche, z.B. Kochrezepte oder Notizen in Form von Markdown-Dateien. Und für ebendiese gibt es eine automatische Preview, wenn an den Dateinamen ein ?preview angehängt wird.

Mit der PHP-Bibliothek Parsedown lässt sich dies relativ einfach implementieren. Zusätzlich zum parsen des Markdown-Content extrahieren wir die Überschrift (um diese außerhalb des <main>-Elements anzuzeigen), und ermitteln die mtime der Datei (Last Modification Time), welche in einer Fußzeile angezeigt wird.

Markdown-Parser mit Parsedown

Das Github-Repo von Parsedown wurde oberhalb des DocumentRoot geklont. Die Verwendung von Parsedown ist einfach und der folgende Code sollte selbsterklärend sein:

require_once("../parsedown/Parsedown.php");

$filename = @$_GET["file"]

$Parsedown = new Parsedown();
$html = $Parsedown->text(file_get_contents($filename));

Überschrift extrahieren

Nun extrahieren wir die erste Level 1-Überschrift aus dem generierten HTML-Code. Falls der reguläre Ausdruck keine Übereinstimmung liefert, wird stattdessen der Dateiname ohne Suffix als Überschrift verwendet:

// RegEx auf $html anwenden
preg_match(
	"'<h1>(.*?)</h1>'si",
	$html,
	$preg_match_h1
);

if ($preg_match_h1) {
	// Übereinstimmung gefunden
	$headline = $preg_match_h1[1];
} else {
	// Keine Übereinstimmung. Dateiname ohne Suffix als Überschrift nutzen
	$headline = str_replace(".md", "", basename($filename));
}

mtime der Markdown-Datei ermitteln

Mit der PHP-Funktion filemtime() ermitteln wir die UNIX-Zeit der letzten Dateiänderung. Bevor diese mit strftime() in ein menschenlesbares Format umgewandelt wird, setzen wir die Locale für Datumsformate, LC_TIME kurzfristig auf deutsch. Achtung: Die Locale-Einstellungen gelten pro Prozess, nicht pro Thread. Um andere PHP-Skripte auf dem selben Server nicht zu behindern, muss die Locale sofort nach dem Aufruf von strftime() wieder auf den vorher gespeicherten Wert zurückgesetzt werden.

// Aktuellen Wert von LC_TIME ermitteln
$original_locale = setlocale(LC_TIME, 0);
// Deutsche Locale
setlocale(LC_TIME, "de_DE.utf8");
// Formattierung wie folgt: Do, 26 Okt 2023 13:37:42 +0100
$mtime = strftime(
	"%a, %d %b %Y %T %z",
	filemtime($filename)
);
// Gespeicherte Locale wieder anwenden
setlocale(LC_TIME, $original_locale);

Fehlerseiten für 404 und 403

Für den Fall, dass die Markdown-Datei nicht existiert oder nicht lesbar ist, müssen wir uns selbst um die Darstellung einer 404er bzw. 403er-Fehlerseite kümmern. Die Pfade in $error_pages zeigen auf eine unter /srv/http/example.com/error-document/ geklonte Kopie meines Github-Repos malte70/error-document; du kannst natürlich auch deine eigenen Fehlerseiten verwenden.

Wichtig ist, dass wir über die Funktion header() den HTTP-Status-Code auf 404 bzw. 403 ändern. Folgender Code sollte am Anfang des Skripts stehen, bevor das Parsedown-Objekt erstellt wird.

$error_pages = Array(
	"403" => "/srv/http/example.com/error-document/403.html",
	"404" => "/srv/http/example.com/error-document/404.html"
);

if (empty($filename) || !file_exists($filename)) {
	// Markdown file not found → Error 404
	header("HTTP/1.0 404 Not Found");
	die(file_get_contents($error_pages["404"]));

} elseif (!is_readable($filename)) {
	// File not readable → Error 403
	header("HTTP/1.0 403 Forbidden");
	die(file_get_contents($error_pages["403"]));
}

HTML

Es folgt ein einfaches HTML-Grundgerüst, welches auch auf f.malte70.de verwendet wird:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
		<title><?=$headline?></title>
		
		<link rel="shortcut icon" type="image/png" sizes="512x512" href="/assets/img/tango-floppy-512.png">
		<link rel="stylesheet" href="/assets/css/markdown.css">
	</head>
	<body>
		<header>
			<h1>
				<?=$headline?>
			</h1>
		</header>
		
		<main>
			<section id="markdown">
<?=$html?>
			</section>
		</main>
		
		<footer>
			<p>
				<a href="<?=$filename?>" download><?=$filename?></a> &middot; Letzte Änderung: <?=$mtime?>
			</p>
		</footer>
	</body>
</html>

Als Favicon wird eine auf 512×512px skalierte PNG-Version des gemeinfreien Floppy-Icons aus dem Tango Icon Theme. Das Stylesheet kann unter xyz.malte70.de/css/markdown.css heruntergeladen und unter den Bedingungen der MIT-Lizenz genutzt werden; es wäre zu lang, um es hier in diesem Beitrag anzugeben.

Das komplette Skript

Hier nocheinmal das komplette Skript markdown_preview.php:

 1<?php
 2require_once("../parsedown/Parsedown.php");
 3
 4$filename = @$_GET["file"]
 5
 6$error_pages = Array(
 7	"403" => "/srv/http/example.com/error-document/403.html",
 8	"404" => "/srv/http/example.com/error-document/404.html"
 9);
10
11if (empty($filename) || !file_exists($filename)) {
12	// Markdown file not found → Error 404
13	header("HTTP/1.0 404 Not Found");
14	die(file_get_contents($error_pages["404"]));
15
16} elseif (!is_readable($filename)) {
17	// File not readable → Error 403
18	header("HTTP/1.0 403 Forbidden");
19	die(file_get_contents($error_pages["403"]));
20}
21
22$Parsedown = new Parsedown();
23$html = $Parsedown->text(file_get_contents($filename));
24
25// RegEx auf $html anwenden
26preg_match(
27	"'<h1>(.*?)</h1>'si",
28	$html,
29	$preg_match_h1
30);
31if ($preg_match_h1) {
32	// Übereinstimmung gefunden
33	$headline = $preg_match_h1[1];
34} else {
35	// Keine Übereinstimmung. Dateiname ohne Suffix als Überschrift nutzen
36	$headline = str_replace(".md", "", basename($filename));
37}
38
39// Aktuellen Wert von LC_TIME ermitteln
40$original_locale = setlocale(LC_TIME, 0);
41// Deutsche Locale
42setlocale(LC_TIME, "de_DE.utf8");
43// Formattierung wie folgt: Do, 26 Okt 2023 13:37:42 +0100
44$mtime = strftime(
45	"%a, %d %b %Y %T %z",
46	filemtime($filename)
47);
48// Gespeicherte Locale wieder anwenden
49setlocale(LC_TIME, $original_locale);
50
51?><!DOCTYPE html>
52<html lang="en">
53	<head>
54		<meta charset="utf-8">
55		<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
56		<title><?=$headline?></title>
57		
58		<link rel="shortcut icon" type="image/png" sizes="512x512" href="/assets/img/tango-floppy-512.png">
59		<link rel="stylesheet" href="/assets/css/markdown.css">
60	</head>
61	<body>
62		<header>
63			<h1>
64				<?=$headline?>
65			</h1>
66		</header>
67		
68		<main>
69			<section id="markdown">
70<?=$html?>
71			</section>
72		</main>
73		
74		<footer>
75			<p>
76				<a href="<?=$filename?>" download><?=$filename?></a> &middot; Letzte Änderung: <?=$mtime?>
77			</p>
78		</footer>
79	</body>
80</html>

mod_rewrite-Regeln

Es fehlt noch eine RewriteRule in der .htaccess, die filename.md?preview zu markdown_preview.php?file=filename.md umleitet:

<IfModule mod_rewrite.c>
	RewriteEngine On
	
	RewriteCond %{QUERY_STRING} ^preview$
	RewriteCond %{REQUEST_FILENAME} -f
	RewriteRule ^(.+)\.md$ markdown_preview.php?file=$1.md [L]
</IfModule>