Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
- flask plugin new now scaffolds the frontend too: List/Detail/Form views on the global styles, a gated route module (ADR-009), and an api-client snippet emitted into the plugin dir. Views are written before the route file so a partially generated plugin cannot 500 the dev server. - docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a sister site develops a plugin in its own repo and runs the framework contract tests in CI against a pinned framework ref (script verified to fail on a broken core_version pin). - docs/CONTRACT-STABILITY.md: settled vs churning contract surface and the provisional 1.0 criteria. - CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340 tests, measuringtools done). Known limitation documented: Path.rglob does not descend symlinks, so the import-surface contract test skips symlinked external plugins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,12 +57,104 @@ def pascal_case(name: str) -> str:
|
||||
return name[:1].upper() + name[1:]
|
||||
|
||||
|
||||
def _render_template(
|
||||
template_path: Path,
|
||||
out_path: Path,
|
||||
substitutions: dict,
|
||||
overwrite: bool,
|
||||
) -> bool:
|
||||
"""Render one template to out_path.
|
||||
|
||||
Skips silently when out_path already exists and overwrite is False, so a
|
||||
scaffold never clobbers a file the author has already edited. Returns True
|
||||
when the file was written, False when it was skipped.
|
||||
"""
|
||||
if out_path.exists() and not overwrite:
|
||||
return False
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = template_path.read_text()
|
||||
out_path.write_text(Template(body).safe_substitute(substitutions))
|
||||
return True
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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,
|
||||
so the views must land first.
|
||||
"""
|
||||
fe_templates = template_root / 'frontend'
|
||||
if not fe_templates.exists():
|
||||
return
|
||||
|
||||
plugin_name = substitutions['Name']
|
||||
|
||||
# snippet lands in the plugin dir; author pastes it into api/index.js
|
||||
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||
if snippet_template.exists():
|
||||
_render_template(
|
||||
snippet_template,
|
||||
plugin_target / 'frontend-api-snippet.js',
|
||||
substitutions,
|
||||
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
|
||||
|
||||
# views first: route file lazy-imports them, missing views 500 vite
|
||||
view_templates = {
|
||||
'List.vue.tmpl': f'{plugin_name}List.vue',
|
||||
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
|
||||
'Form.vue.tmpl': f'{plugin_name}Form.vue',
|
||||
}
|
||||
for template_name, out_name in view_templates.items():
|
||||
template_path = fe_templates / 'views' / template_name
|
||||
if template_path.exists():
|
||||
_render_template(
|
||||
template_path,
|
||||
views_dir / out_name,
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
# route file last: all referenced views now exist on disk
|
||||
route_template = fe_templates / 'routes.js.tmpl'
|
||||
if route_template.exists():
|
||||
_render_template(
|
||||
route_template,
|
||||
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
|
||||
def scaffold_plugin(
|
||||
name: str,
|
||||
description: str,
|
||||
plugins_dir: Path,
|
||||
template_root: Optional[Path] = None,
|
||||
overwrite: bool = False,
|
||||
frontend: bool = True,
|
||||
frontend_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate a new plugin from templates.
|
||||
|
||||
@@ -71,7 +163,14 @@ def scaffold_plugin(
|
||||
description: One-sentence description for manifest.json + README
|
||||
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
|
||||
template_root: Override template source dir (default: bundled templates)
|
||||
overwrite: If True, overwrite an existing plugin directory
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Path to the generated plugin directory.
|
||||
@@ -106,6 +205,10 @@ 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
|
||||
if rel.parts and rel.parts[0] == 'frontend':
|
||||
continue
|
||||
|
||||
out_rel_str = str(rel.with_suffix(''))
|
||||
if 'model.py' in out_rel_str:
|
||||
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
|
||||
@@ -118,4 +221,16 @@ 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,
|
||||
)
|
||||
|
||||
return target
|
||||
|
||||
Reference in New Issue
Block a user