/** * discover.mjs — the auto-generated SPA's registry: every bundle under plugin-designs/, * every profile inside it, classified into a composition tier. * * single → parameterized → monolithic → nested-elements-mfe → nested-mfe-spa * * Conventions it reads (all optional, all per bundle folder): * .meta.json {"type": "", "label": "Human name"} — the ONLY way to declare a * composite tier (monolithic / nested-elements-mfe / nested-mfe-spa). * .instances.json [{id, title, props}] or {"instances": [...]} — marks the bundle's * profiles PARAMETERIZED and expands one element per instance. * Pure + side-effect free: the dev plugin (dev-plugin.mjs) turns its result into the * manifest-shaped model the plugin gallery renders; any tool that needs the catalog can use it. */ import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs' export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') // Composition tiers, most-composed → individual. Drives ordering + labels. export const PROFILE_TIER = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4, } // Reader-facing names (validated vocabulary, see docs/reference.md "Profile types"): the machine // keys above stay canonical; a .meta.json may use either spelling. Pattern: // "-" — single-element → preset-element → // multi-view-element → composite-element → app-shell. export const TYPE_DISPLAY = { 'single': 'single-element', 'parameterized': 'preset-element', 'monolithic': 'multi-view-element', 'nested-elements-mfe': 'composite-element', 'nested-mfe-spa': 'app-shell', } export const TYPE_ALIASES = Object.fromEntries(Object.entries(TYPE_DISPLAY).map(([k, v]) => [v, k])) export const normalizeType = (t) => (t && PROFILE_TIER[t] != null) ? t : (TYPE_ALIASES[t] || null) function readJsonSafe(p) { try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null } } // Classify one profile into a type. Precedence: explicit .meta.json (machine key // or display alias) → parameterized (the bundle ships an *.instances.json) → single (the // simplest, default). Composite kinds are never inferred from source. export function detectProfileType(dir, profile, ownTag, allTags, hasInstances) { const meta = readJsonSafe(resolve(dir, `${profile}.meta.json`)) const declared = meta && normalizeType(meta.type) if (declared) return { type: declared, label: meta.label || profile } // Reliable auto-signals ONLY (source tag-scanning caused false positives): // a bundle with an *.instances.json -> parameterized; otherwise the simplest tier, single. // Composite tiers (monolithic / nested-elements-mfe / nested-mfe-spa) are AUTHORED via an // explicit .meta.json {"type": ...} — the single source of truth (docs/reference.md, // 'Profile types — the composability ladder'). See eco-dx (internal): PS001-composition-model. const type = hasInstances ? 'parameterized' : 'single' return { type, label: (meta && meta.label) || profile } } export function discoverSpaRegistry(root = REPO_ROOT) { const APP_DIR = resolve(root, 'plugin-designs') const bundles = [] if (!existsSync(APP_DIR)) return bundles const dirs = readdirSync(APP_DIR).sort().filter(n => { const d = resolve(APP_DIR, n) return statSync(d).isDirectory() && n !== 'template-bundle' && existsSync(resolve(d, 'index.js')) }) // First pass: every bundle's element tag (needed for composite detection). const tagOf = {} for (const n of dirs) { try { const m = readFileSync(resolve(APP_DIR, n, 'index.js'), 'utf8').match(/customElements\.define\(\s*['"]([^'"]+)['"]/) if (m) tagOf[n] = m[1] } catch { /* ignore */ } } const allTags = Object.values(tagOf) for (const n of dirs) { const dir = resolve(APP_DIR, n), tag = tagOf[n] if (!tag) continue const files = readdirSync(dir) const instFile = files.find(f => /\.instances\.json$/i.test(f)) const rawInst = instFile ? readJsonSafe(resolve(dir, instFile)) : null const instances = Array.isArray(rawInst) ? rawInst : (rawInst && Array.isArray(rawInst.instances) ? rawInst.instances : null) const hasInstances = !!(instances && instances.length) // The table's data API (its top-level apiBaseUrl), baked per instance as generate-tree does. const instApi = rawInst && !Array.isArray(rawInst) && typeof rawInst.apiBaseUrl === 'string' ? rawInst.apiBaseUrl : '' const items = [] for (const profile of files.filter(f => f.endsWith('.vue')).map(f => f.replace(/\.vue$/, '')).sort()) { const t = detectProfileType(dir, profile, tag, allTags, hasInstances) if (t.type === 'parameterized' && hasInstances) { // Expand the parameterized table into one element PER preconfigured instance. for (const inst of instances) { items.push({ profile, id: inst.id || inst.title || profile, label: inst.title || inst.id || profile, type: 'parameterized', props: inst.props || {}, apiBaseUrl: instApi }) } } else { items.push({ profile, id: profile, label: t.label, type: t.type, props: null }) } } if (!items.length) continue // Sort by composability tier ONLY (stable) so parameterized instances keep their // declared table order — e.g. Prayer Map stays first, as the landing view. items.sort((a, b) => (PROFILE_TIER[a.type] ?? 9) - (PROFILE_TIER[b.type] ?? 9)) const bestTier = Math.min(...items.map(i => PROFILE_TIER[i.type] ?? 9)) bundles.push({ bundle: n, tag, items, bestTier }) } bundles.sort((a, b) => a.bestTier - b.bestTier || a.bundle.localeCompare(b.bundle)) return bundles }