sum-cli 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. sum/__init__.py +1 -0
  2. sum/boilerplate/.env.example +124 -0
  3. sum/boilerplate/.gitea/workflows/ci.yml +33 -0
  4. sum/boilerplate/.gitea/workflows/deploy-production.yml +98 -0
  5. sum/boilerplate/.gitea/workflows/deploy-staging.yml +113 -0
  6. sum/boilerplate/.github/workflows/ci.yml +36 -0
  7. sum/boilerplate/.github/workflows/deploy-production.yml +102 -0
  8. sum/boilerplate/.github/workflows/deploy-staging.yml +115 -0
  9. sum/boilerplate/.gitignore +45 -0
  10. sum/boilerplate/README.md +259 -0
  11. sum/boilerplate/manage.py +34 -0
  12. sum/boilerplate/project_name/__init__.py +5 -0
  13. sum/boilerplate/project_name/home/__init__.py +5 -0
  14. sum/boilerplate/project_name/home/apps.py +20 -0
  15. sum/boilerplate/project_name/home/management/__init__.py +0 -0
  16. sum/boilerplate/project_name/home/management/commands/__init__.py +0 -0
  17. sum/boilerplate/project_name/home/management/commands/populate_demo_content.py +644 -0
  18. sum/boilerplate/project_name/home/management/commands/seed.py +129 -0
  19. sum/boilerplate/project_name/home/management/commands/seed_showroom.py +1661 -0
  20. sum/boilerplate/project_name/home/migrations/__init__.py +3 -0
  21. sum/boilerplate/project_name/home/models.py +13 -0
  22. sum/boilerplate/project_name/settings/__init__.py +5 -0
  23. sum/boilerplate/project_name/settings/base.py +348 -0
  24. sum/boilerplate/project_name/settings/local.py +78 -0
  25. sum/boilerplate/project_name/settings/production.py +106 -0
  26. sum/boilerplate/project_name/urls.py +33 -0
  27. sum/boilerplate/project_name/wsgi.py +16 -0
  28. sum/boilerplate/pytest.ini +5 -0
  29. sum/boilerplate/requirements.txt +25 -0
  30. sum/boilerplate/static/client/.gitkeep +3 -0
  31. sum/boilerplate/templates/overrides/.gitkeep +3 -0
  32. sum/boilerplate/tests/__init__.py +3 -0
  33. sum/boilerplate/tests/test_health.py +51 -0
  34. sum/cli.py +42 -0
  35. sum/commands/__init__.py +10 -0
  36. sum/commands/backup.py +308 -0
  37. sum/commands/check.py +128 -0
  38. sum/commands/init.py +265 -0
  39. sum/commands/promote.py +758 -0
  40. sum/commands/run.py +96 -0
  41. sum/commands/themes.py +56 -0
  42. sum/commands/update.py +301 -0
  43. sum/config.py +61 -0
  44. sum/docs/USER_GUIDE.md +663 -0
  45. sum/exceptions.py +45 -0
  46. sum/setup/__init__.py +17 -0
  47. sum/setup/auth.py +184 -0
  48. sum/setup/database.py +58 -0
  49. sum/setup/deps.py +73 -0
  50. sum/setup/git_ops.py +463 -0
  51. sum/setup/infrastructure.py +576 -0
  52. sum/setup/orchestrator.py +354 -0
  53. sum/setup/remote_themes.py +371 -0
  54. sum/setup/scaffold.py +500 -0
  55. sum/setup/seed.py +110 -0
  56. sum/setup/site_orchestrator.py +441 -0
  57. sum/setup/venv.py +89 -0
  58. sum/system_config.py +330 -0
  59. sum/themes_registry.py +180 -0
  60. sum/utils/__init__.py +25 -0
  61. sum/utils/django.py +97 -0
  62. sum/utils/environment.py +76 -0
  63. sum/utils/output.py +78 -0
  64. sum/utils/project.py +110 -0
  65. sum/utils/prompts.py +36 -0
  66. sum/utils/validation.py +313 -0
  67. sum_cli-3.0.0.dist-info/METADATA +127 -0
  68. sum_cli-3.0.0.dist-info/RECORD +72 -0
  69. sum_cli-3.0.0.dist-info/WHEEL +5 -0
  70. sum_cli-3.0.0.dist-info/entry_points.txt +2 -0
  71. sum_cli-3.0.0.dist-info/licenses/LICENSE +29 -0
  72. sum_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,644 @@
1
+ """
2
+ Name: Populate Demo Content Management Command
3
+ Path: project_name/home/management/commands/populate_demo_content.py
4
+ Purpose: Generates realistic dummy content for Wagtail pages to enable fast iteration during development.
5
+ Creates HomePage and multiple StandardPages with varied StreamField blocks.
6
+ Family: Called by Django management command system (python manage.py populate_demo_content)
7
+ Dependencies:
8
+ - wagtail.models (Page)
9
+ - sum_core.pages.StandardPage
10
+ - project_name.home.models.HomePage (dynamically imported)
11
+ - Faker for realistic content generation
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import random
17
+ from typing import Any
18
+
19
+ from django.apps import apps
20
+ from django.core.management.base import BaseCommand, CommandParser
21
+ from wagtail.models import Page, Site
22
+
23
+ try:
24
+ from faker import Faker
25
+ except ImportError:
26
+ Faker = None
27
+
28
+
29
+ class Command(BaseCommand):
30
+ """
31
+ Management command to populate demo content for fast development iteration.
32
+
33
+ Usage:
34
+ python manage.py populate_demo_content
35
+ python manage.py populate_demo_content --clear # Remove existing demo pages first
36
+ """
37
+
38
+ help = "Populate demo content for Wagtail pages with realistic dummy data"
39
+
40
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
41
+ super().__init__(*args, **kwargs)
42
+ self.fake: Any = Faker() if Faker is not None else None
43
+
44
+ def add_arguments(self, parser: CommandParser) -> None:
45
+ parser.add_argument(
46
+ "--clear",
47
+ action="store_true",
48
+ help="Delete existing demo content before creating new content",
49
+ )
50
+ parser.add_argument(
51
+ "--no-images",
52
+ action="store_true",
53
+ help="Skip creating image blocks (useful if no sample images available)",
54
+ )
55
+
56
+ def handle(self, *args: Any, **options: dict[str, Any]) -> None:
57
+ if not self.fake:
58
+ self.stdout.write(
59
+ self.style.ERROR(
60
+ "Faker library not installed. Install with: pip install faker --break-system-packages"
61
+ )
62
+ )
63
+ return
64
+
65
+ self.no_images = options["no_images"]
66
+
67
+ if options["clear"]:
68
+ self.clear_demo_content()
69
+
70
+ self.create_demo_content()
71
+
72
+ self.stdout.write(self.style.SUCCESS("\n✓ Demo content created successfully!"))
73
+
74
+ def get_models(self) -> tuple[Any, Any]:
75
+ """Dynamically import models based on Django app registry."""
76
+ # Find HomePage model using app label pattern
77
+ home_page_model = None
78
+ for app_config in apps.get_app_configs():
79
+ if "home" in app_config.label:
80
+ try:
81
+ home_page_model = apps.get_model(app_config.label, "HomePage")
82
+ break
83
+ except LookupError:
84
+ continue
85
+
86
+ if not home_page_model:
87
+ self.stdout.write(
88
+ self.style.ERROR(
89
+ "HomePage model not found. Make sure your home app is in INSTALLED_APPS"
90
+ )
91
+ )
92
+ return None, None
93
+
94
+ # Get StandardPage
95
+ try:
96
+ from sum_core import pages
97
+
98
+ standard_page_model = pages.StandardPage
99
+ except ImportError:
100
+ self.stdout.write(self.style.ERROR("sum_core.pages not installed"))
101
+ return None, None
102
+
103
+ return home_page_model, standard_page_model
104
+
105
+ def clear_demo_content(self) -> None:
106
+ """Remove existing demo pages."""
107
+ self.stdout.write("Clearing existing demo content...")
108
+
109
+ home_page_model, standard_page_model = self.get_models()
110
+ if not home_page_model or not standard_page_model:
111
+ return
112
+
113
+ # Delete StandardPages
114
+ deleted_standard = standard_page_model.objects.all().delete()
115
+ self.stdout.write(f" Deleted {deleted_standard[0]} StandardPages")
116
+
117
+ # Delete HomePages (but not the root page)
118
+ deleted_home = home_page_model.objects.exclude(depth=2).delete()
119
+ self.stdout.write(f" Deleted {deleted_home[0]} HomePages")
120
+
121
+ def create_demo_content(self) -> None:
122
+ """Create demo pages with varied content."""
123
+ home_page_model, standard_page_model = self.get_models()
124
+ if not home_page_model or not standard_page_model:
125
+ return
126
+
127
+ # Get or create site root
128
+ root_page = Page.objects.get(depth=1)
129
+
130
+ # Check if HomePage already exists
131
+ home_page = home_page_model.objects.first()
132
+ if not home_page:
133
+ self.stdout.write("Creating HomePage...")
134
+ home_page = self._create_home_page(home_page_model, root_page)
135
+ else:
136
+ self.stdout.write(f"Using existing HomePage: {home_page.title}")
137
+
138
+ # Create StandardPages with varied content
139
+ self.stdout.write("\nCreating StandardPages...")
140
+
141
+ pages_config = [
142
+ ("About Us", self._build_about_page),
143
+ ("Our Services", self._build_services_page),
144
+ ("Portfolio", self._build_portfolio_page),
145
+ ("FAQ", self._build_faq_page),
146
+ ("Contact", self._build_contact_page),
147
+ ("How It Works", self._build_process_page),
148
+ ]
149
+
150
+ for title, builder_func in pages_config:
151
+ # Check if page already exists to avoid duplicates
152
+ existing_page = (
153
+ root_page.get_children().filter(slug=self._slugify(title)).first()
154
+ )
155
+ if existing_page:
156
+ self.stdout.write(f" ⊙ Skipping (already exists): {title}")
157
+ continue
158
+
159
+ page = standard_page_model(
160
+ title=title,
161
+ slug=self._slugify(title),
162
+ body=builder_func(),
163
+ )
164
+ root_page.add_child(instance=page)
165
+ page.save_revision().publish()
166
+ self.stdout.write(f" ✓ Created: {title}")
167
+
168
+ def _create_home_page(self, home_page_model: Any, parent: Page) -> Page:
169
+ """Create a HomePage with hero and feature content."""
170
+ import uuid
171
+
172
+ body_content = [
173
+ self._build_hero_gradient(),
174
+ self._build_stats_block(),
175
+ self._build_service_cards(card_count=3),
176
+ self._build_testimonials_block(count=3),
177
+ ]
178
+
179
+ # Create with temporary unique slug to avoid conflicts
180
+ temp_slug = f"home-{uuid.uuid4().hex[:8]}"
181
+ home_page = home_page_model(
182
+ title="Home",
183
+ slug=temp_slug,
184
+ intro=f"<p>{self.fake.paragraph(nb_sentences=2)}</p>",
185
+ body=body_content,
186
+ )
187
+
188
+ parent.add_child(instance=home_page)
189
+ home_page.save_revision().publish()
190
+
191
+ # Get the site and old root page
192
+ site = Site.objects.first()
193
+ old_root = None
194
+ if site:
195
+ old_root = site.root_page
196
+ # Set new page as site root
197
+ site.root_page = home_page
198
+ site.save()
199
+
200
+ # Delete the old default Wagtail page if it exists
201
+ if old_root and old_root.id != parent.id:
202
+ old_slug = old_root.slug
203
+ old_title = old_root.title
204
+ old_root.delete()
205
+ self.stdout.write(f" ✓ Removed old page: '{old_title}' (slug: {old_slug})")
206
+
207
+ # Now update slug to 'home' since the old one is gone
208
+ home_page.slug = "home"
209
+ home_page.save()
210
+
211
+ self.stdout.write(" ✓ Created HomePage")
212
+ return home_page
213
+
214
+ def _build_about_page(self) -> list[tuple[str, dict[str, Any]]]:
215
+ """Build About page with editorial content."""
216
+ return [
217
+ self._build_hero_gradient(
218
+ headline="About <em>Our Company</em>",
219
+ subheadline="Building sustainable solutions since 2020",
220
+ ),
221
+ self._build_rich_text_content(paragraphs=3),
222
+ self._build_stats_block(),
223
+ self._build_rich_text_content(paragraphs=2, center=True),
224
+ self._build_testimonials_block(count=2),
225
+ ]
226
+
227
+ def _build_services_page(self) -> list[tuple[str, dict[str, Any]]]:
228
+ """Build Services page with service cards and features."""
229
+ return [
230
+ self._build_hero_gradient(
231
+ headline="Our <em>Services</em>",
232
+ subheadline="Comprehensive solutions tailored to your needs",
233
+ ),
234
+ self._build_service_cards(card_count=6),
235
+ self._build_divider(),
236
+ self._build_rich_text_content(paragraphs=2, center=True),
237
+ self._build_testimonials_block(count=3),
238
+ ]
239
+
240
+ def _build_portfolio_page(self) -> list[tuple[str, dict[str, Any]]]:
241
+ """Build Portfolio page with project showcase."""
242
+ return [
243
+ self._build_editorial_header(
244
+ heading="Our <em>Portfolio</em>",
245
+ eyebrow="Recent Work",
246
+ ),
247
+ self._build_rich_text_content(paragraphs=1, center=True),
248
+ self._build_portfolio_block(item_count=4),
249
+ self._build_spacer("large"),
250
+ (
251
+ "buttons",
252
+ {
253
+ "alignment": "center",
254
+ "buttons": [
255
+ {
256
+ "label": "View All Projects",
257
+ "url": "/portfolio/",
258
+ "style": "primary",
259
+ }
260
+ ],
261
+ },
262
+ ),
263
+ ]
264
+
265
+ def _build_faq_page(self) -> list[tuple[str, dict[str, Any]]]:
266
+ """Build FAQ page with questions and answers."""
267
+ return [
268
+ self._build_editorial_header(
269
+ heading="Frequently Asked <em>Questions</em>",
270
+ eyebrow="Help Centre",
271
+ ),
272
+ self._build_faq_block(),
273
+ self._build_spacer("large"),
274
+ self._build_rich_text_content(paragraphs=1, center=True),
275
+ (
276
+ "buttons",
277
+ {
278
+ "alignment": "center",
279
+ "buttons": [
280
+ {
281
+ "label": "Contact Support",
282
+ "url": "/contact/",
283
+ "style": "primary",
284
+ }
285
+ ],
286
+ },
287
+ ),
288
+ ]
289
+
290
+ def _build_contact_page(self) -> list[tuple[str, dict[str, Any]]]:
291
+ """Build Contact page with form."""
292
+ return [
293
+ self._build_editorial_header(
294
+ heading="Get in <em>Touch</em>",
295
+ eyebrow="Contact Us",
296
+ ),
297
+ self._build_rich_text_content(paragraphs=1, center=True),
298
+ self._build_contact_form(),
299
+ ]
300
+
301
+ def _build_process_page(self) -> list[tuple[str, dict[str, Any]]]:
302
+ """Build How It Works page with process steps."""
303
+ return [
304
+ self._build_hero_gradient(
305
+ headline="How It <em>Works</em>",
306
+ subheadline="Our proven process for delivering results",
307
+ ),
308
+ self._build_process_steps(),
309
+ self._build_spacer("large"),
310
+ self._build_testimonials_block(count=2),
311
+ self._build_spacer("medium"),
312
+ (
313
+ "buttons",
314
+ {
315
+ "alignment": "center",
316
+ "buttons": [
317
+ {
318
+ "label": "Get Started",
319
+ "url": "/contact/",
320
+ "style": "primary",
321
+ },
322
+ {
323
+ "label": "Learn More",
324
+ "url": "/services/",
325
+ "style": "secondary",
326
+ },
327
+ ],
328
+ },
329
+ ),
330
+ ]
331
+
332
+ # ========== Block Builders ==========
333
+
334
+ def _build_hero_gradient(
335
+ self,
336
+ headline: str | None = None,
337
+ subheadline: str | None = None,
338
+ ) -> tuple[str, dict[str, Any]]:
339
+ """Build a gradient hero block."""
340
+ if not headline:
341
+ company = self.fake.company()
342
+ headline = f"Welcome to <em>{company}</em>"
343
+
344
+ if not subheadline:
345
+ subheadline = self.fake.catch_phrase()
346
+
347
+ return (
348
+ "hero_gradient",
349
+ {
350
+ "headline": f"<p>{headline}</p>",
351
+ "subheadline": subheadline,
352
+ "ctas": [
353
+ {
354
+ "label": "Get Started",
355
+ "url": "/contact/",
356
+ "style": "primary",
357
+ "open_in_new_tab": False,
358
+ },
359
+ {
360
+ "label": "Learn More",
361
+ "url": "/about/",
362
+ "style": "secondary",
363
+ "open_in_new_tab": False,
364
+ },
365
+ ],
366
+ "status": "",
367
+ "gradient_style": random.choice(["primary", "secondary", "accent"]),
368
+ },
369
+ )
370
+
371
+ def _build_service_cards(
372
+ self,
373
+ card_count: int = 3,
374
+ ) -> tuple[str, dict[str, Any]]:
375
+ """Build service cards block."""
376
+ services = [
377
+ ("🔧", "Installation", "Professional installation services"),
378
+ ("⚡", "Maintenance", "Regular maintenance and support"),
379
+ ("📊", "Consulting", "Expert consulting and strategy"),
380
+ ("🎯", "Training", "Comprehensive training programs"),
381
+ ("🔒", "Security", "Advanced security solutions"),
382
+ ("🚀", "Optimization", "Performance optimization services"),
383
+ ]
384
+
385
+ cards = []
386
+ for i in range(min(card_count, len(services))):
387
+ icon, title, desc = services[i]
388
+ cards.append(
389
+ {
390
+ "icon": icon,
391
+ "image": None,
392
+ "title": title,
393
+ "description": f"<p>{desc}. {self.fake.sentence()}</p>",
394
+ "link_url": f"/services/{self._slugify(title)}/",
395
+ "link_label": "Learn more",
396
+ }
397
+ )
398
+
399
+ return (
400
+ "service_cards",
401
+ {
402
+ "eyebrow": "What We Offer",
403
+ "heading": "<p>Our <em>Services</em></p>",
404
+ "intro": self.fake.paragraph(nb_sentences=2),
405
+ "cards": cards,
406
+ "layout_style": "default",
407
+ },
408
+ )
409
+
410
+ def _build_testimonials_block(
411
+ self,
412
+ count: int = 3,
413
+ ) -> tuple[str, dict[str, Any]]:
414
+ """Build testimonials block."""
415
+ testimonials = []
416
+ for _ in range(count):
417
+ testimonials.append(
418
+ {
419
+ "quote": self.fake.paragraph(nb_sentences=3),
420
+ "author_name": self.fake.name(),
421
+ "company": self.fake.company(),
422
+ "photo": None,
423
+ "rating": random.randint(4, 5),
424
+ }
425
+ )
426
+
427
+ return (
428
+ "testimonials",
429
+ {
430
+ "eyebrow": "Client Stories",
431
+ "heading": "<p>What Our <em>Clients Say</em></p>",
432
+ "testimonials": testimonials,
433
+ },
434
+ )
435
+
436
+ def _build_stats_block(self) -> tuple[str, dict[str, Any]]:
437
+ """Build statistics block."""
438
+ return (
439
+ "stats",
440
+ {
441
+ "eyebrow": "By the Numbers",
442
+ "intro": self.fake.sentence(),
443
+ "items": [
444
+ {
445
+ "value": "500",
446
+ "label": "Projects Completed",
447
+ "prefix": ">",
448
+ "suffix": "+",
449
+ },
450
+ {
451
+ "value": "15",
452
+ "label": "Years Experience",
453
+ "prefix": "",
454
+ "suffix": "yrs",
455
+ },
456
+ {
457
+ "value": "98",
458
+ "label": "Client Satisfaction",
459
+ "prefix": "",
460
+ "suffix": "%",
461
+ },
462
+ {
463
+ "value": "24",
464
+ "label": "Support Available",
465
+ "prefix": "",
466
+ "suffix": "/7",
467
+ },
468
+ ],
469
+ },
470
+ )
471
+
472
+ def _build_process_steps(self) -> tuple[str, dict[str, Any]]:
473
+ """Build process steps block."""
474
+ steps = [
475
+ ("Initial Consultation", "We discuss your needs and objectives"),
476
+ ("Planning & Design", "Our team creates a customized solution"),
477
+ ("Implementation", "We execute the plan with precision"),
478
+ ("Testing & QA", "Rigorous testing ensures quality"),
479
+ ("Launch & Support", "Go live with ongoing support"),
480
+ ]
481
+
482
+ step_blocks = []
483
+ for i, (title, desc) in enumerate(steps, 1):
484
+ step_blocks.append(
485
+ {
486
+ "number": i,
487
+ "title": title,
488
+ "description": f"<p>{desc}. {self.fake.sentence()}</p>",
489
+ }
490
+ )
491
+
492
+ return (
493
+ "process",
494
+ {
495
+ "eyebrow": "Our Approach",
496
+ "heading": "<p>How We <em>Work</em></p>",
497
+ "intro": f"<p>{self.fake.paragraph(nb_sentences=2)}</p>",
498
+ "steps": step_blocks,
499
+ },
500
+ )
501
+
502
+ def _build_faq_block(self) -> tuple[str, dict[str, Any]]:
503
+ """Build FAQ accordion block."""
504
+ faqs = [
505
+ (
506
+ "How do I get started?",
507
+ "Getting started is easy! Simply contact us through our form or give us a call.",
508
+ ),
509
+ (
510
+ "What are your pricing plans?",
511
+ "We offer flexible pricing based on your specific needs. Contact us for a custom quote.",
512
+ ),
513
+ (
514
+ "Do you offer support?",
515
+ "Yes, we provide 24/7 support to all our clients with comprehensive assistance.",
516
+ ),
517
+ (
518
+ "How long does implementation take?",
519
+ "Timeline varies by project, but typical implementations take 2-4 weeks.",
520
+ ),
521
+ (
522
+ "What makes you different?",
523
+ "Our commitment to quality, customer service, and innovative solutions sets us apart.",
524
+ ),
525
+ ]
526
+
527
+ faq_items = []
528
+ for question, answer in faqs:
529
+ faq_items.append(
530
+ {
531
+ "question": question,
532
+ "answer": f"<p>{answer} {self.fake.sentence()}</p>",
533
+ }
534
+ )
535
+
536
+ return (
537
+ "faq",
538
+ {
539
+ "eyebrow": "Common Questions",
540
+ "heading": "<p><em>FAQ</em></p>",
541
+ "intro": f"<p>{self.fake.sentence()}</p>",
542
+ "items": faq_items,
543
+ },
544
+ )
545
+
546
+ def _build_portfolio_block(
547
+ self,
548
+ item_count: int = 4,
549
+ ) -> tuple[str, dict[str, Any]]:
550
+ """Build portfolio items block."""
551
+ items = []
552
+ locations = ["London", "Manchester", "Birmingham", "Leeds", "Bristol"]
553
+ services = [
554
+ "Installation • Maintenance",
555
+ "Consulting • Training",
556
+ "Design • Implementation",
557
+ ]
558
+
559
+ for i in range(item_count):
560
+ items.append(
561
+ {
562
+ "image": None, # Would need actual images
563
+ "alt_text": f"Project {i+1}",
564
+ "title": self.fake.catch_phrase(),
565
+ "location": f"{self.fake.city()}, {random.choice(locations)}",
566
+ "services": random.choice(services),
567
+ "link_url": f"/portfolio/project-{i+1}/",
568
+ }
569
+ )
570
+
571
+ return (
572
+ "portfolio",
573
+ {
574
+ "eyebrow": "Recent Work",
575
+ "heading": "<p>Featured <em>Projects</em></p>",
576
+ "intro": self.fake.paragraph(nb_sentences=1),
577
+ "items": items,
578
+ },
579
+ )
580
+
581
+ def _build_contact_form(self) -> tuple[str, dict[str, Any]]:
582
+ """Build contact form block."""
583
+ return (
584
+ "contact_form",
585
+ {
586
+ "eyebrow": "Get In Touch",
587
+ "heading": "<p>Send us a <em>Message</em></p>",
588
+ "intro": f"<p>{self.fake.paragraph(nb_sentences=2)}</p>",
589
+ "success_message": "Thank you for your enquiry! We'll get back to you soon.",
590
+ "submit_label": "Send Enquiry",
591
+ },
592
+ )
593
+
594
+ def _build_rich_text_content(
595
+ self,
596
+ paragraphs: int = 2,
597
+ center: bool = False,
598
+ ) -> tuple[str, dict[str, Any]]:
599
+ """Build rich text content block."""
600
+ content = "".join(
601
+ [f"<p>{self.fake.paragraph(nb_sentences=3)}</p>" for _ in range(paragraphs)]
602
+ )
603
+
604
+ return (
605
+ "content",
606
+ {
607
+ "align": "center" if center else "left",
608
+ "body": content,
609
+ },
610
+ )
611
+
612
+ def _build_editorial_header(
613
+ self,
614
+ heading: str,
615
+ eyebrow: str = "",
616
+ ) -> tuple[str, dict[str, Any]]:
617
+ """Build editorial header block."""
618
+ return (
619
+ "editorial_header",
620
+ {
621
+ "align": "center",
622
+ "eyebrow": eyebrow,
623
+ "heading": f"<p>{heading}</p>",
624
+ },
625
+ )
626
+
627
+ def _build_spacer(self, size: str = "medium") -> tuple[str, dict[str, Any]]:
628
+ """Build spacer block."""
629
+ return ("spacer", {"size": size})
630
+
631
+ def _build_divider(self, style: str = "muted") -> tuple[str, dict[str, Any]]:
632
+ """Build divider block."""
633
+ return ("divider", {"style": style})
634
+
635
+ # ========== Helpers ==========
636
+
637
+ def _slugify(self, text: str) -> str:
638
+ """Convert text to URL-friendly slug."""
639
+ import re
640
+
641
+ text = text.lower()
642
+ text = re.sub(r"[^\w\s-]", "", text)
643
+ text = re.sub(r"[-\s]+", "-", text)
644
+ return text.strip("-")