ADR-013 Phase 4: frontend staging machinery + relocate printedparts; fix router crash
The staging step that makes lean per-site frontend builds possible, plus the
first plugin relocated as the pilot.
- scripts/stage-frontend.mjs: copies each chosen plugin's plugins/<name>/frontend/
into frontend/src/.plugins-staged/<name>/ and codegens routes.gen.js. Plugin
selection via SITE_PLUGINS (comma-separated); empty = all plugins that have a
frontend/ (the full build). Wired as npm predev/prebuild; outputs gitignored.
- Router imports routes.gen.js and merges staged routes with the in-tree
./routes/*.js glob - dual-location during the transition.
- printedparts relocated: its 6 views (list/detail/form/kiosk + the settings and
labels views from the shared dirs) moved into plugins/printedparts/frontend/
views/, core imports rewritten to the @/ alias; routes.js is the self-contained
route module. Its old in-tree route file is removed.
Also fixes a crash the previous commit (37c764b) shipped: slides.js exports only
`toplevel` (its child routes live in core.js), so the router's
flatMap(m => m.default) produced an undefined child and threw
"Cannot read properties of undefined (reading 'path')" at load - the whole SPA
went blank. Guarded with `m.default || []`. (The earlier "print pages are blank"
reading was this crash, not page nature.)
Verified live: /machines renders again; the relocated /printedparts list renders
identically from the staged plugin frontend; SITE_PLUGINS=machines excludes
printedparts from routes.gen. Build (via npm, runs stage) + vitest + naming green.
This commit is contained in:
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
# ADR-013 Phase 4 staged plugin frontends (generated by scripts/stage-frontend.mjs)
|
||||||
|
src/.plugins-staged/
|
||||||
|
src/router/routes.gen.js
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"stage": "node ../scripts/stage-frontend.mjs",
|
||||||
|
"predev": "npm run stage",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "npm run stage",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -6,14 +6,27 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '.
|
|||||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||||
import { useToast } from '../composables/toast'
|
import { useToast } from '../composables/toast'
|
||||||
import { getFacilityName } from '../utils/siteSettings'
|
import { getFacilityName } from '../utils/siteSettings'
|
||||||
|
import { stagedChildren, stagedToplevel } from './routes.gen'
|
||||||
|
|
||||||
// Auto-discover all route modules from routes/ directory. A module's default
|
// Auto-discover all route modules from routes/ directory. A module's default
|
||||||
// export is AppLayout child routes; an optional `toplevel` export is full-screen
|
// export is AppLayout child routes; an optional `toplevel` export is full-screen
|
||||||
// routes (kiosk, print pages) that live OUTSIDE AppLayout. Both let a plugin own
|
// routes (kiosk, print pages) that live OUTSIDE AppLayout. Both let a plugin own
|
||||||
// its routes, so pruning the plugin removes them with no core edit (ADR-013).
|
// its routes, so pruning the plugin removes them with no core edit (ADR-013).
|
||||||
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
||||||
const rawChildren = Object.values(routeModules).flatMap(m => m.default)
|
// Staged plugin frontends (relocated to plugins/<name>/frontend/) are
|
||||||
const pluginTopLevel = Object.values(routeModules).flatMap(m => m.toplevel || [])
|
// aggregated into routes.gen.js by scripts/stage-frontend.mjs. In-tree route
|
||||||
|
// files (./routes/*.js) cover plugins not yet relocated - dual-location during
|
||||||
|
// the Phase 4 transition.
|
||||||
|
const rawChildren = [
|
||||||
|
// A route file may export only `toplevel` (no AppLayout children), so guard
|
||||||
|
// the default with an empty array.
|
||||||
|
...Object.values(routeModules).flatMap(m => m.default || []),
|
||||||
|
...stagedChildren,
|
||||||
|
]
|
||||||
|
const pluginTopLevel = [
|
||||||
|
...Object.values(routeModules).flatMap(m => m.toplevel || []),
|
||||||
|
...stagedToplevel,
|
||||||
|
]
|
||||||
|
|
||||||
// Gather the settings pages (spread across plugin route files) and nest them
|
// Gather the settings pages (spread across plugin route files) and nest them
|
||||||
// under a single two-pane shell so the grouped rail stays put while the right
|
// under a single two-pane shell so the grouped rail stays put while the right
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Printedparts plugin routes.
|
|
||||||
*
|
|
||||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
|
||||||
* edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009
|
|
||||||
* guard redirects to the dashboard when the printedparts backend plugin is
|
|
||||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
|
||||||
* create or edit.
|
|
||||||
*/
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
path: 'printedparts',
|
|
||||||
name: 'printedparts',
|
|
||||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'printedparts/new',
|
|
||||||
name: 'printedparts-new',
|
|
||||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'printedparts/:id',
|
|
||||||
name: 'printedparts-detail',
|
|
||||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'printedparts/:id/edit',
|
|
||||||
name: 'printedparts-edit',
|
|
||||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'settings/printedparts',
|
|
||||||
name: 'settings-printedparts',
|
|
||||||
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
|
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Full-screen routes (outside AppLayout), collected by the router's `toplevel`
|
|
||||||
// pass so pruning printedparts removes them without a core edit (ADR-013).
|
|
||||||
export const toplevel = [
|
|
||||||
{
|
|
||||||
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
|
|
||||||
// Open on purpose - see the decision record in the printedparts proposal.
|
|
||||||
path: '/parts-kiosk',
|
|
||||||
name: 'parts-kiosk',
|
|
||||||
component: () => import('../../views/printedparts/PartsKiosk.vue'),
|
|
||||||
meta: { plugin: 'printedparts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Requires login: lists the whole catalog, printedparts.view-gated at the API.
|
|
||||||
path: '/print/printedparts-labels',
|
|
||||||
name: 'print-printedparts-labels',
|
|
||||||
component: () => import('../../views/print/PrintedPartsLabels.vue'),
|
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
58
plugins/printedparts/frontend/routes.js
Normal file
58
plugins/printedparts/frontend/routes.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Printedparts plugin frontend routes (ADR-013 Phase 4: self-contained plugin
|
||||||
|
* frontend). Views live beside this file under views/; core imports use the
|
||||||
|
* @/ alias. The stage-frontend step copies this whole frontend/ dir into the
|
||||||
|
* Vite tree and aggregates these routes, so a per-site build that omits
|
||||||
|
* printedparts carries none of this code.
|
||||||
|
*
|
||||||
|
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
|
||||||
|
*/
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'printedparts',
|
||||||
|
name: 'printedparts',
|
||||||
|
component: () => import('./views/PrintedItemsList.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printedparts/new',
|
||||||
|
name: 'printedparts-new',
|
||||||
|
component: () => import('./views/PrintedItemForm.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printedparts/:id',
|
||||||
|
name: 'printedparts-detail',
|
||||||
|
component: () => import('./views/PrintedItemDetail.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'printedparts/:id/edit',
|
||||||
|
name: 'printedparts-edit',
|
||||||
|
component: () => import('./views/PrintedItemForm.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/printedparts',
|
||||||
|
name: 'settings-printedparts',
|
||||||
|
component: () => import('./views/PrintedPartsSettings.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const toplevel = [
|
||||||
|
{
|
||||||
|
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
|
||||||
|
path: '/parts-kiosk',
|
||||||
|
name: 'parts-kiosk',
|
||||||
|
component: () => import('./views/PartsKiosk.vue'),
|
||||||
|
meta: { plugin: 'printedparts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Requires login: lists the whole catalog, printedparts.view-gated at the API.
|
||||||
|
path: '/print/printedparts-labels',
|
||||||
|
name: 'print-printedparts-labels',
|
||||||
|
component: () => import('./views/PrintedPartsLabels.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '@/api'
|
||||||
import { withBase } from '../../utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import TouchKeypad from '../../components/TouchKeypad.vue'
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||||
|
|
||||||
const step = ref('item')
|
const step = ref('item')
|
||||||
const item = ref(null)
|
const item = ref(null)
|
||||||
@@ -182,9 +182,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '@/api'
|
||||||
import { withBase } from '../../utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import Modal from '../../components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const item = ref(null)
|
const item = ref(null)
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '@/api'
|
||||||
import { withBase } from '../../utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -91,10 +91,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '@/api'
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
import { withBase } from '../../utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import JsBarcode from 'jsbarcode'
|
import JsBarcode from 'jsbarcode'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '@/api'
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const selectedItems = ref([])
|
const selectedItems = ref([])
|
||||||
61
scripts/stage-frontend.mjs
Normal file
61
scripts/stage-frontend.mjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Stage plugin frontends into the Vite tree (ADR-013 Phase 4).
|
||||||
|
//
|
||||||
|
// Each plugin that owns UI keeps it self-contained under
|
||||||
|
// plugins/<name>/frontend/ (routes.js + views/). This script copies the CHOSEN
|
||||||
|
// plugins' frontend dirs into frontend/src/.plugins-staged/<name>/ and codegens
|
||||||
|
// frontend/src/router/routes.gen.js, which the router imports. A per-site build
|
||||||
|
// selects plugins via the SITE_PLUGINS env (comma-separated); with none set,
|
||||||
|
// every plugin that has a frontend/ is staged (the default full build).
|
||||||
|
//
|
||||||
|
// Both outputs are generated (gitignored). Run by npm predev/prebuild.
|
||||||
|
|
||||||
|
import { readdirSync, existsSync, rmSync, cpSync, writeFileSync, mkdirSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const pluginsDir = join(repoRoot, 'plugins')
|
||||||
|
const stagedDir = join(repoRoot, 'frontend', 'src', '.plugins-staged')
|
||||||
|
const genFile = join(repoRoot, 'frontend', 'src', 'router', 'routes.gen.js')
|
||||||
|
|
||||||
|
const requested = (process.env.SITE_PLUGINS || '')
|
||||||
|
.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
const withFrontend = readdirSync(pluginsDir, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isDirectory()
|
||||||
|
&& existsSync(join(pluginsDir, entry.name, 'frontend', 'routes.js')))
|
||||||
|
.map(entry => entry.name)
|
||||||
|
|
||||||
|
const chosen = requested.length
|
||||||
|
? withFrontend.filter(name => requested.includes(name))
|
||||||
|
: withFrontend
|
||||||
|
|
||||||
|
// Copy each chosen plugin's frontend dir into the staging area (clean first so
|
||||||
|
// a removed plugin does not linger).
|
||||||
|
rmSync(stagedDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(stagedDir, { recursive: true })
|
||||||
|
for (const name of chosen) {
|
||||||
|
cpSync(join(pluginsDir, name, 'frontend'), join(stagedDir, name),
|
||||||
|
{ recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codegen the aggregation the router imports. `toplevel` is optional per plugin.
|
||||||
|
const lines = ['// AUTO-GENERATED by scripts/stage-frontend.mjs - do not edit.']
|
||||||
|
const childParts = []
|
||||||
|
const topParts = []
|
||||||
|
for (const name of chosen) {
|
||||||
|
const id = 'p_' + name.replace(/[^a-zA-Z0-9]/g, '_')
|
||||||
|
lines.push(
|
||||||
|
`import ${id}Default, { toplevel as ${id}Top } from `
|
||||||
|
+ `'../.plugins-staged/${name}/routes.js'`)
|
||||||
|
childParts.push(`...(${id}Default || [])`)
|
||||||
|
topParts.push(`...(${id}Top || [])`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`export const stagedChildren = [${childParts.join(', ')}]`)
|
||||||
|
lines.push(`export const stagedToplevel = [${topParts.join(', ')}]`)
|
||||||
|
lines.push('')
|
||||||
|
writeFileSync(genFile, lines.join('\n'))
|
||||||
|
|
||||||
|
console.log(`staged ${chosen.length} plugin frontend(s): `
|
||||||
|
+ (chosen.join(', ') || '(none)'))
|
||||||
Reference in New Issue
Block a user