Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
All checks were successful
CI / backend (push) Successful in 24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

- 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:
cproudlock
2026-07-11 10:30:03 -04:00
parent 94f852a1c8
commit 529b9f2fed
13 changed files with 1547 additions and 14 deletions

View File

@@ -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