Plugin Studio rail laid out like plugin-designs/: one folder per bundle, its elements inside
The Driver's ask: one folder per BUNDLE with its ELEMENTS inside, bundles sorted by capability, highest first. The rail listed every element flat under its own capability, so a bundle's elements were scattered (gospel-resources' five screens sat in three sections). Now each capability section (5 → 1) holds the bundles whose HIGHEST capability it is, in name order; a bundle with several elements is one folder (country-posters, gospel-resources, my-upg-100-list, regions-dashboard, cities-internet), and a single-element bundle is shown as its element. Composites keep their folded composition trees. StudioTree identifies a plugin's row by a `canonical` flag rather than "depth 0", so auto-reveal opens the bundle folder above an element chosen by URL or restored tab. New folder icon; 'elementsCount' and 'elements' in all 11 studio catalogues. check:studio-rail (headless, gates:post) runs the studio's real rail code on the built manifest and asserts the layout and one canonical node per plugin (21 bundles, 33 elements); it fails on the previous rail. Also: poster.json:dpi is declared identical on purpose in check:i18n-never-translated (a unit symbol kept as-is by project decision), which had turned that gate red on main.
This commit is contained in:
parent
ded4b3c105
commit
1795fb4740
17 changed files with 150 additions and 26 deletions
|
|
@ -58,6 +58,9 @@ const ALLOWED = new Map([
|
|||
'proper noun — Open Bible Stories is the publication\'s name (unfoldingWord), not a description'],
|
||||
['gospel.json:res.scriptureEarth',
|
||||
'proper noun — Scripture Earth is the organisation\'s name; the destination site is English'],
|
||||
// ── UNITS ───────────────────────────────────────────────────────────────────
|
||||
['poster.json:dpi',
|
||||
'unit symbol — "DPI" is how print resolution is labelled in every one of these locales\' print dialogs; kept as-is by project decision (the poster-strings landing)'],
|
||||
// ── DRIVER-GATED, NOT RESOLVED ──────────────────────────────────────────────
|
||||
// ⚠ THESE ARE ALLOWED SO THE GATE CAN GO GREEN ON THE THINGS IT HAS ACTUALLY SETTLED, NOT
|
||||
// because the defect is fixed. "UUPGs" really is an English acronym wedged into an otherwise
|
||||
|
|
|
|||
67
internal/check-studio-rail.mjs
Normal file
67
internal/check-studio-rail.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-studio-rail — the Plugin Studio's rail is laid out like plugin-designs/. Headless.
|
||||
*
|
||||
* The Driver's ask (2026-09-26): one FOLDER per BUNDLE with its ELEMENTS inside, bundles sorted by
|
||||
* capability, highest first. Runs the studio's REAL treeSections / compositionTrees / rowsFromManifest
|
||||
* (loaded through Vite, no browser) against the BUILT manifest and asserts:
|
||||
* - sections run CAP 5 → 1, and each bundle sits in the section of its HIGHEST capability, once;
|
||||
* - bundles within a section are in name order;
|
||||
* - a bundle with several elements is ONE folder holding all of them; a single-element bundle is its element;
|
||||
* - every studio row has exactly ONE canonical node (one identity per plugin; parts only target it).
|
||||
* EXIT 0 pass · 1 fail · 0 with SKIP when there is no built manifest
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
|
||||
if (!existsSync(MANIFEST)) { console.log('STUDIO-RAIL: SKIP — no built manifest'); process.exit(0) }
|
||||
const { createServer } = await import('vite')
|
||||
const { default: vue } = await import('@vitejs/plugin-vue')
|
||||
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, watch: null }, plugins: [vue({ customElement: true })],
|
||||
resolve: { alias: { '@map': join(ROOT, 'library') } }, define: { __DOXA_TREE_NAME__: JSON.stringify(TREE_NAME) } })
|
||||
globalThis.window = globalThis
|
||||
globalThis.localStorage = { getItem: () => null, setItem() {} }
|
||||
let bad = 0
|
||||
const fail = (m) => { bad++; console.log(' FAIL ' + m) }
|
||||
try {
|
||||
const comp = (await server.ssrLoadModule('/plugin-designs/plugin-studio/plugin-studio.vue')).default
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
const ctx = { manifestBundles: manifest.bundles, tr: (k) => k, cfg: {}, base: '' }
|
||||
ctx.rows = comp.methods.rowsFromManifest.call(ctx, manifest)
|
||||
ctx.resolveRef = (ref, fb) => comp.methods.resolveRef.call(ctx, ref, fb)
|
||||
ctx.compositionTrees = comp.computed.compositionTrees.call(ctx)
|
||||
const secs = comp.computed.treeSections.call(ctx)
|
||||
const caps = secs.map((s) => s.cap)
|
||||
if (caps.join() !== [...caps].sort((a, b) => b - a).join()) fail(`sections not CAP 5 → 1: ${caps.join(',')}`)
|
||||
const byBundle = new Map()
|
||||
for (const r of ctx.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
|
||||
const seen = new Map()
|
||||
for (const s of secs) {
|
||||
const tops = s.nodes.filter((n) => n.depth === 0)
|
||||
const names = tops.map((n) => (n.folder ? n.label : ctx.rows.find((r) => r.key === n.target)?.bundle))
|
||||
if (names.join() !== [...names].sort((a, b) => a.localeCompare(b)).join()) fail(`CAP ${s.cap}: bundles not in name order (${names.join(', ')})`)
|
||||
for (const [i, n] of tops.entries()) {
|
||||
const b = names[i]; const rows = byBundle.get(b) || []
|
||||
if (seen.has(b)) fail(`bundle ${b} appears twice (CAP ${seen.get(b)} and CAP ${s.cap})`)
|
||||
seen.set(b, s.cap)
|
||||
const top = Math.max(...rows.map((r) => r.cap))
|
||||
if (top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
|
||||
if (rows.length > 1) {
|
||||
if (!n.folder) fail(`bundle ${b} has ${rows.length} elements but is not a folder`)
|
||||
const kids = s.nodes.filter((k) => k.parent === n.id && k.canonical).map((k) => k.target).sort()
|
||||
if (kids.join() !== rows.map((r) => r.key).sort().join()) fail(`folder ${b} holds ${kids.length} of its ${rows.length} elements`)
|
||||
} else if (n.folder) fail(`single-element bundle ${b} is shown as a folder`)
|
||||
}
|
||||
}
|
||||
for (const b of byBundle.keys()) if (!seen.has(b)) fail(`bundle ${b} is missing from the rail`)
|
||||
const canon = secs.flatMap((s) => s.nodes.filter((n) => n.canonical))
|
||||
for (const r of ctx.rows) { const c = canon.filter((n) => n.target === r.key).length; if (c !== 1) fail(`${r.key} has ${c} canonical nodes (want 1)`) }
|
||||
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s), ${ctx.rows.length} element(s), ${secs.length} capability section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
|
||||
} finally { await server.close() }
|
||||
if (bad) { console.log(`STUDIO-RAIL: FAIL — ${bad} problem(s).`); process.exit(1) }
|
||||
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside, bundles grouped by their highest capability (5 → 1) and in name order, one identity per plugin.')
|
||||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "لا يحتاج إلى رمز",
|
||||
"tokenNotNeeded": "تم اختيار MapLibre: خرائطه لا تحتاج إلى رمز Mapbox",
|
||||
"hideHeader": "إخفاء الشريط العلوي",
|
||||
"showHeader": "إظهار الشريط العلوي"
|
||||
"showHeader": "إظهار الشريط العلوي",
|
||||
"elementsCount": "{n} عناصر",
|
||||
"elements": "عناصر"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "kein Token nötig",
|
||||
"tokenNotNeeded": "MapLibre ist gewählt: Seine Karten brauchen keinen Mapbox-Token",
|
||||
"hideHeader": "Kopfzeile ausblenden",
|
||||
"showHeader": "Kopfzeile einblenden"
|
||||
"showHeader": "Kopfzeile einblenden",
|
||||
"elementsCount": "{n} Elemente",
|
||||
"elements": "Elemente"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "no token needed",
|
||||
"tokenNotNeeded": "MapLibre is selected: its maps need no Mapbox token",
|
||||
"hideHeader": "Hide header",
|
||||
"showHeader": "Show header"
|
||||
"showHeader": "Show header",
|
||||
"elementsCount": "{n} elements",
|
||||
"elements": "elements"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "no necesita token",
|
||||
"tokenNotNeeded": "MapLibre está seleccionado: sus mapas no necesitan token de Mapbox",
|
||||
"hideHeader": "Ocultar encabezado",
|
||||
"showHeader": "Mostrar encabezado"
|
||||
"showHeader": "Mostrar encabezado",
|
||||
"elementsCount": "{n} elementos",
|
||||
"elements": "elementos"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "aucun jeton requis",
|
||||
"tokenNotNeeded": "MapLibre est sélectionné : ses cartes n’ont pas besoin de jeton Mapbox",
|
||||
"hideHeader": "Masquer l'en-tête",
|
||||
"showHeader": "Afficher l'en-tête"
|
||||
"showHeader": "Afficher l'en-tête",
|
||||
"elementsCount": "{n} éléments",
|
||||
"elements": "éléments"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "टोकन की ज़रूरत नहीं",
|
||||
"tokenNotNeeded": "MapLibre चुना गया है: इसके मानचित्रों को Mapbox टोकन नहीं चाहिए",
|
||||
"hideHeader": "हेडर छिपाएँ",
|
||||
"showHeader": "हेडर दिखाएँ"
|
||||
"showHeader": "हेडर दिखाएँ",
|
||||
"elementsCount": "{n} तत्व",
|
||||
"elements": "तत्व"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "nessun token necessario",
|
||||
"tokenNotNeeded": "MapLibre è selezionato: le sue mappe non richiedono un token Mapbox",
|
||||
"hideHeader": "Nascondi intestazione",
|
||||
"showHeader": "Mostra intestazione"
|
||||
"showHeader": "Mostra intestazione",
|
||||
"elementsCount": "{n} elementi",
|
||||
"elements": "elementi"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "não precisa de token",
|
||||
"tokenNotNeeded": "MapLibre está selecionado: os seus mapas não precisam de token do Mapbox",
|
||||
"hideHeader": "Ocultar cabeçalho",
|
||||
"showHeader": "Mostrar cabeçalho"
|
||||
"showHeader": "Mostrar cabeçalho",
|
||||
"elementsCount": "{n} elementos",
|
||||
"elements": "elementos"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "nu necesită token",
|
||||
"tokenNotNeeded": "MapLibre este selectat: hărțile sale nu necesită token Mapbox",
|
||||
"hideHeader": "Ascunde antetul",
|
||||
"showHeader": "Afișează antetul"
|
||||
"showHeader": "Afișează antetul",
|
||||
"elementsCount": "{n} elemente",
|
||||
"elements": "elemente"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "токен не нужен",
|
||||
"tokenNotNeeded": "Выбран MapLibre: его картам не нужен токен Mapbox",
|
||||
"hideHeader": "Скрыть заголовок",
|
||||
"showHeader": "Показать заголовок"
|
||||
"showHeader": "Показать заголовок",
|
||||
"elementsCount": "{n} элементов",
|
||||
"elements": "элементов"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@
|
|||
"engineMaplibreNote": "无需令牌",
|
||||
"tokenNotNeeded": "已选择 MapLibre:其地图无需 Mapbox 令牌",
|
||||
"hideHeader": "隐藏标题栏",
|
||||
"showHeader": "显示标题栏"
|
||||
"showHeader": "显示标题栏",
|
||||
"elementsCount": "{n} 个元素",
|
||||
"elements": "元素"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const P = {
|
|||
pin: '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
layers: '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
|
||||
flask: '<path d="M9 3h6v5l5 11a2 2 0 0 1-2 3H6a2 2 0 0 1-2-3l5-11z"/><line x1="9" y1="3" x2="15" y2="3"/><line x1="6" y1="15" x2="18" y2="15"/>',
|
||||
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/>',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
|
||||
"check:map-attribution": "node internal/check-map-attribution.mjs",
|
||||
"check:studio-rail": "node internal/check-studio-rail.mjs",
|
||||
"check:translator-contacts": "node internal/check-translator-contacts.mjs",
|
||||
"check:translator-contacts:poison": "node internal/check-translator-contacts.mjs --poison",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
|
||||
|
|
@ -206,7 +207,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: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",
|
||||
"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: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",
|
||||
"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",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -358,6 +359,7 @@
|
|||
"check:shot-fidelity": "warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
|
||||
"check:shot-fonts": "can THIS machine render the emoji the UI uses? Run BEFORE `bun run shots` — no font = tofu boxes in every committed PNG, which every other shot gate passes. Deliberately NOT in build:unlocked: it reports an ENVIRONMENT condition (this box has no emoji font), and a gate that always fails in the build makes the build useless.",
|
||||
"check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
|
||||
"check:studio-rail": "the Plugin Studio rail is laid out like plugin-designs/: one folder per bundle with its elements inside, bundles in the section of their highest capability (5 \u2192 1) and in name order, one canonical node per plugin. Runs the studio's real rail code on the built manifest, headless.",
|
||||
"check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.",
|
||||
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
|
|
|
|||
|
|
@ -567,21 +567,30 @@ export default defineComponent({
|
|||
// plugin left no single name to report it by; a node that only TARGETS the canonical row has no
|
||||
// identity of its own, so nesting and the one-name rule coexist.
|
||||
treeSections() {
|
||||
// ★ LAID OUT LIKE plugin-designs/ (Driver, 2026-09-26): one FOLDER per BUNDLE, its ELEMENTS inside,
|
||||
// bundles grouped by their HIGHEST capability (5 → 1) and sorted by name within a group. A bundle
|
||||
// with one element is shown as that element (a folder of one is noise). A composite's parts still
|
||||
// hang under its element, folded, each TARGETING its canonical row (one identity per plugin).
|
||||
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
|
||||
return this.groups.map(g => {
|
||||
const byBundle = new Map()
|
||||
for (const r of this.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
|
||||
const bundles = [...byBundle.entries()].map(([bundle, rows]) => ({ bundle, rows, cap: Math.max(...rows.map(r => r.cap)) }))
|
||||
return [5, 4, 3, 2, 1].map(cap => {
|
||||
const group = bundles.filter(b => b.cap === cap).sort((x, y) => x.bundle.localeCompare(y.bundle))
|
||||
if (!group.length) return null
|
||||
const nodes = []
|
||||
for (const r of g.rows) {
|
||||
const pushElement = (r, depth, parent) => {
|
||||
const t = treeOf.get(r.key)
|
||||
nodes.push({
|
||||
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
|
||||
id: r.key, parent, depth, label: r.label, desc: r.tag, icon: r.icon, target: r.key, canonical: true,
|
||||
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + this.tr('cap' + r.cap)
|
||||
})
|
||||
if (!t) continue
|
||||
if (!t) return
|
||||
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
|
||||
for (const n of t.nodes.slice(1)) {
|
||||
nodes.push({
|
||||
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
|
||||
label: n.label, part: !!n.part, target: n.selects || '',
|
||||
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: depth + n.depth,
|
||||
label: n.label, part: !!n.part, target: n.selects || '', canonical: false,
|
||||
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
|
||||
desc: n.part ? this.tr('noStandalone') : 'cap ' + n.cap + ' · ' + n.tag,
|
||||
kids: n.kids || 0,
|
||||
|
|
@ -590,10 +599,19 @@ export default defineComponent({
|
|||
})
|
||||
}
|
||||
}
|
||||
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
|
||||
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
|
||||
return { id: 'cap-' + g.cap, cap: g.cap, label: this.tr('cap' + g.cap), title: this.tr('cap' + g.cap) + ' · ' + (MACHINE_TYPE[g.cap] || ''), icon: CAP_ICON[g.cap] || 'embed', nodes }
|
||||
})
|
||||
for (const b of group) {
|
||||
const rows = [...b.rows].sort((x, y) => y.cap - x.cap || x.label.localeCompare(y.label))
|
||||
if (rows.length === 1) { pushElement(rows[0], 0, ''); continue }
|
||||
const fid = 'bundle:' + b.bundle
|
||||
nodes.push({
|
||||
id: fid, parent: '', depth: 0, label: b.bundle, desc: this.tr('elementsCount', { n: rows.length }),
|
||||
icon: 'folder', target: rows[0].key, canonical: false, folder: true, kids: rows.length,
|
||||
title: b.bundle + ' · plugin-designs/' + b.bundle + '/ · ' + rows.length + ' ' + this.tr('elements')
|
||||
})
|
||||
for (const r of rows) pushElement(r, 1, fid)
|
||||
}
|
||||
return { id: 'cap-' + cap, cap, label: this.tr('cap' + cap), title: this.tr('cap' + cap) + ' · ' + (MACHINE_TYPE[cap] || ''), icon: CAP_ICON[cap] || 'embed', nodes }
|
||||
}).filter(Boolean)
|
||||
},
|
||||
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
|
||||
// or child lists live in this file, so a new composite appears here with zero edits.
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ const EN = {
|
|||
}
|
||||
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
|
||||
|
||||
// A node is the CANONICAL row of a plugin when the rail says so (canonical: true); older models without
|
||||
// the flag used depth 0 for that. A bundle FOLDER is not a plugin, and neither is a repeated part.
|
||||
const isCanonical = (n) => (n.canonical !== undefined ? !!n.canonical : n.depth === 0)
|
||||
const isAlias = (n) => (n.canonical !== undefined ? !n.canonical && !n.folder : n.depth > 0)
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StudioTree',
|
||||
props: {
|
||||
|
|
@ -209,13 +214,13 @@ export default defineComponent({
|
|||
if (!this.kept && this.secClosed[s.id]) continue
|
||||
for (const n of nodes) {
|
||||
if (!this.kept && !this.ancestorsOpen(n)) continue
|
||||
out.push({ ...n, sectionId: s.id, level: n.depth + 2, expandable: !!n.kids, alias: n.depth > 0 })
|
||||
out.push({ ...n, sectionId: s.id, level: n.depth + 2, expandable: !!n.kids, alias: isAlias(n) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
activeSectionId() {
|
||||
for (const s of this.sections) if (s.nodes.some(n => n.depth === 0 && n.target === this.active)) return s.id
|
||||
for (const s of this.sections) if (s.nodes.some(n => isCanonical(n) && n.target === this.active)) return s.id
|
||||
return ''
|
||||
}
|
||||
},
|
||||
|
|
@ -255,7 +260,7 @@ export default defineComponent({
|
|||
collapseAll() {
|
||||
this.nodeOpen = {}
|
||||
this.saveState()
|
||||
const a = this.visible.find(i => !i.section && i.target === this.active && i.depth === 0)
|
||||
const a = this.visible.find(i => !i.section && i.target === this.active && isCanonical(i))
|
||||
if (a) this.focusId = a.id
|
||||
},
|
||||
rowClass(item) {
|
||||
|
|
@ -325,9 +330,15 @@ export default defineComponent({
|
|||
if (!this.active || !this.sections.length) return
|
||||
let row = null
|
||||
for (const s of this.sections) {
|
||||
row = s.nodes.find(n => n.depth === 0 && n.target === this.active)
|
||||
row = s.nodes.find(n => isCanonical(n) && n.target === this.active)
|
||||
if (row) { if (this.secClosed[s.id]) { this.secClosed = { ...this.secClosed, [s.id]: false }; this.saveState() } break }
|
||||
}
|
||||
// A plugin inside a BUNDLE FOLDER: open the folder(s) above it, or it cannot be seen.
|
||||
if (row) {
|
||||
let p = row.parent, opened = false
|
||||
while (p) { if (!this.nodeOpen[p]) { this.nodeOpen = { ...this.nodeOpen, [p]: true }; opened = true } p = this.byId.get(p) && this.byId.get(p).parent }
|
||||
if (opened) this.saveState()
|
||||
}
|
||||
if (!row) return
|
||||
if (!this.focusId || !this.byId.has(this.focusId) || this.byId.get(this.focusId).target !== this.active) this.focusId = row.id
|
||||
this.$nextTick(() => { const el = this._rows && this._rows[row.id]; if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }) })
|
||||
|
|
|
|||
Loading…
Reference in a new issue