markdown.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. <?php
  2. /*
  3. Copyright (c) 2009-2019 F3::Factory/Bong Cosca, All rights reserved.
  4. This file is part of the Fat-Free Framework (http://fatfreeframework.com).
  5. This is free software: you can redistribute it and/or modify it under the
  6. terms of the GNU General Public License as published by the Free Software
  7. Foundation, either version 3 of the License, or later.
  8. Fat-Free Framework is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  11. General Public License for more details.
  12. You should have received a copy of the GNU General Public License along
  13. with Fat-Free Framework. If not, see <http://www.gnu.org/licenses/>.
  14. */
  15. //! Markdown-to-HTML converter
  16. class Markdown extends Prefab {
  17. protected
  18. //! Parsing rules
  19. $blocks,
  20. //! Special characters
  21. $special;
  22. /**
  23. * Process blockquote
  24. * @return string
  25. * @param $str string
  26. **/
  27. protected function _blockquote($str) {
  28. $str=preg_replace('/(?<=^|\n)\h?>\h?(.*?(?:\n+|$))/','\1',$str);
  29. return strlen($str)?
  30. ('<blockquote>'.$this->build($str).'</blockquote>'."\n\n"):'';
  31. }
  32. /**
  33. * Process whitespace-prefixed code block
  34. * @return string
  35. * @param $str string
  36. **/
  37. protected function _pre($str) {
  38. $str=preg_replace('/(?<=^|\n)(?: {4}|\t)(.+?(?:\n+|$))/','\1',
  39. $this->esc($str));
  40. return strlen($str)?
  41. ('<pre><code>'.
  42. $this->esc($this->snip($str)).
  43. '</code></pre>'."\n\n"):
  44. '';
  45. }
  46. /**
  47. * Process fenced code block
  48. * @return string
  49. * @param $hint string
  50. * @param $str string
  51. **/
  52. protected function _fence($hint,$str) {
  53. $str=$this->snip($str);
  54. $fw=Base::instance();
  55. if ($fw->HIGHLIGHT) {
  56. switch (strtolower($hint)) {
  57. case 'php':
  58. $str=$fw->highlight($str);
  59. break;
  60. case 'apache':
  61. preg_match_all('/(?<=^|\n)(\h*)'.
  62. '(?:(<\/?)(\w+)((?:\h+[^>]+)*)(>)|'.
  63. '(?:(\w+)(\h.+?)))(\h*(?:\n+|$))/',
  64. $str,$matches,PREG_SET_ORDER);
  65. $out='';
  66. foreach ($matches as $match)
  67. $out.=$match[1].
  68. ($match[3]?
  69. ('<span class="section">'.
  70. $this->esc($match[2]).$match[3].
  71. '</span>'.
  72. ($match[4]?
  73. ('<span class="data">'.
  74. $this->esc($match[4]).
  75. '</span>'):
  76. '').
  77. '<span class="section">'.
  78. $this->esc($match[5]).
  79. '</span>'):
  80. ('<span class="directive">'.
  81. $match[6].
  82. '</span>'.
  83. '<span class="data">'.
  84. $this->esc($match[7]).
  85. '</span>')).
  86. $match[8];
  87. $str='<code>'.$out.'</code>';
  88. break;
  89. case 'html':
  90. preg_match_all(
  91. '/(?:(?:<(\/?)(\w+)'.
  92. '((?:\h+(?:\w+\h*=\h*)?".+?"|[^>]+)*|'.
  93. '\h+.+?)(\h*\/?)>)|(.+?))/s',
  94. $str,$matches,PREG_SET_ORDER
  95. );
  96. $out='';
  97. foreach ($matches as $match) {
  98. if ($match[2]) {
  99. $out.='<span class="xml_tag">&lt;'.
  100. $match[1].$match[2].'</span>';
  101. if ($match[3]) {
  102. preg_match_all(
  103. '/(?:\h+(?:(?:(\w+)\h*=\h*)?'.
  104. '(".+?")|(.+)))/',
  105. $match[3],$parts,PREG_SET_ORDER
  106. );
  107. foreach ($parts as $part)
  108. $out.=' '.
  109. (empty($part[3])?
  110. ((empty($part[1])?
  111. '':
  112. ('<span class="xml_attr">'.
  113. $part[1].'</span>=')).
  114. '<span class="xml_data">'.
  115. $part[2].'</span>'):
  116. ('<span class="xml_tag">'.
  117. $part[3].'</span>'));
  118. }
  119. $out.='<span class="xml_tag">'.
  120. $match[4].'&gt;</span>';
  121. }
  122. else
  123. $out.=$this->esc($match[5]);
  124. }
  125. $str='<code>'.$out.'</code>';
  126. break;
  127. case 'ini':
  128. preg_match_all(
  129. '/(?<=^|\n)(?:'.
  130. '(;[^\n]*)|(?:<\?php.+?\?>?)|'.
  131. '(?:\[(.+?)\])|'.
  132. '(.+?)(\h*=\h*)'.
  133. '((?:\\\\\h*\r?\n|.+?)*)'.
  134. ')((?:\r?\n)+|$)/',
  135. $str,$matches,PREG_SET_ORDER
  136. );
  137. $out='';
  138. foreach ($matches as $match) {
  139. if ($match[1])
  140. $out.='<span class="comment">'.$match[1].
  141. '</span>';
  142. elseif ($match[2])
  143. $out.='<span class="ini_section">['.$match[2].']'.
  144. '</span>';
  145. elseif ($match[3])
  146. $out.='<span class="ini_key">'.$match[3].
  147. '</span>'.$match[4].
  148. ($match[5]?
  149. ('<span class="ini_value">'.
  150. $match[5].'</span>'):'');
  151. else
  152. $out.=$match[0];
  153. if (isset($match[6]))
  154. $out.=$match[6];
  155. }
  156. $str='<code>'.$out.'</code>';
  157. break;
  158. default:
  159. $str='<code>'.$this->esc($str).'</code>';
  160. break;
  161. }
  162. }
  163. else
  164. $str='<code>'.$this->esc($str).'</code>';
  165. return '<pre>'.$str.'</pre>'."\n\n";
  166. }
  167. /**
  168. * Process horizontal rule
  169. * @return string
  170. **/
  171. protected function _hr() {
  172. return '<hr />'."\n\n";
  173. }
  174. /**
  175. * Process atx-style heading
  176. * @return string
  177. * @param $type string
  178. * @param $str string
  179. **/
  180. protected function _atx($type,$str) {
  181. $level=strlen($type);
  182. return '<h'.$level.' id="'.Web::instance()->slug($str).'">'.
  183. $this->scan($str).'</h'.$level.'>'."\n\n";
  184. }
  185. /**
  186. * Process setext-style heading
  187. * @return string
  188. * @param $str string
  189. * @param $type string
  190. **/
  191. protected function _setext($str,$type) {
  192. $level=strpos('=-',$type)+1;
  193. return '<h'.$level.' id="'.Web::instance()->slug($str).'">'.
  194. $this->scan($str).'</h'.$level.'>'."\n\n";
  195. }
  196. /**
  197. * Process ordered/unordered list
  198. * @return string
  199. * @param $str string
  200. **/
  201. protected function _li($str) {
  202. // Initialize list parser
  203. $len=strlen($str);
  204. $ptr=0;
  205. $dst='';
  206. $first=TRUE;
  207. $tight=TRUE;
  208. $type='ul';
  209. // Main loop
  210. while ($ptr<$len) {
  211. if (preg_match('/^\h*[*-](?:\h?[*-]){2,}(?:\n+|$)/',
  212. substr($str,$ptr),$match)) {
  213. $ptr+=strlen($match[0]);
  214. // Embedded horizontal rule
  215. return (strlen($dst)?
  216. ('<'.$type.'>'."\n".$dst.'</'.$type.'>'."\n\n"):'').
  217. '<hr />'."\n\n".$this->build(substr($str,$ptr));
  218. }
  219. elseif (preg_match('/(?<=^|\n)([*+-]|\d+\.)\h'.
  220. '(.+?(?:\n+|$))((?:(?: {4}|\t)+.+?(?:\n+|$))*)/s',
  221. substr($str,$ptr),$match)) {
  222. $match[3]=preg_replace('/(?<=^|\n)(?: {4}|\t)/','',$match[3]);
  223. $found=FALSE;
  224. foreach (array_slice($this->blocks,0,-1) as $regex)
  225. if (preg_match($regex,$match[3])) {
  226. $found=TRUE;
  227. break;
  228. }
  229. // List
  230. if ($first) {
  231. // First pass
  232. if (is_numeric($match[1]))
  233. $type='ol';
  234. if (preg_match('/\n{2,}$/',$match[2].
  235. ($found?'':$match[3])))
  236. // Loose structure; Use paragraphs
  237. $tight=FALSE;
  238. $first=FALSE;
  239. }
  240. // Strip leading whitespaces
  241. $ptr+=strlen($match[0]);
  242. $tmp=$this->snip($match[2].$match[3]);
  243. if ($tight) {
  244. if ($found)
  245. $tmp=$match[2].$this->build($this->snip($match[3]));
  246. }
  247. else
  248. $tmp=$this->build($tmp);
  249. $dst.='<li>'.$this->scan(trim($tmp)).'</li>'."\n";
  250. }
  251. }
  252. return strlen($dst)?
  253. ('<'.$type.'>'."\n".$dst.'</'.$type.'>'."\n\n"):'';
  254. }
  255. /**
  256. * Ignore raw HTML
  257. * @return string
  258. * @param $str string
  259. **/
  260. protected function _raw($str) {
  261. return $str;
  262. }
  263. /**
  264. * Process paragraph
  265. * @return string
  266. * @param $str string
  267. **/
  268. protected function _p($str) {
  269. $str=trim($str);
  270. if (strlen($str)) {
  271. if (preg_match('/^(.+?\n)([>#].+)$/s',$str,$parts))
  272. return $this->_p($parts[1]).$this->build($parts[2]);
  273. $str=preg_replace_callback(
  274. '/([^<>\[]+)?(<[\?%].+?[\?%]>|<.+?>|\[.+?\]\s*\(.+?\))|'.
  275. '(.+)/s',
  276. function($expr) {
  277. $tmp='';
  278. if (isset($expr[4]))
  279. $tmp.=$this->esc($expr[4]);
  280. else {
  281. if (isset($expr[1]))
  282. $tmp.=$this->esc($expr[1]);
  283. $tmp.=$expr[2];
  284. if (isset($expr[3]))
  285. $tmp.=$this->esc($expr[3]);
  286. }
  287. return $tmp;
  288. },
  289. $str
  290. );
  291. $str=preg_replace('/\s{2}\r?\n/','<br />',$str);
  292. return '<p>'.$this->scan($str).'</p>'."\n\n";
  293. }
  294. return '';
  295. }
  296. /**
  297. * Process strong/em/strikethrough spans
  298. * @return string
  299. * @param $str string
  300. **/
  301. protected function _text($str) {
  302. $tmp='';
  303. while ($str!=$tmp)
  304. $str=preg_replace_callback(
  305. '/(?<=\s|^)(?<!\\\\)([*_])([*_]?)([*_]?)(.*?)(?!\\\\)\3\2\1(?=[\s[:punct:]]|$)/',
  306. function($expr) {
  307. if ($expr[3])
  308. return '<strong><em>'.$expr[4].'</em></strong>';
  309. if ($expr[2])
  310. return '<strong>'.$expr[4].'</strong>';
  311. return '<em>'.$expr[4].'</em>';
  312. },
  313. preg_replace(
  314. '/(?<!\\\\)~~(.*?)(?!\\\\)~~(?=[\s[:punct:]]|$)/',
  315. '<del>\1</del>',
  316. $tmp=$str
  317. )
  318. );
  319. return $str;
  320. }
  321. /**
  322. * Process image span
  323. * @return string
  324. * @param $str string
  325. **/
  326. protected function _img($str) {
  327. return preg_replace_callback(
  328. '/!(?:\[(.+?)\])?\h*\(<?(.*?)>?(?:\h*"(.*?)"\h*)?\)/',
  329. function($expr) {
  330. return '<img src="'.$expr[2].'"'.
  331. (empty($expr[1])?
  332. '':
  333. (' alt="'.$this->esc($expr[1]).'"')).
  334. (empty($expr[3])?
  335. '':
  336. (' title="'.$this->esc($expr[3]).'"')).' />';
  337. },
  338. $str
  339. );
  340. }
  341. /**
  342. * Process anchor span
  343. * @return string
  344. * @param $str string
  345. **/
  346. protected function _a($str) {
  347. return preg_replace_callback(
  348. '/(?<!\\\\)\[(.+?)(?!\\\\)\]\h*\(<?(.*?)>?(?:\h*"(.*?)"\h*)?\)/',
  349. function($expr) {
  350. return '<a href="'.$this->esc($expr[2]).'"'.
  351. (empty($expr[3])?
  352. '':
  353. (' title="'.$this->esc($expr[3]).'"')).
  354. '>'.$this->scan($expr[1]).'</a>';
  355. },
  356. $str
  357. );
  358. }
  359. /**
  360. * Auto-convert links
  361. * @return string
  362. * @param $str string
  363. **/
  364. protected function _auto($str) {
  365. return preg_replace_callback(
  366. '/`.*?<(.+?)>.*?`|<(.+?)>/',
  367. function($expr) {
  368. if (empty($expr[1]) && parse_url($expr[2],PHP_URL_SCHEME)) {
  369. $expr[2]=$this->esc($expr[2]);
  370. return '<a href="'.$expr[2].'">'.$expr[2].'</a>';
  371. }
  372. return $expr[0];
  373. },
  374. $str
  375. );
  376. }
  377. /**
  378. * Process code span
  379. * @return string
  380. * @param $str string
  381. **/
  382. protected function _code($str) {
  383. return preg_replace_callback(
  384. '/`` (.+?) ``|(?<!\\\\)`(.+?)(?!\\\\)`/',
  385. function($expr) {
  386. return '<code>'.
  387. $this->esc(empty($expr[1])?$expr[2]:$expr[1]).'</code>';
  388. },
  389. $str
  390. );
  391. }
  392. /**
  393. * Convert characters to HTML entities
  394. * @return string
  395. * @param $str string
  396. **/
  397. function esc($str) {
  398. if (!$this->special)
  399. $this->special=[
  400. '...'=>'&hellip;',
  401. '(tm)'=>'&trade;',
  402. '(r)'=>'&reg;',
  403. '(c)'=>'&copy;'
  404. ];
  405. foreach ($this->special as $key=>$val)
  406. $str=preg_replace('/'.preg_quote($key,'/').'/i',$val,$str);
  407. return htmlspecialchars($str,ENT_COMPAT,
  408. Base::instance()->ENCODING,FALSE);
  409. }
  410. /**
  411. * Reduce multiple line feeds
  412. * @return string
  413. * @param $str string
  414. **/
  415. protected function snip($str) {
  416. return preg_replace('/(?:(?<=\n)\n+)|\n+$/',"\n",$str);
  417. }
  418. /**
  419. * Scan line for convertible spans
  420. * @return string
  421. * @param $str string
  422. **/
  423. function scan($str) {
  424. $inline=['img','a','text','auto','code'];
  425. foreach ($inline as $func)
  426. $str=$this->{'_'.$func}($str);
  427. return $str;
  428. }
  429. /**
  430. * Assemble blocks
  431. * @return string
  432. * @param $str string
  433. **/
  434. protected function build($str) {
  435. if (!$this->blocks) {
  436. // Regexes for capturing entire blocks
  437. $this->blocks=[
  438. 'blockquote'=>'/^(?:\h?>\h?.*?(?:\n+|$))+/',
  439. 'pre'=>'/^(?:(?: {4}|\t).+?(?:\n+|$))+/',
  440. 'fence'=>'/^`{3}\h*(\w+)?.*?[^\n]*\n+(.+?)`{3}[^\n]*'.
  441. '(?:\n+|$)/s',
  442. 'hr'=>'/^\h*[*_-](?:\h?[\*_-]){2,}\h*(?:\n+|$)/',
  443. 'atx'=>'/^\h*(#{1,6})\h?(.+?)\h*(?:#.*)?(?:\n+|$)/',
  444. 'setext'=>'/^\h*(.+?)\h*\n([=-])+\h*(?:\n+|$)/',
  445. 'li'=>'/^(?:(?:[*+-]|\d+\.)\h.+?(?:\n+|$)'.
  446. '(?:(?: {4}|\t)+.+?(?:\n+|$))*)+/s',
  447. 'raw'=>'/^((?:<!--.+?-->|'.
  448. '<(address|article|aside|audio|blockquote|canvas|dd|'.
  449. 'div|dl|fieldset|figcaption|figure|footer|form|h\d|'.
  450. 'header|hgroup|hr|noscript|object|ol|output|p|pre|'.
  451. 'section|table|tfoot|ul|video).*?'.
  452. '(?:\/>|>(?:(?>[^><]+)|(?R))*<\/\2>))'.
  453. '\h*(?:\n{2,}|\n*$)|<[\?%].+?[\?%]>\h*(?:\n?$|\n*))/s',
  454. 'p'=>'/^(.+?(?:\n{2,}|\n*$))/s'
  455. ];
  456. }
  457. // Treat lines with nothing but whitespaces as empty lines
  458. $str=preg_replace('/\n\h+(?=\n)/',"\n",$str);
  459. // Initialize block parser
  460. $len=strlen($str);
  461. $ptr=0;
  462. $dst='';
  463. // Main loop
  464. while ($ptr<$len) {
  465. if (preg_match('/^ {0,3}\[([^\[\]]+)\]:\s*<?(.*?)>?\s*'.
  466. '(?:"([^\n]*)")?(?:\n+|$)/s',substr($str,$ptr),$match)) {
  467. // Reference-style link; Backtrack
  468. $ptr+=strlen($match[0]);
  469. $tmp='';
  470. // Catch line breaks in title attribute
  471. $ref=preg_replace('/\h/','\s',preg_quote($match[1],'/'));
  472. while ($dst!=$tmp) {
  473. $dst=preg_replace_callback(
  474. '/(?<!\\\\)\[('.$ref.')(?!\\\\)\]\s*\[\]|'.
  475. '(!?)(?:\[([^\[\]]+)\]\s*)?'.
  476. '(?<!\\\\)\[('.$ref.')(?!\\\\)\]/',
  477. function($expr) use($match) {
  478. return (empty($expr[2]))?
  479. // Anchor
  480. ('<a href="'.$this->esc($match[2]).'"'.
  481. (empty($match[3])?
  482. '':
  483. (' title="'.
  484. $this->esc($match[3]).'"')).'>'.
  485. // Link
  486. $this->scan(
  487. empty($expr[3])?
  488. (empty($expr[1])?
  489. $expr[4]:
  490. $expr[1]):
  491. $expr[3]
  492. ).'</a>'):
  493. // Image
  494. ('<img src="'.$match[2].'"'.
  495. (empty($expr[2])?
  496. '':
  497. (' alt="'.
  498. $this->esc($expr[3]).'"')).
  499. (empty($match[3])?
  500. '':
  501. (' title="'.
  502. $this->esc($match[3]).'"')).
  503. ' />');
  504. },
  505. $tmp=$dst
  506. );
  507. }
  508. }
  509. else
  510. foreach ($this->blocks as $func=>$regex)
  511. if (preg_match($regex,substr($str,$ptr),$match)) {
  512. $ptr+=strlen($match[0]);
  513. $dst.=call_user_func_array(
  514. [$this,'_'.$func],
  515. count($match)>1?array_slice($match,1):$match
  516. );
  517. break;
  518. }
  519. }
  520. return $dst;
  521. }
  522. /**
  523. * Render HTML equivalent of markdown
  524. * @return string
  525. * @param $txt string
  526. **/
  527. function convert($txt) {
  528. $txt=preg_replace_callback(
  529. '/(<code.*?>.+?<\/code>|'.
  530. '<[^>\n]+>|\([^\n\)]+\)|"[^"\n]+")|'.
  531. '\\\\(.)/s',
  532. function($expr) {
  533. // Process escaped characters
  534. return empty($expr[1])?$expr[2]:$expr[1];
  535. },
  536. $this->build(preg_replace('/\r\n|\r/',"\n",$txt))
  537. );
  538. return $this->snip($txt);
  539. }
  540. }