diff --git a/backend/igny8_core/business/publishing/__init__.py b/backend/igny8_core/business/publishing/__init__.py new file mode 100644 index 00000000..421996a0 --- /dev/null +++ b/backend/igny8_core/business/publishing/__init__.py @@ -0,0 +1,5 @@ +""" +Publishing Domain +Phase 5: Sites Renderer & Publishing +""" + diff --git a/backend/igny8_core/business/publishing/apps.py b/backend/igny8_core/business/publishing/apps.py new file mode 100644 index 00000000..89133c4c --- /dev/null +++ b/backend/igny8_core/business/publishing/apps.py @@ -0,0 +1,12 @@ +""" +Publishing App Configuration +""" +from django.apps import AppConfig + + +class PublishingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'igny8_core.business.publishing' + label = 'publishing' + verbose_name = 'Publishing' + diff --git a/backend/igny8_core/business/publishing/migrations/0001_initial.py b/backend/igny8_core/business/publishing/migrations/0001_initial.py new file mode 100644 index 00000000..760c4693 --- /dev/null +++ b/backend/igny8_core/business/publishing/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated manually for Phase 5: Publishing System + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('igny8_core_auth', '0014_remove_plan_operation_limits_phase0'), + ('site_building', '0001_initial'), + ('writer', '0009_add_content_site_source_fields'), + ] + + operations = [ + migrations.CreateModel( + name='PublishingRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('destination', models.CharField(db_index=True, help_text="Destination platform: 'wordpress', 'sites', 'shopify'", max_length=50)), + ('destination_id', models.CharField(blank=True, help_text='External ID in destination platform', max_length=255, null=True)), + ('destination_url', models.URLField(blank=True, help_text='URL of published content/site', null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('publishing', 'Publishing'), ('published', 'Published'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('metadata', models.JSONField(default=dict, help_text='Platform-specific metadata')), + ('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')), + ('content', models.ForeignKey(blank=True, help_text='Content being published (if publishing content)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='publishing_records', to='writer.content')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')), + ('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')), + ('site_blueprint', models.ForeignKey(blank=True, help_text='Site blueprint being published (if publishing site)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='publishing_records', to='site_building.siteblueprint')), + ], + options={ + 'db_table': 'igny8_publishing_records', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='DeploymentRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('version', models.IntegerField(help_text='Blueprint version being deployed')), + ('deployed_version', models.IntegerField(blank=True, help_text='Currently deployed version (after successful deployment)', null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('deploying', 'Deploying'), ('deployed', 'Deployed'), ('failed', 'Failed'), ('rolled_back', 'Rolled Back')], db_index=True, default='pending', max_length=20)), + ('deployed_at', models.DateTimeField(blank=True, null=True)), + ('deployment_url', models.URLField(blank=True, help_text='Public URL of deployed site', null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('metadata', models.JSONField(default=dict, help_text='Deployment metadata (build info, file paths, etc.)')), + ('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')), + ('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')), + ('site_blueprint', models.ForeignKey(help_text='Site blueprint being deployed', on_delete=django.db.models.deletion.CASCADE, related_name='deployments', to='site_building.siteblueprint')), + ], + options={ + 'db_table': 'igny8_deployment_records', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='publishingrecord', + index=models.Index(fields=['destination', 'status'], name='igny8_publi_destina_123abc_idx'), + ), + migrations.AddIndex( + model_name='publishingrecord', + index=models.Index(fields=['content', 'destination'], name='igny8_publi_content_456def_idx'), + ), + migrations.AddIndex( + model_name='publishingrecord', + index=models.Index(fields=['site_blueprint', 'destination'], name='igny8_publi_site_bl_789ghi_idx'), + ), + migrations.AddIndex( + model_name='publishingrecord', + index=models.Index(fields=['account', 'status'], name='igny8_publi_account_012jkl_idx'), + ), + migrations.AddIndex( + model_name='deploymentrecord', + index=models.Index(fields=['site_blueprint', 'status'], name='igny8_deplo_site_bl_345mno_idx'), + ), + migrations.AddIndex( + model_name='deploymentrecord', + index=models.Index(fields=['site_blueprint', 'version'], name='igny8_deplo_site_bl_678pqr_idx'), + ), + migrations.AddIndex( + model_name='deploymentrecord', + index=models.Index(fields=['status'], name='igny8_deplo_status_901stu_idx'), + ), + migrations.AddIndex( + model_name='deploymentrecord', + index=models.Index(fields=['account', 'status'], name='igny8_deplo_account_234vwx_idx'), + ), + ] + diff --git a/backend/igny8_core/business/publishing/migrations/__init__.py b/backend/igny8_core/business/publishing/migrations/__init__.py new file mode 100644 index 00000000..42b78fcd --- /dev/null +++ b/backend/igny8_core/business/publishing/migrations/__init__.py @@ -0,0 +1,4 @@ +""" +Publishing Migrations +""" + diff --git a/backend/igny8_core/business/publishing/models.py b/backend/igny8_core/business/publishing/models.py new file mode 100644 index 00000000..63472522 --- /dev/null +++ b/backend/igny8_core/business/publishing/models.py @@ -0,0 +1,159 @@ +""" +Publishing Models +Phase 5: Sites Renderer & Publishing +""" +from django.db import models +from igny8_core.auth.models import SiteSectorBaseModel + + +class PublishingRecord(SiteSectorBaseModel): + """ + Track content publishing to various destinations. + """ + + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('publishing', 'Publishing'), + ('published', 'Published'), + ('failed', 'Failed'), + ] + + # Content or SiteBlueprint reference (one must be set) + content = models.ForeignKey( + 'content.Content', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='publishing_records', + help_text="Content being published (if publishing content)" + ) + site_blueprint = models.ForeignKey( + 'site_building.SiteBlueprint', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='publishing_records', + help_text="Site blueprint being published (if publishing site)" + ) + + # Destination information + destination = models.CharField( + max_length=50, + db_index=True, + help_text="Destination platform: 'wordpress', 'sites', 'shopify'" + ) + destination_id = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="External ID in destination platform" + ) + destination_url = models.URLField( + blank=True, + null=True, + help_text="URL of published content/site" + ) + + # Status tracking + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending', + db_index=True + ) + published_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(blank=True, null=True) + + # Metadata + metadata = models.JSONField( + default=dict, + help_text="Platform-specific metadata" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'publishing' + db_table = 'igny8_publishing_records' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['destination', 'status']), + models.Index(fields=['content', 'destination']), + models.Index(fields=['site_blueprint', 'destination']), + models.Index(fields=['account', 'status']), + ] + + def __str__(self): + target = self.content or self.site_blueprint + return f"{target} → {self.destination} ({self.get_status_display()})" + + +class DeploymentRecord(SiteSectorBaseModel): + """ + Track site deployments to Sites renderer. + """ + + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('deploying', 'Deploying'), + ('deployed', 'Deployed'), + ('failed', 'Failed'), + ('rolled_back', 'Rolled Back'), + ] + + site_blueprint = models.ForeignKey( + 'site_building.SiteBlueprint', + on_delete=models.CASCADE, + related_name='deployments', + help_text="Site blueprint being deployed" + ) + + # Version tracking + version = models.IntegerField( + help_text="Blueprint version being deployed" + ) + deployed_version = models.IntegerField( + null=True, + blank=True, + help_text="Currently deployed version (after successful deployment)" + ) + + # Status tracking + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending', + db_index=True + ) + deployed_at = models.DateTimeField(null=True, blank=True) + deployment_url = models.URLField( + blank=True, + null=True, + help_text="Public URL of deployed site" + ) + error_message = models.TextField(blank=True, null=True) + + # Metadata + metadata = models.JSONField( + default=dict, + help_text="Deployment metadata (build info, file paths, etc.)" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'publishing' + db_table = 'igny8_deployment_records' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['site_blueprint', 'status']), + models.Index(fields=['site_blueprint', 'version']), + models.Index(fields=['status']), + models.Index(fields=['account', 'status']), + ] + + def __str__(self): + return f"{self.site_blueprint.name} v{self.version} ({self.get_status_display()})" + diff --git a/backend/igny8_core/business/publishing/services/__init__.py b/backend/igny8_core/business/publishing/services/__init__.py new file mode 100644 index 00000000..761a72e1 --- /dev/null +++ b/backend/igny8_core/business/publishing/services/__init__.py @@ -0,0 +1,4 @@ +""" +Publishing Services +""" + diff --git a/backend/igny8_core/business/publishing/services/adapters/__init__.py b/backend/igny8_core/business/publishing/services/adapters/__init__.py new file mode 100644 index 00000000..90eb6f96 --- /dev/null +++ b/backend/igny8_core/business/publishing/services/adapters/__init__.py @@ -0,0 +1,4 @@ +""" +Publishing Adapters +""" + diff --git a/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py b/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py new file mode 100644 index 00000000..d9862caf --- /dev/null +++ b/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py @@ -0,0 +1,203 @@ +""" +Sites Renderer Adapter +Phase 5: Sites Renderer & Publishing + +Adapter for deploying sites to IGNY8 Sites renderer. +""" +import logging +import json +import os +from typing import Dict, Any +from pathlib import Path + +from igny8_core.business.site_building.models import SiteBlueprint +from igny8_core.business.publishing.models import DeploymentRecord + +logger = logging.getLogger(__name__) + + +class SitesRendererAdapter: + """ + Adapter for deploying sites to IGNY8 Sites renderer. + Writes site definitions to filesystem for Sites container to serve. + """ + + def __init__(self): + self.sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data') + + def deploy(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]: + """ + Deploy site blueprint to Sites renderer. + + Args: + site_blueprint: SiteBlueprint instance to deploy + + Returns: + dict: Deployment result with status and deployment record + """ + try: + # Create deployment record + deployment = DeploymentRecord.objects.create( + account=site_blueprint.account, + site=site_blueprint.site, + sector=site_blueprint.sector, + site_blueprint=site_blueprint, + version=site_blueprint.version, + status='deploying' + ) + + # Build site definition + site_definition = self._build_site_definition(site_blueprint) + + # Write to filesystem + deployment_path = self._write_site_definition( + site_blueprint, + site_definition, + deployment.version + ) + + # Update deployment record + deployment.status = 'deployed' + deployment.deployed_version = site_blueprint.version + deployment.deployment_url = self._get_deployment_url(site_blueprint) + deployment.metadata = { + 'deployment_path': str(deployment_path), + 'site_definition': site_definition + } + deployment.save() + + # Update blueprint + site_blueprint.deployed_version = site_blueprint.version + site_blueprint.status = 'deployed' + site_blueprint.save(update_fields=['deployed_version', 'status', 'updated_at']) + + logger.info( + f"[SitesRendererAdapter] Successfully deployed site {site_blueprint.id} v{deployment.version}" + ) + + return { + 'success': True, + 'deployment_id': deployment.id, + 'version': deployment.version, + 'deployment_url': deployment.deployment_url, + 'deployment_path': str(deployment_path) + } + + except Exception as e: + logger.error( + f"[SitesRendererAdapter] Error deploying site {site_blueprint.id}: {str(e)}", + exc_info=True + ) + + # Update deployment record with error + if 'deployment' in locals(): + deployment.status = 'failed' + deployment.error_message = str(e) + deployment.save() + + return { + 'success': False, + 'error': str(e) + } + + def _build_site_definition(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]: + """ + Build site definition JSON from blueprint. + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + dict: Site definition structure + """ + # Get all pages + pages = [] + for page in site_blueprint.pages.all().order_by('order'): + pages.append({ + 'id': page.id, + 'slug': page.slug, + 'title': page.title, + 'type': page.type, + 'blocks': page.blocks_json, + 'status': page.status, + }) + + # Build site definition + definition = { + 'id': site_blueprint.id, + 'name': site_blueprint.name, + 'description': site_blueprint.description, + 'version': site_blueprint.version, + 'layout': site_blueprint.structure_json.get('layout', 'default'), + 'theme': site_blueprint.structure_json.get('theme', {}), + 'navigation': site_blueprint.structure_json.get('navigation', []), + 'pages': pages, + 'config': site_blueprint.config_json, + 'created_at': site_blueprint.created_at.isoformat(), + 'updated_at': site_blueprint.updated_at.isoformat(), + } + + return definition + + def _write_site_definition( + self, + site_blueprint: SiteBlueprint, + site_definition: Dict[str, Any], + version: int + ) -> Path: + """ + Write site definition to filesystem. + + Args: + site_blueprint: SiteBlueprint instance + site_definition: Site definition dict + version: Version number + + Returns: + Path: Deployment path + """ + # Build path: /data/app/sites-data/clients/{site_id}/v{version}/ + site_id = site_blueprint.site.id + deployment_dir = Path(self.sites_data_path) / 'clients' / str(site_id) / f'v{version}' + deployment_dir.mkdir(parents=True, exist_ok=True) + + # Write site.json + site_json_path = deployment_dir / 'site.json' + with open(site_json_path, 'w', encoding='utf-8') as f: + json.dump(site_definition, f, indent=2, ensure_ascii=False) + + # Write pages + pages_dir = deployment_dir / 'pages' + pages_dir.mkdir(exist_ok=True) + + for page in site_definition.get('pages', []): + page_json_path = pages_dir / f"{page['slug']}.json" + with open(page_json_path, 'w', encoding='utf-8') as f: + json.dump(page, f, indent=2, ensure_ascii=False) + + # Ensure assets directory exists + assets_dir = deployment_dir / 'assets' + assets_dir.mkdir(exist_ok=True) + (assets_dir / 'images').mkdir(exist_ok=True) + (assets_dir / 'documents').mkdir(exist_ok=True) + (assets_dir / 'media').mkdir(exist_ok=True) + + logger.info(f"[SitesRendererAdapter] Wrote site definition to {deployment_dir}") + + return deployment_dir + + def _get_deployment_url(self, site_blueprint: SiteBlueprint) -> str: + """ + Get deployment URL for site. + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + str: Deployment URL + """ + # TODO: Implement URL generation based on site configuration + # For now, return placeholder + site_id = site_blueprint.site.id + return f"https://{site_id}.igny8.com" # Placeholder + diff --git a/backend/igny8_core/business/publishing/services/deployment_service.py b/backend/igny8_core/business/publishing/services/deployment_service.py new file mode 100644 index 00000000..e9a1cc5f --- /dev/null +++ b/backend/igny8_core/business/publishing/services/deployment_service.py @@ -0,0 +1,140 @@ +""" +Deployment Service +Phase 5: Sites Renderer & Publishing + +Manages deployment lifecycle for sites. +""" +import logging +from typing import Optional + +from igny8_core.business.site_building.models import SiteBlueprint +from igny8_core.business.publishing.models import DeploymentRecord + +logger = logging.getLogger(__name__) + + +class DeploymentService: + """ + Service for managing site deployment lifecycle. + """ + + def get_status(self, site_blueprint: SiteBlueprint) -> Optional[DeploymentRecord]: + """ + Get current deployment status for a site. + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + DeploymentRecord or None + """ + return DeploymentRecord.objects.filter( + site_blueprint=site_blueprint, + status='deployed' + ).order_by('-deployed_at').first() + + def get_latest_deployment( + self, + site_blueprint: SiteBlueprint + ) -> Optional[DeploymentRecord]: + """ + Get latest deployment record (any status). + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + DeploymentRecord or None + """ + return DeploymentRecord.objects.filter( + site_blueprint=site_blueprint + ).order_by('-created_at').first() + + def rollback( + self, + site_blueprint: SiteBlueprint, + target_version: int + ) -> dict: + """ + Rollback site to a previous version. + + Args: + site_blueprint: SiteBlueprint instance + target_version: Version to rollback to + + Returns: + dict: Rollback result + """ + try: + # Find deployment record for target version + target_deployment = DeploymentRecord.objects.filter( + site_blueprint=site_blueprint, + version=target_version, + status='deployed' + ).first() + + if not target_deployment: + return { + 'success': False, + 'error': f'Deployment for version {target_version} not found' + } + + # Create new deployment record for rollback + rollback_deployment = DeploymentRecord.objects.create( + account=site_blueprint.account, + site=site_blueprint.site, + sector=site_blueprint.sector, + site_blueprint=site_blueprint, + version=target_version, + status='deployed', + deployed_version=target_version, + deployment_url=target_deployment.deployment_url, + metadata={ + 'rollback_from': site_blueprint.version, + 'rollback_to': target_version + } + ) + + # Update blueprint + site_blueprint.deployed_version = target_version + site_blueprint.save(update_fields=['deployed_version', 'updated_at']) + + logger.info( + f"[DeploymentService] Rolled back site {site_blueprint.id} to version {target_version}" + ) + + return { + 'success': True, + 'deployment_id': rollback_deployment.id, + 'version': target_version + } + + except Exception as e: + logger.error( + f"[DeploymentService] Error rolling back site {site_blueprint.id}: {str(e)}", + exc_info=True + ) + return { + 'success': False, + 'error': str(e) + } + + def list_deployments( + self, + site_blueprint: SiteBlueprint + ) -> list: + """ + List all deployments for a site. + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + list: List of DeploymentRecord instances + """ + return list( + DeploymentRecord.objects.filter( + site_blueprint=site_blueprint + ).order_by('-created_at') + ) + diff --git a/backend/igny8_core/business/publishing/services/publisher_service.py b/backend/igny8_core/business/publishing/services/publisher_service.py new file mode 100644 index 00000000..c1cbdc4c --- /dev/null +++ b/backend/igny8_core/business/publishing/services/publisher_service.py @@ -0,0 +1,187 @@ +""" +Publisher Service +Phase 5: Sites Renderer & Publishing + +Main publishing orchestrator for content and sites. +""" +import logging +from typing import Optional, List, Dict, Any + +from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord +from igny8_core.business.site_building.models import SiteBlueprint + +logger = logging.getLogger(__name__) + + +class PublisherService: + """ + Main publishing service for content and sites. + Routes to appropriate adapters based on destination. + """ + + def __init__(self): + self.adapters = {} + self._load_adapters() + + def _load_adapters(self): + """Lazy load adapters to avoid circular imports""" + pass # Will be implemented when adapters are created + + def publish_to_sites(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]: + """ + Publish site to Sites renderer. + + Args: + site_blueprint: SiteBlueprint instance to deploy + + Returns: + dict: Deployment result with status and deployment record + """ + from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter + + adapter = SitesRendererAdapter() + return adapter.deploy(site_blueprint) + + def get_deployment_status(self, site_blueprint: SiteBlueprint) -> Optional[DeploymentRecord]: + """ + Get deployment status for a site. + + Args: + site_blueprint: SiteBlueprint instance + + Returns: + DeploymentRecord or None + """ + from igny8_core.business.publishing.services.deployment_service import DeploymentService + + service = DeploymentService() + return service.get_status(site_blueprint) + + def publish_content( + self, + content_id: int, + destinations: List[str], + account + ) -> Dict[str, Any]: + """ + Publish content to multiple destinations. + + Args: + content_id: Content ID to publish + destinations: List of destination platforms ('wordpress', 'sites', 'shopify') + account: Account instance + + Returns: + dict: Publishing results per destination + """ + from igny8_core.business.content.models import Content + + try: + content = Content.objects.get(id=content_id, account=account) + except Content.DoesNotExist: + return { + 'success': False, + 'error': f'Content {content_id} not found' + } + + results = [] + for destination in destinations: + try: + result = self._publish_to_destination(content, destination, account) + results.append(result) + except Exception as e: + logger.error( + f"Error publishing content {content_id} to {destination}: {str(e)}", + exc_info=True + ) + results.append({ + 'destination': destination, + 'success': False, + 'error': str(e) + }) + + return { + 'success': all(r.get('success', False) for r in results), + 'results': results + } + + def _publish_to_destination( + self, + content, + destination: str, + account + ) -> Dict[str, Any]: + """ + Publish content to a specific destination. + + Args: + content: Content instance + destination: Destination platform name + account: Account instance + + Returns: + dict: Publishing result + """ + # Create publishing record + record = PublishingRecord.objects.create( + account=account, + site=content.site, + sector=content.sector, + content=content, + destination=destination, + status='pending' + ) + + try: + # Get adapter for destination + adapter = self._get_adapter(destination) + if not adapter: + raise ValueError(f"No adapter found for destination: {destination}") + + # Publish via adapter + result = adapter.publish(content, {'account': account}) + + # Update record + record.status = 'published' if result.get('success') else 'failed' + record.destination_id = result.get('external_id') + record.destination_url = result.get('url') + record.published_at = result.get('published_at') + record.error_message = result.get('error') + record.metadata = result.get('metadata', {}) + record.save() + + return { + 'destination': destination, + 'success': result.get('success', False), + 'publishing_record_id': record.id, + 'external_id': result.get('external_id'), + 'url': result.get('url') + } + except Exception as e: + record.status = 'failed' + record.error_message = str(e) + record.save() + raise + + def _get_adapter(self, destination: str): + """ + Get adapter for destination platform. + + Args: + destination: Platform name ('wordpress', 'sites', 'shopify') + + Returns: + Adapter instance or None + """ + # Lazy import to avoid circular dependencies + if destination == 'sites': + from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter + return SitesRendererAdapter() + elif destination == 'wordpress': + # Will be implemented in Phase 6 + return None + elif destination == 'shopify': + # Will be implemented in Phase 6 + return None + return None + diff --git a/backend/igny8_core/modules/publisher/__init__.py b/backend/igny8_core/modules/publisher/__init__.py new file mode 100644 index 00000000..5056aad5 --- /dev/null +++ b/backend/igny8_core/modules/publisher/__init__.py @@ -0,0 +1,5 @@ +""" +Publisher Module +Phase 5: Sites Renderer & Publishing +""" + diff --git a/backend/igny8_core/modules/publisher/apps.py b/backend/igny8_core/modules/publisher/apps.py new file mode 100644 index 00000000..e3b91ddd --- /dev/null +++ b/backend/igny8_core/modules/publisher/apps.py @@ -0,0 +1,12 @@ +""" +Publisher Module App Configuration +""" +from django.apps import AppConfig + + +class PublisherConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'igny8_core.modules.publisher' + label = 'publisher' + verbose_name = 'Publisher' + diff --git a/backend/igny8_core/modules/publisher/urls.py b/backend/igny8_core/modules/publisher/urls.py new file mode 100644 index 00000000..9ae9efd5 --- /dev/null +++ b/backend/igny8_core/modules/publisher/urls.py @@ -0,0 +1,22 @@ +""" +Publisher URLs +Phase 5: Sites Renderer & Publishing +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from igny8_core.modules.publisher.views import ( + PublishingRecordViewSet, + DeploymentRecordViewSet, + PublisherViewSet, +) + +router = DefaultRouter() +router.register(r'publishing-records', PublishingRecordViewSet, basename='publishing-record') +router.register(r'deployments', DeploymentRecordViewSet, basename='deployment') +router.register(r'publisher', PublisherViewSet, basename='publisher') + +urlpatterns = [ + path('', include(router.urls)), +] + diff --git a/backend/igny8_core/modules/publisher/views.py b/backend/igny8_core/modules/publisher/views.py new file mode 100644 index 00000000..633ffd15 --- /dev/null +++ b/backend/igny8_core/modules/publisher/views.py @@ -0,0 +1,198 @@ +""" +Publisher ViewSet +Phase 5: Sites Renderer & Publishing +""" +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from igny8_core.api.base import SiteSectorModelViewSet +from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord +from igny8_core.business.publishing.services.publisher_service import PublisherService +from igny8_core.business.site_building.models import SiteBlueprint + + +class PublishingRecordViewSet(SiteSectorModelViewSet): + """ + ViewSet for PublishingRecord model. + """ + queryset = PublishingRecord.objects.select_related('content', 'site_blueprint') + permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] + throttle_scope = 'publisher' + throttle_classes = [DebugScopedRateThrottle] + + def get_serializer_class(self): + # Will be created in next step + from rest_framework import serializers + + class PublishingRecordSerializer(serializers.ModelSerializer): + class Meta: + model = PublishingRecord + fields = '__all__' + + return PublishingRecordSerializer + + +class DeploymentRecordViewSet(SiteSectorModelViewSet): + """ + ViewSet for DeploymentRecord model. + """ + queryset = DeploymentRecord.objects.select_related('site_blueprint') + permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] + throttle_scope = 'publisher' + throttle_classes = [DebugScopedRateThrottle] + + def get_serializer_class(self): + # Will be created in next step + from rest_framework import serializers + + class DeploymentRecordSerializer(serializers.ModelSerializer): + class Meta: + model = DeploymentRecord + fields = '__all__' + + return DeploymentRecordSerializer + + +class PublisherViewSet(viewsets.ViewSet): + """ + Publisher actions for publishing content and sites. + """ + permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] + throttle_scope = 'publisher' + throttle_classes = [DebugScopedRateThrottle] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.publisher_service = PublisherService() + + @action(detail=False, methods=['post'], url_path='publish') + def publish(self, request): + """ + Publish content or site to destinations. + + Request body: + { + "content_id": 123, # Optional: content to publish + "site_blueprint_id": 456, # Optional: site to publish + "destinations": ["wordpress", "sites"] # Required: list of destinations + } + """ + content_id = request.data.get('content_id') + site_blueprint_id = request.data.get('site_blueprint_id') + destinations = request.data.get('destinations', []) + + if not destinations: + return error_response( + 'destinations is required', + status.HTTP_400_BAD_REQUEST, + request + ) + + account = request.account + + if site_blueprint_id: + # Publish site + try: + blueprint = SiteBlueprint.objects.get(id=site_blueprint_id, account=account) + except SiteBlueprint.DoesNotExist: + return error_response( + f'Site blueprint {site_blueprint_id} not found', + status.HTTP_404_NOT_FOUND, + request + ) + + if 'sites' in destinations: + result = self.publisher_service.publish_to_sites(blueprint) + return success_response(result, request=request) + else: + return error_response( + 'Site publishing only supports "sites" destination', + status.HTTP_400_BAD_REQUEST, + request + ) + + elif content_id: + # Publish content + result = self.publisher_service.publish_content( + content_id, + destinations, + account + ) + return success_response(result, request=request) + + else: + return error_response( + 'Either content_id or site_blueprint_id is required', + status.HTTP_400_BAD_REQUEST, + request + ) + + @action(detail=False, methods=['post'], url_path='deploy/(?P[^/.]+)') + def deploy(self, request, blueprint_id): + """ + Deploy site blueprint to Sites renderer. + + POST /api/v1/publisher/deploy/{blueprint_id}/ + """ + account = request.account + + try: + blueprint = SiteBlueprint.objects.get(id=blueprint_id, account=account) + except SiteBlueprint.DoesNotExist: + return error_response( + f'Site blueprint {blueprint_id} not found', + status.HTTP_404_NOT_FOUND, + request + ) + + result = self.publisher_service.publish_to_sites(blueprint) + + response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST + return success_response(result, request=request, status_code=response_status) + + @action(detail=False, methods=['get'], url_path='status/(?P[^/.]+)') + def get_status(self, request, id): + """ + Get publishing/deployment status. + + GET /api/v1/publisher/status/{id}/ + """ + account = request.account + + # Try deployment record first + try: + deployment = DeploymentRecord.objects.get(id=id, account=account) + return success_response({ + 'type': 'deployment', + 'status': deployment.status, + 'version': deployment.version, + 'deployed_version': deployment.deployed_version, + 'deployment_url': deployment.deployment_url, + 'deployed_at': deployment.deployed_at, + 'error_message': deployment.error_message, + }, request=request) + except DeploymentRecord.DoesNotExist: + pass + + # Try publishing record + try: + publishing = PublishingRecord.objects.get(id=id, account=account) + return success_response({ + 'type': 'publishing', + 'status': publishing.status, + 'destination': publishing.destination, + 'destination_url': publishing.destination_url, + 'published_at': publishing.published_at, + 'error_message': publishing.error_message, + }, request=request) + except PublishingRecord.DoesNotExist: + return error_response( + f'Record {id} not found', + status.HTTP_404_NOT_FOUND, + request + ) + diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 7f9c64f2..4d228bf3 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -54,9 +54,11 @@ INSTALLED_APPS = [ 'igny8_core.modules.automation.apps.AutomationConfig', 'igny8_core.business.site_building.apps.SiteBuildingConfig', 'igny8_core.business.optimization.apps.OptimizationConfig', + 'igny8_core.business.publishing.apps.PublishingConfig', 'igny8_core.modules.site_builder.apps.SiteBuilderConfig', 'igny8_core.modules.linker.apps.LinkerConfig', 'igny8_core.modules.optimizer.apps.OptimizerConfig', + 'igny8_core.modules.publisher.apps.PublisherConfig', ] # System module needs explicit registration for admin diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index f034bd70..cd947d21 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints + path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints # OpenAPI Schema and Documentation path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/docker-compose.app.yml b/docker-compose.app.yml index e1963d3e..4074d315 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -15,6 +15,7 @@ # cd /data/app/igny8/backend && docker build -t igny8-backend:latest -f Dockerfile . # cd /data/app/igny8/frontend && docker build -t igny8-frontend-dev:latest -f Dockerfile.dev . # cd /data/app/igny8/frontend && docker build -t igny8-marketing-dev:latest -f Dockerfile.marketing.dev . +# cd /data/app/igny8/sites && docker build -t igny8-sites-dev:latest -f Dockerfile.dev . # ============================================================================= name: igny8-app @@ -119,6 +120,29 @@ services: - "com.docker.compose.project=igny8-app" - "com.docker.compose.service=igny8_site_builder" + igny8_sites: + # Sites renderer for hosting public sites + # Build separately: docker build -t igny8-sites-dev:latest -f Dockerfile.dev . + image: igny8-sites-dev:latest + container_name: igny8_sites + restart: always + ports: + - "0.0.0.0:8024:5176" # Sites renderer dev server port + environment: + VITE_API_URL: "https://api.igny8.com/api" + SITES_DATA_PATH: "/sites" + volumes: + - /data/app/igny8/sites:/app:rw + - /data/app/sites-data:/sites:ro + - /data/app/igny8/frontend:/frontend:ro + depends_on: + igny8_backend: + condition: service_healthy + networks: [igny8_net] + labels: + - "com.docker.compose.project=igny8-app" + - "com.docker.compose.service=igny8_sites" + igny8_celery_worker: image: igny8-backend:latest container_name: igny8_celery_worker diff --git a/sites/Dockerfile.dev b/sites/Dockerfile.dev new file mode 100644 index 00000000..64399848 --- /dev/null +++ b/sites/Dockerfile.dev @@ -0,0 +1,17 @@ +# Sites Renderer Dev Image (Node 22 to satisfy Vite requirements) +FROM node:22-alpine + +WORKDIR /app + +# Copy package manifests first for better caching +COPY package*.json ./ + +RUN npm install + +# Copy source (still bind-mounted at runtime, but needed for initial run) +COPY . . + +EXPOSE 5176 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5176"] + diff --git a/sites/eslint.config.js b/sites/eslint.config.js new file mode 100644 index 00000000..c34f923c --- /dev/null +++ b/sites/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) + diff --git a/sites/index.html b/sites/index.html new file mode 100644 index 00000000..14ba788e --- /dev/null +++ b/sites/index.html @@ -0,0 +1,14 @@ + + + + + + + IGNY8 Sites + + +
+ + + + diff --git a/sites/package.json b/sites/package.json new file mode 100644 index 00000000..eeab3af0 --- /dev/null +++ b/sites/package.json @@ -0,0 +1,34 @@ +{ + "name": "igny8-sites", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.2", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.3", + "vite": "^7.2.2" + } +} + diff --git a/sites/public/vite.svg b/sites/public/vite.svg new file mode 100644 index 00000000..119d59b9 --- /dev/null +++ b/sites/public/vite.svg @@ -0,0 +1,2 @@ + + diff --git a/sites/src/App.tsx b/sites/src/App.tsx new file mode 100644 index 00000000..28cda5c9 --- /dev/null +++ b/sites/src/App.tsx @@ -0,0 +1,16 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import SiteRenderer from './pages/SiteRenderer'; + +function App() { + return ( + + + } /> + IGNY8 Sites Renderer} /> + + + ); +} + +export default App; + diff --git a/sites/src/index.css b/sites/src/index.css new file mode 100644 index 00000000..7c1a327d --- /dev/null +++ b/sites/src/index.css @@ -0,0 +1,29 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; + margin: 0 auto; + text-align: center; +} + diff --git a/sites/src/loaders/loadSiteDefinition.ts b/sites/src/loaders/loadSiteDefinition.ts new file mode 100644 index 00000000..377c4173 --- /dev/null +++ b/sites/src/loaders/loadSiteDefinition.ts @@ -0,0 +1,92 @@ +/** + * Site Definition Loader + * Phase 5: Sites Renderer & Publishing + * + * Loads site definitions from the filesystem or API. + */ +import axios from 'axios'; +import type { SiteDefinition } from '../types'; + +const API_URL = import.meta.env.VITE_API_URL || 'https://api.igny8.com/api'; +const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites'; + +/** + * Load site definition by site ID. + * First tries to load from filesystem (deployed sites), + * then falls back to API. + */ +export async function loadSiteDefinition(siteId: string): Promise { + // Try filesystem first (for deployed sites) + try { + const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/latest/site.json`; + const response = await fetch(fsPath); + if (response.ok) { + const definition = await response.json(); + return definition; + } + } catch (error) { + // Filesystem load failed, try API + console.warn('Failed to load from filesystem, trying API:', error); + } + + // Fallback to API + try { + const response = await axios.get(`${API_URL}/v1/site-builder/blueprints/${siteId}/`); + const blueprint = response.data; + + // Transform blueprint to site definition format + return transformBlueprintToSiteDefinition(blueprint); + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(`Failed to load site: ${error.message}`); + } + throw error; + } +} + +/** + * Transform SiteBlueprint to SiteDefinition format. + */ +function transformBlueprintToSiteDefinition(blueprint: any): SiteDefinition { + return { + id: blueprint.id, + name: blueprint.name, + description: blueprint.description, + version: blueprint.version, + layout: blueprint.structure_json?.layout || 'default', + theme: blueprint.structure_json?.theme || {}, + navigation: blueprint.structure_json?.navigation || [], + pages: blueprint.pages?.map((page: any) => ({ + id: page.id, + slug: page.slug, + title: page.title, + type: page.type, + blocks: page.blocks_json || [], + status: page.status, + })) || [], + config: blueprint.config_json || {}, + created_at: blueprint.created_at, + updated_at: blueprint.updated_at, + }; +} + +/** + * Load site definition from a specific version. + */ +export async function loadSiteDefinitionByVersion( + siteId: string, + version: number +): Promise { + try { + const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/site.json`; + const response = await fetch(fsPath); + if (response.ok) { + const definition = await response.json(); + return definition; + } + throw new Error(`Version ${version} not found`); + } catch (error) { + throw new Error(`Failed to load site version ${version}: ${error}`); + } +} + diff --git a/sites/src/main.tsx b/sites/src/main.tsx new file mode 100644 index 00000000..1402347c --- /dev/null +++ b/sites/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) + diff --git a/sites/src/pages/SiteRenderer.tsx b/sites/src/pages/SiteRenderer.tsx new file mode 100644 index 00000000..594637ea --- /dev/null +++ b/sites/src/pages/SiteRenderer.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { loadSiteDefinition } from '../loaders/loadSiteDefinition'; +import { renderLayout } from '../utils/layoutRenderer'; +import type { SiteDefinition } from '../types'; + +function SiteRenderer() { + const { siteId } = useParams<{ siteId: string }>(); + const [siteDefinition, setSiteDefinition] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!siteId) { + setError('Site ID is required'); + setLoading(false); + return; + } + + loadSiteDefinition(siteId) + .then((definition) => { + setSiteDefinition(definition); + setLoading(false); + }) + .catch((err) => { + setError(err.message || 'Failed to load site'); + setLoading(false); + }); + }, [siteId]); + + if (loading) { + return
Loading site...
; + } + + if (error) { + return
Error: {error}
; + } + + if (!siteDefinition) { + return
Site not found
; + } + + return ( +
+ {renderLayout(siteDefinition)} +
+ ); +} + +export default SiteRenderer; + diff --git a/sites/src/types/index.ts b/sites/src/types/index.ts new file mode 100644 index 00000000..2492d0dd --- /dev/null +++ b/sites/src/types/index.ts @@ -0,0 +1,34 @@ +export interface SiteDefinition { + id: number; + name: string; + description?: string; + version: number; + layout: string; + theme: Record; + navigation: NavigationItem[]; + pages: PageDefinition[]; + config: Record; + created_at: string; + updated_at: string; +} + +export interface NavigationItem { + label: string; + slug: string; + order: number; +} + +export interface PageDefinition { + id: number; + slug: string; + title: string; + type: string; + blocks: Block[]; + status: string; +} + +export interface Block { + type: string; + data: Record; +} + diff --git a/sites/src/utils/fileAccess.ts b/sites/src/utils/fileAccess.ts new file mode 100644 index 00000000..016dee8d --- /dev/null +++ b/sites/src/utils/fileAccess.ts @@ -0,0 +1,119 @@ +/** + * File Access Utility + * Phase 5: Sites Renderer & Publishing + * + * Integrates with Phase 3's SiteBuilderFileService for file access. + * Provides utilities to access site assets (images, documents, media). + */ + +const SITES_DATA_PATH = import.meta.env.SITES_DATA_PATH || '/sites'; +const API_URL = import.meta.env.VITE_API_URL || 'https://api.igny8.com/api'; + +/** + * Get file URL for a site asset. + * + * @param siteId - Site ID + * @param version - Site version (optional, defaults to 'latest') + * @param filePath - Relative path to file from assets directory + * @returns Full URL to the asset + */ +export function getSiteAssetUrl( + siteId: string | number, + filePath: string, + version: string | number = 'latest' +): string { + // Try filesystem first (for deployed sites) + const fsPath = `${SITES_DATA_PATH}/clients/${siteId}/v${version}/assets/${filePath}`; + + // In browser, we need to use API endpoint + // The backend will serve files from the filesystem + return `${API_URL}/v1/site-builder/assets/${siteId}/${version}/${filePath}`; +} + +/** + * Get image URL for a site. + */ +export function getSiteImageUrl( + siteId: string | number, + imagePath: string, + version: string | number = 'latest' +): string { + return getSiteAssetUrl(siteId, `images/${imagePath}`, version); +} + +/** + * Get document URL for a site. + */ +export function getSiteDocumentUrl( + siteId: string | number, + documentPath: string, + version: string | number = 'latest' +): string { + return getSiteAssetUrl(siteId, `documents/${documentPath}`, version); +} + +/** + * Get media URL for a site. + */ +export function getSiteMediaUrl( + siteId: string | number, + mediaPath: string, + version: string | number = 'latest' +): string { + return getSiteAssetUrl(siteId, `media/${mediaPath}`, version); +} + +/** + * Check if a file exists. + * + * @param siteId - Site ID + * @param filePath - Relative path to file + * @param version - Site version + * @returns Promise that resolves to true if file exists + */ +export async function fileExists( + siteId: string | number, + filePath: string, + version: string | number = 'latest' +): Promise { + try { + const url = getSiteAssetUrl(siteId, filePath, version); + const response = await fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } +} + +/** + * Load file content as text. + */ +export async function loadFileAsText( + siteId: string | number, + filePath: string, + version: string | number = 'latest' +): Promise { + const url = getSiteAssetUrl(siteId, filePath, version); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load file: ${filePath}`); + } + return response.text(); +} + +/** + * Load file content as blob. + */ +export async function loadFileAsBlob( + siteId: string | number, + filePath: string, + version: string | number = 'latest' +): Promise { + const url = getSiteAssetUrl(siteId, filePath, version); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load file: ${filePath}`); + } + return response.blob(); +} + diff --git a/sites/src/utils/layoutRenderer.tsx b/sites/src/utils/layoutRenderer.tsx new file mode 100644 index 00000000..deaad19d --- /dev/null +++ b/sites/src/utils/layoutRenderer.tsx @@ -0,0 +1,205 @@ +/** + * Layout Renderer + * Phase 5: Sites Renderer & Publishing + * + * Renders different layout types for sites. + */ +import React from 'react'; +import type { SiteDefinition } from '../types'; +import { renderTemplate } from './templateEngine'; + +export type LayoutType = + | 'default' + | 'centered' + | 'sidebar' + | 'grid' + | 'magazine' + | 'minimal' + | 'fullwidth'; + +/** + * Render site layout based on site definition. + */ +export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement { + const layoutType = siteDefinition.layout as LayoutType; + + switch (layoutType) { + case 'centered': + return renderCenteredLayout(siteDefinition); + case 'sidebar': + return renderSidebarLayout(siteDefinition); + case 'grid': + return renderGridLayout(siteDefinition); + case 'magazine': + return renderMagazineLayout(siteDefinition); + case 'minimal': + return renderMinimalLayout(siteDefinition); + case 'fullwidth': + return renderFullwidthLayout(siteDefinition); + case 'default': + default: + return renderDefaultLayout(siteDefinition); + } +} + +/** + * Default layout: Standard header, content, footer. + */ +function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+ +
+
+ {renderPages(siteDefinition.pages)} +
+
+

© {new Date().getFullYear()} {siteDefinition.name}

+
+
+ ); +} + +/** + * Centered layout: Content centered with max-width. + */ +function renderCenteredLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+ +
+
+ {renderPages(siteDefinition.pages)} +
+
+

© {new Date().getFullYear()} {siteDefinition.name}

+
+
+ ); +} + +/** + * Sidebar layout: Sidebar navigation with main content. + */ +function renderSidebarLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+ +
+ {renderPages(siteDefinition.pages)} +
+
+ ); +} + +/** + * Grid layout: Grid-based page layout. + */ +function renderGridLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+ +
+
+ {renderPages(siteDefinition.pages)} +
+
+ ); +} + +/** + * Magazine layout: Multi-column magazine style. + */ +function renderMagazineLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+

{siteDefinition.name}

+ +
+
+ {renderPages(siteDefinition.pages)} +
+
+ ); +} + +/** + * Minimal layout: Clean, minimal design. + */ +function renderMinimalLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+ {renderPages(siteDefinition.pages)} +
+
+ ); +} + +/** + * Fullwidth layout: Full-width content. + */ +function renderFullwidthLayout(siteDefinition: SiteDefinition): React.ReactElement { + return ( +
+
+ +
+
+ {renderPages(siteDefinition.pages)} +
+
+ ); +} + +/** + * Render navigation items. + */ +function renderNavigation(navigation: SiteDefinition['navigation']): React.ReactElement { + return ( + + ); +} + +/** + * Render pages. + */ +function renderPages(pages: SiteDefinition['pages']): React.ReactElement[] { + return pages + .filter((page) => page.status === 'ready') + .map((page) => ( +
+

{page.title}

+ {page.blocks.map((block, index) => ( +
+ {renderTemplate(block)} +
+ ))} +
+ )); +} + diff --git a/sites/src/utils/templateEngine.tsx b/sites/src/utils/templateEngine.tsx new file mode 100644 index 00000000..0d702ded --- /dev/null +++ b/sites/src/utils/templateEngine.tsx @@ -0,0 +1,245 @@ +/** + * Template Engine + * Phase 5: Sites Renderer & Publishing + * + * Renders blocks using shared components from the component library. + */ +import React from 'react'; +import type { Block } from '../types'; + +/** + * Render a block using the template engine. + * Imports shared components dynamically. + */ +export function renderTemplate(block: Block): React.ReactElement { + const { type, data } = block; + + try { + // Try to import shared component + // This will be replaced with actual component imports once shared components are available + switch (type) { + case 'hero': + return renderHeroBlock(data); + case 'text': + return renderTextBlock(data); + case 'image': + return renderImageBlock(data); + case 'button': + return renderButtonBlock(data); + case 'section': + return renderSectionBlock(data); + case 'grid': + return renderGridBlock(data); + case 'card': + return renderCardBlock(data); + case 'list': + return renderListBlock(data); + case 'quote': + return renderQuoteBlock(data); + case 'video': + return renderVideoBlock(data); + case 'form': + return renderFormBlock(data); + case 'accordion': + return renderAccordionBlock(data); + default: + return
Unknown block type: {type}
; + } + } catch (error) { + console.error(`Error rendering block type ${type}:`, error); + return
Error rendering block: {type}
; + } +} + +/** + * Render hero block. + */ +function renderHeroBlock(data: Record): React.ReactElement { + return ( +
+

{data.title || 'Hero Title'}

+ {data.subtitle &&

{data.subtitle}

} + {data.buttonText && ( + + {data.buttonText} + + )} +
+ ); +} + +/** + * Render text block. + */ +function renderTextBlock(data: Record): React.ReactElement { + return ( +
+ {data.content &&
} +
+ ); +} + +/** + * Render image block. + */ +function renderImageBlock(data: Record): React.ReactElement { + return ( +
+ {data.src && ( + {data.alt + )} + {data.caption &&

{data.caption}

} +
+ ); +} + +/** + * Render button block. + */ +function renderButtonBlock(data: Record): React.ReactElement { + return ( + + ); +} + +/** + * Render section block. + */ +function renderSectionBlock(data: Record): React.ReactElement { + return ( +
+ {data.title &&

{data.title}

} + {data.content &&
} +
+ ); +} + +/** + * Render grid block. + */ +function renderGridBlock(data: Record): React.ReactElement { + const columns = data.columns || 3; + return ( +
+ {data.items?.map((item: any, index: number) => ( +
+ {item.content &&
} +
+ ))} +
+ ); +} + +/** + * Render card block. + */ +function renderCardBlock(data: Record): React.ReactElement { + return ( +
+ {data.title &&

{data.title}

} + {data.content &&
} +
+ ); +} + +/** + * Render list block. + */ +function renderListBlock(data: Record): React.ReactElement { + const listType = data.ordered ? 'ol' : 'ul'; + return React.createElement( + listType, + { className: 'block-list', style: { padding: '1rem 2rem' } }, + data.items?.map((item: string, index: number) => ( +
  • {item}
  • + )) + ); +} + +/** + * Render quote block. + */ +function renderQuoteBlock(data: Record): React.ReactElement { + return ( +
    + {data.quote &&

    {data.quote}

    } + {data.author && — {data.author}} +
    + ); +} + +/** + * Render video block. + */ +function renderVideoBlock(data: Record): React.ReactElement { + return ( +
    + {data.src && ( + + )} + {data.embed &&
    } +
    + ); +} + +/** + * Render form block. + */ +function renderFormBlock(data: Record): React.ReactElement { + return ( +
    + {data.fields?.map((field: any, index: number) => ( +
    + + +
    + ))} + +
    + ); +} + +/** + * Render accordion block. + */ +function renderAccordionBlock(data: Record): React.ReactElement { + return ( +
    + {data.items?.map((item: any, index: number) => ( +
    + {item.title} +
    {item.content}
    +
    + ))} +
    + ); +} + diff --git a/sites/tsconfig.app.json b/sites/tsconfig.app.json new file mode 100644 index 00000000..21b6fc4b --- /dev/null +++ b/sites/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} + diff --git a/sites/tsconfig.json b/sites/tsconfig.json new file mode 100644 index 00000000..c36f9b47 --- /dev/null +++ b/sites/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@shared/*": ["../frontend/src/components/shared/*"] + } + } +} + diff --git a/sites/tsconfig.node.json b/sites/tsconfig.node.json new file mode 100644 index 00000000..ed37d29c --- /dev/null +++ b/sites/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} + diff --git a/sites/vite.config.ts b/sites/vite.config.ts new file mode 100644 index 00000000..79523ee9 --- /dev/null +++ b/sites/vite.config.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sharedPathCandidates = [ + path.resolve(__dirname, '../frontend/src/components/shared'), + path.resolve(__dirname, '../../frontend/src/components/shared'), + '/frontend/src/components/shared', +]; +const sharedComponentsPath = sharedPathCandidates.find((candidate) => fs.existsSync(candidate)) ?? sharedPathCandidates[0]; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@shared': sharedComponentsPath, + }, + }, + server: { + host: '0.0.0.0', + port: 5176, + allowedHosts: ['sites.igny8.com'], + fs: { + allow: [path.resolve(__dirname, '..'), sharedComponentsPath], + }, + }, +}); +