mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user