import type { CustomJsxAuthoringCatalog, CustomJsxComponentApi, CustomJsxLiteralValue, CustomJsxPropDescriptor, } from "../src/core/component-catalog-types"; import type { CustomJsxComponentPackage } from "../src/core/component-types"; export const GENERATED_COMPONENT_REFERENCE_PATHS = [ "components.md", "mantine-core.md", "mantine-dates.md", "mantine-charts.md", "homarr-components.md", ] as const; export type GeneratedComponentReferencePath = (typeof GENERATED_COMPONENT_REFERENCE_PATHS)[number]; const packageReferences = [ { packageName: "@mantine/core", path: "mantine-core.md", title: "Mantine Core", }, { packageName: "@mantine/dates", path: "mantine-dates.md", title: "Mantine Dates", }, { packageName: "@mantine/charts", path: "mantine-charts.md", title: "Mantine Charts", }, { packageName: "@homarr/widgets", path: "homarr-components.md", title: "Homarr components", }, ] as const satisfies readonly { packageName: CustomJsxComponentPackage; path: Exclude; title: string; }[]; export function renderCustomWidgetComponentReferences( catalog: Readonly, ): Readonly> { validateCatalogReferences(catalog); const references = Object.fromEntries([ ["components.md", renderCatalogOverview(catalog)], ...packageReferences.map(({ packageName, path, title }) => [ path, renderPackageReference(catalog, packageName, title), ]), ]) as Record; return references; } export function getComponentReferenceAnchor(name: string): string { return `component-${[...name] .map((character) => /^[a-z0-9-]$/iu.test(character) ? character.toLowerCase() : `-${character.codePointAt(0)?.toString(16)}-`, ) .join("")}`; } function renderCatalogOverview(catalog: Readonly): string { const lines = [ "# Component catalog", "", generatedNotice(catalog), "", "This is the complete offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: ordinary serializable props pass through unless the capability boundary explicitly blocks them.", "", "## Package references", "", ...packageReferences.map(({ packageName, path, title }) => { const components = catalog.components.filter((component) => component.package === packageName); const enabledCount = components.filter((component) => component.safety !== "denied").length; const deniedCount = components.length - enabledCount; return `- [${title}](${path}) — ${enabledCount} enabled, ${deniedCount} denied`; }), "", "## Authoring boundary", "", "- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.", "- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.", '- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.', "- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).", `- Use [\`RecursiveList\`](homarr-components.md#${getComponentReferenceAnchor("RecursiveList")}) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.`, "- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.", "", "## Complete enabled inventory", "", ...renderInventoryByPackage(catalog, false), "", "## Complete denied inventory", "", ...renderInventoryByPackage(catalog, true), "", "## Shared prop API", "", "These props are available to enabled components unless a component-specific record overrides the same name.", "", renderPropTable(catalog, catalog.globalProps), "", "## Blocked capabilities", "", "| Kind | Name | Reason |", "| --- | --- | --- |", ...catalog.blockedCapabilities.map( (capability) => `| ${escapeTableCell(capability.kind)} | ${renderCode(capability.name)} | ${escapeTableCell(capability.reason)} |`, ), "", ]; return `${lines.join("\n").trimEnd()}\n`; } function renderPackageReference( catalog: Readonly, packageName: CustomJsxComponentPackage, title: string, ): string { const components = catalog.components.filter((component) => component.package === packageName); const enabled = components.filter((component) => component.safety !== "denied"); const denied = components.filter((component) => component.safety === "denied"); const lines = [ `# ${title}`, "", generatedNotice(catalog), "", `Package: \`${packageName}\`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.`, "", ...packageGuidance(packageName), "", "## Enabled inventory", "", enabled.length > 0 ? renderCodeList(enabled.map(({ name }) => name)) : "_None._", "", "## Denied inventory", "", denied.length > 0 ? renderCodeList(denied.map(({ name }) => name)) : "_None._", "", "## Component records", "", ...components.flatMap((component) => renderComponentRecord(catalog, component)), ]; return `${lines.join("\n").trimEnd()}\n`; } function renderComponentRecord( catalog: Readonly, component: Readonly, ): string[] { const details = [ ``, `### \`${component.name}\``, "", `- Safety: \`${component.safety}\``, `- Category: \`${component.category}\``, `- Documentation: [upstream component API](${component.documentationUrl})`, `- Temporary bind: ${renderBinding(component)}`, `- Subcomponents: ${component.subcomponents.length > 0 ? renderCodeList(component.subcomponents) : "none"}`, `- Component-specific blocked props: ${ component.blockedProps.length > 0 ? component.blockedProps .map(({ name, reason }) => `${renderCode(name)} — ${escapeTableCell(reason)}`) .join(", ") : "none" }`, "- Accessibility:", ...(component.accessibilityRequirements.length > 0 ? component.accessibilityRequirements.map((requirement) => ` - ${requirement}`) : [" - No additional catalog requirement; preserve semantic labels and text alternatives."]), ]; if (component.description) details.push("", component.description); if (component.deniedReason) details.push("", `Denied reason: ${component.deniedReason}.`); details.push("", "#### Component-specific props", ""); if (component.props.length === 0) { details.push( component.safety === "denied" ? "_No authorable props because this component is denied._" : "_No component-specific props. Use the shared prop API._", ); } else { details.push(renderPropTable(catalog, component.props)); } details.push(""); return details; } function renderPropTable( catalog: Readonly, props: readonly Readonly[], ): string { const lines = [ "| Prop | Required | Type | Literal values | Description |", "| --- | --- | --- | --- | --- |", ...props.map((prop) => { const type = catalog.types[prop.typeRef]; if (type === undefined) throw new Error(`Prop '${prop.name}' references missing type ${prop.typeRef}`); return `| ${renderCode(prop.name)} | ${prop.required ? "yes" : "no"} | ${renderCode(type)} | ${renderLiteralValues(prop.literalValues)} | ${escapeTableCell(prop.description ?? "—")} |`; }), ]; return lines.join("\n"); } function renderInventoryByPackage(catalog: Readonly, denied: boolean): string[] { return packageReferences.flatMap(({ packageName, path, title }) => { const components = catalog.components.filter( (component) => component.package === packageName && (component.safety === "denied") === denied, ); return [ `### ${title}`, "", components.length > 0 ? components .map( (component) => `[\`${escapeMarkdownLinkLabel(component.name)}\`](${path}#${getComponentReferenceAnchor(component.name)})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`, ) .join(", ") : "_None._", "", ]; }); } function packageGuidance(packageName: CustomJsxComponentPackage): string[] { switch (packageName) { case "@mantine/core": return [ "## Package guidance", "", "Use declarative props and `bind` adapters instead of event callbacks. Homarr scopes supported overlays to the widget root. Use Homarr's `RecursiveList` when a tree needs a custom arbitrary-depth node template.", ]; case "@mantine/dates": return [ "## Package guidance", "", "Date bindings are temporary serialized inputs. Persisted locale, time-zone, and date choices belong in widget options. Include a textual date or agenda when a visual date control carries meaning.", ]; case "@mantine/charts": return [ "## Package guidance", "", "Pass sanitized JSON data through serializable props. Use [immutable local `const` blocks](runtime.md#immutable-local-bindings) for repeated derived chart values; authored formatters and renderer callbacks remain blocked. Include a textual summary and do not rely on color alone.", ]; case "@homarr/widgets": return [ "## Package guidance", "", "Homarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.", ]; } } function renderBinding(component: Readonly): string { if (!component.bind) return "not available"; const conditionalType = getConditionalBindingType(component.name); return `\`${component.bind.type}\` via \`${component.bind.initialProp}\`; read from \`inputs.\`${conditionalType ? `; ${conditionalType}` : ""}`; } function getConditionalBindingType(componentName: string): string | null { if (componentName === "Accordion") return "becomes `string[]` when `multiple` is true"; if (["Chip.Group", "ChipGroup"].includes(componentName)) return "becomes `string[]` when `multiple` is true"; if (componentName === "TreeSelect") return "becomes `string[]` when `mode` is `multiple` or `checkbox`"; if ( ["DatePicker", "DatePickerInput", "MonthPicker", "MonthPickerInput", "YearPicker", "YearPickerInput"].includes( componentName, ) ) { return "becomes `string[]` when `type` is `multiple` or `range`"; } if (["DateTimePicker", "InlineDateTimePicker"].includes(componentName)) return "becomes `string[]` when `type` is `range`; `multiple` is not supported"; return null; } function renderLiteralValues(values: readonly CustomJsxLiteralValue[] | undefined): string { return values && values.length > 0 ? values.map((value) => renderCode(JSON.stringify(value) ?? "null")).join(", ") : "—"; } function renderCodeList(values: readonly string[]): string { return values.map(renderCode).join(", "); } function renderCode(value: string): string { return `${escapeHtml(value)}`; } function escapeTableCell(value: string): string { return escapeHtml(value).replaceAll("|", "|"); } function escapeHtml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|"); } function escapeMarkdownLinkLabel(value: string): string { return value.replaceAll("[", "\\[").replaceAll("]", "\\]"); } function generatedNotice(catalog: Readonly): string { return `> Generated from \`component-catalog-v${catalog.schemaVersion}.json\`. Catalog schema v${catalog.schemaVersion} · Custom Widget ${catalog.customWidgetVersion} · Mantine ${catalog.mantineVersion}. Do not edit this file by hand.`; } function validateCatalogReferences(catalog: Readonly) { const componentNames = catalog.components.map(({ name }) => name); if (new Set(componentNames).size !== componentNames.length) throw new Error("Catalog component names must be unique"); for (const component of catalog.components) { if (!packageReferences.some(({ packageName }) => packageName === component.package)) { throw new Error(`Catalog component '${component.name}' uses unsupported package '${component.package}'`); } for (const prop of component.props) assertPropType(catalog, prop, `${component.name}.${prop.name}`); } for (const prop of catalog.globalProps) assertPropType(catalog, prop, prop.name); } function assertPropType( catalog: Readonly, prop: Readonly, label: string, ) { if (!Number.isSafeInteger(prop.typeRef) || catalog.types[prop.typeRef] === undefined) { throw new Error(`Catalog prop '${label}' references missing type ${prop.typeRef}`); } }