Docs Architecture
This page documents how the docs site itself works — useful if you’re contributing to LoomiUI, debugging a broken preview, or setting up deployment. If you just want to use LoomiUI, you don’t need any of this — see Installation instead.
The problem this solves
Section titled “The problem this solves”Every component page on this site shows a live, interactive rendering of each code
example, not a screenshot — directly above the code that produces it. The challenge:
LoomiUI’s components were never published to npm (yet — see
below), and this docs site has no bundler step of
its own. So how does <loomi-button> actually render in the browser on a static page?
The pipeline
Section titled “The pipeline”Directorycomponents/ (the component monorepo, sibling to this project)
Directorypackages/
Directorybutton/
- dist/loomi-button.js (compiled, but still has bare imports like
from "lit") - README.md (source of truth for the docs page)
- dist/loomi-button.js (compiled, but still has bare imports like
- core/, icons/, theme/, grouping packages, … (other packages)
Directorydocs/ (this project)
Directoryscripts/
- loomiui-packages.mjs (the category map — single source of truth, step 0)
- copy-component-assets.mjs (step 1)
- gen-component-docs.mjs (step 3)
- astro.config.mjs (step 2 — the import map)
- src/components/Head.astro (registers a few LoomiUI components site-wide)
Directorypublic/loomi/<name>/dist/ (generated — step 1’s output)
- …
- src/content/docs/components/<name>.md (generated, flat, one per component — step 3’s output)
- Build the monorepo.
components/builds generated CSS/tokens/manifests where a package needs them, then compiles withtsc— still no bundler — sodist/loomi-button.jsstill contains bare specifiers likefrom "lit"andfrom "@loomidev/core", exactly like what a real npm install would give you. - Copy compiled assets.
copy-component-assets.mjscopies each package’sdist/*.js(filtering out.d.ts/.map— not needed in the browser) intodocs/public/loomi/<name>/dist/, turning them into plain static files this project can serve. - Declare a browser import map.
astro.config.mjsinjects a<script type="importmap">(via Starlight’sheadconfig) that maps every bare specifier a component might use to those static paths —@loomidev/button→/loomi/button/dist/index.js, plus a prefix mapping@loomidev/checkbox/→/loomi/checkbox/dist/for sibling-package subpath imports (<loomi-table>imports<loomi-checkbox>directly). The external runtime importlitmaps to a CDN — exactly what a consumer who hasn’t installed LoomiUI locally would load. - Generate the doc pages.
gen-component-docs.mjsreads each component’s publishedREADME.md, and wraps every fencedhtmlcode block in a<div class="loomi-preview">containing the same markup, plus one<script type="module">import "@loomidev/<name>";</script>per page.
Because Astro’s Markdown renderer passes raw HTML through untouched, that script tag and
those custom-element tags just work — no MDX, no per-page bundling. The browser resolves
@loomidev/button via the import map, the module evaluates, customElements.define()
fires, and every matching tag already in the DOM upgrades automatically (module scripts
are deferred by default, so the timing always works out regardless of where the script
tag sits on the page).
Regenerate before you serve or build. Run
node scripts/copy-component-assets.mjs and node scripts/gen-component-docs.mjs
after rebuilding the component monorepo. The docs package.json currently keeps
pnpm dev and pnpm build as plain Astro commands, so these scripts are not hidden
behind lifecycle hooks.
A real bug this pipeline caught (and its proper fix)
Section titled “A real bug this pipeline caught (and its proper fix)”LoomiUI components default to light-mode token values unless a consumer explicitly
opts into dark mode via <loomi-theme-switcher>. Early on, the preview surface adapted
to Starlight’s own dark/light toggle with nothing else — which made dark-text
components (a secondary-outline button, for instance) unreadable when the site was
dark but the component was still rendering its light-mode default colors.
The first fix was to hardcode the preview surface to a fixed light background regardless
of the site’s theme — safe, but it meant previews never looked dark even when the rest
of the site did. The actual fix, in src/styles/custom.css, is to give previews a real
dark theme: when :root[data-theme="dark"], .loomi-preview overrides the public
--loomi-white/--loomi-gray-*/--loomi-secondary-*/--loomi-black-* slots with an
inverted ramp (50↔900, 100↔800, …, using the real default oklch values from
@loomidev/theme) — the exact mechanism a real consumer’s own :root dark-mode CSS
would use, just scoped to the preview box instead of the whole page.
src/components/Pagination.astro’s <loomi-card> footer links share the identical
override (:is(.loomi-preview, .loomi-pagi-card) in custom.css, since a plain global
stylesheet can target a class from any component regardless of which file defines it).
Dogfooding LoomiUI in the docs site’s own chrome
Section titled “Dogfooding LoomiUI in the docs site’s own chrome”Beyond the live previews, this site uses LoomiUI’s own components for parts of its UI that Starlight would otherwise render with plain HTML or its own built-in components:
| Where | Starlight default | What this site uses instead |
|---|---|---|
| Prev/next footer links | Plain <a> cards | <loomi-card> (see src/components/Pagination.astro) |
| Callouts/emphasis | <Aside> | <loomi-alert> |
| Buttons and links | Plain <button>/<a> elements | <loomi-button> |
This works because <loomi-card>, <loomi-alert>, and <loomi-button> are real custom
elements, not Astro/JSX components — they render anywhere raw HTML is allowed, including
inside a component override like Pagination.astro (where the script registering them
must be marked is:inline, so Astro emits it untouched instead of trying to resolve
@loomidev/card through Node module resolution — see src/components/Head.astro).
FileTree above stays a Starlight component since LoomiUI has no file-tree equivalent —
nothing wrong with mixing the two where it makes sense.
Why Starlight
Section titled “Why Starlight”Astro ships zero JavaScript by default and renders custom
elements natively — no wrapper, no adapter, no virtual DOM reconciling against elements
it doesn’t own. That matters specifically for LoomiUI: a React-based docs framework
(Docusaurus, Next.js) would need an interop layer just to drop <loomi-button> into a
page; Astro just emits the HTML and lets the browser do what browsers already do with
custom elements.
Starlight is Astro’s official docs theme, and it gave us — for free — exactly the shape this site needed:
- Sidebar navigation with autogenerated sections (the “Components” group lists
every
.mdfile insrc/content/docs/components/automatically, alphabetically by slug — add a file, it appears in the nav, no config edit). Starlight’s sidebar groups stay collapsible; the docs site only defines the group labels and lets Starlight handle the interaction. - Built-in search (Pagefind, generated at build time, no external service)
- Light/dark mode toggle, with a
customCsshook for the LoomiUI brand accent and dedicated light/dark logo variants - A component override system (
components: { Head, Pagination }inastro.config.mjs) that let us swap in LoomiUI’s own components without forking Starlight FileTreefor this page’s directory diagram, and Expressive Code (see below) for every fenced code block
The alternative seriously considered was Storybook, which has first-class web
component support and would auto-generate prop tables from a
custom-elements-manifest. It’s the better choice if you want a dedicated
controls-panel-style playground per component. We went with Astro + Starlight because
the ask was a documentation site (narrative pages, installation guides, an MCP server
page) with previews embedded in context — not a component-isolation tool.
Code blocks & line numbers
Section titled “Code blocks & line numbers”Every fenced code block on this site is rendered by Expressive Code
(astro-expressive-code), which Starlight bundles and configures automatically — it’s
what gives every snippet syntax highlighting, the copy-to-clipboard button, frame/title
support, and the red/green diff highlighting used in the npm-migration examples below.
Line numbers are not part of Expressive Code’s core — they’re the separate
@expressive-code/plugin-line-numbers
plugin, registered in astro.config.mjs:
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
starlight({ expressiveCode: { plugins: [pluginLineNumbers()], defaultProps: { showLineNumbers: true }, },});defaultProps: { showLineNumbers: true } turns them on for every code block site-wide.
To opt a single block out, add showLineNumbers=false to its fence’s meta string:
```js showLineNumbers=false// no line numbers on this one```Why some pages are .mdx and others are .md
Section titled “Why some pages are .mdx and others are .md”Astro/Starlight’s own components (FileTree, Card/CardGrid, and the rest of
@astrojs/starlight/components) only work via an import statement and JSX-like usage
(<FileTree>...</FileTree>) — both of which only work in .mdx files. Plain .md
files are parsed as standard Markdown: they don’t execute import statements or recognize
JSX tags as components.
loomi’s own components are different — <loomi-alert>, <loomi-button>, <loomi-card>,
and the rest are real custom elements, not Astro components. They don’t need an import
statement at all; they just need raw HTML to pass through, which plain .md already
does. So the only thing that actually forces a page to be .mdx here is using a
Starlight component:
| File | Extension | Why |
|---|---|---|
architecture.mdx (this page) | .mdx | Uses FileTree |
theming.mdx, mcp-server.mdx | .mdx | No Starlight components left after the dogfooding pass above — kept .mdx anyway since renaming buys nothing (the URL slug doesn’t include the extension) |
All generated components/**/*.md pages, installation.md | .md | Only ever needed raw <script> and custom-element tags |
If you add a Starlight component (FileTree, Card, etc.) to a page that’s currently
.md, you must rename it to .mdx first — the import line will otherwise render as
literal text and the component tags as plain unstyled HTML. (This exact mistake happened
once while building this site, with Tabs/Aside before they were swapped out for
LoomiUI’s own components — see the early git history on theming.mdx for the
cautionary tale.) Adding a LoomiUI component needs no such rename.
What changes once LoomiUI is on npm
Section titled “What changes once LoomiUI is on npm”Right now, “installing” LoomiUI for the docs site means copying compiled files directly
out of the sibling components/ monorepo. Once the packages are published to npm, the
pipeline gets simpler, not more complex.
Update copy-component-assets.mjs
Section titled “Update copy-component-assets.mjs”Change the copy source from the monorepo to node_modules:
const PACKAGES = resolve(__dirname, "../../components/packages");// After publishing: install @loomidev/* as real npm dependencies in docs/package.json,// then copy from node_modules instead of the sibling monorepo.const PACKAGES = resolve(__dirname, "../node_modules/@loomidev");docs/package.json would list each @loomidev/* package (or just @loomidev/components,
the umbrella) as a normal dependency — no more relying on a sibling checkout existing
at all.
Or skip copying entirely
Section titled “Or skip copying entirely”Point the import map straight at a CDN instead of local static files:
"@loomidev/button": "https://esm.sh/@loomidev/button@0.1.0","@loomidev/button/": "https://esm.sh/@loomidev/button@0.1.0/",This removes copy-component-assets.mjs and the ~900 KB public/loomi/ payload
entirely. The tradeoff: previews now depend on the CDN being up, instead of working
fully offline. Pin an exact version (read from each package’s package.json at
generation time) so a docs deploy never silently picks up an unreleased breaking change.
Update gen-component-docs.mjs
Section titled “Update gen-component-docs.mjs”READMEs can also come from node_modules instead of the monorepo:
const readmePath = resolve(PACKAGES, name, "README.md");const readmePath = resolve(__dirname, "../node_modules/@loomidev", name, "README.md");npm packages typically ship their README, so this works for free once published.
scripts/loomiui-packages.mjs (the category map) stays manually maintained either way —
npm has no concept of “which sidebar group does this package belong to.”
The big-picture change: this docs site currently depends on components/ being a
sibling directory on disk. Once LoomiUI is published, that dependency disappears — the
docs site only needs its own package.json and the npm registry. That also simplifies
CI (see below): no more checking out two directories from one (or two) repos.
Automatic deployment from GitHub
Section titled “Automatic deployment from GitHub”Both components/ and docs/ are git repositories with separate remotes:
git@github.com:loomiui/loomiui.git for the component monorepo and
git@github.com:loomiui/loomiui-docs.git for this docs site. Here’s what CI needs to
do, depending on the layout.
Same repo (monorepo)
Section titled “Same repo (monorepo)”If loomiui/ becomes one git repo containing both components/ and docs/, CI is
simplest: one checkout gets you everything gen-component-docs.mjs and
copy-component-assets.mjs need. (This would mean re-initializing as a single repo,
since git can’t nest one repo inside another’s working tree.)
name: Deploy docson: push: branches: [main]jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: "pnpm" } - name: Build components monorepo run: pnpm --dir components install && pnpm --dir components build - name: Build docs run: pnpm --dir docs install && pnpm --dir docs build # then deploy docs/dist — see the hosting options belowSeparate repos
Section titled “Separate repos”This is the current layout: components/ and docs/ are each their own repo (the
former already had real history — CI workflows, Changesets, a test suite — that
splitting into one repo would have meant discarding). The docs repo’s workflow needs an
extra checkout step for the components repo before running its build, since
copy-component-assets.mjs and gen-component-docs.mjs read from
../components/packages/*:
steps: - uses: actions/checkout@v4 # checks out the docs repo itself with: path: docs - uses: actions/checkout@v4 with: repository: loomiui/loomiui path: components # checked out as a sibling directory - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: "pnpm" } - run: pnpm --dir components install && pnpm --dir components build - run: pnpm --dir docs install && pnpm --dir docs buildThis cross-repo checkout requirement is exactly what disappears once LoomiUI is published to npm — at that point the docs repo only needs itself.
Where to actually deploy
Section titled “Where to actually deploy”Vercel / Netlify / Cloudflare Pages
Section titled “Vercel / Netlify / Cloudflare Pages”The least setup: connect the GitHub repo in the host’s dashboard, set the build command
to pnpm build and the output directory to dist for the standalone docs repo (or
pnpm --dir docs build and docs/dist for a monorepo checkout). These hosts
auto-detect Astro, build on every push to main, and give you preview deployments on
every pull request for free — no workflow YAML required at all. With the current
separate-repos layout, you’d need the host’s build to also fetch components/ as a
sibling directory before building docs (most hosts support a build-time clone step or a
monorepo “included repos” setting) — this is the main reason the monorepo layout is
simpler for hosts that don’t run arbitrary multi-repo checkout logic.
GitHub Pages
Section titled “GitHub Pages”Requires an explicit Actions workflow (GitHub Pages doesn’t run your build for you). Add to the workflow from the options above:
- uses: actions/upload-pages-artifact@v3 with: { path: docs/dist } # or dist when the docs repo is checked out at the root - uses: actions/deploy-pages@v4You’ll also need id-token: write and pages: write permissions on the job, and to
enable Pages → “GitHub Actions” as the source in the repo settings. Since this site uses
a custom domain, add a docs/public/CNAME file containing loomiui.com so GitHub Pages
serves the right host, and keep site: "https://loomiui.com" in astro.config.mjs as
is (no base path needed, since a custom domain serves from the root).
DNS. Whichever host you pick, pointing loomiui.com at it is a DNS
change outside of GitHub/CI — typically a CNAME record (or A/ALIAS for an apex
domain) at your domain registrar pointing at the host’s provided target.