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-phpThis 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-phpYou'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-phpOur 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-phpThe 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-phpNow 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-phpFinally, 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-phpAnd 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-phpSeems 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-phpNext, 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-phpLet'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-htmlAnd 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-phpHere 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-htmlNext, 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