Search by

phattarachai / laravel-ai-docs

phatchai

Render a folder of markdown — your .ai/documents/ tree — as a searchable documentation site inside your Laravel app.

Package info

github.com/phattarachai/laravel-ai-docs

pkg:composer/phattarachai/laravel-ai-docs

Statistics

Installs: 451

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.1 2026-10-06 07:47 UTC

README

Latest Version on Packagist Tests Code Style PHP Version Laravel Version Total Downloads

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.

A doc page with the sidebar tree, a rendered mermaid flowchart and the outline column

The same page in the dark scheme

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.
  • mermaid 11 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
```

![Sequence of the retry path](retry.png "print-60")

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
```

![System landscape](landscape.png "wide")

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:

![MJ system landscape](mj-systems.svg "inline wide")

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-family off 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-NN sizing as a mermaid diagram. wide and print-NN combine with inline, 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.