"""Plugin scaffolder. Generates a new plugin skeleton from the templates under shopdb/plugins/templates/. The generated plugin satisfies the framework contract out of the box; tests/test_plugin_scaffold.py is the canary that this stays true as the contract evolves. """ import re from pathlib import Path from string import Template from typing import Optional TEMPLATE_ROOT = Path(__file__).parent / 'templates' VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$') RESERVED_NAMES = { 'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates', 'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts', } class ScaffoldError(Exception): """Raised when scaffolding cannot proceed.""" def validate_name(name: str) -> None: """Validate plugin name against CONTRIBUTING.md and reserved list. Raises ScaffoldError on any violation. """ if not name: raise ScaffoldError('Plugin name is required') if not VALID_NAME_PATTERN.match(name): raise ScaffoldError( f'Plugin name "{name}" must be lowercase letters and digits only, ' f'starting with a letter (no hyphens, underscores, or special chars). ' f'See CONTRIBUTING.md for the naming convention.' ) if name in RESERVED_NAMES: raise ScaffoldError( f'Plugin name "{name}" is reserved. Pick a different name.' ) def pascal_case(name: str) -> str: """Convert lowercase plugin name to PascalCase class name. 'cameras' -> 'Cameras'. The convention assumes single-word lowercase plugin names per the naming rules in CONTRIBUTING.md, so this is just title-casing. """ 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, template_root: Path, overwrite: bool, ) -> None: """Render the frontend starting points for a scaffolded plugin. 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, so the views must land first. """ fe_templates = template_root / 'frontend' if not fe_templates.exists(): return plugin_name = substitutions['Name'] # 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( snippet_template, plugin_target / 'frontend-api-snippet.js', substitutions, overwrite, ) frontend_dir = plugin_target / 'frontend' views_dir = frontend_dir / 'views' # 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 / 'routes.js', substitutions, overwrite, ) def scaffold_plugin( name: str, description: str, plugins_dir: Path, template_root: Optional[Path] = None, overwrite: bool = False, frontend: bool = True, ) -> Path: """Generate a new plugin from templates. Args: name: Plugin name (lowercase, single word) description: One-sentence description for manifest.json + README plugins_dir: Target plugins directory (e.g., /plugins) template_root: Override template source dir (default: bundled templates) 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) 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. Raises: ScaffoldError on validation failure or when target exists and overwrite is False. """ validate_name(name) template_root = template_root or TEMPLATE_ROOT if not template_root.exists(): raise ScaffoldError(f'Template root not found: {template_root}') target = plugins_dir / name if target.exists(): if not overwrite: raise ScaffoldError( f'Plugin directory already exists: {target}. ' f'Pass overwrite=True or remove it first.' ) substitutions = { 'name': name, 'Name': pascal_case(name), 'description': description, } target.mkdir(parents=True, exist_ok=True) for template_path in template_root.rglob('*.tmpl'): rel = template_path.relative_to(template_root) # 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 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') out_path = target / out_rel_str out_path.parent.mkdir(parents=True, exist_ok=True) body = template_path.read_text() rendered = Template(body).safe_substitute(substitutions) out_path.write_text(rendered) if frontend: _scaffold_frontend( name=name, substitutions=substitutions, plugin_target=target, template_root=template_root, overwrite=overwrite, ) return target