refactor: simplify module detail page — drop template previews, inline install command

- Remove `TemplatesList` and its Shiki pre-render pipeline; `adapter` and `previews` are no longer fetched or returned from `getModuleDetail`
- Replace `CommandPreview` + `Selections` wiring in `Install` with a direct `buildAddCommand` call that takes `category` + `id`; adds `PackageManagerSelect` and `CopyableField` inline
- Add `buildAddCommand` to `selection.ts` — emits `pnpm dlx` / `bunx` / `npx stanza-cli@latest add <category> <id>` with no peer flags
- Track copy events via `@vercel/analytics` (`module_add_command_copied`)
This commit is contained in:
2026-06-13 12:42:52 -04:00
parent eee2e80b85
commit 222319cffe
8 changed files with 45 additions and 136 deletions
+1 -1
View File
@@ -4,10 +4,10 @@ import type { CategoryId, ModuleMetadata, PackageManager } from "@withstanza/sch
import { isMulti } from "@withstanza/schema";
import { startTransition, useCallback, useMemo, useOptimistic, useRef } from "react";
import { CommandPreview } from "@/components/builder/command-preview";
import { FilePreview } from "@/components/builder/file-preview";
import { ModuleCards } from "@/components/builder/module-cards";
import { ProjectSetup } from "@/components/builder/project-setup";
import { CommandPreview } from "@/components/command-preview";
import {
type BuilderSearch,
type Selections,
+22 -5
View File
@@ -1,15 +1,32 @@
import { DEFAULT_PACKAGE_MANAGER, type PackageManager } from "@withstanza/schema";
import { track } from "@vercel/analytics";
import { DEFAULT_PACKAGE_MANAGER, type CategoryId, type PackageManager } from "@withstanza/schema";
import { useState } from "react";
import { CommandPreview } from "@/components/command-preview";
import { Section } from "@/components/detail/section";
import type { Selections } from "@/lib/selection";
import { PackageManagerSelect } from "@/components/package-manager-select";
import { CopyableField } from "@/components/ui/copyable-field";
import { buildAddCommand } from "@/lib/selection";
export function Install({ name, selections }: { name: string; selections: Selections }) {
/**
* The command box for adding this module to an existing Stanza project. The
* package-manager picker swaps the dlx-style runner that fronts `stanza-cli`.
*/
export function Install({ category, id }: { category: CategoryId; id: string }) {
const [pm, setPm] = useState<PackageManager>(DEFAULT_PACKAGE_MANAGER);
const command = buildAddCommand({ category, id, pm });
return (
<Section title="Install">
<CommandPreview name={name} selections={selections} pm={pm} onPmChange={setPm} />
<div className="flex items-center gap-1.5">
<PackageManagerSelect value={pm} onValueChange={setPm} />
<CopyableField
label="Install command"
value={command}
showLabel={false}
copyLabel="Copy command"
className="flex-1"
onCopy={() => track("module_add_command_copied", { package_manager: pm, category, id })}
/>
</div>
</Section>
);
}
@@ -1,91 +0,0 @@
import type { TemplateRef } from "@withstanza/schema";
import { useMemo, useState } from "react";
import { Section, SectionList } from "@/components/detail/section";
import { useTheme } from "@/components/theme-provider";
import type { Preview } from "@/server/highlighter";
/**
* Lists the templates an adapter ships, with click-to-expand Shiki previews.
* Previews are pre-rendered server-side and keyed by `dest`.
*/
export function TemplatesList({
templates,
previews,
}: {
templates: TemplateRef[];
previews: Record<string, Preview>;
}) {
if (templates.length === 0) return null;
const sorted = templates.toSorted(
(a, b) => scopeRank(a.scope) - scopeRank(b.scope) || a.dest.localeCompare(b.dest),
);
return (
<Section title="Templates" count={templates.length}>
<SectionList>
{sorted.map((tpl) => (
<TemplateRow key={tpl.dest} template={tpl} preview={previews[tpl.dest]} />
))}
</SectionList>
</Section>
);
}
function TemplateRow({
template,
preview,
}: {
template: TemplateRef;
preview: Preview | undefined;
}) {
const [open, setOpen] = useState(false);
const hasPreview = Boolean(preview);
const previewId = `template-preview-${template.dest.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<li>
<button
type="button"
onClick={() => hasPreview && setOpen((o) => !o)}
disabled={!hasPreview}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left font-mono text-xs transition-colors hover:bg-muted/40 disabled:cursor-default disabled:text-foreground/70 disabled:hover:bg-transparent"
aria-expanded={hasPreview ? open : undefined}
aria-controls={hasPreview ? previewId : undefined}
>
<span className="truncate text-foreground">{template.dest}</span>
<span className="shrink-0 text-[10px] tracking-wider text-muted-foreground/60 uppercase">
{scopeLabel(template.scope)}
</span>
</button>
{open && preview && <PreviewBlock id={previewId} dest={template.dest} preview={preview} />}
</li>
);
}
function PreviewBlock({ id, dest, preview }: { id: string; dest: string; preview: Preview }) {
const { resolvedTheme } = useTheme();
const inner = useMemo(
() => ({ __html: resolvedTheme === "dark" ? preview.dark : preview.light }),
[preview, resolvedTheme],
);
return (
<div
id={id}
role="region"
aria-label={`Preview of ${dest}`}
className="overflow-auto border-t border-border pt-4 text-xs leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0!"
dangerouslySetInnerHTML={inner}
/>
);
}
function scopeLabel(scope: TemplateRef["scope"]): string {
if (scope === "repo") return "repo";
if (scope === "package") return "package";
return "app";
}
function scopeRank(scope: TemplateRef["scope"]): number {
if (scope === "package") return 1;
if (scope === "app") return 2;
return 0; // repo (or undefined default)
}
+17
View File
@@ -219,3 +219,20 @@ export function buildCommand(input: {
const separator = pm === "npm" ? " -- " : " ";
return `${base}${separator}${flags.join(" ")}`;
}
/**
* Build the `stanza add <category> <id>` command that adds a single module to an
* *existing* Stanza project. Unlike `buildCommand`, it carries no peer flags —
* the project's manifest already pins the peers the adapter resolves against.
* Each package manager runs the published `stanza-cli` binary via its own
* dlx-style runner.
*/
export function buildAddCommand(input: {
category: CategoryId;
id: string;
pm?: PackageManager;
}): string {
const pm = input.pm ?? DEFAULT_PACKAGE_MANAGER;
const runner = pm === "pnpm" ? "pnpm dlx" : pm === "bun" ? "bunx" : "npx";
return `${runner} stanza-cli@latest add ${input.category} ${input.id}`;
}
+3 -21
View File
@@ -2,16 +2,14 @@ import { IconArrowLeft, IconExternalLink } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import type { CategoryId } from "@withstanza/schema";
import { categoryLabel, KNOWN_CATEGORIES, PEER_CATEGORIES } from "@withstanza/schema";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { AdapterSwitcher } from "@/components/detail/adapter-switcher";
import { Install } from "@/components/detail/install";
import { DepsTable, ScriptsTable, EnvTable } from "@/components/detail/tables";
import { TemplatesList } from "@/components/detail/templates-list";
import { ModuleLogo } from "@/components/module-logo";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import type { Selections } from "@/lib/selection";
import { buildHead, getSoftwareSourceCodeJsonLd } from "@/lib/seo";
import { getModuleDetail } from "@/server/module-detail.functions";
@@ -71,7 +69,7 @@ function ModuleDetailPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { module, adapter, resolvedPeers, peerOptions, effective, previews, index } = detail;
const { module, resolvedPeers, peerOptions, effective, index } = detail;
const onPeerChange = useCallback(
(category: CategoryId, id: string) => {
@@ -84,21 +82,6 @@ function ModuleDetailPage() {
[navigate, search],
);
const templates = useMemo(() => adapter.templates ?? [], [adapter.templates]);
// Build the install selection: the current module + its resolved peers, as
// arrays (the unified selection shape). CommandPreview turns this into the
// package-manager-specific command string (`--framework=next --testing=vitest`).
const selections = useMemo<Selections>(() => {
const out: Selections = {};
for (const category of KNOWN_CATEGORIES) {
const id = resolvedPeers[category];
if (id !== undefined) out[category] = [id];
}
out[module.category] = [module.id];
return out;
}, [resolvedPeers, module.category, module.id]);
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<div className="mb-6">
@@ -161,8 +144,7 @@ function ModuleDetailPage() {
<DepsTable title="Dev Dependencies" entries={effective.devDependencies} />
<EnvTable env={effective.env} />
<ScriptsTable entries={effective.scripts} />
<TemplatesList templates={templates} previews={previews} />
<Install name="my-app" selections={selections} />
<Install category={module.category} id={module.id} />
</div>
</div>
);
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Client-safe types for the syntax-highlighter. The Shiki runtime that
* produces these lives in `highlighter.server.ts` (server-only). Keeping the
* `Preview` type here lets client components (`file-preview.tsx`,
* `templates-list.tsx`) import it without pulling Shiki into the browser bundle.
* `Preview` type here lets client components (`file-preview.tsx`) import it
* without pulling Shiki into the browser bundle.
*/
export type Preview = { light: string; dark: string };
@@ -17,8 +17,6 @@ import {
PEER_CATEGORIES,
} from "@withstanza/schema";
import type { Preview } from "@/server/highlighter";
import { renderPreview } from "@/server/highlighter.server";
import { loadRegistryFile } from "@/server/registry-base.server";
/** Typed `Object.keys` for a partial category-record (avoids a key-widening cast). */
@@ -46,8 +44,6 @@ export type EffectiveInstallFields = {
export type ModuleDetail = {
module: Module;
/** Adapter the resolver picked, given the (possibly auto-defaulted) peers. */
adapter: ModuleAdapter;
/**
* Peer assignments actually used for resolution — explicit + auto-defaults.
* The detail page renders the switcher with these values pre-selected so the
@@ -58,8 +54,6 @@ export type ModuleDetail = {
peerOptions: Partial<Record<CategoryId, string[]>>;
/** Module-level + adapter-level fields merged with adapter-wins semantics. */
effective: EffectiveInstallFields;
/** Pre-rendered Shiki HTML for each template in the resolved adapter, keyed by `dest`. */
previews: Record<string, Preview>;
/** Light index of every module so client UI can label peer chips, etc. */
index: RegistryIndex;
};
@@ -90,21 +84,11 @@ export const getModuleDetail = createServerFn({ method: "GET" })
const adapter = pickAdapter(module, resolvedPeers);
const effective = effectiveInstallFields(module, adapter);
const previewEntries = await Promise.all(
(adapter.templates ?? []).map(async (tpl) => {
const content = tpl.content ?? "";
const preview = await renderPreview(content, tpl.dest);
return [tpl.dest, preview] as const;
}),
);
return {
module,
adapter,
resolvedPeers,
peerOptions,
effective,
previews: Object.fromEntries(previewEntries),
index,
};
});