phattarachai / laravel-ai-docs
Render a folder of markdown — your .ai/documents/ tree — as a searchable documentation site inside your Laravel app.
Requires
- php: ^8.4
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- inertiajs/inertia-laravel: ^2.0|^3.0
- league/commonmark: ^2.4
- symfony/finder: ^6.4|^7.0|^8.0
- symfony/yaml: ^6.4|^7.0|^8.0
- tempest/highlight: ^2.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.24
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-06 07:48:14 UTC
README
Render a folder of markdown — your .ai/documents/ tree, your handbook, your runbooks — as a searchable documentation
site inside your Laravel app, instead of pushing it to a separate wiki that immediately goes stale.
The files stay where they are, next to the code, versioned with it. The panel mounts at /docs, behind your auth
gate, and reads the directory as it is: folders become sidebar groups and nest as deeply as you nested them,
# Heading becomes the nav label, images sitting beside the markdown just work.
Both screenshots render the fictional documentation tree in art/demo-docs/ — no real project or
person appears in them.
Requirements — read these first
- PHP 8.4+, Laravel 11/12/13.
- Inertia v2/v3 + React 18/19 in the host app. The panel is an Inertia page, not a Blade view.
- The host app builds its own assets — nothing is precompiled or published as a bundle.
mermaid11 or 12 as an npm dependency. It is a peer dependency, lazy-imported; without it diagram fences degrade to plain text rather than breaking the page.- An access gate. There is no default. Until you register one, every request is refused.
No Tailwind, and no @source line. Like its sibling laravel-db-console, this panel ships plain CSS scoped
under .doc-root, imported by the module itself — so it renders correctly in a host with any CSS setup, or none.
Run php artisan ai-docs:doctor at any point: it checks the routes, the gate, the docs root, the published page, the
Vite alias and mermaid, and tells you which one is missing.
Install
composer require phattarachai/laravel-ai-docs php artisan vendor:publish --tag=ai-docs-config # optional php artisan vendor:publish --tag=ai-docs-inertia # required npm install mermaid
The ai-docs-inertia tag is not optional: it publishes one file, resources/js/pages/AiDocs.jsx, and it has to
live there because app.jsx's import.meta.glob('./pages/**/*.jsx') never leaves that directory. The stub is a dozen
lines — a <Head> and <AiDocs {...props} />. The module itself stays in vendor/ and is reached through an alias, so
there is no second copy to drift out of sync.
Add that alias in vite.config.js:
import path from 'node:path' export default defineConfig({ resolve: { alias: { '@ai-docs': path.resolve( __dirname, 'vendor/phattarachai/laravel-ai-docs/resources/js/ai-docs', ), }, }, })
Then register the gate, in AppServiceProvider::boot():
use Illuminate\Http\Request; use Phattarachai\AiDocs\AiDocs; AiDocs::auth(fn (Request $request): bool => $request->user()?->is_admin === true);
Finally, verify and build:
php artisan ai-docs:doctor npm run build
Open /docs.
Access
The gate is closed by default — with no AiDocs::auth() callback registered, the Authorize middleware refuses every
request, including yours. That is deliberate: internal docs are usually the most quotable thing in a codebase, and a
package should not guess who may read them.
The middleware is appended by the service provider after ai-docs.middleware, so it cannot be dropped by editing that
key. A guest who is refused is redirected to redirect_guests_to (default: the login route) with the intended URL
remembered, so signing in lands them on the page they asked for. A signed-in user the gate rejects gets a plain 403,
and so does any request expecting JSON.
Kill it entirely with AI_DOCS_ENABLED=false: no routes are registered at all, so the paths 404 rather than 403.
What it does
The tree is the navigation. Every .md file under root is a page; every folder is its own accordion group,
labelled from the directory name (frontend → Frontend, ui-kit → Ui kit). Root-level pages sit ungrouped at the
top. There is no sidebar config file to keep in sync — add a file, it appears.
Panels — one install can serve several trees. Docs you maintain and task folders you throw away have different half-lives, and merging them into one root buries the first under the second. Give each its own panel and you get its own URL, sidebar and search index, plus a switcher in the header:
'panels' => [ 'docs' => ['root' => '.ai/documents', 'label' => 'Documents'], 'tasks' => ['root' => '.ai/tasks', 'label' => 'Tasks'], ],
Links resolve across panels, so a task can link to a doc and back. Leave panels empty for the single tree built from
path / root / exclude.
Collapsing a column — the header carries a toggle for each side column: the page tree on the left, the outline on the right. Collapse either to give a wide table or diagram the whole width; the choice is remembered per browser. The toggles appear only above the width where that column exists — narrower than that, it is already a drawer.
Search — ⌘K opens a palette over a section-level index built server-side and served from /docs/_search.json.
Every heading is its own hit, scored across title, heading and body text, with the matched terms highlighted in a
snippet. Arrows move, Enter jumps straight to the anchor.
Syntax highlighting is server-side, via tempest/highlight with its CSS theme: the HTML carries .hl-* classes,
the colours are CSS custom properties per scheme, and no highlighting JavaScript reaches the browser. It lands in the
render cache with the rest of the page.
Mermaid diagrams — a ```mermaid fence becomes a diagram, with mermaid lazy-imported on the first page
that has one. Both schemes are themed to match the panel, and a small semantic palette is available to opt into with
class Node decision or Node:::decision — decision, ok, bad, actor.
Diagrams lay out with dagre on either major; mermaid 12's ELK default is not used.
A fence mermaid cannot draw shows its source instead, with the reason in the browser console.
GitHub alerts — > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION] render as titled
callouts, with the title translated.
Images stay private. An image beside your markdown is rewritten to /docs/_media/{path} and streamed through the
same gate, so nothing has to be copied into public/. Only real image extensions inside the docs root are served, and
excluded paths are refused. Every file goes out with X-Content-Type-Options: nosniff, and an SVG also carries a
Content-Security-Policy that allows no script, so opening one directly in the address bar can't run anything inside
it.
Copy the path, not the page. A button on every page copies its repo-relative path —
.ai/documents/billing/payment-retries.md — ready to paste after @ in Claude Code, Cursor, or whatever is reading
your repo. Every heading also gets a #
permalink; clicking it copies the absolute URL rather than navigating.
Full screen — tables, diagrams and images each get a zoom button that opens them in an overlay, because a sequence diagram never fits a documentation column.
Print / Save as PDF — a toolbar button prints the current page through the browser, so any reader can save a PDF
with no server-side dependency. A @media print sheet drops the top bar, both rails and every control, forces the dark
scheme back to ink-on-paper, and keeps code blocks, tables and diagrams from splitting across a page — and because the
whole document already lives in the DOM, the export is the entire page, mermaid diagrams and all, not just the viewport.
A tall image or diagram is scaled down to fit one page. When one lands awkwardly — stranded under a heading with a page
of white space above it — size that one element for print without touching the rest: add a print-NN keyword, where
NN is a rough percent of a page (50, 60, 70, 80, 90, or 100 to leave it at full size).
```mermaid print-70 flowchart TD A --> B ``` 
On a diagram it is a second word in the fence info string; on an image it goes in the title slot (and is stripped, so it never shows as a tooltip). It only affects print — on screen both render full size. Any other value is ignored.
Wide figures — wide lets one diagram or image also take the outline rail's width, for the landscape drawing that
needs every pixel. That happens only on a page with no outline (no ## headings), where the rail is empty. A page with
an outline keeps the figure in the column so the outline is never covered; collapse the rail, or use the figure's zoom
button, to see it larger. wide combines with the other keywords in any order. On a narrower screen, where the outline
is a drawer, the figure stays in the column. On a phone, an inlined SVG marked wide keeps a legible size and scrolls
sideways.
```mermaid wide print-70 flowchart LR A --> B ``` 
An image title holding only keywords (inline, wide, print-NN) is read as directives and removed. A title with any
other word in it stays an ordinary tooltip.
Hand-drawn SVG diagrams
Mermaid suits flows. A landscape map with status pills, coloured connectors and Thai labels is easier to draw by hand.
Keep the .svg beside the markdown and add inline to the image title:

The drawing is written into the page instead of being loaded through <img>, which changes four things:
- It uses the panel's font. Leave
font-familyoff your<text>and Thai labels match the prose around them. An<img>SVG can't load web fonts. - It can follow the light/dark toggle through the panel's CSS custom properties, if you draw with them (below).
- Links work.
<a href="sap-etax.md#flow">around a card is resolved like a markdown link, across panels too, and opens that doc in place. An<a href="#heading">jumps to a heading on the same page. - It gets the same zoom button and
print-NNsizing as a mermaid diagram.wideandprint-NNcombine withinline, e.g."inline wide print-80".
inline applies only to an in-tree .svg that sits alone in its paragraph. A remote SVG, one sharing its line with
text, or a file that won't parse stays a plain <img>. The drawing's <text> is added to search, under the heading it
sits beneath. Editing only the .svg refreshes the cached page.
Server-side sanitizing. The SVG is copied element by element, and only drawing elements cross over: shapes, text,
<defs>, <marker>, gradients, patterns, clip paths, masks, filters, <style>. Script, <foreignObject>, animation
elements, comments and every on* attribute are left behind. An href survives only as a #fragment, as a link to
another doc, or as an <image> of an in-tree picture. External, javascript: and data: URLs are dropped, and so is
any url() that would fetch.
Ids are namespaced per figure. id="arrow" becomes id="ds-…-1-arrow", and every url(#arrow), href="#arrow"
and #arrow selector follows it. The same drawing can therefore appear twice on one page, and the full-screen copy
re-namespaces again. The fixed width/height is dropped and the viewBox kept, so the drawing scales with the
column. The <style> block is scoped to the drawing's own root, so .card { … } can't restyle the page around it.
Theme hooks. These properties are set on the panel in both schemes. Write them with a fallback, so the file still renders on GitHub or in an image viewer:
| Property | Light | Dark | For |
|---|---|---|---|
--doc-bg |
#ffffff |
#16171d |
the page / card fill |
--doc-text |
#1c1d21 |
#e7e8ed |
labels |
--doc-muted |
#71757e |
#9ca0ae |
secondary labels |
--doc-line |
#e6e7ea |
#32333e |
borders, connectors |
--doc-accent |
brand.accent |
same | strokes, tints — not text |
--doc-ok-bg / -line / -text |
#dcfce7 #16a34a #14532d |
#0b2f1a #22c55e #bbf7d0 |
done, migrated |
--doc-warn-bg / -line / -text |
#fef3c7 #d97706 #78350f |
#3b2408 #f59e0b #fde68a |
in progress, dual-run |
--doc-bad-bg / -line / -text |
#fee2e2 #dc2626 #7f1d1d |
#3d1113 #ef4444 #fecaca |
broken, pending |
--doc-actor-bg / -line / -text |
#dbeafe #2563eb #1e3a8a |
#16244d #60a5fa #bfdbfe |
people, outside systems |
--doc-accent is your brand colour, the same in both schemes. A dark brand (#3a4a8c) is unreadable as text on the
dark scheme, so use it for strokes, outlines and tints, and use --doc-text for labels.
The four tones are the same palette as mermaid's ok / decision / bad / actor classes (warn is decision), so
hand-drawn and generated diagrams on one page agree.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 120"> <defs> <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"> <path d="M0,0 L10,5 L0,10 z" style="fill: var(--doc-muted, #71757e)"/> </marker> </defs> <a href="sap-etax.md"> <rect x="10" y="30" width="130" height="60" rx="10" style="fill: var(--doc-ok-bg, #dcfce7); stroke: var(--doc-ok-line, #16a34a)"/> <text x="75" y="65" text-anchor="middle" style="fill: var(--doc-ok-text, #14532d)">sap-etax</text> </a> <path d="M140,60 H220" marker-end="url(#arrow)" style="stroke: var(--doc-line, #e6e7ea)"/> <text x="270" y="65" text-anchor="middle" style="fill: var(--doc-text, #1c1d21)">BBL e-Tax</text> </svg>
Put the variables in style="fill: …", not in a presentation attribute (fill="var(…)"), which doesn't take var()
reliably in every browser. Leave out a full-size white background <rect>, so the panel's own background shows through
and follows the scheme.
Layout — three columns (nav · prose · outline) that collapse into drawers on a phone, a scroll-spy outline, and a
light/dark toggle remembered in localStorage. The scheme lives on .doc-root, never on <html>, so the panel never
fights the host app's own theme state.
Render cache — keyed on the file's mtime, the render pipeline's version and the current locale, so it is
self-invalidating. Edit a file and reload; there is nothing to warm at deploy and nothing to clear. Set
AI_DOCS_CACHE=false while writing if you prefer.
Writing docs
Plain markdown works with no front matter at all. When there is none, the nav label is the H1, cut at the first
—, –, ( or : — so a page titled # Payment retries — the backoff schedule lists as
Payment retries.
Front matter overrides any of that, and every key is optional:
--- title: Payment retries — the backoff schedule nav: Payment retries order: 10 --- # Payment retries > [!IMPORTANT] > A retry never re-runs the pipeline. It resumes from the stored idempotency record. ```mermaid flowchart LR C[Charge failed] --> R{Retryable?}:::decision R -->|yes| S[Schedule backoff] --> D[Settled]:::ok R -->|no| X[Marked uncollectible]:::bad ```
| Key | Effect |
|---|---|
title |
Page title and browser tab. Defaults to the H1, then the filename |
nav |
Sidebar label. Defaults to the shortened title |
order |
Sort position within its group. Unset sorts as 500 |
Within a group, pages sort by order, then index.md first, then nav label. The landing page is index.md at the
root of the docs folder, or the first page of the first group if there is none.
Subfolders nest inside their parent group rather than listing beside it, so cart/prices/ opens inside Cart
and the breadcrumb reads Cart · Prices. There is no depth limit.
When two docs in one folder shorten to the same label — five pages titled Admin panel — … — the panel switches those
docs to the half of the title after the cut (Form conventions, Table conventions) rather than listing the same
word five times. So a collision usually needs no front matter at all; reach for nav when you want a label the title
doesn't contain.
Links between docs are rewritten to panel URLs and navigate through Inertia — including anchors. A link to a folder
lands on whichever page the sidebar lists first under it, so [#94](../cycle-2/94-billing/) works without naming a
file; a folder with no page of its own hands off to the first one below it. A relative link that points outside every
panel's root renders as plain text unless source_link_base is set, in which case it becomes a link to your code host.
How to organize the folder — flat-first structure, the index.md map, the 500-line rule, how order and nav
interact with the folder grouping — is docs/authoring.md. If an agent writes your docs, those
same conventions ship as a drop-in Claude Code skill in
examples/document-skill/.
Configuration
See config/ai-docs.php.
| Key | Default | Env | Notes |
|---|---|---|---|
enabled |
true |
AI_DOCS_ENABLED |
false registers no routes |
path |
docs |
AI_DOCS_PATH |
where it mounts |
domain |
null |
AI_DOCS_DOMAIN |
optional route domain |
middleware |
['web'] |
— | Authorize is always appended |
redirect_guests_to |
login |
AI_DOCS_LOGIN_ROUTE |
route name or URL; null 403s guests |
root |
.ai/documents |
AI_DOCS_ROOT |
relative to the project root |
exclude |
[] |
— | path prefixes, matched by whole segment |
panels |
[] |
— | several trees; empty means one |
tables |
scroll |
AI_DOCS_TABLES |
scroll or wrap; styling only |
source_link_base |
null |
AI_DOCS_SOURCE_BASE |
code-host base URL for out-of-tree links |
cache |
true |
AI_DOCS_CACHE |
render + search cache |
brand.name |
APP_NAME |
AI_DOCS_BRAND |
shown in the header |
brand.accent |
#3b82f6 |
AI_DOCS_ACCENT |
injected as --doc-accent, no rebuild |
brand.url |
/ |
AI_DOCS_BRAND_URL |
where the header logo links back to |
brand.logo |
null |
— | image URL; falls back to the first letter |
exclude matches whole segments relative to root, so 'handbook' drops the handbook/ directory while leaving
handbook.md servable. Excluded paths are dropped from the tree, the search index, direct URLs and _media alike.
A panels entry may set path (defaults to the key), root, label and exclude; whatever it omits falls back to
the top-level key of the same name. Route names gain the panel key — route('ai-docs.tasks.index') — while a single
unnamed panel keeps route('ai-docs.index'). Panels are registered longest-path first, so one nested inside another's
prefix (docs and docs/tasks) still resolves.
Everything else is a CSS custom property you can override:
.doc-root { --doc-accent: #16a34a; --doc-measure: 80ch; }
Translations
Every browser string is handed to React as one flat strings prop from trans('ai-docs::ui'); en and th ship.
Publish and edit them:
php artisan vendor:publish --tag=ai-docs-lang
The React module carries English defaults for the same keys, so it renders standalone even with no lang files at all.
Adding a locale means adding one directory — no JavaScript changes. Note that callout.* and anchor.label are
rendered server-side into the markdown, which is why the render cache is keyed by locale.
Contributing
git clone git@github.com:phattarachai/laravel-ai-docs.git cd laravel-ai-docs composer install composer test
The suite runs on orchestra/testbench against in-memory SQLite — there is no database work in this package, so
nothing heavier is warranted. PHP is formatted with vendor/bin/pint, JavaScript with Prettier (.prettierrc:
2-space, single quote, no semicolons).
Read docs/internals.md before changing the pipeline or the React module. Every note in it is a
bug that already happened once, and the code carries no comment explaining it.
Credits
Built on league/commonmark,
tempest/highlight and Mermaid.
Licence
MIT.

