diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md index 5f16da83..ba55b580 100644 --- a/docs/PLUGIN-QUICKSTART.md +++ b/docs/PLUGIN-QUICKSTART.md @@ -17,7 +17,9 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr flask plugin new cameras --description "Tracks shop-floor surveillance cameras" ``` -Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. When a `frontend/src/` tree is present, it also writes the frontend starting points: `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`, and the auto-discovered route file `frontend/src/router/routes/cameras.js`. (A plugin developed in its own repo, with no frontend tree, gets the backend skeleton plus the snippet only.) +Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. It also writes the frontend starting points, and they live **inside the plugin**: `plugins/cameras/frontend/routes.js` and `plugins/cameras/frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`. + +Nothing is written into `frontend/src/`. The plugin owns its frontend because that is what makes it prunable: `scripts/stage-frontend.mjs` copies each plugin's `frontend/` into `frontend/src/.plugins-staged//` and aggregates the route modules into `routes.gen.js`, so a plugin left out of `SITE_PLUGINS` simply is not copied and its frontend leaves the build with it (ADR-013). A plugin developed in its own repo gets exactly this same layout, and its frontend starts working the moment the directory is dropped into a host repo's `plugins/`. The generated plugin already passes the framework's contract tests. Verify before editing: @@ -142,28 +144,35 @@ Each hook has a default that does nothing. Override only what your plugin needs. The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order: -1. **View files** - the scaffold created `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. +1. **View files** - the scaffold created `plugins/cameras/frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. -2. **Route file** - the scaffold created `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`: + **Imports have one rule, and staging is the reason.** Anything from the CORE tree uses the `@` alias (`@/api`, `@/components/PaginationBar.vue`, `@/stores/auth`); anything inside your own plugin is relative (`./views/...`). A relative path into the core tree such as `../../api` is correct where the file sits in your plugin and wrong once staged into `.plugins-staged/cameras/`, and the failure is a build error rather than anything subtle. + +2. **Route file** - the scaffold created `plugins/cameras/frontend/routes.js` exporting a route array plus a `toplevel` array. `stage-frontend.mjs` aggregates both into `routes.gen.js`, which the router spreads in, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`: ```js export default [ { path: 'cameras', name: 'cameras', - component: () => import('../../views/cameras/CamerasList.vue'), + component: () => import('./views/CamerasList.vue'), meta: { plugin: 'cameras' } }, { path: 'cameras/:id/edit', name: 'cameras-edit', - component: () => import('../../views/cameras/CamerasForm.vue'), + component: () => import('./views/CamerasForm.vue'), meta: { requiresAuth: true, plugin: 'cameras' } } ] + +// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages). +// Export it even when empty: routes.gen.js reads `.toplevel` off every staged +// module, and a missing export is a Rollup warning under strict ESM. +export const toplevel = [] ``` -3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '../../api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`). +3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '@/api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`). 4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`. diff --git a/shopdb/plugins/scaffolder.py b/shopdb/plugins/scaffolder.py index ea6eebe7..acb70434 100644 --- a/shopdb/plugins/scaffolder.py +++ b/shopdb/plugins/scaffolder.py @@ -81,18 +81,28 @@ def _scaffold_frontend( name: str, substitutions: dict, plugin_target: Path, - frontend_dir: Path, template_root: Path, overwrite: bool, ) -> None: """Render the frontend starting points for a scaffolded plugin. - Writes the paste-in api-client snippet into the plugin directory, then the - Vue views and the router route file into the real frontend tree. The - snippet is emitted regardless of whether the frontend tree exists, because - it is a plugin-directory artifact useful even for external-repo plugins. - Views and the route file are skipped when frontend_dir is missing, which is - the normal case for a plugin developed in its own repository. + Everything lands INSIDE the plugin, under `plugins//frontend/`: + `routes.js` plus `views/{List,Detail,Form}.vue`, and the paste-in + api-client snippet at the plugin root. + + The plugin owns its frontend because that is what makes it prunable. + scripts/stage-frontend.mjs copies each plugin's `frontend/` into + `frontend/src/.plugins-staged//` and aggregates the route modules + into `routes.gen.js`; a plugin left out of SITE_PLUGINS is simply not + copied, so its frontend leaves the build with it (ADR-013). A route file + written into the CORE tree instead is still picked up - the router globs + `./routes/*.js` during the Phase 4 dual-location transition - but it is + invisible to that pruning, and deleting the plugin strands its views. + + This is also why nothing here depends on the core frontend tree existing. + A plugin developed in its own repo gets exactly the same layout, and its + frontend starts working the moment the plugin directory is dropped into + `plugins/`. Ordering matters: every view is written before the route file. A route file that lazy-imports a view that is not on disk crashes the Vite dev server, @@ -104,7 +114,7 @@ def _scaffold_frontend( plugin_name = substitutions['Name'] - # snippet lands in the plugin dir; author pastes it into api/index.js + # snippet lands at the plugin root; author pastes it into api/index.js snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl' if snippet_template.exists(): _render_template( @@ -114,11 +124,8 @@ def _scaffold_frontend( overwrite, ) - # views and route need the real frontend tree; external repos skip these - if not frontend_dir.exists(): - return - - views_dir = frontend_dir / 'views' / name + frontend_dir = plugin_target / 'frontend' + views_dir = frontend_dir / 'views' # views first: route file lazy-imports them, missing views 500 vite view_templates = { @@ -141,7 +148,7 @@ def _scaffold_frontend( if route_template.exists(): _render_template( route_template, - frontend_dir / 'router' / 'routes' / f'{name}.js', + frontend_dir / 'routes.js', substitutions, overwrite, ) @@ -154,7 +161,6 @@ def scaffold_plugin( template_root: Optional[Path] = None, overwrite: bool = False, frontend: bool = True, - frontend_dir: Optional[Path] = None, ) -> Path: """Generate a new plugin from templates. @@ -166,11 +172,10 @@ def scaffold_plugin( overwrite: If True, overwrite an existing plugin directory and any existing generated frontend files frontend: If True, also render the Vue frontend starting points (list, - detail, form views, a route file, and a paste-in api-client snippet) - frontend_dir: Frontend src directory to render views/routes into - (default: /../frontend/src). Views and the route file - are skipped when this directory does not exist, which is the normal - case for a plugin developed in its own repository. + detail, form views, a route file, and a paste-in api-client + snippet) into `plugins//frontend/`. There is no separate + output directory: the plugin owns its frontend so that + stage-frontend.mjs can stage it and a lean build can prune it. Returns: Path to the generated plugin directory. @@ -205,7 +210,9 @@ def scaffold_plugin( for template_path in template_root.rglob('*.tmpl'): rel = template_path.relative_to(template_root) - # frontend templates render into the frontend tree, not the plugin dir + # frontend templates are handled by _scaffold_frontend: they are + # renamed on the way out (List.vue.tmpl -> List.vue) and are + # skipped entirely when frontend=False if rel.parts and rel.parts[0] == 'frontend': continue @@ -222,13 +229,10 @@ def scaffold_plugin( out_path.write_text(rendered) if frontend: - if frontend_dir is None: - frontend_dir = plugins_dir.parent / 'frontend' / 'src' _scaffold_frontend( name=name, substitutions=substitutions, plugin_target=target, - frontend_dir=Path(frontend_dir), template_root=template_root, overwrite=overwrite, ) diff --git a/shopdb/plugins/templates/frontend/routes.js.tmpl b/shopdb/plugins/templates/frontend/routes.js.tmpl index 758b6f70..1616ee33 100644 --- a/shopdb/plugins/templates/frontend/routes.js.tmpl +++ b/shopdb/plugins/templates/frontend/routes.js.tmpl @@ -1,35 +1,49 @@ /** * ${Name} plugin routes. * - * Auto-discovered by the router via import.meta.glob, so no registration - * edit is needed. Every route carries meta.plugin '${name}' so the ADR-009 - * guard redirects to the dashboard when the ${name} backend plugin is - * disabled. Form routes add requiresAuth so anonymous users cannot reach - * create or edit. + * This file lives in the plugin, not in the core frontend tree. + * scripts/stage-frontend.mjs copies plugins/${name}/frontend/ into + * frontend/src/.plugins-staged/${name}/ and aggregates this default export + * into routes.gen.js, which the router spreads in - so no registration edit + * is needed, and pruning the plugin for a lean build removes its frontend + * with it (ADR-013). + * + * Every route carries meta.plugin '${name}' so the ADR-009 guard redirects to + * the dashboard when the ${name} backend plugin is disabled. Form routes add + * requiresAuth so anonymous users cannot reach create or edit. + * + * View imports are relative (./views/...) because they travel with this file. + * Anything from the CORE tree uses the @ alias (@/api, @/components/...), + * which resolves the same before and after staging. */ export default [ { path: '${name}', name: '${name}', - component: () => import('../../views/${name}/${Name}List.vue'), + component: () => import('./views/${Name}List.vue'), meta: { plugin: '${name}' } }, { path: '${name}/new', name: '${name}-new', - component: () => import('../../views/${name}/${Name}Form.vue'), + component: () => import('./views/${Name}Form.vue'), meta: { requiresAuth: true, plugin: '${name}' } }, { path: '${name}/:id', name: '${name}-detail', - component: () => import('../../views/${name}/${Name}Detail.vue'), + component: () => import('./views/${Name}Detail.vue'), meta: { plugin: '${name}' } }, { path: '${name}/:id/edit', name: '${name}-edit', - component: () => import('../../views/${name}/${Name}Form.vue'), + component: () => import('./views/${Name}Form.vue'), meta: { requiresAuth: true, plugin: '${name}' } } ] + +// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages). +// Declared even when empty so routes.gen.js can read `.toplevel` off the +// module without Rollup warning about a missing export. +export const toplevel = [] diff --git a/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl index 9777a799..b1dc5ec1 100644 --- a/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl +++ b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl @@ -78,12 +78,12 @@