Nástroje nad zdrojovým textem

Chcete články kontrolovat na chyby, hromadně je upravovat nebo postavit editor, který ukáže, kde je problém? Texy vám dá strom dokumentu, ve kterém každý prvek ví, kde přesně ve zdrojovém textu leží. Úprava pak zasáhne jen to, co měnit chcete, a zbytek souboru zůstane takový, jaký byl. Funguje to pro Markdown i pro syntaxi Texy.

Kontrola článků

Články blogu jsou uložené jako soubory .md. Chceme nástroj, který najde obrázky bez popisu, protože čtenář, který obrázek nevidí, by se nedozvěděl, co na něm je. Hlášku chceme v podobě, jakou znáte z editorů a linterů, se jménem souboru, řádkem a sloupcem:

clanky/susarna.md:5:1 obrázek bez popisu
clanky/susarna.md:7:11 obrázek bez popisu
use Texy\NodeHelpers;
use Texy\Nodes;

$markdown = new Texy\Markdown\Markdown;
$source = Texy\Texy::normalize(file_get_contents($file));
$document = $markdown->parse($source);

foreach (NodeHelpers::find($document, Nodes\ImageNode::class) as $image) {
	if (($image->modifier?->title ?? '') === '') {
		$position = Texy\SourcePosition::fromOffset($source, $image->range->offset);
		echo "$file:$position->line:$position->column obrázek bez popisu\n";
	}
}

Všimněte si tří věcí. Text nepřevádíme na HTML, ale voláme parse(), které ho jen rozebere na strom. Před tím ho necháme projít Texy::normalize(), které sjednotí konce řádků a podobné drobnosti. A každý uzel má v $range rozsah textu, ze kterého vznikl: $range->offset je jeho začátek a $range->length délka, obojí v bajtech normalizovaného textu.

Z pozice v bajtech udělá SourcePosition::fromOffset() řádek a sloupec. Oba se počítají od jedničky a sloupec ve znacích, takže sedí s tím, co ukazuje editor, i když řádek obsahuje písmena s diakritikou.

NodeHelpers::find() najde všechny uzly daného druhu, popis obrázku je v jeho modifikátoru. Jaké uzly ve stromu najdete a jaké mají vlastnosti, popisuje přehled uzlů.

Přepis zdrojového textu

Blog se přestěhoval na HTTPS a v článcích zůstaly odkazy na http://muj-blog.cz. Hledat a nahrazovat napříč soubory by nahradilo i adresy v ukázkách kódu. Strom ale ví, kde leží adresy odkazů, a jen ty změníme:

$source = Texy\Texy::normalize(file_get_contents($file));
$document = $markdown->parse($source);
$rewriter = new Texy\Rewriter($source);

foreach (NodeHelpers::find($document, Nodes\LinkNode::class) as $link) {
	if ($link->urlRange === null) {
		continue; // odkaz, jehož adresa ve zdrojovém textu není zapsaná
	}
	$url = substr($source, $link->urlRange->offset, $link->urlRange->length);
	if (str_starts_with($url, 'http://muj-blog.cz')) {
		$rewriter->replace($link->urlRange, 'https://' . substr($url, 7));
	}
}

file_put_contents($file, $rewriter->getResult());
Sušárnu máme z [obchodu](https://muj-blog.cz/obchod) a <https://muj-blog.cz/recept>.

Adresu v kódu `http://muj-blog.cz` nechceme měnit.

Změnily se oba odkazy, ten v hranatých závorkách i ten ve špičatých, a adresa v kódu zůstala.

Texy\Rewriter náhrady jen sbírá a provede je všechny najednou až v getResult(). Díky tomu rozsahy ze stromu platí po celou dobu, i když se text před nimi mezitím změnil. Hlídá také dvě chyby, na které by se jinak přišlo až v poškozeném souboru:

  • Dvě náhrady, které se překrývají, nejdou provést obě. Rewriter vyhodí Texy\RewriteException s pozicí: Line 1, column 31: the replacement of 'https://example.com/susarna' overlaps another one.
  • Když předáte i text, který na daném místě čekáte, ověří ho. Chrání to před rozsahem z jiného souboru nebo ze starší verze textu:
$rewriter->replace($link->textRange, 'Sušárně u nádraží', expected: 'obchodě u nádraží');

Části zápisu

Rozsah celého uzlu zahrnuje celý jeho zápis, u odkazu tedy závorky, text i adresu. Uzly si ale pamatují i rozsahy částí, které nástroj typicky mění zvlášť:

Uzel Vlastnost Část zápisu
LinkNode $textRange text odkazu
LinkNode $urlRange adresa, u reference její jméno
LinkNode $syntax jakým zápisem byl odkaz napsaný, třeba link v Markdownu, phrase/span pro "text":url v syntaxi Texy
ImageNode $urlRange adresa obrázku
HeadingNode $textRange, $marker text nadpisu a znak, kterým je napsaný
ListItemNode $bulletRange, $bullet odrážka položky
CodeBlockNode $codeMap kde ve zdroji leží jednotlivé části kódu

Třeba přejmenovat obchod v textu odkazu, aniž byste sáhli na adresu:

$rewriter->replace($link->textRange, 'Sušárně u nádraží');
Koupeno v [Sušárně u nádraží](https://example.com/susarna).

Části zná jen uzel vestavěného zápisu. Uzel vaší vlastní syntaxe má jen rozsah celého zápisu, takže nástroj ho může jen celý nahradit.

Nahrazení celého prvku

Někdy je potřeba prvek napsat znovu, třeba s něčím navíc. Články v syntaxi Texy mají obrázky bez rozměrů a my je chceme doplnit podle skutečných souborů. Upravíme kopii uzlu, necháme ji vytisknout jako zdrojový text syntaxe Texy a dáme ji na místo původního zápisu:

$imageDir = __DIR__ . '/www/images';
$texy = new Texy\Texy;
$document = $texy->parse($source);
$rewriter = new Texy\Rewriter($source);
$printer = new Texy\Output\Texy\Renderer;

foreach (NodeHelpers::find($document, Nodes\ImageNode::class) as $image) {
	if ($image->width !== null || $image->height !== null) {
		continue; // rozměry už autor napsal
	}
	[$width, $height] = getimagesize("$imageDir/$image->url");
	$sized = clone $image;
	$sized->width = $width;
	$sized->height = $height;
	$rewriter->replace($image->range, $printer->renderNode($sized));
}
[* susarna.png .(Sušárna na půdě) >] *** Foto: Jana Nováková

se změní na

[* susarna.png 300x200 .(Sušárna na půdě) >] *** Foto: Jana Nováková

Uzel klonujeme, abychom neměnili strom, ze kterého ještě čteme. Texy\Output\Texy\Renderer je tiskárna, která uzel vypíše jako zdrojový text syntaxe Texy. Zápis, který autor napsal jednou z více podob, vytiskne ve stejné podobě, například stejnou odrážku nebo stejný druh nadpisu. Ostatní v jedné ustálené podobě, takže modifikátor vytiskne vždy jako .(titulek)[třída]{styl}.

Uzel vaší vlastní syntaxe tiskárna vytisknout neumí a vyhodí výjimku. Naučíte ji to rendererem, který zaregistrujete přes $printer->addRenderer() stejně jako renderer pro HTML, jen vrací zdrojový text.

Syntaktický strom a dokument

Proč nástroje volají parse() a ne toHtml() nebo process()? Strom totiž existuje ve dvou stavech.

  • Syntaktický strom, který vrátí parse(), popisuje, jak je text napsaný. Rozsahy přesně odpovídají zápisu, nic není dopočítané.
  • Dokument vznikne, když syntaktický strom projde úpravami v transform(). Ty mu dodají význam: najdou, kam vedou reference, vyrovnají úrovně nadpisů, vysází typografii. Rozsah pak říká, odkud uzel pochází, ne co je na tom místě napsané.

Rozdíl je dobře vidět na odkazu přes referenci:

Viz "recept":[recept].


V syntaktickém stromu je $link->url prázdné, $link->ref je recept a $link->urlRange ukazuje na [recept], tedy na to, co je v textu skutečně napsané. Po transform() je v $link->url adresa https://example.com/recept, která na tom místě napsaná není. Nástroj, který by přepisoval podle dokumentu, by tak zapsal adresu na místo jména reference.

Když potřebujete oba stavy, třeba nástroj, který hlásí chyby i ve výsledném HTML, naklonujte syntaktický strom před transformací: NodeHelpers::clone($document).

Pozice a konce řádků

Rozsahy počítají bajty textu, který prošel Texy::normalize(). Normalizace převede konce řádků na \n, odstraní BOM, řídicí znaky, mezery na koncích řádků a prázdné řádky na začátku a na konci textu. Proto nástroj text normalizuje jednou na začátku a dál pracuje jen s výsledkem, stejně jako příklady výše.

Nejjednodušší je ukládat soubory rovnou normalizované. Soubor na disku je pak totožný s textem, ke kterému se pozice vztahují, a pozice z nástroje můžete použít přímo v něm.

normalize() odstraní i nový řádek na konci souboru. Pokud ho v souborech chcete mít, připojte ho při ukládání: file_put_contents($file, $rewriter->getResult() . "\n").

Soubory s konci řádků Windows (CRLF) zpracujte v normalizované podobě a CRLF vraťte až při zápisu:

$source = Texy\Texy::normalize(file_get_contents($file));    // CRLF se změní na LF
$rewriter = new Texy\Rewriter($source);
// ... náhrady podle rozsahů ze stromu ...
file_put_contents($file, str_replace("\n", "\r\n", $rewriter->getResult())); // na disku zase CRLF

Normalizovaný text žádné \r neobsahuje, takže převod je bezztrátový a při dalším načtení dostanete přesně týž text se stejnými pozicemi. Řádky a sloupce jsou v obou podobách stejné, liší se jen pozice v bajtech.

Převod mezi Markdownem a syntaxí Texy

Protože oba jazyky rozebírají text na tentýž strom, převod mezi nimi je jen otázka toho, který jazyk text přečte a který ho vytiskne. Článek v Markdownu převedete na syntaxi Texy jedním voláním:

$markdown = new Texy\Markdown\Markdown;
$texySource = $markdown->toTexy($text);
# Recept na povidla

- natrhat
- [umýt](https://example.com)
Recept na povidla
#################

- natrhat
- "umýt":https://example.com

Obsah zůstane stejný, změní se jen zápis. Tabulky, úkolové seznamy a další rozšíření GFM se převedou, jen když text čtete objektem z Markdown::gfm(). Jinak jsou pro Markdown obyčejným textem.

Opačným směrem převádí Texy\Markdown\Output\Renderer. Dostane dokument po transform():

$texy = new Texy\Texy;
$document = $texy->parse($text);
$texy->transform($document);
$markdownSource = (new Texy\Markdown\Output\Renderer)->render($document);

Co Markdown zapsat neumí, převede na nejbližší podobu, kterou zná. Popisek pod obrázkem se stane kurzívou na dalším řádku, tabulka se sloučenými buňkami HTML tabulkou, a třídy, styly, rozměry a zarovnání obrázků se ztratí.

Tisk zpět do syntaxe Texy

Celý syntaktický strom vytiskne $texy->renderTexy($document). Výsledek se při novém rozebrání rozebere na tentýž strom a tiskem z něj vznikne opět tentýž text. Toho využívají právě nástroje, které strom upraví a chtějí ho uložit.

Několik vzácných zápisů ale zpět přesně nevyjde: buňka tabulky pokračující na více řádků, než kolik řádků tabulky zabírá, řádek začínající /-- nebo \-- uvnitř bloku html nebo text, titulek modifikátoru přes zalomení řádku, prázdný blok /--div, zastaralý zápis obrázku [* a | b *] a řádky odstavce surového HTML, které by se po přesunu na začátek řádku četly jako blok.

Tiskněte syntaktický strom z parse(), ne dokument po transform(). Dokument už má doplněné reference, vysázenou typografii a upravené HTML, takže jeho tiskem by nevznikl text, který autor napsal.