├── .gitignore ├── Dockerfile ├── Parsedown.php ├── README.md ├── img └── screenshot.webp ├── index.php ├── parsedown.css └── render.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules/ 3 | npm-debug.log 4 | package-lock.json 5 | 6 | # Logs 7 | logs 8 | *.log 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Build 16 | dist/ 17 | build/ 18 | out/ 19 | 20 | # Dependencies 21 | /.pnp 22 | .pnp.js 23 | 24 | # Environment 25 | .env 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | # IDE 32 | .idea/ 33 | .vscode/ 34 | *.swp 35 | *.swo 36 | 37 | # OS 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # Testing 42 | /coverage 43 | 44 | # Misc 45 | *.bak 46 | *.tmp 47 | *.temp 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a lightweight PHP image 2 | FROM php:8.2-alpine 3 | 4 | # Set working directory 5 | WORKDIR /var/www/html 6 | 7 | # Copy PHP files 8 | COPY index.php . 9 | COPY Parsedown.php . 10 | COPY parsedown.css . 11 | 12 | # Expose port 8000 for Render 13 | EXPOSE 8000 14 | 15 | # Start the built-in PHP server 16 | CMD ["php", "-S", "0.0.0.0:8000", "-t", "/var/www/html"] 17 | -------------------------------------------------------------------------------- /Parsedown.php: -------------------------------------------------------------------------------- 1 | textElements($text); 27 | 28 | # convert to markup 29 | $markup = $this->elements($Elements); 30 | 31 | # trim line breaks 32 | $markup = trim($markup, "\n"); 33 | 34 | return $markup; 35 | } 36 | 37 | protected function textElements($text) 38 | { 39 | # make sure no definitions are set 40 | $this->DefinitionData = array(); 41 | 42 | # standardize line breaks 43 | $text = str_replace(array("\r\n", "\r"), "\n", $text); 44 | 45 | # remove surrounding line breaks 46 | $text = trim($text, "\n"); 47 | 48 | # split text into lines 49 | $lines = explode("\n", $text); 50 | 51 | # iterate through lines to identify blocks 52 | return $this->linesElements($lines); 53 | } 54 | 55 | # 56 | # Setters 57 | # 58 | 59 | function setBreaksEnabled($breaksEnabled) 60 | { 61 | $this->breaksEnabled = $breaksEnabled; 62 | 63 | return $this; 64 | } 65 | 66 | protected $breaksEnabled; 67 | 68 | function setMarkupEscaped($markupEscaped) 69 | { 70 | $this->markupEscaped = $markupEscaped; 71 | 72 | return $this; 73 | } 74 | 75 | protected $markupEscaped; 76 | 77 | function setUrlsLinked($urlsLinked) 78 | { 79 | $this->urlsLinked = $urlsLinked; 80 | 81 | return $this; 82 | } 83 | 84 | protected $urlsLinked = true; 85 | 86 | function setSafeMode($safeMode) 87 | { 88 | $this->safeMode = (bool) $safeMode; 89 | 90 | return $this; 91 | } 92 | 93 | protected $safeMode; 94 | 95 | function setStrictMode($strictMode) 96 | { 97 | $this->strictMode = (bool) $strictMode; 98 | 99 | return $this; 100 | } 101 | 102 | protected $strictMode; 103 | 104 | protected $safeLinksWhitelist = array( 105 | 'http://', 106 | 'https://', 107 | 'ftp://', 108 | 'ftps://', 109 | 'mailto:', 110 | 'tel:', 111 | 'data:image/png;base64,', 112 | 'data:image/gif;base64,', 113 | 'data:image/jpeg;base64,', 114 | 'irc:', 115 | 'ircs:', 116 | 'git:', 117 | 'ssh:', 118 | 'news:', 119 | 'steam:', 120 | ); 121 | 122 | # 123 | # Lines 124 | # 125 | 126 | protected $BlockTypes = array( 127 | '#' => array('Header'), 128 | '*' => array('Rule', 'List'), 129 | '+' => array('List'), 130 | '-' => array('SetextHeader', 'Table', 'Rule', 'List'), 131 | '0' => array('List'), 132 | '1' => array('List'), 133 | '2' => array('List'), 134 | '3' => array('List'), 135 | '4' => array('List'), 136 | '5' => array('List'), 137 | '6' => array('List'), 138 | '7' => array('List'), 139 | '8' => array('List'), 140 | '9' => array('List'), 141 | ':' => array('Table'), 142 | '<' => array('Comment', 'Markup'), 143 | '=' => array('SetextHeader'), 144 | '>' => array('Quote'), 145 | '[' => array('Reference'), 146 | '_' => array('Rule'), 147 | '`' => array('FencedCode'), 148 | '|' => array('Table'), 149 | '~' => array('FencedCode'), 150 | ); 151 | 152 | # ~ 153 | 154 | protected $unmarkedBlockTypes = array( 155 | 'Code', 156 | ); 157 | 158 | # 159 | # Blocks 160 | # 161 | 162 | protected function lines(array $lines) 163 | { 164 | return $this->elements($this->linesElements($lines)); 165 | } 166 | 167 | protected function linesElements(array $lines) 168 | { 169 | $Elements = array(); 170 | $CurrentBlock = null; 171 | 172 | foreach ($lines as $line) 173 | { 174 | if (chop($line) === '') 175 | { 176 | if (isset($CurrentBlock)) 177 | { 178 | $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) 179 | ? $CurrentBlock['interrupted'] + 1 : 1 180 | ); 181 | } 182 | 183 | continue; 184 | } 185 | 186 | while (($beforeTab = strstr($line, "\t", true)) !== false) 187 | { 188 | $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; 189 | 190 | $line = $beforeTab 191 | . str_repeat(' ', $shortage) 192 | . substr($line, strlen($beforeTab) + 1) 193 | ; 194 | } 195 | 196 | $indent = strspn($line, ' '); 197 | 198 | $text = $indent > 0 ? substr($line, $indent) : $line; 199 | 200 | # ~ 201 | 202 | $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); 203 | 204 | # ~ 205 | 206 | if (isset($CurrentBlock['continuable'])) 207 | { 208 | $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; 209 | $Block = $this->$methodName($Line, $CurrentBlock); 210 | 211 | if (isset($Block)) 212 | { 213 | $CurrentBlock = $Block; 214 | 215 | continue; 216 | } 217 | else 218 | { 219 | if ($this->isBlockCompletable($CurrentBlock['type'])) 220 | { 221 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 222 | $CurrentBlock = $this->$methodName($CurrentBlock); 223 | } 224 | } 225 | } 226 | 227 | # ~ 228 | 229 | $marker = $text[0]; 230 | 231 | # ~ 232 | 233 | $blockTypes = $this->unmarkedBlockTypes; 234 | 235 | if (isset($this->BlockTypes[$marker])) 236 | { 237 | foreach ($this->BlockTypes[$marker] as $blockType) 238 | { 239 | $blockTypes []= $blockType; 240 | } 241 | } 242 | 243 | # 244 | # ~ 245 | 246 | foreach ($blockTypes as $blockType) 247 | { 248 | $Block = $this->{"block$blockType"}($Line, $CurrentBlock); 249 | 250 | if (isset($Block)) 251 | { 252 | $Block['type'] = $blockType; 253 | 254 | if ( ! isset($Block['identified'])) 255 | { 256 | if (isset($CurrentBlock)) 257 | { 258 | $Elements[] = $this->extractElement($CurrentBlock); 259 | } 260 | 261 | $Block['identified'] = true; 262 | } 263 | 264 | if ($this->isBlockContinuable($blockType)) 265 | { 266 | $Block['continuable'] = true; 267 | } 268 | 269 | $CurrentBlock = $Block; 270 | 271 | continue 2; 272 | } 273 | } 274 | 275 | # ~ 276 | 277 | if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') 278 | { 279 | $Block = $this->paragraphContinue($Line, $CurrentBlock); 280 | } 281 | 282 | if (isset($Block)) 283 | { 284 | $CurrentBlock = $Block; 285 | } 286 | else 287 | { 288 | if (isset($CurrentBlock)) 289 | { 290 | $Elements[] = $this->extractElement($CurrentBlock); 291 | } 292 | 293 | $CurrentBlock = $this->paragraph($Line); 294 | 295 | $CurrentBlock['identified'] = true; 296 | } 297 | } 298 | 299 | # ~ 300 | 301 | if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) 302 | { 303 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 304 | $CurrentBlock = $this->$methodName($CurrentBlock); 305 | } 306 | 307 | # ~ 308 | 309 | if (isset($CurrentBlock)) 310 | { 311 | $Elements[] = $this->extractElement($CurrentBlock); 312 | } 313 | 314 | # ~ 315 | 316 | return $Elements; 317 | } 318 | 319 | protected function extractElement(array $Component) 320 | { 321 | if ( ! isset($Component['element'])) 322 | { 323 | if (isset($Component['markup'])) 324 | { 325 | $Component['element'] = array('rawHtml' => $Component['markup']); 326 | } 327 | elseif (isset($Component['hidden'])) 328 | { 329 | $Component['element'] = array(); 330 | } 331 | } 332 | 333 | return $Component['element']; 334 | } 335 | 336 | protected function isBlockContinuable($Type) 337 | { 338 | return method_exists($this, 'block' . $Type . 'Continue'); 339 | } 340 | 341 | protected function isBlockCompletable($Type) 342 | { 343 | return method_exists($this, 'block' . $Type . 'Complete'); 344 | } 345 | 346 | # 347 | # Code 348 | 349 | protected function blockCode($Line, $Block = null) 350 | { 351 | if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) 352 | { 353 | return; 354 | } 355 | 356 | if ($Line['indent'] >= 4) 357 | { 358 | $text = substr($Line['body'], 4); 359 | 360 | $Block = array( 361 | 'element' => array( 362 | 'name' => 'pre', 363 | 'element' => array( 364 | 'name' => 'code', 365 | 'text' => $text, 366 | ), 367 | ), 368 | ); 369 | 370 | return $Block; 371 | } 372 | } 373 | 374 | protected function blockCodeContinue($Line, $Block) 375 | { 376 | if ($Line['indent'] >= 4) 377 | { 378 | if (isset($Block['interrupted'])) 379 | { 380 | $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); 381 | 382 | unset($Block['interrupted']); 383 | } 384 | 385 | $Block['element']['element']['text'] .= "\n"; 386 | 387 | $text = substr($Line['body'], 4); 388 | 389 | $Block['element']['element']['text'] .= $text; 390 | 391 | return $Block; 392 | } 393 | } 394 | 395 | protected function blockCodeComplete($Block) 396 | { 397 | return $Block; 398 | } 399 | 400 | # 401 | # Comment 402 | 403 | protected function blockComment($Line) 404 | { 405 | if ($this->markupEscaped or $this->safeMode) 406 | { 407 | return; 408 | } 409 | 410 | if (strpos($Line['text'], '') !== false) 420 | { 421 | $Block['closed'] = true; 422 | } 423 | 424 | return $Block; 425 | } 426 | } 427 | 428 | protected function blockCommentContinue($Line, array $Block) 429 | { 430 | if (isset($Block['closed'])) 431 | { 432 | return; 433 | } 434 | 435 | $Block['element']['rawHtml'] .= "\n" . $Line['body']; 436 | 437 | if (strpos($Line['text'], '-->') !== false) 438 | { 439 | $Block['closed'] = true; 440 | } 441 | 442 | return $Block; 443 | } 444 | 445 | # 446 | # Fenced Code 447 | 448 | protected function blockFencedCode($Line) 449 | { 450 | $marker = $Line['text'][0]; 451 | 452 | $openerLength = strspn($Line['text'], $marker); 453 | 454 | if ($openerLength < 3) 455 | { 456 | return; 457 | } 458 | 459 | $infostring = trim(substr($Line['text'], $openerLength), "\t "); 460 | 461 | if (strpos($infostring, '`') !== false) 462 | { 463 | return; 464 | } 465 | 466 | $Element = array( 467 | 'name' => 'code', 468 | 'text' => '', 469 | ); 470 | 471 | if ($infostring !== '') 472 | { 473 | /** 474 | * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes 475 | * Every HTML element may have a class attribute specified. 476 | * The attribute, if specified, must have a value that is a set 477 | * of space-separated tokens representing the various classes 478 | * that the element belongs to. 479 | * [...] 480 | * The space characters, for the purposes of this specification, 481 | * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), 482 | * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and 483 | * U+000D CARRIAGE RETURN (CR). 484 | */ 485 | $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); 486 | 487 | $Element['attributes'] = array('class' => "language-$language"); 488 | } 489 | 490 | $Block = array( 491 | 'char' => $marker, 492 | 'openerLength' => $openerLength, 493 | 'element' => array( 494 | 'name' => 'pre', 495 | 'element' => $Element, 496 | ), 497 | ); 498 | 499 | return $Block; 500 | } 501 | 502 | protected function blockFencedCodeContinue($Line, $Block) 503 | { 504 | if (isset($Block['complete'])) 505 | { 506 | return; 507 | } 508 | 509 | if (isset($Block['interrupted'])) 510 | { 511 | $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); 512 | 513 | unset($Block['interrupted']); 514 | } 515 | 516 | if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] 517 | and chop(substr($Line['text'], $len), ' ') === '' 518 | ) { 519 | $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); 520 | 521 | $Block['complete'] = true; 522 | 523 | return $Block; 524 | } 525 | 526 | $Block['element']['element']['text'] .= "\n" . $Line['body']; 527 | 528 | return $Block; 529 | } 530 | 531 | protected function blockFencedCodeComplete($Block) 532 | { 533 | return $Block; 534 | } 535 | 536 | # 537 | # Header 538 | 539 | protected function blockHeader($Line) 540 | { 541 | $level = strspn($Line['text'], '#'); 542 | 543 | if ($level > 6) 544 | { 545 | return; 546 | } 547 | 548 | $text = trim($Line['text'], '#'); 549 | 550 | if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') 551 | { 552 | return; 553 | } 554 | 555 | $text = trim($text, ' '); 556 | 557 | $Block = array( 558 | 'element' => array( 559 | 'name' => 'h' . $level, 560 | 'handler' => array( 561 | 'function' => 'lineElements', 562 | 'argument' => $text, 563 | 'destination' => 'elements', 564 | ) 565 | ), 566 | ); 567 | 568 | return $Block; 569 | } 570 | 571 | # 572 | # List 573 | 574 | protected function blockList($Line, ?array $CurrentBlock = null) 575 | { 576 | list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); 577 | 578 | if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) 579 | { 580 | $contentIndent = strlen($matches[2]); 581 | 582 | if ($contentIndent >= 5) 583 | { 584 | $contentIndent -= 1; 585 | $matches[1] = substr($matches[1], 0, -$contentIndent); 586 | $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; 587 | } 588 | elseif ($contentIndent === 0) 589 | { 590 | $matches[1] .= ' '; 591 | } 592 | 593 | $markerWithoutWhitespace = strstr($matches[1], ' ', true); 594 | 595 | $Block = array( 596 | 'indent' => $Line['indent'], 597 | 'pattern' => $pattern, 598 | 'data' => array( 599 | 'type' => $name, 600 | 'marker' => $matches[1], 601 | 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), 602 | ), 603 | 'element' => array( 604 | 'name' => $name, 605 | 'elements' => array(), 606 | ), 607 | ); 608 | $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); 609 | 610 | if ($name === 'ol') 611 | { 612 | $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; 613 | 614 | if ($listStart !== '1') 615 | { 616 | if ( 617 | isset($CurrentBlock) 618 | and $CurrentBlock['type'] === 'Paragraph' 619 | and ! isset($CurrentBlock['interrupted']) 620 | ) { 621 | return; 622 | } 623 | 624 | $Block['element']['attributes'] = array('start' => $listStart); 625 | } 626 | } 627 | 628 | $Block['li'] = array( 629 | 'name' => 'li', 630 | 'handler' => array( 631 | 'function' => 'li', 632 | 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), 633 | 'destination' => 'elements' 634 | ) 635 | ); 636 | 637 | $Block['element']['elements'] []= & $Block['li']; 638 | 639 | return $Block; 640 | } 641 | } 642 | 643 | protected function blockListContinue($Line, array $Block) 644 | { 645 | if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) 646 | { 647 | return null; 648 | } 649 | 650 | $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); 651 | 652 | if ($Line['indent'] < $requiredIndent 653 | and ( 654 | ( 655 | $Block['data']['type'] === 'ol' 656 | and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) 657 | ) or ( 658 | $Block['data']['type'] === 'ul' 659 | and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) 660 | ) 661 | ) 662 | ) { 663 | if (isset($Block['interrupted'])) 664 | { 665 | $Block['li']['handler']['argument'] []= ''; 666 | 667 | $Block['loose'] = true; 668 | 669 | unset($Block['interrupted']); 670 | } 671 | 672 | unset($Block['li']); 673 | 674 | $text = isset($matches[1]) ? $matches[1] : ''; 675 | 676 | $Block['indent'] = $Line['indent']; 677 | 678 | $Block['li'] = array( 679 | 'name' => 'li', 680 | 'handler' => array( 681 | 'function' => 'li', 682 | 'argument' => array($text), 683 | 'destination' => 'elements' 684 | ) 685 | ); 686 | 687 | $Block['element']['elements'] []= & $Block['li']; 688 | 689 | return $Block; 690 | } 691 | elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) 692 | { 693 | return null; 694 | } 695 | 696 | if ($Line['text'][0] === '[' and $this->blockReference($Line)) 697 | { 698 | return $Block; 699 | } 700 | 701 | if ($Line['indent'] >= $requiredIndent) 702 | { 703 | if (isset($Block['interrupted'])) 704 | { 705 | $Block['li']['handler']['argument'] []= ''; 706 | 707 | $Block['loose'] = true; 708 | 709 | unset($Block['interrupted']); 710 | } 711 | 712 | $text = substr($Line['body'], $requiredIndent); 713 | 714 | $Block['li']['handler']['argument'] []= $text; 715 | 716 | return $Block; 717 | } 718 | 719 | if ( ! isset($Block['interrupted'])) 720 | { 721 | $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']); 722 | 723 | $Block['li']['handler']['argument'] []= $text; 724 | 725 | return $Block; 726 | } 727 | } 728 | 729 | protected function blockListComplete(array $Block) 730 | { 731 | if (isset($Block['loose'])) 732 | { 733 | foreach ($Block['element']['elements'] as &$li) 734 | { 735 | if (end($li['handler']['argument']) !== '') 736 | { 737 | $li['handler']['argument'] []= ''; 738 | } 739 | } 740 | } 741 | 742 | return $Block; 743 | } 744 | 745 | # 746 | # Quote 747 | 748 | protected function blockQuote($Line) 749 | { 750 | if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) 751 | { 752 | $Block = array( 753 | 'element' => array( 754 | 'name' => 'blockquote', 755 | 'handler' => array( 756 | 'function' => 'linesElements', 757 | 'argument' => (array) $matches[1], 758 | 'destination' => 'elements', 759 | ) 760 | ), 761 | ); 762 | 763 | return $Block; 764 | } 765 | } 766 | 767 | protected function blockQuoteContinue($Line, array $Block) 768 | { 769 | if (isset($Block['interrupted'])) 770 | { 771 | return; 772 | } 773 | 774 | if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) 775 | { 776 | $Block['element']['handler']['argument'] []= $matches[1]; 777 | 778 | return $Block; 779 | } 780 | 781 | if ( ! isset($Block['interrupted'])) 782 | { 783 | $Block['element']['handler']['argument'] []= $Line['text']; 784 | 785 | return $Block; 786 | } 787 | } 788 | 789 | # 790 | # Rule 791 | 792 | protected function blockRule($Line) 793 | { 794 | $marker = $Line['text'][0]; 795 | 796 | if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') 797 | { 798 | $Block = array( 799 | 'element' => array( 800 | 'name' => 'hr', 801 | ), 802 | ); 803 | 804 | return $Block; 805 | } 806 | } 807 | 808 | # 809 | # Setext 810 | 811 | protected function blockSetextHeader($Line, ?array $Block = null) 812 | { 813 | if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) 814 | { 815 | return; 816 | } 817 | 818 | if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') 819 | { 820 | $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; 821 | 822 | return $Block; 823 | } 824 | } 825 | 826 | # 827 | # Markup 828 | 829 | protected function blockMarkup($Line) 830 | { 831 | if ($this->markupEscaped or $this->safeMode) 832 | { 833 | return; 834 | } 835 | 836 | if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) 837 | { 838 | $element = strtolower($matches[1]); 839 | 840 | if (in_array($element, $this->textLevelElements)) 841 | { 842 | return; 843 | } 844 | 845 | $Block = array( 846 | 'name' => $matches[1], 847 | 'element' => array( 848 | 'rawHtml' => $Line['text'], 849 | 'autobreak' => true, 850 | ), 851 | ); 852 | 853 | return $Block; 854 | } 855 | } 856 | 857 | protected function blockMarkupContinue($Line, array $Block) 858 | { 859 | if (isset($Block['closed']) or isset($Block['interrupted'])) 860 | { 861 | return; 862 | } 863 | 864 | $Block['element']['rawHtml'] .= "\n" . $Line['body']; 865 | 866 | return $Block; 867 | } 868 | 869 | # 870 | # Reference 871 | 872 | protected function blockReference($Line) 873 | { 874 | if (strpos($Line['text'], ']') !== false 875 | and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) 876 | ) { 877 | $id = strtolower($matches[1]); 878 | 879 | $Data = array( 880 | 'url' => $matches[2], 881 | 'title' => isset($matches[3]) ? $matches[3] : null, 882 | ); 883 | 884 | $this->DefinitionData['Reference'][$id] = $Data; 885 | 886 | $Block = array( 887 | 'element' => array(), 888 | ); 889 | 890 | return $Block; 891 | } 892 | } 893 | 894 | # 895 | # Table 896 | 897 | protected function blockTable($Line, ?array $Block = null) 898 | { 899 | if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) 900 | { 901 | return; 902 | } 903 | 904 | if ( 905 | strpos($Block['element']['handler']['argument'], '|') === false 906 | and strpos($Line['text'], '|') === false 907 | and strpos($Line['text'], ':') === false 908 | or strpos($Block['element']['handler']['argument'], "\n") !== false 909 | ) { 910 | return; 911 | } 912 | 913 | if (chop($Line['text'], ' -:|') !== '') 914 | { 915 | return; 916 | } 917 | 918 | $alignments = array(); 919 | 920 | $divider = $Line['text']; 921 | 922 | $divider = trim($divider); 923 | $divider = trim($divider, '|'); 924 | 925 | $dividerCells = explode('|', $divider); 926 | 927 | foreach ($dividerCells as $dividerCell) 928 | { 929 | $dividerCell = trim($dividerCell); 930 | 931 | if ($dividerCell === '') 932 | { 933 | return; 934 | } 935 | 936 | $alignment = null; 937 | 938 | if ($dividerCell[0] === ':') 939 | { 940 | $alignment = 'left'; 941 | } 942 | 943 | if (substr($dividerCell, - 1) === ':') 944 | { 945 | $alignment = $alignment === 'left' ? 'center' : 'right'; 946 | } 947 | 948 | $alignments []= $alignment; 949 | } 950 | 951 | # ~ 952 | 953 | $HeaderElements = array(); 954 | 955 | $header = $Block['element']['handler']['argument']; 956 | 957 | $header = trim($header); 958 | $header = trim($header, '|'); 959 | 960 | $headerCells = explode('|', $header); 961 | 962 | if (count($headerCells) !== count($alignments)) 963 | { 964 | return; 965 | } 966 | 967 | foreach ($headerCells as $index => $headerCell) 968 | { 969 | $headerCell = trim($headerCell); 970 | 971 | $HeaderElement = array( 972 | 'name' => 'th', 973 | 'handler' => array( 974 | 'function' => 'lineElements', 975 | 'argument' => $headerCell, 976 | 'destination' => 'elements', 977 | ) 978 | ); 979 | 980 | if (isset($alignments[$index])) 981 | { 982 | $alignment = $alignments[$index]; 983 | 984 | $HeaderElement['attributes'] = array( 985 | 'style' => "text-align: $alignment;", 986 | ); 987 | } 988 | 989 | $HeaderElements []= $HeaderElement; 990 | } 991 | 992 | # ~ 993 | 994 | $Block = array( 995 | 'alignments' => $alignments, 996 | 'identified' => true, 997 | 'element' => array( 998 | 'name' => 'table', 999 | 'elements' => array(), 1000 | ), 1001 | ); 1002 | 1003 | $Block['element']['elements'] []= array( 1004 | 'name' => 'thead', 1005 | ); 1006 | 1007 | $Block['element']['elements'] []= array( 1008 | 'name' => 'tbody', 1009 | 'elements' => array(), 1010 | ); 1011 | 1012 | $Block['element']['elements'][0]['elements'] []= array( 1013 | 'name' => 'tr', 1014 | 'elements' => $HeaderElements, 1015 | ); 1016 | 1017 | return $Block; 1018 | } 1019 | 1020 | protected function blockTableContinue($Line, array $Block) 1021 | { 1022 | if (isset($Block['interrupted'])) 1023 | { 1024 | return; 1025 | } 1026 | 1027 | if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) 1028 | { 1029 | $Elements = array(); 1030 | 1031 | $row = $Line['text']; 1032 | 1033 | $row = trim($row); 1034 | $row = trim($row, '|'); 1035 | 1036 | preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); 1037 | 1038 | $cells = array_slice($matches[0], 0, count($Block['alignments'])); 1039 | 1040 | foreach ($cells as $index => $cell) 1041 | { 1042 | $cell = trim($cell); 1043 | 1044 | $Element = array( 1045 | 'name' => 'td', 1046 | 'handler' => array( 1047 | 'function' => 'lineElements', 1048 | 'argument' => $cell, 1049 | 'destination' => 'elements', 1050 | ) 1051 | ); 1052 | 1053 | if (isset($Block['alignments'][$index])) 1054 | { 1055 | $Element['attributes'] = array( 1056 | 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', 1057 | ); 1058 | } 1059 | 1060 | $Elements []= $Element; 1061 | } 1062 | 1063 | $Element = array( 1064 | 'name' => 'tr', 1065 | 'elements' => $Elements, 1066 | ); 1067 | 1068 | $Block['element']['elements'][1]['elements'] []= $Element; 1069 | 1070 | return $Block; 1071 | } 1072 | } 1073 | 1074 | # 1075 | # ~ 1076 | # 1077 | 1078 | protected function paragraph($Line) 1079 | { 1080 | return array( 1081 | 'type' => 'Paragraph', 1082 | 'element' => array( 1083 | 'name' => 'p', 1084 | 'handler' => array( 1085 | 'function' => 'lineElements', 1086 | 'argument' => $Line['text'], 1087 | 'destination' => 'elements', 1088 | ), 1089 | ), 1090 | ); 1091 | } 1092 | 1093 | protected function paragraphContinue($Line, array $Block) 1094 | { 1095 | if (isset($Block['interrupted'])) 1096 | { 1097 | return; 1098 | } 1099 | 1100 | $Block['element']['handler']['argument'] .= "\n".$Line['text']; 1101 | 1102 | return $Block; 1103 | } 1104 | 1105 | # 1106 | # Inline Elements 1107 | # 1108 | 1109 | protected $InlineTypes = array( 1110 | '!' => array('Image'), 1111 | '&' => array('SpecialCharacter'), 1112 | '*' => array('Emphasis'), 1113 | ':' => array('Url'), 1114 | '<' => array('UrlTag', 'EmailTag', 'Markup'), 1115 | '[' => array('Link'), 1116 | '_' => array('Emphasis'), 1117 | '`' => array('Code'), 1118 | '~' => array('Strikethrough'), 1119 | '\\' => array('EscapeSequence'), 1120 | ); 1121 | 1122 | # ~ 1123 | 1124 | protected $inlineMarkerList = '!*_&[:<`~\\'; 1125 | 1126 | # 1127 | # ~ 1128 | # 1129 | 1130 | public function line($text, $nonNestables = array()) 1131 | { 1132 | return $this->elements($this->lineElements($text, $nonNestables)); 1133 | } 1134 | 1135 | protected function lineElements($text, $nonNestables = array()) 1136 | { 1137 | # standardize line breaks 1138 | $text = str_replace(array("\r\n", "\r"), "\n", $text); 1139 | 1140 | $Elements = array(); 1141 | 1142 | $nonNestables = (empty($nonNestables) 1143 | ? array() 1144 | : array_combine($nonNestables, $nonNestables) 1145 | ); 1146 | 1147 | # $excerpt is based on the first occurrence of a marker 1148 | 1149 | while ($excerpt = strpbrk($text, $this->inlineMarkerList)) 1150 | { 1151 | $marker = $excerpt[0]; 1152 | 1153 | $markerPosition = strlen($text) - strlen($excerpt); 1154 | 1155 | $Excerpt = array('text' => $excerpt, 'context' => $text); 1156 | 1157 | foreach ($this->InlineTypes[$marker] as $inlineType) 1158 | { 1159 | # check to see if the current inline type is nestable in the current context 1160 | 1161 | if (isset($nonNestables[$inlineType])) 1162 | { 1163 | continue; 1164 | } 1165 | 1166 | $Inline = $this->{"inline$inlineType"}($Excerpt); 1167 | 1168 | if ( ! isset($Inline)) 1169 | { 1170 | continue; 1171 | } 1172 | 1173 | # makes sure that the inline belongs to "our" marker 1174 | 1175 | if (isset($Inline['position']) and $Inline['position'] > $markerPosition) 1176 | { 1177 | continue; 1178 | } 1179 | 1180 | # sets a default inline position 1181 | 1182 | if ( ! isset($Inline['position'])) 1183 | { 1184 | $Inline['position'] = $markerPosition; 1185 | } 1186 | 1187 | # cause the new element to 'inherit' our non nestables 1188 | 1189 | 1190 | $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) 1191 | ? array_merge($Inline['element']['nonNestables'], $nonNestables) 1192 | : $nonNestables 1193 | ; 1194 | 1195 | # the text that comes before the inline 1196 | $unmarkedText = substr($text, 0, $Inline['position']); 1197 | 1198 | # compile the unmarked text 1199 | $InlineText = $this->inlineText($unmarkedText); 1200 | $Elements[] = $InlineText['element']; 1201 | 1202 | # compile the inline 1203 | $Elements[] = $this->extractElement($Inline); 1204 | 1205 | # remove the examined text 1206 | $text = substr($text, $Inline['position'] + $Inline['extent']); 1207 | 1208 | continue 2; 1209 | } 1210 | 1211 | # the marker does not belong to an inline 1212 | 1213 | $unmarkedText = substr($text, 0, $markerPosition + 1); 1214 | 1215 | $InlineText = $this->inlineText($unmarkedText); 1216 | $Elements[] = $InlineText['element']; 1217 | 1218 | $text = substr($text, $markerPosition + 1); 1219 | } 1220 | 1221 | $InlineText = $this->inlineText($text); 1222 | $Elements[] = $InlineText['element']; 1223 | 1224 | foreach ($Elements as &$Element) 1225 | { 1226 | if ( ! isset($Element['autobreak'])) 1227 | { 1228 | $Element['autobreak'] = false; 1229 | } 1230 | } 1231 | 1232 | return $Elements; 1233 | } 1234 | 1235 | # 1236 | # ~ 1237 | # 1238 | 1239 | protected function inlineText($text) 1240 | { 1241 | $Inline = array( 1242 | 'extent' => strlen($text), 1243 | 'element' => array(), 1244 | ); 1245 | 1246 | $Inline['element']['elements'] = self::pregReplaceElements( 1247 | $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', 1248 | array( 1249 | array('name' => 'br'), 1250 | array('text' => "\n"), 1251 | ), 1252 | $text 1253 | ); 1254 | 1255 | return $Inline; 1256 | } 1257 | 1258 | protected function inlineCode($Excerpt) 1259 | { 1260 | $marker = $Excerpt['text'][0]; 1261 | 1262 | if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), 1269 | 'element' => array( 1270 | 'name' => 'code', 1271 | 'text' => $text, 1272 | ), 1273 | ); 1274 | } 1275 | } 1276 | 1277 | protected function inlineEmailTag($Excerpt) 1278 | { 1279 | $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; 1280 | 1281 | $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' 1282 | . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; 1283 | 1284 | if (strpos($Excerpt['text'], '>') !== false 1285 | and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) 1286 | ){ 1287 | $url = $matches[1]; 1288 | 1289 | if ( ! isset($matches[2])) 1290 | { 1291 | $url = "mailto:$url"; 1292 | } 1293 | 1294 | return array( 1295 | 'extent' => strlen($matches[0]), 1296 | 'element' => array( 1297 | 'name' => 'a', 1298 | 'text' => $matches[1], 1299 | 'attributes' => array( 1300 | 'href' => $url, 1301 | ), 1302 | ), 1303 | ); 1304 | } 1305 | } 1306 | 1307 | protected function inlineEmphasis($Excerpt) 1308 | { 1309 | if ( ! isset($Excerpt['text'][1])) 1310 | { 1311 | return; 1312 | } 1313 | 1314 | $marker = $Excerpt['text'][0]; 1315 | 1316 | if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) 1317 | { 1318 | $emphasis = 'strong'; 1319 | } 1320 | elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) 1321 | { 1322 | $emphasis = 'em'; 1323 | } 1324 | else 1325 | { 1326 | return; 1327 | } 1328 | 1329 | return array( 1330 | 'extent' => strlen($matches[0]), 1331 | 'element' => array( 1332 | 'name' => $emphasis, 1333 | 'handler' => array( 1334 | 'function' => 'lineElements', 1335 | 'argument' => $matches[1], 1336 | 'destination' => 'elements', 1337 | ) 1338 | ), 1339 | ); 1340 | } 1341 | 1342 | protected function inlineEscapeSequence($Excerpt) 1343 | { 1344 | if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) 1345 | { 1346 | return array( 1347 | 'element' => array('rawHtml' => $Excerpt['text'][1]), 1348 | 'extent' => 2, 1349 | ); 1350 | } 1351 | } 1352 | 1353 | protected function inlineImage($Excerpt) 1354 | { 1355 | if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') 1356 | { 1357 | return; 1358 | } 1359 | 1360 | $Excerpt['text']= substr($Excerpt['text'], 1); 1361 | 1362 | $Link = $this->inlineLink($Excerpt); 1363 | 1364 | if ($Link === null) 1365 | { 1366 | return; 1367 | } 1368 | 1369 | $Inline = array( 1370 | 'extent' => $Link['extent'] + 1, 1371 | 'element' => array( 1372 | 'name' => 'img', 1373 | 'attributes' => array( 1374 | 'src' => $Link['element']['attributes']['href'], 1375 | 'alt' => $Link['element']['handler']['argument'], 1376 | ), 1377 | 'autobreak' => true, 1378 | ), 1379 | ); 1380 | 1381 | $Inline['element']['attributes'] += $Link['element']['attributes']; 1382 | 1383 | unset($Inline['element']['attributes']['href']); 1384 | 1385 | return $Inline; 1386 | } 1387 | 1388 | protected function inlineLink($Excerpt) 1389 | { 1390 | $Element = array( 1391 | 'name' => 'a', 1392 | 'handler' => array( 1393 | 'function' => 'lineElements', 1394 | 'argument' => null, 1395 | 'destination' => 'elements', 1396 | ), 1397 | 'nonNestables' => array('Url', 'Link'), 1398 | 'attributes' => array( 1399 | 'href' => null, 1400 | 'title' => null, 1401 | ), 1402 | ); 1403 | 1404 | $extent = 0; 1405 | 1406 | $remainder = $Excerpt['text']; 1407 | 1408 | if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) 1409 | { 1410 | $Element['handler']['argument'] = $matches[1]; 1411 | 1412 | $extent += strlen($matches[0]); 1413 | 1414 | $remainder = substr($remainder, $extent); 1415 | } 1416 | else 1417 | { 1418 | return; 1419 | } 1420 | 1421 | if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) 1422 | { 1423 | $Element['attributes']['href'] = $matches[1]; 1424 | 1425 | if (isset($matches[2])) 1426 | { 1427 | $Element['attributes']['title'] = substr($matches[2], 1, - 1); 1428 | } 1429 | 1430 | $extent += strlen($matches[0]); 1431 | } 1432 | else 1433 | { 1434 | if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) 1435 | { 1436 | $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; 1437 | $definition = strtolower($definition); 1438 | 1439 | $extent += strlen($matches[0]); 1440 | } 1441 | else 1442 | { 1443 | $definition = strtolower($Element['handler']['argument']); 1444 | } 1445 | 1446 | if ( ! isset($this->DefinitionData['Reference'][$definition])) 1447 | { 1448 | return; 1449 | } 1450 | 1451 | $Definition = $this->DefinitionData['Reference'][$definition]; 1452 | 1453 | $Element['attributes']['href'] = $Definition['url']; 1454 | $Element['attributes']['title'] = $Definition['title']; 1455 | } 1456 | 1457 | return array( 1458 | 'extent' => $extent, 1459 | 'element' => $Element, 1460 | ); 1461 | } 1462 | 1463 | protected function inlineMarkup($Excerpt) 1464 | { 1465 | if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) 1466 | { 1467 | return; 1468 | } 1469 | 1470 | if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) 1471 | { 1472 | return array( 1473 | 'element' => array('rawHtml' => $matches[0]), 1474 | 'extent' => strlen($matches[0]), 1475 | ); 1476 | } 1477 | 1478 | if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) 1479 | { 1480 | return array( 1481 | 'element' => array('rawHtml' => $matches[0]), 1482 | 'extent' => strlen($matches[0]), 1483 | ); 1484 | } 1485 | 1486 | if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) 1487 | { 1488 | return array( 1489 | 'element' => array('rawHtml' => $matches[0]), 1490 | 'extent' => strlen($matches[0]), 1491 | ); 1492 | } 1493 | } 1494 | 1495 | protected function inlineSpecialCharacter($Excerpt) 1496 | { 1497 | if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false 1498 | and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) 1499 | ) { 1500 | return array( 1501 | 'element' => array('rawHtml' => '&' . $matches[1] . ';'), 1502 | 'extent' => strlen($matches[0]), 1503 | ); 1504 | } 1505 | 1506 | return; 1507 | } 1508 | 1509 | protected function inlineStrikethrough($Excerpt) 1510 | { 1511 | if ( ! isset($Excerpt['text'][1])) 1512 | { 1513 | return; 1514 | } 1515 | 1516 | if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) 1517 | { 1518 | return array( 1519 | 'extent' => strlen($matches[0]), 1520 | 'element' => array( 1521 | 'name' => 'del', 1522 | 'handler' => array( 1523 | 'function' => 'lineElements', 1524 | 'argument' => $matches[1], 1525 | 'destination' => 'elements', 1526 | ) 1527 | ), 1528 | ); 1529 | } 1530 | } 1531 | 1532 | protected function inlineUrl($Excerpt) 1533 | { 1534 | if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') 1535 | { 1536 | return; 1537 | } 1538 | 1539 | if (strpos($Excerpt['context'], 'http') !== false 1540 | and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) 1541 | ) { 1542 | $url = $matches[0][0]; 1543 | 1544 | $Inline = array( 1545 | 'extent' => strlen($matches[0][0]), 1546 | 'position' => $matches[0][1], 1547 | 'element' => array( 1548 | 'name' => 'a', 1549 | 'text' => $url, 1550 | 'attributes' => array( 1551 | 'href' => $url, 1552 | ), 1553 | ), 1554 | ); 1555 | 1556 | return $Inline; 1557 | } 1558 | } 1559 | 1560 | protected function inlineUrlTag($Excerpt) 1561 | { 1562 | if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) 1563 | { 1564 | $url = $matches[1]; 1565 | 1566 | return array( 1567 | 'extent' => strlen($matches[0]), 1568 | 'element' => array( 1569 | 'name' => 'a', 1570 | 'text' => $url, 1571 | 'attributes' => array( 1572 | 'href' => $url, 1573 | ), 1574 | ), 1575 | ); 1576 | } 1577 | } 1578 | 1579 | # ~ 1580 | 1581 | protected function unmarkedText($text) 1582 | { 1583 | $Inline = $this->inlineText($text); 1584 | return $this->element($Inline['element']); 1585 | } 1586 | 1587 | # 1588 | # Handlers 1589 | # 1590 | 1591 | protected function handle(array $Element) 1592 | { 1593 | if (isset($Element['handler'])) 1594 | { 1595 | if (!isset($Element['nonNestables'])) 1596 | { 1597 | $Element['nonNestables'] = array(); 1598 | } 1599 | 1600 | if (is_string($Element['handler'])) 1601 | { 1602 | $function = $Element['handler']; 1603 | $argument = $Element['text']; 1604 | unset($Element['text']); 1605 | $destination = 'rawHtml'; 1606 | } 1607 | else 1608 | { 1609 | $function = $Element['handler']['function']; 1610 | $argument = $Element['handler']['argument']; 1611 | $destination = $Element['handler']['destination']; 1612 | } 1613 | 1614 | $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); 1615 | 1616 | if ($destination === 'handler') 1617 | { 1618 | $Element = $this->handle($Element); 1619 | } 1620 | 1621 | unset($Element['handler']); 1622 | } 1623 | 1624 | return $Element; 1625 | } 1626 | 1627 | protected function handleElementRecursive(array $Element) 1628 | { 1629 | return $this->elementApplyRecursive(array($this, 'handle'), $Element); 1630 | } 1631 | 1632 | protected function handleElementsRecursive(array $Elements) 1633 | { 1634 | return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); 1635 | } 1636 | 1637 | protected function elementApplyRecursive($closure, array $Element) 1638 | { 1639 | $Element = call_user_func($closure, $Element); 1640 | 1641 | if (isset($Element['elements'])) 1642 | { 1643 | $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); 1644 | } 1645 | elseif (isset($Element['element'])) 1646 | { 1647 | $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); 1648 | } 1649 | 1650 | return $Element; 1651 | } 1652 | 1653 | protected function elementApplyRecursiveDepthFirst($closure, array $Element) 1654 | { 1655 | if (isset($Element['elements'])) 1656 | { 1657 | $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); 1658 | } 1659 | elseif (isset($Element['element'])) 1660 | { 1661 | $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); 1662 | } 1663 | 1664 | $Element = call_user_func($closure, $Element); 1665 | 1666 | return $Element; 1667 | } 1668 | 1669 | protected function elementsApplyRecursive($closure, array $Elements) 1670 | { 1671 | foreach ($Elements as &$Element) 1672 | { 1673 | $Element = $this->elementApplyRecursive($closure, $Element); 1674 | } 1675 | 1676 | return $Elements; 1677 | } 1678 | 1679 | protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) 1680 | { 1681 | foreach ($Elements as &$Element) 1682 | { 1683 | $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); 1684 | } 1685 | 1686 | return $Elements; 1687 | } 1688 | 1689 | protected function element(array $Element) 1690 | { 1691 | if ($this->safeMode) 1692 | { 1693 | $Element = $this->sanitiseElement($Element); 1694 | } 1695 | 1696 | # identity map if element has no handler 1697 | $Element = $this->handle($Element); 1698 | 1699 | $hasName = isset($Element['name']); 1700 | 1701 | $markup = ''; 1702 | 1703 | if ($hasName) 1704 | { 1705 | $markup .= '<' . $Element['name']; 1706 | 1707 | if (isset($Element['attributes'])) 1708 | { 1709 | foreach ($Element['attributes'] as $name => $value) 1710 | { 1711 | if ($value === null) 1712 | { 1713 | continue; 1714 | } 1715 | 1716 | $markup .= " $name=\"".self::escape($value).'"'; 1717 | } 1718 | } 1719 | } 1720 | 1721 | $permitRawHtml = false; 1722 | 1723 | if (isset($Element['text'])) 1724 | { 1725 | $text = $Element['text']; 1726 | } 1727 | // very strongly consider an alternative if you're writing an 1728 | // extension 1729 | elseif (isset($Element['rawHtml'])) 1730 | { 1731 | $text = $Element['rawHtml']; 1732 | 1733 | $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; 1734 | $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; 1735 | } 1736 | 1737 | $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); 1738 | 1739 | if ($hasContent) 1740 | { 1741 | $markup .= $hasName ? '>' : ''; 1742 | 1743 | if (isset($Element['elements'])) 1744 | { 1745 | $markup .= $this->elements($Element['elements']); 1746 | } 1747 | elseif (isset($Element['element'])) 1748 | { 1749 | $markup .= $this->element($Element['element']); 1750 | } 1751 | else 1752 | { 1753 | if (!$permitRawHtml) 1754 | { 1755 | $markup .= self::escape($text, true); 1756 | } 1757 | else 1758 | { 1759 | $markup .= $text; 1760 | } 1761 | } 1762 | 1763 | $markup .= $hasName ? '' : ''; 1764 | } 1765 | elseif ($hasName) 1766 | { 1767 | $markup .= ' />'; 1768 | } 1769 | 1770 | return $markup; 1771 | } 1772 | 1773 | protected function elements(array $Elements) 1774 | { 1775 | $markup = ''; 1776 | 1777 | $autoBreak = true; 1778 | 1779 | foreach ($Elements as $Element) 1780 | { 1781 | if (empty($Element)) 1782 | { 1783 | continue; 1784 | } 1785 | 1786 | $autoBreakNext = (isset($Element['autobreak']) 1787 | ? $Element['autobreak'] : isset($Element['name']) 1788 | ); 1789 | // (autobreak === false) covers both sides of an element 1790 | $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; 1791 | 1792 | $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); 1793 | $autoBreak = $autoBreakNext; 1794 | } 1795 | 1796 | $markup .= $autoBreak ? "\n" : ''; 1797 | 1798 | return $markup; 1799 | } 1800 | 1801 | # ~ 1802 | 1803 | protected function li($lines) 1804 | { 1805 | $Elements = $this->linesElements($lines); 1806 | 1807 | if ( ! in_array('', $lines) 1808 | and isset($Elements[0]) and isset($Elements[0]['name']) 1809 | and $Elements[0]['name'] === 'p' 1810 | ) { 1811 | unset($Elements[0]['name']); 1812 | } 1813 | 1814 | return $Elements; 1815 | } 1816 | 1817 | # 1818 | # AST Convenience 1819 | # 1820 | 1821 | /** 1822 | * Replace occurrences $regexp with $Elements in $text. Return an array of 1823 | * elements representing the replacement. 1824 | */ 1825 | protected static function pregReplaceElements($regexp, $Elements, $text) 1826 | { 1827 | $newElements = array(); 1828 | 1829 | while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) 1830 | { 1831 | $offset = $matches[0][1]; 1832 | $before = substr($text, 0, $offset); 1833 | $after = substr($text, $offset + strlen($matches[0][0])); 1834 | 1835 | $newElements[] = array('text' => $before); 1836 | 1837 | foreach ($Elements as $Element) 1838 | { 1839 | $newElements[] = $Element; 1840 | } 1841 | 1842 | $text = $after; 1843 | } 1844 | 1845 | $newElements[] = array('text' => $text); 1846 | 1847 | return $newElements; 1848 | } 1849 | 1850 | # 1851 | # Deprecated Methods 1852 | # 1853 | 1854 | function parse($text) 1855 | { 1856 | $markup = $this->text($text); 1857 | 1858 | return $markup; 1859 | } 1860 | 1861 | protected function sanitiseElement(array $Element) 1862 | { 1863 | static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; 1864 | static $safeUrlNameToAtt = array( 1865 | 'a' => 'href', 1866 | 'img' => 'src', 1867 | ); 1868 | 1869 | if ( ! isset($Element['name'])) 1870 | { 1871 | unset($Element['attributes']); 1872 | return $Element; 1873 | } 1874 | 1875 | if (isset($safeUrlNameToAtt[$Element['name']])) 1876 | { 1877 | $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); 1878 | } 1879 | 1880 | if ( ! empty($Element['attributes'])) 1881 | { 1882 | foreach ($Element['attributes'] as $att => $val) 1883 | { 1884 | # filter out badly parsed attribute 1885 | if ( ! preg_match($goodAttribute, $att)) 1886 | { 1887 | unset($Element['attributes'][$att]); 1888 | } 1889 | # dump onevent attribute 1890 | elseif (self::striAtStart($att, 'on')) 1891 | { 1892 | unset($Element['attributes'][$att]); 1893 | } 1894 | } 1895 | } 1896 | 1897 | return $Element; 1898 | } 1899 | 1900 | protected function filterUnsafeUrlInAttribute(array $Element, $attribute) 1901 | { 1902 | foreach ($this->safeLinksWhitelist as $scheme) 1903 | { 1904 | if (self::striAtStart($Element['attributes'][$attribute], $scheme)) 1905 | { 1906 | return $Element; 1907 | } 1908 | } 1909 | 1910 | $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); 1911 | 1912 | return $Element; 1913 | } 1914 | 1915 | # 1916 | # Static Methods 1917 | # 1918 | 1919 | protected static function escape($text, $allowQuotes = false) 1920 | { 1921 | return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); 1922 | } 1923 | 1924 | protected static function striAtStart($string, $needle) 1925 | { 1926 | $len = strlen($needle); 1927 | 1928 | if ($len > strlen($string)) 1929 | { 1930 | return false; 1931 | } 1932 | else 1933 | { 1934 | return strtolower(substr($string, 0, $len)) === strtolower($needle); 1935 | } 1936 | } 1937 | 1938 | static function instance($name = 'default') 1939 | { 1940 | if (isset(self::$instances[$name])) 1941 | { 1942 | return self::$instances[$name]; 1943 | } 1944 | 1945 | $instance = new static(); 1946 | 1947 | self::$instances[$name] = $instance; 1948 | 1949 | return $instance; 1950 | } 1951 | 1952 | private static $instances = array(); 1953 | 1954 | # 1955 | # Fields 1956 | # 1957 | 1958 | protected $DefinitionData; 1959 | 1960 | # 1961 | # Read-Only 1962 | 1963 | protected $specialCharacters = array( 1964 | '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' 1965 | ); 1966 | 1967 | protected $StrongRegex = array( 1968 | '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', 1969 | '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', 1970 | ); 1971 | 1972 | protected $EmRegex = array( 1973 | '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', 1974 | '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', 1975 | ); 1976 | 1977 | protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; 1978 | 1979 | protected $voidElements = array( 1980 | 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 1981 | ); 1982 | 1983 | protected $textLevelElements = array( 1984 | 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', 1985 | 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', 1986 | 'i', 'rp', 'del', 'code', 'strike', 'marquee', 1987 | 'q', 'rt', 'ins', 'font', 'strong', 1988 | 's', 'tt', 'kbd', 'mark', 1989 | 'u', 'xm', 'sub', 'nobr', 1990 | 'sup', 'ruby', 1991 | 'var', 'span', 1992 | 'wbr', 'time', 1993 | ); 1994 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grok PHP 2 | 3 | A simple one-file PHP wrapper for the Grok API. 4 | 5 | ## Screenshot 6 | 7 | ![Screenshot](img/screenshot.webp) 8 | 9 | ## Features 10 | 11 | - Single-file PHP implementation for Grok AI 12 | - Support for both text and image inputs 13 | - Cloudflare AI Gateway integration 14 | - Session-based chat history 15 | - Mobile-friendly responsive design 16 | - Image upload and preview functionality 17 | 18 | ## Requirements 19 | 20 | - PHP 8.0 or higher 21 | - cURL extension enabled 22 | - Valid Grok API key 23 | 24 | ## Setup 25 | 26 | 1. Clone this repository: 27 | ```bash 28 | git clone https://github.com/yourusername/grok-php.git 29 | cd grok-php 30 | ``` 31 | 32 | 2. Create a `.env` file with your API credentials: 33 | ``` 34 | api-key=your-grok-api-key 35 | cf-account-id=your-cloudflare-account-id 36 | ``` 37 | Important: You need to create an AI Gateway named ai : [Create AI Gateway](https://dash.cloudflare.com/?to=/:account/ai/ai-gateway) 38 | 39 | 3. Start the PHP dev server: 40 | ```bash 41 | php -S localhost:8000 42 | ``` 43 | 44 | 4. Open your browser and navigate to `http://localhost:8000` 45 | 46 | ## Render Deployment 47 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/365cent/grok-chat) 48 | 49 | Get your Grok API key from [here](https://console.x.ai) 50 | 51 | Create a new [AI Gateway](https://developers.cloudflare.com/ai-gateway/get-started/) on Cloudflare (for free) and get the account id from [here](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/) 52 | 53 | Fill in the API key as `api-key` and the account id as `cf-account-id` in render environment variables 54 | 55 | Deploy the app 56 | 57 | Open the app and start chatting! 58 | 59 | **This app can be deployed 0.1vCPU 0.1GB RAM instance for free on Render** 60 | 61 | ## Thanks 62 | 63 | - [Grok API](https://x.ai) 64 | - [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) 65 | - [Render](https://render.com) 66 | - [grok-chat](https://github.com/365cent/grok-chat) 67 | -------------------------------------------------------------------------------- /img/screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raimbaulty/grok-chat/01cfa67d111db94a744105b170fb64339b6dc8d9/img/screenshot.webp -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | true, 'message' => '凭据已保存']); 13 | exit; 14 | } 15 | } 16 | 17 | /** 18 | * 确定要使用的模型 19 | * @param string $requestedModel 请求的模型 20 | * @param array|null $image 上传的图片 21 | * @return string 确定的模型 22 | */ 23 | function determineModel($requestedModel, $image = null) { 24 | // 如果有图片上传,强制使用视觉模型 25 | if ($image && $image["error"] === 0) { 26 | return MODEL_VISION; 27 | } 28 | 29 | // 处理模型选择 30 | if ($requestedModel === MODEL_IMAGE) { 31 | return MODEL_IMAGE; 32 | } else if ($requestedModel === MODEL_VISION) { 33 | return MODEL_VISION; 34 | } else if ($requestedModel === MODEL_REASONING) { 35 | return MODEL_REASONING; 36 | } else { 37 | return MODEL_CHAT; 38 | } 39 | } 40 | /** 41 | * 构建消息内容 42 | * @param string $message 文本消息 43 | * @param array|null $image 上传的图片 44 | * @return array 消息内容数组 45 | */ 46 | function buildMessageContent($message, $image = null) { 47 | $content = []; 48 | 49 | // 添加文本内容 50 | if ($message) { 51 | $content[] = [ 52 | "type" => "text", 53 | "text" => $message, 54 | ]; 55 | } 56 | 57 | // 添加图片内容 58 | if ($image && $image["error"] === 0) { 59 | $imageData = base64_encode(file_get_contents($image["tmp_name"])); 60 | $content[] = [ 61 | "type" => "image_url", 62 | "image_url" => [ 63 | "url" => "data:image/jpeg;base64," . $imageData, 64 | "detail" => "high", 65 | ], 66 | ]; 67 | } 68 | 69 | return $content; 70 | } 71 | 72 | require_once 'Parsedown.php'; 73 | 74 | // 配置环境参数 75 | if ($env = @parse_ini_file(".env")) { 76 | $_ENV["api-key"] = $env["api-key"]; 77 | $_ENV["cf-account-id"] = $env["cf-account-id"]; 78 | } elseif (getenv("api-key")) { 79 | $_ENV["api-key"] = getenv("api-key"); 80 | $_ENV["cf-account-id"] = getenv("cf-account-id"); 81 | } else { 82 | $_ENV["api-key"] = "YOUR_API_KEY"; // 确保替换为你的真实API密钥 83 | $_ENV["cf-account-id"] = "8c9f126e8236df7c3ecfb44264c18351"; // 确保替换为你的CF账户ID 84 | } 85 | 86 | if ($_SERVER["REQUEST_METHOD"] === "GET") { 87 | header("Cache-Control: public, max-age=120"); 88 | } 89 | 90 | // 检查是否为AJAX请求 91 | $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'; 92 | 93 | // 处理清除历史的 AJAX 请求 94 | if (isset($_GET['clear_history']) && $isAjax) { 95 | header('Content-Type: application/json'); 96 | echo json_encode([ 97 | 'success' => true, 98 | 'message' => '历史已清除' 99 | ]); 100 | exit; 101 | } 102 | 103 | // 处理保存响应的请求 104 | if (isset($_POST['saveResponse']) && $_POST['saveResponse'] === 'true') { 105 | if ($isAjax) { 106 | header('Content-Type: application/json'); 107 | echo json_encode([ 108 | 'success' => true 109 | ]); 110 | exit; 111 | } 112 | } 113 | 114 | // 流式响应处理器 115 | if (isset($_GET['stream'])) { 116 | header('Content-Type: text/event-stream'); 117 | header('Cache-Control: no-cache'); 118 | header('Connection: keep-alive'); 119 | header('X-Accel-Buffering: no'); // 禁用nginx缓冲 120 | ob_implicit_flush(true); // 强制输出缓冲区刷新 121 | if (ob_get_level() > 0) ob_end_flush(); 122 | 123 | if (function_exists('apache_setenv')) { 124 | apache_setenv('no-gzip', '1'); // 禁用Apache的gzip压缩 125 | } 126 | 127 | $msg = trim($_POST["message"] ?? ""); 128 | $requestedModel = $_POST["actual_model"] ?? $_POST["model"] ?? MODEL_CHAT; 129 | $image = $_FILES["image"] ?? null; 130 | 131 | // 使用辅助函数确定模型 132 | $model = determineModel($requestedModel, $image); 133 | 134 | // 使用辅助函数构建消息内容 135 | $content = buildMessageContent($msg, $image); 136 | 137 | // 构建消息数组,使用客户端提供的消息历史 138 | $messages = []; 139 | $messagesJson = $_POST["messages"] ?? "[]"; 140 | $messagesData = json_decode($messagesJson, true); 141 | 142 | if(is_array($messagesData)) { 143 | $messages = $messagesData; 144 | } 145 | 146 | // 添加用户消息 147 | $messages[] = ["role" => "user", "content" => $content]; 148 | 149 | // 获取客户端提供的API密钥和账户ID 150 | $apiKey = $_POST["api_key"] ?? $_ENV["api-key"]; 151 | $accountId = $_POST["account_id"] ?? $_ENV["cf-account-id"]; 152 | 153 | // 用流式处理聊天请求 154 | $ch = curl_init("https://gateway.ai.cloudflare.com/v1/".$accountId."/ai/grok/v1/chat/completions"); 155 | curl_setopt_array($ch, [ 156 | CURLOPT_RETURNTRANSFER => true, 157 | CURLOPT_POST => true, 158 | CURLOPT_POSTFIELDS => json_encode([ 159 | "model" => $model, 160 | "messages" => $messages, 161 | "stream" => true, 162 | ]), 163 | CURLOPT_HTTPHEADER => [ 164 | "Content-Type: application/json", 165 | "Authorization: Bearer " . $apiKey, 166 | ], 167 | CURLOPT_WRITEFUNCTION => function($curl, $data) { 168 | echo $data; 169 | flush(); 170 | return strlen($data); 171 | } 172 | ]); 173 | 174 | $response = curl_exec($ch); 175 | 176 | // 检查是否有错误 177 | if (curl_errno($ch)) { 178 | echo "data: " . json_encode(["error" => curl_error($ch)]) . "\n\n"; 179 | flush(); 180 | } 181 | 182 | curl_close($ch); 183 | exit; 184 | } 185 | 186 | // 图像生成处理器 187 | if (isset($_GET['generate_image'])) { 188 | header('Content-Type: application/json'); 189 | header('Cache-Control: no-cache'); 190 | 191 | $msg = trim($_POST["message"] ?? ""); 192 | 193 | // 图像生成始终使用图像模型 194 | $model = MODEL_IMAGE; 195 | 196 | if ($msg) { 197 | // 构建消息,使用客户端提供的消息历史 198 | $messages = []; 199 | $messagesJson = $_POST["messages"] ?? "[]"; 200 | $messagesData = json_decode($messagesJson, true); 201 | 202 | if(is_array($messagesData)) { 203 | $messages = $messagesData; 204 | } 205 | 206 | // 添加用户消息 207 | $messages[] = [ 208 | "role" => "user", 209 | "content" => [ 210 | [ 211 | "type" => "text", 212 | "text" => $msg, 213 | ] 214 | ] 215 | ]; 216 | 217 | // 获取客户端提供的API密钥和账户ID 218 | $apiKey = $_POST["api_key"] ?? $_ENV["api-key"]; 219 | $accountId = $_POST["account_id"] ?? $_ENV["cf-account-id"]; 220 | 221 | $ch = curl_init("https://gateway.ai.cloudflare.com/v1/".$accountId."/ai/grok/v1/images/generations"); 222 | curl_setopt_array($ch, [ 223 | CURLOPT_RETURNTRANSFER => true, 224 | CURLOPT_POST => true, 225 | CURLOPT_POSTFIELDS => json_encode([ 226 | "model" => MODEL_IMAGE, 227 | "prompt" => $msg, 228 | "n" => 1, 229 | "response_format" => "b64_json" 230 | ]), 231 | CURLOPT_HTTPHEADER => [ 232 | "Content-Type: application/json", 233 | "Authorization: Bearer " . $apiKey, 234 | ], 235 | ]); 236 | 237 | $response = curl_exec($ch); 238 | $res = json_decode($response, true); 239 | 240 | if (!empty($res["data"])) { 241 | $imageContent = []; 242 | foreach ($res["data"] as $image) { 243 | $imageContent[] = [ 244 | "type" => "image_url", 245 | "image_url" => [ 246 | "url" => "data:image/png;base64," . ($image["b64_json"] ?? ''), 247 | ], 248 | ]; 249 | } 250 | $responseMessage = [ 251 | "role" => "assistant", 252 | "content" => $imageContent, 253 | ]; 254 | 255 | $messages[] = $responseMessage; 256 | 257 | echo json_encode([ 258 | 'success' => true, 259 | 'images' => $imageContent, 260 | 'messages' => $messages 261 | ]); 262 | } else { 263 | $responseMessage = [ 264 | "role" => "assistant", 265 | "content" => [ 266 | [ 267 | "type" => "text", 268 | "text" => "图像生成失败。请稍后再试。" 269 | ] 270 | ] 271 | ]; 272 | 273 | $messages[] = $responseMessage; 274 | 275 | echo json_encode([ 276 | 'success' => false, 277 | 'error' => '图像生成失败。请稍后再试。', 278 | 'messages' => $messages 279 | ]); 280 | } 281 | 282 | curl_close($ch); 283 | } else { 284 | echo json_encode([ 285 | 'success' => false, 286 | 'error' => '请提供生成图像的提示文本。' 287 | ]); 288 | } 289 | 290 | exit; 291 | } 292 | 293 | if ($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST['saveResponse'])) { 294 | header('Content-Type: application/json'); 295 | echo json_encode(['success' => true]); 296 | exit; 297 | } 298 | ?> 299 | 300 | 301 | 302 | 303 | Grok Chat 304 | 305 | 306 | 307 | 308 | 538 | 539 | 540 |
541 |
542 |
543 |
544 |
545 | 546 |
547 | Grok 548 |
549 |
550 | 551 | 554 | 557 |
558 |
559 |
560 |
561 |
562 |
563 |
564 |
565 |
566 |
567 | 568 | 574 |
575 |
576 |
577 | 580 | 583 | 587 |
588 |
589 | 590 |
591 |
592 | 593 | 594 | 595 |
596 |
597 |
598 | 599 | 600 | 611 | 612 | 613 | 619 | 620 | 621 | 628 | 629 | 630 | 661 | 662 | 663 | 683 | 684 | 1859 | 1860 | -------------------------------------------------------------------------------- /parsedown.css: -------------------------------------------------------------------------------- 1 | .parsedown p { 2 | margin: 0 0 16px 0; 3 | padding: 0; 4 | } 5 | 6 | .parsedown h1, .parsedown h2, .parsedown h3, .parsedown h4, .parsedown h5, .parsedown h6 { 7 | font-weight: bold; 8 | color: #333333; 9 | } 10 | 11 | .parsedown h1 { 12 | font-size: 24px; 13 | margin: 24px 0 16px 0; 14 | border-bottom: 1px solid #cccccc; 15 | padding-bottom: 6px; 16 | } 17 | 18 | .parsedown h2 { 19 | font-size: 20px; 20 | margin: 20px 0 14px 0; 21 | padding-bottom: 4px; 22 | border-bottom: 1px solid #cccccc; 23 | } 24 | 25 | .parsedown h3 { 26 | font-size: 18px; 27 | margin: 18px 0 12px 0; 28 | } 29 | 30 | .parsedown h4, .parsedown h5, .parsedown h6 { 31 | font-size: 16px; 32 | margin: 16px 0 8px 0; 33 | } 34 | 35 | .parsedown ul, .parsedown ol { 36 | margin: 0 0 16px 0; 37 | padding: 0 0 0 20px; 38 | } 39 | 40 | .parsedown ul { 41 | list-style-type: disc; 42 | } 43 | 44 | .parsedown ol { 45 | list-style-type: decimal; 46 | } 47 | 48 | .parsedown ul li, .parsedown ol li { 49 | margin-bottom: 6px; 50 | } 51 | 52 | .parsedown blockquote { 53 | margin: 16px 0; 54 | padding: 8px 16px; 55 | border-left: 4px solid #cccccc; 56 | background-color: #f0f0f0; 57 | } 58 | 59 | .parsedown code, .parsedown pre { 60 | font-family: monospace; 61 | font-size: 14px; 62 | color: #000000; 63 | background-color: #f0f0f0; 64 | } 65 | 66 | .parsedown code { 67 | padding: 2px 4px; 68 | } 69 | 70 | .parsedown pre { 71 | padding: 12px; 72 | margin: 16px 0; 73 | overflow: auto; 74 | border: 1px solid #cccccc; 75 | border-radius: 0.3em; 76 | } 77 | 78 | .parsedown img { 79 | max-width: 100%; 80 | border: 1px solid #cccccc; 81 | margin: 16px 0; 82 | } 83 | 84 | .parsedown table { 85 | width: 100%; 86 | border-collapse: collapse; 87 | margin: 20px 0; 88 | font-size: 14px; 89 | line-height: 1.5; 90 | border: 1px solid #eaeaea; 91 | border-radius: 1em; 92 | overflow: hidden; 93 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); 94 | } 95 | 96 | .parsedown th, .parsedown td { 97 | padding: 12px 16px; 98 | border: 1px solid #eaeaea; 99 | text-align: left; 100 | } 101 | 102 | .parsedown th { 103 | background-color: #eeeeee; 104 | font-weight: 600; 105 | color: #000; 106 | font-size: 14px; 107 | border-bottom: 1px solid #eaeaea; 108 | } 109 | 110 | .parsedown tr:nth-child(even) td { 111 | background-color: #fafafa; 112 | } 113 | 114 | .parsedown tr:hover td { 115 | background-color: rgba(0, 0, 0, 0.02); 116 | transition: background-color 0.2s ease; 117 | } 118 | 119 | .parsedown a { 120 | color: #da7756; 121 | text-decoration: underline; 122 | } 123 | 124 | .parsedown strong { 125 | font-weight: bold; 126 | } 127 | 128 | .parsedown em { 129 | font-style: italic; 130 | } 131 | 132 | .parsedown hr { 133 | border: none; 134 | height: 1px; 135 | background-color: #cccccc; 136 | margin: 24px 0; 137 | } -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: grok-php 4 | env: docker 5 | repo: https://github.com/365cent/grok-chat 6 | dockerfilePath: ./Dockerfile 7 | healthCheckPath: / 8 | envVars: 9 | - key: api-key 10 | sync: false 11 | - key: cf-account-id 12 | sync: false 13 | autoDeploy: true 14 | plan: free 15 | --------------------------------------------------------------------------------