Bladeren bron

PHP port WIP

main
Rocketsoup 1 jaar geleden
bovenliggende
commit
7b9f48e942
3 gewijzigde bestanden met toevoegingen van 593 en 1 verwijderingen
  1. 1
    1
      js/markdown.js
  2. 11
    0
      markdownphp.php
  3. 581
    0
      php/markdown.php

+ 1
- 1
js/markdown.js Bestand weergeven

@@ -4097,7 +4097,7 @@ class MDHTMLTagNode extends MDInlineNode {
4097 4097
 }
4098 4098
 
4099 4099
 
4100
-// -- Other -----------------------------------------------------------------
4100
+// -- Main class ------------------------------------------------------------
4101 4101
 
4102 4102
 
4103 4103
 /**

+ 11
- 0
markdownphp.php Bestand weergeven

@@ -0,0 +1,11 @@
1
+<?php
2
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
3
+	$readerNames = $_POST['readers'];
4
+	$markdown = $_POST['markdown'];
5
+	print('Not yet implemented');
6
+	exit();
7
+}
8
+?>
9
+<!DOCTYPE html>
10
+<html>
11
+</html>

+ 581
- 0
php/markdown.php Bestand weergeven

@@ -0,0 +1,581 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+class MDUtils {
5
+	// Modified from https://urlregex.com/ to remove capture groups. Matches fully qualified URLs only.
6
+	public static $baseURLRegex = '(?:(?:(?:[a-z]{3,9}:(?:\\/\\/)?)(?:[\\-;:&=\\+\\$,\\w]+@)?[a-z0-9\\.\\-]+|(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)[a-z0-9\\.\\-]+)(?:(?:\\/[\\+~%\\/\\.\\w\\-_]*)?\\??(?:[\\-\\+=&;%@\\.\\w_]*)#?(?:[\\.\\!\\/\\\\\\w]*))?)';
7
+	// Modified from https://emailregex.com/ to remove capture groups.
8
+	public static $baseEmailRegex = '(?:(?:[^<>()\\[\\]\\\\.,;:\\s@"]+(?:\\.[^<>()\\[\\]\\\\.,;:\\s@"]+)*)|(?:".+"))@(?:(?:\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(?:(?:[a-z\\-0-9]+\\.)+[a-z]{2,}))';
9
+
10
+	/**
11
+	 * Encodes characters as HTML numeric entities to make it marginally more
12
+	 * difficult for web scrapers to grab sensitive info. If `text` starts with
13
+	 * `mailto:` only the email address following it will be obfuscated.
14
+	 */
15
+	public static function escapeObfuscated(string $text): string {
16
+		if (str_starts_with($text, 'mailto:')) {
17
+			return 'mailto:' . escapeObfuscated(mb_substr($text, 7));
18
+		}
19
+		$html = '';
20
+		$l = mb_strlen($text);
21
+		for ($p = 0; $p < $l; $p++) {
22
+			$cp = mb_ord(mb_substr($text, $p, 1));
23
+			$html .= "&#{{$cp}}";
24
+		}
25
+		return $html;
26
+	}
27
+
28
+	/**
29
+	 * Removes illegal characters from an HTML attribute name.
30
+	 */
31
+	public static function scrubAttributeName(string $name): string {
32
+		return mb_ereg_replace('[\\t\\n\\f \\/>"\'=]+', '');
33
+	}
34
+
35
+	/**
36
+	 * Strips one or more leading indents from a line or lines of markdown. An
37
+	 * indent is defined as 4 spaces or one tab. Incomplete indents (i.e. 1-3
38
+	 * spaces) are treated like one indent level.
39
+	 *
40
+	 * @param string|string[] $line
41
+	 * @param int $levels
42
+	 * @return string|string[]
43
+	 */
44
+	public static function stripIndent(string|array $line, int $levels=1): string|array {
45
+		$regex = "^(?: {1,4}|\\t){{$levels}}";
46
+		return is_array($line) ? array_map(fn(string $l): string => mb_ereg_replace($regex, '', $l)) : mb_ereg_replace($regex, '', $line);
47
+	}
48
+
49
+	/**
50
+	 * Counts the number of indent levels in a line of text. Partial indents
51
+	 * (1 to 3 spaces) are counted as one indent level unless `fullIndentsOnly`
52
+	 * is `true`.
53
+	 */
54
+	public static function countIndents(string $line, bool $fullIndentsOnly=false): int {
55
+		// normalize indents to tabs
56
+		$t = mb_ereg_replace($fullIndentsOnly ? "(?: {4}|\\t)" : "(?: {1,4}|\\t)", "\t", $line);
57
+		// remove content after indent
58
+		$t = mb_ereg_replace("^(\\t*)(.*?)$", "\\1", $t);
59
+		// count tabs
60
+		return mb_strlen($t);
61
+	}
62
+
63
+	/**
64
+	 * Returns a copy of an array without any whitespace-only lines at the end.
65
+	 *
66
+	 * @param string[] $lines
67
+	 * @return string[]
68
+	 */
69
+	public static function withoutTrailingBlankLines(array $lines): array {
70
+		$stripped = $lines;
71
+		while (sizeof($stripped) > 0 && sizeof(mb_trim($stripped[sizeof($stripped) - 1])) == 0) {
72
+			array_pop($stripped);
73
+		}
74
+		return $stripped;
75
+	}
76
+
77
+	/**
78
+	 * Tests if an array of lines contains at least one blank. A blank line
79
+	 * can contain whitespace.
80
+	 *
81
+	 * @param string[] $lines
82
+	 */
83
+	public static function containsBlankLine(array $lines): bool {
84
+		foreach ($lines as $line) {
85
+			if (mb_len(mb_trim($line)) == 0) return true;
86
+		}
87
+		return false;
88
+	}
89
+
90
+	public static function equalAssocArrays(array $a, array $b) {
91
+		return empty(array_diff_assoc($a, $b));
92
+	}
93
+}
94
+
95
+/**
96
+ * Token type enum for `MDToken`.
97
+ */
98
+enum MDTokenType {
99
+	case Text;
100
+	/**
101
+	 * Only used for the leading and trailing whitespace around a run of text,
102
+	 * not every single whitespace character.
103
+	 */
104
+	case Whitespace;
105
+
106
+	case Underscore;
107
+	case Asterisk;
108
+	case Slash;
109
+	case Tilde;
110
+	case Bang;
111
+	case Backtick;
112
+	case Equal;
113
+	case Caret;
114
+
115
+	case Label; // content=label
116
+	case URL; // content=URL, extra=title
117
+	case Email; // content=email address, extra=title
118
+	case SimpleLink; // content=URL
119
+	case SimpleEmail; // content=email address
120
+	case Footnote; // content=symbol
121
+	case Modifier; // modifier=MDTagModifier
122
+
123
+	case HTMLTag; // tag=MDHTMLTag
124
+
125
+	/** Wildcard for `MDToken.findFirstTokens` */
126
+	case META_AnyNonWhitespace;
127
+	/** Wildcard for `MDToken.findFirstTokens` */
128
+	case META_OptionalWhitespace;
129
+}
130
+
131
+/**
132
+ * Search results from `MDToken.findFirstTokens`.
133
+ */
134
+class MDTokenMatch {
135
+	/** @var MDToken{} */
136
+	public array $tokens;
137
+	public int $index;
138
+
139
+	/**
140
+	 * @param MDToken[] $tokens
141
+	 * @param int $index
142
+	 */
143
+	public function __construct($tokens, $index) {
144
+		$this->tokens = $tokens;
145
+		$this->index = $index;
146
+	}
147
+}
148
+
149
+/**
150
+ * Search results from `MDToken.findPairedTokens`.
151
+ */
152
+class MDPairedTokenMatch {
153
+	/** @var MDToken[] */
154
+	public array $startTokens;
155
+	/** @var MDToken[] */
156
+	public array $contentTokens;
157
+	/** @var MDToken[] */
158
+	public array $endTokens;
159
+	public int $startIndex;
160
+	public int $contentIndex;
161
+	public int $endIndex;
162
+	public int $totalLength;
163
+
164
+	public function __construct($startTokens, $contentTokens, $endTokens, $startIndex, $contentIndex, $endIndex, $totalLength) {
165
+		$this->startTokens = $startTokens;
166
+		$this->contentTokens = $contentTokens;
167
+		$this->endTokens = $endTokens;
168
+		$this->startIndex = $startIndex;
169
+		$this->contentIndex = $contentIndex;
170
+		$this->endIndex = $endIndex;
171
+		$this->totalLength = $totalLength;
172
+	}
173
+}
174
+
175
+/**
176
+ * One lexical unit in inline markdown syntax parsing.
177
+ */
178
+class MDToken {
179
+	/**
180
+	 * The original verbatim token string. Required as a plaintext fallback if
181
+	 * the token remains unresolved.
182
+	 */
183
+	public string $original;
184
+	public MDTokenType $type;
185
+	public ?string $content = null;
186
+	public ?string $extra = null;
187
+	public ?MDHTMLTag $tag = null;
188
+	public ?MDTagModifier $modifier = null;
189
+
190
+	/**
191
+	 * Creates a token.
192
+	 *
193
+	 * @param string original  verbatim token string
194
+	 * @param MDTokenType type  token type
195
+	 * @param string|MDTagModifier|MDHTMLTag|null content  primary content of the token
196
+	 * @param string|null extra  additional content
197
+	 */
198
+	public function __construct(string $original, MDTokenType $type,
199
+			string|MDTagModifier|MDHTMLTag|null $content=null,
200
+			?string $extra=null) {
201
+		$this->original = $original;
202
+		$this->type = $type;
203
+		if ($content instanceof MDTagModifier) {
204
+			$this->modifier = $content;
205
+		} elseif ($content instanceof MDHTMLTag) {
206
+			$this->tag = $content;
207
+		} else {
208
+			$this->content = $content;
209
+		}
210
+		$this->extra = $extra;
211
+	}
212
+
213
+	public function __toString() {
214
+		$classname = get_class($this);
215
+		return "({$classname} type={$this->type} content={$this->content})";
216
+	}
217
+
218
+	/**
219
+	 * Attempts to parse a label token from the beginning of `line`. A label is
220
+	 * of the form `[content]`. If found, returns an array:
221
+	 * - `0`: the entire label including brackets
222
+	 * - `1`: the content of the label
223
+	 *
224
+	 * @param string $line
225
+	 * @return ?string[] match groups or null if not found
226
+	 */
227
+	public static function tokenizeLabel(string $line): ?array {
228
+		if (!str_starts_with($line, '[')) return null;
229
+		$parenCount = 0;
230
+		$bracketCount = 0;
231
+		$l = mb_strlen($line);
232
+		for ($p = 1; $p < $l; $p++) {
233
+			$ch = mb_substr($line, $p, 1);
234
+			if ($ch == '\\') {
235
+				$p++;
236
+			} elseif ($ch == '(') {
237
+				$parenCount++;
238
+			} elseif ($ch == ')') {
239
+				$parenCount--;
240
+				if ($parenCount < 0) return null;
241
+			} elseif ($ch == '[') {
242
+				$bracketCount++;
243
+			} elseif ($ch == ']') {
244
+				if ($bracketCount > 0) {
245
+					$bracketCount--;
246
+				} else {
247
+					return [ mb_substr($line, 0, $p + 1), mb_substr($line, 1, $p) ];
248
+				}
249
+			}
250
+		}
251
+		return null;
252
+	}
253
+
254
+	private static $urlWithTitleRegex = '^\\((\\S+?)\\s+"(.*?)"\\)';  // 1=URL, 2=title
255
+	private static $urlRegex = '^\\((\\S+?)\\)';  // 1=URL
256
+
257
+	/**
258
+	 * Attempts to parse a URL token from the beginning of `line`. A URL token
259
+	 * is of the form `(url)` or `(url "title")`. If found, returns an array:
260
+	 * - `0`: the entire URL token including parentheses
261
+	 * - `1`: the URL
262
+	 * - `2`: the optional title, or `null`
263
+	 *
264
+	 * @param string $line
265
+	 * @return ?array token tuple
266
+	 */
267
+	public static function tokenizeURL(string $line): ?array {
268
+		$groups = [];
269
+		if (mb_eregi($urlWithTitleRegex, $line, $groups)) {
270
+			if (tokenizeEmail($line)) return null; // make sure it's not better described as an email address
271
+			return $groups;
272
+		}
273
+		if (mb_eregi($urlRegex, $line, $groups)) {
274
+			if (tokenizeEmail($line)) return null;
275
+			return [ $groups[0], $groups[1], null ];
276
+		}
277
+		return null;
278
+	}
279
+
280
+	/**
281
+	 * Attempts to parse an email address from the beginning of `line`. An
282
+	 * email address is of the form `(user@example.com)` or
283
+	 * `(user@example.com "link title")`. If found, returns an array:
284
+	 * - `0`: the entire token including parentheses
285
+	 * - `1`: the email address
286
+	 * - `2`: the optional link title, or `null`
287
+	 *
288
+	 * @param string $line
289
+	 * @return string[] token tuple
290
+	 */
291
+	public static function tokenizeEmail(string $line): array {
292
+		$groups;
293
+		if (mb_eregi("^\\(\\s*(" . MDUtils::$baseEmailRegex . ")\\s+\"(.*?)\"\\s*\\)",
294
+				$line, $groups)) {
295
+			return $groups;
296
+		}
297
+		if (mb_eregi("^\\(\\s*(" . MDUtils::$baseEmailRegex . ")\\s*\\)", $line, $groups)) {
298
+			return [ $groups[0], $groups[1], null ];
299
+		}
300
+		return null;
301
+	}
302
+
303
+	/**
304
+	 * Searches an array of `MDToken` for the given pattern of `MDTokenType`s.
305
+	 * If found, returns a `MDTokenMatch`, otherwise `null`.
306
+	 *
307
+	 * Special token types `META_AnyNonWhitespace` and `META_OptionalWhitespace`
308
+	 * are special supported token types. Note that `META_OptionalWhitespace`
309
+	 * may give a result with a variable number of tokens.
310
+	 *
311
+	 * @param (MDToken|MDNode)[] tokensToSearch - mixed array of `MDToken` and
312
+	 *   `MDNode` elements
313
+	 * @param MDTokenType[] pattern - contiguous run of token types to find
314
+	 * @param int startIndex - token index to begin searching (defaults to 0)
315
+	 * @return ?MDTokenMatch match object, or `null` if not found
316
+	 */
317
+	public static function findFirstTokens(array $tokensToSearch, array $pattern, int $startIndex=0): ?MDTokenMatch {
318
+		$matched = [];
319
+		for ($t = $startIndex; $t < sizeof($tokensToSearch); $t++) {
320
+			$matchedAll = true;
321
+			$matched = [];
322
+			$patternOffset = 0;
323
+			for ($p = 0; $p < mb_strlen($pattern); $p++) {
324
+				$t0 = $t + $p + $patternOffset;
325
+				if ($t0 >= sizeof($tokensToSearch)) return null;
326
+				$token = $tokensToSearch[$t0];
327
+				$elem = $pattern[$p];
328
+				if ($elem == MDTokenType::META_OptionalWhitespace) {
329
+					if ($token instanceof MDToken && $token->type == MDTokenType::Whitespace) {
330
+						array_push($matched, $token);
331
+					} else {
332
+						$patternOffset--;
333
+					}
334
+				} elseif ($elem == MDTokenType::META_AnyNonWhitespace) {
335
+					if ($token instanceof MDToken && $token->type == MDTokenType::Whitespace) {
336
+						$matchedAll = false;
337
+						break;
338
+					}
339
+					array_push($matched, $token);
340
+				} else {
341
+					if (!($token instanceof MDToken) || $token->type != $elem) {
342
+						$matchedAll = false;
343
+						break;
344
+					}
345
+					array_push($matched, $token);
346
+				}
347
+			}
348
+			if ($matchedAll) {
349
+				return new MDTokenMatch($matched, $t);
350
+			}
351
+		}
352
+		return null;
353
+	}
354
+
355
+	/**
356
+	 * Searches an array of MDToken for a given starting pattern and ending
357
+	 * pattern and returns match info about both and the tokens in between.
358
+	 *
359
+	 * If `contentValidator` is specified, it will be called with the content
360
+	 * tokens of a potential match. If the validator returns `true`, the result
361
+	 * will be accepted and returned by this method. If the validator returns
362
+	 * `false`, this method will keep looking for another matching pair. If no
363
+	 * validator is given the first match will be returned regardless of content.
364
+	 *
365
+	 * If a match is found, a `MDPairedTokenMatch` is returned with details
366
+	 * of the opening tokens, closing tokens, and content tokens between. Otherwise
367
+	 * `null` is returned.
368
+	 *
369
+	 * @param MDToken[] $tokensToSearch - array of `MDToken` to search in
370
+	 * @param MDTokenType[] $startPattern - array of `MDTokenType` to find first
371
+	 * @param MDTokenType[] $endPattern - array of `MDTokenType` to find positioned after `startPattern`
372
+	 * @param ?callable $contentValidator - optional validator function. If provided, will be passed an array of inner `MDToken`, and the function can return `true` to accept the contents or `false` to keep searching
373
+	 * @param number $startIndex - token index where searching should begin
374
+	 * @return ?MDPairedTokenMatch match, or `null`
375
+	 */
376
+	public static function findPairedTokens(array $tokensToSearch,
377
+			array $startPattern, array $endPattern, ?callable $contentValidator=null,
378
+			int $startIndex=0): ?MDPairedTokenMatch {
379
+		for ($s = $startIndex; $s < sizeof($tokensToSearch); $s++) {
380
+			$startMatch = findFirstTokens($tokensToSearch, $startPattern, $s);
381
+			if ($startMatch === null) return null;
382
+			$endStart = $startMatch->index + sizeof($startMatch->tokens);
383
+			while ($endStart < sizeof($tokensToSearch)) {
384
+				$endMatch = findFirstTokens($tokensToSearch, $endPattern, $endStart);
385
+				if ($endMatch === null) break;
386
+				$contentStart = $startMatch->index + sizeof($startMatch->tokens);
387
+				$contentLength = $endMatch->index - $contentStart;
388
+				$contents = array_slice($tokensToSearch, $contentStart, $contentLength);
389
+				if (sizeof($contents) > 0 && ($contentValidator === null || $contentValidator($contents))) {
390
+					return new MDPairedTokenMatch($startMatch->tokens,
391
+						$contents,
392
+						$endMatch->tokens,
393
+						$startMatch->index,
394
+						$startMatch->index + sizeof($startMatch->tokens),
395
+						$endMatch->index,
396
+						$endMatch->index + sizeof($endMatch->tokens) - $startMatch->index);
397
+				} else {
398
+					// Contents rejected. Try next end match.
399
+					$endStart = $endMatch->index + 1;
400
+				}
401
+			}
402
+			// No end matches. Increment start match.
403
+			$s = $startMatch->index;
404
+		}
405
+		return null;
406
+	}
407
+
408
+	public function equals($other) {
409
+		if (!($other instanceof MDToken)) return false;
410
+		if ($other->original !== $this->original) return false;
411
+		if ($other->type != $this->type) return false;
412
+		if ($other->content !== $this->content) return false;
413
+		if ($other->extra !== $this->extra) return false;
414
+		if ($other->tag !== $this->tag) return false;
415
+		if ($other->modifier != $this->modifier) return false;
416
+		return true;
417
+	}
418
+}
419
+
420
+class MDState {}
421
+
422
+class MDHTMLFilter {}
423
+
424
+class MDHTMLTag {}
425
+
426
+class MDTagModifier {}
427
+
428
+
429
+// -- Readers ---------------------------------------------------------------
430
+
431
+
432
+class MDReader {}
433
+
434
+class MDUnderlinedHeadingReader extends MDReader {}
435
+
436
+class MDHashHeadingReader extends MDReader {}
437
+
438
+class MDSubtextReader extends MDReader {}
439
+
440
+class MDBlockQuoteReader extends MDReader {}
441
+
442
+class _MDListReader extends MDReader {}
443
+
444
+class MDUnorderedListReader extends _MDListReader {}
445
+
446
+class MDOrderedListReader extends _MDListReader {}
447
+
448
+class MDFencedCodeBlockReader extends MDReader {}
449
+
450
+class MDIndentedCodeBlockReader extends MDReader {}
451
+
452
+class MDHorizontalRuleReader extends MDReader {}
453
+
454
+class MDTableReader extends MDReader {}
455
+
456
+class MDDefinitionListReader extends MDReader {}
457
+
458
+class MDFootnoteReader extends MDReader {}
459
+
460
+class MDAbbreviationReader extends MDReader {}
461
+
462
+class MDParagraphReader extends MDReader {}
463
+
464
+class MDSimplePairInlineReader extends MDReader {}
465
+
466
+class MDEmphasisReader extends MDSimplePairInlineReader {}
467
+
468
+class MDStrongReader extends MDSimplePairInlineReader {}
469
+
470
+class MDStrikethroughReader extends MDSimplePairInlineReader {}
471
+
472
+class MDUnderlineReader extends MDSimplePairInlineReader {}
473
+
474
+class MDHighlightReader extends MDSimplePairInlineReader {}
475
+
476
+class MDCodeSpanReader extends MDSimplePairInlineReader {}
477
+
478
+class MDSubscriptReader extends MDSimplePairInlineReader {}
479
+
480
+class MDSuperscriptReader extends MDSimplePairInlineReader {}
481
+
482
+class MDLinkReader extends MDReader {}
483
+
484
+class MDReferencedLinkReader extends MDLinkReader {}
485
+
486
+class MDImageReader extends MDLinkReader {}
487
+
488
+class MDReferencedImageReader extends MDReferencedLinkReader {}
489
+
490
+class MDLineBreakReader extends MDReader {}
491
+
492
+class MDHTMLTagReader extends MDReader {}
493
+
494
+class MDModifierReader extends MDReader {}
495
+
496
+
497
+// -- Nodes -----------------------------------------------------------------
498
+
499
+
500
+class MDNode {}
501
+
502
+class MDBlockNode extends MDNode {}
503
+
504
+class MDParagraphNode extends MDBlockNode {}
505
+
506
+class MDHeadingNode extends MDBlockNode {}
507
+
508
+class MDSubtextNode extends MDBlockNode {}
509
+
510
+class MDHorizontalRuleNode extends MDBlockNode {}
511
+
512
+class MDBlockquoteNode extends MDBlockNode {}
513
+
514
+class MDUnorderedListNode extends MDBlockNode {}
515
+
516
+class MDOrderedListNode extends MDBlockNode {}
517
+
518
+class MDListItemNode extends MDBlockNode {}
519
+
520
+class MDCodeBlockNode extends MDBlockNode {}
521
+
522
+class MDTableNode extends MDBlockNode {}
523
+
524
+class MDTableRowNode extends MDBlockNode {}
525
+
526
+class MDTableCellNode extends MDBlockNode {}
527
+
528
+class MDTableHeaderCellNode extends MDBlockNode {}
529
+
530
+class MDDefinitionListNode extends MDBlockNode {}
531
+
532
+class MDDefinitionListTermNode extends MDBlockNode {}
533
+
534
+class MDDefinitionListDefinitionNode extends MDBlockNode {}
535
+
536
+class MDFootnoteListNode extends MDBlockNode {}
537
+
538
+class MDInlineNode extends MDNode {}
539
+
540
+class MDTextNode extends MDInlineNode {}
541
+
542
+class MDObfuscatedTextNode extends MDTextNode {}
543
+
544
+class MDEmphasisNode extends MDInlineNode {}
545
+
546
+class MDStrongNode extends MDInlineNode {}
547
+
548
+class MDStrikethroughNode extends MDInlineNode {}
549
+
550
+class MDUnderlineNode extends MDInlineNode {}
551
+
552
+class MDHighlightNode extends MDInlineNode {}
553
+
554
+class MDSuperscriptNode extends MDInlineNode {}
555
+
556
+class MDSubscriptNode extends MDInlineNode {}
557
+
558
+class MDCodeNode extends MDInlineNode {}
559
+
560
+class MDFootnoteNode extends MDInlineNode {}
561
+
562
+class MDLinkNode extends MDInlineNode {}
563
+
564
+class MDReferencedLinkNode extends MDLinkNode {}
565
+
566
+class MDImageNode extends MDInlineNode {}
567
+
568
+class MDReferencedImageNode extends MDImageNode {}
569
+
570
+class MDAbbreviationNode extends MDInlineNode {}
571
+
572
+class MDLineBreakNode extends MDInlineNode {}
573
+
574
+class MDHTMLTagNode extends MDInlineNode {}
575
+
576
+
577
+// -- Main class ------------------------------------------------------------
578
+
579
+
580
+class Markdown {}
581
+?>

Laden…
Annuleren
Opslaan