Last active
April 6, 2026 05:45
-
-
Save xwero/38ae5da899a4bbf0a003932eedd5dc5f to your computer and use it in GitHub Desktop.
Symfony Tui: markdown editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App; | |
| enum ActionKeys : string | |
| { | |
| case DeleteFile = "\x09"; // ctrl+i | |
| case Quit = "\x0a"; //ctrl+j | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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"); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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