Files
pos-system/pencil-design/scripts/build.py

536 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Pencil Design Build System
Merges component library into page files
"""
import json
import sys
from pathlib import Path
from typing import Dict, Any, List
class PencilBuilder:
def __init__(self, config_path: str = "pencil.config.json"):
"""Initialize builder with config"""
# Get script directory and project root
self.script_dir = Path(__file__).parent.absolute()
self.project_root = self.script_dir.parent
# Load config from project root
config_full_path = self.project_root / config_path
self.config = self.load_config(config_full_path)
self.components = {}
def load_config(self, path: Path) -> Dict[str, Any]:
"""Load build configuration"""
if path.exists():
with open(path, 'r') as f:
return json.load(f)
# Default config
return {
"sourceDir": "src",
"buildDir": "build",
"componentLibrary": "src/components/ui-kit.pen",
"buildOptions": {
"validateAfterBuild": True
}
}
def _load_components_from_file(self, file_path: Path, components: Dict[str, Any], prefix: str = "") -> None:
"""Helper to load components from a single .pen file"""
try:
with open(file_path, 'r') as f:
data = json.load(f)
def extract_recursive(children: List[Dict], path_prefix: str):
for child in children:
name = child.get('name', '')
if name:
full_name = f"{path_prefix}/{name}" if path_prefix else name
# Only store if reusable or if we want to track everything
# For now, let's allow referencing any named frame
components[full_name] = child
# Store by simple ID/Name too if unique?
# No, strict pathing is better for atomic design
if 'children' in child:
extract_recursive(child['children'], full_name if name else path_prefix)
extract_recursive(data.get('children', []), prefix)
# If this is the main library file (legacy), capture variables too
# For atomic, we handle variables separately or merge them
if not self.variables:
self.variables = data.get('variables', {})
except Exception as e:
print(f"⚠️ Error reading {file_path.name}: {e}")
def load_component_library(self) -> Dict[str, Any]:
"""Load all components from ui-kit.pen OR Atomic Design structure"""
components = {}
self.variables = {}
# 1. Check for Atomic Design Structure
src_dir = self.project_root / self.config['sourceDir']
atomic_dirs = ['foundations', 'atoms', 'molecules', 'organisms']
found_atomic = False
for atom_dir in atomic_dirs:
dir_path = src_dir / atom_dir
if dir_path.exists():
found_atomic = True
print(f"Scanning {atom_dir}...")
for pen_file in dir_path.glob('*.pen'):
# Use filename or directory logic as prefix?
# Atomic design usually implies explicit naming in the file,
# so we don't necessarily need file prefix if component names are good.
self._load_components_from_file(pen_file, components)
if found_atomic:
# Ensure variables are loaded from foundations
tokens_path = src_dir / 'foundations/design-tokens.pen'
if tokens_path.exists():
with open(tokens_path, 'r') as f:
data = json.load(f)
self.variables = data.get('variables', {})
print(f"✓ Loaded {len(components)} components from Atomic Design structure")
return components
# 2. Fallback to Legacy Single File
lib_path = self.project_root / self.config['componentLibrary']
if not lib_path.exists():
print(f"❌ Component library not found: {lib_path}")
sys.exit(1)
print(f"Loading legacy library: {lib_path.name}")
self._load_components_from_file(lib_path, components)
print(f"✓ Loaded {len(components)} components from library")
return components
def process_component_refs(self, children: List[Dict], injected_components: List[Dict]) -> None:
"""Process component_ref and replace with proper Pencil refs"""
for i, child in enumerate(children):
if child.get('type') == 'component_ref':
# Get component name from ref
component_path = child.get('component', '')
# Extract component name (after #)
if '#' in component_path:
component_name = component_path.split('#')[1]
else:
component_name = component_path
# Find matching component
component = None
for comp_path, comp_obj in self.components.items():
if comp_path.endswith(component_name):
component = comp_obj
break
if component:
# Add component to injected list (if not already added)
comp_id = component.get('id')
if not any(c.get('id') == comp_id for c in injected_components):
injected_components.append(component.copy())
# Replace component_ref with proper ref
overrides = child.get('overrides', {})
children[i] = {
'type': 'ref',
'ref': comp_id,
'name': child.get('name', f'ref_{comp_id}'),
'descendants': overrides
}
# Copy position/size if specified
for key in ['x', 'y', 'width', 'height']:
if key in child:
children[i][key] = child[key]
else:
print(f"⚠️ Component not found: {component_name}")
# Recurse into children
if 'children' in child:
self.process_component_refs(child['children'], injected_components)
def build_page(self, page_path: Path, output_path: Path) -> None:
"""Build a single page file"""
print(f"Building {page_path.name}...")
# Load page
with open(page_path, 'r') as f:
page = json.load(f)
# Collect injected components
injected_components = []
# Process all component_ref in the page
self.process_component_refs(page.get('children', []), injected_components)
# Inject components at the beginning of children array
# (Pencil expects reusable components to be defined before usage)
page['children'] = injected_components + page['children']
# Ensure design variables are included
if not page.get('variables'):
page['variables'] = self.variables
# Write output
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w') as f:
json.dump(page, f, indent=2)
print(f"{len(injected_components)} components injected")
print(f" ✓ Saved to {output_path}")
def validate_output(self, file_path: Path) -> bool:
"""Validate built file"""
try:
with open(file_path, 'r') as f:
data = json.load(f)
# Check structure
assert 'version' in data, "Missing version field"
assert 'children' in data, "Missing children array"
assert 'variables' in data, "Missing variables object"
return True
except Exception as e:
print(f"❌ Validation failed: {e}")
return False
def build_monolithic(self, output_filename: str = "tPOS.pen") -> None:
"""Build monolithic file merging all pages and component library"""
print("🔨 Building Monolithic File")
print("=" * 50)
print(f"📁 Project root: {self.project_root}")
print()
# Load component library
lib_path = self.project_root / self.config['componentLibrary']
if not lib_path.exists():
print(f"❌ Component library not found: {lib_path}")
sys.exit(1)
with open(lib_path, 'r') as f:
lib_data = json.load(f)
# Get variables from component library
variables = lib_data.get('variables', {})
# Find all pages in src/pages/
src_pages = self.project_root / self.config['sourceDir'] / 'pages'
if not src_pages.exists():
print(f"❌ Source pages directory not found: {src_pages}")
sys.exit(1)
pages = sorted(src_pages.glob('*.pen'))
if not pages:
print(f"⚠️ No .pen files found in {src_pages}")
return
print(f"Found {len(pages)} page(s) to merge:")
for page in pages:
print(f" - {page.name}")
print()
# Create monolithic structure
monolithic = {
"version": "2.6",
"children": [],
"variables": variables
}
# Load and add all pages
current_x = 0 # Start position
page_spacing = 470 # Spacing between pages
for page_path in pages:
with open(page_path, 'r') as f:
page_data = json.load(f)
# Extract the main page frame (usually first child or children array)
page_children = page_data.get('children', [])
# Add each top-level frame to monolithic children
for child in page_children:
# Set position for this page
child['x'] = current_x
child['y'] = 0
# Calculate next position based on this page's width
page_width = child.get('width', 1440)
if isinstance(page_width, str): # Handle "fill_container"
page_width = 1440 # Default width
monolithic['children'].append(child)
# Update position for next page
current_x += page_width + page_spacing
print(f" ✓ Merged {page_path.name} ({len(page_children)} frame(s)) at x={child.get('x', 0)}")
# Add component library page at the end
lib_children = lib_data.get('children', [])
for child in lib_children:
child['x'] = current_x
child['y'] = 0
monolithic['children'].append(child)
print(f" ✓ Merged component library ({len(lib_children)} frame(s)) at x={current_x}")
print()
# Write output to project root
output_path = self.project_root / output_filename
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(monolithic, f, indent=2, ensure_ascii=False)
# Validate
if self.validate_output(output_path):
print(f" ✓ Validation passed")
print()
print("=" * 50)
print(f"✅ Monolithic build complete!")
print(f"📄 Output: {output_path.absolute()}")
print(f"📊 Total frames: {len(monolithic['children'])}")
print(f"🎨 Design tokens: {len(variables)}")
def _extract_components_recursive(self, children: List[Dict], gathered: List[Dict]) -> None:
"""Recursively find components, unwrapping structural frames"""
for child in children:
# 1. If reusable, ALWAYS keep it (it's explicitly a component)
if child.get('reusable'):
gathered.append(child)
continue
# 2. Check if it's a structural wrapper (Showcase, Section, Examples, Row)
name = child.get('name', '')
is_frame = child.get('type') == 'frame'
is_structural = any(kw in name.lower() for kw in ['showcase', 'section', 'examples', 'row', 'header', 'container'])
if is_frame and is_structural:
# Unwrap structural frames and recurse to find reusable components inside
self._extract_components_recursive(child.get('children', []), gathered)
elif is_frame:
# Non-structural frame without reusable flag
# Check if it has nested reusable components inside
nested_reusables = []
self._find_nested_reusables(child.get('children', []), nested_reusables)
if nested_reusables:
# Has nested reusables, extract them instead of the wrapper
gathered.extend(nested_reusables)
else:
# No nested reusables, keep this frame as potential component
gathered.append(child)
# Primitives are ignored at extraction level
def _find_nested_reusables(self, children: List[Dict], found: List[Dict]) -> None:
"""Find all nested reusable components"""
for child in children:
if child.get('reusable'):
found.append(child)
elif child.get('type') == 'frame':
self._find_nested_reusables(child.get('children', []), found)
def build_library(self, output_path: str = None) -> None:
"""Build merged component library from Atomic Design files"""
print("🔨 Building Component Library")
print("=" * 50)
components = []
variables = {}
# 1. Load Variables from Foundations
src_dir = self.project_root / self.config['sourceDir']
tokens_path = src_dir / 'foundations/design-tokens.pen'
if tokens_path.exists():
with open(tokens_path, 'r') as f:
data = json.load(f)
variables = data.get('variables', {})
print(f"✓ Loaded design tokens from {tokens_path.name}")
else:
print(f"⚠️ Design tokens not found at {tokens_path}")
# 2. Collect Components from Atomic Dirs
atomic_dirs = ['atoms', 'molecules', 'organisms']
total_found = 0
for atom_dir in atomic_dirs:
dir_path = src_dir / atom_dir
if not dir_path.exists():
continue
print(f"Scanning {atom_dir}...")
# Sorting ensures deterministic order - use rglob for subdirectories
for pen_file in sorted(dir_path.rglob('*.pen')):
with open(pen_file, 'r') as f:
data = json.load(f)
# Merge variables from source file (won't override existing tokens)
file_vars = data.get('variables', {})
for key, val in file_vars.items():
if key not in variables:
variables[key] = val
# Use recursive extraction
file_components = []
self._extract_components_recursive(data.get('children', []), file_components)
components.extend(file_components)
total_found += len(file_components)
print(f" ✓ Added {len(file_components)} components from {pen_file.name}")
# 3. Create Library File
library_file = {
"version": "2.6",
"children": [
{
"type": "frame",
"name": "aPOS Component Library", # Main page name
"width": 1440,
"fill": "$bg-page",
"layout": "vertical",
"gap": 40,
"padding": 40,
"children": components
}
],
"variables": variables
}
# Determine output path
if output_path:
out_file = self.project_root / output_path
else:
out_file = self.project_root / self.config['componentLibrary']
# Ensure directory exists
out_file.parent.mkdir(parents=True, exist_ok=True)
with open(out_file, 'w', encoding='utf-8') as f:
json.dump(library_file, f, indent=2)
print()
print("=" * 50)
print(f"✅ Library build complete!")
print(f"📄 Output: {out_file.absolute()}")
print(f"🧩 Components: {total_found}")
print(f"🎨 Tokens: {len(variables)}")
def build_all(self) -> None:
"""Build all pages"""
print("🔨 Pencil Design Build System")
print("=" * 50)
print(f"📁 Project root: {self.project_root}")
print()
# Load component library
self.components = self.load_component_library()
# Find all pages in src/pages/ (relative to project root)
src_pages = self.project_root / self.config['sourceDir'] / 'pages'
build_dir = self.project_root / self.config['buildDir']
if not src_pages.exists():
print(f"❌ Source pages directory not found: {src_pages}")
sys.exit(1)
pages = list(src_pages.glob('*.pen'))
if not pages:
print(f"⚠️ No .pen files found in {src_pages}")
return
print(f"\nBuilding {len(pages)} page(s)...\n")
# Build each page
built_count = 0
for page_path in pages:
output_path = build_dir / page_path.name
self.build_page(page_path, output_path)
# Validate if enabled
if self.config['buildOptions'].get('validateAfterBuild'):
if self.validate_output(output_path):
print(f" ✓ Validation passed")
built_count += 1
else:
built_count += 1
print()
print("=" * 50)
print(f"✅ Build complete! ({built_count}/{len(pages)} pages)")
print(f"📂 Output: {build_dir.absolute()}")
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description='Pencil Design Build System',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# 1. Build Component Library (from Atomic Source)
python build.py --library
# 2. Build Monolithic File (Pages + Library)
python build.py --monolithic
"""
)
parser.add_argument(
'--monolithic', '--mono', '-m',
action='store_true',
help='Build monolithic file (merge all pages and component library)'
)
parser.add_argument(
'--library', '--lib', '-l',
action='store_true',
help='Build component library file from Atomic Design sources'
)
parser.add_argument(
'--mode',
choices=['standard', 'monolithic', 'library'],
default='standard',
help='Build mode'
)
parser.add_argument(
'--output', '-o',
help='Output filename'
)
args = parser.parse_args()
builder = PencilBuilder()
# Determine build mode
if args.library or args.mode == 'library':
builder.build_library(output_path=args.output)
elif args.monolithic or args.mode == 'monolithic':
# Default output for monolithic is tPOS.pen, but allow override
output = args.output if args.output else "tPOS.pen"
builder.build_monolithic(output_filename=output)
else:
builder.build_all()
if __name__ == '__main__':
main()