"""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, 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. 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) frontend_dir: Frontend src directory to render views/routes into (default: /../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. 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 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') 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: 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