Skip to content

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.

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?

  • 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)
      • 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)
  1. Build the monorepo. components/ builds generated CSS/tokens/manifests where a package needs them, then compiles with tsc — still no bundler — so dist/loomi-button.js still contains bare specifiers like from "lit" and from "@loomidev/core", exactly like what a real npm install would give you.
  2. Copy compiled assets. copy-component-assets.mjs copies each package’s dist/*.js (filtering out .d.ts/.map — not needed in the browser) into docs/public/loomi/<name>/dist/, turning them into plain static files this project can serve.
  3. Declare a browser import map. astro.config.mjs injects a <script type="importmap"> (via Starlight’s head config) 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 import lit maps to a CDN — exactly what a consumer who hasn’t installed LoomiUI locally would load.
  4. Generate the doc pages. gen-component-docs.mjs reads each component’s published README.md, and wraps every fenced html code 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:

WhereStarlight defaultWhat this site uses instead
Prev/next footer linksPlain <a> cards<loomi-card> (see src/components/Pagination.astro)
Callouts/emphasis<Aside><loomi-alert>
Buttons and linksPlain <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.

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 .md file in src/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 customCss hook for the LoomiUI brand accent and dedicated light/dark logo variants
  • A component override system (components: { Head, Pagination } in astro.config.mjs) that let us swap in LoomiUI’s own components without forking Starlight
  • FileTree for 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.

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:

FileExtensionWhy
architecture.mdx (this page).mdxUses FileTree
theming.mdx, mcp-server.mdx.mdxNo 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.mdOnly 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.

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.

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.

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.

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.

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.

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.)

.github/workflows/deploy.yml
name: Deploy docs
on:
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 below

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 build

This cross-repo checkout requirement is exactly what disappears once LoomiUI is published to npm — at that point the docs repo only needs itself.

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.

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@v4

You’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.