| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066 |
- <?php
- declare(strict_types=1);
-
- /**
- * Static utilities.
- */
- class MDUtils {
- // Modified from https://urlregex.com/ to remove capture groups. Matches fully qualified URLs only.
- public const baseURLRegex = '(?:(?:(?:[a-z]{3,9}:(?:\\/\\/)?)(?:[\\-;:&=\\+\\$,\\w]+@)?[a-z0-9\\.\\-]+|(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)[a-z0-9\\.\\-]+)(?:(?:\\/[\\+~%\\/\\.\\w\\-_]*)?\\??(?:[\\-\\+=&;%@\\.\\w_]*)#?(?:[\\.\\!\\/\\\\\\w]*))?)';
- // Modified from https://emailregex.com/ to remove capture groups.
- public const 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,}))';
-
- /**
- * Escapes special HTML characters.
- *
- * @param string $str string to escape
- * @param bool $encodeNewlinesAsBreaks whether to convert newline characters to `<br>` tags
- * @return string escaped HTML
- */
- public static function escapeHTML($str, $encodeNewlinesAsBreaks=false) {
- if (!is_string($str)) return '';
- $html = $str;
- $html = mb_ereg_replace('&', '&', $html);
- $html = mb_ereg_replace('<', '<', $html);
- $html = mb_ereg_replace('>', '>', $html);
- $html = mb_ereg_replace('"', '"', $html);
- if ($encodeNewlinesAsBreaks) {
- $html = str_replace("\n", "<br>\n", $html);
- }
- return $html;
- }
-
- /**
- * Encodes characters as HTML numeric entities to make it marginally more
- * difficult for web scrapers to grab sensitive info. If `$text` starts with
- * `mailto:` only the email address following it will be obfuscated.
- */
- public static function escapeObfuscated(string $text): string {
- if (str_starts_with($text, 'mailto:')) {
- return 'mailto:' . self::escapeObfuscated(mb_substr($text, 7));
- }
- $html = '';
- $l = mb_strlen($text);
- for ($p = 0; $p < $l; $p++) {
- $cp = mb_ord(mb_substr($text, $p, 1));
- $html .= "&#{$cp};";
- }
- return $html;
- }
-
- /**
- * Removes illegal characters from an HTML attribute name.
- */
- public static function scrubAttributeName(string $name): string {
- return mb_ereg_replace('[\\t\\n\\f \\/>"\'=]+', '', $name);
- }
-
- /**
- * Strips one or more leading indents from a line or lines of markdown. An
- * indent is defined as 4 spaces or one tab. Incomplete indents (i.e. 1-3
- * spaces) are treated like one indent level.
- *
- * @param string|string[] $line
- * @param int $levels
- * @return string|string[]
- */
- public static function stripIndent(string|array $line, int $levels=1): string|array {
- $regex = "^(?: {1,4}|\\t){{$levels}}";
- return is_array($line) ? array_map(fn(string $l): string => mb_ereg_replace($regex, '', $l), $line) : mb_ereg_replace($regex, '', $line);
- }
-
- /**
- * Counts the number of indent levels in a line of text. Partial indents
- * (1 to 3 spaces) are counted as one indent level unless `$fullIndentsOnly`
- * is `true`.
- */
- public static function countIndents(string $line, bool $fullIndentsOnly=false): int {
- // normalize indents to tabs
- $t = mb_ereg_replace($fullIndentsOnly ? '(?: {4}|\\t)' : '(?: {1,4}|\\t)', "\t", $line);
- // remove content after indent
- $t = mb_ereg_replace('^(\\t*)(.*?)$', '\\1', $t);
- // count tabs
- return mb_strlen($t);
- }
-
- /**
- * Returns a copy of an array without any whitespace-only lines at the end.
- *
- * @param string[] $lines
- * @return string[]
- */
- public static function withoutTrailingBlankLines(array $lines): array {
- $stripped = $lines;
- while (sizeof($stripped) > 0 && mb_strlen(trim($stripped[sizeof($stripped) - 1])) == 0) {
- array_pop($stripped);
- }
- return $stripped;
- }
-
- /**
- * Tests if an array of lines contains at least one blank. A blank line
- * can contain whitespace.
- *
- * @param string[] $lines
- */
- public static function containsBlankLine(array $lines): bool {
- foreach ($lines as $line) {
- if (mb_strlen(trim($line)) == 0) return true;
- }
- return false;
- }
-
- /**
- * Returns a type or class name of a value.
- *
- * @param mixed $value
- * @return string
- */
- public static function typename($value): string {
- $tn = gettype($value);
- return ($tn === 'object') ? get_class($value) : $tn;
- }
- }
-
- /**
- * Token type enum for `MDToken`.
- */
- enum MDTokenType {
- case Text;
- /**
- * Only used for the leading and trailing whitespace around a run of text,
- * not every single whitespace character.
- */
- case Whitespace;
-
- case Underscore;
- case Asterisk;
- case Slash;
- case Tilde;
- case Bang;
- case Backtick;
- case Equal;
- case Caret;
-
- case Label; // content=label
- case URL; // content=URL, extra=title
- case Email; // content=email address, extra=title
- case SimpleLink; // content=URL
- case SimpleEmail; // content=email address
- case Footnote; // content=symbol
- case Modifier; // modifier=MDTagModifier
-
- case HTMLTag; // tag=MDHTMLTag
-
- /** Wildcard for `MDToken::findFirstTokens` */
- case META_AnyNonWhitespace;
- /** Wildcard for `MDToken::findFirstTokens` */
- case META_OptionalWhitespace;
- }
-
- /**
- * Search results from `MDToken.findFirstTokens`.
- */
- class MDTokenMatch {
- /** @var MDToken[] */
- public array $tokens;
- public int $index;
-
- /**
- * @param MDToken[] $tokens
- * @param int $index
- */
- public function __construct(array $tokens, int $index) {
- $this->tokens = $tokens;
- $this->index = $index;
- }
- }
-
- /**
- * Search results from `MDToken.findPairedTokens`.
- */
- class MDPairedTokenMatch {
- /** @var MDToken[] */
- public array $startTokens;
- /** @var MDToken[] */
- public array $contentTokens;
- /** @var MDToken[] */
- public array $endTokens;
- public int $startIndex;
- public int $contentIndex;
- public int $endIndex;
- public int $totalLength;
-
- public function __construct(array $startTokens, array $contentTokens,
- array $endTokens, int $startIndex, int $contentIndex, int $endIndex,
- int $totalLength) {
- $this->startTokens = $startTokens;
- $this->contentTokens = $contentTokens;
- $this->endTokens = $endTokens;
- $this->startIndex = $startIndex;
- $this->contentIndex = $contentIndex;
- $this->endIndex = $endIndex;
- $this->totalLength = $totalLength;
- }
- }
-
- /**
- * One lexical unit in inline markdown syntax parsing.
- */
- class MDToken {
- /**
- * The original verbatim token string. Required as a plaintext fallback if
- * the token remains unresolved.
- */
- public string $original;
- public MDTokenType $type;
- public ?string $content = null;
- public ?string $extra = null;
- public ?MDHTMLTag $tag = null;
- public ?MDTagModifier $modifier = null;
-
- /**
- * Creates a token.
- *
- * @param string $original verbatim token string
- * @param MDTokenType $type token type
- * @param string|MDTagModifier|MDHTMLTag|null $content primary content of
- * the token
- * @param string|null $extra additional content
- */
- public function __construct(string $original, MDTokenType $type,
- string|MDTagModifier|MDHTMLTag|null $content=null,
- ?string $extra=null) {
- $this->original = $original;
- $this->type = $type;
- if ($content instanceof MDTagModifier) {
- $this->modifier = $content;
- } elseif ($content instanceof MDHTMLTag) {
- $this->tag = $content;
- } else {
- $this->content = $content;
- }
- $this->extra = $extra;
- }
-
- public function __toString(): string {
- return "<{" . MDUtils::typename($this) . " type={$this->type->name} " .
- "content=\"{$this->content}\">";
- }
-
- /**
- * Attempts to parse a label token from the beginning of `$line`. A label is
- * of the form `[content]`. If found, returns an array:
- * - `0`: the entire label including brackets
- * - `1`: the content of the label
- *
- * @param string $line
- * @return ?string[] match groups or null if not found
- */
- public static function tokenizeLabel(string $line): ?array {
- if (!str_starts_with($line, '[')) return null;
- $parenCount = 0;
- $bracketCount = 0;
- $l = mb_strlen($line);
- for ($p = 1; $p < $l; $p++) {
- $ch = mb_substr($line, $p, 1);
- if ($ch == '\\') {
- $p++;
- } elseif ($ch == '(') {
- $parenCount++;
- } elseif ($ch == ')') {
- $parenCount--;
- if ($parenCount < 0) return null;
- } elseif ($ch == '[') {
- $bracketCount++;
- } elseif ($ch == ']') {
- if ($bracketCount > 0) {
- $bracketCount--;
- } else {
- $all = mb_substr($line, 0, $p + 1);
- $content = mb_substr($line, 1, $p - 1);
- return [ $all, $content ];
- }
- }
- }
- return null;
- }
-
- private const urlWithTitleRegex = '^\\((\\S+?)\\s+"(.*?)"\\)'; // 1=URL, 2=title
- private const urlRegex = '^\\((\\S+?)\\)'; // 1=URL
-
- /**
- * Attempts to parse a URL token from the beginning of `$line`. A URL token
- * is of the form `(url)` or `(url "title")`. If found, returns an array:
- * - `0`: the entire URL token including parentheses
- * - `1`: the URL
- * - `2`: the optional title, or `null`
- *
- * @param string $line
- * @return ?array token tuple
- */
- public static function tokenizeURL(string $line): ?array {
- $groups = [];
- if (mb_eregi(self::urlWithTitleRegex, $line, $groups)) {
- // make sure it's not better described as an email address
- if (self::tokenizeEmail($line)) return null;
- return $groups;
- }
- if (mb_eregi(self::urlRegex, $line, $groups)) {
- if (self::tokenizeEmail($line)) return null;
- return [ $groups[0], $groups[1], null ];
- }
- return null;
- }
-
- /**
- * Attempts to parse an email address from the beginning of `$line`. An
- * email address is of the form `(user@example.com)` or
- * `(user@example.com "link title")`. If found, returns an array:
- * - `0`: the entire token including parentheses
- * - `1`: the email address
- * - `2`: the optional link title, or `null`
- *
- * @param string $line
- * @return ?string[] token tuple
- */
- public static function tokenizeEmail(string $line): ?array {
- $groups;
- if (mb_eregi("^\\(\\s*(" . MDUtils::baseEmailRegex . ")\\s+\"(.*?)\"\\s*\\)",
- $line, $groups)) {
- return $groups;
- }
- if (mb_eregi("^\\(\\s*(" . MDUtils::baseEmailRegex . ")\\s*\\)", $line, $groups)) {
- return [ $groups[0], $groups[1], null ];
- }
- return null;
- }
-
- /**
- * Searches an array of `MDToken` for the given pattern of `MDTokenType`s.
- * If found, returns a `MDTokenMatch`, otherwise `null`.
- *
- * Special token types `META_AnyNonWhitespace` and `META_OptionalWhitespace`
- * are special supported token types. Note that `META_OptionalWhitespace`
- * may give a result with a variable number of tokens.
- *
- * @param (MDToken|MDNode)[] $tokensToSearch mixed array of `MDToken` and
- * `MDNode` elements
- * @param MDTokenType[] $pattern contiguous run of token types to find
- * @param int $startIndex token index to begin searching (defaults to 0)
- * @return ?MDTokenMatch match object, or `null` if not found
- */
- public static function findFirstTokens(array $tokensToSearch, array $pattern,
- int $startIndex=0): ?MDTokenMatch {
- if (sizeof($pattern) == 0) {
- throw new Error("Pattern cannot be empty");
- }
- $matched = [];
- for ($t = $startIndex; $t < sizeof($tokensToSearch); $t++) {
- $matchedAll = true;
- $matched = [];
- $patternOffset = 0;
- for ($p = 0; $p < sizeof($pattern); $p++) {
- $t0 = $t + $p + $patternOffset;
- if ($t0 >= sizeof($tokensToSearch)) return null;
- $token = $tokensToSearch[$t0];
- $elem = $pattern[$p];
- if ($elem == MDTokenType::META_OptionalWhitespace) {
- if ($token instanceof MDToken && $token->type == MDTokenType::Whitespace) {
- array_push($matched, $token);
- } else {
- $patternOffset--;
- }
- } elseif ($elem == MDTokenType::META_AnyNonWhitespace) {
- if ($token instanceof MDToken && $token->type == MDTokenType::Whitespace) {
- $matchedAll = false;
- break;
- }
- array_push($matched, $token);
- } else {
- if (!($token instanceof MDToken) || $token->type != $elem) {
- $matchedAll = false;
- break;
- }
- array_push($matched, $token);
- }
- }
- if ($matchedAll) {
- return new MDTokenMatch($matched, $t);
- }
- }
- return null;
- }
-
- /**
- * Searches an array of MDToken for a given starting pattern and ending
- * pattern and returns match info about both and the tokens in between.
- *
- * If `$contentValidator` is specified, it will be called with the content
- * tokens of a potential match. If the validator returns `true`, the result
- * will be accepted and returned by this method. If the validator returns
- * `false`, this method will keep looking for another matching pair. If no
- * validator is given the first match will be returned regardless of content.
- *
- * If a match is found, a `MDPairedTokenMatch` is returned with details
- * of the opening tokens, closing tokens, and content tokens between. Otherwise
- * `null` is returned.
- *
- * @param MDToken[] $tokensToSearch array of `MDToken` to search in
- * @param MDTokenType[] $startPattern pattern to find first
- * @param MDTokenType[] $endPattern pattern to find positioned after
- * `$startPattern`
- * @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
- * @param number $startIndex token index where searching should begin
- * @return ?MDPairedTokenMatch match, or `null`
- */
- public static function findPairedTokens(array $tokensToSearch,
- array $startPattern, array $endPattern, ?callable $contentValidator=null,
- int $startIndex=0): ?MDPairedTokenMatch {
- for ($s = $startIndex; $s < sizeof($tokensToSearch); $s++) {
- $startMatch = self::findFirstTokens($tokensToSearch, $startPattern, $s);
- if ($startMatch === null) return null;
- $endStart = $startMatch->index + sizeof($startMatch->tokens);
- while ($endStart < sizeof($tokensToSearch)) {
- $endMatch = self::findFirstTokens($tokensToSearch, $endPattern, $endStart);
- if ($endMatch === null) break;
- $contentStart = $startMatch->index + sizeof($startMatch->tokens);
- $contentLength = $endMatch->index - $contentStart;
- $contents = array_slice($tokensToSearch, $contentStart, $contentLength);
- if (sizeof($contents) > 0 && ($contentValidator === null || $contentValidator($contents))) {
- return new MDPairedTokenMatch($startMatch->tokens,
- $contents,
- $endMatch->tokens,
- $startMatch->index,
- $startMatch->index + sizeof($startMatch->tokens),
- $endMatch->index,
- $endMatch->index + sizeof($endMatch->tokens) - $startMatch->index);
- } else {
- // Contents rejected. Try next end match.
- $endStart = $endMatch->index + 1;
- }
- }
- // No end matches. Increment start match.
- $s = $startMatch->index;
- }
- return null;
- }
-
- public function equals($other) {
- if (!($other instanceof MDToken)) return false;
- if ($other->original !== $this->original) return false;
- if ($other->type != $this->type) return false;
- if ($other->content !== $this->content) return false;
- if ($other->extra !== $this->extra) return false;
- if ($other->tag !== $this->tag) return false;
- if ($other->modifier != $this->modifier) return false;
- return true;
- }
- }
-
- /**
- * Parsing and rendering state. Passed around throughout the parsing process.
- *
- * States are hierarchical. A sub-state can be created by calling `->copy()` with
- * a new array of lines. The sub-state points back to its parent state. This
- * is done to parse inner content of a syntax as its own standalone document.
- *
- * If a custom `MDReader` implementation wants to store data in this object,
- * always do so on `$state->root()` to ensure it's stored on the original state,
- * not a child state. Otherwise data may be lost when the sub-state is discarded.
- */
- class MDState {
- /**
- * Ascends the parent chain to the root `MDState` instance. This should be
- * used when referencing most stored fields except `$lines` and `$p`.
- */
- public function root(): MDState {
- return $this->parent ? $this->parent->root() : $this;
- }
-
- /**
- * Lines of the markdown document. The current line index is pointed to by `$p`.
- *
- * @var string[]
- */
- public array $lines;
-
- /**
- * The current line in `$lines`.
- */
- public function currentLine(): ?string {
- return ($this->p < sizeof($this->lines)) ? $this->lines[$this->p] : null;
- }
-
- /**
- * Current line pointer into array `$lines`.
- */
- public int $p = 0;
-
- /**
- * General storage for anything readers need to track during the parsing
- * process.
- */
- public array $userInfo = [];
-
- private ?MDState $parent = null;
-
- /**
- * Array of `MDReader`s sorted by block reading priority.
- * @var MDReader[]
- */
- public array $readersByBlockPriority = [];
-
- /**
- * Array of `MDReader`s sorted by tokenization priority.
- * @var MDReader[]
- */
- public array $readersByTokenPriority = [];
-
- /**
- * Array of tuples of `pass:number` and `MDReader` sorted by substitution
- * priority.
- * @var array[]
- */
- public array $readersBySubstitutePriority = [];
-
- /**
- * Prefix to include in any generated `id` attributes on HTML elements.
- * Useful for keeping elements unique in multiple parsed documents in the
- * same HTML page.
- */
- public string $elementIdPrefix = '';
-
- /**
- * Filter for removing unapproved HTML tags, attributes, and values.
- */
- public MDHTMLFilter $tagFilter;
-
- /**
- * @param string[] $lines lines of markdown text
- */
- public function __construct(array $lines) {
- $this->lines = $lines;
- $this->startTime = microtime(true);
- }
-
- /**
- * Creates a copy of this state with new lines. Useful for parsing nested
- * content.
- *
- * @param string[] $lines
- * @return MDState copied sub-state
- */
- public function copy(array $lines): MDState {
- $cp = new MDState($lines);
- $cp->parent = $this;
- return $cp;
- }
-
- /**
- * Tests if there are at least `$minCount` lines available to read. If `$p`
- * is not provided it will be relative to `$this->p`.
- */
- public function hasLines(int $minCount, ?int $p=null): bool {
- $relativeTo = ($p === null) ? $this->p : $p;
- return $relativeTo + $minCount <= sizeof($this->lines);
- }
-
- /**
- * Reads and returns an array of blocks from the current line pointer.
- *
- * @return MDBlockNode[] parsed blocks
- */
- public function readBlocks(): array {
- $blocks = [];
- while ($this->hasLines(1)) {
- $block = $this->readNextBlock();
- if ($block) {
- array_push($blocks, $block);
- } else {
- break;
- }
- }
- return $blocks;
- }
-
- /**
- * Creates a simple `MDBlockNode` if no other registered blocks match.
- */
- private function readFallbackBlock(): ?MDBlockNode {
- if ($this->p >= sizeof($this->lines)) return null;
- $lines = MDUtils::withoutTrailingBlankLines(array_slice($this->lines, $this->p));
- if (sizeof($lines) == 0) return null;
- $this->p = sizeof($this->lines);
- return new MDBlockNode($this->inlineMarkdownToNode(implode("\n", $lines)));
- }
-
- /**
- * Attempts to read one block from the current line pointer. The pointer
- * will be positioned just after the end of the block.
- */
- private function readNextBlock(): ?MDBlockNode {
- while ($this->hasLines(1) && mb_strlen(trim($this->lines[$this->p])) == 0) {
- $this->p++;
- }
- if (!$this->hasLines(1)) return null;
- foreach ($this->root()->readersByBlockPriority as $reader) {
- $startP = $this->p;
- $block = $reader->readBlock($this);
- if ($block) {
- if ($this->p == $startP) {
- $readerClassName = MDUtils::typename($reader);
- $blockClassName = MDUtils::typename($block);
- throw new Error("{$readerClassName} returned an " .
- "{$blockClassName} without incrementing MDState.p. " .
- "This could lead to an infinite loop.");
- }
- return $block;
- }
- }
- $fallback = $this->readFallbackBlock();
- return $fallback;
- }
-
- /**
- * @param string $line
- * @return MDToken[]
- */
- private function inlineMarkdownToTokens(string $line): array {
- if ($this->parent) return $this->parent->inlineMarkdownToTokens($line);
-
- $tokens = [];
- $text = '';
- $expectLiteral = false;
-
- /**
- * Flushes accumulated content in `$text` to `$tokens`.
- */
- $endText = function() use (&$tokens, &$text) {
- if (mb_strlen($text) === 0) return;
- $textGroups = [];
- if (mb_eregi('^(\\s+)(.*?)$', $text, $textGroups)) {
- array_push($tokens, new MDToken($textGroups[1], MDTokenType::Whitespace, $textGroups[1]));
- $text = is_string($textGroups[2]) ? $textGroups[2] : '';
- }
- if (mb_eregi('^(.*?)(\\s+)$', $text, $textGroups)) {
- array_push($tokens, new MDToken($textGroups[1], MDTokenType::Text, $textGroups[1]));
- array_push($tokens, new MDToken($textGroups[2], MDTokenType::Whitespace, $textGroups[2]));
- } elseif (mb_strlen($text) > 0) {
- array_push($tokens, new MDToken($text, MDTokenType::Text, $text));
- }
- $text = '';
- };
-
- for ($p = 0; $p < mb_strlen($line); $p++) {
- $ch = mb_substr($line, $p, 1);
- $remainder = mb_substr($line, $p);
- if ($expectLiteral) {
- $text .= $ch;
- $expectLiteral = false;
- continue;
- }
- if ($ch == '\\') {
- $expectLiteral = true;
- continue;
- }
- $found = false;
- foreach ($this->root()->readersByTokenPriority as $reader) {
- $token = $reader->readToken($this, $remainder);
- if ($token === null) continue;
- $endText();
- array_push($tokens, $token);
- if ($token->original == null || mb_strlen($token->original) == 0) {
- $readerClassName = MDUtils::typename($reader);
- throw new Error(`{$readerClassName} returned a token with an empty .original. This would cause an infinite loop.`);
- }
- $p += mb_strlen($token->original) - 1;
- $found = true;
- break;
- }
- if (!$found) {
- $text .= $ch;
- }
- }
- $endText();
- return $tokens;
- }
-
- /**
- * Converts a line of markdown to an `MDInlineNode`.
- *
- * @param string|string[] $line
- * @return MDInlineNode
- */
- public function inlineMarkdownToNode(string|array $line): MDInlineNode {
- $nodes = $this->inlineMarkdownToNodes($line);
- return (sizeof($nodes) == 1) ? $nodes[0] : new MDInlineNode($nodes);
- }
-
- /**
- * Converts a line of markdown to an array of `MDInlineNode`s.
- *
- * @param string|string[] $line
- * @return MDInlineNode[]
- */
- public function inlineMarkdownToNodes(string|array $line): array {
- $tokens = $this->inlineMarkdownToTokens(is_array($line) ? implode("\n", $line) : $line);
- return $this->tokensToNodes($tokens);
- }
-
- /**
- * Converts a mixed array of `MDToken` and `MDInlineNode` elements into an array
- * of only `MDInlineNode` via repeated `MDReader` substition.
- *
- * @param (MDToken|MDInlineNode)[] $tokens
- * @return MDInlineNode[]
- */
- public function tokensToNodes(array $tokens): array {
- $nodes = $tokens;
-
- // Perform repeated substitutions, converting sequences of tokens into
- // nodes, until no more substitutions can be made.
- $anyChanges = false;
- do {
- $anyChanges = false;
- foreach ($this->root()->readersBySubstitutePriority as $readerTuple) {
- /** @var int */
- $pass = $readerTuple[0];
- /** @var MDReader */
- $reader = $readerTuple[1];
- $changed = $reader->substituteTokens($this, $pass, $nodes);
- if (!$changed) continue;
- $anyChanges = true;
- break;
- }
- } while ($anyChanges);
-
- // Convert any remaining tokens to text nodes. Also apply any inline
- // CSS modifiers.
- $lastNode = null;
- $me = $this;
- $nodes = array_map(function($node) use (&$lastNode, $me, $nodes) {
- if ($node instanceof MDToken) {
- /** @var MDToken */
- $token = $node;
- if ($token->type == MDTokenType::Modifier && $lastNode) {
- $me->root()->tagFilter->scrubModifier($token->modifier);
- $token->modifier->applyTo($lastNode);
- $lastNode = null;
- return new MDTextNode('');
- }
- $lastNode = null;
- return new MDTextNode($token->original);
- } elseif ($node instanceof MDNode) {
- $lastNode = ($node instanceof MDTextNode) ? null : $node;
- return $node;
- } else {
- $nodeClassName = MDUtils::typename($node);
- throw new Error("Unexpected node type {$nodeClassName}");
- }
- }, $nodes);
-
- return $nodes;
- }
-
- public $startTime;
-
- /**
- * Checks if parsing has taken an excessive length of time. Because I'm not
- * fully confident in my loops yet. :)
- */
- public function checkExecutionTime(float $maxSeconds=1.0) {
- $elapsed = microtime(true) - $this->root()->startTime;
- if ($elapsed > $maxSeconds) {
- throw new Error("Markdown parsing taking too long. Infinite loop?");
- }
- }
-
- /**
- * Mapping of reference symbols to URLs. Used by `MDReferencedLinkReader`
- * and `MDReferencedImageReader`.
- */
- private array $referenceToURL = [];
-
- /**
- * Mapping of reference symbols to titles. Used by `MDReferencedLinkReader`
- * and `MDReferencedImageReader`.
- */
- private array $referenceToTitle = [];
-
- /**
- * Defines a URL by reference symbol.
- */
- public function defineURL(string $reference, string $url, ?string $title=null) {
- $this->root()->referenceToURL[mb_strtolower($reference)] = $url;
- if ($title !== null) $this->root()->referenceToTitle[mb_strtolower($reference)] = $title;
- }
-
- /**
- * Returns the URL associated with a reference symbol.
- */
- public function urlForReference(string $reference): ?string {
- return $this->root()->referenceToURL[mb_strtolower($reference)] ?? null;
- }
-
- /**
- * Returns the link title associated with a reference symbol.
- */
- public function urlTitleForReference(string $reference): ?string {
- return $this->root()->referenceToTitle[mb_strtolower($reference)] ?? null;
- }
- }
-
- /**
- * Defines a set of allowable HTML tags, attributes, and CSS.
- */
- class MDHTMLFilter {
- /**
- * Mapping of permitted lowercase tag names to objects containing allowable
- * attributes for those tags. Does not need to include those attributes
- * defined in `$allowableGlobalAttributes`.
- *
- * Values are objects with allowable lowercase attribute names mapped to
- * allowable value patterns. A `*` means any value is acceptable. Multiple
- * allowable values can be joined together with `|`. These special symbols
- * represent certain kinds of values and can be used in combination or in
- * place of literal values.
- *
- * - `{classlist}`: A list of legal CSS classnames, separated by spaces
- * - `{int}`: An integer
- * - `{none}`: No value (an attribute with no `=` or value, like `checked`)
- * - `{style}`: One or more CSS declarations, separated by semicolons (simple
- * `key: value;` syntax only)
- * - `{url}`: A URL
- */
- public array $allowableTags = [
- 'address' => [
- 'cite' => '{url}',
- ],
- 'h1' => [],
- 'h2' => [],
- 'h3' => [],
- 'h4' => [],
- 'h5' => [],
- 'h6' => [],
- 'blockquote' => [],
- 'dl' => [],
- 'dt' => [],
- 'dd' => [],
- 'div' => [],
- 'hr' => [],
- 'ul' => [],
- 'ol' => [
- 'start' => '{int}',
- 'type' => 'a|A|i|I|1',
- ],
- 'li' => [
- 'value' => '{int}',
- ],
- 'p' => [],
- 'pre' => [],
- 'table' => [],
- 'thead' => [],
- 'tbody' => [],
- 'tfoot' => [],
- 'tr' => [],
- 'td' => [],
- 'th' => [],
- 'a' => [
- 'href' => '{url}',
- 'target' => '*',
- ],
- 'abbr' => [],
- 'b' => [],
- 'br' => [],
- 'cite' => [],
- 'code' => [],
- 'data' => [
- 'value' => '*',
- ],
- 'dfn' => [],
- 'em' => [],
- 'i' => [],
- 'kbd' => [],
- 'mark' => [],
- 'q' => [
- 'cite' => '{url}',
- ],
- 's' => [],
- 'samp' => [],
- 'small' => [],
- 'span' => [],
- 'strong' => [],
- 'sub' => [],
- 'sup' => [],
- 'time' => [
- 'datetime' => '*',
- ],
- 'u' => [],
- 'var' => [],
- 'wbr' => [],
- 'img' => [
- 'alt' => '*',
- 'href' => '{url}',
- ],
- 'figure' => [],
- 'figcaption' => [],
- 'del' => [],
- 'ins' => [],
- 'details' => [],
- 'summary' => [],
- ];
-
- /**
- * Mapping of allowable lowercase global attributes to their permitted
- * values. Uses same value pattern syntax as described in `$allowableTags`.
- */
- public array $allowableGlobalAttributes = [
- 'class' => '{classlist}',
- 'data-*' => '*',
- 'dir' => 'ltr|rtl|auto',
- 'id' => '*',
- 'lang' => '*',
- 'style' => '{style}',
- 'title' => '*',
- 'translate' => 'yes|no|{none}',
- ];
-
- /**
- * Mapping of allowable CSS style names to their allowable value patterns.
- * Multiple values can be delimited with `|` characters. Limited support
- * so far.
- *
- * Recognized special values:
- * - `{color}`: A hex or named color
- */
- public array $allowableStyleKeys = [
- 'background-color' => '{color}',
- 'color' => '{color}',
- ];
-
- /**
- * Scrubs all forbidden attributes from an HTML tag. Assumes the tag name
- * itself has already been whitelisted.
- *
- * @param MDHTMLTag $tag HTML tag
- */
- public function scrubTag(MDHTMLTag $tag) {
- foreach ($tag->attributes as $name => $value) {
- if (!$this->isValidAttributeName($tag->tagName, $name)) {
- unset($tag->attributes[$name]);
- }
- if (!$this->isValidAttributeValue($tag->tagName, $name, $value)) {
- unset($tag->attributes[$name]);
- }
- }
- }
-
- /**
- * Scrubs all forbidden attributes from an HTML modifier.
- *
- * @param MDTagModifier $modifier
- * @param ?string $tagName HTML tag name, if known, otherwise only
- * global attributes will be permitted
- */
- public function scrubModifier(MDHTMLModifier $modifier, ?string $tagName) {
- if (sizeof($modifier->cssClasses) > 0) {
- $classList = implode(' ', $modifier->cssClasses);
- if (!$this->isValidAttributeValue($tagName, 'class', $classList)) {
- $modifier->cssClasses = [];
- }
- }
- if ($modifier->cssId !== null) {
- if (!$this->isValidAttributeValue($tagName, 'id', $modifier->cssId)) {
- $modifier->cssId = null;
- }
- }
- if (!$this->isValidAttributeName($tagName, 'style')) {
- $modifier->cssStyles = [];
- } else {
- foreach ($modifier->cssStyles as $key => $val) {
- if (!$this->isValidStyleValue($key, $val)) {
- unset($modifier->cssStyles[$key]);
- }
- }
- }
- foreach ($modifier->attributes as $key => $val) {
- if (!$this->isValidAttributeValue($tagName, $key, $val)) {
- unset($modifier->attributes[$key]);
- }
- }
- }
-
- /**
- * Tests if an HTML tag name is permitted.
- */
- public function isValidTagName(string $tagName): bool {
- return ($this->allowableTags[mb_strtolower($tagName)] ?? null) !== null;
- }
-
- /**
- * Tests if an HTML attribute name is permitted.
- */
- public function isValidAttributeName(?string $tagName, string $attributeName): bool {
- $lcAttributeName = mb_strtolower($attributeName);
- if (($this->allowableGlobalAttributes[$lcAttributeName] ?? null) !== null) {
- return true;
- }
- foreach ($this->allowableGlobalAttributes as $pattern => $valuePattern) {
- if (!str_ends_with($pattern, '*')) continue;
- $patternPrefix = mb_substr($pattern, 0, mb_strlen($pattern) - 1);
- if (str_starts_with($lcAttributeName, $patternPrefix)) {
- return true;
- }
- }
- if ($tagName === null) return false;
- $lcTagName = mb_strtolower($tagName);
- $tagAttributes = $this->allowableTags[$lcTagName];
- if ($tagAttributes !== null) {
- return ($tagAttributes[$lcAttributeName] ?? null) !== null;
- }
- return false;
- }
-
- /**
- * Tests if an attribute value is allowable.
- */
- public function isValidAttributeValue(?string $tagName, string $attributeName, $attributeValue): bool {
- $lcAttributeName = mb_strtolower($attributeName);
- $globalPattern = $this->allowableGlobalAttributes[$lcAttributeName] ?? null;
- if ($globalPattern !== null) {
- return $this->attributeValueMatchesPattern($attributeValue, $globalPattern);
- }
- foreach ($this->allowableGlobalAttributes as $namePattern => $valuePattern) {
- if (str_ends_with($namePattern, '*') && str_starts_with($lcAttributeName, mb_substr($namePattern, 0, mb_strlen($namePattern) - 1))) {
- return $this->attributeValueMatchesPattern($attributeValue, $valuePattern);
- }
- }
- if ($tagName === null) return false;
- $lcTagName = mb_strtolower($tagName);
- $tagAttributes = $this->allowableTags[$lcTagName] ?? null;
- if ($tagAttributes === null) return false;
- $valuePattern = $tagAttributes[$lcAttributeName] ?? null;
- if ($valuePattern === null) return false;
- return $this->attributeValueMatchesPattern($attributeValue, $valuePattern);
- }
-
- private const permissiveURLRegex = '^\\S+$';
- private const integerRegex = '^[\\-]?\\d+$';
- private const classListRegex = '^-?[_a-zA-Z]+[_a-zA-Z0-9-]*(?:\\s+-?[_a-zA-Z]+[_a-zA-Z0-9-]*)*$';
-
- private function attributeValueMatchesPattern(string|bool $value, string $pattern): bool {
- $options = explode('|', $pattern);
- foreach ($options as $option) {
- switch ($option) {
- case '*':
- return true;
- case '{classlist}':
- if (mb_eregi(self::classListRegex, $value)) return true;
- break;
- case '{int}':
- if (mb_eregi(self::integerRegex, $value)) return true;
- break;
- case '{none}':
- if ($value === true) return true;
- break;
- case '{style}':
- if ($this->isValidStyleDeclaration($value)) return true;
- break;
- case '{url}':
- if (mb_eregi(self::permissiveURLRegex, $value)) return true;
- break;
- default:
- if ($value === $option) return true;
- break;
- }
- }
- return false;
- }
-
- /**
- * Tests if a string of one or more style `key: value;` declarations is
- * fully allowable.
- */
- public function isValidStyleDeclaration(string $styles): bool {
- $settings = explode(';', $styles);
- foreach ($settings as $setting) {
- if (mb_strlen(trim($setting)) == 0) continue;
- $parts = explode(':', $setting);
- if (sizeof($parts) != 2) return false;
- $name = trim($parts[0]);
- if (!$this->isValidStyleKey($name)) return false;
- $value = trim($parts[1]);
- if (!$this->isValidStyleValue($name, $value)) return false;
- }
- return true;
- }
-
- /**
- * Tests if a CSS style key is allowable.
- */
- public function isValidStyleKey(string $key): bool {
- return ($this->allowableStyleKeys[$key] ?? null) !== null;
- }
-
- /**
- * Tests if a CSS style value is allowable.
- */
- public function isValidStyleValue(string $key, string $value): bool {
- $pattern = $this->allowableStyleKeys[$key] ?? null;
- if ($pattern === null) return false;
- $options = explode('|', $pattern);
- foreach ($options as $option) {
- switch ($option) {
- case '{color}':
- if ($this->isValidCSSColor($value)) return true;
- default:
- if ($value === $option) return true;
- }
- }
- return false;
- }
-
- private const styleColorRegex = '^#[0-9a-f]{3}(?:[0-9a-f]{3})?$|^[a-zA-Z]+$';
-
- private function isValidCSSColor(string $value): bool {
- return mb_eregi(self::styleColorRegex, $value);
- }
- }
-
- /**
- * Represents a single HTML tag. Paired tags are represented separately.
- */
- class MDHTMLTag {
- /**
- * Verbatim string of the original parsed tag. Not modified. Should be
- * considered unsafe for inclusion in the final document. Use `->toString()`
- * instead.
- */
- public string $original;
- public string $tagName;
- public bool $isCloser;
- /**
- * Map of attribute names to value strings.
- */
- public array $attributes;
-
- /**
- * @param string $original
- * @param string $tagName
- * @param bool $isCloser
- * @param array $attributes
- */
- public function __construct(string $original, string $tagName, bool $isCloser,
- array $attributes) {
- $this->original = $original;
- $this->tagName = $tagName;
- $this->isCloser = $isCloser;
- $this->attributes = $attributes;
- }
-
- public function __toString(): string {
- if ($this->isCloser) {
- return "</{$this->tagName}>";
- }
- $html = '<';
- $html .= $this->tagName;
- foreach ($this->attributes as $key => $value) {
- $safeName = MDUtils::scrubAttributeName($key);
- if ($value === true) {
- $html .= " {$safeName}";
- } else {
- $escapedValue = MDUtils::escapeHTML("{$value}");
- $html .= " {$safeName}=\"{$escapedValue}\"";
- }
- }
- $html .= '>';
- return $html;
- }
-
- public function equals($other): bool {
- if (!($other instanceof MDHTMLTag)) return false;
- if ($other->tagName != $this->tagName) return false;
- if ($other->isCloser != $this->isCloser) return false;
- return MDUtils::equal($other->attributes, $this->attributes);
- }
-
- private const htmlTagNameFirstRegex = '[a-z]';
- private const htmlTagNameMedialRegex = '[a-z0-9]';
- private const htmlAttributeNameFirstRegex = '[a-z]';
- private const htmlAttributeNameMedialRegex = '[a-z0-9-]';
- private const whitespaceCharRegex = '\\s';
-
- /**
- * Checks the start of the given string for presence of an HTML tag.
- */
- public static function fromLineStart(string $line): ?MDHTMLTag {
- $expectOpenBracket = 0;
- $expectCloserOrName = 1;
- $expectName = 2;
- $expectAttributeNameOrEnd = 3;
- $expectEqualsOrAttributeOrEnd = 4;
- $expectAttributeValue = 5;
- $expectCloseBracket = 6;
-
- $isCloser = false;
- $tagName = '';
- $attributeName = '';
- $attributeValue = '';
- $attributeQuote = null;
- $attributes = [];
- $fullTag = null;
- $endAttribute = function(bool $unescape=false) use (&$attributes,
- &$attributeName, &$attributeValue, &$attributeQuote) {
- if (mb_strlen($attributeName) > 0) {
- if (mb_strlen($attributeValue) > 0 || $attributeQuote !== null) {
- $attributes[$attributeName] = $unescape ?
- html_entity_decode($attributeValue, ENT_QUOTES |
- ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8') :
- $attributeValue;
- } else {
- $attributes[$attributeName] = true;
- }
- }
- $attributeName = '';
- $attributeValue = '';
- $attributeQuote = null;
- };
-
- $expect = $expectOpenBracket;
- for ($p = 0; $p < mb_strlen($line) && $fullTag === null; $p++) {
- $ch = mb_substr($line, $p, 1);
- $isWhitespace = mb_eregi(self::whitespaceCharRegex, $ch);
- switch ($expect) {
- case $expectOpenBracket:
- if ($ch != '<') return null;
- $expect = $expectCloserOrName;
- break;
- case $expectCloserOrName:
- if ($ch == '/') {
- $isCloser = true;
- } else {
- $p--;
- }
- $expect = $expectName;
- break;
- case $expectName:
- if (mb_strlen($tagName) == 0) {
- if (!mb_eregi(self::htmlTagNameFirstRegex, $ch)) return null;
- $tagName .= $ch;
- } else {
- if (mb_eregi(self::htmlTagNameMedialRegex, $ch)) {
- $tagName .= $ch;
- } else {
- $p--;
- $expect = ($isCloser) ? $expectCloseBracket :
- $expectAttributeNameOrEnd;
- }
- }
- break;
- case $expectAttributeNameOrEnd:
- if (mb_strlen($attributeName) == 0) {
- if ($isWhitespace) {
- // skip whitespace
- } elseif ($ch == '/') {
- $expect = $expectCloseBracket;
- } elseif ($ch == '>') {
- $fullTag = mb_substr($line, 0, $p + 1);
- break;
- } elseif (mb_eregi(self::htmlAttributeNameFirstRegex, $ch)) {
- $attributeName .= $ch;
- } else {
- return null;
- }
- } elseif ($isWhitespace) {
- $expect = $expectEqualsOrAttributeOrEnd;
- } elseif ($ch == '/') {
- $endAttribute();
- $expect = $expectCloseBracket;
- } elseif ($ch == '>') {
- $endAttribute();
- $fullTag = mb_substr($line, 0, $p + 1);
- break;
- } elseif ($ch == '=') {
- $expect = $expectAttributeValue;
- } elseif (mb_eregi(self::htmlAttributeNameMedialRegex, $ch)) {
- $attributeName .= $ch;
- } else {
- return null;
- }
- break;
- case $expectEqualsOrAttributeOrEnd:
- if ($ch == '=') {
- $expect = $expectAttributeValue;
- } elseif ($isWhitespace) {
- // skip whitespace
- } elseif ($ch == '/') {
- $expect = $expectCloseBracket;
- } elseif ($ch == '>') {
- $fullTag = mb_substr($line, 0, $p + 1);
- break;
- } elseif (mb_eregi(self::htmlAttributeNameFirstRegex, $ch)) {
- $endAttribute();
- $expect = $expectAttributeNameOrEnd;
- $p--;
- }
- break;
- case $expectAttributeValue:
- if (mb_strlen($attributeValue) == 0) {
- if ($attributeQuote === null) {
- if ($isWhitespace) {
- // skip whitespace
- } elseif ($ch == '"' || $ch == "'") {
- $attributeQuote = $ch;
- } else {
- $attributeQuote = ''; // explicitly unquoted
- $p--;
- }
- } else {
- if ($ch === $attributeQuote) {
- // Empty string
- $endAttribute($attributeQuote != '');
- $expect = $expectAttributeNameOrEnd;
- } elseif ($attributeQuote === '' && ($ch == '/' || $ch == '>')) {
- return null;
- } else {
- $attributeValue .= $ch;
- }
- }
- } else {
- if ($ch === $attributeQuote) {
- $endAttribute($attributeQuote != '');
- $expect = $expectAttributeNameOrEnd;
- } elseif ($attributeQuote === '' && $isWhitespace) {
- $endAttribute();
- $expect = $expectAttributeNameOrEnd;
- } else {
- $attributeValue .= $ch;
- }
- }
- break;
- case $expectCloseBracket:
- if ($isWhitespace) {
- // ignore whitespace
- } elseif ($ch == '>') {
- $fullTag = mb_substr($line, 0, $p + 1);
- break;
- }
- break;
- }
- }
- if ($fullTag === null) return null;
- $endAttribute();
- return new MDHTMLTag($fullTag, $tagName, $isCloser, $attributes);
- }
- }
-
- /**
- * Represents HTML modifications to a node, such as CSS classes to add or
- * additional attributes. See `MDHTMLFilter->scrubModifier()` to remove disallowed
- * values.
- */
- class MDTagModifier {
- /**
- * Verbatim markdown syntax. Unmodified by changes to other properties.
- */
- public string $original;
- /** @var string[] */
- public array $cssClasses = [];
- public ?string $cssId = null;
- public array $cssStyles = [];
- public array $attributes = [];
-
- private const leadingClassRegex = '^\\{([^}]+?)}';
- private const trailingClassRegex = '^(.*?)\\s*\\{([^}]+?)}\\s*$';
- private const classRegex = '^\\.([a-z_\\-][a-z0-9_\\-]*?)$'; // 1=classname
- private const idRegex = '^#([a-z_\\-][a-z0-9_\\-]*?)$'; // 1=id
- private const attributeRegex = '^([a-z0-9]+?)=([^\\s\\}]+?)$'; // 1=attribute name, 2=attribute value
-
- public function applyTo(MDNode $node) {
- if ($node instanceof MDNode) {
- foreach ($this->cssClasses as $cssClass) {
- $node->addClass($cssClass);
- }
- if ($this->cssId) $node->cssId = $this->cssId;
- foreach ($this->attributes as $name => $value) {
- $node->attributes[$name] = $value;
- }
- foreach ($this->cssStyles as $name => $value) {
- $node->cssStyles[$name] = $value;
- }
- }
- }
-
- /**
- * Adds a CSS class. If already present it will not be duplicated.
- */
- public function addClass(string $cssClass): bool {
- if (array_search($cssClass, $this->cssClasses) !== false) return false;
- array_push($this->cssClasses, $cssClass);
- return true;
- }
-
- /**
- * Removes a CSS class.
- */
- public function removeClass(string $cssClass): bool {
- $beforeLength = sizeof($this->cssClasses);
- $this->cssClasses = array_diff($this->cssClasses, [ $cssClass ]);
- return sizeof($this->cssClasses) != $beforeLength;
- }
-
- public function equals($other): bool {
- if (!($other instanceof MDTagModifier)) return false;
- if (!MDUtils::equal($other->cssClasses, $this->cssClasses)) return false;
- if ($other->cssId !== $this->cssId) return false;
- if (!MDUtils::equal($other->attributes, $this->attributes)) return false;
- return true;
- }
-
- public function __toString(): string {
- return $this->original;
- }
-
- private static function styleToObject(string $styleValue): array {
- $pairs = explode(';', $styleValue);
- $styles = [];
- foreach ($pairs as $pair) {
- $keyAndValue = explode(':', $pair);
- if (sizeof($keyAndValue) != 2) continue;
- $styles[$keyAndValue[0]] = $keyAndValue[1];
- }
- return $styles;
- }
-
- private static function fromContents(string $contents): ?MDTagModifier {
- $modifierTokens = mb_split('\\s+', $contents);
- $mod = new MDTagModifier();
- $mod->original = "{{$contents}}";
- foreach ($modifierTokens as $token) {
- if (trim($token) == '') continue;
- if (mb_eregi(self::classRegex, $token, $groups)) {
- $mod->addClass($groups[1]);
- } elseif (mb_eregi(self::idRegex, $token, $groups)) {
- $mod->cssId = $groups[1];
- } elseif (mb_eregi(self::attributeRegex, $token, $groups)) {
- if ($groups[1] == 'style') {
- $mod->cssStyles = self::styleToObject($groups[2]);
- } else {
- $mod->attributes[$groups[1]] = $groups[2];
- }
- } else {
- return null;
- }
- }
- return $mod;
- }
-
- /**
- * Extracts block modifier from end of a line. Always returns a 2-element
- * tuple array:
- * - `0`: the line without the modifier
- * - `1`: an `MDTagModifier` if found or `null` if not
- *
- * @param string $line
- * @param ?MDState $state
- * @return array tuple with remaining line and `MDTagModifier` or `null`
- */
- public static function fromLine(string $line, ?MDState $state): array {
- if ($state) {
- $found = false;
- foreach ($state->root()->readersByBlockPriority as $reader) {
- if ($reader instanceof MDModifierReader) {
- $found = true;
- break;
- }
- }
- if (!$found) return [ $line, null ];
- }
- if (!mb_eregi(self::trailingClassRegex, $line, $groups)) return [ $line, null ];
- $bareLine = $groups[1];
- $mod = self::fromContents($groups[2]);
- return [ $bareLine, $mod ];
- }
-
- /**
- * Attempts to extract modifier from head of string.
- */
- public static function fromStart(string $line): ?MDTagModifier {
- if (!mb_eregi(self::leadingClassRegex, $line, $groups)) return null;
- return self::fromContents($groups[1]);
- }
-
- /**
- * Discards any modifiers from a line and returns what remains.
- */
- public static function strip(string $line): string {
- if (!mb_eregi(self::trailingClassRegex, $line, $groups)) return $line;
- return $groups[1];
- }
- }
-
-
- // -- Readers ---------------------------------------------------------------
-
-
- /**
- * Base class for readers of various markdown syntax. A `Markdown` instance can
- * be created with any combination of subclasses of these to customize the
- * flavor of markdown parsed.
- *
- * @see {@link custom.md} for details on subclassing
- */
- class MDReader {
- /**
- * Called before processing begins. `$state->lines` is populated and the
- * line pointer `$state->p` will be at `0`.
- *
- * Default implementation does nothing.
- */
- public function preProcess(MDState $state) {}
-
- /**
- * Attempts to read an `MDBlockNode` subclass at the current line pointer
- * `$state->p`. Only matches if the block pattern starts at the line pointer,
- * not elsewhere in the `$state->lines` array. If a block is found, `$state->p`
- * should be incremented to the next line _after_ the block structure and
- * a `MDBlockNode` subclass instance is returned. If no block is found,
- * returns `null`.
- *
- * Default implementation always returns `null`.
- */
- public function readBlock(MDState $state): ?MDBlockNode { return null; }
-
- /**
- * Attempts to read an inline token from the beginning of `$line`. Only the
- * start of the given `$line` is considered. If a matching token is found, an
- * `MDToken` is returned. Otherwise `null` is returned.
- *
- * Default implementation always returns `null`.
- */
- public function readToken(MDState $state, string $line): ?MDToken { return null; }
-
- /**
- * Attempts to find a pattern anywhere in `$tokens` and perform a _single_
- * in-place substitution with one or more `MDNode` subclass instances.
- * If a substitution is performed, must return `true`, otherwise `false`.
- *
- * Default implementation always returns `false`.
- *
- * @param MDState $state
- * @param int $pass what substitution pass this is, starting with 1
- * @param (MDToken|MDInlineNode)[] $tokens mixed array of `MDToken` and
- * `MDInlineNode` elements
- * @return bool `true` if a substitution was performed, `false` if not
- */
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool { return false; }
-
- /**
- * Called after all parsing has completed. An array `$blocks` is passed of
- * all the top-level `MDBlockNode` elements in the document which this
- * method can traverse or alter in-place via `array_splice` operations if
- * necessary.
- *
- * `MDNode->visitChildren` is useful for recursively looking for certain
- * `MDNode` instances. `MDNode::replaceNodes` is useful for swapping in
- * replacements.
- *
- * Default implementation does nothing.
- *
- * @param MDState $state
- * @param MDBlockNode[] $blocks
- */
- public function postProcess(MDState $state, array &$blocks) {}
-
- /**
- * Can be overridden to influence ordering of this reader with respect to
- * another during the block parsing phase. Return `-1` to be ordered before
- * the given reader, `1` to be ordered after it, or `0` for no preference.
- * Only return non-`0` values to resolve specific conflicts.
- *
- * Default implementation always returns `0` (no preference).
- *
- * @param MDReader $other
- * @return int a negative, positive, or 0 value to be ordered before,
- * after, or anwhere relative to `$other`, respectively
- */
- public function compareBlockOrdering(MDReader $other): int {
- return 0;
- }
-
- /**
- * Can be overridden to influence ordering of this reader with respect to
- * another during the tokenizing phase. Return `-1` to be ordered before
- * the given reader, `1` to be ordered after it, or `0` for no preference.
- * Only return non-`0` values to resolve specific conflicts.
- *
- * Default implementation always returns `0` (no preference).
- *
- * @param MDReader $other
- * @return int a negative, positive, or 0 value to be ordered before,
- * after, or anwhere relative to `$other`, respectively
- */
- public function compareTokenizeOrdering(MDReader $other): int {
- return 0;
- }
-
- /**
- * Can be overridden to influence ordering of this reader with respect to
- * another during the substitution phase. Return `-1` to be ordered before
- * the given reader, `1` to be ordered after it, or `0` for no preference.
- * Only return non-`0` values to resolve specific conflicts.
- *
- * Readers are sorted within each substitution pass. All pass 1 readers are
- * processed first, then all pass 2 readers, etc. The number of passes this
- * reader participates in is dictated by `substitionPassCount()`.
- *
- * Default implementation always returns `0` (no preference).
- *
- * @param MDReader $other
- * @param int $pass substitution pass, with numbering starting at `1`
- * @return int a negative, positive, or 0 value to be ordered before,
- * after, or anwhere relative to `$other`, respectively
- */
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- return 0;
- }
-
- /**
- * How many substitution passes this reader requires. Substitution allows
- * all pass 1 readers to process first, then all pass 2 readers, etc.
- */
- public function substitutionPassCount(): int { return 1; }
-
- /**
- * For sorting readers with ordering preferences. The `compare` methods
- * don't have the properties of normal sorting compares so need to sort
- * differently.
- *
- * @param MDReader[] $arr array to sort
- * @param callable $compareFn comparison function, taking two array element
- * arguments and returning -1, 0, or 1 for a < b, a == b, and a > b,
- * respectively
- * @param callable $idFn function for returning a unique hashable id for
- * the array element
- * @return MDReader[] sorted array
- */
- private static function kahnTopologicalSort(array $arr, callable $compareFn,
- callable $idFn): array {
- $graph = [];
- $inDegrees = [];
- $valuesById = [];
-
- // Build the graph and compute in-degrees
- foreach ($arr as $index => $elem) {
- $id = $idFn($elem);
- $graph[$id] = [];
- $inDegrees[$id] = 0;
- $valuesById[$id] = $elem;
- }
-
- for ($i = 0; $i < sizeof($arr); $i++) {
- $elemA = $arr[$i];
- $idA = $idFn($elemA);
- for ($j = 0; $j < sizeof($arr); $j++) {
- if ($i === $j) continue;
- $elemB = $arr[$j];
- $idB = $idFn($elemB);
- $comparisonResult = $compareFn($elemA, $elemB);
- if ($comparisonResult < 0) {
- array_push($graph[$idA], $idB);
- $inDegrees[$idB]++;
- } elseif ($comparisonResult > 0) {
- array_push($graph[$idB], $idA);
- $inDegrees[$idA]++;
- }
- }
- }
-
- // Initialize the queue with zero-inDegree nodes
- $queue = [];
- foreach ($inDegrees as $elemId => $degree) {
- if ($degree === 0) {
- array_push($queue, $elemId);
- }
- }
-
- // Process the queue and build the topological order list
- $sorted = [];
- while (sizeof($queue) > 0) {
- $elemId = array_shift($queue);
- array_push($sorted, $valuesById[$elemId]);
- unset($valuesById[$elemId]);
-
- foreach ($graph[$elemId] as $neighbor) {
- $inDegrees[$neighbor]--;
- if ($inDegrees[$neighbor] === 0) {
- array_push($queue, $neighbor);
- }
- }
- }
- // Anything left over can go at the end. No ordering dependencies.
- foreach ($valuesById as $elemId => $value) {
- array_push($sorted, $value);
- }
-
- return $sorted;
- }
-
- /**
- * Returns a sorted array of readers by their block priority preferences.
- *
- * @param MDReader[] $readers
- * @return MDReader[] sorted readers
- */
- public static function sortReaderForBlocks(array &$readers): array {
- $sorted = $readers;
- return self::kahnTopologicalSort($sorted, function(MDReader $a, MDReader $b): int {
- return $a->compareBlockOrdering($b);
- }, fn($elem) => MDUtils::typename($elem));
- }
-
- /**
- * Returns a sorted array of readers by their tokenization priority preferences.
- *
- * @param MDReader[] $readers
- * @return MDReader[] sorted readers
- */
- public static function sortReadersForTokenizing(array &$readers): array {
- $sorted = $readers;
- return self::kahnTopologicalSort($sorted, function(MDReader $a, MDReader $b): int {
- return $a->compareTokenizeOrdering($b);
- }, fn($elem) => MDUtils::typename($elem));
- }
-
- /**
- * Returns a sorted array of tuples (arrays) containing the substitution
- * pass number and reader instance, sorted by their substitution priority
- * preferences.
- *
- * For readers with `substitutionPassCount()` > `1`, the same reader will
- * appear multiple times in the resulting array, one per pass.
- *
- * @param MDReader[] $readers
- * @return MDReader[] sorted array of tuples with the pass number and
- * reader instance in each
- */
- public static function sortReadersForSubstitution(array &$readers): array {
- $tuples = [];
- $maxPass = 1;
- foreach ($readers as $reader) {
- $passCount = $reader->substitutionPassCount();
- $maxPass = max($maxPass, $passCount);
- for ($pass = 1; $pass <= $passCount; $pass++) {
- array_push($tuples, [ $pass, $reader ]);
- }
- }
- $result = [];
- for ($pass = 1; $pass <= $maxPass; $pass++) {
- $readersThisPass = array_values(array_filter($tuples, fn($tup) => $tup[0] === $pass));
- $passResult = self::kahnTopologicalSort($readersThisPass,
- function(array $a, array $b) use ($pass): int {
- $aReader = $a[1];
- $bReader = $b[1];
- return $aReader->compareSubstituteOrdering($bReader, $pass);
- }, fn($elem) => MDUtils::typename($elem[1]));
- $result = array_merge($result, $passResult);
- }
- return $result;
- }
- }
-
- /**
- * Reads markdown blocks for headings denoted with the underline syntax.
- *
- * Supports `MDTagModifier` suffixes.
- */
- class MDUnderlinedHeadingReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- if (!$state->hasLines(2)) return null;
- $modifier;
- $contentLine = trim($state->lines[$p++]);
- [$contentLine, $modifier] = MDTagModifier::fromLine($contentLine, $state);
- $underLine = trim($state->lines[$p++]);
- if ($contentLine == '') return null;
- if (mb_eregi('^=+$', $underLine)) {
- $state->p = $p;
- $block = new MDHeadingNode(1, $state->inlineMarkdownToNodes($contentLine));
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
- if (mb_eregi('^\-+$', $underLine)) {
- $state->p = $p;
- $block = new MDHeadingNode(2, $state->inlineMarkdownToNodes($contentLine));
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
- return null;
- }
- }
-
- /**
- * Reads markdown blocks for headings denoted with hash marks. Heading levels 1
- * to 6 are supported.
- *
- * Supports `MDTagModifier` suffixes.
- */
- class MDHashHeadingReader extends MDReader {
- private const hashHeadingRegex = '^(#{1,6})\\s*([^#].*?)\\s*\\#*\\s*$'; // 1=hashes, 2=content
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $line = $state->lines[$p++];
- $modifier;
- [$line, $modifier] = MDTagModifier::fromLine($line, $state);
- if (!mb_eregi(self::hashHeadingRegex, $line, $groups)) return null;
- $state->p = $p;
- $level = mb_strlen($groups[1]);
- $content = $groups[2];
- $block = new MDHeadingNode($level, $state->inlineMarkdownToNodes($content));
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
- }
-
- /**
- * Reads subtext blocks. Subtext is smaller, fainter text for things like
- * disclaimers or sources.
- *
- * Supports `MDTagModifier` suffixes.
- */
- class MDSubtextReader extends MDReader {
- private const subtextRegex = '^\\-#\\s*(.*?)\\s*$'; // 1=content
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $line = $state->lines[$p++];
- $modifier;
- [$line, $modifier] = MDTagModifier::fromLine($line, $state);
- if (!mb_eregi(self::subtextRegex, $line, $groups)) return null;
- $state->p = $p;
- $content = $groups[1];
- $block = new MDSubtextNode($state->inlineMarkdownToNodes($content));
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
-
- public function compareBlockOrdering(MDReader $other): int {
- if ($other instanceof MDUnorderedListReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Reads markdown blocks for blockquoted text.
- */
- class MDBlockQuoteReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $blockquoteLines = [];
- $p = $state->p;
- while ($p < sizeof($state->lines)) {
- $line = $state->lines[$p++];
- if (str_starts_with($line, ">")) {
- array_push($blockquoteLines, $line);
- } else {
- break;
- }
- }
- if (sizeof($blockquoteLines) == 0) return null;
- $contentLines = array_map(fn($line) => mb_eregi_replace('^ {0,3}\\t?', '',
- mb_substr($line, 1)), $blockquoteLines);
- $substate = $state->copy($contentLines);
- $quotedBlocks = $substate->readBlocks();
- $state->p = $p;
- return new MDBlockquoteNode($quotedBlocks);
- }
- }
-
- /**
- * Internal abstract base class for ordered and unordered lists.
- */
- class _MDListReader extends MDReader {
- private static function readItemLines(MDState $state, int $firstLineStartPos): array {
- $p = $state->p;
- $lines = [];
- $seenBlankLine = false;
- $stripTrailingBlankLines = true;
- while ($state->hasLines(1, $p)) {
- $isFirstLine = ($p == $state->p);
- $line = $state->lines[$p++];
- if ($isFirstLine) {
- $line = mb_substr($line, $firstLineStartPos);
- }
- if (mb_eregi('^(?:\\*|\\+|\\-|\\d+\\.)\\s+', $line)) {
- // Found next list item
- $stripTrailingBlankLines = false; // because this signals extra spacing intended
- break;
- }
- $isBlankLine = trim($line) == '';
- $isIndented = mb_eregi('^\\s+\\S', $line);
- if ($isBlankLine) {
- $seenBlankLine = true;
- } elseif (!$isIndented && $seenBlankLine) {
- // Post-list content
- break;
- }
- array_push($lines, $line);
- }
- $lines = MDUtils::withoutTrailingBlankLines($lines);
- return MDUtils::stripIndent($lines);
- }
-
- protected function readListItemContent(MDState $state, int $firstLineStartPos): MDNode|array {
- $itemLines = $this->readItemLines($state, $firstLineStartPos);
- $state->p += max(sizeof($itemLines), 1);
-
- if (sizeof($itemLines) == 1) {
- return $state->inlineMarkdownToNodes($itemLines[0]);
- }
-
- $hasBlankLines = sizeof(array_filter($itemLines, fn($line) => trim($line) == '')) > 0;
- if ($hasBlankLines) {
- $substate = $state->copy($itemLines);
- return $substate->readBlocks();
- }
-
- // Multiline content with no blank lines. Search for new block
- // boundaries without the benefit of a blank line to demarcate it.
- for ($p = 1; $p < sizeof($itemLines); $p++) {
- $line = $itemLines[$p];
- if (mb_eregi('^(?:\\*|\\-|\\+|\\d+\\.)\\s+', $line)) {
- // Nested list found
- $firstNodes = $state->inlineMarkdownToNodes(
- implode("\n", array_slice($itemLines, 0, $p)));
- $substate = $state->copy(array_slice($itemLines, $p));
- $blocks = $substate->readBlocks();
- return new MDBlockNode(array_merge($firstNodes, $blocks));
- }
- }
-
- // Ok, give up and just do a standard block read
- {
- $substate = $state->copy($itemLines);
- return $substate->readBlocks();
- }
- }
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $className = MDUtils::typename($this);
- throw new Error("Abstract readBlock must be overridden in {$className}");
- }
- }
-
- /**
- * Block reader for unordered (bulleted) lists.
- */
- class MDUnorderedListReader extends _MDListReader {
- private const unorderedListRegex = '^([\\*\\+\\-]\\s+)(.*)$'; // 1=bullet, 2=content
-
- private function readUnorderedListItem(MDState $state): ?MDListItemNode {
- if (!$state->hasLines(1)) return null;
- $p = $state->p;
- $line = $state->lines[$p];
- if (!mb_eregi(self::unorderedListRegex, $line, $groups)) return null;
- $firstLineOffset = mb_strlen($groups[1]);
- return new MDListItemNode($this->readListItemContent($state, $firstLineOffset));
- }
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $items = [];
- $item = null;
- do {
- $item = $this->readUnorderedListItem($state);
- if ($item) array_push($items, $item);
- } while ($item);
- if (sizeof($items) == 0) return null;
- return new MDUnorderedListNode($items);
- }
- }
-
- /**
- * Block reader for ordered (numbered) lists. The number of the first item is
- * used to begin counting. The subsequent items increase by 1, regardless of
- * their value.
- */
- class MDOrderedListReader extends _MDListReader {
- private const orderedListRegex = '^(\\d+)(\\.\\s+)(.*)$'; // 1=number, 2=dot, 3=content
-
- private function readOrderedListItem(MDState $state): ?MDListItemNode {
- if (!$state->hasLines(1)) return null;
- $p = $state->p;
- $line = $state->lines[$p];
- if (!mb_eregi(self::orderedListRegex, $line, $groups)) return null;
- $ordinal = intval($groups[1]);
- $firstLineOffset = mb_strlen($groups[1]) + mb_strlen($groups[2]);
- return new MDListItemNode($this->readListItemContent($state, $firstLineOffset), $ordinal);
- }
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $items = [];
- $item = null;
- do {
- $item = $this->readOrderedListItem($state);
- if ($item) array_push($items, $item);
- } while ($item);
- if (sizeof($items) == 0) return null;
- return new MDOrderedListNode($items, $items[0]->ordinal);
- }
- }
-
- /**
- * Block reader for code blocks denoted by pairs of triple tickmarks. If
- * a programming language name, _xyz_, immediately follows the backticks, a
- * `language-xyz` CSS class will be added to the resulting `<code>`
- * element.
- *
- * Supports `MDTagModifier` suffix.
- */
- class MDFencedCodeBlockReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- if (!$state->hasLines(2)) return null;
- $p = $state->p;
- $openFenceLine = $state->lines[$p++];
- [$openFenceLine, $modifier] = MDTagModifier::fromLine($openFenceLine, $state);
- if (!mb_eregi('```\\s*([a-z0-9]*)\\s*$', $openFenceLine, $groups)) return null;
- $language = $groups[1] !== false && mb_strlen($groups[1]) > 0 ? $groups[1] : null;
- $codeLines = [];
- while ($state->hasLines(1, $p)) {
- $line = $state->lines[$p++];
- if (trim($line) == '```') {
- $state->p = $p;
- $block = new MDCodeBlockNode(implode("\n", $codeLines), $language);
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
- array_push($codeLines, $line);
- }
- return null;
- }
- }
-
- /**
- * Block reader for code blocks denoted by indenting text.
- */
- class MDIndentedCodeBlockReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $codeLines = [];
- while ($state->hasLines(1, $p)) {
- $line = $state->lines[$p++];
- if (MDUtils::countIndents($line, true) < 1) {
- $p--;
- break;
- }
- array_push($codeLines, MDUtils::stripIndent($line));
- }
- if (sizeof($codeLines) == 0) return null;
- $state->p = $p;
- return new MDCodeBlockNode(implode("\n", $codeLines));
- }
- }
-
- /**
- * Block reader for horizontal rules. Composed of three or more hypens or
- * asterisks on a line by themselves, with or without intermediate whitespace.
- */
- class MDHorizontalRuleReader extends MDReader {
- private const horizontalRuleRegex = '^\\s*(?:\\-(?:\\s*\\-){2,}|\\*(?:\\s*\\*){2,})\\s*$';
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $line = $state->lines[$p++];
- [$line, $modifier] = MDTagModifier::fromLine($line, $state);
- if (mb_eregi(self::horizontalRuleRegex, $line)) {
- $state->p = $p;
- $block = new MDHorizontalRuleNode();
- if ($modifier) $modifier->applyTo($block);
- return $block;
- }
- return null;
- }
-
- public function compareBlockOrdering(MDReader $other): int {
- if ($other instanceof MDUnorderedListReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Block reader for tables.
- *
- * Supports `MDTagModifier` suffix.
- */
- class MDTableReader extends MDReader {
- /**
- * If cell contents begin with `=`, treat entire contents as plaintext.
- * Used by spreadsheet add-on to prevent equation operators from being
- * interpreted as markdown.
- * @type {boolean}
- */
- public bool $preferFormulas = false;
-
- private function readTableRow(MDState $state, bool $isHeader): ?MDTableRowNode {
- if (!$state->hasLines(1)) return null;
- $p = $state->p;
- $line = MDTagModifier::strip(trim($state->lines[$p++]));
- if (!mb_eregi('.*\\|.*', $line)) return null;
- if (str_starts_with($line, '|')) $line = mb_substr($line, 1);
- if (str_ends_with($line, '|')) $line = mb_substr($line, 0, mb_strlen($line) - 1);
- $cellTokens = explode('|', $line);
- $cells = array_map(function($token) use ($state, $isHeader) {
- $trimmedToken = trim($token);
- if ($this->preferFormulas && strpos($trimmedToken, '=') !== false) {
- $content = $this->preserveFormula($state, $trimmedToken);
- if ($content === null) {
- $content = $state->inlineMarkdownToNode($trimmedToken);
- }
- } else {
- $content = $state->inlineMarkdownToNode($trimmedToken);
- }
- return $isHeader ? new MDTableHeaderCellNode($content) : new MDTableCellNode($content);
- }, $cellTokens);
- $state->p = $p;
- return new MDTableRowNode($cells);
- }
-
- /**
- * @param MDState $state
- * @param string $cellContents
- * @return ?MDNode
- */
- private function preserveFormula(MDState $state, string $cellContents): ?MDNode {
- // Up to three prefix punctuation patterns, formula, then three matching
- // suffixes. Not guaranteed to catch every possible syntax but an awful lot.
- // Using preg_match instead for... reasons.
- $regex = '/^([^a-z0-9\\s]*)([^a-z0-9\\s]*)([^a-z0-9\\s]*)(=.*)\\3\\2\\1$/i';
- if (!preg_match($regex, $cellContents, $groups)) {
- return null;
- }
- $prefix = $groups[1] . $groups[2] . $groups[3];
- $formula = $groups[4];
- if ($prefix === '') {
- return new MDTextNode($formula);
- }
- $suffix = $groups[3] . $groups[2] . $groups[1];
- // Parse substitute markdown with the same prefix and suffix but just
- // an "x" as content. We'll swap in the unaltered formula into the
- // parsed nodes.
- $tempInline = $prefix . 'x' . $suffix;
- $tempNodes = $state->inlineMarkdownToNodes($tempInline);
- if (count($tempNodes) != 1) return null;
- $foundText = false;
- if ($tempNodes[0] instanceof MDTextNode && $tempNodes[0]->text === 'x') {
- $tempNodes[0]->text = $formula;
- $foundText = true;
- } else {
- $tempNodes[0]->visitChildren(function($node) use ($formula, &$foundText) {
- if ($node instanceof MDTextNode && $node->text === 'x') {
- $node->text = $formula;
- $foundText = true;
- }
- });
- }
- if (!$foundText) return null;
- return $tempNodes[0];
- }
-
- /**
- * @param string $line
- * @return string[]
- */
- private function parseColumnAlignments(string $line): array {
- $line = trim($line);
- if (str_starts_with($line, '|')) $line = mb_substr($line, 1);
- if (str_ends_with($line, '|')) $line = mb_substr($line, 0, mb_strlen($line) - 1);
- return array_map(function($token) {
- if (str_starts_with($token, ':')) {
- if (str_ends_with($token, ':')) {
- return 'center';
- }
- return 'left';
- } elseif (str_ends_with($token, ':')) {
- return 'right';
- }
- return null;
- }, mb_split('\\s*\\|\\s*', $line));
- }
-
- private const tableDividerRegex = '^\\s*[|]?\\s*(?:[:]?-+[:]?)(?:\\s*\\|\\s*[:]?-+[:]?)*\\s*[|]?\\s*$';
-
- public function readBlock(MDState $state): ?MDBlockNode {
- if (!$state->hasLines(2)) return null;
- $startP = $state->p;
- $firstLine = $state->lines[$startP];
- $modifier = MDTagModifier::fromLine($firstLine, $state)[1];
- $headerRow = $this->readTableRow($state, true);
- if ($headerRow === null) {
- $state->p = $startP;
- return null;
- }
- $dividerLine = $state->lines[$state->p++];
- if (!mb_eregi(self::tableDividerRegex, $dividerLine, $dividerGroups)) {
- $state->p = $startP;
- return null;
- }
- $columnAlignments = $this->parseColumnAlignments($dividerLine);
- $bodyRows = [];
- while ($state->hasLines(1)) {
- $row = $this->readTableRow($state, false);
- if ($row === null) break;
- array_push($bodyRows, $row);
- }
- $table = new MDTableNode($headerRow, $bodyRows);
- $table->columnAlignments = $columnAlignments;
- if ($modifier) $modifier->applyTo($table);
- return $table;
- }
- }
-
- /**
- * Block reader for definition lists. Definitions go directly under terms starting
- * with a colon.
- */
- class MDDefinitionListReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $groups;
- $termCount = 0;
- $definitionCount = 0;
- $defLines = [];
- while ($state->hasLines(1, $p)) {
- $line = $state->lines[$p++];
- if (trim($line) === '') {
- break;
- }
- if (mb_eregi('^\\s+', $line)) {
- if (sizeof($defLines) == 0) return null;
- $defLines[sizeof($defLines) - 1] .= "\n" . $line;
- } elseif (mb_eregi('^:\\s+', $line)) {
- array_push($defLines, $line);
- $definitionCount++;
- } else {
- array_push($defLines, $line);
- $termCount++;
- }
- }
- if ($termCount == 0 || $definitionCount == 0) return null;
- $blocks = array_map(function($line) use ($state) {
- if (mb_eregi('^:\\s+(.*?)$', $line, $groups)) {
- return new MDDefinitionListDefinitionNode($state->inlineMarkdownToNodes($groups[1]));
- } else {
- return new MDDefinitionListTermNode($state->inlineMarkdownToNodes($line));
- }
- }, $defLines);
- $state->p = $p;
- return new MDDefinitionListNode($blocks);
- }
- }
-
- /**
- * Block reader for defining footnote contents. Footnotes can be defined anywhere
- * in the document but will always be rendered at the end of a page or end of
- * the document.
- */
- class MDFootnoteReader extends MDReader {
- private const footnoteWithTitleRegex = '^\\[\\^([^\\s\\[\\]]+?)\\s+"(.*?)"\\]'; // 1=symbol, 2=title
- private const footnoteRegex = '^\\[\\^([^\\s\\[\\]]+?)\\]'; // 1=symbol
-
- /**
- * @param MDState $state
- * @param string $symbol
- * @param MDNode[] $footnote
- */
- private function defineFootnote(MDState $state, string $symbol, array $footnote) {
- $footnotes = $state->root()->userInfo['footnotes'] ?? [];
- $footnotes[$symbol] = $footnote;
- $state->root()->userInfo['footnotes'] = $footnotes;
- }
-
- private function registerUniqueInstance(MDState $state, string $symbol, int $unique) {
- $footnoteInstances = $state->root()->userInfo['footnoteInstances'];
- $instances = $footnoteInstances[$symbol] ?? [];
- array_push($instances, $unique);
- $footnoteInstances[$symbol] = $instances;
- $state->root()->userInfo['footnoteInstances'] = $footnoteInstances;
- }
-
- private function idForFootnoteSymbol(MDState $state, string $symbol): int {
- $footnoteIds = $state->root()->userInfo['footnoteIds'] ?? [];
- $existing = $footnoteIds[$symbol] ?? null;
- if ($existing !== null) return $existing;
- $nextFootnoteId = $state->root()->userInfo['nextFootnoteId'] ?? 1;
- $id = $nextFootnoteId++;
- $footnoteIds[$symbol] = $id;
- $state->root()->userInfo['nextFootnoteId'] = $nextFootnoteId;
- $state->root()->userInfo['footnoteIds'] = $footnoteIds;
- return $id;
- }
-
- public function preProcess(MDState $state) {
- $state->root()->userInfo['footnoteInstances'] = [];
- $state->root()->userInfo['footnotes'] = [];
- $state->root()->userInfo['footnoteIds'] = [];
- $state->root()->userInfo['nextFootnoteId'] = 1;
- }
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- if (!mb_eregi('^\\s*\\[\\^\\s*([^\\]]+)\\s*\\]:\\s+(.*)\\s*$', $state->lines[$p++], $groups)) return null;
- $symbol = $groups[1];
- $def = $groups[2];
- while ($state->hasLines(1, $p)) {
- $line = $state->lines[$p++];
- if (mb_eregi('^\\s+', $line)) {
- $def .= "\n" . $line;
- } else {
- $p--;
- break;
- }
- }
- $content = $state->inlineMarkdownToNodes($def);
- $this->defineFootnote($state, $symbol, $content);
- $state->p = $p;
- return new MDBlockNode(); // empty
- }
-
- public function readToken(MDState $state, string $line): ?MDToken {
- $groups;
- if (mb_eregi(self::footnoteWithTitleRegex, $line, $groups)) {
- return new MDToken($groups[0], MDTokenType::Footnote, $groups[1], $groups[2]);
- }
- if (mb_eregi(self::footnoteRegex, $line, $groups)) {
- return new MDToken($groups[0], MDTokenType::Footnote, $groups[1]);
- }
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Footnote ])) {
- $symbol = $match->tokens[0]->content;
- array_splice($tokens, $match->index, 1, [new MDFootnoteNode($symbol)]);
- return true;
- }
- return false;
- }
-
- /**
- * @param MDState $state
- * @param MDBlockNode[] $blocks
- */
- public function postProcess(MDState $state, array &$blocks) {
- $nextOccurrenceId = 1;
- foreach ($blocks as $block) {
- $block->visitChildren(function($node) use (&$nextOccurrenceId, $state) {
- if (!($node instanceof MDFootnoteNode)) return;
- $node->footnoteId = $this->idForFootnoteSymbol($state, $node->symbol);
- $node->occurrenceId = $nextOccurrenceId++;
- $node->displaySymbol = strval($node->footnoteId);
- $this->registerUniqueInstance($state, $node->symbol, $node->occurrenceId);
- });
- }
- if (sizeof($state->userInfo['footnotes']) == 0) return;
- array_push($blocks, new MDFootnoteListNode());
- }
-
- public function compareBlockOrdering(MDReader $other): int {
- if ($other instanceof MDLinkReader || $other instanceof MDImageReader) {
- return -1;
- }
- return 0;
- }
-
- public function compareTokenizeOrdering(MDReader $other): int {
- if ($other instanceof MDLinkReader || $other instanceof MDImageReader) {
- return -1;
- }
- return 0;
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if ($other instanceof MDLinkReader || $other instanceof MDImageReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Block reader for abbreviation definitions. Anywhere the abbreviation appears
- * in plain text will have its definition available when hovering over it.
- * Definitions can appear anywhere in the document. Their content should only
- * contain simple text, not markdown.
- */
- class MDAbbreviationReader extends MDReader {
- private function defineAbbreviation(MDState $state, string $abbreviation, string $definition) {
- $abbrevs = $state->root()->userInfo['abbreviations'];
- $abbrevs[$abbreviation] = $definition;
- $state->root()->userInfo['abbreviations'] = $abbrevs;
- }
-
- public function preProcess(MDState $state) {
- $state->root()->userInfo['abbreviations'] = [];
- }
-
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $line = $state->lines[$p++];
- if (!mb_eregi('^\\s*\\*\\[([^\\]]+?)\\]:\\s+(.*?)\\s*$', $line, $groups)) return null;
- $abbrev = $groups[1];
- $def = $groups[2];
- $this->defineAbbreviation($state, $abbrev, $def);
- $state->p = $p;
- return new MDBlockNode(); // empty
- }
-
- /**
- * @param MDState $state
- * @param MDNode[] $blocks
- */
- public function postProcess(MDState $state, array &$blocks) {
- $abbreviations = $state->root()->userInfo['abbreviations'];
- MDNode::replaceNodes($state, $blocks, function($original) use ($abbreviations) {
- if (!($original instanceof MDTextNode)) return null;
- $changed = false;
- $elems = [ $original->text ]; // mix of strings and MDNodes
- for ($i = 0; $i < sizeof($elems); $i++) {
- $text = $elems[$i];
- if (!is_string($text)) continue;
- foreach ($abbreviations as $abbreviation => $definition) {
- $index = strpos($text, $abbreviation);
- if ($index === false) continue;
- $prefix = substr($text, 0, $index);
- $suffix = substr($text, $index + strlen($abbreviation));
- array_splice($elems, $i, 1, [$prefix,
- new MDAbbreviationNode($abbreviation, $definition),
- $suffix]);
- $i = -1; // start over
- $changed = true;
- break;
- }
- }
- if (!$changed) return null;
- $nodes = array_map(fn($elem) => is_string($elem) ? new MDTextNode($elem) : $elem, $elems);
- return new MDNode($nodes);
- });
- }
- }
-
- /**
- * Block reader for simple paragraphs. Paragraphs are separated by a blank (or
- * whitespace-only) line. This reader is prioritized after every other reader
- * since there is no distinguishing syntax.
- */
- class MDParagraphReader extends MDReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $paragraphLines = [];
- $p = $state->p;
- while ($state->hasLines(1, $p)) {
- $line = $state->lines[$p++];
- if (trim($line) === '') {
- break;
- }
- array_push($paragraphLines, $line);
- }
- if ($state->p == 0 && $p >= sizeof($state->lines)) {
- // If it's the entire document don't wrap it in a paragraph
- return null;
- }
- if (sizeof($paragraphLines) > 0) {
- $state->p = $p;
- $content = implode("\n", $paragraphLines);
- return new MDParagraphNode($state->inlineMarkdownToNodes($content));
- }
- return null;
- }
-
- public function compareBlockOrdering(MDReader $other): int {
- return 1; // always dead last
- }
- }
-
- /**
- * Abstract base class for readers that look for one or two delimiting tokens
- * on either side of some content. E.g. `**strong**`.
- */
- class MDSimplePairInlineReader extends MDReader {
- // Passes:
- // 1. Syntaxes with two delimiting tokens, interior tokens of the same
- // kind must be even in number
- // 2. Syntaxes with one delimiting token, interior tokens of the same
- // kind must be even in number
- // 3. Syntaxes with two delimiting tokens, any tokens inside
- // 4. Syntaxes with one delimiting token, any tokens inside
- public function substitutionPassCount(): int { return 4; }
-
- /**
- * Attempts a substitution of a matched pair of delimiting token types.
- * If successful, the substitution is performed on `$tokens` and `true` is
- * returned, otherwise `false` is returned and the array is untouched.
- *
- * If `this->substitutionPassCount()` is greater than 1, the first pass
- * will reject matches with the delimiting character inside the content
- * tokens. If the reader uses a single pass or a subsequent pass is performed
- * with multiple pass any contents will be accepted.
- *
- * @param MDState $state
- * @param int $pass pass number, starting with `1`
- * @param (MDToken|MDNode)[] $tokens tokens/nodes to perform substitution on
- * @param string $nodeClass class of the node to return if matched
- * @param MDTokenType $delimiter delimiting token
- * @param int $count how many times the token is repeated to form the delimiter
- * @param bool $plaintext whether to create `$nodeClass` with a verbatim
- * content string instead of parsed `MDNode`s
- * @return bool `true` if substitution was performed, `false` if not
- */
- public function attemptPair(MDState $state, int $pass, array &$tokens,
- string $nodeClass, MDTokenType $delimiter, int $count=1,
- bool $plaintext=false): bool {
- // We do four passes. #1: doubles without inner tokens, #2: singles
- // without inner tokens, #3: doubles with paired inner tokens,
- // #4: singles with paired inner tokens
- if ($count == 1 && $pass != 2 && $pass != 4) return false;
- if ($count > 1 && $pass != 1 && $pass != 3) return false;
- $delimiters = array_fill(0, $count, $delimiter);
- $isFirstOfMultiplePasses = $this->substitutionPassCount() > 1 && $pass == 1;
- $match = MDToken::findPairedTokens($tokens, $delimiters, $delimiters,
- function($content) use ($nodeClass, $isFirstOfMultiplePasses, $delimiter) {
- $firstType = $content[0] instanceof MDToken ? $content[0]->type : null;
- $lastType = $content[sizeof($content) - 1] instanceof MDToken ?
- $content[sizeof($content) - 1]->type : null;
- if ($firstType == MDTokenType::Whitespace) return false;
- if ($lastType == MDTokenType::Whitespace) return false;
- foreach ($content as $token) {
- // Don't allow nesting
- if (MDUtils::typename($token) == $nodeClass) return false;
- }
- if ($isFirstOfMultiplePasses) {
- $innerCount = 0;
- foreach ($content as $token) {
- if ($token instanceof MDToken && $token->type == $delimiter) $innerCount++;
- }
- if (($innerCount % 2) != 0) return false;
- }
- return true;
- });
- if ($match === null) return false;
- $state->checkExecutionTime();
- if ($plaintext) {
- $content = implode('', array_map(fn($token) => $token instanceof MDToken ?
- $token->original : $token->toPlaintext($state), $match->contentTokens));
- } else {
- $content = $state->tokensToNodes($match->contentTokens);
- }
- $ref = new ReflectionClass($nodeClass);
- $node = $ref->newInstanceArgs([ $content ]);
- array_splice($tokens, $match->startIndex, $match->totalLength, [$node]);
- return true;
- }
-
- private static $firstTime = null;
- }
-
- /**
- * Reader for emphasis syntax. Denoted with a single underscore on either side of
- * some text (preferred) or a single asterisk on either side.
- */
- class MDEmphasisReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '_')) return new MDToken('_', MDTokenType::Underscore);
- if (str_starts_with($line, '*')) return new MDToken('*', MDTokenType::Asterisk);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($this->attemptPair($state, $pass, $tokens, 'MDEmphasisNode', MDTokenType::Underscore)) return true;
- if ($this->attemptPair($state, $pass, $tokens, 'MDEmphasisNode', MDTokenType::Asterisk)) return true;
- return false;
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if ($other instanceof MDStrongReader) {
- return 1;
- }
- return 0;
- }
- }
-
- /**
- * Reader for strong syntax. Denoted with two asterisks on either side of some
- * text (preferred) or two underscores on either side. Note that if
- * `MDUnderlineReader` is in use, it will replace the double-underscore syntax.
- */
- class MDStrongReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '*')) return new MDToken('*', MDTokenType::Asterisk);
- if (str_starts_with($line, '_')) return new MDToken('_', MDTokenType::Underscore);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($this->attemptPair($state, $pass, $tokens, 'MDStrongNode', MDTokenType::Asterisk, 2)) return true;
- if ($this->attemptPair($state, $pass, $tokens, 'MDStrongNode', MDTokenType::Underscore, 2)) return true;
- return false;
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if ($other instanceof MDEmphasisReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Reader for strikethrough syntax. Consists of two tildes on either side of
- * some text (preferred) or single tildes on either side. Note that if
- * `MDSubscriptReader` is in use, it will replace the single-tilde syntax.
- *
- * The number of recognized tildes can be configured.
- */
- class MDStrikethroughReader extends MDSimplePairInlineReader {
- public bool $singleTildeEnabled = true;
- public bool $doubleTildeEnabled = true;
-
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '~')) return new MDToken('~', MDTokenType::Tilde);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($this->singleTildeEnabled) {
- if ($this->attemptPair($state, $pass, $tokens, 'MDStrikethroughNode', MDTokenType::Tilde, 2)) return true;
- }
- if ($this->doubleTildeEnabled) {
- if ($this->attemptPair($state, $pass, $tokens, 'MDStrikethroughNode', MDTokenType::Tilde)) return true;
- }
- return false;
- }
- }
-
- /**
- * Reader for underline syntax. Consists of two underscores on either side of
- * some text. If used with `MDStrongReader` which also looks for double
- * underscores, this reader will take priority.
- */
- class MDUnderlineReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '_')) return new MDToken('_', MDTokenType::Underscore);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- return $this->attemptPair($state, $pass, $tokens, 'MDUnderlineNode', MDTokenType::Underscore, 2);
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if ($other instanceof MDStrongReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Reader for highlight syntax. Consists of pairs of equal signs on either side
- * of some text.
- */
- class MDHighlightReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '=')) return new MDToken('=', MDTokenType::Equal);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- return $this->attemptPair($state, $pass, $tokens, 'MDHighlightNode', MDTokenType::Equal, 2);
- }
- }
-
- /**
- * Reader for inline code syntax. Consists of one or two delimiting backticks
- * around text. The contents between the backticks will be rendered verbatim,
- * ignoring any inner markdown syntax. To include a backtick inside, escape it
- * with a backslash.
- */
- class MDCodeSpanReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '`')) return new MDToken('`', MDTokenType::Backtick);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($this->attemptPair($state, $pass, $tokens, 'MDCodeNode', MDTokenType::Backtick, 2, true)) return true;
- if ($this->attemptPair($state, $pass, $tokens, 'MDCodeNode', MDTokenType::Backtick, 1, true)) return true;
- return false;
- }
- }
-
- /**
- * Reader for subscript syntax. Consists of single tildes on either side of
- * some text. If used with `MDStrikethroughReader`, this reader will take
- * precedence, and strikethrough can only be done with double tildes.
- */
- class MDSubscriptReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '~')) return new MDToken('~', MDTokenType::Tilde);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- return $this->attemptPair($state, $pass, $tokens, 'MDSubscriptNode', MDTokenType::Tilde);
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if ($other instanceof MDStrikethroughReader) {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Reader for superscript syntax. Consists of single caret characters on either
- * side of some text.
- */
- class MDSuperscriptReader extends MDSimplePairInlineReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- if (str_starts_with($line, '^')) return new MDToken('^', MDTokenType::Caret);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- return $this->attemptPair($state, $pass, $tokens, 'MDSuperscriptNode', MDTokenType::Caret);
- }
- }
-
- /**
- * Reads a hypertext link. Consists of link text between square brackets
- * followed immediately by a URL in parentheses.
- */
- class MDLinkReader extends MDReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- $simpleEmailRegex = "^<(" . MDUtils::baseEmailRegex . ")>";
- $simpleURLRegex = "^<(" . MDUtils::baseURLRegex . ")>";
- if ($groups = MDToken::tokenizeLabel($line)) {
- return new MDToken($groups[0], MDTokenType::Label, $groups[1]);
- }
- if ($groups = MDToken::tokenizeEmail($line)) {
- return new MDToken($groups[0], MDTokenType::Email, $groups[1], $groups[2]);
- }
- if ($groups = MDToken::tokenizeURL($line)) {
- return new MDToken($groups[0], MDTokenType::URL, $groups[1], $groups[2]);
- }
- if (mb_eregi($simpleEmailRegex, $line, $groups)) {
- return new MDToken($groups[0], MDTokenType::SimpleEmail, $groups[1]);
- }
- if (mb_eregi($simpleURLRegex, $line, $groups)) {
- return new MDToken($groups[0], MDTokenType::SimpleLink, $groups[1]);
- }
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Label,
- MDTokenType::META_OptionalWhitespace, MDTokenType::URL ])) {
- $text = $match->tokens[0]->content;
- $url = $match->tokens[sizeof($match->tokens) - 1]->content;
- $title = $match->tokens[sizeof($match->tokens) - 1]->extra;
- array_splice($tokens, $match->index, sizeof($match->tokens),
- [new MDLinkNode($url, $state->inlineMarkdownToNode($text), $title)]);
- return true;
- }
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Label,
- MDTokenType::META_OptionalWhitespace, MDTokenType::Email ])) {
- $text = $match->tokens[0]->content;
- $email = $match->tokens[sizeof($match->tokens) - 1]->content;
- $url = "mailto:{$email}";
- $title = $match->tokens[sizeof($match->tokens) - 1]->extra;
- array_splice($tokens, $match->index, sizeof($match->tokens),
- [new MDLinkNode($url, $state->inlineMarkdownToNodes($text), $title)]);
- return true;
- }
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::SimpleEmail ])) {
- $token = $match->tokens[0];
- $link = "mailto:{$token->content}";
- $node = new MDLinkNode($link, new MDObfuscatedTextNode($token->content));
- array_splice($tokens, $match->index, 1, [$node]);
- return true;
- }
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::SimpleLink ])) {
- $token = $match->tokens[0];
- $link = $token->content;
- $node = new MDLinkNode($link, new MDTextNode($link));
- array_splice($tokens, $match->index, 1, [$node]);
- return true;
- }
- return false;
- }
- }
-
- /**
- * Reader for referential URL definitions. Consists of link text between square
- * brackets followed immediately by a reference symbol also in square brackets.
- * The URL can be defined elsewhere on a line by itself with the symbol in square
- * brackets, colon, and the URL (and optional title in quotes).
- */
- class MDReferencedLinkReader extends MDLinkReader {
- public function readBlock(MDState $state): ?MDBlockNode {
- $p = $state->p;
- $line = $state->lines[$p++];
- if (mb_eregi('^\\s*\\[(.+?)]:\\s*(\\S+)\\s+"(.*?)"\\s*$', $line, $groups)) {
- $symbol = $groups[1];
- $url = $groups[2];
- $title = $groups[3];
- } else {
- if (mb_eregi('^\\s*\\[(.+?)]:\\s*(\\S+)\\s*$', $line, $groups)) {
- $symbol = $groups[1];
- $url = $groups[2];
- $title = null;
- } else {
- return null;
- }
- }
- $state->defineURL($symbol, $url, $title);
- $state->p = $p;
- return new MDBlockNode([]); // empty
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Label,
- MDTokenType::META_OptionalWhitespace, MDTokenType::Label ])) {
- $text = $match->tokens[0]->content;
- $ref = $match->tokens[sizeof($match->tokens) - 1]->content;
- array_splice($tokens, $match->index, sizeof($match->tokens),
- [new MDReferencedLinkNode($ref, $state->inlineMarkdownToNodes($text))]);
- return true;
- }
- return false;
- }
- }
-
- /**
- * Reader for images. Consists of an exclamation, alt text in square brackets,
- * and image URL in parentheses.
- */
- class MDImageReader extends MDLinkReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- $s = parent::readToken($state, $line);
- if ($s) return $s;
- if (str_starts_with($line, '!')) return new MDToken('!', MDTokenType::Bang);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Bang,
- MDTokenType::Label, MDTokenType::META_OptionalWhitespace, MDTokenType::URL ])) {
- $alt = $match->tokens[1]->content;
- $url = $match->tokens[sizeof($match->tokens) - 1]->content;
- $title = $match->tokens[sizeof($match->tokens) - 1]->extra;
- $node = new MDImageNode($url, $alt);
- if ($title !== null) {
- $node->attributes['title'] = $title;
- }
- array_splice($tokens, $match->index, sizeof($match->tokens), [$node]);
- return true;
- }
- return false;
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if (get_class($other) === 'MDLinkReader' || get_class($other) === 'MDReferencedLinkReader') {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Reader for images with referential URL definitions. Consists of an
- * exclamation, alt text in square brackets, and link symbol in square brackets.
- * URL is defined the same as for `MDReferencedLinkReader`.
- */
- class MDReferencedImageReader extends MDReferencedLinkReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- $s = parent::readToken($state, $line);
- if ($s) return $s;
- if (str_starts_with($line, '!')) return new MDToken('!', MDTokenType::Bang);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::Bang,
- MDTokenType::Label, MDTokenType::META_OptionalWhitespace, MDTokenType::Label ])) {
- $alt = $match->tokens[1]->content;
- $ref = $match->tokens[sizeof($match->tokens) - 1]->content;
- array_splice($tokens, $match->index, sizeof($match->tokens),
- [new MDReferencedImageNode($ref, $alt)]);
- return true;
- }
- return false;
- }
-
- public function compareSubstituteOrdering(MDReader $other, int $pass): int {
- if (get_class($other) === 'MDLinkReader' || get_class($other) === 'MDReferencedLinkReader') {
- return -1;
- }
- return 0;
- }
- }
-
- /**
- * Converts line breaks within blocks into line breaks in the HTML. Not
- * included in any of the default reader sets since most flavors ignore
- * line breaks within blocks.
- */
- class MDLineBreakReader extends MDReader {
- public function postProcess(MDState $state, array &$blocks) {
- MDNode::replaceNodes($state, $blocks, function(MDNode $original) {
- if (!($original instanceof MDTextNode)) return null;
- $lines = explode("\n", $original->text);
- if (sizeof($lines) == 1) return null;
- $nodes = [];
- foreach ($lines as $i => $line) {
- if ($i > 0) {
- array_push($nodes, new MDLineBreakNode());
- }
- array_push($nodes, new MDTextNode($line));
- }
- return new MDNode($nodes);
- });
- }
- }
-
- /**
- * Reads a verbatim HTML tag, and if it passes validation by `MDState->$tagFilter`,
- * will be rendered in the final HTML document. Disallowed tags will be rendered
- * as plain text in the resulting document.
- */
- class MDHTMLTagReader extends MDReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- $tag = MDHTMLTag::fromLineStart($line, $state);
- if ($tag === null) return null;
- if (!$state->root()->tagFilter->isValidTagName($tag->tagName)) return null;
- $state->root()->tagFilter->scrubTag($tag);
- return new MDToken($tag->original, MDTokenType::HTMLTag, $tag);
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- if ($match = MDToken::findFirstTokens($tokens, [ MDTokenType::HTMLTag ])) {
- $tag = $match->tokens[0]->tag;
- array_splice($tokens, $match->index, 1, [new MDHTMLTagNode($tag)]);
- return true;
- }
- return false;
- }
- }
-
- /**
- * Reads tag modifiers. Consists of curly braces with one or more CSS classes,
- * IDs, or custom attributes separated by spaces to apply to the preceding
- * node. Validation is performed on modifiers and only acceptable values are
- * applied.
- */
- class MDModifierReader extends MDReader {
- public function readToken(MDState $state, string $line): ?MDToken {
- $modifier = MDTagModifier::fromStart($line);
- if ($modifier) return new MDToken($modifier->original, MDTokenType::Modifier, $modifier);
- return null;
- }
-
- public function substituteTokens(MDState $state, int $pass, array &$tokens): bool {
- // Modifiers are applied elsewhere, and if they're not it's fine if they're
- // rendered as the original syntax.
- return false;
- }
- }
-
-
- // -- Nodes -----------------------------------------------------------------
-
-
- /**
- * Base class for nodes in the assembled document tree.
- */
- class MDNode {
- /**
- * Array of CSS classes to add to the node when rendered as HTML.
- * @var string[]
- */
- public array $cssClasses = [];
-
- public ?string $cssId = null;
-
- /**
- * Mapping of CSS attributes to values.
- * @var string[]
- */
- public array $cssStyles = [];
-
- /**
- * Mapping of arbitrary attributes and values to add to this node's top-level
- * tag when rendered as HTML. For `class`, `id`, and `style` attributes, use
- * `$cssClasses`, `$cssId`, and `$cssStyles` instead.
- * @var array
- */
- public array $attributes = [];
-
- /**
- * All child nodes in this node.
- * @var MDNode[]
- */
- public array $children = [];
-
- /**
- * @param MDNode|MDNode[] $children
- */
- public function __construct(MDNode|array $children=[]) {
- if (is_array($children)) {
- foreach ($children as $elem) {
- if (!($elem instanceof MDNode)) {
- $thisClassName = MDUtils::typename($this);
- $elemClassName = MDUtils::typename($elem);
- throw new Error("{$thisClassName} expects children of type " .
- "MDNode[] or MDNode, got array with {$elemClassName} element");
- }
- }
- $this->children = $children;
- } elseif ($children instanceof MDNode) {
- $this->children = [ $children ];
- } else {
- $thisClassName = MDUtils::typename($this);
- $elemClassName = MDUtils::typename($children);
- throw new Error("{$thisClassName} expects children of type MDNode[] " .
- "or MDNode, got {$elemClassName}");
- }
- }
-
- public function __toString(): string {
- $s = "<" . get_class($this);
- foreach ($this->children as $child) {
- $s .= " {$child}";
- }
- $s .= ">";
- return $s;
- }
-
- /**
- * Adds a CSS class. If already present it will not be duplicated.
- */
- public function addClass(string $cssClass): bool {
- if (array_search($cssClass, $this->cssClasses) !== false) return false;
- array_push($this->cssClasses, $cssClass);
- return true;
- }
-
- /**
- * Removes a CSS class.
- *
- * @param string $cssClass
- * @return bool whether the class was present and removed
- */
- public function removeClass(string $cssClass): bool {
- $beforeLength = sizeof($this->cssClasses);
- $this->cssClasses = array_diff($this->cssClasses, [ $cssClass ]);
- return sizeof($this->cssClasses) != $beforeLength;
- }
-
- /**
- * Renders this node and any children as an HTML string. If the node has no
- * content an empty string should be returned.
- */
- public function toHTML(MDState $state): string {
- return MDNode::arrayToHTML($this->children, $state);
- }
-
- /**
- * Renders this node and any children as a plain text string. The conversion
- * should only render ordinary text, not attempt markdown-like formatting
- * (e.g. list items should not be prefixed with asterisks, only have their
- * content text returned). If the node has no renderable content an empty
- * string should be returned.
- */
- public function toPlaintext(MDState $state): string {
- return MDNode::arrayToPlaintext($this->children, $state);
- }
-
- /**
- * Protected helper method that renders an HTML fragment of the attributes
- * to apply to the root HTML tag representation of this node.
- *
- * Example result with a couple `$cssClasses`, a `$cssId`, and a custom
- * `$attributes` key-value pair:
- *
- * ```
- * class="foo bar" id="baz" lang="en"
- * ```
- *
- * The value includes a leading space if it's non-empty so that it can be
- * concatenated directly after the tag name and before the closing `>`.
- */
- protected function htmlAttributes(): string {
- $html = '';
- if (sizeof($this->cssClasses) > 0) {
- $classlist = MDUtils::escapeHTML(implode(' ', $this->cssClasses));
- $html .= " class=\"{$classlist}\"";
- }
- if ($this->cssId !== null && mb_strlen($this->cssId) > 0) {
- $html .= " id=\"" . MDUtils::escapeHTML($this->cssId) . "\"";
- }
- $styles = [];
- foreach ($this->cssStyles as $key => $value) {
- array_push($styles, "{$key}: {$value};");
- }
- if (sizeof($styles) > 0) {
- $escaped = MDUtils::escapeHTML(implode(' ', $styles));
- $html .= " style=\"{$escaped}\"";
- }
- foreach ($this->attributes as $key => $value) {
- if ($key === 'class' || $key === 'id' || $key === 'style') continue;
- $cleanKey = MDUtils::scrubAttributeName($key);
- if (mb_strlen($cleanKey) == 0) continue;
- $cleanValue = MDUtils::escapeHTML($value);
- $html .= " {$cleanKey}=\"{$cleanValue}\"";
- }
- return $html;
- }
-
- /**
- * Protected helper that renders and concatenates the HTML of all children
- * of this node. Mostly for use by subclasses in their `toHTML`
- * implementations.
- */
- protected function childHTML(MDState $state): string {
- return MDNode::arrayToHTML($this->children, $state);
- }
-
- /**
- * Protected helper that renders and concatenates the plaintext of all
- * children of this node.
- */
- protected function childPlaintext(MDState $state): string {
- return MDNode::arrayToPlaintext($this->children, $state);
- }
-
- /**
- * Protected helper for rendering nodes represented by simple paired HTML
- * tags. Custom CSS classes and attributes will be included in the result,
- * and child content will be rendered between the tags.
- */
- protected function simplePairedTagHTML(MDState $state, string $tagName): string {
- $openTagSuffix = ($this->children[0] ?? null) instanceof MDBlockNode ? "\n" : "";
- $closeTagPrefix = ($this->children[sizeof($this->children) - 1] ?? null) instanceof MDBlockNode ? "\n" : '';
- $closeTagSuffix = $this instanceof MDBlockNode ? "\n" : '';
- $attr = $this->htmlAttributes();
- $childHTML = $this->childHTML($state);
- return "<{$tagName}{$attr}>{$openTagSuffix}{$childHTML}{$closeTagPrefix}</{$tagName}>{$closeTagSuffix}";
- }
-
- /**
- * Calls the given callback function with every child node, recursively.
- * Nodes are visited depth-first.
- */
- public function visitChildren(callable $fn) {
- foreach ($this->children as $child) {
- $fn($child);
- $child->visitChildren($fn);
- }
- }
-
- /**
- * Helper for rendering and concatenating HTML from an array of `MDNode`s.
- *
- * @param MDNode[] $nodes
- * @param MDState $state
- * @return string HTML string
- */
- public static function arrayToHTML(array $nodes, MDState $state): string {
- return implode('', array_map(fn($node) => $node->toHTML($state) . ($node instanceof MDBlockNode ? "\n" : ''), $nodes));
- }
-
- /**
- * Helper for rendering and concatenating plaintext from an array of `MDNode`s.
- *
- * @param MDNode[] $nodes
- * @param MDState $state
- * @return string plaintext
- */
- public static function arrayToPlaintext(array $nodes, MDState $state): string {
- return implode('', array_map(fn($node) => $node->toPlaintext($state), $nodes));
- }
-
- /**
- * Recursively searches and replaces nodes in a tree. The given `$replacer`
- * is passed every node in the tree. If `$replacer` returns a new `MDNode`
- * the original will be replaced with it. If the function returns `null` no
- * change will be made to that node. Traversal is depth-first.
- *
- * @param MDState $state
- * @param MDNode[] $nodes
- * @param callable $replacer takes a node as an argument, returns either
- * a new node or `null` to leave it unchanged
- */
- public static function replaceNodes(MDState $state, array &$nodes, callable $replacer) {
- for ($i = 0; $i < sizeof($nodes); $i++) {
- $originalNode = $nodes[$i];
- $replacement = $replacer($originalNode);
- if ($replacement instanceof MDNode) {
- array_splice($nodes, $i, 1, [$replacement]);
- } else {
- self::replaceNodes($state, $originalNode->children, $replacer);
- }
- }
- }
- }
-
- /**
- * Marker subclass that indicates a node represents block syntax.
- */
- class MDBlockNode extends MDNode {}
-
- /**
- * Paragraph block.
- */
- class MDParagraphNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'p');
- }
- }
-
- /**
- * A heading block with a level from 1 to 6.
- */
- class MDHeadingNode extends MDBlockNode {
- public int $level;
-
- /**
- * @param int $level
- * @param MDNode|MDNode[] $children
- */
- public function __construct(int $level, MDNode|array $children) {
- parent::__construct($children);
- if (!is_int($level) || ($level < 1 || $level > 6)) {
- $thisClassName = MDUtils::typename($this);
- throw new Error("{$thisClassName} requires heading level 1 to 6");
- }
- $this->level = $level;
- }
-
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, "h{$this->level}");
- }
- }
-
- /**
- * A sub-text block with smaller, less prominent text.
- */
- class MDSubtextNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- $this->addClass('subtext');
- return $this->simplePairedTagHTML($state, 'div');
- }
- }
-
- /**
- * Node for a horizontal dividing line.
- */
- class MDHorizontalRuleNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return "<hr" . $this->htmlAttributes() . ">";
- }
- }
-
- /**
- * A block quote, usually rendered indented from other text.
- */
- class MDBlockquoteNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'blockquote');
- }
- }
-
- /**
- * A bulleted list. Contains `MDListItemNode` children.
- */
- class MDUnorderedListNode extends MDBlockNode {
- /** @var MDListItemNode[] $children */
-
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'ul');
- }
- }
-
- /**
- * A numbered list. Contains `MDListItemNode` children.
- */
- class MDOrderedListNode extends MDBlockNode {
- /** @var MDListItemNode[] $children */
-
- public ?int $startOrdinal;
-
- /**
- * @param MDListItemNode[] $children
- * @param ?int $startOrdinal
- */
- public function __construct(array $children, ?int $startOrdinal=null) {
- parent::__construct($children);
- $this->startOrdinal = $startOrdinal;
- }
-
- public function toHTML(MDState $state): string {
- if ($this->startOrdinal !== null && $this->startOrdinal != 1) {
- $this->attributes['start'] = strval($this->startOrdinal);
- }
- return $this->simplePairedTagHTML($state, 'ol');
- }
- }
-
- /**
- * An item in a bulleted or numbered list.
- */
- class MDListItemNode extends MDBlockNode {
- public ?int $ordinal;
-
- /**
- * @param MDNode|MDNode[] $children
- * @param ?int $ordinal
- */
- public function __construct(MDNode|array $children, ?int $ordinal=null) {
- parent::__construct($children);
- $this->ordinal = $ordinal;
- }
-
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'li');
- }
- }
-
- /**
- * A block of preformatted computer code. Inner markdown is ignored.
- */
- class MDCodeBlockNode extends MDBlockNode {
- public string $text;
-
- /**
- * The programming language of the content.
- */
- public ?string $language;
-
- public function __construct(string $text, ?string $language=null) {
- parent::__construct([]);
- $this->text = $text;
- $this->language = $language;
- }
-
- public function toHTML(MDState $state): string {
- $languageModifier = ($this->language !== null) ? " class=\"language-{$this->language}\"" : '';
- return "<pre" . $this->htmlAttributes() . "><code{$languageModifier}>" .
- MDUtils::escapeHTML($this->text) . "</code></pre>\n";
- }
- }
-
- /**
- * A table node with a single header row and any number of body rows.
- */
- class MDTableNode extends MDBlockNode {
- /** @var MDTableRowNode[] $children */
-
- public function headerRow(): ?MDTableRowNode { return $this->children[0] ?? null; }
-
- public function bodyRows(): array { return array_slice($this->children, 1); }
-
- /**
- * How to align each column. Columns beyond the length of the array or with
- * corresponding `null` elements will have no alignment set. Values should
- * be valid CSS `text-align` values.
- *
- * @var string[]
- */
- public array $columnAlignments = [];
-
- /**
- * @param MDTableRowNode $headerRow
- * @param MDTableRowNode[] $bodyRows
- */
- public function __construct(MDTableRowNode $headerRow, array $bodyRows) {
- parent::__construct(array_merge([ $headerRow ], $bodyRows));
- }
-
- /**
- * Returns a given body cell.
- *
- * @param {number} column
- * @param {number} row
- * @returns {MDTableCellNode|null} cell or `null` if out of bounds
- */
- public function bodyCellAt(int $column, int $row): ?MDTableCellNode {
- $rowNode = $this->bodyRows()[$row] ?? null;
- if ($rowNode === null) return null;
- $cellNode = $rowNode->children[$column] ?? null;
- return ($cellNode === null) ? null : $cellNode;
- }
-
- public function applyAlignments() {
- foreach ($this->children as $child) {
- $this->applyAlignmentsToRow($child);
- }
- }
-
- private function applyAlignmentsToRow(MDTableRowNode $row) {
- foreach ($row->children as $columnIndex => $cell) {
- $alignment = $this->columnAlignments[$columnIndex] ?? null;
- $this->applyAlignmentToCell($cell, $alignment);
- }
- }
-
- public function applyAlignmentToCell(MDTableCellNode $cell, ?string $alignment) {
- if ($alignment) {
- $cell->cssStyles['text-align'] = $alignment;
- } else {
- unset($cell->cssStyles['text-align']);
- }
- }
-
- public function toHTML(MDState $state): string {
- $this->applyAlignments();
- $html = '';
- $html .= "<table" . $this->htmlAttributes() . ">\n";
- $html .= "<thead>\n";
- $html .= $this->headerRow()->toHTML($state) . "\n";
- $html .= "</thead>\n";
- $html .= "<tbody>\n";
- $html .= MDNode::arrayToHTML($this->bodyRows(), $state) . "\n";
- $html .= "</tbody>\n";
- $html .= "</table>\n";
- return $html;
- }
- }
-
- /**
- * Node for one row (header or body) in a table.
- */
- class MDTableRowNode extends MDBlockNode {
- /** @var MDTableCellNode[] $children */
-
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'tr');
- }
- }
-
- /**
- * Node for one cell in a table row.
- */
- class MDTableCellNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'td');
- }
- }
-
- /**
- * Node for a header cell in a header table row.
- */
- class MDTableHeaderCellNode extends MDTableCellNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'th');
- }
- }
-
- /**
- * Definition list with `MDDefinitionListTermNode` and
- * `MDDefinitionListDefinitionNode` children.
- */
- class MDDefinitionListNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'dl');
- }
- }
-
- /**
- * A word or term in a definition list.
- */
- class MDDefinitionListTermNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'dt');
- }
- }
-
- /**
- * The definition of a word or term in a definition list. Should follow a
- * definition term, or another definition to serve as an alternate.
- */
- class MDDefinitionListDefinitionNode extends MDBlockNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'dd');
- }
- }
-
- /**
- * Block at the bottom of a document listing all the footnotes with their
- * content.
- */
- class MDFootnoteListNode extends MDBlockNode {
- private function footnoteId(MDState $state, string $symbol): ?int {
- $lookup = $state->root()->userInfo['footnoteIds'];
- if (!$lookup) return null;
- return $lookup[$symbol] ?? null;
- }
-
- public function toHTML(MDState $state): string {
- $footnotes = $state->root()->userInfo['footnotes'];
- $symbolOrder = array_keys($footnotes);
- if (sizeof($footnotes) == 0) return '';
- $footnoteUniques = $state->root()->userInfo['footnoteInstances'];
- $html = '';
- $html .= '<div class="footnotes">';
- $html .= '<ol>';
- foreach ($symbolOrder as $symbolRaw) {
- $symbol = "{$symbolRaw}";
- $content = $footnotes[$symbol];
- if (!$content) continue;
- $footnoteId = $this->footnoteId($state, $symbol);
- $contentHTML = MDNode::arrayToHTML($content, $state);
- $html .= "<li value=\"{$footnoteId}\" id=\"" .
- "{$state->root()->elementIdPrefix}footnote_{$footnoteId}\">{$contentHTML}";
- $uniques = $footnoteUniques[$symbol] ?? null;
- if ($uniques) {
- foreach ($uniques as $unique) {
- $html .= " <a href=\"#{$state->root()->elementIdPrefix}footnoteref_{$unique}\"" .
- " class=\"footnote-backref\">↩︎</a>";
- }
- }
- $html .= "</li>\n";
- }
- $html .= '</ol>';
- $html .= '</div>';
- return $html;
- }
-
- public function toPlaintext(MDState $state): string {
- $footnotes = $state->userInfo['footnotes'];
- $symbolOrder = array_keys($footnotes);
- if (sizeof($footnotes) == 0) return '';
- $text = '';
- foreach ($symbolOrder as $symbolRaw) {
- $symbol = "{$symbolRaw}";
- $content = $footnotes[$symbol];
- if (!$content) continue;
- $text .= "{$symbol}. " . $this->childPlaintext(state) . "\n";
- }
- return trim($text);
- }
- }
-
- /**
- * Marker subclass that indicates a node represents inline syntax.
- */
- class MDInlineNode extends MDNode {}
-
- /**
- * Contains plain text. Special HTML characters are escaped when rendered.
- */
- class MDTextNode extends MDInlineNode {
- public string $text;
-
- public function __construct(string $text) {
- parent::__construct([]);
- $this->text = $text;
- }
-
- public function toHTML(MDState $state): string {
- return MDUtils::escapeHTML($this->text);
- }
-
- public function toPlaintext(MDState $state): string {
- return $this->text;
- }
-
- public function __toString(): string {
- return "<MDTextNode \"{$this->text}\">";
- }
- }
-
- /**
- * Contains plain text which is rendered with HTML entities when rendered to
- * be marginally more difficult for web scapers to decipher. Used for
- * semi-sensitive info like email addresses.
- */
- class MDObfuscatedTextNode extends MDTextNode {
- public function toHTML(MDState $state): string {
- return MDUtils::escapeObfuscated($this->text);
- }
- }
-
- /**
- * Emphasized (italicized) content.
- */
- class MDEmphasisNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'em');
- }
- }
-
- /**
- * Strong (bold) content.
- */
- class MDStrongNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'strong');
- }
- }
-
- /**
- * Content rendered with a line through it.
- */
- class MDStrikethroughNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 's');
- }
- }
-
- /**
- * Underlined content.
- */
- class MDUnderlineNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'u');
- }
- }
-
- /**
- * Highlighted content. Usually rendered with a bright colored background.
- */
- class MDHighlightNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'mark');
- }
- }
-
- /**
- * Superscripted content.
- */
- class MDSuperscriptNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'sup');
- }
- }
-
- /**
- * Subscripted content.
- */
- class MDSubscriptNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return $this->simplePairedTagHTML($state, 'sub');
- }
- }
-
- /**
- * Inline plaintext indicating computer code.
- */
- class MDCodeNode extends MDInlineNode {
- public string $text;
-
- public function __construct(string $text) {
- parent::__construct([]);
- $this->text = $text;
- }
-
- public function toHTML(MDState $state): string {
- return "<code" . $this->htmlAttributes() . ">" . MDUtils::escapeHTML($this->text) . "</code>";
- }
- }
-
- /**
- * A footnote symbol in a document. Denoted as a superscripted number that can
- * be clicked to go to its content at the bottom of the document.
- */
- class MDFootnoteNode extends MDInlineNode {
- /**
- * Symbol the author used to match up the footnote to its content definition.
- */
- public string $symbol;
-
- /**
- * The superscript symbol rendered in HTML. May be the same or different
- * than `$symbol`.
- */
- public ?string $displaySymbol = null;
-
- /**
- * Unique ID for the footnote definition.
- */
- public ?int $footnoteId = null;
-
- /**
- * Unique number for backlinking to a footnote occurrence. Populated by
- * `MDFootnoteReader->postProcess()`.
- */
- public ?int $occurrenceId = null;
-
- public function __construct(string $symbol, ?string $title=null) {
- parent::__construct([]);
- $this->symbol = $symbol;
- if ($title) $this->attributes['title'] = $title;
- }
-
- public function toHTML(MDState $state): string {
- if ($this->footnoteId !== null) {
- return "<sup class=\"footnote\" id=\"{$state->root()->elementIdPrefix}footnoteref_{$this->occurrenceId}\"" .
- $this->htmlAttributes() . ">" .
- "<a href=\"#{$state->root()->elementIdPrefix}footnote_{$this->footnoteId}\">" .
- MDUtils::escapeHTML($this->displaySymbol ?? $this->symbol) . "</a></sup>";
- }
- return "<!--FNREF:{{$this->symbol}}-->";
- }
- }
-
- /**
- * A clickable hypertext link.
- */
- class MDLinkNode extends MDInlineNode {
- public string $href;
-
- /**
- * @param string $href
- * @param MDNode|MDNode[] $children
- * @param ?string $title
- */
- public function __construct(string $href, MDNode|array $children, ?string $title=null) {
- parent::__construct($children);
- $this->href = $href;
- if ($title !== null) $this->attributes['title'] = $title;
- }
-
- public function toHTML(MDState $state): string {
- if (str_starts_with($this->href, 'mailto:')) {
- $escapedLink = MDUtils::escapeObfuscated($this->href);
- } else {
- $escapedLink = MDUtils::escapeHTML($this->href);
- }
- return "<a href=\"{$escapedLink}\"" . $this->htmlAttributes() . ">" . $this->childHTML($state) . "</a>";
- }
- }
-
- /**
- * A clickable hypertext link where the URL is defined elsewhere by reference.
- */
- class MDReferencedLinkNode extends MDLinkNode {
- public string $reference;
-
- /**
- * @param string $reference
- * @param MDNode|MDNode[] $children
- */
- public function __construct(string $reference, MDNode|array $children) {
- parent::__construct('', $children);
- $this->reference = $reference;
- }
-
- public function toHTML(MDState $state): string {
- if ($this->href === '') {
- $url = $state->urlForReference($this->reference);
- if ($url) $this->href = $url;
- $title = $state->urlTitleForReference($this->reference);
- if ($title) $this->attributes['title'] = $title;
- }
- return parent::toHTML($state);
- }
- }
-
- /**
- * An inline image.
- */
- class MDImageNode extends MDInlineNode {
- public string $src;
-
- public ?string $alt;
-
- public function __construct(string $src, ?string $alt) {
- parent::__construct([]);
- $this->src = $src;
- $this->alt = $alt;
- }
-
- public function toHTML(MDState $state): string {
- $html = "<img src=\"" . MDUtils::escapeHTML($this->src) . "\"";
- if ($this->alt) $html .= " alt=\"" . MDUtils::escapeHTML($this->alt) . "\"";
- $html .= $this->htmlAttributes() . ">";
- return $html;
- }
- }
-
- /**
- * An inline image where the URL is defined elsewhere by reference.
- */
- class MDReferencedImageNode extends MDImageNode {
- public string $reference;
-
- public function __construct(string $reference, ?string $alt=null) {
- parent::__construct('', $alt, []);
- $this->reference = $reference;
- }
-
- public function toHTML(MDState $state): string {
- if ($this->src === '') {
- $url = $state->urlForReference($this->reference);
- if ($url !== null) $this->src = $url;
- $title = $state->urlTitleForReference($this->reference);
- if ($title !== null) $this->attributes['title'] = $title;
- }
- return parent::toHTML($state);
- }
- }
-
- /**
- * An abbreviation that can be hovered over to see its full expansion.
- */
- class MDAbbreviationNode extends MDInlineNode {
- public string $abbreviation;
-
- /**
- * @param string $abbreviation
- * @param string $definition
- */
- public function __construct(string $abbreviation, string $definition) {
- parent::__construct([]);
- $this->abbreviation = $abbreviation;
- $this->attributes['title'] = $definition;
- }
-
- public function toHTML(MDState $state): string {
- return "<abbr" . $this->htmlAttributes() . ">" . MDUtils::escapeHTML($this->abbreviation) . "</abbr>";
- }
- }
-
- /**
- * A line break that is preserved when rendered to HTML.
- */
- class MDLineBreakNode extends MDInlineNode {
- public function toHTML(MDState $state): string {
- return '<br>';
- }
-
- public function toPlaintext(MDState $state): string {
- return "\n";
- }
- }
-
- /**
- * A verbatim HTML tag. May be altered to strip out disallowed attributes or
- * CSS values.
- */
- class MDHTMLTagNode extends MDInlineNode {
- public MDHTMLTag $tag;
-
- public function __construct(MDHTMLTag $tag) {
- parent::__construct([]);
- $this->tag = $tag;
- }
-
- public function toHTML(MDState $state): string {
- return "{$this->tag}";
- }
- }
-
-
- // -- Main class ------------------------------------------------------------
-
-
- /**
- * Markdown parser.
- */
- class Markdown {
- /**
- * Set of standard readers to handle common syntax.
- * @return MDReader[]
- */
- public static function standardReaders(): array {
- if (self::$sharedStandardReaders === null) {
- self::$sharedStandardReaders = [
- new MDUnderlinedHeadingReader(),
- new MDHashHeadingReader(),
- new MDBlockQuoteReader(),
- new MDHorizontalRuleReader(),
- new MDUnorderedListReader(),
- new MDOrderedListReader(),
- new MDFencedCodeBlockReader(),
- new MDIndentedCodeBlockReader(),
- new MDParagraphReader(),
-
- new MDStrongReader(),
- new MDEmphasisReader(),
- new MDCodeSpanReader(),
- new MDImageReader(),
- new MDLinkReader(),
- new MDHTMLTagReader(),
- ];
- }
- return self::$sharedStandardReaders;
- }
- private static ?array $sharedStandardReaders = null;
-
- /**
- * All supported readers except `MDLineBreakReader`.
- * @return MDReader[]
- */
- public static function allReaders(): array {
- if (self::$sharedAllReaders === null) {
- $sharedAllReaders = array_merge(self::standardReaders(), [
- new MDSubtextReader(),
- new MDTableReader(),
- new MDDefinitionListReader(),
- new MDFootnoteReader(),
- new MDAbbreviationReader(),
-
- new MDUnderlineReader(),
- new MDSubscriptReader(),
- new MDStrikethroughReader(),
- new MDHighlightReader(),
- new MDSuperscriptReader(),
- new MDReferencedImageReader(),
- new MDReferencedLinkReader(),
- new MDModifierReader(),
- ]);
- }
- return $sharedAllReaders;
- }
- private static ?array $sharedAllReaders = null;
-
- /**
- * Shared instance of a parser with standard syntax.
- */
- public static function standardParser(): Markdown {
- if (self::$sharedStandardMarkdown === null) {
- self::$sharedStandardMarkdown = new Markdown(self::standardReaders());
- }
- return self::$sharedStandardMarkdown;
- }
- private static ?Markdown $sharedStandardMarkdown = null;
-
- /**
- * Shared instance of a parser with all supported syntax.
- */
- public static function completeParser(): Markdown {
- if (self::$sharedCompleteParser === null) {
- self::$sharedCompleteParser = new Markdown(self::allReaders());
- }
- return self::$sharedCompleteParser;
- }
- public static ?Markdown $sharedCompleteParser = null;
-
- /**
- * Filter for what non-markdown HTML is permitted. HTML generated as a
- * result of markdown is unaffected.
- */
- public MDHTMLFilter $tagFilter;
-
- /**
- * If an exception occurs, attempts to narrow down the portion of the
- * markdown that triggered the error and outputs it to the console. For
- * debugging. Investigation mode can be slow.
- */
- public bool $investigateErrors = false;
-
- /** @var MDReader[] */
- private array $readers;
-
- /** @var MDReader[] */
- private array $readersByBlockPriority;
- /** @var MDReader[] */
- private array $readersByTokenPriority;
- private array $readersBySubstitutePriority;
-
- /**
- * Creates a Markdown parser with the given syntax readers.
- *
- * @param MDReader[] $readers
- */
- public function __construct(?array $readers=null) {
- $this->readers = $readers ?? self::allReaders();
- $this->readersByBlockPriority = MDReader::sortReaderForBlocks($this->readers);
- $this->readersByTokenPriority = MDReader::sortReadersForTokenizing($this->readers);
- $this->readersBySubstitutePriority = MDReader::sortReadersForSubstitution($this->readers);
- $this->tagFilter = new MDHTMLFilter();
- }
-
- /**
- * Converts a markdown string to an HTML string.
- *
- * @param string $markdown
- * @param string $elementIdPrefix Optional prefix for generated element
- * `id`s and links to them. For differentiating multiple markdown docs in
- * the same HTML page.
- * @return string HTML
- */
- public function toHTML(string $markdown, string $elementIdPrefix='') {
- $lines = mb_split('(?:\\n|\\r|\\r\\n)', $markdown);
- try {
- return $this->parse($lines, $elementIdPrefix);
- } catch (Error $e) {
- if ($this->investigateErrors) {
- $this->investigateException($lines, $elementIdPrefix);
- }
- throw $e;
- }
- }
-
- /**
- * @param string[] $lines
- * @param string $elementIdPrefix
- */
- private function parse(array $lines, string $elementIdPrefix) {
- $state = new MDState($lines);
- $state->readersByBlockPriority = $this->readersByBlockPriority;
- $state->readersByTokenPriority = $this->readersByTokenPriority;
- $state->readersBySubstitutePriority = $this->readersBySubstitutePriority;
- $state->tagFilter = $this->tagFilter;
- $state->elementIdPrefix = $elementIdPrefix;
- foreach ($this->readers as $reader) {
- $reader->preProcess($state);
- }
- $nodes = $state->readBlocks();
- foreach ($this->readers as $reader) {
- $reader->postProcess($state, $nodes);
- }
- return MDNode::arrayToHTML($nodes, $state);
- }
-
- /**
- * Keeps removing first and last lines of markdown to locate the source of
- * an exception and prints the minimal snippet.
- *
- * @param string[] $lines
- * @param string $elementIdPrefix
- */
- private function investigateException(array $lines, string $elementIdPrefix) {
- print("Investigating error...\n");
- $startIndex = 0;
- $endIndex = sizeof($lines);
- // Keep stripping away first line until an exception stops being thrown
- for ($i = 0; $i < sizeof($lines); $i++) {
- try {
- $this->parse(array_slice($lines, $i, $endIndex), $elementIdPrefix);
- break;
- } catch (Error $e0) {
- $startIndex = $i;
- }
- }
- // Keep stripping away last line until an exception stops being thrown
- for ($i = sizeof($lines); $i > $startIndex; $i--) {
- try {
- $this->parse(array_slice($lines, $startIndex, $i), $elementIdPrefix);
- break;
- } catch (Error $e0) {
- $endIndex = $i;
- }
- }
- $problematicMarkdown = implode("\n", array_slice($lines, $startIndex, $endIndex));
- print("This portion of markdown caused an unexpected exception:\n{$problematicMarkdown}\n");
- }
- }
- ?>
|