A three column document layout viewer for .md files using Laravel and Flux

Documentation

A 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