CLAUDE.md
June 10, 2026 · View on GitHub
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
adminlte-vue — an AdminLTE 4 / Bootstrap 5.3 admin dashboard for Vue 3 & Nuxt. It is a
faithful port of the official React (adminlte-react) and Laravel (adminlte-laravel) editions.
This is a pnpm monorepo (pnpm-workspace.yaml → packages/*, apps/*):
packages/adminlte-vue— the publishable, framework-agnostic Vue 3 component library (works in any Vite / Nuxt / Vue 3 app). Built with Vite library mode → ESM +.d.ts.packages/nuxt(@adminlte/nuxt) — a thin Nuxt module that auto-registers the components, auto-imports the composables, injects the CSS, initializes Bootstrap's JS client-side, and adds an SSR-safe color-mode head script. Built with@nuxt/module-builder.apps/demo— a Nuxt 4 demo that dogfoods the library + module viaworkspace:*. It is the pristine 1:1 clone of the official AdminLTE demo (incl. 26 docs pages) — keep it that way; do not refactor its pages. Uses its ownDemoLayout.apps/docs(adminlte-docs) — the documentation site (Nuxt 4 + @nuxt/content v3, better-sqlite3). Markdown lives incontent/**(numeric-prefixed for order;sectionfront-matter drives the sidebar grouping); a custom docs layout provides header/search/color-mode, a section-grouped sidebar, a TOC, and reading-order prev/next (viauseDocsPages). Documents the Vue/Nuxt port itself. Run withpnpm dev:docs/pnpm build:docs.
Correctness gates (all must pass): pnpm --filter adminlte-vue type-check (vue-tsc --noEmit),
pnpm --filter adminlte-vue test (Vitest — jsdom + @vue/test-utils), pnpm lint (ESLint 9 flat
config over packages/*/src; apps/** excluded), pnpm build:demo, and pnpm build:docs.
CI (.github/workflows/ci.yml) runs exactly these on every push/PR, after pnpm build.
Commands
From the repo root:
pnpm install # install all workspace deps
pnpm build # build packages/* in dependency order (lib → nuxt module)
pnpm build:demo # production build of the demo (the strongest SSR/hydration check)
pnpm dev # parallel watch of all packages
pnpm dev:demo # run the Nuxt demo (nuxi dev)
pnpm type-check # type-check every package
pnpm test # library unit tests (vitest run)
pnpm lint # ESLint over packages/*/src
Tests are co-located *.test.ts files under packages/adminlte-vue/src/** (config in
vitest.config.ts — deliberately separate from the lib-mode vite.config.ts). To run a single
test file or watch:
pnpm --filter adminlte-vue exec vitest run src/widget/LteCard.test.ts # one file
pnpm --filter adminlte-vue exec vitest run -t 'collapses' # by test name
pnpm --filter adminlte-vue test:watch # watch mode
Per package:
pnpm --filter adminlte-vue build # vite build (ESM + dts + copy-css)
pnpm --filter adminlte-vue dev # vite build --watch
pnpm --filter adminlte-vue type-check # vue-tsc --noEmit ← primary library gate
pnpm --filter @adminlte/nuxt build # nuxt-module-build
pnpm --filter @adminlte/nuxt dev # nuxt-module-build --stub (live src in the demo)
The demo consumes the library's built dist/. After editing library source, rebuild it (or keep
pnpm --filter adminlte-vue dev running) before the demo reflects the change. To verify end to end:
pnpm build:demo then node apps/demo/.output/server/index.mjs.
The build pipeline
Library (packages/adminlte-vue, Vite library mode — vite.config.ts):
@vitejs/plugin-vuecompiles.vueSFCs (<script setup lang="ts">). Do not switch to tsup/esbuild — esbuild can't compile SFCs (that's why the React port could use tsup and we can't).vite-plugin-dts(backed byvue-tsc) emits the.d.tstree. It globssrc/**only — ambient module declarations must live insrc/shims.d.ts, not rootenv.d.ts(which the dts program doesn't see).- A
closeBundlehook copiesadmin-lte/dist/css/adminlte.css(+.rtl.css) →dist/css/(exposed as the./cssand./css/rtlexports). Mirrors the React port'scopy-cssstep.
Output is ESM-only with two entries: index (.) and plugins (./plugins). The split
keeps the heavy plugin libs out of the default import. The build uses preserveModules: true
(per-module files mirroring src/, root dist/), so plain-Vite consumers tree-shake at file
granularity instead of relying on export-level shaking of one bundled chunk. The ./plugins types
live at dist/plugins/index.d.ts (the entry JS stays dist/plugins.js).
Gotcha —
build.minify: false. Vite lib-mode esbuild minification produced a duplicate- identifier collision in a shared chunk (LteTomSelect). Libraries should ship readable ESM and let the consuming app minify, so minify is disabled. Keep it off.
Heavy plugin libs (apexcharts, tabulator-tables, quill, flatpickr, tom-select,
sortablejs, jsvectormap, overlayscrollbars, @fullcalendar/*) plus vue/bootstrap are
external in rollupOptions and declared optional peerDependencies — never bundled.
Architecture
State = composables + provide/inject (no Pinia)
This is the Vue analog of the React port's Context + hooks. LteDashboardLayout is the single
provider host: in setup() it calls provideSidebar(), provideColorMode(),
provideCommandPalette(), and installs useLteBehaviors() + useAccessibility(). Descendants
(LteTopbar, LteSidebar, LteColorModeToggle, …) consume via useSidebar() / useColorMode() /
useCommandPalette(), which inject and throw a clear error if used outside the layout. Injection
keys are module-singleton Symbols in src/composables/keys.ts — this is why the library is
ESM-only (a dual ESM/CJS build could duplicate the Symbols and break injection).
Per-component state (useCardWidget, useFullscreen, useDirectChat, treeview open-state) is
local, not provided.
SSR-safety rules (the crux — don't break these)
Every browser-API access (window, document, localStorage, matchMedia) is isolated inside
onMounted / watchEffect / event handlers, never at module top level or synchronously in setup.
Specific patterns:
- Color mode writes
data-bs-themeon<html>and persists the preference under thelte-themelocalStorage key. To avoid a flash,@adminlte/nuxtinjects a blocking inline head script (thethemeScriptoption) that sets the attribute before first paint;useColorModeonly owns reactive updates after hydration. The toggle glyph is rendered under<ClientOnly>in the demo. - Sidebar collapse persists (opt-in, default off) under
lte.sidebar.state. Body layout classes (layout-fixed,sidebar-expand-lg,sidebar-collapse,sidebar-mini, …) are toggled ondocument.bodyfrom awatchEffectinsideprovideSidebar—<body>lives outside the Vue app tree, so this is hydration-safe (no class-mismatch warnings). - Window size for the push-menu breakpoint starts as
null(treated as desktop) so SSR renders deterministically; the resize listener attaches inonMounted.
Single menu structure drives nav + command palette
src/types/menu.ts defines MenuNode — a discriminated union of header | item | group (groups
nest MenuNode[]). The same array feeds the recursive LteSidebarNavItem tree and
flattenMenuToCommands() for the ⌘K palette. The recursive item self-references in its own template
(Vue supports recursive SFCs by filename).
Routing decoupling
The core library is framework-agnostic, so it takes a currentPath prop (active-link detection)
and a linkComponent prop (default 'a') instead of importing a router. The demo passes
useRoute().path and resolveComponent('NuxtLink'), and a navigate callback (navigateTo) for the
command palette.
Dynamic-import plugin pattern
Heavy libs are never statically imported. Each wrapper in src/plugins/*.vue does
await import(...) inside onMounted, guards if (!el.value) return (component may unmount before
the import resolves), and destroys the instance in onBeforeUnmount. LteApexChart.vue is the
reference implementation. In the demo, wrap these components in <ClientOnly> with a
#fallback. Consumers install the matching lib as their own dep + load its CSS.
Nuxt module (packages/nuxt/src/module.ts)
defineNuxtModule with configKey: 'adminlte'. It: pushes adminlte-vue into build.transpile
(mandatory — SFC ESM breaks SSR externalization otherwise); adds bootstrap/@popperjs/core to
vite.optimizeDeps.include; auto-registers components via addComponent({ export, filePath: 'adminlte-vue' | 'adminlte-vue/plugins' }); auto-imports composables via addImports; pushes
adminlte-vue/css into nuxt.options.css; injects the theme head script; and adds a .client
plugin that imports bootstrap (its data-API delegation powers dropdowns/modals/offcanvas).
Demo (Nuxt 4, apps/demo)
Nuxt 4 app/ srcDir convention — pages in app/pages, layouts in app/layouts. Components and
composables are auto-imported by the module (no imports in pages). Route → layout is chosen with
definePageMeta({ layout }): dashboard pages use the default layout (DemoLayout wraps
LteDashboardLayout); examples/* use layout: 'auth'; the layout/* variant pages use
layout: false and render <DemoLayout :fixed-* …> directly to showcase a flag.
Demo-fidelity tooling (scripts/)
Two utilities support the "pristine 1:1 clone" mandate:
node scripts/clone-doc.mjs <slug> [...]— clones an original AdminLTE 4 docs page (from theadmin-ltepackage'sdist/docs) intoapps/demo/app/pages/docs/, rewriting intra-doc links to/docs/*routes and relative asset paths to/assets/....node scripts/verify-screenshots.mjs '<json targets>'— Playwright side-by-side screenshots of the Vue demo (:3000) vs the original AdminLTE dist (:8899), theme forced before any script runs (THEME=light|dark,WIDTH=<px>env vars). Output goes to/tmp/lte-verify. Reference screenshots live indocs/screenshots/.
Subpath static export (adminlte.io hosting)
pnpm --filter demo run export runs EXPORT=true nuxi generate → a static apps/demo/.output/public
configured for https://adminlte.io/themes/vue-nuxt/ (sibling of the React edition's
/themes/next-react/ and the v4 HTML demo at /themes/v4/). The subpath is gated behind the
EXPORT env so local pnpm dev:demo (and the plain build:demo SSR gate) stay at the domain root.
Mechanics (all in the demo; the published library is untouched):
nuxt.config.tssetsapp.baseURLto/themes/vue-nuxt/whenEXPORT=true. Nuxt'sbaseURLnatively prefixes the router (everyNuxtLink, the SSR-rendered hrefs, route payloads) and the build assets (_nuxt/) — so unlike Next we need no asset-prefix config. It also exposesruntimeConfig.public.basePath(for the shim) and a Vitedefine__ADMINLTE_BASE__(forwithBase).app/utils/withBase.tswithBase()(auto-imported) prefixes absolute paths using the compile-time__ADMINLTE_BASE__constant — a literal define, notuseRuntimeConfig, so it is safe in data arrays and template expressions and is fully inlined / no-op at the root. Applied at the source to every image that renders on initial load:DemoLayout.vue(topbar message avatars + brandlogo) and the<img src="/assets/…">inindex/index2/index3/ui/timeline/layout/logo-switch/examples/lockscreen/docs/components/main-header. Gotcha: a client shim can't fix these — the browser fetches a server-rendered<img src>before any JS runs, so the first request 404s; initial-render images must be prefixed at the source (this is the Vue analog of React'schart-data.ts). Escaped"/assets…"inside docs code-blocks are snippets, left as-is.app/plugins/subpath-links.client.tsis the idempotent after-paint shim (runs onpage:finish), prefixing the long tail of raw<a href="/…">and any stray<img src="/assets/…">. It is a safety net only — a no-op at the root. Verified:.output/publicserved at the subpath renders home, the dashboards, docs, and auth pages with 0 failed requests (370 responses across 9 pages; SPA-nav clean).- Docs site (
apps/docs) ships at/themes/vue-nuxt/docs/— the real Vue/Nuxt API docs (@nuxt/content).pnpm --filter adminlte-docs run exportrunsEXPORT=true nuxi generatewithapp.baseURLgated to/themes/vue-nuxt/docs/(dev stays/docs/). It takes over the demo's/docspath: the demo's own cloned AdminLTE docs pages are excluded from the demo deploy (tar --exclude='./docs'), and the demo's two "Documentation" links (DemoLayouttopbar +menu.ts) open it withtarget="_blank"(full page load into the separate app — not an in-SPA route). Notenuxi generatecrawls links and fails on any 404, so docs internal links must resolve. - Deploy (Hetzner,
ssh hetzner→/var/www/adminlte.io/public/themes/vue-nuxt/, ownerweb_adminlte_io:www-data, dirs 750 / files 640). Two static apps share the prefix, so deploy in two steps: (1) demo without its docs —COPYFILE_DISABLE=1 tar --exclude='./docs' -C apps/demo/.output/public -czf - . | ssh hetzner 'DEST=…/themes/vue-nuxt; rm -rf $DEST && mkdir -p $DEST && tar -C $DEST -xzf -'; (2) docs intodocs/—tar -C apps/docs/.output/public -czf - . | ssh hetzner 'tar -C …/themes/vue-nuxt/docs -xzf -'; then chown/chmod and purge Cloudflare (token + zone id in the server'swp-config.php—awk -F"'" '/CLOUDFLARE_API_TOKEN/{print \$4}'; purge the changed/old URLs, including any previously-cached cloned/docs/pages). Don't curl a URL before its files are deployed — Cloudflare caches the 404 (4h TTL). nginx (sites-enabled/adminlte.io.conf):location /themes/vue-nuxt/plus a nestedlocation /themes/vue-nuxt/docs/, eachtry_files $uri $uri/ =404with its own themed404.html, so unmatched subpaths don't fall through to WordPress.
Code style
<script setup lang="ts">SFCs.Lteprefix on every component (matches AdminLTE'slte.*runtime namespace and satisfies Vue's multi-word-name rule). Composables are unprefixeduseX.- No
<style scoped>— styling comes entirely from the prebuilt AdminLTE CSS; components are class-only. (The one exception,LteCommandPalette, uses inline:styleobjects to avoid emitting a component CSS file.) verbatimModuleSyntaxis on → useimport typefor type-only imports.- Use the
cn()/biClass()helpers insrc/lib/class-name.tsfor class composition.
Adding a component (three touch-points)
- Create the SFC under the right folder:
src/layout/widget/form/tool(core) orsrc/plugins(wraps a 3rd-party lib). - Export it from the barrel:
src/index.ts(and add it to thecomponentsmap soapp.use()registers it globally) — orsrc/plugins/index.tsfor a plugin wrapper. - Add its name to
CORE_COMPONENTS(orPLUGIN_COMPONENTS) inpackages/nuxt/src/module.tsso the Nuxt module auto-registers it.
Add public types to src/types/* (re-exported by src/types/index.ts). After any library change,
run pnpm --filter adminlte-vue type-check and pnpm build:demo.