Skip to content

Instantly share code, notes, and snippets.

@xwero
Last active April 6, 2026 05:45
Show Gist options
  • Select an option

  • Save xwero/38ae5da899a4bbf0a003932eedd5dc5f to your computer and use it in GitHub Desktop.

Select an option

Save xwero/38ae5da899a4bbf0a003932eedd5dc5f to your computer and use it in GitHub Desktop.
Symfony Tui: markdown editor
<?php
namespace App;
enum ActionKeys : string
{
case DeleteFile = "\x09"; // ctrl+i
case Quit = "\x0a"; //ctrl+j
}
<?php
declare(strict_types=1);
namespace App;
use App\Widgets\SourceFilesWidget;
use InvalidArgumentException;
use Symfony\Component\Tui\Event\InputEvent;
use Symfony\Component\Tui\Event\SelectEvent;
use Symfony\Component\Tui\Event\SubmitEvent;
use Symfony\Component\Tui\Style\Direction;
use Symfony\Component\Tui\Style\Style;
use Symfony\Component\Tui\Style\TailwindStylesheet;
use Symfony\Component\Tui\Tui;
use Symfony\Component\Tui\Widget\ContainerWidget;
use Symfony\Component\Tui\Widget\EditorWidget;
use Symfony\Component\Tui\Widget\InputWidget;
use Symfony\Component\Tui\Widget\TextWidget;
class BlogAdminTui
{
private const FILE_SYSTEM_ID = 'file_system';
private const SOURCE_FILES_ID = 'source_files';
private const NAV_SOURCE_FILES = "\x02"; // ctrl+b
private const NEW_FILE_ID = 'new_file';
private const NAV_NEW_FILE = "\x04"; //ctrl+d
private const CONTENT_ID = 'content';
private const CONTENT_TABS_ID = 'content_tabs';
private const CONTENT_EDITORS_ID = 'content_editors';
private const MARKDOWN_TAB_ID = 'markdown_tab';
private const MARKDOWN_EDITOR_ID = 'markdown_editor';
private const NAV_MARKDOWN_EDITOR = "\x05"; // ctrl+e
private const FRONTMATTER_TAB_ID = 'frontmatter_tab';
private const FRONTMATTER_EDITOR_ID = 'frontmatter_editor';
private const NAV_FRONTMATTER_EDITOR = "\x12"; // ctrl+r
private Tui|null $tui = null;
public function __construct(private string $source)
{
if(!is_dir($source)) {
throw new InvalidArgumentException("Source $source is not a directory");
}
$this->buildTui()->buildEvents();
}
public function run(): void
{
$this->tui->run();
}
private function buildTui(): static
{
$this->tui = new Tui(new TailwindStylesheet());
$admin = new ContainerWidget();
$admin->setStyleClasses(['flex-row', 'gap-1']);
$admin->setStyle(new Style(direction: Direction::Horizontal, gap: 1));
$admin->add($this->buildFileSystemContainer());
$admin->add($this->buildContentContainer());
$this->tui->add($admin);
$this->setEditorText($this->tui->getById(self::SOURCE_FILES_ID)->getSelectedItem());
return $this;
}
private function buildFileSystemContainer(): ContainerWidget
{
$selectList = new SourceFilesWidget([], source: $this->source);
$selectList->setId(self::SOURCE_FILES_ID);
$selectList->onSelect(function(SelectEvent $event) {
$this->setEditorText($event->getItem());
$this->tui->setFocus($this->tui->getById(self::MARKDOWN_EDITOR_ID));
});
$newFile = new InputWidget();
$newFile->setId(self::NEW_FILE_ID);
$newFile->setPrompt('New File (can contain path): ');
$newFile->onSubmit(function(SubmitEvent $event) {
if($event->isEmpty()) {
return;
}
$input = $event->getValue();
if(!str_ends_with($input, '.md')) {
$input .= '.md';
}
touch($this->source . '/' . $input);
$event->getTarget()->setValue('');
$sourceFiles = $this->tui->getById(self::SOURCE_FILES_ID);
$sourceFiles->onFileAction();
$this->tui->handleInput(self::NAV_SOURCE_FILES);
});
$fileSystemContainer = new ContainerWidget();
$fileSystemContainer->setId(self::FILE_SYSTEM_ID);
$fileSystemContainer->setStyleClasses(['flex-col', 'gap-1']);
$fileSystemContainer->add($selectList);
$fileSystemContainer->add($newFile);
return $fileSystemContainer;
}
private function buildContentContainer(): ContainerWidget
{
$markdownTab = new TextWidget('Markdown');
$markdownTab->setId(self::MARKDOWN_TAB_ID);
$markdownTab->addStyleClass('bold');
$frontmatterTab = new TextWidget('Frontmatter')->setId(self::FRONTMATTER_TAB_ID);
$tabs = new ContainerWidget();
$tabs->setStyleClasses(['flex-row', 'gap-1']);
$tabs->setId(self::CONTENT_TABS_ID);
$tabs->add($markdownTab);
$tabs->add($frontmatterTab);
$markdownEditor = new EditorWidget()->setId(self::MARKDOWN_EDITOR_ID);
$markdownEditor->onSubmit($this->saveEditorsToFile(...));
$frontmatterEditor = new EditorWidget()->setId(self::FRONTMATTER_EDITOR_ID);
$frontmatterEditor->setStyleClasses(['hidden']);
$frontmatterEditor->onSubmit($this->saveEditorsToFile(...));
$editors = new ContainerWidget();
$editors->setId(self::CONTENT_EDITORS_ID);
$editors->add($markdownEditor);
$editors->add($frontmatterEditor);
$content = new ContainerWidget();
$content->setId(self::CONTENT_ID);
$content->setStyleClasses(['flex-col', 'gap-1']);
$content->add($tabs);
$content->add($editors);
return $content;
}
private function buildEvents(): static
{
$this->buildFileSystemEvents();
$this->buildContentEvents();
$this->tui->on(InputEvent::class, function(InputEvent $event) {
$input = $event->getData();
if($input == ActionKeys::Quit->value) {
$this->tui->stop();
}
});
return $this;
}
private function buildFileSystemEvents(): void
{
$this->tui->on(InputEvent::class, function(InputEvent $event) {
$input = $event->getData();
$sourceFilesSelect = $this->tui->getById(self::SOURCE_FILES_ID);
$newFileInput = $this->tui->getById(self::NEW_FILE_ID);
if($input == ActionKeys::DeleteFile->value) {
$selected = $sourceFilesSelect->getSelectedItem();
if ($selected && file_exists($selected['value']) && $sourceFilesSelect->isLastItem() == false) {
unlink($selected['value']);
$sourceFilesSelect->onFileAction();
}
}
if ($input === self::NAV_SOURCE_FILES && $this->tui->getFocus() !== $sourceFilesSelect) {
$this->tui->setFocus($sourceFilesSelect);
}
if ($input === self::NAV_NEW_FILE && $this->tui->getFocus() !== $newFileInput) {
$this->tui->setFocus($newFileInput);
}
});
}
private function buildContentEvents(): void
{
$this->tui->on(InputEvent::class, function(InputEvent $event) {
$input = $event->getData();
$markdownTab = $this->tui->getById(self::MARKDOWN_TAB_ID);
$markdownEditor = $this->tui->getById(self::MARKDOWN_EDITOR_ID);
$frontmatterTab = $this->tui->getById(self::FRONTMATTER_TAB_ID);
$frontmatterEditor = $this->tui->getById(self::FRONTMATTER_EDITOR_ID);
if($input == self::NAV_FRONTMATTER_EDITOR && $this->tui->getFocus() !== $frontmatterEditor) {
$frontmatterTab->addStyleClass('bold');
$markdownTab->removeStyleClass('bold');
$frontmatterEditor->removeStyleClass('hidden');
$markdownEditor->addStyleClass('hidden');
$this->tui->setFocus($frontmatterEditor);
}
if($input == self::NAV_MARKDOWN_EDITOR && $this->tui->getFocus() !== $markdownEditor) {
$markdownTab->addStyleClass('bold');
$frontmatterTab->removeStyleClass('bold');
$markdownEditor->removeStyleClass('hidden');
$frontmatterEditor->addStyleClass('hidden');
$this->tui->setFocus($markdownEditor);
}
});
}
private function setEditorText(array $item) {
$data = new MarkdownFile($item['value']);
$markdownEditor = $this->tui->getById(self::MARKDOWN_EDITOR_ID);
$markdownEditor->setText($data->getMarkdown());
$frontMatterEditor = $this->tui->getById(self::FRONTMATTER_EDITOR_ID);
$frontMatterEditor->setText($data->getFrontmatter());
}
private function saveEditorsToFile(SubmitEvent $event): void
{
$markdownEditor = $this->tui->getById(self::MARKDOWN_EDITOR_ID);
$frontmatterEditor = $this->tui->getById(self::FRONTMATTER_EDITOR_ID);
$file = $this->tui->getById(self::SOURCE_FILES_ID)->getSelectedItem()['value'];
new MarkdownFile($file)
->setFrontmatter($frontmatterEditor->getText())
->setMarkdown($markdownEditor->getText())
->save();
}
}
#!/usr/bin/env php
<?php
require __DIR__.'/../vendor/autoload.php';
use App\Commands\MarkdownAdminCommand;
use Symfony\Component\Console\Application;
$application = new Application();
$application->addCommand(new MarkdownAdminCommand());
$application->run();
<?php
declare(strict_types=1);
namespace App\Commands;
use App\BlogAdminTui;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
#[AsCommand('admin:markdown')]
class MarkdownAdminCommand
{
public function __invoke(
OutputInterface $output,
#[Argument('Markdown directory')] string $directory,
) : int
{
try {
$output->writeln('<info>Markdown Editor</info>');
$output->writeln('Shortcuts: ctrl+b -> filetree (return to display content, ctrl+i -> delete file) ; ctrl+d -> new file (return to create) ; ctrl+e -> markdown ; ctrl+r -> frontmatter (both editors return to save) ; ctrl+j -> quit.');
$output->writeln('-----------------------------------------------------');
$tui = new BlogAdminTui($directory);
$tui->run();
} catch (Throwable $exception) {
$output->writeln('<error>' . $exception->getMessage() . '</error>');
return Command::FAILURE;
}
return Command::SUCCESS;
}
}
<?php
declare(strict_types=1);
namespace App;
use InvalidArgumentException;
use RuntimeException;
class MarkdownFile
{
private string $filePath;
private string $frontmatter = '';
private string $markdown = '';
public function __construct(string $filePath)
{
if (!file_exists($filePath)) {
throw new InvalidArgumentException("File $filePath does not exist");
}
$this->filePath = $filePath;
$this->parseFile();
}
private function parseFile(): void
{
$content = file_get_contents($this->filePath);
if ($content === false) {
throw new RuntimeException("Failed to read file: $this->filePath");
}
// Split frontmatter and markdown
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*?)$/s', $content, $matches)) {
$this->frontmatter = $matches[1];
$this->markdown = $matches[2];
} else {
$this->markdown = $content;
}
}
public function getFrontmatter(): string
{
return $this->frontmatter;
}
public function setFrontmatter(string $frontmatter): static
{
$this->frontmatter = $frontmatter;
return $this;
}
public function getMarkdown(): string
{
return $this->markdown;
}
public function setMarkdown(string $markdown): static
{
$this->markdown = $markdown;
return $this;
}
public function save(): void
{
if(trim($this->frontmatter) == '' || trim($this->markdown) == '') {
throw new InvalidArgumentException("Frontmatter or Markdown missing");
}
$content = "---\n" . $this->frontmatter . "\n---\n\n" . $this->markdown;
if (file_put_contents($this->filePath, $content) === false) {
throw new RuntimeException("Failed to write file: $this->filePath");
}
}
}
<?php
declare(strict_types=1);
namespace App\Widgets;
use FilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Tui\Event\SelectionChangeEvent;
use Symfony\Component\Tui\Input\Keybindings;
use Symfony\Component\Tui\Widget\SelectListWidget;
class SourceFilesWidget extends SelectListWidget
{
private array $myItems = [];
private int $myItemsCount = 0;
private string $source = '';
public function __construct(array $items, int $maxVisible = 5, ?Keybindings $keybindings = null, ?string $source = null)
{
$this->source = $source;
$items = $this->getSourceFiles();
$this->myItems = $items;
$this->myItemsCount = count($this->myItems);
parent::__construct($items, $maxVisible, $keybindings);
}
public function onFileAction() : void
{
$items = $this->getSourceFiles();
$this->myItems = $items;
$this->myItemsCount = count($this->myItems);
$this->setItems($items);
$selectedItem = $this->myItems[0] ?? null;
if (null !== $selectedItem) {
$this->dispatch(new SelectionChangeEvent($this, $selectedItem));
}
}
/**
* Prevents removing all files because this
*/
public function isLastItem(): bool
{
return $this->myItemsCount == 1;
}
private function getSourceFiles(): array
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->source, RecursiveDirectoryIterator::SKIP_DOTS),
);
$filteredFiles = new class($iterator) extends FilterIterator {
public function accept(): bool
{
$file = $this->getInnerIterator();
return $file->isFile() && str_ends_with($file->getFilename(), '.md');
}
};
$files = [];
foreach ($filteredFiles as $filePath => $fileInfo) {
$label = str_replace($this->source, '', $fileInfo->getPath());
if($this->source != $fileInfo->getPath()) {
$label .= '/';
}
$label .= $fileInfo->getFilename();
$files[] = ['value' => $filePath, 'label' => $label];
}
return $files;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment