A plugin says who else may list it: visibility, refused when it is not one of two words
The platform dashboard is moving off one shared index onto each team's own /cdn/dx-maps/manifest.json, so this manifest now has to answer a question it never carried: may another team list this plugin. Per bundle and per profile: "team" listed only in the owning team's library "public" also listed in every other team's library, read-only from our CDN absent team Emitted ONLY when public, so every manifest in the tree keeps the bytes it has and nothing becomes visible by upgrading the generator — verified by rebuilding the tree and comparing: byte-identical. Three things this encodes rather than states. `stage` is NOT this field. It sits directly beside it, reads like a fit, and is maturity rather than audience — using it would hide every experimental plugin from its own team and publish every production one to everybody, wrong in both directions at once. The header says so where the next reader will be standing. An unrecognised value is REFUSED, not defaulted. Coercing "Public" to team is the safe direction and that is what makes it dangerous: the author has written their opt-in, the manifest says private, and the plugin never appears anywhere, with no error to search for. The generator exits, naming the profile and the value, at the build that would have published it. Both halves were mutation tested: "public" reaches the manifest at bundle and profile level; "Public" exits 1; absent leaves the file byte-identical. A bundle is public if ANY of its profiles is, because a bundle is one IIFE. Profiles share a single .js, so publishing one publishes the file the others compile into; there is no shape of this field that makes half a script fetchable. The per-profile key is still emitted, so a reader can list only the profiles that opted in while loading the one bundle. The built manifest and the dev server's both go through profile-meta.mjs, so the dev Studio and the built Studio cannot disagree about this the way they once did about "widget".
This commit is contained in:
parent
182611c94b
commit
674121df5d
3 changed files with 64 additions and 6 deletions
|
|
@ -45,7 +45,7 @@ import { createHash } from 'node:crypto'
|
|||
// without a separate tierMap. Previously the manifest carried no `type`, so a manifest-fed
|
||||
// consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth.
|
||||
import { detectProfileType } from './plugin-gallery/discover.mjs'
|
||||
import { profileMetaFields, bundleStage } from './profile-meta.mjs'
|
||||
import { profileMetaFields, bundleStage, bundleVisibility, readProfileMeta, VISIBILITY } from './profile-meta.mjs'
|
||||
import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs'
|
||||
import { deriveComposedTiers } from './_composed-tiers.mjs'
|
||||
import { syncAppShells } from './sync-app-shells.mjs'
|
||||
|
|
@ -1165,6 +1165,9 @@ function scanBundles() {
|
|||
}
|
||||
const PROTOTYPE_RE = /template|example|starter|sample|demo|clone/i
|
||||
const bundles = []
|
||||
// <profile>.meta.json `visibility` values this generator does not recognise. Collected across the
|
||||
// whole scan and reported together below, so an author fixing three typos sees three, not one.
|
||||
const visibilityErrors = []
|
||||
for (const entry of readdirSync(APP_PROFILES).sort()) {
|
||||
const dir = join(APP_PROFILES, entry)
|
||||
if (!isBundleDir(entry) || !statSync(dir).isDirectory()) continue
|
||||
|
|
@ -1304,9 +1307,13 @@ function scanBundles() {
|
|||
? composesAMapLeaf(dir, profileName, file)
|
||||
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
|
||||
|
||||
// An unrecognised `visibility` is collected, not coerced — see profile-meta.mjs VISIBILITY.
|
||||
const badVisibility = readProfileMeta(dir, profileName).visibilityInvalid
|
||||
if (badVisibility !== null) visibilityErrors.push(`${entry}/${profileName}: "${badVisibility}"`)
|
||||
|
||||
return {
|
||||
profile: profileName,
|
||||
...profileMetaFields(dir, profileName), // stage / heavy / widget, shared with the dev manifest
|
||||
...profileMetaFields(dir, profileName), // stage / visibility / heavy / widget, shared with the dev manifest
|
||||
file,
|
||||
description: description || fileTag || '',
|
||||
element: tag,
|
||||
|
|
@ -1366,9 +1373,22 @@ function scanBundles() {
|
|||
elements: bundleElements,
|
||||
...(composesUnique.length ? { composes: composesUnique } : {}),
|
||||
stage: bundleStage(profiles),
|
||||
// Only when public — absent means team (profile-meta.mjs), so today's manifests are unchanged.
|
||||
...(bundleVisibility(profiles) === 'public' ? { visibility: 'public' } : {}),
|
||||
profiles,
|
||||
})
|
||||
}
|
||||
|
||||
// ★ REFUSE AN UNKNOWN VISIBILITY. This field decides whether another team's dashboard lists the
|
||||
// plugin, and the safe default (team) is precisely why a typo must not be silent: the author has
|
||||
// written their opt-in, the manifest says private, and the plugin simply never appears anywhere —
|
||||
// a bug with no error to search for. Fail here, at the build that would have published it.
|
||||
if (visibilityErrors.length) {
|
||||
console.error('[tree] unknown `visibility` in a profile meta.json — expected ' + VISIBILITY.map((v) => `"${v}"`).join(' or ') + ':')
|
||||
for (const e of visibilityErrors) console.error(` ${e}`)
|
||||
console.error(' Absent means "team". The generator will not guess which you meant.')
|
||||
process.exit(1)
|
||||
}
|
||||
return bundles
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs'
|
|||
import { REPO_ROOT, discoverSpaRegistry } from './discover.mjs'
|
||||
import { readBuildConfig, renderGallery } from '../generate-gallery.mjs'
|
||||
import { TREE_NAME } from '../_tree-name.mjs'
|
||||
import { profileMetaFields, bundleStage } from '../profile-meta.mjs'
|
||||
import { profileMetaFields, bundleStage, bundleVisibility } from '../profile-meta.mjs'
|
||||
|
||||
// ─── DEV plugin gallery ──────────────────────────────────────────────────────
|
||||
// `bun x vite` (dev) serves the plugin gallery at `/` — the same page `bun run build`
|
||||
|
|
@ -137,7 +137,7 @@ export function galleryModel(reg) {
|
|||
// to drop them, so under the dev server — which is what the Driver's laptop runs — the studio's
|
||||
// Composed-of panel and composition trees were silently empty for every composite.
|
||||
const composes = [...new Set([...presets.keys()].flatMap((profile) => readComposes(b.bundle, profile)))]
|
||||
return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], stage: bundleStage(profiles), ...(composes.length ? { composes } : {}), profiles }
|
||||
return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], stage: bundleStage(profiles), ...(bundleVisibility(profiles) === 'public' ? { visibility: 'public' } : {}), ...(composes.length ? { composes } : {}), profiles }
|
||||
})
|
||||
return {
|
||||
schema: 'doxa-maps-manifest@1', generatedFrom: 'plugin-designs/*/*.vue (dev discovery)', embedBase: '.',
|
||||
|
|
|
|||
|
|
@ -7,12 +7,34 @@
|
|||
* stage "production" | "experimental" default experimental
|
||||
* heavy true when the element holds live WebGL contexts
|
||||
* widget { corner } for a corner widget the Plugin Studio mounts once, fixed
|
||||
* visibility "team" | "public" default team — see VISIBILITY below
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']
|
||||
|
||||
/**
|
||||
* ★ WHO ELSE MAY LIST THIS PLUGIN (dx-base, 2026-09-28). The platform dashboard reads each team's
|
||||
* own /cdn/dx-maps/manifest.json instead of one shared index, so this field is what decides whether
|
||||
* a plugin appears in ANOTHER team's library (loaded read-only from the owner's CDN).
|
||||
*
|
||||
* "team" listed only in the owning team's library
|
||||
* "public" also listed in every other team's library
|
||||
* ABSENT team — every manifest that exists today is private until a plugin opts in
|
||||
*
|
||||
* ★ DO NOT REACH FOR `stage` TO EXPRESS THIS. It sits right next to it in this file and reads like
|
||||
* a fit: it is maturity, not audience. Using it would hide every experimental plugin from its OWN
|
||||
* team and publish every production one to everybody — wrong in both directions at once.
|
||||
*
|
||||
* ★ AN UNKNOWN VALUE IS REFUSED, NOT DEFAULTED. Defaulting "Public" or "pubic" to team is the safe
|
||||
* direction and that is exactly what makes it dangerous: the author reads their opt-in as done, the
|
||||
* field says private, and nobody finds out until someone asks why the plugin never appeared. The
|
||||
* generator exits on it (generate-tree, "visibility" block) so a typo is a build failure, loud, at
|
||||
* the moment it is introduced.
|
||||
*/
|
||||
export const VISIBILITY = ['team', 'public']
|
||||
|
||||
export function readProfileMeta(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
|
|
@ -26,15 +48,31 @@ export function readProfileMeta(dir, profileName) {
|
|||
label: name ? name.trim() : '',
|
||||
// Where the plugin runs ("web", "desktop"…); absent = web. A desktop-only plugin is never a gallery tab.
|
||||
targets: Array.isArray(meta?.targets) ? meta.targets.filter((t) => typeof t === 'string') : null,
|
||||
visibility: VISIBILITY.includes(meta?.visibility) ? meta.visibility : 'team',
|
||||
// The declared-but-unrecognised value, kept so the generator can name it in the failure.
|
||||
// null when the key is absent (the supported way to say "team") or already valid.
|
||||
visibilityInvalid: meta?.visibility === undefined || VISIBILITY.includes(meta?.visibility)
|
||||
? null
|
||||
: String(meta.visibility),
|
||||
}
|
||||
} catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null } }
|
||||
} catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null } }
|
||||
}
|
||||
|
||||
/** The optional manifest keys for one profile (stage always; heavy / widget only when set). */
|
||||
export function profileMetaFields(dir, profileName) {
|
||||
const m = readProfileMeta(dir, profileName)
|
||||
return { stage: m.stage, ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}) }
|
||||
// visibility is emitted ONLY when it is "public": absent means team, so every manifest in the
|
||||
// tree today keeps the bytes it has and nothing becomes visible by upgrading the generator.
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}) }
|
||||
}
|
||||
|
||||
/** A bundle is production if ANY of its profiles says so. */
|
||||
export const bundleStage = (profiles) => (profiles.some((p) => p.stage === 'production') ? 'production' : 'experimental')
|
||||
|
||||
/**
|
||||
* A bundle is public if ANY of its profiles is — because a bundle is ONE IIFE. The profiles in it
|
||||
* share a single .js, so publishing one profile publishes the file the others are compiled into;
|
||||
* there is no shape of this field that makes half a script fetchable. The per-profile key is still
|
||||
* emitted, so a reader can list only the profiles that opted in while loading the one bundle.
|
||||
*/
|
||||
export const bundleVisibility = (profiles) => (profiles.some((p) => p.visibility === 'public') ? 'public' : 'team')
|
||||
|
|
|
|||
Loading…
Reference in a new issue