Back to Plate

Shadcn registry install commands should use configured namespaces

docs/solutions/developer-experience/2026-05-24-shadcn-registry-install-commands-should-use-configured-namespaces.md

53.0.88.1 KB
Original Source

Shadcn registry install commands should use configured namespaces

Problem

Plate templates already configure the @plate registry namespace, but several docs and block surfaces still displayed or copied raw registry URLs such as http://localhost:3000/rd/table-kit.

That mixed two different contracts: raw /r/* URLs are good for resolvable registry content, while user install commands should use the shadcn namespace syntax that components.json provides.

Symptoms

  • ComponentInstallation, ComponentSource, block preview toolbars, block metadata, and MCP setup each assembled their own npx shadcn@latest add ... command.
  • The displayed button text said npx shadcn@latest add {name} while the copied value used a raw registry URL.
  • The MCP dialog told users to add @plate to components.json, then initialized from a raw siteConfig.registryUrl.
  • build-registry.mts and build-docs-registry.mts still expanded Plate self-dependencies into https://platejs.org/r/*.json.
  • update-template.sh --local uses shadcn local-file mode from a prepared JSON mirror, so changing generated dependencies to @plate/* without rewriting the local mirror would accidentally send local template sync back through the configured remote registry.
  • Opening the MCP dialog in browser smoke logged Radix's missing description warning.
  • BlockViewer loaded only the first registry source file in compiled MDX, so removing lazy hydration made non-initial code-tab files blank.
  • OpenInV0Button was no longer rendered, but the file and a commented toolbar block still pointed at v0.dev.

What Didn't Work

  • Treating siteConfig.registryUrl as the general install-command source. That URL is still needed for LLM context, v0 URLs, and direct registry content links, but it is the wrong default for shadcn install UX once a namespace exists.
  • Hand-editing templates. The template components.json files were already correct and tooling/scripts/update-template.sh already defaults to @plate.
  • Changing public registry output to @plate/* without updating prepare-local-template-registry.mjs. Local-file template sync needs file names such as toolbar.json, not namespaced remote specifiers.
  • Ignoring browser smoke because the command text was easy to inspect statically. The smoke found the separate MCP dialog description warning that static grep would miss.
  • Baking every highlighted registry file into MDX. That fixes the blank-tab data gap in isolation, but compiled docs chunks balloon and cold Turbopack route compilation gets slow enough to look hung.
  • Leaving dead v0 code commented out. v0 is a discarded upstream surface for Plate's restart; dead references are not harmless when they preserve a wrong product direction.

Solution

Centralize the visible install command contract:

ts
const absoluteUrlPattern = /^https?:\/\//;

export function getRegistryItemSpecifier(name: string) {
  const item = name.trim();

  if (
    item.startsWith('@') ||
    item.startsWith('/') ||
    item.startsWith('./') ||
    item.startsWith('../') ||
    absoluteUrlPattern.test(item)
  ) {
    return item;
  }

  return `@plate/${item}`;
}

export function getRegistryInstallCommand(name: string) {
  return `npx shadcn@latest add ${getRegistryItemSpecifier(name)}`;
}

Then use that helper in every user-facing install surface:

  • registry docs installation blocks
  • component source copy buttons
  • block preview and block viewer toolbars
  • MCP setup
  • block metadata descriptions

For generated registry output, use the same namespace boundary for Plate self-dependencies:

ts
export function toRegistryDependencySpecifier(dependency: string) {
  if (isDirectDependencySpecifier(dependency)) {
    return dependency;
  }

  return `@plate/${dependency}`;
}

Use it from both registry builders:

  • apps/www/scripts/build-registry.mts
  • apps/www/scripts/build-docs-registry.mts

Then keep local template sync local by rewriting Plate namespace dependencies in the prepared mirror:

js
if (dependency.startsWith('@plate/')) {
  return `${dependency.slice('@plate/'.length)}.json`;
}

Keep raw registry URLs where the user or tool needs a URL that resolves directly:

  • LLM context links
  • /r/{name} registry content links
  • local registry debugging scripts

For the MCP dialog warning, add a real DialogDescription inside the dialog header instead of using an unassociated paragraph.

For source preview, keep compiled MDX small and hydrate full code only when the user opens the Code tab:

ts
const item = await getRegistryItem(name, true);

Use the prefetched item for the initial preview, then let BlockViewer fetch /api/registry-source/[name] when later files have no content. The route calls getRegistryItem(name) and highlightFiles(item.files), so copy buttons and non-initial code tabs receive the full highlighted payload without putting that payload into every MDX chunk. Keep this route scoped to docs code-view source; public registry install surfaces stay on /r, /rd, @plate/*, and /init.

Delete v0-only UI instead of keeping commented code. Plate owns a registry around the shadcn contract; it does not need upstream's v0 open flow.

Why This Works

Shadcn owns the installer behavior and namespace resolver. Plate owns the registry content and template config.

Once components.json contains:

json
{
  "registries": {
    "@plate": "https://platejs.org/r/{name}.json"
  }
}

the installer command should be npx shadcn@latest add @plate/table-kit. The CLI resolves the namespace to the URL template, so docs do not need to expose raw registry URLs as the primary install path.

Keeping raw URLs only for direct content links avoids breaking tools that need actual fetchable registry JSON.

For template local sync, the prepared mirror is the boundary. Public/dev registry JSON can use the upstream namespace contract, then prepare-local-template-registry.mjs converts @plate/foo to foo.json before shadcn local-file install runs.

Lazy source hydration is the right boundary for MDX-authored ComponentPreview blocks. The page HTML carries enough data for the initial file and tree, while the static registry route carries the expensive full highlighted file list only after the Code tab needs it.

Prevention

  • Before changing registry install UX, check both template components.json and tooling/scripts/update-template.sh; if they already use @plate, update display/copy surfaces instead of template output.
  • Before changing generated registryDependencies, check tooling/scripts/prepare-local-template-registry.mjs too. Namespace output and local-file sync are coupled.
  • Search visible command text separately from raw registry content links:
bash
rg -n 'siteConfig\.registryUrl|npx shadcn@latest add http|npx shadcn@latest add https|npx shadcn@latest add \{name\}' apps/www/src
  • Browser-smoke at least one registry docs page, one plugin docs page with ComponentSource, the MCP dialog, and block metadata after changing install command text.
  • Search for v0 residue and accidental full-MDX hydration before finishing shadcn-base work:
bash
rg -n 'OpenInV0|v0\\.dev|getRegistryItem\\(name\\)' apps/www/src
  • Browser-smoke a ComponentPreview page by selecting a non-initial file in the Code tab and copying it; the clipboard should contain real source, not an empty string.
  • Add or keep a pure resolver test for namespace output and local mirror rewriting, then let pnpm --filter www typecheck run check-docs-source-parity.mts against the docs registry aggregate dependencies.
  • If full pnpm check fails only on test:slowest fast-suite timing, rerun pnpm test:slowest once before moving tests. A clean isolated rerun points to machine load, not a real test classification fix.