2162 lines
86 KiB
JavaScript
2162 lines
86 KiB
JavaScript
import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||
import { dirname, join } from "node:path";
|
||
import process from "node:process";
|
||
import { fileURLToPath } from "node:url";
|
||
import { eventDescriptionFor, profileFor } from "./showcase-profiles.mjs";
|
||
|
||
const exampleRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const workspaceRoot = join(exampleRoot, "..", "..");
|
||
const referencePath = join(workspaceRoot, "packages", "ui", "component-reference.json");
|
||
const pagesDir = join(exampleRoot, "app", "pages");
|
||
const detailPagesDir = join(pagesDir, "components");
|
||
const layoutsDir = join(exampleRoot, "app", "layouts");
|
||
const reference = JSON.parse(readFileSync(referencePath, "utf8"));
|
||
|
||
const interactiveOverlayComponents = new Set([
|
||
"ContextMenu",
|
||
"Drawer",
|
||
"Dropdown",
|
||
"Modal",
|
||
"Popover",
|
||
"Tooltip",
|
||
]);
|
||
|
||
const overlayVisibilityProps = new Set(["open", "defaultopen", "visible"]);
|
||
|
||
function isInteractiveOverlay(component) {
|
||
return interactiveOverlayComponents.has(component.name);
|
||
}
|
||
|
||
function overlayInteractionLabel(component) {
|
||
switch (component.name) {
|
||
case "ContextMenu":
|
||
return "Right-click or use the trigger button";
|
||
case "Tooltip":
|
||
return "Hover, focus, or click the trigger";
|
||
case "Drawer":
|
||
return "Click the trigger to open the drawer";
|
||
case "Modal":
|
||
return "Click the trigger to open the modal";
|
||
case "Dropdown":
|
||
return "Click the trigger to open the menu";
|
||
case "Popover":
|
||
return "Click the trigger to open the popover";
|
||
default:
|
||
return "Interact with the trigger";
|
||
}
|
||
}
|
||
|
||
const displayName = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||
const baseSlugOf = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||
const slugCounts = new Map();
|
||
for (const component of reference.components) {
|
||
const slug = baseSlugOf(component.name);
|
||
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
|
||
}
|
||
const slugOf = (name) => {
|
||
const slug = baseSlugOf(name);
|
||
if (slugCounts.get(slug) === 1) return slug;
|
||
const component = reference.components.find((entry) => entry.name === name);
|
||
return `${slug}-${component?.category ?? "component"}`;
|
||
};
|
||
const titleCase = (value) =>
|
||
value
|
||
.split("-")
|
||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||
.join(" ");
|
||
const identifier = (value) =>
|
||
value.replace(/(^|[^A-Za-z0-9_])([A-Za-z0-9])/g, (_, _sep, char) => char.toUpperCase());
|
||
const escapeText = (value) =>
|
||
String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||
// WRN uses braces for reactive expressions. Encode braces inside documentation
|
||
// code so hydration keeps them as literal, copyable source text.
|
||
const escapeCode = (value) => escapeText(value).replaceAll("{", "{").replaceAll("}", "}");
|
||
const escapeAttribute = (value) =>
|
||
escapeText(value).replaceAll('"', """).replaceAll("'", "'");
|
||
|
||
function sampleItem(component, index, variation) {
|
||
const number = index + 1;
|
||
const labels = ["Primary workflow", "Secondary workflow", "Advanced workflow"];
|
||
return {
|
||
id: `${slugOf(component.name)}-${variation}-${number}`,
|
||
key: `${slugOf(component.name)}-${variation}-${number}`,
|
||
value: index === 0 ? "primary" : "secondary",
|
||
label: index === 0 ? labels[variation] : "Additional option",
|
||
title: index === 0 ? labels[variation] : "Supporting example",
|
||
description:
|
||
variation === 0
|
||
? "A clean default configuration for everyday product interfaces."
|
||
: variation === 1
|
||
? "A compact configuration designed for dense application layouts."
|
||
: "A richer configuration with more supporting information and actions.",
|
||
text: index === 0 ? "A configurable sample message." : "Another configurable message.",
|
||
href: `#${slugOf(component.name)}-demo`,
|
||
actionHref: `#${slugOf(component.name)}-demo`,
|
||
actionLabel: variation === 2 ? "Explore workflow" : "View details",
|
||
icon:
|
||
variation === 0
|
||
? "icon-[lucide--sparkles]"
|
||
: variation === 1
|
||
? "icon-[lucide--zap]"
|
||
: "icon-[lucide--circle-check]",
|
||
iconClass:
|
||
variation === 0
|
||
? "icon-[lucide--sparkles]"
|
||
: variation === 1
|
||
? "icon-[lucide--zap]"
|
||
: "icon-[lucide--circle-check]",
|
||
variant: variation === 0 ? "primary" : variation === 1 ? "secondary" : "success",
|
||
status: variation === 2 ? "Complete" : "Active",
|
||
time: variation === 0 ? "09:30" : variation === 1 ? "10:15" : "11:45",
|
||
current: index === 0,
|
||
selected: index === 0,
|
||
checked: index === 0,
|
||
disabled: variation === 2 && index === 1,
|
||
outgoing: index === 1,
|
||
open: true,
|
||
number,
|
||
count: number * (variation + 2) * 8,
|
||
percentage: variation === 0 ? 72 : variation === 1 ? 48 : 91,
|
||
color: variation === 0 ? "#7c3aed" : variation === 1 ? "#0284c7" : "#059669",
|
||
target: "_self",
|
||
ariaLabel: `Open ${labels[variation].toLowerCase()} ${number}`,
|
||
items: [
|
||
{ label: "Nested option A", value: "nested-a" },
|
||
{ label: "Nested option B", value: "nested-b" },
|
||
],
|
||
links: [
|
||
{ label: "Documentation", href: "#documentation" },
|
||
{ label: "API reference", href: "#api-reference" },
|
||
],
|
||
values: ["Included", variation === 2 ? "Unlimited" : "Standard"],
|
||
};
|
||
}
|
||
|
||
function sampleArray(prop, component, variation) {
|
||
const name = prop.name.toLowerCase();
|
||
if (name.includes("chartlabel") || name === "labels")
|
||
return variation === 1 ? ["Q1", "Q2", "Q3", "Q4"] : ["Mon", "Tue", "Wed", "Thu", "Fri"];
|
||
if (name === "data" || name.includes("series"))
|
||
return variation === 0
|
||
? [18, 32, 27, 48, 64]
|
||
: variation === 1
|
||
? [64, 51, 43, 35]
|
||
: [24, 46, 72, 88];
|
||
if (name === "pages")
|
||
return [
|
||
{ number: 1, href: "#page-1", current: variation === 0, ellipsis: false },
|
||
{ number: 2, href: "#page-2", current: variation !== 0, ellipsis: false },
|
||
{ number: 3, href: "#page-3", current: false, ellipsis: false },
|
||
];
|
||
if (name === "plans")
|
||
return [
|
||
{ name: "Starter", label: "Starter", value: "starter" },
|
||
{ name: "Pro", label: "Pro", value: "pro" },
|
||
{ name: "Scale", label: "Scale", value: "scale" },
|
||
];
|
||
if (name === "features")
|
||
return [
|
||
{ label: "Team members", values: ["5", "Unlimited", "Unlimited"] },
|
||
{ label: "Support", values: ["Email", "Priority", "Dedicated"] },
|
||
{ label: "Audit history", values: ["7 days", "90 days", "Custom"] },
|
||
];
|
||
if (name === "logos")
|
||
return ["Acme", "Northstar", "Vertex", "Orbit"].map((logo, index) => ({
|
||
name: logo,
|
||
alt: `${logo} logo`,
|
||
href: `#${slugOf(component.name)}-demo`,
|
||
external: false,
|
||
src: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 36'%3E%3Crect width='120' height='36' rx='8' fill='%23${variation === 2 ? "059669" : variation === 1 ? "0284c7" : "7c3aed"}'/%3E%3Ctext x='60' y='23' text-anchor='middle' font-family='Arial' font-size='13' fill='white'%3E${logo}%3C/text%3E%3C/svg%3E`,
|
||
order: index,
|
||
}));
|
||
if (name.includes("navigationcolumns"))
|
||
return [
|
||
{
|
||
title: "Product",
|
||
links: [
|
||
{ label: "Overview", href: "#overview" },
|
||
{ label: "Pricing", href: "#pricing" },
|
||
],
|
||
},
|
||
{
|
||
title: "Resources",
|
||
links: [
|
||
{ label: "Guides", href: "#guides" },
|
||
{ label: "Support", href: "#support" },
|
||
],
|
||
},
|
||
];
|
||
if (name === "sections")
|
||
return [
|
||
{
|
||
label: "Product",
|
||
value: "product",
|
||
description: "Product navigation examples.",
|
||
items: [
|
||
{ label: "Platform", href: "#platform", description: "Explore the platform." },
|
||
{ label: "Security", href: "#security", description: "Review security controls." },
|
||
],
|
||
},
|
||
{
|
||
label: "Developers",
|
||
value: "developers",
|
||
description: "Developer resources.",
|
||
items: [{ label: "Documentation", href: "#docs", description: "Read the guides." }],
|
||
},
|
||
];
|
||
if (name === "toasts")
|
||
return [
|
||
{
|
||
title: variation === 2 ? "Deployment complete" : "Changes saved",
|
||
description:
|
||
variation === 2
|
||
? "The production release completed successfully."
|
||
: "Your settings were saved successfully.",
|
||
variant: "success",
|
||
icon: "icon-[lucide--circle-check]",
|
||
open: true,
|
||
dismissible: true,
|
||
dismissLabel: "Dismiss notification",
|
||
},
|
||
];
|
||
if (name === "stages")
|
||
return [
|
||
{
|
||
title: "Event received",
|
||
description: "A customer event started the flow.",
|
||
icon: "icon-[lucide--webhook]",
|
||
},
|
||
{
|
||
title: "Delivery",
|
||
description: "Eligible channels are selected.",
|
||
icon: "icon-[lucide--send]",
|
||
items: [{ label: "Email" }, { label: "SMS" }],
|
||
},
|
||
];
|
||
return [sampleItem(component, 0, variation), sampleItem(component, 1, variation)];
|
||
}
|
||
|
||
function defaultValue(prop) {
|
||
if (prop.default === null) return undefined;
|
||
if (prop.default === "true") return true;
|
||
if (prop.default === "false") return false;
|
||
if (/^-?\d+(?:\.\d+)?$/.test(prop.default ?? "")) return Number(prop.default);
|
||
if (/^".*"$/.test(prop.default ?? "")) {
|
||
try {
|
||
return JSON.parse(prop.default);
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function sampleValue(prop, component, variation) {
|
||
const name = prop.name.toLowerCase();
|
||
if (component.name === "AdvancedSelect") {
|
||
const configuration = advancedSelectConfiguration(variation);
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (component.name === "ComboBox") {
|
||
const configuration = comboboxConfiguration(variation);
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (component.name === "PinInput") {
|
||
const configuration = pinInputConfiguration(variation);
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (component.name === "StrongPassword") {
|
||
const configuration = strongPasswordConfiguration(variation);
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (component.name === "TogglePassword") {
|
||
const configuration = togglePasswordConfiguration(variation);
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (component.name === "InputNumber") {
|
||
const configuration = inputNumberConfiguration(variation);
|
||
|
||
if (Object.hasOwn(configuration, prop.name)) {
|
||
return configuration[prop.name];
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
const isArray = prop.default === "[]" || prop.type.includes("[]") || prop.type === "array";
|
||
const isObject = prop.default === "{}" || prop.type === "object";
|
||
if (isArray) return sampleArray(prop, component, variation);
|
||
if (isObject) return sampleItem(component, 0, variation);
|
||
|
||
if (name === "class") return `showcase-instance showcase-instance--${variation + 1}`;
|
||
if (component.name === "Button") {
|
||
const configurations = [
|
||
{
|
||
label: "Save & Submit",
|
||
as: "button",
|
||
type: "submit",
|
||
},
|
||
{
|
||
label: "Learn more",
|
||
as: "a",
|
||
href: "#button-demo",
|
||
variant: "outline",
|
||
size: "sm",
|
||
icon: "icon-[lucide--arrow-right]",
|
||
iconPosition: "end",
|
||
},
|
||
{ label: "Upgrade plan", as: "button", variant: "secondary", size: "lg", pill: true },
|
||
{
|
||
label: "Open settings",
|
||
as: "button",
|
||
ariaLabel: "Open settings",
|
||
variant: "ghost",
|
||
size: "icon",
|
||
icon: "icon-[lucide--settings]",
|
||
},
|
||
{
|
||
label: "Delete project",
|
||
as: "button",
|
||
variant: "destructive",
|
||
icon: "icon-[lucide--trash-2]",
|
||
},
|
||
{ label: "Read documentation", as: "a", href: "#button-demo", variant: "link" },
|
||
{ label: "Publish", as: "button", loadingLabel: "Publishing…", loading: true },
|
||
{ label: "Unavailable action", as: "button", disabled: true, variant: "outline" },
|
||
];
|
||
const configuration = configurations[variation] ?? configurations[0];
|
||
if (Object.hasOwn(configuration, prop.name)) return configuration[prop.name];
|
||
return undefined;
|
||
}
|
||
if (name === "controlclass") return variation === 2 ? "shadow-xl" : "";
|
||
if (prop.type === "boolean") {
|
||
// Interactive overlays must render closed. A hard-coded open=true prop turns
|
||
// the component into controlled mode, so its own click/hover handlers cannot
|
||
// close it and multiple overlays block the complete showcase page.
|
||
if (isInteractiveOverlay(component) && overlayVisibilityProps.has(name)) return false;
|
||
if (name === "compact") return variation === 1;
|
||
if (name === "fullwidth") return variation === 2;
|
||
if (name === "loading" || name === "disabled") return false;
|
||
if (name === "open" || name === "visible" || name === "dismissible" || name.startsWith("show"))
|
||
return true;
|
||
return defaultValue(prop) ?? variation === 2;
|
||
}
|
||
if (prop.type === "number") {
|
||
if (name === "value") return 36 + variation * 24;
|
||
if (name.includes("count") || name.includes("total") || name.includes("page"))
|
||
return 3 + variation * 4;
|
||
return defaultValue(prop) ?? variation + 1;
|
||
}
|
||
if (name.includes("arialabel")) return `${displayName(component.name)} ${variation + 1} example`;
|
||
if (name === "title" || name.endsWith("title"))
|
||
return variation === 0
|
||
? `${displayName(component.name)} example`
|
||
: variation === 1
|
||
? `Compact ${displayName(component.name)}`
|
||
: `Advanced ${displayName(component.name)}`;
|
||
if (name.includes("description") || name === "summary")
|
||
return variation === 0
|
||
? `A polished default ${displayName(component.name).toLowerCase()} for a modern application.`
|
||
: variation === 1
|
||
? "A compact configuration for dashboards and dense product surfaces."
|
||
: "A richer configuration demonstrating additional content and hierarchy.";
|
||
if (name === "label" || name.endsWith("label"))
|
||
return variation === 0
|
||
? displayName(component.name)
|
||
: variation === 1
|
||
? "Compact example"
|
||
: "Advanced example";
|
||
if (name.includes("placeholder"))
|
||
return variation === 2 ? "Search by name, email, or identifier" : "Enter a sample value";
|
||
if (name.includes("email")) return variation === 2 ? "team@example.com" : "developer@example.com";
|
||
if (name.includes("href") || name === "action") {
|
||
if (component.name === "Button" && variation === 0) return "";
|
||
return `#${slugOf(component.name)}-demo`;
|
||
}
|
||
if (name === "icon")
|
||
return variation === 0
|
||
? ""
|
||
: variation === 1
|
||
? "icon-[lucide--arrow-right]"
|
||
: "icon-[lucide--trash-2]";
|
||
if (name === "variant")
|
||
if (component.name === "Button")
|
||
return variation === 0 ? "primary" : variation === 1 ? "secondary" : "danger";
|
||
if (name === "variant")
|
||
return variation === 0
|
||
? defaultValue(prop) || "primary"
|
||
: variation === 1
|
||
? "secondary"
|
||
: "success";
|
||
if (name === "size")
|
||
return variation === 0 ? defaultValue(prop) || "md" : variation === 1 ? "sm" : "lg";
|
||
if (name === "align") return variation === 1 ? "center" : variation === 2 ? "end" : "start";
|
||
if (name === "value") return `sample-${variation + 1}`;
|
||
if (name.includes("count") || name.includes("total") || name.includes("page")) return undefined;
|
||
if (prop.required) {
|
||
return `${displayName(component.name)} sample`;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function wrnAttribute(name, value) {
|
||
if (typeof value === "object" && value !== null) {
|
||
const expression = JSON.stringify(value).replaceAll("'", "\\u0027");
|
||
return `${name}='${expression}'`;
|
||
}
|
||
return `${name}="${escapeAttribute(String(value))}"`;
|
||
}
|
||
|
||
function componentAttributes(component, variation, overrides = {}) {
|
||
const attributes = [];
|
||
for (const prop of component.props) {
|
||
const value = Object.hasOwn(overrides, prop.name)
|
||
? overrides[prop.name]
|
||
: sampleValue(prop, component, variation);
|
||
if (value === undefined) continue;
|
||
attributes.push(wrnAttribute(prop.name, value));
|
||
}
|
||
return attributes;
|
||
}
|
||
|
||
function configuredSlotMarkup(component, variation, overrides = {}) {
|
||
const configured = overrides.__slots ?? {};
|
||
return component.slots
|
||
.map((slot) => {
|
||
const explicitlyConfigured = Object.hasOwn(configured, slot);
|
||
if (slot !== "default" && !explicitlyConfigured) return "";
|
||
|
||
const custom = configured[slot];
|
||
const content = custom ?? (slot === "default" ? defaultSlotMarkup(component, variation) : "");
|
||
if (!content) return "";
|
||
return slot === "default"
|
||
? content
|
||
: `<div data-slot="${escapeAttribute(slot)}">${content}</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function componentMount(component, variation = 0, overrides = {}) {
|
||
const attributes = componentAttributes(component, variation, overrides);
|
||
const slotMarkup = configuredSlotMarkup(component, variation, overrides);
|
||
const publicTag = /^[A-Z][A-Za-z0-9_]*$/.test(component.mount);
|
||
if (publicTag) {
|
||
const attributesSource = attributes.length ? ` ${attributes.join(" ")}` : "";
|
||
return slotMarkup
|
||
? `<${component.mount}${attributesSource}>${slotMarkup}</${component.mount}>`
|
||
: `<${component.mount}${attributesSource} />`;
|
||
}
|
||
const attributesSource = attributes.length ? ` ${attributes.join(" ")}` : "";
|
||
return `<div data-component="${escapeAttribute(component.mount)}"${attributesSource}>${slotMarkup}</div>`;
|
||
}
|
||
|
||
const structuralDefaultSlots = new Set([
|
||
"Accordion",
|
||
"AspectRatio",
|
||
"AutoGrid",
|
||
"card",
|
||
"Cluster",
|
||
"CodeTabs",
|
||
"Container",
|
||
"ContentSection",
|
||
"FilterBar",
|
||
"ScrollArea",
|
||
"Section",
|
||
"SidebarLayout",
|
||
"SplitLayout",
|
||
"Stack",
|
||
"Sticky",
|
||
"StickyLayout",
|
||
"Surface",
|
||
"Typography",
|
||
"VisuallyHidden",
|
||
"Well",
|
||
"WideContainer",
|
||
]);
|
||
|
||
function defaultSlotMarkup(component, variation) {
|
||
if (component.name === "Select")
|
||
return '<option value="starter">Starter</option><option value="growth">Growth</option>';
|
||
if (component.name === "Table")
|
||
return "<thead><tr><th>Name</th><th>Status</th></tr></thead><tbody><tr><td>Production</td><td>Active</td></tr><tr><td>Staging</td><td>Ready</td></tr></tbody>";
|
||
if (component.name === "Tooltip") return "";
|
||
if (!structuralDefaultSlots.has(component.name)) return "";
|
||
return `<div class="showcase-slot"><span class="icon-[lucide--layers-3] size-4" aria-hidden="true"></span><span>${variation === 2 ? "Rich supporting content with additional context." : "Composable content area."}</span></div>`;
|
||
}
|
||
|
||
function indentSource(value, spaces = 2) {
|
||
const indent = " ".repeat(spaces);
|
||
return String(value)
|
||
.trim()
|
||
.split("\n")
|
||
.map((line) => `${indent}${line}`)
|
||
.join("\n");
|
||
}
|
||
|
||
function sourceSnippet(component, variation, overrides = {}) {
|
||
const attributes = component.props.flatMap((prop) => {
|
||
const value = Object.hasOwn(overrides, prop.name)
|
||
? overrides[prop.name]
|
||
: sampleValue(prop, component, variation);
|
||
if (value === undefined || value === "" || prop.name === "class") return [];
|
||
const declaredDefault = defaultValue(prop);
|
||
if (
|
||
declaredDefault !== undefined &&
|
||
typeof value !== "object" &&
|
||
String(value) === String(declaredDefault)
|
||
)
|
||
return [];
|
||
if (typeof value === "object") {
|
||
const serialized = JSON.stringify(value, null, 2)
|
||
.replaceAll("'", "\\u0027")
|
||
.replaceAll("\n", "\n ");
|
||
return [`${prop.name}='${serialized}'`];
|
||
}
|
||
return [`${prop.name}="${String(value).replaceAll('"', '\\"')}"`];
|
||
});
|
||
const formattedAttributes = attributes.length ? `\n ${attributes.join("\n ")}` : "";
|
||
const hasPublicComponentTag = /^[A-Z][A-Za-z0-9_]*$/.test(component.mount);
|
||
const configured = overrides.__slots ?? {};
|
||
const slotParts = component.slots.flatMap((slot) => {
|
||
const custom = configured[slot];
|
||
const fallback =
|
||
slot === "default"
|
||
? defaultSlotMarkup(component, variation)
|
||
: `<div class="showcase-slot">${displayName(slot)} slot</div>`;
|
||
const content = custom ?? fallback;
|
||
if (!content) return [];
|
||
if (slot === "default") return [indentSource(content, 2)];
|
||
return [` <div data-slot="${slot}">\n${indentSource(content, 4)}\n </div>`];
|
||
});
|
||
const slotMarkup = slotParts.length ? `\n${slotParts.join("\n")}\n` : "";
|
||
|
||
if (hasPublicComponentTag) {
|
||
const source = slotMarkup
|
||
? `<${component.mount}${formattedAttributes}>${slotMarkup}</${component.mount}>`
|
||
: formattedAttributes
|
||
? `<${component.mount}${formattedAttributes}\n/>`
|
||
: `<${component.mount} />`;
|
||
return escapeCode(source);
|
||
}
|
||
|
||
const mountAttribute = `data-component="${component.mount}"`;
|
||
const source = slotMarkup
|
||
? `<div\n ${mountAttribute}${formattedAttributes}>${slotMarkup}</div>`
|
||
: `<div\n ${mountAttribute}${formattedAttributes}\n></div>`;
|
||
return escapeCode(source);
|
||
}
|
||
|
||
function propsTable(component) {
|
||
if (component.props.length === 0)
|
||
return `<div class="docs-empty"><span class="icon-[lucide--package-open] size-5" aria-hidden="true"></span>No props. Compose this component with slots.</div>`;
|
||
return `<div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody>${component.props
|
||
.map(
|
||
(prop) =>
|
||
`<tr><td><code>${escapeCode(prop.name)}</code></td><td>${escapeText(prop.type)}</td><td><code>${escapeCode(prop.default ?? "—")}</code></td><td>${prop.required ? '<span class="docs-required">Yes</span>' : "No"}</td></tr>`,
|
||
)
|
||
.join("")}</tbody></table></div>`;
|
||
}
|
||
|
||
function playgroundInitialValue(prop, component) {
|
||
const profileValue = profileFor(component.name)?.playground?.[prop.name];
|
||
if (profileValue !== undefined) return profileValue;
|
||
const sampled = sampleValue(prop, component, 0);
|
||
return sampled === undefined ? (defaultValue(prop) ?? "") : sampled;
|
||
}
|
||
|
||
function playgroundValue(prop, component) {
|
||
const value = playgroundInitialValue(prop, component);
|
||
return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
||
}
|
||
|
||
function playgroundStateName(prop) {
|
||
return `playground_${prop.name.replace(/[^A-Za-z0-9_$]/g, "_")}`;
|
||
}
|
||
|
||
function isStructuredProp(prop) {
|
||
return (
|
||
prop.default === "[]" ||
|
||
prop.default === "{}" ||
|
||
prop.type === "array" ||
|
||
prop.type === "object" ||
|
||
prop.type.includes("[]")
|
||
);
|
||
}
|
||
|
||
function playgroundStates(component) {
|
||
return component.props
|
||
.map((prop) => {
|
||
const initialValue = playgroundInitialValue(prop, component);
|
||
const query = `ctx.url.searchParams.get("pg_${prop.name}")`;
|
||
if (prop.type === "boolean") {
|
||
const initial = initialValue === true ? "true" : "false";
|
||
return ` state ${playgroundStateName(prop)} = (${query} ?? "${initial}") === "true"`;
|
||
}
|
||
if (prop.type === "number") {
|
||
const initial = Number(initialValue || 0);
|
||
return ` state ${playgroundStateName(prop)} = Number(${query} ?? ${JSON.stringify(String(initial))})`;
|
||
}
|
||
if (isStructuredProp(prop)) {
|
||
const initial = JSON.stringify(initialValue ?? (prop.default === "{}" ? {} : []));
|
||
return ` state ${playgroundStateName(prop)} = JSON.parse(${query} ?? ${JSON.stringify(initial)})`;
|
||
}
|
||
return ` state ${playgroundStateName(prop)} = ${query} ?? ${JSON.stringify(String(initialValue ?? ""))}`;
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
function playgroundMount(component) {
|
||
const attributes = component.props.map((prop) => `${prop.name}='{${playgroundStateName(prop)}}'`);
|
||
const profileSlots = profileFor(component.name)?.demos?.[0]?.slots ?? {};
|
||
const slotMarkup = configuredSlotMarkup(component, 0, { __slots: profileSlots });
|
||
const publicTag = /^[A-Z][A-Za-z0-9_]*$/.test(component.mount);
|
||
if (publicTag) {
|
||
const attributesSource = attributes.length ? ` ${attributes.join(" ")}` : "";
|
||
return slotMarkup
|
||
? `<${component.mount}${attributesSource}>${slotMarkup}</${component.mount}>`
|
||
: `<${component.mount}${attributesSource} />`;
|
||
}
|
||
const attributesSource = attributes.length ? ` ${attributes.join(" ")}` : "";
|
||
return `<div data-component="${escapeAttribute(component.mount)}"${attributesSource}>${slotMarkup}</div>`;
|
||
}
|
||
|
||
function playgroundControl(prop, component) {
|
||
const value = playgroundValue(prop, component);
|
||
const id = `playground-${slugOf(component.name)}-${prop.name}`;
|
||
const label = escapeText(displayName(prop.name));
|
||
const meta = `<small>${escapeText(prop.type)}${prop.required ? " · required" : ""}</small>`;
|
||
|
||
if (prop.type === "boolean") {
|
||
return `<label class="playground-toggle" for="${id}"><span><strong>${label}</strong>${meta}</span><input id="${id}" name="pg_${escapeAttribute(prop.name)}" type="checkbox" value="true"${value === "true" ? " checked" : ""} data-playground-boolean /><i aria-hidden="true"></i></label>`;
|
||
}
|
||
|
||
const structured =
|
||
prop.default === "[]" ||
|
||
prop.default === "{}" ||
|
||
prop.type === "array" ||
|
||
prop.type === "object" ||
|
||
prop.type.includes("[]");
|
||
if (structured) {
|
||
return `<label class="playground-field" for="${id}"><span><strong>${label}</strong>${meta}</span><textarea id="${id}" name="pg_${escapeAttribute(prop.name)}" rows="5" spellcheck="false" data-playground-json>${escapeText(value)}</textarea><em data-playground-error></em></label>`;
|
||
}
|
||
|
||
const options = playgroundOptions(prop, component, value);
|
||
if (options.length > 1) {
|
||
return `<label class="playground-field" for="${id}"><span><strong>${label}</strong>${meta}</span><select id="${id}" name="pg_${escapeAttribute(prop.name)}">${options
|
||
.map(
|
||
(option) =>
|
||
`<option value="${escapeAttribute(option)}"${option === value ? " selected" : ""}>${escapeText(option || "Auto / default")}</option>`,
|
||
)
|
||
.join("")}</select></label>`;
|
||
}
|
||
|
||
const inputType =
|
||
prop.type === "number"
|
||
? "number"
|
||
: prop.name.toLowerCase().includes("color") && /^#[0-9a-f]{6}$/i.test(value)
|
||
? "color"
|
||
: "text";
|
||
return `<label class="playground-field" for="${id}"><span><strong>${label}</strong>${meta}</span><input id="${id}" name="pg_${escapeAttribute(prop.name)}" type="${inputType}" value="${escapeAttribute(value)}" /></label>`;
|
||
}
|
||
|
||
const commonPlaygroundOptions = {
|
||
align: ["left", "center", "right", "start", "end", "stretch"],
|
||
alignment: ["left", "center", "right", "start", "end", "stretch"],
|
||
as: ["", "button", "a"],
|
||
color: ["primary", "secondary", "success", "warning", "danger", "info"],
|
||
direction: ["row", "column"],
|
||
gap: ["none", "xs", "sm", "md", "lg", "xl", "2xl"],
|
||
iconposition: ["start", "end", "left", "right"],
|
||
imageposition: ["top", "left", "right"],
|
||
layout: ["content", "split", "stacked", "balanced", "visual"],
|
||
maxwidth: ["compact", "lg", "xl", "wide", "2xl", "full"],
|
||
method: ["get", "post"],
|
||
orientation: ["horizontal", "vertical"],
|
||
placement: [
|
||
"top",
|
||
"top-start",
|
||
"top-end",
|
||
"right",
|
||
"right-start",
|
||
"right-end",
|
||
"bottom",
|
||
"bottom-start",
|
||
"bottom-end",
|
||
"left",
|
||
"left-start",
|
||
"left-end",
|
||
],
|
||
position: ["top", "right", "bottom", "left", "start", "end", "center"],
|
||
shape: ["round", "rounded", "pill"],
|
||
size: ["default", "xs", "sm", "md", "lg", "xl", "icon", "icon-xs", "icon-sm", "icon-lg"],
|
||
target: ["_self", "_blank", "_parent", "_top"],
|
||
trigger: ["click", "hover", "focus", "manual"],
|
||
visualposition: ["left", "right"],
|
||
width: ["default", "compact", "wide", "full"],
|
||
};
|
||
|
||
function playgroundOptions(prop, component, currentValue) {
|
||
if (prop.type !== "string" || prop.name === "class" || prop.name === "controlClass") return [];
|
||
|
||
const profileOptions = profileFor(component.name)?.options?.[prop.name] ?? [];
|
||
const common = commonPlaygroundOptions[prop.name.toLowerCase()] ?? [];
|
||
const componentSpecific =
|
||
component.name === "Button" && prop.name === "type" ? ["button", "submit", "reset"] : [];
|
||
const declared = [...profileOptions, ...componentSpecific, ...common];
|
||
|
||
// Free-form strings such as icon names, labels, URLs, IDs, and descriptions
|
||
// must remain text inputs. Implementation comparisons are not a public enum.
|
||
if (declared.length === 0) return [];
|
||
|
||
const values = new Set();
|
||
if (typeof currentValue === "string" && currentValue.length <= 120) values.add(currentValue);
|
||
const declaredDefault = defaultValue(prop);
|
||
if (typeof declaredDefault === "string" && declaredDefault.length <= 120)
|
||
values.add(declaredDefault);
|
||
for (const option of declared) {
|
||
if (typeof option === "string" && option.length <= 120) values.add(option);
|
||
}
|
||
return [...values];
|
||
}
|
||
|
||
function playgroundSection(component) {
|
||
const controls = component.props.map((prop) => playgroundControl(prop, component)).join("");
|
||
const publicTag = /^[A-Z][A-Za-z0-9_]*$/.test(component.mount);
|
||
const eventNames = component.events.join(",");
|
||
return `<section id="playground" class="detail-section playground-section" data-playground data-playground-component="${escapeAttribute(component.mount)}" data-playground-public-tag="${publicTag}" data-playground-has-slot="${component.slots.length > 0}" data-playground-events="${escapeAttribute(eventNames)}">
|
||
<div class="detail-section-heading"><span>Interactive playground</span><h2>Configure ${escapeText(displayName(component.name))}</h2><p>Change any prop and inspect the server-rendered component immediately.</p></div>
|
||
<div class="playground-workbench">
|
||
<div class="playground-preview">
|
||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||
<div class="playground-preview-source" data-playground-preview>${playgroundMount(component)}</div>
|
||
<section class="playground-code" aria-label="Current component code">
|
||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component code</span><button type="button" data-playground-copy><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span>Copy</button></header>
|
||
<pre><code data-playground-code>${sourceSnippet(component, 0)}</code></pre>
|
||
</section>
|
||
<div class="playground-status" data-playground-status aria-live="polite"></div>
|
||
${component.events.length ? `<div class="playground-event-log" data-playground-event-log aria-live="polite"><strong>Event output</strong><span>Interact with the preview to inspect the typed <code>payload</code>.</span></div>` : ""}
|
||
</div>
|
||
<form class="playground-controls" data-playground-form>
|
||
<header><div><span>Component props</span><strong>${component.props.length} controls</strong></div><button type="reset"><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset</button></header>
|
||
<div class="playground-fields">${controls || '<p class="playground-empty">This component has no declared props. Its slot content remains composable.</p>'}</div>
|
||
</form>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
const defaultUses = [
|
||
{
|
||
eyebrow: "Recommended",
|
||
title: "Production default",
|
||
description: "Balanced spacing, hierarchy, and content for the most common product workflow.",
|
||
},
|
||
{
|
||
eyebrow: "Dense UI",
|
||
title: "Compact application",
|
||
description:
|
||
"A tighter variation for dashboards, side panels, tables, and operational interfaces.",
|
||
},
|
||
{
|
||
eyebrow: "Extended",
|
||
title: "Rich configuration",
|
||
description:
|
||
"A more expressive variation using additional data, stronger emphasis, and optional states.",
|
||
},
|
||
];
|
||
|
||
const buttonUses = [
|
||
{
|
||
eyebrow: "Default",
|
||
title: "Primary action",
|
||
description: "The standard high-emphasis action for forms and product workflows.",
|
||
},
|
||
{
|
||
eyebrow: "Outline · Small",
|
||
title: "Secondary navigation",
|
||
description: "A compact link button with an end icon and restrained emphasis.",
|
||
},
|
||
{
|
||
eyebrow: "Secondary · Large · Pill",
|
||
title: "Prominent rounded action",
|
||
description: "A large secondary action using the optional pill shape.",
|
||
},
|
||
{
|
||
eyebrow: "Ghost · Icon",
|
||
title: "Accessible icon action",
|
||
description: "An icon-only control with its accessible name supplied through props.",
|
||
},
|
||
{
|
||
eyebrow: "Destructive",
|
||
title: "Dangerous action",
|
||
description: "A destructive treatment for irreversible or high-risk operations.",
|
||
},
|
||
{
|
||
eyebrow: "Link",
|
||
title: "Inline action",
|
||
description: "A low-chrome action that behaves and reads like an inline link.",
|
||
},
|
||
{
|
||
eyebrow: "Loading",
|
||
title: "Pending action",
|
||
description: "The button owns its busy state, loading label, spinner, and disabled behavior.",
|
||
},
|
||
{
|
||
eyebrow: "Disabled",
|
||
title: "Unavailable action",
|
||
description: "A visibly and semantically disabled control.",
|
||
},
|
||
];
|
||
|
||
const advancedSelectTitles = [
|
||
"Default advanced select",
|
||
"Grouped options",
|
||
"Fixed-position dropdown",
|
||
"Placeholder with icon",
|
||
"Allow empty selection",
|
||
"Multiple selection",
|
||
"Multiple selection with optgroups",
|
||
"Multiple selection with option templates",
|
||
"Multiple selection with counter",
|
||
"Multiple selection with conditional counter",
|
||
"Counter with option templates",
|
||
"Searchable dropdown",
|
||
"Minimum search length",
|
||
"Search result limit",
|
||
"Scroll to selected option",
|
||
"Search match mode",
|
||
"Search labels and descriptions",
|
||
"Tag-style selection",
|
||
"Disabled tag selection",
|
||
"Option template with icons",
|
||
"Option template with avatars",
|
||
"Option template with color indicators",
|
||
"Advanced select sizes",
|
||
"Disabled advanced select",
|
||
"Inside a modal",
|
||
"Validation states",
|
||
"Dynamic validation styling",
|
||
"Add or remove options",
|
||
"Add or remove options in multiple mode",
|
||
"Set a single value programmatically",
|
||
"Set multiple values programmatically",
|
||
"Remote data source",
|
||
"Remote data source (multiple)",
|
||
"Remote data tags",
|
||
"Option templates with remote data",
|
||
"Conditional counter with remote data",
|
||
"Avatar template with remote data",
|
||
"Preselected values",
|
||
"Infinite scroll",
|
||
"Inside overflow-hidden containers",
|
||
"Destroy and reinitialize",
|
||
];
|
||
|
||
const advancedSelectUses = advancedSelectTitles.map((title, index) => ({
|
||
eyebrow: index < 11 ? "Selection" : index < 23 ? "Search & templates" : "Advanced behavior",
|
||
title,
|
||
description: `Live ${title.toLowerCase()} configuration with copyable component code.`,
|
||
}));
|
||
|
||
function advancedSelectConfiguration(variation) {
|
||
const title = advancedSelectTitles[variation] ?? advancedSelectTitles[0];
|
||
const lower = title.toLowerCase();
|
||
const options = [
|
||
{
|
||
value: "design",
|
||
label: "Design",
|
||
description: "Product and visual design",
|
||
icon: "icon-[lucide--palette]",
|
||
color: "#8b5cf6",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%238b5cf6'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3ED%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{
|
||
value: "engineering",
|
||
label: "Engineering",
|
||
description: "Platform and application engineering",
|
||
icon: "icon-[lucide--code-2]",
|
||
color: "#0ea5e9",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%230ea5e9'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3EE%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{
|
||
value: "growth",
|
||
label: "Growth",
|
||
description: "Marketing and customer growth",
|
||
icon: "icon-[lucide--trending-up]",
|
||
color: "#10b981",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2310b981'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3EG%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{ value: "support", label: "Support", description: "Customer operations", disabled: false },
|
||
];
|
||
const multiple = lower.includes("multiple") || lower.includes("tags");
|
||
const remote = lower.includes("remote") || lower.includes("infinite");
|
||
const grouped = lower.includes("group");
|
||
return {
|
||
label: title,
|
||
name: `advanced-select-${variation + 1}`,
|
||
value:
|
||
multiple || lower.includes("placeholder")
|
||
? ""
|
||
: lower.includes("preselected")
|
||
? "engineering"
|
||
: "design",
|
||
values: multiple ? ["design", "engineering"] : [],
|
||
options: grouped ? [] : options,
|
||
groups: grouped
|
||
? [
|
||
{ label: "Product", options: options.slice(0, 2) },
|
||
{ label: "Business", options: options.slice(2) },
|
||
]
|
||
: [],
|
||
placeholder: lower.includes("placeholder") ? "Choose a workspace" : "Choose a team",
|
||
placeholderIcon: lower.includes("placeholder") ? "icon-[lucide--building-2]" : "",
|
||
multiple,
|
||
searchable: lower.includes("search") || remote || variation === 0,
|
||
allowEmpty: lower.includes("empty"),
|
||
tags: lower.includes("tag"),
|
||
disabled: lower.includes("disabled"),
|
||
required: lower.includes("validation"),
|
||
invalid: false,
|
||
validationMessage: "",
|
||
minSearchLength: lower.includes("minimum") ? 3 : remote && !lower.includes("infinite") ? 2 : 0,
|
||
searchResultLimit: lower.includes("result limit") ? 3 : 0,
|
||
searchMode: lower.includes("match mode") ? "startsWith" : "contains",
|
||
showCounter: lower.includes("counter"),
|
||
maxSelections: lower.includes("conditional") ? 3 : 0,
|
||
optionTemplate: lower.includes("avatar")
|
||
? "avatar"
|
||
: lower.includes("color")
|
||
? "color"
|
||
: lower.includes("icon") || lower.includes("template")
|
||
? "icon"
|
||
: "default",
|
||
fixed: lower.includes("fixed") || lower.includes("overflow"),
|
||
remote,
|
||
remoteUrl: remote ? "/api/teams" : "",
|
||
infinite: lower.includes("infinite"),
|
||
hasMore: lower.includes("infinite"),
|
||
size: lower.includes("sizes") ? "lg" : "default",
|
||
};
|
||
}
|
||
|
||
const comboboxTitles = [
|
||
"Default autocomplete",
|
||
"Local HTML options",
|
||
"Grouped suggestions",
|
||
"Minimum search length",
|
||
"Search result limit",
|
||
"Starts-with matching",
|
||
"Search labels and descriptions",
|
||
"Suggestions with icons",
|
||
"Suggestions with avatars",
|
||
"Suggestions with color indicators",
|
||
"Clearable value",
|
||
"Custom text values",
|
||
"Preselected value",
|
||
"Disabled combobox",
|
||
"Validation state",
|
||
"Remote data with automatic preload",
|
||
"Remote search API",
|
||
"Remote query parameters",
|
||
"Infinite remote suggestions",
|
||
"Fixed-position suggestions",
|
||
"JavaScript methods",
|
||
];
|
||
|
||
const comboboxUses = comboboxTitles.map((title, index) => ({
|
||
eyebrow: index < 7 ? "Autocomplete" : index < 15 ? "Templates & states" : "Remote & API",
|
||
title,
|
||
description: `Live ${title.toLowerCase()} combobox with editable autocomplete and copyable component code.`,
|
||
}));
|
||
|
||
function comboboxConfiguration(variation) {
|
||
const title = comboboxTitles[variation] ?? comboboxTitles[0];
|
||
const lower = title.toLowerCase();
|
||
const options = [
|
||
{
|
||
value: "design",
|
||
label: "Design",
|
||
description: "Product and visual design",
|
||
icon: "icon-[lucide--palette]",
|
||
color: "#8b5cf6",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%238b5cf6'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3ED%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{
|
||
value: "engineering",
|
||
label: "Engineering",
|
||
description: "Platform and application engineering",
|
||
icon: "icon-[lucide--code-2]",
|
||
color: "#0ea5e9",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%230ea5e9'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3EE%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{
|
||
value: "growth",
|
||
label: "Growth",
|
||
description: "Marketing and customer growth",
|
||
icon: "icon-[lucide--trending-up]",
|
||
color: "#10b981",
|
||
avatar:
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2310b981'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-family='Arial' font-size='24' fill='white'%3EG%3C/text%3E%3C/svg%3E",
|
||
},
|
||
{
|
||
value: "support",
|
||
label: "Support",
|
||
description: "Customer operations",
|
||
icon: "icon-[lucide--life-buoy]",
|
||
color: "#f59e0b",
|
||
},
|
||
];
|
||
const remote =
|
||
lower.includes("remote") || lower.includes("infinite") || lower.includes("query parameter");
|
||
const grouped = lower.includes("grouped");
|
||
return {
|
||
label: title,
|
||
name: `combobox-${variation + 1}`,
|
||
value: lower.includes("preselected") ? "engineering" : "",
|
||
options: grouped ? [] : options,
|
||
groups: grouped
|
||
? [
|
||
{ label: "Product", options: options.slice(0, 2) },
|
||
{ label: "Business", options: options.slice(2) },
|
||
]
|
||
: [],
|
||
placeholder: remote ? "Search remote teams" : "Search teams",
|
||
allowCustomValue: lower.includes("custom"),
|
||
clearable: true,
|
||
disabled: lower.includes("disabled"),
|
||
required: lower.includes("validation"),
|
||
invalid: false,
|
||
validationMessage: "",
|
||
minSearchLength: lower.includes("minimum") ? 3 : remote ? 2 : 0,
|
||
searchResultLimit: lower.includes("result limit") ? 2 : 0,
|
||
searchMode: lower.includes("starts-with") ? "startsWith" : "contains",
|
||
optionTemplate: lower.includes("avatar")
|
||
? "avatar"
|
||
: lower.includes("color")
|
||
? "color"
|
||
: lower.includes("icon")
|
||
? "icon"
|
||
: "default",
|
||
fixed: lower.includes("fixed"),
|
||
remote,
|
||
remoteUrl: remote ? "/api/teams" : "",
|
||
remoteQueryParam: "q",
|
||
remoteAutoLoad: true,
|
||
infinite: lower.includes("infinite"),
|
||
hasMore: lower.includes("infinite"),
|
||
};
|
||
}
|
||
|
||
const pinInputTitles = [
|
||
"Default four-digit PIN",
|
||
"Different lengths",
|
||
"Pre-filled verification code",
|
||
"Masked security PIN",
|
||
"Alphanumeric regex",
|
||
"Grouped code with separator",
|
||
"Paste from clipboard",
|
||
"Disabled PIN input",
|
||
"Read-only code",
|
||
"Validation state",
|
||
"Supporting help text",
|
||
"Large PIN cells",
|
||
"Custom placeholder",
|
||
"Automatic form submission",
|
||
"Completion events",
|
||
"JavaScript methods",
|
||
];
|
||
|
||
const pinInputUses = pinInputTitles.map((title, index) => ({
|
||
eyebrow: index < 7 ? "Entry behavior" : index < 13 ? "States & design" : "Automation",
|
||
title,
|
||
description: `Live ${title.toLowerCase()} example with keyboard navigation, filtering, and copyable component code.`,
|
||
}));
|
||
|
||
function pinInputConfiguration(variation) {
|
||
const title = pinInputTitles[variation] ?? pinInputTitles[0];
|
||
const lower = title.toLowerCase();
|
||
const fourDigits = lower.includes("four-digit") || lower.includes("security pin");
|
||
return {
|
||
label: title,
|
||
name: `pin-input-${variation + 1}`,
|
||
value: lower.includes("pre-filled") ? "482915" : lower.includes("read-only") ? "739204" : "",
|
||
length: fourDigits ? 4 : 6,
|
||
pattern: lower.includes("alphanumeric") ? "[A-Z0-9]" : "[0-9]",
|
||
inputMode: lower.includes("alphanumeric") ? "text" : "numeric",
|
||
placeholder: lower.includes("custom placeholder") ? "•" : "○",
|
||
masked: lower.includes("masked"),
|
||
disabled: lower.includes("disabled"),
|
||
readonly: lower.includes("read-only"),
|
||
required: lower.includes("validation"),
|
||
invalid: false,
|
||
validationMessage: "",
|
||
allowPaste: true,
|
||
separator: lower.includes("separator") ? "–" : "",
|
||
groupSize: lower.includes("separator") ? 3 : 0,
|
||
helpText: lower.includes("help text")
|
||
? "Enter the code sent to your registered email address."
|
||
: "",
|
||
autoSubmit: lower.includes("automatic"),
|
||
size: lower.includes("large") ? "lg" : "default",
|
||
};
|
||
}
|
||
|
||
const strongPasswordTitles = [
|
||
"Live strength meter",
|
||
"Requirements and hint text",
|
||
"Requirements in a popover",
|
||
"Custom special characters",
|
||
"Optional requirements",
|
||
"Disabled password strength",
|
||
];
|
||
|
||
const strongPasswordUses = strongPasswordTitles.map((title, index) => ({
|
||
eyebrow: index < 3 ? "Strength feedback" : "Configuration",
|
||
title,
|
||
description: `Live ${title.toLowerCase()} example with accessible scoring feedback and copyable component code.`,
|
||
}));
|
||
|
||
function strongPasswordConfiguration(variation) {
|
||
const title = strongPasswordTitles[variation] ?? strongPasswordTitles[0];
|
||
const lower = title.toLowerCase();
|
||
return {
|
||
label: title,
|
||
name: `strong-password-${variation + 1}`,
|
||
value: lower.includes("live") ? "Northstar!2026" : "",
|
||
placeholder: "Create a strong password",
|
||
autocomplete: "new-password",
|
||
minLength: 8,
|
||
specialCharactersSet: lower.includes("custom") ? "@#_-." : "!@#$%^&*()_+-=[]{}|;:,.<>?",
|
||
requireLowercase: true,
|
||
requireUppercase: true,
|
||
requireNumber: true,
|
||
requireSpecialCharacter: !lower.includes("optional"),
|
||
showRequirements: !lower.includes("live"),
|
||
presentation: lower.includes("popover") ? "popover" : "inline",
|
||
hintText: lower.includes("hint")
|
||
? "Use a unique password you do not use for another account."
|
||
: "",
|
||
disabled: lower.includes("disabled"),
|
||
readonly: false,
|
||
required: false,
|
||
invalid: false,
|
||
validationMessage: "",
|
||
};
|
||
}
|
||
|
||
const togglePasswordTitles = [
|
||
"Inline visibility button",
|
||
"Checkbox-controlled toggle",
|
||
"Synchronized password fields",
|
||
"Without visibility toggle",
|
||
"Supporting help text",
|
||
"Required validation",
|
||
"Disabled password",
|
||
"Read-only password",
|
||
"Large password field",
|
||
"Toggle events",
|
||
];
|
||
|
||
const togglePasswordUses = togglePasswordTitles.map((title, index) => ({
|
||
eyebrow: index < 4 ? "Visibility" : index < 8 ? "Forms & states" : "Design & events",
|
||
title,
|
||
description: `Live ${title.toLowerCase()} example with accessible visibility controls and copyable component code.`,
|
||
}));
|
||
|
||
function togglePasswordConfiguration(variation) {
|
||
const title = togglePasswordTitles[variation] ?? togglePasswordTitles[0];
|
||
const lower = title.toLowerCase();
|
||
const checkbox = lower.includes("checkbox");
|
||
const synchronized = lower.includes("synchronized");
|
||
return {
|
||
label: synchronized ? "Passwords" : lower.includes("inline") ? "Password" : title,
|
||
name: `toggle-password-${variation + 1}`,
|
||
value:
|
||
lower.includes("inline") || checkbox || lower.includes("read-only") ? "Northstar!2026" : "",
|
||
placeholder: "Enter your password",
|
||
autocomplete: "current-password",
|
||
minlength: lower.includes("validation") ? "8" : "",
|
||
maxlength: "128",
|
||
pattern: "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}",
|
||
fields: synchronized
|
||
? [
|
||
{
|
||
label: "New password",
|
||
name: "new-password",
|
||
placeholder: "Enter new password",
|
||
autocomplete: "new-password",
|
||
},
|
||
{
|
||
label: "Current password",
|
||
name: "current-password",
|
||
value: "Northstar!2026",
|
||
autocomplete: "current-password",
|
||
},
|
||
]
|
||
: [],
|
||
visible: false,
|
||
toggleable: !lower.includes("without"),
|
||
toggleMode: checkbox ? "checkbox" : "button",
|
||
checkboxLabel: "Show password",
|
||
disabled: lower.includes("disabled"),
|
||
readonly: lower.includes("read-only"),
|
||
required: lower.includes("validation"),
|
||
helpText: lower.includes("help text")
|
||
? "Use at least eight characters and avoid passwords used on other sites."
|
||
: "",
|
||
validationMessage: "",
|
||
invalid: false,
|
||
size: lower.includes("large") ? "lg" : "default",
|
||
};
|
||
}
|
||
|
||
const inputNumberUses = [
|
||
{
|
||
eyebrow: "Basic usage",
|
||
title: "Default controls",
|
||
description: "Use standard decrement and increment buttons alongside a numeric input field.",
|
||
},
|
||
{
|
||
eyebrow: "Basic usage",
|
||
title: "Labeled input style",
|
||
description:
|
||
"Add supporting label text and rounded controls for a more form-like number input.",
|
||
},
|
||
{
|
||
eyebrow: "Layout",
|
||
title: "Vertical buttons",
|
||
description: "Stack the increment and decrement buttons vertically beside the field.",
|
||
},
|
||
{
|
||
eyebrow: "Layout",
|
||
title: "Horizontal buttons",
|
||
description: "Place the increment and decrement buttons on opposite sides of the value.",
|
||
},
|
||
{
|
||
eyebrow: "Sizing",
|
||
title: "Compact size",
|
||
description: "Use a smaller compact control when the available space is limited.",
|
||
},
|
||
{
|
||
eyebrow: "Commerce",
|
||
title: "Seat quantity selector",
|
||
description: "Pair the control with pricing information for seat counts or plan add-ons.",
|
||
},
|
||
{
|
||
eyebrow: "States",
|
||
title: "Disabled input",
|
||
description: "Disable manual entry while keeping supported quantity controls available.",
|
||
},
|
||
{
|
||
eyebrow: "States",
|
||
title: "Disabled buttons",
|
||
description:
|
||
"Disable increment and decrement controls while leaving the current value visible.",
|
||
},
|
||
{
|
||
eyebrow: "Behaviour",
|
||
title: "Custom step size",
|
||
description: "Set a custom step so every button press changes the value by two.",
|
||
},
|
||
{
|
||
eyebrow: "Limits",
|
||
title: "Negative values",
|
||
description: "Use the min option to permit controlled negative values.",
|
||
},
|
||
{
|
||
eyebrow: "Limits",
|
||
title: "Maximum limit",
|
||
description: "Set an upper limit and disable incrementing when that limit is reached.",
|
||
},
|
||
{
|
||
eyebrow: "Validation",
|
||
title: "Validation states",
|
||
description: "Communicate whether the current numerical value is valid.",
|
||
},
|
||
];
|
||
|
||
function inputNumberConfiguration(variation) {
|
||
const configurations = [
|
||
{
|
||
name: "default-quantity",
|
||
value: 1,
|
||
min: 0,
|
||
max: 99,
|
||
step: 1,
|
||
variant: "default",
|
||
},
|
||
{
|
||
name: "labeled-quantity",
|
||
label: "Select quantity",
|
||
value: 1,
|
||
min: 1,
|
||
max: 25,
|
||
step: 1,
|
||
variant: "labeled",
|
||
},
|
||
{
|
||
name: "vertical-quantity",
|
||
label: "Select quantity",
|
||
value: 1,
|
||
min: 0,
|
||
max: 20,
|
||
variant: "vertical",
|
||
},
|
||
{
|
||
name: "horizontal-quantity",
|
||
value: 1,
|
||
min: 0,
|
||
max: 20,
|
||
variant: "horizontal",
|
||
},
|
||
{
|
||
name: "compact-quantity",
|
||
value: 0,
|
||
min: 0,
|
||
max: 10,
|
||
variant: "compact",
|
||
size: "sm",
|
||
},
|
||
{
|
||
name: "additional-seats",
|
||
label: "Additional seats",
|
||
description: "$39 monthly",
|
||
value: 0,
|
||
min: 0,
|
||
max: 50,
|
||
variant: "seat",
|
||
},
|
||
{
|
||
name: "disabled-input",
|
||
value: 10,
|
||
inputDisabled: true,
|
||
},
|
||
{
|
||
name: "disabled-buttons",
|
||
value: 10,
|
||
buttonsDisabled: true,
|
||
},
|
||
{
|
||
name: "custom-step",
|
||
value: 0,
|
||
min: 0,
|
||
max: 20,
|
||
step: 2,
|
||
},
|
||
{
|
||
name: "negative-values",
|
||
value: -10,
|
||
min: -100,
|
||
max: 100,
|
||
},
|
||
{
|
||
name: "maximum-limit",
|
||
value: 10,
|
||
min: 0,
|
||
max: 10,
|
||
help: "The maximum value is 10.",
|
||
},
|
||
{
|
||
name: "invalid-quantity",
|
||
value: 10,
|
||
min: 0,
|
||
max: 5,
|
||
error: "Out of limit",
|
||
},
|
||
];
|
||
|
||
return {
|
||
color: "primary",
|
||
size: "md",
|
||
...configurations[variation],
|
||
};
|
||
}
|
||
|
||
function demoUses(component) {
|
||
const profile = profileFor(component.name);
|
||
if (profile?.demos?.length) {
|
||
return profile.demos.map(({ eyebrow, title, description }) => ({
|
||
eyebrow,
|
||
title,
|
||
description,
|
||
}));
|
||
}
|
||
|
||
switch (component.name) {
|
||
case "Button":
|
||
return buttonUses;
|
||
|
||
case "AdvancedSelect":
|
||
return advancedSelectUses;
|
||
|
||
case "ComboBox":
|
||
return comboboxUses;
|
||
|
||
case "InputNumber":
|
||
return inputNumberUses;
|
||
|
||
case "PinInput":
|
||
return pinInputUses;
|
||
|
||
case "StrongPassword":
|
||
return strongPasswordUses;
|
||
|
||
case "TogglePassword":
|
||
return togglePasswordUses;
|
||
|
||
default:
|
||
return defaultUses;
|
||
}
|
||
}
|
||
|
||
function demoConfiguration(component, variation) {
|
||
const demo = profileFor(component.name)?.demos?.[variation];
|
||
if (!demo) return {};
|
||
return {
|
||
...(demo.props ?? {}),
|
||
__slots: demo.slots ?? {},
|
||
};
|
||
}
|
||
|
||
function eventDocumentation(component) {
|
||
if (!component.events.length) return "";
|
||
|
||
const eventRows = component.events
|
||
.map(
|
||
(eventName) =>
|
||
`<div><code>@${escapeCode(eventName)}</code><span>${escapeText(eventDescriptionFor(component.name, eventName))}</span></div>`,
|
||
)
|
||
.join("");
|
||
const declarativeAttributes = component.events
|
||
.map((eventName) => ` @${eventName}='console.log(payload)'`)
|
||
.join("\n");
|
||
const declarativeCode = `<${component.mount}\n${declarativeAttributes}\n/>`;
|
||
const selector = `[data-ui-component="${component.name}"], [data-component="${component.mount}"]`;
|
||
const listenerCode = `import { registerOutputHandler } from "@wrnexus/csr/outputs"\n\nconst component = document.querySelector(${JSON.stringify(selector)})\n\n${component.events
|
||
.map(
|
||
(eventName) =>
|
||
`if (component) registerOutputHandler(component, ${JSON.stringify(eventName)}, (payload) => {\n console.log(${JSON.stringify(eventName)}, payload)\n})`,
|
||
)
|
||
.join("\n\n")}`;
|
||
|
||
return `<section id="events" class="detail-section"><div class="detail-section-heading"><span>Component outputs</span><h2>Receive every typed component output</h2><p>Use declarative output handlers in <code>.wrn</code> files or register a direct output handler from JavaScript. The canonical API exposes <code>payload</code> and does not require <code>event.detail</code>.</p></div><div class="detail-events-grid"><div class="detail-events-list">${eventRows}</div><div class="detail-event-examples"><section class="demo-code"><header><span><span class="icon-[lucide--braces] size-4" aria-hidden="true"></span>Declarative handlers</span><small>.wrn</small></header><pre><code>${escapeCode(declarativeCode)}</code></pre></section><section class="demo-code"><header><span><span class="icon-[lucide--radio] size-4" aria-hidden="true"></span>Direct output handlers</span><small>.js</small></header><pre><code>${escapeCode(listenerCode)}</code></pre></section></div></div></section>`;
|
||
}
|
||
|
||
function detailPage(component, categoryComponents) {
|
||
const index = categoryComponents.findIndex((item) => item.name === component.name);
|
||
const previous = categoryComponents[index - 1];
|
||
const next = categoryComponents[index + 1];
|
||
const uses = demoUses(component);
|
||
const demos = uses
|
||
.map((useCase, variation) => {
|
||
const configuredDemo = demoConfiguration(component, variation);
|
||
const isPinLengthDemo = component.name === "PinInput" && variation === 1;
|
||
const pinLengths = [3, 5, 7];
|
||
const mount = isPinLengthDemo
|
||
? `<div class="pin-length-demo">${pinLengths
|
||
.map((length) =>
|
||
componentMount(component, variation, {
|
||
...configuredDemo,
|
||
label: `${length}-digit code`,
|
||
name: `pin-length-${length}`,
|
||
length,
|
||
}),
|
||
)
|
||
.join("")}</div>`
|
||
: componentMount(component, variation, configuredDemo);
|
||
const snippet = isPinLengthDemo
|
||
? pinLengths
|
||
.map((length) =>
|
||
sourceSnippet(component, variation, {
|
||
...configuredDemo,
|
||
label: `${length}-digit code`,
|
||
name: `pinLength${length}`,
|
||
length,
|
||
}),
|
||
)
|
||
.join("\n\n")
|
||
: sourceSnippet(component, variation, configuredDemo);
|
||
const validationSchema =
|
||
component.name === "AdvancedSelect" && variation === 25
|
||
? "advanced-select-validation"
|
||
: component.name === "AdvancedSelect" && variation === 26
|
||
? "advanced-select-dynamic-validation"
|
||
: component.name === "ComboBox" && variation === 14
|
||
? "combobox-validation"
|
||
: component.name === "PinInput" && variation === 9
|
||
? "pin-input-validation"
|
||
: component.name === "TogglePassword" && variation === 5
|
||
? "toggle-password-validation"
|
||
: "";
|
||
const preview = validationSchema
|
||
? `<form data-schema="${validationSchema}" class="demo-validation-form">${mount}<button type="submit" class="wire-btn wire-btn--variant-default">Validate selection</button></form>`
|
||
: mount;
|
||
return `
|
||
<article class="demo-case">
|
||
<header class="demo-case-header">
|
||
<div><span class="demo-case-eyebrow">${useCase.eyebrow}</span><h2>${useCase.title}</h2><p>${useCase.description}</p></div>
|
||
<span class="demo-case-number">0${variation + 1}</span>
|
||
</header>
|
||
<div class="demo-workbench">
|
||
<div id="${slugOf(component.name)}-demo-${variation + 1}" class="demo-canvas demo-canvas--${variation + 1}">
|
||
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
|
||
${isInteractiveOverlay(component) ? `<p class="demo-interaction-hint"><span class="icon-[lucide--mouse-pointer-click] size-4" aria-hidden="true"></span>${escapeText(overlayInteractionLabel(component))}</p>` : ""}
|
||
<div class="demo-render">${preview}</div>
|
||
</div>
|
||
<section class="demo-code" aria-label="${escapeAttribute(useCase.title)} usage">
|
||
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
|
||
<pre><code>${snippet}</code></pre>
|
||
</section>
|
||
</div>
|
||
</article>`;
|
||
})
|
||
.join("");
|
||
|
||
return `// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
page ${component.name.replace(/[^A-Za-z0-9_]/g, "")}Detail {
|
||
layout = "showcase"
|
||
${playgroundStates(component)}
|
||
seo {
|
||
title = "${escapeAttribute(displayName(component.name))}"
|
||
description = "${escapeAttribute(component.purpose)}"
|
||
}
|
||
view {
|
||
<nav class="docs-breadcrumbs" aria-label="Breadcrumb">
|
||
<a href="/">Components</a><span aria-hidden="true">/</span><a href="/${component.category}">${titleCase(component.category)}</a><span aria-hidden="true">/</span><span>${escapeText(displayName(component.name))}</span>
|
||
</nav>
|
||
<header class="detail-hero">
|
||
<div class="detail-hero-copy">
|
||
<span class="showcase-eyebrow">${titleCase(component.category)} component</span>
|
||
<h1>${escapeText(displayName(component.name))}</h1>
|
||
<p>${escapeText(component.purpose)}</p>
|
||
<div class="detail-badges"><span>${component.props.length} props</span><span>${component.slots.length} slots</span><span>${component.events.length} outputs</span><span>Theme ready</span><span>Responsive</span></div>
|
||
</div>
|
||
<div class="detail-hero-icon"><span class="icon-[lucide--component] size-10" aria-hidden="true"></span></div>
|
||
</header>
|
||
${playgroundSection(component)}
|
||
<div class="detail-layout">
|
||
<main class="detail-content">
|
||
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">${demos}</div></section>
|
||
${eventDocumentation(component)}
|
||
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div>${propsTable(component)}</section>
|
||
</main>
|
||
<aside class="detail-aside">
|
||
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a>${uses.map((useCase, variation) => `<a href="#${slugOf(component.name)}-demo-${variation + 1}">${escapeText(useCase.title)}</a>`).join("")}${component.events.length ? '<a href="#events">Outputs</a>' : ""}<a href="#api">Props API</a></div>
|
||
</aside>
|
||
</div>
|
||
<nav class="detail-pagination">
|
||
${previous ? `<a href="/components/${slugOf(previous.name)}"><small>Previous</small><strong>← ${escapeText(displayName(previous.name))}</strong></a>` : "<span></span>"}
|
||
${next ? `<a href="/components/${slugOf(next.name)}"><small>Next</small><strong>${escapeText(displayName(next.name))} →</strong></a>` : "<span></span>"}
|
||
</nav>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function categoryPage(category, components) {
|
||
const liveDemoCount = components.reduce(
|
||
(total, component) => total + demoUses(component).length,
|
||
0,
|
||
);
|
||
const cards = components
|
||
.map(
|
||
(component) => `
|
||
<article class="catalog-card" data-interactive-preview="${isInteractiveOverlay(component) ? "true" : "false"}">
|
||
<div class="catalog-card-preview">
|
||
<div class="catalog-card-glow" aria-hidden="true"></div>
|
||
<div class="catalog-card-stage">${componentMount(component, 0, demoConfiguration(component, 0))}${isInteractiveOverlay(component) ? `<p class="catalog-interaction-hint"><span class="icon-[lucide--mouse-pointer-click] size-4" aria-hidden="true"></span>${escapeText(overlayInteractionLabel(component))}</p>` : ""}</div>
|
||
</div>
|
||
<div class="catalog-card-body">
|
||
<div><span class="catalog-card-category">${titleCase(category)}</span><h2>${escapeText(displayName(component.name))}</h2><p>${escapeText(component.purpose)}</p></div>
|
||
<div class="catalog-card-footer"><span>${component.props.length} props · ${component.slots.length} slots</span><a href="/components/${slugOf(component.name)}">Explore component <span aria-hidden="true">→</span></a></div>
|
||
</div>
|
||
</article>`,
|
||
)
|
||
.join("");
|
||
return `// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
page ${identifier(category)}Showcase {
|
||
layout = "showcase"
|
||
seo {
|
||
title = "${titleCase(category)} components"
|
||
description = "${components.length} ${category} components from @wrnexus/ui."
|
||
}
|
||
view {
|
||
<header class="showcase-hero showcase-hero--category">
|
||
<span class="showcase-eyebrow">Component category</span>
|
||
<h1 class="showcase-title">${titleCase(category)}</h1>
|
||
<p class="showcase-description">${components.length} unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
|
||
<div class="category-meta"><span>${components.length} components</span><span>Multiple use cases</span><span>${liveDemoCount} live configurations</span></div>
|
||
</header>
|
||
<div class="catalog-grid">${cards}</div>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
const categories = Object.groupBy(reference.components, (component) => component.category);
|
||
const categoryEntries = Object.entries(categories).sort(([left], [right]) =>
|
||
left.localeCompare(right),
|
||
);
|
||
const totalDemoCount = reference.components.reduce(
|
||
(total, component) => total + demoUses(component).length,
|
||
0,
|
||
);
|
||
|
||
function showcaseLayout() {
|
||
const directory = categoryEntries
|
||
.map(
|
||
([category, components]) => `
|
||
<section class="docs-directory-group">
|
||
<a class="docs-directory-heading" href="/${category}">${titleCase(category)}</a>
|
||
<div>${components
|
||
.map(
|
||
(component) =>
|
||
`<a data-docs-component-link href="/components/${slugOf(component.name)}">${escapeText(displayName(component.name))}</a>`,
|
||
)
|
||
.join("")}</div>
|
||
</section>`,
|
||
)
|
||
.join("");
|
||
|
||
return `// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
layout Showcase {
|
||
view {
|
||
<div class="showcase-shell">
|
||
<header class="showcase-header">
|
||
<div class="showcase-header-start">
|
||
<button type="button" class="docs-menu-toggle" data-docs-menu-toggle aria-label="Toggle component directory"><span class="icon-[lucide--menu] size-5" aria-hidden="true"></span></button>
|
||
<a class="showcase-brand" href="/"><span class="showcase-brand-mark"><span class="icon-[lucide--blocks] size-4" aria-hidden="true"></span></span>WRNexus UI</a>
|
||
<label class="docs-search"><span class="icon-[lucide--search] size-4" aria-hidden="true"></span><input type="search" placeholder="Search documentation..." data-docs-search /><kbd>⌘ K</kbd></label>
|
||
</div>
|
||
<div class="showcase-header-end">
|
||
<nav aria-label="Primary navigation" class="showcase-nav"><a href="/docs">Docs</a><a href="/block-library">Blocks</a><a href="/templates">Templates</a></nav>
|
||
<button type="button" class="showcase-theme" data-design-panel-toggle aria-label="Customize design"><span class="icon-[lucide--palette] size-5" aria-hidden="true"></span></button>
|
||
</div>
|
||
</header>
|
||
<aside class="design-panel" data-design-panel aria-label="Design configuration">
|
||
<header><div><strong>Customize UI</strong><span>Changes apply to every component</span></div><button type="button" data-design-panel-toggle aria-label="Close customization"><span class="icon-[lucide--x] size-5" aria-hidden="true"></span></button></header>
|
||
<label><span>Theme</span><select data-design-setting="style"><option value="default">Default</option><option value="soft">Soft</option><option value="sharp">Sharp</option><option value="glass">Glass</option></select></label>
|
||
<label><span>Color</span><select data-design-setting="palette"><option value="violet">Violet</option><option value="blue">Blue</option><option value="emerald">Emerald</option><option value="rose">Rose</option><option value="amber">Amber</option><option value="cyan">Cyan</option><option value="slate">Slate</option></select></label>
|
||
<label><span>Mode</span><select data-design-setting="mode"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label>
|
||
<label><span>Font</span><select data-design-setting="font"><option value="jakarta">Plus Jakarta Sans</option><option value="system">System UI</option><option value="serif">Instrument Serif</option><option value="mono">JetBrains Mono</option></select></label>
|
||
<label><span>Size</span><select data-design-setting="scale"><option value="compact">Compact</option><option value="default">Default</option><option value="comfortable">Comfortable</option><option value="large">Large</option></select></label>
|
||
<button class="design-reset" type="button" data-design-reset><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset configuration</button>
|
||
</aside>
|
||
<div class="docs-shell">
|
||
<aside class="docs-directory" data-docs-directory>
|
||
<nav aria-label="Documentation directory">
|
||
<section class="docs-directory-group docs-directory-guides"><strong>Getting Started</strong><div><a href="/docs">Introduction</a><a href="/installation">Installation</a><a href="/framework-guides">Framework guides</a><a href="/accessibility">Accessibility</a><a href="/resources">Resources</a></div></section>
|
||
<section class="docs-directory-group docs-directory-guides"><strong>WRNexusJS v0.6</strong><div><a href="/v06-types">Types</a><a href="/v06-functions">Functions</a><a href="/v06-outputs">Outputs</a><a href="/v06-stores">Stores</a><a href="/v06-imports">Imports</a><a href="/v06-state">State</a><a href="/v06-runtime">Runtime</a><a href="/v06-migration">Migration</a></div></section>
|
||
<section class="docs-directory-group docs-directory-guides"><strong>Customization</strong><div><a href="/dark-mode">Dark Mode</a><a href="/themes">Themes <small>New</small></a><a href="/colors">Colors <small>New</small></a><a href="/fonts">Fonts</a><a href="/sizing">Sizing</a></div></section>
|
||
${directory}
|
||
</nav>
|
||
<p class="docs-directory-empty" data-docs-empty>No components found.</p>
|
||
</aside>
|
||
<main class="showcase-main"><slot /></main>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function documentLayout() {
|
||
return `// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
layout Document {
|
||
props {
|
||
cookies = {}
|
||
theme = "light"
|
||
language = "en"
|
||
url = ""
|
||
pathname = "/"
|
||
}
|
||
view {
|
||
<html
|
||
data-ui-style="{cookies['wrn-ui-style'] || 'default'}"
|
||
data-ui-palette="{cookies['wrn-ui-palette'] || 'violet'}"
|
||
data-ui-mode="{cookies['wrn-ui-mode'] || 'system'}"
|
||
data-ui-font="{cookies['wrn-ui-font'] || 'jakarta'}"
|
||
data-ui-scale="{cookies['wrn-ui-scale'] || 'default'}"
|
||
>
|
||
<head></head>
|
||
<body><div id="app"><slot /></div></body>
|
||
</html>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
mkdirSync(pagesDir, { recursive: true });
|
||
mkdirSync(detailPagesDir, { recursive: true });
|
||
mkdirSync(layoutsDir, { recursive: true });
|
||
writeFileSync(join(layoutsDir, "showcase.wrn"), showcaseLayout(), "utf8");
|
||
writeFileSync(join(layoutsDir, "document.wrn"), documentLayout(), "utf8");
|
||
for (const file of readdirSync(pagesDir).filter((entry) => entry.endsWith(".wrn"))) {
|
||
unlinkSync(join(pagesDir, file));
|
||
}
|
||
for (const file of readdirSync(detailPagesDir).filter((entry) => entry.endsWith(".wrn"))) {
|
||
unlinkSync(join(detailPagesDir, file));
|
||
}
|
||
|
||
for (const [category, components] of categoryEntries) {
|
||
writeFileSync(join(pagesDir, `${category}.wrn`), categoryPage(category, components), "utf8");
|
||
for (const component of components) {
|
||
writeFileSync(
|
||
join(detailPagesDir, `${slugOf(component.name)}.wrn`),
|
||
detailPage(component, components),
|
||
"utf8",
|
||
);
|
||
}
|
||
}
|
||
|
||
const showcaseManifest = {
|
||
generatedFrom: "packages/ui/component-reference.json",
|
||
componentCount: reference.count,
|
||
categoryCount: categoryEntries.length,
|
||
totalDemoCount,
|
||
components: reference.components.map((component) => ({
|
||
name: component.name,
|
||
mount: component.mount,
|
||
slug: slugOf(component.name),
|
||
category: component.category,
|
||
purpose: component.purpose,
|
||
demoCount: demoUses(component).length,
|
||
propCount: component.props.length,
|
||
slots: component.slots,
|
||
events: component.events,
|
||
profiled: Boolean(profileFor(component.name)),
|
||
})),
|
||
};
|
||
writeFileSync(
|
||
join(exampleRoot, "showcase-manifest.json"),
|
||
`${JSON.stringify(showcaseManifest, null, 2)}\n`,
|
||
"utf8",
|
||
);
|
||
|
||
const categoryCards = categoryEntries
|
||
.map(
|
||
([category, components], index) =>
|
||
`<a class="home-category home-category--${(index % 4) + 1}" href="/${category}"><span class="home-category-index">0${index + 1}</span><div><strong>${titleCase(category)}</strong><p>${components.length} components · ${components.reduce((total, component) => total + demoUses(component).length, 0)} live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a>`,
|
||
)
|
||
.join("");
|
||
|
||
writeFileSync(
|
||
join(pagesDir, "index.wrn"),
|
||
`// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
page ComponentShowcase {
|
||
layout = "showcase"
|
||
seo {
|
||
title = "WRNexus component system"
|
||
description = "Explore every unique @wrnexus/ui component through live, production-quality examples."
|
||
}
|
||
view {
|
||
<div class="page-home">
|
||
<header class="showcase-hero showcase-hero--home">
|
||
<span class="showcase-eyebrow">The UI foundation for WRNexus</span>
|
||
<h1 class="showcase-title">Build modern products<br /><em>at framework speed.</em></h1>
|
||
<p class="showcase-description">A complete, accessible component system with ${reference.count} primitives, ready-made blocks, page templates, responsive behavior, and deeply configurable design tokens.</p>
|
||
<div class="home-actions"><a class="home-primary-action" href="/docs">Get started <span aria-hidden="true">→</span></a><a class="home-secondary-action" href="/block-library">Explore blocks</a></div>
|
||
<div class="home-install"><span>$</span><code>bun add @wrnexus/ui</code><button type="button" data-copy-text="bun add @wrnexus/ui" aria-label="Copy install command"><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span></button></div>
|
||
</header>
|
||
<section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>${reference.count} components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/block-library">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section>
|
||
<section class="showcase-stats">
|
||
<div class="showcase-stat"><strong>${reference.count}</strong><span>Unique components</span></div>
|
||
<div class="showcase-stat"><strong>${totalDemoCount}</strong><span>Live configurations</span></div>
|
||
<div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div>
|
||
<div class="showcase-stat"><strong>${categoryEntries.length}</strong><span>Focused categories</span></div>
|
||
</section>
|
||
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid">${categoryCards}</div></section>
|
||
</div>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
|
||
const docsSections = [
|
||
[
|
||
"installation",
|
||
"Installation",
|
||
"Install the package and import the shared stylesheet once in your application.",
|
||
"bun add @wrnexus/ui",
|
||
],
|
||
[
|
||
"framework-guides",
|
||
"Framework guides",
|
||
"WRNexus components use native HTML semantics and framework component mounts.",
|
||
],
|
||
[
|
||
"accessibility",
|
||
"Accessibility",
|
||
"Keyboard interaction, visible focus states, labels, and reduced-motion support ship by default.",
|
||
],
|
||
[
|
||
"dark-mode",
|
||
"Dark mode",
|
||
"Choose light, dark, or system mode. The selection persists and updates every preview.",
|
||
],
|
||
[
|
||
"themes",
|
||
"Themes",
|
||
"Theme presets control radius, surface treatment, borders, shadows, and motion.",
|
||
],
|
||
[
|
||
"colors",
|
||
"Colors",
|
||
"Semantic primary, secondary, success, warning, danger, and info tokens remain available in every palette.",
|
||
],
|
||
[
|
||
"fonts",
|
||
"Fonts",
|
||
"Select a font family globally while components continue to inherit typography correctly.",
|
||
],
|
||
[
|
||
"sizing",
|
||
"Sizing",
|
||
"Compact through large scales adjust the complete component system consistently.",
|
||
],
|
||
[
|
||
"resources",
|
||
"Resources",
|
||
"Use the component API pages, blocks, and templates as production starting points.",
|
||
],
|
||
];
|
||
writeFileSync(
|
||
join(pagesDir, "docs.wrn"),
|
||
`page Documentation {
|
||
layout = "showcase"
|
||
seo { title = "Documentation" description = "Install, configure, and use WRNexus UI." }
|
||
view {
|
||
<div class="page-docs">
|
||
<header class="docs-intro"><span class="showcase-eyebrow">Getting started</span><h1>Build with WRNexus UI</h1><p>Configure one design system and use it across components, blocks, and complete page templates.</p></header>
|
||
<section class="guide-section"><h2>Explore the documentation</h2><p>Every Getting Started and Customization topic now has its own page. Choose a guide from the sidebar to continue.</p></section>
|
||
</div>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
|
||
const v06Guides = [
|
||
[
|
||
"types",
|
||
"Types",
|
||
"TypeScript contracts for props, state, outputs, imported application types, and generated declarations.",
|
||
[
|
||
[
|
||
"Application types",
|
||
"Load ambient app/types/global.d.ts and explicitly import types from app/types/*.ts.",
|
||
],
|
||
[
|
||
"Typed contracts",
|
||
"Validate component props, state assignments, function parameters, returns, and output payloads.",
|
||
],
|
||
[
|
||
"Generated declarations",
|
||
"Generate component, output, store, layout, and server-call declarations under dist/types.",
|
||
],
|
||
],
|
||
],
|
||
[
|
||
"functions",
|
||
"Functions",
|
||
"Classify behavior for the browser, server, or both runtimes from one functions block.",
|
||
[
|
||
["Client", "Compile browser-only functions into CSP-safe client modules."],
|
||
[
|
||
"Server",
|
||
"Keep server-only functions out of browser artifacts and expose typed RPC calls when referenced.",
|
||
],
|
||
["Shared", "Emit deterministic shared helpers to both compilation targets."],
|
||
],
|
||
],
|
||
[
|
||
"outputs",
|
||
"Outputs",
|
||
"Use typed callable outputs for child-to-parent communication without application-level $emit or event.detail.",
|
||
[
|
||
["Declare", "Define zero- or one-payload output signatures in an outputs block."],
|
||
["Invoke", "Call output.name(payload) from client functions."],
|
||
["Consume", "Receive the typed payload local in parent component listeners."],
|
||
],
|
||
],
|
||
[
|
||
"stores",
|
||
"Stores",
|
||
"Create request-safe global stores and route-scoped page stores with typed actions and computed state.",
|
||
[
|
||
["Global stores", "Survive CSR navigation while remaining isolated for every SSR request."],
|
||
[
|
||
"Page stores",
|
||
"Share state with route descendants and dispose it when navigation leaves the route.",
|
||
],
|
||
["Persistence", "Persist include-only safe fields to memory, session, or local storage."],
|
||
],
|
||
],
|
||
[
|
||
"imports",
|
||
"Imports",
|
||
"Declare component, layout, store, package, and type dependencies explicitly while retaining compatibility mode.",
|
||
[
|
||
["Application imports", "Resolve @/ aliases to the active application directory."],
|
||
["Package imports", "Use named imports for components exported by @wrnexus/ui."],
|
||
[
|
||
"Compatibility",
|
||
"Run legacy, compatible, or explicit import modes during project migration.",
|
||
],
|
||
],
|
||
],
|
||
[
|
||
"state",
|
||
"State",
|
||
"Use individual or grouped typed state declarations with shared, client-only, and server-only scopes.",
|
||
[
|
||
[
|
||
"Shared state",
|
||
"Render during SSR and safely hydrate serializable values into the browser.",
|
||
],
|
||
["Client state", "Keep browser-only interaction state out of server artifacts."],
|
||
["Server state", "Keep sensitive request-only state out of hydration data."],
|
||
],
|
||
],
|
||
[
|
||
"runtime",
|
||
"Runtime",
|
||
"Split server and browser artifacts, hydrate only interactive components, and preserve compatible state during HMR.",
|
||
[
|
||
[
|
||
"Partial hydration",
|
||
"Load client code using load, idle, visible, interaction, media, or none strategies.",
|
||
],
|
||
[
|
||
"RPC",
|
||
"Validate browser-to-server inputs and outputs with same-origin and CSRF-aware defaults.",
|
||
],
|
||
["HMR", "Replace functions and preserve compatible component and store state."],
|
||
],
|
||
],
|
||
[
|
||
"migration",
|
||
"Migration",
|
||
"Upgrade older projects through idempotent v0.6 migrations with backups, dry runs, and review reports.",
|
||
[
|
||
[
|
||
"Legacy outputs",
|
||
"Convert @event declarations, $emit calls, and event.detail listeners to outputs and payload.",
|
||
],
|
||
[
|
||
"Explicit dependencies",
|
||
"Add component and layout imports only when resolution is unambiguous.",
|
||
],
|
||
[
|
||
"Safety",
|
||
"Parse and type-check changed files and roll back when migration validation fails.",
|
||
],
|
||
],
|
||
],
|
||
];
|
||
for (const [slug, title, description, sections] of v06Guides) {
|
||
writeFileSync(
|
||
join(pagesDir, `v06-${slug}.wrn`),
|
||
`// Generated by scripts/generate-showcase.mjs. Do not edit directly.
|
||
page V06${identifier(title)}Guide {
|
||
layout = "showcase"
|
||
seo {
|
||
title = "WRNexusJS v0.6 ${escapeAttribute(title)}"
|
||
description = "${escapeAttribute(description)}"
|
||
}
|
||
view {
|
||
<article class="page-docs guide-page">
|
||
<nav class="docs-breadcrumbs" aria-label="Breadcrumb"><a href="/docs">Docs</a><span aria-hidden="true">/</span><span>WRNexusJS v0.6</span><span aria-hidden="true">/</span><span>${escapeText(title)}</span></nav>
|
||
<header class="docs-intro"><span class="showcase-eyebrow">WRNexusJS v0.6</span><h1>${escapeText(title)}</h1><p>${escapeText(description)}</p></header>
|
||
${sections.map(([heading, copy]) => `<section class="guide-section"><h2>${escapeText(heading)}</h2><p>${escapeText(copy)}</p></section>`).join("")}
|
||
<nav class="guide-next"><a href="/framework-guides">Framework guides <span aria-hidden="true">→</span></a><a href="/v06-migration">Migration guide <span aria-hidden="true">→</span></a></nav>
|
||
</article>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
}
|
||
|
||
const customizationGuides = new Set(["dark-mode", "themes", "colors", "fonts", "sizing"]);
|
||
for (const [slug, title, description, code] of docsSections) {
|
||
const section = customizationGuides.has(slug) ? "Customization" : "Getting Started";
|
||
const examples =
|
||
slug === "colors"
|
||
? [
|
||
["Brand colors", "Primary and secondary tokens communicate brand hierarchy."],
|
||
[
|
||
"Status colors",
|
||
"Success, warning, danger, and info preserve their semantic meaning in every palette.",
|
||
],
|
||
[
|
||
"Component override",
|
||
'Pass color="success" or another semantic color to any component.',
|
||
],
|
||
]
|
||
: slug === "sizing"
|
||
? [
|
||
["Component size", "Every component accepts xs, sm, default, md, lg, and xl."],
|
||
[
|
||
"Global density",
|
||
"Compact, default, comfortable, and large scale the complete interface.",
|
||
],
|
||
["Responsive sizing", "Component sizing cooperates with responsive breakpoints."],
|
||
]
|
||
: slug === "themes"
|
||
? [
|
||
["Default", "Balanced surfaces, borders, elevation, and radius."],
|
||
["Soft and sharp", "Choose friendly rounded surfaces or dense operational styling."],
|
||
["Glass", "Use translucent layered surfaces while retaining semantic tokens."],
|
||
]
|
||
: [
|
||
["Overview", description],
|
||
["Configuration", "Use the design switcher in the header to test this setting live."],
|
||
[
|
||
"Component behavior",
|
||
"The setting is inherited by components, blocks, templates, and playground previews.",
|
||
],
|
||
];
|
||
writeFileSync(
|
||
join(pagesDir, `${slug}.wrn`),
|
||
`page ${identifier(slug)}Guide {
|
||
layout = "showcase"
|
||
seo {
|
||
title = "${escapeAttribute(title)}"
|
||
description = "${escapeAttribute(description)}"
|
||
}
|
||
view {
|
||
<article class="page-docs guide-page">
|
||
<nav class="docs-breadcrumbs" aria-label="Breadcrumb"><a href="/docs">Docs</a><span aria-hidden="true">/</span><span>${escapeText(title)}</span></nav>
|
||
<header class="docs-intro"><span class="showcase-eyebrow">${section}</span><h1>${escapeText(title)}</h1><p>${escapeText(description)}</p></header>
|
||
${examples.map(([heading, copy]) => `<section class="guide-section"><h2>${escapeText(heading)}</h2><p>${escapeText(copy)}</p></section>`).join("")}
|
||
${code ? `<section class="guide-section"><h2>Quick start</h2><pre><code>${escapeCode(code)}</code></pre></section>` : ""}
|
||
<nav class="guide-next"><a href="/components/button">Explore components <span aria-hidden="true">→</span></a><a href="/block-library">Browse blocks <span aria-hidden="true">→</span></a></nav>
|
||
</article>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
}
|
||
|
||
const blockCards = [
|
||
[
|
||
"Hero sections",
|
||
"Marketing",
|
||
"layout-template",
|
||
"Hero, actions, product proof, and visual stage.",
|
||
],
|
||
[
|
||
"Feature grids",
|
||
"Marketing",
|
||
"grid-2x2",
|
||
"Responsive benefits and product capability sections.",
|
||
],
|
||
["Pricing sections", "Marketing", "badge-dollar-sign", "Tier cards, comparison grids, and CTAs."],
|
||
[
|
||
"Application shells",
|
||
"Application UI",
|
||
"panel-left",
|
||
"Headers, sidebars, content, and command areas.",
|
||
],
|
||
[
|
||
"Dashboard stats",
|
||
"Application UI",
|
||
"chart-no-axes-combined",
|
||
"Metrics, trends, filters, and summaries.",
|
||
],
|
||
["Checkout panels", "Ecommerce", "shopping-cart", "Cart, address, payment, and order summary."],
|
||
[
|
||
"Article layouts",
|
||
"Blog & Articles",
|
||
"newspaper",
|
||
"Editorial headers, content, author, and sharing.",
|
||
],
|
||
[
|
||
"Authentication",
|
||
"Application UI",
|
||
"shield-check",
|
||
"Sign in, registration, recovery, and verification.",
|
||
],
|
||
];
|
||
// Keep /blocks for the generated Blocks component category.
|
||
writeFileSync(
|
||
join(pagesDir, "block-library.wrn"),
|
||
`page BlockLibrary {
|
||
layout = "showcase"
|
||
seo { title = "UI blocks" description = "Complete sections composed from WRNexus UI components." }
|
||
view {
|
||
<div class="page-marketplace page-blocks">
|
||
<nav class="market-tabs"><a href="#all">All blocks</a><a href="#marketing">Marketing</a><a href="#application">Application UI</a><a href="#ecommerce">Ecommerce</a><a href="#blog">Blog & Articles</a></nav>
|
||
<header><span class="showcase-eyebrow">Composable sections</span><h1>Ready-to-use UI blocks</h1><p>Complete responsive sections composed exclusively from the components in this library.</p></header>
|
||
<div class="market-grid">${blockCards.map(([title, category, icon, description], index) => `<article class="market-card"><div class="market-preview market-preview--${(index % 4) + 1}"><span class="icon-[lucide--${icon}] size-10"></span><div><i></i><i></i><i></i></div></div><div><small>${category}</small><h2>${title}</h2><p>${description}</p><a href="/components/${index % 2 ? "grid" : "container"}">View components →</a></div></article>`).join("")}</div>
|
||
</div>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
|
||
const templates = [
|
||
[
|
||
"Admin dashboard",
|
||
"Dashboard & Admin",
|
||
"31 pages",
|
||
"Analytics, teams, settings, billing, and operational workflows.",
|
||
],
|
||
[
|
||
"Commerce storefront",
|
||
"Ecommerce",
|
||
"18 pages",
|
||
"Catalog, product, cart, checkout, and customer account pages.",
|
||
],
|
||
[
|
||
"SaaS website",
|
||
"Websites",
|
||
"12 pages",
|
||
"Landing, pricing, about, blog, contact, and legal pages.",
|
||
],
|
||
[
|
||
"Support center",
|
||
"Support",
|
||
"9 pages",
|
||
"Knowledge base, ticketing, status, and customer help experiences.",
|
||
],
|
||
[
|
||
"Finance workspace",
|
||
"Finance & Payments",
|
||
"14 pages",
|
||
"Accounts, transactions, reporting, transfers, and controls.",
|
||
],
|
||
];
|
||
writeFileSync(
|
||
join(pagesDir, "templates.wrn"),
|
||
`page Templates {
|
||
layout = "showcase"
|
||
seo { title = "Page templates" description = "Complete pages assembled from WRNexus UI blocks." }
|
||
view {
|
||
<div class="page-marketplace page-templates">
|
||
<nav class="market-tabs"><a href="#all">All templates</a><a href="#admin">Dashboards & Admin</a><a href="#commerce">Ecommerce</a><a href="#websites">Websites</a><a href="#support">Support</a><a href="#finance">Finance</a></nav>
|
||
<header><span class="showcase-eyebrow">Complete products</span><h1>Production-ready templates</h1><p>Responsive page systems assembled from WRNexus blocks and components.</p></header>
|
||
<div class="template-list">${templates.map(([title, category, count], index) => `<article class="template-card"><div><small>${count}</small><h2>${title}</h2><p>${category} template with consistent navigation, content, forms, and responsive behavior.</p><a href="/block-library">Preview <span>→</span></a></div><div class="template-scenes template-scenes--${index + 1}"><i></i><i></i><i></i></div></article>`).join("")}</div>
|
||
</div>
|
||
}
|
||
}
|
||
`,
|
||
"utf8",
|
||
);
|
||
|
||
process.stdout.write(
|
||
`Generated ${reference.count} component detail pages, ${totalDemoCount} live demos, and ${categoryEntries.length} category pages.\n`,
|
||
);
|