Scaffold a plugin's frontend into the plugin, so it can be pruned

`flask plugin new` wrote views to frontend/src/views/<name>/ and a route file
to frontend/src/router/routes/<name>.js. Every one of the 16 bundled plugins
keeps its frontend in plugins/<name>/frontend/ instead, staged into
.plugins-staged by stage-frontend.mjs and aggregated into routes.gen.js.

It was not visibly broken, which is why it lasted: the router still globs
./routes/*.js during the Phase 4 dual-location transition, so a scaffolded
plugin's pages loaded. What a new plugin author silently lost was ADR-013 lean
builds. Pruning works off the staged tree, so a frontend in the core tree
cannot be excluded from a site build, and deleting the plugin strands its
views behind.

The templates for the correct layout already existed; only the output paths
were wrong. Also fixed the imports they carry: core imports now use the @
alias (@/api, @/components/...), which resolves both before and after staging,
where the previous ../../api was correct only in the old location. Sibling
views stay relative and travel with the route file.

frontend_dir is gone from scaffold_plugin. There is no second output tree to
point at, and a plugin developed in its own repo now gets the same frontend as
a bundled one rather than being skipped.

Proven end to end: scaffolded a throwaway plugin, confirmed it stages, builds
real chunks, and that SITE_PLUGINS=network prunes it, which is what the old
layout could not do.
This commit is contained in:
cproudlock
2026-08-24 14:05:15 -04:00
parent eecb0595dd
commit 7b79a444b8
7 changed files with 232 additions and 207 deletions

View File

@@ -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/<name>/frontend/`:
`routes.js` plus `views/<Name>{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/<name>/` 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: <plugins_dir>/../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/<name>/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 -> <Name>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,
)

View File

@@ -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 = []

View File

@@ -78,12 +78,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -77,11 +77,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import api from '@/api'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -66,12 +66,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import api from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },