From 73ee142b7bf276266b4f6109a496268edce10c41 Mon Sep 17 00:00:00 2001 From: Alexander Rybakov Date: Thu, 21 May 2026 13:19:55 +0200 Subject: [PATCH 1/3] add @@slots route --- packages/cmsui/config/routes.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index 9032a55a241..b9ee78c5522 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -49,6 +49,17 @@ export default function install(config: ConfigType) { }, ], }, + { + type: 'prefix', + path: '@@slots', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/slots.tsx', + }, + ], + }, { type: 'prefix', path: 'controlpanel', From a2c661dabbb082b2f46e4332fbc31fcbc877c6bc Mon Sep 17 00:00:00 2001 From: Alexander Rybakov Date: Thu, 21 May 2026 14:56:52 +0200 Subject: [PATCH 2/3] build slot composition tree in init-loaders.js --- packages/registry/bin/init-loaders.js | 6 +- packages/registry/bin/slot-tree.js | 89 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/registry/bin/slot-tree.js diff --git a/packages/registry/bin/init-loaders.js b/packages/registry/bin/init-loaders.js index fca8a9b3514..d8eb79c5189 100755 --- a/packages/registry/bin/init-loaders.js +++ b/packages/registry/bin/init-loaders.js @@ -15,6 +15,7 @@ import { createAddonsStyleLoader } from '@plone/registry/create-addons-styles-lo import { createAddonsLocalesLoader } from '@plone/registry/create-addons-locales-loader'; import { PloneRegistryVitePlugin } from '@plone/registry/vite-plugin'; import config from '@plone/registry'; +import { buildSlotTree } from './slot-tree.js'; const titleCase = (w) => w.slice(0, 1).toUpperCase() + w.slice(1, w.length); @@ -106,14 +107,17 @@ async function evaluateAddons(addonsLoaderPath) { const { default: loader, addonsInfo } = await server.ssrLoadModule(addonsLoaderPath); + const populatedConfig = loader(config); + fs.writeFileSync( path.join(ploneDir, 'registry.routes.json'), - JSON.stringify(loader(config).routes, null, 2), + JSON.stringify(populatedConfig.routes, null, 2), ); fs.writeFileSync( path.join(ploneDir, 'registry.addonsInfo.json'), JSON.stringify(addonsInfo, null, 2), ); + await buildSlotTree(populatedConfig, ploneDir, server); } finally { await server.close(); } diff --git a/packages/registry/bin/slot-tree.js b/packages/registry/bin/slot-tree.js new file mode 100644 index 00000000000..5a1d5e44a1f --- /dev/null +++ b/packages/registry/bin/slot-tree.js @@ -0,0 +1,89 @@ +import fs from 'fs'; +import path from 'path'; + +// Uses the Vite module graph rather than file scanning so only truly-loaded +// modules are included and addons in node_modules are covered naturally. +async function findSlotRendererImporters(server) { + const resolved = await server.pluginContainer.resolveId( + '@plone/layout/slots/SlotRenderer', + undefined, + { ssr: true }, + ); + if (!resolved) return []; + + const mod = server.moduleGraph.getModuleById(resolved.id); + if (!mod) return []; + + return [...mod.importers].filter((m) => m.file); +} + +// Uses client-side (non-SSR) transforms so "SlotRenderer" stays visible as a +// named binding in the compiled output, letting the pattern anchor on it and +// avoid false positives from unrelated `name` props. +async function findReferencedSlotNames(server) { + const importers = await findSlotRendererImporters(server); + const names = new Set(); + const pattern = + /\w+\s*\(\s*SlotRenderer\s*,\s*\{[^{]*\bname\s*:\s*["']([^"']+)["']/g; + + for (const mod of importers) { + try { + const result = await server.transformRequest(mod.url); + if (!result?.code) continue; + for (const match of result.code.matchAll(pattern)) { + names.add(match[1]); + } + } catch { + // ignore modules that fail to transform + } + } + + return names; +} + +// The SSR transform renames import bindings, so component.toString() no longer +// mentions "SlotRenderer" by name — but JSX always emits name: "foo" as an +// object property, which this pattern matches reliably. +function childSlotsOf(component, allKnownSlots, parentSlotId) { + let source; + try { + source = component.toString(); + } catch { + return []; + } + return [...source.matchAll(/name\s*:\s*["']([^"']+)["']/g)] + .map(([, name]) => name) + .filter((name) => allKnownSlots.has(name) && name !== parentSlotId); +} + +function buildChildrenMap(slots, allKnownSlots) { + const childrenOf = {}; + for (const [slotId, slotManager] of Object.entries(slots)) { + for (const componentName of slotManager.slots) { + const children = new Set( + (slotManager.data[componentName] ?? []).flatMap(({ component }) => + childSlotsOf(component, allKnownSlots, slotId), + ), + ); + if (children.size > 0) { + childrenOf[slotId] ??= {}; + childrenOf[slotId][componentName] = [...children]; + } + } + } + return childrenOf; +} + +export async function buildSlotTree(config, ploneDir, server) { + const referencedSlotNames = await findReferencedSlotNames(server); + const allKnownSlots = new Set([ + ...Object.keys(config.slots), + ...referencedSlotNames, + ]); + const childrenOf = buildChildrenMap(config.slots, allKnownSlots); + + fs.writeFileSync( + path.join(ploneDir, 'registry.slottree.json'), + JSON.stringify(childrenOf, null, 2), + ); +} From ccc0e9068aaab193a5755e16874e0deefd36b7de Mon Sep 17 00:00:00 2001 From: Alexander Rybakov Date: Thu, 21 May 2026 15:01:37 +0200 Subject: [PATCH 3/3] implemented slots view --- packages/cmsui/locales/de/common.json | 13 ++ packages/cmsui/locales/en/common.json | 9 + packages/cmsui/locales/it/common.json | 9 + packages/cmsui/news/8264.feature | 1 + packages/cmsui/routes/slots.tsx | 241 ++++++++++++++++++++++++++ packages/registry/news/8264.feature | 1 + 6 files changed, 274 insertions(+) create mode 100644 packages/cmsui/locales/de/common.json create mode 100644 packages/cmsui/news/8264.feature create mode 100644 packages/cmsui/routes/slots.tsx create mode 100644 packages/registry/news/8264.feature diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json new file mode 100644 index 00000000000..174cfd63bb6 --- /dev/null +++ b/packages/cmsui/locales/de/common.json @@ -0,0 +1,13 @@ +{ + "cmsui": { + "views": { + "slots": { + "heading": "Slots", + "registeredTab": "Registrierte Slots", + "registeredDescription": "Alle in der Konfiguration registrierten Slots, jeweils mit ihrer geordneten Liste von Komponenten.", + "compositionTab": "Slot-Komposition", + "compositionDescription": "Slots als Kompositionsbaum, der zeigt, welche Komponenten verschachtelte Slots rendern. Enthält auch Slots, denen noch keine Komponenten zugewiesen wurden." + } + } + } +} \ No newline at end of file diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index d76a51746ab..2a971393604 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -62,6 +62,15 @@ }, "sidebar": { "label": "Sidebar" + }, + "views": { + "slots": { + "heading": "Slots", + "registeredTab": "Registered Slots", + "registeredDescription": "All slots registered in the configuration, each with their ordered list of components.", + "compositionTab": "Slot Composition", + "compositionDescription": "Slots as a composition tree, showing which components render nested slots. Includes slots with no components registered yet." + } } } } diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 3f7ee0f1274..597ea127bc8 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -52,6 +52,15 @@ }, "sidebar": { "label": "Barra laterale destra" + }, + "views": { + "slots": { + "heading": "Slot", + "registeredTab": "Slot registrati", + "registeredDescription": "Tutti gli slot registrati nella configurazione, ciascuno con il proprio elenco ordinato di componenti.", + "compositionTab": "Composizione degli slot", + "compositionDescription": "Slot come albero di composizione, che mostra quali componenti rendono slot annidati. Include gli slot senza componenti ancora registrati." + } } } } diff --git a/packages/cmsui/news/8264.feature b/packages/cmsui/news/8264.feature new file mode 100644 index 00000000000..993320bbbeb --- /dev/null +++ b/packages/cmsui/news/8264.feature @@ -0,0 +1 @@ +Add slots manager view at `@@slots` for overview over registered slots and slot composition. @arybakov05 \ No newline at end of file diff --git a/packages/cmsui/routes/slots.tsx b/packages/cmsui/routes/slots.tsx new file mode 100644 index 00000000000..c48480a05bc --- /dev/null +++ b/packages/cmsui/routes/slots.tsx @@ -0,0 +1,241 @@ +import { useState } from 'react'; +import { + type LoaderFunctionArgs, + redirect, + type RouterContextProvider, + useNavigate, +} from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { clsx } from 'clsx'; +import { Button, Container, Tabs } from '@plone/components/quanta'; +import ChevronRight from '@plone/components/icons/chevron-right.svg?react'; +import { Plug } from '@plone/layout/components/Pluggable'; +import Back from '@plone/components/icons/arrow-left.svg?react'; +import config from '@plone/registry'; +// eslint-disable-next-line import/no-unresolved +import slotTree from 'seven/.plone/registry.slottree.json'; +import { ploneContentContext } from 'seven/app/middleware.server'; +import { requireAuthCookie } from '@plone/react-router'; + +// { slotId: { componentName: [childSlotId, ...] } } +type SlotTreeMap = Record>; + +interface SlotNodeProps { + slotId: string; + tree: SlotTreeMap; + depth: number; +} + +interface ComponentNodeProps { + name: string; + childSlots: string[]; + tree: SlotTreeMap; + depth: number; +} + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const content = context.get(ploneContentContext); + const userActions = content['@components']?.actions?.user ?? []; + + const isManager = userActions.find((action) => action.id === 'plone_setup'); + + if (!isManager) { + return redirect('/login'); + } +} + +function ComponentNode({ name, childSlots, tree, depth }: ComponentNodeProps) { + const [isOpen, setIsOpen] = useState(false); + const isLeaf = childSlots.length === 0; + + return ( +
+ + {isOpen && ( +
+ {childSlots.map((slotId) => ( + + ))} +
+ )} +
+ ); +} + +function SlotNode({ slotId, tree, depth }: SlotNodeProps) { + const [isOpen, setIsOpen] = useState(false); + const registeredComponents = config.slots[slotId]?.slots ?? []; + const componentChildMap = tree[slotId] ?? {}; + const isLeaf = registeredComponents.length === 0; + + return ( +
0 ? 'border-t border-border' : ''}> + + {isOpen && ( +
+ {registeredComponents.map((name) => ( + + ))} +
+ )} +
+ ); +} + +function RegisteredSlotCard({ slotId }: { slotId: string }) { + const components = config.slots[slotId]?.slots ?? []; + + return ( +
+
+ {slotId} + ({components.length}) +
+ {components.length > 0 && ( +
+ {components.join(' · ')} +
+ )} +
+ ); +} + +function SlotList({ slotIds, tree }: { slotIds: string[]; tree: SlotTreeMap }) { + return ( +
+ {slotIds.map((slotId) => ( +
+ +
+ ))} +
+ ); +} + +function RegisteredSlotList({ slotIds }: { slotIds: string[] }) { + return ( +
+ {slotIds.map((slotId) => ( + + ))} +
+ ); +} + +export default function SlotsView() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const tree = slotTree as SlotTreeMap; + + const allChildren = new Set( + Object.values(tree).flatMap((componentMap) => + Object.values(componentMap).flat(), + ), + ); + + const registeredSlots = Object.keys(config.slots).sort((a, b) => + a.localeCompare(b), + ); + const rootSlots = registeredSlots.filter((s) => !allChildren.has(s)); + + return ( +
+ + + + +

+ {t('cmsui.views.slots.heading')} +

+ +

+ {t('cmsui.views.slots.registeredDescription')} +

+ + + ), + }, + { + id: 'composition', + title: t('cmsui.views.slots.compositionTab'), + content: ( + <> +

+ {t('cmsui.views.slots.compositionDescription')} +

+ + + ), + }, + ]} + /> +
+
+ ); +} diff --git a/packages/registry/news/8264.feature b/packages/registry/news/8264.feature new file mode 100644 index 00000000000..d38d1eb3bb2 --- /dev/null +++ b/packages/registry/news/8264.feature @@ -0,0 +1 @@ +Build a slot composition tree during init-loaders, mapping which components render which nested slots. @arybakov05 \ No newline at end of file