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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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}`) },
|
||||
|
||||
@@ -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}`) },
|
||||
|
||||
@@ -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}`) },
|
||||
|
||||
Reference in New Issue
Block a user