A three column document layout viewer for .md files using Laravel and Flux
DocumentationA few years ago I caught Freek just at the right time on X and asked him how they keep track of documentation, specifically, if they use an open source tool. The quick answer was they have an in-house tool. It took me a while but I can finally present that works for me and is quick and easy. This solution using Flux and Tailwind 4.
Update: Yesterday (21 August 2025) I discovered Vitepress after installing LaraDumps.
`composer require livewire/flux`
Let's start with `web.php` routes:
Route::get('/docs', [App\Http\Controllers\DocsController::class, 'index'])->name('docs'); Route::get('/docs/{page}', [App\Http\Controllers\DocsController::class, 'show'])->name('docs.show'); lang-php
This is what the controller looks like:
<?php namespace App\Http\Controllers; use Illuminate\View\View; class DocsController extends Controller { public function index(): View { return view('docs'); } public function show(string $page): View { $pages = config('docs.pages'); return view('docs.page', compact('page')); } } lang-php
You'll notice the controller refers to a configuration file. This is where each page definition that has to go live has to be added to. Below is an example `config/docs.pages` configuration file. You can set the title, the icon, the order, and even link to other documents as your application's `CHANGELOG.md` file by specifying the right path.
<?php return [ 'pages' => [ 'getting-started' => [ 'title' => 'Getting Started', 'icon' => 'rocket-launch', 'order' => 1, ], 'working-with-lists' => [ 'title' => 'Working with Lists', 'icon' => 'list-bullet', 'order' => 2, ], 'troubleshooting-and-support' => [ 'title' => 'Troubleshooting & Support', 'icon' => 'lifebuoy', 'order' => 3, ], 'Change Log' => [ 'viewPath' => base_path('CHANGELOG.md'), 'title' => 'Change Log', 'icon' => 'clock', 'order' => 10, ], ], ]; lang-php
Our next stop is the document index page called `resources/views/docs.blade.php`:
@php // The default documentation page that will be displayed when the /docs route is visited. $markdownContent = file_get_contents(resource_path('views/docs/getting-started.md')); @endphp <x-layouts.docs-with-toc :title="__(config('app.name') . ' Docs')"> <div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl"> <div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 p-6"> <div class="prose prose-neutral dark:prose-invert max-w-none"> {!! Str::markdown($markdownContent) !!} </div> </div> </div> <x-slot name="toc"> <x-table-of-contents :content="$markdownContent" /> </x-slot> </x-layouts.docs-with-toc> lang-php
The next file is the document `show` page called `resources/views/docs.page.blade.php`:
@php $page = $page ?? 'getting-started'; // The default page that will be displayed when the /docs route is visited. $markdownFile = config("docs.pages.{$page}.viewPath") ?? resource_path("views/docs/{$page}.md"); $markdownContent = file_exists($markdownFile) ? file_get_contents($markdownFile) : ''; $pageConfig = config("docs.pages.{$page}"); $title = $pageConfig['title'] ?? ucfirst($page); @endphp <x-layouts.docs-with-toc :title="__($title . ' - Todo Magic')"> <div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl"> <div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 p-6"> <div class="prose prose-neutral dark:prose-invert max-w-none"> {!! Str::markdown($markdownContent) !!} </div> </div> </div> <x-slot name="toc"> <x-table-of-contents :content="$markdownContent" /> </x-slot> </x-layouts.docs-with-toc> lang-php
Now let's see what `views/components/layouts/x-layouts.docs-with-toc` looks like:
<x-layouts.docs.sidebar :title="$title ?? null"> <flux:main> <div class="flex h-full w-full flex-1"> <!-- Main content area --> <div class="flex-1 min-w-0"> {{ $slot }} </div> <!-- Right sidebar - Table of Contents --> @php $tocContent = $toc ?? ''; $hasTocLinks = str_contains($tocContent, 'toc-link'); @endphp @if($hasTocLinks) <div class="hidden xl:block w-64 border-l border-neutral-200 dark:border-neutral-700 pl-6"> <div class="sticky top-6"> <div class="flex items-center gap-2 mb-4"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> </svg> <h5 class="text-sm font-medium text-neutral-900 dark:text-neutral-100">On this page</h5> </div> <nav class="space-y-1"> {{ $toc }} </nav> </div> </div> @endif </div> </flux:main> </x-layouts.docs.sidebar> lang-php
Finally, the file you've been looking for. `resources/views/components/table-of-contents.blade.php` with all the fancy JavaScript:
@if(!empty($toc)) <ul class="space-y-1" id="table-of-contents"> @foreach($toc as $item) <li> <a href="#{{ $item['anchor'] }}" class="block text-sm text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 transition-colors duration-200 toc-link" data-anchor="{{ $item['anchor'] }}"> {{ $item['title'] }} </a> @if(!empty($item['children'])) <ul class="mt-1 ml-4 space-y-1"> @foreach($item['children'] as $child) <li> <a href="#{{ $child['anchor'] }}" class="block text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors duration-200 toc-link" data-anchor="{{ $child['anchor'] }}"> {{ $child['title'] }} </a> @if(!empty($child['children'])) <ul class="mt-1 ml-4 space-y-1"> @foreach($child['children'] as $grandchild) <li> <a href="#{{ $grandchild['anchor'] }}" class="block text-sm text-neutral-400 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-400 transition-colors duration-200 toc-link" data-anchor="{{ $grandchild['anchor'] }}"> {{ $grandchild['title'] }} </a> </li> @endforeach </ul> @endif </li> @endforeach </ul> @endif </li> @endforeach </ul> @push('scripts') <script> // Use a more robust initialization strategy function initializeToc() { // Wait for both DOM and content to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { setTimeout(initTableOfContents, 100); }); } else { // DOM is already loaded, wait for content setTimeout(initTableOfContents, 100); } // Also try after window load window.addEventListener('load', function() { setTimeout(initTableOfContents, 200); }); } function initTableOfContents() { const tocLinks = document.querySelectorAll('.toc-link'); if (tocLinks.length === 0) { // If TOC links aren't ready yet, try again setTimeout(initTableOfContents, 100); return; } // Smooth scrolling for TOC links tocLinks.forEach(link => { link.addEventListener('click', function(e) { e.preventDefault(); const targetId = this.getAttribute('href').substring(1); // Try to find the target element let targetElement = document.getElementById(targetId); // If not found by ID, try to find by anchor name if (!targetElement) { targetElement = document.querySelector(`a[name="${targetId}"]`); } // If still not found, try to find by header text (case-insensitive) if (!targetElement) { const headers = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (const header of headers) { if (header.textContent.toLowerCase().includes(targetId.toLowerCase())) { targetElement = header; break; } } } if (targetElement) { // Add a small offset to account for fixed headers const offset = 50; // Reduced to match the detection offset const elementPosition = targetElement.offsetTop - offset; window.scrollTo({ top: elementPosition, behavior: 'smooth' }); } else { console.warn(`Target element not found for anchor: ${targetId}`); } }); }); // Highlight current section based on scroll position function updateActiveTocLink() { const headers = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); const offset = 300; // Reduced from 100 to be less eager let currentSection = ''; let currentHeader = null; let bestMatch = null; let bestScore = -1; // Find the header that's currently in view with better logic headers.forEach(header => { const rect = header.getBoundingClientRect(); const headerText = header.textContent.trim(); // Skip headers that are clearly not content (like "On this page") if (headerText.toLowerCase() === 'on this page' || headerText.toLowerCase() === 'installation') { return; } // Calculate how "in view" this header is const viewportHeight = window.innerHeight; const headerTop = rect.top; const headerBottom = rect.bottom; // A header is "in view" if it's within the offset from the top if (headerTop <= offset && headerBottom > 0) { // Calculate a score based on how close to the top it is const score = offset - headerTop; if (score > bestScore) { bestScore = score; bestMatch = header; } } }); // If no header is currently in the offset area, find the last header that was passed if (!bestMatch) { let lastPassedHeader = null; let lastPassedDistance = Infinity; headers.forEach(header => { const rect = header.getBoundingClientRect(); const headerText = header.textContent.trim(); // Skip non-content headers if (headerText.toLowerCase() === 'on this page' || headerText.toLowerCase() === 'installation') { return; } // If this header is above the offset area, it's a candidate // But only if it's not too far above (within 200px) if (rect.top < offset && rect.top > offset - 200) { const distance = offset - rect.top; if (distance < lastPassedDistance) { lastPassedDistance = distance; lastPassedHeader = header; } } }); bestMatch = lastPassedHeader; } currentHeader = bestMatch; // Remove active class from all links tocLinks.forEach(link => { link.classList.remove('text-blue-600', 'dark:text-blue-400', 'font-medium', 'font-semibold', 'text-blue-700', 'dark:text-blue-300'); // Remove the red line indicator link.style.borderLeft = ''; link.style.paddingLeft = ''; }); // Add active class to current section link if (currentHeader) { // Try to find matching TOC link by comparing header text with TOC link text const headerText = currentHeader.textContent.trim().toLowerCase(); let activeLink = null; tocLinks.forEach(link => { const linkText = link.textContent.trim().toLowerCase(); // Check if the link text matches the header text if (linkText === headerText) { activeLink = link; } // Also check if the anchor matches the header text (for kebab-case anchors) else { const anchor = link.getAttribute('data-anchor'); if (anchor) { const anchorText = anchor.replace(/-/g, ' ').toLowerCase(); if (anchorText === headerText) { activeLink = link; } } } }); if (activeLink) { activeLink.classList.add('text-blue-700', 'dark:text-blue-300', 'font-semibold'); // Add the red line indicator activeLink.style.borderLeft = '3px solid #ef4444'; activeLink.style.paddingLeft = '12px'; } } } // Update on scroll with throttling let ticking = false; function requestTick() { if (!ticking) { requestAnimationFrame(() => { updateActiveTocLink(); ticking = false; }); ticking = true; } } window.addEventListener('scroll', requestTick); // Initial update updateActiveTocLink(); } // Start initialization initializeToc(); </script> @endpush @else <p class="text-sm text-neutral-500 dark:text-neutral-400">No sections found</p> @endif lang-php
And it's associated View component in `app/View/Components`:
<?php namespace App\View\Components; use Illuminate\View\Component; class TableOfContents extends Component { public function __construct( public string $content = '' ) {} public function render() { $toc = $this->generateOnThisPageSidebarContent($this->content); return view('components.table-of-contents', [ 'toc' => $toc, ]); } private function generateOnThisPageSidebarContent(string $markdown): array { $lines = explode("\n", $markdown); $toc = []; $inToc = false; $currentParent = null; foreach ($lines as $line) { $trimmed = trim($line); // Skip empty lines if (empty($trimmed)) { continue; } // Check for indented items first (exactly 4 spaces) - check raw line if ($inToc && preg_match('/^ - \[(.+?)\]\(#(.+?)\)$/', $line, $matches)) { $title = $matches[1]; $anchor = $matches[2]; $item = [ 'level' => 2, 'title' => $title, 'anchor' => $anchor, 'children' => [], ]; // Add to the current parent if ($currentParent) { $currentParent['children'][] = $item; } } // Check if we're in the table of contents section (level 1 items) elseif (preg_match('/^- \[(.+?)\]\(#(.+?)\)$/', $trimmed, $matches)) { $inToc = true; $title = $matches[1]; $anchor = $matches[2]; $item = [ 'level' => 1, 'title' => $title, 'anchor' => $anchor, 'children' => [], ]; $toc[] = $item; $currentParent = &$toc[count($toc) - 1]; } // Check if we've reached the end of the TOC (first header after TOC) elseif ($inToc && preg_match('/^#{1,6}\s+/', $trimmed)) { break; } } return $toc; } } lang-php
Seems we'll also need `resources/views/components/layouts/docs-with-toc.blade.php`:
<x-layouts.docs.sidebar :title="$title ?? null"> <flux:main> <div class="flex h-full w-full flex-1"> <!-- Main content area --> <div class="flex-1 min-w-0"> {{ $slot }} </div> <!-- Right sidebar - Table of Contents --> @php $tocContent = $toc ?? ''; $hasTocLinks = str_contains($tocContent, 'toc-link'); @endphp @if($hasTocLinks) <div class="hidden xl:block w-64 border-l border-neutral-200 dark:border-neutral-700 pl-6"> <div class="sticky top-6"> <div class="flex items-center gap-2 mb-4"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> </svg> <h5 class="text-sm font-medium text-neutral-900 dark:text-neutral-100">On this page</h5> </div> <nav class="space-y-1"> {{ $toc }} </nav> </div> </div> @endif </div> </flux:main> </x-layouts.docs.sidebar> lang-php
Next, we need `layouts/docs/sidebar`:
<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <head> @include('partials.head') </head> <body class="min-h-screen bg-white dark:bg-zinc-800"> <flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900"> <flux:sidebar.toggle class="lg:hidden" icon="x-mark" /> <a href="{{ route('docs') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse" wire:navigate> <x-app-logo /> </a> <flux:navlist variant="outline"> <flux:navlist.group :heading="__('Documentation')" class="grid"> @php $pages = collect(config('docs.pages'))->sortBy('order'); @endphp @foreach($pages as $slug => $page) <flux:navlist.item :icon="$page['icon']" :href="route('docs.show', $slug)" :current="request()->routeIs('docs.show') && request()->route('page') === $slug" wire:navigate>{{ __($page['title']) }} </flux:navlist.item> @endforeach </flux:navlist.group> </flux:navlist> <flux:spacer /> </flux:sidebar> <!-- Mobile User Menu --> <flux:header class="lg:hidden"> <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" /> <flux:spacer /> </flux:header> {{ $slot }} @stack('scripts') @fluxScripts </body> </html> lang-php
Let's add the app logo:
<div class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground"> <x-app-logo-icon class="size-5 fill-current text-white dark:text-black" /> </div> <div class="ms-1 grid flex-1 text-start text-sm"> <span class="mb-0.5 truncate leading-tight font-semibold">{{ config('app.name') }}</span> </div> lang-html
And then `resources/views/partials/head.blade.php` (this is stock)
<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{{ $title ?? config('app.name') }}</title> <link rel="icon" href="/favicon.ico" sizes="any"> <link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="preconnect" href="https://fonts.bunny.net"> <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" /> @vite(['resources/css/app.css', 'resources/js/app.js']) @fluxAppearance lang-php
Here is my `resources/views/components/app-logo-icon.blade.php` file:
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"> <g transform="scale(0.85) translate(40, 40)"> <path d="m214.706 312.295c-3.839 0-7.678-1.465-10.606-4.394-5.858-5.857-5.858-15.355 0-21.213l151.433-151.435c5.857-5.857 15.355-5.857 21.213 0s5.858 15.355 0 21.213l-151.434 151.435c-2.928 2.929-6.767 4.394-10.606 4.394z" fill="#ffb454"/><path d="m204.1 307.901c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.465 10.606-4.394l151.433-151.435c5.858-5.857 5.858-15.355 0-21.213l-172.645 172.648c-.001 0-.001 0 0 0z" fill="#ff8659"/><path d="m386.868 227.538c-.779 0-1.564-.061-2.349-.185-5.639-.894-10.283-4.911-11.977-10.363l-18.385-59.146-59.147-18.385c-5.452-1.695-9.469-6.338-10.363-11.978-.893-5.64 1.493-11.297 6.154-14.594l50.57-35.763-.792-61.934c-.073-5.709 3.102-10.965 8.189-13.557 5.088-2.594 11.206-2.07 15.781 1.344l49.639 37.043 58.657-19.891c5.408-1.836 11.387-.439 15.424 3.599 4.038 4.037 5.433 10.017 3.599 15.424l-19.892 58.657 37.044 49.639c3.415 4.576 3.936 10.694 1.344 15.781-2.592 5.088-7.848 8.272-13.557 8.189l-61.934-.791-35.763 50.569c-2.836 4.016-7.424 6.342-12.242 6.342z" fill="#ffdd54"/><path d="m8.787 503.213c-11.716-11.716-11.716-30.711 0-42.426l180.312-180.312c5.858-5.858 15.355-5.858 21.213 0l21.213 21.213c5.858 5.858 5.858 15.355 0 21.213l-180.312 180.312c-11.716 11.716-30.71 11.716-42.426 0z" fill="#6b61b1"/><g fill="#ffb454"><path d="m497 320h-15v-15c0-8.284-6.716-15-15-15s-15 6.716-15 15v15h-15c-8.284 0-15 6.716-15 15s6.716 15 15 15h15v15c0 8.284 6.716 15 15 15s15-6.716 15-15v-15h15c8.284 0 15-6.716 15-15s-6.716-15-15-15z"/><circle cx="347" cy="320" r="15"/><circle cx="467" cy="240" r="15"/></g><g fill="#ffdd54"><path d="m206 170h-15v-15c0-8.284-6.716-15-15-15s-15 6.716-15 15v15h-15c-8.284 0-15 6.716-15 15s6.716 15 15 15h15v15c0 8.284 6.716 15 15 15s15-6.716 15-15v-15h15c8.284 0 15-6.716 15-15s-6.716-15-15-15z"/><path d="m276 30h-15v-15c0-8.284-6.716-15-15-15s-15 6.716-15 15v15h-15c-8.284 0-15 6.716-15 15s6.716 15 15 15h15v15c0 8.284 6.716 15 15 15s15-6.716 15-15v-15h15c8.284 0 15-6.716 15-15s-6.716-15-15-15z"/><circle cx="141" cy="90" r="15"/></g><g><path d="m354.156 157.844 18.385 59.146c1.694 5.452 6.338 9.47 11.977 10.363.785.124 1.57.185 2.349.185 4.819 0 9.407-2.326 12.245-6.339l35.763-50.569 61.934.791c5.708.083 10.964-3.102 13.557-8.189 2.592-5.087 2.071-11.205-1.344-15.781l-37.044-49.639 19.892-58.657c1.833-5.407.438-11.387-3.599-15.424z" fill="#ffb454"/><path d="m8.787 503.213c11.716 11.716 30.711 11.716 42.426 0l180.312-180.312c5.858-5.858 5.858-15.355 0-21.213l-10.607-10.607z" fill="#453d81"/></g></g></svg> lang-html
Next, create a file in `resources/views/docs` e.g. `getting-started.md`.
At this point, you can navigate to `/docs` in your application and see how it looks.
The challenges in the end were:
- Tailwind 4 `resources/css/app.css` changes from Tailwind 3 to Tailwind 4.
- Also remember to install Tailwind topography