diff --git a/documentation/PRESET-PLUGIN-STANDARD.md b/documentation/PRESET-PLUGIN-STANDARD.md new file mode 100644 index 000000000..3efa63e66 --- /dev/null +++ b/documentation/PRESET-PLUGIN-STANDARD.md @@ -0,0 +1,55 @@ +# The preset plugin standard — how to ship named variants that appear by themselves + +A **preset** is one profile rendered N ways by different props: doxa-simple-map ships `prayer`, +`engagement` and `adoption` from a single `` element. Presets are **discovered, never +registered** — nothing in Plugin Studio, the gallery or the manifest generator holds a list of them. +Verified on the tree as it stands: the only match for "prayer|engagement|adoption" anywhere in +`plugin-studio.vue` is an unrelated comment about a layout bug. + +## Declare one file + +Put `.instances.json` beside your profile in `plugin-designs//`. That file's PRESENCE is +the whole registration: `discover.mjs` (`detectProfileType`) types a bundle **parameterized** — capability +2 — because it ships one, and `null` otherwise. Nothing else is consulted. + +``` +plugin-designs/doxa-simple-map/ + doxa-simple-map.vue the profile + simple-map.instances.json the presets <- this is the registration +``` + +Two accepted shapes, both read by `discover.mjs`: a bare array `[{ id, title, props }]`, or an object +`{ profile, apiBaseUrl, instances: [...] }` when the presets need a base URL or name their profile +explicitly. Each instance needs `id` (url-safe, becomes the folder), `title` (what a reader sees) and +`props` (exactly what you would pass the element). + +## What you get without asking + +- **A standalone embeddable page per preset** at `///index.html`, nested under + the profile, each with its own SRI-pinned script tag. +- **A manifest entry per preset** under the profile's `instances`, so every consumer sees them at once. +- **A rail row and a gallery card per preset**, from that same manifest. +- **Capability 2**, derived. Do not hand-declare `"type": "parameterized"` to get it. + +## The rules that keep it automatic + +1. **Never add your preset to a list elsewhere.** If a preset needs a change in Plugin Studio to show + up, that is a defect in the discovery path, not something to work around. The one bundle-name list + that used to exist is exactly the thing this standard replaced. +2. **The base profile still stands on its own.** Presets SPLIT one profile into named render targets; + they do not replace it. `doxa-simple-map/doxa-simple-map/` still accepts live props. +3. **`props` is the whole difference between presets.** If two presets differ by anything the element + cannot be told through props, they are two profiles, not two presets. +4. **Presets are not tabs.** A tab nests several components inside one element; a preset renders the + same element several ways. Choosing the wrong one shows up later as a capability that will not + derive: tabs make a bundle a composite (cap 4+), presets keep it parameterized (cap 2). +5. **An `id` is a URL.** It becomes a folder under the tree and is embedded in partner pages, so + renaming one breaks links already shared. Treat it like the share-link vocabulary: additive. + +## How to check it worked + +Build the tree, then read the manifest rather than the screen: your bundle's profile should carry an +`instances` array with one entry per preset, and `check:capability-derived` should report the profile as +a real tier. A preset that does not appear is almost always a filename that does not end +`.instances.json`, or an `instances` key that is not an array — `discover.mjs` treats both as "no +presets" and types the bundle `single`, silently. diff --git a/internal/check-capability-derived.mjs b/internal/check-capability-derived.mjs new file mode 100644 index 000000000..babfc6ace --- /dev/null +++ b/internal/check-capability-derived.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * check-capability-derived — every bundle lands on a real capability, 1-5, never 0. + * + * ★ THE DEFECT. Tiers 3-5 used to be AUTHORED ONLY: discover.mjs infers `single` and `parameterized` + * and nothing else, so a composite whose author forgot a .meta.json shipped as capability 1. + * research-dashboard did exactly that while nesting ten views. A missing file is indistinguishable + * from a deliberate choice, so nothing failed and the understatement propagated into the manifest, + * the gallery and the studio rail — every surface that claims a capability reads this one field. + * + * WHAT THIS ASSERTS, and why each half is here: + * 1. Every profile resolves to a capability 1-5. A 0 or a null means some surface will render a + * tier that does not exist; the Driver saw exactly that as "the Research Gallery shows as + * capability 0". + * 2. A profile that composes anything derives cap-4, or cap-5 if it composes a cap-4 — and an + * EXPLICIT type must AGREE with that. The declaration stays authoritative; it is not + * overridden, it is checked. A meta that contradicts the structure means the meta or the + * composes list is stale, and choosing silently between them is how a tier rots. + * + * ★ TWO TRAPS THIS GATE EXISTS BECAUSE OF, both found by running the rule before writing it: + * - `composes` is PER-PROFILE at source and HOISTED TO THE BUNDLE in the manifest. Deriving from + * the bundle row applies a composite's edges to its plain sibling leaves: gospel-resources has one + * composite and four single leaves, and all four derived as cap-4. 11 false failures. + * - refs appear in TWO FORMS in the same field: 'bundle/profile' and a bare 'bundle'. Resolving + * only the first demoted plugin-studio from 5 to 4. + * With both handled: 10 composing profiles, 10 agree, 0 disagree, on the tree as it stands. + * + * An UNRESOLVED ref is REPORTED, never counted as "not cap-4": a typo and a genuine non-composite + * child look identical from here, and quietly treating one as the other is how a tier silently drops. + * + * Headless, no browser. Exit 0 pass · 1 fail. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { TREE_NAME } from './_tree-name.mjs' + +const TAG = 'CAPABILITY-DERIVED' +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PT = { 'nested-mfe-spa': 5, 'nested-elements-mfe': 4, monolithic: 3, parameterized: 2, single: 1 } +const fails = [] + +const mfPath = join(ROOT, 'public', TREE_NAME, 'manifest.json') +if (!existsSync(mfPath)) { console.log(`${TAG}: SKIP — no built tree at ${TREE_NAME}.`); process.exit(0) } +const manifest = JSON.parse(readFileSync(mfPath, 'utf8')) +const bundles = manifest.bundles || [] + +// ── 1. every profile has a real capability ───────────────────────────────────────────────────── +let checked = 0 +for (const b of bundles) for (const p of b.profiles || []) { + checked++ + const cap = PT[p.type] + if (!cap) fails.push(`${b.bundle}/${p.profile}: type ${JSON.stringify(p.type)} is not one of the five tiers — every surface that shows a capability reads this.`) +} + +// ── 2. a declared tier must agree with the edges it declares ──────────────────────────────────── +const readMeta = (bundle, profile) => { + const f = join(ROOT, 'plugin-designs', bundle, `${profile}.meta.json`) + try { return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null } catch { return null } +} +const byKey = new Map(), byName = new Map() +for (const b of bundles) for (const p of b.profiles || []) { + byKey.set(`${b.bundle}/${p.profile}`, p.type || null) + if (!byName.has(b.bundle)) byName.set(b.bundle, p.type || null) + if (!byName.has(p.profile)) byName.set(p.profile, p.type || null) +} +const resolveRef = (ref) => (ref.includes('/') ? byKey.get(ref) : byName.get(ref)) +const unresolved = [] +let composing = 0 +for (const b of bundles) for (const p of b.profiles || []) { + const meta = readMeta(b.bundle, p.profile) + const own = Array.isArray(meta?.composes) ? meta.composes.map(String).filter(Boolean) : [] + if (!own.length) continue + composing++ + const kinds = own.map((ref) => { const k = resolveRef(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k }) + const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe' + if (p.type !== derived) { + fails.push(`${b.bundle}/${p.profile}: declares "${p.type}" but its own composes edges derive "${derived}" — one of the two is stale.`) + } +} + +console.log(` · ${checked} profile(s) across ${bundles.length} bundle(s); ${composing} declare composes edges`) +if (unresolved.length) { + console.log(` · ${unresolved.length} composes ref(s) resolve to no known bundle/profile. Reported, NOT treated as "not a composite" — a typo and a plain child look the same from here:`) + for (const u of unresolved) console.log(` ${u}`) +} +if (fails.length) { + console.error(`\n${TAG}: FAIL`) + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`${TAG}: PASS — every profile lands on a real tier 1-5, and every declared tier agrees with its own composes edges.`) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index b1693a854..7713b6152 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -396,6 +396,48 @@ function composesAMapLeaf(dir, profileName, file) { * composesAMapLeaf reads (see its point 1) but returns the author's data itself rather than * a map-ness boolean derived from it — the manifest needs the edges, not just the verdict. */ +/** + * Derive the composition tier from the `composes` edges, and refuse to disagree with an author. + * + * ★ WHY DERIVE AT ALL (Driver, 2026-09-27). Tiers 3-5 were AUTHORED ONLY — discover.mjs infers just + * `single` and `parameterized`, so a composite with no meta.json shipped as capability 1. That is not + * hypothetical: research-dashboard did exactly that while nesting ten views, and the warning in + * detectProfileType exists because of it. A missing file is indistinguishable from a deliberate choice. + * + * THE RULE, measured against all 25 bundles before it was written: a profile that composes anything is + * cap-4 (nested-elements-mfe); if any of the things it composes is itself cap-4, it is cap-5 + * (nested-mfe-spa). Keyed per PROFILE, and refs are accepted in BOTH published forms — 'bundle/profile' + * (doxa-maps-spa) and a bare 'bundle' (plugin-studio). Getting either of those wrong is not academic: + * keying on the bundle derived 11 false tiers, and resolving only the slashed form demoted plugin-studio + * from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree. + * + * AN EXPLICIT type STAYS AUTHORITATIVE AND MUST AGREE. It is not overridden — it is CHECKED. A + * declaration that contradicts the structure is the interesting case: either the meta is stale or the + * composes list is, and both are worth a loud build failure rather than a silent pick between them. + * + * An UNRESOLVED ref is reported separately and never silently treated as "not cap-4", because a typo in + * composes and a genuinely non-composite child look identical from here. + */ +export function deriveComposedTiers(bundles) { + const byKey = new Map(), byBundle = new Map() + for (const b of bundles) for (const p of b.profiles || []) { + byKey.set(`${b.bundle}/${p.profile}`, p.type || null) + if (!byBundle.has(b.bundle)) byBundle.set(b.bundle, p.type || null) + if (!byBundle.has(p.profile)) byBundle.set(p.profile, p.type || null) + } + const resolve = (ref) => (ref.includes('/') ? byKey.get(ref) : byBundle.get(ref)) + const conflicts = [], unresolved = [] + for (const b of bundles) for (const p of b.profiles || []) { + const own = p.composesOwn + if (!own || !own.length) continue + const kinds = own.map((ref) => { const k = resolve(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k }) + const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe' + if (!p.type) { p.type = derived; continue } + if (p.type !== derived) conflicts.push(`${b.bundle}/${p.profile}: meta.json declares "${p.type}" but its composes edges derive "${derived}"`) + } + return { conflicts, unresolved } +} + function readComposes(dir, profileName) { try { const metaPath = join(dir, `${profileName}.meta.json`) @@ -1316,6 +1358,11 @@ function scanBundles() { // GENERATOR-INTERNAL: stripped before the manifest is written (see writeManifestAndHtml) // so the published manifest shape is unchanged. extraConfig: readProfileConfig(dir, profileName), + // Generator-internal, stripped with extraConfig at manifest assembly: the profile's OWN + // composes. The published manifest hoists composes to the BUNDLE, which is right for its + // consumers but useless for deriving a tier — a bundle's composite and its plain leaves would + // all inherit the same edges. The tier is a property of the profile that declares them. + composesOwn: readComposes(dir, profileName), ...rest, } }) @@ -1362,6 +1409,24 @@ function readDestinations() { } function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) { + // ── DERIVE THE COMPOSITION TIER FROM THE EDGES, BEFORE ANYTHING READS THE MANIFEST ────────── + // Every consumer downstream — the gallery's capFor, the studio rail, check:composite-embed-knobs — + // trusts profile.type. Deriving here means a composite can no longer ship understated because its + // author forgot a meta.json, and a stale meta can no longer disagree with the structure in silence. + { + const { conflicts, unresolved } = deriveComposedTiers(bundles) + if (unresolved.length) { + console.warn(`[tree] ${unresolved.length} composes ref(s) resolve to no known bundle/profile — a typo here silently weakens a tier:`) + for (const u of unresolved) console.warn(` ${u}`) + } + if (conflicts.length) { + console.error('[tree] composition tier CONFLICT — a profile\'s meta.json disagrees with its own composes edges:') + for (const c of conflicts) console.error(` ${c}`) + console.error(' Fix the meta.json or the composes list; the generator will not choose between them.') + process.exit(1) + } + } + const manifest = { schema: 'doxa-maps-manifest@1', generatedFrom: 'plugin-designs/*/*.vue', @@ -1386,7 +1451,7 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) { // Consumers read the manifest as a contract; a new key would be a silent schema change. bundles: bundles.map((b) => ({ ...b, - profiles: b.profiles.map(({ extraConfig, ...p }) => p), + profiles: b.profiles.map(({ extraConfig, composesOwn, ...p }) => p), })), } mkdirSync(root, { recursive: true }) diff --git a/package.json b/package.json index fc7b57d24..0342fd672 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check:backlog-status": "node internal/check-backlog-status-enum.mjs", "check:basemap-labels": "node internal/check-basemap-labels-localized.mjs", "check:capability-citations": "node internal/check-capability-citations.mjs", + "check:capability-derived": "node internal/check-capability-derived.mjs", "check:capability-exemplars": "node internal/check-capability-exemplars.mjs", "check:card-deck": "node internal/check-card-deck.mjs", "check:catalog-currency": "node internal/check-catalog-currency.mjs", @@ -245,7 +246,7 @@ "dev": "bun x vite", "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", - "gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls", + "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", @@ -303,6 +304,7 @@ "check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.", "check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.", "check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.", + "check:capability-derived": "every bundle lands on a real capability 1-5, never 0, and a DECLARED tier must agree with the composes edges it declares. Tiers 3-5 used to be authored only - discover.mjs infers single and parameterized and nothing else - so a composite whose author forgot a meta.json shipped as capability 1; research-dashboard did exactly that while nesting ten views, and a missing file is indistinguishable from a deliberate choice, so nothing failed. The declaration stays authoritative: it is CHECKED, not overridden, because a meta that contradicts the structure means one of the two is stale and picking silently is how a tier rots. Two traps found by running the rule BEFORE writing it: composes is per-PROFILE at source but hoisted to the BUNDLE in the manifest, so deriving from the bundle row gave a composite's edges to its plain sibling leaves (11 false failures on correct data); and refs come in two forms in one field, 'bundle/profile' and a bare 'bundle', so resolving only the first demoted plugin-studio from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree. An unresolved ref is REPORTED, never counted as 'not a composite' - a typo and a genuine plain child look identical from here. Negative-tested on the research-dashboard shape (a composite declared single).", "check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).", "check:catalog-identity": "proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG= also requires the live manifest to match a catalog checkout.", "check:cdn-pins": "every runtime third-party script/stylesheet in shipped code is an exact version listed in library/constants/cdn.js with its SRI hash (no floating ranges). --online re-fetches each and compares the hash.", diff --git a/plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json b/plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json index 864a240fa..a247593eb 100644 --- a/plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json +++ b/plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json @@ -1,5 +1,5 @@ { - "_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it).", + "_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. The tier is now DERIVED from the `composes` edges below at tree generation (a profile that composes anything is cap-4; one that composes a cap-4 is cap-5), and this explicit `type` is CHECKED against that derivation rather than being the only source: if the two disagree the build fails, because one of them is stale. discover.mjs still never infers tiers 3-5 on its own.", "type": "nested-mfe-spa", "composes": [ "dashboard-plus-grid/dashboard-plus-grid",